@bettercms-ai/convert 0.11.0 → 0.12.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.d.ts CHANGED
@@ -197,6 +197,83 @@ declare function locate(brief: BriefLike, sources: SourceFile[], options?: Locat
197
197
  unlocated: UnlocatedPath[];
198
198
  };
199
199
 
200
+ /**
201
+ * What to DO about a path this run could not bind — an instruction, never a category name.
202
+ *
203
+ * 🔴 A REASON IS A DIAGNOSIS AND AN AGENT CANNOT ACT ON ONE. `IN_EXPRESSION` tells a coding agent
204
+ * that something, somewhere, is an expression; it does not say which file, which line, or what to
205
+ * type. So every pending row leaves with a `fix`: one file, one line, the bytes that are there
206
+ * now, and the one edit that makes the path bindable on the next run. The closed `action` set is
207
+ * the contract the server validates (`src/lib/validation/schemas/conversion-receipt.ts`) and the
208
+ * loop `get_next_steps` reads — an unknown action is a 400, so it is minted in exactly one place.
209
+ */
210
+
211
+ /**
212
+ * The edits an agent can be asked for, and nothing else.
213
+ *
214
+ * `wrap-span` the value has no element of its own — wrap it in `<span data-bcms-field>`.
215
+ * `declare-attr` the element is there — add `data-bcms-field` to it at file:line.
216
+ * `declare-richtext` the container renders markup — bind it with the richtext helper.
217
+ * `bind-expression` the value is computed — replace the expression with the helper's read.
218
+ * `bind-data` the copy lives in a data file or a content entry — bind THAT field.
219
+ * `manual` none of the above is true, and `why` says what a human has to decide.
220
+ */
221
+ type FixAction = "wrap-span" | "declare-attr" | "bind-expression" | "declare-richtext" | "bind-data" | "manual";
222
+ /** One mechanical instruction, at one place in one file. @see FixAction */
223
+ interface PendingFix {
224
+ file: string;
225
+ /** 1-based. */
226
+ line: number;
227
+ /** 1-based, when the literal was found on the line. */
228
+ col?: number;
229
+ /** The bytes at `line`, trimmed — so the agent can see it is looking at the right place. */
230
+ snippet: string;
231
+ action: FixAction;
232
+ /** One sentence, present whenever the action alone does not say enough. */
233
+ why?: string;
234
+ }
235
+ /** Everything about one pending path a fix is derived from. Pure input; no filesystem. */
236
+ interface FixInput {
237
+ /** The copy the repository renders, flattened. Empty when the brief captured none. */
238
+ literal: string;
239
+ kind: string;
240
+ /** The richtext value IS the element's whole inline subtree. @see brief.ts */
241
+ inline?: true;
242
+ /** Files this path's copy was found in, best first. May be empty. */
243
+ candidates: string[];
244
+ /** Source, by path — only the files named in `candidates` need be present. */
245
+ contents: ReadonlyMap<string, string>;
246
+ /** How many ROUTES share this `(scope, path)` in the pending list. @see sharedChrome */
247
+ routes: number;
248
+ }
249
+ /**
250
+ * Where `literal` sits in `content` — tolerant of the two things every template does to copy.
251
+ *
252
+ * A template wraps its copy across lines and indents it, and it puts inline markup in the middle
253
+ * of it (`Read <strong>this</strong> now`). Both are invisible to a reader and fatal to
254
+ * `indexOf`, so the words are matched with whitespace OR a tag between them. Bounded at
255
+ * `MAX_WORDS`, because a paragraph's first dozen words identify it as well as all ninety do.
256
+ */
257
+ declare function findLiteral(content: string, literal: string): {
258
+ index: number;
259
+ length: number;
260
+ } | null;
261
+ /**
262
+ * The fix for one pending row. Always answers: a row without one is a row an agent cannot act on.
263
+ *
264
+ * The FILE is the row's own when it has one (the lane that refused named it), and otherwise the
265
+ * first candidate `locate` found — and when there is neither, the fix still names the route's page
266
+ * file with line 1, because "go and look here" beats "go and look".
267
+ */
268
+ declare function fixFor(row: PendingPath, input: FixInput): PendingFix;
269
+ /**
270
+ * How many ROUTES each `(scope, path)` is pending on — the shared-chrome tell. @see actionFor
271
+ *
272
+ * Counted over the whole pending list rather than per row: one nav label pending on twelve routes
273
+ * is one element, and the fix for every one of those rows is the same sentence about promotion.
274
+ */
275
+ declare function routeCounts(pending: readonly PendingPath[]): Map<string, number>;
276
+
200
277
  /**
201
278
  * What the conversion did, and — the half that matters — what it did not.
202
279
  *
@@ -209,6 +286,7 @@ declare function locate(brief: BriefLike, sources: SourceFile[], options?: Locat
209
286
  * sentence appears in three files" and "this field is bound" are different questions — and a path
210
287
  * is only `rewritten` when EVERY located occurrence of it was.
211
288
  */
289
+
212
290
  type PendingReason = "NO_ORIGINAL" | "NOT_IN_SOURCE" | "DIALECT_UNSUPPORTED" | "IN_EXPRESSION" | "IN_SCRIPT_OR_COMMENT" | "IN_DATA_FILE" | "AMBIGUOUS_LITERAL" | "KIND_MISMATCH" | "SUBSTRING_ONLY"
213
291
  /** An INLINE richtext value whose element holds a component, a `<slot>` or an expression. */
214
292
  | "SUBTREE_NOT_HTML" | "PROP_TARGET_NOT_FOUND" | "PROP_DRILLED_DEEP" | "REPEATER_FIXED_LENGTH"
@@ -231,6 +309,15 @@ interface PendingPath {
231
309
  line?: number;
232
310
  /** What to do about it, in prose — a recipe, not a diagnosis. @see message */
233
311
  hint?: string;
312
+ /**
313
+ * The EDIT that makes this path bindable on the next run — one file, one line, one action.
314
+ *
315
+ * 🔴 THE REASON IS A DIAGNOSIS AND NOBODY CAN ACT ON ONE. Every row carries this, because a
316
+ * conversion ends when nothing is pending and the only way an agent gets there is by being told
317
+ * what to type and where. OPTIONAL in the type so a receipt written by an older version of this
318
+ * package still parses; this package emits one for every row. @see fix.ts
319
+ */
320
+ fix?: PendingFix;
234
321
  }
235
322
  /**
236
323
  * One path that was already DECLARED before this run — and how honestly.
@@ -1720,4 +1807,4 @@ declare function convertSources(briefIn: Brief, sources: SourceFile[], options?:
1720
1807
  receipt: ConversionReceipt;
1721
1808
  }>;
1722
1809
 
1723
- export { ASTRO_LANE_PENDING, type AstNode, type AttrBinding, type Brief, type BriefForm, type BriefPage, type BriefPath, COMPONENT_DIR, CONTENT_DIR, type CanvasResult, type CanvasSource, type Carried, type ComponentizeOptions, type ComponentizePlan, type ComponentizePlanComponent, type ComponentizePlanField, type ComponentizePlanPage, type ComponentizePlanSection, type ComponentizeReceipt, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type DataMember, type DataResult, type DeclaredPath, type Dialect, type DynamicBinding, FORM_SCRIPT, type FileDeclaration, type FindSitesResult, FormInvariantError, type FormPendingReason, type FormsReceipt, HELPER_PATH, HELPER_VERSION, type ImageResult, type ImageTarget, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingForm, type PendingPath, type PendingReason, type PendingSection, type PlanFile, REGISTRY_MARKER, type ReceiptFile, ReceiptInvariantError, type ReceiptNote, type Rewrite, SECTIONS_LIB, SECTION_MARKER, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, SSR_DRAFT_RECIPE, STUB_CONTENT, SectionInvariantError, type SectionPendingReason, type SectionsReceipt, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, type ValidationCode, ValidationError, type Xray, type XrayBucket, aliasesFrom, assertForms, assertReceipt, assertSections, assetCandidates, assetImport, attrsFor, bindDataLiterals, bindImages, briefDigest, canvasBridge, carryFor, componentizeSources, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, extendedConfigs, findSites, findSitesTolerant, flat, helperSource, importsIn, isDecorativeGlyph, isDynamicRoute, isKnownHelper, isSourceCandidate, locate, measureUnaddressable, overlapping, pageFilesFor, parseFile, propsAttribute, readBrief, readComponentizePlan, readDeclarations, readExpr, readPlan, relativeImport, resolveSpecifier, rewriteFile, routeOfFile, stripTags, unreadableConfigs, walkAst, wireForms };
1810
+ export { ASTRO_LANE_PENDING, type AstNode, type AttrBinding, type Brief, type BriefForm, type BriefPage, type BriefPath, COMPONENT_DIR, CONTENT_DIR, type CanvasResult, type CanvasSource, type Carried, type ComponentizeOptions, type ComponentizePlan, type ComponentizePlanComponent, type ComponentizePlanField, type ComponentizePlanPage, type ComponentizePlanSection, type ComponentizeReceipt, type ConversionReceipt, ConvertError, type ConvertErrorCode, type ConvertOptions, DIALECT_RULES, type DataMember, type DataResult, type DeclaredPath, type Dialect, type DynamicBinding, FORM_SCRIPT, type FileDeclaration, type FindSitesResult, type FixAction, FormInvariantError, type FormPendingReason, type FormsReceipt, HELPER_PATH, HELPER_VERSION, type ImageResult, type ImageTarget, type ImportedFrom, type LlmFallback, type LocatedPath, type Node, PARSE_FILE, type ParsedFile, type ParserError, type PathLocator, type PendingFix, type PendingForm, type PendingPath, type PendingReason, type PendingSection, type PlanFile, REGISTRY_MARKER, type ReceiptFile, ReceiptInvariantError, type ReceiptNote, type Rewrite, SECTIONS_LIB, SECTION_MARKER, SKIP_DIRS, SKIP_FILES, SOURCE_EXTENSIONS, SSR_DRAFT_RECIPE, STUB_CONTENT, SectionInvariantError, type SectionPendingReason, type SectionsReceipt, type Site, type SiteWhere, type SourceFile, type Splice, type TargetIdentity, type UnlocatedPath, type ValidationCode, ValidationError, type Xray, type XrayBucket, aliasesFrom, assertForms, assertReceipt, assertSections, assetCandidates, assetImport, attrsFor, bindDataLiterals, bindImages, briefDigest, canvasBridge, carryFor, componentizeSources, convertSources, convertedHere, coverageOf, declaringElements, dialectOf, exportedFunction, extendedConfigs, findLiteral, findSites, findSitesTolerant, fixFor, flat, helperSource, importsIn, isDecorativeGlyph, isDynamicRoute, isKnownHelper, isSourceCandidate, locate, measureUnaddressable, overlapping, pageFilesFor, parseFile, propsAttribute, readBrief, readComponentizePlan, readDeclarations, readExpr, readPlan, relativeImport, resolveSpecifier, rewriteFile, routeCounts, routeOfFile, stripTags, unreadableConfigs, walkAst, wireForms };
package/dist/index.js CHANGED
@@ -5318,6 +5318,138 @@ function coverageOf(receipt) {
5318
5318
  };
5319
5319
  }
5320
5320
 
5321
+ // src/fix.ts
5322
+ var MAX_SNIPPET = 160;
5323
+ var MAX_WORDS = 12;
5324
+ var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5325
+ function findLiteral(content, literal) {
5326
+ const words = literal.split(/\s+/).filter(Boolean).slice(0, MAX_WORDS);
5327
+ if (words.length === 0) return null;
5328
+ const pattern = new RegExp(words.map(escapeRegExp2).join("(?:\\s|<[^>]*>)+"), "u");
5329
+ const match = pattern.exec(content);
5330
+ return match ? { index: match.index, length: match[0].length } : null;
5331
+ }
5332
+ function placeOf(content, index) {
5333
+ const before = content.slice(0, index);
5334
+ const line = before.split("\n").length;
5335
+ const start = before.lastIndexOf("\n") + 1;
5336
+ const end = content.indexOf("\n", index);
5337
+ return {
5338
+ line,
5339
+ col: index - start + 1,
5340
+ snippet: content.slice(start, end < 0 ? content.length : end).trim().slice(0, MAX_SNIPPET)
5341
+ };
5342
+ }
5343
+ function fileHead(content) {
5344
+ return { line: 1, snippet: (content ?? "").split("\n", 1)[0].trim().slice(0, MAX_SNIPPET) };
5345
+ }
5346
+ var isMarkdown = (file) => /\.mdx?$/i.test(file);
5347
+ var isDataModule = (file) => /\.(json|ya?ml|[mc]?[jt]s)$/i.test(file) && !/\.d\.ts$/i.test(file);
5348
+ function inDataRegion(content, index) {
5349
+ if (content.startsWith("---")) {
5350
+ const close = content.indexOf("\n---", 3);
5351
+ if (close >= 0 && index < close) return true;
5352
+ }
5353
+ for (const match of content.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
5354
+ const start = match.index + match[0].indexOf(">") + 1;
5355
+ if (index >= start && index < start + match[1].length) return true;
5356
+ }
5357
+ return false;
5358
+ }
5359
+ var ELEMENT_ACTIONS = /* @__PURE__ */ new Set([
5360
+ "wrap-span",
5361
+ "declare-attr",
5362
+ "declare-richtext"
5363
+ ]);
5364
+ function actionFor(reason, input, file, inData) {
5365
+ const chosen = reasonAction(reason, input, file, inData);
5366
+ if (inData && ELEMENT_ACTIONS.has(chosen.action)) {
5367
+ return { action: "bind-data", why: "The copy is a row in this file's own data \u2014 bind that field." };
5368
+ }
5369
+ return chosen;
5370
+ }
5371
+ function reasonAction(reason, input, file, inData) {
5372
+ const richtext = input.kind === "richtext" || input.inline === true;
5373
+ switch (reason) {
5374
+ case "AMBIGUOUS_LITERAL":
5375
+ if (input.routes > 1) {
5376
+ return {
5377
+ action: "manual",
5378
+ 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.`
5379
+ };
5380
+ }
5381
+ return {
5382
+ action: "declare-attr",
5383
+ 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."
5384
+ };
5385
+ case "REPEATER_AMBIGUOUS":
5386
+ case "REPEATER_FIXED_LENGTH":
5387
+ return {
5388
+ action: "declare-attr",
5389
+ why: "Declare this row's leaf on the element itself, or rewrite the loop to read the CMS array."
5390
+ };
5391
+ case "IN_EXPRESSION":
5392
+ if (inData) {
5393
+ return { action: "bind-data", why: "The copy is a row in this file's own data \u2014 bind that field." };
5394
+ }
5395
+ 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." };
5396
+ case "DIALECT_UNSUPPORTED":
5397
+ if (isMarkdown(file)) {
5398
+ return {
5399
+ action: "bind-data",
5400
+ why: "This is a content-collection body: bind the element that renders it and make the entry the source."
5401
+ };
5402
+ }
5403
+ 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.` };
5404
+ case "NOT_IN_SOURCE":
5405
+ case "NO_ORIGINAL":
5406
+ return isDataModule(file) || inData ? { action: "bind-data", why: "The template computes this from data \u2014 bind the data field instead of the text." } : {
5407
+ action: "manual",
5408
+ why: "The built page renders this and no source file holds it verbatim; find what formats it."
5409
+ };
5410
+ case "IN_DATA_FILE":
5411
+ case "IMAGE_ASSET_UNRESOLVED":
5412
+ return { action: "bind-data", why: "Bind the data field this value comes from." };
5413
+ case "PROP_TARGET_NOT_FOUND":
5414
+ case "PROP_DRILLED_DEEP":
5415
+ return {
5416
+ action: "wrap-span",
5417
+ why: "The component renders this in a position no attribute reaches \u2014 wrap it in a span of its own."
5418
+ };
5419
+ case "SUBSTRING_ONLY":
5420
+ return { action: "wrap-span", why: "Only part of the element's text is this value \u2014 give it its own span." };
5421
+ case "SUBTREE_NOT_HTML":
5422
+ return { action: "declare-richtext", why: "The value is the element's whole subtree \u2014 bind it as richtext." };
5423
+ default:
5424
+ return { action: "manual", why: `${reason} \u2014 see the receipt's message for what blocked this path.` };
5425
+ }
5426
+ }
5427
+ function fixFor(row, input) {
5428
+ const file = row.file ?? input.candidates[0] ?? "";
5429
+ const content = input.contents.get(file);
5430
+ const at = content ? findLiteral(content, input.literal) : null;
5431
+ const place = at ? placeOf(content, at.index) : { ...fileHead(content), col: void 0 };
5432
+ const { action, why } = actionFor(row.reason, input, file, at !== null && inDataRegion(content, at.index));
5433
+ return {
5434
+ file,
5435
+ line: row.line ?? place.line,
5436
+ ...place.col !== void 0 ? { col: place.col } : {},
5437
+ snippet: place.snippet,
5438
+ action,
5439
+ ...why ? { why } : {}
5440
+ };
5441
+ }
5442
+ function routeCounts(pending) {
5443
+ const routes = /* @__PURE__ */ new Map();
5444
+ for (const row of pending) {
5445
+ const id = `${row.scope}|${row.path}`;
5446
+ const held = routes.get(id) ?? /* @__PURE__ */ new Set();
5447
+ held.add(row.route);
5448
+ routes.set(id, held);
5449
+ }
5450
+ return new Map([...routes].map(([id, set]) => [id, set.size]));
5451
+ }
5452
+
5321
5453
  // src/sites/html.ts
5322
5454
  import { parse as parseHtml } from "parse5";
5323
5455
  var RAW_TAGS = /* @__PURE__ */ new Set(["script", "style", "noscript", "template"]);
@@ -6438,7 +6570,9 @@ var SKIP_DIRS = [
6438
6570
  ".vercel/",
6439
6571
  ".cache/",
6440
6572
  "coverage/",
6441
- "vendor/"
6573
+ "vendor/",
6574
+ "__tests__/",
6575
+ "__mocks__/"
6442
6576
  ];
6443
6577
  var SKIP_FILES = ["package-lock.json", "bun.lock", "bun.lockb", "yarn.lock", "pnpm-lock.yaml"];
6444
6578
  var SOURCE_EXTENSIONS = [
@@ -6476,11 +6610,13 @@ var TEMPLATE_DIRS = /* @__PURE__ */ new Set([
6476
6610
  "_includes",
6477
6611
  "_layouts"
6478
6612
  ]);
6613
+ var isAboutTheSite = (lower) => /\.(test|spec)\.[^./]+$/.test(lower) || /^[^/]*\.config\.[^./]+$/.test(lower);
6479
6614
  function isSourceCandidate(path) {
6480
6615
  const lower = path.toLowerCase();
6481
6616
  if (SKIP_DIRS.some((dir) => lower === dir.slice(0, -1) || lower.startsWith(dir) || lower.includes(`/${dir}`)))
6482
6617
  return false;
6483
6618
  if (SKIP_FILES.includes(lower.split("/").pop() ?? "")) return false;
6619
+ if (isAboutTheSite(lower)) return false;
6484
6620
  if (/(^|\/)[jt]sconfig\.json$/.test(lower)) return true;
6485
6621
  if (!SOURCE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return false;
6486
6622
  if (!PROSE_EXTENSIONS.some((ext) => lower.endsWith(ext))) return true;
@@ -8429,7 +8565,7 @@ var ConvertError = class extends Error {
8429
8565
  code;
8430
8566
  };
8431
8567
  var keyOf = (route, scope, path) => scope === "layout" ? `layout::${path}` : `${route}::page::${path}`;
8432
- var escapeRegExp2 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8568
+ var escapeRegExp3 = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8433
8569
  var identifierFor2 = (slug) => `bcms${slug.replace(/[^A-Za-z0-9]+(.)?/g, (_, c) => c ? c.toUpperCase() : "").replace(/^./, (c) => c.toUpperCase())}`;
8434
8570
  var UNPLACED = /* @__PURE__ */ new Set(["NOT_IN_SOURCE", "IN_EXPRESSION"]);
8435
8571
  async function drillProp(ctx, fromFile, component, prop, kind, hop) {
@@ -8776,6 +8912,33 @@ async function convertSources(briefIn, sources, options) {
8776
8912
  else outFiles.push({ path, operation: "modify", content });
8777
8913
  }
8778
8914
  pending.push(...canvas.pending);
8915
+ for (const file of outFiles) if (file.operation === "modify") latest.set(file.path, file.content);
8916
+ const pageFilesOrTemplate = (route, files) => {
8917
+ const all = [...files];
8918
+ const exact = pageFilesFor(route, all);
8919
+ if (exact.length > 0) return exact;
8920
+ const wanted = route.split("/").filter(Boolean);
8921
+ return all.filter((file) => {
8922
+ if (!isDynamicRoute(file)) return false;
8923
+ const pattern = routeOfFile(file)?.split("/").filter(Boolean);
8924
+ if (!pattern) return false;
8925
+ const rest = pattern.some((segment) => segment.startsWith("[..."));
8926
+ if (rest ? wanted.length < pattern.length : wanted.length !== pattern.length) return false;
8927
+ return pattern.every((segment, i) => segment.startsWith("[") || segment === wanted[i]);
8928
+ }).sort();
8929
+ };
8930
+ const routes = routeCounts(pending);
8931
+ for (const row of pending) {
8932
+ const target = targets.get(keyOf(row.route, row.scope, row.path));
8933
+ row.fix = fixFor(row, {
8934
+ literal: target?.literal ?? "",
8935
+ kind: row.kind,
8936
+ ...target?.inline ? { inline: true } : {},
8937
+ candidates: locatedByKey.get(keyOf(row.route, row.scope, row.path))?.files ?? pageFilesOrTemplate(row.route, latest.keys()),
8938
+ contents: latest,
8939
+ routes: routes.get(`${row.scope}|${row.path}`) ?? 1
8940
+ });
8941
+ }
8779
8942
  const receipt = assertReceipt({
8780
8943
  briefDigest: digest,
8781
8944
  paths: {
@@ -8988,13 +9151,13 @@ async function plan(file, content, dialect, targets, sites, importAt, outcomes,
8988
9151
  for (const target of sharing) outcomes.set(target.key, missing(literal));
8989
9152
  continue;
8990
9153
  }
8991
- const assigned = sharing.length === 1 ? /* @__PURE__ */ new Map([[sharing[0].key, forLiteral]]) : resolvePositionally(sharing, forLiteral);
8992
- if (!assigned) {
8993
- for (const target of sharing) outcomes.set(target.key, "AMBIGUOUS_LITERAL");
8994
- continue;
8995
- }
9154
+ const resolved = sharing.length === 1 ? { assigned: /* @__PURE__ */ new Map([[sharing[0].key, forLiteral]]), ambiguous: /* @__PURE__ */ new Set() } : resolvePositionally(sharing, forLiteral);
8996
9155
  for (const target of sharing) {
8997
- const mine = assigned.get(target.key) ?? [];
9156
+ if (resolved.ambiguous.has(target.key)) {
9157
+ outcomes.set(target.key, "AMBIGUOUS_LITERAL");
9158
+ continue;
9159
+ }
9160
+ const mine = resolved.assigned.get(target.key) ?? [];
8998
9161
  const rewritable = mine.filter((s) => s.where !== "script" && s.where !== "comment" && !s.partial);
8999
9162
  if (rewritable.length === 0) {
9000
9163
  outcomes.set(
@@ -9339,19 +9502,25 @@ function repeatGroups(targets) {
9339
9502
  }
9340
9503
  function resolvePositionally(targets, sites) {
9341
9504
  const assigned = /* @__PURE__ */ new Map();
9342
- const claimed = /* @__PURE__ */ new Set();
9505
+ const ambiguous = /* @__PURE__ */ new Set();
9506
+ const picked = /* @__PURE__ */ new Map();
9507
+ const wanters = /* @__PURE__ */ new Map();
9343
9508
  for (const target of targets) {
9344
9509
  const locator = target.locator;
9345
- if (!locator) return null;
9346
- const candidates = sites.filter(
9347
- (s) => s.siblingIndex === locator.siblingIndex && isSubsequence(locator.ancestors, s.ancestors)
9348
- );
9349
- const site = locator.repeat ? candidates[locator.repeat.index] : candidates.length === 1 ? candidates[0] : void 0;
9350
- if (!site || claimed.has(site)) return null;
9351
- claimed.add(site);
9352
- assigned.set(target.key, [site]);
9510
+ const candidates = locator ? sites.filter((s) => s.siblingIndex === locator.siblingIndex && isSubsequence(locator.ancestors, s.ancestors)) : [];
9511
+ const site = locator?.repeat ? candidates[locator.repeat.index] : candidates.length === 1 ? candidates[0] : void 0;
9512
+ if (!site) {
9513
+ ambiguous.add(target.key);
9514
+ continue;
9515
+ }
9516
+ picked.set(target.key, site);
9517
+ wanters.set(site, (wanters.get(site) ?? 0) + 1);
9518
+ }
9519
+ for (const [key, site] of picked) {
9520
+ if ((wanters.get(site) ?? 0) > 1) ambiguous.add(key);
9521
+ else assigned.set(key, [site]);
9353
9522
  }
9354
- return assigned;
9523
+ return { assigned, ambiguous };
9355
9524
  }
9356
9525
  function isSubsequence(wanted, chain) {
9357
9526
  let at = 0;
@@ -9374,7 +9543,7 @@ async function tierThree(file, content, dialect, targets, llmFallback) {
9374
9543
  if (elements.length !== targets.length) return { ok: false, reason: "unverifiable" };
9375
9544
  const locals = helperLocals(reparsed, dialect, file);
9376
9545
  const reads = DIALECT_RULES[dialect].text !== null;
9377
- const boundary = (name) => `(?<![\\w$])${escapeRegExp2(name)}(?![\\w$])`;
9546
+ const boundary = (name) => `(?<![\\w$])${escapeRegExp3(name)}(?![\\w$])`;
9378
9547
  const claimed = /* @__PURE__ */ new Set();
9379
9548
  for (const element of elements) {
9380
9549
  const candidates = targets.filter(
@@ -9382,7 +9551,7 @@ async function tierThree(file, content, dialect, targets, llmFallback) {
9382
9551
  );
9383
9552
  const match = candidates.find((target) => {
9384
9553
  if (!reads) return true;
9385
- const quoted = escapeRegExp2(JSON.stringify(target.path));
9554
+ const quoted = escapeRegExp3(JSON.stringify(target.path));
9386
9555
  if (target.scope === "layout") {
9387
9556
  return locals.bcmsLayout.some(
9388
9557
  (name) => new RegExp(`${boundary(name)}\\s*\\(\\s*${quoted}`).test(element.own)
@@ -9482,8 +9651,10 @@ export {
9482
9651
  dialectOf,
9483
9652
  exportedFunction,
9484
9653
  extendedConfigs,
9654
+ findLiteral,
9485
9655
  findSites,
9486
9656
  findSitesTolerant,
9657
+ fixFor,
9487
9658
  flat,
9488
9659
  helperSource,
9489
9660
  importsIn,
@@ -9505,6 +9676,7 @@ export {
9505
9676
  relativeImport,
9506
9677
  resolveSpecifier,
9507
9678
  rewriteFile,
9679
+ routeCounts,
9508
9680
  routeOfFile,
9509
9681
  stripTags,
9510
9682
  unreadableConfigs,