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.
Files changed (52) hide show
  1. package/assets/skills/fimo-migration/references/pages.md +140 -1
  2. package/dist/cli/bundle.json +1 -1
  3. package/dist/cli/index.js +359 -187
  4. package/dist/runtime/app/FimoProviders.js +29 -7
  5. package/dist/runtime/primitives/components/RichText.d.ts +5 -1
  6. package/dist/runtime/primitives/components/RichText.js +2 -2
  7. package/dist/runtime/react/FimoSpaProviders.js +82 -14
  8. package/dist/runtime/react/index.d.ts +1 -0
  9. package/dist/runtime/react/index.js +1 -0
  10. package/dist/runtime/react/spa-content-cache.d.ts +27 -0
  11. package/dist/runtime/react/spa-content-cache.js +36 -0
  12. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
  13. package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
  14. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  15. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  16. package/dist/runtime/wrapped/server.d.ts +5 -2
  17. package/dist/runtime/wrapped/server.js +15 -45
  18. package/dist/scripts/apply-wrap-templates.js +1 -1
  19. package/dist/scripts/apply-wrap.js +26 -2
  20. package/dist/scripts/cms/strapi/wiring.js +1 -1
  21. package/dist/scripts/detect-framework.js +6 -0
  22. package/dist/scripts/draft-rows.js +36 -0
  23. package/dist/scripts/frameworks/astro/routes.js +2 -18
  24. package/dist/scripts/frameworks/discover.d.ts +19 -6
  25. package/dist/scripts/frameworks/discover.js +96 -3
  26. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  27. package/dist/scripts/frameworks/discovered-route.js +17 -0
  28. package/dist/scripts/frameworks/next/routes.d.ts +7 -0
  29. package/dist/scripts/frameworks/next/routes.js +31 -12
  30. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  31. package/dist/scripts/frameworks/remix/routes.js +4 -18
  32. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  33. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  34. package/dist/scripts/frameworks/vite/routes.js +59 -9
  35. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  36. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  37. package/dist/scripts/lib/humanize.d.ts +14 -0
  38. package/dist/scripts/lib/humanize.js +54 -0
  39. package/dist/scripts/render-parity/capture.d.ts +7 -0
  40. package/dist/scripts/render-parity/capture.js +32 -4
  41. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  42. package/dist/scripts/routes/label-prefixes.js +136 -0
  43. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  44. package/dist/scripts/routes/module-resolve.js +78 -0
  45. package/dist/scripts/routes/page-structure.d.ts +72 -0
  46. package/dist/scripts/routes/page-structure.js +234 -0
  47. package/dist/scripts/scan-wrappable.d.ts +4 -0
  48. package/dist/scripts/scan-wrappable.js +15 -2
  49. package/package.json +1 -1
  50. package/templates/react-router/package.json +1 -1
  51. package/dist/scripts/lib/discover-next-routes.d.ts +0 -17
  52. 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
- // The JSX argument of the root render call, found structurally: a call
37
- // whose callee property is `render` with a JSX element first argument
38
- // (covers `createRoot(…).render(<X/>)` and `ReactDOM.render(<X/>, node)`).
39
- const renderArg = (function find(node) {
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 null;
43
+ return;
42
44
  }
43
45
  const candidate = node;
44
- if (candidate.type === 'CallExpression' &&
45
- candidate.callee?.type === 'MemberExpression' &&
46
- candidate.callee?.property?.name === 'render' &&
47
- (candidate.arguments?.[0]?.type === 'JSXElement' || candidate.arguments?.[0]?.type === 'JSXFragment')) {
48
- return candidate.arguments[0];
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
- const found = find(entry);
54
- if (found) {
55
- return found;
56
- }
58
+ find(entry);
57
59
  }
58
60
  }
59
61
  else if (value && typeof value === 'object') {
60
- const found = find(value);
61
- if (found) {
62
- return found;
63
- }
62
+ find(value);
64
63
  }
65
64
  }
66
- return null;
67
65
  })(program);
68
- if (!renderArg) {
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.slice(0, renderArg.start) + `${open}\n ${jsx}\n </FimoSpaProviders>` + code.slice(renderArg.end);
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
- return { changes: [`wired FimoSpaProviders into ${rel}`], warnings: [], wired: true };
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
- const response = await fetch(origin, { signal: AbortSignal.timeout(2_000) });
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
- try {
277
- await fetch(origin, { signal: AbortSignal.timeout(5_000) });
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
- catch {
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;