@distinctagency/cms-client 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/markup/react.tsx","../src/visual-editing/protocol.ts","../src/markup/protocol.ts","../src/markup/selector.ts","../src/markup/bridge.ts"],"sourcesContent":["\"use client\"\nimport { useEffect } from \"react\"\nimport { createMarkupBridge } from \"./bridge\"\n\nexport interface CmsMarkupBridgeProps {\n trustedOrigins?: string[]\n /** Query param that activates markup mode. Defaults to \"cms_markup\". */\n param?: string\n}\n\n/**\n * Mount once in your site (e.g. root layout, alongside CmsVisualBridge).\n * Renders nothing; activates only when loaded with the markup param inside a\n * trusted CMS frame.\n */\nexport function CmsMarkupBridge({ trustedOrigins, param = \"cms_markup\" }: CmsMarkupBridgeProps) {\n useEffect(() => {\n if (typeof window === \"undefined\") return\n const params = new URLSearchParams(window.location.search)\n if (!params.has(param)) return\n return createMarkupBridge({ trustedOrigins })\n }, [trustedOrigins, param])\n return null\n}\n","/** Discriminator stamped on every visual-editing postMessage. */\nexport const CMS_VISUAL_SOURCE = \"cms-visual\" as const\n\n/** CMS origins the bridge will trust by default. */\nexport const DEFAULT_TRUSTED_ORIGINS: readonly string[] = [\n \"https://cms.distinctstudio.co.nz\",\n \"https://distinctcms.com\",\n]\n\nexport type VisualFieldType = \"text\" | \"image\"\n\n/** Compact wire type announced in `ready`; the schema adds length constraints. */\nexport interface VisualFieldMeta {\n name: string\n type: VisualFieldType\n}\n\nexport interface VisualFieldSchema {\n name: string\n type: VisualFieldType\n max_length?: number\n recommended_length?: number\n}\n\n// bridge -> parent\nexport interface ReadyMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"ready\"\n fields: VisualFieldMeta[]\n}\nexport interface EditMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"edit\"\n name: string\n value: string\n length: number\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\nexport interface PickImageMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"pick-image\"\n name: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\n// parent -> bridge\nexport interface InitMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"init\"\n values: Record<string, string>\n schema: Record<string, VisualFieldSchema>\n}\nexport interface SetMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"set\"\n name: string\n value: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\nexport type BridgeOutbound = ReadyMessage | EditMessage | PickImageMessage\nexport type BridgeInbound = InitMessage | SetMessage\nexport type VisualMessage = BridgeOutbound | BridgeInbound\n\n/** Exact-match origin allowlist, plus any localhost port for local dev. */\nexport function isTrustedOrigin(\n origin: string,\n allowlist: readonly string[]\n): boolean {\n if (allowlist.includes(origin)) return true\n return /^https?:\\/\\/localhost(:\\d+)?$/.test(origin)\n}\n\nconst VALID_VISUAL_TYPES = new Set([\"ready\", \"edit\", \"pick-image\", \"init\", \"set\"])\n\n/** Narrow an unknown postMessage payload to a visual message, or null. */\nexport function parseVisualMessage(data: unknown): VisualMessage | null {\n if (\n typeof data === \"object\" &&\n data !== null &&\n (data as Record<string, unknown>).source === CMS_VISUAL_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID_VISUAL_TYPES.has((data as Record<string, unknown>).type as string)\n ) {\n return data as VisualMessage\n }\n return null\n}\n","import { isTrustedOrigin } from \"../visual-editing/protocol\"\n\nexport { isTrustedOrigin }\nexport const CMS_MARKUP_SOURCE = \"cms-markup\" as const\n\nexport interface MarkupReady {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-ready\"\n docW: number\n docH: number\n}\nexport interface MarkupPin {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-pin\"\n xPct: number\n yPct: number\n relX: number\n relY: number\n selector: string | null\n viewportW: number\n docW: number\n docH: number\n}\nexport interface MarkupViewport {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-viewport\"\n scrollX: number\n scrollY: number\n docW: number\n docH: number\n viewportW: number\n}\nexport interface MarkupLocated {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-located\"\n id: string\n rect: { x: number; y: number; w: number; h: number } | null\n}\nexport interface MarkupInit {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-init\"\n}\nexport interface MarkupLocate {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-locate\"\n id: string\n selector: string\n}\n\nexport type MarkupOutbound = MarkupReady | MarkupPin | MarkupViewport | MarkupLocated\nexport type MarkupInbound = MarkupInit | MarkupLocate\nexport type MarkupMessage = MarkupOutbound | MarkupInbound\n\nconst VALID = new Set([\n \"markup-ready\", \"markup-pin\", \"markup-viewport\", \"markup-located\",\n \"markup-init\", \"markup-locate\",\n])\n\nexport function parseMarkupMessage(data: unknown): MarkupMessage | null {\n if (\n typeof data === \"object\" && data !== null &&\n (data as Record<string, unknown>).source === CMS_MARKUP_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID.has((data as Record<string, unknown>).type as string)\n ) {\n return data as MarkupMessage\n }\n return null\n}\n","/** Build a stable-ish CSS selector for an element, for re-anchoring pins. */\nexport function computeSelector(el: Element): string | null {\n if (!el || !el.isConnected) return null\n if (el.id) return `#${CSS.escape(el.id)}`\n const field = el.getAttribute(\"data-cms-field\")\n if (field) return `[data-cms-field=\"${CSS.escape(field)}\"]`\n\n const parts: string[] = []\n let node: Element | null = el\n while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== \"html\") {\n if (node.id) { parts.unshift(`#${CSS.escape(node.id)}`); break }\n const tag = node.tagName.toLowerCase()\n const parent: Element | null = node.parentElement\n if (!parent) { parts.unshift(tag); break }\n const sameType = Array.from(parent.children).filter((c) => c.tagName === node!.tagName)\n const idx = sameType.indexOf(node) + 1\n parts.unshift(sameType.length > 1 ? `${tag}:nth-of-type(${idx})` : tag)\n node = parent\n }\n return parts.join(\" > \")\n}\n","import { DEFAULT_TRUSTED_ORIGINS } from \"../visual-editing/protocol\"\nimport { CMS_MARKUP_SOURCE, isTrustedOrigin, parseMarkupMessage, type MarkupOutbound } from \"./protocol\"\nimport { computeSelector } from \"./selector\"\n\nexport interface MarkupBridgeOptions { trustedOrigins?: string[] }\n\n/**\n * In-page markup capture bridge. Dormant until a trusted CMS parent sends\n * `markup-init`. Captures clicks anywhere, reports pin coords + selector, and\n * answers `markup-locate` so the parent can re-anchor pins. Returns cleanup.\n */\nexport function createMarkupBridge(options: MarkupBridgeOptions = {}): () => void {\n if (typeof window === \"undefined\" || window.parent === window) return () => {}\n const allow = options.trustedOrigins ?? DEFAULT_TRUSTED_ORIGINS\n let parentOrigin: string | null = null\n let capturing = false\n\n const doc = () => ({\n docW: document.documentElement.scrollWidth,\n docH: document.documentElement.scrollHeight,\n })\n\n function post(msg: MarkupOutbound) {\n if (parentOrigin) window.parent.postMessage(msg, parentOrigin)\n }\n\n function onClick(e: MouseEvent) {\n if (!capturing || !parentOrigin) return\n const el = e.target as Element | null\n if (!el) return\n e.preventDefault()\n e.stopPropagation()\n const { docW, docH } = doc()\n const sx = window.scrollX, sy = window.scrollY\n const docX = e.clientX + sx, docY = e.clientY + sy\n const r = el.getBoundingClientRect()\n const rx = r.width > 0 ? (e.clientX - r.left) / r.width : 0\n const ry = r.height > 0 ? (e.clientY - r.top) / r.height : 0\n post({\n source: CMS_MARKUP_SOURCE, type: \"markup-pin\",\n xPct: docW > 0 ? docX / docW : 0,\n yPct: docH > 0 ? docY / docH : 0,\n relX: Math.min(1, Math.max(0, rx)),\n relY: Math.min(1, Math.max(0, ry)),\n selector: computeSelector(el),\n viewportW: window.innerWidth, docW, docH,\n })\n }\n\n function emitViewport() {\n if (!parentOrigin) return\n const { docW, docH } = doc()\n post({\n source: CMS_MARKUP_SOURCE, type: \"markup-viewport\",\n scrollX: window.scrollX, scrollY: window.scrollY,\n docW, docH, viewportW: window.innerWidth,\n })\n }\n\n function onMessage(e: MessageEvent) {\n if (!isTrustedOrigin(e.origin, allow)) return\n const msg = parseMarkupMessage(e.data)\n if (!msg) return\n if (msg.type === \"markup-init\") {\n parentOrigin = e.origin\n capturing = true\n document.addEventListener(\"click\", onClick, true)\n document.body.style.cursor = \"crosshair\"\n emitViewport()\n } else if (msg.type === \"markup-locate\") {\n const el = (() => { try { return document.querySelector(msg.selector) } catch { return null } })()\n const rect = el\n ? (() => {\n const r = (el as Element).getBoundingClientRect()\n return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height }\n })()\n : null\n post({ source: CMS_MARKUP_SOURCE, type: \"markup-located\", id: msg.id, rect })\n }\n }\n\n window.addEventListener(\"message\", onMessage)\n window.addEventListener(\"scroll\", emitViewport, true)\n window.addEventListener(\"resize\", emitViewport)\n\n // Announce readiness on mount — parent replies with markup-init to start capture.\n const initialDoc = doc()\n window.parent.postMessage(\n { source: CMS_MARKUP_SOURCE, type: \"markup-ready\", docW: initialDoc.docW, docH: initialDoc.docH },\n \"*\" // markup-ready carries no sensitive data; parent validates origin on its side\n )\n\n return () => {\n window.removeEventListener(\"message\", onMessage)\n window.removeEventListener(\"scroll\", emitViewport, true)\n window.removeEventListener(\"resize\", emitViewport)\n document.removeEventListener(\"click\", onClick, true)\n document.body.style.cursor = \"\"\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,169 @@
1
+ "use client";
2
+ "use client";
3
+
4
+ // src/markup/react.tsx
5
+ import { useEffect } from "react";
6
+
7
+ // src/visual-editing/protocol.ts
8
+ var DEFAULT_TRUSTED_ORIGINS = [
9
+ "https://cms.distinctstudio.co.nz",
10
+ "https://distinctcms.com"
11
+ ];
12
+ function isTrustedOrigin(origin, allowlist) {
13
+ if (allowlist.includes(origin)) return true;
14
+ return /^https?:\/\/localhost(:\d+)?$/.test(origin);
15
+ }
16
+
17
+ // src/markup/protocol.ts
18
+ var CMS_MARKUP_SOURCE = "cms-markup";
19
+ var VALID = /* @__PURE__ */ new Set([
20
+ "markup-ready",
21
+ "markup-pin",
22
+ "markup-viewport",
23
+ "markup-located",
24
+ "markup-init",
25
+ "markup-locate"
26
+ ]);
27
+ function parseMarkupMessage(data) {
28
+ if (typeof data === "object" && data !== null && data.source === CMS_MARKUP_SOURCE && typeof data.type === "string" && VALID.has(data.type)) {
29
+ return data;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ // src/markup/selector.ts
35
+ function computeSelector(el) {
36
+ if (!el || !el.isConnected) return null;
37
+ if (el.id) return `#${CSS.escape(el.id)}`;
38
+ const field = el.getAttribute("data-cms-field");
39
+ if (field) return `[data-cms-field="${CSS.escape(field)}"]`;
40
+ const parts = [];
41
+ let node = el;
42
+ while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== "html") {
43
+ if (node.id) {
44
+ parts.unshift(`#${CSS.escape(node.id)}`);
45
+ break;
46
+ }
47
+ const tag = node.tagName.toLowerCase();
48
+ const parent = node.parentElement;
49
+ if (!parent) {
50
+ parts.unshift(tag);
51
+ break;
52
+ }
53
+ const sameType = Array.from(parent.children).filter((c) => c.tagName === node.tagName);
54
+ const idx = sameType.indexOf(node) + 1;
55
+ parts.unshift(sameType.length > 1 ? `${tag}:nth-of-type(${idx})` : tag);
56
+ node = parent;
57
+ }
58
+ return parts.join(" > ");
59
+ }
60
+
61
+ // src/markup/bridge.ts
62
+ function createMarkupBridge(options = {}) {
63
+ if (typeof window === "undefined" || window.parent === window) return () => {
64
+ };
65
+ const allow = options.trustedOrigins ?? DEFAULT_TRUSTED_ORIGINS;
66
+ let parentOrigin = null;
67
+ let capturing = false;
68
+ const doc = () => ({
69
+ docW: document.documentElement.scrollWidth,
70
+ docH: document.documentElement.scrollHeight
71
+ });
72
+ function post(msg) {
73
+ if (parentOrigin) window.parent.postMessage(msg, parentOrigin);
74
+ }
75
+ function onClick(e) {
76
+ if (!capturing || !parentOrigin) return;
77
+ const el = e.target;
78
+ if (!el) return;
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ const { docW, docH } = doc();
82
+ const sx = window.scrollX, sy = window.scrollY;
83
+ const docX = e.clientX + sx, docY = e.clientY + sy;
84
+ const r = el.getBoundingClientRect();
85
+ const rx = r.width > 0 ? (e.clientX - r.left) / r.width : 0;
86
+ const ry = r.height > 0 ? (e.clientY - r.top) / r.height : 0;
87
+ post({
88
+ source: CMS_MARKUP_SOURCE,
89
+ type: "markup-pin",
90
+ xPct: docW > 0 ? docX / docW : 0,
91
+ yPct: docH > 0 ? docY / docH : 0,
92
+ relX: Math.min(1, Math.max(0, rx)),
93
+ relY: Math.min(1, Math.max(0, ry)),
94
+ selector: computeSelector(el),
95
+ viewportW: window.innerWidth,
96
+ docW,
97
+ docH
98
+ });
99
+ }
100
+ function emitViewport() {
101
+ if (!parentOrigin) return;
102
+ const { docW, docH } = doc();
103
+ post({
104
+ source: CMS_MARKUP_SOURCE,
105
+ type: "markup-viewport",
106
+ scrollX: window.scrollX,
107
+ scrollY: window.scrollY,
108
+ docW,
109
+ docH,
110
+ viewportW: window.innerWidth
111
+ });
112
+ }
113
+ function onMessage(e) {
114
+ if (!isTrustedOrigin(e.origin, allow)) return;
115
+ const msg = parseMarkupMessage(e.data);
116
+ if (!msg) return;
117
+ if (msg.type === "markup-init") {
118
+ parentOrigin = e.origin;
119
+ capturing = true;
120
+ document.addEventListener("click", onClick, true);
121
+ document.body.style.cursor = "crosshair";
122
+ emitViewport();
123
+ } else if (msg.type === "markup-locate") {
124
+ const el = (() => {
125
+ try {
126
+ return document.querySelector(msg.selector);
127
+ } catch {
128
+ return null;
129
+ }
130
+ })();
131
+ const rect = el ? (() => {
132
+ const r = el.getBoundingClientRect();
133
+ return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height };
134
+ })() : null;
135
+ post({ source: CMS_MARKUP_SOURCE, type: "markup-located", id: msg.id, rect });
136
+ }
137
+ }
138
+ window.addEventListener("message", onMessage);
139
+ window.addEventListener("scroll", emitViewport, true);
140
+ window.addEventListener("resize", emitViewport);
141
+ const initialDoc = doc();
142
+ window.parent.postMessage(
143
+ { source: CMS_MARKUP_SOURCE, type: "markup-ready", docW: initialDoc.docW, docH: initialDoc.docH },
144
+ "*"
145
+ // markup-ready carries no sensitive data; parent validates origin on its side
146
+ );
147
+ return () => {
148
+ window.removeEventListener("message", onMessage);
149
+ window.removeEventListener("scroll", emitViewport, true);
150
+ window.removeEventListener("resize", emitViewport);
151
+ document.removeEventListener("click", onClick, true);
152
+ document.body.style.cursor = "";
153
+ };
154
+ }
155
+
156
+ // src/markup/react.tsx
157
+ function CmsMarkupBridge({ trustedOrigins, param = "cms_markup" }) {
158
+ useEffect(() => {
159
+ if (typeof window === "undefined") return;
160
+ const params = new URLSearchParams(window.location.search);
161
+ if (!params.has(param)) return;
162
+ return createMarkupBridge({ trustedOrigins });
163
+ }, [trustedOrigins, param]);
164
+ return null;
165
+ }
166
+ export {
167
+ CmsMarkupBridge
168
+ };
169
+ //# sourceMappingURL=markup.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/markup/react.tsx","../src/visual-editing/protocol.ts","../src/markup/protocol.ts","../src/markup/selector.ts","../src/markup/bridge.ts"],"sourcesContent":["\"use client\"\nimport { useEffect } from \"react\"\nimport { createMarkupBridge } from \"./bridge\"\n\nexport interface CmsMarkupBridgeProps {\n trustedOrigins?: string[]\n /** Query param that activates markup mode. Defaults to \"cms_markup\". */\n param?: string\n}\n\n/**\n * Mount once in your site (e.g. root layout, alongside CmsVisualBridge).\n * Renders nothing; activates only when loaded with the markup param inside a\n * trusted CMS frame.\n */\nexport function CmsMarkupBridge({ trustedOrigins, param = \"cms_markup\" }: CmsMarkupBridgeProps) {\n useEffect(() => {\n if (typeof window === \"undefined\") return\n const params = new URLSearchParams(window.location.search)\n if (!params.has(param)) return\n return createMarkupBridge({ trustedOrigins })\n }, [trustedOrigins, param])\n return null\n}\n","/** Discriminator stamped on every visual-editing postMessage. */\nexport const CMS_VISUAL_SOURCE = \"cms-visual\" as const\n\n/** CMS origins the bridge will trust by default. */\nexport const DEFAULT_TRUSTED_ORIGINS: readonly string[] = [\n \"https://cms.distinctstudio.co.nz\",\n \"https://distinctcms.com\",\n]\n\nexport type VisualFieldType = \"text\" | \"image\"\n\n/** Compact wire type announced in `ready`; the schema adds length constraints. */\nexport interface VisualFieldMeta {\n name: string\n type: VisualFieldType\n}\n\nexport interface VisualFieldSchema {\n name: string\n type: VisualFieldType\n max_length?: number\n recommended_length?: number\n}\n\n// bridge -> parent\nexport interface ReadyMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"ready\"\n fields: VisualFieldMeta[]\n}\nexport interface EditMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"edit\"\n name: string\n value: string\n length: number\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\nexport interface PickImageMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"pick-image\"\n name: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\n// parent -> bridge\nexport interface InitMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"init\"\n values: Record<string, string>\n schema: Record<string, VisualFieldSchema>\n}\nexport interface SetMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"set\"\n name: string\n value: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\nexport type BridgeOutbound = ReadyMessage | EditMessage | PickImageMessage\nexport type BridgeInbound = InitMessage | SetMessage\nexport type VisualMessage = BridgeOutbound | BridgeInbound\n\n/** Exact-match origin allowlist, plus any localhost port for local dev. */\nexport function isTrustedOrigin(\n origin: string,\n allowlist: readonly string[]\n): boolean {\n if (allowlist.includes(origin)) return true\n return /^https?:\\/\\/localhost(:\\d+)?$/.test(origin)\n}\n\nconst VALID_VISUAL_TYPES = new Set([\"ready\", \"edit\", \"pick-image\", \"init\", \"set\"])\n\n/** Narrow an unknown postMessage payload to a visual message, or null. */\nexport function parseVisualMessage(data: unknown): VisualMessage | null {\n if (\n typeof data === \"object\" &&\n data !== null &&\n (data as Record<string, unknown>).source === CMS_VISUAL_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID_VISUAL_TYPES.has((data as Record<string, unknown>).type as string)\n ) {\n return data as VisualMessage\n }\n return null\n}\n","import { isTrustedOrigin } from \"../visual-editing/protocol\"\n\nexport { isTrustedOrigin }\nexport const CMS_MARKUP_SOURCE = \"cms-markup\" as const\n\nexport interface MarkupReady {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-ready\"\n docW: number\n docH: number\n}\nexport interface MarkupPin {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-pin\"\n xPct: number\n yPct: number\n relX: number\n relY: number\n selector: string | null\n viewportW: number\n docW: number\n docH: number\n}\nexport interface MarkupViewport {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-viewport\"\n scrollX: number\n scrollY: number\n docW: number\n docH: number\n viewportW: number\n}\nexport interface MarkupLocated {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-located\"\n id: string\n rect: { x: number; y: number; w: number; h: number } | null\n}\nexport interface MarkupInit {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-init\"\n}\nexport interface MarkupLocate {\n source: typeof CMS_MARKUP_SOURCE\n type: \"markup-locate\"\n id: string\n selector: string\n}\n\nexport type MarkupOutbound = MarkupReady | MarkupPin | MarkupViewport | MarkupLocated\nexport type MarkupInbound = MarkupInit | MarkupLocate\nexport type MarkupMessage = MarkupOutbound | MarkupInbound\n\nconst VALID = new Set([\n \"markup-ready\", \"markup-pin\", \"markup-viewport\", \"markup-located\",\n \"markup-init\", \"markup-locate\",\n])\n\nexport function parseMarkupMessage(data: unknown): MarkupMessage | null {\n if (\n typeof data === \"object\" && data !== null &&\n (data as Record<string, unknown>).source === CMS_MARKUP_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID.has((data as Record<string, unknown>).type as string)\n ) {\n return data as MarkupMessage\n }\n return null\n}\n","/** Build a stable-ish CSS selector for an element, for re-anchoring pins. */\nexport function computeSelector(el: Element): string | null {\n if (!el || !el.isConnected) return null\n if (el.id) return `#${CSS.escape(el.id)}`\n const field = el.getAttribute(\"data-cms-field\")\n if (field) return `[data-cms-field=\"${CSS.escape(field)}\"]`\n\n const parts: string[] = []\n let node: Element | null = el\n while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== \"html\") {\n if (node.id) { parts.unshift(`#${CSS.escape(node.id)}`); break }\n const tag = node.tagName.toLowerCase()\n const parent: Element | null = node.parentElement\n if (!parent) { parts.unshift(tag); break }\n const sameType = Array.from(parent.children).filter((c) => c.tagName === node!.tagName)\n const idx = sameType.indexOf(node) + 1\n parts.unshift(sameType.length > 1 ? `${tag}:nth-of-type(${idx})` : tag)\n node = parent\n }\n return parts.join(\" > \")\n}\n","import { DEFAULT_TRUSTED_ORIGINS } from \"../visual-editing/protocol\"\nimport { CMS_MARKUP_SOURCE, isTrustedOrigin, parseMarkupMessage, type MarkupOutbound } from \"./protocol\"\nimport { computeSelector } from \"./selector\"\n\nexport interface MarkupBridgeOptions { trustedOrigins?: string[] }\n\n/**\n * In-page markup capture bridge. Dormant until a trusted CMS parent sends\n * `markup-init`. Captures clicks anywhere, reports pin coords + selector, and\n * answers `markup-locate` so the parent can re-anchor pins. Returns cleanup.\n */\nexport function createMarkupBridge(options: MarkupBridgeOptions = {}): () => void {\n if (typeof window === \"undefined\" || window.parent === window) return () => {}\n const allow = options.trustedOrigins ?? DEFAULT_TRUSTED_ORIGINS\n let parentOrigin: string | null = null\n let capturing = false\n\n const doc = () => ({\n docW: document.documentElement.scrollWidth,\n docH: document.documentElement.scrollHeight,\n })\n\n function post(msg: MarkupOutbound) {\n if (parentOrigin) window.parent.postMessage(msg, parentOrigin)\n }\n\n function onClick(e: MouseEvent) {\n if (!capturing || !parentOrigin) return\n const el = e.target as Element | null\n if (!el) return\n e.preventDefault()\n e.stopPropagation()\n const { docW, docH } = doc()\n const sx = window.scrollX, sy = window.scrollY\n const docX = e.clientX + sx, docY = e.clientY + sy\n const r = el.getBoundingClientRect()\n const rx = r.width > 0 ? (e.clientX - r.left) / r.width : 0\n const ry = r.height > 0 ? (e.clientY - r.top) / r.height : 0\n post({\n source: CMS_MARKUP_SOURCE, type: \"markup-pin\",\n xPct: docW > 0 ? docX / docW : 0,\n yPct: docH > 0 ? docY / docH : 0,\n relX: Math.min(1, Math.max(0, rx)),\n relY: Math.min(1, Math.max(0, ry)),\n selector: computeSelector(el),\n viewportW: window.innerWidth, docW, docH,\n })\n }\n\n function emitViewport() {\n if (!parentOrigin) return\n const { docW, docH } = doc()\n post({\n source: CMS_MARKUP_SOURCE, type: \"markup-viewport\",\n scrollX: window.scrollX, scrollY: window.scrollY,\n docW, docH, viewportW: window.innerWidth,\n })\n }\n\n function onMessage(e: MessageEvent) {\n if (!isTrustedOrigin(e.origin, allow)) return\n const msg = parseMarkupMessage(e.data)\n if (!msg) return\n if (msg.type === \"markup-init\") {\n parentOrigin = e.origin\n capturing = true\n document.addEventListener(\"click\", onClick, true)\n document.body.style.cursor = \"crosshair\"\n emitViewport()\n } else if (msg.type === \"markup-locate\") {\n const el = (() => { try { return document.querySelector(msg.selector) } catch { return null } })()\n const rect = el\n ? (() => {\n const r = (el as Element).getBoundingClientRect()\n return { x: r.left + window.scrollX, y: r.top + window.scrollY, w: r.width, h: r.height }\n })()\n : null\n post({ source: CMS_MARKUP_SOURCE, type: \"markup-located\", id: msg.id, rect })\n }\n }\n\n window.addEventListener(\"message\", onMessage)\n window.addEventListener(\"scroll\", emitViewport, true)\n window.addEventListener(\"resize\", emitViewport)\n\n // Announce readiness on mount — parent replies with markup-init to start capture.\n const initialDoc = doc()\n window.parent.postMessage(\n { source: CMS_MARKUP_SOURCE, type: \"markup-ready\", docW: initialDoc.docW, docH: initialDoc.docH },\n \"*\" // markup-ready carries no sensitive data; parent validates origin on its side\n )\n\n return () => {\n window.removeEventListener(\"message\", onMessage)\n window.removeEventListener(\"scroll\", emitViewport, true)\n window.removeEventListener(\"resize\", emitViewport)\n document.removeEventListener(\"click\", onClick, true)\n document.body.style.cursor = \"\"\n }\n}\n"],"mappings":"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","names":[]}
@@ -61,6 +61,15 @@ function enforceMaxLength(value, max) {
61
61
  if (max == null) return value;
62
62
  return value.length > max ? value.slice(0, Math.max(0, max)) : value;
63
63
  }
64
+ function resolveFieldAddress(el, root) {
65
+ const field = el.getAttribute("data-cms-field") ?? "";
66
+ const item = el.closest("[data-cms-item]");
67
+ if (!item) return { field };
68
+ const list = item.getAttribute("data-cms-item") ?? "";
69
+ const siblings = Array.from(root.querySelectorAll(`[data-cms-item="${CSS.escape(list)}"]`));
70
+ const index = siblings.indexOf(item);
71
+ return { field, list, index };
72
+ }
64
73
 
65
74
  // src/visual-editing/bridge.ts
66
75
  function createVisualBridge(options = {}) {
@@ -70,6 +79,8 @@ function createVisualBridge(options = {}) {
70
79
  let parentOrigin = null;
71
80
  let schema = {};
72
81
  let activeEl = null;
82
+ let activeAddr = null;
83
+ let hoverEnabled = false;
73
84
  const counter = createCounterEl();
74
85
  function post(msg) {
75
86
  if (parentOrigin) window.parent.postMessage(msg, parentOrigin);
@@ -77,59 +88,71 @@ function createVisualBridge(options = {}) {
77
88
  function elFor(name) {
78
89
  return document.querySelector(`[data-cms-field="${CSS.escape(name)}"]`);
79
90
  }
80
- function applyValue(name, value) {
81
- const el = elFor(name);
91
+ function elForAddress(field, list, index) {
92
+ if (list == null || index == null) return elFor(field);
93
+ const items = document.querySelectorAll(`[data-cms-item="${CSS.escape(list)}"]`);
94
+ const item = items[index];
95
+ return item ? item.querySelector(`[data-cms-field="${CSS.escape(field)}"]`) : null;
96
+ }
97
+ function applyValueEl(el, value) {
82
98
  if (!el) return;
83
99
  if (el.getAttribute("data-cms-type") === "image") {
84
100
  if (el instanceof HTMLImageElement) el.src = value;
85
- } else {
86
- el.textContent = value;
87
- }
101
+ } else el.textContent = value;
102
+ }
103
+ function applyValue(name, value) {
104
+ applyValueEl(elFor(name), value);
105
+ }
106
+ function schemaKeyOf(a) {
107
+ return a.list ? `${a.list}.${a.field}` : a.field;
88
108
  }
89
109
  function onClick(e) {
90
110
  const el = e.target?.closest("[data-cms-field]");
91
111
  if (!el || !parentOrigin) return;
92
- const name = el.getAttribute("data-cms-field");
93
112
  e.preventDefault();
113
+ const addr = resolveFieldAddress(el, document);
94
114
  if (el.getAttribute("data-cms-type") === "image") {
95
- post({ source: CMS_VISUAL_SOURCE, type: "pick-image", name });
115
+ post({ source: CMS_VISUAL_SOURCE, type: "pick-image", name: addr.field, list: addr.list, index: addr.index });
96
116
  return;
97
117
  }
98
118
  activeEl = el;
119
+ activeAddr = addr;
99
120
  el.setAttribute("contenteditable", "true");
100
121
  el.focus();
101
- positionCounter(el, name);
122
+ positionCounter(el, schemaKeyOf(addr));
102
123
  }
103
124
  function onInput(e) {
104
125
  const el = e.target;
105
- const name = el.getAttribute?.("data-cms-field");
106
- if (!name || el !== activeEl) return;
107
- const max = schema[name]?.max_length;
126
+ if (!el.getAttribute?.("data-cms-field") || el !== activeEl) return;
127
+ const addr = activeAddr ?? { field: el.getAttribute("data-cms-field") ?? "" };
128
+ const key = schemaKeyOf(addr);
129
+ const max = schema[key]?.max_length;
108
130
  const clamped = enforceMaxLength(el.textContent ?? "", max);
109
131
  if (clamped !== el.textContent) {
110
132
  el.textContent = clamped;
111
133
  placeCaretAtEnd(el);
112
134
  }
113
- updateCounter(name, clamped.length);
114
- post({ source: CMS_VISUAL_SOURCE, type: "edit", name, value: clamped, length: clamped.length });
135
+ updateCounter(key, clamped.length);
136
+ post({ source: CMS_VISUAL_SOURCE, type: "edit", name: addr.field, value: clamped, length: clamped.length, list: addr.list, index: addr.index });
115
137
  }
116
138
  function onBlur(e) {
117
139
  const el = e.target;
118
140
  if (el === activeEl) {
119
141
  el.removeAttribute("contenteditable");
120
142
  activeEl = null;
143
+ activeAddr = null;
121
144
  counter.style.display = "none";
122
145
  }
123
146
  }
124
- function positionCounter(el, name) {
147
+ function positionCounter(el, schemaKey) {
125
148
  const r = el.getBoundingClientRect();
126
149
  counter.style.top = `${window.scrollY + r.bottom + 4}px`;
127
150
  counter.style.left = `${window.scrollX + r.left}px`;
128
151
  counter.style.display = "block";
129
- updateCounter(name, (el.textContent ?? "").length);
152
+ updateCounter(schemaKey, (el.textContent ?? "").length);
130
153
  }
131
- function updateCounter(name, len) {
132
- const f = schema[name];
154
+ function updateCounter(schemaKey, len) {
155
+ const f = schema[schemaKey];
133
156
  const rec = f?.recommended_length;
134
157
  const max = f?.max_length;
135
158
  counter.textContent = max ? `${len} / ${rec ?? max}${rec && max ? ` \xB7 max ${max}` : ""}` : `${len}`;
@@ -147,10 +170,12 @@ function createVisualBridge(options = {}) {
147
170
  for (const [name, value] of Object.entries(msg.values)) applyValue(name, value);
148
171
  enableHover();
149
172
  } else if (msg.type === "set") {
150
- applyValue(msg.name, msg.value);
173
+ applyValueEl(elForAddress(msg.name, msg.list, msg.index), msg.value);
151
174
  }
152
175
  }
153
176
  function enableHover() {
177
+ if (hoverEnabled) return;
178
+ hoverEnabled = true;
154
179
  document.body.setAttribute("data-cms-editing", "true");
155
180
  injectHoverStyles();
156
181
  document.addEventListener("click", onClick, true);
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/visual-editing/react.tsx","../src/visual-editing/protocol.ts","../src/visual-editing/dom.ts","../src/visual-editing/bridge.ts"],"sourcesContent":["// packages/client/src/visual-editing/react.tsx\n\"use client\"\nimport { useEffect } from \"react\"\nimport { createVisualBridge } from \"./bridge\"\n\nexport interface CmsVisualBridgeProps {\n /** Override the CMS origins the bridge trusts (defaults to Distinct CMS origins + localhost). */\n trustedOrigins?: string[]\n /** Query param that activates preview mode. Defaults to \"cms_preview\". */\n param?: string\n}\n\n/**\n * Mount once in your site (e.g. root layout). Renders nothing and stays inert\n * for normal visitors — it only activates when the page is loaded with the\n * preview param inside a trusted CMS frame.\n */\nexport function CmsVisualBridge({ trustedOrigins, param = \"cms_preview\" }: CmsVisualBridgeProps) {\n useEffect(() => {\n if (typeof window === \"undefined\") return\n const params = new URLSearchParams(window.location.search)\n if (!params.has(param)) return\n const cleanup = createVisualBridge({ trustedOrigins })\n return cleanup\n }, [trustedOrigins, param])\n return null\n}\n","/** Discriminator stamped on every visual-editing postMessage. */\nexport const CMS_VISUAL_SOURCE = \"cms-visual\" as const\n\n/** CMS origins the bridge will trust by default. */\nexport const DEFAULT_TRUSTED_ORIGINS: readonly string[] = [\n \"https://cms.distinctstudio.co.nz\",\n \"https://distinctcms.com\",\n]\n\nexport type VisualFieldType = \"text\" | \"image\"\n\n/** Compact wire type announced in `ready`; the schema adds length constraints. */\nexport interface VisualFieldMeta {\n name: string\n type: VisualFieldType\n}\n\nexport interface VisualFieldSchema {\n name: string\n type: VisualFieldType\n max_length?: number\n recommended_length?: number\n}\n\n// bridge -> parent\nexport interface ReadyMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"ready\"\n fields: VisualFieldMeta[]\n}\nexport interface EditMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"edit\"\n name: string\n value: string\n length: number\n}\nexport interface PickImageMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"pick-image\"\n name: string\n}\n\n// parent -> bridge\nexport interface InitMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"init\"\n values: Record<string, string>\n schema: Record<string, VisualFieldSchema>\n}\nexport interface SetMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"set\"\n name: string\n value: string\n}\n\nexport type BridgeOutbound = ReadyMessage | EditMessage | PickImageMessage\nexport type BridgeInbound = InitMessage | SetMessage\nexport type VisualMessage = BridgeOutbound | BridgeInbound\n\n/** Exact-match origin allowlist, plus any localhost port for local dev. */\nexport function isTrustedOrigin(\n origin: string,\n allowlist: readonly string[]\n): boolean {\n if (allowlist.includes(origin)) return true\n return /^https?:\\/\\/localhost(:\\d+)?$/.test(origin)\n}\n\nconst VALID_VISUAL_TYPES = new Set([\"ready\", \"edit\", \"pick-image\", \"init\", \"set\"])\n\n/** Narrow an unknown postMessage payload to a visual message, or null. */\nexport function parseVisualMessage(data: unknown): VisualMessage | null {\n if (\n typeof data === \"object\" &&\n data !== null &&\n (data as Record<string, unknown>).source === CMS_VISUAL_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID_VISUAL_TYPES.has((data as Record<string, unknown>).type as string)\n ) {\n return data as VisualMessage\n }\n return null\n}\n","import type { VisualFieldMeta } from \"./protocol\"\n\n/** Read every [data-cms-field] element in document order. */\nexport function discoverFields(root: Document | HTMLElement): VisualFieldMeta[] {\n const nodes = root.querySelectorAll<HTMLElement>(\"[data-cms-field]\")\n const fields: VisualFieldMeta[] = []\n nodes.forEach((el) => {\n const name = el.getAttribute(\"data-cms-field\")\n if (!name) return\n const type = el.getAttribute(\"data-cms-type\") === \"image\" ? \"image\" : \"text\"\n fields.push({ name, type })\n })\n return fields\n}\n\n/** Truncate text to a hard max. Undefined max means no limit. */\nexport function enforceMaxLength(value: string, max: number | undefined): string {\n if (max == null) return value\n return value.length > max ? value.slice(0, Math.max(0, max)) : value\n}\n","// packages/client/src/visual-editing/bridge.ts\nimport {\n CMS_VISUAL_SOURCE,\n DEFAULT_TRUSTED_ORIGINS,\n isTrustedOrigin,\n parseVisualMessage,\n type BridgeOutbound,\n type VisualFieldSchema,\n} from \"./protocol\"\nimport { discoverFields, enforceMaxLength } from \"./dom\"\n\nexport interface VisualBridgeOptions {\n trustedOrigins?: string[]\n}\n\n/**\n * Mounts the in-page editor bridge. Dormant until a trusted CMS parent sends\n * `init`. Returns a cleanup function. No-op when not running inside a frame.\n */\nexport function createVisualBridge(options: VisualBridgeOptions = {}): () => void {\n if (typeof window === \"undefined\" || window.parent === window) return () => {}\n const allow = options.trustedOrigins ?? DEFAULT_TRUSTED_ORIGINS\n\n let parentOrigin: string | null = null\n let schema: Record<string, VisualFieldSchema> = {}\n let activeEl: HTMLElement | null = null\n const counter = createCounterEl()\n\n function post(msg: BridgeOutbound) {\n if (parentOrigin) window.parent.postMessage(msg, parentOrigin)\n }\n\n function elFor(name: string): HTMLElement | null {\n return document.querySelector<HTMLElement>(`[data-cms-field=\"${CSS.escape(name)}\"]`)\n }\n\n function applyValue(name: string, value: string) {\n const el = elFor(name)\n if (!el) return\n if (el.getAttribute(\"data-cms-type\") === \"image\") {\n if (el instanceof HTMLImageElement) el.src = value\n } else {\n el.textContent = value\n }\n }\n\n function onClick(e: MouseEvent) {\n const el = (e.target as HTMLElement)?.closest<HTMLElement>(\"[data-cms-field]\")\n if (!el || !parentOrigin) return\n const name = el.getAttribute(\"data-cms-field\")!\n e.preventDefault()\n if (el.getAttribute(\"data-cms-type\") === \"image\") {\n post({ source: CMS_VISUAL_SOURCE, type: \"pick-image\", name })\n return\n }\n // text: edit in place\n activeEl = el\n el.setAttribute(\"contenteditable\", \"true\")\n el.focus()\n positionCounter(el, name)\n }\n\n function onInput(e: Event) {\n const el = e.target as HTMLElement\n const name = el.getAttribute?.(\"data-cms-field\")\n if (!name || el !== activeEl) return\n const max = schema[name]?.max_length\n const clamped = enforceMaxLength(el.textContent ?? \"\", max)\n if (clamped !== el.textContent) {\n el.textContent = clamped\n placeCaretAtEnd(el)\n }\n updateCounter(name, clamped.length)\n post({ source: CMS_VISUAL_SOURCE, type: \"edit\", name, value: clamped, length: clamped.length })\n }\n\n function onBlur(e: FocusEvent) {\n const el = e.target as HTMLElement\n if (el === activeEl) {\n el.removeAttribute(\"contenteditable\")\n activeEl = null\n counter.style.display = \"none\"\n }\n }\n\n function positionCounter(el: HTMLElement, name: string) {\n const r = el.getBoundingClientRect()\n counter.style.top = `${window.scrollY + r.bottom + 4}px`\n counter.style.left = `${window.scrollX + r.left}px`\n counter.style.display = \"block\"\n updateCounter(name, (el.textContent ?? \"\").length)\n }\n\n function updateCounter(name: string, len: number) {\n const f = schema[name]\n const rec = f?.recommended_length\n const max = f?.max_length\n counter.textContent = max ? `${len} / ${rec ?? max}${rec && max ? ` · max ${max}` : \"\"}` : `${len}`\n const over = max != null && len >= max\n const warn = !over && rec != null && len > rec\n counter.style.background = over ? \"#ef4444\" : warn ? \"#f59e0b\" : \"#334155\"\n }\n\n function onMessage(e: MessageEvent) {\n if (!isTrustedOrigin(e.origin, allow)) return\n const msg = parseVisualMessage(e.data)\n if (!msg) return\n if (msg.type === \"init\") {\n parentOrigin = e.origin\n schema = msg.schema\n for (const [name, value] of Object.entries(msg.values)) applyValue(name, value)\n enableHover()\n } else if (msg.type === \"set\") {\n applyValue(msg.name, msg.value)\n }\n }\n\n function enableHover() {\n document.body.setAttribute(\"data-cms-editing\", \"true\")\n injectHoverStyles()\n document.addEventListener(\"click\", onClick, true)\n document.addEventListener(\"input\", onInput, true)\n document.addEventListener(\"blur\", onBlur, true)\n }\n\n window.addEventListener(\"message\", onMessage)\n // Announce readiness to whatever parent embedded us.\n window.parent.postMessage(\n { source: CMS_VISUAL_SOURCE, type: \"ready\", fields: discoverFields(document) },\n \"*\" // ready carries no data; parent validates origin on its side\n )\n\n return () => {\n window.removeEventListener(\"message\", onMessage)\n document.removeEventListener(\"click\", onClick, true)\n document.removeEventListener(\"input\", onInput, true)\n document.removeEventListener(\"blur\", onBlur, true)\n document.body.removeAttribute(\"data-cms-editing\")\n counter.remove()\n }\n}\n\nfunction createCounterEl(): HTMLElement {\n const el = document.createElement(\"div\")\n el.setAttribute(\"data-cms-counter\", \"\")\n Object.assign(el.style, {\n position: \"absolute\", display: \"none\", zIndex: \"2147483647\",\n padding: \"2px 6px\", borderRadius: \"4px\", color: \"#fff\",\n font: \"12px/1.4 system-ui, sans-serif\", pointerEvents: \"none\",\n } as CSSStyleDeclaration)\n document.body.appendChild(el)\n return el\n}\n\nfunction injectHoverStyles() {\n if (document.getElementById(\"cms-visual-styles\")) return\n const s = document.createElement(\"style\")\n s.id = \"cms-visual-styles\"\n s.textContent = `\n [data-cms-editing] [data-cms-field]{outline:1px dashed rgba(99,102,241,.6);outline-offset:2px;cursor:text}\n [data-cms-editing] [data-cms-field][data-cms-type=\"image\"]{cursor:pointer}\n [data-cms-editing] [data-cms-field]:hover{outline:2px solid #6366f1}\n `\n document.head.appendChild(s)\n}\n\nfunction placeCaretAtEnd(el: HTMLElement) {\n const range = document.createRange()\n range.selectNodeContents(el)\n range.collapse(false)\n const sel = window.getSelection()\n sel?.removeAllRanges()\n sel?.addRange(range)\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/visual-editing/react.tsx","../src/visual-editing/protocol.ts","../src/visual-editing/dom.ts","../src/visual-editing/bridge.ts"],"sourcesContent":["// packages/client/src/visual-editing/react.tsx\n\"use client\"\nimport { useEffect } from \"react\"\nimport { createVisualBridge } from \"./bridge\"\n\nexport interface CmsVisualBridgeProps {\n /** Override the CMS origins the bridge trusts (defaults to Distinct CMS origins + localhost). */\n trustedOrigins?: string[]\n /** Query param that activates preview mode. Defaults to \"cms_preview\". */\n param?: string\n}\n\n/**\n * Mount once in your site (e.g. root layout). Renders nothing and stays inert\n * for normal visitors — it only activates when the page is loaded with the\n * preview param inside a trusted CMS frame.\n */\nexport function CmsVisualBridge({ trustedOrigins, param = \"cms_preview\" }: CmsVisualBridgeProps) {\n useEffect(() => {\n if (typeof window === \"undefined\") return\n const params = new URLSearchParams(window.location.search)\n if (!params.has(param)) return\n const cleanup = createVisualBridge({ trustedOrigins })\n return cleanup\n }, [trustedOrigins, param])\n return null\n}\n","/** Discriminator stamped on every visual-editing postMessage. */\nexport const CMS_VISUAL_SOURCE = \"cms-visual\" as const\n\n/** CMS origins the bridge will trust by default. */\nexport const DEFAULT_TRUSTED_ORIGINS: readonly string[] = [\n \"https://cms.distinctstudio.co.nz\",\n \"https://distinctcms.com\",\n]\n\nexport type VisualFieldType = \"text\" | \"image\"\n\n/** Compact wire type announced in `ready`; the schema adds length constraints. */\nexport interface VisualFieldMeta {\n name: string\n type: VisualFieldType\n}\n\nexport interface VisualFieldSchema {\n name: string\n type: VisualFieldType\n max_length?: number\n recommended_length?: number\n}\n\n// bridge -> parent\nexport interface ReadyMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"ready\"\n fields: VisualFieldMeta[]\n}\nexport interface EditMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"edit\"\n name: string\n value: string\n length: number\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\nexport interface PickImageMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"pick-image\"\n name: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\n// parent -> bridge\nexport interface InitMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"init\"\n values: Record<string, string>\n schema: Record<string, VisualFieldSchema>\n}\nexport interface SetMessage {\n source: typeof CMS_VISUAL_SOURCE\n type: \"set\"\n name: string\n value: string\n /** When the field is inside a repeating group item, the group field name. */\n list?: string\n /** Item index within the group (document order). */\n index?: number\n}\n\nexport type BridgeOutbound = ReadyMessage | EditMessage | PickImageMessage\nexport type BridgeInbound = InitMessage | SetMessage\nexport type VisualMessage = BridgeOutbound | BridgeInbound\n\n/** Exact-match origin allowlist, plus any localhost port for local dev. */\nexport function isTrustedOrigin(\n origin: string,\n allowlist: readonly string[]\n): boolean {\n if (allowlist.includes(origin)) return true\n return /^https?:\\/\\/localhost(:\\d+)?$/.test(origin)\n}\n\nconst VALID_VISUAL_TYPES = new Set([\"ready\", \"edit\", \"pick-image\", \"init\", \"set\"])\n\n/** Narrow an unknown postMessage payload to a visual message, or null. */\nexport function parseVisualMessage(data: unknown): VisualMessage | null {\n if (\n typeof data === \"object\" &&\n data !== null &&\n (data as Record<string, unknown>).source === CMS_VISUAL_SOURCE &&\n typeof (data as Record<string, unknown>).type === \"string\" &&\n VALID_VISUAL_TYPES.has((data as Record<string, unknown>).type as string)\n ) {\n return data as VisualMessage\n }\n return null\n}\n","import type { VisualFieldMeta } from \"./protocol\"\n\n/** Read every [data-cms-field] element in document order. */\nexport function discoverFields(root: Document | HTMLElement): VisualFieldMeta[] {\n const nodes = root.querySelectorAll<HTMLElement>(\"[data-cms-field]\")\n const fields: VisualFieldMeta[] = []\n nodes.forEach((el) => {\n const name = el.getAttribute(\"data-cms-field\")\n if (!name) return\n const type = el.getAttribute(\"data-cms-type\") === \"image\" ? \"image\" : \"text\"\n fields.push({ name, type })\n })\n return fields\n}\n\n/** Truncate text to a hard max. Undefined max means no limit. */\nexport function enforceMaxLength(value: string, max: number | undefined): string {\n if (max == null) return value\n return value.length > max ? value.slice(0, Math.max(0, max)) : value\n}\n\nexport interface FieldAddress {\n field: string\n list?: string\n index?: number\n}\n\n/** Resolve a [data-cms-field] element to a flat field or a repeating-item address. */\nexport function resolveFieldAddress(el: HTMLElement, root: Document | HTMLElement): FieldAddress {\n const field = el.getAttribute(\"data-cms-field\") ?? \"\"\n const item = el.closest<HTMLElement>(\"[data-cms-item]\")\n if (!item) return { field }\n const list = item.getAttribute(\"data-cms-item\") ?? \"\"\n const siblings = Array.from(root.querySelectorAll<HTMLElement>(`[data-cms-item=\"${CSS.escape(list)}\"]`))\n const index = siblings.indexOf(item)\n return { field, list, index }\n}\n","// packages/client/src/visual-editing/bridge.ts\nimport {\n CMS_VISUAL_SOURCE,\n DEFAULT_TRUSTED_ORIGINS,\n isTrustedOrigin,\n parseVisualMessage,\n type BridgeOutbound,\n type VisualFieldSchema,\n} from \"./protocol\"\nimport { discoverFields, enforceMaxLength, resolveFieldAddress, type FieldAddress } from \"./dom\"\n\nexport interface VisualBridgeOptions {\n trustedOrigins?: string[]\n}\n\n/**\n * Mounts the in-page editor bridge. Dormant until a trusted CMS parent sends\n * `init`. Returns a cleanup function. No-op when not running inside a frame.\n */\nexport function createVisualBridge(options: VisualBridgeOptions = {}): () => void {\n if (typeof window === \"undefined\" || window.parent === window) return () => {}\n const allow = options.trustedOrigins ?? DEFAULT_TRUSTED_ORIGINS\n\n let parentOrigin: string | null = null\n let schema: Record<string, VisualFieldSchema> = {}\n let activeEl: HTMLElement | null = null\n let activeAddr: FieldAddress | null = null\n let hoverEnabled = false\n const counter = createCounterEl()\n\n function post(msg: BridgeOutbound) {\n if (parentOrigin) window.parent.postMessage(msg, parentOrigin)\n }\n\n function elFor(name: string): HTMLElement | null {\n return document.querySelector<HTMLElement>(`[data-cms-field=\"${CSS.escape(name)}\"]`)\n }\n\n function elForAddress(field: string, list?: string, index?: number): HTMLElement | null {\n if (list == null || index == null) return elFor(field)\n const items = document.querySelectorAll<HTMLElement>(`[data-cms-item=\"${CSS.escape(list)}\"]`)\n const item = items[index]\n return item ? item.querySelector<HTMLElement>(`[data-cms-field=\"${CSS.escape(field)}\"]`) : null\n }\n\n function applyValueEl(el: HTMLElement | null, value: string) {\n if (!el) return\n if (el.getAttribute(\"data-cms-type\") === \"image\") { if (el instanceof HTMLImageElement) el.src = value }\n else el.textContent = value\n }\n\n function applyValue(name: string, value: string) { applyValueEl(elFor(name), value) }\n\n function schemaKeyOf(a: FieldAddress): string { return a.list ? `${a.list}.${a.field}` : a.field }\n\n function onClick(e: MouseEvent) {\n const el = (e.target as HTMLElement)?.closest<HTMLElement>(\"[data-cms-field]\")\n if (!el || !parentOrigin) return\n e.preventDefault()\n const addr = resolveFieldAddress(el, document)\n if (el.getAttribute(\"data-cms-type\") === \"image\") {\n post({ source: CMS_VISUAL_SOURCE, type: \"pick-image\", name: addr.field, list: addr.list, index: addr.index })\n return\n }\n // text: edit in place\n activeEl = el\n activeAddr = addr\n el.setAttribute(\"contenteditable\", \"true\")\n el.focus()\n positionCounter(el, schemaKeyOf(addr))\n }\n\n function onInput(e: Event) {\n const el = e.target as HTMLElement\n if (!el.getAttribute?.(\"data-cms-field\") || el !== activeEl) return\n const addr = activeAddr ?? { field: el.getAttribute(\"data-cms-field\") ?? \"\" }\n const key = schemaKeyOf(addr)\n const max = schema[key]?.max_length\n const clamped = enforceMaxLength(el.textContent ?? \"\", max)\n if (clamped !== el.textContent) { el.textContent = clamped; placeCaretAtEnd(el) }\n updateCounter(key, clamped.length)\n post({ source: CMS_VISUAL_SOURCE, type: \"edit\", name: addr.field, value: clamped, length: clamped.length, list: addr.list, index: addr.index })\n }\n\n function onBlur(e: FocusEvent) {\n const el = e.target as HTMLElement\n if (el === activeEl) {\n el.removeAttribute(\"contenteditable\")\n activeEl = null\n activeAddr = null\n counter.style.display = \"none\"\n }\n }\n\n function positionCounter(el: HTMLElement, schemaKey: string) {\n const r = el.getBoundingClientRect()\n counter.style.top = `${window.scrollY + r.bottom + 4}px`\n counter.style.left = `${window.scrollX + r.left}px`\n counter.style.display = \"block\"\n updateCounter(schemaKey, (el.textContent ?? \"\").length)\n }\n\n function updateCounter(schemaKey: string, len: number) {\n const f = schema[schemaKey]\n const rec = f?.recommended_length\n const max = f?.max_length\n counter.textContent = max ? `${len} / ${rec ?? max}${rec && max ? ` · max ${max}` : \"\"}` : `${len}`\n const over = max != null && len >= max\n const warn = !over && rec != null && len > rec\n counter.style.background = over ? \"#ef4444\" : warn ? \"#f59e0b\" : \"#334155\"\n }\n\n function onMessage(e: MessageEvent) {\n if (!isTrustedOrigin(e.origin, allow)) return\n const msg = parseVisualMessage(e.data)\n if (!msg) return\n if (msg.type === \"init\") {\n parentOrigin = e.origin\n schema = msg.schema\n for (const [name, value] of Object.entries(msg.values)) applyValue(name, value)\n enableHover()\n } else if (msg.type === \"set\") {\n applyValueEl(elForAddress(msg.name, msg.list, msg.index), msg.value)\n }\n }\n\n function enableHover() {\n if (hoverEnabled) return\n hoverEnabled = true\n document.body.setAttribute(\"data-cms-editing\", \"true\")\n injectHoverStyles()\n document.addEventListener(\"click\", onClick, true)\n document.addEventListener(\"input\", onInput, true)\n document.addEventListener(\"blur\", onBlur, true)\n }\n\n window.addEventListener(\"message\", onMessage)\n // Announce readiness to whatever parent embedded us.\n window.parent.postMessage(\n { source: CMS_VISUAL_SOURCE, type: \"ready\", fields: discoverFields(document) },\n \"*\" // ready carries no data; parent validates origin on its side\n )\n\n return () => {\n window.removeEventListener(\"message\", onMessage)\n document.removeEventListener(\"click\", onClick, true)\n document.removeEventListener(\"input\", onInput, true)\n document.removeEventListener(\"blur\", onBlur, true)\n document.body.removeAttribute(\"data-cms-editing\")\n counter.remove()\n }\n}\n\nfunction createCounterEl(): HTMLElement {\n const el = document.createElement(\"div\")\n el.setAttribute(\"data-cms-counter\", \"\")\n Object.assign(el.style, {\n position: \"absolute\", display: \"none\", zIndex: \"2147483647\",\n padding: \"2px 6px\", borderRadius: \"4px\", color: \"#fff\",\n font: \"12px/1.4 system-ui, sans-serif\", pointerEvents: \"none\",\n } as CSSStyleDeclaration)\n document.body.appendChild(el)\n return el\n}\n\nfunction injectHoverStyles() {\n if (document.getElementById(\"cms-visual-styles\")) return\n const s = document.createElement(\"style\")\n s.id = \"cms-visual-styles\"\n s.textContent = `\n [data-cms-editing] [data-cms-field]{outline:1px dashed rgba(99,102,241,.6);outline-offset:2px;cursor:text}\n [data-cms-editing] [data-cms-field][data-cms-type=\"image\"]{cursor:pointer}\n [data-cms-editing] [data-cms-field]:hover{outline:2px solid #6366f1}\n `\n document.head.appendChild(s)\n}\n\nfunction placeCaretAtEnd(el: HTMLElement) {\n const range = document.createRange()\n range.selectNodeContents(el)\n range.collapse(false)\n const sel = window.getSelection()\n sel?.removeAllRanges()\n sel?.addRange(range)\n}\n"],"mappings":"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","names":[]}
@@ -38,6 +38,15 @@ function enforceMaxLength(value, max) {
38
38
  if (max == null) return value;
39
39
  return value.length > max ? value.slice(0, Math.max(0, max)) : value;
40
40
  }
41
+ function resolveFieldAddress(el, root) {
42
+ const field = el.getAttribute("data-cms-field") ?? "";
43
+ const item = el.closest("[data-cms-item]");
44
+ if (!item) return { field };
45
+ const list = item.getAttribute("data-cms-item") ?? "";
46
+ const siblings = Array.from(root.querySelectorAll(`[data-cms-item="${CSS.escape(list)}"]`));
47
+ const index = siblings.indexOf(item);
48
+ return { field, list, index };
49
+ }
41
50
 
42
51
  // src/visual-editing/bridge.ts
43
52
  function createVisualBridge(options = {}) {
@@ -47,6 +56,8 @@ function createVisualBridge(options = {}) {
47
56
  let parentOrigin = null;
48
57
  let schema = {};
49
58
  let activeEl = null;
59
+ let activeAddr = null;
60
+ let hoverEnabled = false;
50
61
  const counter = createCounterEl();
51
62
  function post(msg) {
52
63
  if (parentOrigin) window.parent.postMessage(msg, parentOrigin);
@@ -54,59 +65,71 @@ function createVisualBridge(options = {}) {
54
65
  function elFor(name) {
55
66
  return document.querySelector(`[data-cms-field="${CSS.escape(name)}"]`);
56
67
  }
57
- function applyValue(name, value) {
58
- const el = elFor(name);
68
+ function elForAddress(field, list, index) {
69
+ if (list == null || index == null) return elFor(field);
70
+ const items = document.querySelectorAll(`[data-cms-item="${CSS.escape(list)}"]`);
71
+ const item = items[index];
72
+ return item ? item.querySelector(`[data-cms-field="${CSS.escape(field)}"]`) : null;
73
+ }
74
+ function applyValueEl(el, value) {
59
75
  if (!el) return;
60
76
  if (el.getAttribute("data-cms-type") === "image") {
61
77
  if (el instanceof HTMLImageElement) el.src = value;
62
- } else {
63
- el.textContent = value;
64
- }
78
+ } else el.textContent = value;
79
+ }
80
+ function applyValue(name, value) {
81
+ applyValueEl(elFor(name), value);
82
+ }
83
+ function schemaKeyOf(a) {
84
+ return a.list ? `${a.list}.${a.field}` : a.field;
65
85
  }
66
86
  function onClick(e) {
67
87
  const el = e.target?.closest("[data-cms-field]");
68
88
  if (!el || !parentOrigin) return;
69
- const name = el.getAttribute("data-cms-field");
70
89
  e.preventDefault();
90
+ const addr = resolveFieldAddress(el, document);
71
91
  if (el.getAttribute("data-cms-type") === "image") {
72
- post({ source: CMS_VISUAL_SOURCE, type: "pick-image", name });
92
+ post({ source: CMS_VISUAL_SOURCE, type: "pick-image", name: addr.field, list: addr.list, index: addr.index });
73
93
  return;
74
94
  }
75
95
  activeEl = el;
96
+ activeAddr = addr;
76
97
  el.setAttribute("contenteditable", "true");
77
98
  el.focus();
78
- positionCounter(el, name);
99
+ positionCounter(el, schemaKeyOf(addr));
79
100
  }
80
101
  function onInput(e) {
81
102
  const el = e.target;
82
- const name = el.getAttribute?.("data-cms-field");
83
- if (!name || el !== activeEl) return;
84
- const max = schema[name]?.max_length;
103
+ if (!el.getAttribute?.("data-cms-field") || el !== activeEl) return;
104
+ const addr = activeAddr ?? { field: el.getAttribute("data-cms-field") ?? "" };
105
+ const key = schemaKeyOf(addr);
106
+ const max = schema[key]?.max_length;
85
107
  const clamped = enforceMaxLength(el.textContent ?? "", max);
86
108
  if (clamped !== el.textContent) {
87
109
  el.textContent = clamped;
88
110
  placeCaretAtEnd(el);
89
111
  }
90
- updateCounter(name, clamped.length);
91
- post({ source: CMS_VISUAL_SOURCE, type: "edit", name, value: clamped, length: clamped.length });
112
+ updateCounter(key, clamped.length);
113
+ post({ source: CMS_VISUAL_SOURCE, type: "edit", name: addr.field, value: clamped, length: clamped.length, list: addr.list, index: addr.index });
92
114
  }
93
115
  function onBlur(e) {
94
116
  const el = e.target;
95
117
  if (el === activeEl) {
96
118
  el.removeAttribute("contenteditable");
97
119
  activeEl = null;
120
+ activeAddr = null;
98
121
  counter.style.display = "none";
99
122
  }
100
123
  }
101
- function positionCounter(el, name) {
124
+ function positionCounter(el, schemaKey) {
102
125
  const r = el.getBoundingClientRect();
103
126
  counter.style.top = `${window.scrollY + r.bottom + 4}px`;
104
127
  counter.style.left = `${window.scrollX + r.left}px`;
105
128
  counter.style.display = "block";
106
- updateCounter(name, (el.textContent ?? "").length);
129
+ updateCounter(schemaKey, (el.textContent ?? "").length);
107
130
  }
108
- function updateCounter(name, len) {
109
- const f = schema[name];
131
+ function updateCounter(schemaKey, len) {
132
+ const f = schema[schemaKey];
110
133
  const rec = f?.recommended_length;
111
134
  const max = f?.max_length;
112
135
  counter.textContent = max ? `${len} / ${rec ?? max}${rec && max ? ` \xB7 max ${max}` : ""}` : `${len}`;
@@ -124,10 +147,12 @@ function createVisualBridge(options = {}) {
124
147
  for (const [name, value] of Object.entries(msg.values)) applyValue(name, value);
125
148
  enableHover();
126
149
  } else if (msg.type === "set") {
127
- applyValue(msg.name, msg.value);
150
+ applyValueEl(elForAddress(msg.name, msg.list, msg.index), msg.value);
128
151
  }
129
152
  }
130
153
  function enableHover() {
154
+ if (hoverEnabled) return;
155
+ hoverEnabled = true;
131
156
  document.body.setAttribute("data-cms-editing", "true");
132
157
  injectHoverStyles();
133
158
  document.addEventListener("click", onClick, true);