diff --git a/mesh/mesh-integration/mesh-manifest.reference.json b/mesh/mesh-integration/mesh-manifest.reference.json
index 501083b7..7379b20a 100644
--- a/mesh/mesh-integration/mesh-manifest.reference.json
+++ b/mesh/mesh-integration/mesh-manifest.reference.json
@@ -27,6 +27,7 @@
"displayName": "Reference Archetype",
"dataEditorUrl": "/reference/dataResource",
"typeEditorUrl": "/reference/dataType",
+ "dataResourceSelectorUrl": "/reference/dataResourceSelector",
"typeEditorLocations": {
"teDialog": {
"url": "../namedDialog"
@@ -121,7 +122,20 @@
{
"id": "composition-editor-tool",
"name": "Composition Editor Tool",
- "url": "/reference/canvas-editor-tools",
+ "url": "/reference/canvasEditorTools",
+ "iconUrl": "/uniform.png"
+ },
+ {
+ "id": "composition-editor-tool-2",
+ "name": "Composition Editor Tool 2",
+ "url": "/reference/canvasEditorTools",
+ "iconUrl": "/cat.svg",
+ "editorTypes": ["composition"]
+ },
+ {
+ "id": "translation-tool",
+ "name": "Example Translation Tool",
+ "url": "/reference/canvasEditorToolsTranslation",
"iconUrl": "/uniform.png"
}
]
diff --git a/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx b/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx
deleted file mode 100644
index 4a1f5a2e..00000000
--- a/mesh/mesh-integration/pages/reference/canvas-editor-tools.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { useMeshLocation } from '@uniformdev/mesh-sdk-react';
-
-const CanvasEditorTools = () => {
- const { value, metadata } = useMeshLocation('canvasEditorTools');
- return (
-
-
{`Raw ${value.entityType} data`}
-
{JSON.stringify(value)}
-
Metadata
-
{JSON.stringify(metadata)}
-
- );
-};
-
-export default CanvasEditorTools;
diff --git a/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx b/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx
new file mode 100644
index 00000000..d6607ea5
--- /dev/null
+++ b/mesh/mesh-integration/pages/reference/canvasEditorTools.tsx
@@ -0,0 +1,111 @@
+import {
+ type EditorNode,
+ type EditorNodeChildren,
+ type EditorStateApi,
+ useMeshLocation,
+} from '@uniformdev/mesh-sdk-react';
+import { useEffect, useState } from 'react';
+
+type SlotViewerProps = {
+ slotName: string;
+ childIds: string[];
+ editorState: EditorStateApi;
+};
+
+const SlotViewer = ({ slotName, childIds, editorState }: SlotViewerProps) => {
+ const [isOpen, setIsOpen] = useState(false);
+
+ return (
+ e.target === e.currentTarget && setIsOpen((e.target as HTMLDetailsElement).open)}
+ style={{ marginLeft: 8 }}
+ >
+
+ {slotName} (slot)
+
+ {isOpen && childIds.map((id) => )}
+
+ );
+};
+
+type NodeViewerProps = {
+ nodeId: string;
+ editorState: EditorStateApi;
+};
+
+const NodeViewer = ({ nodeId, editorState }: NodeViewerProps) => {
+ const [node, setNode] = useState(null);
+ const [children, setChildren] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+
+ useEffect(() => {
+ editorState.getNodeById({ nodeId }).then((n) => setNode(n ?? null));
+ }, [nodeId, editorState]);
+
+ useEffect(() => {
+ if (!isOpen) {
+ setChildren(null);
+ return;
+ }
+ editorState.getNodeChildren({ nodeId }).then((c) => setChildren(c ?? null));
+ }, [isOpen, nodeId, editorState]);
+
+ if (!node) return null;
+
+ return (
+ e.target === e.currentTarget && setIsOpen((e.target as HTMLDetailsElement).open)}
+ style={{ marginLeft: 16 }}
+ >
+
+ {node.value.type}
+
+ {isOpen &&
+ children &&
+ Object.entries(children).map(([slot, ids]) => (
+
+ ))}
+
+ );
+};
+
+const CanvasEditorTools = () => {
+ const { value, metadata, editorState } = useMeshLocation('canvasEditorTools');
+ const [rootId, setRootId] = useState(null);
+
+ useEffect(() => {
+ editorState.getRootNodeId().then(setRootId);
+ }, [editorState]);
+
+ return (
+
+
Metadata
+
{JSON.stringify(metadata)}
+
+
+
{value.entityType} data
+ {rootId ?
:
Loading root...
}
+
+
+
Set the Title property in en-US
+
{
+ if (!rootId) return;
+ editorState.updateNodeProperty({
+ nodeId: rootId,
+ property: 'title',
+ value: e.target.value,
+ conditionIndex: -1,
+ locale: 'en-US',
+ type: 'text',
+ });
+ }}
+ />
+
+ );
+};
+
+export default CanvasEditorTools;
diff --git a/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx b/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx
new file mode 100644
index 00000000..b82241b6
--- /dev/null
+++ b/mesh/mesh-integration/pages/reference/canvasEditorToolsTranslation.tsx
@@ -0,0 +1,398 @@
+import {
+ type ComponentParameter,
+ type EntryData,
+ parseVariableExpression,
+ type RootComponentInstance,
+ walkNodeTree,
+} from '@uniformdev/canvas';
+import { Button, Callout, InputSelect, VerticalRhythm } from '@uniformdev/design-system';
+import { type EditorStateApi, useMeshLocation } from '@uniformdev/mesh-sdk-react';
+import { useCallback, useState } from 'react';
+
+/*
+ Example of a canvaseditor tool to perform machine translation.
+ This is a placeholder implementation that simulates a machine translation service.
+ In a real-world scenario, you would use a machine translation API to translate the values.
+ This implementation is not production-ready and is only meant as an example.
+*/
+
+type TranslationProgress = {
+ nodeType: string;
+ property: string;
+ conditionIndex: number;
+ sourceValue: unknown;
+ count: number;
+};
+
+type TranslationStatus = 'idle' | 'translating' | 'done';
+
+/**
+ * Checks if a string is purely a dynamic token bind expression with no static text.
+ * Uses the canvas parseVariableExpression to detect if the entire value is a single variable reference.
+ */
+function isPureBindExpression(value: string): boolean {
+ let hasText = false;
+ let variableCount = 0;
+
+ parseVariableExpression(value, (_, type) => {
+ if (type === 'text') {
+ hasText = true;
+ } else if (type === 'variable') {
+ variableCount++;
+ }
+ });
+
+ // It's a pure bind expression if there's exactly one variable and no text tokens
+ return variableCount === 1 && !hasText;
+}
+
+/**
+ * Placeholder translate function that prefixes string values with the target locale.
+ * Includes an artificial 1s delay to simulate real translation.
+ */
+async function placeholderTranslate(value: unknown, targetLocale: string): Promise {
+ // Artificial 1s delay to simulate async translation
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ if (typeof value === 'string') {
+ if (isPureBindExpression(value)) {
+ return value;
+ }
+
+ return `in ${targetLocale} (mocked): ${value}`;
+ }
+
+ // FUTURE ENHANCEMENT: translate other value types, e.g. rich text, image alt text, etc
+
+ // Skip unknown value types
+ return value;
+}
+
+/**
+ * Processes a single property value and translates it if needed.
+ */
+async function translatePropertyValue(
+ editorState: EditorStateApi,
+ nodeId: string,
+ propertyName: string,
+ propertyType: string,
+ sourceValue: unknown,
+ conditionIndex: number,
+ hasSourceLocaleValue: boolean,
+ targetLocale: string
+): Promise {
+ // Only select the component/parameter if it has a value in the source locale
+ if (hasSourceLocaleValue) {
+ await editorState.setSelectedNodeId({ nodeId });
+ await editorState.setSelectedParameterId({ parameterId: propertyName });
+ }
+
+ // Translate the value
+ const translatedValue = await placeholderTranslate(sourceValue, targetLocale);
+
+ // Update the property with the translated value in the target locale
+ await editorState.updateNodeProperty({
+ nodeId,
+ property: propertyName,
+ value: translatedValue,
+ type: propertyType,
+ locale: targetLocale,
+ conditionIndex,
+ });
+}
+
+/**
+ * Processes a single property (parameter or field) and translates all its values.
+ */
+async function processProperty(
+ editorState: EditorStateApi,
+ nodeId: string,
+ nodeType: string,
+ propertyName: string,
+ property: ComponentParameter,
+ sourceLocale: string | undefined,
+ targetLocale: string,
+ onProgress: (progress: TranslationProgress) => void,
+ countRef: { current: number }
+): Promise {
+ // Skip block parameters (they contain nested components, not translatable values)
+ if (property.type === 'block') {
+ return;
+ }
+
+ // Process base value (from locales or invariant)
+ let sourceValue: unknown;
+ let hasSourceLocaleValue = false;
+
+ if (sourceLocale) {
+ const localeValue = property.locales?.[sourceLocale];
+ if (localeValue !== undefined) {
+ sourceValue = localeValue;
+ hasSourceLocaleValue = true;
+ } else {
+ sourceValue = property.value;
+ hasSourceLocaleValue = false;
+ }
+ } else {
+ sourceValue = property.value;
+ hasSourceLocaleValue = false;
+ }
+
+ // Translate base value if it exists
+ if (sourceValue !== undefined && sourceValue !== null && sourceValue !== '') {
+ countRef.current++;
+ onProgress({
+ nodeType,
+ property: propertyName,
+ conditionIndex: -1,
+ sourceValue,
+ count: countRef.current,
+ });
+
+ await translatePropertyValue(
+ editorState,
+ nodeId,
+ propertyName,
+ property.type,
+ sourceValue,
+ -1,
+ hasSourceLocaleValue,
+ targetLocale
+ );
+ }
+
+ // Process conditional values (from localesConditions )
+ const conditions = property.localesConditions?.[sourceLocale ?? ''];
+
+ if (conditions) {
+ for (let i = 0; i < conditions.length; i++) {
+ const condition = conditions[i];
+ if (condition.value !== undefined && condition.value !== null && condition.value !== '') {
+ countRef.current++;
+ onProgress({
+ nodeType,
+ property: propertyName,
+ conditionIndex: i,
+ sourceValue: condition.value,
+ count: countRef.current,
+ });
+
+ await translatePropertyValue(
+ editorState,
+ nodeId,
+ propertyName,
+ property.type,
+ condition.value,
+ i,
+ sourceLocale !== undefined,
+ targetLocale
+ );
+ }
+ }
+ }
+}
+
+/**
+ * Walks the tree and translates all values as they are discovered.
+ */
+async function translateTree(
+ tree: RootComponentInstance | EntryData,
+ editorState: EditorStateApi,
+ sourceLocale: string | undefined,
+ targetLocale: string,
+ onProgress: (progress: TranslationProgress) => void
+): Promise {
+ // Check enabled locales from root node
+ const enabledLocales: string[] = tree._locales ?? [];
+
+ // Only enable the target locale if it's not already enabled
+ if (!enabledLocales.includes(targetLocale)) {
+ await editorState.enableLocale({ locale: targetLocale });
+ }
+
+ // Switch to the target locale in the editor UI
+ await editorState.setCurrentLocale({ locale: targetLocale });
+
+ const countRef = { current: 0 };
+
+ // Collect all nodes first (walkNodeTree is synchronous)
+ const nodes: Array<{
+ type: 'component' | 'entry';
+ nodeId: string;
+ nodeType: string;
+ properties: Record;
+ }> = [];
+
+ walkNodeTree(tree, ({ type, node }) => {
+ if (type === 'component') {
+ const nodeId = node._id;
+ if (nodeId) {
+ nodes.push({ type: 'component', nodeId, nodeType: node.type, properties: node.parameters ?? {} });
+ }
+ } else if (type === 'entry') {
+ const nodeId = node._id;
+ if (nodeId) {
+ nodes.push({ type: 'entry', nodeId, nodeType: node.type, properties: node.fields ?? {} });
+ }
+ }
+ });
+
+ // Process each node's properties
+ for (const nodeInfo of nodes) {
+ for (const [propertyName, property] of Object.entries(nodeInfo.properties)) {
+ await processProperty(
+ editorState,
+ nodeInfo.nodeId,
+ nodeInfo.nodeType,
+ propertyName,
+ property,
+ sourceLocale,
+ targetLocale,
+ onProgress,
+ countRef
+ );
+ }
+ }
+
+ return countRef.current;
+}
+
+const TranslationTool = () => {
+ const { metadata, editorState } = useMeshLocation('canvasEditorTools');
+
+ const [sourceLocale, setSourceLocale] = useState(metadata.currentLocale || '');
+ const [targetLocale, setTargetLocale] = useState('');
+ const [status, setStatus] = useState('idle');
+ const [progress, setProgress] = useState(null);
+ const [totalTranslated, setTotalTranslated] = useState(0);
+ const [error, setError] = useState(null);
+
+ const allLocaleOptions = [
+ { label: 'Select one...', value: '' },
+ ...metadata.locales.map((l) => ({ label: l.displayName, value: l.locale })),
+ ];
+
+ const canTranslate = targetLocale && sourceLocale !== targetLocale;
+
+ const handleTranslate = useCallback(async () => {
+ if (!canTranslate) {
+ return;
+ }
+
+ setStatus('translating');
+ setError(null);
+ setProgress(null);
+ setTotalTranslated(0);
+
+ try {
+ const tree = await editorState.exportTree();
+ const count = await translateTree(
+ tree,
+ editorState,
+ sourceLocale || undefined,
+ targetLocale,
+ setProgress
+ );
+ setTotalTranslated(count);
+ setProgress(null);
+ setStatus('done');
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error(e);
+ setError(e instanceof Error ? e.message : 'Translation failed');
+ setStatus('idle');
+ }
+ }, [editorState, sourceLocale, targetLocale, canTranslate]);
+
+ const handleReset = useCallback(() => {
+ setStatus('idle');
+ setProgress(null);
+ setTotalTranslated(0);
+ setError(null);
+ }, []);
+
+ const sourceLocaleDisplay = sourceLocale
+ ? metadata.locales.find((l) => l.locale === sourceLocale)?.displayName || sourceLocale
+ : '(invariant)';
+ const targetLocaleDisplay = targetLocale
+ ? metadata.locales.find((l) => l.locale === targetLocale)?.displayName || targetLocale
+ : '';
+
+ return (
+
+ {error && {error}
}
+
+ {status === 'idle' && (
+
+ setSourceLocale(e.target.value)}
+ options={allLocaleOptions}
+ />
+
+ setTargetLocale(e.target.value)}
+ options={allLocaleOptions}
+ />
+
+
+
+ )}
+
+ {status === 'translating' && (
+
+
+ Translating from {sourceLocaleDisplay} to {targetLocaleDisplay}
+
+
+ Translated: {progress?.count ?? 0} values
+
+ {progress && (
+
+
+
+ Translating:
+
+
+ Node: {progress.nodeType}
+
+
+ Property: {progress.property}
+ {progress.conditionIndex >= 0 && [condition {progress.conditionIndex}]}
+
+
+ {typeof progress.sourceValue === 'string'
+ ? `"${progress.sourceValue.substring(0, 100)}${progress.sourceValue.length > 100 ? '...' : ''}"`
+ : `(${typeof progress.sourceValue})`}
+
+
+
+ )}
+
+ )}
+
+ {status === 'done' && (
+
+
+ Translated {totalTranslated} values to {targetLocaleDisplay}.
+
+
+
+
+ )}
+
+ );
+};
+
+export default TranslationTool;
diff --git a/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx b/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx
new file mode 100644
index 00000000..a4d96523
--- /dev/null
+++ b/mesh/mesh-integration/pages/reference/dataResourceSelector.tsx
@@ -0,0 +1,85 @@
+import { VerticalRhythm } from '@uniformdev/design-system';
+import { Callout, useMeshLocation } from '@uniformdev/mesh-sdk-react';
+import type { NextPage } from 'next';
+
+/**
+ * Data Resource Selector demonstration
+ *
+ * This location replaces the default JSON tree viewer when selecting dynamic tokens
+ * from data of an archetype that has dataResourceSelectorUrl configured.
+ *
+ * Available metadata:
+ * - dataResourceValue: The resolved data for the data resource
+ * - dataResourceName: The name of the data resource
+ * - dataTypeId: The data type ID (for fetching additional context if needed)
+ * - archetype: The archetype key (useful for apps sharing a selector across archetypes)
+ * - allowedTypes: Array of bindable types valid for selection
+ * - componentDefinitions: Component definitions index, keyed by public id
+ *
+ * Available location API:
+ * - editorState: Imperative API for inspecting/mutating the composition/entry tree (use for component/root context)
+ *
+ * The mesh app can call setValue with a JSON pointer, and getDataResource to fetch more data.
+ */
+
+const DataResourceSelector: NextPage = () => {
+ const location = useMeshLocation('dataResourceSelector');
+ const { value, setValue, metadata, isReadOnly } = location;
+ const { dataResourceValue } = metadata;
+
+ if (!dataResourceValue || typeof dataResourceValue !== 'object') {
+ return No data available for this data resource.;
+ }
+
+ // Get top-level keys from the data resource
+ const keys = Object.keys(dataResourceValue);
+
+ const handleSelect = (valueToSelect: string) => {
+ if (isReadOnly) {
+ return;
+ }
+ // Set the JSON pointer for the selected key
+ setValue(() => {
+ return {
+ newValue: valueToSelect,
+ };
+ });
+ };
+
+ return (
+
+ Select a top-level array:
+
+
+ {keys.map((key) => {
+ const jsonPointerToSelect = `/${key}`;
+ const isSelected = value === jsonPointerToSelect;
+ const targetValue = (dataResourceValue as Record)[key];
+
+ if (!Array.isArray(targetValue)) {
+ return null;
+ }
+
+ return (
+
+ );
+ })}
+
+
+ {value ? (
+
+ Current selection: {value}
+
+ ) : null}
+
+ );
+};
+
+export default DataResourceSelector;