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.
Files changed (31) 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/skills/fimo/references/translations.md +75 -0
  4. package/assets/skills/fimo-cli/references/create.md +90 -0
  5. package/assets/skills/fimo-cli/references/translations.md +43 -0
  6. package/assets/skills/fimo-migration/SKILL.md +2 -0
  7. package/assets/skills/fimo-migration/references/manifest.md +11 -0
  8. package/assets/skills/fimo-migration/references/pages.md +95 -0
  9. package/assets/skills/fimo-migration/references/wrap.md +29 -0
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +171 -112
  12. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  13. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  14. package/dist/runtime/seo/neutral.d.ts +22 -0
  15. package/dist/runtime/seo/neutral.js +17 -0
  16. package/dist/runtime/strapi/client.js +45 -2
  17. package/dist/scripts/cms/example-content.js +8 -1
  18. package/dist/scripts/cms/strapi/schema-map.js +13 -3
  19. package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
  20. package/dist/scripts/frameworks/next/rewire.js +26 -2
  21. package/dist/scripts/frameworks/next/routes.d.ts +6 -0
  22. package/dist/scripts/frameworks/next/routes.js +24 -1
  23. package/dist/scripts/inject-translations.d.ts +6 -0
  24. package/dist/scripts/inject-translations.js +168 -0
  25. package/dist/scripts/lib/discover-next-routes.d.ts +17 -0
  26. package/dist/scripts/lib/discover-next-routes.js +134 -0
  27. package/dist/scripts/routes/bindings.d.ts +93 -0
  28. package/dist/scripts/routes/bindings.js +117 -0
  29. package/package.json +5 -1
  30. package/templates/react-router/package.json +1 -1
  31. package/templates/react-router/translations/en.json +1 -0
@@ -0,0 +1,2 @@
1
+ export declare function BuiltWithBadge(): import("react").ReactPortal | null;
2
+ //# sourceMappingURL=BuiltWithBadge.d.ts.map
@@ -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
- return schema.oneOf.slice(0, EXAMPLE_BLOCK_VARIANTS).map((variant) => ({
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: raw.schema.kind === 'singleType',
198
+ isSingleton,
191
199
  localized: raw.schema.pluginOptions?.i18n?.localized === true,
192
200
  fields,
193
201
  sourceUid: raw.uid,
194
- 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)}`,
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,6 @@
1
+ export interface InjectTranslationsResult {
2
+ updated: number;
3
+ }
4
+ export declare function injectTranslations(options?: {
5
+ cwd?: string;
6
+ }): InjectTranslationsResult;
@@ -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[];