fimo 0.8.0 → 0.9.0-experimental.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/assets/content-templates/hooks-template.eta +4 -2
  2. package/assets/content-templates/types-template.eta +66 -1
  3. package/assets/skills/fimo/SKILL.md +1 -1
  4. package/assets/skills/fimo/references/content.md +86 -15
  5. package/assets/skills/fimo-cli/SKILL.md +6 -2
  6. package/assets/skills/fimo-cli/references/content.md +61 -2
  7. package/assets/skills/fimo-cli/references/integrations/firecrawl.md +20 -4
  8. package/assets/skills/fimo-cli/references/integrations.md +5 -1
  9. package/assets/skills/fimo-cli/references/webhooks.md +162 -0
  10. package/assets/skills/fimo-migration/SKILL.md +22 -2
  11. package/assets/skills/fimo-migration/references/content.md +24 -0
  12. package/assets/skills/fimo-migration/references/manifest.md +35 -0
  13. package/assets/skills/fimo-migration/references/qa.md +24 -2
  14. package/assets/skills/fimo-migration/references/workflow.md +12 -1
  15. package/assets/skills/fimo-migration/references/wrap.md +617 -0
  16. package/assets/skills/fimo-studio/SKILL.md +5 -3
  17. package/assets/skills/fimo-studio/references/cms.md +9 -1
  18. package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
  19. package/assets/skills/fimo-studio/references/webhooks.md +42 -0
  20. package/dist/build/vite/plugins/translations.d.ts +2 -8
  21. package/dist/build/vite/plugins/translations.js +3 -121
  22. package/dist/cli/bundle.json +1 -1
  23. package/dist/cli/index.js +1051 -144
  24. package/dist/runtime/app/FimoProviders.d.ts +1 -11
  25. package/dist/runtime/app/FimoProviders.js +2 -23
  26. package/dist/runtime/astro/index.d.ts +2 -0
  27. package/dist/runtime/astro/index.js +3 -0
  28. package/dist/runtime/astro/server.d.ts +3 -0
  29. package/dist/runtime/astro/server.js +6 -0
  30. package/dist/runtime/content/client/internal.js +1 -0
  31. package/dist/runtime/content/client/types.d.ts +16 -0
  32. package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
  33. package/dist/runtime/next/FimoNextProviders.d.ts +24 -0
  34. package/dist/runtime/next/FimoNextProviders.js +20 -0
  35. package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
  36. package/dist/runtime/next/FimoNextScripts.js +19 -0
  37. package/dist/runtime/next/index.d.ts +14 -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.js +4 -0
  41. package/dist/runtime/nuxt/composables.d.ts +9 -0
  42. package/dist/runtime/nuxt/composables.js +57 -0
  43. package/dist/runtime/nuxt/index.d.ts +3 -0
  44. package/dist/runtime/nuxt/index.js +6 -0
  45. package/dist/runtime/nuxt/server.d.ts +3 -0
  46. package/dist/runtime/nuxt/server.js +6 -0
  47. package/dist/runtime/primitives/labels-hook.d.ts +33 -0
  48. package/dist/runtime/primitives/labels-hook.js +41 -0
  49. package/dist/runtime/primitives/labels-store.d.ts +12 -0
  50. package/dist/runtime/primitives/labels-store.js +60 -0
  51. package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
  52. package/dist/runtime/primitives/lib/useFimoLiveValue.js +11 -4
  53. package/dist/runtime/primitives/translations.d.ts +1 -27
  54. package/dist/runtime/primitives/translations.js +7 -85
  55. package/dist/runtime/react/FimoSpaProviders.d.ts +36 -0
  56. package/dist/runtime/react/FimoSpaProviders.js +63 -0
  57. package/dist/runtime/react/index.d.ts +33 -0
  58. package/dist/runtime/react/index.js +39 -0
  59. package/dist/runtime/remix/FimoRemixProviders.d.ts +16 -0
  60. package/dist/runtime/remix/FimoRemixProviders.js +12 -0
  61. package/dist/runtime/remix/FimoRemixScripts.d.ts +10 -0
  62. package/dist/runtime/remix/FimoRemixScripts.js +19 -0
  63. package/dist/runtime/remix/index.d.ts +12 -0
  64. package/dist/runtime/remix/index.js +11 -0
  65. package/dist/runtime/remix/server.d.ts +2 -0
  66. package/dist/runtime/remix/server.js +2 -0
  67. package/dist/runtime/shared/display-routes.d.ts +14 -0
  68. package/dist/runtime/shared/display-routes.js +25 -0
  69. package/dist/runtime/shared/fimo-config.server.js +1 -0
  70. package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
  71. package/dist/runtime/shared/i18n-messages.d.ts +29 -0
  72. package/dist/runtime/shared/i18n-messages.js +79 -0
  73. package/dist/runtime/shared/labels-fetch.pure.d.ts +17 -0
  74. package/dist/runtime/shared/labels-fetch.pure.js +71 -0
  75. package/dist/runtime/shared/labels-fetch.server.d.ts +7 -0
  76. package/dist/runtime/shared/labels-fetch.server.js +97 -0
  77. package/dist/runtime/shared/labels-t.pure.d.ts +2 -0
  78. package/dist/runtime/shared/labels-t.pure.js +10 -0
  79. package/dist/runtime/strapi/client.d.ts +30 -0
  80. package/dist/runtime/strapi/client.js +89 -0
  81. package/dist/runtime/strapi/index.d.ts +5 -0
  82. package/dist/runtime/strapi/index.js +6 -0
  83. package/dist/runtime/strapi/reshape.d.ts +46 -0
  84. package/dist/runtime/strapi/reshape.js +114 -0
  85. package/dist/runtime/svelte/index.d.ts +3 -0
  86. package/dist/runtime/svelte/index.js +7 -0
  87. package/dist/runtime/svelte/server.d.ts +3 -0
  88. package/dist/runtime/svelte/server.js +6 -0
  89. package/dist/runtime/vue/index.d.ts +3 -0
  90. package/dist/runtime/vue/index.js +5 -0
  91. package/dist/runtime/vue/server.d.ts +3 -0
  92. package/dist/runtime/vue/server.js +6 -0
  93. package/dist/runtime/wrapped/FimoFileLink.d.ts +17 -0
  94. package/dist/runtime/wrapped/FimoFileLink.js +19 -0
  95. package/dist/runtime/wrapped/FimoMediaImage.d.ts +14 -0
  96. package/dist/runtime/wrapped/FimoMediaImage.js +20 -0
  97. package/dist/runtime/wrapped/FimoMediaVideo.d.ts +14 -0
  98. package/dist/runtime/wrapped/FimoMediaVideo.js +20 -0
  99. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +23 -0
  100. package/dist/runtime/wrapped/FimoRichTextBlock.js +22 -0
  101. package/dist/runtime/wrapped/FimoText.d.ts +22 -0
  102. package/dist/runtime/wrapped/FimoText.js +20 -0
  103. package/dist/runtime/wrapped/WrappedContentContext.d.ts +28 -0
  104. package/dist/runtime/wrapped/WrappedContentContext.js +30 -0
  105. package/dist/runtime/wrapped/WrappedProviders.d.ts +33 -0
  106. package/dist/runtime/wrapped/WrappedProviders.js +101 -0
  107. package/dist/runtime/wrapped/dom.d.ts +4 -0
  108. package/dist/runtime/wrapped/dom.js +23 -0
  109. package/dist/runtime/wrapped/server.d.ts +77 -0
  110. package/dist/runtime/wrapped/server.js +237 -0
  111. package/dist/scripts/apply-wrap-templates.d.ts +61 -0
  112. package/dist/scripts/apply-wrap-templates.js +132 -0
  113. package/dist/scripts/apply-wrap.d.ts +46 -0
  114. package/dist/scripts/apply-wrap.js +278 -0
  115. package/dist/scripts/cms/discover.d.ts +2 -0
  116. package/dist/scripts/cms/discover.js +17 -0
  117. package/dist/scripts/cms/example-content.d.ts +33 -0
  118. package/dist/scripts/cms/example-content.js +162 -0
  119. package/dist/scripts/cms/strapi/detect.d.ts +2 -0
  120. package/dist/scripts/cms/strapi/detect.js +186 -0
  121. package/dist/scripts/cms/strapi/index.d.ts +3 -0
  122. package/dist/scripts/cms/strapi/index.js +25 -0
  123. package/dist/scripts/cms/strapi/list-source.d.ts +17 -0
  124. package/dist/scripts/cms/strapi/list-source.js +90 -0
  125. package/dist/scripts/cms/strapi/schema-map.d.ts +9 -0
  126. package/dist/scripts/cms/strapi/schema-map.js +277 -0
  127. package/dist/scripts/cms/strapi/shapes.d.ts +23 -0
  128. package/dist/scripts/cms/strapi/shapes.js +38 -0
  129. package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
  130. package/dist/scripts/cms/strapi/touchpoints.js +145 -0
  131. package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
  132. package/dist/scripts/cms/strapi/wire-client.js +107 -0
  133. package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
  134. package/dist/scripts/cms/strapi/wiring.js +46 -0
  135. package/dist/scripts/cms/types.d.ts +145 -0
  136. package/dist/scripts/cms/types.js +11 -0
  137. package/dist/scripts/detect-framework.d.ts +39 -0
  138. package/dist/scripts/detect-framework.js +133 -0
  139. package/dist/scripts/draft-rows.d.ts +19 -0
  140. package/dist/scripts/draft-rows.js +135 -0
  141. package/dist/scripts/draft-schemas.d.ts +27 -0
  142. package/dist/scripts/draft-schemas.js +188 -0
  143. package/dist/scripts/extract-translations.js +16 -11
  144. package/dist/scripts/frameworks/astro/routes.d.ts +3 -0
  145. package/dist/scripts/frameworks/astro/routes.js +55 -0
  146. package/dist/scripts/frameworks/astro/template-style.d.ts +7 -0
  147. package/dist/scripts/frameworks/astro/template-style.js +19 -0
  148. package/dist/scripts/frameworks/discover.d.ts +70 -0
  149. package/dist/scripts/frameworks/discover.js +178 -0
  150. package/dist/scripts/frameworks/next/rewire.d.ts +30 -0
  151. package/dist/scripts/frameworks/next/rewire.js +258 -0
  152. package/dist/scripts/frameworks/next/routes.d.ts +17 -0
  153. package/dist/scripts/frameworks/next/routes.js +134 -0
  154. package/dist/scripts/frameworks/next/wiring.d.ts +33 -0
  155. package/dist/scripts/frameworks/next/wiring.js +186 -0
  156. package/dist/scripts/frameworks/nuxt/routes.d.ts +3 -0
  157. package/dist/scripts/frameworks/nuxt/routes.js +54 -0
  158. package/dist/scripts/frameworks/nuxt/template-style.d.ts +9 -0
  159. package/dist/scripts/frameworks/nuxt/template-style.js +27 -0
  160. package/dist/scripts/frameworks/remix/routes.d.ts +3 -0
  161. package/dist/scripts/frameworks/remix/routes.js +50 -0
  162. package/dist/scripts/frameworks/support.d.ts +75 -0
  163. package/dist/scripts/frameworks/support.js +165 -0
  164. package/dist/scripts/frameworks/sveltekit/routes.d.ts +3 -0
  165. package/dist/scripts/frameworks/sveltekit/routes.js +50 -0
  166. package/dist/scripts/frameworks/sveltekit/template-style.d.ts +9 -0
  167. package/dist/scripts/frameworks/sveltekit/template-style.js +54 -0
  168. package/dist/scripts/frameworks/vite/routes.d.ts +15 -0
  169. package/dist/scripts/frameworks/vite/routes.js +93 -0
  170. package/dist/scripts/frameworks/vite/wiring.d.ts +10 -0
  171. package/dist/scripts/frameworks/vite/wiring.js +99 -0
  172. package/dist/scripts/i18n-namespace.d.ts +27 -0
  173. package/dist/scripts/i18n-namespace.js +155 -0
  174. package/dist/scripts/lib/validation-report.d.ts +1 -0
  175. package/dist/scripts/lib/validation-report.js +3 -0
  176. package/dist/scripts/render-parity/capture.d.ts +56 -0
  177. package/dist/scripts/render-parity/capture.js +366 -0
  178. package/dist/scripts/render-parity/compare.d.ts +59 -0
  179. package/dist/scripts/render-parity/compare.js +0 -0
  180. package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
  181. package/dist/scripts/render-parity/normalize-html.js +223 -0
  182. package/dist/scripts/render-parity/types.d.ts +41 -0
  183. package/dist/scripts/render-parity/types.js +1 -0
  184. package/dist/scripts/scan-wrappable.d.ts +196 -0
  185. package/dist/scripts/scan-wrappable.js +1232 -0
  186. package/dist/scripts/validate-route-metadata.js +11 -0
  187. package/dist/scripts/validate-schemas.js +42 -1
  188. package/package.json +84 -4
  189. package/release.json +2 -2
  190. package/templates/react-router/package.json +1 -1
@@ -0,0 +1,133 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ /**
4
+ * Detect the framework of an existing site from its package.json and layout.
5
+ * Used by `fimo init` (adopt-existing-site) and the wrap migration to decide
6
+ * how routes are discovered and which env flavor to write.
7
+ */
8
+ export function detectFramework(cwd) {
9
+ const pkgPath = join(cwd, 'package.json');
10
+ if (!existsSync(pkgPath)) {
11
+ return { framework: null };
12
+ }
13
+ let pkg;
14
+ try {
15
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
16
+ }
17
+ catch {
18
+ return { framework: null };
19
+ }
20
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+ if (deps.next) {
22
+ const appDir = ['app', 'src/app'].find((dir) => existsSync(join(cwd, dir)));
23
+ const pagesDir = ['pages', 'src/pages'].find((dir) => existsSync(join(cwd, dir)));
24
+ return { framework: 'next', appDir, pagesDir };
25
+ }
26
+ if (deps['react-router'] || deps['@react-router/dev']) {
27
+ return { framework: 'react-router' };
28
+ }
29
+ if (deps['@remix-run/react']) {
30
+ const pagesDir = ['app/routes'].find((dir) => existsSync(join(cwd, dir)));
31
+ return { framework: 'remix', pagesDir };
32
+ }
33
+ if (deps.astro) {
34
+ const pagesDir = ['src/pages'].find((dir) => existsSync(join(cwd, dir)));
35
+ return { framework: 'astro', pagesDir };
36
+ }
37
+ if (deps.nuxt) {
38
+ // 'src/pages' covers the common custom srcDir (nuxt.config `srcDir: './src'`).
39
+ const pagesDir = ['pages', 'app/pages', 'src/pages'].find((dir) => existsSync(join(cwd, dir)));
40
+ return { framework: 'nuxt', pagesDir };
41
+ }
42
+ if (deps['@sveltejs/kit']) {
43
+ const pagesDir = ['src/routes'].find((dir) => existsSync(join(cwd, dir)));
44
+ return { framework: 'sveltekit', pagesDir };
45
+ }
46
+ if (deps.vue && deps.vite) {
47
+ return { framework: 'vue' };
48
+ }
49
+ if (deps.vite) {
50
+ // A react dependency guarantees the JSX runtime: the --apply codemod and
51
+ // react-router route discovery are safe. Bare vite (Solid, Preact, lit)
52
+ // stays hands-off.
53
+ return { framework: 'vite', react: Boolean(deps.react) };
54
+ }
55
+ // Known frameworks we can NAME but not wrap yet — lets `fimo init` route
56
+ // honestly instead of silently falling through to create-new.
57
+ const unsupportedByDep = [
58
+ ['gatsby', 'Gatsby'],
59
+ ['@angular/core', 'Angular'],
60
+ ['@docusaurus/core', 'Docusaurus'],
61
+ ['@11ty/eleventy', 'Eleventy'],
62
+ ];
63
+ for (const [dep, name] of unsupportedByDep) {
64
+ if (deps[dep]) {
65
+ return { framework: null, unsupported: name };
66
+ }
67
+ }
68
+ return { framework: null };
69
+ }
70
+ const WORKSPACE_SKIP = new Set([
71
+ 'node_modules',
72
+ '.git',
73
+ 'dist',
74
+ 'build',
75
+ 'out',
76
+ '.next',
77
+ '.nuxt',
78
+ '.svelte-kit',
79
+ '.turbo',
80
+ '.vercel',
81
+ 'coverage',
82
+ ]);
83
+ /** How deep below the root the scan looks for a framework app (`apps/ui` is
84
+ * depth 2, `packages/frontend/web` is depth 3). */
85
+ const MAX_SCAN_DEPTH = 3;
86
+ /**
87
+ * Find frontend apps to adopt anywhere inside a directory tree. Most real
88
+ * sites are monorepos where the framework app is NOT at the root and NOT
89
+ * necessarily under `apps/` (`frontend/`, `sites/web`, `packages/marketing`,
90
+ * …) — `detectFramework(root)` returns null and `fimo init` would wrongly
91
+ * scaffold a new project. This walks the tree up to a bounded depth, running
92
+ * `detectFramework` on every directory that has a package.json and pruning
93
+ * `node_modules`/build output, so it finds the app wherever it lives without
94
+ * relying on a naming convention or a workspace manifest. The root itself is
95
+ * excluded (the caller checks it directly); descent stops at the first
96
+ * framework match in a subtree (an app's own subdirs are not separate apps).
97
+ */
98
+ export function findAdoptableApps(rootDir) {
99
+ const apps = [];
100
+ const walk = (dir, depth) => {
101
+ if (depth > MAX_SCAN_DEPTH) {
102
+ return;
103
+ }
104
+ if (dir !== rootDir && existsSync(join(dir, 'package.json'))) {
105
+ const detection = detectFramework(dir);
106
+ if (detection.framework) {
107
+ apps.push({ dir, relDir: relative(rootDir, dir), detection });
108
+ return; // don't descend into an app's own subtree
109
+ }
110
+ }
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(dir, { withFileTypes: true });
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ for (const entry of entries) {
119
+ if (entry.isDirectory() && !WORKSPACE_SKIP.has(entry.name) && !entry.name.startsWith('.')) {
120
+ walk(join(dir, entry.name), depth + 1);
121
+ }
122
+ }
123
+ };
124
+ walk(rootDir, 0);
125
+ // Shallower + apps/-conventional first (the most likely primary site), then
126
+ // alphabetical — the order the interactive picker presents them in.
127
+ return apps.sort((a, b) => {
128
+ const depthA = a.relDir.split('/').length;
129
+ const depthB = b.relDir.split('/').length;
130
+ const rank = (relDir) => (relDir.startsWith('apps/') ? 0 : 1);
131
+ return rank(a.relDir) - rank(b.relDir) || depthA - depthB || a.relDir.localeCompare(b.relDir);
132
+ });
133
+ }
@@ -0,0 +1,19 @@
1
+ import type { SchemaDraft } from './draft-schemas.js';
2
+ import type { WrapScanResult } from './scan-wrappable.js';
3
+ export interface RowsFile {
4
+ draftUid: string;
5
+ source: string;
6
+ references: Record<string, {
7
+ target?: string;
8
+ targets?: string[];
9
+ by: 'slug';
10
+ many?: boolean;
11
+ }>;
12
+ rows: Array<Record<string, unknown>>;
13
+ }
14
+ /**
15
+ * Stage rows for every draft. Drafts carry the inferred field types, the
16
+ * scan carries the raw rows (typedRows for in-code candidates; data files
17
+ * are re-read from disk for the full, untruncated dataset).
18
+ */
19
+ export declare function writeRowsStaging(rootDir: string, scan: WrapScanResult, drafts: SchemaDraft[]): number;
@@ -0,0 +1,135 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import YAML from 'yaml';
4
+ /**
5
+ * Machine-staged entry ROWS for every drafted collection: the deterministic
6
+ * transformation agents kept re-implementing by hand (media envelopes,
7
+ * richtext docs, slugs inside data), applied once by the machine. Reference
8
+ * fields keep their SOURCE values (slugs/names) and each file carries a
9
+ * references descriptor — the resolution to documentIds happens at seed
10
+ * time (agent-side maps today; server-side via the batch endpoint next).
11
+ *
12
+ * Output: `.fimo/migration/rows/<ProvisionalUid>.rows.json`
13
+ * { draftUid, source, references: { field: { target, by, many? } }, rows }
14
+ *
15
+ * Regenerated on every migrate run; `schemas adopt-drafts` rewrites
16
+ * draftUid and reference targets through the same renames it applies to the
17
+ * schema files.
18
+ */
19
+ const MEDIA_PATH = /^\/(images|img|videos|video|media|assets|files|docs|documents)\//i;
20
+ const MEDIA_EXT = /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov|mp3|wav|pdf|zip|docx?)$/i;
21
+ function toRichTextDoc(text) {
22
+ const paragraphs = text
23
+ .split(/\n\n+/)
24
+ .map((paragraph) => paragraph.trim())
25
+ .filter(Boolean);
26
+ return {
27
+ type: 'doc',
28
+ content: paragraphs.map((paragraph) => ({
29
+ type: 'paragraph',
30
+ content: [{ type: 'text', text: paragraph }],
31
+ })),
32
+ };
33
+ }
34
+ function transformValue(fieldType, value) {
35
+ if (value == null) {
36
+ return value;
37
+ }
38
+ if (fieldType === 'media' && typeof value === 'string' && (MEDIA_PATH.test(value) || MEDIA_EXT.test(value))) {
39
+ return { source: 'local', url: value, alt: '' };
40
+ }
41
+ if (fieldType === 'richtext' && typeof value === 'string') {
42
+ return toRichTextDoc(value);
43
+ }
44
+ return value;
45
+ }
46
+ function loadSourceRows(rootDir, source) {
47
+ const filePath = join(rootDir, source);
48
+ if (!existsSync(filePath)) {
49
+ return null;
50
+ }
51
+ try {
52
+ const raw = readFileSync(filePath, 'utf8');
53
+ const parsed = /\.ya?ml$/.test(source) ? YAML.parse(raw) : JSON.parse(raw);
54
+ return Array.isArray(parsed) ? parsed : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Stage rows for every draft. Drafts carry the inferred field types, the
62
+ * scan carries the raw rows (typedRows for in-code candidates; data files
63
+ * are re-read from disk for the full, untruncated dataset).
64
+ */
65
+ export function writeRowsStaging(rootDir, scan, drafts) {
66
+ const rowsDir = join(rootDir, '.fimo', 'migration', 'rows');
67
+ rmSync(rowsDir, { recursive: true, force: true });
68
+ if (drafts.length === 0) {
69
+ return 0;
70
+ }
71
+ const bySourceLabel = new Map(drafts.map((draft) => [draft.source, draft]));
72
+ const provisionalByName = new Map(drafts.map((draft) => [draft.sourceName, draft.uid]));
73
+ const toProvisional = (name) => provisionalByName.get(name) ?? name;
74
+ let written = 0;
75
+ const stage = (draft, source, rawRows, references) => {
76
+ const fieldTypes = new Map(Object.entries(draft.fields).map(([name, def]) => [
77
+ name,
78
+ def && typeof def === 'object' ? (def.type ?? 'string') : 'string',
79
+ ]));
80
+ const referenceFields = new Set(references.map((reference) => reference.field));
81
+ const rows = rawRows.map((row) => {
82
+ const out = {};
83
+ for (const [key, value] of Object.entries(row)) {
84
+ // Reference values stay as SOURCE values for later resolution.
85
+ out[key] = referenceFields.has(key) ? value : transformValue(fieldTypes.get(key), value);
86
+ }
87
+ return out;
88
+ });
89
+ const descriptor = {};
90
+ for (const reference of references) {
91
+ const targets = reference.targets.map(toProvisional);
92
+ descriptor[reference.field] = {
93
+ ...(targets.length > 1 ? { targets } : { target: targets[0] }),
94
+ by: 'slug',
95
+ ...(reference.many ? { many: true } : {}),
96
+ };
97
+ }
98
+ mkdirSync(rowsDir, { recursive: true });
99
+ writeFileSync(join(rowsDir, `${draft.uid}.rows.json`), `${JSON.stringify({ draftUid: draft.uid, source, references: descriptor, rows }, null, 1)}\n`);
100
+ written += 1;
101
+ };
102
+ for (const candidate of scan.collections) {
103
+ const label = `${candidate.file}:${candidate.line}`;
104
+ const draft = bySourceLabel.get(label);
105
+ if (!draft || !candidate.typedRows || candidate.typedRows.length === 0) {
106
+ continue;
107
+ }
108
+ stage(draft, label, candidate.typedRows, (candidate.references ?? []).map((reference) => ({
109
+ field: reference.field,
110
+ targets: reference.targets,
111
+ many: reference.many,
112
+ })));
113
+ }
114
+ for (const dataFile of scan.dataFiles) {
115
+ const draft = bySourceLabel.get(dataFile.file);
116
+ if (!draft) {
117
+ continue;
118
+ }
119
+ // Markdown groups can't be re-read from a single path (`<dir>/*.md`) — the
120
+ // scanner already parsed every document into the collection's typedRows.
121
+ const rawRows = dataFile.kind === 'markdown'
122
+ ? (dataFile.collections[0]?.typedRows ?? null)
123
+ : loadSourceRows(rootDir, dataFile.file);
124
+ if (!rawRows) {
125
+ continue;
126
+ }
127
+ const references = dataFile.collections.flatMap((collection) => (collection.references ?? []).map((reference) => ({
128
+ field: reference.field,
129
+ targets: reference.targets,
130
+ many: reference.many,
131
+ })));
132
+ stage(draft, dataFile.file, rawRows, references);
133
+ }
134
+ return written;
135
+ }
@@ -0,0 +1,27 @@
1
+ import type { WrapScanResult } from './scan-wrappable.js';
2
+ /**
3
+ * Machine-typed schema DRAFTS for every detected collection. The forensic
4
+ * timeline of a real 43-minute agent migration showed ~26 minutes spent
5
+ * deliberating and re-probing type behavior before the first schema file
6
+ * appeared — while the scanner already knew every field, its sampled values,
7
+ * and the reference targets. This module turns that knowledge into pushable
8
+ * draft files so the agent's job collapses to what only an agent can do:
9
+ * CLASSIFY the candidate and NAME the type. Names emitted here are
10
+ * PROVISIONAL by contract (mechanical singular/PascalCase of the source
11
+ * name); the drafts never touch src/schemas — the agent reviews, renames,
12
+ * copies, pushes.
13
+ */
14
+ export interface SchemaDraft {
15
+ file: string;
16
+ uid: string;
17
+ /** Original candidate/source name (pre-rename), for cross-artifact maps. */
18
+ sourceName: string;
19
+ source: string;
20
+ fields: Record<string, unknown>;
21
+ }
22
+ /**
23
+ * Write `.fimo/migration/schema-drafts/<Provisional>.json` (exact pushable
24
+ * shape) for every detected collection, plus a README stating the contract.
25
+ * Regenerated on every run — drafts are machine output, never hand-edited.
26
+ */
27
+ export declare function writeSchemaDrafts(rootDir: string, scan: WrapScanResult): SchemaDraft[];
@@ -0,0 +1,188 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}([T ].*)?$/;
4
+ const MEDIA_PATH = /^\/(images|img|videos|video|media|assets|files|docs|documents)\//i;
5
+ const MEDIA_EXT = /\.(png|jpe?g|gif|webp|avif|svg|mp4|webm|mov|mp3|wav|pdf|zip|docx?)$/i;
6
+ function singularPascal(name) {
7
+ const base = name
8
+ .replace(/[^a-zA-Z0-9]+(.)/g, (_, c) => c.toUpperCase())
9
+ .replace(/^(.)/, (c) => c.toUpperCase());
10
+ if (/ies$/i.test(base)) {
11
+ return base.replace(/ies$/i, 'y');
12
+ }
13
+ if (/[^aeiou](ses|xes|zes)$/i.test(base) || /(ches|shes)$/i.test(base)) {
14
+ // Classes -> Class, Boxes -> Box — but vowel+ses words (Releases,
15
+ // Cases) are plain plurals and only drop the trailing s below.
16
+ return base.replace(/es$/i, '');
17
+ }
18
+ if (/s$/i.test(base) && !/ss$/i.test(base)) {
19
+ return base.replace(/s$/i, '');
20
+ }
21
+ return base;
22
+ }
23
+ function inferScalarType(sampleValue, stringValues) {
24
+ if (typeof sampleValue === 'boolean') {
25
+ return { type: 'boolean' };
26
+ }
27
+ if (typeof sampleValue === 'number') {
28
+ return { type: 'number' };
29
+ }
30
+ const values = stringValues.length > 0 ? stringValues : typeof sampleValue === 'string' ? [sampleValue] : [];
31
+ if (values.length > 0) {
32
+ const nonEmpty = values.filter(Boolean);
33
+ if (nonEmpty.length > 0 && nonEmpty.every((value) => ISO_DATE.test(value))) {
34
+ return { type: 'date' };
35
+ }
36
+ if (nonEmpty.length > 0 && nonEmpty.every((value) => MEDIA_PATH.test(value) || MEDIA_EXT.test(value))) {
37
+ return { type: 'media' };
38
+ }
39
+ // Multi-paragraph prose reads as rich text; single lines stay strings.
40
+ if (nonEmpty.some((value) => value.includes('\n\n'))) {
41
+ return { type: 'richtext' };
42
+ }
43
+ }
44
+ return { type: 'string' };
45
+ }
46
+ function referenceField(reference, provisionalNames) {
47
+ const targets = reference.targets.map((target) => provisionalNames.get(target) ?? singularPascal(target));
48
+ const base = targets.length > 1 ? { type: 'reference', targets } : { type: 'reference', target: targets[0] };
49
+ return reference.many ? { ...base, many: true } : base;
50
+ }
51
+ function collectSources(scan) {
52
+ const sources = [];
53
+ for (const candidate of scan.collections) {
54
+ sources.push({
55
+ sourceName: candidate.name,
56
+ sourceLabel: `${candidate.file}:${candidate.line}`,
57
+ rowCount: candidate.count,
58
+ fields: candidate.fields,
59
+ typedSample: candidate.typedSample ?? {},
60
+ fieldValues: candidate.fieldValues ?? {},
61
+ references: candidate.references ?? [],
62
+ manyFields: candidate.manyFields ?? [],
63
+ verbatimFields: [],
64
+ });
65
+ }
66
+ for (const dataFile of scan.dataFiles) {
67
+ for (const collection of dataFile.collections) {
68
+ const stem = dataFile.file.replace(/^.*\//, '').replace(/\.[a-z]+$/i, '');
69
+ const pathName = (collection.path ?? '').replace(/^\$\.?/, '').replace(/^\.+$/, '');
70
+ const name = pathName || stem;
71
+ sources.push({
72
+ sourceName: name,
73
+ sourceLabel: dataFile.file,
74
+ rowCount: collection.length,
75
+ fields: Object.keys(collection.typedSample ?? {}),
76
+ typedSample: collection.typedSample ?? {},
77
+ fieldValues: collection.fieldValues ?? {},
78
+ references: collection.references ?? [],
79
+ manyFields: collection.manyFields ?? [],
80
+ verbatimFields: collection.verbatimFields ?? [],
81
+ });
82
+ }
83
+ }
84
+ return sources;
85
+ }
86
+ /** Compact per-field preview so classification/naming never needs the
87
+ * source files: one representative value, long strings truncated. */
88
+ function sampleFor(typedSample) {
89
+ const sample = {};
90
+ for (const [field, value] of Object.entries(typedSample)) {
91
+ if (typeof value === 'string' && value.length > 80) {
92
+ sample[field] = `${value.slice(0, 77)}…`;
93
+ }
94
+ else if (Array.isArray(value)) {
95
+ sample[field] = value.slice(0, 3);
96
+ }
97
+ else {
98
+ sample[field] = value;
99
+ }
100
+ }
101
+ return sample;
102
+ }
103
+ /**
104
+ * Write `.fimo/migration/schema-drafts/<Provisional>.json` (exact pushable
105
+ * shape) for every detected collection, plus a README stating the contract.
106
+ * Regenerated on every run — drafts are machine output, never hand-edited.
107
+ */
108
+ export function writeSchemaDrafts(rootDir, scan) {
109
+ const sources = collectSources(scan);
110
+ const draftsDir = join(rootDir, '.fimo', 'migration', 'schema-drafts');
111
+ rmSync(draftsDir, { recursive: true, force: true });
112
+ if (sources.length === 0) {
113
+ return [];
114
+ }
115
+ mkdirSync(draftsDir, { recursive: true });
116
+ const provisionalNames = new Map();
117
+ for (const source of sources) {
118
+ provisionalNames.set(source.sourceName, singularPascal(source.sourceName));
119
+ }
120
+ const drafts = [];
121
+ for (const source of sources) {
122
+ const uid = provisionalNames.get(source.sourceName);
123
+ const referenceByField = new Map(source.references.map((reference) => [reference.field, reference]));
124
+ const fields = {};
125
+ // candidate.fields only lists STRING fields; booleans and numbers live
126
+ // in the typed sample — union both so no field is dropped.
127
+ const fieldNames = [...new Set([...source.fields, ...Object.keys(source.typedSample)])];
128
+ for (const field of fieldNames) {
129
+ if (field === 'slug') {
130
+ continue; // reserved entry metadata: set inside data on create
131
+ }
132
+ const reference = referenceByField.get(field);
133
+ if (reference) {
134
+ fields[field] = referenceField(reference, provisionalNames);
135
+ continue;
136
+ }
137
+ if (source.verbatimFields.includes(field)) {
138
+ // Markdown/MDX body: keep the raw source string so the user's own
139
+ // renderer still handles it — never reshape to richtext.
140
+ fields[field] = { type: 'string' };
141
+ continue;
142
+ }
143
+ if (source.manyFields.includes(field)) {
144
+ // String array with no reference target (a free tag list): `many` is
145
+ // reference-only on the server, so model it as json — the array
146
+ // round-trips verbatim and stays editable.
147
+ fields[field] = { type: 'json' };
148
+ continue;
149
+ }
150
+ fields[field] = inferScalarType(source.typedSample[field], source.fieldValues[field] ?? []);
151
+ }
152
+ // `source`, `rowCount`, and `sample` exist so the classify/name decision
153
+ // can be made from this file ALONE; adopt-drafts strips them on copy
154
+ // (only uid/name/fields are pushed).
155
+ const draft = {
156
+ uid,
157
+ name: uid,
158
+ source: source.sourceLabel,
159
+ rowCount: source.rowCount,
160
+ sample: sampleFor(source.typedSample),
161
+ fields,
162
+ };
163
+ const file = join(draftsDir, `${uid}.json`);
164
+ writeFileSync(file, `${JSON.stringify(draft, null, 2)}\n`);
165
+ drafts.push({ file, uid, sourceName: source.sourceName, source: source.sourceLabel, fields });
166
+ }
167
+ writeFileSync(join(draftsDir, 'README.md'), [
168
+ '# Machine-typed schema drafts (regenerated by `fimo migrate`)',
169
+ '',
170
+ 'Field TYPES here are inferred from the actual source values (dates,',
171
+ 'booleans, numbers, media paths, prose, reference targets) and are',
172
+ 'reliable. Each draft carries its `source`, `rowCount`, and a `sample`',
173
+ 'row — everything needed to classify and name WITHOUT opening the',
174
+ 'source files. The NAMES are PROVISIONAL mechanical guesses — that',
175
+ 'judgment is yours, and it is TWO commands total:',
176
+ '',
177
+ '1. `npx fimo schemas adopt-drafts --rename "Prov=Final,…" --drop "Junk,…"`',
178
+ ' (copies kept drafts to src/schemas, rewrites reference targets and',
179
+ ' staged rows files through the renames, and pushes in dependency order).',
180
+ '2. `npx fimo migrate seed` (seeds every staged rows file in reference',
181
+ ' order; the server resolves reference slugs to documentIds).',
182
+ '',
183
+ 'Do not edit files in this directory: they are overwritten on every',
184
+ '`fimo migrate` run.',
185
+ '',
186
+ ].join('\n'));
187
+ return drafts;
188
+ }
@@ -8,6 +8,7 @@
8
8
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { parseSync } from 'oxc-parser';
11
+ import { collectScopedBindings, isFunctionLike, resolveScopedKey } from './i18n-namespace.js';
11
12
  function findReactFiles(dir, files = []) {
12
13
  if (!existsSync(dir)) {
13
14
  return files;
@@ -34,17 +35,21 @@ function extractFromFile(filePath) {
34
35
  const code = readFileSync(filePath, 'utf-8');
35
36
  const result = parseSync(filePath, code);
36
37
  const ast = result.program;
37
- function visit(node) {
38
+ function visit(node, scopes) {
38
39
  if (!node || typeof node !== 'object') {
39
40
  return;
40
41
  }
41
- if (node.type === 'CallExpression') {
42
- const callee = node.callee;
43
- if (callee?.type === 'Identifier' && callee?.name === 't') {
44
- const args = node.arguments || [];
45
- const firstArg = args[0];
46
- if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
47
- const key = firstArg.value;
42
+ // A function introduces a new scope layer with its `useTranslations`
43
+ // bindings; nested calls resolve innermost-first.
44
+ const childScopes = isFunctionLike(node) ? [...scopes, collectScopedBindings(node)] : scopes;
45
+ if (node.type === 'CallExpression' && node.callee?.type === 'Identifier') {
46
+ const name = node.callee.name;
47
+ const firstArg = (node.arguments ?? [])[0];
48
+ if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
49
+ const { key, scoped, unresolvable } = resolveScopedKey(name, firstArg.value, childScopes);
50
+ // A namespace-scoped var (any name) OR a bare Fimo `t(...)`. A passed-in
51
+ // param `t` is unresolvable (the caller owns the namespace) — skip it.
52
+ if (!unresolvable && (scoped || name === 't')) {
48
53
  declarations.push({ key, file: filePath });
49
54
  }
50
55
  }
@@ -53,15 +58,15 @@ function extractFromFile(filePath) {
53
58
  const child = node[key];
54
59
  if (Array.isArray(child)) {
55
60
  for (const item of child) {
56
- visit(item);
61
+ visit(item, childScopes);
57
62
  }
58
63
  }
59
64
  else if (typeof child === 'object' && child !== null) {
60
- visit(child);
65
+ visit(child, childScopes);
61
66
  }
62
67
  }
63
68
  }
64
- visit(ast);
69
+ visit(ast, []);
65
70
  }
66
71
  catch (error) {
67
72
  console.error(`Failed to parse ${filePath}:`, error);
@@ -0,0 +1,3 @@
1
+ import type { DiscoveredRoute } from '../next/routes.js';
2
+ /** Astro file-based routes under src/pages (index files map to the dir path). */
3
+ export declare function discoverAstroRoutes(pagesDir: string): DiscoveredRoute[];
@@ -0,0 +1,55 @@
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const titleCase = (seg) => seg
4
+ .replace(/[_\-]+/g, ' ')
5
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
6
+ .split(/\s+/)
7
+ .filter(Boolean)
8
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
9
+ .join(' ');
10
+ function pushRoute(routes, parts) {
11
+ const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
12
+ const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
13
+ routes.push({
14
+ id: routePath === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
15
+ path: routePath,
16
+ label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
17
+ });
18
+ }
19
+ const PAGE_EXT = /\.(astro|md|mdx|html)$/;
20
+ const toSegment = (name) => name.replace(/^\[\.\.\.(.+)\]$/, ':$1').replace(/^\[(.+)\]$/, ':$1');
21
+ /** Astro file-based routes under src/pages (index files map to the dir path). */
22
+ export function discoverAstroRoutes(pagesDir) {
23
+ const routes = [];
24
+ const walk = (dir, segments) => {
25
+ let entries;
26
+ try {
27
+ entries = readdirSync(dir);
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ for (const entry of entries) {
33
+ const full = join(dir, entry);
34
+ let isDir = false;
35
+ try {
36
+ isDir = statSync(full).isDirectory();
37
+ }
38
+ catch {
39
+ continue;
40
+ }
41
+ if (isDir) {
42
+ walk(full, [...segments, toSegment(entry)]);
43
+ continue;
44
+ }
45
+ if (!PAGE_EXT.test(entry) || entry.startsWith('_')) {
46
+ continue;
47
+ }
48
+ const base = entry.replace(PAGE_EXT, '');
49
+ pushRoute(routes, base === 'index' ? segments : [...segments, toSegment(base)]);
50
+ }
51
+ };
52
+ walk(pagesDir, []);
53
+ routes.sort((a, b) => a.path.localeCompare(b.path));
54
+ return routes;
55
+ }
@@ -0,0 +1,7 @@
1
+ import type { TemplateWrapStyle } from '../../apply-wrap-templates.js';
2
+ /**
3
+ * Astro template wraps: t() resolves in the frontmatter (server-only, so the
4
+ * server barrel is safe to import there), values interpolate as {t(...)}.
5
+ * Files without a frontmatter fence get one — Astro allows an empty fence.
6
+ */
7
+ export declare const astroTemplateWrapStyle: TemplateWrapStyle;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Astro template wraps: t() resolves in the frontmatter (server-only, so the
3
+ * server barrel is safe to import there), values interpolate as {t(...)}.
4
+ * Files without a frontmatter fence get one — Astro allows an empty fence.
5
+ */
6
+ export const astroTemplateWrapStyle = {
7
+ ext: '.astro',
8
+ head: 'import { loadFimoT } from "fimo/astro/server";\nconst t = await loadFimoT();',
9
+ headMarker: /loadFimoT/,
10
+ open: '{t(',
11
+ close: ')}',
12
+ injectHead: (code, head) => {
13
+ const fence = code.match(/^---\r?\n/);
14
+ if (fence) {
15
+ return `${code.slice(0, fence[0].length)}${head}\n${code.slice(fence[0].length)}`;
16
+ }
17
+ return `---\n${head}\n---\n${code}`;
18
+ },
19
+ };