fimo 0.9.0-experimental.1 → 0.9.0-experimental.10
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 +234 -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 +484 -243
- 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 +72 -0
- package/dist/scripts/routes/page-structure.js +234 -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
|
@@ -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();
|
|
@@ -76,6 +90,29 @@ function workspaceRoot(cwd) {
|
|
|
76
90
|
}
|
|
77
91
|
return null;
|
|
78
92
|
}
|
|
93
|
+
/** Every relative path target in an exports map (conditions and subpaths
|
|
94
|
+
* flattened; `require`/`import`/`types`/`default` values alike). */
|
|
95
|
+
function exportsTargets(exportsMap) {
|
|
96
|
+
if (typeof exportsMap === 'string') {
|
|
97
|
+
return exportsMap.startsWith('./') ? [exportsMap] : [];
|
|
98
|
+
}
|
|
99
|
+
if (Array.isArray(exportsMap)) {
|
|
100
|
+
return exportsMap.flatMap(exportsTargets);
|
|
101
|
+
}
|
|
102
|
+
if (exportsMap && typeof exportsMap === 'object') {
|
|
103
|
+
return Object.values(exportsMap).flatMap(exportsTargets);
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
/** A target file (or, for a `*` pattern, its base directory) exists. */
|
|
108
|
+
function exportsTargetExists(depDir, target) {
|
|
109
|
+
const star = target.indexOf('*');
|
|
110
|
+
if (star === -1) {
|
|
111
|
+
return fs.existsSync(path.join(depDir, target));
|
|
112
|
+
}
|
|
113
|
+
const base = target.slice(0, star).replace(/\/[^/]*$/, '');
|
|
114
|
+
return fs.existsSync(path.join(depDir, base || '.'));
|
|
115
|
+
}
|
|
79
116
|
/** Internal workspace packages this app depends on (`workspace:` / `catalog:`
|
|
80
117
|
* protocol) whose build output is missing — a bare `next dev` fails on
|
|
81
118
|
* `Cannot find module '@repo/…/dist/…'` until they are built. */
|
|
@@ -98,8 +135,11 @@ function unbuiltWorkspaceDeps(cwd) {
|
|
|
98
135
|
if (typeof entry === 'string') {
|
|
99
136
|
return !fs.existsSync(path.join(depDir, entry));
|
|
100
137
|
}
|
|
101
|
-
//
|
|
102
|
-
|
|
138
|
+
// Exports-only package: unbuilt when a RESOLVED target is missing — an
|
|
139
|
+
// exports map pointing at checked-in sources (eslint configs, type-only
|
|
140
|
+
// packages) never produces dist/ and must not be flagged forever.
|
|
141
|
+
const targets = exportsTargets(depPkg.exports);
|
|
142
|
+
return targets.length > 0 && targets.some((target) => !exportsTargetExists(depDir, target));
|
|
103
143
|
}
|
|
104
144
|
catch {
|
|
105
145
|
return false; // can't resolve the link → don't force a build
|
|
@@ -110,10 +150,11 @@ function unbuiltWorkspaceDeps(cwd) {
|
|
|
110
150
|
* Build workspace packages the app needs BEFORE booting its dev server. Fimo
|
|
111
151
|
* runs the app's bare `next dev`, but in a Turborepo/pnpm monorepo the
|
|
112
152
|
* internal deps (`@repo/*`) aren't built by that — `turbo dev` would build
|
|
113
|
-
* them first.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
153
|
+
* them first. Builds are SCOPED to the flagged packages (turbo/pnpm/npm
|
|
154
|
+
* filters) — the root build script builds the whole app too and fails for
|
|
155
|
+
* reasons unrelated to the deps; it is only the last resort when no filter
|
|
156
|
+
* mechanism exists. A build failure is surfaced with an actionable message
|
|
157
|
+
* instead of a cryptic `Cannot find module` at boot.
|
|
117
158
|
*/
|
|
118
159
|
export function ensureWorkspaceDepsBuilt(cwd, packageManager, onProgress) {
|
|
119
160
|
const root = workspaceRoot(cwd);
|
|
@@ -125,30 +166,45 @@ export function ensureWorkspaceDepsBuilt(cwd, packageManager, onProgress) {
|
|
|
125
166
|
return;
|
|
126
167
|
}
|
|
127
168
|
onProgress?.(`building workspace packages (${unbuilt.slice(0, 3).join(', ')}${unbuilt.length > 3 ? '…' : ''})`);
|
|
128
|
-
let
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (rootPkg.scripts?.build) {
|
|
132
|
-
rootBuild = { command: packageManager, args: ['run', 'build'] };
|
|
133
|
-
}
|
|
169
|
+
let build = null;
|
|
170
|
+
if (fs.existsSync(path.join(root, 'turbo.json'))) {
|
|
171
|
+
build = { command: 'npx', args: ['turbo', 'run', 'build', ...unbuilt.map((name) => `--filter=${name}`)] };
|
|
134
172
|
}
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
else if (packageManager === 'pnpm') {
|
|
174
|
+
build = {
|
|
175
|
+
command: 'pnpm',
|
|
176
|
+
args: [...unbuilt.flatMap((name) => ['--filter', name]), 'run', '--if-present', 'build'],
|
|
177
|
+
};
|
|
137
178
|
}
|
|
138
|
-
if (
|
|
139
|
-
|
|
179
|
+
else if (packageManager === 'npm') {
|
|
180
|
+
build = {
|
|
181
|
+
command: 'npm',
|
|
182
|
+
args: ['run', 'build', '--if-present', ...unbuilt.flatMap((name) => ['--workspace', name])],
|
|
183
|
+
};
|
|
140
184
|
}
|
|
141
|
-
|
|
142
|
-
|
|
185
|
+
else {
|
|
186
|
+
// No filter mechanism (yarn classic, bun) — the root build is the last resort.
|
|
187
|
+
try {
|
|
188
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
189
|
+
if (rootPkg.scripts?.build) {
|
|
190
|
+
build = { command: packageManager, args: ['run', 'build'] };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// handled below
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!build) {
|
|
198
|
+
throw new CaptureError(`Workspace packages (${unbuilt.join(', ')}) must be built before the dev server can boot, and the monorepo has no build mechanism the migration can drive. Build them yourself, then re-run \`npx fimo migrate\`.`);
|
|
143
199
|
}
|
|
144
|
-
const result = spawnSync(
|
|
200
|
+
const result = spawnSync(build.command, build.args, {
|
|
145
201
|
cwd: root,
|
|
146
202
|
timeout: 300_000,
|
|
147
203
|
encoding: 'utf8',
|
|
148
204
|
env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' },
|
|
149
205
|
});
|
|
150
206
|
if (result.status !== 0) {
|
|
151
|
-
throw new CaptureError(`Workspace packages (${unbuilt.join(', ')}) need building for the dev server, but \`${
|
|
207
|
+
throw new CaptureError(`Workspace packages (${unbuilt.join(', ')}) need building for the dev server, but \`${build.command} ${build.args.join(' ')}\` failed at ${path.relative(cwd, root) || 'the repo root'}. Build them yourself, then re-run \`npx fimo migrate\`.\n${(result.stderr || result.stdout || '').slice(-800)}`);
|
|
152
208
|
}
|
|
153
209
|
}
|
|
154
210
|
export async function startDevServer(opts) {
|
|
@@ -206,7 +262,10 @@ export async function startDevServer(opts) {
|
|
|
206
262
|
// Dev scripts that hardcode their own port are handled by sniffing stdout.
|
|
207
263
|
const probe = async (origin) => {
|
|
208
264
|
try {
|
|
209
|
-
|
|
265
|
+
// A generous per-attempt cap: connection-refused fails instantly, so
|
|
266
|
+
// this only holds when a server is LISTENING but slow (first SSR
|
|
267
|
+
// compile) — the exact case a short timeout misclassified as down.
|
|
268
|
+
const response = await fetch(origin, { signal: AbortSignal.timeout(15_000) });
|
|
210
269
|
return response.status < 500;
|
|
211
270
|
}
|
|
212
271
|
catch {
|
|
@@ -273,10 +332,21 @@ export async function captureParityBaseline(opts) {
|
|
|
273
332
|
if (opts.serverOrigin) {
|
|
274
333
|
const origin = opts.serverOrigin.replace(/\/+$/, '');
|
|
275
334
|
opts.onProgress?.(`using the running dev server at ${origin}`);
|
|
276
|
-
|
|
277
|
-
|
|
335
|
+
// Retry across a mid-recompile stall (Turbopack holds requests for
|
|
336
|
+
// seconds) — one short-timeout attempt misread a busy server as down.
|
|
337
|
+
let answered = false;
|
|
338
|
+
for (let attempt = 0; attempt < 3 && !answered; attempt++) {
|
|
339
|
+
try {
|
|
340
|
+
await fetch(origin, { signal: AbortSignal.timeout(10_000) });
|
|
341
|
+
answered = true;
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
if (attempt < 2) {
|
|
345
|
+
await sleep(2_000);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
278
348
|
}
|
|
279
|
-
|
|
349
|
+
if (!answered) {
|
|
280
350
|
throw new CaptureError(`No dev server answered at ${origin} — start it (e.g. \`${opts.packageManager} run dev\`) or drop --server to boot one.`);
|
|
281
351
|
}
|
|
282
352
|
server = { origin, stderrTail: () => '', stop: async () => { } };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CmsCollectionSchema } from '../cms/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Page-binding evidence for the page registry (`fimo-config.json#routes`).
|
|
4
|
+
* A dynamic page template (`/blog/:slug`) is only navigable in the Studio
|
|
5
|
+
* preview once its content param carries a binding
|
|
6
|
+
* `{ schema, queryField, displayField }` — the schema whose entries
|
|
7
|
+
* materialize the page instances. No binding can be inferred with certainty,
|
|
8
|
+
* so this module grades evidence: `strong` bindings are written by the
|
|
9
|
+
* machine, `candidate` ones ship to the agent prompt as ready-to-apply
|
|
10
|
+
* suggestions. Neutral: evidence comes in as plain route/schema facts; the
|
|
11
|
+
* framework and CMS specifics stay in their adapters.
|
|
12
|
+
*/
|
|
13
|
+
export interface PageParamBinding {
|
|
14
|
+
schema: string;
|
|
15
|
+
queryField: string;
|
|
16
|
+
displayField: string;
|
|
17
|
+
}
|
|
18
|
+
export interface PageBinding {
|
|
19
|
+
routeId: string;
|
|
20
|
+
path: string;
|
|
21
|
+
param: string;
|
|
22
|
+
binding: PageParamBinding;
|
|
23
|
+
confidence: 'strong' | 'candidate';
|
|
24
|
+
source: 'rewire' | 'cms-wiring';
|
|
25
|
+
}
|
|
26
|
+
export interface UnboundPage {
|
|
27
|
+
id: string;
|
|
28
|
+
path: string;
|
|
29
|
+
candidates: PageBinding[];
|
|
30
|
+
}
|
|
31
|
+
export interface PageBindingsResult {
|
|
32
|
+
/** Written into the registry by the caller. */
|
|
33
|
+
strong: PageBinding[];
|
|
34
|
+
/** Dynamic pages left without params — with any candidate suggestions. */
|
|
35
|
+
unbound: UnboundPage[];
|
|
36
|
+
/** Dynamic pages that already carry params (manual or a prior run). */
|
|
37
|
+
bound: Array<{
|
|
38
|
+
id: string;
|
|
39
|
+
path: string;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
/** A list page known to render a collection (the page-rewire join). */
|
|
43
|
+
export interface ListPageEvidence {
|
|
44
|
+
routePath: string | null;
|
|
45
|
+
schema: string;
|
|
46
|
+
}
|
|
47
|
+
export interface SchemaFieldsLike {
|
|
48
|
+
readonly [field: string]: {
|
|
49
|
+
readonly type?: string;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export declare function isLocaleParam(name: string): boolean;
|
|
53
|
+
interface ParsedDynamicPath {
|
|
54
|
+
/** The trailing content param name (without `:`/`*`). */
|
|
55
|
+
contentParam: string;
|
|
56
|
+
/** Route path minus the trailing content-param segment. */
|
|
57
|
+
parentPath: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A bindable dynamic path = optional locale-ish params + ONE trailing content
|
|
61
|
+
* param (catch-all or plain). Anything else (multiple content params) needs
|
|
62
|
+
* judgment beyond a single binding.
|
|
63
|
+
*/
|
|
64
|
+
export declare function parseDynamicPath(path: string): ParsedDynamicPath | null;
|
|
65
|
+
/** First title-ish string field of a schema — the picker label the Studio
|
|
66
|
+
* shows. Null when the schema has none (binding downgrades to candidate). */
|
|
67
|
+
export declare function displayFieldFor(fields: SchemaFieldsLike | undefined): string | null;
|
|
68
|
+
/** CMS collections that structurally look like page-builder page types: a
|
|
69
|
+
* top-level dynamic-zone (blocks oneOf) field. Structural, never name-based. */
|
|
70
|
+
export declare function cmsPageTypeEvidence(schemas: readonly CmsCollectionSchema[]): Array<{
|
|
71
|
+
schema: string;
|
|
72
|
+
fields: SchemaFieldsLike;
|
|
73
|
+
}>;
|
|
74
|
+
export declare function collectPageBindings(options: {
|
|
75
|
+
routes: Record<string, {
|
|
76
|
+
path?: string;
|
|
77
|
+
params?: unknown;
|
|
78
|
+
}>;
|
|
79
|
+
/** Rewired list pages (route-space, from the framework adapter). */
|
|
80
|
+
listPages?: readonly ListPageEvidence[];
|
|
81
|
+
/** Structural CMS page types (from `cmsPageTypeEvidence`), only when the
|
|
82
|
+
* CMS reads are actually wired to Fimo. */
|
|
83
|
+
cmsPageTypes?: ReadonlyArray<{
|
|
84
|
+
schema: string;
|
|
85
|
+
fields: SchemaFieldsLike;
|
|
86
|
+
}>;
|
|
87
|
+
/** Uids still provisional (machine drafts not yet adopted) — their bindings
|
|
88
|
+
* are candidates: `adopt-drafts` will rename them. */
|
|
89
|
+
provisionalUids?: ReadonlySet<string>;
|
|
90
|
+
/** Known fields per schema, for the displayField pick. */
|
|
91
|
+
schemaFields?: Record<string, SchemaFieldsLike>;
|
|
92
|
+
}): PageBindingsResult;
|
|
93
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** Params that identify a locale, not content — never schema-bound. The
|
|
2
|
+
* Studio preview substitutes content params only; locale params are a known
|
|
3
|
+
* limitation documented in the prompt. Mirrors the parity capture's list. */
|
|
4
|
+
const LOCALE_PARAMS = /^(locale|lang|language)$/i;
|
|
5
|
+
export function isLocaleParam(name) {
|
|
6
|
+
return LOCALE_PARAMS.test(name);
|
|
7
|
+
}
|
|
8
|
+
const TITLE_FIELDS = /^(title|name|label|heading)$/i;
|
|
9
|
+
/**
|
|
10
|
+
* A bindable dynamic path = optional locale-ish params + ONE trailing content
|
|
11
|
+
* param (catch-all or plain). Anything else (multiple content params) needs
|
|
12
|
+
* judgment beyond a single binding.
|
|
13
|
+
*/
|
|
14
|
+
export function parseDynamicPath(path) {
|
|
15
|
+
const params = [...path.matchAll(/:([^/*]+)\*?/g)].map((match) => match[1]);
|
|
16
|
+
if (params.length === 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const contentParams = params.filter((param) => !LOCALE_PARAMS.test(param));
|
|
20
|
+
if (contentParams.length !== 1) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const contentParam = contentParams[0];
|
|
24
|
+
if (!new RegExp(`/:${contentParam}\\*?$`).test(path)) {
|
|
25
|
+
return null; // the content param must be the trailing segment
|
|
26
|
+
}
|
|
27
|
+
const parentPath = path.replace(new RegExp(`/:${contentParam}\\*?$`), '') || '/';
|
|
28
|
+
return { contentParam, parentPath };
|
|
29
|
+
}
|
|
30
|
+
function isCatchAll(path, param) {
|
|
31
|
+
return path.endsWith(`:${param}*`);
|
|
32
|
+
}
|
|
33
|
+
/** First title-ish string field of a schema — the picker label the Studio
|
|
34
|
+
* shows. Null when the schema has none (binding downgrades to candidate). */
|
|
35
|
+
export function displayFieldFor(fields) {
|
|
36
|
+
for (const [name, def] of Object.entries(fields ?? {})) {
|
|
37
|
+
if (TITLE_FIELDS.test(name) && (def.type === undefined || def.type === 'string')) {
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
/** CMS collections that structurally look like page-builder page types: a
|
|
44
|
+
* top-level dynamic-zone (blocks oneOf) field. Structural, never name-based. */
|
|
45
|
+
export function cmsPageTypeEvidence(schemas) {
|
|
46
|
+
return schemas
|
|
47
|
+
.filter((schema) => !schema.isSingleton &&
|
|
48
|
+
Object.values(schema.fields).some((field) => field.type === 'blocks' && 'oneOf' in field.schema))
|
|
49
|
+
.map((schema) => ({ schema: schema.name, fields: schema.fields }));
|
|
50
|
+
}
|
|
51
|
+
export function collectPageBindings(options) {
|
|
52
|
+
const { routes, listPages = [], cmsPageTypes = [], provisionalUids, schemaFields = {} } = options;
|
|
53
|
+
const strong = [];
|
|
54
|
+
const unbound = [];
|
|
55
|
+
const bound = [];
|
|
56
|
+
for (const [id, route] of Object.entries(routes)) {
|
|
57
|
+
const path = route.path;
|
|
58
|
+
if (typeof path !== 'string' || !path.includes(':')) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (route.params && typeof route.params === 'object' && Object.keys(route.params).length > 0) {
|
|
62
|
+
bound.push({ id, path });
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const params = [...path.matchAll(/:([^/*]+)\*?/g)].map((match) => match[1]);
|
|
66
|
+
if (params.every((param) => isLocaleParam(param))) {
|
|
67
|
+
continue; // locale-only page — nothing to bind, effectively static
|
|
68
|
+
}
|
|
69
|
+
const parsed = parseDynamicPath(path);
|
|
70
|
+
if (!parsed) {
|
|
71
|
+
unbound.push({ id, path, candidates: [] });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const candidates = [];
|
|
75
|
+
const make = (schema, source, baseConfidence) => {
|
|
76
|
+
const fields = schemaFields[schema] ?? cmsPageTypes.find((type) => type.schema === schema)?.fields;
|
|
77
|
+
const display = displayFieldFor(fields);
|
|
78
|
+
const confidence = provisionalUids?.has(schema) || display === null ? 'candidate' : baseConfidence;
|
|
79
|
+
return {
|
|
80
|
+
routeId: id,
|
|
81
|
+
path,
|
|
82
|
+
param: parsed.contentParam,
|
|
83
|
+
binding: { schema, queryField: 'slug', displayField: display ?? 'slug' },
|
|
84
|
+
confidence,
|
|
85
|
+
source,
|
|
86
|
+
};
|
|
87
|
+
};
|
|
88
|
+
// Rewire join: a list page rendering collection X at the dynamic page's
|
|
89
|
+
// parent path is strong evidence the detail param resolves against X.
|
|
90
|
+
const parents = listPages.filter((page) => page.routePath === parsed.parentPath);
|
|
91
|
+
const parentSchemas = [...new Set(parents.map((page) => page.schema))];
|
|
92
|
+
if (parentSchemas.length === 1) {
|
|
93
|
+
candidates.push(make(parentSchemas[0], 'rewire', 'strong'));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
for (const schema of parentSchemas) {
|
|
97
|
+
candidates.push(make(schema, 'rewire', 'candidate'));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// CMS-structural join: a catch-all page of a CMS-wired site renders the
|
|
101
|
+
// CMS's page-builder type. Unique page-builder type → strong.
|
|
102
|
+
if (isCatchAll(path, parsed.contentParam) && cmsPageTypes.length > 0) {
|
|
103
|
+
const unique = cmsPageTypes.length === 1;
|
|
104
|
+
for (const type of cmsPageTypes) {
|
|
105
|
+
candidates.push(make(type.schema, 'cms-wiring', unique ? 'strong' : 'candidate'));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const winner = candidates.find((candidate) => candidate.confidence === 'strong');
|
|
109
|
+
if (winner && candidates.filter((candidate) => candidate.confidence === 'strong').length === 1) {
|
|
110
|
+
strong.push(winner);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
unbound.push({ id, path, candidates });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { strong, unbound, bound };
|
|
117
|
+
}
|
|
@@ -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
|
+
}
|