fimo 0.9.0-experimental.4 → 0.9.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.
- package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
- package/assets/agents-starter/claude/settings.json +19 -0
- package/assets/skills/fimo/references/translations.md +75 -0
- package/assets/skills/fimo-cli/references/create.md +90 -0
- package/assets/skills/fimo-cli/references/translations.md +43 -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 +95 -0
- package/assets/skills/fimo-migration/references/wrap.md +29 -0
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +171 -112
- package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
- package/dist/runtime/core/BuiltWithBadge.js +68 -0
- package/dist/runtime/seo/neutral.d.ts +22 -0
- package/dist/runtime/seo/neutral.js +17 -0
- package/dist/runtime/strapi/client.js +45 -2
- package/dist/scripts/cms/example-content.js +8 -1
- package/dist/scripts/cms/strapi/schema-map.js +13 -3
- 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 +6 -0
- package/dist/scripts/frameworks/next/routes.js +24 -1
- package/dist/scripts/inject-translations.d.ts +6 -0
- package/dist/scripts/inject-translations.js +168 -0
- package/dist/scripts/lib/discover-next-routes.d.ts +17 -0
- package/dist/scripts/lib/discover-next-routes.js +134 -0
- package/dist/scripts/routes/bindings.d.ts +93 -0
- package/dist/scripts/routes/bindings.js +117 -0
- 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,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { createPortal } from 'react-dom';
|
|
4
|
+
const STORAGE_KEY = 'fimoBadgeDismissed';
|
|
5
|
+
export function BuiltWithBadge() {
|
|
6
|
+
const hideFimoBadge = __FIMO_HIDE_BADGE__;
|
|
7
|
+
const [visible, setVisible] = useState(false);
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
try {
|
|
10
|
+
const dismissed = sessionStorage.getItem(STORAGE_KEY) === 'true';
|
|
11
|
+
setVisible(!dismissed);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
setVisible(true);
|
|
15
|
+
}
|
|
16
|
+
}, []);
|
|
17
|
+
if (hideFimoBadge) {
|
|
18
|
+
return null;
|
|
19
|
+
} // hide fimo badge config
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
if (!import.meta.env.PROD) {
|
|
22
|
+
return null;
|
|
23
|
+
} // only show in prod
|
|
24
|
+
if (!visible) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
if (typeof document === 'undefined') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return createPortal(_jsx("div", { style: {
|
|
31
|
+
position: 'fixed',
|
|
32
|
+
right: '16px',
|
|
33
|
+
bottom: '16px',
|
|
34
|
+
zIndex: 100000,
|
|
35
|
+
margin: 0,
|
|
36
|
+
}, children: _jsxs("div", { style: {
|
|
37
|
+
display: 'flex',
|
|
38
|
+
alignItems: 'center',
|
|
39
|
+
gap: '8px',
|
|
40
|
+
borderRadius: '9999px',
|
|
41
|
+
color: '#fff',
|
|
42
|
+
boxShadow: '0 12px 30px rgba(0, 0, 0, 0.35)',
|
|
43
|
+
padding: '8px 12px',
|
|
44
|
+
fontSize: '12px',
|
|
45
|
+
lineHeight: 1.2,
|
|
46
|
+
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
|
47
|
+
fontFamily: 'ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif',
|
|
48
|
+
}, children: [_jsxs("a", { href: "https://fimo.ai", target: "_blank", rel: "noopener noreferrer", style: {
|
|
49
|
+
display: 'inline-flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
gap: '8px',
|
|
52
|
+
color: '#fff',
|
|
53
|
+
textDecoration: 'none',
|
|
54
|
+
}, children: [_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "18", height: "18", viewBox: "0 0 24 12", fill: "none", children: _jsx("path", { d: "M22.8569 4.85715H14.9866L15.9132 3.58186C16.5027 2.77062 17.4534 2.28627 18.4561 2.28627H22.8569C23.4882 2.28627 23.9999 1.7745 23.9999 1.14328C23.9999 0.512061 23.4884 0 22.8569 0H18.4561C16.7239 0 15.082 0.836671 14.0639 2.23798L12.1611 4.85715H6.9979C5.2657 4.85715 3.62379 5.69382 2.60567 7.09513L0.360255 10.1852C-0.0109321 10.6958 0.10251 11.4108 0.613142 11.7817C0.816023 11.9291 1.05119 12 1.28408 12C1.63755 12 1.98616 11.8366 2.20962 11.5288L4.45474 8.43872C5.04424 7.62748 5.99493 7.14314 6.99762 7.14314H10.5L8.28977 10.1852C7.91858 10.6958 8.03203 11.4108 8.54266 11.7817C8.74554 11.9291 8.98071 12 9.2136 12C9.56707 12 9.91568 11.8366 10.1391 11.5288L12.3843 8.43872C12.9738 7.62748 13.9244 7.14314 14.9271 7.14314H22.8566C23.4879 7.14314 23.9996 6.63136 23.9996 6.00014C23.9996 5.36893 23.4879 4.85715 22.8566 4.85715H22.8569Z", fill: "white" }) }), _jsx("span", { style: { fontWeight: 600 }, children: "Built with Fimo" })] }), _jsx("button", { "aria-label": "Dismiss badge", style: {
|
|
55
|
+
color: '#ddd',
|
|
56
|
+
background: 'transparent',
|
|
57
|
+
cursor: 'pointer',
|
|
58
|
+
border: 'none',
|
|
59
|
+
padding: 0,
|
|
60
|
+
lineHeight: 0,
|
|
61
|
+
}, onClick: () => {
|
|
62
|
+
try {
|
|
63
|
+
sessionStorage.setItem(STORAGE_KEY, 'true');
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
setVisible(false);
|
|
67
|
+
}, type: "button", children: _jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [_jsx("path", { d: "M18 6 6 18" }), _jsx("path", { d: "m6 6 12 12" })] }) })] }) }), document.body);
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { FimoSEO } from '../shared/fimo-config.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The framework-neutral `fimo/seo` surface: pure per-page SEO resolution over
|
|
4
|
+
* the page registry (`fimo-config.json#routes`), with no framework adapter,
|
|
5
|
+
* no vite virtual module, and no React. Every framework's injection recipe
|
|
6
|
+
* (Next `generateMetadata`, an Astro head component, a SvelteKit load) calls
|
|
7
|
+
* `getFimoSeoForPath` server-side and maps the result to its own head API.
|
|
8
|
+
* SEO stays wired only when the project owner explicitly opted into
|
|
9
|
+
* Fimo-managed SEO — the migration never wires it by default.
|
|
10
|
+
*/
|
|
11
|
+
export { resolveSeoForPath } from './resolver.js';
|
|
12
|
+
export type { FimoSEO, RouteSEO } from '../shared/fimo-config.types.js';
|
|
13
|
+
export interface GetFimoSeoOptions {
|
|
14
|
+
/** Project root holding fimo-config.json (default: process.cwd()). */
|
|
15
|
+
rootDir?: string;
|
|
16
|
+
/** Instance data for `{{variable}}` templates in route-level SEO (e.g. the
|
|
17
|
+
* entry rendered by a dynamic page: `{ "blog-post": entry }`). */
|
|
18
|
+
routeData?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve the SEO values for one page path from the page registry on disk. */
|
|
21
|
+
export declare function getFimoSeoForPath(pathname: string, options?: GetFimoSeoOptions): FimoSEO | undefined;
|
|
22
|
+
//# sourceMappingURL=neutral.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getConfigServer } from '../shared/fimo-config.server.js';
|
|
2
|
+
import { resolveSeoForPath } from './resolver.js';
|
|
3
|
+
/**
|
|
4
|
+
* The framework-neutral `fimo/seo` surface: pure per-page SEO resolution over
|
|
5
|
+
* the page registry (`fimo-config.json#routes`), with no framework adapter,
|
|
6
|
+
* no vite virtual module, and no React. Every framework's injection recipe
|
|
7
|
+
* (Next `generateMetadata`, an Astro head component, a SvelteKit load) calls
|
|
8
|
+
* `getFimoSeoForPath` server-side and maps the result to its own head API.
|
|
9
|
+
* SEO stays wired only when the project owner explicitly opted into
|
|
10
|
+
* Fimo-managed SEO — the migration never wires it by default.
|
|
11
|
+
*/
|
|
12
|
+
export { resolveSeoForPath } from './resolver.js';
|
|
13
|
+
/** Resolve the SEO values for one page path from the page registry on disk. */
|
|
14
|
+
export function getFimoSeoForPath(pathname, options = {}) {
|
|
15
|
+
const config = getConfigServer(options.rootDir ?? process.cwd());
|
|
16
|
+
return resolveSeoForPath(pathname, config, options.routeData);
|
|
17
|
+
}
|
|
@@ -57,15 +57,40 @@ export function parseStrapiUrl(rawUrl, apiPrefix = '/api') {
|
|
|
57
57
|
where: Object.keys(where).length > 0 ? where : undefined,
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
const isDev = () => typeof process !== 'undefined' && process.env != null && process.env.NODE_ENV !== 'production';
|
|
60
61
|
/** Build a `fetch`-compatible function that serves scaffolded content from Fimo. */
|
|
61
62
|
export function createFimoStrapiFetch(options, realFetch = fetch) {
|
|
62
63
|
const singletons = new Set(options.singletons ?? []);
|
|
63
64
|
const read = { apiUrl: options.apiUrl, rootDir: options.rootDir };
|
|
65
|
+
// Lowercased type-name index, used only to flag a *near-miss* fallthrough: a
|
|
66
|
+
// path segment that looks like a scaffolded type but doesn't match a TYPES key
|
|
67
|
+
// (the classic singular-vs-plural keying bug). Genuinely foreign paths
|
|
68
|
+
// (`/api/upload/files`) don't match and stay silent.
|
|
69
|
+
const typeNamesLower = new Set(Object.values(options.types).map((name) => name.toLowerCase()));
|
|
70
|
+
const warnedPaths = new Set();
|
|
71
|
+
const warnNearMiss = (segment) => {
|
|
72
|
+
if (!isDev() || warnedPaths.has(segment)) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const seg = segment.toLowerCase();
|
|
76
|
+
const nearMiss = typeNamesLower.has(seg) ||
|
|
77
|
+
typeNamesLower.has(seg.replace(/s$/, '')) ||
|
|
78
|
+
typeNamesLower.has(seg.replace(/ies$/, 'y'));
|
|
79
|
+
if (nearMiss) {
|
|
80
|
+
warnedPaths.add(segment);
|
|
81
|
+
console.warn(`[fimo/strapi] /api/${segment} looks like a scaffolded type but is not in the type map — ` +
|
|
82
|
+
`this read fell through to the live CMS. If a single type reads empty, its map key is likely ` +
|
|
83
|
+
`plural where the fetch is singular (regenerate with \`fimo migrate --scaffold-cms\`).`);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
64
86
|
return (async (input, init) => {
|
|
65
87
|
const rawUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
66
88
|
const parsed = parseStrapiUrl(rawUrl ?? '', options.apiPrefix);
|
|
67
89
|
const typeName = parsed ? options.types[parsed.pluralName] : undefined;
|
|
68
90
|
if (!parsed || !typeName) {
|
|
91
|
+
if (parsed) {
|
|
92
|
+
warnNearMiss(parsed.pluralName);
|
|
93
|
+
}
|
|
69
94
|
return realFetch(input, init); // not a mapped content read → real CMS
|
|
70
95
|
}
|
|
71
96
|
const entries = await fetchEntries(typeName, {
|
|
@@ -73,11 +98,29 @@ export function createFimoStrapiFetch(options, realFetch = fetch) {
|
|
|
73
98
|
...(parsed.where ? { where: parsed.where } : {}),
|
|
74
99
|
...(parsed.pageSize ? { limit: parsed.pageSize } : {}),
|
|
75
100
|
});
|
|
101
|
+
const shapes = options.fieldShapes?.[typeName];
|
|
102
|
+
const single = Boolean(parsed.documentId) || singletons.has(typeName);
|
|
76
103
|
if (entries.length === 0) {
|
|
104
|
+
// A FILTERED miss on a type Fimo owns (it has entries) answers with the
|
|
105
|
+
// honest empty envelope — the document just doesn't exist. Falling
|
|
106
|
+
// through here would leak every unknown-slug read (404s included) to the
|
|
107
|
+
// old CMS: dead-CMS timeouts, and content resurrection when it's alive.
|
|
108
|
+
if (parsed.where || parsed.documentId) {
|
|
109
|
+
const any = await fetchEntries(typeName, { ...read, limit: 1 });
|
|
110
|
+
if (any.length > 0) {
|
|
111
|
+
// Canonical Strapi shapes: collection miss → 200 `{data: []}`,
|
|
112
|
+
// document miss → 404 with the Strapi error body.
|
|
113
|
+
const empty = single
|
|
114
|
+
? { data: null, error: { status: 404, name: 'NotFoundError', message: 'Not Found', details: {} } }
|
|
115
|
+
: fimoEntriesToStrapiList([], { pageSize: parsed.pageSize, total: 0, shapes });
|
|
116
|
+
return new Response(JSON.stringify(empty), {
|
|
117
|
+
status: single ? 404 : 200,
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
77
122
|
return realFetch(input, init); // nothing scaffolded → fall back to the CMS
|
|
78
123
|
}
|
|
79
|
-
const shapes = options.fieldShapes?.[typeName];
|
|
80
|
-
const single = Boolean(parsed.documentId) || singletons.has(typeName);
|
|
81
124
|
const payload = single
|
|
82
125
|
? fimoEntryToStrapiSingle(parsed.documentId ? (entries.find((e) => e.id === parsed.documentId) ?? entries[0]) : entries[0], shapes)
|
|
83
126
|
: fimoEntriesToStrapiList(entries, { pageSize: parsed.pageSize, total: entries.length, shapes });
|
|
@@ -100,7 +100,14 @@ function fillBlocks(def, ctx) {
|
|
|
100
100
|
const childCtx = { ...ctx, depth: ctx.depth + 1 };
|
|
101
101
|
const schema = def.schema;
|
|
102
102
|
if ('oneOf' in schema) {
|
|
103
|
-
|
|
103
|
+
// Rotate the variant window by row index so example entries render
|
|
104
|
+
// DIFFERENT block sets (entry 1 → variants 0-4, entry 2 → 5-9, …) —
|
|
105
|
+
// distinct-looking pages, and collectively more of the page builder
|
|
106
|
+
// exercised than the first five variants over and over.
|
|
107
|
+
const total = schema.oneOf.length;
|
|
108
|
+
const take = Math.min(EXAMPLE_BLOCK_VARIANTS, total);
|
|
109
|
+
const offset = total > 0 ? ((ctx.index - 1) * take) % total : 0;
|
|
110
|
+
return Array.from({ length: take }, (_, k) => schema.oneOf[(offset + k) % total]).map((variant) => ({
|
|
104
111
|
_type: variant.uid,
|
|
105
112
|
...fillItem(variant.fields, childCtx),
|
|
106
113
|
}));
|
|
@@ -57,8 +57,15 @@ function mapAttribute(field, attr, ctx) {
|
|
|
57
57
|
case 'text':
|
|
58
58
|
case 'email':
|
|
59
59
|
case 'uid':
|
|
60
|
-
case 'enumeration':
|
|
61
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
|
+
}
|
|
62
69
|
case 'richtext':
|
|
63
70
|
case 'blocks':
|
|
64
71
|
return { type: 'richtext' };
|
|
@@ -184,14 +191,17 @@ function mapSchema(raw, uidToName, components) {
|
|
|
184
191
|
fields[field] = mapped;
|
|
185
192
|
}
|
|
186
193
|
}
|
|
194
|
+
const isSingleton = raw.schema.kind === 'singleType';
|
|
187
195
|
return {
|
|
188
196
|
name,
|
|
189
197
|
label: info.displayName || name,
|
|
190
|
-
isSingleton
|
|
198
|
+
isSingleton,
|
|
191
199
|
localized: raw.schema.pluginOptions?.i18n?.localized === true,
|
|
192
200
|
fields,
|
|
193
201
|
sourceUid: raw.uid,
|
|
194
|
-
|
|
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)}`,
|
|
195
205
|
};
|
|
196
206
|
}
|
|
197
207
|
/** Walk `<strapiAppDir>/src/api/<api>/content-types/<ct>/schema.json`. */
|
|
@@ -17,11 +17,24 @@ import type { WrapScanResult } from '../../scan-wrappable.js';
|
|
|
17
17
|
* Uids are the PROVISIONAL draft names; `schemas adopt-drafts` rewrites the
|
|
18
18
|
* call sites through its renames.
|
|
19
19
|
*/
|
|
20
|
+
export interface RewiredPage {
|
|
21
|
+
file: string;
|
|
22
|
+
/** Collection uid the page reads through `wrapCollection` (provisional until
|
|
23
|
+
* `adopt-drafts` renames it). */
|
|
24
|
+
uid: string;
|
|
25
|
+
/** The page's registry route path (`:param` normalized), null when the file
|
|
26
|
+
* sits in a non-routable location. Joins the page back to the page registry
|
|
27
|
+
* as binding evidence. */
|
|
28
|
+
routePath: string | null;
|
|
29
|
+
}
|
|
20
30
|
export interface CollectionRewireResult {
|
|
21
31
|
changes: string[];
|
|
22
32
|
warnings: string[];
|
|
23
33
|
/** Pages successfully rewired. */
|
|
24
34
|
rewired: number;
|
|
35
|
+
/** Every page known to read a collection through `wrapCollection` — rewired
|
|
36
|
+
* this run or found already wired on a re-run. */
|
|
37
|
+
pages: RewiredPage[];
|
|
25
38
|
}
|
|
26
39
|
export declare function rewireNextCollectionPages(cwd: string, detection: Pick<FrameworkDetection, 'appDir'>, scan: WrapScanResult, drafts: Array<{
|
|
27
40
|
uid: string;
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseSync } from 'oxc-parser';
|
|
4
4
|
import { ensureFimoImport } from '../../apply-wrap.js';
|
|
5
|
+
import { routePathForAppPage } from './routes.js';
|
|
5
6
|
function applyEdits(code, edits) {
|
|
6
7
|
let next = code;
|
|
7
8
|
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
|
|
@@ -85,9 +86,10 @@ function pageFilesUnder(dir) {
|
|
|
85
86
|
export function rewireNextCollectionPages(cwd, detection, scan, drafts) {
|
|
86
87
|
const changes = [];
|
|
87
88
|
const warnings = [];
|
|
89
|
+
const pages = [];
|
|
88
90
|
let rewired = 0;
|
|
89
91
|
if (!detection.appDir) {
|
|
90
|
-
return { changes, warnings: ['Collection pages not rewired: pages-router project.'], rewired };
|
|
92
|
+
return { changes, warnings: ['Collection pages not rewired: pages-router project.'], rewired, pages };
|
|
91
93
|
}
|
|
92
94
|
const appDirAbs = path.join(cwd, detection.appDir);
|
|
93
95
|
const uidBySourceName = new Map(drafts.map((draft) => [draft.sourceName, draft.uid]));
|
|
@@ -100,6 +102,24 @@ export function rewireNextCollectionPages(cwd, detection, scan, drafts) {
|
|
|
100
102
|
const bail = (page, reason) => {
|
|
101
103
|
warnings.push(`Page not rewired (${page}): ${reason} — wire it with wrapCollection per the prompt recipe.`);
|
|
102
104
|
};
|
|
105
|
+
const recordPage = (fileAbs, uid) => {
|
|
106
|
+
pages.push({ file: path.relative(cwd, fileAbs), uid, routePath: routePathForAppPage(appDirAbs, fileAbs) });
|
|
107
|
+
};
|
|
108
|
+
// Pages wired on a PREVIOUS run still carry binding evidence (the uid in the
|
|
109
|
+
// call site is final once `adopt-drafts` renamed it) — collect them before
|
|
110
|
+
// rewiring so re-runs keep feeding the page registry.
|
|
111
|
+
for (const pageAbs of pageFilesUnder(appDirAbs)) {
|
|
112
|
+
let code;
|
|
113
|
+
try {
|
|
114
|
+
code = fs.readFileSync(pageAbs, 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const match of code.matchAll(/wrapCollection\(\s*['"]([^'"]+)['"]/g)) {
|
|
120
|
+
recordPage(pageAbs, match[1]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
103
123
|
// ── In-code array literals: extract to src/data/<name>.json + wire ──
|
|
104
124
|
for (const candidate of scan.collections) {
|
|
105
125
|
const pageAbs = path.join(cwd, candidate.file);
|
|
@@ -173,6 +193,7 @@ export function rewireNextCollectionPages(cwd, detection, scan, drafts) {
|
|
|
173
193
|
next = ensureFimoImport(next, ['wrapCollection'], 'fimo/next/server');
|
|
174
194
|
fs.writeFileSync(pageAbs, next);
|
|
175
195
|
rewired += 1;
|
|
196
|
+
recordPage(pageAbs, uid);
|
|
176
197
|
changes.push(`rewired ${candidate.file}: \`${candidate.name}\` extracted to src/data/${candidate.name}.json and read through wrapCollection('${uid}')`);
|
|
177
198
|
}
|
|
178
199
|
// ── Data-file default imports: wire the importing pages ──
|
|
@@ -251,8 +272,11 @@ export function rewireNextCollectionPages(cwd, detection, scan, drafts) {
|
|
|
251
272
|
next = ensureFimoImport(next, ['wrapCollection'], 'fimo/next/server');
|
|
252
273
|
fs.writeFileSync(pageAbs, next);
|
|
253
274
|
rewired += 1;
|
|
275
|
+
for (const target of targets) {
|
|
276
|
+
recordPage(pageAbs, target.uid);
|
|
277
|
+
}
|
|
254
278
|
changes.push(`rewired ${rel}: ${targets.map((target) => `wrapCollection('${target.uid}')`).join(', ')} over the imported source rows`);
|
|
255
279
|
}
|
|
256
280
|
}
|
|
257
|
-
return { changes, warnings, rewired };
|
|
281
|
+
return { changes, warnings, rewired, pages };
|
|
258
282
|
}
|
|
@@ -3,6 +3,12 @@ export interface DiscoveredRoute {
|
|
|
3
3
|
path: string;
|
|
4
4
|
label: string;
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Route path for ONE app-router page file, using the same segment
|
|
8
|
+
* normalization as discovery — so a rewired page can be joined back to its
|
|
9
|
+
* registry route. Returns null for non-routable locations (`@slot`, `_lib`).
|
|
10
|
+
*/
|
|
11
|
+
export declare function routePathForAppPage(appDir: string, pageFile: string): string | null;
|
|
6
12
|
/**
|
|
7
13
|
* Discover page routes of a Next.js app-router directory (`app/` or
|
|
8
14
|
* `src/app/`). Returns display routes for `fimo-config.json#routes` so the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, relative, sep } from 'node:path';
|
|
3
3
|
const PAGE_FILES = new Set(['page.tsx', 'page.jsx', 'page.ts', 'page.js', 'page.mdx']);
|
|
4
4
|
const titleCase = (seg) => seg
|
|
5
5
|
.replace(/[_\-]+/g, ' ')
|
|
@@ -27,6 +27,29 @@ function segmentToPath(segment) {
|
|
|
27
27
|
}
|
|
28
28
|
return segment;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Route path for ONE app-router page file, using the same segment
|
|
32
|
+
* normalization as discovery — so a rewired page can be joined back to its
|
|
33
|
+
* registry route. Returns null for non-routable locations (`@slot`, `_lib`).
|
|
34
|
+
*/
|
|
35
|
+
export function routePathForAppPage(appDir, pageFile) {
|
|
36
|
+
const rel = relative(appDir, pageFile);
|
|
37
|
+
if (rel.startsWith('..')) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const segments = rel.split(sep).slice(0, -1);
|
|
41
|
+
const parts = [];
|
|
42
|
+
for (const segment of segments) {
|
|
43
|
+
const mapped = segmentToPath(segment);
|
|
44
|
+
if (mapped === null) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (mapped.length > 0) {
|
|
48
|
+
parts.push(mapped);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return `/${parts.join('/')}`.replace(/\/+/g, '/');
|
|
52
|
+
}
|
|
30
53
|
/**
|
|
31
54
|
* Discover page routes of a Next.js app-router directory (`app/` or
|
|
32
55
|
* `src/app/`). Returns display routes for `fimo-config.json#routes` so the
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject translation values from translations/en.json back into code.
|
|
3
|
+
*
|
|
4
|
+
* Reads `translations/en.json` (treated as authoritative input — typically
|
|
5
|
+
* written by the backend after a dashboard editor edits an English string)
|
|
6
|
+
* and rewrites the second argument of every matching `t('key', 'default')`
|
|
7
|
+
* call in `src/` to match. Idempotent.
|
|
8
|
+
*
|
|
9
|
+
* Exposed as `fimo scripts inject-translations` (hidden namespace). The
|
|
10
|
+
* backend's bidirectional-sync flow will eventually invoke this command in
|
|
11
|
+
* the sandbox instead of the legacy `node .fimo/i18n/scripts/...` path.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { parseSync } from 'oxc-parser';
|
|
16
|
+
function findReactFiles(dir, files = []) {
|
|
17
|
+
if (!existsSync(dir)) {
|
|
18
|
+
return files;
|
|
19
|
+
}
|
|
20
|
+
const entries = readdirSync(dir);
|
|
21
|
+
for (const entry of entries) {
|
|
22
|
+
const fullPath = join(dir, entry);
|
|
23
|
+
const stat = statSync(fullPath);
|
|
24
|
+
if (stat.isDirectory()) {
|
|
25
|
+
if (entry === 'ui' || entry === 'node_modules') {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
findReactFiles(fullPath, files);
|
|
29
|
+
}
|
|
30
|
+
else if (/\.(ts|tsx|js|jsx)$/.test(entry)) {
|
|
31
|
+
files.push(fullPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return files;
|
|
35
|
+
}
|
|
36
|
+
function findTCalls(filePath, code) {
|
|
37
|
+
const locations = [];
|
|
38
|
+
try {
|
|
39
|
+
const result = parseSync(filePath, code);
|
|
40
|
+
const ast = result.program;
|
|
41
|
+
function visit(node) {
|
|
42
|
+
if (!node || typeof node !== 'object') {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (node.type === 'CallExpression') {
|
|
46
|
+
const callee = node.callee;
|
|
47
|
+
if (callee?.type === 'Identifier' && callee?.name === 't') {
|
|
48
|
+
const args = node.arguments || [];
|
|
49
|
+
const firstArg = args[0];
|
|
50
|
+
if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
51
|
+
const key = firstArg.value;
|
|
52
|
+
const secondArg = args[1];
|
|
53
|
+
if (secondArg?.type === 'Literal' && typeof secondArg.value === 'string') {
|
|
54
|
+
locations.push({
|
|
55
|
+
file: filePath,
|
|
56
|
+
key,
|
|
57
|
+
currentDefault: secondArg.value,
|
|
58
|
+
secondArgStart: secondArg.start,
|
|
59
|
+
secondArgEnd: secondArg.end,
|
|
60
|
+
hasSecondArg: true,
|
|
61
|
+
firstArgEnd: firstArg.end,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else if (!secondArg) {
|
|
65
|
+
locations.push({
|
|
66
|
+
file: filePath,
|
|
67
|
+
key,
|
|
68
|
+
currentDefault: '',
|
|
69
|
+
secondArgStart: 0,
|
|
70
|
+
secondArgEnd: 0,
|
|
71
|
+
hasSecondArg: false,
|
|
72
|
+
firstArgEnd: firstArg.end,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
for (const k of Object.keys(node)) {
|
|
79
|
+
const child = node[k];
|
|
80
|
+
if (Array.isArray(child)) {
|
|
81
|
+
for (const item of child) {
|
|
82
|
+
visit(item);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (typeof child === 'object' && child !== null) {
|
|
86
|
+
visit(child);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
visit(ast);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
console.error(`Failed to parse ${filePath}:`, error);
|
|
94
|
+
}
|
|
95
|
+
return locations;
|
|
96
|
+
}
|
|
97
|
+
function escapeString(str) {
|
|
98
|
+
return str
|
|
99
|
+
.replace(/\\/g, '\\\\')
|
|
100
|
+
.replace(/'/g, "\\'")
|
|
101
|
+
.replace(/\n/g, '\\n')
|
|
102
|
+
.replace(/\r/g, '\\r')
|
|
103
|
+
.replace(/\t/g, '\\t');
|
|
104
|
+
}
|
|
105
|
+
export function injectTranslations(options = {}) {
|
|
106
|
+
const cwd = options.cwd ?? process.cwd();
|
|
107
|
+
const translationsFile = join(cwd, 'translations', 'en.json');
|
|
108
|
+
if (!existsSync(translationsFile)) {
|
|
109
|
+
return { updated: 0 };
|
|
110
|
+
}
|
|
111
|
+
let translations;
|
|
112
|
+
try {
|
|
113
|
+
translations = JSON.parse(readFileSync(translationsFile, 'utf-8'));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error(`Failed to parse ${translationsFile}`);
|
|
117
|
+
}
|
|
118
|
+
const srcDir = join(cwd, 'src');
|
|
119
|
+
const files = findReactFiles(srcDir);
|
|
120
|
+
const fileContents = new Map();
|
|
121
|
+
const fileLocations = new Map();
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const code = readFileSync(file, 'utf-8');
|
|
124
|
+
fileContents.set(file, code);
|
|
125
|
+
const locations = findTCalls(file, code);
|
|
126
|
+
if (locations.length > 0) {
|
|
127
|
+
fileLocations.set(file, locations);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let totalUpdates = 0;
|
|
131
|
+
for (const [file, locations] of fileLocations) {
|
|
132
|
+
let code = fileContents.get(file);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
let fileModified = false;
|
|
135
|
+
const sortedLocations = [...locations].sort((a, b) => a.secondArgStart - b.secondArgStart);
|
|
136
|
+
for (const loc of sortedLocations) {
|
|
137
|
+
const expectedValue = translations[loc.key];
|
|
138
|
+
if (expectedValue === undefined) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (loc.hasSecondArg) {
|
|
142
|
+
if (loc.currentDefault !== expectedValue) {
|
|
143
|
+
const newLiteral = `'${escapeString(expectedValue)}'`;
|
|
144
|
+
const before = code.slice(0, loc.secondArgStart + offset);
|
|
145
|
+
const after = code.slice(loc.secondArgEnd + offset);
|
|
146
|
+
code = before + newLiteral + after;
|
|
147
|
+
offset += newLiteral.length - (loc.secondArgEnd - loc.secondArgStart);
|
|
148
|
+
fileModified = true;
|
|
149
|
+
totalUpdates++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
if (expectedValue !== '') {
|
|
154
|
+
const newArg = `, '${escapeString(expectedValue)}'`;
|
|
155
|
+
const insertPos = loc.firstArgEnd + offset;
|
|
156
|
+
code = code.slice(0, insertPos) + newArg + code.slice(insertPos);
|
|
157
|
+
offset += newArg.length;
|
|
158
|
+
fileModified = true;
|
|
159
|
+
totalUpdates++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (fileModified) {
|
|
164
|
+
writeFileSync(file, code, 'utf-8');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { updated: totalUpdates };
|
|
168
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DiscoveredRoute {
|
|
2
|
+
id: string;
|
|
3
|
+
path: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Discover page routes of a Next.js app-router directory (`app/` or
|
|
8
|
+
* `src/app/`). Returns display routes for `fimo-config.json#routes` so the
|
|
9
|
+
* Studio page switcher can enumerate a wrapped site's pages.
|
|
10
|
+
*/
|
|
11
|
+
export declare function discoverNextRoutes(appDir: string): DiscoveredRoute[];
|
|
12
|
+
/**
|
|
13
|
+
* Pages-router discovery (`pages/` or `src/pages/`): every module is a route
|
|
14
|
+
* except `_app`/`_document`/`_error` and `api/`. `[param]` segments map to
|
|
15
|
+
* `:param` (the parity capture skips dynamic routes, same as app router).
|
|
16
|
+
*/
|
|
17
|
+
export declare function discoverNextPagesRoutes(pagesDir: string): DiscoveredRoute[];
|