fimo 0.6.0 → 0.7.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 (297) hide show
  1. package/assets/skills/fimo/SKILL.md +2 -2
  2. package/assets/skills/fimo/references/locales.md +0 -8
  3. package/assets/skills/fimo/references/setup-react-router.md +2 -2
  4. package/assets/skills/fimo/references/ui.md +1 -1
  5. package/assets/skills/fimo-cli/references/content.md +0 -3
  6. package/assets/skills/fimo-cli/references/locales.md +0 -6
  7. package/assets/skills/fimo-migration/SKILL.md +22 -2
  8. package/assets/skills/fimo-migration/references/content.md +8 -0
  9. package/assets/skills/fimo-migration/references/manifest.md +35 -0
  10. package/assets/skills/fimo-migration/references/qa.md +24 -2
  11. package/assets/skills/fimo-migration/references/workflow.md +12 -1
  12. package/assets/skills/fimo-migration/references/wrap.md +617 -0
  13. package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
  14. package/dist/build/vite/plugins/translations.d.ts +2 -8
  15. package/dist/build/vite/plugins/translations.d.ts.map +1 -1
  16. package/dist/build/vite/plugins/translations.js +3 -121
  17. package/dist/cli/bundle.json +1 -1
  18. package/dist/cli/index.js +29490 -21165
  19. package/dist/runtime/app/FimoProviders.d.ts +1 -11
  20. package/dist/runtime/app/FimoProviders.d.ts.map +1 -1
  21. package/dist/runtime/app/FimoProviders.js +2 -23
  22. package/dist/runtime/astro/index.d.ts +2 -0
  23. package/dist/runtime/astro/index.d.ts.map +1 -0
  24. package/dist/runtime/astro/index.js +3 -0
  25. package/dist/runtime/astro/server.d.ts +3 -0
  26. package/dist/runtime/astro/server.d.ts.map +1 -0
  27. package/dist/runtime/astro/server.js +6 -0
  28. package/dist/runtime/i18n/FimoLocaleContext.d.ts.map +1 -1
  29. package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
  30. package/dist/runtime/next/FimoNextProviders.d.ts +24 -0
  31. package/dist/runtime/next/FimoNextProviders.d.ts.map +1 -0
  32. package/dist/runtime/next/FimoNextProviders.js +20 -0
  33. package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
  34. package/dist/runtime/next/FimoNextScripts.d.ts.map +1 -0
  35. package/dist/runtime/next/FimoNextScripts.js +19 -0
  36. package/dist/runtime/next/index.d.ts +14 -0
  37. package/dist/runtime/next/index.d.ts.map +1 -0
  38. package/dist/runtime/next/index.js +14 -0
  39. package/dist/runtime/next/server.d.ts +2 -0
  40. package/dist/runtime/next/server.d.ts.map +1 -0
  41. package/dist/runtime/next/server.js +4 -0
  42. package/dist/runtime/nuxt/composables.d.ts +9 -0
  43. package/dist/runtime/nuxt/composables.d.ts.map +1 -0
  44. package/dist/runtime/nuxt/composables.js +57 -0
  45. package/dist/runtime/nuxt/index.d.ts +3 -0
  46. package/dist/runtime/nuxt/index.d.ts.map +1 -0
  47. package/dist/runtime/nuxt/index.js +6 -0
  48. package/dist/runtime/nuxt/server.d.ts +3 -0
  49. package/dist/runtime/nuxt/server.d.ts.map +1 -0
  50. package/dist/runtime/nuxt/server.js +6 -0
  51. package/dist/runtime/paths/get-fimo-paths.d.ts.map +1 -1
  52. package/dist/runtime/paths/get-fimo-paths.js +5 -15
  53. package/dist/runtime/primitives/labels-hook.d.ts +33 -0
  54. package/dist/runtime/primitives/labels-hook.d.ts.map +1 -0
  55. package/dist/runtime/primitives/labels-hook.js +41 -0
  56. package/dist/runtime/primitives/labels-store.d.ts +12 -0
  57. package/dist/runtime/primitives/labels-store.d.ts.map +1 -0
  58. package/dist/runtime/primitives/labels-store.js +60 -0
  59. package/dist/runtime/primitives/labels-store.test.d.ts +2 -0
  60. package/dist/runtime/primitives/labels-store.test.d.ts.map +1 -0
  61. package/dist/runtime/primitives/labels-store.test.js +66 -0
  62. package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
  63. package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts.map +1 -1
  64. package/dist/runtime/primitives/lib/useFimoLiveValue.js +11 -4
  65. package/dist/runtime/primitives/translations.d.ts +1 -27
  66. package/dist/runtime/primitives/translations.d.ts.map +1 -1
  67. package/dist/runtime/primitives/translations.js +7 -85
  68. package/dist/runtime/react/FimoSpaProviders.d.ts +36 -0
  69. package/dist/runtime/react/FimoSpaProviders.d.ts.map +1 -0
  70. package/dist/runtime/react/FimoSpaProviders.js +63 -0
  71. package/dist/runtime/react/index.d.ts +33 -0
  72. package/dist/runtime/react/index.d.ts.map +1 -0
  73. package/dist/runtime/react/index.js +39 -0
  74. package/dist/runtime/remix/FimoRemixProviders.d.ts +16 -0
  75. package/dist/runtime/remix/FimoRemixProviders.d.ts.map +1 -0
  76. package/dist/runtime/remix/FimoRemixProviders.js +12 -0
  77. package/dist/runtime/remix/FimoRemixScripts.d.ts +10 -0
  78. package/dist/runtime/remix/FimoRemixScripts.d.ts.map +1 -0
  79. package/dist/runtime/remix/FimoRemixScripts.js +19 -0
  80. package/dist/runtime/remix/index.d.ts +12 -0
  81. package/dist/runtime/remix/index.d.ts.map +1 -0
  82. package/dist/runtime/remix/index.js +11 -0
  83. package/dist/runtime/remix/server.d.ts +2 -0
  84. package/dist/runtime/remix/server.d.ts.map +1 -0
  85. package/dist/runtime/remix/server.js +2 -0
  86. package/dist/runtime/seo/buildSeoForPath.d.ts +3 -1
  87. package/dist/runtime/seo/buildSeoForPath.d.ts.map +1 -1
  88. package/dist/runtime/seo/buildSeoForPath.js +1 -4
  89. package/dist/runtime/shared/display-routes.d.ts +14 -0
  90. package/dist/runtime/shared/display-routes.d.ts.map +1 -0
  91. package/dist/runtime/shared/display-routes.js +25 -0
  92. package/dist/runtime/shared/fimo-config.server.d.ts.map +1 -1
  93. package/dist/runtime/shared/fimo-config.server.js +1 -0
  94. package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
  95. package/dist/runtime/shared/fimo-config.types.d.ts.map +1 -1
  96. package/dist/runtime/shared/i18n-messages.d.ts +29 -0
  97. package/dist/runtime/shared/i18n-messages.d.ts.map +1 -0
  98. package/dist/runtime/shared/i18n-messages.js +79 -0
  99. package/dist/runtime/shared/i18n-messages.test.d.ts +2 -0
  100. package/dist/runtime/shared/i18n-messages.test.d.ts.map +1 -0
  101. package/dist/runtime/shared/i18n-messages.test.js +39 -0
  102. package/dist/runtime/shared/labels-fetch.pure.d.ts +17 -0
  103. package/dist/runtime/shared/labels-fetch.pure.d.ts.map +1 -0
  104. package/dist/runtime/shared/labels-fetch.pure.js +71 -0
  105. package/dist/runtime/shared/labels-fetch.server.d.ts +7 -0
  106. package/dist/runtime/shared/labels-fetch.server.d.ts.map +1 -0
  107. package/dist/runtime/shared/labels-fetch.server.js +97 -0
  108. package/dist/runtime/shared/labels-t.pure.d.ts +2 -0
  109. package/dist/runtime/shared/labels-t.pure.d.ts.map +1 -0
  110. package/dist/runtime/shared/labels-t.pure.js +10 -0
  111. package/dist/runtime/strapi/client.d.ts +26 -0
  112. package/dist/runtime/strapi/client.d.ts.map +1 -0
  113. package/dist/runtime/strapi/client.js +88 -0
  114. package/dist/runtime/strapi/client.test.d.ts +2 -0
  115. package/dist/runtime/strapi/client.test.d.ts.map +1 -0
  116. package/dist/runtime/strapi/client.test.js +64 -0
  117. package/dist/runtime/strapi/index.d.ts +5 -0
  118. package/dist/runtime/strapi/index.d.ts.map +1 -0
  119. package/dist/runtime/strapi/index.js +6 -0
  120. package/dist/runtime/strapi/reshape.d.ts +28 -0
  121. package/dist/runtime/strapi/reshape.d.ts.map +1 -0
  122. package/dist/runtime/strapi/reshape.js +79 -0
  123. package/dist/runtime/strapi/reshape.test.d.ts +2 -0
  124. package/dist/runtime/strapi/reshape.test.d.ts.map +1 -0
  125. package/dist/runtime/strapi/reshape.test.js +33 -0
  126. package/dist/runtime/svelte/index.d.ts +3 -0
  127. package/dist/runtime/svelte/index.d.ts.map +1 -0
  128. package/dist/runtime/svelte/index.js +7 -0
  129. package/dist/runtime/svelte/server.d.ts +3 -0
  130. package/dist/runtime/svelte/server.d.ts.map +1 -0
  131. package/dist/runtime/svelte/server.js +6 -0
  132. package/dist/runtime/vue/index.d.ts +3 -0
  133. package/dist/runtime/vue/index.d.ts.map +1 -0
  134. package/dist/runtime/vue/index.js +5 -0
  135. package/dist/runtime/vue/server.d.ts +3 -0
  136. package/dist/runtime/vue/server.d.ts.map +1 -0
  137. package/dist/runtime/vue/server.js +6 -0
  138. package/dist/runtime/wrapped/FimoFileLink.d.ts +17 -0
  139. package/dist/runtime/wrapped/FimoFileLink.d.ts.map +1 -0
  140. package/dist/runtime/wrapped/FimoFileLink.js +19 -0
  141. package/dist/runtime/wrapped/FimoMediaImage.d.ts +14 -0
  142. package/dist/runtime/wrapped/FimoMediaImage.d.ts.map +1 -0
  143. package/dist/runtime/wrapped/FimoMediaImage.js +20 -0
  144. package/dist/runtime/wrapped/FimoMediaVideo.d.ts +14 -0
  145. package/dist/runtime/wrapped/FimoMediaVideo.d.ts.map +1 -0
  146. package/dist/runtime/wrapped/FimoMediaVideo.js +20 -0
  147. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +23 -0
  148. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts.map +1 -0
  149. package/dist/runtime/wrapped/FimoRichTextBlock.js +22 -0
  150. package/dist/runtime/wrapped/FimoText.d.ts +22 -0
  151. package/dist/runtime/wrapped/FimoText.d.ts.map +1 -0
  152. package/dist/runtime/wrapped/FimoText.js +20 -0
  153. package/dist/runtime/wrapped/WrappedContentContext.d.ts +28 -0
  154. package/dist/runtime/wrapped/WrappedContentContext.d.ts.map +1 -0
  155. package/dist/runtime/wrapped/WrappedContentContext.js +30 -0
  156. package/dist/runtime/wrapped/WrappedProviders.d.ts +33 -0
  157. package/dist/runtime/wrapped/WrappedProviders.d.ts.map +1 -0
  158. package/dist/runtime/wrapped/WrappedProviders.js +101 -0
  159. package/dist/runtime/wrapped/dom.d.ts +4 -0
  160. package/dist/runtime/wrapped/dom.d.ts.map +1 -0
  161. package/dist/runtime/wrapped/dom.js +23 -0
  162. package/dist/runtime/wrapped/dom.test.d.ts +2 -0
  163. package/dist/runtime/wrapped/dom.test.d.ts.map +1 -0
  164. package/dist/runtime/wrapped/dom.test.js +16 -0
  165. package/dist/runtime/wrapped/server.d.ts +77 -0
  166. package/dist/runtime/wrapped/server.d.ts.map +1 -0
  167. package/dist/runtime/wrapped/server.js +237 -0
  168. package/dist/runtime/wrapped/server.test.d.ts +2 -0
  169. package/dist/runtime/wrapped/server.test.d.ts.map +1 -0
  170. package/dist/runtime/wrapped/server.test.js +91 -0
  171. package/dist/scripts/apply-wrap-templates.d.ts +61 -0
  172. package/dist/scripts/apply-wrap-templates.js +132 -0
  173. package/dist/scripts/apply-wrap-templates.test.d.ts +1 -0
  174. package/dist/scripts/apply-wrap-templates.test.js +199 -0
  175. package/dist/scripts/apply-wrap.d.ts +46 -0
  176. package/dist/scripts/apply-wrap.js +278 -0
  177. package/dist/scripts/apply-wrap.test.d.ts +1 -0
  178. package/dist/scripts/apply-wrap.test.js +226 -0
  179. package/dist/scripts/cms/discover.d.ts +2 -0
  180. package/dist/scripts/cms/discover.js +17 -0
  181. package/dist/scripts/cms/example-content.d.ts +32 -0
  182. package/dist/scripts/cms/example-content.js +108 -0
  183. package/dist/scripts/cms/example-content.test.d.ts +1 -0
  184. package/dist/scripts/cms/example-content.test.js +66 -0
  185. package/dist/scripts/cms/strapi/detect.d.ts +2 -0
  186. package/dist/scripts/cms/strapi/detect.js +186 -0
  187. package/dist/scripts/cms/strapi/detect.test.d.ts +1 -0
  188. package/dist/scripts/cms/strapi/detect.test.js +56 -0
  189. package/dist/scripts/cms/strapi/index.d.ts +3 -0
  190. package/dist/scripts/cms/strapi/index.js +25 -0
  191. package/dist/scripts/cms/strapi/list-source.d.ts +17 -0
  192. package/dist/scripts/cms/strapi/list-source.js +90 -0
  193. package/dist/scripts/cms/strapi/list-source.test.d.ts +1 -0
  194. package/dist/scripts/cms/strapi/list-source.test.js +33 -0
  195. package/dist/scripts/cms/strapi/schema-map.d.ts +9 -0
  196. package/dist/scripts/cms/strapi/schema-map.js +149 -0
  197. package/dist/scripts/cms/strapi/schema-map.test.d.ts +1 -0
  198. package/dist/scripts/cms/strapi/schema-map.test.js +91 -0
  199. package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
  200. package/dist/scripts/cms/strapi/touchpoints.js +145 -0
  201. package/dist/scripts/cms/strapi/touchpoints.test.d.ts +1 -0
  202. package/dist/scripts/cms/strapi/touchpoints.test.js +62 -0
  203. package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
  204. package/dist/scripts/cms/strapi/wire-client.js +107 -0
  205. package/dist/scripts/cms/strapi/wire-client.test.d.ts +1 -0
  206. package/dist/scripts/cms/strapi/wire-client.test.js +51 -0
  207. package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
  208. package/dist/scripts/cms/strapi/wiring.js +36 -0
  209. package/dist/scripts/cms/strapi/wiring.test.d.ts +1 -0
  210. package/dist/scripts/cms/strapi/wiring.test.js +27 -0
  211. package/dist/scripts/cms/types.d.ts +117 -0
  212. package/dist/scripts/cms/types.js +11 -0
  213. package/dist/scripts/detect-framework.d.ts +39 -0
  214. package/dist/scripts/detect-framework.js +133 -0
  215. package/dist/scripts/detect-framework.test.d.ts +1 -0
  216. package/dist/scripts/detect-framework.test.js +122 -0
  217. package/dist/scripts/draft-rows.d.ts +19 -0
  218. package/dist/scripts/draft-rows.js +135 -0
  219. package/dist/scripts/draft-rows.test.d.ts +1 -0
  220. package/dist/scripts/draft-rows.test.js +113 -0
  221. package/dist/scripts/draft-schemas.d.ts +27 -0
  222. package/dist/scripts/draft-schemas.js +188 -0
  223. package/dist/scripts/draft-schemas.test.d.ts +1 -0
  224. package/dist/scripts/draft-schemas.test.js +152 -0
  225. package/dist/scripts/extract-translations.js +16 -11
  226. package/dist/scripts/extract-translations.test.d.ts +1 -0
  227. package/dist/scripts/extract-translations.test.js +66 -0
  228. package/dist/scripts/frameworks/astro/routes.d.ts +3 -0
  229. package/dist/scripts/frameworks/astro/routes.js +55 -0
  230. package/dist/scripts/frameworks/astro/template-style.d.ts +7 -0
  231. package/dist/scripts/frameworks/astro/template-style.js +19 -0
  232. package/dist/scripts/frameworks/discover.d.ts +70 -0
  233. package/dist/scripts/frameworks/discover.js +178 -0
  234. package/dist/scripts/frameworks/next/rewire.d.ts +30 -0
  235. package/dist/scripts/frameworks/next/rewire.js +258 -0
  236. package/dist/scripts/frameworks/next/rewire.test.d.ts +1 -0
  237. package/dist/scripts/frameworks/next/rewire.test.js +134 -0
  238. package/dist/scripts/frameworks/next/routes.d.ts +17 -0
  239. package/dist/scripts/frameworks/next/routes.js +134 -0
  240. package/dist/scripts/frameworks/next/routes.test.d.ts +1 -0
  241. package/dist/scripts/frameworks/next/routes.test.js +64 -0
  242. package/dist/scripts/frameworks/next/wiring.d.ts +33 -0
  243. package/dist/scripts/frameworks/next/wiring.js +186 -0
  244. package/dist/scripts/frameworks/next/wiring.test.d.ts +1 -0
  245. package/dist/scripts/frameworks/next/wiring.test.js +174 -0
  246. package/dist/scripts/frameworks/nuxt/routes.d.ts +3 -0
  247. package/dist/scripts/frameworks/nuxt/routes.js +54 -0
  248. package/dist/scripts/frameworks/nuxt/template-style.d.ts +9 -0
  249. package/dist/scripts/frameworks/nuxt/template-style.js +27 -0
  250. package/dist/scripts/frameworks/remix/routes.d.ts +3 -0
  251. package/dist/scripts/frameworks/remix/routes.js +50 -0
  252. package/dist/scripts/frameworks/support.d.ts +75 -0
  253. package/dist/scripts/frameworks/support.js +165 -0
  254. package/dist/scripts/frameworks/support.test.d.ts +1 -0
  255. package/dist/scripts/frameworks/support.test.js +87 -0
  256. package/dist/scripts/frameworks/sveltekit/routes.d.ts +3 -0
  257. package/dist/scripts/frameworks/sveltekit/routes.js +50 -0
  258. package/dist/scripts/frameworks/sveltekit/template-style.d.ts +9 -0
  259. package/dist/scripts/frameworks/sveltekit/template-style.js +54 -0
  260. package/dist/scripts/frameworks/vite/routes.d.ts +15 -0
  261. package/dist/scripts/frameworks/vite/routes.js +93 -0
  262. package/dist/scripts/frameworks/vite/routes.test.d.ts +1 -0
  263. package/dist/scripts/frameworks/vite/routes.test.js +73 -0
  264. package/dist/scripts/frameworks/vite/wiring.d.ts +10 -0
  265. package/dist/scripts/frameworks/vite/wiring.js +99 -0
  266. package/dist/scripts/frameworks/vite/wiring.test.d.ts +1 -0
  267. package/dist/scripts/frameworks/vite/wiring.test.js +88 -0
  268. package/dist/scripts/i18n-namespace.d.ts +25 -0
  269. package/dist/scripts/i18n-namespace.js +53 -0
  270. package/dist/scripts/lib/validation-report.d.ts +1 -0
  271. package/dist/scripts/lib/validation-report.js +3 -0
  272. package/dist/scripts/render-parity/capture.d.ts +56 -0
  273. package/dist/scripts/render-parity/capture.js +366 -0
  274. package/dist/scripts/render-parity/capture.test.d.ts +1 -0
  275. package/dist/scripts/render-parity/capture.test.js +306 -0
  276. package/dist/scripts/render-parity/compare.d.ts +59 -0
  277. package/dist/scripts/render-parity/compare.js +0 -0
  278. package/dist/scripts/render-parity/compare.test.d.ts +1 -0
  279. package/dist/scripts/render-parity/compare.test.js +98 -0
  280. package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
  281. package/dist/scripts/render-parity/normalize-html.js +223 -0
  282. package/dist/scripts/render-parity/normalize-html.test.d.ts +1 -0
  283. package/dist/scripts/render-parity/normalize-html.test.js +58 -0
  284. package/dist/scripts/render-parity/types.d.ts +41 -0
  285. package/dist/scripts/render-parity/types.js +1 -0
  286. package/dist/scripts/resolve-routes.js +1 -1
  287. package/dist/scripts/scan-wrappable.d.ts +196 -0
  288. package/dist/scripts/scan-wrappable.js +1227 -0
  289. package/dist/scripts/scan-wrappable.test.d.ts +1 -0
  290. package/dist/scripts/scan-wrappable.test.js +521 -0
  291. package/dist/scripts/validate-route-metadata.js +11 -0
  292. package/package.json +92 -5
  293. package/release.json +2 -2
  294. package/templates/react-router/package.json +7 -10
  295. package/dist/runtime/paths/get-fimo-paths.test.d.ts +0 -2
  296. package/dist/runtime/paths/get-fimo-paths.test.d.ts.map +0 -1
  297. package/dist/runtime/paths/get-fimo-paths.test.js +0 -69
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,521 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { annotateCandidateReferences, scanDataJson, scanWrappableFile } from './scan-wrappable.js';
3
+ const scan = (code) => scanWrappableFile('/test/page.tsx', code, 'app/page.tsx');
4
+ describe('scanWrappableFile', () => {
5
+ it('records next-intl t() keys with their useTranslations namespace once i18n is Fimo-wired', () => {
6
+ const code = `import { useTranslations } from 'next-intl';
7
+ export function Checkout() {
8
+ const t = useTranslations('plans.pricingCheckoutModal');
9
+ return <button aria-label={t('buyNow')}>{t('cancel')}</button>;
10
+ }`;
11
+ // Foreign-but-Fimo-wired: the t() calls count as wrapped, and the recorded
12
+ // key must be the FULL namespaced form (matching the ingested DB label),
13
+ // not the bare leaf — the seeding gate compares against these.
14
+ const wired = scanWrappableFile('/x/checkout.tsx', code, 'app/checkout.tsx', { i18nFimoWired: true });
15
+ expect(wired.wrappedIds.labels.sort()).toEqual([
16
+ 'plans.pricingCheckoutModal.buyNow',
17
+ 'plans.pricingCheckoutModal.cancel',
18
+ ]);
19
+ // Not yet wired: still foreign, counted separately, not claimed as coverage.
20
+ const foreign = scanWrappableFile('/x/checkout.tsx', code, 'app/checkout.tsx');
21
+ expect(foreign.wrappedIds.labels).toEqual([]);
22
+ expect(foreign.foreignI18n).toBe(2);
23
+ });
24
+ it('finds hardcoded JSX text with its line', () => {
25
+ const { items } = scan(`export default function Page() {
26
+ return (
27
+ <div>
28
+ <h1>Ship your site in hours</h1>
29
+ </div>
30
+ );
31
+ }`);
32
+ expect(items).toMatchObject([{ kind: 'text', file: 'app/page.tsx', line: 4, preview: 'Ship your site in hours' }]);
33
+ expect(items[0].span).toMatchObject({ parentTag: 'h1' });
34
+ });
35
+ it('skips whitespace and short fragments', () => {
36
+ const { items } = scan(`const x = <div> {'a'} <span>ok</span> </div>;`);
37
+ expect(items.filter((i) => i.kind === 'text')).toEqual([]);
38
+ });
39
+ it('finds images, videos, file links, and attribute strings', () => {
40
+ const { items } = scan(`const x = (
41
+ <div>
42
+ <img src="/images/hero.png" alt="Hero" />
43
+ <video src="/demo.mp4" />
44
+ <a href="/whitepaper.pdf">Download</a>
45
+ <input placeholder="Your work email" />
46
+ </div>
47
+ );`);
48
+ expect(items.map((i) => i.kind).sort()).toEqual(['attribute', 'file-link', 'image', 'text', 'video']);
49
+ expect(items.find((i) => i.kind === 'attribute')?.preview).toBe('placeholder: Your work email');
50
+ });
51
+ it('treats content inside Fimo wrappers as wrapped', () => {
52
+ const { items, wrapped } = scan(`const x = (
53
+ <div>
54
+ <FimoText id="home.hero.title">Ship your site in hours</FimoText>
55
+ <FimoMediaImage id="home.hero.image" src="/images/hero.png" alt="Hero" />
56
+ </div>
57
+ );`);
58
+ expect(items).toEqual([]);
59
+ expect(wrapped).toBe(2);
60
+ });
61
+ it('collects wrapped ids grouped by kind', () => {
62
+ const { wrappedIds } = scan(`const x = (
63
+ <div>
64
+ <FimoText id="home.hero.title">Title</FimoText>
65
+ <FimoRichTextBlock id="home.hero.body">Body</FimoRichTextBlock>
66
+ <FimoMediaImage id="home.hero.image" src="/a.png" />
67
+ <FimoFileLink id="home.hero.pdf" href="/a.pdf">PDF</FimoFileLink>
68
+ <input placeholder={String(t('home.hero.email', 'Email'))} />
69
+ </div>
70
+ );`);
71
+ expect(wrappedIds).toEqual({
72
+ labels: ['home.hero.title', 'home.hero.email'],
73
+ media: ['home.hero.image'],
74
+ richText: ['home.hero.body'],
75
+ files: ['home.hero.pdf'],
76
+ });
77
+ });
78
+ it('counts t() calls as wrapped and skips regular links', () => {
79
+ const { items, wrapped } = scan(`const x = <input placeholder={String(t('home.email', 'Your email'))} />;
80
+ const y = <a href="/about">About</a>;`);
81
+ expect(items.filter((i) => i.kind === 'attribute')).toEqual([]);
82
+ expect(items.filter((i) => i.kind === 'file-link')).toEqual([]);
83
+ expect(wrapped).toBe(1);
84
+ });
85
+ });
86
+ describe('collection candidates', () => {
87
+ it('detects array literals of same-shaped objects with copy fields', () => {
88
+ const { collections } = scan(`const focusItems = [
89
+ { key: "Product", title: "Product Engineering Services", body: "I plan, build, and ship products." },
90
+ { key: "OSS", title: "Open Source and Dev Tooling", body: "I create reusable components." },
91
+ ] as const;
92
+ export function X() { return <div>{focusItems.map((i) => <p key={i.key}>{i.title}</p>)}</div>; }`);
93
+ expect(collections).toMatchObject([
94
+ { name: 'focusItems', count: 2, fields: ['body', 'key', 'title'], file: 'app/page.tsx' },
95
+ ]);
96
+ });
97
+ it('ignores config arrays without copy and short lists', () => {
98
+ const { collections } = scan(`const sizes = [{ w: "sm" }, { w: "lg" }];
99
+ const single = [{ title: "Only one entry here" }];
100
+ export function X() { return null; }`);
101
+ expect(collections).toEqual([]);
102
+ });
103
+ });
104
+ describe('reference detection across collection candidates', () => {
105
+ const postsAndCategories = (postsExtra = '') => `
106
+ const categories = [
107
+ { slug: "tools", title: "Great tools to use" },
108
+ { slug: "react", title: "React patterns explained" },
109
+ { slug: "career", title: "Career growth advice" },
110
+ ];
111
+ const authors = [
112
+ { slug: "jane-doe", name: "Jane Doe", bio: "Writes about frontend things" },
113
+ { slug: "sam-lee", name: "Sam Lee", bio: "Backend and infra columns" },
114
+ ];
115
+ const posts = [
116
+ { title: "Picking your stack wisely", category: "tools", author: "jane-doe", tags: ["tools", "react"]${postsExtra} },
117
+ { title: "Hooks beyond the basics", category: "react", author: "sam-lee", tags: ["react"]${postsExtra} },
118
+ { title: "Negotiating your next role", category: "career", author: "jane-doe", tags: ["career", "tools"]${postsExtra} },
119
+ ];
120
+ export default function Page() {
121
+ return <div>{posts.map((p) => <span>{p.title}</span>)}</div>;
122
+ }`;
123
+ it('flags fixed and many references by identity-value overlap', () => {
124
+ const result = scanWrappableFile('/x/page.tsx', postsAndCategories(), 'app/blog/page.tsx');
125
+ annotateCandidateReferences(result.collections);
126
+ const posts = result.collections.find((candidate) => candidate.name === 'posts');
127
+ expect(posts?.references).toEqual([
128
+ { field: 'category', targets: ['categories'], many: false },
129
+ { field: 'author', targets: ['authors'], many: false },
130
+ { field: 'tags', targets: ['categories'], many: true },
131
+ ]);
132
+ const categories = result.collections.find((candidate) => candidate.name === 'categories');
133
+ expect(categories?.references).toBeUndefined();
134
+ });
135
+ it('still detects a reference when dangling sentinels dilute distinct coverage (the camp9 miss)', () => {
136
+ // 4 real authors + 3 distinct dangling values: raw distinct coverage is
137
+ // 4/7 ≈ 0.57 (< 0.8), but every unmatched value is dangling — the
138
+ // dangling-adjusted coverage is 4/4 = 1.0.
139
+ const code = `
140
+ const authors = [
141
+ { slug: "jane-doe", name: "Jane Doe", bio: "Writes about frontend things" },
142
+ { slug: "sam-lee", name: "Sam Lee", bio: "Backend and infra columns" },
143
+ { slug: "ana-ruiz", name: "Ana Ruiz", bio: "Design systems and tokens" },
144
+ { slug: "kit-tran", name: "Kit Tran", bio: "Performance budgets and CWV" },
145
+ ];
146
+ const posts = [
147
+ { title: "Stack wisdom", author: "jane-doe" },
148
+ { title: "Hooks again", author: "sam-lee" },
149
+ { title: "Tokens talk", author: "ana-ruiz" },
150
+ { title: "Budget lines", author: "kit-tran" },
151
+ { title: "Orphan one", author: "missing-authors-101" },
152
+ { title: "Orphan two", author: "missing-authors-202" },
153
+ { title: "Orphan three", author: "missing-authors-303" },
154
+ ];
155
+ export default function Page() {
156
+ return <div>{posts.map((p) => <span>{p.title}</span>)}</div>;
157
+ }`;
158
+ const result = scanWrappableFile('/x/page.tsx', code, 'app/blog/page.tsx');
159
+ annotateCandidateReferences(result.collections);
160
+ const posts = result.collections.find((candidate) => candidate.name === 'posts');
161
+ expect(posts?.references).toEqual([{ field: 'author', targets: ['authors'], many: false }]);
162
+ });
163
+ it('detects references across LARGE collections (identity sets are not window-capped)', () => {
164
+ // 1,500 authors referenced by 3,000 posts (+10% dangling): the old
165
+ // 100-value window saw ~7% of the identity space and dropped the
166
+ // relation entirely at this scale (the camp9/camp10 miss).
167
+ const authors = Array.from({ length: 1500 }, (_, index) => ({
168
+ slug: `author-${index}`,
169
+ name: `Author Number ${index}`,
170
+ bio: `Writes the column number ${index} about harbors`,
171
+ }));
172
+ const posts = Array.from({ length: 3000 }, (_, index) => ({
173
+ slug: `post-${index}`,
174
+ title: `Post number ${index} about the tide`,
175
+ author: index % 10 === 9 ? `missing-authors-${index}` : `author-${index % 1500}`,
176
+ }));
177
+ const result = scanDataJson('src/data/authors.json', JSON.stringify(authors));
178
+ const postsScan = scanDataJson('src/data/posts.json', JSON.stringify(posts));
179
+ const matchables = [
180
+ { name: 'authors', fieldValues: result.collections[0].fieldValues, manyFields: [] },
181
+ { name: 'posts', fieldValues: postsScan.collections[0].fieldValues, manyFields: [] },
182
+ ];
183
+ annotateCandidateReferences(matchables);
184
+ expect(matchables[1].references).toEqual([
185
+ { field: 'author', targets: ['authors'], many: false },
186
+ ]);
187
+ });
188
+ it('does not promote a field whose values mostly match nothing (coincidence guard)', () => {
189
+ const code = `
190
+ const authors = [
191
+ { slug: "red", name: "Red Woods", bio: "Writes the forest column" },
192
+ { slug: "blue", name: "Blue Lake", bio: "Covers the waterline" },
193
+ { slug: "jane-doe", name: "Jane Doe", bio: "Writes about frontend" },
194
+ ];
195
+ const cards = [
196
+ { label: "One", color: "red" },
197
+ { label: "Two", color: "blue" },
198
+ { label: "Three", color: "crimson" },
199
+ { label: "Four", color: "teal" },
200
+ { label: "Five", color: "amber" },
201
+ ];
202
+ export default function Page() {
203
+ return <div>{cards.map((c) => <span>{c.label}</span>)}</div>;
204
+ }`;
205
+ const result = scanWrappableFile('/x/page.tsx', code, 'app/page.tsx');
206
+ annotateCandidateReferences(result.collections);
207
+ const cards = result.collections.find((candidate) => candidate.name === 'cards');
208
+ expect(cards?.references).toBeUndefined();
209
+ });
210
+ it('flags polymorphic references when values split across two candidates', () => {
211
+ const code = `
212
+ const articles = [
213
+ { slug: "intro-post", title: "An introduction post" },
214
+ { slug: "deep-dive", title: "A deep dive article" },
215
+ ];
216
+ const videos = [
217
+ { slug: "launch-video", title: "The launch keynote video" },
218
+ { slug: "demo-video", title: "A recorded product demo" },
219
+ ];
220
+ const featured = [
221
+ { label: "First featured slot", item: "intro-post" },
222
+ { label: "Second featured slot", item: "launch-video" },
223
+ { label: "Third featured slot", item: "deep-dive" },
224
+ { label: "Fourth featured slot", item: "demo-video" },
225
+ ];
226
+ export default function Page() {
227
+ return <div>{featured.map((f) => <span>{f.label}</span>)}</div>;
228
+ }`;
229
+ const result = scanWrappableFile('/x/page.tsx', code, 'app/page.tsx');
230
+ annotateCandidateReferences(result.collections);
231
+ const featured = result.collections.find((candidate) => candidate.name === 'featured');
232
+ expect(featured?.references).toEqual([{ field: 'item', targets: ['articles', 'videos'], many: false }]);
233
+ });
234
+ it('annotates across files and strips internal value sets at scan level', async () => {
235
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
236
+ const { tmpdir } = await import('node:os');
237
+ const { join } = await import('node:path');
238
+ const { scanWrappable } = await import('./scan-wrappable.js');
239
+ const root = mkdtempSync(join(tmpdir(), 'ref-scan-'));
240
+ try {
241
+ mkdirSync(join(root, 'app'), { recursive: true });
242
+ writeFileSync(join(root, 'app', 'categories.ts'), `export const categories = [
243
+ { slug: "tools", title: "Great tools to use" },
244
+ { slug: "react", title: "React patterns explained" },
245
+ ];\n`);
246
+ writeFileSync(join(root, 'app', 'page.tsx'), `const posts = [
247
+ { title: "Picking your stack wisely", category: "tools" },
248
+ { title: "Hooks beyond the basics", category: "react" },
249
+ ];
250
+ export default function Page() {
251
+ return <div>{posts.map((p) => <span>{p.title}</span>)}</div>;
252
+ }\n`);
253
+ const result = scanWrappable(root, { dirs: ['app'] });
254
+ const posts = result.collections.find((candidate) => candidate.name === 'posts');
255
+ expect(posts?.references).toEqual([{ field: 'category', targets: ['categories'], many: false }]);
256
+ for (const candidate of result.collections) {
257
+ expect(candidate.fieldValues).toBeUndefined();
258
+ }
259
+ }
260
+ finally {
261
+ rmSync(root, { recursive: true, force: true });
262
+ }
263
+ });
264
+ });
265
+ describe('data-file copy detection', () => {
266
+ it('flags a config-driven copy file with collection shapes, skipping identifiers and URLs', () => {
267
+ const detected = scanDataJson('src/data/landing.json', JSON.stringify({
268
+ meta: { title: 'Luna Landing your perfect landing page', lang: 'en', url: 'https://x.dev' },
269
+ hero: { title: 'Open Your Mind to the Universe', primaryCta: 'Github Repo', icon: 'DevIcon' },
270
+ services: [
271
+ { title: 'Professional Templates', icon: 'DevIcon', description: 'Crafted by experienced designers' },
272
+ { title: 'Code Quality', icon: 'CheckIcon', description: 'Readable and maintainable output' },
273
+ ],
274
+ }));
275
+ expect(detected).not.toBeNull();
276
+ expect(detected.strings).toBeGreaterThanOrEqual(5);
277
+ expect(detected.collections).toHaveLength(1);
278
+ expect(detected.collections[0]).toMatchObject({
279
+ path: 'services',
280
+ length: 2,
281
+ copyFields: ['title', 'description'],
282
+ });
283
+ });
284
+ it('parses YAML data files (Nuxt Content index.yml) with the same walker', () => {
285
+ const detected = scanDataJson('content/index.yml', [
286
+ 'title: "Observe less. Understand more."',
287
+ 'description: Map your entire system in real-time and surface anomalies.',
288
+ 'hero:',
289
+ ' headline: "v2.0 — Now with predictive alerting"',
290
+ ' links:',
291
+ ' - label: Start for free',
292
+ ' to: "#"',
293
+ ' - label: View the demo now',
294
+ ' to: "#"',
295
+ ].join('\n'));
296
+ expect(detected).not.toBeNull();
297
+ expect(detected.strings).toBeGreaterThanOrEqual(3);
298
+ });
299
+ it('ignores GeoJSON payloads whose name properties read like prose', () => {
300
+ const detected = scanDataJson('src/lib/world.json', JSON.stringify({
301
+ type: 'FeatureCollection',
302
+ features: [
303
+ { type: 'Feature', properties: { name: 'Bosnia and Herzegovina' }, geometry: {} },
304
+ { type: 'Feature', properties: { name: 'United Arab Emirates' }, geometry: {} },
305
+ { type: 'Feature', properties: { name: 'Papua New Guinea' }, geometry: {} },
306
+ ],
307
+ }));
308
+ expect(detected).toBeNull();
309
+ });
310
+ it('ignores non-copy json (identifiers, paths, short tokens)', () => {
311
+ const detected = scanDataJson('src/data/routes.json', JSON.stringify({
312
+ routes: [
313
+ { id: 'a', href: '/a' },
314
+ { id: 'b', href: '/b' },
315
+ ],
316
+ theme: 'dark',
317
+ }));
318
+ expect(detected).toBeNull();
319
+ });
320
+ });
321
+ describe('markdown prose triage', () => {
322
+ it('groups md/mdx documents per directory into dataFiles', async () => {
323
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
324
+ const { tmpdir } = await import('node:os');
325
+ const { join } = await import('node:path');
326
+ const { scanWrappable } = await import('./scan-wrappable.js');
327
+ const root = mkdtempSync(join(tmpdir(), 'md-scan-'));
328
+ try {
329
+ mkdirSync(join(root, 'src', 'posts'), { recursive: true });
330
+ const prose = Array.from({ length: 4 }, (_, i) => `This is a long enough prose paragraph number ${i} for the post.`).join('\n');
331
+ writeFileSync(join(root, 'src', 'posts', 'one.md'), `---\ntitle: One\n---\n${prose}\n`);
332
+ writeFileSync(join(root, 'src', 'posts', 'two.mdx'), `${prose}\n`);
333
+ writeFileSync(join(root, 'src', 'README.md'), `${prose}\n`);
334
+ const result = scanWrappable(root, { dirs: ['src'] });
335
+ const groups = result.dataFiles.filter((f) => f.kind === 'markdown');
336
+ expect(groups).toHaveLength(1);
337
+ expect(groups[0]).toMatchObject({ file: 'src/posts/*.md', count: 2 });
338
+ }
339
+ finally {
340
+ rmSync(root, { recursive: true, force: true });
341
+ }
342
+ });
343
+ });
344
+ describe('scanExternalSources', () => {
345
+ it('records API base URLs declared as constants in plain script files', async () => {
346
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
347
+ const { tmpdir } = await import('node:os');
348
+ const { join } = await import('node:path');
349
+ const { scanWrappable } = await import('./scan-wrappable.js');
350
+ const root = mkdtempSync(join(tmpdir(), 'ext-src-'));
351
+ try {
352
+ mkdirSync(join(root, 'server'), { recursive: true });
353
+ writeFileSync(join(root, 'server', 'constants.ts'), "export const API = 'https://data.example-api.dev/v0';\n");
354
+ mkdirSync(join(root, 'app'), { recursive: true });
355
+ writeFileSync(join(root, 'app', 'page.tsx'), 'export const links = <a href="https://github.com/x">gh</a>;\n');
356
+ const result = scanWrappable(root, { dirs: ['app', 'server'] });
357
+ const hosts = result.externalSources.map((source) => source.host);
358
+ expect(hosts).toContain('data.example-api.dev');
359
+ expect(hosts).not.toContain('github.com');
360
+ }
361
+ finally {
362
+ rmSync(root, { recursive: true, force: true });
363
+ }
364
+ });
365
+ it('finds third-party fetches by host, ignoring internal ones', async () => {
366
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
367
+ const { tmpdir } = await import('node:os');
368
+ const { join } = await import('node:path');
369
+ const dir = mkdtempSync(join(tmpdir(), 'ext-src-'));
370
+ try {
371
+ mkdirSync(join(dir, 'src/lib'), { recursive: true });
372
+ writeFileSync(join(dir, 'src/lib/data.ts'), 'export async function x(u: string) {\n' +
373
+ ' await fetch("https://api.example.com/articles");\n' +
374
+ ' await fetch(`https://cdn.example.net/users/${u}/repos`);\n' +
375
+ ' await fetch("http://localhost:3000/api/labels");\n' +
376
+ ' await fetch("/api/relative");\n' +
377
+ '}\n');
378
+ const { scanExternalSources } = await import('./scan-wrappable.js');
379
+ const sources = scanExternalSources(dir, ['src']);
380
+ expect(sources.map((source) => source.host)).toEqual(['api.example.com', 'cdn.example.net']);
381
+ expect(sources[0].files).toEqual(['src/lib/data.ts']);
382
+ }
383
+ finally {
384
+ rmSync(dir, { recursive: true, force: true });
385
+ }
386
+ });
387
+ });
388
+ describe('CMS SDK detection', () => {
389
+ // Any entry of the CMS_SDKS list works here — the LIST is the contract,
390
+ // no vendor is special-cased.
391
+ it('flags headless-CMS dependencies as cms sources', async () => {
392
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
393
+ const { tmpdir } = await import('node:os');
394
+ const { join } = await import('node:path');
395
+ const dir = mkdtempSync(join(tmpdir(), 'cms-'));
396
+ try {
397
+ mkdirSync(join(dir, 'src'), { recursive: true });
398
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { contentful: '^11.0.0', next: '^16' } }));
399
+ writeFileSync(join(dir, 'src/a.tsx'), 'export const x = 1;');
400
+ const { scanExternalSources } = await import('./scan-wrappable.js');
401
+ const sources = scanExternalSources(dir, ['src']);
402
+ expect(sources).toMatchObject([{ host: 'Contentful', kind: 'cms', files: ['package.json'] }]);
403
+ }
404
+ finally {
405
+ rmSync(dir, { recursive: true, force: true });
406
+ }
407
+ });
408
+ });
409
+ describe('template scanning (.astro/.vue/.svelte)', () => {
410
+ it('counts raw text as items and t()/data-fimo-source as wrapped', () => {
411
+ const astro = scanWrappableFile('/x/page.astro', `---\nimport { loadFimoT } from 'fimo/astro/server';\nconst t = await loadFimoT();\n---\n<h1>{t('home.title', 'Hello')}</h1>\n<p>Raw hardcoded copy here</p>`, 'src/pages/index.astro');
412
+ expect(astro.wrapped).toBeGreaterThanOrEqual(1);
413
+ expect(astro.wrappedIds.labels).toContain('home.title');
414
+ expect(astro.items.map((i) => i.preview)).toContain('Raw hardcoded copy here');
415
+ const vue = scanWrappableFile('/x/index.vue', `<template>\n <h1>{{ t('a.b', 'Hi') }}</h1>\n <p>Plain vue text content</p>\n</template>\n<script setup>const x = 1;</script>`, 'pages/index.vue');
416
+ expect(vue.wrappedIds.labels).toContain('a.b');
417
+ expect(vue.items.map((i) => i.preview)).toContain('Plain vue text content');
418
+ const svelte = scanWrappableFile('/x/+page.svelte', `<script>let n = 1;</script>\n<h1 data-fimo-source="__labels.en.k.value">Val</h1>\n<p>Svelte hardcoded words</p>`, 'src/routes/+page.svelte');
419
+ expect(svelte.wrapped).toBeGreaterThanOrEqual(1);
420
+ expect(svelte.items.map((i) => i.preview)).toContain('Svelte hardcoded words');
421
+ });
422
+ it('never reports astro <script>/<style> bodies or nested {expressions} as copy', () => {
423
+ const astro = scanWrappableFile('/x/page.astro', [
424
+ '---',
425
+ 'const { title, body } = Astro.props;',
426
+ '---',
427
+ '<script>',
428
+ ' import { timeline } from "motion";',
429
+ ' const sequence = [loaderAnimation];',
430
+ '</script>',
431
+ '<style>',
432
+ ' @font-face { font-family: X; } body { color: red; }',
433
+ '</style>',
434
+ '{title && <h2 class="text-xl">{title}</h2>}',
435
+ '{body && <p>{body}</p>}',
436
+ '<p>Actual astro copy stays visible</p>',
437
+ ].join('\n'), 'src/components/Content.astro');
438
+ expect(astro.items.map((i) => i.preview)).toEqual(['Actual astro copy stays visible']);
439
+ const copy = astro.items[0];
440
+ expect(copy.line).toBe(13);
441
+ });
442
+ it('strips HTML comments, including ones with apostrophes', () => {
443
+ const astro = scanWrappableFile('/x/Layout.astro', `---\n---\n<!-- Basic OG tags for sharing your website's content -->\n<p>Layout visible copy</p>`, 'src/layouts/Layout.astro');
444
+ expect(astro.items.map((i) => i.preview)).toEqual(['Layout visible copy']);
445
+ });
446
+ it('handles > inside quoted attributes (v-if="page > 1")', () => {
447
+ const vue = scanWrappableFile('/x/Nav.vue', `<template>\n<a v-if="page > 1" :to="\`/\${feed}/\${page - 1}\`">&lt; prev</a>\n<span>Real nav copy</span>\n</template>`, 'components/Nav.vue');
448
+ expect(vue.items.map((i) => i.preview)).toEqual(['&lt; prev', 'Real nav copy']);
449
+ });
450
+ it('skips inline-svg <title> inside vue templates', () => {
451
+ const vue = scanWrappableFile('/x/Navbar.vue', `<template>\n<svg><title>Menu</title><path d="M0 0"/></svg>\n<p>Visible vue nav copy</p>\n</template>`, 'components/Navbar.vue');
452
+ expect(vue.items.map((i) => i.preview)).toEqual(['Visible vue nav copy']);
453
+ });
454
+ it('skips <title> head metadata (svelte:head and astro layouts)', () => {
455
+ const svelte = scanWrappableFile('/x/+page.svelte', `<svelte:head>\n <title>Home page name</title>\n</svelte:head>\n<p>Visible svelte body copy</p>`, 'src/routes/+page.svelte');
456
+ expect(svelte.items.map((i) => i.preview)).toEqual(['Visible svelte body copy']);
457
+ });
458
+ it('detects collection candidates inside SFC script blocks (pricing tiers in script setup)', () => {
459
+ const vue = scanWrappableFile('/x/pricing.vue', [
460
+ '<script setup>',
461
+ 'const pricing = [',
462
+ ' { name: "Personal", price: "Free tier", button: "Get Started" },',
463
+ ' { name: "Startup", price: "$19 monthly", button: "Try free" },',
464
+ '];',
465
+ '</script>',
466
+ '<template><div v-for="tier in pricing">{{ tier.name }}</div></template>',
467
+ ].join('\n'), 'pages/pricing.vue');
468
+ expect(vue.collections).toHaveLength(1);
469
+ expect(vue.collections[0]).toMatchObject({ name: 'pricing', count: 2, file: 'pages/pricing.vue', line: 2 });
470
+ });
471
+ it('handles CRLF astro frontmatter fences', () => {
472
+ const astro = scanWrappableFile('/x/page.astro', "---\r\nimport Layout from '../layouts/Default.astro'\r\n---\r\n<p>Windows checkout copy</p>\r\n", 'src/pages/index.astro');
473
+ expect(astro.items.map((i) => i.preview)).toEqual(['Windows checkout copy']);
474
+ });
475
+ it('counts t() inside astro template <script> blocks as wrapped', () => {
476
+ const astro = scanWrappableFile('/x/page.astro', `---\n---\n<script>\n const label = t('nav.home', 'Home');\n</script>\n<h1>Hi there friend</h1>`, 'src/pages/index.astro');
477
+ expect(astro.wrappedIds.labels).toContain('nav.home');
478
+ });
479
+ });
480
+ describe('foreign i18n coverage (the next-intl false-positive)', () => {
481
+ it('does NOT count t() as wrapped when t is bound from next-intl', () => {
482
+ const { wrapped, wrappedIds } = scan(`import { useTranslations } from 'next-intl';
483
+ export default function Page() {
484
+ const t = useTranslations('home');
485
+ return <div><h1>{t('title')}</h1><p>{t('subtitle')}</p></div>;
486
+ }`);
487
+ expect(wrapped).toBe(0);
488
+ expect(wrappedIds.labels).toEqual([]);
489
+ });
490
+ it('DOES count t() as wrapped when t is Fimo (useLabels)', () => {
491
+ const { wrapped, wrappedIds } = scan(`import { useLabels } from 'fimo/next';
492
+ export default function Page() {
493
+ const { t } = useLabels();
494
+ return <div><h1>{t('home.title', 'Title')}</h1></div>;
495
+ }`);
496
+ expect(wrapped).toBe(1);
497
+ expect(wrappedIds.labels).toEqual(['home.title']);
498
+ });
499
+ it('reports the foreign i18n count at the scan level', async () => {
500
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
501
+ const { tmpdir } = await import('node:os');
502
+ const { join } = await import('node:path');
503
+ const { scanWrappable } = await import('./scan-wrappable.js');
504
+ const root = mkdtempSync(join(tmpdir(), 'foreign-i18n-'));
505
+ try {
506
+ mkdirSync(join(root, 'app'), { recursive: true });
507
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 't', dependencies: { next: '^15.0.0' } }));
508
+ writeFileSync(join(root, 'app', 'page.tsx'), `import { useTranslations } from 'next-intl';
509
+ export default function Page() {
510
+ const t = useTranslations();
511
+ return <div>{t('a')} {t('b')} {t('c')}</div>;
512
+ }`);
513
+ const result = scanWrappable(root);
514
+ expect(result.foreignI18nCount).toBe(3);
515
+ expect(result.wrappedCount).toBe(0);
516
+ }
517
+ finally {
518
+ rmSync(root, { recursive: true, force: true });
519
+ }
520
+ });
521
+ });
@@ -31,6 +31,17 @@ export function validateRouteMetadata(options = {}) {
31
31
  const srcDir = path.resolve(cwd, 'src');
32
32
  const routesFilePath = path.resolve(srcDir, 'routes.ts');
33
33
  const errors = [];
34
+ // Everything below the i18n checks cross-validates React Router's
35
+ // src/routes.ts. Adopted (wrapped) projects on another framework declare it
36
+ // in fimo-config.json — their routes come from framework scans, so only the
37
+ // framework-neutral config checks apply.
38
+ const earlyConfig = readJson(configFilePath);
39
+ const framework = typeof earlyConfig.framework === 'string' ? earlyConfig.framework : 'react-router';
40
+ if (framework !== 'react-router') {
41
+ const i18n = validateI18nConfig(earlyConfig);
42
+ const configRoutes = loadConfigRoutes(earlyConfig);
43
+ return { errors: i18n.errors, routesChecked: configRoutes.length };
44
+ }
34
45
  // Parse routes from src/routes.ts (file → path mapping)
35
46
  let fileRoutes = [];
36
47
  if (!fs.existsSync(routesFilePath)) {