fimo 0.7.0-experimental.4 → 0.7.0-experimental.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/cli/bundle.json +1 -1
  2. package/dist/cli/index.js +827 -464
  3. package/dist/runtime/strapi/client.d.ts +26 -0
  4. package/dist/runtime/strapi/client.d.ts.map +1 -0
  5. package/dist/runtime/strapi/client.js +88 -0
  6. package/dist/runtime/strapi/client.test.d.ts +2 -0
  7. package/dist/runtime/strapi/client.test.d.ts.map +1 -0
  8. package/dist/runtime/strapi/client.test.js +64 -0
  9. package/dist/runtime/strapi/index.d.ts +5 -0
  10. package/dist/runtime/strapi/index.d.ts.map +1 -0
  11. package/dist/runtime/strapi/index.js +6 -0
  12. package/dist/runtime/strapi/reshape.d.ts +28 -0
  13. package/dist/runtime/strapi/reshape.d.ts.map +1 -0
  14. package/dist/runtime/strapi/reshape.js +79 -0
  15. package/dist/runtime/strapi/reshape.test.d.ts +2 -0
  16. package/dist/runtime/strapi/reshape.test.d.ts.map +1 -0
  17. package/dist/runtime/strapi/reshape.test.js +33 -0
  18. package/dist/scripts/cms/example-content.js +22 -1
  19. package/dist/scripts/cms/example-content.test.js +11 -2
  20. package/dist/scripts/cms/strapi/detect.js +1 -0
  21. package/dist/scripts/cms/strapi/index.js +8 -0
  22. package/dist/scripts/cms/strapi/schema-map.js +11 -0
  23. package/dist/scripts/cms/strapi/schema-map.test.js +4 -0
  24. package/dist/scripts/cms/strapi/wire-client.d.ts +15 -0
  25. package/dist/scripts/cms/strapi/wire-client.js +107 -0
  26. package/dist/scripts/cms/strapi/wire-client.test.d.ts +1 -0
  27. package/dist/scripts/cms/strapi/wire-client.test.js +51 -0
  28. package/dist/scripts/cms/strapi/wiring.d.ts +17 -0
  29. package/dist/scripts/cms/strapi/wiring.js +36 -0
  30. package/dist/scripts/cms/strapi/wiring.test.d.ts +1 -0
  31. package/dist/scripts/cms/strapi/wiring.test.js +27 -0
  32. package/dist/scripts/cms/types.d.ts +18 -0
  33. package/package.json +5 -1
  34. package/templates/react-router/package.json +1 -1
@@ -0,0 +1,26 @@
1
+ import { type FimoServerOptions } from '../wrapped/server.js';
2
+ export interface FimoStrapiFetchOptions extends FimoServerOptions {
3
+ /** Strapi REST path segment (pluralName) → Fimo type name. */
4
+ types: Record<string, string>;
5
+ /** Fimo type names that are single types (served as `{data:{}}`, not a list). */
6
+ singletons?: string[];
7
+ /** Path prefix before the content segment (Strapi default `/api`). */
8
+ apiPrefix?: string;
9
+ }
10
+ export interface ParsedStrapiUrl {
11
+ pluralName: string;
12
+ documentId?: string;
13
+ locale?: string;
14
+ pageSize?: number;
15
+ where?: Record<string, unknown>;
16
+ }
17
+ /**
18
+ * Parse a Strapi REST URL into the parts the Fimo read needs. Best-effort:
19
+ * pagination + locale + top-level `$eq` filters (enough for list, detail-by-slug,
20
+ * and singleton reads); richer `qs` filters are ignored (fake content tolerates
21
+ * an unfiltered set).
22
+ */
23
+ export declare function parseStrapiUrl(rawUrl: string, apiPrefix?: string): ParsedStrapiUrl | null;
24
+ /** Build a `fetch`-compatible function that serves scaffolded content from Fimo. */
25
+ export declare function createFimoStrapiFetch(options: FimoStrapiFetchOptions, realFetch?: typeof fetch): typeof fetch;
26
+ //# sourceMappingURL=client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../../src/runtime/strapi/client.ts"],"names":[],"mappings":"AASA,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAG5E,MAAM,WAAW,sBAAuB,SAAQ,iBAAiB;IAC/D,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,iFAAiF;IACjF,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAUD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,SAAS,GAAG,eAAe,GAAG,IAAI,CAkCzF;AAED,oFAAoF;AACpF,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,EAAE,SAAS,GAAE,OAAO,KAAa,GAAG,OAAO,KAAK,CAiCpH"}
@@ -0,0 +1,88 @@
1
+ // Native `fimo/strapi` read adapter — the generic interception point. Every
2
+ // Strapi client (hand-rolled or `@strapi/client`) ultimately does a single
3
+ // `fetch(<strapiRestUrl>)`; this returns a `fetch`-compatible function that
4
+ // serves that request from Fimo (via `fetchEntries` + the reshape) when the URL
5
+ // maps to a scaffolded content type, and transparently falls through to the
6
+ // real fetch (the live CMS) otherwise. Intercepting at the fetch boundary is
7
+ // what makes "any Strapi project" work — the client's method surface is
8
+ // irrelevant.
9
+ import { fetchEntries } from '../wrapped/server.js';
10
+ import { fimoEntriesToStrapiList, fimoEntryToStrapiSingle } from './reshape.js';
11
+ function positiveInt(value) {
12
+ if (value == null) {
13
+ return undefined;
14
+ }
15
+ const n = Number(value);
16
+ return Number.isFinite(n) && n > 0 ? n : undefined;
17
+ }
18
+ /**
19
+ * Parse a Strapi REST URL into the parts the Fimo read needs. Best-effort:
20
+ * pagination + locale + top-level `$eq` filters (enough for list, detail-by-slug,
21
+ * and singleton reads); richer `qs` filters are ignored (fake content tolerates
22
+ * an unfiltered set).
23
+ */
24
+ export function parseStrapiUrl(rawUrl, apiPrefix = '/api') {
25
+ let url;
26
+ try {
27
+ url = new URL(rawUrl, 'http://fimo.local');
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ const marker = `${apiPrefix}/`;
33
+ const at = url.pathname.indexOf(marker);
34
+ if (at === -1) {
35
+ return null;
36
+ }
37
+ const segments = url.pathname
38
+ .slice(at + marker.length)
39
+ .split('/')
40
+ .filter(Boolean);
41
+ if (segments.length === 0) {
42
+ return null;
43
+ }
44
+ const q = url.searchParams;
45
+ const where = {};
46
+ for (const [key, value] of q.entries()) {
47
+ const eq = key.match(/^filters\[([^\][]+)\]\[\$eq\]$/);
48
+ if (eq) {
49
+ where[eq[1]] = value;
50
+ }
51
+ }
52
+ return {
53
+ pluralName: segments[0],
54
+ documentId: segments[1],
55
+ locale: q.get('locale') ?? undefined,
56
+ pageSize: positiveInt(q.get('pagination[pageSize]') ?? q.get('pagination[limit]')),
57
+ where: Object.keys(where).length > 0 ? where : undefined,
58
+ };
59
+ }
60
+ /** Build a `fetch`-compatible function that serves scaffolded content from Fimo. */
61
+ export function createFimoStrapiFetch(options, realFetch = fetch) {
62
+ const singletons = new Set(options.singletons ?? []);
63
+ const read = { apiUrl: options.apiUrl, rootDir: options.rootDir };
64
+ return (async (input, init) => {
65
+ const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
66
+ const parsed = parseStrapiUrl(rawUrl ?? '', options.apiPrefix);
67
+ const typeName = parsed ? options.types[parsed.pluralName] : undefined;
68
+ if (!parsed || !typeName) {
69
+ return realFetch(input, init); // not a mapped content read → real CMS
70
+ }
71
+ const entries = await fetchEntries(typeName, {
72
+ ...read,
73
+ ...(parsed.where ? { where: parsed.where } : {}),
74
+ ...(parsed.pageSize ? { limit: parsed.pageSize } : {}),
75
+ });
76
+ if (entries.length === 0) {
77
+ return realFetch(input, init); // nothing scaffolded → fall back to the CMS
78
+ }
79
+ const single = Boolean(parsed.documentId) || singletons.has(typeName);
80
+ const payload = single
81
+ ? fimoEntryToStrapiSingle(parsed.documentId ? (entries.find((e) => e.id === parsed.documentId) ?? entries[0]) : entries[0])
82
+ : fimoEntriesToStrapiList(entries, { pageSize: parsed.pageSize, total: entries.length });
83
+ return new Response(JSON.stringify(payload), {
84
+ status: 200,
85
+ headers: { 'content-type': 'application/json' },
86
+ });
87
+ });
88
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../../src/runtime/strapi/client.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,64 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ const fetchEntriesMock = vi.fn();
3
+ vi.mock('../wrapped/server.js', () => ({
4
+ fetchEntries: (...args) => fetchEntriesMock(...args),
5
+ }));
6
+ import { createFimoStrapiFetch, parseStrapiUrl } from './client.js';
7
+ describe('parseStrapiUrl', () => {
8
+ it('parses type, pagination, locale and $eq filters', () => {
9
+ const parsed = parseStrapiUrl('https://cms.example/api/blog-posts?locale=en&pagination[pageSize]=5&filters[slug][$eq]=hello');
10
+ expect(parsed).toMatchObject({ pluralName: 'blog-posts', locale: 'en', pageSize: 5, where: { slug: 'hello' } });
11
+ });
12
+ it('parses a documentId path', () => {
13
+ expect(parseStrapiUrl('https://cms.example/api/pages/abc123')).toMatchObject({
14
+ pluralName: 'pages',
15
+ documentId: 'abc123',
16
+ });
17
+ });
18
+ it('returns null for non-/api paths', () => {
19
+ expect(parseStrapiUrl('https://cms.example/health')).toBeNull();
20
+ });
21
+ });
22
+ describe('createFimoStrapiFetch', () => {
23
+ const realFetch = vi.fn(async () => new Response('REAL', { status: 200 }));
24
+ beforeEach(() => {
25
+ fetchEntriesMock.mockReset();
26
+ realFetch.mockClear();
27
+ });
28
+ it('serves a mapped collection from Fimo as a Strapi list envelope', async () => {
29
+ fetchEntriesMock.mockResolvedValue([
30
+ { id: '1', data: { title: 'A' } },
31
+ { id: '2', data: { title: 'B' } },
32
+ ]);
33
+ const fimoFetch = createFimoStrapiFetch({ types: { 'blog-posts': 'BlogPost' } }, realFetch);
34
+ const body = await (await fimoFetch('https://cms.example/api/blog-posts?pagination[pageSize]=10')).json();
35
+ expect(body.data).toHaveLength(2);
36
+ expect(body.data[0]).toMatchObject({ id: '1', documentId: '1', title: 'A' });
37
+ expect(body.meta.pagination.total).toBe(2);
38
+ expect(realFetch).not.toHaveBeenCalled();
39
+ expect(fetchEntriesMock).toHaveBeenCalledWith('BlogPost', expect.objectContaining({ limit: 10 }));
40
+ });
41
+ it('serves a singleton as a single envelope, not a list', async () => {
42
+ fetchEntriesMock.mockResolvedValue([{ id: 'g', data: { siteName: 'X' } }]);
43
+ const fimoFetch = createFimoStrapiFetch({ types: { global: 'Global' }, singletons: ['Global'] }, realFetch);
44
+ const body = await (await fimoFetch('https://cms.example/api/global')).json();
45
+ expect(Array.isArray(body.data)).toBe(false);
46
+ expect(body.data).toMatchObject({ id: 'g', siteName: 'X' });
47
+ });
48
+ it('passes a slug filter through to fetchEntries', async () => {
49
+ fetchEntriesMock.mockResolvedValue([{ id: '1', data: { slug: 'hello' } }]);
50
+ const fimoFetch = createFimoStrapiFetch({ types: { 'blog-posts': 'BlogPost' } }, realFetch);
51
+ await fimoFetch('https://cms.example/api/blog-posts?filters[slug][$eq]=hello');
52
+ expect(fetchEntriesMock).toHaveBeenCalledWith('BlogPost', expect.objectContaining({ where: { slug: 'hello' } }));
53
+ });
54
+ it('falls through to the real fetch for an unmapped path', async () => {
55
+ const fimoFetch = createFimoStrapiFetch({ types: { 'blog-posts': 'BlogPost' } }, realFetch);
56
+ expect(await (await fimoFetch('https://cms.example/api/users?filters[slug][$eq]=x')).text()).toBe('REAL');
57
+ expect(realFetch).toHaveBeenCalled();
58
+ });
59
+ it('falls through when Fimo has nothing scaffolded for the type', async () => {
60
+ fetchEntriesMock.mockResolvedValue([]);
61
+ const fimoFetch = createFimoStrapiFetch({ types: { 'blog-posts': 'BlogPost' } }, realFetch);
62
+ expect(await (await fimoFetch('https://cms.example/api/blog-posts')).text()).toBe('REAL');
63
+ });
64
+ });
@@ -0,0 +1,5 @@
1
+ export { createFimoStrapiFetch, parseStrapiUrl } from './client.js';
2
+ export type { FimoStrapiFetchOptions, ParsedStrapiUrl } from './client.js';
3
+ export { fimoEntriesToStrapiList, fimoEntryToStrapiItem, fimoEntryToStrapiSingle } from './reshape.js';
4
+ export type { StrapiListEnvelope, StrapiPagination, StrapiSingleEnvelope } from './reshape.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/runtime/strapi/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AACpE,YAAY,EAAE,sBAAsB,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC3E,OAAO,EAAE,uBAAuB,EAAE,qBAAqB,EAAE,uBAAuB,EAAE,MAAM,cAAc,CAAC;AACvG,YAAY,EAAE,kBAAkB,EAAE,gBAAgB,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,6 @@
1
+ // `fimo/strapi` — native read adapter that serves a wrapped Strapi frontend's
2
+ // content from Fimo. Drop `createFimoStrapiFetch(...)` in at the CMS client's
3
+ // single `fetch` and every read (list, detail, singleton) is served from Fimo,
4
+ // falling through to the live CMS for anything not scaffolded.
5
+ export { createFimoStrapiFetch, parseStrapiUrl } from './client.js';
6
+ export { fimoEntriesToStrapiList, fimoEntryToStrapiItem, fimoEntryToStrapiSingle } from './reshape.js';
@@ -0,0 +1,28 @@
1
+ import type { FimoEntry } from '../wrapped/server.js';
2
+ export interface StrapiPagination {
3
+ page: number;
4
+ pageSize: number;
5
+ pageCount: number;
6
+ total: number;
7
+ }
8
+ export interface StrapiListEnvelope {
9
+ data: Record<string, unknown>[];
10
+ meta: {
11
+ pagination: StrapiPagination;
12
+ };
13
+ }
14
+ export interface StrapiSingleEnvelope {
15
+ data: Record<string, unknown> | null;
16
+ meta: Record<string, unknown>;
17
+ }
18
+ /** One Fimo entry → one Strapi-v5 item (flat fields, `id` + `documentId`). */
19
+ export declare function fimoEntryToStrapiItem(entry: FimoEntry): Record<string, unknown>;
20
+ /** Fimo entries → a Strapi-v5 collection envelope (`{ data, meta.pagination }`). */
21
+ export declare function fimoEntriesToStrapiList(entries: FimoEntry[], options?: {
22
+ page?: number;
23
+ pageSize?: number;
24
+ total?: number;
25
+ }): StrapiListEnvelope;
26
+ /** Fimo entry → a Strapi-v5 single/singleton envelope (`{ data, meta }`). */
27
+ export declare function fimoEntryToStrapiSingle(entry: FimoEntry | undefined): StrapiSingleEnvelope;
28
+ //# sourceMappingURL=reshape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reshape.d.ts","sourceRoot":"","sources":["../../../src/runtime/strapi/reshape.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,IAAI,EAAE;QAAE,UAAU,EAAE,gBAAgB,CAAA;KAAE,CAAC;CACxC;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAqDD,8EAA8E;AAC9E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAM/E;AAED,oFAAoF;AACpF,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,SAAS,EAAE,EACpB,OAAO,GAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACjE,kBAAkB,CAepB;AAED,6EAA6E;AAC7E,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,SAAS,GAAG,SAAS,GAAG,oBAAoB,CAE1F"}
@@ -0,0 +1,79 @@
1
+ // Native `fimo/strapi` read adapter — the generic reshape from Fimo entries
2
+ // (as `fetchEntries` returns them) into the Strapi-v5 response shape a wrapped
3
+ // Strapi frontend's components read. Value-inspection based, so it is generic:
4
+ // no content-type or field names are hardcoded. Relation POPULATION (turning a
5
+ // reference handle into a nested object) is resolved one level up in the client
6
+ // — this module reshapes a single entry's own field values.
7
+ /** ProseMirror/TipTap doc → plain text (Strapi richtext is a string; string
8
+ * consumers like read-time estimates must never receive an object). */
9
+ function docToText(node) {
10
+ if (!node || typeof node !== 'object') {
11
+ return '';
12
+ }
13
+ const record = node;
14
+ if (typeof record.text === 'string') {
15
+ return record.text;
16
+ }
17
+ if (Array.isArray(record.content)) {
18
+ return record.content.map(docToText).join(' ').trim();
19
+ }
20
+ return '';
21
+ }
22
+ function isRichTextDoc(value) {
23
+ return Boolean(value) && typeof value === 'object' && value.type === 'doc';
24
+ }
25
+ /** A Fimo media value (seeded/loaded as `{ url, alt, width, height, … }`). */
26
+ function isMedia(value) {
27
+ return (Boolean(value) &&
28
+ typeof value === 'object' &&
29
+ !Array.isArray(value) &&
30
+ typeof value.url === 'string');
31
+ }
32
+ function reshapeMedia(value) {
33
+ return {
34
+ url: value.url,
35
+ alternativeText: value.alt ?? value.alternativeText ?? null,
36
+ ...(typeof value.width === 'number' ? { width: value.width } : {}),
37
+ ...(typeof value.height === 'number' ? { height: value.height } : {}),
38
+ ...(typeof value.name === 'string' ? { name: value.name } : {}),
39
+ ...(typeof value.mime === 'string' ? { mime: value.mime } : {}),
40
+ };
41
+ }
42
+ function reshapeValue(value) {
43
+ if (isRichTextDoc(value)) {
44
+ return docToText(value);
45
+ }
46
+ if (isMedia(value)) {
47
+ return reshapeMedia(value);
48
+ }
49
+ return value; // scalars, arrays, reference handles pass through
50
+ }
51
+ /** One Fimo entry → one Strapi-v5 item (flat fields, `id` + `documentId`). */
52
+ export function fimoEntryToStrapiItem(entry) {
53
+ const item = { id: entry.id, documentId: entry.id };
54
+ for (const [field, value] of Object.entries(entry.data)) {
55
+ item[field] = reshapeValue(value);
56
+ }
57
+ return item;
58
+ }
59
+ /** Fimo entries → a Strapi-v5 collection envelope (`{ data, meta.pagination }`). */
60
+ export function fimoEntriesToStrapiList(entries, options = {}) {
61
+ const data = entries.map(fimoEntryToStrapiItem);
62
+ const total = options.total ?? data.length;
63
+ const pageSize = options.pageSize ?? (data.length || 1);
64
+ return {
65
+ data,
66
+ meta: {
67
+ pagination: {
68
+ page: options.page ?? 1,
69
+ pageSize,
70
+ pageCount: pageSize > 0 ? Math.max(1, Math.ceil(total / pageSize)) : 1,
71
+ total,
72
+ },
73
+ },
74
+ };
75
+ }
76
+ /** Fimo entry → a Strapi-v5 single/singleton envelope (`{ data, meta }`). */
77
+ export function fimoEntryToStrapiSingle(entry) {
78
+ return { data: entry ? fimoEntryToStrapiItem(entry) : null, meta: {} };
79
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=reshape.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reshape.test.d.ts","sourceRoot":"","sources":["../../../src/runtime/strapi/reshape.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { fimoEntriesToStrapiList, fimoEntryToStrapiItem, fimoEntryToStrapiSingle } from './reshape.js';
3
+ const entry = {
4
+ id: 'abc',
5
+ data: {
6
+ title: 'Hello',
7
+ readTime: 3,
8
+ content: { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Body text' }] }] },
9
+ cover: { source: 'local', url: '/img/x.png', alt: 'Alt', width: 800, height: 600 },
10
+ category: 'post-category-example-1',
11
+ },
12
+ };
13
+ describe('reshape', () => {
14
+ it('flattens an entry to a Strapi-v5 item (id + documentId, richtext→text, media→strapi shape)', () => {
15
+ const item = fimoEntryToStrapiItem(entry);
16
+ expect(item.id).toBe('abc');
17
+ expect(item.documentId).toBe('abc');
18
+ expect(item.title).toBe('Hello');
19
+ expect(item.readTime).toBe(3);
20
+ expect(item.content).toBe('Body text');
21
+ expect(item.cover).toEqual({ url: '/img/x.png', alternativeText: 'Alt', width: 800, height: 600 });
22
+ expect(item.category).toBe('post-category-example-1'); // reference handle passes through
23
+ });
24
+ it('wraps a list in a Strapi envelope with correct pagination', () => {
25
+ const env = fimoEntriesToStrapiList([entry, entry], { total: 5, pageSize: 2 });
26
+ expect(env.data).toHaveLength(2);
27
+ expect(env.meta.pagination).toEqual({ page: 1, pageSize: 2, pageCount: 3, total: 5 });
28
+ });
29
+ it('wraps a single/singleton entry, or null when absent', () => {
30
+ expect(fimoEntryToStrapiSingle(entry).data).toMatchObject({ id: 'abc', title: 'Hello' });
31
+ expect(fimoEntryToStrapiSingle(undefined).data).toBeNull();
32
+ });
33
+ });
@@ -18,6 +18,21 @@ function richTextDoc(text) {
18
18
  content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
19
19
  };
20
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
+ }
21
36
  /** Deterministic ISO date (no wall clock): 2024-01-01 + (n-1) days. */
22
37
  function exampleDate(n) {
23
38
  return new Date(Date.UTC(2024, 0, n)).toISOString().slice(0, 10);
@@ -42,6 +57,12 @@ export function generateExampleSeed(schema, options = {}) {
42
57
  row.slug = `${kebabCase(schema.name)}-example-${i}`;
43
58
  }
44
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
+ }
45
66
  switch (def.type) {
46
67
  case 'string':
47
68
  row[field] = `${humanize(field)} example ${i}${localeSuffix}`;
@@ -62,7 +83,7 @@ export function generateExampleSeed(schema, options = {}) {
62
83
  row[field] = {};
63
84
  break;
64
85
  case 'media':
65
- // Omit: a placeholder URL would render as a broken image.
86
+ row[field] = placeholderMedia(humanize(field));
66
87
  break;
67
88
  case 'reference': {
68
89
  const value = exampleReferenceSlug(def);
@@ -34,9 +34,10 @@ describe('generateExampleSeed', () => {
34
34
  expect(second.publishedOn).toBe('2024-01-02');
35
35
  expect(first.meta).toEqual({});
36
36
  });
37
- it('omits media fields (a fake URL would render broken)', () => {
37
+ it('seeds a self-contained placeholder for media fields', () => {
38
38
  const seed = generateExampleSeed(blogPost, { count: 1 });
39
- expect('cover' in seed.rows[0]).toBe(false);
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');
40
41
  });
41
42
  it('points references at the first example of the target, with a descriptor', () => {
42
43
  const seed = generateExampleSeed(blogPost, { count: 1 });
@@ -54,4 +55,12 @@ describe('generateExampleSeed', () => {
54
55
  const fr = generateExampleSeed(blogPost, { count: 1, locale: 'fr' });
55
56
  expect(fr.rows[0].title).toBe('Title example 1 (fr)');
56
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
+ });
57
66
  });
@@ -179,6 +179,7 @@ export function detectStrapi(frontendDir) {
179
179
  cms: 'Strapi',
180
180
  host: 'Strapi',
181
181
  appDir,
182
+ clientDir: libModule,
182
183
  evidence,
183
184
  locales: [],
184
185
  };
@@ -1,6 +1,7 @@
1
1
  import { detectStrapi } from './detect.js';
2
2
  import { renderStrapiListSource } from './list-source.js';
3
3
  import { readStrapiSchemas } from './schema-map.js';
4
+ import { wireStrapiClient } from './wire-client.js';
4
5
  /** Assemble the Strapi connector for a wrapped frontend, or null if no Strapi. */
5
6
  export function strapiConnector(frontendDir) {
6
7
  const detection = detectStrapi(frontendDir);
@@ -11,5 +12,12 @@ export function strapiConnector(frontendDir) {
11
12
  detection,
12
13
  readSchemas: () => (detection.appDir ? readStrapiSchemas(detection.appDir) : []),
13
14
  renderListSource: (schemas, serverImport) => schemas.length > 0 ? renderStrapiListSource(schemas, serverImport) : null,
15
+ wireClient: (cwd, schemas) => detection.clientDir
16
+ ? wireStrapiClient(cwd, detection.clientDir, schemas)
17
+ : {
18
+ changes: [],
19
+ warnings: ['No Strapi client directory detected — wire fimoStrapiFetch manually.'],
20
+ wired: false,
21
+ },
14
22
  };
15
23
  }
@@ -67,10 +67,20 @@ function mapSchema(raw, uidToName) {
67
67
  const info = raw.schema.info ?? {};
68
68
  const name = uidToName.get(raw.uid);
69
69
  const fields = {};
70
+ const examples = {};
70
71
  for (const [field, attr] of Object.entries(raw.schema.attributes ?? {})) {
71
72
  const mapped = mapAttribute(field, attr, uidToName);
72
73
  if (mapped) {
73
74
  fields[field] = mapped;
75
+ // Dynamic zones (and repeatable components) render as ARRAYS a page
76
+ // builder maps over; a single component as an OBJECT. Seed the correct
77
+ // empty shape so pages render instead of crashing on `{}.map(...)`.
78
+ if (attr.type === 'dynamiczone' || (attr.type === 'component' && attr.repeatable)) {
79
+ examples[field] = [];
80
+ }
81
+ else if (attr.type === 'component') {
82
+ examples[field] = {};
83
+ }
74
84
  }
75
85
  }
76
86
  return {
@@ -79,6 +89,7 @@ function mapSchema(raw, uidToName) {
79
89
  isSingleton: raw.schema.kind === 'singleType',
80
90
  localized: raw.schema.pluginOptions?.i18n?.localized === true,
81
91
  fields,
92
+ ...(Object.keys(examples).length > 0 ? { examples } : {}),
82
93
  sourceUid: raw.uid,
83
94
  sourcePath: `/${info.pluralName ?? raw.contentTypeName}`,
84
95
  };
@@ -75,6 +75,10 @@ describe('readStrapiSchemas', () => {
75
75
  });
76
76
  // slug is reserved entry metadata — never a schema field.
77
77
  expect('slug' in blogPost.fields).toBe(false);
78
+ // Structural fields get the correct empty shape so pages render:
79
+ // dynamic zone → array, single component → object.
80
+ expect(blogPost.examples?.sections).toEqual([]);
81
+ expect(blogPost.examples?.hero).toEqual({});
78
82
  const global = schemas.find((s) => s.name === 'Global');
79
83
  expect(global.isSingleton).toBe(true);
80
84
  expect(global.localized).toBe(true);
@@ -0,0 +1,15 @@
1
+ import type { CmsCollectionSchema } from '../types.js';
2
+ /**
3
+ * Machine-wire a Strapi frontend to read from Fimo: emit the `fimo-strapi`
4
+ * module in the CMS client dir, then swap every bare `fetch(...)` in that dir
5
+ * to `fimoStrapiFetch(...)`. Because every Strapi client funnels through one
6
+ * `fetch`, this makes the WHOLE site read scaffolded content from Fimo — no
7
+ * per-method or per-call-site knowledge, no agent step. Unscaffolded types fall
8
+ * through to the live CMS inside the shim.
9
+ */
10
+ export interface StrapiWireResult {
11
+ changes: string[];
12
+ warnings: string[];
13
+ wired: boolean;
14
+ }
15
+ export declare function wireStrapiClient(cwd: string, clientDir: string, schemas: CmsCollectionSchema[]): StrapiWireResult;
@@ -0,0 +1,107 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { parseSync } from 'oxc-parser';
4
+ import { renderFimoStrapiModule } from './wiring.js';
5
+ function walk(node, visit) {
6
+ if (!node || typeof node !== 'object') {
7
+ return;
8
+ }
9
+ if (Array.isArray(node)) {
10
+ for (const child of node) {
11
+ walk(child, visit);
12
+ }
13
+ return;
14
+ }
15
+ const record = node;
16
+ if (typeof record.type === 'string') {
17
+ visit(record);
18
+ }
19
+ for (const [key, value] of Object.entries(record)) {
20
+ if (key !== 'type' && value && typeof value === 'object') {
21
+ walk(value, visit);
22
+ }
23
+ }
24
+ }
25
+ /** Callee spans of bare `fetch(...)` calls (not `x.fetch()` / `this.fetch()`). */
26
+ function bareFetchCallees(filePath, code) {
27
+ let program;
28
+ try {
29
+ program = parseSync(filePath, code).program;
30
+ }
31
+ catch {
32
+ return [];
33
+ }
34
+ const spans = [];
35
+ walk(program, (n) => {
36
+ if (n.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name === 'fetch') {
37
+ spans.push({ start: n.callee.start, end: n.callee.end });
38
+ }
39
+ });
40
+ return spans;
41
+ }
42
+ function applyReplacements(code, edits) {
43
+ let next = code;
44
+ for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
45
+ next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
46
+ }
47
+ return next;
48
+ }
49
+ function relativeImport(fromFile, toFile) {
50
+ const rel = path
51
+ .relative(path.dirname(fromFile), toFile)
52
+ .replace(/\.tsx?$/, '')
53
+ .split(path.sep)
54
+ .join('/');
55
+ return rel.startsWith('.') ? rel : `./${rel}`;
56
+ }
57
+ function sourceFiles(dir) {
58
+ const out = [];
59
+ const walkDir = (current) => {
60
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
61
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
62
+ continue;
63
+ }
64
+ const full = path.join(current, entry.name);
65
+ if (entry.isDirectory()) {
66
+ walkDir(full);
67
+ }
68
+ else if (/\.(tsx?|jsx?|mjs)$/.test(entry.name)) {
69
+ out.push(full);
70
+ }
71
+ }
72
+ };
73
+ if (fs.existsSync(dir)) {
74
+ walkDir(dir);
75
+ }
76
+ return out;
77
+ }
78
+ export function wireStrapiClient(cwd, clientDir, schemas) {
79
+ const changes = [];
80
+ const warnings = [];
81
+ if (!fs.existsSync(clientDir)) {
82
+ return { changes, warnings: [`CMS client directory not found: ${path.relative(cwd, clientDir)}`], wired: false };
83
+ }
84
+ const modulePath = path.join(clientDir, 'fimo-strapi.ts');
85
+ fs.writeFileSync(modulePath, renderFimoStrapiModule(schemas));
86
+ changes.push(`generated ${path.relative(cwd, modulePath)}`);
87
+ let swapped = 0;
88
+ for (const file of sourceFiles(clientDir)) {
89
+ if (path.resolve(file) === path.resolve(modulePath)) {
90
+ continue;
91
+ }
92
+ const code = fs.readFileSync(file, 'utf8');
93
+ const spans = bareFetchCallees(file, code);
94
+ if (spans.length === 0) {
95
+ continue;
96
+ }
97
+ let next = applyReplacements(code, spans.map((span) => ({ ...span, text: 'fimoStrapiFetch' })));
98
+ next = `import { fimoStrapiFetch } from '${relativeImport(file, modulePath)}';\n${next}`;
99
+ fs.writeFileSync(file, next);
100
+ swapped += spans.length;
101
+ changes.push(`rewired ${path.relative(cwd, file)}: ${spans.length} fetch call(s) → fimoStrapiFetch`);
102
+ }
103
+ if (swapped === 0) {
104
+ warnings.push(`No bare fetch() call found under ${path.relative(cwd, clientDir)} — wire fimoStrapiFetch into the client manually.`);
105
+ }
106
+ return { changes, warnings, wired: swapped > 0 };
107
+ }
@@ -0,0 +1 @@
1
+ export {};