fimo 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/assets/astro/components/DateTime.astro +22 -0
  2. package/assets/astro/components/index.d.ts +1 -0
  3. package/assets/astro/components/index.js +1 -0
  4. package/assets/content-templates/shared/types.eta +2 -0
  5. package/assets/skills/fimo-cli/SKILL.md +8 -6
  6. package/assets/skills/fimo-cli/references/branches.md +2 -2
  7. package/assets/skills/fimo-cli/references/content.md +2 -2
  8. package/assets/skills/fimo-cli/references/deploy.md +17 -9
  9. package/assets/skills/fimo-cli/references/hosting.md +114 -0
  10. package/assets/skills/fimo-cli/references/init.md +13 -6
  11. package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
  12. package/assets/skills/fimo-cli/references/locales.md +38 -14
  13. package/assets/skills/fimo-cli/references/pages.md +70 -0
  14. package/assets/skills/fimo-cli/references/preview.md +26 -20
  15. package/assets/skills/fimo-cli/references/webhooks.md +1 -1
  16. package/assets/skills/fimo-code/SKILL.md +11 -0
  17. package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
  18. package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
  19. package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
  20. package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
  21. package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
  22. package/assets/skills/fimo-code/references/content/richtext.md +5 -0
  23. package/assets/skills/fimo-code/references/content/schema.md +4 -1
  24. package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
  25. package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
  26. package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
  27. package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
  28. package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
  29. package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
  30. package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
  31. package/assets/skills/fimo-code/references/labels.md +9 -1
  32. package/assets/skills/fimo-code/references/locales.md +8 -2
  33. package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
  34. package/assets/skills/fimo-code/references/ui.md +21 -2
  35. package/assets/skills/fimo-migration/SKILL.md +276 -51
  36. package/assets/skills/fimo-migration/references/content.md +119 -35
  37. package/assets/skills/fimo-migration/references/decision.md +72 -0
  38. package/assets/skills/fimo-migration/references/forms.md +38 -6
  39. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
  40. package/assets/skills/fimo-migration/references/import-contract.md +71 -19
  41. package/assets/skills/fimo-migration/references/manifest.md +11 -161
  42. package/assets/skills/fimo-migration/references/media.md +33 -1
  43. package/assets/skills/fimo-migration/references/platforms.md +26 -2
  44. package/assets/skills/fimo-migration/references/qa.md +167 -13
  45. package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
  46. package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
  47. package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
  48. package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
  49. package/assets/skills/fimo-migration/references/workers.md +82 -0
  50. package/assets/skills/fimo-migration/references/workflow.md +140 -192
  51. package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
  52. package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
  53. package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
  54. package/assets/skills/fimo-studio/references/ai-chat.md +1 -1
  55. package/assets/skills/fimo-studio/references/cms.md +25 -7
  56. package/assets/skills/fimo-studio/references/webhooks.md +1 -1
  57. package/assets/sveltekit/components/DateTime.svelte +35 -0
  58. package/assets/sveltekit/components/FimoProvider.svelte +2 -2
  59. package/assets/sveltekit/components/index.d.ts +1 -0
  60. package/assets/sveltekit/components/index.js +1 -0
  61. package/dist/build/next/config.d.ts +4 -1
  62. package/dist/build/next/config.js +21 -5
  63. package/dist/build/sveltekit/vite.d.ts +3 -4
  64. package/dist/build/sveltekit/vite.js +3 -4
  65. package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
  66. package/dist/build/vite/plugins/runtime-env.js +14 -10
  67. package/dist/cli/bundle.json +2 -2
  68. package/dist/cli/index.js +113 -108
  69. package/dist/runtime/app/FimoProviders.js +25 -14
  70. package/dist/runtime/app/prefetch.d.ts +3 -1
  71. package/dist/runtime/app/prefetch.js +11 -55
  72. package/dist/runtime/astro/preview.js +7 -9
  73. package/dist/runtime/content/client/internal.js +1 -0
  74. package/dist/runtime/i18n/locale.d.ts +6 -3
  75. package/dist/runtime/i18n/locale.js +16 -41
  76. package/dist/runtime/next/client-runtime.js +5 -1
  77. package/dist/runtime/next/client.d.ts +3 -2
  78. package/dist/runtime/next/client.js +2 -1
  79. package/dist/runtime/next/labels.d.ts +2 -2
  80. package/dist/runtime/next/labels.js +2 -2
  81. package/dist/runtime/next/preview.d.ts +13 -0
  82. package/dist/runtime/next/preview.js +34 -0
  83. package/dist/runtime/next/primitives/server.d.ts +2 -0
  84. package/dist/runtime/next/primitives/server.js +8 -0
  85. package/dist/runtime/next/provider.d.ts +5 -0
  86. package/dist/runtime/next/provider.js +17 -3
  87. package/dist/runtime/next/server-runtime.js +4 -1
  88. package/dist/runtime/next/server.d.ts +1 -1
  89. package/dist/runtime/next/server.js +1 -1
  90. package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
  91. package/dist/runtime/paths/get-fimo-paths.js +43 -42
  92. package/dist/runtime/preview/location.d.ts +19 -2
  93. package/dist/runtime/preview/location.js +16 -2
  94. package/dist/runtime/primitives/index.d.ts +1 -0
  95. package/dist/runtime/primitives/index.js +1 -0
  96. package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
  97. package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
  98. package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
  99. package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
  100. package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
  101. package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
  102. package/dist/runtime/routes/index.d.ts +162 -0
  103. package/dist/runtime/routes/index.js +335 -0
  104. package/dist/runtime/routing/AppRoutesProvider.js +4 -3
  105. package/dist/runtime/seo/resolver.d.ts +5 -0
  106. package/dist/runtime/seo/resolver.js +10 -42
  107. package/dist/runtime/shared/fimo-config.types.d.ts +14 -13
  108. package/dist/runtime/shared/preview-location.d.ts +1 -22
  109. package/dist/runtime/shared/preview-location.js +5 -93
  110. package/dist/runtime/sveltekit/preview.d.ts +12 -3
  111. package/dist/runtime/sveltekit/preview.js +13 -8
  112. package/dist/runtime/templates.d.ts +19 -0
  113. package/dist/runtime/templates.js +33 -0
  114. package/dist/scripts/sync-forms.js +1 -1
  115. package/dist/scripts/sync-schemas.js +1 -4
  116. package/dist/scripts/validate-fimo-config.js +5 -1
  117. package/dist/scripts/validate-page-registry.d.ts +2 -0
  118. package/dist/scripts/validate-page-registry.js +167 -0
  119. package/dist/scripts/validate-route-metadata.js +132 -41
  120. package/package.json +10 -5
  121. package/templates/astro/.fimo/config.json +13 -5
  122. package/templates/astro/AGENTS.md +5 -1
  123. package/templates/astro/astro.config.mjs +7 -0
  124. package/templates/astro/pnpm-workspace.yaml +0 -3
  125. package/templates/astro/src/components/ProjectOverview.astro +9 -6
  126. package/templates/astro/src/layouts/Base.astro +1 -1
  127. package/templates/astro/src/locales.ts +4 -0
  128. package/templates/astro/src/pages/[locale]/index.astro +19 -0
  129. package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
  130. package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
  131. package/templates/astro/src/pages/index.astro +5 -2
  132. package/templates/astro/src/pages/projects/[slug].astro +4 -2
  133. package/templates/astro/src/pages/projects/index.astro +5 -2
  134. package/templates/nextjs/.fimo/config.json +2 -1
  135. package/templates/nextjs/AGENTS.md +5 -0
  136. package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
  137. package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
  138. package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
  139. package/templates/nextjs/src/components/Document.tsx +26 -0
  140. package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
  141. package/templates/nextjs/src/locales.ts +13 -0
  142. package/templates/react-router/.fimo/config.json +3 -6
  143. package/templates/react-router/AGENTS.md +4 -0
  144. package/templates/react-router/package.json +1 -1
  145. package/templates/react-router/pnpm-workspace.yaml +1 -3
  146. package/templates/react-router/react-router.config.ts +4 -10
  147. package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
  148. package/templates/react-router/src/root.tsx +2 -4
  149. package/templates/react-router/src/routes.ts +5 -3
  150. package/templates/sveltekit/.fimo/config.json +2 -1
  151. package/templates/sveltekit/AGENTS.md +5 -0
  152. package/templates/sveltekit/src/app.html +1 -1
  153. package/templates/sveltekit/src/hooks.server.ts +11 -0
  154. package/templates/sveltekit/src/locales.ts +8 -0
  155. package/templates/sveltekit/src/params/locale.ts +5 -0
  156. package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
  157. package/templates/sveltekit/src/routes/+layout.svelte +5 -0
  158. package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
  159. package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
  160. package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
  161. package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
  162. package/templates/nextjs/src/app/layout.tsx +0 -17
  163. /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
  164. /package/templates/sveltekit/src/routes/{+page.svelte → [[locale=locale]]/+page.svelte} +0 -0
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+
7
+ const MANIFEST_REL = '.fimo/migration/manifest.json';
8
+ const DECISION_REL = '.fimo/migration/decision.json';
9
+ const RUN_REL = '.fimo/migration/apply-run.json';
10
+ // Entry lookup pages rather than filtering server-side because `slug` is entry
11
+ // metadata, not a queryable schema field. The cap makes the script fail closed:
12
+ // creating a second entry for a slug we could not prove absent would duplicate.
13
+ const ENTRY_PAGE = 100;
14
+ const MAX_ENTRY_SCAN = 2_000;
15
+
16
+ function main() {
17
+ const args = parseArgs(process.argv.slice(2));
18
+ const projectRoot = path.resolve(args.projectRoot ?? process.cwd());
19
+ const manifestPath = path.resolve(projectRoot, args.manifest ?? MANIFEST_REL);
20
+ const decisionPath = path.resolve(projectRoot, args.decision ?? DECISION_REL);
21
+ const runPath = path.resolve(projectRoot, RUN_REL);
22
+
23
+ const manifest = readJson(manifestPath);
24
+ const decision = readDecision(readJson(decisionPath), decisionPath);
25
+ const fimo = new Fimo(projectRoot, args.fimo ?? 'npx fimo@latest');
26
+ const uploads = new Map();
27
+ const phases = [];
28
+
29
+ const contentTypes = phase(phases, 'schemas', () => applySchemas(projectRoot, fimo, decision));
30
+ const media = phase(phases, 'media', () => applyAssets(fimo, decision, uploads, asArray(manifest.media)));
31
+ const collectionBatches = phase(phases, 'entries/references', () => applyEntries(fimo, decision, uploads));
32
+ const singleBatches = phase(phases, 'singles', () => applySingles(fimo, decision, uploads));
33
+ const labels = phase(phases, 'labels', () => applyLabels(fimo, decision));
34
+
35
+ const updated = {
36
+ ...manifest,
37
+ routes: decision.routes.map(routeItem),
38
+ contentTypes,
39
+ entries: [...collectionBatches, ...singleBatches],
40
+ labels,
41
+ media,
42
+ locales: decision.locales.map((locale) => ({
43
+ id: locale,
44
+ locale,
45
+ status: 'imported',
46
+ isDefault: locale === decision.defaultLocale,
47
+ })),
48
+ unresolved: decision.unresolved,
49
+ };
50
+
51
+ writeJsonFile(manifestPath, updated);
52
+ writeJsonFile(runPath, { version: 1, phases });
53
+
54
+ console.log(`Applied migration decision from ${path.relative(projectRoot, decisionPath)}`);
55
+ for (const item of phases) {
56
+ console.log(` ${item.phase}: ${item.durationMs}ms`);
57
+ }
58
+ }
59
+
60
+ // ── CLI invocation ────────────────────────────────────────────────────────
61
+
62
+ class Fimo {
63
+ constructor(projectRoot, invocation) {
64
+ const parts = invocation.trim().split(/\s+/).filter(Boolean);
65
+ if (parts.length === 0) {
66
+ fail('--fimo needs a CLI invocation, for example "npx fimo@latest".');
67
+ }
68
+ this.binary = parts[0];
69
+ this.prefix = parts.slice(1);
70
+ this.cwd = projectRoot;
71
+ }
72
+
73
+ run(args) {
74
+ const result = spawnSync(this.binary, [...this.prefix, '--json', ...args], {
75
+ cwd: this.cwd,
76
+ encoding: 'utf8',
77
+ maxBuffer: 64_000_000,
78
+ });
79
+ if (result.error) {
80
+ fail(`Could not run ${this.binary}: ${result.error.message}`);
81
+ }
82
+ const envelope = parseEnvelope(result.stdout);
83
+ if (result.status !== 0 || envelope?.status !== 'ok') {
84
+ const reason = envelope?.error?.message || result.stderr.trim() || result.stdout.trim() || 'no output';
85
+ fail(`fimo ${args.join(' ')} failed (exit ${result.status}): ${reason}`);
86
+ }
87
+ return envelope.data;
88
+ }
89
+ }
90
+
91
+ /** The CLI writes one envelope per command; tolerate leading diagnostic lines. */
92
+ function parseEnvelope(stdout) {
93
+ const lines = String(stdout)
94
+ .split('\n')
95
+ .filter((line) => line.trim() !== '');
96
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
97
+ try {
98
+ const parsed = JSON.parse(lines[index]);
99
+ if (isRecord(parsed) && typeof parsed.status === 'string') {
100
+ return parsed;
101
+ }
102
+ } catch {
103
+ continue;
104
+ }
105
+ }
106
+ return null;
107
+ }
108
+
109
+ // ── Phases ────────────────────────────────────────────────────────────────
110
+
111
+ function applySchemas(projectRoot, fimo, decision) {
112
+ const items = [];
113
+ for (const definition of [...decision.collections, ...decision.singles]) {
114
+ const schemaFile = path.resolve(projectRoot, definition.schemaFile);
115
+ writeJsonFile(schemaFile, definition.schema);
116
+ fimo.run(['schemas', 'push', definition.schema.uid]);
117
+ items.push({
118
+ id: definition.id,
119
+ uid: definition.schema.uid,
120
+ name: definition.schema.name ?? definition.schema.uid,
121
+ status: 'imported',
122
+ sourceFiles: asArray(definition.sourceFiles),
123
+ targetFiles: [toPosix(definition.schemaFile)],
124
+ fields: definition.schema.fields ?? {},
125
+ });
126
+ }
127
+ return items;
128
+ }
129
+
130
+ function applyAssets(fimo, decision, uploads, previousMedia) {
131
+ const alreadyUploaded = new Map(previousMedia.map((item) => [item.id, item.targetAssetId]));
132
+ const items = [];
133
+ for (const asset of decision.assets) {
134
+ const name = asset.name ?? path.posix.basename(toPosix(asset.sourcePath));
135
+ const existing = findAsset(fimo, name, alreadyUploaded.get(asset.id));
136
+ const stored = existing ?? unwrapData(fimo.run(['assets', 'upload', asset.sourcePath, '--name', name]));
137
+ if (!isRecord(stored) || typeof stored.id !== 'string' || typeof stored.url !== 'string') {
138
+ fail(`Asset upload did not return an id and url: ${asset.id}`);
139
+ }
140
+ uploads.set(asset.id, { url: stored.url, alt: asset.alt ?? null });
141
+ items.push({
142
+ id: asset.id,
143
+ status: 'imported',
144
+ sourcePath: toPosix(asset.sourcePath),
145
+ targetAssetId: stored.id,
146
+ targetUrl: stored.url,
147
+ alt: asset.alt ?? null,
148
+ referencedBy: asArray(asset.referencedBy),
149
+ uploadError: null,
150
+ });
151
+ }
152
+ return items;
153
+ }
154
+
155
+ function applyEntries(fimo, decision, uploads) {
156
+ return decision.collections.map((collection) => {
157
+ const uid = collection.schema.uid;
158
+ const localized = collection.schema.i18n?.enabled !== false;
159
+ const sourceFiles = new Set(asArray(collection.sourceFiles));
160
+ let imported = 0;
161
+
162
+ for (const document of collection.documents) {
163
+ const locales = orderedLocales(document.locales, decision);
164
+ assertLocalePayloads(uid, document.slug, locales, decision.defaultLocale, localized);
165
+ const existingByLocale = new Map(
166
+ locales.map((locale) => [locale, findEntry(fimo, uid, localized ? locale : null, document.slug)])
167
+ );
168
+ let documentId = localized ? entryDocumentId(existingByLocale.get(locales[0])) : null;
169
+
170
+ if (localized) {
171
+ for (const locale of locales.slice(1)) {
172
+ const existing = existingByLocale.get(locale);
173
+ if (!existing) {
174
+ continue;
175
+ }
176
+ const localizedDocumentId = entryDocumentId(existing);
177
+ if (!documentId || localizedDocumentId !== documentId) {
178
+ fail(
179
+ `Existing localized entry ${uid}/${document.slug} (${locale}) has documentId ` +
180
+ `${localizedDocumentId ?? '(missing)'}, expected ${documentId ?? '(missing default-locale documentId)'}.`
181
+ );
182
+ }
183
+ }
184
+ }
185
+
186
+ for (const locale of locales) {
187
+ // Without the default locale's document id every later locale becomes a
188
+ // separate document, which is a silent duplicate rather than an error.
189
+ if (locale !== locales[0] && !documentId) {
190
+ fail(`Could not resolve the ${locales[0]} document id for ${uid}/${document.slug}.`);
191
+ }
192
+ const payload = document.locales[locale];
193
+ if (payload.sourceFile) {
194
+ sourceFiles.add(toPosix(payload.sourceFile));
195
+ }
196
+ const body = JSON.stringify({
197
+ data: { slug: document.slug, ...resolveAssets(payload.data, uploads) },
198
+ });
199
+ const existing = existingByLocale.get(locale);
200
+ const localeArgs = localized ? ['--locale', locale] : [];
201
+ const entry = existing
202
+ ? fimo.run(['entries', 'update', uid, existing.id, ...localeArgs, '--body', body])
203
+ : fimo.run([
204
+ 'entries',
205
+ 'create',
206
+ uid,
207
+ ...localeArgs,
208
+ ...(localized && documentId ? ['--document-id', documentId] : []),
209
+ '--body',
210
+ body,
211
+ ]);
212
+ documentId ??= entryDocumentId(entry);
213
+ imported += 1;
214
+ }
215
+ }
216
+
217
+ const sourceCount = collection.documents.reduce(
218
+ (total, document) => total + Object.keys(document.locales).length,
219
+ 0
220
+ );
221
+ return {
222
+ id: `${collection.id}-entries`,
223
+ contentType: uid,
224
+ status: 'imported',
225
+ sourceFiles: [...sourceFiles].sort(compareStrings),
226
+ sourceCount,
227
+ generatedCount: sourceCount,
228
+ importedCount: imported,
229
+ skipped: [],
230
+ };
231
+ });
232
+ }
233
+
234
+ function applySingles(fimo, decision, uploads) {
235
+ return decision.singles.map((single) => {
236
+ const uid = single.schema.uid;
237
+ const localized = single.schema.i18n?.enabled !== false;
238
+ const sourceFiles = new Set(asArray(single.sourceFiles));
239
+ let imported = 0;
240
+
241
+ const locales = orderedLocales(single.locales, decision);
242
+ assertLocalePayloads(uid, single.id, locales, decision.defaultLocale, localized);
243
+ for (const locale of locales) {
244
+ const payload = single.locales[locale];
245
+ if (payload.sourceFile) {
246
+ sourceFiles.add(toPosix(payload.sourceFile));
247
+ }
248
+ fimo.run([
249
+ 'singles',
250
+ 'update',
251
+ uid,
252
+ ...(localized ? ['--locale', locale] : []),
253
+ '--body',
254
+ JSON.stringify({ data: resolveAssets(payload.data, uploads) }),
255
+ ]);
256
+ imported += 1;
257
+ }
258
+
259
+ const sourceCount = Object.keys(single.locales).length;
260
+ return {
261
+ id: `${single.id}-content`,
262
+ contentType: uid,
263
+ status: 'imported',
264
+ sourceFiles: [...sourceFiles].sort(compareStrings),
265
+ sourceCount,
266
+ generatedCount: sourceCount,
267
+ importedCount: imported,
268
+ skipped: [],
269
+ };
270
+ });
271
+ }
272
+
273
+ function applyLabels(fimo, decision) {
274
+ const byLocale = new Map();
275
+ for (const label of decision.labels) {
276
+ for (const [locale, value] of Object.entries(label.values)) {
277
+ byLocale.set(locale, { ...(byLocale.get(locale) ?? {}), [label.key]: value });
278
+ }
279
+ }
280
+ for (const [locale, values] of [...byLocale.entries()].sort(([left], [right]) => compareStrings(left, right))) {
281
+ fimo.run(['labels', 'set-many', '--locale', locale, '--data', JSON.stringify(values)]);
282
+ }
283
+ return decision.labels.map((label) => ({
284
+ id: label.id,
285
+ key: label.key,
286
+ status: 'imported',
287
+ locales: Object.keys(label.values).sort(compareStrings),
288
+ sourceFiles: asArray(label.sourceFiles),
289
+ }));
290
+ }
291
+
292
+ // ── Create-or-update lookups ──────────────────────────────────────────────
293
+
294
+ /**
295
+ * The previously recorded target id is the primary key, so a rerun stays
296
+ * idempotent even if the media library normalized the display name on upload.
297
+ * The name match only has to catch a first run against an already-seeded
298
+ * library.
299
+ */
300
+ function findAsset(fimo, name, previousAssetId) {
301
+ const listed = pluckArray(fimo.run(['assets', 'list', '--type', 'file', '--search', name, '--limit', '100']))
302
+ .filter(isRecord)
303
+ .filter((item) => typeof item.id === 'string' && typeof item.url === 'string');
304
+ return (
305
+ listed.find((item) => previousAssetId && item.id === previousAssetId) ??
306
+ listed.find((item) => item.name === name || item.filename === name) ??
307
+ null
308
+ );
309
+ }
310
+
311
+ function findEntry(fimo, uid, locale, slug) {
312
+ for (let offset = 0; offset < MAX_ENTRY_SCAN; offset += ENTRY_PAGE) {
313
+ const page = pluckArray(
314
+ fimo.run([
315
+ 'entries',
316
+ 'list',
317
+ uid,
318
+ ...(locale ? ['--locale', locale] : []),
319
+ '--limit',
320
+ String(ENTRY_PAGE),
321
+ '--offset',
322
+ String(offset),
323
+ ])
324
+ );
325
+ const match = page.find((item) => isRecord(item) && item.slug === slug && typeof item.id === 'string');
326
+ if (match) {
327
+ return match;
328
+ }
329
+ if (page.length < ENTRY_PAGE) {
330
+ return null;
331
+ }
332
+ }
333
+ fail(`${uid} has more than ${MAX_ENTRY_SCAN} entries; cannot prove ${slug} (${locale}) is absent.`);
334
+ }
335
+
336
+ function entryDocumentId(entry) {
337
+ return isRecord(entry) && typeof entry.documentId === 'string' ? entry.documentId : null;
338
+ }
339
+
340
+ // ── Decision reading ──────────────────────────────────────────────────────
341
+
342
+ function readDecision(input, decisionPath) {
343
+ if (!isRecord(input) || input.version !== 1) {
344
+ fail(`Invalid migration decision (expected version 1): ${decisionPath}`);
345
+ }
346
+ const decision = {
347
+ defaultLocale: typeof input.defaultLocale === 'string' ? input.defaultLocale : 'en',
348
+ locales: asArray(input.locales).filter((locale) => typeof locale === 'string'),
349
+ labels: asArray(input.labels),
350
+ assets: asArray(input.assets),
351
+ collections: asArray(input.collections),
352
+ singles: asArray(input.singles),
353
+ routes: asArray(input.routes),
354
+ unresolved: asArray(input.unresolved),
355
+ };
356
+ if (decision.locales.length === 0) {
357
+ decision.locales = [decision.defaultLocale];
358
+ }
359
+
360
+ for (const definition of [...decision.collections, ...decision.singles]) {
361
+ if (!isRecord(definition.schema) || typeof definition.schema.uid !== 'string') {
362
+ fail(`Every collection and single needs a schema with a uid: ${definition.id ?? '(missing id)'}`);
363
+ }
364
+ if (typeof definition.schemaFile !== 'string') {
365
+ fail(`Every collection and single needs a schemaFile: ${definition.schema.uid}`);
366
+ }
367
+ }
368
+ for (const label of decision.labels) {
369
+ if (typeof label.key !== 'string' || !isRecord(label.values)) {
370
+ fail(`Every label needs a key and per-locale values: ${label.id ?? '(missing id)'}`);
371
+ }
372
+ }
373
+ for (const item of decision.unresolved) {
374
+ if (!item.message || !item.suggestedAction) {
375
+ fail(`Every unresolved item needs a message and a suggestedAction: ${item.id ?? '(missing id)'}`);
376
+ }
377
+ }
378
+ return decision;
379
+ }
380
+
381
+ /**
382
+ * The default locale is applied first so the localized variants can reuse its
383
+ * `documentId`; without that they become separate documents.
384
+ */
385
+ function orderedLocales(locales, decision) {
386
+ const present = Object.keys(locales);
387
+ const unknown = present.filter((locale) => !decision.locales.includes(locale));
388
+ if (unknown.length > 0) {
389
+ fail(`Decision uses locales that are not declared: ${unknown.sort(compareStrings).join(', ')}`);
390
+ }
391
+ return present.sort((left, right) => {
392
+ if (left === decision.defaultLocale) return -1;
393
+ if (right === decision.defaultLocale) return 1;
394
+ return compareStrings(left, right);
395
+ });
396
+ }
397
+
398
+ function assertLocalePayloads(uid, itemId, locales, defaultLocale, localized) {
399
+ if (locales.length === 0) {
400
+ fail(`${uid}/${itemId} needs at least one locale payload.`);
401
+ }
402
+ if (!locales.includes(defaultLocale)) {
403
+ fail(`${uid}/${itemId} needs a ${defaultLocale} default-locale payload.`);
404
+ }
405
+ if (!localized && (locales.length !== 1 || locales[0] !== defaultLocale)) {
406
+ fail(`${uid}/${itemId} is shared and needs exactly one ${defaultLocale} payload.`);
407
+ }
408
+ }
409
+
410
+ function resolveAssets(value, uploads) {
411
+ if (Array.isArray(value)) {
412
+ return value.map((item) => resolveAssets(item, uploads));
413
+ }
414
+ if (!isRecord(value)) {
415
+ return value;
416
+ }
417
+ if (typeof value.$asset === 'string') {
418
+ const uploaded = uploads.get(value.$asset);
419
+ if (!uploaded) {
420
+ fail(`Decision references an asset that is not declared: ${value.$asset}`);
421
+ }
422
+ const alt = value.alt ?? uploaded.alt;
423
+ return { source: 'local', url: uploaded.url, ...(alt ? { alt } : {}) };
424
+ }
425
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolveAssets(item, uploads)]));
426
+ }
427
+
428
+ function routeItem(route) {
429
+ return {
430
+ id: route.id,
431
+ route: route.route,
432
+ // A route lands in the target as application code, so writing its module is
433
+ // the import. `generated` is reserved for artifacts still outside Fimo.
434
+ status: route.status ?? 'imported',
435
+ sourceFiles: asArray(route.sourceFiles),
436
+ targetFiles: asArray(route.targetFiles),
437
+ };
438
+ }
439
+
440
+ // ── Utilities ─────────────────────────────────────────────────────────────
441
+
442
+ function parseArgs(argv) {
443
+ const args = {};
444
+ const named = { '--manifest': 'manifest', '--decision': 'decision', '--fimo': 'fimo' };
445
+ for (let index = 0; index < argv.length; index += 1) {
446
+ const arg = argv[index];
447
+ if (arg === '--help' || arg === '-h') {
448
+ console.log(
449
+ 'Usage: node apply-decision.mjs [project-root] [--decision .fimo/migration/decision.json] ' +
450
+ '[--manifest .fimo/migration/manifest.json] [--fimo "npx fimo@latest"]'
451
+ );
452
+ process.exit(0);
453
+ }
454
+ const inlined = Object.keys(named).find((flag) => arg.startsWith(`${flag}=`));
455
+ if (inlined) {
456
+ args[named[inlined]] = arg.slice(inlined.length + 1);
457
+ continue;
458
+ }
459
+ if (named[arg]) {
460
+ args[named[arg]] = argv[++index];
461
+ continue;
462
+ }
463
+ if (!args.projectRoot) {
464
+ args.projectRoot = arg;
465
+ continue;
466
+ }
467
+ fail(`Unknown argument: ${arg}`);
468
+ }
469
+ return args;
470
+ }
471
+
472
+ function phase(phases, name, run) {
473
+ const started = Date.now();
474
+ const value = run();
475
+ phases.push({ phase: name, durationMs: Date.now() - started });
476
+ return value;
477
+ }
478
+
479
+ function unwrapData(value) {
480
+ return isRecord(value) && isRecord(value.data) ? value.data : value;
481
+ }
482
+
483
+ function pluckArray(value) {
484
+ if (Array.isArray(value)) {
485
+ return value;
486
+ }
487
+ if (isRecord(value)) {
488
+ for (const key of ['data', 'items', 'results']) {
489
+ if (Array.isArray(value[key])) {
490
+ return value[key];
491
+ }
492
+ }
493
+ }
494
+ return [];
495
+ }
496
+
497
+ function readJson(filePath) {
498
+ if (!fs.existsSync(filePath)) {
499
+ fail(`File not found: ${filePath}`);
500
+ }
501
+ try {
502
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
503
+ } catch (error) {
504
+ fail(`Could not parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
505
+ }
506
+ }
507
+
508
+ function writeJsonFile(filePath, value) {
509
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
510
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
511
+ }
512
+
513
+ function isRecord(value) {
514
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
515
+ }
516
+
517
+ function asArray(value) {
518
+ return Array.isArray(value) ? value : [];
519
+ }
520
+
521
+ function compareStrings(left, right) {
522
+ return left < right ? -1 : left > right ? 1 : 0;
523
+ }
524
+
525
+ function toPosix(value) {
526
+ return String(value).split(path.sep).join('/');
527
+ }
528
+
529
+ function fail(message) {
530
+ console.error(message);
531
+ process.exit(1);
532
+ }
533
+
534
+ main();
@@ -29,7 +29,8 @@ function main() {
29
29
 
30
30
  if (!urlLedger && !sourceLedger) {
31
31
  fail(
32
- `No migration ledgers found in ${projectRoot}. Expected .fimo/migrations/current/manifest.json or .fimo/source-migration/manifest.json.`
32
+ `No migration ledgers found in ${projectRoot}. Expected .fimo/migrations/current/manifest.json or .fimo/source-migration/manifest.json. ` +
33
+ 'This script only merges existing draft ledgers; for a fresh source-backed migration write .fimo/migration/manifest.json directly following references/manifest.md.'
33
34
  );
34
35
  }
35
36
 
@@ -83,7 +84,7 @@ function parseArgs(argv) {
83
84
  }
84
85
 
85
86
  function buildCanonicalManifest({ projectRoot, urlLedger, urlLedgerPath, sourceLedger, sourceLedgerPath, previous }) {
86
- const source = buildSource({ urlLedger, urlLedgerPath, sourceLedger, sourceLedgerPath, projectRoot });
87
+ const source = buildSource({ urlLedger, urlLedgerPath, sourceLedger, sourceLedgerPath, projectRoot, previous });
87
88
  const routes = applyGeneratedRouteArtifacts(
88
89
  projectRoot,
89
90
  mergeItems([...normalizeSourceRoutes(sourceLedger?.routes), ...normalizeUrlRoutes(urlLedger?.pages)])
@@ -123,19 +124,21 @@ function buildCanonicalManifest({ projectRoot, urlLedger, urlLedgerPath, sourceL
123
124
  return preservePreviousProgress(manifest, previous);
124
125
  }
125
126
 
126
- function buildSource({ urlLedger, urlLedgerPath, sourceLedger, sourceLedgerPath, projectRoot }) {
127
+ function buildSource({ urlLedger, urlLedgerPath, sourceLedger, sourceLedgerPath, projectRoot, previous }) {
127
128
  const hasUrl = Boolean(urlLedger);
128
129
  const hasSource = Boolean(sourceLedger);
129
130
  const sourceLedgers = [];
130
131
  if (hasUrl) sourceLedgers.push(toPosix(path.relative(projectRoot, urlLedgerPath)));
131
132
  if (hasSource) sourceLedgers.push(toPosix(path.relative(projectRoot, sourceLedgerPath)));
132
133
 
134
+ const cli = sourceLedger?.source?.cli ?? urlLedger?.source?.cli ?? previous?.source?.cli;
135
+
133
136
  return {
134
137
  mode: hasUrl && hasSource ? 'hybrid' : hasSource ? 'source-backed' : 'url-only',
135
138
  platform: sourceLedger?.source?.platform ?? urlLedger?.source?.platform ?? 'unknown',
136
139
  publicUrl: sourceLedger?.source?.publicUrl ?? urlLedger?.source?.url ?? null,
137
140
  rootPath: sourceLedger?.source?.rootPath ?? null,
138
- inventoryPath: sourceLedger?.source?.inventoryPath ?? null,
141
+ ...(cli ? { cli } : {}),
139
142
  sourceLedgers,
140
143
  };
141
144
  }