fimo 0.17.0 → 0.19.0
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/astro/components/DateTime.astro +22 -0
- package/assets/astro/components/index.d.ts +1 -0
- package/assets/astro/components/index.js +1 -0
- package/assets/content-templates/shared/types.eta +2 -0
- package/assets/skills/fimo-cli/SKILL.md +8 -6
- package/assets/skills/fimo-cli/references/branches.md +2 -2
- package/assets/skills/fimo-cli/references/content.md +2 -2
- package/assets/skills/fimo-cli/references/deploy.md +17 -9
- package/assets/skills/fimo-cli/references/hosting.md +114 -0
- package/assets/skills/fimo-cli/references/init.md +13 -6
- package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
- package/assets/skills/fimo-cli/references/locales.md +38 -14
- package/assets/skills/fimo-cli/references/pages.md +70 -0
- package/assets/skills/fimo-cli/references/preview.md +26 -20
- package/assets/skills/fimo-cli/references/webhooks.md +1 -1
- package/assets/skills/fimo-code/SKILL.md +11 -0
- package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
- package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
- package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
- package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
- package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
- package/assets/skills/fimo-code/references/content/richtext.md +5 -0
- package/assets/skills/fimo-code/references/content/schema.md +4 -1
- package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
- package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
- package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
- package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
- package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
- package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
- package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
- package/assets/skills/fimo-code/references/labels.md +9 -1
- package/assets/skills/fimo-code/references/locales.md +8 -2
- package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
- package/assets/skills/fimo-code/references/ui.md +21 -2
- package/assets/skills/fimo-migration/SKILL.md +276 -51
- package/assets/skills/fimo-migration/references/content.md +119 -35
- package/assets/skills/fimo-migration/references/decision.md +72 -0
- package/assets/skills/fimo-migration/references/forms.md +38 -6
- package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
- package/assets/skills/fimo-migration/references/import-contract.md +71 -19
- package/assets/skills/fimo-migration/references/manifest.md +11 -161
- package/assets/skills/fimo-migration/references/media.md +33 -1
- package/assets/skills/fimo-migration/references/platforms.md +26 -2
- package/assets/skills/fimo-migration/references/qa.md +167 -13
- package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
- package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
- package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
- package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
- package/assets/skills/fimo-migration/references/workers.md +82 -0
- package/assets/skills/fimo-migration/references/workflow.md +140 -192
- package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
- package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
- package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
- package/assets/skills/fimo-studio/references/ai-chat.md +1 -1
- package/assets/skills/fimo-studio/references/cms.md +25 -7
- package/assets/skills/fimo-studio/references/webhooks.md +1 -1
- package/assets/sveltekit/components/DateTime.svelte +35 -0
- package/assets/sveltekit/components/FimoProvider.svelte +2 -2
- package/assets/sveltekit/components/index.d.ts +1 -0
- package/assets/sveltekit/components/index.js +1 -0
- package/dist/build/next/config.d.ts +4 -1
- package/dist/build/next/config.js +21 -5
- package/dist/build/sveltekit/vite.d.ts +3 -4
- package/dist/build/sveltekit/vite.js +3 -4
- package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
- package/dist/build/vite/plugins/runtime-env.js +14 -10
- package/dist/cli/bundle.json +2 -2
- package/dist/cli/index.js +113 -108
- package/dist/runtime/app/FimoProviders.js +25 -14
- package/dist/runtime/app/prefetch.d.ts +3 -1
- package/dist/runtime/app/prefetch.js +11 -55
- package/dist/runtime/astro/preview.js +7 -9
- package/dist/runtime/content/client/internal.js +1 -0
- package/dist/runtime/i18n/locale.d.ts +6 -3
- package/dist/runtime/i18n/locale.js +16 -41
- package/dist/runtime/next/client-runtime.js +5 -1
- package/dist/runtime/next/client.d.ts +3 -2
- package/dist/runtime/next/client.js +2 -1
- package/dist/runtime/next/labels.d.ts +2 -2
- package/dist/runtime/next/labels.js +2 -2
- package/dist/runtime/next/preview.d.ts +13 -0
- package/dist/runtime/next/preview.js +34 -0
- package/dist/runtime/next/primitives/server.d.ts +2 -0
- package/dist/runtime/next/primitives/server.js +8 -0
- package/dist/runtime/next/provider.d.ts +5 -0
- package/dist/runtime/next/provider.js +17 -3
- package/dist/runtime/next/server-runtime.js +4 -1
- package/dist/runtime/next/server.d.ts +1 -1
- package/dist/runtime/next/server.js +1 -1
- package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
- package/dist/runtime/paths/get-fimo-paths.js +43 -42
- package/dist/runtime/preview/location.d.ts +19 -2
- package/dist/runtime/preview/location.js +16 -2
- package/dist/runtime/primitives/index.d.ts +1 -0
- package/dist/runtime/primitives/index.js +1 -0
- package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
- package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
- package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
- package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
- package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
- package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
- package/dist/runtime/routes/index.d.ts +162 -0
- package/dist/runtime/routes/index.js +335 -0
- package/dist/runtime/routing/AppRoutesProvider.js +4 -3
- package/dist/runtime/seo/resolver.d.ts +5 -0
- package/dist/runtime/seo/resolver.js +10 -42
- package/dist/runtime/shared/fimo-config.types.d.ts +14 -13
- package/dist/runtime/shared/preview-location.d.ts +1 -22
- package/dist/runtime/shared/preview-location.js +5 -93
- package/dist/runtime/sveltekit/preview.d.ts +12 -3
- package/dist/runtime/sveltekit/preview.js +13 -8
- package/dist/runtime/templates.d.ts +19 -0
- package/dist/runtime/templates.js +33 -0
- package/dist/scripts/sync-forms.js +1 -1
- package/dist/scripts/sync-schemas.js +1 -4
- package/dist/scripts/validate-fimo-config.js +5 -1
- package/dist/scripts/validate-page-registry.d.ts +2 -0
- package/dist/scripts/validate-page-registry.js +167 -0
- package/dist/scripts/validate-route-metadata.js +132 -41
- package/package.json +10 -5
- package/templates/astro/.fimo/config.json +13 -5
- package/templates/astro/AGENTS.md +5 -1
- package/templates/astro/astro.config.mjs +7 -0
- package/templates/astro/pnpm-workspace.yaml +0 -3
- package/templates/astro/src/components/ProjectOverview.astro +9 -6
- package/templates/astro/src/layouts/Base.astro +1 -1
- package/templates/astro/src/locales.ts +4 -0
- package/templates/astro/src/pages/[locale]/index.astro +19 -0
- package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
- package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
- package/templates/astro/src/pages/index.astro +5 -2
- package/templates/astro/src/pages/projects/[slug].astro +4 -2
- package/templates/astro/src/pages/projects/index.astro +5 -2
- package/templates/nextjs/.fimo/config.json +2 -1
- package/templates/nextjs/AGENTS.md +5 -0
- package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
- package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
- package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
- package/templates/nextjs/src/components/Document.tsx +26 -0
- package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
- package/templates/nextjs/src/locales.ts +13 -0
- package/templates/react-router/.fimo/config.json +3 -6
- package/templates/react-router/AGENTS.md +4 -0
- package/templates/react-router/package.json +1 -1
- package/templates/react-router/pnpm-workspace.yaml +1 -3
- package/templates/react-router/react-router.config.ts +4 -10
- package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
- package/templates/react-router/src/root.tsx +2 -4
- package/templates/react-router/src/routes.ts +5 -3
- package/templates/sveltekit/.fimo/config.json +2 -1
- package/templates/sveltekit/AGENTS.md +5 -0
- package/templates/sveltekit/src/app.html +1 -1
- package/templates/sveltekit/src/hooks.server.ts +11 -0
- package/templates/sveltekit/src/locales.ts +8 -0
- package/templates/sveltekit/src/params/locale.ts +5 -0
- package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
- package/templates/sveltekit/src/routes/+layout.svelte +5 -0
- package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
- package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
- package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
- package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
- package/templates/nextjs/src/app/layout.tsx +0 -17
- /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
- /package/templates/sveltekit/src/routes/{+page.svelte → [[locale=locale]]/+page.svelte} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
|
|
1
|
+
import { getDocumentLocale, getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
|
|
2
2
|
/** Load the platform preview script once. Empty configuration keeps preview disabled. */
|
|
3
3
|
export function ensurePreviewScript(src) {
|
|
4
4
|
if (!src) {
|
|
@@ -13,14 +13,19 @@ export function ensurePreviewScript(src) {
|
|
|
13
13
|
}
|
|
14
14
|
return true;
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Report where the app is now, and in which locale. Studio keeps its URL,
|
|
18
|
+
* route selector and content locale in sync from this.
|
|
19
|
+
*
|
|
20
|
+
* `locale` defaults to the locale the SvelteKit shell rendered into
|
|
21
|
+
* `<html lang>`; pass one explicitly when the app resolves it somewhere the
|
|
22
|
+
* document does not show, for example a cookie or a user preference.
|
|
23
|
+
*/
|
|
24
|
+
export function sendPreviewLocation(locale) {
|
|
18
25
|
const parentOrigin = getPreviewParentOrigin();
|
|
19
|
-
const locationChange = getPreviewLocationChange(window.location);
|
|
20
26
|
window.parent.postMessage({
|
|
21
27
|
type: 'nav/locationChange',
|
|
22
|
-
|
|
23
|
-
payload: parentOrigin ? locationChange : { path: locationChange.path },
|
|
28
|
+
payload: getPreviewLocationChange(window.location, locale ?? getDocumentLocale(document, 'en'), parentOrigin),
|
|
24
29
|
}, parentOrigin ?? '*');
|
|
25
30
|
}
|
|
26
31
|
/** Reliable-delivery reply: the parent retries an unacknowledged message forever. */
|
|
@@ -48,7 +53,7 @@ async function refreshContent(message, invalidateAll) {
|
|
|
48
53
|
* Only messages from the established parent origin are honored. History stays
|
|
49
54
|
* native, so Studio's back/forward controls need no protocol of their own.
|
|
50
55
|
*/
|
|
51
|
-
export function connectPreview({ goto, invalidateAll }) {
|
|
56
|
+
export function connectPreview({ goto, invalidateAll, locale }) {
|
|
52
57
|
const onMessage = (event) => {
|
|
53
58
|
const parentOrigin = getPreviewParentOrigin();
|
|
54
59
|
if (event.source !== window.parent || !parentOrigin || event.origin !== parentOrigin) {
|
|
@@ -63,7 +68,7 @@ export function connectPreview({ goto, invalidateAll }) {
|
|
|
63
68
|
return;
|
|
64
69
|
case 'nav/read-routes':
|
|
65
70
|
// The app owns its route table; answer with where it actually is.
|
|
66
|
-
sendPreviewLocation();
|
|
71
|
+
sendPreviewLocation(locale?.());
|
|
67
72
|
return;
|
|
68
73
|
case 'content/refresh':
|
|
69
74
|
void refreshContent(message, invalidateAll);
|
|
@@ -62,6 +62,25 @@ interface PkgWithDeps {
|
|
|
62
62
|
dependencies?: Record<string, string>;
|
|
63
63
|
devDependencies?: Record<string, string>;
|
|
64
64
|
}
|
|
65
|
+
interface PkgWithScripts {
|
|
66
|
+
scripts?: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Point the build script's `fimo validate` at the exact CLI that scaffolded the
|
|
70
|
+
* project.
|
|
71
|
+
*
|
|
72
|
+
* Inside an npm script a bare `fimo` resolves to whatever is installed in the
|
|
73
|
+
* project, which is normally the pinned dependency — but only when the install
|
|
74
|
+
* is current. The hosted build installs the tree itself and then runs this
|
|
75
|
+
* script, and `validate` is an API client: resolving it from install state
|
|
76
|
+
* means a project can be checked by a different version than the one it pins,
|
|
77
|
+
* which is how a project can look clean locally and fail on publish.
|
|
78
|
+
*
|
|
79
|
+
* Only an exact npm version is pinned. A source checkout resolves to
|
|
80
|
+
* `file:<path>`, which is not an npx spec — and there a bare `fimo` already
|
|
81
|
+
* runs the very binary being developed, which is the point of that flow.
|
|
82
|
+
*/
|
|
83
|
+
export declare function rewriteFimoValidateCommand(pkg: PkgWithScripts, pin?: string | null): boolean;
|
|
65
84
|
/**
|
|
66
85
|
* Rewrite `fimo` (in dependencies + devDependencies) to the current pin. Only
|
|
67
86
|
* touches entries that look like a value we own: the template's dist-tag, an old
|
|
@@ -164,6 +164,39 @@ function findDevJson(startPath) {
|
|
|
164
164
|
current = parent;
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* Point the build script's `fimo validate` at the exact CLI that scaffolded the
|
|
169
|
+
* project.
|
|
170
|
+
*
|
|
171
|
+
* Inside an npm script a bare `fimo` resolves to whatever is installed in the
|
|
172
|
+
* project, which is normally the pinned dependency — but only when the install
|
|
173
|
+
* is current. The hosted build installs the tree itself and then runs this
|
|
174
|
+
* script, and `validate` is an API client: resolving it from install state
|
|
175
|
+
* means a project can be checked by a different version than the one it pins,
|
|
176
|
+
* which is how a project can look clean locally and fail on publish.
|
|
177
|
+
*
|
|
178
|
+
* Only an exact npm version is pinned. A source checkout resolves to
|
|
179
|
+
* `file:<path>`, which is not an npx spec — and there a bare `fimo` already
|
|
180
|
+
* runs the very binary being developed, which is the point of that flow.
|
|
181
|
+
*/
|
|
182
|
+
export function rewriteFimoValidateCommand(pkg, pin = resolveFimoPin()) {
|
|
183
|
+
if (!pin || !/^\d+\.\d+\.\d+/.test(pin)) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
const build = pkg.scripts?.build;
|
|
187
|
+
if (typeof build !== 'string') {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// Only the validate invocation. Anything else the script runs through `fimo`
|
|
191
|
+
// is local file work, not an API client, and pinning it would add a fetch for
|
|
192
|
+
// no reason.
|
|
193
|
+
const pinned = build.replace(/\bfimo validate\b/, `npx fimo@${pin} validate`);
|
|
194
|
+
if (pinned === build) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
pkg.scripts.build = pinned;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
167
200
|
/**
|
|
168
201
|
* Rewrite `fimo` (in dependencies + devDependencies) to the current pin. Only
|
|
169
202
|
* touches entries that look like a value we own: the template's dist-tag, an old
|
|
@@ -120,10 +120,7 @@ function contentRuntimeImportFor(framework) {
|
|
|
120
120
|
if (framework === 'astro') {
|
|
121
121
|
return 'fimo/astro';
|
|
122
122
|
}
|
|
123
|
-
|
|
124
|
-
// public runtime profile is `sveltekit`. Both describe the same codegen
|
|
125
|
-
// target; callers may supply either the detector evidence or the profile.
|
|
126
|
-
if (framework === 'sveltekit' || framework === 'sveltekit-1') {
|
|
123
|
+
if (framework === 'sveltekit') {
|
|
127
124
|
return 'fimo/sveltekit';
|
|
128
125
|
}
|
|
129
126
|
return 'fimo/ui';
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { FimoAppConfigSchema } from '@fimo/api-contracts';
|
|
4
4
|
import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
|
|
5
|
+
import { validatePageRegistrySchemaReferences } from './validate-page-registry.js';
|
|
5
6
|
export function validateFimoConfig(cwd) {
|
|
6
7
|
const configPath = resolveFimoConfigPath(cwd);
|
|
7
8
|
if (!fs.existsSync(configPath)) {
|
|
@@ -17,7 +18,10 @@ export function validateFimoConfig(cwd) {
|
|
|
17
18
|
}
|
|
18
19
|
const parsed = FimoAppConfigSchema.safeParse(value);
|
|
19
20
|
if (parsed.success) {
|
|
20
|
-
return {
|
|
21
|
+
return {
|
|
22
|
+
errors: validatePageRegistrySchemaReferences(cwd, value, displayPath),
|
|
23
|
+
configChecked: true,
|
|
24
|
+
};
|
|
21
25
|
}
|
|
22
26
|
return {
|
|
23
27
|
errors: parsed.error.issues.map((issue) => {
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pageSourceParamExpressions, parsePageParamExpression, } from '@fimo/api-contracts';
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function fieldList(value) {
|
|
8
|
+
if (typeof value === 'string') {
|
|
9
|
+
return [value];
|
|
10
|
+
}
|
|
11
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
|
|
12
|
+
}
|
|
13
|
+
function queryFields(query) {
|
|
14
|
+
if (!isRecord(query)) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const fields = new Set();
|
|
18
|
+
if (isRecord(query.where)) {
|
|
19
|
+
for (const field of Object.keys(query.where)) {
|
|
20
|
+
if (!field.startsWith('$')) {
|
|
21
|
+
fields.add(field.split('.')[0]);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const sort of fieldList(query.sort)) {
|
|
26
|
+
fields.add(sort.replace(/^-/, '').split('.')[0]);
|
|
27
|
+
}
|
|
28
|
+
for (const field of [...fieldList(query.fields), ...fieldList(query.populate)]) {
|
|
29
|
+
fields.add(field.split('.')[0]);
|
|
30
|
+
}
|
|
31
|
+
return [...fields];
|
|
32
|
+
}
|
|
33
|
+
function readSchemas(cwd) {
|
|
34
|
+
const directory = path.join(cwd, 'src/schemas');
|
|
35
|
+
if (!fs.existsSync(directory)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const schemas = new Map();
|
|
39
|
+
for (const file of fs.readdirSync(directory).filter((name) => name.endsWith('.json'))) {
|
|
40
|
+
const fallbackUid = path.basename(file, '.json');
|
|
41
|
+
let value;
|
|
42
|
+
try {
|
|
43
|
+
value = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!isRecord(value)) {
|
|
50
|
+
schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const uid = typeof value.uid === 'string' && value.uid ? value.uid : fallbackUid;
|
|
54
|
+
schemas.set(uid, {
|
|
55
|
+
fields: isRecord(value.fields) ? value.fields : {},
|
|
56
|
+
isSingleton: value.isSingleton === true,
|
|
57
|
+
valid: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return schemas;
|
|
61
|
+
}
|
|
62
|
+
function schemaFields(schema) {
|
|
63
|
+
return new Set([...Object.keys(schema.fields), 'id', 'slug']);
|
|
64
|
+
}
|
|
65
|
+
function queryableFields(schema) {
|
|
66
|
+
return new Set([...schemaFields(schema), 'createdAt', 'updatedAt']);
|
|
67
|
+
}
|
|
68
|
+
function validateQuery(query, pointer, schema, params, schemas) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
const unknownField = queryFields(query).find((field) => !queryableFields(schema).has(field));
|
|
71
|
+
if (unknownField) {
|
|
72
|
+
errors.push(`${pointer}: Query field "${unknownField}" does not exist on the referenced schema.`);
|
|
73
|
+
}
|
|
74
|
+
for (const expression of pageSourceParamExpressions(query)) {
|
|
75
|
+
const parsed = parsePageParamExpression(expression);
|
|
76
|
+
const binding = parsed ? params[parsed.param] : undefined;
|
|
77
|
+
if (!parsed || !binding || typeof binding.schema !== 'string') {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const boundSchema = schemas.get(binding.schema);
|
|
81
|
+
const field = parsed.field.split('.')[0];
|
|
82
|
+
if (boundSchema?.valid && !schemaFields(boundSchema).has(field)) {
|
|
83
|
+
errors.push(`${pointer}: ${expression} reads missing field "${field}" from schema "${binding.schema}".`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return errors;
|
|
87
|
+
}
|
|
88
|
+
/** Cross-check Page config references against the project's schema source files. */
|
|
89
|
+
export function validatePageRegistrySchemaReferences(cwd, config, displayPath) {
|
|
90
|
+
const schemas = readSchemas(cwd);
|
|
91
|
+
if (!schemas || !isRecord(config.routes)) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const errors = [];
|
|
95
|
+
for (const [routeId, rawRoute] of Object.entries(config.routes)) {
|
|
96
|
+
if (!isRecord(rawRoute)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const params = isRecord(rawRoute.params) ? rawRoute.params : {};
|
|
100
|
+
for (const [param, binding] of Object.entries(params)) {
|
|
101
|
+
const pointer = `${displayPath}#routes.${routeId}.params.${param}`;
|
|
102
|
+
if (typeof binding.schema !== 'string') {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const schema = schemas.get(binding.schema);
|
|
106
|
+
if (!schema) {
|
|
107
|
+
errors.push(`${pointer}.schema: Unknown schema "${binding.schema}".`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!schema.valid) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (schema.isSingleton) {
|
|
114
|
+
errors.push(`${pointer}.schema: Page params require a Collection, but "${binding.schema}" is a Single.`);
|
|
115
|
+
}
|
|
116
|
+
const fields = schemaFields(schema);
|
|
117
|
+
for (const key of ['queryField', 'displayField']) {
|
|
118
|
+
const field = binding[key];
|
|
119
|
+
if (typeof field === 'string' && !fields.has(field)) {
|
|
120
|
+
errors.push(`${pointer}.${key}: Field "${field}" does not exist on schema "${binding.schema}".`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (typeof binding.queryField === 'string' && !['id', 'slug'].includes(binding.queryField)) {
|
|
124
|
+
const definition = schema.fields[binding.queryField];
|
|
125
|
+
if (isRecord(definition)) {
|
|
126
|
+
if (definition.required !== true) {
|
|
127
|
+
errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "required": true.`);
|
|
128
|
+
}
|
|
129
|
+
if (definition.unique !== true) {
|
|
130
|
+
errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "unique": true.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
errors.push(...validateQuery(binding.query, `${pointer}.query`, schema, params, schemas));
|
|
135
|
+
}
|
|
136
|
+
if (!isRecord(rawRoute.sources)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
for (const [sourceName, source] of Object.entries(rawRoute.sources)) {
|
|
140
|
+
if (!isRecord(source) || (source.type !== 'single' && source.type !== 'collection')) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const pointer = `${displayPath}#routes.${routeId}.sources.${sourceName}`;
|
|
144
|
+
if (typeof source.schema !== 'string') {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const schema = schemas.get(source.schema);
|
|
148
|
+
if (!schema) {
|
|
149
|
+
errors.push(`${pointer}.schema: Unknown schema "${source.schema}".`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (!schema.valid) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (source.type === 'single' && !schema.isSingleton) {
|
|
156
|
+
errors.push(`${pointer}.schema: Source expects a Single, but "${source.schema}" is a Collection.`);
|
|
157
|
+
}
|
|
158
|
+
if (source.type === 'collection' && schema.isSingleton) {
|
|
159
|
+
errors.push(`${pointer}.schema: Source expects a Collection, but "${source.schema}" is a Single.`);
|
|
160
|
+
}
|
|
161
|
+
if (source.type === 'collection') {
|
|
162
|
+
errors.push(...validateQuery(source.query, `${pointer}.query`, schema, params, schemas));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return errors;
|
|
167
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { pageSourceParamExpressions } from '@fimo/api-contracts';
|
|
4
|
+
import { FIMO_LOCALE_PARAM, resolveRouteContract } from '../runtime/routes/index.js';
|
|
3
5
|
import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
|
|
4
6
|
import { parseRoutesSource } from './lib/parse-routes-file.js';
|
|
5
7
|
function readJson(filePath) {
|
|
@@ -10,20 +12,15 @@ function readJson(filePath) {
|
|
|
10
12
|
return {};
|
|
11
13
|
}
|
|
12
14
|
}
|
|
13
|
-
function getParamNamesFromPath(routePath) {
|
|
14
|
-
const normalized = routePath === '/' ? '/' : routePath.replace(/\/+$/, '');
|
|
15
|
-
const segments = normalized.split('/').filter(Boolean);
|
|
16
|
-
return segments.filter((segment) => segment.startsWith(':')).map((segment) => segment.slice(1));
|
|
17
|
-
}
|
|
18
15
|
function loadConfigRoutes(config) {
|
|
19
16
|
const configRoutes = config.routes ?? {};
|
|
20
17
|
return Object.entries(configRoutes).map(([id, route]) => ({
|
|
21
18
|
id,
|
|
22
19
|
path: route.path ?? '/',
|
|
23
20
|
label: route.label ?? null,
|
|
24
|
-
|
|
25
|
-
params: route.params ?? null,
|
|
21
|
+
sources: route.sources ?? null,
|
|
26
22
|
seo: route.seo ?? null,
|
|
23
|
+
contract: resolveRouteContract(id, route),
|
|
27
24
|
}));
|
|
28
25
|
}
|
|
29
26
|
export function validateRouteMetadata(options = {}) {
|
|
@@ -47,26 +44,26 @@ export function validateRouteMetadata(options = {}) {
|
|
|
47
44
|
const pathToFile = new Map();
|
|
48
45
|
for (const fr of fileRoutes) {
|
|
49
46
|
if (fr.path && fr.file) {
|
|
50
|
-
pathToFile.set(fr.path, fr.file);
|
|
47
|
+
pathToFile.set(normalizeLocaleSpelling(fr.path), fr.file);
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
50
|
const i18n = validateI18nConfig(config);
|
|
54
51
|
errors.push(...i18n.errors);
|
|
55
52
|
const configRoutes = loadConfigRoutes(config);
|
|
56
53
|
// Cross-validate: non-layout routes in routes.ts should have matching fimo-config entries
|
|
57
|
-
const configPaths = new Set(configRoutes.
|
|
54
|
+
const configPaths = new Set(configRoutes.flatMap(registrablePaths));
|
|
58
55
|
for (const fileRoute of fileRoutes) {
|
|
59
56
|
if (fileRoute.isLayout) {
|
|
60
57
|
continue;
|
|
61
58
|
}
|
|
62
|
-
if (fileRoute.path && !configPaths.has(fileRoute.path)) {
|
|
59
|
+
if (fileRoute.path && !configPaths.has(normalizeLocaleSpelling(fileRoute.path))) {
|
|
63
60
|
errors.push(`Route with path "${fileRoute.path}" (file: "${fileRoute.file}") exists in src/routes.ts but has no matching entry in ${configDisplayPath}.\nFix: add a route entry to ${configDisplayPath} "routes" with a matching "path".`);
|
|
64
61
|
}
|
|
65
62
|
}
|
|
66
63
|
// Cross-validate: fimo-config routes should have matching routes.ts entries
|
|
67
|
-
const filePaths = new Set(fileRoutes.filter((r) => !r.isLayout && r.path).map((r) => r.path));
|
|
64
|
+
const filePaths = new Set(fileRoutes.filter((r) => !r.isLayout && r.path).map((r) => normalizeLocaleSpelling(r.path)));
|
|
68
65
|
for (const route of configRoutes) {
|
|
69
|
-
if (!filePaths.has(
|
|
66
|
+
if (!registrablePaths(route).some((candidate) => filePaths.has(candidate))) {
|
|
70
67
|
errors.push(`Route "${route.id}" (${route.path}) exists in ${configDisplayPath} but is not registered in src/routes.ts.\nFix: add the route to src/routes.ts. Example: route('${route.path.replace(/^\//, '')}', './pages/YourPage.tsx')`);
|
|
71
68
|
}
|
|
72
69
|
}
|
|
@@ -76,7 +73,7 @@ export function validateRouteMetadata(options = {}) {
|
|
|
76
73
|
fileRoutes,
|
|
77
74
|
i18n,
|
|
78
75
|
configDisplayPath,
|
|
79
|
-
|
|
76
|
+
routesSourceUsesLegacyHelper: sourceUsesLegacyFimoRoutes(routesFilePath),
|
|
80
77
|
}));
|
|
81
78
|
}
|
|
82
79
|
const routeById = new Map();
|
|
@@ -88,9 +85,18 @@ export function validateRouteMetadata(options = {}) {
|
|
|
88
85
|
}
|
|
89
86
|
routeById.set(route.id, route);
|
|
90
87
|
}
|
|
88
|
+
for (const route of configRoutes) {
|
|
89
|
+
for (const issue of route.contract.issues) {
|
|
90
|
+
errors.push(`Route "${route.id}" (${route.path}): ${issue.message}\nFix: ${issue.fix}`);
|
|
91
|
+
}
|
|
92
|
+
errors.push(...validatePageSources(route));
|
|
93
|
+
}
|
|
94
|
+
errors.push(...validateRouteShapeCollisions(configRoutes));
|
|
91
95
|
// Validate route modules (exports, loaders, meta)
|
|
92
96
|
for (const route of configRoutes) {
|
|
93
|
-
const file =
|
|
97
|
+
const file = registrablePaths(route)
|
|
98
|
+
.map((candidate) => pathToFile.get(candidate))
|
|
99
|
+
.find(Boolean);
|
|
94
100
|
if (!file) {
|
|
95
101
|
continue;
|
|
96
102
|
}
|
|
@@ -99,22 +105,6 @@ export function validateRouteMetadata(options = {}) {
|
|
|
99
105
|
errors.push(`Route "${route.id}" (${route.path}) references missing module "${file}".\nFix: create the file at src/${file}.`);
|
|
100
106
|
continue;
|
|
101
107
|
}
|
|
102
|
-
if (route.paramNames.length > 0) {
|
|
103
|
-
if (!route.params) {
|
|
104
|
-
errors.push(`Dynamic route "${route.id}" (${route.path}) needs \`params\` metadata to generate prerender URLs.\nFix: add to the route in ${configDisplayPath}: \`"params": { "${route.paramNames[0] || 'param'}": { "schema": "SchemaName", "queryField": "${route.paramNames[0] || 'slug'}", "displayField": "title" } }\``);
|
|
105
|
-
}
|
|
106
|
-
else {
|
|
107
|
-
for (const paramName of route.paramNames) {
|
|
108
|
-
const paramInfo = route.params[paramName];
|
|
109
|
-
if (!paramInfo || typeof paramInfo !== 'object') {
|
|
110
|
-
errors.push(`Dynamic route "${route.id}" (${route.path}): param "${paramName}" must be an object with "schema" and "queryField".\nFix: \`"params": { "${paramName}": { "schema": "SchemaName", "queryField": "${paramName}" } }\``);
|
|
111
|
-
}
|
|
112
|
-
else if (!paramInfo.schema || !paramInfo.queryField) {
|
|
113
|
-
errors.push(`Dynamic route "${route.id}" (${route.path}): param "${paramName}" is missing ${!paramInfo.schema ? '"schema"' : '"queryField"'}.\nFix: \`"${paramName}": { "schema": "SchemaName", "queryField": "${paramName}" }\``);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
108
|
if (route.seo) {
|
|
119
109
|
const GLOBAL_VARIABLES = new Set(['siteName', 'label']);
|
|
120
110
|
const seoFields = [
|
|
@@ -132,15 +122,17 @@ export function validateRouteMetadata(options = {}) {
|
|
|
132
122
|
if (GLOBAL_VARIABLES.has(variable)) {
|
|
133
123
|
continue;
|
|
134
124
|
}
|
|
125
|
+
// Only entry params carry data a template can read; the locale
|
|
126
|
+
// segment resolves to a tag, never to an entry.
|
|
127
|
+
const entryParamNames = route.contract.entryParams.map((param) => param.name);
|
|
128
|
+
const availableParams = entryParamNames.length > 0 ? entryParamNames.join(', ') : 'none';
|
|
135
129
|
if (variable.includes('.')) {
|
|
136
130
|
const paramKey = variable.split('.')[0];
|
|
137
|
-
if (!
|
|
138
|
-
|
|
139
|
-
errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" but "${paramKey}" is not a declared param. Available params: ${availableParams}.`);
|
|
131
|
+
if (!entryParamNames.includes(paramKey)) {
|
|
132
|
+
errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" but "${paramKey}" is not a declared entry param. Available params: ${availableParams}.`);
|
|
140
133
|
}
|
|
141
134
|
}
|
|
142
135
|
else {
|
|
143
|
-
const availableParams = route.params ? Object.keys(route.params).join(', ') : 'none';
|
|
144
136
|
errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" which is not a global variable (siteName, label) and not a dotted param reference (e.g., "{{param.field}}"). Available params: ${availableParams}.`);
|
|
145
137
|
}
|
|
146
138
|
}
|
|
@@ -149,6 +141,52 @@ export function validateRouteMetadata(options = {}) {
|
|
|
149
141
|
}
|
|
150
142
|
return { errors, routesChecked: configRoutes.length };
|
|
151
143
|
}
|
|
144
|
+
function validatePageSources(route) {
|
|
145
|
+
if (!route.sources) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
const errors = [];
|
|
149
|
+
const entryParamNames = new Set(route.contract.entryParams.map((param) => param.name));
|
|
150
|
+
for (const [sourceName, rawSource] of Object.entries(route.sources)) {
|
|
151
|
+
if (!/^[A-Za-z][\w-]*$/.test(sourceName) || !isRecord(rawSource)) {
|
|
152
|
+
errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" is not a valid named source.`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (rawSource.type === 'labels') {
|
|
156
|
+
const match = rawSource.match;
|
|
157
|
+
if (!Array.isArray(match) ||
|
|
158
|
+
match.length === 0 ||
|
|
159
|
+
match.some((pattern) => {
|
|
160
|
+
if (typeof pattern !== 'string')
|
|
161
|
+
return true;
|
|
162
|
+
const wildcard = pattern.indexOf('*');
|
|
163
|
+
return wildcard !== -1 && !(pattern.length > 2 && pattern.endsWith('.*') && wildcard === pattern.length - 1);
|
|
164
|
+
})) {
|
|
165
|
+
errors.push(`Route "${route.id}" (${route.path}): Labels source "${sourceName}" needs exact keys or prefixes ending in .*.`);
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (rawSource.type !== 'single' && rawSource.type !== 'collection') {
|
|
170
|
+
errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" type must be labels, single, or collection.`);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (typeof rawSource.schema !== 'string' || !rawSource.schema) {
|
|
174
|
+
errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" needs a schema.`);
|
|
175
|
+
}
|
|
176
|
+
if (rawSource.type === 'collection' && rawSource.query !== undefined && !isRecord(rawSource.query)) {
|
|
177
|
+
errors.push(`Route "${route.id}" (${route.path}): Collection source "${sourceName}" query must be an object.`);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
for (const expression of pageSourceParamExpressions(rawSource.query)) {
|
|
181
|
+
const match = /^\$params\.([A-Za-z_][\w-]*)\.([A-Za-z_][\w.-]*)$/.exec(expression);
|
|
182
|
+
const param = match?.[1];
|
|
183
|
+
if (!param || !entryParamNames.has(param)) {
|
|
184
|
+
errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" references undeclared param "${param ?? expression}". Fix: use $params.<declared-entry-param>.<field>.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return errors;
|
|
189
|
+
}
|
|
152
190
|
function validateI18nConfig(config) {
|
|
153
191
|
const rawI18n = isRecord(config.i18n) ? config.i18n : {};
|
|
154
192
|
const errors = [];
|
|
@@ -192,37 +230,90 @@ function validateI18nConfig(config) {
|
|
|
192
230
|
defaultLocale: effectiveDefaultLocale,
|
|
193
231
|
locales,
|
|
194
232
|
routingStrategy,
|
|
195
|
-
prefixDefaultLocale: rawRouting.prefixDefaultLocale === true,
|
|
196
233
|
errors,
|
|
197
234
|
};
|
|
198
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Two routes that produce the same URL are only detectable once the locale
|
|
238
|
+
* segment is stripped: `/pricing` and `/:locale/pricing` both answer
|
|
239
|
+
* `/pricing`, and nothing downstream can decide which one owns it.
|
|
240
|
+
*/
|
|
241
|
+
function validateRouteShapeCollisions(configRoutes) {
|
|
242
|
+
const errors = [];
|
|
243
|
+
const byShape = new Map();
|
|
244
|
+
for (const route of configRoutes) {
|
|
245
|
+
const shape = route.contract.canonicalPath
|
|
246
|
+
.split('/')
|
|
247
|
+
.map((segment) => (segment.startsWith(':') ? ':param' : segment))
|
|
248
|
+
.join('/');
|
|
249
|
+
const previous = byShape.get(shape);
|
|
250
|
+
if (previous && (previous.contract.localeParam || route.contract.localeParam)) {
|
|
251
|
+
errors.push(`Routes "${previous.id}" (${previous.path}) and "${route.id}" (${route.path}) both answer "${route.contract.canonicalPath}".\nFix: keep one of them, or give them distinct paths — a locale segment does not make two routes distinct.`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (!previous) {
|
|
255
|
+
byShape.set(shape, route);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return errors;
|
|
259
|
+
}
|
|
199
260
|
function validateLocaleRouteCollisions(args) {
|
|
200
261
|
const errors = [];
|
|
201
262
|
const reservedPrefixes = new Set(args.i18n.locales.map((locale) => locale.toLowerCase()));
|
|
202
263
|
for (const route of args.configRoutes) {
|
|
203
264
|
const first = firstPathSegment(route.path);
|
|
204
265
|
if (first && reservedPrefixes.has(first.toLowerCase())) {
|
|
205
|
-
errors.push(`Route "${route.id}" (${route.path}) uses reserved locale prefix "${first}".
|
|
266
|
+
errors.push(`Route "${route.id}" (${route.path}) uses reserved locale prefix "${first}". Declare the locale as "/:locale/..." in ${args.configDisplayPath} and as ":locale?/..." in React Router so the default locale remains unprefixed.`);
|
|
206
267
|
}
|
|
207
268
|
}
|
|
208
|
-
if (!args.
|
|
269
|
+
if (!args.routesSourceUsesLegacyHelper) {
|
|
209
270
|
for (const route of args.fileRoutes) {
|
|
210
271
|
if (!route.path || route.isLayout) {
|
|
211
272
|
continue;
|
|
212
273
|
}
|
|
213
274
|
const first = firstPathSegment(route.path);
|
|
275
|
+
// `:locale` is the declared locale segment, not an accidental catch-all.
|
|
276
|
+
if (first && normalizeLocaleSpelling(first) === `:${FIMO_LOCALE_PARAM}`) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
214
279
|
if (first?.startsWith(':') || first === '*') {
|
|
215
|
-
errors.push(`Route "${route.path}" is a top-level dynamic route in a multi-locale
|
|
280
|
+
errors.push(`Route "${route.path}" is a top-level dynamic route in a multi-locale project. It can capture locale URLs like "/${args.i18n.locales[0]}". Put an explicit optional ":locale?" segment first in the React Router path, or keep this route outside the localized route boundary.`);
|
|
216
281
|
}
|
|
217
282
|
}
|
|
218
283
|
}
|
|
219
284
|
return errors;
|
|
220
285
|
}
|
|
221
|
-
function
|
|
222
|
-
if (
|
|
286
|
+
function sourceUsesLegacyFimoRoutes(routesFilePath) {
|
|
287
|
+
if (!fs.existsSync(routesFilePath)) {
|
|
223
288
|
return false;
|
|
224
289
|
}
|
|
225
|
-
|
|
290
|
+
const source = fs.readFileSync(routesFilePath, 'utf-8');
|
|
291
|
+
const importMatch = source.match(/import\s*\{(?<imports>[^}]*)\}\s*from\s*['"]fimo\/react-router\/routes['"]/s);
|
|
292
|
+
const importedName = importMatch?.groups?.imports
|
|
293
|
+
?.split(',')
|
|
294
|
+
.map((specifier) => specifier.trim().match(/^fimoRoutes(?:\s+as\s+(?<alias>[A-Za-z_$][\w$]*))?$/)?.groups?.alias ??
|
|
295
|
+
(specifier.trim() === 'fimoRoutes' ? 'fimoRoutes' : null))
|
|
296
|
+
.find((name) => Boolean(name));
|
|
297
|
+
return importedName ? new RegExp(`\\b${importedName}\\s*\\(`).test(source) : false;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* React Router spells an omittable segment `:locale?`; the config spells the
|
|
301
|
+
* same thing `:locale`. Compare the two worlds in one spelling.
|
|
302
|
+
*/
|
|
303
|
+
function normalizeLocaleSpelling(routePath) {
|
|
304
|
+
return routePath.replace(`:${FIMO_LOCALE_PARAM}?`, `:${FIMO_LOCALE_PARAM}`);
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* The `src/routes.ts` paths that legitimately register one config route. A
|
|
308
|
+
* route with an optional locale answers two URL shapes, so an app may register
|
|
309
|
+
* one optional-segment route or one route per shape.
|
|
310
|
+
*/
|
|
311
|
+
function registrablePaths(route) {
|
|
312
|
+
const declared = normalizeLocaleSpelling(route.contract.path);
|
|
313
|
+
if (!route.contract.localeParam?.optional) {
|
|
314
|
+
return [declared];
|
|
315
|
+
}
|
|
316
|
+
return [declared, route.contract.canonicalPath];
|
|
226
317
|
}
|
|
227
318
|
function firstPathSegment(routePath) {
|
|
228
319
|
return routePath.split('/').filter(Boolean)[0] ?? null;
|