fimo 0.7.0-experimental.1 → 0.7.0-experimental.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/content-templates/hooks-template.eta +27 -73
- package/assets/content-templates/types-template.eta +1 -4
- package/assets/skills/fimo/SKILL.md +2 -2
- package/assets/skills/fimo/references/content.md +42 -46
- package/assets/skills/fimo/references/locales.md +0 -8
- package/assets/skills/fimo/references/setup-react-router.md +2 -2
- package/assets/skills/fimo/references/ui.md +1 -1
- package/assets/skills/fimo-cli/references/content.md +14 -17
- package/assets/skills/fimo-cli/references/locales.md +0 -6
- package/assets/skills/fimo-migration/SKILL.md +22 -2
- package/assets/skills/fimo-migration/references/content.md +8 -0
- package/assets/skills/fimo-migration/references/manifest.md +35 -0
- package/assets/skills/fimo-migration/references/qa.md +24 -2
- package/assets/skills/fimo-migration/references/workflow.md +12 -1
- package/assets/skills/fimo-migration/references/wrap.md +617 -0
- package/assets/skills/fimo-studio/references/visual-editing.md +5 -3
- package/dist/build/vite/plugins/translations.d.ts +2 -8
- package/dist/build/vite/plugins/translations.d.ts.map +1 -1
- package/dist/build/vite/plugins/translations.js +3 -121
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +20655 -12551
- package/dist/runtime/app/FimoProviders.d.ts +1 -11
- package/dist/runtime/app/FimoProviders.d.ts.map +1 -1
- package/dist/runtime/app/FimoProviders.js +2 -23
- package/dist/runtime/astro/index.d.ts +2 -0
- package/dist/runtime/astro/index.d.ts.map +1 -0
- package/dist/runtime/astro/index.js +3 -0
- package/dist/runtime/astro/server.d.ts +3 -0
- package/dist/runtime/astro/server.d.ts.map +1 -0
- package/dist/runtime/astro/server.js +6 -0
- package/dist/runtime/content/utils/retrieve-data-sources.d.ts +0 -1
- package/dist/runtime/content/utils/retrieve-data-sources.d.ts.map +1 -1
- package/dist/runtime/content/utils/retrieve-data-sources.js +4 -27
- package/dist/runtime/i18n/FimoLocaleContext.d.ts.map +1 -1
- package/dist/runtime/i18n/FimoLocaleContext.js +1 -0
- package/dist/runtime/next/FimoNextProviders.d.ts +24 -0
- package/dist/runtime/next/FimoNextProviders.d.ts.map +1 -0
- package/dist/runtime/next/FimoNextProviders.js +20 -0
- package/dist/runtime/next/FimoNextScripts.d.ts +10 -0
- package/dist/runtime/next/FimoNextScripts.d.ts.map +1 -0
- package/dist/runtime/next/FimoNextScripts.js +19 -0
- package/dist/runtime/next/index.d.ts +14 -0
- package/dist/runtime/next/index.d.ts.map +1 -0
- package/dist/runtime/next/index.js +14 -0
- package/dist/runtime/next/server.d.ts +2 -0
- package/dist/runtime/next/server.d.ts.map +1 -0
- package/dist/runtime/next/server.js +4 -0
- package/dist/runtime/nuxt/composables.d.ts +9 -0
- package/dist/runtime/nuxt/composables.d.ts.map +1 -0
- package/dist/runtime/nuxt/composables.js +57 -0
- package/dist/runtime/nuxt/index.d.ts +3 -0
- package/dist/runtime/nuxt/index.d.ts.map +1 -0
- package/dist/runtime/nuxt/index.js +6 -0
- package/dist/runtime/nuxt/server.d.ts +3 -0
- package/dist/runtime/nuxt/server.d.ts.map +1 -0
- package/dist/runtime/nuxt/server.js +6 -0
- package/dist/runtime/paths/get-fimo-paths.d.ts.map +1 -1
- package/dist/runtime/paths/get-fimo-paths.js +5 -15
- package/dist/runtime/primitives/labels-hook.d.ts +33 -0
- package/dist/runtime/primitives/labels-hook.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-hook.js +41 -0
- package/dist/runtime/primitives/labels-store.d.ts +12 -0
- package/dist/runtime/primitives/labels-store.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-store.js +60 -0
- package/dist/runtime/primitives/labels-store.test.d.ts +2 -0
- package/dist/runtime/primitives/labels-store.test.d.ts.map +1 -0
- package/dist/runtime/primitives/labels-store.test.js +66 -0
- package/dist/runtime/primitives/lib/primitives.d.ts +2 -2
- package/dist/runtime/primitives/lib/primitives.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/query.d.ts +3 -5
- package/dist/runtime/primitives/lib/query.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts +5 -3
- package/dist/runtime/primitives/lib/useFimoLiveValue.d.ts.map +1 -1
- package/dist/runtime/primitives/lib/useFimoLiveValue.js +11 -4
- package/dist/runtime/primitives/public-surface.test.js +5 -9
- package/dist/runtime/primitives/translations.d.ts +1 -27
- package/dist/runtime/primitives/translations.d.ts.map +1 -1
- package/dist/runtime/primitives/translations.js +7 -85
- package/dist/runtime/react/FimoSpaProviders.d.ts +36 -0
- package/dist/runtime/react/FimoSpaProviders.d.ts.map +1 -0
- package/dist/runtime/react/FimoSpaProviders.js +63 -0
- package/dist/runtime/react/index.d.ts +33 -0
- package/dist/runtime/react/index.d.ts.map +1 -0
- package/dist/runtime/react/index.js +39 -0
- package/dist/runtime/remix/FimoRemixProviders.d.ts +16 -0
- package/dist/runtime/remix/FimoRemixProviders.d.ts.map +1 -0
- package/dist/runtime/remix/FimoRemixProviders.js +12 -0
- package/dist/runtime/remix/FimoRemixScripts.d.ts +10 -0
- package/dist/runtime/remix/FimoRemixScripts.d.ts.map +1 -0
- package/dist/runtime/remix/FimoRemixScripts.js +19 -0
- package/dist/runtime/remix/index.d.ts +12 -0
- package/dist/runtime/remix/index.d.ts.map +1 -0
- package/dist/runtime/remix/index.js +11 -0
- package/dist/runtime/remix/server.d.ts +2 -0
- package/dist/runtime/remix/server.d.ts.map +1 -0
- package/dist/runtime/remix/server.js +2 -0
- package/dist/runtime/seo/buildSeoForPath.d.ts +3 -1
- package/dist/runtime/seo/buildSeoForPath.d.ts.map +1 -1
- package/dist/runtime/seo/buildSeoForPath.js +1 -4
- package/dist/runtime/shared/display-routes.d.ts +14 -0
- package/dist/runtime/shared/display-routes.d.ts.map +1 -0
- package/dist/runtime/shared/display-routes.js +25 -0
- package/dist/runtime/shared/fimo-config.server.d.ts.map +1 -1
- package/dist/runtime/shared/fimo-config.server.js +1 -0
- package/dist/runtime/shared/fimo-config.types.d.ts +2 -0
- package/dist/runtime/shared/fimo-config.types.d.ts.map +1 -1
- package/dist/runtime/shared/i18n-messages.d.ts +29 -0
- package/dist/runtime/shared/i18n-messages.d.ts.map +1 -0
- package/dist/runtime/shared/i18n-messages.js +79 -0
- package/dist/runtime/shared/i18n-messages.test.d.ts +2 -0
- package/dist/runtime/shared/i18n-messages.test.d.ts.map +1 -0
- package/dist/runtime/shared/i18n-messages.test.js +39 -0
- package/dist/runtime/shared/labels-fetch.pure.d.ts +17 -0
- package/dist/runtime/shared/labels-fetch.pure.d.ts.map +1 -0
- package/dist/runtime/shared/labels-fetch.pure.js +71 -0
- package/dist/runtime/shared/labels-fetch.server.d.ts +7 -0
- package/dist/runtime/shared/labels-fetch.server.d.ts.map +1 -0
- package/dist/runtime/shared/labels-fetch.server.js +97 -0
- package/dist/runtime/shared/labels-t.pure.d.ts +2 -0
- package/dist/runtime/shared/labels-t.pure.d.ts.map +1 -0
- package/dist/runtime/shared/labels-t.pure.js +10 -0
- package/dist/runtime/strapi/client.d.ts +26 -0
- package/dist/runtime/strapi/client.d.ts.map +1 -0
- package/dist/runtime/strapi/client.js +88 -0
- package/dist/runtime/strapi/client.test.d.ts +2 -0
- package/dist/runtime/strapi/client.test.d.ts.map +1 -0
- package/dist/runtime/strapi/client.test.js +64 -0
- package/dist/runtime/strapi/index.d.ts +5 -0
- package/dist/runtime/strapi/index.d.ts.map +1 -0
- package/dist/runtime/strapi/index.js +6 -0
- package/dist/runtime/strapi/reshape.d.ts +28 -0
- package/dist/runtime/strapi/reshape.d.ts.map +1 -0
- package/dist/runtime/strapi/reshape.js +79 -0
- package/dist/runtime/strapi/reshape.test.d.ts +2 -0
- package/dist/runtime/strapi/reshape.test.d.ts.map +1 -0
- package/dist/runtime/strapi/reshape.test.js +33 -0
- package/dist/runtime/svelte/index.d.ts +3 -0
- package/dist/runtime/svelte/index.d.ts.map +1 -0
- package/dist/runtime/svelte/index.js +7 -0
- package/dist/runtime/svelte/server.d.ts +3 -0
- package/dist/runtime/svelte/server.d.ts.map +1 -0
- package/dist/runtime/svelte/server.js +6 -0
- package/dist/runtime/vue/index.d.ts +3 -0
- package/dist/runtime/vue/index.d.ts.map +1 -0
- package/dist/runtime/vue/index.js +5 -0
- package/dist/runtime/vue/server.d.ts +3 -0
- package/dist/runtime/vue/server.d.ts.map +1 -0
- package/dist/runtime/vue/server.js +6 -0
- package/dist/runtime/wrapped/FimoFileLink.d.ts +17 -0
- package/dist/runtime/wrapped/FimoFileLink.d.ts.map +1 -0
- package/dist/runtime/wrapped/FimoFileLink.js +19 -0
- package/dist/runtime/wrapped/FimoMediaImage.d.ts +14 -0
- package/dist/runtime/wrapped/FimoMediaImage.d.ts.map +1 -0
- package/dist/runtime/wrapped/FimoMediaImage.js +20 -0
- package/dist/runtime/wrapped/FimoMediaVideo.d.ts +14 -0
- package/dist/runtime/wrapped/FimoMediaVideo.d.ts.map +1 -0
- package/dist/runtime/wrapped/FimoMediaVideo.js +20 -0
- package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +23 -0
- package/dist/runtime/wrapped/FimoRichTextBlock.d.ts.map +1 -0
- package/dist/runtime/wrapped/FimoRichTextBlock.js +22 -0
- package/dist/runtime/wrapped/FimoText.d.ts +22 -0
- package/dist/runtime/wrapped/FimoText.d.ts.map +1 -0
- package/dist/runtime/wrapped/FimoText.js +20 -0
- package/dist/runtime/wrapped/WrappedContentContext.d.ts +28 -0
- package/dist/runtime/wrapped/WrappedContentContext.d.ts.map +1 -0
- package/dist/runtime/wrapped/WrappedContentContext.js +30 -0
- package/dist/runtime/wrapped/WrappedProviders.d.ts +33 -0
- package/dist/runtime/wrapped/WrappedProviders.d.ts.map +1 -0
- package/dist/runtime/wrapped/WrappedProviders.js +101 -0
- package/dist/runtime/wrapped/dom.d.ts +4 -0
- package/dist/runtime/wrapped/dom.d.ts.map +1 -0
- package/dist/runtime/wrapped/dom.js +23 -0
- package/dist/runtime/wrapped/dom.test.d.ts +2 -0
- package/dist/runtime/wrapped/dom.test.d.ts.map +1 -0
- package/dist/runtime/wrapped/dom.test.js +16 -0
- package/dist/runtime/wrapped/server.d.ts +77 -0
- package/dist/runtime/wrapped/server.d.ts.map +1 -0
- package/dist/runtime/wrapped/server.js +237 -0
- package/dist/runtime/wrapped/server.test.d.ts +2 -0
- package/dist/runtime/wrapped/server.test.d.ts.map +1 -0
- package/dist/runtime/wrapped/server.test.js +91 -0
- package/dist/scripts/apply-wrap-templates.d.ts +61 -0
- package/dist/scripts/apply-wrap-templates.js +132 -0
- package/dist/scripts/apply-wrap-templates.test.d.ts +1 -0
- package/dist/scripts/apply-wrap-templates.test.js +199 -0
- package/dist/scripts/apply-wrap.d.ts +46 -0
- package/dist/scripts/apply-wrap.js +278 -0
- package/dist/scripts/apply-wrap.test.d.ts +1 -0
- package/dist/scripts/apply-wrap.test.js +226 -0
- package/dist/scripts/cms/discover.d.ts +2 -0
- package/dist/scripts/cms/discover.js +17 -0
- package/dist/scripts/cms/example-content.d.ts +32 -0
- package/dist/scripts/cms/example-content.js +108 -0
- package/dist/scripts/cms/example-content.test.d.ts +1 -0
- package/dist/scripts/cms/example-content.test.js +66 -0
- package/dist/scripts/cms/strapi/detect.d.ts +2 -0
- package/dist/scripts/cms/strapi/detect.js +186 -0
- package/dist/scripts/cms/strapi/detect.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/detect.test.js +56 -0
- package/dist/scripts/cms/strapi/index.d.ts +3 -0
- package/dist/scripts/cms/strapi/index.js +25 -0
- package/dist/scripts/cms/strapi/list-source.d.ts +17 -0
- package/dist/scripts/cms/strapi/list-source.js +90 -0
- package/dist/scripts/cms/strapi/list-source.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/list-source.test.js +33 -0
- package/dist/scripts/cms/strapi/schema-map.d.ts +9 -0
- package/dist/scripts/cms/strapi/schema-map.js +149 -0
- package/dist/scripts/cms/strapi/schema-map.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/schema-map.test.js +91 -0
- package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
- package/dist/scripts/cms/strapi/touchpoints.js +145 -0
- package/dist/scripts/cms/strapi/touchpoints.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/touchpoints.test.js +62 -0
- package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
- package/dist/scripts/cms/strapi/wire-client.js +107 -0
- package/dist/scripts/cms/strapi/wire-client.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/wire-client.test.js +51 -0
- package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
- package/dist/scripts/cms/strapi/wiring.js +36 -0
- package/dist/scripts/cms/strapi/wiring.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/wiring.test.js +27 -0
- package/dist/scripts/cms/types.d.ts +117 -0
- package/dist/scripts/cms/types.js +11 -0
- package/dist/scripts/detect-framework.d.ts +39 -0
- package/dist/scripts/detect-framework.js +133 -0
- package/dist/scripts/detect-framework.test.d.ts +1 -0
- package/dist/scripts/detect-framework.test.js +122 -0
- package/dist/scripts/draft-rows.d.ts +19 -0
- package/dist/scripts/draft-rows.js +135 -0
- package/dist/scripts/draft-rows.test.d.ts +1 -0
- package/dist/scripts/draft-rows.test.js +113 -0
- package/dist/scripts/draft-schemas.d.ts +27 -0
- package/dist/scripts/draft-schemas.js +188 -0
- package/dist/scripts/draft-schemas.test.d.ts +1 -0
- package/dist/scripts/draft-schemas.test.js +152 -0
- package/dist/scripts/extract-translations.js +16 -11
- package/dist/scripts/extract-translations.test.d.ts +1 -0
- package/dist/scripts/extract-translations.test.js +66 -0
- package/dist/scripts/frameworks/astro/routes.d.ts +3 -0
- package/dist/scripts/frameworks/astro/routes.js +55 -0
- package/dist/scripts/frameworks/astro/template-style.d.ts +7 -0
- package/dist/scripts/frameworks/astro/template-style.js +19 -0
- package/dist/scripts/frameworks/discover.d.ts +70 -0
- package/dist/scripts/frameworks/discover.js +178 -0
- package/dist/scripts/frameworks/next/rewire.d.ts +30 -0
- package/dist/scripts/frameworks/next/rewire.js +258 -0
- package/dist/scripts/frameworks/next/rewire.test.d.ts +1 -0
- package/dist/scripts/frameworks/next/rewire.test.js +134 -0
- package/dist/scripts/frameworks/next/routes.d.ts +17 -0
- package/dist/scripts/frameworks/next/routes.js +134 -0
- package/dist/scripts/frameworks/next/routes.test.d.ts +1 -0
- package/dist/scripts/frameworks/next/routes.test.js +64 -0
- package/dist/scripts/frameworks/next/wiring.d.ts +33 -0
- package/dist/scripts/frameworks/next/wiring.js +186 -0
- package/dist/scripts/frameworks/next/wiring.test.d.ts +1 -0
- package/dist/scripts/frameworks/next/wiring.test.js +174 -0
- package/dist/scripts/frameworks/nuxt/routes.d.ts +3 -0
- package/dist/scripts/frameworks/nuxt/routes.js +54 -0
- package/dist/scripts/frameworks/nuxt/template-style.d.ts +9 -0
- package/dist/scripts/frameworks/nuxt/template-style.js +27 -0
- package/dist/scripts/frameworks/remix/routes.d.ts +3 -0
- package/dist/scripts/frameworks/remix/routes.js +50 -0
- package/dist/scripts/frameworks/support.d.ts +75 -0
- package/dist/scripts/frameworks/support.js +165 -0
- package/dist/scripts/frameworks/support.test.d.ts +1 -0
- package/dist/scripts/frameworks/support.test.js +87 -0
- package/dist/scripts/frameworks/sveltekit/routes.d.ts +3 -0
- package/dist/scripts/frameworks/sveltekit/routes.js +50 -0
- package/dist/scripts/frameworks/sveltekit/template-style.d.ts +9 -0
- package/dist/scripts/frameworks/sveltekit/template-style.js +54 -0
- package/dist/scripts/frameworks/vite/routes.d.ts +15 -0
- package/dist/scripts/frameworks/vite/routes.js +93 -0
- package/dist/scripts/frameworks/vite/routes.test.d.ts +1 -0
- package/dist/scripts/frameworks/vite/routes.test.js +73 -0
- package/dist/scripts/frameworks/vite/wiring.d.ts +10 -0
- package/dist/scripts/frameworks/vite/wiring.js +99 -0
- package/dist/scripts/frameworks/vite/wiring.test.d.ts +1 -0
- package/dist/scripts/frameworks/vite/wiring.test.js +88 -0
- package/dist/scripts/generated-types.test.js +1 -1
- package/dist/scripts/i18n-namespace.d.ts +25 -0
- package/dist/scripts/i18n-namespace.js +53 -0
- package/dist/scripts/lib/validation-report.d.ts +1 -0
- package/dist/scripts/lib/validation-report.js +3 -0
- package/dist/scripts/render-parity/capture.d.ts +56 -0
- package/dist/scripts/render-parity/capture.js +366 -0
- package/dist/scripts/render-parity/capture.test.d.ts +1 -0
- package/dist/scripts/render-parity/capture.test.js +306 -0
- package/dist/scripts/render-parity/compare.d.ts +59 -0
- package/dist/scripts/render-parity/compare.js +0 -0
- package/dist/scripts/render-parity/compare.test.d.ts +1 -0
- package/dist/scripts/render-parity/compare.test.js +98 -0
- package/dist/scripts/render-parity/normalize-html.d.ts +13 -0
- package/dist/scripts/render-parity/normalize-html.js +223 -0
- package/dist/scripts/render-parity/normalize-html.test.d.ts +1 -0
- package/dist/scripts/render-parity/normalize-html.test.js +58 -0
- package/dist/scripts/render-parity/types.d.ts +41 -0
- package/dist/scripts/render-parity/types.js +1 -0
- package/dist/scripts/resolve-routes.js +1 -1
- package/dist/scripts/scan-wrappable.d.ts +196 -0
- package/dist/scripts/scan-wrappable.js +1227 -0
- package/dist/scripts/scan-wrappable.test.d.ts +1 -0
- package/dist/scripts/scan-wrappable.test.js +521 -0
- package/dist/scripts/sync-schemas.test.js +4 -24
- package/dist/scripts/validate-route-metadata.js +11 -0
- package/dist/scripts/validate-schemas.test.js +2 -2
- package/package.json +92 -5
- package/templates/react-router/package.json +7 -10
- package/dist/runtime/paths/get-fimo-paths.test.d.ts +0 -2
- package/dist/runtime/paths/get-fimo-paths.test.d.ts.map +0 -1
- package/dist/runtime/paths/get-fimo-paths.test.js +0 -69
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { writeRowsStaging } from './draft-rows.js';
|
|
6
|
+
import { writeSchemaDrafts } from './draft-schemas.js';
|
|
7
|
+
import { scanWrappable } from './scan-wrappable.js';
|
|
8
|
+
let dir;
|
|
9
|
+
const project = (files) => {
|
|
10
|
+
dir = mkdtempSync(join(tmpdir(), 'draft-rows-'));
|
|
11
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
12
|
+
const full = join(dir, rel);
|
|
13
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
14
|
+
writeFileSync(full, content);
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
};
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (dir) {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
describe('writeRowsStaging', () => {
|
|
24
|
+
it('stages data-file rows with media envelopes, richtext docs, and a reference descriptor', () => {
|
|
25
|
+
const root = project({
|
|
26
|
+
'src/data/authors.json': JSON.stringify([
|
|
27
|
+
{ slug: 'author-1', name: 'Maë Falk', bio: 'Keeps the berths in order at dawn.' },
|
|
28
|
+
{ slug: 'author-2', name: 'Idris Kade', bio: 'Maps the tide tables by hand.' },
|
|
29
|
+
{ slug: 'author-3', name: 'Sol Ferry', bio: 'Counts the lanterns every night.' },
|
|
30
|
+
]),
|
|
31
|
+
'src/data/posts.json': JSON.stringify([
|
|
32
|
+
{
|
|
33
|
+
slug: 'post-1',
|
|
34
|
+
title: 'Charting the bay together',
|
|
35
|
+
body: 'First paragraph of prose.\n\nSecond paragraph of prose.',
|
|
36
|
+
coverImage: '/images/bay.png',
|
|
37
|
+
author: 'author-1',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
slug: 'post-2',
|
|
41
|
+
title: 'Berth songs for the crew',
|
|
42
|
+
body: 'Another body paragraph.\n\nAnd its second half.',
|
|
43
|
+
coverImage: '/images/berth.png',
|
|
44
|
+
author: 'author-2',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
slug: 'post-3',
|
|
48
|
+
title: 'Lanterns over the reef',
|
|
49
|
+
body: 'Prose again in two parts.\n\nHere is the second.',
|
|
50
|
+
coverImage: '/images/reef.png',
|
|
51
|
+
author: 'author-3',
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
});
|
|
55
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
56
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
57
|
+
const written = writeRowsStaging(root, scan, drafts);
|
|
58
|
+
expect(written).toBe(2);
|
|
59
|
+
const posts = JSON.parse(readFileSync(join(root, '.fimo/migration/rows/Post.rows.json'), 'utf8'));
|
|
60
|
+
expect(posts.draftUid).toBe('Post');
|
|
61
|
+
expect(posts.references.author).toEqual({ target: 'Author', by: 'slug' });
|
|
62
|
+
expect(posts.rows).toHaveLength(3);
|
|
63
|
+
// Media enveloped, richtext converted, reference kept as SOURCE value.
|
|
64
|
+
expect(posts.rows[0].coverImage).toEqual({ source: 'local', url: '/images/bay.png', alt: '' });
|
|
65
|
+
expect(posts.rows[0].body.type).toBe('doc');
|
|
66
|
+
expect(posts.rows[0].body.content).toHaveLength(2);
|
|
67
|
+
expect(posts.rows[0].author).toBe('author-1');
|
|
68
|
+
});
|
|
69
|
+
it('stages a markdown group: verbatim body + typed frontmatter, one row per doc', () => {
|
|
70
|
+
const md = (title, body) => `---\ntitle: ${title}\ndate: 2026-03-01\n---\n\n${body}\n`;
|
|
71
|
+
const root = project({
|
|
72
|
+
'src/updates/first-release.md': md('First release', 'We shipped **the harbor** at last.\n\nMore detail here.'),
|
|
73
|
+
'src/updates/second-release.md': md('Second release', 'The north pier reopened.\n\nWith new lanterns.'),
|
|
74
|
+
});
|
|
75
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
76
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
77
|
+
const written = writeRowsStaging(root, scan, drafts);
|
|
78
|
+
expect(written).toBe(1);
|
|
79
|
+
const update = JSON.parse(readFileSync(join(root, '.fimo/migration/rows/Update.rows.json'), 'utf8'));
|
|
80
|
+
expect(update.draftUid).toBe('Update');
|
|
81
|
+
expect(update.rows).toHaveLength(2);
|
|
82
|
+
// Slug from the filename stem; body is the raw markdown, verbatim.
|
|
83
|
+
expect(update.rows[0].slug).toBe('first-release');
|
|
84
|
+
expect(update.rows[0].title).toBe('First release');
|
|
85
|
+
expect(update.rows[0].body).toBe('We shipped **the harbor** at last.\n\nMore detail here.');
|
|
86
|
+
expect(typeof update.rows[0].body).toBe('string');
|
|
87
|
+
});
|
|
88
|
+
it('stages in-code candidates from their typed rows', () => {
|
|
89
|
+
const root = project({
|
|
90
|
+
'src/pages/team.tsx': `const teamMembers = [
|
|
91
|
+
{ name: 'Maë Falk', bio: 'Keeps the berths in order.', headshot: '/images/mae.png', boats: 3 },
|
|
92
|
+
{ name: 'Idris Kade', bio: 'Maps every tide table.', headshot: '/images/idris.png', boats: 5 },
|
|
93
|
+
];
|
|
94
|
+
export default function Team() {
|
|
95
|
+
return <ul>{teamMembers.map((m) => (<li key={m.name}>{m.name} {m.bio}</li>))}</ul>;
|
|
96
|
+
}
|
|
97
|
+
`,
|
|
98
|
+
});
|
|
99
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
100
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
101
|
+
const written = writeRowsStaging(root, scan, drafts);
|
|
102
|
+
expect(written).toBe(1);
|
|
103
|
+
const team = JSON.parse(readFileSync(join(root, '.fimo/migration/rows/TeamMember.rows.json'), 'utf8'));
|
|
104
|
+
expect(team.rows).toHaveLength(2);
|
|
105
|
+
expect(team.rows[1]).toEqual({
|
|
106
|
+
name: 'Idris Kade',
|
|
107
|
+
bio: 'Maps every tide table.',
|
|
108
|
+
headshot: { source: 'local', url: '/images/idris.png', alt: '' },
|
|
109
|
+
boats: 5,
|
|
110
|
+
});
|
|
111
|
+
expect(existsSync(join(root, '.fimo/migration/rows'))).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { writeSchemaDrafts } from './draft-schemas.js';
|
|
6
|
+
import { scanWrappable } from './scan-wrappable.js';
|
|
7
|
+
let dir;
|
|
8
|
+
const project = (files) => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), 'draft-schemas-'));
|
|
10
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
11
|
+
const full = join(dir, rel);
|
|
12
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
13
|
+
writeFileSync(full, content);
|
|
14
|
+
}
|
|
15
|
+
return dir;
|
|
16
|
+
};
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (dir) {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
describe('writeSchemaDrafts', () => {
|
|
23
|
+
it('infers deep field types from an in-code candidate and names it singular PascalCase', () => {
|
|
24
|
+
const root = project({
|
|
25
|
+
'src/pages/team.tsx': `const teamMembers = [
|
|
26
|
+
{ name: 'Maë Falk', bio: 'Keeps the berths in order.', headshot: '/images/mae.png', startedAt: '2019-04-02', active: true, boats: 3 },
|
|
27
|
+
{ name: 'Idris Kade', bio: 'Maps every tide table.', headshot: '/images/idris.png', startedAt: '2021-11-20', active: false, boats: 5 },
|
|
28
|
+
];
|
|
29
|
+
export default function Team() {
|
|
30
|
+
return <ul>{teamMembers.map((m) => (<li key={m.name}>{m.name} {m.bio}</li>))}</ul>;
|
|
31
|
+
}
|
|
32
|
+
`,
|
|
33
|
+
});
|
|
34
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
35
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
36
|
+
expect(drafts.map((draft) => draft.uid)).toEqual(['TeamMember']);
|
|
37
|
+
const draft = JSON.parse(readFileSync(join(root, '.fimo/migration/schema-drafts/TeamMember.json'), 'utf8'));
|
|
38
|
+
expect(draft.uid).toBe('TeamMember');
|
|
39
|
+
// Classification context so the agent never reopens the source files.
|
|
40
|
+
expect(draft.source).toContain('src/pages/team.tsx');
|
|
41
|
+
expect(draft.rowCount).toBe(2);
|
|
42
|
+
expect(draft.sample.name).toBe('Maë Falk');
|
|
43
|
+
expect(draft.fields.name).toEqual({ type: 'string' });
|
|
44
|
+
expect(draft.fields.headshot).toEqual({ type: 'media' });
|
|
45
|
+
expect(draft.fields.startedAt).toEqual({ type: 'date' });
|
|
46
|
+
expect(draft.fields.active).toEqual({ type: 'boolean' });
|
|
47
|
+
expect(draft.fields.boats).toEqual({ type: 'number' });
|
|
48
|
+
expect(existsSync(join(root, '.fimo/migration/schema-drafts/README.md'))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
it('maps detected references to reference fields with provisional target names', () => {
|
|
51
|
+
const root = project({
|
|
52
|
+
'src/pages/board.tsx': `const authors = [
|
|
53
|
+
{ slug: 'author-1', name: 'Maë Falk', role: 'Writer of long tides' },
|
|
54
|
+
{ slug: 'author-2', name: 'Idris Kade', role: 'Editor of every chart' },
|
|
55
|
+
{ slug: 'author-3', name: 'Sol Ferry', role: 'Keeper of the lanterns' },
|
|
56
|
+
];
|
|
57
|
+
const posts = [
|
|
58
|
+
{ slug: 'post-1', title: 'Charting the bay together', author: 'author-1', tags: ['tag-a', 'tag-b'] },
|
|
59
|
+
{ slug: 'post-2', title: 'Berth songs for the crew', author: 'author-2', tags: ['tag-a'] },
|
|
60
|
+
{ slug: 'post-3', title: 'Lanterns over the reef', author: 'author-3', tags: ['tag-b'] },
|
|
61
|
+
];
|
|
62
|
+
export default function Board() {
|
|
63
|
+
return (
|
|
64
|
+
<div>
|
|
65
|
+
<ul>{authors.map((a) => (<li key={a.slug}>{a.name} {a.role}</li>))}</ul>
|
|
66
|
+
<ol>{posts.map((p) => (<li key={p.slug}>{p.title}</li>))}</ol>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
});
|
|
72
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
73
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
74
|
+
const post = JSON.parse(readFileSync(join(root, '.fimo/migration/schema-drafts/Post.json'), 'utf8'));
|
|
75
|
+
expect(post.fields.author).toEqual({ type: 'reference', target: 'Author' });
|
|
76
|
+
expect(post.fields.slug).toBeUndefined();
|
|
77
|
+
expect(drafts.some((draft) => draft.uid === 'Author')).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it('drafts data-file collections with prose as richtext', () => {
|
|
80
|
+
const root = project({
|
|
81
|
+
'src/data/caseStudies.json': JSON.stringify([
|
|
82
|
+
{
|
|
83
|
+
slug: 'case-1',
|
|
84
|
+
title: 'Rebuilding the north pier',
|
|
85
|
+
challenge: 'First paragraph of the challenge.\n\nSecond paragraph with detail.',
|
|
86
|
+
publishedAt: '2025-03-14',
|
|
87
|
+
coverImage: '/images/pier.png',
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
slug: 'case-2',
|
|
91
|
+
title: 'Charting the shallows',
|
|
92
|
+
challenge: 'Another two-paragraph body.\n\nSecond part here.',
|
|
93
|
+
publishedAt: '2025-06-02',
|
|
94
|
+
coverImage: '/images/shallows.png',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
slug: 'case-3',
|
|
98
|
+
title: 'The lantern census',
|
|
99
|
+
challenge: 'More prose in paragraphs.\n\nStill more prose.',
|
|
100
|
+
publishedAt: '2025-08-19',
|
|
101
|
+
coverImage: '/images/lanterns.png',
|
|
102
|
+
},
|
|
103
|
+
]),
|
|
104
|
+
});
|
|
105
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
106
|
+
writeSchemaDrafts(root, scan);
|
|
107
|
+
const draft = JSON.parse(readFileSync(join(root, '.fimo/migration/schema-drafts/CaseStudy.json'), 'utf8'));
|
|
108
|
+
expect(draft.uid).toBe('CaseStudy');
|
|
109
|
+
expect(draft.fields.challenge).toEqual({ type: 'richtext' });
|
|
110
|
+
expect(draft.fields.publishedAt).toEqual({ type: 'date' });
|
|
111
|
+
expect(draft.fields.coverImage).toEqual({ type: 'media' });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe('markdown group staging (Tier 1b)', () => {
|
|
115
|
+
const post = (title, author, date, body) => `---\ntitle: ${title}\nauthor: ${author}\ndate: ${date}\nfeatured: true\n---\n\n${body}\n`;
|
|
116
|
+
it('drafts a markdown blog as a collection: typed frontmatter, verbatim body, reference', () => {
|
|
117
|
+
const root = project({
|
|
118
|
+
'src/authors/authors.json': JSON.stringify([
|
|
119
|
+
{ slug: 'mae-falk', name: 'Maë Falk', bio: 'Keeps the berths in order at dawn each day.' },
|
|
120
|
+
{ slug: 'idris-kade', name: 'Idris Kade', bio: 'Maps every tide table by hand and by heart.' },
|
|
121
|
+
]),
|
|
122
|
+
'src/posts/charting-the-bay.md': post('Charting the bay', 'mae-falk', '2026-01-12', '# Charting the bay\n\nFirst paragraph with **bold** markdown.\n\nSecond paragraph here.'),
|
|
123
|
+
'src/posts/berth-songs.md': post('Berth songs', 'idris-kade', '2026-02-03', '## Berth songs\n\nAnother body with a [link](https://x.test).\n\nAnd its second half.'),
|
|
124
|
+
});
|
|
125
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
126
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
127
|
+
expect(drafts.map((draft) => draft.uid).sort()).toEqual(['Author', 'Post']);
|
|
128
|
+
const post_ = JSON.parse(readFileSync(join(root, '.fimo/migration/schema-drafts/Post.json'), 'utf8'));
|
|
129
|
+
// Body stays a verbatim string (NOT richtext) so the user's md renderer works.
|
|
130
|
+
expect(post_.fields.body).toEqual({ type: 'string' });
|
|
131
|
+
expect(post_.fields.title).toEqual({ type: 'string' });
|
|
132
|
+
expect(post_.fields.date).toEqual({ type: 'date' });
|
|
133
|
+
expect(post_.fields.featured).toEqual({ type: 'boolean' });
|
|
134
|
+
// Frontmatter author matching authors.json → a reference, not a string.
|
|
135
|
+
expect(post_.fields.author).toEqual({ type: 'reference', target: 'Author' });
|
|
136
|
+
expect(post_.fields.slug).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('singularPascal edge cases', () => {
|
|
140
|
+
it('keeps vowel+ses plurals whole (the PressReleas bug)', () => {
|
|
141
|
+
const root = project({
|
|
142
|
+
'src/data/pressReleases.json': JSON.stringify([
|
|
143
|
+
{ slug: 'pr-1', title: 'Harbor opens the north pier to night traffic' },
|
|
144
|
+
{ slug: 'pr-2', title: 'New tide charts published for every berth' },
|
|
145
|
+
{ slug: 'pr-3', title: 'Lantern census completes its tenth year' },
|
|
146
|
+
]),
|
|
147
|
+
});
|
|
148
|
+
const scan = scanWrappable(root, { keepInternal: true });
|
|
149
|
+
const drafts = writeSchemaDrafts(root, scan);
|
|
150
|
+
expect(drafts.map((draft) => draft.uid)).toContain('PressRelease');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 } = resolveScopedKey(name, firstArg.value, childScopes);
|
|
50
|
+
// A namespace-scoped var (any name) OR a bare Fimo `t(...)` — record the
|
|
51
|
+
// resolved key (scoped prepends the namespace; unscoped `t` is the leaf).
|
|
52
|
+
if (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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { extractLabelKeys } from './extract-translations.js';
|
|
6
|
+
let dir;
|
|
7
|
+
function project(files) {
|
|
8
|
+
dir = mkdtempSync(join(tmpdir(), 'extract-tr-'));
|
|
9
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
10
|
+
const full = join(dir, rel);
|
|
11
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
12
|
+
writeFileSync(full, content);
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
const keysOf = (cwd) => extractLabelKeys({ cwd }).map((declaration) => declaration.key);
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (dir) {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
describe('extractLabelKeys', () => {
|
|
23
|
+
it('extracts a bare Fimo t() key verbatim', () => {
|
|
24
|
+
const cwd = project({ 'src/a.tsx': "export function A() { return <b>{t('nav.home')}</b>; }" });
|
|
25
|
+
expect(keysOf(cwd)).toEqual(['nav.home']);
|
|
26
|
+
});
|
|
27
|
+
it('resolves next-intl useTranslations(namespace) into the full key', () => {
|
|
28
|
+
const cwd = project({
|
|
29
|
+
'src/checkout.tsx': `import { useTranslations } from 'next-intl';
|
|
30
|
+
export function Checkout() {
|
|
31
|
+
const t = useTranslations('plans.pricingCheckoutModal');
|
|
32
|
+
return <button>{t('buyNow')}</button>;
|
|
33
|
+
}`,
|
|
34
|
+
});
|
|
35
|
+
expect(keysOf(cwd)).toEqual(['plans.pricingCheckoutModal.buyNow']);
|
|
36
|
+
});
|
|
37
|
+
it('scopes per component — two useTranslations in one file do not cross-contaminate', () => {
|
|
38
|
+
const cwd = project({
|
|
39
|
+
'src/two.tsx': `import { useTranslations } from 'next-intl';
|
|
40
|
+
export function A() { const t = useTranslations('a'); return <p>{t('x')}</p>; }
|
|
41
|
+
export function B() { const t = useTranslations('b'); return <p>{t('x')}</p>; }`,
|
|
42
|
+
});
|
|
43
|
+
expect(keysOf(cwd)).toEqual(['a.x', 'b.x']);
|
|
44
|
+
});
|
|
45
|
+
it('handles a non-t binding and a root-scoped useTranslations()', () => {
|
|
46
|
+
const cwd = project({
|
|
47
|
+
'src/misc.tsx': `import { useTranslations } from 'next-intl';
|
|
48
|
+
export function Nav() {
|
|
49
|
+
const tNav = useTranslations('nav');
|
|
50
|
+
const tRoot = useTranslations();
|
|
51
|
+
return <>{tNav('home')}{tRoot('global.title')}</>;
|
|
52
|
+
}`,
|
|
53
|
+
});
|
|
54
|
+
expect(keysOf(cwd)).toEqual(['global.title', 'nav.home']);
|
|
55
|
+
});
|
|
56
|
+
it('resolves server getTranslations too', () => {
|
|
57
|
+
const cwd = project({
|
|
58
|
+
'src/page.tsx': `import { getTranslations } from 'next-intl/server';
|
|
59
|
+
export default async function Page() {
|
|
60
|
+
const t = await getTranslations('home');
|
|
61
|
+
return <h1>{t('title')}</h1>;
|
|
62
|
+
}`,
|
|
63
|
+
});
|
|
64
|
+
expect(keysOf(cwd)).toEqual(['home.title']);
|
|
65
|
+
});
|
|
66
|
+
});
|