fimo 0.9.0-experimental.7 → 0.9.0-experimental.9
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/pages.md +140 -1
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +359 -187
- package/dist/runtime/app/FimoProviders.js +29 -7
- 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 +82 -14
- package/dist/runtime/react/index.d.ts +1 -0
- package/dist/runtime/react/index.js +1 -0
- package/dist/runtime/react/spa-content-cache.d.ts +27 -0
- package/dist/runtime/react/spa-content-cache.js +36 -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 +1 -1
- package/dist/scripts/apply-wrap.js +26 -2
- package/dist/scripts/cms/strapi/wiring.js +1 -1
- package/dist/scripts/detect-framework.js +6 -0
- package/dist/scripts/draft-rows.js +36 -0
- package/dist/scripts/frameworks/astro/routes.js +2 -18
- package/dist/scripts/frameworks/discover.d.ts +19 -6
- package/dist/scripts/frameworks/discover.js +96 -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/routes.d.ts +7 -0
- package/dist/scripts/frameworks/next/routes.js +31 -12
- 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/render-parity/capture.d.ts +7 -0
- package/dist/scripts/render-parity/capture.js +32 -4
- 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 +72 -0
- package/dist/scripts/routes/page-structure.js +234 -0
- package/dist/scripts/scan-wrappable.d.ts +4 -0
- package/dist/scripts/scan-wrappable.js +15 -2
- package/package.json +1 -1
- package/templates/react-router/package.json +1 -1
- package/dist/scripts/lib/discover-next-routes.d.ts +0 -17
- package/dist/scripts/lib/discover-next-routes.js +0 -134
|
@@ -33,52 +33,53 @@ export function ensureSpaEntryWiring(cwd) {
|
|
|
33
33
|
catch {
|
|
34
34
|
return bail('entry file could not be parsed', rel);
|
|
35
35
|
}
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
36
|
+
// EVERY root-mount JSX argument, found structurally: `render(<X/>)` calls
|
|
37
|
+
// (createRoot/ReactDOM) AND `hydrateRoot(container, <X/>)` — a prerendered
|
|
38
|
+
// SPA mounts through BOTH branches, and wiring only the createRoot one
|
|
39
|
+
// left the production hydrate path without providers (run-5 finding).
|
|
40
|
+
const renderArgs = [];
|
|
41
|
+
(function find(node) {
|
|
40
42
|
if (!node || typeof node !== 'object') {
|
|
41
|
-
return
|
|
43
|
+
return;
|
|
42
44
|
}
|
|
43
45
|
const candidate = node;
|
|
44
|
-
if (candidate.type === 'CallExpression'
|
|
45
|
-
candidate.callee?.type === 'MemberExpression' &&
|
|
46
|
-
candidate.callee?.
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
if (candidate.type === 'CallExpression') {
|
|
47
|
+
const isRender = candidate.callee?.type === 'MemberExpression' && candidate.callee?.property?.name === 'render';
|
|
48
|
+
const isHydrate = candidate.callee?.name === 'hydrateRoot' ||
|
|
49
|
+
(candidate.callee?.type === 'MemberExpression' && candidate.callee?.property?.name === 'hydrateRoot');
|
|
50
|
+
const arg = isRender ? candidate.arguments?.[0] : isHydrate ? candidate.arguments?.[1] : undefined;
|
|
51
|
+
if (arg && (arg.type === 'JSXElement' || arg.type === 'JSXFragment')) {
|
|
52
|
+
renderArgs.push(arg);
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
for (const value of Object.values(candidate)) {
|
|
51
56
|
if (Array.isArray(value)) {
|
|
52
57
|
for (const entry of value) {
|
|
53
|
-
|
|
54
|
-
if (found) {
|
|
55
|
-
return found;
|
|
56
|
-
}
|
|
58
|
+
find(entry);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
else if (value && typeof value === 'object') {
|
|
60
|
-
|
|
61
|
-
if (found) {
|
|
62
|
-
return found;
|
|
63
|
-
}
|
|
62
|
+
find(value);
|
|
64
63
|
}
|
|
65
64
|
}
|
|
66
|
-
return null;
|
|
67
65
|
})(program);
|
|
68
|
-
if (
|
|
66
|
+
if (renderArgs.length === 0) {
|
|
69
67
|
return bail('no root render call with a JSX argument found', rel);
|
|
70
68
|
}
|
|
71
69
|
const configImportPath = path
|
|
72
70
|
.relative(path.dirname(entryFile), path.join(cwd, 'fimo-config.json'))
|
|
73
71
|
.split(path.sep)
|
|
74
72
|
.join('/');
|
|
75
|
-
const jsx = code.slice(renderArg.start, renderArg.end);
|
|
76
73
|
const open = '<FimoSpaProviders\n' +
|
|
77
74
|
' config={fimoConfig}\n' +
|
|
78
75
|
' apiUrl={import.meta.env.VITE_API_URL}\n' +
|
|
79
76
|
' previewScriptUrl={import.meta.env.DEV ? import.meta.env.VITE_FIMO_PREVIEW_SCRIPT_URL : undefined}\n' +
|
|
80
77
|
' >';
|
|
81
|
-
let next = code
|
|
78
|
+
let next = code;
|
|
79
|
+
for (const renderArg of [...renderArgs].sort((a, b) => b.start - a.start)) {
|
|
80
|
+
const jsx = next.slice(renderArg.start, renderArg.end);
|
|
81
|
+
next = next.slice(0, renderArg.start) + `${open}\n ${jsx}\n </FimoSpaProviders>` + next.slice(renderArg.end);
|
|
82
|
+
}
|
|
82
83
|
// AST-positioned like ensureFimoImport: a multiline import's first line is
|
|
83
84
|
// not the end of the imports.
|
|
84
85
|
const importBlock = `import { FimoSpaProviders } from 'fimo/react';\nimport fimoConfig from '${configImportPath.startsWith('.') ? configImportPath : `./${configImportPath}`}';`;
|
|
@@ -95,5 +96,49 @@ export function ensureSpaEntryWiring(cwd) {
|
|
|
95
96
|
? `${next.slice(0, lastImportEnd)}\n${importBlock}${next.slice(lastImportEnd)}`
|
|
96
97
|
: `${importBlock}\n${next}`;
|
|
97
98
|
fs.writeFileSync(entryFile, next);
|
|
98
|
-
|
|
99
|
+
const changes = [`wired FimoSpaProviders into ${rel} (${renderArgs.length} mount point(s))`];
|
|
100
|
+
const pluginResult = ensureViteFimoPlugin(cwd);
|
|
101
|
+
return {
|
|
102
|
+
changes: [...changes, ...pluginResult.changes],
|
|
103
|
+
warnings: pluginResult.warnings,
|
|
104
|
+
wired: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The `fimo()` Vite plugin serves the virtual modules the generated content
|
|
109
|
+
* clients import (`virtual:fimo-config`, …). Without it, the first
|
|
110
|
+
* dependency re-optimization 500s every page the moment an agent uses a
|
|
111
|
+
* generated client — both run-5 agents had to discover and add it by hand.
|
|
112
|
+
* Insert `fimo()` into the config's plugins array; unusual configs bail
|
|
113
|
+
* with the recipe.
|
|
114
|
+
*/
|
|
115
|
+
export function ensureViteFimoPlugin(cwd) {
|
|
116
|
+
const configFile = ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']
|
|
117
|
+
.map((candidate) => path.join(cwd, candidate))
|
|
118
|
+
.find((candidate) => fs.existsSync(candidate));
|
|
119
|
+
if (!configFile) {
|
|
120
|
+
return { changes: [], warnings: ['No vite.config found — add the fimo() plugin per the agent-prompt recipe.'] };
|
|
121
|
+
}
|
|
122
|
+
const rel = path.relative(cwd, configFile);
|
|
123
|
+
const code = fs.readFileSync(configFile, 'utf8');
|
|
124
|
+
if (/\bfimo\s*\(/.test(code) && code.includes('fimo/vite')) {
|
|
125
|
+
return { changes: [], warnings: [] };
|
|
126
|
+
}
|
|
127
|
+
const pluginsMatch = code.match(/\bplugins\s*:\s*\[/);
|
|
128
|
+
if (!pluginsMatch || pluginsMatch.index === undefined) {
|
|
129
|
+
return {
|
|
130
|
+
changes: [],
|
|
131
|
+
warnings: [`No plugins array found in ${rel} — add the fimo() plugin per the agent-prompt recipe.`],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const insertAt = pluginsMatch.index + pluginsMatch[0].length;
|
|
135
|
+
let next = `${code.slice(0, insertAt)}fimo(), ${code.slice(insertAt)}`;
|
|
136
|
+
const importLine = `import { fimo } from 'fimo/vite';`;
|
|
137
|
+
const lastImport = [...next.matchAll(/^import .*$/gm)].pop();
|
|
138
|
+
next =
|
|
139
|
+
lastImport && lastImport.index !== undefined
|
|
140
|
+
? `${next.slice(0, lastImport.index + lastImport[0].length)}\n${importLine}${next.slice(lastImport.index + lastImport[0].length)}`
|
|
141
|
+
: `${importLine}\n${next}`;
|
|
142
|
+
fs.writeFileSync(configFile, next);
|
|
143
|
+
return { changes: [`added the fimo() plugin to ${rel}`], warnings: [] };
|
|
99
144
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing names derived from slugs and identifiers — route labels, page
|
|
3
|
+
* names, field titles. One rule set so "ai-stack" reads "AI Stack" and
|
|
4
|
+
* "agentic-cms" reads "Agentic CMS" everywhere the machine writes a name.
|
|
5
|
+
*/
|
|
6
|
+
/** "ai-stack" → "AI Stack"; "agenticCms" → "Agentic CMS"; "blog" → "Blog". */
|
|
7
|
+
export declare function humanizeSegment(segment: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* The discovery-time label for a route path. Dynamic templates never share
|
|
10
|
+
* the parent index page's name (the run-4 tree showed "Blog" twice): the
|
|
11
|
+
* param joins the label ("/blog/:slug" → "Blog Slug") until a schema
|
|
12
|
+
* binding upgrades it to the schema's name ("Blog Post").
|
|
13
|
+
*/
|
|
14
|
+
export declare function discoveredRouteLabel(path: string): string;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-facing names derived from slugs and identifiers — route labels, page
|
|
3
|
+
* names, field titles. One rule set so "ai-stack" reads "AI Stack" and
|
|
4
|
+
* "agentic-cms" reads "Agentic CMS" everywhere the machine writes a name.
|
|
5
|
+
*/
|
|
6
|
+
const ACRONYMS = new Set([
|
|
7
|
+
'ai',
|
|
8
|
+
'api',
|
|
9
|
+
'cd',
|
|
10
|
+
'ci',
|
|
11
|
+
'cli',
|
|
12
|
+
'cms',
|
|
13
|
+
'cta',
|
|
14
|
+
'faq',
|
|
15
|
+
'geo',
|
|
16
|
+
'llm',
|
|
17
|
+
'sdk',
|
|
18
|
+
'seo',
|
|
19
|
+
'spa',
|
|
20
|
+
'ssg',
|
|
21
|
+
'ssr',
|
|
22
|
+
'ui',
|
|
23
|
+
'url',
|
|
24
|
+
'ux',
|
|
25
|
+
]);
|
|
26
|
+
/** "ai-stack" → "AI Stack"; "agenticCms" → "Agentic CMS"; "blog" → "Blog". */
|
|
27
|
+
export function humanizeSegment(segment) {
|
|
28
|
+
return segment
|
|
29
|
+
.replace(/[_\-]+/g, ' ')
|
|
30
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
31
|
+
.split(/\s+/)
|
|
32
|
+
.filter(Boolean)
|
|
33
|
+
.map((word) => ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
34
|
+
.join(' ');
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The discovery-time label for a route path. Dynamic templates never share
|
|
38
|
+
* the parent index page's name (the run-4 tree showed "Blog" twice): the
|
|
39
|
+
* param joins the label ("/blog/:slug" → "Blog Slug") until a schema
|
|
40
|
+
* binding upgrades it to the schema's name ("Blog Post").
|
|
41
|
+
*/
|
|
42
|
+
export function discoveredRouteLabel(path) {
|
|
43
|
+
if (path === '/') {
|
|
44
|
+
return 'Home';
|
|
45
|
+
}
|
|
46
|
+
const parts = path.split('/').filter(Boolean);
|
|
47
|
+
const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
|
|
48
|
+
const base = humanizeSegment(lastStatic ?? parts[parts.length - 1]?.replace(/^:/, '') ?? path);
|
|
49
|
+
const lastPart = parts[parts.length - 1];
|
|
50
|
+
if (lastPart?.startsWith(':') && lastStatic) {
|
|
51
|
+
return `${base} ${humanizeSegment(lastPart.replace(/^:|\*$/g, ''))}`;
|
|
52
|
+
}
|
|
53
|
+
return base;
|
|
54
|
+
}
|
|
@@ -26,6 +26,13 @@ export declare class CaptureError extends Error {
|
|
|
26
26
|
readonly stderrTail: string;
|
|
27
27
|
constructor(message: string, stderrTail?: string);
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* pnpm's minimumReleaseAge setting refuses freshly published packages —
|
|
31
|
+
* exactly what a just-published fimo experimental is. The refusal surfaces
|
|
32
|
+
* deep in install/boot output; detect it and hand back the one-line fix
|
|
33
|
+
* instead of leaving an opaque boot failure.
|
|
34
|
+
*/
|
|
35
|
+
export declare function pnpmReleaseAgeHint(output: string): string | null;
|
|
29
36
|
export declare function findFreePort(): Promise<number>;
|
|
30
37
|
/**
|
|
31
38
|
* Build workspace packages the app needs BEFORE booting its dev server. Fimo
|
|
@@ -22,6 +22,20 @@ export class CaptureError extends Error {
|
|
|
22
22
|
this.stderrTail = stderrTail;
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* pnpm's minimumReleaseAge setting refuses freshly published packages —
|
|
27
|
+
* exactly what a just-published fimo experimental is. The refusal surfaces
|
|
28
|
+
* deep in install/boot output; detect it and hand back the one-line fix
|
|
29
|
+
* instead of leaving an opaque boot failure.
|
|
30
|
+
*/
|
|
31
|
+
export function pnpmReleaseAgeHint(output) {
|
|
32
|
+
if (!/minimumReleaseAge|ERR_PNPM_.*RELEASE/i.test(output)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return ('pnpm is refusing a freshly published package (minimumReleaseAge). ' +
|
|
36
|
+
'Add `minimumReleaseAgeExclude: ["fimo"]` to pnpm-workspace.yaml (or set `minimumReleaseAge: 0`), ' +
|
|
37
|
+
're-run your install, then re-run `npx fimo migrate`.');
|
|
38
|
+
}
|
|
25
39
|
export function findFreePort() {
|
|
26
40
|
return new Promise((resolve, reject) => {
|
|
27
41
|
const server = net.createServer();
|
|
@@ -206,7 +220,10 @@ export async function startDevServer(opts) {
|
|
|
206
220
|
// Dev scripts that hardcode their own port are handled by sniffing stdout.
|
|
207
221
|
const probe = async (origin) => {
|
|
208
222
|
try {
|
|
209
|
-
|
|
223
|
+
// A generous per-attempt cap: connection-refused fails instantly, so
|
|
224
|
+
// this only holds when a server is LISTENING but slow (first SSR
|
|
225
|
+
// compile) — the exact case a short timeout misclassified as down.
|
|
226
|
+
const response = await fetch(origin, { signal: AbortSignal.timeout(15_000) });
|
|
210
227
|
return response.status < 500;
|
|
211
228
|
}
|
|
212
229
|
catch {
|
|
@@ -273,10 +290,21 @@ export async function captureParityBaseline(opts) {
|
|
|
273
290
|
if (opts.serverOrigin) {
|
|
274
291
|
const origin = opts.serverOrigin.replace(/\/+$/, '');
|
|
275
292
|
opts.onProgress?.(`using the running dev server at ${origin}`);
|
|
276
|
-
|
|
277
|
-
|
|
293
|
+
// Retry across a mid-recompile stall (Turbopack holds requests for
|
|
294
|
+
// seconds) — one short-timeout attempt misread a busy server as down.
|
|
295
|
+
let answered = false;
|
|
296
|
+
for (let attempt = 0; attempt < 3 && !answered; attempt++) {
|
|
297
|
+
try {
|
|
298
|
+
await fetch(origin, { signal: AbortSignal.timeout(10_000) });
|
|
299
|
+
answered = true;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
if (attempt < 2) {
|
|
303
|
+
await sleep(2_000);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
278
306
|
}
|
|
279
|
-
|
|
307
|
+
if (!answered) {
|
|
280
308
|
throw new CaptureError(`No dev server answered at ${origin} — start it (e.g. \`${opts.packageManager} run dev\`) or drop --server to boot one.`);
|
|
281
309
|
}
|
|
282
310
|
server = { origin, stderrTail: () => '', stop: async () => { } };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type RouteFileResolution, type RouteLike } from './module-resolve.js';
|
|
2
|
+
/**
|
|
3
|
+
* The route ↔ file ↔ label-keys join. A page's Studio view resolves its
|
|
4
|
+
* labels by key prefix, defaulting to `<pageId>.` — but wrapped keys derive
|
|
5
|
+
* from FILE names (AiCms.tsx → ai-cms.*), which can diverge from the
|
|
6
|
+
* URL-derived page id (agentic-cms). Every divergence left unmapped is an
|
|
7
|
+
* EMPTY page in the Studio and an orphan pile in Labels & Text, so migrate
|
|
8
|
+
* prefills `labelPrefixes` wherever the join is provable and reports every
|
|
9
|
+
* staged prefix no page claims.
|
|
10
|
+
*
|
|
11
|
+
* Evidence rule: a page claims a prefix only when one of that prefix's
|
|
12
|
+
* staged keys appears VERBATIM in the page's own component file — never by
|
|
13
|
+
* name similarity. Shared prefixes (data files, chrome) match no page file
|
|
14
|
+
* and surface in the unclaimed report for the agent/user to place.
|
|
15
|
+
*
|
|
16
|
+
* This module is CORE: it never tests a framework name. The per-framework
|
|
17
|
+
* route-file resolvers live in `scripts/frameworks/<name>/routes.ts` behind
|
|
18
|
+
* the `frameworkRouteFiles` registry.
|
|
19
|
+
*/
|
|
20
|
+
export { componentImports, resolveModuleFile, type RouteFileResolution } from './module-resolve.js';
|
|
21
|
+
export interface UnclaimedPrefix {
|
|
22
|
+
prefix: string;
|
|
23
|
+
exampleKey: string;
|
|
24
|
+
keys: number;
|
|
25
|
+
/** Shared site chrome (header/footer/nav/…) — belongs in a Single Type,
|
|
26
|
+
* never on a page and never left as loose labels. */
|
|
27
|
+
chrome: boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function isChromePrefix(prefix: string): boolean;
|
|
30
|
+
export declare function resolveRouteFiles(cwd: string, framework: string | null, routes: Record<string, RouteLike>): RouteFileResolution[];
|
|
31
|
+
/**
|
|
32
|
+
* For each resolved page file, the staged prefixes whose keys appear verbatim
|
|
33
|
+
* in it. Returns only the routes whose evidence DIFFERS from the default
|
|
34
|
+
* (`{routeId}`) — and never touches a route that already declares prefixes.
|
|
35
|
+
*/
|
|
36
|
+
export declare function collectPageLabelPrefixes(resolutions: RouteFileResolution[], stagedKeys: Iterable<string>, routes: Record<string, RouteLike>): Map<string, string[]>;
|
|
37
|
+
/**
|
|
38
|
+
* Staged prefixes no page claims (neither as its id nor via labelPrefixes) —
|
|
39
|
+
* each is either shared content (leave in Labels & Text) or a missed page
|
|
40
|
+
* mapping the agent must place with `fimo pages set --label-prefix`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function unclaimedLabelPrefixes(stagedKeys: Iterable<string>, routes: Record<string, RouteLike>): UnclaimedPrefix[];
|
|
43
|
+
/**
|
|
44
|
+
* Page ↔ Single Type claims, by verbatim read evidence: a page owns single S
|
|
45
|
+
* only when its component file provably READS S's document (`documents/S`,
|
|
46
|
+
* `useFimoSingle('S')`, `fetchDocument('S')`). Claimed singles edit inline
|
|
47
|
+
* in the Studio's page view; unclaimed ones stay in the Singles tab.
|
|
48
|
+
*/
|
|
49
|
+
export declare function collectPageSingles(resolutions: RouteFileResolution[], singletonUids: string[], routes: Record<string, RouteLike>): Map<string, string[]>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { frameworkRouteFiles } from '../frameworks/discover.js';
|
|
3
|
+
/**
|
|
4
|
+
* The route ↔ file ↔ label-keys join. A page's Studio view resolves its
|
|
5
|
+
* labels by key prefix, defaulting to `<pageId>.` — but wrapped keys derive
|
|
6
|
+
* from FILE names (AiCms.tsx → ai-cms.*), which can diverge from the
|
|
7
|
+
* URL-derived page id (agentic-cms). Every divergence left unmapped is an
|
|
8
|
+
* EMPTY page in the Studio and an orphan pile in Labels & Text, so migrate
|
|
9
|
+
* prefills `labelPrefixes` wherever the join is provable and reports every
|
|
10
|
+
* staged prefix no page claims.
|
|
11
|
+
*
|
|
12
|
+
* Evidence rule: a page claims a prefix only when one of that prefix's
|
|
13
|
+
* staged keys appears VERBATIM in the page's own component file — never by
|
|
14
|
+
* name similarity. Shared prefixes (data files, chrome) match no page file
|
|
15
|
+
* and surface in the unclaimed report for the agent/user to place.
|
|
16
|
+
*
|
|
17
|
+
* This module is CORE: it never tests a framework name. The per-framework
|
|
18
|
+
* route-file resolvers live in `scripts/frameworks/<name>/routes.ts` behind
|
|
19
|
+
* the `frameworkRouteFiles` registry.
|
|
20
|
+
*/
|
|
21
|
+
export { componentImports, resolveModuleFile } from './module-resolve.js';
|
|
22
|
+
const CHROME_PREFIX = /(header|footer|nav|menu|topbar|sidebar|banner|cookie|breadcrumb|social)/i;
|
|
23
|
+
export function isChromePrefix(prefix) {
|
|
24
|
+
return CHROME_PREFIX.test(prefix);
|
|
25
|
+
}
|
|
26
|
+
export function resolveRouteFiles(cwd, framework, routes) {
|
|
27
|
+
return frameworkRouteFiles(framework)?.(cwd, routes) ?? [];
|
|
28
|
+
}
|
|
29
|
+
function keyPrefix(key) {
|
|
30
|
+
const dot = key.indexOf('.');
|
|
31
|
+
return dot > 0 ? key.slice(0, dot) : null;
|
|
32
|
+
}
|
|
33
|
+
function groupKeysByPrefix(stagedKeys) {
|
|
34
|
+
const groups = new Map();
|
|
35
|
+
for (const key of stagedKeys) {
|
|
36
|
+
const prefix = keyPrefix(key);
|
|
37
|
+
if (!prefix) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
groups.set(prefix, [...(groups.get(prefix) ?? []), key]);
|
|
41
|
+
}
|
|
42
|
+
return groups;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* For each resolved page file, the staged prefixes whose keys appear verbatim
|
|
46
|
+
* in it. Returns only the routes whose evidence DIFFERS from the default
|
|
47
|
+
* (`{routeId}`) — and never touches a route that already declares prefixes.
|
|
48
|
+
*/
|
|
49
|
+
export function collectPageLabelPrefixes(resolutions, stagedKeys, routes) {
|
|
50
|
+
const groups = groupKeysByPrefix(stagedKeys);
|
|
51
|
+
const updates = new Map();
|
|
52
|
+
for (const { routeId, file } of resolutions) {
|
|
53
|
+
const route = routes[routeId];
|
|
54
|
+
if (!route || (Array.isArray(route.labelPrefixes) && route.labelPrefixes.length > 0)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
let code;
|
|
58
|
+
try {
|
|
59
|
+
code = readFileSync(file, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const matched = [];
|
|
65
|
+
for (const [prefix, keys] of groups) {
|
|
66
|
+
if (keys.some((key) => code.includes(key))) {
|
|
67
|
+
matched.push(prefix);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
matched.sort();
|
|
71
|
+
if (matched.length > 0 && (matched.length > 1 || matched[0] !== routeId)) {
|
|
72
|
+
updates.set(routeId, matched);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return updates;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Staged prefixes no page claims (neither as its id nor via labelPrefixes) —
|
|
79
|
+
* each is either shared content (leave in Labels & Text) or a missed page
|
|
80
|
+
* mapping the agent must place with `fimo pages set --label-prefix`.
|
|
81
|
+
*/
|
|
82
|
+
export function unclaimedLabelPrefixes(stagedKeys, routes) {
|
|
83
|
+
const claimed = new Set();
|
|
84
|
+
for (const [routeId, route] of Object.entries(routes)) {
|
|
85
|
+
claimed.add(routeId);
|
|
86
|
+
if (Array.isArray(route?.labelPrefixes)) {
|
|
87
|
+
for (const prefix of route.labelPrefixes) {
|
|
88
|
+
if (typeof prefix === 'string') {
|
|
89
|
+
claimed.add(prefix.replace(/\.$/, ''));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...groupKeysByPrefix(stagedKeys)]
|
|
95
|
+
.filter(([prefix]) => !claimed.has(prefix))
|
|
96
|
+
.map(([prefix, keys]) => ({ prefix, exampleKey: keys[0], keys: keys.length, chrome: isChromePrefix(prefix) }))
|
|
97
|
+
.sort((a, b) => a.prefix.localeCompare(b.prefix));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Page ↔ Single Type claims, by verbatim read evidence: a page owns single S
|
|
101
|
+
* only when its component file provably READS S's document (`documents/S`,
|
|
102
|
+
* `useFimoSingle('S')`, `fetchDocument('S')`). Claimed singles edit inline
|
|
103
|
+
* in the Studio's page view; unclaimed ones stay in the Singles tab.
|
|
104
|
+
*/
|
|
105
|
+
export function collectPageSingles(resolutions, singletonUids, routes) {
|
|
106
|
+
const updates = new Map();
|
|
107
|
+
if (singletonUids.length === 0) {
|
|
108
|
+
return updates;
|
|
109
|
+
}
|
|
110
|
+
for (const { routeId, file } of resolutions) {
|
|
111
|
+
const route = routes[routeId];
|
|
112
|
+
if (!route || (Array.isArray(route.singles) && route.singles.length > 0)) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
let code;
|
|
116
|
+
try {
|
|
117
|
+
code = readFileSync(file, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const claimed = singletonUids
|
|
123
|
+
.filter((uid) => [
|
|
124
|
+
`documents/${uid}`,
|
|
125
|
+
`useFimoSingle('${uid}'`,
|
|
126
|
+
`useFimoSingle("${uid}"`,
|
|
127
|
+
`fetchDocument('${uid}'`,
|
|
128
|
+
`fetchDocument("${uid}"`,
|
|
129
|
+
].some((marker) => code.includes(marker)))
|
|
130
|
+
.sort();
|
|
131
|
+
if (claimed.length > 0) {
|
|
132
|
+
updates.set(routeId, claimed);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return updates;
|
|
136
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic source-tree helpers shared by the per-framework route
|
|
3
|
+
* resolvers and the page-structure drafter: file walking, import maps, and
|
|
4
|
+
* module-specifier resolution. No framework name may appear here — framework
|
|
5
|
+
* knowledge lives in `scripts/frameworks/<name>/` behind the registry.
|
|
6
|
+
*/
|
|
7
|
+
export interface RouteFileResolution {
|
|
8
|
+
routeId: string;
|
|
9
|
+
file: string;
|
|
10
|
+
}
|
|
11
|
+
export interface RouteLike {
|
|
12
|
+
path?: string;
|
|
13
|
+
labelPrefixes?: unknown;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export declare const DEFAULT_COMPONENT_EXTENSIONS: string[];
|
|
17
|
+
export declare function walkFiles(dir: string, matches: (name: string) => boolean, out: string[]): void;
|
|
18
|
+
/** Import map of a routes file: local component name → module specifier. */
|
|
19
|
+
export declare function componentImports(code: string): Map<string, string>;
|
|
20
|
+
export declare function resolveModuleFile(cwd: string, fromFile: string, spec: string, extensions?: readonly string[]): string | null;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.next', '.fimo']);
|
|
4
|
+
export const DEFAULT_COMPONENT_EXTENSIONS = ['.tsx', '.jsx', '.ts', '.js'];
|
|
5
|
+
export function walkFiles(dir, matches, out) {
|
|
6
|
+
let entries;
|
|
7
|
+
try {
|
|
8
|
+
entries = readdirSync(dir);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
if (SKIP_DIRS.has(entry)) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const full = join(dir, entry);
|
|
18
|
+
let isDir = false;
|
|
19
|
+
try {
|
|
20
|
+
isDir = statSync(full).isDirectory();
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (isDir) {
|
|
26
|
+
walkFiles(full, matches, out);
|
|
27
|
+
}
|
|
28
|
+
else if (matches(entry)) {
|
|
29
|
+
out.push(full);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Import map of a routes file: local component name → module specifier. */
|
|
34
|
+
export function componentImports(code) {
|
|
35
|
+
const imports = new Map();
|
|
36
|
+
for (const match of code.matchAll(/import\s+(\w+)\s*(?:,\s*\{[^}]*\})?\s+from\s+['"]([^'"]+)['"]/g)) {
|
|
37
|
+
imports.set(match[1], match[2]);
|
|
38
|
+
}
|
|
39
|
+
for (const match of code.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
|
|
40
|
+
for (const token of match[1].split(',')) {
|
|
41
|
+
const name = token.replace(/\s+as\s+(\w+)\s*$/, '$1').trim();
|
|
42
|
+
if (/^\w+$/.test(name)) {
|
|
43
|
+
imports.set(name, match[2]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const match of code.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:React\.)?lazy\(\s*(?:async\s*)?\(\)\s*=>\s*import\(\s*['"]([^'"]+)['"]\s*\)/g)) {
|
|
48
|
+
imports.set(match[1], match[2]);
|
|
49
|
+
}
|
|
50
|
+
return imports;
|
|
51
|
+
}
|
|
52
|
+
export function resolveModuleFile(cwd, fromFile, spec, extensions = DEFAULT_COMPONENT_EXTENSIONS) {
|
|
53
|
+
let base;
|
|
54
|
+
if (spec.startsWith('.')) {
|
|
55
|
+
base = join(dirname(fromFile), spec);
|
|
56
|
+
}
|
|
57
|
+
else if (spec.startsWith('~/') || spec.startsWith('@/')) {
|
|
58
|
+
base = join(cwd, 'src', spec.slice(2));
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
for (const candidate of [
|
|
64
|
+
...extensions.map((ext) => `${base}${ext}`),
|
|
65
|
+
...extensions.map((ext) => join(base, `index${ext}`)),
|
|
66
|
+
base,
|
|
67
|
+
]) {
|
|
68
|
+
try {
|
|
69
|
+
if (statSync(candidate).isFile()) {
|
|
70
|
+
return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
/* try next */
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { CollectionCandidate } from '../scan-wrappable.js';
|
|
2
|
+
/**
|
|
3
|
+
* Page-structure drafts — the machine's proposed NESTED model of a page.
|
|
4
|
+
* The scanner already sees everything the model needs: the page file's
|
|
5
|
+
* section components (capitalized JSX children, in document order), each
|
|
6
|
+
* section's label keys (its file-derived prefix) and its in-code collection
|
|
7
|
+
* candidates (typed rows). Joined, that IS the page's Single: one nested
|
|
8
|
+
* block field per section, recursing into the section's own components so
|
|
9
|
+
* deep structure (a Features grid whose groups live in their own files)
|
|
10
|
+
* surfaces too. The agent refines names and shapes — it no longer decides
|
|
11
|
+
* WHETHER to model, and real marketing copy stops dying as "page furniture"
|
|
12
|
+
* exemptions.
|
|
13
|
+
*/
|
|
14
|
+
export interface PageSectionDraft {
|
|
15
|
+
/** Proposed field key on the page Single (camelCase of the component). */
|
|
16
|
+
key: string;
|
|
17
|
+
component: string;
|
|
18
|
+
file: string;
|
|
19
|
+
/** The section's label-key prefix (file-derived). */
|
|
20
|
+
prefix: string;
|
|
21
|
+
/** Staged label keys the section owns — its scalar copy fields. */
|
|
22
|
+
labelKeys: string[];
|
|
23
|
+
/** In-code collection candidates found in the section's file — its
|
|
24
|
+
* repeated block items (cards, quotes, logos), with their source rows so
|
|
25
|
+
* the Single's document can be assembled without reopening the code. */
|
|
26
|
+
collections: Array<{
|
|
27
|
+
name: string;
|
|
28
|
+
fields: string[];
|
|
29
|
+
count: number;
|
|
30
|
+
rows?: Array<Record<string, unknown>>;
|
|
31
|
+
}>;
|
|
32
|
+
/** Content-bearing components the section renders — nested blocks. */
|
|
33
|
+
children?: PageSectionDraft[];
|
|
34
|
+
/** The same file drafts on several pages — chrome. Model it ONCE as a
|
|
35
|
+
* Single Type, never as a per-page block. */
|
|
36
|
+
shared?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface PageStructureDraft {
|
|
39
|
+
pageId: string;
|
|
40
|
+
/** Proposed Single uid (PascalCase of the page id). */
|
|
41
|
+
single: string;
|
|
42
|
+
sections: PageSectionDraft[];
|
|
43
|
+
}
|
|
44
|
+
export declare function draftPageStructures(args: {
|
|
45
|
+
cwd: string;
|
|
46
|
+
framework: string | null;
|
|
47
|
+
routes: Record<string, {
|
|
48
|
+
path?: string;
|
|
49
|
+
labelPrefixes?: unknown;
|
|
50
|
+
[key: string]: unknown;
|
|
51
|
+
}>;
|
|
52
|
+
stagedKeys: Iterable<string>;
|
|
53
|
+
collections: CollectionCandidate[];
|
|
54
|
+
}): PageStructureDraft[];
|
|
55
|
+
/** Flat section count across the nesting — the summary's visibility number. */
|
|
56
|
+
export declare function countDraftSections(draft: PageStructureDraft): number;
|
|
57
|
+
/**
|
|
58
|
+
* The machine-BUILT page Single: a ready-to-push singleton schema plus its
|
|
59
|
+
* filled document, assembled from the draft's non-shared sections (shared
|
|
60
|
+
* sections are chrome — modeled once elsewhere). One `max: 1` block per
|
|
61
|
+
* section; the section's labels become scalar fields carrying the staged
|
|
62
|
+
* values, its repeated arrays become nested blocks carrying the source
|
|
63
|
+
* rows, its child components nest the same way. The agent renames fields
|
|
64
|
+
* to editorial names and rewires reads — it no longer composes anything.
|
|
65
|
+
*/
|
|
66
|
+
export interface PageSingleDraft {
|
|
67
|
+
uid: string;
|
|
68
|
+
pageId: string;
|
|
69
|
+
schema: Record<string, unknown>;
|
|
70
|
+
document: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
export declare function buildPageSingleDraft(draft: PageStructureDraft, labelValues: Record<string, string>): PageSingleDraft | null;
|