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.
- package/assets/agent-templates/content-translator/scripts/write-locale-files.ts +66 -0
- package/assets/agents-starter/claude/settings.json +19 -0
- package/assets/content-templates/types-template.eta +1 -0
- package/assets/skills/fimo/references/translations.md +75 -0
- package/assets/skills/fimo-migration/SKILL.md +2 -0
- package/assets/skills/fimo-migration/references/manifest.md +11 -0
- package/assets/skills/fimo-migration/references/pages.md +298 -0
- package/assets/skills/fimo-migration/references/wrap.md +45 -0
- package/dist/build/vite/plugins/translations.js +28 -10
- package/dist/cli/bundle.json +1 -1
- package/dist/cli/index.js +577 -269
- package/dist/runtime/app/FimoProviders.js +29 -7
- package/dist/runtime/core/BuiltWithBadge.d.ts +2 -0
- package/dist/runtime/core/BuiltWithBadge.js +68 -0
- 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 +83 -24
- package/dist/runtime/react/index.d.ts +2 -0
- package/dist/runtime/react/index.js +1 -0
- package/dist/runtime/react/spa-content-cache.d.ts +38 -0
- package/dist/runtime/react/spa-content-cache.js +64 -0
- package/dist/runtime/seo/neutral.d.ts +22 -0
- package/dist/runtime/seo/neutral.js +17 -0
- package/dist/runtime/shared/fimo-config.server.d.ts +0 -1
- package/dist/runtime/shared/fimo-config.server.js +0 -13
- package/dist/runtime/strapi/client.js +45 -2
- package/dist/runtime/strapi/reshape.d.ts +10 -6
- package/dist/runtime/strapi/reshape.js +7 -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 +10 -5
- package/dist/scripts/apply-wrap.d.ts +29 -2
- package/dist/scripts/apply-wrap.js +69 -7
- package/dist/scripts/cms/example-content.js +16 -13
- package/dist/scripts/cms/strapi/schema-map.js +16 -4
- package/dist/scripts/cms/strapi/shapes.d.ts +7 -5
- package/dist/scripts/cms/strapi/shapes.js +10 -0
- package/dist/scripts/cms/strapi/wiring.js +1 -1
- package/dist/scripts/detect-framework.js +6 -0
- package/dist/scripts/draft-rows.js +44 -1
- package/dist/scripts/draft-schemas.js +64 -5
- package/dist/scripts/frameworks/astro/routes.js +8 -19
- package/dist/scripts/frameworks/discover.d.ts +19 -6
- package/dist/scripts/frameworks/discover.js +113 -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/rewire.d.ts +13 -0
- package/dist/scripts/frameworks/next/rewire.js +26 -2
- package/dist/scripts/frameworks/next/routes.d.ts +13 -0
- package/dist/scripts/frameworks/next/routes.js +55 -13
- 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/lib/validation-report.d.ts +0 -3
- package/dist/scripts/lib/validation-report.js +0 -9
- package/dist/scripts/render-parity/capture.d.ts +12 -4
- package/dist/scripts/render-parity/capture.js +94 -24
- package/dist/scripts/routes/bindings.d.ts +93 -0
- package/dist/scripts/routes/bindings.js +117 -0
- 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 +136 -0
- package/dist/scripts/routes/page-structure.js +393 -0
- package/dist/scripts/scan-wrappable.d.ts +5 -1
- package/dist/scripts/scan-wrappable.js +126 -3
- package/package.json +5 -1
- package/templates/react-router/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
+
}
|
|
@@ -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.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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 {
|