fimo 0.8.0 → 0.9.0-experimental.10

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.
Files changed (218) hide show
  1. package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
  2. package/assets/agents-starter/claude/settings.json +19 -0
  3. package/assets/content-templates/hooks-template.eta +4 -2
  4. package/assets/content-templates/types-template.eta +67 -1
  5. package/assets/skills/fimo/SKILL.md +1 -1
  6. package/assets/skills/fimo/references/content.md +86 -15
  7. package/assets/skills/fimo/references/translations.md +75 -0
  8. package/assets/skills/fimo-cli/SKILL.md +6 -2
  9. package/assets/skills/fimo-cli/references/content.md +61 -2
  10. package/assets/skills/fimo-cli/references/integrations/firecrawl.md +20 -4
  11. package/assets/skills/fimo-cli/references/integrations.md +5 -1
  12. package/assets/skills/fimo-cli/references/webhooks.md +162 -0
  13. package/assets/skills/fimo-migration/SKILL.md +24 -2
  14. package/assets/skills/fimo-migration/references/content.md +24 -0
  15. package/assets/skills/fimo-migration/references/manifest.md +46 -0
  16. package/assets/skills/fimo-migration/references/pages.md +234 -0
  17. package/assets/skills/fimo-migration/references/qa.md +24 -2
  18. package/assets/skills/fimo-migration/references/workflow.md +12 -1
  19. package/assets/skills/fimo-migration/references/wrap.md +662 -0
  20. package/assets/skills/fimo-studio/SKILL.md +5 -3
  21. package/assets/skills/fimo-studio/references/cms.md +9 -1
  22. package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
  23. package/assets/skills/fimo-studio/references/webhooks.md +42 -0
  24. package/dist/build/vite/plugins/translations.d.ts +2 -8
  25. package/dist/build/vite/plugins/translations.js +31 -131
  26. package/dist/cli/bundle.json +1 -1
  27. package/dist/cli/index.js +1337 -189
  28. package/dist/runtime/app/FimoProviders.d.ts +1 -11
  29. package/dist/runtime/app/FimoProviders.js +31 -30
  30. package/dist/runtime/astro/index.d.ts +2 -0
  31. package/dist/runtime/astro/index.js +3 -0
  32. package/dist/runtime/astro/server.d.ts +3 -0
  33. package/dist/runtime/astro/server.js +6 -0
  34. package/dist/runtime/content/client/internal.js +1 -0
  35. package/dist/runtime/content/client/types.d.ts +16 -0
  36. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  37. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  38. package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
  39. package/dist/runtime/next/FimoNextProviders.d.ts +24 -0
  40. package/dist/runtime/next/FimoNextProviders.js +20 -0
  41. package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
  42. package/dist/runtime/next/FimoNextScripts.js +19 -0
  43. package/dist/runtime/next/index.d.ts +14 -0
  44. package/dist/runtime/next/index.js +14 -0
  45. package/dist/runtime/next/server.d.ts +2 -0
  46. package/dist/runtime/next/server.js +4 -0
  47. package/dist/runtime/nuxt/composables.d.ts +9 -0
  48. package/dist/runtime/nuxt/composables.js +57 -0
  49. package/dist/runtime/nuxt/index.d.ts +3 -0
  50. package/dist/runtime/nuxt/index.js +6 -0
  51. package/dist/runtime/nuxt/server.d.ts +3 -0
  52. package/dist/runtime/nuxt/server.js +6 -0
  53. package/dist/runtime/primitives/components/RichText.d.ts +5 -1
  54. package/dist/runtime/primitives/components/RichText.js +2 -2
  55. package/dist/runtime/primitives/labels-hook.d.ts +33 -0
  56. package/dist/runtime/primitives/labels-hook.js +41 -0
  57. package/dist/runtime/primitives/labels-store.d.ts +12 -0
  58. package/dist/runtime/primitives/labels-store.js +60 -0
  59. package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
  60. package/dist/runtime/primitives/lib/useFimoLiveValue.js +11 -4
  61. package/dist/runtime/primitives/translations.d.ts +1 -27
  62. package/dist/runtime/primitives/translations.js +7 -85
  63. package/dist/runtime/react/FimoSpaProviders.d.ts +36 -0
  64. package/dist/runtime/react/FimoSpaProviders.js +122 -0
  65. package/dist/runtime/react/index.d.ts +35 -0
  66. package/dist/runtime/react/index.js +40 -0
  67. package/dist/runtime/react/spa-content-cache.d.ts +38 -0
  68. package/dist/runtime/react/spa-content-cache.js +64 -0
  69. package/dist/runtime/remix/FimoRemixProviders.d.ts +16 -0
  70. package/dist/runtime/remix/FimoRemixProviders.js +12 -0
  71. package/dist/runtime/remix/FimoRemixScripts.d.ts +10 -0
  72. package/dist/runtime/remix/FimoRemixScripts.js +19 -0
  73. package/dist/runtime/remix/index.d.ts +12 -0
  74. package/dist/runtime/remix/index.js +11 -0
  75. package/dist/runtime/remix/server.d.ts +2 -0
  76. package/dist/runtime/remix/server.js +2 -0
  77. package/dist/runtime/seo/neutral.d.ts +22 -0
  78. package/dist/runtime/seo/neutral.js +17 -0
  79. package/dist/runtime/shared/display-routes.d.ts +14 -0
  80. package/dist/runtime/shared/display-routes.js +25 -0
  81. package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
  82. package/dist/runtime/shared/fimo-config.server.js +1 -13
  83. package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
  84. package/dist/runtime/shared/i18n-messages.d.ts +29 -0
  85. package/dist/runtime/shared/i18n-messages.js +79 -0
  86. package/dist/runtime/shared/labels-fetch.pure.d.ts +17 -0
  87. package/dist/runtime/shared/labels-fetch.pure.js +71 -0
  88. package/dist/runtime/shared/labels-fetch.server.d.ts +7 -0
  89. package/dist/runtime/shared/labels-fetch.server.js +97 -0
  90. package/dist/runtime/shared/labels-t.pure.d.ts +2 -0
  91. package/dist/runtime/shared/labels-t.pure.js +10 -0
  92. package/dist/runtime/strapi/client.d.ts +30 -0
  93. package/dist/runtime/strapi/client.js +132 -0
  94. package/dist/runtime/strapi/index.d.ts +5 -0
  95. package/dist/runtime/strapi/index.js +6 -0
  96. package/dist/runtime/strapi/reshape.d.ts +50 -0
  97. package/dist/runtime/strapi/reshape.js +121 -0
  98. package/dist/runtime/svelte/index.d.ts +3 -0
  99. package/dist/runtime/svelte/index.js +7 -0
  100. package/dist/runtime/svelte/server.d.ts +3 -0
  101. package/dist/runtime/svelte/server.js +6 -0
  102. package/dist/runtime/vue/index.d.ts +3 -0
  103. package/dist/runtime/vue/index.js +5 -0
  104. package/dist/runtime/vue/server.d.ts +3 -0
  105. package/dist/runtime/vue/server.js +6 -0
  106. package/dist/runtime/wrapped/FimoFileLink.d.ts +17 -0
  107. package/dist/runtime/wrapped/FimoFileLink.js +19 -0
  108. package/dist/runtime/wrapped/FimoMediaImage.d.ts +14 -0
  109. package/dist/runtime/wrapped/FimoMediaImage.js +20 -0
  110. package/dist/runtime/wrapped/FimoMediaVideo.d.ts +14 -0
  111. package/dist/runtime/wrapped/FimoMediaVideo.js +20 -0
  112. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +26 -0
  113. package/dist/runtime/wrapped/FimoRichTextBlock.js +22 -0
  114. package/dist/runtime/wrapped/FimoText.d.ts +22 -0
  115. package/dist/runtime/wrapped/FimoText.js +20 -0
  116. package/dist/runtime/wrapped/WrappedContentContext.d.ts +28 -0
  117. package/dist/runtime/wrapped/WrappedContentContext.js +30 -0
  118. package/dist/runtime/wrapped/WrappedProviders.d.ts +33 -0
  119. package/dist/runtime/wrapped/WrappedProviders.js +101 -0
  120. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  121. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  122. package/dist/runtime/wrapped/dom.d.ts +4 -0
  123. package/dist/runtime/wrapped/dom.js +23 -0
  124. package/dist/runtime/wrapped/server.d.ts +80 -0
  125. package/dist/runtime/wrapped/server.js +207 -0
  126. package/dist/scripts/apply-wrap-templates.d.ts +61 -0
  127. package/dist/scripts/apply-wrap-templates.js +137 -0
  128. package/dist/scripts/apply-wrap.d.ts +73 -0
  129. package/dist/scripts/apply-wrap.js +340 -0
  130. package/dist/scripts/cms/discover.d.ts +2 -0
  131. package/dist/scripts/cms/discover.js +17 -0
  132. package/dist/scripts/cms/example-content.d.ts +33 -0
  133. package/dist/scripts/cms/example-content.js +165 -0
  134. package/dist/scripts/cms/strapi/detect.d.ts +2 -0
  135. package/dist/scripts/cms/strapi/detect.js +186 -0
  136. package/dist/scripts/cms/strapi/index.d.ts +3 -0
  137. package/dist/scripts/cms/strapi/index.js +25 -0
  138. package/dist/scripts/cms/strapi/list-source.d.ts +17 -0
  139. package/dist/scripts/cms/strapi/list-source.js +90 -0
  140. package/dist/scripts/cms/strapi/schema-map.d.ts +9 -0
  141. package/dist/scripts/cms/strapi/schema-map.js +289 -0
  142. package/dist/scripts/cms/strapi/shapes.d.ts +25 -0
  143. package/dist/scripts/cms/strapi/shapes.js +48 -0
  144. package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
  145. package/dist/scripts/cms/strapi/touchpoints.js +145 -0
  146. package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
  147. package/dist/scripts/cms/strapi/wire-client.js +107 -0
  148. package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
  149. package/dist/scripts/cms/strapi/wiring.js +46 -0
  150. package/dist/scripts/cms/types.d.ts +145 -0
  151. package/dist/scripts/cms/types.js +11 -0
  152. package/dist/scripts/detect-framework.d.ts +39 -0
  153. package/dist/scripts/detect-framework.js +139 -0
  154. package/dist/scripts/draft-rows.d.ts +19 -0
  155. package/dist/scripts/draft-rows.js +178 -0
  156. package/dist/scripts/draft-schemas.d.ts +27 -0
  157. package/dist/scripts/draft-schemas.js +247 -0
  158. package/dist/scripts/extract-translations.js +16 -11
  159. package/dist/scripts/frameworks/astro/routes.d.ts +3 -0
  160. package/dist/scripts/frameworks/astro/routes.js +44 -0
  161. package/dist/scripts/frameworks/astro/template-style.d.ts +7 -0
  162. package/dist/scripts/frameworks/astro/template-style.js +19 -0
  163. package/dist/scripts/frameworks/discover.d.ts +83 -0
  164. package/dist/scripts/frameworks/discover.js +288 -0
  165. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  166. package/dist/scripts/frameworks/discovered-route.js +17 -0
  167. package/dist/scripts/frameworks/next/rewire.d.ts +43 -0
  168. package/dist/scripts/frameworks/next/rewire.js +282 -0
  169. package/dist/scripts/frameworks/next/routes.d.ts +30 -0
  170. package/dist/scripts/frameworks/next/routes.js +176 -0
  171. package/dist/scripts/frameworks/next/wiring.d.ts +33 -0
  172. package/dist/scripts/frameworks/next/wiring.js +186 -0
  173. package/dist/scripts/frameworks/nuxt/routes.d.ts +3 -0
  174. package/dist/scripts/frameworks/nuxt/routes.js +38 -0
  175. package/dist/scripts/frameworks/nuxt/template-style.d.ts +9 -0
  176. package/dist/scripts/frameworks/nuxt/template-style.js +27 -0
  177. package/dist/scripts/frameworks/remix/routes.d.ts +3 -0
  178. package/dist/scripts/frameworks/remix/routes.js +36 -0
  179. package/dist/scripts/frameworks/support.d.ts +75 -0
  180. package/dist/scripts/frameworks/support.js +165 -0
  181. package/dist/scripts/frameworks/sveltekit/routes.d.ts +3 -0
  182. package/dist/scripts/frameworks/sveltekit/routes.js +35 -0
  183. package/dist/scripts/frameworks/sveltekit/template-style.d.ts +9 -0
  184. package/dist/scripts/frameworks/sveltekit/template-style.js +54 -0
  185. package/dist/scripts/frameworks/vite/routes.d.ts +20 -0
  186. package/dist/scripts/frameworks/vite/routes.js +143 -0
  187. package/dist/scripts/frameworks/vite/wiring.d.ts +22 -0
  188. package/dist/scripts/frameworks/vite/wiring.js +144 -0
  189. package/dist/scripts/i18n-namespace.d.ts +27 -0
  190. package/dist/scripts/i18n-namespace.js +155 -0
  191. package/dist/scripts/lib/humanize.d.ts +14 -0
  192. package/dist/scripts/lib/humanize.js +54 -0
  193. package/dist/scripts/lib/validation-report.d.ts +1 -3
  194. package/dist/scripts/lib/validation-report.js +3 -9
  195. package/dist/scripts/render-parity/capture.d.ts +64 -0
  196. package/dist/scripts/render-parity/capture.js +436 -0
  197. package/dist/scripts/render-parity/compare.d.ts +59 -0
  198. package/dist/scripts/render-parity/compare.js +0 -0
  199. package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
  200. package/dist/scripts/render-parity/normalize-html.js +223 -0
  201. package/dist/scripts/render-parity/types.d.ts +41 -0
  202. package/dist/scripts/render-parity/types.js +1 -0
  203. package/dist/scripts/routes/bindings.d.ts +93 -0
  204. package/dist/scripts/routes/bindings.js +117 -0
  205. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  206. package/dist/scripts/routes/label-prefixes.js +136 -0
  207. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  208. package/dist/scripts/routes/module-resolve.js +78 -0
  209. package/dist/scripts/routes/page-structure.d.ts +72 -0
  210. package/dist/scripts/routes/page-structure.js +234 -0
  211. package/dist/scripts/scan-wrappable.d.ts +200 -0
  212. package/dist/scripts/scan-wrappable.js +1355 -0
  213. package/dist/scripts/validate-route-metadata.js +11 -0
  214. package/dist/scripts/validate-schemas.js +42 -1
  215. package/package.json +88 -4
  216. package/release.json +2 -2
  217. package/templates/react-router/package.json +1 -1
  218. package/templates/react-router/translations/en.json +1 -0
@@ -0,0 +1,1355 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { parseSync } from 'oxc-parser';
4
+ import YAML from 'yaml';
5
+ import { collectScopedBindings, isFunctionLike, resolveScopedKey } from './i18n-namespace.js';
6
+ // Fields whose values act as an entry's identity when another candidate's
7
+ // field points at it. Matching is by VALUE OVERLAP, so a rename-heavy schema
8
+ // still resolves; thresholds keep accidental overlaps out.
9
+ /**
10
+ * Fields whose values identify a row STABLY (kebab slugs, ids, keys).
11
+ * Display fields (name, title) are deliberately excluded: matching them
12
+ * promotes person-name coincidences into references, and staged reference
13
+ * resolution is `by: slug` — a display-name "reference" could never resolve.
14
+ */
15
+ const IDENTITY_FIELDS = ['slug', 'id', 'key'];
16
+ /**
17
+ * Occurrence-capped value collection for reference detection. Identity sets
18
+ * must SPAN the whole collection — a 100-value window over 1,727 authors
19
+ * hides 94% of the identity space and every large relation with it — and
20
+ * coverage is occurrence-weighted, so duplicates are kept. The generous cap
21
+ * only bounds memory on very large sources.
22
+ */
23
+ const FIELD_VALUES_CAP = 20_000;
24
+ function capOccurrences(values) {
25
+ const kept = [];
26
+ for (const value of values) {
27
+ if (value) {
28
+ kept.push(value);
29
+ if (kept.length >= FIELD_VALUES_CAP) {
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ return kept;
35
+ }
36
+ /**
37
+ * Annotate candidates whose field values point at another candidate's
38
+ * identity values: one full-coverage target = fixed reference, several
39
+ * partial targets covering together = polymorphic. Mutates in place.
40
+ *
41
+ * Coverage is OCCURRENCE-weighted and dangling-adjusted: values matching no
42
+ * candidate's identity set are dangling references by definition (deleted
43
+ * targets, sentinels) and drop out of the denominator, weighted by how
44
+ * often they occur — 1,700 case studies pointing at 12 clients with 190
45
+ * distinct ghost values is still a reference field, because 89% of the ROWS
46
+ * resolve. Guards against coincidence: at least 2 distinct matched values
47
+ * and at least half the occurrences must resolve somewhere.
48
+ */
49
+ export function annotateCandidateReferences(candidates) {
50
+ const identitySets = candidates.map((candidate) => {
51
+ const sets = [];
52
+ for (const field of IDENTITY_FIELDS) {
53
+ const values = candidate.fieldValues?.[field];
54
+ if (values && new Set(values).size >= 2) {
55
+ sets.push(new Set(values));
56
+ }
57
+ }
58
+ return sets;
59
+ });
60
+ candidates.forEach((candidate, index) => {
61
+ const references = [];
62
+ for (const [field, rawValues] of Object.entries(candidate.fieldValues ?? {})) {
63
+ const values = rawValues.filter(Boolean);
64
+ if (new Set(values).size < 2) {
65
+ continue;
66
+ }
67
+ const matchesAnywhere = (value) => candidates.some((_, otherIndex) => otherIndex !== index && identitySets[otherIndex].some((set) => set.has(value)));
68
+ const explained = values.filter(matchesAnywhere).length;
69
+ if (explained / values.length < 0.5) {
70
+ continue;
71
+ }
72
+ const coverage = [];
73
+ candidates.forEach((other, otherIndex) => {
74
+ if (otherIndex === index) {
75
+ return;
76
+ }
77
+ let matched = 0;
78
+ let distinctMatched = 0;
79
+ for (const set of identitySets[otherIndex]) {
80
+ const hits = values.filter((value) => set.has(value));
81
+ if (hits.length > matched) {
82
+ matched = hits.length;
83
+ distinctMatched = new Set(hits).size;
84
+ }
85
+ }
86
+ if (matched / values.length >= 0.3) {
87
+ coverage.push({ name: other.name, cover: matched / explained, distinctMatched });
88
+ }
89
+ });
90
+ const full = coverage.filter((target) => target.cover >= 0.8 && target.distinctMatched >= 2);
91
+ const combined = coverage.reduce((sum, target) => sum + target.cover, 0);
92
+ const many = candidate.manyFields?.includes(field) ?? false;
93
+ if (full.length === 1) {
94
+ references.push({ field, targets: [full[0].name], many });
95
+ }
96
+ else if (full.length === 0 && coverage.length >= 2 && combined >= 0.8) {
97
+ references.push({ field, targets: coverage.map((target) => target.name).sort(), many });
98
+ }
99
+ }
100
+ if (references.length > 0) {
101
+ candidate.references = references;
102
+ }
103
+ });
104
+ }
105
+ // Elements whose subtree counts as already wrapped.
106
+ const WRAPPING_COMPONENTS = new Set([
107
+ 'FimoText',
108
+ 'FimoRichTextBlock',
109
+ 'FimoMediaImage',
110
+ 'FimoMediaVideo',
111
+ 'FimoFileLink',
112
+ 'Text',
113
+ 'RichText',
114
+ ]);
115
+ const IMAGE_ELEMENTS = new Set(['img', 'Image']);
116
+ const VIDEO_ELEMENTS = new Set(['video', 'source', 'Video']);
117
+ const WRAPPABLE_ATTRIBUTES = new Set(['placeholder', 'title', 'aria-label']);
118
+ const FILE_LINK_PATTERN = /\.(pdf|zip|docx?|xlsx?|pptx?|csv|mp4|mov|webm)$/i;
119
+ const SKIP_DIRS = new Set(['node_modules', '.next', '.git', 'dist', 'build', '.fimo']);
120
+ const MIN_TEXT_LENGTH = 3;
121
+ /**
122
+ * Prose containers whose children mix raw text with inline elements or
123
+ * expressions. Wrapping such text node-by-node produces FRAGMENT labels
124
+ * (", and agencies building…") that nobody can find or edit as a sentence —
125
+ * the whole block merges into ONE `formatted-text` item instead (agent work:
126
+ * the fimo`` tagged template or RichText, never the auto-wrap codemod).
127
+ */
128
+ const PROSE_TAGS = new Set([
129
+ 'p',
130
+ 'h1',
131
+ 'h2',
132
+ 'h3',
133
+ 'h4',
134
+ 'h5',
135
+ 'h6',
136
+ 'li',
137
+ 'blockquote',
138
+ 'figcaption',
139
+ 'dt',
140
+ 'dd',
141
+ 'label',
142
+ 'legend',
143
+ 'td',
144
+ 'th',
145
+ 'caption',
146
+ 'summary',
147
+ ]);
148
+ function meaningfulLength(text) {
149
+ return text.replace(/[^\p{L}\p{N}]/gu, '').length;
150
+ }
151
+ const PREVIEW_LENGTH = 60;
152
+ function toPreview(value) {
153
+ const collapsed = value.replace(/\s+/g, ' ').trim();
154
+ return collapsed.length > PREVIEW_LENGTH ? `${collapsed.slice(0, PREVIEW_LENGTH)}…` : collapsed;
155
+ }
156
+ function lineOf(code, offset) {
157
+ return code.slice(0, offset).split('\n').length;
158
+ }
159
+ function jsxElementName(node) {
160
+ const name = node?.openingElement?.name ?? node?.name;
161
+ if (!name) {
162
+ return null;
163
+ }
164
+ if (name.type === 'JSXIdentifier') {
165
+ return name.name;
166
+ }
167
+ if (name.type === 'JSXMemberExpression') {
168
+ return name.property?.name ?? null;
169
+ }
170
+ return null;
171
+ }
172
+ function attributeValue(node, attribute) {
173
+ const attrs = node?.openingElement?.attributes ?? [];
174
+ for (const attr of attrs) {
175
+ if (attr.type !== 'JSXAttribute' || attr.name?.name !== attribute) {
176
+ continue;
177
+ }
178
+ const value = attr.value;
179
+ if (value?.type === 'Literal' && typeof value.value === 'string' && value.value.length > 0) {
180
+ return { value: value.value, start: attr.start ?? node.start ?? 0 };
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ const WRAPPED_ID_KIND = {
186
+ FimoText: 'labels',
187
+ Text: 'labels',
188
+ FimoRichTextBlock: 'richText',
189
+ RichText: 'richText',
190
+ FimoMediaImage: 'media',
191
+ FimoMediaVideo: 'media',
192
+ FimoFileLink: 'files',
193
+ };
194
+ // ── Non-JSX template scanning (.astro / .vue / .svelte) ─────────────────────
195
+ // Coverage-only: text nodes are counted as wrappable items (no AST span, so
196
+ // the codemod skips them — the agent wraps via the per-framework t() recipe),
197
+ // and t('key', ...) usages count as wrapped so the gate converges.
198
+ const TEMPLATE_EXPR_ONLY = /^[\s{}]*$|^\{[\s\S]*\}$|^\{\{[\s\S]*\}\}$/;
199
+ /**
200
+ * Blank out <script>/<style>/<title> blocks in a markup body, preserving
201
+ * offsets. Script content is returned so t('key') usages inside it still
202
+ * count as wrapped; style content is dropped (CSS is never copy); <title>
203
+ * is head metadata the DOM edit contract cannot target (SEO surface, not
204
+ * body copy).
205
+ */
206
+ function stripScriptAndStyle(markup) {
207
+ let scripts = '';
208
+ let out = markup.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (block, inner) => {
209
+ scripts += `${inner}\n`;
210
+ return ' '.repeat(block.length);
211
+ });
212
+ // Comments are stripped explicitly: the quote-aware tag splitter must never
213
+ // be asked to eat `<!-- ... -->` (an apostrophe inside opens a fake quote).
214
+ for (const dropRe of [/<!--[\s\S]*?-->/g, /<style\b[^>]*>[\s\S]*?<\/style>/gi, /<title\b[^>]*>[\s\S]*?<\/title>/gi]) {
215
+ out = out.replace(dropRe, (block) => ' '.repeat(block.length));
216
+ }
217
+ return { markup: out, scripts };
218
+ }
219
+ function extractTemplate(ext, code) {
220
+ if (ext === 'astro') {
221
+ // \r?\n: CRLF checkouts exist in the wild (a2-fresh dogfood).
222
+ const match = code.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
223
+ const offset = match ? match[0].length : 0;
224
+ const body = stripScriptAndStyle(code.slice(offset));
225
+ return { template: body.markup, offset, frontmatter: `${match ? match[1] : ''}\n${body.scripts}` };
226
+ }
227
+ if (ext === 'vue') {
228
+ const match = code.match(/<template[^>]*>([\s\S]*)<\/template>/);
229
+ const script = code.match(/<script[^>]*>([\s\S]*?)<\/script>/);
230
+ // The template region can itself contain <title> (inline SVG) — strip it
231
+ // like the astro/svelte branches do.
232
+ const body = stripScriptAndStyle(match ? match[1] : '');
233
+ return {
234
+ template: body.markup,
235
+ offset: match ? code.indexOf(match[1]) : 0,
236
+ frontmatter: `${script ? script[1] : ''}\n${body.scripts}`,
237
+ };
238
+ }
239
+ // svelte: strip script/style/title blocks, keep the markup
240
+ const body = stripScriptAndStyle(code);
241
+ return { template: body.markup, offset: 0, frontmatter: body.scripts };
242
+ }
243
+ function scanTemplateFile(ext, code, relPath) {
244
+ const items = [];
245
+ const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
246
+ let wrapped = 0;
247
+ const { template, offset, frontmatter } = extractTemplate(ext, code);
248
+ // t('key', ...) in frontmatter/script and in template expressions = wrapped.
249
+ for (const source of [frontmatter, template]) {
250
+ for (const match of source.matchAll(/\bt\(\s*['"]([^'"]+)['"]/g)) {
251
+ wrapped += 1;
252
+ wrappedIds.labels.push(match[1]);
253
+ wrappedIds.ordered.push(match[1]);
254
+ }
255
+ }
256
+ // data-fimo-source attributes = wrapped media/labels rendered via attrs.
257
+ wrapped += (template.match(/data-fimo-source/g) ?? []).length;
258
+ // Raw text nodes: strip tags and expressions, then measure leftover copy.
259
+ // Expression removal loops to a fixpoint so nested braces vanish entirely —
260
+ // `{title && <h2>{title}</h2>}` is prop-driven rendering, not copy.
261
+ let withoutExpr = template;
262
+ let before = '';
263
+ while (before !== withoutExpr) {
264
+ before = withoutExpr;
265
+ withoutExpr = withoutExpr.replace(/\{\{[\s\S]*?\}\}|\{[^{}]*\}/g, (m) => ' '.repeat(m.length));
266
+ }
267
+ // Tag-matching tolerates quoted attribute sections: vue templates write
268
+ // `v-if="page > 1"` and a naive <[^>]*> ends the tag at that inner '>'.
269
+ const textChunks = withoutExpr.split(/<(?:"[^"]*"|'[^']*'|[^"'>])*>/);
270
+ let cursor = 0;
271
+ for (const chunk of textChunks) {
272
+ const at = withoutExpr.indexOf(chunk, cursor);
273
+ cursor = at + chunk.length;
274
+ const meaningful = chunk.replace(/[^\p{L}\p{N}]/gu, '');
275
+ if (meaningful.length >= MIN_TEXT_LENGTH && !TEMPLATE_EXPR_ONLY.test(chunk)) {
276
+ items.push({
277
+ kind: 'text',
278
+ file: relPath,
279
+ line: lineOf(code, offset + Math.max(at, 0)),
280
+ preview: toPreview(chunk),
281
+ });
282
+ }
283
+ }
284
+ return { items, wrapped, wrappedIds };
285
+ }
286
+ function unwrapTsExpression(node) {
287
+ let current = node;
288
+ while (current && (current.type === 'TSAsExpression' || current.type === 'TSSatisfiesExpression')) {
289
+ current = current.expression;
290
+ }
291
+ return current;
292
+ }
293
+ /** First array object as NATIVE values (literals + literal arrays only) —
294
+ * the typed input the schema-draft generator infers field types from.
295
+ * Internal like fieldValues: stripped before the plan is written. */
296
+ function literalObjectSample(obj) {
297
+ const out = {};
298
+ for (const prop of obj?.properties ?? []) {
299
+ if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') {
300
+ continue;
301
+ }
302
+ const key = prop.key?.name ?? (typeof prop.key?.value === 'string' ? prop.key.value : null);
303
+ if (!key) {
304
+ continue;
305
+ }
306
+ const value = unwrapTsExpression(prop.value);
307
+ if (value?.type === 'Literal') {
308
+ out[key] = value.value;
309
+ }
310
+ else if (value?.type === 'ArrayExpression') {
311
+ const elements = (value.elements ?? []).map(unwrapTsExpression);
312
+ if (elements.every((el) => el?.type === 'Literal')) {
313
+ out[key] = elements.map((el) => el.value);
314
+ }
315
+ }
316
+ }
317
+ return out;
318
+ }
319
+ function detectCollectionFromDeclarator(declarator, codeForLines, scriptOffset, relPath, out) {
320
+ const init = unwrapTsExpression(declarator?.init);
321
+ if (!init || init.type !== 'ArrayExpression' || !Array.isArray(init.elements) || init.elements.length < 2) {
322
+ return;
323
+ }
324
+ const objects = init.elements.map(unwrapTsExpression);
325
+ if (!objects.every((el) => el?.type === 'ObjectExpression')) {
326
+ return;
327
+ }
328
+ // One level of nesting flattens into dotted keys ("button.text"): pricing
329
+ // tiers keep their copy inside nested objects and would otherwise slip by.
330
+ const stringFields = (obj, prefix = '', depth = 0) => {
331
+ const map = new Map();
332
+ for (const prop of obj.properties ?? []) {
333
+ if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') {
334
+ continue;
335
+ }
336
+ const key = prop.key?.name ?? (typeof prop.key?.value === 'string' ? prop.key.value : null);
337
+ const value = unwrapTsExpression(prop.value);
338
+ if (!key) {
339
+ continue;
340
+ }
341
+ if (value?.type === 'Literal' && typeof value.value === 'string') {
342
+ map.set(`${prefix}${key}`, value.value);
343
+ }
344
+ else if (value?.type === 'ObjectExpression' && depth < 1) {
345
+ for (const [nestedKey, nestedValue] of stringFields(value, `${prefix}${key}.`, depth + 1)) {
346
+ map.set(nestedKey, nestedValue);
347
+ }
348
+ }
349
+ }
350
+ return map;
351
+ };
352
+ // String-array fields (tags: ['a', 'b']) are candidate MANY-references.
353
+ const arrayStringFields = (obj) => {
354
+ const map = new Map();
355
+ for (const prop of obj.properties ?? []) {
356
+ if (prop.type !== 'Property' && prop.type !== 'ObjectProperty') {
357
+ continue;
358
+ }
359
+ const key = prop.key?.name ?? (typeof prop.key?.value === 'string' ? prop.key.value : null);
360
+ const value = unwrapTsExpression(prop.value);
361
+ if (!key || value?.type !== 'ArrayExpression') {
362
+ continue;
363
+ }
364
+ const elements = (value.elements ?? []).map(unwrapTsExpression);
365
+ if (elements.length > 0 && elements.every((el) => el?.type === 'Literal' && typeof el.value === 'string')) {
366
+ map.set(key, elements.map((el) => el.value));
367
+ }
368
+ }
369
+ return map;
370
+ };
371
+ const first = stringFields(objects[0]);
372
+ const common = [...first.keys()].filter((key) => objects.every((obj) => stringFields(obj).has(key)));
373
+ // User-visible heuristic: at least one shared field reads like copy.
374
+ const copyField = common.find((key) => objects.some((obj) => /\s/.test(stringFields(obj).get(key) ?? '')));
375
+ if (common.length === 0 || !copyField) {
376
+ return;
377
+ }
378
+ const fieldValues = {};
379
+ for (const key of common) {
380
+ fieldValues[key] = capOccurrences(objects.map((obj) => stringFields(obj).get(key) ?? ''));
381
+ }
382
+ const manyFields = [];
383
+ const firstArrays = arrayStringFields(objects[0]);
384
+ for (const key of firstArrays.keys()) {
385
+ if (objects.every((obj) => arrayStringFields(obj).has(key))) {
386
+ fieldValues[key] = capOccurrences(objects.flatMap((obj) => arrayStringFields(obj).get(key) ?? []));
387
+ manyFields.push(key);
388
+ }
389
+ }
390
+ out.push({
391
+ name: declarator.id?.name ?? 'items',
392
+ file: relPath,
393
+ line: lineOf(codeForLines, scriptOffset + (declarator.start ?? 0)),
394
+ count: objects.length,
395
+ fields: common.sort(),
396
+ sample: toPreview(stringFields(objects[0]).get(copyField) ?? ''),
397
+ fieldValues,
398
+ typedSample: literalObjectSample(objects[0]),
399
+ typedRows: objects.slice(0, 500).map(literalObjectSample),
400
+ ...(manyFields.length > 0 ? { manyFields } : {}),
401
+ });
402
+ }
403
+ /**
404
+ * Collection candidates inside an SFC script block (.vue `<script setup>`,
405
+ * .astro frontmatter, .svelte scripts) — the pricing/features arrays of
406
+ * config-in-component templates live there, invisible to template text
407
+ * scanning (the nuxtship dogfood's pricing tiers proved it).
408
+ */
409
+ function scanScriptCollections(scriptCode, originalCode, relPath) {
410
+ const collections = [];
411
+ if (!scriptCode.trim()) {
412
+ return collections;
413
+ }
414
+ let program;
415
+ try {
416
+ program = parseSync('inline-script.ts', scriptCode).program;
417
+ }
418
+ catch {
419
+ return collections;
420
+ }
421
+ const base = originalCode.indexOf(scriptCode);
422
+ const codeForLines = base >= 0 ? originalCode : scriptCode;
423
+ const scriptOffset = base >= 0 ? base : 0;
424
+ const walk = (node) => {
425
+ if (!node || typeof node !== 'object') {
426
+ return;
427
+ }
428
+ if (node.type === 'VariableDeclarator') {
429
+ detectCollectionFromDeclarator(node, codeForLines, scriptOffset, relPath, collections);
430
+ }
431
+ for (const value of Object.values(node)) {
432
+ if (Array.isArray(value)) {
433
+ value.forEach(walk);
434
+ }
435
+ else if (value && typeof value === 'object' && value.type) {
436
+ walk(value);
437
+ }
438
+ }
439
+ };
440
+ walk(program);
441
+ return collections;
442
+ }
443
+ /** i18n libraries whose `t`/`useTranslations` is NOT Fimo: their `t('key')`
444
+ * calls read from the library's own message store (locales/*.json), so
445
+ * counting them as Fimo coverage is a lie — editing them in Fimo Studio does
446
+ * nothing to the live site. */
447
+ const FOREIGN_I18N_SOURCES = [
448
+ /^next-intl(\/|$)/,
449
+ /^use-intl(\/|$)/,
450
+ /^react-i18next$/,
451
+ /^i18next$/,
452
+ /^react-intl$/,
453
+ /^@lingui\//,
454
+ ];
455
+ /** Does this file bind `t` from a foreign i18n library (and NOT from Fimo)?
456
+ * Only then are its `t()` calls excluded from the wrapped count. */
457
+ function foreignI18nBinding(ast) {
458
+ let foreign = false;
459
+ let fimo = false;
460
+ for (const node of ast.body ?? []) {
461
+ if (node.type !== 'ImportDeclaration' || typeof node.source?.value !== 'string') {
462
+ continue;
463
+ }
464
+ const src = node.source.value;
465
+ if (/^fimo(\/|$)/.test(src)) {
466
+ fimo = true;
467
+ }
468
+ if (FOREIGN_I18N_SOURCES.some((re) => re.test(src))) {
469
+ foreign = true;
470
+ }
471
+ }
472
+ return foreign && !fimo;
473
+ }
474
+ export function scanWrappableFile(filePath, code, relPath, opts = {}) {
475
+ const items = [];
476
+ let wrapped = 0;
477
+ let foreignI18n = 0;
478
+ const wrappedIds = { labels: [], media: [], richText: [], files: [], ordered: [] };
479
+ const collections = [];
480
+ const templateExt = filePath.match(/\.(astro|vue|svelte)$/)?.[1];
481
+ if (templateExt) {
482
+ const result = scanTemplateFile(templateExt, code, relPath);
483
+ const { frontmatter } = extractTemplate(templateExt, code);
484
+ return { ...result, collections: scanScriptCollections(frontmatter, code, relPath) };
485
+ }
486
+ let ast;
487
+ try {
488
+ ast = parseSync(filePath, code).program;
489
+ }
490
+ catch {
491
+ return { items, wrapped, wrappedIds, collections };
492
+ }
493
+ // A foreign `t()` is Fimo-backed once the message loader is wired to Fimo
494
+ // (the ingested labels flow through it), so it counts as covered.
495
+ const foreignT = !opts.i18nFimoWired && foreignI18nBinding(ast);
496
+ function detectCollection(declarator) {
497
+ detectCollectionFromDeclarator(declarator, code, 0, relPath, collections);
498
+ }
499
+ // Text nodes absorbed into a merged formatted-text item (keyed by offset) —
500
+ // the JSXText visitor skips them so fragments never become labels.
501
+ const mergedText = new Set();
502
+ function containsWrappedContent(node) {
503
+ if (!node || typeof node !== 'object') {
504
+ return false;
505
+ }
506
+ if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 't') {
507
+ return true;
508
+ }
509
+ if (node.type === 'TaggedTemplateExpression' && node.tag?.type === 'Identifier' && node.tag.name === 'fimo') {
510
+ return true;
511
+ }
512
+ if (node.type === 'JSXElement') {
513
+ const name = jsxElementName(node);
514
+ if (name && WRAPPING_COMPONENTS.has(name)) {
515
+ return true;
516
+ }
517
+ }
518
+ for (const key of Object.keys(node)) {
519
+ const child = node[key];
520
+ if (Array.isArray(child)) {
521
+ if (child.some((item) => containsWrappedContent(item))) {
522
+ return true;
523
+ }
524
+ }
525
+ else if (typeof child === 'object' && child !== null && containsWrappedContent(child)) {
526
+ return true;
527
+ }
528
+ }
529
+ return false;
530
+ }
531
+ /** Every JSXText inside a prose element, recursing through inline children
532
+ * (inside a `<p>`, nested elements are inline by HTML rules). */
533
+ function collectProseTextNodes(node, out) {
534
+ for (const child of node.children ?? []) {
535
+ if (child.type === 'JSXText') {
536
+ out.push(child);
537
+ }
538
+ else if (child.type === 'JSXElement') {
539
+ collectProseTextNodes(child, out);
540
+ }
541
+ }
542
+ }
543
+ function visit(node, insideWrapped, parentTag, scopes) {
544
+ if (!node || typeof node !== 'object') {
545
+ return;
546
+ }
547
+ let childInsideWrapped = insideWrapped;
548
+ let childParentTag = parentTag;
549
+ // A function introduces a new scope layer with its useTranslations bindings,
550
+ // so a namespaced `t("leaf")` records the full key (matching the ingested DB).
551
+ const childScopes = isFunctionLike(node) ? [...scopes, collectScopedBindings(node)] : scopes;
552
+ if (node.type === 'JSXElement') {
553
+ const name = jsxElementName(node);
554
+ if (name) {
555
+ childParentTag = name;
556
+ }
557
+ if (name && WRAPPING_COMPONENTS.has(name)) {
558
+ wrapped += 1;
559
+ childInsideWrapped = true;
560
+ const kind = WRAPPED_ID_KIND[name];
561
+ const id = attributeValue(node, 'id');
562
+ if (kind && id) {
563
+ wrappedIds[kind].push(id.value);
564
+ wrappedIds.ordered.push(id.value);
565
+ }
566
+ }
567
+ if (!insideWrapped && name) {
568
+ if (IMAGE_ELEMENTS.has(name)) {
569
+ const src = attributeValue(node, 'src');
570
+ if (src) {
571
+ items.push({
572
+ kind: 'image',
573
+ file: relPath,
574
+ line: lineOf(code, src.start),
575
+ preview: toPreview(src.value),
576
+ span: {
577
+ start: node.start,
578
+ end: node.end,
579
+ openNameStart: node.openingElement?.name?.start,
580
+ openNameEnd: node.openingElement?.name?.end,
581
+ },
582
+ });
583
+ }
584
+ }
585
+ else if (VIDEO_ELEMENTS.has(name)) {
586
+ const src = attributeValue(node, 'src');
587
+ if (src) {
588
+ items.push({ kind: 'video', file: relPath, line: lineOf(code, src.start), preview: toPreview(src.value) });
589
+ }
590
+ }
591
+ else if (name === 'a') {
592
+ const href = attributeValue(node, 'href');
593
+ if (href && FILE_LINK_PATTERN.test(href.value)) {
594
+ items.push({
595
+ kind: 'file-link',
596
+ file: relPath,
597
+ line: lineOf(code, href.start),
598
+ preview: toPreview(href.value),
599
+ span: {
600
+ start: node.start,
601
+ end: node.end,
602
+ openNameStart: node.openingElement?.name?.start,
603
+ openNameEnd: node.openingElement?.name?.end,
604
+ closeNameStart: node.closingElement?.name?.start,
605
+ closeNameEnd: node.closingElement?.name?.end,
606
+ },
607
+ });
608
+ }
609
+ }
610
+ for (const attribute of WRAPPABLE_ATTRIBUTES) {
611
+ const value = attributeValue(node, attribute);
612
+ // Technical enums are not copy: next/image placeholder="blur|empty".
613
+ if (value && attribute === 'placeholder' && /^(blur|empty)$/.test(value.value)) {
614
+ continue;
615
+ }
616
+ if (value) {
617
+ items.push({
618
+ kind: 'attribute',
619
+ file: relPath,
620
+ line: lineOf(code, value.start),
621
+ preview: `${attribute}: ${toPreview(value.value)}`,
622
+ });
623
+ }
624
+ }
625
+ }
626
+ }
627
+ // Mixed prose detection runs on the ELEMENT (before its children are
628
+ // visited): a prose tag whose direct children interleave raw text with
629
+ // inline elements/expressions becomes ONE formatted-text item, and its
630
+ // text nodes are recorded so the JSXText visitor below skips them.
631
+ if (node.type === 'JSXElement' && !insideWrapped) {
632
+ const proseName = jsxElementName(node);
633
+ if (proseName && PROSE_TAGS.has(proseName)) {
634
+ const children = node.children ?? [];
635
+ const directText = children.filter((child) => child.type === 'JSXText' && meaningfulLength(String(child.value ?? '')) > 0);
636
+ const inline = children.filter((child) => child.type === 'JSXElement' ||
637
+ (child.type === 'JSXExpressionContainer' && child.expression?.type !== 'JSXEmptyExpression'));
638
+ if (directText.length > 0 &&
639
+ inline.length > 0 &&
640
+ !directText.some((child) => mergedText.has(child.start)) &&
641
+ !children.some((child) => containsWrappedContent(child))) {
642
+ const textNodes = [];
643
+ collectProseTextNodes(node, textNodes);
644
+ const combined = textNodes.map((textNode) => String(textNode.value ?? '')).join(' ');
645
+ if (meaningfulLength(combined) >= MIN_TEXT_LENGTH) {
646
+ for (const textNode of textNodes) {
647
+ if (typeof textNode.start === 'number') {
648
+ mergedText.add(textNode.start);
649
+ }
650
+ }
651
+ const inner = code.slice(node.openingElement?.end ?? node.start ?? 0, node.closingElement?.start ?? node.end ?? 0);
652
+ items.push({
653
+ kind: 'formatted-text',
654
+ file: relPath,
655
+ line: lineOf(code, node.start ?? 0),
656
+ preview: toPreview(inner),
657
+ span: { start: node.start, end: node.end, parentTag: proseName },
658
+ });
659
+ }
660
+ }
661
+ }
662
+ }
663
+ if (node.type === 'JSXText' && !insideWrapped && !mergedText.has(node.start)) {
664
+ const text = String(node.value ?? '');
665
+ const meaningful = text.replace(/[^\p{L}\p{N}]/gu, '');
666
+ if (meaningful.length >= MIN_TEXT_LENGTH) {
667
+ items.push({
668
+ kind: 'text',
669
+ file: relPath,
670
+ line: lineOf(code, node.start ?? 0),
671
+ preview: toPreview(text),
672
+ span: { start: node.start, end: node.end, parentTag },
673
+ });
674
+ }
675
+ }
676
+ if (node.type === 'VariableDeclarator') {
677
+ detectCollection(node);
678
+ }
679
+ // t('key') calls count as wrapped content — but ONLY when `t` is Fimo's.
680
+ // A file that binds `t` from next-intl/i18next reads its own message store
681
+ // at runtime, so counting those calls as Fimo coverage is a false positive
682
+ // (the honest-agent finding: ~88 pre-existing next-intl keys "covered" but
683
+ // never actually editable in Fimo).
684
+ if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 't') {
685
+ if (foreignT) {
686
+ foreignI18n += 1;
687
+ }
688
+ else {
689
+ wrapped += 1;
690
+ const firstArg = node.arguments?.[0];
691
+ if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
692
+ // Resolve the next-intl namespace so the recorded key matches the
693
+ // ingested (namespaced) label in the DB. A passed-in param `t` is
694
+ // unresolvable (the caller owns the namespace) — the call still counts
695
+ // as wrapped, but we do not demand its bare key from the seeding gate.
696
+ const resolved = resolveScopedKey(node.callee.name, firstArg.value, childScopes);
697
+ if (!resolved.unresolvable) {
698
+ wrappedIds.labels.push(resolved.key);
699
+ wrappedIds.ordered.push(resolved.key);
700
+ }
701
+ }
702
+ }
703
+ }
704
+ for (const key of Object.keys(node)) {
705
+ const child = node[key];
706
+ if (Array.isArray(child)) {
707
+ for (const item of child) {
708
+ visit(item, childInsideWrapped, childParentTag, childScopes);
709
+ }
710
+ }
711
+ else if (typeof child === 'object' && child !== null) {
712
+ visit(child, childInsideWrapped, childParentTag, childScopes);
713
+ }
714
+ }
715
+ }
716
+ visit(ast, false, undefined, []);
717
+ return { items, wrapped, wrappedIds, collections, ...(foreignI18n > 0 ? { foreignI18n } : {}) };
718
+ }
719
+ function collectSourceFiles(dir, out = []) {
720
+ if (!existsSync(dir)) {
721
+ return out;
722
+ }
723
+ for (const entry of readdirSync(dir)) {
724
+ if (SKIP_DIRS.has(entry)) {
725
+ continue;
726
+ }
727
+ const full = join(dir, entry);
728
+ let stats;
729
+ try {
730
+ stats = statSync(full);
731
+ }
732
+ catch {
733
+ continue;
734
+ }
735
+ if (stats.isDirectory()) {
736
+ collectSourceFiles(full, out);
737
+ }
738
+ else if (/\.(ts|tsx|jsx|astro|vue|svelte)$/.test(entry) &&
739
+ !/\.d\.ts$/.test(entry) &&
740
+ !/\.(test|spec)\./.test(entry)) {
741
+ // Plain .ts included: collection arrays live in lib/constants-style
742
+ // modules, not only in JSX files.
743
+ out.push(full);
744
+ }
745
+ }
746
+ return out;
747
+ }
748
+ // ── Data-file copy detection (config-driven sites) ──────────────────────────
749
+ const DATA_KEY_SKIP = new Set([
750
+ 'icon',
751
+ 'href',
752
+ 'url',
753
+ 'src',
754
+ 'slug',
755
+ 'id',
756
+ 'lang',
757
+ 'charset',
758
+ 'type',
759
+ 'logo',
760
+ 'image',
761
+ 'video',
762
+ 'class',
763
+ 'variant',
764
+ 'theme',
765
+ 'color',
766
+ ]);
767
+ function isProseValue(key, value) {
768
+ if (DATA_KEY_SKIP.has(key.toLowerCase()) || key.startsWith('@')) {
769
+ return false;
770
+ }
771
+ const trimmed = value.trim();
772
+ if (trimmed.length < 3 || /^(https?:|mailto:|tel:|[./#@])/.test(trimmed)) {
773
+ return false;
774
+ }
775
+ // Prose has spaces; a short single token is an identifier, not copy.
776
+ return trimmed.includes(' ') || trimmed.length >= 12;
777
+ }
778
+ /**
779
+ * Parse ONE markdown/MDX document into a typed row: YAML frontmatter fields
780
+ * (natively typed — dates, booleans, numbers, arrays) plus a `slug` (from
781
+ * frontmatter or the filename stem) and a `body` holding the raw markdown
782
+ * after the frontmatter, verbatim. The body stays a string so the user's
783
+ * existing markdown renderer keeps rendering it unchanged (wrap mode).
784
+ */
785
+ export function parseMarkdownDoc(fileName, raw) {
786
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
787
+ let frontmatter = {};
788
+ if (match) {
789
+ try {
790
+ const parsed = YAML.parse(match[1]);
791
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
792
+ frontmatter = parsed;
793
+ }
794
+ }
795
+ catch {
796
+ return null;
797
+ }
798
+ }
799
+ const body = raw.slice(match ? match[0].length : 0).trim();
800
+ const stem = fileName.replace(/\.[^.]+$/, '');
801
+ const slug = typeof frontmatter.slug === 'string' && frontmatter.slug.length > 0 ? frontmatter.slug : stem;
802
+ return { ...frontmatter, slug, body };
803
+ }
804
+ /**
805
+ * Read every markdown/MDX document in a directory (non-recursively — one
806
+ * group per directory) into typed rows. Shared by the scanner (sampling for
807
+ * schema drafts + reference detection) and the rows stager (full dataset).
808
+ */
809
+ export function readMarkdownGroupRows(dir) {
810
+ if (!existsSync(dir)) {
811
+ return [];
812
+ }
813
+ const rows = [];
814
+ for (const entry of readdirSync(dir).sort()) {
815
+ if (!/\.(md|mdx)$/.test(entry) || /^(readme|changelog|license|contributing|agents|claude)\.md$/i.test(entry)) {
816
+ continue;
817
+ }
818
+ try {
819
+ const row = parseMarkdownDoc(entry, readFileSync(join(dir, entry), 'utf8'));
820
+ if (row) {
821
+ rows.push(row);
822
+ }
823
+ }
824
+ catch {
825
+ // unreadable document: skip it
826
+ }
827
+ }
828
+ return rows;
829
+ }
830
+ /**
831
+ * Build a DataFileCollection from markdown rows, mirroring scanDataJson's
832
+ * array branch (fieldValues for reference detection, typedSample for type
833
+ * inference) but keeping ALL rows in typedRows and marking `body` verbatim.
834
+ */
835
+ function markdownGroupCollection(name, rows) {
836
+ if (rows.length < 2) {
837
+ return null;
838
+ }
839
+ const shape = Object.keys(rows[0]).sort().join(',');
840
+ if (!rows.every((row) => Object.keys(row).sort().join(',') === shape)) {
841
+ return null;
842
+ }
843
+ const fieldValues = {};
844
+ const manyFields = [];
845
+ for (const field of Object.keys(rows[0])) {
846
+ if (field === 'body') {
847
+ continue; // prose body is not a reference identity
848
+ }
849
+ if (rows.every((row) => typeof row[field] === 'string')) {
850
+ fieldValues[field] = capOccurrences(rows.map((row) => row[field]));
851
+ }
852
+ else if (rows.every((row) => Array.isArray(row[field]) && row[field].every((el) => typeof el === 'string'))) {
853
+ fieldValues[field] = capOccurrences(rows.flatMap((row) => row[field]));
854
+ manyFields.push(field);
855
+ }
856
+ }
857
+ return {
858
+ path: name,
859
+ length: rows.length,
860
+ copyFields: ['body'],
861
+ fieldValues,
862
+ typedSample: rows[0],
863
+ typedRows: rows,
864
+ verbatimFields: ['body'],
865
+ ...(manyFields.length > 0 ? { manyFields } : {}),
866
+ };
867
+ }
868
+ export function scanDataJson(relPath, raw) {
869
+ let parsed;
870
+ try {
871
+ parsed = /\.ya?ml$/.test(relPath) ? YAML.parse(raw) : JSON.parse(raw);
872
+ }
873
+ catch {
874
+ return null;
875
+ }
876
+ // Machine-format payloads whose strings are data, not copy: GeoJSON /
877
+ // TopoJSON name properties (map/globe sites) read as prose but are not
878
+ // site text.
879
+ const rootType = parsed?.type;
880
+ if (typeof rootType === 'string' &&
881
+ ['FeatureCollection', 'Feature', 'Topology', 'GeometryCollection'].includes(rootType)) {
882
+ return null;
883
+ }
884
+ const found = { file: relPath, strings: 0, samples: [], collections: [] };
885
+ const walk = (node, jsonPath, key) => {
886
+ if (typeof node === 'string') {
887
+ if (isProseValue(key, node)) {
888
+ found.strings += 1;
889
+ if (found.samples.length < 3) {
890
+ found.samples.push(node.length > 60 ? `${node.slice(0, 57)}…` : node);
891
+ }
892
+ }
893
+ return;
894
+ }
895
+ if (Array.isArray(node)) {
896
+ const objects = node.filter((entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry));
897
+ if (objects.length >= 2 && objects.length === node.length) {
898
+ const shape = Object.keys(objects[0]).sort().join(',');
899
+ const sameShape = objects.every((entry) => Object.keys(entry).sort().join(',') === shape);
900
+ const copyFields = Object.keys(objects[0]).filter((field) => objects.every((entry) => typeof entry[field] === 'string' && isProseValue(field, entry[field])));
901
+ if (sameShape && copyFields.length > 0) {
902
+ // All common string fields (identity slugs included, prose or not)
903
+ // plus string-array fields feed reference detection.
904
+ const fieldValues = {};
905
+ const manyFields = [];
906
+ const typedSample = objects[0];
907
+ for (const field of Object.keys(objects[0])) {
908
+ if (objects.every((entry) => typeof entry[field] === 'string')) {
909
+ fieldValues[field] = capOccurrences(objects.map((entry) => entry[field]));
910
+ }
911
+ else if (objects.every((entry) => Array.isArray(entry[field]) && entry[field].every((el) => typeof el === 'string'))) {
912
+ fieldValues[field] = capOccurrences(objects.flatMap((entry) => entry[field]));
913
+ manyFields.push(field);
914
+ }
915
+ }
916
+ found.collections.push({
917
+ path: jsonPath || '.',
918
+ length: node.length,
919
+ copyFields,
920
+ fieldValues,
921
+ typedSample,
922
+ ...(manyFields.length > 0 ? { manyFields } : {}),
923
+ });
924
+ }
925
+ }
926
+ node.forEach((entry, index) => walk(entry, `${jsonPath}[${index}]`, key));
927
+ return;
928
+ }
929
+ if (node && typeof node === 'object') {
930
+ for (const [childKey, child] of Object.entries(node)) {
931
+ walk(child, jsonPath ? `${jsonPath}.${childKey}` : childKey, childKey);
932
+ }
933
+ }
934
+ };
935
+ walk(parsed, '', '');
936
+ return found.strings >= 3 || found.collections.length > 0 ? found : null;
937
+ }
938
+ /**
939
+ * The label keys a `decision: "labels"` data file is expected to seed —
940
+ * one per prose string, keyed by JSON path under the recorded prefix. The
941
+ * seeding gate verifies each exists in the database.
942
+ */
943
+ export function dataFileLabelKeys(raw, prefix, filePath = '') {
944
+ let parsed;
945
+ try {
946
+ parsed = /\.ya?ml$/.test(filePath) ? YAML.parse(raw) : JSON.parse(raw);
947
+ }
948
+ catch {
949
+ return [];
950
+ }
951
+ const keys = [];
952
+ const walk = (node, jsonPath, key) => {
953
+ if (typeof node === 'string') {
954
+ if (isProseValue(key, node)) {
955
+ keys.push(`${prefix}${jsonPath}`);
956
+ }
957
+ return;
958
+ }
959
+ if (Array.isArray(node)) {
960
+ node.forEach((entry, index) => walk(entry, `${jsonPath}.${index}`, key));
961
+ return;
962
+ }
963
+ if (node && typeof node === 'object') {
964
+ for (const [childKey, child] of Object.entries(node)) {
965
+ walk(child, jsonPath ? `${jsonPath}.${childKey}` : childKey, childKey);
966
+ }
967
+ }
968
+ };
969
+ walk(parsed, '', '');
970
+ return keys;
971
+ }
972
+ function collectDataFiles(dir, out = []) {
973
+ if (!existsSync(dir)) {
974
+ return out;
975
+ }
976
+ for (const entry of readdirSync(dir)) {
977
+ if (SKIP_DIRS.has(entry)) {
978
+ continue;
979
+ }
980
+ const full = join(dir, entry);
981
+ let stats;
982
+ try {
983
+ stats = statSync(full);
984
+ }
985
+ catch {
986
+ continue;
987
+ }
988
+ if (stats.isDirectory()) {
989
+ collectDataFiles(full, out);
990
+ }
991
+ else if (/\.(json|ya?ml)$/.test(entry) &&
992
+ !/^(package(-lock)?|tsconfig.*|.*\.config)\.json$/.test(entry) &&
993
+ !/^(pnpm-workspace|pnpm-lock|\.?prettierrc.*|renovate)\.(ya?ml)$/.test(entry)) {
994
+ out.push(full);
995
+ }
996
+ }
997
+ return out;
998
+ }
999
+ const INTERNAL_HOSTS = /^(localhost|127\.0\.0\.1|.*\.fimo\.ai)$/;
1000
+ function collectAllScriptFiles(dir, out = []) {
1001
+ if (!existsSync(dir)) {
1002
+ return out;
1003
+ }
1004
+ for (const entry of readdirSync(dir)) {
1005
+ if (SKIP_DIRS.has(entry)) {
1006
+ continue;
1007
+ }
1008
+ const full = join(dir, entry);
1009
+ let stats;
1010
+ try {
1011
+ stats = statSync(full);
1012
+ }
1013
+ catch {
1014
+ continue;
1015
+ }
1016
+ if (stats.isDirectory()) {
1017
+ collectAllScriptFiles(full, out);
1018
+ }
1019
+ else if (/\.(ts|tsx|js|jsx|mjs)$/.test(entry) && !/\.(test|spec)\./.test(entry)) {
1020
+ out.push(full);
1021
+ }
1022
+ }
1023
+ return out;
1024
+ }
1025
+ /** Find render-time fetches of third-party APIs (fetch with an http(s) literal or template prefix). */
1026
+ export function scanExternalSources(rootDir, dirs) {
1027
+ const byHost = new Map();
1028
+ const record = (rawUrl, relPath) => {
1029
+ let host;
1030
+ try {
1031
+ host = new URL(rawUrl).hostname;
1032
+ }
1033
+ catch {
1034
+ return;
1035
+ }
1036
+ if (!host || INTERNAL_HOSTS.test(host)) {
1037
+ return;
1038
+ }
1039
+ const bucket = byHost.get(host) ?? { files: new Set(), count: 0 };
1040
+ bucket.files.add(relPath);
1041
+ bucket.count += 1;
1042
+ byHost.set(host, bucket);
1043
+ };
1044
+ for (const dir of dirs) {
1045
+ for (const file of collectAllScriptFiles(join(rootDir, dir))) {
1046
+ let code;
1047
+ try {
1048
+ code = readFileSync(file, 'utf8');
1049
+ }
1050
+ catch {
1051
+ continue;
1052
+ }
1053
+ const plainScript = /\.(ts|js|mjs)$/.test(file);
1054
+ if (!code.includes('fetch(') && !(plainScript && /https?:\/\//.test(code))) {
1055
+ continue;
1056
+ }
1057
+ const relPath = relative(rootDir, file);
1058
+ let ast;
1059
+ try {
1060
+ ast = parseSync(file, code).program;
1061
+ }
1062
+ catch {
1063
+ continue;
1064
+ }
1065
+ const walk = (node) => {
1066
+ if (!node || typeof node !== 'object') {
1067
+ return;
1068
+ }
1069
+ if (node.type === 'CallExpression') {
1070
+ const callee = node.callee;
1071
+ const isFetch = (callee?.type === 'Identifier' && callee.name === 'fetch') ||
1072
+ (callee?.type === 'MemberExpression' && callee.property?.name === 'fetch');
1073
+ if (isFetch) {
1074
+ const arg = node.arguments?.[0];
1075
+ if (arg?.type === 'Literal' && typeof arg.value === 'string' && /^https?:\/\//.test(arg.value)) {
1076
+ record(arg.value, relPath);
1077
+ }
1078
+ else if (arg?.type === 'TemplateLiteral') {
1079
+ const head = arg.quasis?.[0]?.value?.cooked ?? '';
1080
+ if (/^https?:\/\//.test(head)) {
1081
+ record(head, relPath);
1082
+ }
1083
+ }
1084
+ else if (arg?.type === 'NewExpression' && arg.callee?.name === 'URL') {
1085
+ const urlArg = arg.arguments?.[0];
1086
+ if (urlArg?.type === 'Literal' && typeof urlArg.value === 'string' && /^https?:\/\//.test(urlArg.value)) {
1087
+ record(urlArg.value, relPath);
1088
+ }
1089
+ }
1090
+ }
1091
+ }
1092
+ // API base URLs typically live as constants in plain .ts/.js helper
1093
+ // files, separate from the fetch call itself (hackernews dogfood).
1094
+ // Template/component files are excluded: their URL literals are links.
1095
+ if (plainScript &&
1096
+ node.type === 'VariableDeclarator' &&
1097
+ node.init?.type === 'Literal' &&
1098
+ typeof node.init.value === 'string' &&
1099
+ /^https?:\/\//.test(node.init.value)) {
1100
+ record(node.init.value, relPath);
1101
+ }
1102
+ for (const key of Object.keys(node)) {
1103
+ const child = node[key];
1104
+ if (Array.isArray(child)) {
1105
+ for (const item of child) {
1106
+ walk(item);
1107
+ }
1108
+ }
1109
+ else if (typeof child === 'object' && child !== null) {
1110
+ walk(child);
1111
+ }
1112
+ }
1113
+ };
1114
+ walk(ast);
1115
+ }
1116
+ }
1117
+ const sources = [...byHost.entries()].map(([host, bucket]) => ({
1118
+ host,
1119
+ files: [...bucket.files].sort(),
1120
+ count: bucket.count,
1121
+ kind: 'api',
1122
+ }));
1123
+ // Headless-CMS SDK clients never show up as fetch(url) literals — detect
1124
+ // them from package.json. For these, the site's content LIVES in the CMS.
1125
+ const CMS_SDKS = [
1126
+ ['basehub', 'BaseHub'],
1127
+ ['contentful', 'Contentful'],
1128
+ ['@sanity/client', 'Sanity'],
1129
+ ['next-sanity', 'Sanity'],
1130
+ ['@prismicio/client', 'Prismic'],
1131
+ ['@datocms/cma-client', 'DatoCMS'],
1132
+ ['react-datocms', 'DatoCMS'],
1133
+ ['@storyblok/react', 'Storyblok'],
1134
+ ['storyblok-js-client', 'Storyblok'],
1135
+ ['payload', 'Payload'],
1136
+ ['@hygraph/management-sdk', 'Hygraph'],
1137
+ ];
1138
+ try {
1139
+ const pkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'));
1140
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
1141
+ const seen = new Set();
1142
+ for (const [dep, name] of CMS_SDKS) {
1143
+ if (!name || !deps[dep] || seen.has(name)) {
1144
+ continue;
1145
+ }
1146
+ seen.add(name);
1147
+ sources.push({ host: name, files: ['package.json'], count: 1, kind: 'cms' });
1148
+ }
1149
+ }
1150
+ catch {
1151
+ // no package.json — nothing to add
1152
+ }
1153
+ return sources.sort((a, b) => a.host.localeCompare(b.host));
1154
+ }
1155
+ /**
1156
+ * Scan a project's source directories for unwrapped content. `dirs` defaults
1157
+ * to the common Next/React layouts that exist under `rootDir`.
1158
+ */
1159
+ export function scanWrappable(rootDir, opts = {}) {
1160
+ const dirs = (opts.dirs ?? ['app', 'src', 'pages', 'components']).filter((dir) => existsSync(join(rootDir, dir)));
1161
+ const excluded = (opts.excludePrefixes ?? []).map((prefix) => prefix.replace(/\/+$/, ''));
1162
+ const files = new Set();
1163
+ for (const dir of dirs) {
1164
+ for (const file of collectSourceFiles(join(rootDir, dir))) {
1165
+ const rel = relative(rootDir, file);
1166
+ if (excluded.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`))) {
1167
+ continue;
1168
+ }
1169
+ files.add(file);
1170
+ }
1171
+ }
1172
+ const items = [];
1173
+ const collections = [];
1174
+ let wrappedCount = 0;
1175
+ let foreignI18nCount = 0;
1176
+ const idSets = {
1177
+ labels: new Set(),
1178
+ media: new Set(),
1179
+ richText: new Set(),
1180
+ files: new Set(),
1181
+ };
1182
+ const orderedIds = [];
1183
+ const orderedSeen = new Set();
1184
+ for (const file of files) {
1185
+ let code;
1186
+ try {
1187
+ code = readFileSync(file, 'utf8');
1188
+ }
1189
+ catch {
1190
+ continue;
1191
+ }
1192
+ const result = scanWrappableFile(file, code, relative(rootDir, file), {
1193
+ i18nFimoWired: opts.i18nFimoWired,
1194
+ });
1195
+ items.push(...result.items);
1196
+ collections.push(...result.collections);
1197
+ wrappedCount += result.wrapped;
1198
+ foreignI18nCount += result.foreignI18n ?? 0;
1199
+ for (const kind of ['labels', 'media', 'richText', 'files']) {
1200
+ for (const id of result.wrappedIds[kind]) {
1201
+ idSets[kind].add(id);
1202
+ }
1203
+ }
1204
+ for (const id of result.wrappedIds.ordered) {
1205
+ if (!orderedSeen.has(id)) {
1206
+ orderedSeen.add(id);
1207
+ orderedIds.push(id);
1208
+ }
1209
+ }
1210
+ }
1211
+ const dataFiles = [];
1212
+ // Markdown/MDX documents: group per directory so a 40-post blog is ONE
1213
+ // triage decision, not forty. A directory holding ≥2 content docs is a
1214
+ // group; the per-doc rows are parsed once by markdownGroupCollection.
1215
+ const markdownDirs = new Set();
1216
+ const collectMarkdown = (dir) => {
1217
+ if (!existsSync(dir)) {
1218
+ return;
1219
+ }
1220
+ let hasContentDoc = false;
1221
+ for (const entry of readdirSync(dir)) {
1222
+ if (SKIP_DIRS.has(entry)) {
1223
+ continue;
1224
+ }
1225
+ const full = join(dir, entry);
1226
+ let stats;
1227
+ try {
1228
+ stats = statSync(full);
1229
+ }
1230
+ catch {
1231
+ continue;
1232
+ }
1233
+ if (stats.isDirectory()) {
1234
+ collectMarkdown(full);
1235
+ }
1236
+ else if (/\.(md|mdx)$/.test(entry) &&
1237
+ !/^(readme|changelog|license|contributing|agents|claude)\.md$/i.test(entry)) {
1238
+ hasContentDoc = true;
1239
+ }
1240
+ }
1241
+ if (hasContentDoc) {
1242
+ markdownDirs.add(relative(rootDir, dir) || '.');
1243
+ }
1244
+ };
1245
+ // Frameworks may keep page copy outside the source roots — those extra
1246
+ // directories come from the framework registry (frameworks/discover.ts),
1247
+ // never from names hardcoded here.
1248
+ const dataDirs = [...dirs, ...(opts.dataDirs ?? []).filter((dir) => existsSync(join(rootDir, dir)))];
1249
+ for (const dir of dataDirs) {
1250
+ for (const file of collectDataFiles(join(rootDir, dir))) {
1251
+ const rel = relative(rootDir, file);
1252
+ if (excluded.some((prefix) => rel === prefix || rel.startsWith(`${prefix}/`))) {
1253
+ continue;
1254
+ }
1255
+ try {
1256
+ const detected = scanDataJson(rel, readFileSync(file, 'utf8'));
1257
+ if (detected) {
1258
+ dataFiles.push(detected);
1259
+ }
1260
+ }
1261
+ catch {
1262
+ // unreadable data file: not copy we can act on
1263
+ }
1264
+ }
1265
+ }
1266
+ for (const dir of dataDirs) {
1267
+ collectMarkdown(join(rootDir, dir));
1268
+ }
1269
+ for (const groupKey of [...markdownDirs].sort()) {
1270
+ const rows = readMarkdownGroupRows(join(rootDir, groupKey));
1271
+ if (rows.length === 0) {
1272
+ continue;
1273
+ }
1274
+ // A same-shaped group of ≥2 docs becomes a stageable collection (schema
1275
+ // draft + rows), exactly like a JSON data file; a ragged or single-doc
1276
+ // group stays a bare markdown triage item for the agent to decide.
1277
+ const name = groupKey.split('/').filter(Boolean).pop() ?? 'content';
1278
+ const collection = markdownGroupCollection(name, rows);
1279
+ const samples = rows
1280
+ .map((row) => (typeof row.body === 'string' ? toPreview(row.body) : ''))
1281
+ .filter(Boolean)
1282
+ .slice(0, 2);
1283
+ dataFiles.push({
1284
+ file: `${groupKey}/*.md`,
1285
+ strings: rows.length,
1286
+ samples,
1287
+ collections: collection ? [collection] : [],
1288
+ kind: 'markdown',
1289
+ count: rows.length,
1290
+ });
1291
+ }
1292
+ dataFiles.sort((a, b) => a.file.localeCompare(b.file));
1293
+ items.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
1294
+ collections.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
1295
+ // Cross-candidate reference detection in ONE unified pass: in-code
1296
+ // candidates AND data-file collections all match against each other —
1297
+ // posts.json pointing at authors.json (the most common real-world shape)
1298
+ // is a reference like any other. Data-file matchables are named like the
1299
+ // schema drafts name them (json path, else the file stem) so annotations,
1300
+ // drafts, and staged rows stay in one namespace. The raw value sets are
1301
+ // internal — strip them once annotated.
1302
+ const dataFileMatchables = dataFiles.flatMap((dataFile) => dataFile.collections.map((collection) => {
1303
+ const stem = dataFile.file.replace(/^.*\//, '').replace(/\.[a-z]+$/i, '');
1304
+ const pathName = (collection.path ?? '').replace(/^\$\.?/, '').replace(/^\.+$/, '');
1305
+ return {
1306
+ collection,
1307
+ matchable: {
1308
+ name: pathName || stem,
1309
+ fieldValues: collection.fieldValues,
1310
+ manyFields: collection.manyFields,
1311
+ },
1312
+ };
1313
+ }));
1314
+ annotateCandidateReferences([...collections, ...dataFileMatchables.map((entry) => entry.matchable)]);
1315
+ for (const entry of dataFileMatchables) {
1316
+ if (entry.matchable.references) {
1317
+ entry.collection.references = entry.matchable.references;
1318
+ }
1319
+ }
1320
+ if (!opts.keepInternal) {
1321
+ for (const candidate of collections) {
1322
+ delete candidate.fieldValues;
1323
+ delete candidate.typedSample;
1324
+ delete candidate.manyFields;
1325
+ }
1326
+ for (const dataFile of dataFiles) {
1327
+ for (const collection of dataFile.collections) {
1328
+ delete collection.fieldValues;
1329
+ delete collection.typedSample;
1330
+ delete collection.typedRows;
1331
+ delete collection.manyFields;
1332
+ delete collection.verbatimFields;
1333
+ }
1334
+ }
1335
+ }
1336
+ return {
1337
+ items,
1338
+ collections,
1339
+ dataFiles,
1340
+ // Live data sources often sit in backend/helper roots (server routes,
1341
+ // shared utils) rather than page roots — sweep those too.
1342
+ externalSources: scanExternalSources(rootDir, [...new Set([...dirs, 'server', 'utils', 'lib'])].filter((dir) => existsSync(join(rootDir, dir)))),
1343
+ wrappedCount,
1344
+ foreignI18nCount,
1345
+ filesScanned: files.size,
1346
+ wrappedIds: {
1347
+ labels: [...idSets.labels].sort(),
1348
+ media: [...idSets.media].sort(),
1349
+ richText: [...idSets.richText].sort(),
1350
+ files: [...idSets.files].sort(),
1351
+ // Encounter order, never sorted — the on-page reading order.
1352
+ ordered: orderedIds,
1353
+ },
1354
+ };
1355
+ }