fimo 0.9.0-experimental.1 → 0.9.0-experimental.11
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/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
- package/assets/agents-starter/claude/settings.json +19 -0
- package/assets/content-templates/types-template.eta +1 -0
- package/assets/skills/fimo/references/translations.md +75 -0
- package/assets/skills/fimo-migration/SKILL.md +2 -0
- package/assets/skills/fimo-migration/references/manifest.md +11 -0
- package/assets/skills/fimo-migration/references/pages.md +298 -0
- package/assets/skills/fimo-migration/references/wrap.md +45 -0
- package/dist/build/vite/plugins/translations.js +28 -10
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +577 -269
- package/dist/runtime/app/FimoProviders.js +29 -7
- package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
- package/dist/runtime/core/BuiltWithBadge.js +68 -0
- package/dist/runtime/primitives/components/RichText.d.ts +5 -1
- package/dist/runtime/primitives/components/RichText.js +2 -2
- package/dist/runtime/react/FimoSpaProviders.js +83 -24
- package/dist/runtime/react/index.d.ts +2 -0
- package/dist/runtime/react/index.js +1 -0
- package/dist/runtime/react/spa-content-cache.d.ts +38 -0
- package/dist/runtime/react/spa-content-cache.js +64 -0
- package/dist/runtime/seo/neutral.d.ts +22 -0
- package/dist/runtime/seo/neutral.js +17 -0
- package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
- package/dist/runtime/shared/fimo-config.server.js +0 -13
- package/dist/runtime/strapi/client.js +45 -2
- package/dist/runtime/strapi/reshape.d.ts +10 -6
- package/dist/runtime/strapi/reshape.js +7 -0
- package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
- package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
- package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
- package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
- package/dist/runtime/wrapped/server.d.ts +5 -2
- package/dist/runtime/wrapped/server.js +15 -45
- package/dist/scripts/apply-wrap-templates.js +10 -5
- package/dist/scripts/apply-wrap.d.ts +29 -2
- package/dist/scripts/apply-wrap.js +69 -7
- package/dist/scripts/cms/example-content.js +16 -13
- package/dist/scripts/cms/strapi/schema-map.js +16 -4
- package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
- package/dist/scripts/cms/strapi/shapes.js +10 -0
- package/dist/scripts/cms/strapi/wiring.js +1 -1
- package/dist/scripts/detect-framework.js +6 -0
- package/dist/scripts/draft-rows.js +44 -1
- package/dist/scripts/draft-schemas.js +64 -5
- package/dist/scripts/frameworks/astro/routes.js +8 -19
- package/dist/scripts/frameworks/discover.d.ts +19 -6
- package/dist/scripts/frameworks/discover.js +113 -3
- package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
- package/dist/scripts/frameworks/discovered-route.js +17 -0
- package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
- package/dist/scripts/frameworks/next/rewire.js +26 -2
- package/dist/scripts/frameworks/next/routes.d.ts +13 -0
- package/dist/scripts/frameworks/next/routes.js +55 -13
- package/dist/scripts/frameworks/nuxt/routes.js +2 -18
- package/dist/scripts/frameworks/remix/routes.js +4 -18
- package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
- package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
- package/dist/scripts/frameworks/vite/routes.js +59 -9
- package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
- package/dist/scripts/frameworks/vite/wiring.js +68 -23
- package/dist/scripts/lib/humanize.d.ts +14 -0
- package/dist/scripts/lib/humanize.js +54 -0
- package/dist/scripts/lib/validation-report.d.ts +0 -3
- package/dist/scripts/lib/validation-report.js +0 -9
- package/dist/scripts/render-parity/capture.d.ts +12 -4
- package/dist/scripts/render-parity/capture.js +94 -24
- package/dist/scripts/routes/bindings.d.ts +93 -0
- package/dist/scripts/routes/bindings.js +117 -0
- package/dist/scripts/routes/label-prefixes.d.ts +49 -0
- package/dist/scripts/routes/label-prefixes.js +136 -0
- package/dist/scripts/routes/module-resolve.d.ts +20 -0
- package/dist/scripts/routes/module-resolve.js +78 -0
- package/dist/scripts/routes/page-structure.d.ts +136 -0
- package/dist/scripts/routes/page-structure.js +393 -0
- package/dist/scripts/scan-wrappable.d.ts +5 -1
- package/dist/scripts/scan-wrappable.js +126 -3
- package/package.json +5 -1
- package/templates/react-router/package.json +1 -1
- 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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
''': "'",
|
|
34
34
|
' ': ' ',
|
|
35
35
|
'©': '©',
|
|
36
|
+
'®': '®',
|
|
37
|
+
'™': '™',
|
|
38
|
+
'°': '°',
|
|
39
|
+
'·': '·',
|
|
40
|
+
'•': '•',
|
|
41
|
+
'…': '…',
|
|
42
|
+
'—': '—',
|
|
43
|
+
'–': '–',
|
|
44
|
+
'‘': '‘',
|
|
45
|
+
'’': '’',
|
|
46
|
+
'“': '“',
|
|
47
|
+
'”': '”',
|
|
48
|
+
'«': '«',
|
|
49
|
+
'»': '»',
|
|
50
|
+
'×': '×',
|
|
51
|
+
'→': '→',
|
|
52
|
+
'←': '←',
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
// Slug from the DECODED text — "'" 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] =
|
|
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
|
|
8
|
-
*
|
|
9
|
-
|
|
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 `${
|
|
65
|
+
return `${humanizeSegment(field)} example ${ctx.index}${ctx.localeSuffix}`;
|
|
70
66
|
case 'richtext':
|
|
71
|
-
return richTextDoc(`Example ${
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
198
|
+
isSingleton,
|
|
189
199
|
localized: raw.schema.pluginOptions?.i18n?.localized === true,
|
|
190
200
|
fields,
|
|
191
201
|
sourceUid: raw.uid,
|
|
192
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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)));
|