fimo 0.9.0-experimental.1 → 0.9.0-experimental.11

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 (80) hide show
  1. package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
  2. package/assets/agents-starter/claude/settings.json +19 -0
  3. package/assets/content-templates/types-template.eta +1 -0
  4. package/assets/skills/fimo/references/translations.md +75 -0
  5. package/assets/skills/fimo-migration/SKILL.md +2 -0
  6. package/assets/skills/fimo-migration/references/manifest.md +11 -0
  7. package/assets/skills/fimo-migration/references/pages.md +298 -0
  8. package/assets/skills/fimo-migration/references/wrap.md +45 -0
  9. package/dist/build/vite/plugins/translations.js +28 -10
  10. package/dist/cli/bundle.json +1 -1
  11. package/dist/cli/index.js +577 -269
  12. package/dist/runtime/app/FimoProviders.js +29 -7
  13. package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
  14. package/dist/runtime/core/BuiltWithBadge.js +68 -0
  15. package/dist/runtime/primitives/components/RichText.d.ts +5 -1
  16. package/dist/runtime/primitives/components/RichText.js +2 -2
  17. package/dist/runtime/react/FimoSpaProviders.js +83 -24
  18. package/dist/runtime/react/index.d.ts +2 -0
  19. package/dist/runtime/react/index.js +1 -0
  20. package/dist/runtime/react/spa-content-cache.d.ts +38 -0
  21. package/dist/runtime/react/spa-content-cache.js +64 -0
  22. package/dist/runtime/seo/neutral.d.ts +22 -0
  23. package/dist/runtime/seo/neutral.js +17 -0
  24. package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
  25. package/dist/runtime/shared/fimo-config.server.js +0 -13
  26. package/dist/runtime/strapi/client.js +45 -2
  27. package/dist/runtime/strapi/reshape.d.ts +10 -6
  28. package/dist/runtime/strapi/reshape.js +7 -0
  29. package/dist/runtime/wrapped/FimoRichTextBlock.d.ts +4 -1
  30. package/dist/runtime/wrapped/FimoRichTextBlock.js +2 -2
  31. package/dist/runtime/wrapped/content-fetch.pure.d.ts +20 -0
  32. package/dist/runtime/wrapped/content-fetch.pure.js +106 -0
  33. package/dist/runtime/wrapped/server.d.ts +5 -2
  34. package/dist/runtime/wrapped/server.js +15 -45
  35. package/dist/scripts/apply-wrap-templates.js +10 -5
  36. package/dist/scripts/apply-wrap.d.ts +29 -2
  37. package/dist/scripts/apply-wrap.js +69 -7
  38. package/dist/scripts/cms/example-content.js +16 -13
  39. package/dist/scripts/cms/strapi/schema-map.js +16 -4
  40. package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
  41. package/dist/scripts/cms/strapi/shapes.js +10 -0
  42. package/dist/scripts/cms/strapi/wiring.js +1 -1
  43. package/dist/scripts/detect-framework.js +6 -0
  44. package/dist/scripts/draft-rows.js +44 -1
  45. package/dist/scripts/draft-schemas.js +64 -5
  46. package/dist/scripts/frameworks/astro/routes.js +8 -19
  47. package/dist/scripts/frameworks/discover.d.ts +19 -6
  48. package/dist/scripts/frameworks/discover.js +113 -3
  49. package/dist/scripts/frameworks/discovered-route.d.ts +9 -0
  50. package/dist/scripts/frameworks/discovered-route.js +17 -0
  51. package/dist/scripts/frameworks/next/rewire.d.ts +13 -0
  52. package/dist/scripts/frameworks/next/rewire.js +26 -2
  53. package/dist/scripts/frameworks/next/routes.d.ts +13 -0
  54. package/dist/scripts/frameworks/next/routes.js +55 -13
  55. package/dist/scripts/frameworks/nuxt/routes.js +2 -18
  56. package/dist/scripts/frameworks/remix/routes.js +4 -18
  57. package/dist/scripts/frameworks/sveltekit/routes.js +4 -19
  58. package/dist/scripts/frameworks/vite/routes.d.ts +5 -0
  59. package/dist/scripts/frameworks/vite/routes.js +59 -9
  60. package/dist/scripts/frameworks/vite/wiring.d.ts +12 -0
  61. package/dist/scripts/frameworks/vite/wiring.js +68 -23
  62. package/dist/scripts/lib/humanize.d.ts +14 -0
  63. package/dist/scripts/lib/humanize.js +54 -0
  64. package/dist/scripts/lib/validation-report.d.ts +0 -3
  65. package/dist/scripts/lib/validation-report.js +0 -9
  66. package/dist/scripts/render-parity/capture.d.ts +12 -4
  67. package/dist/scripts/render-parity/capture.js +94 -24
  68. package/dist/scripts/routes/bindings.d.ts +93 -0
  69. package/dist/scripts/routes/bindings.js +117 -0
  70. package/dist/scripts/routes/label-prefixes.d.ts +49 -0
  71. package/dist/scripts/routes/label-prefixes.js +136 -0
  72. package/dist/scripts/routes/module-resolve.d.ts +20 -0
  73. package/dist/scripts/routes/module-resolve.js +78 -0
  74. package/dist/scripts/routes/page-structure.d.ts +136 -0
  75. package/dist/scripts/routes/page-structure.js +393 -0
  76. package/dist/scripts/scan-wrappable.d.ts +5 -1
  77. package/dist/scripts/scan-wrappable.js +126 -3
  78. package/package.json +5 -1
  79. package/templates/react-router/package.json +1 -1
  80. package/templates/react-router/translations/en.json +1 -0
@@ -1,22 +1,6 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- const titleCase = (seg) => seg
4
- .replace(/[_\-]+/g, ' ')
5
- .replace(/([a-z])([A-Z])/g, '$1 $2')
6
- .split(/\s+/)
7
- .filter(Boolean)
8
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
9
- .join(' ');
10
- function pushRoute(routes, parts) {
11
- const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
12
- const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
13
- routes.push({
14
- id: routePath === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
15
- path: routePath,
16
- label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
17
- });
18
- }
19
- const toSegment = (name) => name.replace(/^\[\.\.\.(.+)\]$/, ':$1').replace(/^\[(.+)\]$/, ':$1');
3
+ import { pushRoute, toSegment } from '../discovered-route.js';
20
4
  /** Nuxt file-based routes under pages/ (index.vue maps to the dir path). */
21
5
  export function discoverNuxtRoutes(pagesDir) {
22
6
  const routes = [];
@@ -45,7 +29,7 @@ export function discoverNuxtRoutes(pagesDir) {
45
29
  continue;
46
30
  }
47
31
  const base = entry.replace(/\.vue$/, '');
48
- pushRoute(routes, base === 'index' ? segments : [...segments, toSegment(base)]);
32
+ pushRoute(routes, base === 'index' ? segments : [...segments, toSegment(base)], full);
49
33
  }
50
34
  };
51
35
  walk(pagesDir, []);
@@ -1,20 +1,6 @@
1
1
  import { readdirSync } from 'node:fs';
2
- const titleCase = (seg) => seg
3
- .replace(/[_\-]+/g, ' ')
4
- .replace(/([a-z])([A-Z])/g, '$1 $2')
5
- .split(/\s+/)
6
- .filter(Boolean)
7
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
8
- .join(' ');
9
- function pushRoute(routes, parts) {
10
- const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
11
- const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
12
- routes.push({
13
- id: routePath === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
14
- path: routePath,
15
- label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
16
- });
17
- }
2
+ import { join } from 'node:path';
3
+ import { pushRoute } from '../discovered-route.js';
18
4
  const FILE_EXT = /\.(tsx|jsx|ts|js|mdx)$/;
19
5
  /** Remix v2 flat routes under app/routes: dots are slashes, $param is dynamic. */
20
6
  export function discoverRemixRoutes(routesDir) {
@@ -32,7 +18,7 @@ export function discoverRemixRoutes(routesDir) {
32
18
  }
33
19
  const base = entry.replace(FILE_EXT, '');
34
20
  if (base === '_index') {
35
- pushRoute(routes, []);
21
+ pushRoute(routes, [], join(routesDir, entry));
36
22
  continue;
37
23
  }
38
24
  // Pathless layout segments start with _ ; trailing ._index maps to the parent.
@@ -42,7 +28,7 @@ export function discoverRemixRoutes(routesDir) {
42
28
  .filter((seg) => !seg.startsWith('_'))
43
29
  .map((seg) => (seg.startsWith('$') ? `:${seg.slice(1) || 'splat'}` : seg));
44
30
  if (parts.length > 0) {
45
- pushRoute(routes, parts);
31
+ pushRoute(routes, parts, join(routesDir, entry));
46
32
  }
47
33
  }
48
34
  routes.sort((a, b) => a.path.localeCompare(b.path));
@@ -1,22 +1,6 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- const titleCase = (seg) => seg
4
- .replace(/[_\-]+/g, ' ')
5
- .replace(/([a-z])([A-Z])/g, '$1 $2')
6
- .split(/\s+/)
7
- .filter(Boolean)
8
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
9
- .join(' ');
10
- function pushRoute(routes, parts) {
11
- const routePath = `/${parts.join('/')}`.replace(/\/+/g, '/');
12
- const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
13
- routes.push({
14
- id: routePath === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
15
- path: routePath,
16
- label: routePath === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? routePath),
17
- });
18
- }
19
- const toSegment = (name) => name.replace(/^\[\.\.\.(.+)\]$/, ':$1').replace(/^\[(.+)\]$/, ':$1');
3
+ import { pushRoute, toSegment } from '../discovered-route.js';
20
4
  /** SvelteKit routes: every directory under src/routes holding a +page file. */
21
5
  export function discoverSvelteKitRoutes(routesDir) {
22
6
  const routes = [];
@@ -28,9 +12,10 @@ export function discoverSvelteKitRoutes(routesDir) {
28
12
  catch {
29
13
  return;
30
14
  }
31
- if (entries.some((entry) => /^\+page\.(svelte|md)$/.test(entry))) {
15
+ const pageFile = entries.find((entry) => /^\+page\.(svelte|md)$/.test(entry));
16
+ if (pageFile) {
32
17
  // Route groups (parens) are layout-only segments.
33
- pushRoute(routes, segments.filter((seg) => !seg.startsWith('(')));
18
+ pushRoute(routes, segments.filter((seg) => !seg.startsWith('(')), join(dir, pageFile));
34
19
  }
35
20
  for (const entry of entries) {
36
21
  const full = join(dir, entry);
@@ -1,3 +1,4 @@
1
+ import { type RouteFileResolution, type RouteLike } from '../../routes/module-resolve.js';
1
2
  import type { DiscoveredRoute } from '../next/routes.js';
2
3
  /**
3
4
  * Route discovery for Vite React SPAs: there is no file convention, so the
@@ -13,3 +14,7 @@ import type { DiscoveredRoute } from '../next/routes.js';
13
14
  * anything the scan cannot see, and scanned routes only ever ADD to it.
14
15
  */
15
16
  export declare function discoverReactRouterDomRoutes(rootDir: string): DiscoveredRoute[];
17
+ /** Registry-routed (frameworkRouteFiles): registry route ids → the SPA
18
+ * component files that render them, via <Route element>/object-form route
19
+ * declarations and import resolution (lazy() included). */
20
+ export declare function resolveSpaRouteFiles(cwd: string, routes: Record<string, RouteLike>): RouteFileResolution[];
@@ -1,12 +1,7 @@
1
1
  import { readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- const titleCase = (seg) => seg
4
- .replace(/[_\-]+/g, ' ')
5
- .replace(/([a-z])([A-Z])/g, '$1 $2')
6
- .split(/\s+/)
7
- .filter(Boolean)
8
- .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
9
- .join(' ');
3
+ import { discoveredRouteLabel } from '../../lib/humanize.js';
4
+ import { componentImports, resolveModuleFile, walkFiles, } from '../../routes/module-resolve.js';
10
5
  const ROUTE_SOURCE = /\.(tsx|jsx|ts|js)$/;
11
6
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git']);
12
7
  /**
@@ -81,13 +76,68 @@ export function discoverReactRouterDomRoutes(rootDir) {
81
76
  continue;
82
77
  }
83
78
  const parts = path.split('/').filter(Boolean);
84
- const lastStatic = [...parts].reverse().find((part) => !part.startsWith(':'));
85
79
  routes.set(path, {
86
80
  id: path === '/' ? 'home' : parts.map((part) => part.replace(/^:/, '')).join('-'),
87
81
  path,
88
- label: path === '/' ? 'Home' : titleCase(lastStatic ?? parts[parts.length - 1] ?? path),
82
+ label: discoveredRouteLabel(path),
89
83
  });
90
84
  }
91
85
  }
92
86
  return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
93
87
  }
88
+ /** Registry-routed (frameworkRouteFiles): registry route ids → the SPA
89
+ * component files that render them, via <Route element>/object-form route
90
+ * declarations and import resolution (lazy() included). */
91
+ export function resolveSpaRouteFiles(cwd, routes) {
92
+ const srcDir = join(cwd, 'src');
93
+ const files = [];
94
+ walkFiles(srcDir, (name) => ROUTE_SOURCE.test(name), files);
95
+ const byPath = new Map();
96
+ for (const [routeId, route] of Object.entries(routes)) {
97
+ if (typeof route?.path === 'string') {
98
+ byPath.set(route.path, routeId);
99
+ }
100
+ }
101
+ const resolutions = new Map();
102
+ for (const file of files) {
103
+ let code;
104
+ try {
105
+ code = readFileSync(file, 'utf8');
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ if (!/from\s+['"]react-router(?:-dom)?['"]/.test(code)) {
111
+ continue;
112
+ }
113
+ const imports = componentImports(code);
114
+ const pairs = [];
115
+ // JSX shape: one <Route …> tag carries both path and element, in either
116
+ // attribute order.
117
+ for (const tag of code.matchAll(/<Route\b((?:"[^"]*"|'[^']*'|\{[^{}]*\}|[^>"'{}])*)\/?>/g)) {
118
+ const attrs = tag[1];
119
+ const routePath = attrs.match(/\bpath\s*=\s*(?:"([^"]+)"|'([^']+)')/);
120
+ const element = attrs.match(/\belement\s*=\s*\{\s*<(\w+)[\s/>]/) ?? attrs.match(/\bComponent\s*=\s*\{(\w+)\}/);
121
+ if (routePath && element) {
122
+ pairs.push({ path: routePath[1] ?? routePath[2], component: element[1] });
123
+ }
124
+ }
125
+ // Object shape: { path: '/x', element: <X …/> } or { path: '/x', Component: X }.
126
+ for (const entry of code.matchAll(/path\s*:\s*(?:"([^"]+)"|'([^']+)')\s*,\s*(?:element\s*:\s*<(\w+)[\s/>]|Component\s*:\s*(\w+))/g)) {
127
+ pairs.push({ path: entry[1] ?? entry[2], component: entry[3] ?? entry[4] });
128
+ }
129
+ for (const pair of pairs) {
130
+ const normalized = pair.path !== '/' && pair.path.endsWith('/') ? pair.path.slice(0, -1) : pair.path;
131
+ const routeId = byPath.get(normalized);
132
+ if (!routeId || resolutions.has(routeId)) {
133
+ continue;
134
+ }
135
+ const spec = imports.get(pair.component);
136
+ const componentFile = spec ? resolveModuleFile(cwd, file, spec) : null;
137
+ if (componentFile) {
138
+ resolutions.set(routeId, componentFile);
139
+ }
140
+ }
141
+ }
142
+ return [...resolutions.entries()].map(([routeId, file]) => ({ routeId, file }));
143
+ }
@@ -8,3 +8,15 @@ import type { RootWiringResult } from '../next/wiring.js';
8
8
  * Anything else bails with the reason and stays agent work via the recipe.
9
9
  */
10
10
  export declare function ensureSpaEntryWiring(cwd: string): RootWiringResult;
11
+ /**
12
+ * The `fimo()` Vite plugin serves the virtual modules the generated content
13
+ * clients import (`virtual:fimo-config`, …). Without it, the first
14
+ * dependency re-optimization 500s every page the moment an agent uses a
15
+ * generated client — both run-5 agents had to discover and add it by hand.
16
+ * Insert `fimo()` into the config's plugins array; unusual configs bail
17
+ * with the recipe.
18
+ */
19
+ export declare function ensureViteFimoPlugin(cwd: string): {
20
+ changes: string[];
21
+ warnings: string[];
22
+ };
@@ -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
+ }
@@ -34,12 +34,9 @@ export interface ValidationReport {
34
34
  sync: PhaseResult;
35
35
  };
36
36
  }
37
- export declare function emptyPhase(name: string): PhaseResult;
38
37
  export declare function skippedPhase(name: string, reason: string): PhaseResult;
39
38
  export declare function computeExitCode(report: ValidationReport): 0 | 1 | 2;
40
39
  export declare function finalizeReport(report: Omit<ValidationReport, 'ok' | 'exitCode'>): ValidationReport;
41
40
  export declare function authorError(opts: Omit<ValidationError, 'kind'>): ValidationError;
42
- export declare function infraError(opts: Omit<ValidationError, 'kind'>): ValidationError;
43
41
  export declare function infraErrorFromException(rule: string, error: unknown): ValidationError;
44
- export declare function formatReportJson(report: ValidationReport): string;
45
42
  export declare function formatReportHuman(report: ValidationReport): string;
@@ -6,9 +6,6 @@
6
6
  * (the Fimo backend is unreachable or returned a server error — agent should
7
7
  * surface to the human and stop). Exit codes follow the same split.
8
8
  */
9
- export function emptyPhase(name) {
10
- return { name, ok: true, errors: [] };
11
- }
12
9
  export function skippedPhase(name, reason) {
13
10
  return { name, ok: false, errors: [], skipped: { reason } };
14
11
  }
@@ -32,9 +29,6 @@ export function finalizeReport(report) {
32
29
  export function authorError(opts) {
33
30
  return { kind: 'author', ...opts };
34
31
  }
35
- export function infraError(opts) {
36
- return { kind: 'infra', ...opts };
37
- }
38
32
  export function infraErrorFromException(rule, error) {
39
33
  return {
40
34
  kind: 'infra',
@@ -42,9 +36,6 @@ export function infraErrorFromException(rule, error) {
42
36
  message: error instanceof Error ? error.message : String(error),
43
37
  };
44
38
  }
45
- export function formatReportJson(report) {
46
- return JSON.stringify(report, null, 2);
47
- }
48
39
  export function formatReportHuman(report) {
49
40
  const lines = [];
50
41
  for (const phase of [report.phases.checks, report.phases.codegen, report.phases.sync]) {
@@ -26,15 +26,23 @@ 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
32
39
  * runs the app's bare `next dev`, but in a Turborepo/pnpm monorepo the
33
40
  * internal deps (`@repo/*`) aren't built by that — `turbo dev` would build
34
- * them first. This detects unbuilt internal deps and runs the monorepo's
35
- * build once (the root `build` script, else `turbo run build`); turbo's cache
36
- * makes an already-built repo near-instant. A build failure is surfaced with
37
- * an actionable message instead of a cryptic `Cannot find module` at boot.
41
+ * them first. Builds are SCOPED to the flagged packages (turbo/pnpm/npm
42
+ * filters) — the root build script builds the whole app too and fails for
43
+ * reasons unrelated to the deps; it is only the last resort when no filter
44
+ * mechanism exists. A build failure is surfaced with an actionable message
45
+ * instead of a cryptic `Cannot find module` at boot.
38
46
  */
39
47
  export declare function ensureWorkspaceDepsBuilt(cwd: string, packageManager: string, onProgress?: (message: string) => void): void;
40
48
  interface DevServer {