@bettercms-ai/convert 0.11.0 → 0.12.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # @bettercms-ai/convert
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 6d826cf: The trunk-zero rule is per-country data, not a regex
8
+
9
+ `bcmsPhone` stripped a leading zero from every national number with a bare
10
+ `.replace(/^0/, "")`. That is only correct for countries that actually use a trunk
11
+ prefix. Where the leading digit is part of the number, the submitted value was
12
+ silently corrupted — one digit short, and wrong in a way nobody notices until a
13
+ customer cannot be called back.
14
+
15
+ The country list now carries a `trunk` flag derived from libphonenumber's own example
16
+ numbers, rendered as `data-trunk` on each option, and the zero is dropped only when
17
+ that country says it is a trunk prefix.
18
+
19
+ The inline script exists once per vanilla lane — the Astro component, the converter and
20
+ the server renderer — because each ships as inline script text on someone else's page
21
+ and cannot import at runtime. All three carry the identical change, which
22
+ `src/__tests__/content/form-script-pin.test.ts` pins byte for byte.
23
+
24
+ ## 0.12.0
25
+
26
+ ### Minor Changes
27
+
28
+ - a243594: Every pending path leaves with an exact fix, and a locator resolves each path sharing a literal.
29
+
30
+ `paths.pending[]` rows now carry `fix: { file, line, col?, snippet, action, why? }` — one action
31
+ out of a closed set (`wrap-span`, `declare-attr`, `bind-expression`, `declare-richtext`,
32
+ `bind-data`, `manual`), so "rerun until nothing is pending" is a loop an agent can finish instead
33
+ of a list of category names. A literal one element renders on several routes is named as shared
34
+ chrome rather than sent for an attribute that could only ever be one route's.
35
+
36
+ Positional resolution is now per PATH rather than per literal: one path with no locator used to
37
+ refuse the whole literal, so a page whose rows carried locators converted none of them.
38
+
39
+ Two paths whose locators cannot be told apart now place NEITHER. Resolution counts the targets
40
+ wanting each element before it assigns any, so it no longer depends on brief order: `plan` runs
41
+ per file, and claiming greedily let the first of two indistinguishable paths win in every file —
42
+ writing one path's `data-bcms-field` onto the other's element and reporting it bound.
43
+
44
+ An action that edits an element (`wrap-span`, `declare-attr`, `declare-richtext`) is never issued
45
+ at a position inside a frontmatter array or a `<script>`; there is no element there, so the fix is
46
+ `bind-data` on the row the copy actually lives in. A fix for a dynamic route names the template
47
+ that generates it instead of an empty file. Files that talk ABOUT the site — `*.test.*`,
48
+ `*.spec.*`, `__tests__/`, `__mocks__/`, root `*.config.*` — are no longer read, so a test
49
+ asserting on the hero line stops holding an already-declared path pending.
50
+
51
+ Fix positions are measured against the bytes the run WRITES, not the ones it read — the same run
52
+ inserts a helper import above most pending lines, so a receipt minted from the originals named a
53
+ line one or more short of the element on 616 of 948 Shatter rows.
54
+
3
55
  ## 0.11.0
4
56
 
5
57
  ### Minor Changes
package/README.md CHANGED
@@ -184,6 +184,60 @@ the receipt's arithmetic is checked, not hoped for:
184
184
  paths.declared === paths.rewritten + paths.alreadyDeclared + paths.pending.length
185
185
  ```
186
186
 
187
+ ### What pending means — every row carries the edit, not the diagnosis
188
+
189
+ A reason is a diagnosis and nobody can act on one. So every `paths.pending[]` row also carries a
190
+ `fix`: the file, the 1-based line and column, the bytes that are there now, and ONE action out of
191
+ a closed set. That is what makes "run it again until nothing is pending" a loop an agent can
192
+ actually finish — and what `get_next_steps` prints, one line per path.
193
+
194
+ ```jsonc
195
+ { "route": "/about", "scope": "page", "path": "p-founded-in-2019-in", "kind": "text",
196
+ "reason": "DIALECT_UNSUPPORTED",
197
+ "fix": { "file": "src/data/facts.js", "line": 3, "col": 25,
198
+ "snippet": "export const founded = \"Founded in 2019 in Bristol.\";",
199
+ "action": "bind-data",
200
+ "why": "src/data/facts.js holds this copy as data — bind the element that renders it." } }
201
+ ```
202
+
203
+ | action | what to type |
204
+ |---|---|
205
+ | `wrap-span` | the value shares its element with something else, or is rendered in a position no attribute reaches — give it a `<span data-bcms-field="…">` of its own. |
206
+ | `declare-attr` | the element is there and nothing said which path it is — add `data-bcms-field` to it at `file:line`. |
207
+ | `declare-richtext` | the container is fed markup (`set:html`, `dangerouslySetInnerHTML`) — bind it with the richtext helper. |
208
+ | `bind-expression` | the value is computed in the markup (a ternary, a template literal) — read the path there instead. |
209
+ | `bind-data` | the copy lives in a data module, a frontmatter array or a content-collection entry — bind THAT field, and the element that renders it. |
210
+ | `manual` | none of the above is true; `why` says what a human has to decide. |
211
+
212
+ Reason → action, and the two places the answer is not the obvious one:
213
+
214
+ | reason | action |
215
+ |---|---|
216
+ | `AMBIGUOUS_LITERAL`, one route | `declare-attr` — `why` names every candidate file. |
217
+ | `AMBIGUOUS_LITERAL`, **several routes** | `manual` — one element cannot carry N routes' bindings. This is SHARED CHROME the derive lane did not promote: a Layout field is one value for the whole site, and an attribute here would bind one route and silently freeze the rest. |
218
+ | `IN_EXPRESSION` in the markup | `bind-expression`, or `declare-richtext` for a richtext value. |
219
+ | `IN_EXPRESSION` **in the frontmatter or a `<script>`** | `bind-data` — the copy is a row in the file's own data, and the array is what gets bound, not the `{l.label}` that reads it. |
220
+ | `DIALECT_UNSUPPORTED` on `.md`/`.mdx` | `bind-data` — a content-collection body is the entry's `document` field. |
221
+ | `NOT_IN_SOURCE` / `NO_ORIGINAL` | `bind-data` when the copy is in a data module, else `manual`: the built page formats it and the source holds the input. |
222
+ | `PROP_TARGET_NOT_FOUND`, `PROP_DRILLED_DEEP`, `SUBSTRING_ONLY` | `wrap-span`. |
223
+ | `REPEATER_AMBIGUOUS`, `REPEATER_FIXED_LENGTH` | `declare-attr` on the row's own element. |
224
+ | `IMAGE_ASSET_UNRESOLVED`, `IN_DATA_FILE` | `bind-data`. |
225
+ | `SUBTREE_NOT_HTML` | `declare-richtext`. |
226
+ | `KIND_MISMATCH`, `IN_SCRIPT_OR_COMMENT`, `DYNAMIC_PARAMS_UNAVAILABLE`, `BRIEF_META_UNPLACED`, `PARSE_ERROR`, `TIER3_UNVERIFIABLE`, `CANVAS_BRIDGE_MANUAL`, `SSR_DRAFT_ROUTE` | `manual`, with the reason quoted in `why` — read the row's `message` for what blocked it. |
227
+
228
+ **A file that talks ABOUT the site is not a source file.** A path is rewritten only when every
229
+ located occurrence was, so a test asserting on the hero line, or a build config quoting the font
230
+ stack, used to hold an already-declared path pending. `*.test.*`, `*.spec.*`, `__tests__/`,
231
+ `__mocks__/` and root-level `*.config.*` are never opened, alongside `node_modules/` and `dist/`.
232
+ A data module under `src/` — `src/site.config.ts` — is not a build config and stays bindable.
233
+
234
+ **`--xray` and the receipt exclude the same things.** The xray measures a BUILT site's visible
235
+ copy and the receipt measures the brief's paths in SOURCE, so they never have the same
236
+ denominator — but neither counts a decorative glyph, a `<script>` or a `<style>` as copy
237
+ (`isDecorativeGlyph` is the one reader), and the two lane rows `CANVAS_BRIDGE_MANUAL` and
238
+ `SSR_DRAFT_ROUTE` ride `paths.pending` without entering the arithmetic above. A path the receipt
239
+ calls pending is copy the xray will still count as unaddressed; a path it calls rewritten is not.
240
+
187
241
  A path counts as rewritten only when EVERY located occurrence of it was. A value rendered by both
188
242
  the home page and a shared header is two places a reader sees it, and converting one of them reads
189
243
  as a finished job.
package/dist/cli.js CHANGED
@@ -5312,6 +5312,138 @@ function assertReceipt(receipt) {
5312
5312
  return receipt;
5313
5313
  }
5314
5314
 
5315
+ // src/fix.ts
5316
+ var MAX_SNIPPET = 160;
5317
+ var MAX_WORDS = 12;
5318
+ var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5319
+ function findLiteral(content, literal) {
5320
+ const words = literal.split(/\s+/).filter(Boolean).slice(0, MAX_WORDS);
5321
+ if (words.length === 0) return null;
5322
+ const pattern = new RegExp(words.map(escapeRegExp2).join("(?:\\s|<[^>]*>)+"), "u");
5323
+ const match = pattern.exec(content);
5324
+ return match ? { index: match.index, length: match[0].length } : null;
5325
+ }
5326
+ function placeOf(content, index) {
5327
+ const before = content.slice(0, index);
5328
+ const line = before.split("\n").length;
5329
+ const start = before.lastIndexOf("\n") + 1;
5330
+ const end = content.indexOf("\n", index);
5331
+ return {
5332
+ line,
5333
+ col: index - start + 1,
5334
+ snippet: content.slice(start, end < 0 ? content.length : end).trim().slice(0, MAX_SNIPPET)
5335
+ };
5336
+ }
5337
+ function fileHead(content) {
5338
+ return { line: 1, snippet: (content ?? "").split("\n", 1)[0].trim().slice(0, MAX_SNIPPET) };
5339
+ }
5340
+ var isMarkdown = (file) => /\.mdx?$/i.test(file);
5341
+ var isDataModule = (file) => /\.(json|ya?ml|[mc]?[jt]s)$/i.test(file) && !/\.d\.ts$/i.test(file);
5342
+ function inDataRegion(content, index) {
5343
+ if (content.startsWith("---")) {
5344
+ const close = content.indexOf("\n---", 3);
5345
+ if (close >= 0 && index < close) return true;
5346
+ }
5347
+ for (const match of content.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
5348
+ const start = match.index + match[0].indexOf(">") + 1;
5349
+ if (index >= start && index < start + match[1].length) return true;
5350
+ }
5351
+ return false;
5352
+ }
5353
+ var ELEMENT_ACTIONS = /* @__PURE__ */ new Set([
5354
+ "wrap-span",
5355
+ "declare-attr",
5356
+ "declare-richtext"
5357
+ ]);
5358
+ function actionFor(reason, input, file, inData) {
5359
+ const chosen = reasonAction(reason, input, file, inData);
5360
+ if (inData && ELEMENT_ACTIONS.has(chosen.action)) {
5361
+ return { action: "bind-data", why: "The copy is a row in this file's own data \u2014 bind that field." };
5362
+ }
5363
+ return chosen;
5364
+ }
5365
+ function reasonAction(reason, input, file, inData) {
5366
+ const richtext = input.kind === "richtext" || input.inline === true;
5367
+ switch (reason) {
5368
+ case "AMBIGUOUS_LITERAL":
5369
+ if (input.routes > 1) {
5370
+ return {
5371
+ action: "manual",
5372
+ why: `${file} renders this value on ${input.routes} routes, so it is shared chrome rather than page copy: promote it to the project Layout (a Layout field is one value for the whole site) and rerun, or give each route its own element.`
5373
+ };
5374
+ }
5375
+ return {
5376
+ action: "declare-attr",
5377
+ why: input.candidates.length > 1 ? `This copy appears in ${input.candidates.join(", ")}; declare the one this route renders.` : "Two paths want this element and nothing says which \u2014 declare the right one by hand."
5378
+ };
5379
+ case "REPEATER_AMBIGUOUS":
5380
+ case "REPEATER_FIXED_LENGTH":
5381
+ return {
5382
+ action: "declare-attr",
5383
+ why: "Declare this row's leaf on the element itself, or rewrite the loop to read the CMS array."
5384
+ };
5385
+ case "IN_EXPRESSION":
5386
+ if (inData) {
5387
+ return { action: "bind-data", why: "The copy is a row in this file's own data \u2014 bind that field." };
5388
+ }
5389
+ return richtext ? { action: "declare-richtext", why: "The container is fed markup \u2014 bind it with the richtext helper." } : { action: "bind-expression", why: "Replace the computed value with the helper's read for this path." };
5390
+ case "DIALECT_UNSUPPORTED":
5391
+ if (isMarkdown(file)) {
5392
+ return {
5393
+ action: "bind-data",
5394
+ why: "This is a content-collection body: bind the element that renders it and make the entry the source."
5395
+ };
5396
+ }
5397
+ return isDataModule(file) ? { action: "bind-data", why: `${file} holds this copy as data \u2014 bind the element that renders it.` } : { action: "manual", why: `No parser here reads ${file}, so the declaration has to be written by hand.` };
5398
+ case "NOT_IN_SOURCE":
5399
+ case "NO_ORIGINAL":
5400
+ return isDataModule(file) || inData ? { action: "bind-data", why: "The template computes this from data \u2014 bind the data field instead of the text." } : {
5401
+ action: "manual",
5402
+ why: "The built page renders this and no source file holds it verbatim; find what formats it."
5403
+ };
5404
+ case "IN_DATA_FILE":
5405
+ case "IMAGE_ASSET_UNRESOLVED":
5406
+ return { action: "bind-data", why: "Bind the data field this value comes from." };
5407
+ case "PROP_TARGET_NOT_FOUND":
5408
+ case "PROP_DRILLED_DEEP":
5409
+ return {
5410
+ action: "wrap-span",
5411
+ why: "The component renders this in a position no attribute reaches \u2014 wrap it in a span of its own."
5412
+ };
5413
+ case "SUBSTRING_ONLY":
5414
+ return { action: "wrap-span", why: "Only part of the element's text is this value \u2014 give it its own span." };
5415
+ case "SUBTREE_NOT_HTML":
5416
+ return { action: "declare-richtext", why: "The value is the element's whole subtree \u2014 bind it as richtext." };
5417
+ default:
5418
+ return { action: "manual", why: `${reason} \u2014 see the receipt's message for what blocked this path.` };
5419
+ }
5420
+ }
5421
+ function fixFor(row, input) {
5422
+ const file = row.file ?? input.candidates[0] ?? "";
5423
+ const content = input.contents.get(file);
5424
+ const at = content ? findLiteral(content, input.literal) : null;
5425
+ const place = at ? placeOf(content, at.index) : { ...fileHead(content), col: void 0 };
5426
+ const { action, why } = actionFor(row.reason, input, file, at !== null && inDataRegion(content, at.index));
5427
+ return {
5428
+ file,
5429
+ line: row.line ?? place.line,
5430
+ ...place.col !== void 0 ? { col: place.col } : {},
5431
+ snippet: place.snippet,
5432
+ action,
5433
+ ...why ? { why } : {}
5434
+ };
5435
+ }
5436
+ function routeCounts(pending) {
5437
+ const routes = /* @__PURE__ */ new Map();
5438
+ for (const row of pending) {
5439
+ const id = `${row.scope}|${row.path}`;
5440
+ const held = routes.get(id) ?? /* @__PURE__ */ new Set();
5441
+ held.add(row.route);
5442
+ routes.set(id, held);
5443
+ }
5444
+ return new Map([...routes].map(([id, set]) => [id, set.size]));
5445
+ }
5446
+
5315
5447
  // src/sites/html.ts
5316
5448
  import { parse as parseHtml } from "parse5";
5317
5449
  var RAW_TAGS = /* @__PURE__ */ new Set(["script", "style", "noscript", "template"]);
@@ -6189,6 +6321,7 @@ import MagicString4 from "magic-string";
6189
6321
  var WIRABLE = ["html", "astro", "jsx"];
6190
6322
  var CONTROLS = /* @__PURE__ */ new Set(["input", "select", "textarea"]);
6191
6323
  var FORM_SCRIPT = `<script>
6324
+ function bcmsPhone(cc,raw,trunk){var d=String(raw||'').trim();if(!d)return '';if(d.charAt(0)==='+'){var i=d.replace(/D/g,'');return i?'+'+i:'';}if(!cc)return d;var n=d.replace(/D/g,'');if(trunk&&n.charAt(0)==='0')n=n.slice(1);return n?'+'+cc+n:'';}
6192
6325
  document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6193
6326
  f.addEventListener('submit',function(e){
6194
6327
  e.preventDefault();
@@ -6204,6 +6337,15 @@ document.querySelectorAll('form[data-bcms-form]').forEach(function(f){
6204
6337
  if(Object.prototype.hasOwnProperty.call(data,k))data[k]=(Array.isArray(data[k])?data[k]:[data[k]]).concat(v);
6205
6338
  else data[k]=v;
6206
6339
  });
6340
+ // Country-picker phone fields post ONE key. The __country select is scaffolding: it is
6341
+ // composed into the field's own value here, AFTER collection, so a showIf that points at a
6342
+ // phone key still compares the raw digits. Then it is removed from the body.
6343
+ f.querySelectorAll('select[name$="__country"]').forEach(function(sel){
6344
+ var k=sel.name.slice(0,-9);
6345
+ var o=sel.options[sel.selectedIndex];
6346
+ data[k]=bcmsPhone(o?(o.getAttribute('data-cc')||''):'',data[k],o?o.getAttribute('data-trunk')==='1':false);
6347
+ delete data[k+'__country'];
6348
+ });
6207
6349
  var body={data:data};
6208
6350
  var tok=f.querySelector('[name="cf-turnstile-response"]');
6209
6351
  if(tok&&tok.value)body['cf-turnstile-response']=tok.value;
@@ -6428,7 +6570,9 @@ var SKIP_DIRS = [
6428
6570
  ".vercel/",
6429
6571
  ".cache/",
6430
6572
  "coverage/",
6431
- "vendor/"
6573
+ "vendor/",
6574
+ "__tests__/",
6575
+ "__mocks__/"
6432
6576
  ];
6433
6577
  var SKIP_FILES = ["package-lock.json", "bun.lock", "bun.lockb", "yarn.lock", "pnpm-lock.yaml"];
6434
6578
  var SOURCE_EXTENSIONS = [
@@ -6466,11 +6610,13 @@ var TEMPLATE_DIRS = /* @__PURE__ */ new Set([
6466
6610
  "_includes",
6467
6611
  "_layouts"
6468
6612
  ]);
6613
+ var isAboutTheSite = (lower) => /\.(test|spec)\.[^./]+$/.test(lower) || /^[^/]*\.config\.[^./]+$/.test(lower);
6469
6614
  function isSourceCandidate(path) {
6470
6615
  const lower = path.toLowerCase();
6471
6616
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
6472
6617
  return false;
6473
6618
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
6619
+ if (isAboutTheSite(lower)) return false;
6474
6620
  if (/(^|\/)[jt]sconfig\.json$/.test(lower)) return true;
6475
6621
  if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
6476
6622
  if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
@@ -8411,7 +8557,7 @@ var ConvertError = class extends Error {
8411
8557
  code;
8412
8558
  };
8413
8559
  var keyOf = (route, scope, path) => scope === "layout" ? `layout::${path}` : `${route}::page::${path}`;
8414
- var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8560
+ var escapeRegExp3 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8415
8561
  var identifierFor2 = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
8416
8562
  var UNPLACED = /* @__PURE__ */ new Set(["NOT_IN_SOURCE", "IN_EXPRESSION"]);
8417
8563
  async function drillProp(ctx, fromFile, component, prop, kind, hop) {
@@ -8758,6 +8904,33 @@ async function convertSources(briefIn, sources, options) {
8758
8904
  else outFiles.push({ path, operation: "modify", content });
8759
8905
  }
8760
8906
  pending.push(...canvas.pending);
8907
+ for (const file of outFiles) if (file.operation === "modify") latest.set(file.path, file.content);
8908
+ const pageFilesOrTemplate = (route, files) => {
8909
+ const all = [...files];
8910
+ const exact = pageFilesFor(route, all);
8911
+ if (exact.length > 0) return exact;
8912
+ const wanted = route.split("/").filter(Boolean);
8913
+ return all.filter((file) => {
8914
+ if (!isDynamicRoute(file)) return false;
8915
+ const pattern = routeOfFile(file)?.split("/").filter(Boolean);
8916
+ if (!pattern) return false;
8917
+ const rest = pattern.some((segment) => segment.startsWith("[..."));
8918
+ if (rest ? wanted.length < pattern.length : wanted.length !== pattern.length) return false;
8919
+ return pattern.every((segment, i) => segment.startsWith("[") || segment === wanted[i]);
8920
+ }).sort();
8921
+ };
8922
+ const routes = routeCounts(pending);
8923
+ for (const row of pending) {
8924
+ const target = targets.get(keyOf(row.route, row.scope, row.path));
8925
+ row.fix = fixFor(row, {
8926
+ literal: target?.literal ?? "",
8927
+ kind: row.kind,
8928
+ ...target?.inline ? { inline: true } : {},
8929
+ candidates: locatedByKey.get(keyOf(row.route, row.scope, row.path))?.files ?? pageFilesOrTemplate(row.route, latest.keys()),
8930
+ contents: latest,
8931
+ routes: routes.get(`${row.scope}|${row.path}`) ?? 1
8932
+ });
8933
+ }
8761
8934
  const receipt = assertReceipt({
8762
8935
  briefDigest: digest,
8763
8936
  paths: {
@@ -8970,13 +9143,13 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8970
9143
  for (const target of sharing) outcomes.set(target.key, missing(literal));
8971
9144
  continue;
8972
9145
  }
8973
- const assigned = sharing.length === 1 ? /* @__PURE__ */ new Map([[sharing[0].key, forLiteral]]) : resolvePositionally(sharing, forLiteral);
8974
- if (!assigned) {
8975
- for (const target of sharing) outcomes.set(target.key, "AMBIGUOUS_LITERAL");
8976
- continue;
8977
- }
9146
+ const resolved = sharing.length === 1 ? { assigned: /* @__PURE__ */ new Map([[sharing[0].key, forLiteral]]), ambiguous: /* @__PURE__ */ new Set() } : resolvePositionally(sharing, forLiteral);
8978
9147
  for (const target of sharing) {
8979
- const mine = assigned.get(target.key) ?? [];
9148
+ if (resolved.ambiguous.has(target.key)) {
9149
+ outcomes.set(target.key, "AMBIGUOUS_LITERAL");
9150
+ continue;
9151
+ }
9152
+ const mine = resolved.assigned.get(target.key) ?? [];
8980
9153
  const rewritable = mine.filter((s) => s.where !== "script" && s.where !== "comment" && !s.partial);
8981
9154
  if (rewritable.length === 0) {
8982
9155
  outcomes.set(
@@ -9321,19 +9494,25 @@ function repeatGroups(targets) {
9321
9494
  }
9322
9495
  function resolvePositionally(targets, sites) {
9323
9496
  const assigned = /* @__PURE__ */ new Map();
9324
- const claimed = /* @__PURE__ */ new Set();
9497
+ const ambiguous = /* @__PURE__ */ new Set();
9498
+ const picked = /* @__PURE__ */ new Map();
9499
+ const wanters = /* @__PURE__ */ new Map();
9325
9500
  for (const target of targets) {
9326
9501
  const locator = target.locator;
9327
- if (!locator) return null;
9328
- const candidates = sites.filter(
9329
- (s) => s.siblingIndex === locator.siblingIndex && isSubsequence(locator.ancestors, s.ancestors)
9330
- );
9331
- const site = locator.repeat ? candidates[locator.repeat.index] : candidates.length === 1 ? candidates[0] : void 0;
9332
- if (!site || claimed.has(site)) return null;
9333
- claimed.add(site);
9334
- assigned.set(target.key, [site]);
9502
+ const candidates = locator ? sites.filter((s) => s.siblingIndex === locator.siblingIndex && isSubsequence(locator.ancestors, s.ancestors)) : [];
9503
+ const site = locator?.repeat ? candidates[locator.repeat.index] : candidates.length === 1 ? candidates[0] : void 0;
9504
+ if (!site) {
9505
+ ambiguous.add(target.key);
9506
+ continue;
9507
+ }
9508
+ picked.set(target.key, site);
9509
+ wanters.set(site, (wanters.get(site) ?? 0) + 1);
9510
+ }
9511
+ for (const [key, site] of picked) {
9512
+ if ((wanters.get(site) ?? 0) > 1) ambiguous.add(key);
9513
+ else assigned.set(key, [site]);
9335
9514
  }
9336
- return assigned;
9515
+ return { assigned, ambiguous };
9337
9516
  }
9338
9517
  function isSubsequence(wanted, chain) {
9339
9518
  let at = 0;
@@ -9356,7 +9535,7 @@ async function tierThree(file, content, dialect, targets, llmFallback) {
9356
9535
  if (elements.length !== targets.length) return { ok: false, reason: "unverifiable" };
9357
9536
  const locals = helperLocals(reparsed, dialect, file);
9358
9537
  const reads = DIALECT_RULES[dialect].text !== null;
9359
- const boundary = (name) => `(?<![\\w$])${escapeRegExp2(name)}(?![\\w$])`;
9538
+ const boundary = (name) => `(?<![\\w$])${escapeRegExp3(name)}(?![\\w$])`;
9360
9539
  const claimed = /* @__PURE__ */ new Set();
9361
9540
  for (const element of elements) {
9362
9541
  const candidates = targets.filter(
@@ -9364,7 +9543,7 @@ async function tierThree(file, content, dialect, targets, llmFallback) {
9364
9543
  );
9365
9544
  const match = candidates.find((target) => {
9366
9545
  if (!reads) return true;
9367
- const quoted = escapeRegExp2(JSON.stringify(target.path));
9546
+ const quoted = escapeRegExp3(JSON.stringify(target.path));
9368
9547
  if (target.scope === "layout") {
9369
9548
  return locals.bcmsLayout.some(
9370
9549
  (name) => new RegExp(`${boundary(name)}\\s*\\(\\s*${quoted}`).test(element.own)
@@ -9426,7 +9605,7 @@ function emitStubs2(dialects, slugs, contents, out) {
9426
9605
  // package.json
9427
9606
  var package_default = {
9428
9607
  name: "@bettercms-ai/convert",
9429
- version: "0.11.0",
9608
+ version: "0.12.1",
9430
9609
  type: "module",
9431
9610
  description: "Deterministic codemod that turns an imported site's text-matched bindings into declared ones \u2014 parser offsets in, one magic-string pass out, plus a receipt of everything it could not do.",
9432
9611
  bin: {
@@ -9466,7 +9645,7 @@ var package_default = {
9466
9645
  },
9467
9646
  devDependencies: {
9468
9647
  "@types/node": "^20",
9469
- esbuild: "^0.27.7",
9648
+ esbuild: "^0.28.1",
9470
9649
  tsup: "^8.5.1",
9471
9650
  typescript: "^5",
9472
9651
  vitest: "^4.1.4"