@bettercms-ai/convert 0.5.0 → 0.7.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.
package/dist/index.js CHANGED
@@ -141,195 +141,77 @@ function propsAttribute(props) {
141
141
  return `data-bcms-props="${value}"`;
142
142
  }
143
143
 
144
- // src/helper.ts
145
- import { createHash as createHash2 } from "crypto";
146
- var HELPER_VERSION = 4;
147
- var HEADER = "// @bettercms-ai/convert helper v";
148
- var HELPER_PATH = Object.fromEntries(
149
- Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
150
- );
151
- var CONTENT_DIR = "bcms-content";
152
- var STUB_CONTENT = "{}";
153
- function relativeImport(fromFile, toFile) {
154
- const from = fromFile.split("/").slice(0, -1);
155
- const to = toFile.split("/");
156
- let shared = 0;
157
- while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared++;
158
- const up = from.length - shared;
159
- const path = [...Array(up).fill(".."), ...to.slice(shared)].join("/");
160
- return up === 0 ? `./${path}` : path;
161
- }
162
- function helperSource(dialect) {
163
- const path = HELPER_PATH[dialect];
164
- if (!path) throw new Error(`The ${dialect} dialect reads its values at publish time, not at build time.`);
165
- const layout = relativeImport(path, `${CONTENT_DIR}/layout.json`);
166
- return `${HEADER}${HELPER_VERSION}
167
- // Values come from bcms-content/*.json, written into the tree before the build. The committed
168
- // stubs are {} \u2014 a build with them renders every fallback below and still succeeds.
169
- import layoutSnapshot from "${layout}";
170
-
171
- const isRecord = (v: unknown): v is Record<string, unknown> =>
172
- typeof v === "object" && v !== null && !Array.isArray(v);
173
-
174
- /** Dotted keys and \`[n]\` indices, positionally \u2014 the editor's own path grammar. */
175
- function readPath(root: unknown, path: string): unknown {
176
- let cursor: unknown = root;
177
- for (const segment of path.split(".")) {
178
- if (!segment) return undefined;
179
- const key = segment.replace(/\\[\\d+\\]/g, "");
180
- if (key) {
181
- if (!isRecord(cursor)) return undefined;
182
- cursor = cursor[key];
144
+ // src/declarations.ts
145
+ var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
146
+ var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
147
+ var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
148
+ var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
149
+ var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
150
+ const [address, kind] = entry.split("|");
151
+ if (!address) return [];
152
+ const scope = address.startsWith("layout:") ? "layout" : "page";
153
+ return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
154
+ });
155
+ var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
156
+ var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
157
+ var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
158
+ function declarationsIn(roots, content) {
159
+ const out = [];
160
+ const visit = (node) => {
161
+ if (node.type === "expr") {
162
+ for (const child of node.children) visit(child);
163
+ return;
183
164
  }
184
- for (const index of segment.matchAll(/\\[(\\d+)\\]/g)) {
185
- if (!Array.isArray(cursor)) return undefined;
186
- cursor = cursor[Number(index[1])];
165
+ if (node.type !== "element") return;
166
+ const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
167
+ const at = node.attrInsertAt;
168
+ for (const attr of node.attrs) {
169
+ const name = attr.name.replace(/^:/, "");
170
+ if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
171
+ const scope = name === "data-bcms-layout-field" ? "layout" : "page";
172
+ const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
173
+ const templated = template?.[1] ?? template?.[2];
174
+ if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
175
+ else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
176
+ } else if (attr.name === "data-bcms-props") {
177
+ out.push(...propsDeclarations(attr.value, at));
178
+ } else if (attr.name === "bcmsBindings") {
179
+ for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
180
+ out.push({ scope: "page", path, kind: "", at, via: "prop" });
181
+ }
182
+ }
187
183
  }
188
- }
189
- return cursor;
190
- }
191
-
192
- /** A stored value as a template renders it: richtext is its html, an image is its url. */
193
- function render(value: unknown): string | undefined {
194
- if (typeof value === "string") return value;
195
- if (typeof value === "number" || typeof value === "boolean") return String(value);
196
- if (isRecord(value)) {
197
- if (typeof value.html === "string") return value.html;
198
- if (typeof value.url === "string") return value.url;
199
- }
200
- return undefined;
201
- }
202
-
203
- /** One field of one page. \`slug\` selects the entry on a dynamic route. */
204
- export function bcms(page: unknown, path: string, fallback: string, slug?: string): string {
205
- const entries = isRecord(page) ? page.entries : undefined;
206
- const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
207
- return render(readPath(root, path)) ?? fallback;
208
- }
209
-
210
- /** One field of the shared layout \u2014 the header and footer every route renders. */
211
- export function bcmsLayout(path: string, fallback: string): string {
212
- return render(readPath(layoutSnapshot, path)) ?? fallback;
213
- }
214
-
215
- /**
216
- * The ROWS of a repeater. \`fallback\` is every row the template shipped with, so a build against
217
- * an empty snapshot still renders the same number of cards it always did.
218
- */
219
- export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: string): T[] {
220
- const entries = isRecord(page) ? page.entries : undefined;
221
- const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
222
- const value = readPath(root, path);
223
- return Array.isArray(value) ? (value as T[]) : fallback;
224
- }
225
-
226
- /**
227
- * ONE SECTION's copy, for a component extracted by \`--componentize\`.
228
- *
229
- * OVERRIDES FIRST, PER KEY: \`overrides[key] ?? group[key]\`. An instance that owns its copy
230
- * (Phase B) carries an override for every prop it declares, so the group underneath it is never
231
- * read; a placement still bound to a page field group (Phase A) carries none, so the group is all
232
- * there is. The interesting case is the one in between \u2014 a section half moved \u2014 where a key the
233
- * instance does not hold has to keep rendering the page's value rather than nothing.
234
- *
235
- * \u{1F534} \`??\`, NOT \`||\`. An empty string is a value an editor typed, and treating it as absent put
236
- * the page's old copy back into a heading somebody had deliberately cleared.
237
- *
238
- * With neither an override nor a group, the answer is \`{}\` and every \`bcms(...)\` below renders
239
- * the fallback the template shipped with \u2014 which is why a section renders correctly against the
240
- * committed \`{}\` stub.
241
- */
242
- export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
243
- const group = bind ? readPath(page, bind) : undefined;
244
- const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
245
- if (!isRecord(overrides)) return base;
246
- for (const key of Object.keys(overrides)) {
247
- const value = overrides[key];
248
- if (value === undefined || value === null) continue;
249
- /**
250
- * \u{1F534} A TABLE PROP IS STORED AS \`{ rows: [...] }\` AND READ AS A LIST. That is the shape the
251
- * dashboard edits a repeater in; handing it to \`bcmsRows\` unchanged is not an array, so the
252
- * helper fell back to the rows the template shipped with and the editor's work never showed.
253
- */
254
- base[key] = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
255
- }
256
- return base;
257
- }
258
-
259
- /**
260
- * WHICH ADDRESS one bound element declares \u2014 decided at RENDER time, from the placement.
261
- *
262
- * An element carries exactly ONE \`data-bcms-field\`, and which lane it names depends on where its
263
- * copy lives. A placement that owns its copy declares the BLOCK lane \u2014
264
- * \`<blockId>__overrides.<propKey>\` \u2014 because that is the address the editor commits an instance's
265
- * own value to. A placement bound to a page field group declares the PAGE path, which is what the
266
- * page form and the coverage meter already know.
267
- *
268
- * \u{1F534} THE BOUND ADDRESS IS RE-BASED ONTO **THIS** PLACEMENT'S GROUP. The markup was cut from ONE
269
- * page and carries that page's paths (\`hero.title\`); the whole point of \`shapeHash\` is that a
270
- * second page renders the same component bound to a group of its own (\`bind="landingHero"\`).
271
- * Handing the embedded path through unchanged declared \`hero.title\` on a page whose field is
272
- * \`landingHero.title\` \u2014 an address that page does not have, so the editor wrote into nothing and
273
- * the meter counted a binding that was never there. The prefix the markup was cut with comes out,
274
- * the placement's own group goes in.
275
- *
276
- * \u{1F534} THE SAME EXTRACTED FILE SERVES BOTH LANES. The choice is a function of the props, never of
277
- * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
278
- * instance on the next, and a repository does not have to be re-cut when its copy moves.
279
- */
280
- export function bcmsFields(
281
- placement: { blockId?: string; bind?: string; overrides?: unknown },
282
- pathPrefix: string,
283
- ): (path: string) => string {
284
- const id = placement.blockId;
285
- const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
286
- /**
287
- * What replaces the prefix the markup was cut with, or null for "leave every path alone".
288
- *
289
- * Null covers a placement with neither a bind nor an instance \u2014 a shipped fallback, or a page
290
- * nobody has componentized yet \u2014 and a bound placement in a component whose prefix is EMPTY,
291
- * where the plan said the prop key IS the whole page path: there is no group segment to swap,
292
- * so prepending one would invent an address rather than correct it.
293
- */
294
- const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
295
- return (path: string): string => {
296
- // A path outside the group this section was cut from is not one this placement re-addresses:
297
- // it names a key the instance does not hold and a group this placement is not bound to.
298
- // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
299
- if (lane === null || !path.startsWith(pathPrefix)) return path;
300
- return lane + path.slice(pathPrefix.length);
184
+ for (const child of node.children) visit(child);
301
185
  };
186
+ for (const root of roots) visit(root);
187
+ return out;
302
188
  }
303
- `;
304
- }
305
- var HISTORICAL = {
306
- "src/bcms-content.ts": {
307
- 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
308
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
309
- 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
310
- },
311
- "src/lib/bcms-content.ts": {
312
- 1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
313
- 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
314
- 3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e"
315
- },
316
- "composables/bcms-content.ts": {
317
- 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
318
- 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
319
- 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227"
189
+ var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
190
+ var scopedPath = (scope, path) => `${scope}::${path}`;
191
+ var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
192
+ var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
193
+ var scannedDeclarations = (content) => [...declaredByScan(content)].map((entry) => {
194
+ const [scope, ...rest] = entry.split("::");
195
+ return { scope, path: rest.join("::") };
196
+ });
197
+ function declaredByScan(content) {
198
+ const out = /* @__PURE__ */ new Set();
199
+ const add = (scope, path) => void out.add(scopedPath(scope, path));
200
+ for (const match of content.matchAll(BINDING_ATTR)) {
201
+ add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
320
202
  }
321
- };
322
- var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
323
- function isKnownHelper(content, dialect) {
324
- if (content === helperSource(dialect)) return HELPER_VERSION;
325
- const path = HELPER_PATH[dialect];
326
- if (!path) return null;
327
- const digest = sha256(content);
328
- for (const [version, known] of Object.entries(HISTORICAL[path] ?? {})) {
329
- if (known === digest) return Number(version);
203
+ for (const match of content.matchAll(TEMPLATE_SCAN)) {
204
+ add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
330
205
  }
331
- return null;
206
+ for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
207
+ for (const match of content.matchAll(PROPS_ATTR))
208
+ for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
209
+ return out;
332
210
  }
211
+ var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
212
+
213
+ // src/sites/jsx.ts
214
+ import { parse as parseModule } from "@babel/parser";
333
215
 
334
216
  // src/entities.ts
335
217
  var LONGEST_ENTITY = 32;
@@ -2667,202 +2549,6 @@ function decodeStringEscapes(body) {
2667
2549
  }
2668
2550
  var unwrapJsxStrings = (value) => value.replace(JSX_STRING_CHILD, (_whole, _quote, body) => decodeStringEscapes(body));
2669
2551
 
2670
- // src/locate.ts
2671
- var flat = (value) => value.replace(/\s+/g, " ").trim();
2672
- var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
2673
- function locate(brief, sources, options) {
2674
- const flattened = sources.map((s) => {
2675
- const readable = unwrapJsxStrings(decodeEntities(s.content));
2676
- return {
2677
- path: s.path,
2678
- content: flat(readable),
2679
- text: options?.stripTags ? flat(stripTags(readable)) : null
2680
- };
2681
- });
2682
- const located = [];
2683
- const unlocated = [];
2684
- for (const page of brief.pages) {
2685
- for (const p of page.paths) {
2686
- const original = p.original === null ? "" : flat(p.original);
2687
- if (!original) {
2688
- unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
2689
- continue;
2690
- }
2691
- const files = flattened.filter((f) => f.content.includes(original) || f.text !== null && f.text.includes(original)).map((f) => f.path);
2692
- if (files.length === 0) {
2693
- unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
2694
- continue;
2695
- }
2696
- located.push({ route: page.route, scope: p.scope, path: p.path, kind: p.kind, original, files });
2697
- }
2698
- }
2699
- return { located, unlocated };
2700
- }
2701
-
2702
- // src/loops.ts
2703
- import MagicString from "magic-string";
2704
-
2705
- // src/declarations.ts
2706
- var BINDING_ATTR = /data-bcms-(?:layout-)?field\s*=\s*["']([^"']+)["']/g;
2707
- var PROPS_ATTR = /data-bcms-props\s*=\s*["']([^"']+)["']/g;
2708
- var TEMPLATE_SCAN = /:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")/g;
2709
- var BINDINGS_SCAN = /bcmsBindings\s*=\s*\{\{[^}]*\}\}/g;
2710
- var propsDeclarations = (value, at) => value.split(";").flatMap((entry) => {
2711
- const [address, kind] = entry.split("|");
2712
- if (!address) return [];
2713
- const scope = address.startsWith("layout:") ? "layout" : "page";
2714
- return [{ scope, path: stripLayoutPrefix(address), kind: kind ?? "", at, via: "props" }];
2715
- });
2716
- var propsPaths = (value) => propsDeclarations(value, 0).map((d) => ({ scope: d.scope, path: d.path }));
2717
- var TEMPLATE_BINDING = /^:?data-bcms-(?:layout-)?field\s*=\s*(?:\{\s*`([^`]+)`\s*\}|"\s*`([^`]+)`\s*")$/;
2718
- var shapeOfPath = (path) => path.replace(/\[(?:\d+|\$\{[^}]*\})\]/g, "[*]");
2719
- function declarationsIn(roots, content) {
2720
- const out = [];
2721
- const visit = (node) => {
2722
- if (node.type === "expr") {
2723
- for (const child of node.children) visit(child);
2724
- return;
2725
- }
2726
- if (node.type !== "element") return;
2727
- const kind = node.attrs.find((a) => a.name === "data-bcms-kind")?.value ?? "";
2728
- const at = node.attrInsertAt;
2729
- for (const attr of node.attrs) {
2730
- const name = attr.name.replace(/^:/, "");
2731
- if (name === "data-bcms-field" || name === "data-bcms-layout-field") {
2732
- const scope = name === "data-bcms-layout-field" ? "layout" : "page";
2733
- const template = content.slice(attr.range.start, attr.range.end).match(TEMPLATE_BINDING);
2734
- const templated = template?.[1] ?? template?.[2];
2735
- if (templated) out.push({ scope, path: shapeOfPath(stripLayoutPrefix(templated)), kind, at, via: "field" });
2736
- else if (attr.value) out.push({ scope, path: stripLayoutPrefix(attr.value), kind, at, via: "field" });
2737
- } else if (attr.name === "data-bcms-props") {
2738
- out.push(...propsDeclarations(attr.value, at));
2739
- } else if (attr.name === "bcmsBindings") {
2740
- for (const path of bindingsPaths(content.slice(attr.range.start, attr.range.end))) {
2741
- out.push({ scope: "page", path, kind: "", at, via: "prop" });
2742
- }
2743
- }
2744
- }
2745
- for (const child of node.children) visit(child);
2746
- };
2747
- for (const root of roots) visit(root);
2748
- return out;
2749
- }
2750
- var bindingsPaths = (text) => [...text.matchAll(/["']([^"']+)["']/g)].map((match) => match[1]);
2751
- var scopedPath = (scope, path) => `${scope}::${path}`;
2752
- var covers = (declared, target) => declared !== void 0 && (declared.has(scopedPath(target.scope, target.path)) || declared.has(scopedPath(target.scope, shapeOfPath(target.path))));
2753
- var declaredInTree = (roots, content) => new Set(declarationsIn(roots, content).map((d) => scopedPath(d.scope, d.path)));
2754
- var scannedDeclarations = (content) => [...declaredByScan(content)].map((entry) => {
2755
- const [scope, ...rest] = entry.split("::");
2756
- return { scope, path: rest.join("::") };
2757
- });
2758
- function declaredByScan(content) {
2759
- const out = /* @__PURE__ */ new Set();
2760
- const add = (scope, path) => void out.add(scopedPath(scope, path));
2761
- for (const match of content.matchAll(BINDING_ATTR)) {
2762
- add(match[0].includes("layout-field") ? "layout" : "page", stripLayoutPrefix(match[1]));
2763
- }
2764
- for (const match of content.matchAll(TEMPLATE_SCAN)) {
2765
- add(match[0].includes("layout-field") ? "layout" : "page", shapeOfPath(stripLayoutPrefix(match[1] ?? match[2])));
2766
- }
2767
- for (const match of content.matchAll(BINDINGS_SCAN)) for (const path of bindingsPaths(match[0])) add("page", path);
2768
- for (const match of content.matchAll(PROPS_ATTR))
2769
- for (const entry of propsPaths(match[1])) add(entry.scope, entry.path);
2770
- return out;
2771
- }
2772
- var stripLayoutPrefix = (value) => value.startsWith("layout:") ? value.slice("layout:".length) : value;
2773
-
2774
- // src/scope.ts
2775
- function patternNames(node, out) {
2776
- if (!node || typeof node !== "object") return;
2777
- const typed = node;
2778
- switch (typed.type) {
2779
- case "Identifier":
2780
- out.add(String(typed.name));
2781
- return;
2782
- case "ObjectPattern":
2783
- for (const property of typed.properties ?? [])
2784
- patternNames(property.type === "RestElement" ? property.argument : property.value, out);
2785
- return;
2786
- case "ArrayPattern":
2787
- for (const element of typed.elements ?? []) patternNames(element, out);
2788
- return;
2789
- case "AssignmentPattern":
2790
- patternNames(typed.left, out);
2791
- return;
2792
- case "RestElement":
2793
- patternNames(typed.argument, out);
2794
- return;
2795
- default:
2796
- return;
2797
- }
2798
- }
2799
- function statementNames(node, out) {
2800
- if (!node || typeof node !== "object") return;
2801
- const typed = node;
2802
- switch (typed.type) {
2803
- case "ExportNamedDeclaration":
2804
- case "ExportDefaultDeclaration":
2805
- statementNames(typed.declaration, out);
2806
- return;
2807
- case "ImportDeclaration":
2808
- for (const specifier of typed.specifiers ?? []) patternNames(specifier.local, out);
2809
- return;
2810
- case "VariableDeclaration":
2811
- for (const declarator of typed.declarations ?? []) patternNames(declarator.id, out);
2812
- return;
2813
- case "FunctionDeclaration":
2814
- case "ClassDeclaration":
2815
- case "TSTypeAliasDeclaration":
2816
- case "TSInterfaceDeclaration":
2817
- case "TSEnumDeclaration":
2818
- case "TSModuleDeclaration":
2819
- patternNames(typed.id, out);
2820
- return;
2821
- default:
2822
- return;
2823
- }
2824
- }
2825
- var DECLARED = /\b(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
2826
- var IMPORTED = /\bimport\s+([^;\n]*?)\s+from\b/g;
2827
- function scanNames(code, out) {
2828
- for (const match of code.matchAll(DECLARED)) out.add(match[1]);
2829
- for (const match of code.matchAll(IMPORTED))
2830
- for (const name of match[1].match(/[A-Za-z_$][\w$]*/g) ?? []) {
2831
- if (name !== "as" && name !== "type") out.add(name);
2832
- }
2833
- }
2834
- function scriptText(nodes, content, out) {
2835
- for (const node of nodes) {
2836
- if (node.type !== "element") continue;
2837
- if (node.raw && node.tag === "script" && node.inner) out.push(content.slice(node.inner.start, node.inner.end));
2838
- scriptText(node.children, content, out);
2839
- }
2840
- }
2841
- function topLevelBindings(dialect, parsed, content) {
2842
- const out = /* @__PURE__ */ new Set();
2843
- const program = parsed.ast?.program;
2844
- if (program) for (const statement of program.body ?? []) statementNames(statement, out);
2845
- else if (parsed.script) scanNames(parsed.script.code, out);
2846
- if (dialect === "html") {
2847
- const scripts = [];
2848
- scriptText(parsed.roots, content, scripts);
2849
- for (const code of scripts) scanNames(code, out);
2850
- }
2851
- return out;
2852
- }
2853
- function allocateNames(taken, wanted) {
2854
- const used = new Set(taken);
2855
- const out = /* @__PURE__ */ new Map();
2856
- for (const { key, name } of wanted) {
2857
- if (out.has(key)) continue;
2858
- let candidate = name;
2859
- for (let n = 1; used.has(candidate); n++) candidate = `${name}_${n}`;
2860
- used.add(candidate);
2861
- out.set(key, candidate);
2862
- }
2863
- return out;
2864
- }
2865
-
2866
2552
  // src/sites/shared.ts
2867
2553
  var isIntrinsic = (tag) => tag !== "" && !tag.includes(".") && tag[0] === tag[0].toLowerCase();
2868
2554
  var IMAGE_SOURCES = {
@@ -2903,7 +2589,7 @@ function renderedText(node) {
2903
2589
  var attrsOf = (node) => Object.fromEntries(node.attrs.map((a) => [a.name, a.value]));
2904
2590
  function soleTextChild(node) {
2905
2591
  const texts = node.children.filter((c) => c.type === "text" && !isBlank(c.value));
2906
- return texts.length === 1 ? texts[0].range : null;
2592
+ return texts.length === 1 ? texts[0] : null;
2907
2593
  }
2908
2594
  function collectSites(roots, literals) {
2909
2595
  const wanted = new Set(literals.map(norm));
@@ -2963,13 +2649,39 @@ function collectSites(roots, literals) {
2963
2649
  scope: "page",
2964
2650
  existingAttrs: attrs,
2965
2651
  mixed: node.children.some((c) => c.type !== "text"),
2966
- textRange: soleTextChild(node),
2652
+ textRange: soleTextChild(node)?.range ?? null,
2967
2653
  literal,
2968
2654
  partial: false,
2969
2655
  ancestors: walk.ancestors,
2970
2656
  siblingIndex: walk.siblingIndex
2971
2657
  });
2972
2658
  }
2659
+ const own = soleTextChild(node);
2660
+ if (own && node.children.some((c) => c.type !== "text")) {
2661
+ const value = norm(own.value);
2662
+ for (const literal of wanted) {
2663
+ if (matched.has(literal) || value !== literal) continue;
2664
+ matched.add(literal);
2665
+ sites.push({
2666
+ file: "",
2667
+ tag: node.tag,
2668
+ attrInsertAt: node.attrInsertAt,
2669
+ // The TEXT NODE's bytes, not the element's: two values inside one element must not be
2670
+ // read as claiming the same span. @see overlapping
2671
+ range: own.range,
2672
+ inner: node.inner,
2673
+ where: "text",
2674
+ scope: "page",
2675
+ existingAttrs: attrs,
2676
+ mixed: true,
2677
+ textRange: own.range,
2678
+ literal,
2679
+ partial: false,
2680
+ ancestors: walk.ancestors,
2681
+ siblingIndex: walk.siblingIndex
2682
+ });
2683
+ }
2684
+ }
2973
2685
  const bindingsAttr = node.attrs.find((a) => a.name === "bcmsBindings")?.range;
2974
2686
  for (const attr of node.attrs) {
2975
2687
  if (attr.expression || !attr.valueRange || BCMS_ATTRS.includes(attr.name)) continue;
@@ -3036,7 +2748,7 @@ function partials(node, literals, walk, out) {
3036
2748
  scope: "page",
3037
2749
  existingAttrs: attrsOf(node),
3038
2750
  mixed: node.children.some((c) => c.type !== "text"),
3039
- textRange: soleTextChild(node),
2751
+ textRange: soleTextChild(node)?.range ?? null,
3040
2752
  literal,
3041
2753
  partial: true,
3042
2754
  ancestors: walk.ancestors,
@@ -3056,264 +2768,32 @@ function wordBounded(haystack, needle) {
3056
2768
  return false;
3057
2769
  }
3058
2770
 
3059
- // src/loops.ts
3060
- function splitNested(leaf) {
3061
- const match = leaf.match(/^([^[\]]+)\[(\d+)\]\.(.+)$/);
3062
- return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3063
- }
3064
- var elementEnd = (node) => node.inner ? node.inner.end + node.tag.length + 3 : null;
3065
- function findRows(roots, members) {
3066
- const wanted = new Set(members.map((m) => m.index));
3067
- const search = (node) => {
3068
- if (node.type !== "element") return null;
3069
- for (const child of node.children) {
3070
- const deeper = search(child);
3071
- if (deeper) return deeper;
3072
- }
3073
- const rows = [];
3074
- for (const child of node.children) {
3075
- if (child.type !== "element") continue;
3076
- const end = elementEnd(child);
3077
- if (end === null) continue;
3078
- const inside = members.filter((m) => m.site.range.start >= child.start && m.site.range.end <= end);
3079
- if (inside.length === 0) continue;
3080
- const indices = new Set(inside.map((m) => m.index));
3081
- if (indices.size !== 1) return null;
3082
- rows.push({
3083
- index: inside[0].index,
3084
- range: { start: child.start, end },
3085
- attrInsertAt: child.attrInsertAt,
3086
- // `:key` in vue, `key` in jsx — one existing key, whichever way it is spelled.
3087
- keyAttr: child.attrs.find((a) => a.name.replace(/^:/, "") === "key")?.range ?? null,
3088
- node: child,
3089
- members: inside
3090
- });
3091
- }
3092
- if (rows.length === 0) return null;
3093
- const covered = new Set(rows.flatMap((r) => r.members.map((m) => m.index)));
3094
- if (covered.size !== wanted.size || rows.length !== wanted.size) return null;
3095
- if (rows.some((r) => r.members.length !== members.length / wanted.size)) return null;
3096
- return rows.sort((a, b) => a.range.start - b.range.start);
3097
- };
3098
- for (const root of roots) {
3099
- const found = search(root);
3100
- if (found) return found;
3101
- }
3102
- return null;
3103
- }
3104
- var consecutive = (rows, content) => rows.every((row, at) => at === 0 || content.slice(rows[at - 1].range.end, row.range.start).trim() === "");
3105
- function maskedRow(row, content) {
3106
- const masked = new Map(row.members.map((member) => [member.site.inner.start, member.leaf]));
3107
- return canonical(row.node, content, masked);
3108
- }
3109
- var TRIVIA = "";
3110
- function canonical(node, content, masked) {
3111
- if (node.type === "text") {
3112
- return node.value.trim() === "" ? TRIVIA : content.slice(node.range.start, node.range.end);
3113
- }
3114
- if (node.type === "comment" || node.type === "expr") {
3115
- return content.slice(node.range.start, node.range.end);
3116
- }
3117
- if (node.type !== "element") return "";
3118
- const open = content.slice(node.start, node.inner ? node.inner.start : node.attrInsertAt);
3119
- if (node.inner && masked.has(node.inner.start)) {
3120
- return `${open}\0${masked.get(node.inner.start)}\0</${node.tag}>`;
3121
- }
3122
- const inside = node.children.map((child) => canonical(child, content, masked)).join("");
3123
- return node.inner ? `${open}${inside}</${node.tag}>` : open;
3124
- }
3125
- function sameShape(rows, content) {
3126
- const first = maskedRow(rows[0], content);
3127
- return rows.every((row) => maskedRow(row, content) === first);
3128
- }
3129
- var indented = (body, indent) => body.split("\n").map((line) => line.startsWith(indent) ? `${indent} ${line.slice(indent.length)}` : `${indent} ${line}`).join("\n");
3130
- function indentOf(content, at) {
3131
- const lineStart = content.lastIndexOf("\n", at - 1) + 1;
3132
- const prefix = content.slice(lineStart, at);
3133
- return prefix.trim() === "" ? prefix : "";
3134
- }
3135
- function writeLeaf(source, shift, member, rule, read, row, literal) {
3136
- const site = member.site;
3137
- const expression = `${read}(${row}, ${JSON.stringify(member.leaf)}, "")`;
3138
- if (member.kind === "richtext" && rule.richtext) {
3139
- source.remove(site.inner.start + shift, site.inner.end + shift);
3140
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
3141
- } else {
3142
- source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
3143
- }
3144
- const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
3145
- source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", `\`${literal}\``)}${kind}`);
3146
- }
3147
- function emitNested(rule, array, members, prefix, outer, parent, ctx) {
3148
- if (!rule.loop || !rule.text) return null;
3149
- const inner = [];
3150
- for (const member of members) {
3151
- const split = splitNested(member.leaf);
3152
- if (!split || splitNested(split.leaf) !== null) return null;
3153
- inner.push({ ...member, index: split.index, leaf: split.leaf });
3154
- }
3155
- const rows = findRows([parent.node], inner);
3156
- if (!rows || rows.length === 0) return null;
3157
- if (!consecutive(rows, ctx.content)) return null;
3158
- if (!sameShape(rows, ctx.content)) return null;
3159
- const template = rows[0];
3160
- const used = /* @__PURE__ */ new Set([
3161
- ...ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? [],
3162
- outer.row,
3163
- outer.index
3164
- ]);
3165
- const allocated = allocateNames(used, [
3166
- { key: "row", name: "item" },
3167
- { key: "index", name: "j" }
3168
- ]);
3169
- const names = { row: allocated.get("row"), index: allocated.get("index") };
3170
- const fallback = [];
3171
- const writable = inner.every((member) => setLeaf(fallback[member.index] ??= emptyRow(), member.leaf, member.fallback));
3172
- if (!writable) return null;
3173
- const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
3174
- const shift = -template.range.start;
3175
- const declarations = [];
3176
- for (const member of template.members) {
3177
- const literal = `${prefix}.${array}[\${${names.index}}].${member.leaf}`;
3178
- writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3179
- declarations.push({ path: shapeOfPath(literal), literal });
3180
- }
3181
- const rowsExpr = `${ctx.rows}(${outer.row}, ${JSON.stringify(array)}, ${JSON.stringify(fallback.filter(Boolean))})`;
3182
- if (rule.loop.kind === "attr") {
3183
- source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
3184
- }
3185
- if (rule.loopKey) {
3186
- const key = rule.loopKey(names.index);
3187
- if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
3188
- else if (template.keyAttr.end > template.keyAttr.start)
3189
- source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
3190
- else return null;
3191
- }
3192
- const indent = indentOf(ctx.content, template.range.start);
3193
- const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
3194
- ${indented(source.toString(), indent)}
3195
- ${indent}${rule.loop.close}` : source.toString();
3196
- return { range: { start: template.range.start, end: rows[rows.length - 1].range.end }, text, declarations };
3197
- }
3198
- function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
3199
- const rule = DIALECT_RULES[dialect];
3200
- if (!rule.loop || !rule.text || group.length === 0) return null;
3201
- const rows = findRows(ctx.roots, group);
3202
- if (!rows || rows.length < 2) return null;
3203
- if (!consecutive(rows, ctx.content)) return null;
3204
- if (!sameShape(rows, ctx.content)) return null;
3205
- if (group.some((m) => m.site.where !== "text" || !isIntrinsic(m.site.tag) || !m.site.inner)) return null;
3206
- const template = rows[0];
3207
- const used = new Set(ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? []);
3208
- const allocated = allocateNames(used, [
3209
- { key: "row", name: "card" },
3210
- { key: "index", name: "i" }
3211
- ]);
3212
- const names = { row: allocated.get("row"), index: allocated.get("index") };
3213
- const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
3214
- const shift = -template.range.start;
3215
- const declarations = [];
3216
- const nested = /* @__PURE__ */ new Map();
3217
- const direct = [];
3218
- for (const member of template.members) {
3219
- const inner = splitNested(member.leaf);
3220
- if (inner === null) direct.push(member);
3221
- else nested.set(inner.array, [...nested.get(inner.array) ?? [], member]);
3222
- }
3223
- for (const [array, members] of nested) {
3224
- const plan2 = emitNested(rule, array, members, `${arrayPath}[\${${names.index}}]`, names, template, ctx);
3225
- if (!plan2) return null;
3226
- if (direct.some((m) => m.site.range.start >= plan2.range.start && m.site.range.end <= plan2.range.end)) return null;
3227
- source.overwrite(plan2.range.start + shift, plan2.range.end + shift, plan2.text);
3228
- declarations.push(...plan2.declarations);
3229
- }
3230
- for (const member of direct) {
3231
- const literal = `${arrayPath}[\${${names.index}}].${member.leaf}`;
3232
- writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
3233
- declarations.push({ path: shapeOfPath(literal), literal });
3234
- }
3235
- const args = [ctx.snapshot, JSON.stringify(arrayPath), JSON.stringify(fallbackRows), ...ctx.slug ? [ctx.slug] : []];
3236
- const rowsExpr = `${ctx.rows}(${args.join(", ")})`;
3237
- if (rule.loop.kind === "attr") {
3238
- source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
3239
- }
3240
- if (rule.loopKey) {
3241
- const key = rule.loopKey(names.index);
3242
- if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
3243
- else if (template.keyAttr.end > template.keyAttr.start)
3244
- source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
3245
- else return null;
3246
- }
3247
- const indent = indentOf(ctx.content, template.range.start);
3248
- const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
3249
- ${indented(source.toString(), indent)}
3250
- ${indent}${rule.loop.close}` : source.toString();
3251
- return {
3252
- range: { start: template.range.start, end: rows[rows.length - 1].range.end },
3253
- text,
3254
- declarations,
3255
- bindings: [...new Set(declarations.map((d) => d.path))],
3256
- names
3257
- };
3258
- }
3259
- function setLeaf(row, leaf, value) {
3260
- const segments = leaf.split(".").flatMap((segment) => {
3261
- const name = segment.replace(/\[\d+\]/g, "");
3262
- const indices = [...segment.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
3263
- return [...name ? [name] : [], ...indices];
3264
- });
3265
- if (segments.some((segment) => typeof segment === "string" && FORBIDDEN.has(segment))) return false;
3266
- let cursor = row;
3267
- for (const [at, segment] of segments.entries()) {
3268
- const key = segment;
3269
- const last = at === segments.length - 1;
3270
- if (last) {
3271
- cursor[key] = value;
3272
- return true;
3273
- }
3274
- const held = Object.prototype.hasOwnProperty.call(cursor, key) ? cursor[key] : void 0;
3275
- if (held === void 0 || typeof held !== "object" || held === null) {
3276
- cursor[key] = typeof segments[at + 1] === "number" ? [] : emptyRow();
3277
- }
3278
- cursor = cursor[key];
3279
- }
3280
- return true;
3281
- }
3282
- var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
3283
- var emptyRow = () => /* @__PURE__ */ Object.create(null);
3284
- function splitIndexed(arrayPath, path) {
3285
- if (!path.startsWith(`${arrayPath}[`)) return null;
3286
- const match = path.slice(arrayPath.length).match(/^\[(\d+)\]\.(.+)$/);
3287
- return match ? { index: Number(match[1]), leaf: match[2] } : null;
3288
- }
3289
-
3290
- // src/sites/jsx.ts
3291
- import { parse as parseModule } from "@babel/parser";
3292
- var nameOf = (node) => {
3293
- if (!node) return "";
3294
- if (node.type === "JSXIdentifier") return String(node.name);
3295
- if (node.type === "JSXMemberExpression")
3296
- return `${nameOf(node.object)}.${nameOf(node.property)}`;
3297
- if (node.type === "JSXNamespacedName")
3298
- return `${nameOf(node.namespace)}:${nameOf(node.name)}`;
3299
- return "";
3300
- };
3301
- var SPREAD = "{...}";
3302
- function attrsOf2(open, content) {
3303
- return (open.attributes ?? []).flatMap((attr) => {
3304
- if (attr.type === "JSXSpreadAttribute") {
3305
- const argument = attr.argument;
3306
- return [
3307
- {
3308
- name: SPREAD,
3309
- value: content.slice(argument?.start ?? 0, argument?.end ?? 0),
3310
- valueRange: null,
3311
- expression: true,
3312
- range: { start: attr.start ?? 0, end: attr.end ?? 0 },
3313
- // `{...props}` — the braces ARE the attribute, so the expression is its whole span.
3314
- expressionRange: { start: attr.start ?? 0, end: attr.end ?? 0 }
3315
- }
3316
- ];
2771
+ // src/sites/jsx.ts
2772
+ var nameOf = (node) => {
2773
+ if (!node) return "";
2774
+ if (node.type === "JSXIdentifier") return String(node.name);
2775
+ if (node.type === "JSXMemberExpression")
2776
+ return `${nameOf(node.object)}.${nameOf(node.property)}`;
2777
+ if (node.type === "JSXNamespacedName")
2778
+ return `${nameOf(node.namespace)}:${nameOf(node.name)}`;
2779
+ return "";
2780
+ };
2781
+ var SPREAD = "{...}";
2782
+ function attrsOf2(open, content) {
2783
+ return (open.attributes ?? []).flatMap((attr) => {
2784
+ if (attr.type === "JSXSpreadAttribute") {
2785
+ const argument = attr.argument;
2786
+ return [
2787
+ {
2788
+ name: SPREAD,
2789
+ value: content.slice(argument?.start ?? 0, argument?.end ?? 0),
2790
+ valueRange: null,
2791
+ expression: true,
2792
+ range: { start: attr.start ?? 0, end: attr.end ?? 0 },
2793
+ // `{...props}` — the braces ARE the attribute, so the expression is its whole span.
2794
+ expressionRange: { start: attr.start ?? 0, end: attr.end ?? 0 }
2795
+ }
2796
+ ];
3317
2797
  }
3318
2798
  if (attr.type !== "JSXAttribute") return [];
3319
2799
  const value = attr.value;
@@ -3756,130 +3236,1090 @@ function tryParse(code) {
3756
3236
  } catch {
3757
3237
  return void 0;
3758
3238
  }
3759
- }
3760
- var bindingsName = (viaProps) => viaProps ? "props.bcmsBindings" : "bcmsBindings";
3761
- var bindingsRead = (prop, viaProps) => `${bindingsName(viaProps)}?.${prop}`;
3762
-
3763
- // src/resolve.ts
3764
- var EXTENSIONS = [".astro", ".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".mjs", ".cjs"];
3765
- function join(fromFile, specifier) {
3766
- const parts = fromFile.split("/").slice(0, -1);
3767
- for (const segment of specifier.split("/")) {
3768
- if (segment === "." || segment === "") continue;
3769
- if (segment === "..") parts.pop();
3770
- else parts.push(segment);
3239
+ }
3240
+ var bindingsName = (viaProps) => viaProps ? "props.bcmsBindings" : "bcmsBindings";
3241
+ var bindingsRead = (prop, viaProps) => `${bindingsName(viaProps)}?.${prop}`;
3242
+
3243
+ // src/data.ts
3244
+ function splitGroup(path) {
3245
+ const match = path.match(/^([^[\]]+)\[(\d+)\]\.(.+)$/);
3246
+ return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
3247
+ }
3248
+ function moduleOf(parsed) {
3249
+ if (!parsed.ast) return null;
3250
+ return { ast: parsed.ast, offset: parsed.script?.offset ?? 0 };
3251
+ }
3252
+ function arrayLiterals(ast) {
3253
+ const out = /* @__PURE__ */ new Map();
3254
+ walkAst(ast, (node) => {
3255
+ if (node.type !== "VariableDeclarator") return;
3256
+ const id = node.id;
3257
+ const init = node.init;
3258
+ if (id?.type !== "Identifier" || init?.type !== "ArrayExpression") return;
3259
+ out.set(String(id.name), init);
3260
+ });
3261
+ return out;
3262
+ }
3263
+ function propertyFor(row, literal) {
3264
+ if (row?.type !== "ObjectExpression") return null;
3265
+ for (const property of row.properties ?? []) {
3266
+ if (property.type !== "ObjectProperty" && property.type !== "Property") continue;
3267
+ const key = property.key;
3268
+ const value = property.value;
3269
+ if (value?.type !== "StringLiteral" || key?.type !== "Identifier") continue;
3270
+ if (norm(String(value.value)) === literal) return String(key.name);
3271
+ }
3272
+ return null;
3273
+ }
3274
+ function iterations(roots, identifier2) {
3275
+ const pattern = new RegExp(`(?<![\\w$])${identifier2}(?![\\w$])\\s*\\.\\s*map\\s*\\(`);
3276
+ const out = [];
3277
+ const visit = (node) => {
3278
+ if (node.type === "expr") {
3279
+ if (pattern.test(node.value)) out.push(node);
3280
+ return;
3281
+ }
3282
+ if (node.type === "element" && !node.raw) for (const child of node.children) visit(child);
3283
+ };
3284
+ for (const root of roots) visit(root);
3285
+ return out;
3286
+ }
3287
+ function callbackParams(expression, identifier2) {
3288
+ const pattern = new RegExp(
3289
+ `(?<![\\w$])${identifier2}(?![\\w$])\\s*\\.\\s*map\\s*\\(\\s*(?:\\(\\s*([A-Za-z_$][\\w$]*)\\s*(,\\s*([A-Za-z_$][\\w$]*)\\s*)?\\)|([A-Za-z_$][\\w$]*))\\s*=>`
3290
+ );
3291
+ const match = expression.value.match(pattern);
3292
+ if (!match || match.index === void 0) return null;
3293
+ const row = match[1] ?? match[4];
3294
+ if (match[4] !== void 0) return { row, index: null, addIndexAt: -1 };
3295
+ const within = match[0].lastIndexOf(row);
3296
+ return {
3297
+ row,
3298
+ index: match[3] ?? null,
3299
+ addIndexAt: expression.range.start + match.index + within + row.length
3300
+ };
3301
+ }
3302
+ function renders(content, node, row) {
3303
+ if (!node.inner) return null;
3304
+ const inner = content.slice(node.inner.start, node.inner.end).trim();
3305
+ const match = inner.match(/^\{\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*\}$/);
3306
+ return match && match[1] === row ? match[2] : null;
3307
+ }
3308
+ function leafElements(content, expression, row) {
3309
+ const out = /* @__PURE__ */ new Map();
3310
+ const visit = (node) => {
3311
+ if (node.type === "expr") {
3312
+ for (const child of node.children) visit(child);
3313
+ return;
3314
+ }
3315
+ if (node.type !== "element" || node.raw) return;
3316
+ const property = isIntrinsic(node.tag) ? renders(content, node, row) : null;
3317
+ if (property) out.set(property, [...out.get(property) ?? [], node]);
3318
+ for (const child of node.children) visit(child);
3319
+ };
3320
+ for (const child of expression.children) visit(child);
3321
+ return out;
3322
+ }
3323
+ function stringLiterals(ast) {
3324
+ const out = /* @__PURE__ */ new Map();
3325
+ walkAst(ast, (node) => {
3326
+ if (node.type !== "VariableDeclarator") return;
3327
+ const id = node.id;
3328
+ const init = node.init;
3329
+ if (id?.type !== "Identifier" || init?.type !== "StringLiteral") return;
3330
+ out.set(String(id.name), init);
3331
+ });
3332
+ return out;
3333
+ }
3334
+ function scalarUses(content, roots, id) {
3335
+ const mention = new RegExp(`(?<![\\w$])${id}(?![\\w$])`, "g");
3336
+ const alone = new RegExp(`^\\{\\s*${id}\\s*\\}$`);
3337
+ const count = (text) => (text.match(mention) ?? []).length;
3338
+ let total = 0;
3339
+ const hosts = [];
3340
+ const visit = (node) => {
3341
+ if (node.type === "expr") {
3342
+ total += count(node.value);
3343
+ for (const child of node.children) visit(child);
3344
+ return;
3345
+ }
3346
+ if (node.type !== "element" || node.raw) return;
3347
+ for (const attr of node.attrs) {
3348
+ if (attr.expressionRange) total += count(content.slice(attr.expressionRange.start, attr.expressionRange.end));
3349
+ }
3350
+ if (node.inner && isIntrinsic(node.tag) && alone.test(content.slice(node.inner.start, node.inner.end).trim())) {
3351
+ hosts.push(node);
3352
+ }
3353
+ for (const child of node.children) visit(child);
3354
+ };
3355
+ for (const root of roots) visit(root);
3356
+ return { total, hosts };
3357
+ }
3358
+ function bindScalar(ctx, member, literals, offset) {
3359
+ const rule = DIALECT_RULES[ctx.dialect];
3360
+ const named = [...literals].filter(([, init2]) => norm(String(init2.value)) === member.literal);
3361
+ if (named.length !== 1) return { splices: [], refusal: null };
3362
+ const [id, init] = named[0];
3363
+ const uses = scalarUses(ctx.content, ctx.parsed.roots, id);
3364
+ if (uses.hosts.length !== 1 || uses.total !== 1) return { splices: [], refusal: "IN_EXPRESSION" };
3365
+ const host = uses.hosts[0];
3366
+ const at = { start: offset + (init.start ?? 0), end: offset + (init.end ?? 0) };
3367
+ const args = [
3368
+ ctx.snapshotName(member.slug),
3369
+ JSON.stringify(member.path),
3370
+ // The literal EXACTLY as the author wrote it — quotes, escapes and all — so the fallback the
3371
+ // build renders is the byte-for-byte copy the repository renders today.
3372
+ ctx.content.slice(at.start, at.end),
3373
+ ...ctx.slug ? [ctx.slug] : []
3374
+ ];
3375
+ const attributes = [
3376
+ `data-bcms-field="${member.path}"`,
3377
+ ...member.kind ? [`data-bcms-kind="${member.kind}"`] : [],
3378
+ ...member.kind === "richtext" && rule.richtext ? [rule.richtext(id)] : []
3379
+ ];
3380
+ const splices = [
3381
+ { start: at.start, end: at.end, text: `${ctx.read}(${args.join(", ")})` },
3382
+ { start: host.attrInsertAt, end: host.attrInsertAt, text: ` ${attributes.join(" ")}` }
3383
+ ];
3384
+ if (member.kind === "richtext" && rule.richtext && host.inner) {
3385
+ splices.push({ start: host.inner.start, end: host.inner.end, text: "" });
3386
+ }
3387
+ return { splices, refusal: null };
3388
+ }
3389
+ function bindDataLiterals(ctx, members) {
3390
+ const out = { splices: [], usesRead: false, usesRows: false, slugs: [], bound: [], declarations: [], refused: [] };
3391
+ const rule = DIALECT_RULES[ctx.dialect];
3392
+ const module = moduleOf(ctx.parsed);
3393
+ if (!module || !rule.text) return out;
3394
+ const groups = /* @__PURE__ */ new Map();
3395
+ const scalars = [];
3396
+ for (const member of members) {
3397
+ const split = splitGroup(member.path);
3398
+ if (!split) {
3399
+ scalars.push(member);
3400
+ continue;
3401
+ }
3402
+ const held = { ...member, array: split.array, index: split.index, leaf: split.leaf };
3403
+ const key = `${member.slug}\0${split.array}`;
3404
+ groups.set(key, [...groups.get(key) ?? [], held]);
3405
+ }
3406
+ const strings = stringLiterals(module.ast);
3407
+ for (const member of scalars) {
3408
+ const scalar = bindScalar(ctx, member, strings, module.offset);
3409
+ if (scalar.refusal) out.refused.push({ key: member.key, reason: scalar.refusal });
3410
+ if (scalar.splices.length === 0) continue;
3411
+ out.splices.push(...scalar.splices);
3412
+ out.usesRead = true;
3413
+ out.slugs.push(member.slug);
3414
+ out.bound.push(member.key);
3415
+ }
3416
+ if (groups.size === 0) return out;
3417
+ const literals = arrayLiterals(module.ast);
3418
+ for (const all of groups.values()) {
3419
+ const array = all[0].array;
3420
+ const nested = all.filter((member) => /\[\d+\]/.test(member.leaf));
3421
+ for (const member of nested) out.refused.push({ key: member.key, reason: "PROP_DRILLED_DEEP" });
3422
+ const group = all.filter((member) => !nested.includes(member));
3423
+ if (group.length === 0) continue;
3424
+ const refuse = (reason) => {
3425
+ for (const member of group) out.refused.push({ key: member.key, reason });
3426
+ };
3427
+ let identifier2 = null;
3428
+ let keys = null;
3429
+ for (const [name, literal] of literals) {
3430
+ const elements2 = literal.elements ?? [];
3431
+ const mapped = /* @__PURE__ */ new Map();
3432
+ const ok = group.every((member) => {
3433
+ const property = propertyFor(elements2[member.index] ?? void 0, member.literal);
3434
+ if (!property) return false;
3435
+ const held = mapped.get(property);
3436
+ if (held !== void 0 && held !== member.leaf) return false;
3437
+ mapped.set(property, member.leaf);
3438
+ return true;
3439
+ });
3440
+ if (!ok) continue;
3441
+ if (identifier2 !== null) {
3442
+ identifier2 = null;
3443
+ break;
3444
+ }
3445
+ identifier2 = name;
3446
+ keys = mapped;
3447
+ }
3448
+ if (identifier2 === null || keys === null) continue;
3449
+ const loops = iterations(ctx.parsed.roots, identifier2);
3450
+ if (loops.length === 0) continue;
3451
+ if (loops.length > 1) {
3452
+ refuse("REPEATER_AMBIGUOUS");
3453
+ continue;
3454
+ }
3455
+ const loop = loops[0];
3456
+ const params = callbackParams(loop, identifier2);
3457
+ if (!params) {
3458
+ refuse("IN_EXPRESSION");
3459
+ continue;
3460
+ }
3461
+ const elements = leafElements(ctx.content, loop, params.row);
3462
+ const placed = /* @__PURE__ */ new Map();
3463
+ let refusal = null;
3464
+ for (const property of keys.keys()) {
3465
+ const found = elements.get(property) ?? [];
3466
+ if (found.length === 1) placed.set(property, found[0]);
3467
+ else refusal = found.length > 1 ? "REPEATER_AMBIGUOUS" : leafRefusal(loop.value, params.row, property);
3468
+ }
3469
+ if (refusal !== null) {
3470
+ refuse(refusal);
3471
+ continue;
3472
+ }
3473
+ const index = params.index ?? allocateIndex(loop.value);
3474
+ const splices = [];
3475
+ if (rule.loopKey) {
3476
+ const rows = loop.children.filter((child) => child.type === "element");
3477
+ const row = rows.length === 1 ? rows[0] : null;
3478
+ if (row && row.tag !== "" && !row.attrs.some((a) => a.name.replace(/^:/, "") === "key")) {
3479
+ splices.push({ start: row.attrInsertAt, end: row.attrInsertAt, text: ` ${rule.loopKey(index)}` });
3480
+ }
3481
+ }
3482
+ if (params.index === null) {
3483
+ if (params.addIndexAt < 0) {
3484
+ refuse("IN_EXPRESSION");
3485
+ continue;
3486
+ }
3487
+ splices.push({ start: params.addIndexAt, end: params.addIndexAt, text: `, ${index}` });
3488
+ }
3489
+ for (const [property, element] of placed) {
3490
+ const leaf = keys.get(property);
3491
+ const literal = `${array}[\${${index}}].${leaf}`;
3492
+ const kind = group.find((member) => member.leaf === leaf).kind;
3493
+ const attributes = [
3494
+ rule.attr("data-bcms-field", `\`${literal}\``),
3495
+ ...kind ? [`data-bcms-kind="${kind}"`] : [],
3496
+ // A richtext row value is html, so it goes into the dialect's html sink rather than being
3497
+ // printed as characters — the same rule `writeLeaf` follows one level down. @see loops.ts
3498
+ ...kind === "richtext" && rule.richtext ? [rule.richtext(`${params.row}.${property}`)] : []
3499
+ ];
3500
+ splices.push({ start: element.attrInsertAt, end: element.attrInsertAt, text: ` ${attributes.join(" ")}` });
3501
+ if (kind === "richtext" && rule.richtext && element.inner) {
3502
+ splices.push({ start: element.inner.start, end: element.inner.end, text: "" });
3503
+ }
3504
+ out.declarations.push({ path: shapeOfPath(literal), literal });
3505
+ }
3506
+ const at = { start: module.offset + (literals.get(identifier2).start ?? 0), end: module.offset + (literals.get(identifier2).end ?? 0) };
3507
+ const map = [...keys].map(([property, leaf]) => `${property}: ${JSON.stringify(leaf)}`).join(", ");
3508
+ const args = [
3509
+ ctx.snapshotName(group[0].slug),
3510
+ JSON.stringify(array),
3511
+ ctx.content.slice(at.start, at.end),
3512
+ `{ ${map} }`,
3513
+ ...ctx.slug ? [ctx.slug] : []
3514
+ ];
3515
+ splices.push({ start: at.start, end: at.end, text: `${ctx.rowsAs}(${args.join(", ")})` });
3516
+ out.splices.push(...splices);
3517
+ out.usesRows = true;
3518
+ out.slugs.push(...group.map((member) => member.slug));
3519
+ out.bound.push(...group.map((member) => member.key));
3520
+ }
3521
+ return out;
3522
+ }
3523
+ function leafRefusal(loop, row, property) {
3524
+ const prop = new RegExp(`=\\s*\\{\\s*${row}\\s*\\.\\s*${property}\\s*\\}|\\{\\s*\\.\\.\\.\\s*${row}\\s*\\}`);
3525
+ return prop.test(loop) ? "PROP_TARGET_NOT_FOUND" : "IN_EXPRESSION";
3526
+ }
3527
+ function allocateIndex(loop) {
3528
+ const used = new Set(loop.match(/[A-Za-z_$][\w$]*/g) ?? []);
3529
+ if (!used.has("i")) return "i";
3530
+ for (let n = 1; ; n++) if (!used.has(`i_${n}`)) return `i_${n}`;
3531
+ }
3532
+
3533
+ // src/resolve.ts
3534
+ var EXTENSIONS = [".astro", ".tsx", ".jsx", ".ts", ".js", ".svelte", ".vue", ".mjs", ".cjs"];
3535
+ function join(fromFile, specifier) {
3536
+ const parts = fromFile.split("/").slice(0, -1);
3537
+ for (const segment of specifier.split("/")) {
3538
+ if (segment === "." || segment === "") continue;
3539
+ if (segment === "..") parts.pop();
3540
+ else parts.push(segment);
3541
+ }
3542
+ return parts.join("/");
3543
+ }
3544
+ var firstPresent = (base, files) => [base, ...EXTENSIONS.map((ext) => `${base}${ext}`), ...EXTENSIONS.map((ext) => `${base}/index${ext}`)].find(
3545
+ (candidate) => files.has(candidate)
3546
+ ) ?? null;
3547
+ function aliasesFrom(sources) {
3548
+ const out = /* @__PURE__ */ new Map();
3549
+ for (const source of sources) {
3550
+ if (!isProjectConfig(source.path)) continue;
3551
+ const parsed = readJsonc(source.content);
3552
+ if (parsed === null) continue;
3553
+ const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3554
+ const root = source.path.split("/").slice(0, -1).join("/");
3555
+ for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3556
+ out.set(
3557
+ pattern,
3558
+ targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3559
+ );
3560
+ }
3561
+ }
3562
+ return out;
3563
+ }
3564
+ var isProjectConfig = (path) => path.endsWith("tsconfig.json") || path.endsWith("jsconfig.json");
3565
+ function readJsonc(content) {
3566
+ let out = "";
3567
+ let at = 0;
3568
+ while (at < content.length) {
3569
+ const char = content[at];
3570
+ if (char === '"') {
3571
+ const start = at++;
3572
+ while (at < content.length && content[at] !== '"') at += content[at] === "\\" ? 2 : 1;
3573
+ out += content.slice(start, Math.min(at + 1, content.length));
3574
+ at++;
3575
+ continue;
3576
+ }
3577
+ if (char === "/" && content[at + 1] === "/") {
3578
+ while (at < content.length && content[at] !== "\n") at++;
3579
+ continue;
3580
+ }
3581
+ if (char === "/" && content[at + 1] === "*") {
3582
+ const close = content.indexOf("*/", at + 2);
3583
+ at = close < 0 ? content.length : close + 2;
3584
+ continue;
3585
+ }
3586
+ out += char;
3587
+ at++;
3588
+ }
3589
+ try {
3590
+ const parsed = JSON.parse(out.replace(/,(?=\s*[}\]])/g, ""));
3591
+ return isAliasShaped(parsed) ? parsed : null;
3592
+ } catch {
3593
+ return null;
3594
+ }
3595
+ }
3596
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3597
+ function isAliasShaped(value) {
3598
+ if (!isRecord(value)) return false;
3599
+ const options = value.compilerOptions;
3600
+ if (options === void 0) return true;
3601
+ if (!isRecord(options)) return false;
3602
+ if (options.baseUrl !== void 0 && typeof options.baseUrl !== "string") return false;
3603
+ if (options.paths === void 0) return true;
3604
+ if (!isRecord(options.paths)) return false;
3605
+ return Object.values(options.paths).every(
3606
+ (targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string")
3607
+ );
3608
+ }
3609
+ function unreadableConfigs(sources) {
3610
+ return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3611
+ }
3612
+ function expand(specifier, aliases) {
3613
+ const out = [];
3614
+ for (const [pattern, targets] of aliases) {
3615
+ const star = pattern.indexOf("*");
3616
+ if (star < 0) {
3617
+ if (specifier === pattern) out.push(...targets);
3618
+ continue;
3619
+ }
3620
+ const head = pattern.slice(0, star);
3621
+ const tail = pattern.slice(star + 1);
3622
+ if (!specifier.startsWith(head) || !specifier.endsWith(tail)) continue;
3623
+ const middle = specifier.slice(head.length, specifier.length - tail.length);
3624
+ out.push(...targets.map((target) => target.replace("*", middle)));
3625
+ }
3626
+ return out;
3627
+ }
3628
+ function resolveSpecifier(fromFile, specifier, files, aliases) {
3629
+ if (specifier.startsWith(".")) return firstPresent(join(fromFile, specifier), files);
3630
+ for (const candidate of expand(specifier, aliases)) {
3631
+ const found = firstPresent(candidate.replace(/\/$/, ""), files);
3632
+ if (found) return found;
3633
+ }
3634
+ return null;
3635
+ }
3636
+ function importsIn(parsed, _dialect) {
3637
+ const out = /* @__PURE__ */ new Map();
3638
+ walkAst(parsed.ast, (node) => {
3639
+ if (node.type !== "ImportDeclaration") return;
3640
+ if (node.importKind === "type") return;
3641
+ const specifier = node.source?.value;
3642
+ if (typeof specifier !== "string") return;
3643
+ for (const entry of node.specifiers ?? []) {
3644
+ if (entry.importKind === "type") continue;
3645
+ const local = entry.local?.name;
3646
+ if (typeof local !== "string") continue;
3647
+ const kind = entry.type === "ImportDefaultSpecifier" ? "default" : entry.type === "ImportNamespaceSpecifier" ? "namespace" : "named";
3648
+ const imported = kind === "named" ? entry.imported?.name ?? local : null;
3649
+ out.set(local, { specifier, imported, kind });
3650
+ }
3651
+ });
3652
+ return out;
3653
+ }
3654
+
3655
+ // src/images.ts
3656
+ var IMAGE_EXTENSIONS = [".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif", ".svg"];
3657
+ var ASSET_COMPONENTS = /* @__PURE__ */ new Set(["Image", "Picture"]);
3658
+ var ASSET_MODULE = "astro:assets";
3659
+ function baseName(url) {
3660
+ const file = url.split(/[?#]/)[0].split("/").pop() ?? "";
3661
+ const base = file.split(".")[0] ?? "";
3662
+ return base === "" ? null : base.toLowerCase();
3663
+ }
3664
+ function assetCandidates(parsed, dialect, original) {
3665
+ const wanted = baseName(original);
3666
+ if (wanted === null || parsed.error) return [];
3667
+ const found = [];
3668
+ for (const [local, from] of importsIn(parsed, dialect)) {
3669
+ const specifier = from.specifier.toLowerCase();
3670
+ if (from.kind !== "default" || !IMAGE_EXTENSIONS.some((ext) => specifier.endsWith(ext))) continue;
3671
+ if (baseName(from.specifier) === wanted) found.push(local);
3672
+ }
3673
+ return found;
3674
+ }
3675
+ function assetImport(parsed, dialect, original) {
3676
+ const found = assetCandidates(parsed, dialect, original);
3677
+ return found.length === 1 ? found[0] : null;
3678
+ }
3679
+ function renderedBy(content, parsed, dialect, binding) {
3680
+ const assets = new Set(
3681
+ [...importsIn(parsed, dialect)].filter(([, from]) => from.specifier === ASSET_MODULE).map(([local]) => local)
3682
+ );
3683
+ const out = [];
3684
+ const visit = (node) => {
3685
+ if (node.type === "expr") {
3686
+ for (const child of node.children) visit(child);
3687
+ return;
3688
+ }
3689
+ if (node.type !== "element" || node.raw) return;
3690
+ const sources = IMAGE_SOURCES[node.tag.toLowerCase()] ?? (assets.has(node.tag) && ASSET_COMPONENTS.has(node.tag) ? ["src"] : []);
3691
+ for (const attr of node.attrs) {
3692
+ if (!sources.includes(attr.name) || !attr.expressionRange) continue;
3693
+ const inner = content.slice(attr.expressionRange.start, attr.expressionRange.end).replace(/^\{|\}$/g, "").trim();
3694
+ if (inner === binding) out.push({ node, attr: attr.name });
3695
+ }
3696
+ for (const child of node.children) visit(child);
3697
+ };
3698
+ for (const root of parsed.roots) visit(root);
3699
+ return out;
3700
+ }
3701
+ function bindImages(ctx, targets) {
3702
+ const out = { splices: [], slugs: [], bound: [], refused: [] };
3703
+ const rule = DIALECT_RULES[ctx.dialect];
3704
+ if (!rule.attr || !rule.text || ctx.parsed.error) return out;
3705
+ const claimed = /* @__PURE__ */ new Set();
3706
+ for (const target of targets) {
3707
+ const binding = assetImport(ctx.parsed, ctx.dialect, target.original);
3708
+ const rendered = binding === null ? [] : renderedBy(ctx.content, ctx.parsed, ctx.dialect, binding);
3709
+ const free = rendered.filter((hit2) => !claimed.has(hit2.node.attrInsertAt));
3710
+ if (free.length !== 1) {
3711
+ out.refused.push({ key: target.key, reason: "IMAGE_ASSET_UNRESOLVED" });
3712
+ continue;
3713
+ }
3714
+ const hit = free[0];
3715
+ claimed.add(hit.node.attrInsertAt);
3716
+ const attr = hit.node.attrs.find((a) => a.name === hit.attr);
3717
+ const args = [
3718
+ ctx.snapshotName(target.slug),
3719
+ JSON.stringify(target.path),
3720
+ binding,
3721
+ ...ctx.slug ? [ctx.slug] : []
3722
+ ];
3723
+ out.splices.push({
3724
+ start: attr.range.start,
3725
+ end: attr.range.end,
3726
+ text: rule.attr(hit.attr, `${ctx.image}(${args.join(", ")})`)
3727
+ });
3728
+ out.splices.push({
3729
+ start: hit.node.attrInsertAt,
3730
+ end: hit.node.attrInsertAt,
3731
+ text: ` data-bcms-field="${target.path}" data-bcms-kind="${target.kind || "image"}"`
3732
+ });
3733
+ out.slugs.push(target.slug);
3734
+ out.bound.push(target.key);
3735
+ }
3736
+ return out;
3737
+ }
3738
+
3739
+ // src/helper.ts
3740
+ import { createHash as createHash2 } from "crypto";
3741
+ var HELPER_VERSION = 5;
3742
+ var HEADER = "// @bettercms-ai/convert helper v";
3743
+ var HELPER_PATH = Object.fromEntries(
3744
+ Object.keys(DIALECT_RULES).map((d) => [d, DIALECT_RULES[d].helper])
3745
+ );
3746
+ var CONTENT_DIR = "bcms-content";
3747
+ var STUB_CONTENT = "{}";
3748
+ function relativeImport(fromFile, toFile) {
3749
+ const from = fromFile.split("/").slice(0, -1);
3750
+ const to = toFile.split("/");
3751
+ let shared = 0;
3752
+ while (shared < from.length && shared < to.length - 1 && from[shared] === to[shared]) shared++;
3753
+ const up = from.length - shared;
3754
+ const path = [...Array(up).fill(".."), ...to.slice(shared)].join("/");
3755
+ return up === 0 ? `./${path}` : path;
3756
+ }
3757
+ function helperSource(dialect) {
3758
+ const path = HELPER_PATH[dialect];
3759
+ if (!path) throw new Error(`The ${dialect} dialect reads its values at publish time, not at build time.`);
3760
+ const layout = relativeImport(path, `${CONTENT_DIR}/layout.json`);
3761
+ return `${HEADER}${HELPER_VERSION}
3762
+ // Values come from bcms-content/*.json, written into the tree before the build. The committed
3763
+ // stubs are {} \u2014 a build with them renders every fallback below and still succeeds.
3764
+ import layoutSnapshot from "${layout}";
3765
+
3766
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
3767
+ typeof v === "object" && v !== null && !Array.isArray(v);
3768
+
3769
+ /** Dotted keys and \`[n]\` indices, positionally \u2014 the editor's own path grammar. */
3770
+ function readPath(root: unknown, path: string): unknown {
3771
+ let cursor: unknown = root;
3772
+ for (const segment of path.split(".")) {
3773
+ if (!segment) return undefined;
3774
+ const key = segment.replace(/\\[\\d+\\]/g, "");
3775
+ if (key) {
3776
+ if (!isRecord(cursor)) return undefined;
3777
+ cursor = cursor[key];
3778
+ }
3779
+ for (const index of segment.matchAll(/\\[(\\d+)\\]/g)) {
3780
+ if (!Array.isArray(cursor)) return undefined;
3781
+ cursor = cursor[Number(index[1])];
3782
+ }
3783
+ }
3784
+ return cursor;
3785
+ }
3786
+
3787
+ /** A stored value as a template renders it: richtext is its html, an image is its url. */
3788
+ function render(value: unknown): string | undefined {
3789
+ if (typeof value === "string") return value;
3790
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
3791
+ if (isRecord(value)) {
3792
+ if (typeof value.html === "string") return value.html;
3793
+ if (typeof value.url === "string") return value.url;
3794
+ }
3795
+ return undefined;
3796
+ }
3797
+
3798
+ /** One field of one page. \`slug\` selects the entry on a dynamic route. */
3799
+ export function bcms(page: unknown, path: string, fallback: string, slug?: string): string {
3800
+ const entries = isRecord(page) ? page.entries : undefined;
3801
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
3802
+ return render(readPath(root, path)) ?? fallback;
3803
+ }
3804
+
3805
+ /** One field of the shared layout \u2014 the header and footer every route renders. */
3806
+ export function bcmsLayout(path: string, fallback: string): string {
3807
+ return render(readPath(layoutSnapshot, path)) ?? fallback;
3808
+ }
3809
+
3810
+ /**
3811
+ * The ROWS of a repeater. \`fallback\` is every row the template shipped with, so a build against
3812
+ * an empty snapshot still renders the same number of cards it always did.
3813
+ */
3814
+ export function bcmsRows<T>(page: unknown, path: string, fallback: T[], slug?: string): T[] {
3815
+ const entries = isRecord(page) ? page.entries : undefined;
3816
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
3817
+ const value = readPath(root, path);
3818
+ return Array.isArray(value) ? (value as T[]) : fallback;
3819
+ }
3820
+
3821
+ /**
3822
+ * The rows of a repeater whose template renders its OWN property names.
3823
+ *
3824
+ * \u{1F534} A ROW IS MORE THAN ITS COPY. The template's array carries \`tone\`, \`link\`, an icon \u2014 styling
3825
+ * the brief never heard of, because the CMS holds the four sentences and not the class names. So a
3826
+ * CMS row is merged OVER the row the template shipped with rather than replacing it, and \`keys\`
3827
+ * says which template property each CMS leaf lands on. A row an editor ADDS has no counterpart, so
3828
+ * it inherits row 0's styling \u2014 the only answer that renders at all.
3829
+ */
3830
+ export function bcmsRowsAs<T>(
3831
+ page: unknown,
3832
+ path: string,
3833
+ fallback: T[],
3834
+ keys: Record<string, string>,
3835
+ slug?: string,
3836
+ ): T[] {
3837
+ const rows = bcmsRows<unknown>(page, path, [], slug);
3838
+ if (rows.length === 0) return fallback;
3839
+ return rows.map((row, index) => {
3840
+ const base = { ...(fallback[index] ?? fallback[0] ?? {}) } as Record<string, unknown>;
3841
+ for (const property of Object.keys(keys)) {
3842
+ const value = render(readPath(row, keys[property] as string));
3843
+ if (value !== undefined) base[property] = value;
3844
+ }
3845
+ return base as T;
3846
+ });
3847
+ }
3848
+
3849
+ /** An image field's url, or the asset the template already imports. @see IMAGE_ASSET_UNRESOLVED */
3850
+ export function bcmsImage<T>(page: unknown, path: string, fallback: T, slug?: string): T | string {
3851
+ const entries = isRecord(page) ? page.entries : undefined;
3852
+ const root = slug !== undefined && isRecord(entries) ? entries[slug] : page;
3853
+ return render(readPath(root, path)) ?? fallback;
3854
+ }
3855
+
3856
+ /**
3857
+ * ONE SECTION's copy, for a component extracted by \`--componentize\`.
3858
+ *
3859
+ * OVERRIDES FIRST, PER KEY: \`overrides[key] ?? group[key]\`. An instance that owns its copy
3860
+ * (Phase B) carries an override for every prop it declares, so the group underneath it is never
3861
+ * read; a placement still bound to a page field group (Phase A) carries none, so the group is all
3862
+ * there is. The interesting case is the one in between \u2014 a section half moved \u2014 where a key the
3863
+ * instance does not hold has to keep rendering the page's value rather than nothing.
3864
+ *
3865
+ * \u{1F534} \`??\`, NOT \`||\`. An empty string is a value an editor typed, and treating it as absent put
3866
+ * the page's old copy back into a heading somebody had deliberately cleared.
3867
+ *
3868
+ * With neither an override nor a group, the answer is \`{}\` and every \`bcms(...)\` below renders
3869
+ * the fallback the template shipped with \u2014 which is why a section renders correctly against the
3870
+ * committed \`{}\` stub.
3871
+ */
3872
+ export function bcmsSection(page: unknown, bind?: string, overrides?: unknown): Record<string, unknown> {
3873
+ const group = bind ? readPath(page, bind) : undefined;
3874
+ const base: Record<string, unknown> = isRecord(group) ? { ...group } : {};
3875
+ if (!isRecord(overrides)) return base;
3876
+ for (const key of Object.keys(overrides)) {
3877
+ const value = overrides[key];
3878
+ if (value === undefined || value === null) continue;
3879
+ /**
3880
+ * \u{1F534} A TABLE PROP IS STORED AS \`{ rows: [...] }\` AND READ AS A LIST. That is the shape the
3881
+ * dashboard edits a repeater in; handing it to \`bcmsRows\` unchanged is not an array, so the
3882
+ * helper fell back to the rows the template shipped with and the editor's work never showed.
3883
+ */
3884
+ base[key] = isRecord(value) && Array.isArray(value.rows) ? value.rows : value;
3885
+ }
3886
+ return base;
3887
+ }
3888
+
3889
+ /**
3890
+ * WHICH ADDRESS one bound element declares \u2014 decided at RENDER time, from the placement.
3891
+ *
3892
+ * An element carries exactly ONE \`data-bcms-field\`, and which lane it names depends on where its
3893
+ * copy lives. A placement that owns its copy declares the BLOCK lane \u2014
3894
+ * \`<blockId>__overrides.<propKey>\` \u2014 because that is the address the editor commits an instance's
3895
+ * own value to. A placement bound to a page field group declares the PAGE path, which is what the
3896
+ * page form and the coverage meter already know.
3897
+ *
3898
+ * \u{1F534} THE BOUND ADDRESS IS RE-BASED ONTO **THIS** PLACEMENT'S GROUP. The markup was cut from ONE
3899
+ * page and carries that page's paths (\`hero.title\`); the whole point of \`shapeHash\` is that a
3900
+ * second page renders the same component bound to a group of its own (\`bind="landingHero"\`).
3901
+ * Handing the embedded path through unchanged declared \`hero.title\` on a page whose field is
3902
+ * \`landingHero.title\` \u2014 an address that page does not have, so the editor wrote into nothing and
3903
+ * the meter counted a binding that was never there. The prefix the markup was cut with comes out,
3904
+ * the placement's own group goes in.
3905
+ *
3906
+ * \u{1F534} THE SAME EXTRACTED FILE SERVES BOTH LANES. The choice is a function of the props, never of
3907
+ * how the codemod ran: one component renders a Phase A placement on one page and a Phase B
3908
+ * instance on the next, and a repository does not have to be re-cut when its copy moves.
3909
+ */
3910
+ export function bcmsFields(
3911
+ placement: { blockId?: string; bind?: string; overrides?: unknown },
3912
+ pathPrefix: string,
3913
+ ): (path: string) => string {
3914
+ const id = placement.blockId;
3915
+ const own = typeof id === "string" && id !== "" && !placement.bind && isRecord(placement.overrides);
3916
+ /**
3917
+ * What replaces the prefix the markup was cut with, or null for "leave every path alone".
3918
+ *
3919
+ * Null covers a placement with neither a bind nor an instance \u2014 a shipped fallback, or a page
3920
+ * nobody has componentized yet \u2014 and a bound placement in a component whose prefix is EMPTY,
3921
+ * where the plan said the prop key IS the whole page path: there is no group segment to swap,
3922
+ * so prepending one would invent an address rather than correct it.
3923
+ */
3924
+ const lane = own ? \`\${id}__overrides.\` : placement.bind && pathPrefix ? \`\${placement.bind}.\` : null;
3925
+ return (path: string): string => {
3926
+ // A path outside the group this section was cut from is not one this placement re-addresses:
3927
+ // it names a key the instance does not hold and a group this placement is not bound to.
3928
+ // (An EMPTY prefix is a plan saying the prop key IS the page path, and matches everything.)
3929
+ if (lane === null || !path.startsWith(pathPrefix)) return path;
3930
+ return lane + path.slice(pathPrefix.length);
3931
+ };
3932
+ }
3933
+ `;
3934
+ }
3935
+ var HISTORICAL = {
3936
+ "src/bcms-content.ts": {
3937
+ 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3938
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3939
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3940
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
3941
+ },
3942
+ "src/lib/bcms-content.ts": {
3943
+ 1: "ae72ae14d706943d481c880d70da5789b4816ba1ba319a10fbc63ded4d0b42f2",
3944
+ 2: "a18731eb42f7db55dac0591c45f873758cde8e103fefcd6e5caf67936ae6a002",
3945
+ 3: "0be9c73160361882a038552bc02673c14ed532287823dabaafe36179bc55ed7e",
3946
+ 4: "0146d027a727ecd3c9e3fc336fcf1299b59ddf75b04091c05703f3f983ec28af"
3947
+ },
3948
+ "composables/bcms-content.ts": {
3949
+ 1: "c272412532efed7db64b6cf313b2bed4291f9951430412289c8a588b7f565407",
3950
+ 2: "51e2ae65ddd86bf43d14e29b3865e3f16e45f72069692d1fea57e1f986a894f0",
3951
+ 3: "d4305b2983adcb456ef782d35ea9d083a04465c2b68baa0d44a084dfa01a9227",
3952
+ 4: "a5742f64dacb1b26ee6dd5e1469a54871487934c71bc8267df19e3a80e417595"
3953
+ }
3954
+ };
3955
+ var sha256 = (value) => createHash2("sha256").update(value, "utf8").digest("hex");
3956
+ function isKnownHelper(content, dialect) {
3957
+ if (content === helperSource(dialect)) return HELPER_VERSION;
3958
+ const path = HELPER_PATH[dialect];
3959
+ if (!path) return null;
3960
+ const digest = sha256(content);
3961
+ for (const [version, known] of Object.entries(HISTORICAL[path] ?? {})) {
3962
+ if (known === digest) return Number(version);
3963
+ }
3964
+ return null;
3965
+ }
3966
+
3967
+ // src/locate.ts
3968
+ var flat = (value) => value.replace(/\s+/g, " ").trim();
3969
+ var stripTags = (value) => value.replace(/<br\s*\/?>/gi, " ").replace(/<[^>]*>/g, "");
3970
+ function locate(brief, sources, options) {
3971
+ const flattened = sources.map((s) => {
3972
+ const readable = unwrapJsxStrings(decodeEntities(s.content));
3973
+ return {
3974
+ path: s.path,
3975
+ content: flat(readable),
3976
+ text: options?.stripTags ? flat(stripTags(readable)) : null
3977
+ };
3978
+ });
3979
+ const located = [];
3980
+ const unlocated = [];
3981
+ for (const page of brief.pages) {
3982
+ for (const p of page.paths) {
3983
+ const original = p.original === null ? "" : flat(p.original);
3984
+ if (!original) {
3985
+ unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "no-original" });
3986
+ continue;
3987
+ }
3988
+ const files = flattened.filter((f) => f.content.includes(original) || f.text !== null && f.text.includes(original)).map((f) => f.path);
3989
+ if (files.length === 0) {
3990
+ unlocated.push({ route: page.route, scope: p.scope, path: p.path, reason: "not-in-source" });
3991
+ continue;
3992
+ }
3993
+ located.push({ route: page.route, scope: p.scope, path: p.path, kind: p.kind, original, files });
3994
+ }
3995
+ }
3996
+ return { located, unlocated };
3997
+ }
3998
+
3999
+ // src/loops.ts
4000
+ import MagicString from "magic-string";
4001
+
4002
+ // src/scope.ts
4003
+ function patternNames(node, out) {
4004
+ if (!node || typeof node !== "object") return;
4005
+ const typed = node;
4006
+ switch (typed.type) {
4007
+ case "Identifier":
4008
+ out.add(String(typed.name));
4009
+ return;
4010
+ case "ObjectPattern":
4011
+ for (const property of typed.properties ?? [])
4012
+ patternNames(property.type === "RestElement" ? property.argument : property.value, out);
4013
+ return;
4014
+ case "ArrayPattern":
4015
+ for (const element of typed.elements ?? []) patternNames(element, out);
4016
+ return;
4017
+ case "AssignmentPattern":
4018
+ patternNames(typed.left, out);
4019
+ return;
4020
+ case "RestElement":
4021
+ patternNames(typed.argument, out);
4022
+ return;
4023
+ default:
4024
+ return;
4025
+ }
4026
+ }
4027
+ function statementNames(node, out) {
4028
+ if (!node || typeof node !== "object") return;
4029
+ const typed = node;
4030
+ switch (typed.type) {
4031
+ case "ExportNamedDeclaration":
4032
+ case "ExportDefaultDeclaration":
4033
+ statementNames(typed.declaration, out);
4034
+ return;
4035
+ case "ImportDeclaration":
4036
+ for (const specifier of typed.specifiers ?? []) patternNames(specifier.local, out);
4037
+ return;
4038
+ case "VariableDeclaration":
4039
+ for (const declarator of typed.declarations ?? []) patternNames(declarator.id, out);
4040
+ return;
4041
+ case "FunctionDeclaration":
4042
+ case "ClassDeclaration":
4043
+ case "TSTypeAliasDeclaration":
4044
+ case "TSInterfaceDeclaration":
4045
+ case "TSEnumDeclaration":
4046
+ case "TSModuleDeclaration":
4047
+ patternNames(typed.id, out);
4048
+ return;
4049
+ default:
4050
+ return;
4051
+ }
4052
+ }
4053
+ var DECLARED = /\b(?:const|let|var|function|class|interface|type|enum)\s+([A-Za-z_$][\w$]*)/g;
4054
+ var IMPORTED = /\bimport\s+([^;\n]*?)\s+from\b/g;
4055
+ function scanNames(code, out) {
4056
+ for (const match of code.matchAll(DECLARED)) out.add(match[1]);
4057
+ for (const match of code.matchAll(IMPORTED))
4058
+ for (const name of match[1].match(/[A-Za-z_$][\w$]*/g) ?? []) {
4059
+ if (name !== "as" && name !== "type") out.add(name);
4060
+ }
4061
+ }
4062
+ function scriptText(nodes, content, out) {
4063
+ for (const node of nodes) {
4064
+ if (node.type !== "element") continue;
4065
+ if (node.raw && node.tag === "script" && node.inner) out.push(content.slice(node.inner.start, node.inner.end));
4066
+ scriptText(node.children, content, out);
4067
+ }
4068
+ }
4069
+ function topLevelBindings(dialect, parsed, content) {
4070
+ const out = /* @__PURE__ */ new Set();
4071
+ const program = parsed.ast?.program;
4072
+ if (program) for (const statement of program.body ?? []) statementNames(statement, out);
4073
+ else if (parsed.script) scanNames(parsed.script.code, out);
4074
+ if (dialect === "html") {
4075
+ const scripts = [];
4076
+ scriptText(parsed.roots, content, scripts);
4077
+ for (const code of scripts) scanNames(code, out);
4078
+ }
4079
+ return out;
4080
+ }
4081
+ function allocateNames(taken, wanted) {
4082
+ const used = new Set(taken);
4083
+ const out = /* @__PURE__ */ new Map();
4084
+ for (const { key, name } of wanted) {
4085
+ if (out.has(key)) continue;
4086
+ let candidate = name;
4087
+ for (let n = 1; used.has(candidate); n++) candidate = `${name}_${n}`;
4088
+ used.add(candidate);
4089
+ out.set(key, candidate);
4090
+ }
4091
+ return out;
4092
+ }
4093
+
4094
+ // src/loops.ts
4095
+ function splitNested(leaf) {
4096
+ const match = leaf.match(/^([^[\]]+)\[(\d+)\]\.(.+)$/);
4097
+ return match ? { array: match[1], index: Number(match[2]), leaf: match[3] } : null;
4098
+ }
4099
+ var elementEnd = (node) => node.inner ? node.inner.end + node.tag.length + 3 : null;
4100
+ function findRows(roots, members) {
4101
+ const wanted = new Set(members.map((m) => m.index));
4102
+ const search = (node) => {
4103
+ if (node.type !== "element") return null;
4104
+ for (const child of node.children) {
4105
+ const deeper = search(child);
4106
+ if (deeper) return deeper;
4107
+ }
4108
+ const rows = [];
4109
+ for (const child of node.children) {
4110
+ if (child.type !== "element") continue;
4111
+ const end = elementEnd(child);
4112
+ if (end === null) continue;
4113
+ const inside = members.filter((m) => m.site.range.start >= child.start && m.site.range.end <= end);
4114
+ if (inside.length === 0) continue;
4115
+ const indices = new Set(inside.map((m) => m.index));
4116
+ if (indices.size !== 1) return null;
4117
+ rows.push({
4118
+ index: inside[0].index,
4119
+ range: { start: child.start, end },
4120
+ attrInsertAt: child.attrInsertAt,
4121
+ // `:key` in vue, `key` in jsx — one existing key, whichever way it is spelled.
4122
+ keyAttr: child.attrs.find((a) => a.name.replace(/^:/, "") === "key")?.range ?? null,
4123
+ node: child,
4124
+ members: inside
4125
+ });
4126
+ }
4127
+ if (rows.length === 0) return null;
4128
+ const covered = new Set(rows.flatMap((r) => r.members.map((m) => m.index)));
4129
+ if (covered.size !== wanted.size || rows.length !== wanted.size) return null;
4130
+ if (rows.some((r) => r.members.length !== members.length / wanted.size)) return null;
4131
+ return rows.sort((a, b) => a.range.start - b.range.start);
4132
+ };
4133
+ for (const root of roots) {
4134
+ const found = search(root);
4135
+ if (found) return found;
4136
+ }
4137
+ return null;
4138
+ }
4139
+ var consecutive = (rows, content) => rows.every((row, at) => at === 0 || content.slice(rows[at - 1].range.end, row.range.start).trim() === "");
4140
+ function maskedRow(row, content) {
4141
+ const masked = new Map(row.members.map((member) => [member.site.inner.start, member.leaf]));
4142
+ return canonical(row.node, content, masked);
4143
+ }
4144
+ var TRIVIA = "";
4145
+ function canonical(node, content, masked) {
4146
+ if (node.type === "text") {
4147
+ return node.value.trim() === "" ? TRIVIA : content.slice(node.range.start, node.range.end);
4148
+ }
4149
+ if (node.type === "comment" || node.type === "expr") {
4150
+ return content.slice(node.range.start, node.range.end);
4151
+ }
4152
+ if (node.type !== "element") return "";
4153
+ const open = content.slice(node.start, node.inner ? node.inner.start : node.attrInsertAt);
4154
+ if (node.inner && masked.has(node.inner.start)) {
4155
+ return `${open}\0${masked.get(node.inner.start)}\0</${node.tag}>`;
4156
+ }
4157
+ const inside = node.children.map((child) => canonical(child, content, masked)).join("");
4158
+ return node.inner ? `${open}${inside}</${node.tag}>` : open;
4159
+ }
4160
+ function sameShape(rows, content) {
4161
+ const first = maskedRow(rows[0], content);
4162
+ return rows.every((row) => maskedRow(row, content) === first);
4163
+ }
4164
+ var indented = (body, indent) => body.split("\n").map((line) => line.startsWith(indent) ? `${indent} ${line.slice(indent.length)}` : `${indent} ${line}`).join("\n");
4165
+ function indentOf(content, at) {
4166
+ const lineStart = content.lastIndexOf("\n", at - 1) + 1;
4167
+ const prefix = content.slice(lineStart, at);
4168
+ return prefix.trim() === "" ? prefix : "";
4169
+ }
4170
+ function writeLeaf(source, shift, member, rule, read, row, literal) {
4171
+ const site = member.site;
4172
+ const expression = `${read}(${row}, ${JSON.stringify(member.leaf)}, "")`;
4173
+ if (member.kind === "richtext" && rule.richtext) {
4174
+ source.remove(site.inner.start + shift, site.inner.end + shift);
4175
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.richtext(expression)}`);
4176
+ } else {
4177
+ source.overwrite(site.inner.start + shift, site.inner.end + shift, rule.text(expression));
4178
+ }
4179
+ const kind = member.kind ? ` data-bcms-kind="${member.kind}"` : "";
4180
+ source.appendLeft(site.attrInsertAt + shift, ` ${rule.attr("data-bcms-field", `\`${literal}\``)}${kind}`);
4181
+ }
4182
+ function emitNested(rule, array, members, prefix, outer, parent, ctx) {
4183
+ if (!rule.loop || !rule.text) return null;
4184
+ const inner = [];
4185
+ for (const member of members) {
4186
+ const split = splitNested(member.leaf);
4187
+ if (!split || splitNested(split.leaf) !== null) return null;
4188
+ inner.push({ ...member, index: split.index, leaf: split.leaf });
4189
+ }
4190
+ const rows = findRows([parent.node], inner);
4191
+ if (!rows || rows.length === 0) return null;
4192
+ if (!consecutive(rows, ctx.content)) return null;
4193
+ if (!sameShape(rows, ctx.content)) return null;
4194
+ const template = rows[0];
4195
+ const used = /* @__PURE__ */ new Set([
4196
+ ...ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? [],
4197
+ outer.row,
4198
+ outer.index
4199
+ ]);
4200
+ const allocated = allocateNames(used, [
4201
+ { key: "row", name: "item" },
4202
+ { key: "index", name: "j" }
4203
+ ]);
4204
+ const names = { row: allocated.get("row"), index: allocated.get("index") };
4205
+ const fallback = [];
4206
+ const writable = inner.every((member) => setLeaf(fallback[member.index] ??= emptyRow(), member.leaf, member.fallback));
4207
+ if (!writable) return null;
4208
+ const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
4209
+ const shift = -template.range.start;
4210
+ const declarations = [];
4211
+ for (const member of template.members) {
4212
+ const literal = `${prefix}.${array}[\${${names.index}}].${member.leaf}`;
4213
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
4214
+ declarations.push({ path: shapeOfPath(literal), literal });
4215
+ }
4216
+ const rowsExpr = `${ctx.rows}(${outer.row}, ${JSON.stringify(array)}, ${JSON.stringify(fallback.filter(Boolean))})`;
4217
+ if (rule.loop.kind === "attr") {
4218
+ source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
4219
+ }
4220
+ if (rule.loopKey) {
4221
+ const key = rule.loopKey(names.index);
4222
+ if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
4223
+ else if (template.keyAttr.end > template.keyAttr.start)
4224
+ source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
4225
+ else return null;
3771
4226
  }
3772
- return parts.join("/");
4227
+ const indent = indentOf(ctx.content, template.range.start);
4228
+ const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
4229
+ ${indented(source.toString(), indent)}
4230
+ ${indent}${rule.loop.close}` : source.toString();
4231
+ return { range: { start: template.range.start, end: rows[rows.length - 1].range.end }, text, declarations };
3773
4232
  }
3774
- var firstPresent = (base, files) => [base, ...EXTENSIONS.map((ext) => `${base}${ext}`), ...EXTENSIONS.map((ext) => `${base}/index${ext}`)].find(
3775
- (candidate) => files.has(candidate)
3776
- ) ?? null;
3777
- function aliasesFrom(sources) {
3778
- const out = /* @__PURE__ */ new Map();
3779
- for (const source of sources) {
3780
- if (!isProjectConfig(source.path)) continue;
3781
- const parsed = readJsonc(source.content);
3782
- if (parsed === null) continue;
3783
- const base = parsed.compilerOptions?.baseUrl?.replace(/^\.\/?/, "").replace(/\/$/, "") ?? "";
3784
- const root = source.path.split("/").slice(0, -1).join("/");
3785
- for (const [pattern, targets] of Object.entries(parsed.compilerOptions?.paths ?? {})) {
3786
- out.set(
3787
- pattern,
3788
- targets.map((target) => [root, base, target.replace(/^\.\/?/, "")].filter(Boolean).join("/"))
3789
- );
3790
- }
4233
+ function emitRepeater(dialect, group, arrayPath, fallbackRows, ctx) {
4234
+ const rule = DIALECT_RULES[dialect];
4235
+ if (!rule.loop || !rule.text || group.length === 0) return null;
4236
+ const rows = findRows(ctx.roots, group);
4237
+ if (!rows || rows.length < 2) return null;
4238
+ if (!consecutive(rows, ctx.content)) return null;
4239
+ if (!sameShape(rows, ctx.content)) return null;
4240
+ if (group.some((m) => m.site.where !== "text" || !isIntrinsic(m.site.tag) || !m.site.inner)) return null;
4241
+ const template = rows[0];
4242
+ const used = new Set(ctx.content.slice(template.range.start, template.range.end).match(/[A-Za-z_$][\w$]*/g) ?? []);
4243
+ const allocated = allocateNames(used, [
4244
+ { key: "row", name: "card" },
4245
+ { key: "index", name: "i" }
4246
+ ]);
4247
+ const names = { row: allocated.get("row"), index: allocated.get("index") };
4248
+ const source = new MagicString(ctx.content.slice(template.range.start, template.range.end));
4249
+ const shift = -template.range.start;
4250
+ const declarations = [];
4251
+ const nested = /* @__PURE__ */ new Map();
4252
+ const direct = [];
4253
+ for (const member of template.members) {
4254
+ const inner = splitNested(member.leaf);
4255
+ if (inner === null) direct.push(member);
4256
+ else nested.set(inner.array, [...nested.get(inner.array) ?? [], member]);
3791
4257
  }
3792
- return out;
3793
- }
3794
- var isProjectConfig = (path) => path.endsWith("tsconfig.json") || path.endsWith("jsconfig.json");
3795
- function readJsonc(content) {
3796
- let out = "";
3797
- let at = 0;
3798
- while (at < content.length) {
3799
- const char = content[at];
3800
- if (char === '"') {
3801
- const start = at++;
3802
- while (at < content.length && content[at] !== '"') at += content[at] === "\\" ? 2 : 1;
3803
- out += content.slice(start, Math.min(at + 1, content.length));
3804
- at++;
3805
- continue;
3806
- }
3807
- if (char === "/" && content[at + 1] === "/") {
3808
- while (at < content.length && content[at] !== "\n") at++;
3809
- continue;
3810
- }
3811
- if (char === "/" && content[at + 1] === "*") {
3812
- const close = content.indexOf("*/", at + 2);
3813
- at = close < 0 ? content.length : close + 2;
3814
- continue;
3815
- }
3816
- out += char;
3817
- at++;
4258
+ for (const [array, members] of nested) {
4259
+ const plan2 = emitNested(rule, array, members, `${arrayPath}[\${${names.index}}]`, names, template, ctx);
4260
+ if (!plan2) return null;
4261
+ if (direct.some((m) => m.site.range.start >= plan2.range.start && m.site.range.end <= plan2.range.end)) return null;
4262
+ source.overwrite(plan2.range.start + shift, plan2.range.end + shift, plan2.text);
4263
+ declarations.push(...plan2.declarations);
3818
4264
  }
3819
- try {
3820
- const parsed = JSON.parse(out.replace(/,(?=\s*[}\]])/g, ""));
3821
- return isAliasShaped(parsed) ? parsed : null;
3822
- } catch {
3823
- return null;
4265
+ for (const member of direct) {
4266
+ const literal = `${arrayPath}[\${${names.index}}].${member.leaf}`;
4267
+ writeLeaf(source, shift, member, rule, ctx.read, names.row, literal);
4268
+ declarations.push({ path: shapeOfPath(literal), literal });
3824
4269
  }
3825
- }
3826
- var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3827
- function isAliasShaped(value) {
3828
- if (!isRecord(value)) return false;
3829
- const options = value.compilerOptions;
3830
- if (options === void 0) return true;
3831
- if (!isRecord(options)) return false;
3832
- if (options.baseUrl !== void 0 && typeof options.baseUrl !== "string") return false;
3833
- if (options.paths === void 0) return true;
3834
- if (!isRecord(options.paths)) return false;
3835
- return Object.values(options.paths).every(
3836
- (targets) => Array.isArray(targets) && targets.every((target) => typeof target === "string")
3837
- );
3838
- }
3839
- function unreadableConfigs(sources) {
3840
- return sources.filter((source) => isProjectConfig(source.path) && readJsonc(source.content) === null).map((source) => source.path);
3841
- }
3842
- function expand(specifier, aliases) {
3843
- const out = [];
3844
- for (const [pattern, targets] of aliases) {
3845
- const star = pattern.indexOf("*");
3846
- if (star < 0) {
3847
- if (specifier === pattern) out.push(...targets);
3848
- continue;
3849
- }
3850
- const head = pattern.slice(0, star);
3851
- const tail = pattern.slice(star + 1);
3852
- if (!specifier.startsWith(head) || !specifier.endsWith(tail)) continue;
3853
- const middle = specifier.slice(head.length, specifier.length - tail.length);
3854
- out.push(...targets.map((target) => target.replace("*", middle)));
4270
+ const args = [ctx.snapshot, JSON.stringify(arrayPath), JSON.stringify(fallbackRows), ...ctx.slug ? [ctx.slug] : []];
4271
+ const rowsExpr = `${ctx.rows}(${args.join(", ")})`;
4272
+ if (rule.loop.kind === "attr") {
4273
+ source.appendRight(template.attrInsertAt + shift, ` ${rule.loop.attr(rowsExpr, names.row, names.index)}`);
3855
4274
  }
3856
- return out;
3857
- }
3858
- function resolveSpecifier(fromFile, specifier, files, aliases) {
3859
- if (specifier.startsWith(".")) return firstPresent(join(fromFile, specifier), files);
3860
- for (const candidate of expand(specifier, aliases)) {
3861
- const found = firstPresent(candidate.replace(/\/$/, ""), files);
3862
- if (found) return found;
4275
+ if (rule.loopKey) {
4276
+ const key = rule.loopKey(names.index);
4277
+ if (!template.keyAttr) source.appendRight(template.attrInsertAt + shift, ` ${key}`);
4278
+ else if (template.keyAttr.end > template.keyAttr.start)
4279
+ source.overwrite(template.keyAttr.start + shift, template.keyAttr.end + shift, key);
4280
+ else return null;
3863
4281
  }
3864
- return null;
4282
+ const indent = indentOf(ctx.content, template.range.start);
4283
+ const text = rule.loop.kind === "wrap" ? `${rule.loop.open(rowsExpr, names.row, names.index)}
4284
+ ${indented(source.toString(), indent)}
4285
+ ${indent}${rule.loop.close}` : source.toString();
4286
+ return {
4287
+ range: { start: template.range.start, end: rows[rows.length - 1].range.end },
4288
+ text,
4289
+ declarations,
4290
+ bindings: [...new Set(declarations.map((d) => d.path))],
4291
+ names
4292
+ };
3865
4293
  }
3866
- function importsIn(parsed, _dialect) {
3867
- const out = /* @__PURE__ */ new Map();
3868
- walkAst(parsed.ast, (node) => {
3869
- if (node.type !== "ImportDeclaration") return;
3870
- if (node.importKind === "type") return;
3871
- const specifier = node.source?.value;
3872
- if (typeof specifier !== "string") return;
3873
- for (const entry of node.specifiers ?? []) {
3874
- if (entry.importKind === "type") continue;
3875
- const local = entry.local?.name;
3876
- if (typeof local !== "string") continue;
3877
- const kind = entry.type === "ImportDefaultSpecifier" ? "default" : entry.type === "ImportNamespaceSpecifier" ? "namespace" : "named";
3878
- const imported = kind === "named" ? entry.imported?.name ?? local : null;
3879
- out.set(local, { specifier, imported, kind });
3880
- }
4294
+ function setLeaf(row, leaf, value) {
4295
+ const segments = leaf.split(".").flatMap((segment) => {
4296
+ const name = segment.replace(/\[\d+\]/g, "");
4297
+ const indices = [...segment.matchAll(/\[(\d+)\]/g)].map((m) => Number(m[1]));
4298
+ return [...name ? [name] : [], ...indices];
3881
4299
  });
3882
- return out;
4300
+ if (segments.some((segment) => typeof segment === "string" && FORBIDDEN.has(segment))) return false;
4301
+ let cursor = row;
4302
+ for (const [at, segment] of segments.entries()) {
4303
+ const key = segment;
4304
+ const last = at === segments.length - 1;
4305
+ if (last) {
4306
+ cursor[key] = value;
4307
+ return true;
4308
+ }
4309
+ const held = Object.prototype.hasOwnProperty.call(cursor, key) ? cursor[key] : void 0;
4310
+ if (held === void 0 || typeof held !== "object" || held === null) {
4311
+ cursor[key] = typeof segments[at + 1] === "number" ? [] : emptyRow();
4312
+ }
4313
+ cursor = cursor[key];
4314
+ }
4315
+ return true;
4316
+ }
4317
+ var FORBIDDEN = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
4318
+ var emptyRow = () => /* @__PURE__ */ Object.create(null);
4319
+ function splitIndexed(arrayPath, path) {
4320
+ if (!path.startsWith(`${arrayPath}[`)) return null;
4321
+ const match = path.slice(arrayPath.length).match(/^\[(\d+)\]\.(.+)$/);
4322
+ return match ? { index: Number(match[1]), leaf: match[2] } : null;
3883
4323
  }
3884
4324
 
3885
4325
  // src/routes.ts
@@ -4075,13 +4515,15 @@ function declaringElements(roots, content) {
4075
4515
  return out;
4076
4516
  }
4077
4517
  function helperLocals(parsed, dialect, file) {
4078
- const out = { bcms: [], bcmsRows: [], bcmsLayout: [] };
4518
+ const out = { bcms: [], bcmsRows: [], bcmsRowsAs: [], bcmsImage: [], bcmsLayout: [] };
4079
4519
  const helper = HELPER_PATH[dialect];
4080
4520
  if (!helper) return out;
4081
4521
  for (const [local, from] of importsIn(parsed, dialect)) {
4082
4522
  if (!namesFile(file, from.specifier, helper)) continue;
4083
4523
  if (from.imported === "bcms") out.bcms.push(local);
4084
4524
  if (from.imported === "bcmsRows") out.bcmsRows.push(local);
4525
+ if (from.imported === "bcmsRowsAs") out.bcmsRowsAs.push(local);
4526
+ if (from.imported === "bcmsImage") out.bcmsImage.push(local);
4085
4527
  if (from.imported === "bcmsLayout") out.bcmsLayout.push(local);
4086
4528
  }
4087
4529
  return out;
@@ -4113,19 +4555,29 @@ function snapshotLocal(parsed, dialect, slug, file) {
4113
4555
  }
4114
4556
  return null;
4115
4557
  }
4558
+ function moduleBytes(roots, content) {
4559
+ let out = "";
4560
+ let at = 0;
4561
+ for (const root of roots) {
4562
+ if (root.type !== "element" || root.start < at) continue;
4563
+ out += content.slice(at, root.start);
4564
+ at = Math.max(at, root.end);
4565
+ }
4566
+ return out + content.slice(at);
4567
+ }
4116
4568
  var identifier = (name) => `(?<![\\w$])${escapeRegExp(name)}(?![\\w$])`;
4117
- function elementReads(own, target, locals, snapshot) {
4569
+ function elementReads(own, target, locals, snapshot, viaRow) {
4118
4570
  const quoted = escapeRegExp(JSON.stringify(target.path));
4119
4571
  if (target.scope === "layout") {
4120
4572
  return locals.bcmsLayout.some((name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${quoted}`).test(own));
4121
4573
  }
4122
4574
  const indexed = splitArrayPath(target.path);
4123
- if (indexed) {
4575
+ if (viaRow && indexed) {
4124
4576
  const leaf = escapeRegExp(JSON.stringify(indexed.leaf));
4125
4577
  return locals.bcms.some((name) => new RegExp(`${identifier(name)}\\s*\\([^)]*,\\s*${leaf}`).test(own));
4126
4578
  }
4127
4579
  if (!snapshot) return false;
4128
- return locals.bcms.some(
4580
+ return [...locals.bcms, ...locals.bcmsImage].some(
4129
4581
  (name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,\\s*${quoted}`).test(own)
4130
4582
  );
4131
4583
  }
@@ -4135,16 +4587,33 @@ function convertedHere(content, parsed, dialect, target, file) {
4135
4587
  const snapshot = snapshotLocal(parsed, dialect, target.slug, file);
4136
4588
  const shape = shapeOfPath(target.path);
4137
4589
  const indexed = splitArrayPath(target.path);
4590
+ let keyed = false;
4591
+ let rows = false;
4138
4592
  if (indexed) {
4139
4593
  const array = escapeRegExp(JSON.stringify(indexed.array));
4140
- const rows = locals.bcmsRows.some(
4594
+ const reads = (names) => names.some(
4141
4595
  (name) => snapshot !== null && new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,\\s*${array}`).test(content)
4142
4596
  );
4143
- if (!rows) return false;
4144
- }
4145
- return declaringElements(parsed.roots, content).some(
4146
- (element) => element.scope === target.scope && (element.path === target.path || element.path === shape) && elementReads(element.own, target, locals, snapshot)
4147
- );
4597
+ keyed = reads(locals.bcmsRowsAs);
4598
+ rows = keyed || reads(locals.bcmsRows);
4599
+ }
4600
+ let cached = null;
4601
+ const moduleRead = () => {
4602
+ if (indexed || snapshot === null) return false;
4603
+ cached ??= moduleBytes(parsed.roots, content);
4604
+ const quoted = escapeRegExp(JSON.stringify(target.path));
4605
+ return locals.bcms.some(
4606
+ (name) => new RegExp(`${identifier(name)}\\s*\\(\\s*${identifier(snapshot)}\\s*,\\s*${quoted}`).test(cached)
4607
+ );
4608
+ };
4609
+ return declaringElements(parsed.roots, content).some((element) => {
4610
+ if (element.scope !== target.scope) return false;
4611
+ if (element.path === target.path) {
4612
+ return elementReads(element.own, target, locals, snapshot, false) || moduleRead();
4613
+ }
4614
+ if (element.path !== shape) return false;
4615
+ return rows && (keyed || elementReads(element.own, target, locals, snapshot, true));
4616
+ });
4148
4617
  }
4149
4618
 
4150
4619
  // src/rewrite.ts
@@ -4210,7 +4679,7 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
4210
4679
  addDeclaration(site.attrInsertAt, declaration(rewrite));
4211
4680
  continue;
4212
4681
  }
4213
- if (kind === "richtext") {
4682
+ if (kind === "richtext" && !(site.mixed && site.textRange)) {
4214
4683
  if (!isIntrinsic(site.tag)) continue;
4215
4684
  if (rule.richtext && site.inner) {
4216
4685
  source.remove(site.inner.start, site.inner.end);
@@ -4221,11 +4690,13 @@ function rewriteFile(content, dialect, rewrites, imports, extra = []) {
4221
4690
  continue;
4222
4691
  }
4223
4692
  if (site.mixed && site.textRange) {
4693
+ const html = kind === "richtext" && rule.richtext ? rule.richtext(expression) : null;
4224
4694
  const inner = rule.text ? rule.text(expression) : content.slice(site.textRange.start, site.textRange.end).trim();
4695
+ const attributes2 = attrsFor(path, kind, void 0, rewrite.scope);
4225
4696
  source.overwrite(
4226
4697
  site.textRange.start,
4227
4698
  site.textRange.end,
4228
- `<span ${attrsFor(path, kind, void 0, rewrite.scope)}>${inner}</span>`
4699
+ html ? `<span ${attributes2} ${html}></span>` : `<span ${attributes2}>${inner}</span>`
4229
4700
  );
4230
4701
  continue;
4231
4702
  }
@@ -4481,6 +4952,7 @@ function convert3(node, content) {
4481
4952
  }
4482
4953
  ];
4483
4954
  }
4955
+ if (node.type === "fragment") return (node.children ?? []).flatMap((child) => convert3(child, content));
4484
4956
  if (node.type !== "element" && node.type !== "component" && node.type !== "custom-element") return [];
4485
4957
  const tag = node.name ?? "";
4486
4958
  const children = (node.children ?? []).flatMap((child) => convert3(child, content));
@@ -4804,7 +5276,7 @@ var findSites = async (dialect, content, literals) => {
4804
5276
 
4805
5277
  // src/canvas.ts
4806
5278
  import MagicString3 from "magic-string";
4807
- var SSR_DRAFT_RECIPE = "Astro renders this site at build time, so there is no server to answer the editor's draft request. Either mount <BcmsLiveBlocks client:load> from @bettercms-ai/astro on the pages whose body is a block tree, or switch the preview routes to `output: 'server'` with the node adapter, mount @bettercms-ai/astro's draft routes, set BCMS_DRAFT_SECRET, redeploy, and switch the project to 'Run as a server app'.";
5279
+ var SSR_DRAFT_RECIPE = "Astro renders this site at build time, so there is no server to answer the editor's draft request. Either mount <BcmsLiveBlocks client:load> from @bettercms-ai/astro on the pages whose body is a block tree, or switch to `output: 'server'` with @astrojs/node (mode: 'standalone') \u2014 the bettercms() integration injects the draft routes for you \u2014 render pages from loadPage + loadComponents, mount <BcmsLive>, set BCMS_DRAFT_SECRET (BetterCMS hosting sets it for you), redeploy, and switch the project to 'Run as a server app'.";
4808
5280
  var isNode = (value) => !!value && typeof value === "object" && typeof value.type === "string";
4809
5281
  var FUNCTIONS3 = /* @__PURE__ */ new Set([
4810
5282
  "FunctionDeclaration",
@@ -4855,16 +5327,16 @@ function functionBindings(fn) {
4855
5327
  if (body?.type === "BlockStatement") statementNames2(body.body, out);
4856
5328
  return out;
4857
5329
  }
4858
- function stringLiterals(node, out) {
5330
+ function stringLiterals2(node, out) {
4859
5331
  if (!isNode(node)) {
4860
- if (Array.isArray(node)) for (const child of node) stringLiterals(child, out);
5332
+ if (Array.isArray(node)) for (const child of node) stringLiterals2(child, out);
4861
5333
  return;
4862
5334
  }
4863
5335
  if (FUNCTIONS3.has(node.type)) return;
4864
5336
  if (node.type === "StringLiteral" && typeof node.value === "string") out.add(node.value);
4865
5337
  for (const key of Object.keys(node)) {
4866
5338
  if (key === "loc" || key === "leadingComments" || key === "trailingComments") continue;
4867
- stringLiterals(node[key], out);
5339
+ stringLiterals2(node[key], out);
4868
5340
  }
4869
5341
  }
4870
5342
  function usages(ast, local) {
@@ -4881,7 +5353,7 @@ function usages(ast, local) {
4881
5353
  if (FUNCTIONS3.has(node.type)) {
4882
5354
  nextScope = /* @__PURE__ */ new Set([...scope, ...functionBindings(node)]);
4883
5355
  nextLiterals = /* @__PURE__ */ new Set();
4884
- stringLiterals(node.body, nextLiterals);
5356
+ stringLiterals2(node.body, nextLiterals);
4885
5357
  }
4886
5358
  if (node.type === "JSXElement") {
4887
5359
  const name = node.openingElement?.name;
@@ -5211,12 +5683,28 @@ var SOURCE_EXTENSIONS = [
5211
5683
  ".erb",
5212
5684
  ".twig"
5213
5685
  ];
5686
+ var PROSE_EXTENSIONS = [".md", ".mdx", ".njk", ".hbs", ".ejs", ".liquid", ".erb", ".twig"];
5687
+ var TEMPLATE_DIRS = /* @__PURE__ */ new Set([
5688
+ "pages",
5689
+ "content",
5690
+ "src",
5691
+ "app",
5692
+ "routes",
5693
+ "layouts",
5694
+ "components",
5695
+ "templates",
5696
+ "views",
5697
+ "_includes",
5698
+ "_layouts"
5699
+ ]);
5214
5700
  function isSourceCandidate(path) {
5215
5701
  const lower = path.toLowerCase();
5216
5702
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
5217
5703
  return false;
5218
5704
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
5219
- return SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext));
5705
+ if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
5706
+ if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
5707
+ return lower.split("/").slice(0, -1).some((segment) => TEMPLATE_DIRS.has(segment));
5220
5708
  }
5221
5709
 
5222
5710
  // src/declared.ts
@@ -7042,6 +7530,7 @@ var ConvertError = class extends Error {
7042
7530
  var keyOf = (route, scope, path) => scope === "layout" ? `layout::${path}` : `${route}::page::${path}`;
7043
7531
  var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7044
7532
  var identifierFor2 = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
7533
+ var UNPLACED = /* @__PURE__ */ new Set(["NOT_IN_SOURCE", "IN_EXPRESSION"]);
7045
7534
  async function drillProp(ctx, fromFile, component, prop, kind, hop) {
7046
7535
  const miss = { ok: false, reason: "PROP_TARGET_NOT_FOUND", edits: [] };
7047
7536
  if (hop > MAX_HOPS) return { ok: false, reason: "PROP_DRILLED_DEEP", edits: [] };
@@ -7138,7 +7627,7 @@ async function convertSources(briefIn, sources, options) {
7138
7627
  const parsed = await parseOf(source.path);
7139
7628
  declarations.set(
7140
7629
  source.path,
7141
- parsed === null ? /* @__PURE__ */ new Set() : parsed.error ? declaredByScan(source.content) : declaredInTree(parsed.roots, source.content)
7630
+ parsed === null ? isTolerantDialect(source.path) ? declaredByScan(source.content) : /* @__PURE__ */ new Set() : parsed.error ? declaredByScan(source.content) : declaredInTree(parsed.roots, source.content)
7142
7631
  );
7143
7632
  }
7144
7633
  let declaredOnly = 0;
@@ -7170,6 +7659,36 @@ async function convertSources(briefIn, sources, options) {
7170
7659
  const key = keyOf(hit.route, hit.scope ?? "page", hit.path);
7171
7660
  if (!locatedByKey.has(key)) locatedByKey.set(key, hit);
7172
7661
  }
7662
+ const imageUnresolved = /* @__PURE__ */ new Map();
7663
+ for (const miss of unlocated) {
7664
+ const key = keyOf(miss.route, miss.scope ?? "page", miss.path);
7665
+ const target = targets.get(key);
7666
+ if (!target || target.kind !== "image" || !target.fallback || locatedByKey.has(key)) continue;
7667
+ const hosts = [];
7668
+ for (const file of pageFilesFor(target.route, contents.keys())) {
7669
+ const parsed = await parseOf(file);
7670
+ const dialect = dialectOf(file, contents.get(file) ?? "");
7671
+ const bindings = parsed && dialect ? assetCandidates(parsed, dialect, target.fallback) : [];
7672
+ if (bindings.length > 0) hosts.push({ file, bindings });
7673
+ }
7674
+ const only = hosts.length === 1 && hosts[0].bindings.length === 1 ? hosts[0] : null;
7675
+ if (only) {
7676
+ locatedByKey.set(key, {
7677
+ route: target.route,
7678
+ scope: target.scope,
7679
+ path: target.path,
7680
+ kind: target.kind,
7681
+ original: target.literal,
7682
+ files: [only.file]
7683
+ });
7684
+ continue;
7685
+ }
7686
+ const named = hosts.flatMap((host) => host.bindings.map((binding) => `${host.file}:${binding}`));
7687
+ imageUnresolved.set(
7688
+ key,
7689
+ named.length === 0 ? `No asset imported by this route's page has the base name of ${target.fallback}.` : `${named.join(", ")} all match the base name of ${target.fallback}, and nothing says which.`
7690
+ );
7691
+ }
7173
7692
  const sharingPath = /* @__PURE__ */ new Map();
7174
7693
  for (const [key, hit] of locatedByKey) {
7175
7694
  const held = targets.get(key);
@@ -7213,6 +7732,11 @@ async function convertSources(briefIn, sources, options) {
7213
7732
  declaredRow(target, false, byHand);
7214
7733
  continue;
7215
7734
  }
7735
+ const unresolved = imageUnresolved.get(key);
7736
+ if (unresolved !== void 0) {
7737
+ pend2(target, "IMAGE_ASSET_UNRESOLVED", void 0, unresolved);
7738
+ continue;
7739
+ }
7216
7740
  pend2(target, miss.reason === "no-original" ? "NO_ORIGINAL" : "NOT_IN_SOURCE");
7217
7741
  }
7218
7742
  const byFile = /* @__PURE__ */ new Map();
@@ -7270,7 +7794,14 @@ async function convertSources(briefIn, sources, options) {
7270
7794
  const worst = outcomes.find((o) => o !== "rewritten" && o !== "declared");
7271
7795
  if (worst) {
7272
7796
  const file = hit.files[outcomes.indexOf(worst)];
7273
- pend2(target, worst, file);
7797
+ pend2(
7798
+ target,
7799
+ worst,
7800
+ file,
7801
+ // The reason says the source does not render it and the file name says where it was
7802
+ // found; without the second half the two read as a contradiction. @see unplacedReason
7803
+ worst === "NOT_IN_SOURCE" && file ? `${file} contains this copy, but no element renders it on its own.` : void 0
7804
+ );
7274
7805
  } else if (outcomes.every((o) => o === "declared")) {
7275
7806
  const witness = await witnessed(target);
7276
7807
  const byHand = witness === null ? declaredByHand(target, hit.files) : null;
@@ -7381,7 +7912,11 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7381
7912
  if (pendingTargets.length === 0) return empty;
7382
7913
  if (!dialect) {
7383
7914
  if (isTolerantDialect(file)) {
7384
- const tolerantLiterals = [...new Set(pendingTargets.map((t) => t.literal))];
7915
+ const already = declaredByScan(content);
7916
+ const remaining = pendingTargets.filter((target) => !covers(already, target));
7917
+ for (const target of pendingTargets) if (covers(already, target)) outcomes.set(target.key, "declared");
7918
+ if (remaining.length === 0) return { ...empty, dialect: null, outcomes };
7919
+ const tolerantLiterals = [...new Set(remaining.map((t) => t.literal))];
7385
7920
  const tolerantSites = findSitesTolerant(content, tolerantLiterals);
7386
7921
  if (tolerantSites.length > 0) {
7387
7922
  return plan(
@@ -7391,7 +7926,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7391
7926
  // argument is behind `!parsed.error` or `tier !== 2`. Passing the writing dialect keeps
7392
7927
  // the parameter honest rather than widening its type for one caller.
7393
7928
  "html",
7394
- pendingTargets,
7929
+ remaining,
7395
7930
  tolerantSites,
7396
7931
  void 0,
7397
7932
  // no parse, no import point: tier 2 declares, it does not read
@@ -7404,7 +7939,7 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7404
7939
  );
7405
7940
  }
7406
7941
  }
7407
- for (const target of pendingTargets) outcomes.set(target.key, "DIALECT_UNSUPPORTED");
7942
+ for (const target of pendingTargets) if (!outcomes.has(target.key)) outcomes.set(target.key, "DIALECT_UNSUPPORTED");
7408
7943
  return { ...empty, dialect: null, outcomes };
7409
7944
  }
7410
7945
  const literals = [...new Set(pendingTargets.map((t) => t.literal))];
@@ -7441,8 +7976,8 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7441
7976
  return { ...empty, tier: 3, outcomes, dialect: null };
7442
7977
  }
7443
7978
  }
7444
- if (sites.length === 0) {
7445
- const reason = tier1.error?.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : tier1.error ? "PARSE_ERROR" : "NOT_IN_SOURCE";
7979
+ if (sites.length === 0 && tier1.error) {
7980
+ const reason = tier1.error.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR";
7446
7981
  for (const target of pendingTargets) outcomes.set(target.key, reason);
7447
7982
  return { ...empty, tier, outcomes, dialect };
7448
7983
  }
@@ -7460,9 +7995,24 @@ async function convertFile(file, content, targets, parsed, declared, llmFallback
7460
7995
  tier1.error ? [] : declarationsIn(tier1.roots, content)
7461
7996
  );
7462
7997
  }
7998
+ function unplacedReason(parsed, literal) {
7999
+ if (!parsed || parsed.error) return "NOT_IN_SOURCE";
8000
+ if (parsed.script && norm(parsed.script.code).includes(literal)) return "IN_EXPRESSION";
8001
+ let found = false;
8002
+ const visit = (node) => {
8003
+ if (found) return;
8004
+ if (node.type === "expr") {
8005
+ if (norm(node.value).includes(literal)) found = true;
8006
+ return;
8007
+ }
8008
+ if (node.type === "element" && !node.raw) for (const child of node.children) visit(child);
8009
+ };
8010
+ for (const root of parsed.roots) visit(root);
8011
+ return found ? "IN_EXPRESSION" : "NOT_IN_SOURCE";
8012
+ }
7463
8013
  async function plan(file, content, dialect, targets, sites, importAt, outcomes, tier, parsed, drill, declarations) {
7464
8014
  const writing = tier === 2 ? "html" : dialect;
7465
- const missing = tier !== 2 ? "NOT_IN_SOURCE" : parsed.error?.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR";
8015
+ const missing = (literal) => tier !== 2 ? unplacedReason(parsed, literal) : parsed.error?.code === "DIALECT_UNSUPPORTED" ? "DIALECT_UNSUPPORTED" : "PARSE_ERROR";
7466
8016
  const taken = parsed.error ? /* @__PURE__ */ new Set() : topLevelBindings(dialect, parsed, content);
7467
8017
  const slugs = [...new Set(targets.map((t) => t.slug))].sort();
7468
8018
  const preset = /* @__PURE__ */ new Map();
@@ -7471,7 +8021,9 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7471
8021
  for (const [key, held] of [
7472
8022
  ["helper:bcms", locals.bcms],
7473
8023
  ["helper:bcmsRows", locals.bcmsRows],
7474
- ["helper:bcmsLayout", locals.bcmsLayout]
8024
+ ["helper:bcmsLayout", locals.bcmsLayout],
8025
+ ["helper:bcmsRowsAs", locals.bcmsRowsAs],
8026
+ ["helper:bcmsImage", locals.bcmsImage]
7475
8027
  ]) {
7476
8028
  if (held[0]) preset.set(key, held[0]);
7477
8029
  }
@@ -7484,6 +8036,8 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7484
8036
  { key: "helper:bcms", name: "bcms" },
7485
8037
  { key: "helper:bcmsRows", name: "bcmsRows" },
7486
8038
  { key: "helper:bcmsLayout", name: "bcmsLayout" },
8039
+ { key: "helper:bcmsRowsAs", name: "bcmsRowsAs" },
8040
+ { key: "helper:bcmsImage", name: "bcmsImage" },
7487
8041
  // Keyed by the SLUG: two slugs that spell one identifier are still two pages. @see scope.ts
7488
8042
  ...slugs.map((slug) => ({ key: `slug:${slug}`, name: identifierFor2(slug) }))
7489
8043
  ].filter((entry) => !preset.has(entry.key));
@@ -7501,7 +8055,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7501
8055
  for (const [literal, sharing] of byLiteral) {
7502
8056
  const forLiteral = sites.filter((s) => s.literal === literal);
7503
8057
  if (forLiteral.length === 0) {
7504
- for (const target of sharing) outcomes.set(target.key, missing);
8058
+ for (const target of sharing) outcomes.set(target.key, missing(literal));
7505
8059
  continue;
7506
8060
  }
7507
8061
  const assigned = sharing.length === 1 ? /* @__PURE__ */ new Map([[sharing[0].key, forLiteral]]) : resolvePositionally(sharing, forLiteral);
@@ -7515,7 +8069,7 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7515
8069
  if (rewritable.length === 0) {
7516
8070
  outcomes.set(
7517
8071
  target.key,
7518
- mine.some((s) => s.partial) ? "SUBSTRING_ONLY" : mine.some((s) => s.where === "script" || s.where === "comment") ? "IN_SCRIPT_OR_COMMENT" : missing
8072
+ mine.some((s) => s.partial) ? "SUBSTRING_ONLY" : mine.some((s) => s.where === "script" || s.where === "comment") ? "IN_SCRIPT_OR_COMMENT" : missing(literal)
7519
8073
  );
7520
8074
  continue;
7521
8075
  }
@@ -7590,7 +8144,11 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7590
8144
  // `original` has been through `firstBlockText`, so it is the text a reader sees with
7591
8145
  // the markup already stripped. Keeping that as the in-code fallback would silently
7592
8146
  // flatten every bold and link the moment a build ran against a stub snapshot.
7593
- fallback: target.kind === "richtext" && site.inner ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
8147
+ //
8148
+ // 🔴 AND ONLY WHEN THE ELEMENT'S SUBTREE IS THE VALUE. A value that is one TEXT NODE of
8149
+ // a mixed element gets a wrapper of its own, so taking the element's inner range as its
8150
+ // fallback swallowed the `<em>` beside it — the wrapper then rendered the sibling twice.
8151
+ fallback: target.kind === "richtext" && site.inner && !(site.mixed && site.textRange) ? content.slice(site.inner.start, site.inner.end).trim() : target.fallback,
7594
8152
  snapshot: snapshotName(target.slug),
7595
8153
  slug: target.slug,
7596
8154
  scope: target.scope,
@@ -7671,10 +8229,48 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7671
8229
  const collided = new Set(overlapping(kept).map((r) => r.key));
7672
8230
  const applied = kept.filter((r) => !collided.has(r.key));
7673
8231
  for (const key of collided) outcomes.set(key, "AMBIGUOUS_LITERAL");
7674
- if (applied.length === 0) {
8232
+ const data = bindDataLiterals(
8233
+ {
8234
+ content,
8235
+ parsed,
8236
+ dialect: writing,
8237
+ rowsAs: helperName("bcmsRowsAs"),
8238
+ read: helperName("bcms"),
8239
+ snapshotName,
8240
+ slug: params?.slug ?? null
8241
+ },
8242
+ targets.filter((t) => {
8243
+ const outcome = outcomes.get(t.key);
8244
+ return t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
8245
+ }).map((t) => ({
8246
+ key: t.key,
8247
+ path: t.path,
8248
+ kind: t.kind,
8249
+ literal: t.literal,
8250
+ slug: t.slug
8251
+ }))
8252
+ );
8253
+ for (const key of data.bound) outcomes.set(key, "rewritten");
8254
+ for (const refusal of data.refused) outcomes.set(refusal.key, refusal.reason);
8255
+ for (const declaration2 of data.declarations) {
8256
+ dynamic.push({ file, prop: null, path: declaration2.path, callSites: [{ file, literal: declaration2.literal }] });
8257
+ }
8258
+ const images = bindImages(
8259
+ { content, parsed, dialect: writing, image: helperName("bcmsImage"), snapshotName, slug: params?.slug ?? null },
8260
+ targets.filter((t) => {
8261
+ const outcome = outcomes.get(t.key);
8262
+ return t.kind === "image" && t.scope === "page" && (outcome === void 0 || UNPLACED.has(outcome));
8263
+ }).map((t) => ({ key: t.key, path: t.path, kind: t.kind, original: t.fallback, slug: t.slug }))
8264
+ );
8265
+ for (const key of images.bound) outcomes.set(key, "rewritten");
8266
+ for (const refusal of images.refused) outcomes.set(refusal.key, refusal.reason);
8267
+ const lanes = [...data.splices, ...images.splices];
8268
+ if (applied.length === 0 && lanes.length === 0) {
7675
8269
  return { tier, content: null, outcomes, bindings: [], occurrences: { located, rewritten: 0 }, dialect: writing, dynamic: [], componentEdits, snapshots: [] };
7676
8270
  }
7677
- const readSlugs = [...new Set(applied.filter((r) => r.scope === "page").map((r) => r.slug))].sort();
8271
+ const readSlugs = [
8272
+ .../* @__PURE__ */ new Set([...applied.filter((r) => r.scope === "page").map((r) => r.slug), ...data.slugs, ...images.slugs])
8273
+ ].sort();
7678
8274
  const helper = HELPER_PATH[writing];
7679
8275
  const rewritten = rewriteFile(content, writing, applied, {
7680
8276
  // html reads nothing at build time, so it imports nothing: `injectFields` writes its values
@@ -7695,10 +8291,13 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
7695
8291
  { name: "bcms", alias: helperName("bcms") },
7696
8292
  { name: "bcmsRows", alias: helperName("bcmsRows") }
7697
8293
  ] : [],
7698
- ...applied.some((r) => r.scope === "layout") ? [{ name: "bcmsLayout", alias: helperName("bcmsLayout") }] : []
8294
+ ...applied.some((r) => r.scope === "layout") ? [{ name: "bcmsLayout", alias: helperName("bcmsLayout") }] : [],
8295
+ ...data.usesRows ? [{ name: "bcmsRowsAs", alias: helperName("bcmsRowsAs") }] : [],
8296
+ ...data.usesRead ? [{ name: "bcms", alias: helperName("bcms") }] : [],
8297
+ ...images.splices.length > 0 ? [{ name: "bcmsImage", alias: helperName("bcmsImage") }] : []
7699
8298
  ].filter((helper2, at, all) => all.findIndex((h) => h.name === helper2.name) === at).filter((helper2) => !preset.has(`helper:${helper2.name}`)),
7700
8299
  at: importAt
7701
- }, params?.splices ?? []);
8300
+ }, [...params?.splices ?? [], ...lanes]);
7702
8301
  return {
7703
8302
  tier,
7704
8303
  content: rewritten,
@@ -7906,7 +8505,11 @@ export {
7906
8505
  aliasesFrom,
7907
8506
  assertReceipt,
7908
8507
  assertSections,
8508
+ assetCandidates,
8509
+ assetImport,
7909
8510
  attrsFor,
8511
+ bindDataLiterals,
8512
+ bindImages,
7910
8513
  briefDigest,
7911
8514
  canvasBridge,
7912
8515
  carryFor,