fimo 0.6.0 → 0.7.0-experimental.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/skills/fimo/SKILL.md +2 -2
- 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 +0 -3
- 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 +29490 -21165
- 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/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/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/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/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/validate-route-metadata.js +11 -0
- package/package.json +92 -5
- package/release.json +2 -2
- 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,226 @@
|
|
|
1
|
+
import { 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 { applyWrap, ensureFimoImport, keyScope, slugify } from './apply-wrap.js';
|
|
6
|
+
import { scanWrappable } from './scan-wrappable.js';
|
|
7
|
+
let dir;
|
|
8
|
+
const project = (files) => {
|
|
9
|
+
dir = mkdtempSync(join(tmpdir(), 'apply-wrap-'));
|
|
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('keyScope / slugify', () => {
|
|
23
|
+
it('derives scopes from app-router paths', () => {
|
|
24
|
+
expect(keyScope('app/page.tsx')).toBe('home');
|
|
25
|
+
expect(keyScope('app/about/page.tsx')).toBe('about');
|
|
26
|
+
expect(keyScope('app/(marketing)/pricing/page.tsx')).toBe('pricing');
|
|
27
|
+
expect(keyScope('app/layout.tsx')).toBe('layout');
|
|
28
|
+
expect(keyScope('components/Footer.tsx')).toBe('footer');
|
|
29
|
+
});
|
|
30
|
+
it('slugifies text deterministically', () => {
|
|
31
|
+
expect(slugify('To get started, edit the page.tsx file.')).toBe('to-get-started-edit');
|
|
32
|
+
expect(slugify('Déjà vu!')).toBe('deja-vu');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('ensureFimoImport', () => {
|
|
36
|
+
it('inserts after the last import', () => {
|
|
37
|
+
const out = ensureFimoImport('import x from "y";\n\nconst a = 1;', ['FimoText']);
|
|
38
|
+
expect(out).toContain('import x from "y";\nimport { FimoText } from "fimo/next";');
|
|
39
|
+
});
|
|
40
|
+
it('merges into an existing fimo/next import', () => {
|
|
41
|
+
const out = ensureFimoImport('import { FimoText } from "fimo/next";\n', ['FimoMediaImage', 'FimoText']);
|
|
42
|
+
expect(out).toContain('import { FimoMediaImage, FimoText } from "fimo/next";');
|
|
43
|
+
});
|
|
44
|
+
it('decodes JSX entities in generated label values (fallback keeps the source form)', async () => {
|
|
45
|
+
const { applyWrapToFile, KeyAllocator } = await import('./apply-wrap.js');
|
|
46
|
+
const code = `export default function P() {\n return <p>What's next & more?</p>;\n}`;
|
|
47
|
+
const result = applyWrapToFile(code, [
|
|
48
|
+
{
|
|
49
|
+
kind: 'text',
|
|
50
|
+
file: 'app/page.tsx',
|
|
51
|
+
line: 2,
|
|
52
|
+
preview: 'What's next & more?',
|
|
53
|
+
span: { start: code.indexOf('What'), end: code.indexOf('?</p>') + 1, parentTag: 'p' },
|
|
54
|
+
},
|
|
55
|
+
], new KeyAllocator([]));
|
|
56
|
+
const [value] = Object.values(result.labels);
|
|
57
|
+
expect(value).toBe("What's next & more?");
|
|
58
|
+
expect(result.code).toContain('What's next & more?');
|
|
59
|
+
});
|
|
60
|
+
it('targets the adapter module of the detected framework, never fimo/next unconditionally', () => {
|
|
61
|
+
const inserted = ensureFimoImport('const a = 1;', ['FimoText'], 'fimo/remix');
|
|
62
|
+
expect(inserted).toContain('import { FimoText } from "fimo/remix";');
|
|
63
|
+
const merged = ensureFimoImport('import { FimoText } from "fimo/remix";\n', ['FimoMediaImage'], 'fimo/remix');
|
|
64
|
+
expect(merged).toContain('import { FimoMediaImage, FimoText } from "fimo/remix";');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('applyWrap', () => {
|
|
68
|
+
it('wraps plain text, images, and file links; leaves judgment items', () => {
|
|
69
|
+
const cwd = project({
|
|
70
|
+
'app/page.tsx': `export default function Page() {
|
|
71
|
+
return (
|
|
72
|
+
<main>
|
|
73
|
+
<h1>Ship your site in hours</h1>
|
|
74
|
+
<img src="/hero.png" alt="Hero" />
|
|
75
|
+
<a href="/whitepaper.pdf">Download the whitepaper</a>
|
|
76
|
+
<p>Read <strong>honest numbers</strong> today</p>
|
|
77
|
+
<input placeholder="Your email" />
|
|
78
|
+
<select><option>Pick one</option></select>
|
|
79
|
+
</main>
|
|
80
|
+
);
|
|
81
|
+
}`,
|
|
82
|
+
});
|
|
83
|
+
const result = applyWrap(cwd);
|
|
84
|
+
const code = readFileSync(join(cwd, 'app/page.tsx'), 'utf8');
|
|
85
|
+
expect(code).toContain('<FimoText id="home.ship-your-site-in">Ship your site in hours</FimoText>');
|
|
86
|
+
expect(code).toContain('<FimoMediaImage id="home.hero" src="/hero.png" alt="Hero" />');
|
|
87
|
+
expect(code).toContain('<FimoFileLink id="home.whitepaper" href="/whitepaper.pdf">');
|
|
88
|
+
expect(code).toContain('</FimoFileLink>');
|
|
89
|
+
expect(code).toContain('import { FimoFileLink, FimoMediaImage, FimoText } from "fimo/next";');
|
|
90
|
+
// Judgment items untouched: option text, attribute, rich-ish fragments.
|
|
91
|
+
expect(code).toContain('<option>Pick one</option>');
|
|
92
|
+
expect(code).toContain('placeholder="Your email"');
|
|
93
|
+
expect(result.skipped.some((entry) => entry.reason.includes('<option>'))).toBe(true);
|
|
94
|
+
expect(result.skipped.some((entry) => entry.item.kind === 'attribute')).toBe(true);
|
|
95
|
+
// Labels generated for wrapped text (incl. link text wrapped inside FimoFileLink).
|
|
96
|
+
expect(result.labels['home.ship-your-site-in']).toBe('Ship your site in hours');
|
|
97
|
+
// The wrapped file parses again and the safe items are gone.
|
|
98
|
+
const rescan = scanWrappable(cwd);
|
|
99
|
+
expect(rescan.items.filter((item) => item.kind === 'image' || item.kind === 'file-link')).toEqual([]);
|
|
100
|
+
expect(rescan.wrappedIds.media).toEqual(['home.hero']);
|
|
101
|
+
expect(rescan.wrappedIds.files).toEqual(['home.whitepaper']);
|
|
102
|
+
});
|
|
103
|
+
it('is idempotent: a second apply changes nothing', () => {
|
|
104
|
+
const cwd = project({
|
|
105
|
+
'app/about/page.tsx': `export default function About() {
|
|
106
|
+
return <h1>We believe analytics should be readable</h1>;
|
|
107
|
+
}`,
|
|
108
|
+
});
|
|
109
|
+
applyWrap(cwd);
|
|
110
|
+
const once = readFileSync(join(cwd, 'app/about/page.tsx'), 'utf8');
|
|
111
|
+
const second = applyWrap(cwd);
|
|
112
|
+
const twice = readFileSync(join(cwd, 'app/about/page.tsx'), 'utf8');
|
|
113
|
+
expect(twice).toBe(once);
|
|
114
|
+
expect(second.applied).toEqual([]);
|
|
115
|
+
expect(second.filesChanged).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
it('skips items recorded as exemptions (exempt-first, then --apply wraps the rest)', () => {
|
|
118
|
+
const cwd = project({
|
|
119
|
+
'app/page.tsx': `export default function Page() {
|
|
120
|
+
return (
|
|
121
|
+
<main>
|
|
122
|
+
<h1>Wrap this headline</h1>
|
|
123
|
+
<p>But keep this dev note verbatim</p>
|
|
124
|
+
</main>
|
|
125
|
+
);
|
|
126
|
+
}`,
|
|
127
|
+
'.fimo/migration/manifest.json': JSON.stringify({
|
|
128
|
+
wrap: {
|
|
129
|
+
exemptions: [
|
|
130
|
+
{
|
|
131
|
+
file: 'app/page.tsx',
|
|
132
|
+
kind: 'text',
|
|
133
|
+
preview: 'But keep this dev note verbatim',
|
|
134
|
+
reason: 'dev-only note',
|
|
135
|
+
status: 'unresolved',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
const result = applyWrap(cwd);
|
|
142
|
+
const code = readFileSync(join(cwd, 'app/page.tsx'), 'utf8');
|
|
143
|
+
// The non-exempt headline is auto-wrapped...
|
|
144
|
+
expect(code).toContain('<FimoText id="home.wrap-this-headline">Wrap this headline</FimoText>');
|
|
145
|
+
// ...the exempt item is left byte-for-byte and never labelled.
|
|
146
|
+
expect(code).toContain('<p>But keep this dev note verbatim</p>');
|
|
147
|
+
expect(code).not.toContain('FimoText id="home.but-keep');
|
|
148
|
+
expect(Object.values(result.labels)).not.toContain('But keep this dev note verbatim');
|
|
149
|
+
expect(Object.values(result.labels)).toContain('Wrap this headline');
|
|
150
|
+
});
|
|
151
|
+
it('leaves next/image components to the agent', () => {
|
|
152
|
+
const cwd = project({
|
|
153
|
+
'app/page.tsx': `import Image from "next/image";
|
|
154
|
+
export default function Page() {
|
|
155
|
+
return <Image src="/logo.svg" alt="Logo" width={10} height={10} priority />;
|
|
156
|
+
}`,
|
|
157
|
+
});
|
|
158
|
+
const result = applyWrap(cwd);
|
|
159
|
+
expect(result.applied).toEqual([]);
|
|
160
|
+
expect(result.skipped.some((entry) => entry.reason.includes('next/image'))).toBe(true);
|
|
161
|
+
expect(readFileSync(join(cwd, 'app/page.tsx'), 'utf8')).toContain('<Image src="/logo.svg"');
|
|
162
|
+
});
|
|
163
|
+
it('preserves surrounding whitespace so rendering stays identical', () => {
|
|
164
|
+
const cwd = project({
|
|
165
|
+
'app/page.tsx': `export default function Page() {
|
|
166
|
+
return (
|
|
167
|
+
<p>
|
|
168
|
+
Hello world from Fimo
|
|
169
|
+
</p>
|
|
170
|
+
);
|
|
171
|
+
}`,
|
|
172
|
+
});
|
|
173
|
+
applyWrap(cwd);
|
|
174
|
+
const code = readFileSync(join(cwd, 'app/page.tsx'), 'utf8');
|
|
175
|
+
expect(code).toMatch(/<p>\n <FimoText id="home\.hello-world-from-fimo">Hello world from Fimo<\/FimoText>\n <\/p>/);
|
|
176
|
+
});
|
|
177
|
+
it('avoids key collisions across duplicate texts', () => {
|
|
178
|
+
const cwd = project({
|
|
179
|
+
'app/page.tsx': `export default function Page() {
|
|
180
|
+
return (
|
|
181
|
+
<div>
|
|
182
|
+
<button>Contact us</button>
|
|
183
|
+
<button>Contact us</button>
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}`,
|
|
187
|
+
});
|
|
188
|
+
const result = applyWrap(cwd);
|
|
189
|
+
expect(Object.keys(result.labels).sort()).toEqual(['home.contact-us', 'home.contact-us-2']);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe('back-office exclusion', () => {
|
|
193
|
+
it('never touches excluded sections', () => {
|
|
194
|
+
const cwd = project({
|
|
195
|
+
'app/admin/page.tsx': `export default function Admin() {
|
|
196
|
+
return <h1>Secret admin panel</h1>;
|
|
197
|
+
}`,
|
|
198
|
+
'app/page.tsx': `export default function Home() {
|
|
199
|
+
return <h1>Public home</h1>;
|
|
200
|
+
}`,
|
|
201
|
+
});
|
|
202
|
+
const result = applyWrap(cwd, { excludePrefixes: ['app/admin'] });
|
|
203
|
+
expect(readFileSync(join(cwd, 'app/admin/page.tsx'), 'utf8')).not.toContain('FimoText');
|
|
204
|
+
expect(readFileSync(join(cwd, 'app/page.tsx'), 'utf8')).toContain('FimoText');
|
|
205
|
+
expect(result.applied.map((entry) => entry.file)).toEqual(['app/page.tsx']);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
describe('ensureFimoImport with multiline imports', () => {
|
|
209
|
+
it('inserts after a multiline import statement, never inside it', () => {
|
|
210
|
+
const code = `import { useEffect } from "react";
|
|
211
|
+
import {
|
|
212
|
+
AnimatedSpan,
|
|
213
|
+
Terminal,
|
|
214
|
+
TypingAnimation,
|
|
215
|
+
} from "@/components/magicui/terminal";
|
|
216
|
+
|
|
217
|
+
export function X() { return null; }`;
|
|
218
|
+
const out = ensureFimoImport(code, ['FimoText']);
|
|
219
|
+
expect(out).toContain('} from "@/components/magicui/terminal";\nimport { FimoText } from "fimo/next";');
|
|
220
|
+
expect(out.indexOf('import { FimoText }')).toBeGreaterThan(out.indexOf('TypingAnimation'));
|
|
221
|
+
});
|
|
222
|
+
it('prepends when the file has no imports', () => {
|
|
223
|
+
const out = ensureFimoImport('export const x = 1;', ['FimoText']);
|
|
224
|
+
expect(out.startsWith('import { FimoText } from "fimo/next";\n')).toBe(true);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { strapiConnector } from './strapi/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* CMS-connector registry. Add a connector factory here to support a new
|
|
4
|
+
* headless CMS; each returns its `CmsConnector` when it recognizes the
|
|
5
|
+
* frontend, or null. First match wins. This is the ONLY CMS-neutral file that
|
|
6
|
+
* enumerates the supported CMSs (the analogue of `frameworks/discover.ts`).
|
|
7
|
+
*/
|
|
8
|
+
const CONNECTORS = [strapiConnector];
|
|
9
|
+
export function detectCms(frontendDir) {
|
|
10
|
+
for (const factory of CONNECTORS) {
|
|
11
|
+
const connector = factory(frontendDir);
|
|
12
|
+
if (connector) {
|
|
13
|
+
return connector;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CmsCollectionSchema } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate example content for a CMS schema WITHOUT connecting to the CMS. The
|
|
4
|
+
* shapes match `api.importEntries`: media fields are omitted (a fake URL
|
|
5
|
+
* would render a broken image), references point at the first example of the
|
|
6
|
+
* target type (`<target>-example-1`) so they resolve against the co-seeded
|
|
7
|
+
* data, and localized types get per-locale string variance so the per-language
|
|
8
|
+
* editability is visible in Fimo. This is CMS-neutral: it consumes the mapped
|
|
9
|
+
* Fimo schema only.
|
|
10
|
+
*/
|
|
11
|
+
export interface ExampleSeed {
|
|
12
|
+
/** Fimo type name. */
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly isSingleton: boolean;
|
|
15
|
+
readonly localized: boolean;
|
|
16
|
+
/** Entry data rows (slug lives inside data for collections). */
|
|
17
|
+
readonly rows: Array<Record<string, unknown>>;
|
|
18
|
+
/** Reference descriptor for `importEntries`. */
|
|
19
|
+
readonly references: Record<string, {
|
|
20
|
+
target?: string;
|
|
21
|
+
targets?: string[];
|
|
22
|
+
by: 'slug';
|
|
23
|
+
many?: boolean;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface ExampleOptions {
|
|
27
|
+
/** Rows per collection type (singletons always get exactly one). */
|
|
28
|
+
readonly count?: number;
|
|
29
|
+
/** Locale suffix on string values + the batch locale (for localized types). */
|
|
30
|
+
readonly locale?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function generateExampleSeed(schema: CmsCollectionSchema, options?: ExampleOptions): ExampleSeed;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const DEFAULT_COUNT = 3;
|
|
2
|
+
function kebabCase(name) {
|
|
3
|
+
return name
|
|
4
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
5
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
6
|
+
.toLowerCase()
|
|
7
|
+
.replace(/^-+|-+$/g, '');
|
|
8
|
+
}
|
|
9
|
+
function humanize(field) {
|
|
10
|
+
return field
|
|
11
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
12
|
+
.replace(/[_-]+/g, ' ')
|
|
13
|
+
.replace(/^\w/, (c) => c.toUpperCase());
|
|
14
|
+
}
|
|
15
|
+
function richTextDoc(text) {
|
|
16
|
+
return {
|
|
17
|
+
type: 'doc',
|
|
18
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** A self-contained placeholder image (inline SVG data-URI) in the media
|
|
22
|
+
* envelope shape the wrap runtime already understands — no external host, no
|
|
23
|
+
* CSP surprises, so cards/heroes render instead of showing broken images. */
|
|
24
|
+
function placeholderMedia(label) {
|
|
25
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630"><rect width="100%" height="100%" fill="#e5e7eb"/><text x="50%" y="50%" font-family="sans-serif" font-size="44" fill="#9ca3af" text-anchor="middle" dominant-baseline="middle">${label}</text></svg>`;
|
|
26
|
+
return {
|
|
27
|
+
source: 'local',
|
|
28
|
+
url: `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`,
|
|
29
|
+
alt: `${label} placeholder`,
|
|
30
|
+
width: 1200,
|
|
31
|
+
height: 630,
|
|
32
|
+
mime: 'image/svg+xml',
|
|
33
|
+
name: 'placeholder.svg',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** Deterministic ISO date (no wall clock): 2024-01-01 + (n-1) days. */
|
|
37
|
+
function exampleDate(n) {
|
|
38
|
+
return new Date(Date.UTC(2024, 0, n)).toISOString().slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
function exampleReferenceSlug(field) {
|
|
41
|
+
const targetName = field.target ?? field.targets?.[0];
|
|
42
|
+
if (!targetName) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const slug = `${kebabCase(targetName)}-example-1`;
|
|
46
|
+
return field.many ? [slug] : slug;
|
|
47
|
+
}
|
|
48
|
+
export function generateExampleSeed(schema, options = {}) {
|
|
49
|
+
const locale = options.locale;
|
|
50
|
+
const localeSuffix = locale ? ` (${locale})` : '';
|
|
51
|
+
const count = schema.isSingleton ? 1 : Math.max(1, options.count ?? DEFAULT_COUNT);
|
|
52
|
+
const references = {};
|
|
53
|
+
const rows = [];
|
|
54
|
+
for (let i = 1; i <= count; i += 1) {
|
|
55
|
+
const row = {};
|
|
56
|
+
if (!schema.isSingleton) {
|
|
57
|
+
row.slug = `${kebabCase(schema.name)}-example-${i}`;
|
|
58
|
+
}
|
|
59
|
+
for (const [field, def] of Object.entries(schema.fields)) {
|
|
60
|
+
// A connector-supplied example (dynamic zones, components) wins over the
|
|
61
|
+
// type-driven default — critical for dynamic zones, which must be arrays.
|
|
62
|
+
if (schema.examples && field in schema.examples) {
|
|
63
|
+
row[field] = schema.examples[field];
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
switch (def.type) {
|
|
67
|
+
case 'string':
|
|
68
|
+
row[field] = `${humanize(field)} example ${i}${localeSuffix}`;
|
|
69
|
+
break;
|
|
70
|
+
case 'richtext':
|
|
71
|
+
row[field] = richTextDoc(`Example ${humanize(schema.name).toLowerCase()} content ${i}${localeSuffix}.`);
|
|
72
|
+
break;
|
|
73
|
+
case 'number':
|
|
74
|
+
row[field] = i;
|
|
75
|
+
break;
|
|
76
|
+
case 'boolean':
|
|
77
|
+
row[field] = i % 2 === 1;
|
|
78
|
+
break;
|
|
79
|
+
case 'date':
|
|
80
|
+
row[field] = exampleDate(i);
|
|
81
|
+
break;
|
|
82
|
+
case 'json':
|
|
83
|
+
row[field] = {};
|
|
84
|
+
break;
|
|
85
|
+
case 'media':
|
|
86
|
+
row[field] = placeholderMedia(humanize(field));
|
|
87
|
+
break;
|
|
88
|
+
case 'reference': {
|
|
89
|
+
const value = exampleReferenceSlug(def);
|
|
90
|
+
if (value !== undefined) {
|
|
91
|
+
row[field] = value;
|
|
92
|
+
references[field] = {
|
|
93
|
+
...(def.target ? { target: def.target } : {}),
|
|
94
|
+
...(def.targets ? { targets: def.targets } : {}),
|
|
95
|
+
by: 'slug',
|
|
96
|
+
...(def.many ? { many: true } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
default:
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
rows.push(row);
|
|
106
|
+
}
|
|
107
|
+
return { name: schema.name, isSingleton: schema.isSingleton, localized: schema.localized, rows, references };
|
|
108
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { generateExampleSeed } from './example-content.js';
|
|
3
|
+
const blogPost = {
|
|
4
|
+
name: 'BlogPost',
|
|
5
|
+
label: 'Blog Post',
|
|
6
|
+
isSingleton: false,
|
|
7
|
+
localized: false,
|
|
8
|
+
fields: {
|
|
9
|
+
title: { type: 'string' },
|
|
10
|
+
content: { type: 'richtext' },
|
|
11
|
+
readTime: { type: 'number' },
|
|
12
|
+
featured: { type: 'boolean' },
|
|
13
|
+
publishedOn: { type: 'date' },
|
|
14
|
+
meta: { type: 'json' },
|
|
15
|
+
cover: { type: 'media' },
|
|
16
|
+
category: { type: 'reference', target: 'PostCategory' },
|
|
17
|
+
tags: { type: 'reference', target: 'PostTag', many: true },
|
|
18
|
+
},
|
|
19
|
+
sourceUid: 'api::blog-post.blog-post',
|
|
20
|
+
sourcePath: '/blog-posts',
|
|
21
|
+
};
|
|
22
|
+
describe('generateExampleSeed', () => {
|
|
23
|
+
it('generates typed rows with a slug per collection entry', () => {
|
|
24
|
+
const seed = generateExampleSeed(blogPost, { count: 2 });
|
|
25
|
+
expect(seed.rows).toHaveLength(2);
|
|
26
|
+
const [first, second] = seed.rows;
|
|
27
|
+
expect(first.slug).toBe('blog-post-example-1');
|
|
28
|
+
expect(first.title).toBe('Title example 1');
|
|
29
|
+
expect(first.content).toMatchObject({ type: 'doc' });
|
|
30
|
+
expect(first.readTime).toBe(1);
|
|
31
|
+
expect(first.featured).toBe(true);
|
|
32
|
+
expect(second.featured).toBe(false);
|
|
33
|
+
expect(first.publishedOn).toBe('2024-01-01');
|
|
34
|
+
expect(second.publishedOn).toBe('2024-01-02');
|
|
35
|
+
expect(first.meta).toEqual({});
|
|
36
|
+
});
|
|
37
|
+
it('seeds a self-contained placeholder for media fields', () => {
|
|
38
|
+
const seed = generateExampleSeed(blogPost, { count: 1 });
|
|
39
|
+
expect(seed.rows[0].cover).toMatchObject({ source: 'local', width: 1200, height: 630 });
|
|
40
|
+
expect(seed.rows[0].cover.url).toContain('data:image/svg+xml');
|
|
41
|
+
});
|
|
42
|
+
it('points references at the first example of the target, with a descriptor', () => {
|
|
43
|
+
const seed = generateExampleSeed(blogPost, { count: 1 });
|
|
44
|
+
expect(seed.rows[0].category).toBe('post-category-example-1');
|
|
45
|
+
expect(seed.rows[0].tags).toEqual(['post-tag-example-1']);
|
|
46
|
+
expect(seed.references.category).toEqual({ target: 'PostCategory', by: 'slug' });
|
|
47
|
+
expect(seed.references.tags).toEqual({ target: 'PostTag', by: 'slug', many: true });
|
|
48
|
+
});
|
|
49
|
+
it('gives a singleton exactly one slug-less row', () => {
|
|
50
|
+
const seed = generateExampleSeed({ ...blogPost, name: 'Global', isSingleton: true }, { count: 5 });
|
|
51
|
+
expect(seed.rows).toHaveLength(1);
|
|
52
|
+
expect(seed.rows[0].slug).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
it('varies string values per locale so per-language editing is visible', () => {
|
|
55
|
+
const fr = generateExampleSeed(blogPost, { count: 1, locale: 'fr' });
|
|
56
|
+
expect(fr.rows[0].title).toBe('Title example 1 (fr)');
|
|
57
|
+
});
|
|
58
|
+
it('uses a connector-supplied example (dynamic-zone array) over the type default', () => {
|
|
59
|
+
const withExample = {
|
|
60
|
+
...blogPost,
|
|
61
|
+
fields: { ...blogPost.fields, sections: { type: 'json' } },
|
|
62
|
+
examples: { sections: [] },
|
|
63
|
+
};
|
|
64
|
+
expect(generateExampleSeed(withExample, { count: 1 }).rows[0].sections).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Detect a Strapi backend behind a wrapped frontend. Strapi is consumed via a
|
|
5
|
+
* hand-rolled fetch client against an env-var base URL (no SDK, no hardcoded
|
|
6
|
+
* URL), so the generic external-source scanner misses it entirely — detection
|
|
7
|
+
* keys on the distinctive local signals instead:
|
|
8
|
+
* - a dependency whose name mentions strapi (`@repo/strapi-types`,
|
|
9
|
+
* `@strapi/client`, generated types packages);
|
|
10
|
+
* - a `strapi`-named client module under `src/lib`/`src`;
|
|
11
|
+
* - Strapi's `api::x.y` content-type UID strings in the source;
|
|
12
|
+
* - a `STRAPI`-flavored base-URL env var.
|
|
13
|
+
* The co-located Strapi app (monorepo) is located by walking up to the
|
|
14
|
+
* workspace root and finding the sibling that depends on `@strapi/strapi`; its
|
|
15
|
+
* path drives schema import. A decoupled Strapi (no co-located app) still
|
|
16
|
+
* surfaces as a source for the keep-live decision, with `appDir: null`.
|
|
17
|
+
*/
|
|
18
|
+
const UID_RE = /["'`]api::[a-z0-9-]+\.[a-z0-9-]+["'`]/;
|
|
19
|
+
const STRAPI_ENV_RE = /STRAPI[A-Z0-9_]*_URL|NEXT_PUBLIC_STRAPI/;
|
|
20
|
+
const SOURCE_EXT_RE = /\.(tsx?|jsx?|mjs|cjs)$/;
|
|
21
|
+
const SKIP_DIRS = new Set(['node_modules', '.next', '.turbo', 'dist', 'build', '.git', 'coverage']);
|
|
22
|
+
const MAX_FILES_SCANNED = 400;
|
|
23
|
+
function safeReaddir(dir) {
|
|
24
|
+
try {
|
|
25
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function readDeps(dir) {
|
|
32
|
+
try {
|
|
33
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
34
|
+
return { ...pkg.dependencies, ...pkg.devDependencies };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function strapiDependency(deps) {
|
|
41
|
+
for (const name of Object.keys(deps)) {
|
|
42
|
+
if (/strapi/i.test(name)) {
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** Shallow look for a `strapi`-named client module under src/lib or src. */
|
|
49
|
+
function strapiLibModule(frontendDir) {
|
|
50
|
+
for (const base of [join(frontendDir, 'src', 'lib'), join(frontendDir, 'src'), join(frontendDir, 'lib')]) {
|
|
51
|
+
if (!existsSync(base)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
|
56
|
+
if (/strapi/i.test(entry.name)) {
|
|
57
|
+
return join(base, entry.name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// unreadable dir — ignore
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/** Bounded source scan for `api::x.y` UID strings or a STRAPI base-URL env. */
|
|
68
|
+
function scanSourceSignals(frontendDir) {
|
|
69
|
+
const root = existsSync(join(frontendDir, 'src')) ? join(frontendDir, 'src') : frontendDir;
|
|
70
|
+
let scanned = 0;
|
|
71
|
+
let uid = false;
|
|
72
|
+
let env = false;
|
|
73
|
+
const walk = (dir) => {
|
|
74
|
+
if (scanned >= MAX_FILES_SCANNED || (uid && env)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of safeReaddir(dir)) {
|
|
78
|
+
if (scanned >= MAX_FILES_SCANNED || (uid && env)) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
83
|
+
walk(join(dir, entry.name));
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (!SOURCE_EXT_RE.test(entry.name)) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
scanned += 1;
|
|
91
|
+
try {
|
|
92
|
+
const text = readFileSync(join(dir, entry.name), 'utf8');
|
|
93
|
+
if (!uid && UID_RE.test(text)) {
|
|
94
|
+
uid = true;
|
|
95
|
+
}
|
|
96
|
+
if (!env && STRAPI_ENV_RE.test(text)) {
|
|
97
|
+
env = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// unreadable file — ignore
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
walk(root);
|
|
106
|
+
return { uid, env };
|
|
107
|
+
}
|
|
108
|
+
function findWorkspaceRoot(startDir) {
|
|
109
|
+
let dir = startDir;
|
|
110
|
+
for (let depth = 0; depth < 6; depth += 1) {
|
|
111
|
+
if (existsSync(join(dir, 'pnpm-workspace.yaml')) ||
|
|
112
|
+
existsSync(join(dir, 'turbo.json')) ||
|
|
113
|
+
existsSync(join(dir, 'lerna.json'))) {
|
|
114
|
+
return dir;
|
|
115
|
+
}
|
|
116
|
+
const parent = dirname(dir);
|
|
117
|
+
if (parent === dir) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
dir = parent;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
/** Find the sibling app that depends on `@strapi/strapi` (the CMS app). */
|
|
125
|
+
function findStrapiApp(frontendDir) {
|
|
126
|
+
const root = findWorkspaceRoot(frontendDir);
|
|
127
|
+
if (!root) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const searchRoots = [join(root, 'apps'), root, join(root, 'packages')];
|
|
131
|
+
for (const searchRoot of searchRoots) {
|
|
132
|
+
if (!existsSync(searchRoot)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (const entry of safeReaddir(searchRoot)) {
|
|
136
|
+
if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const candidate = join(searchRoot, entry.name);
|
|
140
|
+
const deps = readDeps(candidate);
|
|
141
|
+
if (deps['@strapi/strapi'] && existsSync(join(candidate, 'src', 'api'))) {
|
|
142
|
+
return candidate;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
export function detectStrapi(frontendDir) {
|
|
149
|
+
if (!existsSync(frontendDir) || !statSync(frontendDir).isDirectory()) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const evidence = [];
|
|
153
|
+
const dep = strapiDependency(readDeps(frontendDir));
|
|
154
|
+
if (dep) {
|
|
155
|
+
evidence.push(`dependency ${dep}`);
|
|
156
|
+
}
|
|
157
|
+
const libModule = strapiLibModule(frontendDir);
|
|
158
|
+
if (libModule) {
|
|
159
|
+
evidence.push(`client module ${libModule.slice(frontendDir.length + 1)}`);
|
|
160
|
+
}
|
|
161
|
+
// Only pay for the source scan when the cheap signals are inconclusive.
|
|
162
|
+
if (evidence.length === 0) {
|
|
163
|
+
const signals = scanSourceSignals(frontendDir);
|
|
164
|
+
if (signals.uid) {
|
|
165
|
+
evidence.push('api::* content-type UIDs in source');
|
|
166
|
+
}
|
|
167
|
+
if (signals.env) {
|
|
168
|
+
evidence.push('STRAPI base-URL env var');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (evidence.length === 0) {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const appDir = findStrapiApp(frontendDir);
|
|
175
|
+
if (appDir) {
|
|
176
|
+
evidence.push(`schemas in ${appDir.slice(findWorkspaceRoot(frontendDir).length + 1)}`);
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
cms: 'Strapi',
|
|
180
|
+
host: 'Strapi',
|
|
181
|
+
appDir,
|
|
182
|
+
clientDir: libModule,
|
|
183
|
+
evidence,
|
|
184
|
+
locales: [],
|
|
185
|
+
};
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|