fimo 0.7.0-experimental.6 → 0.7.0-experimental.8
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/skills/fimo-migration/references/wrap.md +35 -0
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +2796 -2352
- package/dist/scripts/cms/example-content.d.ts +2 -2
- package/dist/scripts/cms/strapi/index.js +2 -0
- package/dist/scripts/cms/strapi/touchpoints.d.ts +31 -0
- package/dist/scripts/cms/strapi/touchpoints.js +145 -0
- package/dist/scripts/cms/strapi/touchpoints.test.d.ts +1 -0
- package/dist/scripts/cms/strapi/touchpoints.test.js +62 -0
- package/dist/scripts/cms/types.d.ts +24 -1
- package/dist/scripts/extract-translations.js +59 -12
- package/dist/scripts/extract-translations.test.d.ts +1 -0
- package/dist/scripts/extract-translations.test.js +66 -0
- package/dist/scripts/frameworks/discover.d.ts +1 -1
- package/dist/scripts/frameworks/discover.js +12 -5
- package/dist/scripts/frameworks/next/wiring.d.ts +7 -0
- package/dist/scripts/frameworks/next/wiring.js +29 -6
- package/dist/scripts/frameworks/next/wiring.test.js +28 -0
- package/dist/scripts/frameworks/support.d.ts +75 -0
- package/dist/scripts/frameworks/support.js +165 -0
- package/dist/scripts/frameworks/support.test.d.ts +1 -0
- package/dist/scripts/frameworks/support.test.js +87 -0
- package/package.json +3 -3
- package/templates/react-router/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { CmsCollectionSchema } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Generate example content for a CMS schema WITHOUT connecting to the CMS. The
|
|
4
|
-
* shapes match `api.
|
|
4
|
+
* shapes match `api.importEntries`: media fields are omitted (a fake URL
|
|
5
5
|
* would render a broken image), references point at the first example of the
|
|
6
6
|
* target type (`<target>-example-1`) so they resolve against the co-seeded
|
|
7
7
|
* data, and localized types get per-locale string variance so the per-language
|
|
@@ -15,7 +15,7 @@ export interface ExampleSeed {
|
|
|
15
15
|
readonly localized: boolean;
|
|
16
16
|
/** Entry data rows (slug lives inside data for collections). */
|
|
17
17
|
readonly rows: Array<Record<string, unknown>>;
|
|
18
|
-
/** Reference descriptor for `
|
|
18
|
+
/** Reference descriptor for `importEntries`. */
|
|
19
19
|
readonly references: Record<string, {
|
|
20
20
|
target?: string;
|
|
21
21
|
targets?: string[];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { detectStrapi } from './detect.js';
|
|
2
2
|
import { renderStrapiListSource } from './list-source.js';
|
|
3
3
|
import { readStrapiSchemas } from './schema-map.js';
|
|
4
|
+
import { detectStrapiTouchpoints } from './touchpoints.js';
|
|
4
5
|
import { wireStrapiClient } from './wire-client.js';
|
|
5
6
|
/** Assemble the Strapi connector for a wrapped frontend, or null if no Strapi. */
|
|
6
7
|
export function strapiConnector(frontendDir) {
|
|
@@ -19,5 +20,6 @@ export function strapiConnector(frontendDir) {
|
|
|
19
20
|
warnings: ['No Strapi client directory detected — wire fimoStrapiFetch manually.'],
|
|
20
21
|
wired: false,
|
|
21
22
|
},
|
|
23
|
+
detectTouchpoints: (cwd) => detectStrapiTouchpoints(cwd),
|
|
22
24
|
};
|
|
23
25
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type StrapiCallReason = 'strapi-sdk-import' | 'env-ref' | 'uid-ref';
|
|
2
|
+
export interface StrapiCallSite {
|
|
3
|
+
/** Path relative to the frontend directory. */
|
|
4
|
+
file: string;
|
|
5
|
+
line: number;
|
|
6
|
+
reason: StrapiCallReason;
|
|
7
|
+
snippet: string;
|
|
8
|
+
}
|
|
9
|
+
export interface StrapiTouchpoints {
|
|
10
|
+
/** Strapi SDK / client / generated-type packages still declared in package.json. */
|
|
11
|
+
dependencies: string[];
|
|
12
|
+
/** Source lines that still reach Strapi at runtime. */
|
|
13
|
+
callSites: StrapiCallSite[];
|
|
14
|
+
/** `STRAPI_*` keys still present in dotenv files (cleanup; low severity). */
|
|
15
|
+
envVars: string[];
|
|
16
|
+
/** No runtime Strapi coupling remains (no SDK deps, no call sites): the cutover is complete. */
|
|
17
|
+
clean: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Enumerate every remaining Strapi coupling in a wrapped frontend. This is the
|
|
21
|
+
* shared primitive behind both the unwire worklist (the agent's to-do) and the
|
|
22
|
+
* cutover gate (`clean === true`).
|
|
23
|
+
*
|
|
24
|
+
* `clean` is true when no runtime coupling remains — no Strapi SDK/client
|
|
25
|
+
* dependency and no source line that reaches Strapi — i.e. the site can no
|
|
26
|
+
* longer call Strapi. Detection keys on actual Strapi *references* (base-URL
|
|
27
|
+
* env, `api::` UID, SDK import), NOT filenames: a `lib/strapi.ts` the agent has
|
|
28
|
+
* rewritten to call Fimo has no Strapi references and therefore reads as clean.
|
|
29
|
+
* The `fimo/strapi` compat adapter is never counted.
|
|
30
|
+
*/
|
|
31
|
+
export declare function detectStrapiTouchpoints(frontendDir: string): StrapiTouchpoints;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
const SOURCE_EXT_RE = /\.(tsx?|jsx?|mjs|cjs|astro|vue|svelte)$/;
|
|
4
|
+
const SKIP_DIRS = new Set([
|
|
5
|
+
'node_modules',
|
|
6
|
+
'.next',
|
|
7
|
+
'.nuxt',
|
|
8
|
+
'.svelte-kit',
|
|
9
|
+
'.turbo',
|
|
10
|
+
'dist',
|
|
11
|
+
'build',
|
|
12
|
+
'out',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.vercel',
|
|
15
|
+
'.git',
|
|
16
|
+
]);
|
|
17
|
+
const MAX_FILES = 2000;
|
|
18
|
+
/** A runtime reference to Strapi's base-URL / token env config. */
|
|
19
|
+
const ENV_REF_RE = /\bSTRAPI[A-Z0-9_]*_URL\b|\bNEXT_PUBLIC_STRAPI[A-Z0-9_]*\b|\bSTRAPI[A-Z0-9_]*_TOKEN\b/;
|
|
20
|
+
/** A Strapi content-type UID string (`api::article.article`). */
|
|
21
|
+
const UID_REF_RE = /api::[a-z0-9-]+\.[a-z0-9-]+/;
|
|
22
|
+
/** An import/require specifier — captured so a Strapi SDK can be told apart from `fimo/strapi`. */
|
|
23
|
+
const IMPORT_RE = /(?:from\s+|require\(\s*|import\(\s*)['"`]([^'"`]+)['"`]/;
|
|
24
|
+
/** A STRAPI env assignment in a dotenv file. */
|
|
25
|
+
const ENV_KEY_RE = /^\s*([A-Z0-9_]*STRAPI[A-Z0-9_]*)\s*=/;
|
|
26
|
+
function readDeps(dir) {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
|
|
29
|
+
return { ...pkg.dependencies, ...pkg.devDependencies };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function classifyLine(line) {
|
|
36
|
+
const importMatch = IMPORT_RE.exec(line);
|
|
37
|
+
// A Strapi SDK/client import — but NOT the `fimo/strapi` compat adapter.
|
|
38
|
+
if (importMatch && /strapi/i.test(importMatch[1]) && !/fimo/i.test(importMatch[1])) {
|
|
39
|
+
return 'strapi-sdk-import';
|
|
40
|
+
}
|
|
41
|
+
if (ENV_REF_RE.test(line)) {
|
|
42
|
+
return 'env-ref';
|
|
43
|
+
}
|
|
44
|
+
if (UID_REF_RE.test(line)) {
|
|
45
|
+
return 'uid-ref';
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function scanCallSites(frontendDir) {
|
|
50
|
+
const sites = [];
|
|
51
|
+
let scanned = 0;
|
|
52
|
+
const walk = (dir) => {
|
|
53
|
+
if (scanned >= MAX_FILES) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let entries;
|
|
57
|
+
try {
|
|
58
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
if (scanned >= MAX_FILES) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
|
|
69
|
+
walk(join(dir, entry.name));
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!SOURCE_EXT_RE.test(entry.name)) {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
scanned += 1;
|
|
77
|
+
const full = join(dir, entry.name);
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
text = readFileSync(full, 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const lines = text.split('\n');
|
|
86
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
87
|
+
const reason = classifyLine(lines[i]);
|
|
88
|
+
if (reason) {
|
|
89
|
+
sites.push({
|
|
90
|
+
file: relative(frontendDir, full),
|
|
91
|
+
line: i + 1,
|
|
92
|
+
reason,
|
|
93
|
+
snippet: lines[i].trim().slice(0, 200),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(frontendDir);
|
|
100
|
+
return sites;
|
|
101
|
+
}
|
|
102
|
+
function scanEnvKeys(frontendDir) {
|
|
103
|
+
const keys = new Set();
|
|
104
|
+
for (const name of ['.env', '.env.local', '.env.development', '.env.production']) {
|
|
105
|
+
const file = join(frontendDir, name);
|
|
106
|
+
if (!existsSync(file)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
111
|
+
const match = ENV_KEY_RE.exec(line);
|
|
112
|
+
if (match) {
|
|
113
|
+
keys.add(match[1]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// unreadable — ignore
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return [...keys];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Enumerate every remaining Strapi coupling in a wrapped frontend. This is the
|
|
125
|
+
* shared primitive behind both the unwire worklist (the agent's to-do) and the
|
|
126
|
+
* cutover gate (`clean === true`).
|
|
127
|
+
*
|
|
128
|
+
* `clean` is true when no runtime coupling remains — no Strapi SDK/client
|
|
129
|
+
* dependency and no source line that reaches Strapi — i.e. the site can no
|
|
130
|
+
* longer call Strapi. Detection keys on actual Strapi *references* (base-URL
|
|
131
|
+
* env, `api::` UID, SDK import), NOT filenames: a `lib/strapi.ts` the agent has
|
|
132
|
+
* rewritten to call Fimo has no Strapi references and therefore reads as clean.
|
|
133
|
+
* The `fimo/strapi` compat adapter is never counted.
|
|
134
|
+
*/
|
|
135
|
+
export function detectStrapiTouchpoints(frontendDir) {
|
|
136
|
+
const dependencies = Object.keys(readDeps(frontendDir)).filter((name) => /strapi/i.test(name));
|
|
137
|
+
const callSites = scanCallSites(frontendDir);
|
|
138
|
+
const envVars = scanEnvKeys(frontendDir);
|
|
139
|
+
return {
|
|
140
|
+
dependencies,
|
|
141
|
+
callSites,
|
|
142
|
+
envVars,
|
|
143
|
+
clean: dependencies.length === 0 && callSites.length === 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { detectStrapiTouchpoints } from './touchpoints.js';
|
|
6
|
+
let dir;
|
|
7
|
+
function frontend(files) {
|
|
8
|
+
dir = mkdtempSync(join(tmpdir(), 'strapi-touchpoints-'));
|
|
9
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
10
|
+
const full = join(dir, rel);
|
|
11
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
12
|
+
writeFileSync(full, content);
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (dir) {
|
|
18
|
+
rmSync(dir, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
describe('detectStrapiTouchpoints', () => {
|
|
22
|
+
it('enumerates deps, call sites, and env for a still-wired frontend', () => {
|
|
23
|
+
const cwd = frontend({
|
|
24
|
+
'package.json': JSON.stringify({
|
|
25
|
+
dependencies: { '@strapi/client': '^1.0.0', next: '16.0.0' },
|
|
26
|
+
devDependencies: { '@repo/strapi-types': 'workspace:*' },
|
|
27
|
+
}),
|
|
28
|
+
'src/lib/strapi.ts': "import { strapi } from '@strapi/client';\nexport const client = strapi({ baseURL: process.env.STRAPI_URL });\n",
|
|
29
|
+
'src/app/page.tsx': "const data = await client.collection('api::article.article').find();\n",
|
|
30
|
+
'.env': 'STRAPI_URL=https://cms.example.com\nNEXT_PUBLIC_STRAPI_URL=https://cms.example.com\nOTHER=1\n',
|
|
31
|
+
});
|
|
32
|
+
const result = detectStrapiTouchpoints(cwd);
|
|
33
|
+
expect(result.dependencies.sort()).toEqual(['@repo/strapi-types', '@strapi/client']);
|
|
34
|
+
expect(result.envVars.sort()).toEqual(['NEXT_PUBLIC_STRAPI_URL', 'STRAPI_URL']);
|
|
35
|
+
expect(result.callSites.map((site) => site.reason).sort()).toEqual(['env-ref', 'strapi-sdk-import', 'uid-ref']);
|
|
36
|
+
expect(result.callSites.find((site) => site.reason === 'strapi-sdk-import')?.file).toBe('src/lib/strapi.ts');
|
|
37
|
+
expect(result.clean).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('reads a rewired frontend as clean — a lib/strapi.ts calling Fimo is not a touchpoint', () => {
|
|
40
|
+
const cwd = frontend({
|
|
41
|
+
'package.json': JSON.stringify({ dependencies: { fimo: '0.7.0', next: '16.0.0' } }),
|
|
42
|
+
// Same filename, but now Fimo-backed: no Strapi references.
|
|
43
|
+
'src/lib/strapi.ts': "import { fimoStrapiFetch } from 'fimo/strapi';\nexport const client = fimoStrapiFetch;\n",
|
|
44
|
+
'src/app/page.tsx': "const data = await client('/articles');\n",
|
|
45
|
+
});
|
|
46
|
+
const result = detectStrapiTouchpoints(cwd);
|
|
47
|
+
expect(result.dependencies).toEqual([]);
|
|
48
|
+
expect(result.callSites).toEqual([]);
|
|
49
|
+
expect(result.clean).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('flags a lingering STRAPI env reference in config even when deps are gone', () => {
|
|
52
|
+
const cwd = frontend({
|
|
53
|
+
'package.json': JSON.stringify({ dependencies: { next: '16.0.0' } }),
|
|
54
|
+
'next.config.mjs': 'export default { images: { domains: [process.env.STRAPI_MEDIA_URL] } };\n',
|
|
55
|
+
});
|
|
56
|
+
const result = detectStrapiTouchpoints(cwd);
|
|
57
|
+
expect(result.dependencies).toEqual([]);
|
|
58
|
+
expect(result.callSites).toHaveLength(1);
|
|
59
|
+
expect(result.callSites[0]).toMatchObject({ file: 'next.config.mjs', reason: 'env-ref' });
|
|
60
|
+
expect(result.clean).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -33,7 +33,7 @@ export interface CmsCollectionSchema {
|
|
|
33
33
|
readonly name: string;
|
|
34
34
|
/** Human display label. */
|
|
35
35
|
readonly label: string;
|
|
36
|
-
/** Strapi singleType → true;
|
|
36
|
+
/** A single-document type (e.g. a Strapi singleType) → true; a collection → false. */
|
|
37
37
|
readonly isSingleton: boolean;
|
|
38
38
|
/** The CMS marks this type as localized → seed per configured locale. */
|
|
39
39
|
readonly localized: boolean;
|
|
@@ -86,9 +86,32 @@ export interface CmsConnector {
|
|
|
86
86
|
* Fimo. Deterministic — no agent step.
|
|
87
87
|
*/
|
|
88
88
|
wireClient?(cwd: string, schemas: CmsCollectionSchema[]): CmsWireResult;
|
|
89
|
+
/**
|
|
90
|
+
* Enumerate the frontend's remaining couplings to this CMS — the unwire
|
|
91
|
+
* worklist and the cutover gate (`clean`). Keys on actual CMS references, not
|
|
92
|
+
* filenames, so a client module rewritten to call Fimo reads as clean.
|
|
93
|
+
* Returns undefined for a connector without touchpoint detection.
|
|
94
|
+
*/
|
|
95
|
+
detectTouchpoints?(cwd: string): CmsTouchpoints;
|
|
89
96
|
}
|
|
90
97
|
export interface CmsWireResult {
|
|
91
98
|
changes: string[];
|
|
92
99
|
warnings: string[];
|
|
93
100
|
wired: boolean;
|
|
94
101
|
}
|
|
102
|
+
export interface CmsTouchpointSite {
|
|
103
|
+
file: string;
|
|
104
|
+
line: number;
|
|
105
|
+
reason: string;
|
|
106
|
+
snippet: string;
|
|
107
|
+
}
|
|
108
|
+
export interface CmsTouchpoints {
|
|
109
|
+
/** SDK/client/type packages still declared in the frontend's package.json. */
|
|
110
|
+
dependencies: string[];
|
|
111
|
+
/** Source lines that still reach the CMS at runtime. */
|
|
112
|
+
callSites: CmsTouchpointSite[];
|
|
113
|
+
/** CMS base-URL / token env keys still present in dotenv files. */
|
|
114
|
+
envVars: string[];
|
|
115
|
+
/** No runtime coupling remains — the cutover is complete. */
|
|
116
|
+
clean: boolean;
|
|
117
|
+
}
|
|
@@ -28,24 +28,71 @@ function findReactFiles(dir, files = []) {
|
|
|
28
28
|
}
|
|
29
29
|
return files;
|
|
30
30
|
}
|
|
31
|
+
const isFunctionLike = (node) => node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
|
|
32
|
+
/**
|
|
33
|
+
* next-intl (and similar) scope a `t` to a namespace:
|
|
34
|
+
* `const t = useTranslations("plans.checkout")` makes every `t("buyNow")` in
|
|
35
|
+
* that component resolve to `plans.checkout.buyNow`. Collect those bindings from
|
|
36
|
+
* a function's direct body so the FULL key is recorded — otherwise the labels
|
|
37
|
+
* gate looks for the bare leaf (`buyNow`) and never finds it in the DB. Also
|
|
38
|
+
* handles `await getTranslations(ns)` (server components) and a root-scoped
|
|
39
|
+
* `useTranslations()` (namespace `""`, keys stay full).
|
|
40
|
+
*/
|
|
41
|
+
function collectScopedBindings(fnNode) {
|
|
42
|
+
const bindings = new Map();
|
|
43
|
+
const body = fnNode.body;
|
|
44
|
+
const statements = body && Array.isArray(body.body) ? body.body : [];
|
|
45
|
+
for (const stmt of statements) {
|
|
46
|
+
if (stmt?.type !== 'VariableDeclaration') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
for (const decl of stmt.declarations ?? []) {
|
|
50
|
+
if (decl?.id?.type !== 'Identifier') {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const init = decl.init?.type === 'AwaitExpression' ? decl.init.argument : decl.init;
|
|
54
|
+
if (init?.type === 'CallExpression' &&
|
|
55
|
+
init.callee?.type === 'Identifier' &&
|
|
56
|
+
(init.callee.name === 'useTranslations' || init.callee.name === 'getTranslations')) {
|
|
57
|
+
const nsArg = init.arguments?.[0];
|
|
58
|
+
const namespace = nsArg?.type === 'Literal' && typeof nsArg.value === 'string' ? nsArg.value : '';
|
|
59
|
+
bindings.set(decl.id.name, namespace);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return bindings;
|
|
64
|
+
}
|
|
31
65
|
function extractFromFile(filePath) {
|
|
32
66
|
const declarations = [];
|
|
33
67
|
try {
|
|
34
68
|
const code = readFileSync(filePath, 'utf-8');
|
|
35
69
|
const result = parseSync(filePath, code);
|
|
36
70
|
const ast = result.program;
|
|
37
|
-
function visit(node) {
|
|
71
|
+
function visit(node, scopes) {
|
|
38
72
|
if (!node || typeof node !== 'object') {
|
|
39
73
|
return;
|
|
40
74
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
// A function introduces a new scope layer with its `useTranslations`
|
|
76
|
+
// bindings; nested calls resolve innermost-first.
|
|
77
|
+
const childScopes = isFunctionLike(node) ? [...scopes, collectScopedBindings(node)] : scopes;
|
|
78
|
+
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier') {
|
|
79
|
+
const name = node.callee.name;
|
|
80
|
+
const firstArg = (node.arguments ?? [])[0];
|
|
81
|
+
if (firstArg?.type === 'Literal' && typeof firstArg.value === 'string') {
|
|
82
|
+
let namespace;
|
|
83
|
+
for (let i = childScopes.length - 1; i >= 0; i -= 1) {
|
|
84
|
+
if (childScopes[i].has(name)) {
|
|
85
|
+
namespace = childScopes[i].get(name);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (namespace !== undefined) {
|
|
90
|
+
// A namespace-scoped var (any name): prepend the namespace.
|
|
91
|
+
declarations.push({ key: namespace ? `${namespace}.${firstArg.value}` : firstArg.value, file: filePath });
|
|
92
|
+
}
|
|
93
|
+
else if (name === 't') {
|
|
94
|
+
// A bare `t(...)` — Fimo's label call, key already full.
|
|
95
|
+
declarations.push({ key: firstArg.value, file: filePath });
|
|
49
96
|
}
|
|
50
97
|
}
|
|
51
98
|
}
|
|
@@ -53,15 +100,15 @@ function extractFromFile(filePath) {
|
|
|
53
100
|
const child = node[key];
|
|
54
101
|
if (Array.isArray(child)) {
|
|
55
102
|
for (const item of child) {
|
|
56
|
-
visit(item);
|
|
103
|
+
visit(item, childScopes);
|
|
57
104
|
}
|
|
58
105
|
}
|
|
59
106
|
else if (typeof child === 'object' && child !== null) {
|
|
60
|
-
visit(child);
|
|
107
|
+
visit(child, childScopes);
|
|
61
108
|
}
|
|
62
109
|
}
|
|
63
110
|
}
|
|
64
|
-
visit(ast);
|
|
111
|
+
visit(ast, []);
|
|
65
112
|
}
|
|
66
113
|
catch (error) {
|
|
67
114
|
console.error(`Failed to parse ${filePath}:`, error);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { extractLabelKeys } from './extract-translations.js';
|
|
6
|
+
let dir;
|
|
7
|
+
function project(files) {
|
|
8
|
+
dir = mkdtempSync(join(tmpdir(), 'extract-tr-'));
|
|
9
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
10
|
+
const full = join(dir, rel);
|
|
11
|
+
mkdirSync(join(full, '..'), { recursive: true });
|
|
12
|
+
writeFileSync(full, content);
|
|
13
|
+
}
|
|
14
|
+
return dir;
|
|
15
|
+
}
|
|
16
|
+
const keysOf = (cwd) => extractLabelKeys({ cwd }).map((declaration) => declaration.key);
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (dir) {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
describe('extractLabelKeys', () => {
|
|
23
|
+
it('extracts a bare Fimo t() key verbatim', () => {
|
|
24
|
+
const cwd = project({ 'src/a.tsx': "export function A() { return <b>{t('nav.home')}</b>; }" });
|
|
25
|
+
expect(keysOf(cwd)).toEqual(['nav.home']);
|
|
26
|
+
});
|
|
27
|
+
it('resolves next-intl useTranslations(namespace) into the full key', () => {
|
|
28
|
+
const cwd = project({
|
|
29
|
+
'src/checkout.tsx': `import { useTranslations } from 'next-intl';
|
|
30
|
+
export function Checkout() {
|
|
31
|
+
const t = useTranslations('plans.pricingCheckoutModal');
|
|
32
|
+
return <button>{t('buyNow')}</button>;
|
|
33
|
+
}`,
|
|
34
|
+
});
|
|
35
|
+
expect(keysOf(cwd)).toEqual(['plans.pricingCheckoutModal.buyNow']);
|
|
36
|
+
});
|
|
37
|
+
it('scopes per component — two useTranslations in one file do not cross-contaminate', () => {
|
|
38
|
+
const cwd = project({
|
|
39
|
+
'src/two.tsx': `import { useTranslations } from 'next-intl';
|
|
40
|
+
export function A() { const t = useTranslations('a'); return <p>{t('x')}</p>; }
|
|
41
|
+
export function B() { const t = useTranslations('b'); return <p>{t('x')}</p>; }`,
|
|
42
|
+
});
|
|
43
|
+
expect(keysOf(cwd)).toEqual(['a.x', 'b.x']);
|
|
44
|
+
});
|
|
45
|
+
it('handles a non-t binding and a root-scoped useTranslations()', () => {
|
|
46
|
+
const cwd = project({
|
|
47
|
+
'src/misc.tsx': `import { useTranslations } from 'next-intl';
|
|
48
|
+
export function Nav() {
|
|
49
|
+
const tNav = useTranslations('nav');
|
|
50
|
+
const tRoot = useTranslations();
|
|
51
|
+
return <>{tNav('home')}{tRoot('global.title')}</>;
|
|
52
|
+
}`,
|
|
53
|
+
});
|
|
54
|
+
expect(keysOf(cwd)).toEqual(['global.title', 'nav.home']);
|
|
55
|
+
});
|
|
56
|
+
it('resolves server getTranslations too', () => {
|
|
57
|
+
const cwd = project({
|
|
58
|
+
'src/page.tsx': `import { getTranslations } from 'next-intl/server';
|
|
59
|
+
export default async function Page() {
|
|
60
|
+
const t = await getTranslations('home');
|
|
61
|
+
return <h1>{t('title')}</h1>;
|
|
62
|
+
}`,
|
|
63
|
+
});
|
|
64
|
+
expect(keysOf(cwd)).toEqual(['home.title']);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -67,4 +67,4 @@ export interface WiringMarker {
|
|
|
67
67
|
* wraps), parity (fallbacks render identically), nor seeding (DB-side)
|
|
68
68
|
* notices — editability would ship silently broken.
|
|
69
69
|
*/
|
|
70
|
-
export declare function frameworkWiringMarker(detection: Pick<FrameworkDetection, 'framework' | 'react' | 'appDir'
|
|
70
|
+
export declare function frameworkWiringMarker(detection: Pick<FrameworkDetection, 'framework' | 'react' | 'appDir'>, cwd?: string): WiringMarker | null;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { join } from 'node:path';
|
|
1
|
+
import { join, relative } from 'node:path';
|
|
2
2
|
import { discoverAstroRoutes } from './astro/routes.js';
|
|
3
3
|
import { astroTemplateWrapStyle } from './astro/template-style.js';
|
|
4
4
|
import { rewireNextCollectionPages } from './next/rewire.js';
|
|
5
5
|
import { discoverNextPagesRoutes, discoverNextRoutes } from './next/routes.js';
|
|
6
|
-
import { ensureNextLayoutWiring } from './next/wiring.js';
|
|
6
|
+
import { ensureNextLayoutWiring, nextRootLayoutFiles } from './next/wiring.js';
|
|
7
7
|
import { discoverNuxtRoutes } from './nuxt/routes.js';
|
|
8
8
|
import { nuxtTemplateWrapStyle } from './nuxt/template-style.js';
|
|
9
9
|
import { discoverRemixRoutes } from './remix/routes.js';
|
|
@@ -128,9 +128,9 @@ export function frameworkCollectionRewire(detection) {
|
|
|
128
128
|
* wraps), parity (fallbacks render identically), nor seeding (DB-side)
|
|
129
129
|
* notices — editability would ship silently broken.
|
|
130
130
|
*/
|
|
131
|
-
export function frameworkWiringMarker(detection) {
|
|
131
|
+
export function frameworkWiringMarker(detection, cwd) {
|
|
132
132
|
switch (detection.framework) {
|
|
133
|
-
case 'next':
|
|
133
|
+
case 'next': {
|
|
134
134
|
if (!detection.appDir) {
|
|
135
135
|
return {
|
|
136
136
|
files: ['pages/_app.tsx', 'pages/_app.jsx', 'src/pages/_app.tsx', 'src/pages/_app.jsx'],
|
|
@@ -139,11 +139,18 @@ export function frameworkWiringMarker(detection) {
|
|
|
139
139
|
warnOnly: true,
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
|
+
// Resolve the real root layout(s), including the next-intl `[locale]`
|
|
143
|
+
// segment; fall back to the direct path when nothing is on disk yet.
|
|
144
|
+
const resolved = cwd ? nextRootLayoutFiles(cwd, detection.appDir).map((file) => relative(cwd, file)) : [];
|
|
145
|
+
const files = resolved.length > 0
|
|
146
|
+
? resolved
|
|
147
|
+
: [`${detection.appDir}/layout.tsx`, `${detection.appDir}/layout.jsx`, `${detection.appDir}/layout.js`];
|
|
142
148
|
return {
|
|
143
|
-
files
|
|
149
|
+
files,
|
|
144
150
|
marker: /FimoNextProviders/,
|
|
145
151
|
hint: `mount FimoNextProviders + FimoNextScripts in ${detection.appDir}/layout (agent prompt section 0)`,
|
|
146
152
|
};
|
|
153
|
+
}
|
|
147
154
|
case 'vite':
|
|
148
155
|
if (!detection.react) {
|
|
149
156
|
return null;
|
|
@@ -14,6 +14,13 @@ export interface RootWiringResult {
|
|
|
14
14
|
/** True when the root wiring is in place (pre-existing or just applied). */
|
|
15
15
|
wired: boolean;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Candidate app-router root-layout files: the direct `<appDir>/layout.*` plus,
|
|
19
|
+
* for i18n apps that place the `<html>` layout under a dynamic segment
|
|
20
|
+
* (next-intl's `app/[locale]/layout.tsx`), any `<appDir>/[segment]/layout.*`.
|
|
21
|
+
* Existing files only, in resolution order.
|
|
22
|
+
*/
|
|
23
|
+
export declare function nextRootLayoutFiles(cwd: string, appDir: string): string[];
|
|
17
24
|
/**
|
|
18
25
|
* Wire the app-router root layout deterministically: imports, async default
|
|
19
26
|
* export, loader calls, `<FimoNextScripts />` in head, and `{children}`
|
|
@@ -62,6 +62,27 @@ function findFirst(node, predicate) {
|
|
|
62
62
|
return null;
|
|
63
63
|
}
|
|
64
64
|
const jsxName = (element) => element?.openingElement?.name?.name;
|
|
65
|
+
/**
|
|
66
|
+
* Candidate app-router root-layout files: the direct `<appDir>/layout.*` plus,
|
|
67
|
+
* for i18n apps that place the `<html>` layout under a dynamic segment
|
|
68
|
+
* (next-intl's `app/[locale]/layout.tsx`), any `<appDir>/[segment]/layout.*`.
|
|
69
|
+
* Existing files only, in resolution order.
|
|
70
|
+
*/
|
|
71
|
+
export function nextRootLayoutFiles(cwd, appDir) {
|
|
72
|
+
const names = ['layout.tsx', 'layout.jsx', 'layout.js'];
|
|
73
|
+
const dirs = [path.join(cwd, appDir)];
|
|
74
|
+
try {
|
|
75
|
+
for (const entry of fs.readdirSync(path.join(cwd, appDir), { withFileTypes: true })) {
|
|
76
|
+
if (entry.isDirectory() && entry.name.startsWith('[') && entry.name.endsWith(']')) {
|
|
77
|
+
dirs.push(path.join(cwd, appDir, entry.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// appDir missing — no candidates
|
|
83
|
+
}
|
|
84
|
+
return dirs.flatMap((dir) => names.map((name) => path.join(dir, name))).filter((file) => fs.existsSync(file));
|
|
85
|
+
}
|
|
65
86
|
/**
|
|
66
87
|
* Wire the app-router root layout deterministically: imports, async default
|
|
67
88
|
* export, loader calls, `<FimoNextScripts />` in head, and `{children}`
|
|
@@ -79,9 +100,14 @@ export function ensureNextLayoutWiring(cwd, detection) {
|
|
|
79
100
|
wired: false,
|
|
80
101
|
};
|
|
81
102
|
}
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
103
|
+
const candidates = nextRootLayoutFiles(cwd, detection.appDir);
|
|
104
|
+
// Already wired anywhere — an agent may have mounted the [locale] layout by hand.
|
|
105
|
+
if (candidates.some((file) => fs.readFileSync(file, 'utf8').includes('FimoNextProviders'))) {
|
|
106
|
+
return { changes: [], warnings: [], wired: true };
|
|
107
|
+
}
|
|
108
|
+
// The real root layout is the one that returns <html> (next-intl puts it under
|
|
109
|
+
// app/[locale]/); fall back to the first candidate for the bail message.
|
|
110
|
+
const layoutFile = candidates.find((file) => fs.readFileSync(file, 'utf8').includes('<html')) ?? candidates[0];
|
|
85
111
|
const rel = layoutFile ? path.relative(cwd, layoutFile) : `${detection.appDir}/layout.tsx`;
|
|
86
112
|
const bail = (reason) => ({
|
|
87
113
|
changes: [],
|
|
@@ -92,9 +118,6 @@ export function ensureNextLayoutWiring(cwd, detection) {
|
|
|
92
118
|
return bail('no root layout file found');
|
|
93
119
|
}
|
|
94
120
|
const code = fs.readFileSync(layoutFile, 'utf8');
|
|
95
|
-
if (code.includes('FimoNextProviders')) {
|
|
96
|
-
return { changes: [], warnings: [], wired: true };
|
|
97
|
-
}
|
|
98
121
|
let program;
|
|
99
122
|
try {
|
|
100
123
|
program = parseSync(layoutFile, code).program;
|
|
@@ -50,6 +50,34 @@ describe('ensureNextLayoutWiring', () => {
|
|
|
50
50
|
expect(code.indexOf('<FimoNextScripts />')).toBeGreaterThan(code.indexOf('<html'));
|
|
51
51
|
expect(code.indexOf('<FimoNextScripts />')).toBeLessThan(code.indexOf('<body'));
|
|
52
52
|
});
|
|
53
|
+
it('wires the root layout under a [locale] segment (next-intl)', () => {
|
|
54
|
+
const root = project({ 'src/app/[locale]/layout.tsx': CREATE_NEXT_APP_LAYOUT });
|
|
55
|
+
const result = ensureNextLayoutWiring(root, { appDir: 'src/app' });
|
|
56
|
+
const code = readFileSync(join(root, 'src/app/[locale]/layout.tsx'), 'utf8');
|
|
57
|
+
expect(result.wired).toBe(true);
|
|
58
|
+
expect(result.changes).toEqual(['wired Fimo providers into src/app/[locale]/layout.tsx']);
|
|
59
|
+
expect(code).toContain('<FimoNextProviders config={config} labels={labels}>{children}</FimoNextProviders>');
|
|
60
|
+
expect(code).toContain('<FimoNextScripts />');
|
|
61
|
+
});
|
|
62
|
+
it('picks the <html> layout when a passthrough app/layout also exists', () => {
|
|
63
|
+
const root = project({
|
|
64
|
+
'src/app/layout.tsx': 'export default function Root({ children }) {\n return children;\n}\n',
|
|
65
|
+
'src/app/[locale]/layout.tsx': CREATE_NEXT_APP_LAYOUT,
|
|
66
|
+
});
|
|
67
|
+
const result = ensureNextLayoutWiring(root, { appDir: 'src/app' });
|
|
68
|
+
expect(result.wired).toBe(true);
|
|
69
|
+
expect(result.changes).toEqual(['wired Fimo providers into src/app/[locale]/layout.tsx']);
|
|
70
|
+
// the passthrough root layout stays untouched
|
|
71
|
+
expect(readFileSync(join(root, 'src/app/layout.tsx'), 'utf8')).not.toContain('FimoNextProviders');
|
|
72
|
+
});
|
|
73
|
+
it('treats a [locale] layout already wired by hand as a no-op', () => {
|
|
74
|
+
const root = project({
|
|
75
|
+
'src/app/[locale]/layout.tsx': CREATE_NEXT_APP_LAYOUT.replace('{children}', '<FimoNextProviders config={config} labels={labels}>{children}</FimoNextProviders>'),
|
|
76
|
+
});
|
|
77
|
+
const result = ensureNextLayoutWiring(root, { appDir: 'src/app' });
|
|
78
|
+
expect(result.wired).toBe(true);
|
|
79
|
+
expect(result.changes).toEqual([]);
|
|
80
|
+
});
|
|
53
81
|
it('reuses an existing <head> element and keeps an async layout async', () => {
|
|
54
82
|
const root = project({
|
|
55
83
|
'src/app/layout.jsx': `export default async function Layout({ children }) {
|