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.
- package/assets/astro/components/DateTime.astro +22 -0
- package/assets/astro/components/index.d.ts +1 -0
- package/assets/astro/components/index.js +1 -0
- package/assets/content-templates/shared/types.eta +2 -0
- package/assets/skills/fimo-cli/SKILL.md +8 -6
- package/assets/skills/fimo-cli/references/branches.md +2 -2
- package/assets/skills/fimo-cli/references/content.md +2 -2
- package/assets/skills/fimo-cli/references/deploy.md +17 -9
- package/assets/skills/fimo-cli/references/hosting.md +114 -0
- package/assets/skills/fimo-cli/references/init.md +13 -6
- package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
- package/assets/skills/fimo-cli/references/locales.md +38 -14
- package/assets/skills/fimo-cli/references/pages.md +70 -0
- package/assets/skills/fimo-cli/references/preview.md +26 -20
- package/assets/skills/fimo-cli/references/webhooks.md +1 -1
- package/assets/skills/fimo-code/SKILL.md +11 -0
- package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
- package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
- package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
- package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
- package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
- package/assets/skills/fimo-code/references/content/richtext.md +5 -0
- package/assets/skills/fimo-code/references/content/schema.md +4 -1
- package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
- package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
- package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
- package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
- package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
- package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
- package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
- package/assets/skills/fimo-code/references/labels.md +9 -1
- package/assets/skills/fimo-code/references/locales.md +8 -2
- package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
- package/assets/skills/fimo-code/references/ui.md +21 -2
- package/assets/skills/fimo-migration/SKILL.md +276 -51
- package/assets/skills/fimo-migration/references/content.md +119 -35
- package/assets/skills/fimo-migration/references/decision.md +72 -0
- package/assets/skills/fimo-migration/references/forms.md +38 -6
- package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
- package/assets/skills/fimo-migration/references/import-contract.md +71 -19
- package/assets/skills/fimo-migration/references/manifest.md +11 -161
- package/assets/skills/fimo-migration/references/media.md +33 -1
- package/assets/skills/fimo-migration/references/platforms.md +26 -2
- package/assets/skills/fimo-migration/references/qa.md +167 -13
- package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
- package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
- package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
- package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
- package/assets/skills/fimo-migration/references/workers.md +82 -0
- package/assets/skills/fimo-migration/references/workflow.md +140 -192
- package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
- package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
- package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
- package/assets/skills/fimo-studio/references/ai-chat.md +1 -1
- package/assets/skills/fimo-studio/references/cms.md +25 -7
- package/assets/skills/fimo-studio/references/webhooks.md +1 -1
- package/assets/sveltekit/components/DateTime.svelte +35 -0
- package/assets/sveltekit/components/FimoProvider.svelte +2 -2
- package/assets/sveltekit/components/index.d.ts +1 -0
- package/assets/sveltekit/components/index.js +1 -0
- package/dist/build/next/config.d.ts +4 -1
- package/dist/build/next/config.js +21 -5
- package/dist/build/sveltekit/vite.d.ts +3 -4
- package/dist/build/sveltekit/vite.js +3 -4
- package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
- package/dist/build/vite/plugins/runtime-env.js +14 -10
- package/dist/cli/bundle.json +2 -2
- package/dist/cli/index.js +113 -108
- package/dist/runtime/app/FimoProviders.js +25 -14
- package/dist/runtime/app/prefetch.d.ts +3 -1
- package/dist/runtime/app/prefetch.js +11 -55
- package/dist/runtime/astro/preview.js +7 -9
- package/dist/runtime/content/client/internal.js +1 -0
- package/dist/runtime/i18n/locale.d.ts +6 -3
- package/dist/runtime/i18n/locale.js +16 -41
- package/dist/runtime/next/client-runtime.js +5 -1
- package/dist/runtime/next/client.d.ts +3 -2
- package/dist/runtime/next/client.js +2 -1
- package/dist/runtime/next/labels.d.ts +2 -2
- package/dist/runtime/next/labels.js +2 -2
- package/dist/runtime/next/preview.d.ts +13 -0
- package/dist/runtime/next/preview.js +34 -0
- package/dist/runtime/next/primitives/server.d.ts +2 -0
- package/dist/runtime/next/primitives/server.js +8 -0
- package/dist/runtime/next/provider.d.ts +5 -0
- package/dist/runtime/next/provider.js +17 -3
- package/dist/runtime/next/server-runtime.js +4 -1
- package/dist/runtime/next/server.d.ts +1 -1
- package/dist/runtime/next/server.js +1 -1
- package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
- package/dist/runtime/paths/get-fimo-paths.js +43 -42
- package/dist/runtime/preview/location.d.ts +19 -2
- package/dist/runtime/preview/location.js +16 -2
- package/dist/runtime/primitives/index.d.ts +1 -0
- package/dist/runtime/primitives/index.js +1 -0
- package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
- package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
- package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
- package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
- package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
- package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
- package/dist/runtime/routes/index.d.ts +162 -0
- package/dist/runtime/routes/index.js +335 -0
- package/dist/runtime/routing/AppRoutesProvider.js +4 -3
- package/dist/runtime/seo/resolver.d.ts +5 -0
- package/dist/runtime/seo/resolver.js +10 -42
- package/dist/runtime/shared/fimo-config.types.d.ts +14 -13
- package/dist/runtime/shared/preview-location.d.ts +1 -22
- package/dist/runtime/shared/preview-location.js +5 -93
- package/dist/runtime/sveltekit/preview.d.ts +12 -3
- package/dist/runtime/sveltekit/preview.js +13 -8
- package/dist/runtime/templates.d.ts +19 -0
- package/dist/runtime/templates.js +33 -0
- package/dist/scripts/sync-forms.js +1 -1
- package/dist/scripts/sync-schemas.js +1 -4
- package/dist/scripts/validate-fimo-config.js +5 -1
- package/dist/scripts/validate-page-registry.d.ts +2 -0
- package/dist/scripts/validate-page-registry.js +167 -0
- package/dist/scripts/validate-route-metadata.js +132 -41
- package/package.json +10 -5
- package/templates/astro/.fimo/config.json +13 -5
- package/templates/astro/AGENTS.md +5 -1
- package/templates/astro/astro.config.mjs +7 -0
- package/templates/astro/pnpm-workspace.yaml +0 -3
- package/templates/astro/src/components/ProjectOverview.astro +9 -6
- package/templates/astro/src/layouts/Base.astro +1 -1
- package/templates/astro/src/locales.ts +4 -0
- package/templates/astro/src/pages/[locale]/index.astro +19 -0
- package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
- package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
- package/templates/astro/src/pages/index.astro +5 -2
- package/templates/astro/src/pages/projects/[slug].astro +4 -2
- package/templates/astro/src/pages/projects/index.astro +5 -2
- package/templates/nextjs/.fimo/config.json +2 -1
- package/templates/nextjs/AGENTS.md +5 -0
- package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
- package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
- package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
- package/templates/nextjs/src/components/Document.tsx +26 -0
- package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
- package/templates/nextjs/src/locales.ts +13 -0
- package/templates/react-router/.fimo/config.json +3 -6
- package/templates/react-router/AGENTS.md +4 -0
- package/templates/react-router/package.json +1 -1
- package/templates/react-router/pnpm-workspace.yaml +1 -3
- package/templates/react-router/react-router.config.ts +4 -10
- package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
- package/templates/react-router/src/root.tsx +2 -4
- package/templates/react-router/src/routes.ts +5 -3
- package/templates/sveltekit/.fimo/config.json +2 -1
- package/templates/sveltekit/AGENTS.md +5 -0
- package/templates/sveltekit/src/app.html +1 -1
- package/templates/sveltekit/src/hooks.server.ts +11 -0
- package/templates/sveltekit/src/locales.ts +8 -0
- package/templates/sveltekit/src/params/locale.ts +5 -0
- package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
- package/templates/sveltekit/src/routes/+layout.svelte +5 -0
- package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
- package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
- package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
- package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
- package/templates/nextjs/src/app/layout.tsx +0 -17
- /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
- /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
|
-
|
|
141
|
+
...(cli ? { cli } : {}),
|
|
139
142
|
sourceLedgers,
|
|
140
143
|
};
|
|
141
144
|
}
|