fimo 0.9.0-experimental.1 → 0.9.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.
Files changed (80) hide show
  1. package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
  2. package/assets/agents-starter/claude/settings.json +19 -0
  3. package/assets/content-templates/types-template.eta +1 -0
  4. package/assets/skills/fimo/references/translations.md +75 -0
  5. package/assets/skills/fimo-migration/SKILL.md +2 -0
  6. package/assets/skills/fimo-migration/references/manifest.md +11 -0
  7. package/assets/skills/fimo-migration/references/pages.md +234 -0
  8. package/assets/skills/fimo-migration/references/wrap.md +45 -0
  9. package/dist/build/vite/plugins/translations.js +28 -10
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +484 -243
  12. package/dist/runtime/app/FimoProviders.js +29 -7
  13. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  14. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  15. package/dist/runtime/primitives/components/RichText.d.ts +5 -1
  16. package/dist/runtime/primitives/components/RichText.js +2 -2
  17. package/dist/runtime/react/FimoSpaProviders.js +83 -24
  18. package/dist/runtime/react/index.d.ts +2 -0
  19. package/dist/runtime/react/index.js +1 -0
  20. package/dist/runtime/react/spa-content-cache.d.ts +38 -0
  21. package/dist/runtime/react/spa-content-cache.js +64 -0
  22. package/dist/runtime/seo/neutral.d.ts +22 -0
  23. package/dist/runtime/seo/neutral.js +17 -0
  24. package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
  25. package/dist/runtime/shared/fimo-config.server.js +0 -13
  26. package/dist/runtime/strapi/client.js +45 -2
  27. package/dist/runtime/strapi/reshape.d.ts +10 -6
  28. package/dist/runtime/strapi/reshape.js +7 -0
  29. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
  30. package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
  31. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  32. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  33. package/dist/runtime/wrapped/server.d.ts +5 -2
  34. package/dist/runtime/wrapped/server.js +15 -45
  35. package/dist/scripts/apply-wrap-templates.js +10 -5
  36. package/dist/scripts/apply-wrap.d.ts +29 -2
  37. package/dist/scripts/apply-wrap.js +69 -7
  38. package/dist/scripts/cms/example-content.js +16 -13
  39. package/dist/scripts/cms/strapi/schema-map.js +16 -4
  40. package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
  41. package/dist/scripts/cms/strapi/shapes.js +10 -0
  42. package/dist/scripts/cms/strapi/wiring.js +1 -1
  43. package/dist/scripts/detect-framework.js +6 -0
  44. package/dist/scripts/draft-rows.js +44 -1
  45. package/dist/scripts/draft-schemas.js +64 -5
  46. package/dist/scripts/frameworks/astro/routes.js +8 -19
  47. package/dist/scripts/frameworks/discover.d.ts +19 -6
  48. package/dist/scripts/frameworks/discover.js +113 -3
  49. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  50. package/dist/scripts/frameworks/discovered-route.js +17 -0
  51. package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
  52. package/dist/scripts/frameworks/next/rewire.js +26 -2
  53. package/dist/scripts/frameworks/next/routes.d.ts +13 -0
  54. package/dist/scripts/frameworks/next/routes.js +55 -13
  55. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  56. package/dist/scripts/frameworks/remix/routes.js +4 -18
  57. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  58. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  59. package/dist/scripts/frameworks/vite/routes.js +59 -9
  60. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  61. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  62. package/dist/scripts/lib/humanize.d.ts +14 -0
  63. package/dist/scripts/lib/humanize.js +54 -0
  64. package/dist/scripts/lib/validation-report.d.ts +0 -3
  65. package/dist/scripts/lib/validation-report.js +0 -9
  66. package/dist/scripts/render-parity/capture.d.ts +12 -4
  67. package/dist/scripts/render-parity/capture.js +94 -24
  68. package/dist/scripts/routes/bindings.d.ts +93 -0
  69. package/dist/scripts/routes/bindings.js +117 -0
  70. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  71. package/dist/scripts/routes/label-prefixes.js +136 -0
  72. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  73. package/dist/scripts/routes/module-resolve.js +78 -0
  74. package/dist/scripts/routes/page-structure.d.ts +72 -0
  75. package/dist/scripts/routes/page-structure.js +234 -0
  76. package/dist/scripts/scan-wrappable.d.ts +5 -1
  77. package/dist/scripts/scan-wrappable.js +126 -3
  78. package/package.json +5 -1
  79. package/templates/react-router/package.json +1 -1
  80. package/templates/react-router/translations/en.json +1 -0
@@ -0,0 +1,20 @@
1
+ import type { WrappedMediaEntry, WrappedRichTextEntry } from './WrappedContentContext.js';
2
+ export interface WrappedEntryRow {
3
+ id: string;
4
+ data: Record<string, unknown>;
5
+ }
6
+ /** Every published row of a content type, paginated to the end. Returns []
7
+ * when the API is unreachable so wrapped sites keep their fallbacks. */
8
+ export declare function fetchAllWrappedRows(apiUrl: string, typeName: string): Promise<WrappedEntryRow[]>;
9
+ /** WrappedMedia rows → key → media entry (the FimoMediaImage/Video id map). */
10
+ export declare function mapWrappedMedia(rows: WrappedEntryRow[]): Record<string, WrappedMediaEntry>;
11
+ /** WrappedRichText rows → key → TipTap content (the FimoRichTextBlock map). */
12
+ export declare function mapWrappedRichText(rows: WrappedEntryRow[]): Record<string, WrappedRichTextEntry>;
13
+ export interface WrappedContent {
14
+ media: Record<string, WrappedMediaEntry>;
15
+ richText: Record<string, WrappedRichTextEntry>;
16
+ }
17
+ /** Both plumbing maps in one round: what a client-rendered wrapped app needs
18
+ * so Studio edits to rich text and media are LIVE, not fallback-only. */
19
+ export declare function fetchWrappedContent(apiUrl: string): Promise<WrappedContent>;
20
+ //# sourceMappingURL=content-fetch.pure.d.ts.map
@@ -0,0 +1,106 @@
1
+ // Wrapped-content fetching, PURE core: fetch/URL/AbortController only — no
2
+ // node builtins, importable from client-bundled code (the SPA provider) and
3
+ // from the server loaders alike. The plumbing types (WrappedRichText,
4
+ // WrappedMedia) hold ALL of a site's wrapped rich text and media, so the
5
+ // fetch PAGINATES to the end — the tenant API caps limit at 100 and a real
6
+ // site exceeds it (the Fimo site carries 147 rich-text entries).
7
+ import { fetchWithTimeout } from '../shared/labels-fetch.pure.js';
8
+ const PAGE_LIMIT = 100;
9
+ const MAX_ROWS = 5000;
10
+ function readString(value) {
11
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
12
+ }
13
+ function readNumber(value) {
14
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
15
+ }
16
+ /** Every published row of a content type, paginated to the end. Returns []
17
+ * when the API is unreachable so wrapped sites keep their fallbacks. */
18
+ export async function fetchAllWrappedRows(apiUrl, typeName) {
19
+ const rows = [];
20
+ try {
21
+ for (let offset = 0; offset < MAX_ROWS; offset += PAGE_LIMIT) {
22
+ const url = new URL(`/entries/${typeName}`, apiUrl);
23
+ url.searchParams.set('limit', String(PAGE_LIMIT));
24
+ url.searchParams.set('offset', String(offset));
25
+ const response = await fetchWithTimeout(url, 4000);
26
+ if (!response.ok) {
27
+ break;
28
+ }
29
+ const body = (await response.json());
30
+ const page = Array.isArray(body.data) ? body.data : [];
31
+ for (const row of page) {
32
+ if (!row || typeof row !== 'object') {
33
+ continue;
34
+ }
35
+ const record = row;
36
+ const id = String(record.id ?? record.documentId ?? '');
37
+ if (id.length === 0) {
38
+ continue;
39
+ }
40
+ rows.push({
41
+ id,
42
+ data: (record.data && typeof record.data === 'object' ? record.data : record),
43
+ });
44
+ }
45
+ const hasMore = body.meta?.hasMore === true || page.length === PAGE_LIMIT;
46
+ if (!hasMore) {
47
+ break;
48
+ }
49
+ }
50
+ }
51
+ catch {
52
+ // Unreachable API mid-pagination: return what we have (possibly []).
53
+ }
54
+ return rows;
55
+ }
56
+ /** WrappedMedia rows → key → media entry (the FimoMediaImage/Video id map). */
57
+ export function mapWrappedMedia(rows) {
58
+ const map = {};
59
+ for (const entry of rows) {
60
+ const key = readString(entry.data.key) ?? readString(entry.data.slug);
61
+ const asset = entry.data.image;
62
+ if (!key || !asset || typeof asset !== 'object') {
63
+ continue;
64
+ }
65
+ const assetRecord = asset;
66
+ const url = readString(assetRecord.url);
67
+ if (!url) {
68
+ continue;
69
+ }
70
+ map[key] = {
71
+ url,
72
+ alt: readString(assetRecord.alt) ?? readString(entry.data.alt),
73
+ name: readString(assetRecord.name),
74
+ mime: readString(assetRecord.mime) ?? readString(assetRecord.mimeType),
75
+ width: readNumber(assetRecord.width),
76
+ height: readNumber(assetRecord.height),
77
+ source: `WrappedMedia.${entry.id}.image`,
78
+ };
79
+ }
80
+ return map;
81
+ }
82
+ /** WrappedRichText rows → key → TipTap content (the FimoRichTextBlock map). */
83
+ export function mapWrappedRichText(rows) {
84
+ const map = {};
85
+ for (const entry of rows) {
86
+ const key = readString(entry.data.key) ?? readString(entry.data.slug);
87
+ const body = entry.data.body;
88
+ if (!key || !body || typeof body !== 'object') {
89
+ continue;
90
+ }
91
+ map[key] = {
92
+ content: body,
93
+ source: `WrappedRichText.${entry.id}.body`,
94
+ };
95
+ }
96
+ return map;
97
+ }
98
+ /** Both plumbing maps in one round: what a client-rendered wrapped app needs
99
+ * so Studio edits to rich text and media are LIVE, not fallback-only. */
100
+ export async function fetchWrappedContent(apiUrl) {
101
+ const [mediaRows, richTextRows] = await Promise.all([
102
+ fetchAllWrappedRows(apiUrl, 'WrappedMedia'),
103
+ fetchAllWrappedRows(apiUrl, 'WrappedRichText'),
104
+ ]);
105
+ return { media: mapWrappedMedia(mediaRows), richText: mapWrappedRichText(richTextRows) };
106
+ }
@@ -39,12 +39,15 @@ export declare function fetchEntries(typeName: string, options?: FimoServerOptio
39
39
  /**
40
40
  * Load the WrappedMedia entries (key → media) seeded by the wrap migration.
41
41
  * Each entry has a `key` (the FimoMediaImage/Video/FileLink id) and an
42
- * asset-typed `image` field.
42
+ * asset-typed `image` field. Paginates to the end — a site's wrapped media
43
+ * routinely exceeds one API page.
43
44
  */
44
45
  export declare function loadWrappedMedia(options?: FimoServerOptions): Promise<Record<string, WrappedMediaEntry>>;
45
46
  /**
46
47
  * Load the WrappedRichText entries (key → TipTap JSON) seeded by the wrap
47
- * migration for formatted text (bold/italic/underline/links).
48
+ * migration for formatted text (bold/italic/underline/links). Paginates to
49
+ * the end — the Fimo site alone carries 147 entries against a 20-row API
50
+ * default page.
48
51
  */
49
52
  export declare function loadWrappedRichText(options?: FimoServerOptions): Promise<Record<string, WrappedRichTextEntry>>;
50
53
  /**
@@ -5,6 +5,7 @@
5
5
  import { getConfigServer } from '../shared/fimo-config.server.js';
6
6
  import { deepMergeMessages, unflattenMessages } from '../shared/i18n-messages.js';
7
7
  import { fetchLabelDictionary, fetchWithTimeout, labelSignature, loadLabelBundle, readProjectEnv, resolveTenantApiUrl, } from '../shared/labels-fetch.server.js';
8
+ import { fetchAllWrappedRows, mapWrappedMedia, mapWrappedRichText } from './content-fetch.pure.js';
8
9
  export function loadFimoConfig(rootDir = process.cwd()) {
9
10
  return getConfigServer(rootDir);
10
11
  }
@@ -72,62 +73,31 @@ export async function fetchEntries(typeName, options = {}) {
72
73
  return [];
73
74
  }
74
75
  }
75
- function readString(value) {
76
- return typeof value === 'string' && value.length > 0 ? value : undefined;
77
- }
78
- function readNumber(value) {
79
- return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
80
- }
81
76
  /**
82
77
  * Load the WrappedMedia entries (key → media) seeded by the wrap migration.
83
78
  * Each entry has a `key` (the FimoMediaImage/Video/FileLink id) and an
84
- * asset-typed `image` field.
79
+ * asset-typed `image` field. Paginates to the end — a site's wrapped media
80
+ * routinely exceeds one API page.
85
81
  */
86
82
  export async function loadWrappedMedia(options = {}) {
87
- const entries = await fetchEntries('WrappedMedia', options);
88
- const map = {};
89
- for (const entry of entries) {
90
- const key = readString(entry.data.key) ?? readString(entry.data.slug);
91
- const asset = entry.data.image;
92
- if (!key || !asset || typeof asset !== 'object') {
93
- continue;
94
- }
95
- const assetRecord = asset;
96
- const url = readString(assetRecord.url);
97
- if (!url) {
98
- continue;
99
- }
100
- map[key] = {
101
- url,
102
- alt: readString(assetRecord.alt) ?? readString(entry.data.alt),
103
- name: readString(assetRecord.name),
104
- mime: readString(assetRecord.mime) ?? readString(assetRecord.mimeType),
105
- width: readNumber(assetRecord.width),
106
- height: readNumber(assetRecord.height),
107
- source: `WrappedMedia.${entry.id}.image`,
108
- };
109
- }
110
- return map;
83
+ const apiUrl = await resolveApiUrl(options);
84
+ if (!apiUrl) {
85
+ return {};
86
+ }
87
+ return mapWrappedMedia(await fetchAllWrappedRows(apiUrl, 'WrappedMedia'));
111
88
  }
112
89
  /**
113
90
  * Load the WrappedRichText entries (key → TipTap JSON) seeded by the wrap
114
- * migration for formatted text (bold/italic/underline/links).
91
+ * migration for formatted text (bold/italic/underline/links). Paginates to
92
+ * the end — the Fimo site alone carries 147 entries against a 20-row API
93
+ * default page.
115
94
  */
116
95
  export async function loadWrappedRichText(options = {}) {
117
- const entries = await fetchEntries('WrappedRichText', options);
118
- const map = {};
119
- for (const entry of entries) {
120
- const key = readString(entry.data.key) ?? readString(entry.data.slug);
121
- const body = entry.data.body;
122
- if (!key || !body || typeof body !== 'object') {
123
- continue;
124
- }
125
- map[key] = {
126
- content: body,
127
- source: `WrappedRichText.${entry.id}.body`,
128
- };
96
+ const apiUrl = await resolveApiUrl(options);
97
+ if (!apiUrl) {
98
+ return {};
129
99
  }
130
- return map;
100
+ return mapWrappedRichText(await fetchAllWrappedRows(apiUrl, 'WrappedRichText'));
131
101
  }
132
102
  /** Key fields tried, in order, to align a source row with its Fimo entry. */
133
103
  const COLLECTION_KEY_FIELDS = ['slug', 'id', 'key', 'name', 'title', 'question'];
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { basename, join } from 'node:path';
3
- import { decodeJsxEntities, KeyAllocator, slugify } from './apply-wrap.js';
3
+ import { decodeJsxEntities, KeyAllocator, readApplyExemptions, reusableStagedLabels, slugify, } from './apply-wrap.js';
4
4
  import { scanWrappable } from './scan-wrappable.js';
5
5
  /**
6
6
  * Shared injection helper for SFC-style templates: first instance script,
@@ -66,11 +66,12 @@ export function applyTemplateWrapToFile(code, style, items, allocator) {
66
66
  continue;
67
67
  }
68
68
  const collapsed = match[2].replace(/\s+/g, ' ').trim();
69
- const id = allocator.allocate(scope || 'shared', slugify(collapsed));
69
+ const value = decodeJsxEntities(collapsed);
70
+ const id = allocator.allocate(scope || 'shared', slugify(value), value);
70
71
  const escaped = collapsed.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
71
72
  const replacement = `>${match[1]}${style.open}"${id}", "${escaped}"${style.close}${match[3]}<`;
72
73
  next = next.slice(0, match.index) + replacement + next.slice(match.index + match[0].length);
73
- labels[id] = decodeJsxEntities(collapsed);
74
+ labels[id] = value;
74
75
  applied.push({ kind: 'text', file: item.file, line: item.line, id, preview: item.preview });
75
76
  }
76
77
  if (applied.length > 0 && !style.headMarker.test(next)) {
@@ -95,11 +96,15 @@ export function applyTemplateWrap(rootDir, opts) {
95
96
  return {};
96
97
  }
97
98
  })();
98
- const allocator = new KeyAllocator([...Object.keys(existingLabels), ...scan.wrappedIds.labels]);
99
+ // Honor recorded exemptions — same contract as the JSX codemod: --apply
100
+ // must never wrap an item the user deliberately kept out.
101
+ const exemptions = readApplyExemptions(rootDir);
102
+ const isExempt = (item) => exemptions.some((exemption) => exemption.file === item.file && exemption.kind === item.kind && exemption.preview === item.preview);
103
+ const allocator = new KeyAllocator([...Object.keys(existingLabels), ...scan.wrappedIds.labels], reusableStagedLabels(existingLabels, scan.wrappedIds.labels));
99
104
  const result = { applied: [], skipped: [], labels: {}, filesChanged: [] };
100
105
  const byFile = new Map();
101
106
  for (const item of scan.items) {
102
- if (!item.file.endsWith(style.ext)) {
107
+ if (!item.file.endsWith(style.ext) || isExempt(item)) {
103
108
  continue;
104
109
  }
105
110
  const list = byFile.get(item.file) ?? [];
@@ -16,14 +16,27 @@ export interface ApplyWrapResult {
16
16
  filesChanged: string[];
17
17
  }
18
18
  export declare function decodeJsxEntities(text: string): string;
19
+ /** Conservative markdown detection: bold pairs, inline code, links, and a
20
+ * leading heading marker — not lone `*`/`[` characters in ordinary copy. */
21
+ export declare function looksLikeMarkdown(text: string): boolean;
19
22
  /** Deterministic scope from the file path: app/about/page.tsx -> about. */
20
23
  export declare function keyScope(file: string): string;
21
24
  export declare function slugify(text: string): string;
22
25
  export declare class KeyAllocator {
23
26
  private readonly used;
24
- constructor(existing: Iterable<string>);
25
- allocate(scope: string, slug: string): string;
27
+ private readonly reusable;
28
+ /**
29
+ * `reusable` maps staged keys that no longer appear in code (a wrap the
30
+ * user reverted) to their staged value: re-applying the codemod over the
31
+ * same text reclaims the ORIGINAL key instead of minting a `-N` duplicate
32
+ * that orphans the old label (and its seeded row).
33
+ */
34
+ constructor(existing: Iterable<string>, reusable?: ReadonlyMap<string, string>);
35
+ allocate(scope: string, slug: string, value?: string): string;
26
36
  }
37
+ /** Staged keys absent from the current code scan, with their staged values —
38
+ * the KeyAllocator's reuse pool. */
39
+ export declare function reusableStagedLabels(existingLabels: Record<string, string>, wrappedInCode: Iterable<string>): Map<string, string>;
27
40
  /**
28
41
  * Merge the fimo/next import with an existing one, or insert a new one AFTER
29
42
  * the last import statement — positioned via the AST, never line matching
@@ -40,6 +53,20 @@ export declare function applyWrapToFile(code: string, items: WrappableItem[], al
40
53
  }>;
41
54
  labels: Record<string, string>;
42
55
  };
56
+ /**
57
+ * Apply the safe transforms across the project. Mutates source files and
58
+ * merges generated labels into `.fimo/migration/labels.json` (never
59
+ * overwriting existing keys).
60
+ */
61
+ /** Recorded coverage exemptions the codemod must NOT wrap — items the user
62
+ * deliberately kept out (dev-only UI, OG-image markup, …). Matched by the
63
+ * same (file, kind, preview) triple the coverage gate uses, so "record the
64
+ * exemption first, then --apply auto-wraps the rest" holds. */
65
+ export declare function readApplyExemptions(rootDir: string): Array<{
66
+ file: string;
67
+ kind: string;
68
+ preview: string;
69
+ }>;
43
70
  export declare function applyWrap(rootDir: string, opts?: {
44
71
  excludePrefixes?: string[];
45
72
  importModule?: string;
@@ -33,13 +33,36 @@ const JSX_ENTITIES = {
33
33
  '&apos;': "'",
34
34
  '&nbsp;': ' ',
35
35
  '&copy;': '©',
36
+ '&reg;': '®',
37
+ '&trade;': '™',
38
+ '&deg;': '°',
39
+ '&middot;': '·',
40
+ '&bull;': '•',
41
+ '&hellip;': '…',
42
+ '&mdash;': '—',
43
+ '&ndash;': '–',
44
+ '&lsquo;': '‘',
45
+ '&rsquo;': '’',
46
+ '&ldquo;': '“',
47
+ '&rdquo;': '”',
48
+ '&laquo;': '«',
49
+ '&raquo;': '»',
50
+ '&times;': '×',
51
+ '&rarr;': '→',
52
+ '&larr;': '←',
36
53
  };
37
54
  export function decodeJsxEntities(text) {
38
55
  return text
39
- .replace(/&(amp|lt|gt|quot|apos|nbsp|copy);/g, (entity) => JSX_ENTITIES[entity] ?? entity)
56
+ .replace(/&(amp|lt|gt|quot|apos|nbsp|copy|reg|trade|deg|middot|bull|hellip|mdash|ndash|lsquo|rsquo|ldquo|rdquo|laquo|raquo|times|rarr|larr);/g, (entity) => JSX_ENTITIES[entity] ?? entity)
40
57
  .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
41
58
  .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
42
59
  }
60
+ const MARKDOWN_SYNTAX = [/\*\*[^*]+\*\*/, /(?<!`)`[^`]+`/, /\[[^\]]+\]\([^)]+\)/, /^#{1,6}\s/];
61
+ /** Conservative markdown detection: bold pairs, inline code, links, and a
62
+ * leading heading marker — not lone `*`/`[` characters in ordinary copy. */
63
+ export function looksLikeMarkdown(text) {
64
+ return MARKDOWN_SYNTAX.some((pattern) => pattern.test(text));
65
+ }
43
66
  /** Deterministic scope from the file path: app/about/page.tsx -> about. */
44
67
  export function keyScope(file) {
45
68
  const normalized = file.replace(/\\/g, '/');
@@ -62,7 +85,12 @@ export function keyScope(file) {
62
85
  return slugify(stem) || 'shared';
63
86
  }
64
87
  export function slugify(text) {
88
+ // camelCase boundaries become segments BEFORE lowercasing, so component
89
+ // file names keep their word structure (AgentStrip.tsx → agent-strip.*)
90
+ // and every humanized display of the key reads like the component name.
65
91
  return collapse(text)
92
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
93
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
66
94
  .toLowerCase()
67
95
  .normalize('NFD')
68
96
  .replace(/[̀-ͯ]/g, '')
@@ -74,22 +102,44 @@ export function slugify(text) {
74
102
  }
75
103
  export class KeyAllocator {
76
104
  used = new Set();
77
- constructor(existing) {
105
+ reusable = new Map();
106
+ /**
107
+ * `reusable` maps staged keys that no longer appear in code (a wrap the
108
+ * user reverted) to their staged value: re-applying the codemod over the
109
+ * same text reclaims the ORIGINAL key instead of minting a `-N` duplicate
110
+ * that orphans the old label (and its seeded row).
111
+ */
112
+ constructor(existing, reusable) {
78
113
  for (const key of existing) {
79
114
  this.used.add(key);
80
115
  }
116
+ if (reusable) {
117
+ for (const [key, value] of reusable) {
118
+ this.reusable.set(key, value);
119
+ }
120
+ }
81
121
  }
82
- allocate(scope, slug) {
122
+ allocate(scope, slug, value) {
83
123
  const base = `${scope}.${slug || 'item'}`;
84
124
  let candidate = base;
85
125
  let counter = 2;
86
126
  while (this.used.has(candidate)) {
127
+ if (value !== undefined && this.reusable.get(candidate) === value) {
128
+ this.reusable.delete(candidate);
129
+ return candidate;
130
+ }
87
131
  candidate = `${base}-${counter++}`;
88
132
  }
89
133
  this.used.add(candidate);
90
134
  return candidate;
91
135
  }
92
136
  }
137
+ /** Staged keys absent from the current code scan, with their staged values —
138
+ * the KeyAllocator's reuse pool. */
139
+ export function reusableStagedLabels(existingLabels, wrappedInCode) {
140
+ const inCode = new Set(wrappedInCode);
141
+ return new Map(Object.entries(existingLabels).filter(([key]) => !inCode.has(key)));
142
+ }
93
143
  /**
94
144
  * Merge the fimo/next import with an existing one, or insert a new one AFTER
95
145
  * the last import statement — positioned via the AST, never line matching
@@ -152,16 +202,28 @@ export function applyWrapToFile(code, items, allocator, importModule = 'fimo/nex
152
202
  skipped.push({ item, reason: 'whitespace only' });
153
203
  continue;
154
204
  }
205
+ if (looksLikeMarkdown(core)) {
206
+ // A parent that pipes its string child through a markdown renderer
207
+ // receives a React element after wrapping — the pipeline stops
208
+ // parsing and the **markers** render literally (a real parity break
209
+ // a dogfood gate caught). Text carrying markdown syntax stays agent
210
+ // work; syntax-free text renders identically either way.
211
+ skipped.push({ item, reason: 'markdown syntax in text — wrapping could break a markdown pipeline' });
212
+ continue;
213
+ }
155
214
  const lead = raw.slice(0, raw.indexOf(core));
156
215
  const trail = raw.slice(raw.indexOf(core) + core.length);
157
- const id = allocator.allocate(scope, slugify(core));
216
+ // Slug from the DECODED text — "&apos;" must never leak "apos" into a
217
+ // key ("today-apos-s-total" read "Today Apos S Total" in the Studio).
218
+ const value = decodeJsxEntities(collapse(core));
219
+ const id = allocator.allocate(scope, slugify(decodeJsxEntities(core)), value);
158
220
  edits.push({
159
221
  start: span.start,
160
222
  end: span.end,
161
223
  replacement: `${lead}<FimoText id="${id}">${core}</FimoText>${trail}`,
162
224
  });
163
225
  specifiers.add('FimoText');
164
- labels[id] = decodeJsxEntities(collapse(core));
226
+ labels[id] = value;
165
227
  applied.push({ kind: 'text', file: item.file, line: item.line, id, preview: item.preview });
166
228
  continue;
167
229
  }
@@ -215,7 +277,7 @@ export function applyWrapToFile(code, items, allocator, importModule = 'fimo/nex
215
277
  * deliberately kept out (dev-only UI, OG-image markup, …). Matched by the
216
278
  * same (file, kind, preview) triple the coverage gate uses, so "record the
217
279
  * exemption first, then --apply auto-wraps the rest" holds. */
218
- function readApplyExemptions(rootDir) {
280
+ export function readApplyExemptions(rootDir) {
219
281
  try {
220
282
  const manifest = JSON.parse(readFileSync(join(rootDir, '.fimo', 'migration', 'manifest.json'), 'utf8'));
221
283
  const raw = manifest?.wrap?.exemptions;
@@ -244,7 +306,7 @@ export function applyWrap(rootDir, opts = {}) {
244
306
  // an item the user just exempted and the gate would flag the drift.
245
307
  const exemptions = readApplyExemptions(rootDir);
246
308
  const isExempt = (item) => exemptions.some((exemption) => exemption.file === item.file && exemption.kind === item.kind && exemption.preview === item.preview);
247
- const allocator = new KeyAllocator([...Object.keys(existingLabels), ...scan.wrappedIds.labels]);
309
+ const allocator = new KeyAllocator([...Object.keys(existingLabels), ...scan.wrappedIds.labels], reusableStagedLabels(existingLabels, scan.wrappedIds.labels));
248
310
  const byFile = new Map();
249
311
  for (const item of scan.items) {
250
312
  if (isExempt(item)) {
@@ -1,12 +1,14 @@
1
+ import { humanizeSegment } from '../lib/humanize.js';
1
2
  const DEFAULT_COUNT = 3;
2
3
  /** Variants seeded per dynamic-zone (`oneOf`) field — a representative page, not
3
4
  * all N components. Real content (the pull) exercises the rest. */
4
5
  const EXAMPLE_BLOCK_VARIANTS = 5;
5
6
  /** Items seeded for a repeatable fixed-shape block. */
6
7
  const EXAMPLE_REPEATABLE_ITEMS = 2;
7
- /** Recursion cap for nested blocks — the schema is already depth-bounded; this
8
- * keeps generation finite even for pathological nesting. */
9
- const MAX_FILL_DEPTH = 3;
8
+ /** Recursion cap for nested blocks — fill to the schema's real nesting so deep
9
+ * page-builders render, matching the blocks-depth limit (7) in api-contracts.
10
+ * The schema is already depth-bounded, so generation stays finite. */
11
+ const MAX_FILL_DEPTH = 6;
10
12
  function kebabCase(name) {
11
13
  return name
12
14
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
@@ -14,12 +16,6 @@ function kebabCase(name) {
14
16
  .toLowerCase()
15
17
  .replace(/^-+|-+$/g, '');
16
18
  }
17
- function humanize(field) {
18
- return field
19
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
20
- .replace(/[_-]+/g, ' ')
21
- .replace(/^\w/, (c) => c.toUpperCase());
22
- }
23
19
  function richTextDoc(text) {
24
20
  return {
25
21
  type: 'doc',
@@ -66,9 +62,9 @@ function fillFieldValue(field, def, ctx) {
66
62
  }
67
63
  switch (def.type) {
68
64
  case 'string':
69
- return `${humanize(field)} example ${ctx.index}${ctx.localeSuffix}`;
65
+ return `${humanizeSegment(field)} example ${ctx.index}${ctx.localeSuffix}`;
70
66
  case 'richtext':
71
- return richTextDoc(`Example ${humanize(ctx.schemaName).toLowerCase()} content ${ctx.index}${ctx.localeSuffix}.`);
67
+ return richTextDoc(`Example ${humanizeSegment(ctx.schemaName).toLowerCase()} content ${ctx.index}${ctx.localeSuffix}.`);
72
68
  case 'number':
73
69
  return ctx.index;
74
70
  case 'boolean':
@@ -78,7 +74,7 @@ function fillFieldValue(field, def, ctx) {
78
74
  case 'json':
79
75
  return {};
80
76
  case 'media':
81
- return placeholderMedia(humanize(field));
77
+ return placeholderMedia(humanizeSegment(field));
82
78
  case 'blocks':
83
79
  return fillBlocks(def, ctx);
84
80
  default:
@@ -99,7 +95,14 @@ function fillBlocks(def, ctx) {
99
95
  const childCtx = { ...ctx, depth: ctx.depth + 1 };
100
96
  const schema = def.schema;
101
97
  if ('oneOf' in schema) {
102
- return schema.oneOf.slice(0, EXAMPLE_BLOCK_VARIANTS).map((variant) => ({
98
+ // Rotate the variant window by row index so example entries render
99
+ // DIFFERENT block sets (entry 1 → variants 0-4, entry 2 → 5-9, …) —
100
+ // distinct-looking pages, and collectively more of the page builder
101
+ // exercised than the first five variants over and over.
102
+ const total = schema.oneOf.length;
103
+ const take = Math.min(EXAMPLE_BLOCK_VARIANTS, total);
104
+ const offset = total > 0 ? ((ctx.index - 1) * take) % total : 0;
105
+ return Array.from({ length: take }, (_, k) => schema.oneOf[(offset + k) % total]).map((variant) => ({
103
106
  _type: variant.uid,
104
107
  ...fillItem(variant.fields, childCtx),
105
108
  }));
@@ -19,7 +19,9 @@ import { join } from 'node:path';
19
19
  const MAX_BLOCK_VARIANTS = 40;
20
20
  // Bound recursion into nested components; beyond this a nested component falls
21
21
  // back to `json` (still round-trips, just not granularly editable that deep).
22
- const MAX_COMPONENT_DEPTH = 5;
22
+ // Kept one below MAX_STRUCTURED_CONTENT_DEPTH (7) in @fimo/api-contracts so the
23
+ // deepest blocks we emit still pass the server's structured-content-depth gate.
24
+ const MAX_COMPONENT_DEPTH = 6;
23
25
  // Field-name + type-name contracts from @fimo/api-contracts (content-fields.ts).
24
26
  const FIELD_NAME_PATTERN = /^[A-Za-z_]\w*$/;
25
27
  const RESERVED_BLOCK_FIELD_NAMES = new Set(['_key', '_type']);
@@ -55,8 +57,15 @@ function mapAttribute(field, attr, ctx) {
55
57
  case 'text':
56
58
  case 'email':
57
59
  case 'uid':
58
- case 'enumeration':
59
60
  return { type: 'string' };
61
+ case 'enumeration': {
62
+ // Enum values drive rendering switches (link types, section variants) —
63
+ // an invalid member makes components filter the item out entirely. Bake
64
+ // the schema's declared default (else the first member) so generated
65
+ // content always carries a valid value; the field stays editable.
66
+ const value = attr.default ?? (Array.isArray(attr.enum) ? attr.enum[0] : undefined);
67
+ return { type: 'string', ...(value !== undefined ? { default: value } : {}) };
68
+ }
60
69
  case 'richtext':
61
70
  case 'blocks':
62
71
  return { type: 'richtext' };
@@ -182,14 +191,17 @@ function mapSchema(raw, uidToName, components) {
182
191
  fields[field] = mapped;
183
192
  }
184
193
  }
194
+ const isSingleton = raw.schema.kind === 'singleType';
185
195
  return {
186
196
  name,
187
197
  label: info.displayName || name,
188
- isSingleton: raw.schema.kind === 'singleType',
198
+ isSingleton,
189
199
  localized: raw.schema.pluginOptions?.i18n?.localized === true,
190
200
  fields,
191
201
  sourceUid: raw.uid,
192
- sourcePath: `/${info.pluralName ?? raw.contentTypeName}`,
202
+ // Strapi single types are fetched by SINGULAR name (`/api/global`), collections
203
+ // by plural (`/api/pages`); the wiring keys its type map off this path.
204
+ sourcePath: `/${isSingleton ? (info.singularName ?? raw.contentTypeName) : (info.pluralName ?? raw.contentTypeName)}`,
193
205
  };
194
206
  }
195
207
  /** Walk `<strapiAppDir>/src/api/<api>/content-types/<ct>/schema.json`. */
@@ -3,17 +3,19 @@ import type { CmsCollectionSchema } from '../types.js';
3
3
  * Field-shape manifest for the `fimo/strapi` reshape. A Fimo blocks value is
4
4
  * always an array, but a Strapi single component reads as an object — so the
5
5
  * reshape needs to know, per structural field, whether to unwrap the one-item
6
- * array (`single`) or keep it (`list`), plus the nested shapes inside. Leaf
7
- * fields (scalars, media, richtext, references) are omitted — the runtime
8
- * reshapes those by value-inspection. Derived from the Strapi source at scaffold
9
- * time and baked into the generated module, so nothing is inferred at runtime.
6
+ * array (`single`) or keep it (`list`), plus the nested shapes inside.
7
+ * To-many relations are baked as `many-ref` so the reshape can coerce them to
8
+ * arrays invariantly. Other leaf fields (scalars, media, richtext, single
9
+ * references) are omitted — the runtime reshapes those by value-inspection.
10
+ * Derived from the Strapi source at scaffold time and baked into the
11
+ * generated module, so nothing is inferred at runtime.
10
12
  *
11
13
  * This mirrors `BlockFieldShape` in `runtime/strapi/reshape.ts` (the composite
12
14
  * boundary forbids sharing the type across `scripts`/`runtime`); the
13
15
  * `shapes.test.ts` round-trip guards their agreement.
14
16
  */
15
17
  export interface BlockFieldShape {
16
- kind: 'single' | 'list';
18
+ kind: 'single' | 'list' | 'many-ref';
17
19
  fields?: FieldShapes;
18
20
  variants?: Record<string, FieldShapes>;
19
21
  }
@@ -1,6 +1,16 @@
1
1
  function shapesFrom(fields) {
2
2
  const shapes = {};
3
3
  for (const [name, def] of Object.entries(fields)) {
4
+ if (def.type === 'reference') {
5
+ // Many-relations are baked as 'many-ref' so the reshape can hold the
6
+ // array invariant — value-inspection alone cannot tell a stray single
7
+ // handle from a legitimate object, and a Strapi frontend iterating a
8
+ // oneToMany 500s the moment one arrives as an object.
9
+ if (def.many) {
10
+ shapes[name] = { kind: 'many-ref' };
11
+ }
12
+ continue;
13
+ }
4
14
  if (def.type !== 'blocks') {
5
15
  continue; // leaves are value-inspected by the runtime reshape
6
16
  }
@@ -35,7 +35,7 @@ const SINGLETONS = [${singletons.map((name) => `'${name}'`).join(', ')}];
35
35
 
36
36
  // Structural field shapes (single components read as objects; block items and
37
37
  // dynamic zones reshape into the Strapi shape the components expect).
38
- const FIELD_SHAPES = ${fieldShapes};
38
+ const FIELD_SHAPES = ${fieldShapes} as const;
39
39
 
40
40
  export const fimoStrapiFetch = createFimoStrapiFetch({
41
41
  types: TYPES,
@@ -18,6 +18,12 @@ export function detectFramework(cwd) {
18
18
  return { framework: null };
19
19
  }
20
20
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
21
+ // A CMS backend is never an adoptable frontend — Strapi 5 ships vite+react
22
+ // devDeps for its admin panel, which the bare-vite fallback would match
23
+ // (the sandbox/website monorepo suggested `fimo init apps/strapi`).
24
+ if (deps['@strapi/strapi']) {
25
+ return { framework: null };
26
+ }
21
27
  if (deps.next) {
22
28
  const appDir = ['app', 'src/app'].find((dir) => existsSync(join(cwd, dir)));
23
29
  const pagesDir = ['pages', 'src/pages'].find((dir) => existsSync(join(cwd, dir)));