@getrefino/onboarding 0.1.0-rc.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/dist/scan.js ADDED
@@ -0,0 +1,1486 @@
1
+ /**
2
+ * Visible-copy discovery. Walks React source with the TypeScript parser and
3
+ * collects string candidates from JSX text, literal JSX children, display
4
+ * attributes and data arrays that feed JSX. Never mutates anything.
5
+ *
6
+ * Every candidate gets a classification and a reason, so a human or an
7
+ * agent can see why something was or was not selected.
8
+ */
9
+ import ts from "typescript";
10
+ import { isTestOrStoryFile, readText, rel, walkFiles } from "./fs.js";
11
+ import { appDirectory } from "./inspect.js";
12
+ import { computeFileScopes, parseSource, readPathAliases, resolveImport } from "./graph.js";
13
+ import { arrayOfStrings, callbackRendersItem, componentRenderInfo, createDataflowContext, enclosingScope, fieldNameOf, findBinding, literalText, mapCallbackOf, recordOfStringArrays, resolveComponent, stateLiterals, } from "./dataflow.js";
14
+ import { pathSegment, proposeIds, slugFromText } from "./ids.js";
15
+ const DISPLAY_PROPS = new Set([
16
+ "title",
17
+ "subtitle",
18
+ "heading",
19
+ "subheading",
20
+ "description",
21
+ "label",
22
+ "text",
23
+ "tagline",
24
+ "caption",
25
+ "cta",
26
+ "ctaText",
27
+ "ctaLabel",
28
+ "buttonText",
29
+ "buttonLabel",
30
+ "eyebrow",
31
+ "quote",
32
+ "body",
33
+ "summary",
34
+ "message",
35
+ "question",
36
+ "answer",
37
+ "name",
38
+ "role",
39
+ "author",
40
+ "badge",
41
+ "note",
42
+ "lead",
43
+ "intro",
44
+ "kicker",
45
+ "overline",
46
+ "blurb",
47
+ "excerpt",
48
+ "headline",
49
+ "subheadline",
50
+ "footnote",
51
+ "disclaimer",
52
+ "hint",
53
+ ]);
54
+ /** Elements whose first short label names everything inside them (a card, a figure, a list item). */
55
+ const ITEM_CONTAINER_TAGS = new Set(["figure", "li", "article"]);
56
+ const ACCESSIBILITY_PROPS = new Set(["alt", "aria-label", "aria-description", "placeholder"]);
57
+ const DEVELOPER_TAGS = new Set(["code", "pre", "kbd", "samp", "script", "style", "var"]);
58
+ const LANDMARK_TAGS = new Set(["section", "header", "footer", "nav", "main", "aside", "article", "form", "dialog"]);
59
+ const UI_WORDS = new Set([
60
+ "ok",
61
+ "cancel",
62
+ "close",
63
+ "submit",
64
+ "save",
65
+ "delete",
66
+ "edit",
67
+ "loading",
68
+ "loading...",
69
+ "loading…",
70
+ "error",
71
+ "retry",
72
+ "back",
73
+ "next",
74
+ "previous",
75
+ "prev",
76
+ "yes",
77
+ "no",
78
+ "menu",
79
+ "open menu",
80
+ "close menu",
81
+ "toggle menu",
82
+ "search",
83
+ "skip to content",
84
+ "skip to main content",
85
+ "×",
86
+ "✕",
87
+ ]);
88
+ function collapse(text) {
89
+ return text.replace(/\s+/g, " ").trim();
90
+ }
91
+ const NAMED_ENTITIES = {
92
+ amp: "&",
93
+ lt: "<",
94
+ gt: ">",
95
+ quot: '"',
96
+ apos: "'",
97
+ nbsp: "\u00a0",
98
+ ensp: "\u2002",
99
+ emsp: "\u2003",
100
+ thinsp: "\u2009",
101
+ shy: "",
102
+ lsquo: "\u2018",
103
+ rsquo: "\u2019",
104
+ ldquo: "\u201c",
105
+ rdquo: "\u201d",
106
+ hellip: "\u2026",
107
+ ndash: "\u2013",
108
+ mdash: "\u2014",
109
+ middot: "\u00b7",
110
+ bull: "\u2022",
111
+ copy: "\u00a9",
112
+ reg: "\u00ae",
113
+ trade: "\u2122",
114
+ times: "\u00d7",
115
+ laquo: "\u00ab",
116
+ raquo: "\u00bb",
117
+ deg: "\u00b0",
118
+ euro: "\u20ac",
119
+ pound: "\u00a3",
120
+ };
121
+ /** JSX text keeps entities as written (`&amp;`); the copy file must hold the real characters. */
122
+ export function decodeJsxEntities(text) {
123
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (match, body) => {
124
+ if (body[0] === "#") {
125
+ const code = body[1]?.toLowerCase() === "x" ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
126
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match;
127
+ }
128
+ return NAMED_ENTITIES[body] ?? match;
129
+ });
130
+ }
131
+ const UTILITY_KEYWORDS = new Set([
132
+ "flex", "grid", "block", "inline", "inline-block", "inline-flex", "hidden", "contents", "flow-root", "table",
133
+ "relative", "absolute", "fixed", "sticky", "static", "isolate", "container", "group", "peer",
134
+ "truncate", "antialiased", "italic", "uppercase", "lowercase", "capitalize", "invisible", "visible",
135
+ "sr-only", "not-sr-only", "underline", "overline", "line-through", "no-underline", "shadow", "border", "rounded",
136
+ "transition", "shrink-0", "grow", "shrink", "outline-none", "resize", "appearance-none",
137
+ ]);
138
+ const UTILITY_PREFIX = /^-?(p|m|px|py|pt|pb|pl|pr|ps|pe|mx|my|mt|mb|ml|mr|ms|me|w|h|min-w|min-h|max-w|max-h|size|gap|gap-x|gap-y|space-x|space-y|inset|top|left|right|bottom|start|end|z|text|bg|border|border-[trblxy]|rounded|rounded-[trbl]{1,2}|shadow|opacity|font|leading|tracking|flex|grid|grid-cols|grid-rows|col|row|col-span|row-span|order|basis|overflow|object|aspect|scroll-[mp][trblxy]?|divide|divide-[xy]|ring|ring-offset|outline|decoration|underline-offset|translate|translate-[xy]|rotate|scale|skew|origin|duration|delay|ease|cursor|select|pointer-events|place-(items|content|self)|items|justify|content|self|columns|break|break-(inside|before|after)|whitespace|list|align|fill|stroke|blur|backdrop|animate|will-change|accent|caret|from|via|to|line-clamp|indent|hyphens|mix-blend)(-|$)/;
139
+ /** Tailwind-style utility tokens (`sticky`, `py-24`, `lg:grid-cols-2`, `text-[1rem]`) never name a section. */
140
+ export function isUtilityClass(token) {
141
+ if (token.includes(":") || token.includes("[") || token.includes("/"))
142
+ return true;
143
+ if (UTILITY_KEYWORDS.has(token))
144
+ return true;
145
+ if (/^-?[a-z]+(-[a-z0-9.]+)*-\d+(\.\d+)?$/.test(token))
146
+ return true;
147
+ return UTILITY_PREFIX.test(token);
148
+ }
149
+ const SENTENCE_END = /[.!?\u2026]$/u;
150
+ const WORD_WITH_JOINERS = /^\p{L}+(?:[-'\u2019]\p{L}+)*$/u;
151
+ /**
152
+ * One word a person reads rather than an identifier: "Dinner.", "Really?",
153
+ * "Kid-friendly". A lowercase hyphenated token ("lunchbox-guides", "text-sm")
154
+ * is still a slug, so it stays out.
155
+ */
156
+ function isProseWord(text) {
157
+ const bare = text.replace(SENTENCE_END, "");
158
+ if (!WORD_WITH_JOINERS.test(bare))
159
+ return false;
160
+ if (SENTENCE_END.test(text))
161
+ return true;
162
+ return /[-'\u2019]/u.test(bare) && /\p{Lu}/u.test(bare);
163
+ }
164
+ function looksLikeCode(text) {
165
+ if (/^[A-Z0-9_]{3,}$/.test(text) && !/\s/.test(text))
166
+ return true; // ENV_VAR
167
+ if (/^(https?:)?\/\//.test(text) || /^\/[\w./-]*$/.test(text))
168
+ return true; // url or route
169
+ if (/^[\w-]+\.(png|jpe?g|svg|gif|webp|ico|css|js|ts|json|md)$/i.test(text))
170
+ return true;
171
+ if (!/\s/.test(text) && /[-_./:{}[\]()<>=]/.test(text) && text.length > 2 && !isProseWord(text))
172
+ return true; // class names, paths, keys
173
+ if (!/\s/.test(text) && /^[a-z]+[A-Z]/.test(text))
174
+ return true; // camelCase identifier
175
+ if (/^#[0-9a-f]{3,8}$/i.test(text))
176
+ return true;
177
+ if (/^\$?\d+(\.\d+)?%?$/.test(text) && text.length < 3)
178
+ return true;
179
+ return false;
180
+ }
181
+ function isPunctuationOnly(text) {
182
+ return !/[\p{L}\p{N}]/u.test(text);
183
+ }
184
+ function hasLetters(text) {
185
+ return /\p{L}/u.test(text);
186
+ }
187
+ function tagName(node) {
188
+ const name = node.tagName;
189
+ if (ts.isIdentifier(name))
190
+ return name.text;
191
+ if (ts.isPropertyAccessExpression(name))
192
+ return name.getText();
193
+ return name.getText();
194
+ }
195
+ function attributeString(node, name) {
196
+ for (const attr of node.attributes.properties) {
197
+ if (!ts.isJsxAttribute(attr) || !ts.isIdentifier(attr.name) || attr.name.text !== name)
198
+ continue;
199
+ if (!attr.initializer)
200
+ return null;
201
+ if (ts.isStringLiteral(attr.initializer))
202
+ return attr.initializer.text;
203
+ if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
204
+ const expression = attr.initializer.expression;
205
+ if (ts.isStringLiteral(expression) || ts.isNoSubstitutionTemplateLiteral(expression))
206
+ return expression.text;
207
+ if (ts.isTemplateExpression(expression))
208
+ return expression.head.text;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+ function attributeIsTrue(node, name) {
214
+ const value = attributeString(node, name);
215
+ if (value === "true")
216
+ return true;
217
+ for (const attr of node.attributes.properties) {
218
+ if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name && !attr.initializer)
219
+ return true;
220
+ if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name && attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression?.kind === ts.SyntaxKind.TrueKeyword)
221
+ return true;
222
+ }
223
+ return false;
224
+ }
225
+ function enclosingComponentName(node) {
226
+ let current = node;
227
+ while (current) {
228
+ if (ts.isFunctionDeclaration(current) && current.name)
229
+ return current.name.text;
230
+ if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && current.parent && ts.isVariableDeclaration(current.parent) && ts.isIdentifier(current.parent.name)) {
231
+ return current.parent.name.text;
232
+ }
233
+ if (ts.isClassDeclaration(current) && current.name)
234
+ return current.name.text;
235
+ current = current.parent;
236
+ }
237
+ return null;
238
+ }
239
+ function insideMapCallback(node) {
240
+ let current = node;
241
+ while (current) {
242
+ if ((ts.isArrowFunction(current) || ts.isFunctionExpression(current)) && current.parent && ts.isCallExpression(current.parent)) {
243
+ const callee = current.parent.expression;
244
+ if (ts.isPropertyAccessExpression(callee) && ["map", "flatMap", "forEach"].includes(callee.name.text))
245
+ return true;
246
+ }
247
+ current = current.parent;
248
+ }
249
+ return false;
250
+ }
251
+ function jsxAncestors(node) {
252
+ const result = [];
253
+ let current = node.parent;
254
+ while (current && !ts.isFunctionLike(current) && !ts.isSourceFile(current)) {
255
+ if (ts.isJsxElement(current))
256
+ result.unshift({ tag: tagName(current.openingElement), node: current.openingElement });
257
+ else if (ts.isJsxSelfClosingElement(current))
258
+ result.unshift({ tag: tagName(current), node: current });
259
+ current = current.parent;
260
+ }
261
+ return result;
262
+ }
263
+ function landmarkFor(ancestors) {
264
+ for (let index = ancestors.length - 1; index >= 0; index -= 1) {
265
+ const { tag, node } = ancestors[index];
266
+ const id = attributeString(node, "id");
267
+ if (id && LANDMARK_TAGS.has(tag))
268
+ return id;
269
+ if (id && index < ancestors.length - 1 && tag === "div")
270
+ return id;
271
+ if (LANDMARK_TAGS.has(tag)) {
272
+ const className = attributeString(node, "className") ?? attributeString(node, "class");
273
+ const token = className
274
+ ?.split(/\s+/)
275
+ .filter(Boolean)
276
+ .map((entry) => entry.split(/__|--/)[0])
277
+ .find((entry) => !isUtilityClass(entry) && !/^(container|wrapper|inner|content|row|col|section)$/.test(entry));
278
+ if (token)
279
+ return token;
280
+ if (tag !== "main" && tag !== "form")
281
+ return tag;
282
+ }
283
+ }
284
+ return null;
285
+ }
286
+ function metadataContext(node) {
287
+ let current = node;
288
+ while (current) {
289
+ if (ts.isVariableDeclaration(current) && ts.isIdentifier(current.name) && /^(metadata|viewport)$/.test(current.name.text))
290
+ return true;
291
+ if (ts.isFunctionDeclaration(current) && current.name && /^generate(Metadata|Viewport)$/.test(current.name.text))
292
+ return true;
293
+ current = current.parent;
294
+ }
295
+ return false;
296
+ }
297
+ function jsxElementText(element) {
298
+ const parts = [];
299
+ for (const child of element.children) {
300
+ if (ts.isJsxText(child))
301
+ parts.push(child.text);
302
+ else if (ts.isJsxExpression(child) && child.expression && (ts.isStringLiteral(child.expression) || ts.isNoSubstitutionTemplateLiteral(child.expression)))
303
+ parts.push(child.expression.text);
304
+ else
305
+ return null;
306
+ }
307
+ const text = collapse(parts.join(" "));
308
+ return text || null;
309
+ }
310
+ /** For a <dd>, the text of the <dt> right before it; for a <dt>, its own text. */
311
+ function pairTitleFor(elementNode, tag) {
312
+ if (!elementNode || !ts.isJsxElement(elementNode))
313
+ return null;
314
+ if (tag === "dt" || tag === "summary")
315
+ return jsxElementText(elementNode);
316
+ if (tag !== "dd")
317
+ return null;
318
+ const parent = elementNode.parent;
319
+ if (!parent || !ts.isJsxElement(parent))
320
+ return null;
321
+ let previous = null;
322
+ for (const child of parent.children) {
323
+ if (child === elementNode)
324
+ break;
325
+ if (ts.isJsxElement(child))
326
+ previous = child;
327
+ }
328
+ return previous && tagName(previous.openingElement) === "dt" ? jsxElementText(previous) : null;
329
+ }
330
+ const SITE_PATH = /^\/[\w./#-]*$/;
331
+ /** A literal `href`/`to` that points inside the site ("/pricing", "/#features"), else null. */
332
+ function sitePathOf(node) {
333
+ if (!node)
334
+ return null;
335
+ const value = attributeString(node, "href") ?? attributeString(node, "to");
336
+ return value && SITE_PATH.test(value) ? value : null;
337
+ }
338
+ function isLabelElement(tag, className) {
339
+ if (/^(h[1-6]|dt|strong|b|summary)$/.test(tag))
340
+ return true;
341
+ if (/^(Eyebrow|Kicker|Overline|Heading|Title|Label)$/.test(tag))
342
+ return true;
343
+ return className !== null && /(^|[\s_-])(eyebrow|kicker|overline|label|title|heading)([\s_-]|$)/i.test(className);
344
+ }
345
+ function isShortLabelText(text) {
346
+ return text.split(/\s+/).length <= 4 && text.length <= 40;
347
+ }
348
+ /** The first short label text directly inside `element` (its own heading, eyebrow or bold lead-in), and how many texts it holds. */
349
+ function firstLabelIn(element) {
350
+ let label = null;
351
+ let texts = 0;
352
+ const visit = (node) => {
353
+ if (ts.isJsxText(node) && !node.containsOnlyTriviaWhiteSpaces) {
354
+ const text = collapse(decodeJsxEntities(node.text));
355
+ if (!hasLetters(text))
356
+ return;
357
+ texts += 1;
358
+ if (label === null && node.parent && ts.isJsxElement(node.parent) && isShortLabelText(text)) {
359
+ const opening = node.parent.openingElement;
360
+ if (isLabelElement(tagName(opening), attributeString(opening, "className") ?? attributeString(opening, "class")))
361
+ label = text;
362
+ }
363
+ }
364
+ ts.forEachChild(node, visit);
365
+ };
366
+ visit(element);
367
+ return { label, texts };
368
+ }
369
+ /** Same-tag siblings of `element` under its parent that are JSX elements. */
370
+ function elementSiblings(element) {
371
+ const parent = element.parent;
372
+ if (!parent || !ts.isJsxElement(parent))
373
+ return [element];
374
+ const tag = tagName(element.openingElement);
375
+ return parent.children.filter((child) => ts.isJsxElement(child) && tagName(child.openingElement) === tag);
376
+ }
377
+ /**
378
+ * For text inside an item container, the container's first short label (a
379
+ * heading, eyebrow or bold lead-in) names the whole item:
380
+ * `home.story.howYouFeel.body` instead of `body3`. A container is a figure,
381
+ * list item or article, or any element that is one of at least two same-tag
382
+ * siblings that each open with such a label (a row of feature cards built
383
+ * from divs). Never inside a map callback: mapped items are named by their data.
384
+ */
385
+ function containerLabelFor(textNode) {
386
+ let current = textNode.parent;
387
+ while (current && !ts.isFunctionLike(current) && !ts.isSourceFile(current)) {
388
+ if (ts.isJsxElement(current)) {
389
+ if (insideMapCallback(current))
390
+ return null;
391
+ const tag = tagName(current.openingElement);
392
+ if (ITEM_CONTAINER_TAGS.has(tag)) {
393
+ const { label, texts } = firstLabelIn(current);
394
+ return texts >= 2 ? label : null;
395
+ }
396
+ const siblings = elementSiblings(current);
397
+ if (siblings.length >= 2) {
398
+ const groups = siblings.map((sibling) => firstLabelIn(sibling));
399
+ const own = groups[siblings.indexOf(current)];
400
+ // Every sibling must be a labelled group holding more than its label; then the structure is a repeated card/step pattern.
401
+ if (own.label && own.texts >= 2 && groups.every((group) => group.label !== null && group.texts >= 2))
402
+ return own.label;
403
+ }
404
+ }
405
+ current = current.parent;
406
+ }
407
+ return null;
408
+ }
409
+ /**
410
+ * Heading-then-content flow without wrappers (`<h3>Guides</h3><p>…</p><h3>Company</h3><p>…</p>`):
411
+ * when the parent holds at least two short labelled headings, a non-heading
412
+ * element is named by the nearest heading before it.
413
+ */
414
+ function precedingLabelFor(elementNode) {
415
+ if (!elementNode || !ts.isJsxElement(elementNode))
416
+ return null;
417
+ const tag = tagName(elementNode.openingElement);
418
+ if (/^h[1-6]$/.test(tag) || tag === "dt" || tag === "dd" || tag === "summary")
419
+ return null;
420
+ const parent = elementNode.parent;
421
+ if (!parent || !ts.isJsxElement(parent) || insideMapCallback(elementNode))
422
+ return null;
423
+ let preceding = null;
424
+ let headings = 0;
425
+ for (const child of parent.children) {
426
+ if (!ts.isJsxElement(child))
427
+ continue;
428
+ const childTag = tagName(child.openingElement);
429
+ if (/^h[2-6]$/.test(childTag)) {
430
+ const text = jsxElementText(child);
431
+ if (text && isShortLabelText(text)) {
432
+ headings += 1;
433
+ if (child.pos < elementNode.pos)
434
+ preceding = text;
435
+ }
436
+ }
437
+ }
438
+ return headings >= 2 ? preceding : null;
439
+ }
440
+ function isInlineInSentence(elementNode) {
441
+ if (!elementNode || !ts.isJsxElement(elementNode))
442
+ return false;
443
+ const grandparent = elementNode.parent;
444
+ if (!grandparent || !ts.isJsxElement(grandparent))
445
+ return false;
446
+ return grandparent.children.some((child) => ts.isJsxText(child) && !child.containsOnlyTriviaWhiteSpaces);
447
+ }
448
+ function siblingCountOf(node, tag) {
449
+ const parent = node.parent;
450
+ if (!parent || !ts.isJsxElement(parent))
451
+ return 1;
452
+ let count = 0;
453
+ for (const child of parent.children) {
454
+ const childTag = ts.isJsxElement(child) ? tagName(child.openingElement) : ts.isJsxSelfClosingElement(child) ? tagName(child) : null;
455
+ if (childTag === tag)
456
+ count += 1;
457
+ }
458
+ return Math.max(count, 1);
459
+ }
460
+ function siblingIndexOf(node, tag) {
461
+ const parent = node.parent;
462
+ if (!parent || !ts.isJsxElement(parent))
463
+ return 0;
464
+ let index = 0;
465
+ for (const child of parent.children) {
466
+ if (child === node)
467
+ return index;
468
+ const childTag = ts.isJsxElement(child) ? tagName(child.openingElement) : ts.isJsxSelfClosingElement(child) ? tagName(child) : null;
469
+ if (childTag === tag)
470
+ index += 1;
471
+ }
472
+ return index;
473
+ }
474
+ function pushJsx(ctx, sourceFile, textNode, rawText, source) {
475
+ const text = collapse(source === "jsx-text" ? decodeJsxEntities(rawText) : rawText);
476
+ if (!text)
477
+ return;
478
+ const ancestors = jsxAncestors(textNode);
479
+ const parent = ancestors[ancestors.length - 1] ?? null;
480
+ const element = parent?.tag ?? null;
481
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(textNode.getStart(sourceFile));
482
+ const component = enclosingComponentName(textNode);
483
+ const inMap = insideMapCallback(textNode);
484
+ let classification = "visible-text";
485
+ let reason = "Plain text inside a rendered element.";
486
+ const parentNode = textNode.parent;
487
+ const siblings = parentNode && ts.isJsxElement(parentNode) ? parentNode.children : [];
488
+ const hasElementSiblings = siblings.some((child) => ts.isJsxElement(child) || ts.isJsxSelfClosingElement(child) || ts.isJsxFragment(child));
489
+ const hasExpressionSiblings = siblings.some((child) => ts.isJsxExpression(child) && child.expression && !ts.isStringLiteral(child.expression) && !ts.isNoSubstitutionTemplateLiteral(child.expression));
490
+ if (metadataContext(textNode)) {
491
+ classification = "metadata";
492
+ reason = "Inside page metadata; out of scope unless explicitly requested.";
493
+ }
494
+ else if (element && DEVELOPER_TAGS.has(element)) {
495
+ classification = "developer-text";
496
+ reason = `Inside <${element}>, which shows code rather than copy.`;
497
+ }
498
+ else if (ancestors.some((a) => attributeIsTrue(a.node, "aria-hidden"))) {
499
+ classification = "decorative";
500
+ reason = "Inside an aria-hidden element; decorative, not copy.";
501
+ }
502
+ else if (isPunctuationOnly(text)) {
503
+ classification = "decorative";
504
+ reason = "Punctuation or symbols only.";
505
+ }
506
+ else if (looksLikeCode(text) || !hasLetters(text)) {
507
+ classification = "developer-text";
508
+ reason = "Looks like an identifier, path, URL or number rather than prose.";
509
+ }
510
+ else if (hasExpressionSiblings) {
511
+ classification = "dynamic";
512
+ reason = "Shares its element with an interpolated expression; the sentence is assembled at runtime.";
513
+ }
514
+ else if (hasElementSiblings) {
515
+ classification = "mixed-markup";
516
+ reason = "Shares its element with inline markup (a link, icon or emphasis). Split the element or leave it in code.";
517
+ }
518
+ else if (UI_WORDS.has(text.toLowerCase()) && !(element && /^(h[1-6]|p|dt|dd|blockquote|figcaption)$/.test(element))) {
519
+ // "Save" on a button is a control; "Save" as a step heading is copy.
520
+ classification = "ui-text";
521
+ reason = "Common interface control label, not marketing copy.";
522
+ }
523
+ else if (text.length < 2) {
524
+ classification = "uncertain";
525
+ reason = "Too short to be sure it is copy.";
526
+ }
527
+ ctx.candidates.push({
528
+ text,
529
+ file: ctx.relFile,
530
+ line: line + 1,
531
+ column: character + 1,
532
+ source,
533
+ element,
534
+ key: null,
535
+ component,
536
+ scope: ctx.scope,
537
+ classification,
538
+ reason,
539
+ context: {
540
+ ancestors: ancestors.map((a) => a.tag),
541
+ landmark: landmarkFor(ancestors),
542
+ className: parent ? (attributeString(parent.node, "className") ?? attributeString(parent.node, "class")) : null,
543
+ collection: null,
544
+ itemKey: null,
545
+ itemTitle: pairTitleFor(parentNode, element) ?? containerLabelFor(textNode) ?? precedingLabelFor(parentNode),
546
+ siblingIndex: parentNode ? siblingIndexOf(parentNode, element ?? "") : 0,
547
+ siblingCount: parentNode ? siblingCountOf(parentNode, element ?? "") : 1,
548
+ insideMap: inMap,
549
+ inlineInSentence: isInlineInSentence(parentNode),
550
+ href: sitePathOf(parent?.node ?? null),
551
+ collectionImported: false,
552
+ route: ctx.route,
553
+ },
554
+ });
555
+ }
556
+ function pushAttribute(ctx, sourceFile, attr, owner, name, value, renderedBy) {
557
+ const text = collapse(value);
558
+ if (!text)
559
+ return;
560
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(attr.getStart(sourceFile));
561
+ // The element this attribute belongs to: the self-closing node itself, or the JsxElement around an opening tag.
562
+ const elementNode = ts.isJsxSelfClosingElement(owner) ? owner : owner.parent;
563
+ const ancestors = jsxAncestors(elementNode);
564
+ const tag = tagName(owner);
565
+ const isComponent = /^[A-Z]/.test(tag);
566
+ let classification;
567
+ let reason;
568
+ if (metadataContext(attr)) {
569
+ classification = "metadata";
570
+ reason = "Inside page metadata; out of scope unless explicitly requested.";
571
+ }
572
+ else if (ACCESSIBILITY_PROPS.has(name)) {
573
+ classification = "accessibility";
574
+ reason = `"${name}" is assistive or placeholder text; editable later, not part of the first migration.`;
575
+ }
576
+ else if (looksLikeCode(text) || !hasLetters(text)) {
577
+ classification = "developer-text";
578
+ reason = "Looks like an identifier, path or URL rather than prose.";
579
+ }
580
+ else if (isComponent && (DISPLAY_PROPS.has(name) || renderedBy)) {
581
+ classification = "display-prop";
582
+ reason = renderedBy ?? `String passed as the "${name}" prop of <${tag}>, which renders it.`;
583
+ }
584
+ else if (!isComponent && name === "title") {
585
+ classification = "accessibility";
586
+ reason = "Native title attribute renders as a tooltip only.";
587
+ }
588
+ else {
589
+ classification = "uncertain";
590
+ reason = `String prop "${name}" on <${tag}>; cannot tell whether it is rendered.`;
591
+ }
592
+ const titleProp = attributeString(owner, "title") ?? attributeString(owner, "heading") ?? attributeString(owner, "name") ?? attributeString(owner, "label");
593
+ // A component keyed by where it links (`<FeatureCard href="/features/privacy">`) has a stable, non-text identity.
594
+ const ownerPath = sitePathOf(owner) ?? (attributeString(owner, "path") && SITE_PATH.test(attributeString(owner, "path")) ? attributeString(owner, "path") : null);
595
+ ctx.candidates.push({
596
+ text,
597
+ file: ctx.relFile,
598
+ line: line + 1,
599
+ column: character + 1,
600
+ source: "jsx-attribute",
601
+ element: tag,
602
+ key: name,
603
+ component: enclosingComponentName(attr),
604
+ scope: ctx.scope,
605
+ classification,
606
+ reason,
607
+ context: {
608
+ ancestors: ancestors.map((a) => a.tag),
609
+ landmark: landmarkFor(ancestors),
610
+ className: null,
611
+ collection: null,
612
+ itemKey: attributeString(owner, "id") ?? attributeString(owner, "key") ?? attributeString(owner, "slug") ?? (ownerPath ? pathSegment(ownerPath) : null),
613
+ itemTitle: titleProp,
614
+ siblingIndex: siblingIndexOf(elementNode, tag),
615
+ siblingCount: siblingCountOf(elementNode, tag),
616
+ insideMap: insideMapCallback(attr),
617
+ inlineInSentence: false,
618
+ href: ownerPath,
619
+ collectionImported: false,
620
+ route: ctx.route,
621
+ },
622
+ });
623
+ }
624
+ /** Find `VARIABLE.map((item) => <jsx/>)` inside `scope` and record which item fields it renders. */
625
+ function mapUsageFor(scope, variable) {
626
+ let usage = null;
627
+ const visit = (node) => {
628
+ if (usage)
629
+ return;
630
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["map", "flatMap"].includes(node.expression.name.text) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === variable) {
631
+ const callback = node.arguments[0];
632
+ const renderedFields = new Set();
633
+ let keyField = null;
634
+ let spread = false;
635
+ if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
636
+ const param = callback.parameters[0]?.name;
637
+ const paramName = param && ts.isIdentifier(param) ? param.text : null;
638
+ const destructured = new Set();
639
+ if (param && ts.isObjectBindingPattern(param))
640
+ for (const binding of param.elements)
641
+ if (ts.isIdentifier(binding.name))
642
+ destructured.add((binding.propertyName && ts.isIdentifier(binding.propertyName) ? binding.propertyName : binding.name).text);
643
+ const fieldOf = (expression) => {
644
+ if (paramName && ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === paramName)
645
+ return expression.name.text;
646
+ if (ts.isIdentifier(expression) && destructured.has(expression.text))
647
+ return expression.text;
648
+ return null;
649
+ };
650
+ const walk = (inner) => {
651
+ if (ts.isJsxExpression(inner) && inner.expression && inner.parent && ts.isJsxElement(inner.parent)) {
652
+ const field = fieldOf(inner.expression);
653
+ if (field)
654
+ renderedFields.add(field);
655
+ }
656
+ else if (ts.isJsxAttribute(inner) && ts.isIdentifier(inner.name) && inner.initializer && ts.isJsxExpression(inner.initializer) && inner.initializer.expression) {
657
+ const owner = inner.parent.parent;
658
+ if (inner.name.text === "key") {
659
+ keyField ??= fieldOf(inner.initializer.expression);
660
+ }
661
+ else if (DISPLAY_PROPS.has(inner.name.text)) {
662
+ const field = /^[A-Z]/.test(tagName(owner)) ? fieldOf(inner.initializer.expression) : null;
663
+ if (field)
664
+ renderedFields.add(field);
665
+ }
666
+ }
667
+ else if (ts.isJsxSpreadAttribute(inner) && paramName && ts.isIdentifier(inner.expression) && inner.expression.text === paramName) {
668
+ const owner = inner.parent.parent;
669
+ if (/^[A-Z]/.test(tagName(owner)))
670
+ spread = true;
671
+ }
672
+ ts.forEachChild(inner, walk);
673
+ };
674
+ walk(callback.body);
675
+ }
676
+ usage = { renderedFields, component: enclosingComponentName(node), keyField, spread };
677
+ return;
678
+ }
679
+ ts.forEachChild(node, visit);
680
+ };
681
+ visit(scope);
682
+ return usage;
683
+ }
684
+ function unwrapExpression(expression) {
685
+ let current = expression;
686
+ while (ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isParenthesizedExpression(current) || ts.isTypeAssertionExpression(current))
687
+ current = current.expression;
688
+ return current;
689
+ }
690
+ /** The enclosing function body of a declaration, or the source file for a top-level one. */
691
+ function declarationScope(node) {
692
+ let current = node.parent;
693
+ while (current && !ts.isSourceFile(current)) {
694
+ if (ts.isFunctionLike(current))
695
+ return current;
696
+ current = current.parent;
697
+ }
698
+ return node.getSourceFile();
699
+ }
700
+ /** `const X = [...]` declarations anywhere in a file (top level or inside a component function). */
701
+ function arrayDeclarations(sourceFile) {
702
+ const found = [];
703
+ const visit = (node) => {
704
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
705
+ const initializer = unwrapExpression(node.initializer);
706
+ if (ts.isArrayLiteralExpression(initializer))
707
+ found.push({ declaration: node, array: initializer });
708
+ }
709
+ ts.forEachChild(node, visit);
710
+ };
711
+ visit(sourceFile);
712
+ return found;
713
+ }
714
+ /** Exported array literals of a data module: `export const X = [...]`, `const X = [...]; export { X }`, `export default [...]`. */
715
+ function exportedArrays(sourceFile) {
716
+ const arrays = new Map();
717
+ const local = new Map();
718
+ for (const { declaration, array } of arrayDeclarations(sourceFile)) {
719
+ if (!ts.isIdentifier(declaration.name) || !ts.isSourceFile(declarationScope(declaration)))
720
+ continue;
721
+ local.set(declaration.name.text, array);
722
+ const statement = declaration.parent.parent;
723
+ if (ts.isVariableStatement(statement) && statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
724
+ arrays.set(declaration.name.text, array);
725
+ }
726
+ for (const statement of sourceFile.statements) {
727
+ if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause) && !statement.moduleSpecifier) {
728
+ for (const element of statement.exportClause.elements) {
729
+ const source = local.get((element.propertyName ?? element.name).text);
730
+ if (source)
731
+ arrays.set(element.name.text, source);
732
+ }
733
+ }
734
+ else if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
735
+ const expression = unwrapExpression(statement.expression);
736
+ if (ts.isArrayLiteralExpression(expression))
737
+ arrays.set("default", expression);
738
+ else if (ts.isIdentifier(expression) && local.has(expression.text))
739
+ arrays.set("default", local.get(expression.text));
740
+ }
741
+ }
742
+ return arrays;
743
+ }
744
+ /** Arrays this file imports from local data modules, keyed by the local binding name. */
745
+ function importedArrays(ctx, sourceFile, absoluteFile, aliases) {
746
+ const result = new Map();
747
+ for (const statement of sourceFile.statements) {
748
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.importClause || statement.importClause.isTypeOnly)
749
+ continue;
750
+ const resolved = resolveImport(absoluteFile, statement.moduleSpecifier.text, ctx.appDir, aliases);
751
+ if (!resolved || resolved.includes("/node_modules/"))
752
+ continue;
753
+ const text = readText(resolved);
754
+ if (!text)
755
+ continue;
756
+ const moduleSource = parseSource(resolved, text);
757
+ const exported = exportedArrays(moduleSource);
758
+ if (exported.size === 0)
759
+ continue;
760
+ const file = rel(ctx.appDir, resolved);
761
+ const add = (localName, exportName) => {
762
+ const array = exported.get(exportName);
763
+ if (array)
764
+ result.set(localName, { variable: localName, elements: array.elements, sourceFile: moduleSource, file });
765
+ };
766
+ if (statement.importClause.name)
767
+ add(statement.importClause.name.text, "default");
768
+ const bindings = statement.importClause.namedBindings;
769
+ if (bindings && ts.isNamedImports(bindings))
770
+ for (const element of bindings.elements)
771
+ add(element.name.text, (element.propertyName ?? element.name).text);
772
+ }
773
+ return result;
774
+ }
775
+ const SHORT_LABEL_WORDS = 4;
776
+ function isShortLabelValue(value) {
777
+ return Boolean(value && hasLetters(value) && !looksLikeCode(value) && value.split(/\s+/).length <= SHORT_LABEL_WORDS && value.length <= 40);
778
+ }
779
+ /** Emit the display fields of one array's object items. */
780
+ function collectArrayItems(ctx, declaration, usage, emitted) {
781
+ const { variable, sourceFile } = declaration;
782
+ const rendered = usage?.renderedFields ?? new Set();
783
+ for (const element of declaration.elements) {
784
+ if (!ts.isObjectLiteralExpression(element))
785
+ continue;
786
+ const props = new Map();
787
+ for (const prop of element.properties) {
788
+ if (!ts.isPropertyAssignment(prop))
789
+ continue;
790
+ const key = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
791
+ if (!key)
792
+ continue;
793
+ if (ts.isStringLiteral(prop.initializer) || ts.isNoSubstitutionTemplateLiteral(prop.initializer))
794
+ props.set(key, prop.initializer.text);
795
+ }
796
+ // A link item is naturally keyed by where it goes: { path: "/ftp-calculator" } -> ftpCalculator.
797
+ const pathKey = [props.get("path"), props.get("href"), props.get("to"), props.get("url")].find((value) => value && SITE_PATH.test(value));
798
+ // The React key the callback uses is the developer's statement of the item's identity ({ factor: "Sleep" } with key={item.factor}).
799
+ const reactKey = usage?.keyField ? props.get(usage.keyField) : undefined;
800
+ const keyedByReactKey = !props.get("id") && !props.get("key") && !props.get("slug") && !pathKey && isShortLabelValue(reactKey);
801
+ const itemKey = props.get("id") ?? props.get("key") ?? props.get("slug") ?? (pathKey ? pathSegment(pathKey) : null) ?? (keyedByReactKey ? slugFromText(reactKey, 3) || null : null);
802
+ // Without a title-like field, the first rendered short label ({ factor: "Sleep", line: "…" }) names the item.
803
+ const renderedLabel = [...rendered].map((field) => props.get(field)).find((value) => isShortLabelValue(value));
804
+ const itemTitle = props.get("title") ?? props.get("name") ?? props.get("heading") ?? props.get("label") ?? props.get("question") ?? renderedLabel ?? null;
805
+ for (const prop of element.properties) {
806
+ if (!ts.isPropertyAssignment(prop))
807
+ continue;
808
+ const key = ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name) ? prop.name.text : null;
809
+ if (!key || (!DISPLAY_PROPS.has(key) && !rendered.has(key)))
810
+ continue;
811
+ if (!ts.isStringLiteral(prop.initializer) && !ts.isNoSubstitutionTemplateLiteral(prop.initializer))
812
+ continue;
813
+ const value = collapse(prop.initializer.text);
814
+ if (!value || looksLikeCode(value) || !hasLetters(value))
815
+ continue;
816
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(prop.initializer.getStart(sourceFile));
817
+ const identity = `${declaration.file}:${line + 1}:${character + 1}`;
818
+ if (emitted.has(identity))
819
+ continue;
820
+ emitted.add(identity);
821
+ const how = rendered.has(key) ? "the .map callback renders it as text" : usage?.spread ? `the .map callback spreads each item into <${usage.component ?? "a component"}>` : "which the file maps into JSX";
822
+ const where = declaration.file === ctx.relFile ? variable : `${variable} (imported from ${declaration.file})`;
823
+ ctx.candidates.push({
824
+ text: value,
825
+ file: declaration.file,
826
+ line: line + 1,
827
+ column: character + 1,
828
+ source: "data-object",
829
+ element: null,
830
+ key,
831
+ component: usage?.component ?? variable,
832
+ scope: ctx.scope,
833
+ classification: "data-object",
834
+ reason: `"${key}" field of an item in ${where}; ${how}${keyedByReactKey ? `; the item is keyed by its React key (${usage.keyField})` : ""}.`,
835
+ context: {
836
+ ancestors: [],
837
+ landmark: null,
838
+ className: null,
839
+ collection: variable,
840
+ itemKey,
841
+ itemTitle,
842
+ siblingIndex: 0,
843
+ siblingCount: 1,
844
+ insideMap: false,
845
+ inlineInSentence: false,
846
+ href: pathKey ?? null,
847
+ collectionImported: declaration.file !== ctx.relFile,
848
+ route: ctx.route,
849
+ },
850
+ });
851
+ }
852
+ }
853
+ }
854
+ /**
855
+ * `const ITEMS = [{ title: "...", ... }]` arrays that JSX maps over: declared
856
+ * at the top of the file, inside the component function, or imported from a
857
+ * local data module. Arrays nothing maps are ignored.
858
+ */
859
+ function collectDataObjects(ctx, sourceFile, absoluteFile, aliases, emitted) {
860
+ const text = sourceFile.text;
861
+ for (const { declaration, array } of arrayDeclarations(sourceFile)) {
862
+ const variable = declaration.name.text;
863
+ const scope = declarationScope(declaration);
864
+ const usage = mapUsageFor(scope, variable);
865
+ const usedInJsx = usage !== null || (ts.isSourceFile(scope) && new RegExp(`\\{\\s*${variable}\\s*\\}`).test(text));
866
+ if (!usedInJsx)
867
+ continue;
868
+ collectArrayItems(ctx, { variable, elements: array.elements, sourceFile, file: ctx.relFile }, usage, emitted);
869
+ }
870
+ for (const [variable, declaration] of importedArrays(ctx, sourceFile, absoluteFile, aliases)) {
871
+ const usage = mapUsageFor(sourceFile, variable);
872
+ if (!usage)
873
+ continue;
874
+ collectArrayItems(ctx, declaration, usage, emitted);
875
+ }
876
+ }
877
+ const aliasCache = new Map();
878
+ function aliasesFor(appDir) {
879
+ let aliases = aliasCache.get(appDir);
880
+ if (!aliases)
881
+ aliasCache.set(appDir, (aliases = readPathAliases(appDir)));
882
+ return aliases;
883
+ }
884
+ export function scanFile(appDir, absoluteFile, scope, route, options = {}) {
885
+ const text = readText(absoluteFile);
886
+ if (text === null)
887
+ return [];
888
+ const sourceFile = parseSource(absoluteFile, text);
889
+ const aliases = aliasesFor(appDir);
890
+ const emitted = options.emitted ?? new Set();
891
+ const ctx = {
892
+ appDir,
893
+ relFile: rel(appDir, absoluteFile),
894
+ scope,
895
+ route,
896
+ candidates: [],
897
+ flow: options.flow ?? createDataflowContext(appDir, aliases),
898
+ sourceFile,
899
+ absoluteFile,
900
+ emitted,
901
+ ordinals: new Map(),
902
+ };
903
+ const visit = (node) => {
904
+ if (ts.isJsxText(node)) {
905
+ if (!node.containsOnlyTriviaWhiteSpaces)
906
+ pushJsx(ctx, sourceFile, node, node.text, "jsx-text");
907
+ }
908
+ else if (ts.isJsxExpression(node) && node.expression && node.parent && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
909
+ handleExpressionChild(ctx, node);
910
+ }
911
+ else if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && node.initializer) {
912
+ handleAttribute(ctx, sourceFile, node);
913
+ }
914
+ ts.forEachChild(node, visit);
915
+ };
916
+ visit(sourceFile);
917
+ collectDataObjects(ctx, sourceFile, absoluteFile, aliases, emitted);
918
+ collectMappedValues(ctx, sourceFile);
919
+ collectPropDefaults(ctx, sourceFile);
920
+ return ctx.candidates;
921
+ }
922
+ function isMigratable(candidate) {
923
+ return candidate.classification === "visible-text" || candidate.classification === "display-prop" || candidate.classification === "data-object";
924
+ }
925
+ export function scanCopy(inspection, options = {}) {
926
+ const appDir = appDirectory(inspection);
927
+ const includeShared = options.includeShared ?? true;
928
+ const requestedRoutes = options.all ? inspection.routes.map((route) => route.path) : (options.routes ?? (inspection.routes.some((route) => route.path === "/") ? ["/"] : inspection.routes.slice(0, 1).map((route) => route.path)));
929
+ const scopes = computeFileScopes(appDir, inspection.routes, inspection.layouts);
930
+ const files = new Map();
931
+ const allSource = walkFiles(appDir).filter((file) => !isTestOrStoryFile(rel(appDir, file)));
932
+ if (options.all && inspection.routes.length === 0) {
933
+ for (const file of allSource)
934
+ files.set(file, { scope: "unrouted", route: null });
935
+ }
936
+ else {
937
+ for (const file of allSource) {
938
+ const routesReaching = scopes.byFile.get(file);
939
+ const isShared = scopes.shared.has(file);
940
+ if (isShared) {
941
+ // Chrome a layout renders always belongs to the migration. A component that several
942
+ // routes share (a blog CTA, a calculator widget) belongs only when a requested route
943
+ // renders it; otherwise it is another page's copy.
944
+ const relevant = scopes.fromLayouts.has(file) || [...(routesReaching ?? [])].some((route) => requestedRoutes.includes(route));
945
+ if (includeShared && relevant)
946
+ files.set(file, { scope: "shared", route: null });
947
+ continue;
948
+ }
949
+ if (!routesReaching)
950
+ continue;
951
+ const matching = [...routesReaching].filter((route) => requestedRoutes.includes(route));
952
+ if (matching.length === 0)
953
+ continue;
954
+ files.set(file, { scope: matching[0], route: matching[0] });
955
+ }
956
+ if (options.all) {
957
+ for (const file of allSource) {
958
+ if (!files.has(file) && !scopes.byFile.has(file) && !scopes.shared.has(file))
959
+ files.set(file, { scope: "unrouted", route: null });
960
+ }
961
+ }
962
+ }
963
+ // Shared chrome only counts when it is reachable from a selected route or a layout.
964
+ const raw = [];
965
+ const scanned = [];
966
+ const emitted = new Set();
967
+ const flow = createDataflowContext(appDir, aliasesFor(appDir));
968
+ for (const [file, info] of [...files.entries()].sort(([a], [b]) => a.localeCompare(b))) {
969
+ scanned.push(rel(appDir, file));
970
+ raw.push(...scanFile(appDir, file, info.scope, info.route, { emitted, flow }));
971
+ }
972
+ const withIds = proposeIds(raw);
973
+ const candidates = withIds.map(({ raw: candidate, id, idReason, review }) => {
974
+ let status;
975
+ let reason = candidate.reason;
976
+ if (!isMigratable(candidate)) {
977
+ status = candidate.classification === "mixed-markup" || candidate.classification === "dynamic" ? "unsupported" : "excluded";
978
+ }
979
+ else if (!id) {
980
+ status = "needs-review";
981
+ reason = idReason ?? "No sensible ID could be derived.";
982
+ }
983
+ else if (candidate.context.inlineInSentence) {
984
+ status = "needs-review";
985
+ reason = "Inline element inside a sentence whose surrounding text is not editable; migrate the whole sentence by splitting it, or leave it in code.";
986
+ }
987
+ else if (review || candidate.context.ambiguous === true) {
988
+ status = "needs-review";
989
+ // Keep both halves: why the string counts as copy, and why its id still
990
+ // needs a decision. An agent renaming it needs the provenance too.
991
+ reason = idReason && idReason !== reason ? `${reason} ${idReason}` : reason;
992
+ }
993
+ else {
994
+ status = "selected";
995
+ }
996
+ return {
997
+ id,
998
+ text: candidate.text,
999
+ file: candidate.file,
1000
+ line: candidate.line,
1001
+ column: candidate.column,
1002
+ source: candidate.source,
1003
+ element: candidate.element,
1004
+ key: candidate.key,
1005
+ component: candidate.component,
1006
+ scope: candidate.scope,
1007
+ classification: candidate.classification,
1008
+ status,
1009
+ reason,
1010
+ };
1011
+ });
1012
+ return { filesScanned: scanned, candidates };
1013
+ }
1014
+ /** Emit one resolved literal, once per source position. */
1015
+ function emitValue(ctx, options) {
1016
+ const raw = ts.isStringLiteral(options.node) || ts.isNoSubstitutionTemplateLiteral(options.node) ? options.node.text : null;
1017
+ if (raw === null)
1018
+ return;
1019
+ const text = collapse(raw);
1020
+ if (!text || looksLikeCode(text) || !hasLetters(text) || isPunctuationOnly(text))
1021
+ return;
1022
+ const { line, character } = options.sourceFile.getLineAndCharacterOfPosition(options.node.getStart(options.sourceFile));
1023
+ const identity = `${options.file}:${line + 1}:${character + 1}`;
1024
+ if (ctx.emitted.has(identity))
1025
+ return;
1026
+ ctx.emitted.add(identity);
1027
+ ctx.candidates.push({
1028
+ text,
1029
+ file: options.file,
1030
+ line: line + 1,
1031
+ column: character + 1,
1032
+ source: options.source,
1033
+ element: options.element,
1034
+ key: options.key,
1035
+ component: options.component,
1036
+ scope: ctx.scope,
1037
+ classification: options.classification,
1038
+ reason: options.reason,
1039
+ context: {
1040
+ ancestors: options.ancestors ?? [],
1041
+ landmark: options.landmark ?? null,
1042
+ className: options.className ?? null,
1043
+ collection: options.collection,
1044
+ itemKey: options.itemKey,
1045
+ itemTitle: options.itemTitle,
1046
+ siblingIndex: 0,
1047
+ siblingCount: 1,
1048
+ insideMap: false,
1049
+ inlineInSentence: false,
1050
+ href: null,
1051
+ collectionImported: options.collectionImported ?? false,
1052
+ ...(options.ambiguous === true ? { ambiguous: true } : {}),
1053
+ route: ctx.route,
1054
+ },
1055
+ });
1056
+ }
1057
+ /** JSX context of a node, for naming. */
1058
+ function jsxContextOf(node) {
1059
+ const ancestors = jsxAncestors(node);
1060
+ const parent = ancestors[ancestors.length - 1] ?? null;
1061
+ return {
1062
+ element: parent?.tag ?? null,
1063
+ ancestors: ancestors.map((entry) => entry.tag),
1064
+ landmark: landmarkFor(ancestors),
1065
+ className: parent ? (attributeString(parent.node, "className") ?? attributeString(parent.node, "class")) : null,
1066
+ };
1067
+ }
1068
+ /** A non-literal `{expression}` child: a constant, a state value or a choice between two strings. */
1069
+ function handleExpressionChild(ctx, node) {
1070
+ if (!node.expression)
1071
+ return;
1072
+ const expression = unwrapExpression(node.expression);
1073
+ const literal = literalText(expression);
1074
+ if (literal !== null) {
1075
+ pushJsx(ctx, ctx.sourceFile, node, literal, "jsx-child-literal");
1076
+ return;
1077
+ }
1078
+ const where = jsxContextOf(node);
1079
+ const component = enclosingComponentName(node);
1080
+ const common = {
1081
+ sourceFile: ctx.sourceFile,
1082
+ file: ctx.relFile,
1083
+ source: "jsx-child-literal",
1084
+ element: where.element,
1085
+ key: null,
1086
+ component,
1087
+ collection: null,
1088
+ itemTitle: null,
1089
+ classification: "visible-text",
1090
+ ancestors: where.ancestors,
1091
+ landmark: where.landmark,
1092
+ className: where.className,
1093
+ };
1094
+ if (ts.isConditionalExpression(expression)) {
1095
+ const branches = [expression.whenTrue, expression.whenFalse].map((branch) => unwrapExpression(branch));
1096
+ if (branches.every((branch) => literalText(branch) !== null)) {
1097
+ for (const branch of branches) {
1098
+ emitValue(ctx, {
1099
+ ...common,
1100
+ node: branch,
1101
+ itemKey: null,
1102
+ ambiguous: true,
1103
+ reason: "One of two alternative states of this element; both are shown to a reader, so each needs its own id.",
1104
+ });
1105
+ }
1106
+ }
1107
+ return;
1108
+ }
1109
+ if (!ts.isIdentifier(expression))
1110
+ return;
1111
+ const states = stateLiterals(enclosingScope(node), expression.text);
1112
+ if (states) {
1113
+ for (const state of states) {
1114
+ emitValue(ctx, {
1115
+ ...common,
1116
+ node: state,
1117
+ itemKey: null,
1118
+ ambiguous: true,
1119
+ reason: `A value the "${expression.text}" state can hold: the component sets it to this string and this element renders it.`,
1120
+ });
1121
+ }
1122
+ return;
1123
+ }
1124
+ const binding = findBinding(node, expression.text);
1125
+ if (binding?.initializer && literalText(binding.initializer) !== null) {
1126
+ emitValue(ctx, {
1127
+ ...common,
1128
+ node: unwrapExpression(binding.initializer),
1129
+ collection: expression.text,
1130
+ itemKey: null,
1131
+ reason: `The ${expression.text} constant, interpolated into this element.`,
1132
+ });
1133
+ }
1134
+ }
1135
+ /** A component tag as an id segment: `Findings` -> `findings`. */
1136
+ function segmentFromTag(tag) {
1137
+ const bare = tag.replace(/^.*\./, "");
1138
+ return bare.charAt(0).toLowerCase() + bare.slice(1);
1139
+ }
1140
+ /** Next ordinal for a collection within this file. */
1141
+ function nextOrdinal(ctx, collection) {
1142
+ const next = (ctx.ordinals.get(collection) ?? 0) + 1;
1143
+ ctx.ordinals.set(collection, next);
1144
+ return next;
1145
+ }
1146
+ /** A prop with a literal value, or a literal array, that the receiving component renders. */
1147
+ function handleAttribute(ctx, sourceFile, attr) {
1148
+ if (!ts.isIdentifier(attr.name) || !attr.initializer)
1149
+ return;
1150
+ const owner = attr.parent.parent;
1151
+ const name = attr.name.text;
1152
+ const tag = tagName(owner);
1153
+ let value = null;
1154
+ if (ts.isStringLiteral(attr.initializer))
1155
+ value = attr.initializer.text;
1156
+ else if (ts.isJsxExpression(attr.initializer) && attr.initializer.expression)
1157
+ value = literalText(attr.initializer.expression);
1158
+ if (value !== null && (DISPLAY_PROPS.has(name) || ACCESSIBILITY_PROPS.has(name) || name === "title")) {
1159
+ pushAttribute(ctx, sourceFile, attr, owner, name, value);
1160
+ return;
1161
+ }
1162
+ if (!/^[A-Z]/.test(tag) || tag.includes("."))
1163
+ return;
1164
+ const info = resolveComponent(ctx.flow, ctx.sourceFile, ctx.absoluteFile, tag);
1165
+ const role = info?.props.get(name)?.role;
1166
+ if (!role)
1167
+ return;
1168
+ if (value !== null && role === "text") {
1169
+ pushAttribute(ctx, sourceFile, attr, owner, name, value, `String passed as the "${name}" prop of <${tag}>, which renders it as text (${info.file}).`);
1170
+ return;
1171
+ }
1172
+ if (role === "list" && ts.isJsxExpression(attr.initializer) && attr.initializer.expression) {
1173
+ const elements = arrayOfStrings(attr.initializer.expression);
1174
+ if (!elements)
1175
+ return;
1176
+ const where = jsxContextOf(owner);
1177
+ // The component names the collection, so two different list components stay apart.
1178
+ const collection = segmentFromTag(tag);
1179
+ for (const element of elements) {
1180
+ const ordinal = nextOrdinal(ctx, collection);
1181
+ emitValue(ctx, {
1182
+ node: element,
1183
+ sourceFile,
1184
+ file: ctx.relFile,
1185
+ source: "data-object",
1186
+ element: tag,
1187
+ key: null,
1188
+ component: enclosingComponentName(attr),
1189
+ collection,
1190
+ itemKey: String(ordinal),
1191
+ itemTitle: null,
1192
+ classification: "data-object",
1193
+ ambiguous: true,
1194
+ ancestors: where.ancestors,
1195
+ landmark: where.landmark,
1196
+ reason: `Item ${ordinal} of the "${name}" prop of <${tag}>, which renders each item as text; the ordinal is a placeholder, choose a semantic name.`,
1197
+ });
1198
+ }
1199
+ }
1200
+ }
1201
+ /** A literal default for a prop the component renders: visible whenever the caller omits it. */
1202
+ function collectPropDefaults(ctx, sourceFile) {
1203
+ const names = new Set();
1204
+ const visit = (node) => {
1205
+ if (ts.isFunctionDeclaration(node) && node.name && /^[A-Z]/.test(node.name.text))
1206
+ names.add(node.name.text);
1207
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && /^[A-Z]/.test(node.name.text) && node.initializer) {
1208
+ const initializer = unwrapExpression(node.initializer);
1209
+ if (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
1210
+ names.add(node.name.text);
1211
+ }
1212
+ ts.forEachChild(node, visit);
1213
+ };
1214
+ visit(sourceFile);
1215
+ for (const name of names) {
1216
+ const info = componentRenderInfo(sourceFile, ctx.relFile, name);
1217
+ if (!info)
1218
+ continue;
1219
+ for (const [prop, detail] of info.props) {
1220
+ if (detail.role !== "text" || detail.defaultText === null || !detail.defaultNode)
1221
+ continue;
1222
+ emitValue(ctx, {
1223
+ node: unwrapExpression(detail.defaultNode),
1224
+ sourceFile,
1225
+ file: ctx.relFile,
1226
+ source: "jsx-attribute",
1227
+ element: name,
1228
+ key: prop,
1229
+ component: name,
1230
+ collection: null,
1231
+ itemKey: null,
1232
+ itemTitle: null,
1233
+ classification: "display-prop",
1234
+ reason: `Default for the "${prop}" prop of <${name}>, which renders it as text; visible whenever a caller omits the prop.`,
1235
+ });
1236
+ }
1237
+ }
1238
+ }
1239
+ /** Object literals of an array, when the array resolves to one. */
1240
+ function objectElementsOf(elements) {
1241
+ const objects = elements.map((element) => unwrapExpression(element)).filter((element) => ts.isObjectLiteralExpression(element));
1242
+ return objects.length === elements.length && objects.length > 0 ? objects : null;
1243
+ }
1244
+ function propertyOf(object, key) {
1245
+ for (const property of object.properties) {
1246
+ if (!ts.isPropertyAssignment(property))
1247
+ continue;
1248
+ const name = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) ? property.name.text : null;
1249
+ if (name === key)
1250
+ return property.initializer;
1251
+ }
1252
+ return null;
1253
+ }
1254
+ function stableKeyOf(object, fallback) {
1255
+ for (const key of ["id", "key", "slug"]) {
1256
+ const value = literalText(propertyOf(object, key) ?? undefined);
1257
+ if (value)
1258
+ return value;
1259
+ }
1260
+ for (const key of ["href", "path", "to", "url"]) {
1261
+ const value = literalText(propertyOf(object, key) ?? undefined);
1262
+ if (value && SITE_PATH.test(value))
1263
+ return pathSegment(value);
1264
+ }
1265
+ return String(fallback + 1);
1266
+ }
1267
+ /** The array a map call reads, when it can be resolved statically in this file. */
1268
+ function resolveArrayTarget(ctx, node, target) {
1269
+ if (ts.isArrayLiteralExpression(target))
1270
+ return { elements: target.elements, collection: null, recordKey: null };
1271
+ if (ts.isIdentifier(target)) {
1272
+ const binding = findBinding(node, target.text);
1273
+ const initializer = binding?.initializer ? unwrapExpression(binding.initializer) : null;
1274
+ if (initializer && ts.isArrayLiteralExpression(initializer))
1275
+ return { elements: initializer.elements, collection: target.text, recordKey: null };
1276
+ return null;
1277
+ }
1278
+ if (ts.isElementAccessExpression(target) && ts.isIdentifier(target.expression)) {
1279
+ const binding = findBinding(node, target.expression.text);
1280
+ const record = binding?.initializer ? recordOfStringArrays(binding.initializer) : null;
1281
+ if (!record)
1282
+ return null;
1283
+ // Indexed by a variable: every branch of the record can be shown.
1284
+ const elements = [];
1285
+ for (const values of record.values())
1286
+ elements.push(...values);
1287
+ return { elements, collection: target.expression.text, recordKey: null };
1288
+ }
1289
+ return null;
1290
+ }
1291
+ /** Record key an element belongs to, for naming values of `Record<string, string[]>`. */
1292
+ function recordKeyFor(ctx, node, target, element) {
1293
+ if (!ts.isElementAccessExpression(target) || !ts.isIdentifier(target.expression))
1294
+ return null;
1295
+ const binding = findBinding(node, target.expression.text);
1296
+ const record = binding?.initializer ? recordOfStringArrays(binding.initializer) : null;
1297
+ if (!record)
1298
+ return null;
1299
+ for (const [key, values] of record)
1300
+ if (values.includes(element))
1301
+ return key;
1302
+ return null;
1303
+ }
1304
+ /** `X.map((item) => …)` where the callback renders the item, or one field of it. */
1305
+ function collectMappedValues(ctx, sourceFile) {
1306
+ const visit = (node) => {
1307
+ const mapped = mapCallbackOf(node);
1308
+ if (mapped)
1309
+ emitMapped(ctx, sourceFile, node, mapped.target, mapped.callback);
1310
+ ts.forEachChild(node, visit);
1311
+ };
1312
+ visit(sourceFile);
1313
+ }
1314
+ function emitMapped(ctx, sourceFile, node, target, callback) {
1315
+ const resolved = resolveArrayTarget(ctx, node, target);
1316
+ if (!resolved)
1317
+ return;
1318
+ const body = callback.body;
1319
+ if (!body)
1320
+ return;
1321
+ const where = jsxContextOf(node);
1322
+ const component = enclosingComponentName(node);
1323
+ const collection = resolved.collection;
1324
+ const label = collection ?? "the inline list";
1325
+ // Case 1: the callback renders the item itself, so the array holds visible strings.
1326
+ const strings = arrayOfStrings(ts.factory.createArrayLiteralExpression(resolved.elements.map((element) => unwrapExpression(element))));
1327
+ if (callbackRendersItem(callback) && strings) {
1328
+ const perKey = new Map();
1329
+ resolved.elements.forEach((raw, index) => {
1330
+ const element = unwrapExpression(raw);
1331
+ const recordKey = recordKeyFor(ctx, node, target, element);
1332
+ // Values of one record entry are numbered within that entry; a plain list is numbered once.
1333
+ const position = recordKey ? (perKey.set(recordKey, (perKey.get(recordKey) ?? 0) + 1), perKey.get(recordKey)) : index + 1;
1334
+ emitValue(ctx, {
1335
+ node: element,
1336
+ sourceFile,
1337
+ file: ctx.relFile,
1338
+ source: "data-object",
1339
+ element: where.element,
1340
+ key: recordKey ? String(position) : null,
1341
+ component,
1342
+ collection,
1343
+ itemKey: recordKey ?? String(position),
1344
+ itemTitle: null,
1345
+ classification: "data-object",
1346
+ ambiguous: true,
1347
+ ancestors: where.ancestors,
1348
+ landmark: where.landmark,
1349
+ reason: recordKey
1350
+ ? `Item ${position} under "${recordKey}" in ${label}, which this element renders as text; confirm the id before migrating.`
1351
+ : `Item ${position} of ${label}, which this element renders as text; the ordinal is a placeholder, choose a semantic name.`,
1352
+ });
1353
+ });
1354
+ return;
1355
+ }
1356
+ // Case 2: the callback renders fields of each object, including nested lists.
1357
+ const objects = objectElementsOf(resolved.elements);
1358
+ if (!objects)
1359
+ return;
1360
+ const parameter = callback.parameters[0]?.name;
1361
+ const paramName = parameter && ts.isIdentifier(parameter) ? parameter.text : null;
1362
+ const destructured = new Set();
1363
+ if (parameter && ts.isObjectBindingPattern(parameter)) {
1364
+ for (const binding of parameter.elements)
1365
+ if (ts.isIdentifier(binding.name))
1366
+ destructured.add((binding.propertyName && ts.isIdentifier(binding.propertyName) ? binding.propertyName : binding.name).text);
1367
+ }
1368
+ const renderedFields = new Set();
1369
+ const nestedFields = new Set();
1370
+ const walk = (inner) => {
1371
+ if (ts.isJsxExpression(inner) && inner.expression && inner.parent && (ts.isJsxElement(inner.parent) || ts.isJsxFragment(inner.parent))) {
1372
+ const field = fieldNameOf(inner.expression, paramName, destructured);
1373
+ if (field)
1374
+ renderedFields.add(field);
1375
+ }
1376
+ const innerMap = mapCallbackOf(inner);
1377
+ if (innerMap) {
1378
+ const field = fieldNameOf(innerMap.target, paramName, destructured);
1379
+ if (field)
1380
+ nestedFields.add(field);
1381
+ }
1382
+ ts.forEachChild(inner, walk);
1383
+ };
1384
+ walk(body);
1385
+ objects.forEach((object, index) => {
1386
+ const itemKey = stableKeyOf(object, index);
1387
+ const title = ["title", "name", "heading", "label", "question"].map((key) => literalText(propertyOf(object, key) ?? undefined)).find((value) => value) ?? null;
1388
+ for (const field of renderedFields) {
1389
+ const value = propertyOf(object, field);
1390
+ if (!value || literalText(value) === null)
1391
+ continue;
1392
+ emitValue(ctx, {
1393
+ node: unwrapExpression(value),
1394
+ sourceFile,
1395
+ file: ctx.relFile,
1396
+ source: "data-object",
1397
+ element: where.element,
1398
+ key: field,
1399
+ component,
1400
+ collection,
1401
+ itemKey,
1402
+ itemTitle: title,
1403
+ classification: "data-object",
1404
+ reason: `"${field}" field of an item in ${label}; the .map callback renders it as text.`,
1405
+ });
1406
+ }
1407
+ for (const field of nestedFields) {
1408
+ const nested = propertyOf(object, field);
1409
+ if (!nested)
1410
+ continue;
1411
+ const nestedArray = unwrapExpression(nested);
1412
+ if (!ts.isArrayLiteralExpression(nestedArray))
1413
+ continue;
1414
+ const nestedStrings = arrayOfStrings(nestedArray);
1415
+ if (nestedStrings) {
1416
+ nestedStrings.forEach((element, nestedIndex) => {
1417
+ emitValue(ctx, {
1418
+ node: element,
1419
+ sourceFile,
1420
+ file: ctx.relFile,
1421
+ source: "data-object",
1422
+ element: where.element,
1423
+ // The field and position sit after the item, so the id reads item.field.n.
1424
+ key: `${field}.${nestedIndex + 1}`,
1425
+ component,
1426
+ collection,
1427
+ itemKey,
1428
+ itemTitle: title,
1429
+ classification: "data-object",
1430
+ ambiguous: true,
1431
+ reason: `Item ${nestedIndex + 1} of the "${field}" list on an item of ${label}, rendered as text; the ordinal is a placeholder, choose a semantic name.`,
1432
+ });
1433
+ });
1434
+ continue;
1435
+ }
1436
+ const nestedObjects = objectElementsOf(nestedArray.elements);
1437
+ if (!nestedObjects)
1438
+ continue;
1439
+ const nestedRendered = new Set();
1440
+ const findNested = (inner) => {
1441
+ const innerMap = mapCallbackOf(inner);
1442
+ if (innerMap && fieldNameOf(innerMap.target, paramName, destructured) === field) {
1443
+ const innerParam = innerMap.callback.parameters[0]?.name;
1444
+ const innerName = innerParam && ts.isIdentifier(innerParam) ? innerParam.text : null;
1445
+ const innerBody = innerMap.callback.body;
1446
+ const innerWalk = (deeper) => {
1447
+ if (ts.isJsxExpression(deeper) && deeper.expression && deeper.parent && (ts.isJsxElement(deeper.parent) || ts.isJsxFragment(deeper.parent))) {
1448
+ const nestedField = fieldNameOf(deeper.expression, innerName, new Set());
1449
+ if (nestedField)
1450
+ nestedRendered.add(nestedField);
1451
+ }
1452
+ ts.forEachChild(deeper, innerWalk);
1453
+ };
1454
+ if (innerBody)
1455
+ innerWalk(innerBody);
1456
+ }
1457
+ ts.forEachChild(inner, findNested);
1458
+ };
1459
+ findNested(body);
1460
+ nestedObjects.forEach((nestedObject, nestedIndex) => {
1461
+ const nestedKey = stableKeyOf(nestedObject, nestedIndex);
1462
+ // A nested item with its own stable key (an href, id or slug) needs no parent prefix.
1463
+ const stable = !/^\d+$/.test(nestedKey);
1464
+ for (const nestedField of nestedRendered) {
1465
+ const value = propertyOf(nestedObject, nestedField);
1466
+ if (!value || literalText(value) === null)
1467
+ continue;
1468
+ emitValue(ctx, {
1469
+ node: unwrapExpression(value),
1470
+ sourceFile,
1471
+ file: ctx.relFile,
1472
+ source: "data-object",
1473
+ element: where.element,
1474
+ key: nestedField,
1475
+ component,
1476
+ collection: field,
1477
+ itemKey: stable ? nestedKey : `${itemKey}-${nestedKey}`,
1478
+ itemTitle: title,
1479
+ classification: "data-object",
1480
+ reason: `"${nestedField}" of an item in the nested "${field}" list of ${label}; the inner .map renders it as text.`,
1481
+ });
1482
+ }
1483
+ });
1484
+ }
1485
+ });
1486
+ }