fimo 0.18.0 → 0.20.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/skills/fimo-cli/SKILL.md +7 -4
- package/assets/skills/fimo-cli/references/agents.md +4 -1
- package/assets/skills/fimo-cli/references/branches.md +2 -2
- package/assets/skills/fimo-cli/references/deploy.md +23 -9
- package/assets/skills/fimo-cli/references/hosting.md +114 -0
- package/assets/skills/fimo-cli/references/init.md +2 -2
- package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
- package/assets/skills/fimo-cli/references/preview.md +26 -20
- package/assets/skills/fimo-migration/references/qa.md +7 -0
- package/assets/skills/fimo-studio/references/ai-chat.md +1 -1
- package/dist/cli/bundle.json +2 -2
- package/dist/cli/index.js +107 -97
- package/dist/runtime/app/FimoProviders.js +1 -1
- package/dist/runtime/app/prefetch.js +1 -1
- package/dist/runtime/i18n/locale.d.ts +2 -3
- package/dist/runtime/i18n/locale.js +2 -27
- package/dist/runtime/paths/get-fimo-paths.js +1 -1
- package/dist/runtime/react-router/legacy/routes.js +1 -1
- package/dist/runtime/routes/index.d.ts +161 -2
- package/dist/runtime/routes/index.js +335 -3
- package/dist/runtime/seo/resolver.js +1 -1
- package/dist/runtime/shared/fimo-config.types.d.ts +4 -29
- package/dist/runtime/templates.d.ts +19 -0
- package/dist/runtime/templates.js +33 -0
- package/dist/scripts/validate-route-metadata.js +1 -1
- package/package.json +4 -3
- package/templates/react-router/package.json +1 -1
- package/dist/runtime/routes/contract.d.ts +0 -116
- package/dist/runtime/routes/contract.js +0 -377
|
@@ -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
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pageSourceParamExpressions } from '@fimo/api-contracts';
|
|
4
|
-
import { FIMO_LOCALE_PARAM, resolveRouteContract } from '../runtime/routes/
|
|
4
|
+
import { FIMO_LOCALE_PARAM, resolveRouteContract } from '../runtime/routes/index.js';
|
|
5
5
|
import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
|
|
6
6
|
import { parseRoutesSource } from './lib/parse-routes-file.js';
|
|
7
7
|
function readJson(filePath) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fimo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Fimo CLI - create, deploy, and manage Fimo projects",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fimo": "dist/cli/index.js"
|
|
@@ -118,13 +118,13 @@
|
|
|
118
118
|
},
|
|
119
119
|
"scripts": {
|
|
120
120
|
"build": "pnpm run clean && tsc -b tsconfig.json && chmod +x dist/cli/index.js",
|
|
121
|
-
"build:bundle": "node scripts/bundle-cli.mjs && node scripts/bundle-sveltekit.mjs",
|
|
121
|
+
"build:bundle": "node scripts/bundle-cli.mjs && node scripts/bundle-routes.mjs && node scripts/bundle-sveltekit.mjs",
|
|
122
122
|
"check:types": "tsc -b tsconfig.json && tsc -p tsconfig.test.json",
|
|
123
123
|
"start": "tsx src/cli/index.ts",
|
|
124
124
|
"install-cli": "pnpm build && node scripts/install-cli.mjs",
|
|
125
125
|
"publish-cli": "node scripts/publish-tarball.mjs",
|
|
126
126
|
"publish:npm": "node scripts/publish-npm.mjs",
|
|
127
|
-
"prepublishOnly": "pnpm run build && pnpm run build:bundle",
|
|
127
|
+
"prepublishOnly": "pnpm -F @fimo/routes build && pnpm run build && pnpm run build:bundle",
|
|
128
128
|
"release:bump": "node scripts/bump-version.mjs",
|
|
129
129
|
"publish:dry-run": "npm publish --dry-run",
|
|
130
130
|
"postinstall": "node scripts/postinstall.mjs",
|
|
@@ -156,6 +156,7 @@
|
|
|
156
156
|
"@fimo/cli-kit": "workspace:*",
|
|
157
157
|
"@fimo/framework-detect": "workspace:*",
|
|
158
158
|
"@fimo/mcp": "workspace:*",
|
|
159
|
+
"@fimo/routes": "workspace:*",
|
|
159
160
|
"@fimo/tsconfig": "workspace:*",
|
|
160
161
|
"@modelcontextprotocol/sdk": "1.27.1",
|
|
161
162
|
"@sveltejs/adapter-vercel": "6.3.4",
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import type { FimoConfig, FimoRouteConfig } from '../shared/fimo-config.types.js';
|
|
2
|
-
/**
|
|
3
|
-
* The reserved parameter name for the locale segment. A `:locale` placeholder
|
|
4
|
-
* is always the locale, whether or not the route declares a descriptor for it.
|
|
5
|
-
*/
|
|
6
|
-
export declare const FIMO_LOCALE_PARAM = "locale";
|
|
7
|
-
export interface FimoEntryParam {
|
|
8
|
-
type: 'entry';
|
|
9
|
-
name: string;
|
|
10
|
-
schema: string;
|
|
11
|
-
queryField: string;
|
|
12
|
-
displayField?: string;
|
|
13
|
-
}
|
|
14
|
-
export interface FimoLocaleParam {
|
|
15
|
-
type: 'locale';
|
|
16
|
-
name: string;
|
|
17
|
-
/**
|
|
18
|
-
* True (the default) means the default locale renders at the unprefixed
|
|
19
|
-
* path and only other locales carry a `/<locale>` prefix. False means every
|
|
20
|
-
* locale, the default included, renders behind the prefix.
|
|
21
|
-
*/
|
|
22
|
-
optional: boolean;
|
|
23
|
-
}
|
|
24
|
-
export type FimoRouteParam = FimoEntryParam | FimoLocaleParam;
|
|
25
|
-
export interface FimoRouteIssue {
|
|
26
|
-
/** The parameter the issue is about, when it is about one. */
|
|
27
|
-
param?: string;
|
|
28
|
-
message: string;
|
|
29
|
-
/** A concrete edit that resolves the issue. */
|
|
30
|
-
fix: string;
|
|
31
|
-
}
|
|
32
|
-
export interface FimoRouteContract {
|
|
33
|
-
id: string;
|
|
34
|
-
/** The declared route entry, kept so callers can reach `label` / `seo`. */
|
|
35
|
-
config: FimoRouteConfig;
|
|
36
|
-
/** The declared path, locale placeholder included (`/:locale/blog/:slug`). */
|
|
37
|
-
path: string;
|
|
38
|
-
/** The declared path without the locale placeholder (`/blog/:slug`). */
|
|
39
|
-
canonicalPath: string;
|
|
40
|
-
params: FimoRouteParam[];
|
|
41
|
-
entryParams: FimoEntryParam[];
|
|
42
|
-
localeParam: FimoLocaleParam | null;
|
|
43
|
-
/** Placeholders in the path that no usable descriptor resolves. */
|
|
44
|
-
unresolvedParams: string[];
|
|
45
|
-
issues: FimoRouteIssue[];
|
|
46
|
-
}
|
|
47
|
-
export interface FimoRouteMatch {
|
|
48
|
-
contract: FimoRouteContract;
|
|
49
|
-
/** Every resolved path parameter, the locale segment included. */
|
|
50
|
-
params: Record<string, string>;
|
|
51
|
-
/** Entry parameters only — the subset a content lookup may use. */
|
|
52
|
-
entryParams: Record<string, string>;
|
|
53
|
-
/** The locale this URL renders. The project default when the path omits it. */
|
|
54
|
-
locale: string;
|
|
55
|
-
/** True when the locale came from a path segment rather than the default. */
|
|
56
|
-
localeFromPath: boolean;
|
|
57
|
-
/** Static segments matched. Higher wins when several routes match. */
|
|
58
|
-
score: number;
|
|
59
|
-
}
|
|
60
|
-
/** One URL shape a route produces for one locale. */
|
|
61
|
-
export interface FimoRouteLocaleTarget {
|
|
62
|
-
locale: string;
|
|
63
|
-
/** Whether this locale renders behind a `/<locale>` prefix. */
|
|
64
|
-
prefixed: boolean;
|
|
65
|
-
}
|
|
66
|
-
export declare function normalizeRoutePath(path: string): string;
|
|
67
|
-
/**
|
|
68
|
-
* True when the path declares a locale segment, in either spelling: the config
|
|
69
|
-
* writes `:locale`, a React Router registration writes `:locale?` when the
|
|
70
|
-
* segment may be omitted.
|
|
71
|
-
*/
|
|
72
|
-
export declare function hasLocaleSegment(path: string): boolean;
|
|
73
|
-
/**
|
|
74
|
-
* Normalize one declared route into the contract every consumer reads.
|
|
75
|
-
*
|
|
76
|
-
* Parameter types resolve in this order:
|
|
77
|
-
* 1. an explicit `type`;
|
|
78
|
-
* 2. the reserved name `locale`, which infers the locale type;
|
|
79
|
-
* 3. otherwise the entry type, which is what every route written before
|
|
80
|
-
* locale-aware routing existed relies on.
|
|
81
|
-
*/
|
|
82
|
-
export declare function resolveRouteContract(id: string, route: FimoRouteConfig): FimoRouteContract;
|
|
83
|
-
export declare function resolveRouteContracts(routes: Record<string, FimoRouteConfig> | undefined): FimoRouteContract[];
|
|
84
|
-
/**
|
|
85
|
-
* Match a concrete pathname against the project's routes.
|
|
86
|
-
*
|
|
87
|
-
* The truth table, for a route declared `/:locale/x/:slug` in a project whose
|
|
88
|
-
* default locale is `en`:
|
|
89
|
-
*
|
|
90
|
-
* | URL | locale param | resolved locale | entry params |
|
|
91
|
-
* | --------------- | ------------ | --------------- | ------------------- |
|
|
92
|
-
* | `/x/hello` | optional | `en` (default) | `{ slug: 'hello' }` |
|
|
93
|
-
* | `/fr/x/bonjour` | optional | `fr` | `{ slug: 'bonjour'}`|
|
|
94
|
-
* | `/en/x/hello` | optional | no match | — |
|
|
95
|
-
* | `/x/hello` | required | no match | — |
|
|
96
|
-
* | `/en/x/hello` | required | `en` | `{ slug: 'hello' }` |
|
|
97
|
-
*
|
|
98
|
-
* A route with no locale segment always resolves the default locale.
|
|
99
|
-
*/
|
|
100
|
-
export declare function matchFimoRoute(pathnameOrUrl: string, config: Pick<FimoConfig, 'routes' | 'i18n'>): FimoRouteMatch | null;
|
|
101
|
-
/**
|
|
102
|
-
* The URL shapes a route produces, one per locale it can render.
|
|
103
|
-
*
|
|
104
|
-
* A route without a locale segment produces a single default-locale URL: under
|
|
105
|
-
* path-prefix routing it has no way to express another locale in its path.
|
|
106
|
-
*/
|
|
107
|
-
export declare function routeLocaleTargets(contract: FimoRouteContract, config: Pick<FimoConfig, 'i18n'>): FimoRouteLocaleTarget[];
|
|
108
|
-
/**
|
|
109
|
-
* Build the concrete URL for one locale target. Entry param values are
|
|
110
|
-
* URL-encoded; the locale segment is a prefix, never a substitution, so the
|
|
111
|
-
* canonical default-locale URL carries no locale at all.
|
|
112
|
-
*/
|
|
113
|
-
export declare function materializeRoutePath(contract: FimoRouteContract, target: FimoRouteLocaleTarget & {
|
|
114
|
-
params?: Record<string, string>;
|
|
115
|
-
}): string;
|
|
116
|
-
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
// The single source of truth for what a `.fimo/config.json` route means:
|
|
2
|
-
// which of its path placeholders are locales, which are content entries, which
|
|
3
|
-
// URLs it matches, and which URLs it produces. Prefetch, SEO, SSG, sitemap and
|
|
4
|
-
// `fimo validate` all read this module so they cannot drift apart.
|
|
5
|
-
//
|
|
6
|
-
// Browser-safe on purpose: no `node:` imports, no filesystem, no network.
|
|
7
|
-
import { getFimoDefaultLocale, getFimoLocales } from '../i18n/locale.js';
|
|
8
|
-
/**
|
|
9
|
-
* The reserved parameter name for the locale segment. A `:locale` placeholder
|
|
10
|
-
* is always the locale, whether or not the route declares a descriptor for it.
|
|
11
|
-
*/
|
|
12
|
-
export const FIMO_LOCALE_PARAM = 'locale';
|
|
13
|
-
export function normalizeRoutePath(path) {
|
|
14
|
-
const withSlash = path.startsWith('/') ? path : `/${path}`;
|
|
15
|
-
const collapsed = withSlash.replace(/\/{2,}/g, '/');
|
|
16
|
-
return collapsed.length > 1 ? collapsed.replace(/\/+$/, '') : collapsed;
|
|
17
|
-
}
|
|
18
|
-
function splitSegments(path) {
|
|
19
|
-
return path.split('/').filter(Boolean);
|
|
20
|
-
}
|
|
21
|
-
function isPlaceholder(segment) {
|
|
22
|
-
return segment.startsWith(':') && segment.length > 1;
|
|
23
|
-
}
|
|
24
|
-
function readPlaceholder(segment) {
|
|
25
|
-
if (!isPlaceholder(segment)) {
|
|
26
|
-
return null;
|
|
27
|
-
}
|
|
28
|
-
return {
|
|
29
|
-
name: segment.slice(1).replace(/[?*]$/, ''),
|
|
30
|
-
catchAll: segment.endsWith('*'),
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
/**
|
|
34
|
-
* True when the path declares a locale segment, in either spelling: the config
|
|
35
|
-
* writes `:locale`, a React Router registration writes `:locale?` when the
|
|
36
|
-
* segment may be omitted.
|
|
37
|
-
*/
|
|
38
|
-
export function hasLocaleSegment(path) {
|
|
39
|
-
return splitSegments(normalizeRoutePath(path)).some((segment) => segment === `:${FIMO_LOCALE_PARAM}` || segment === `:${FIMO_LOCALE_PARAM}?`);
|
|
40
|
-
}
|
|
41
|
-
function resolveParamType(name, descriptor) {
|
|
42
|
-
if (descriptor?.type) {
|
|
43
|
-
return descriptor.type;
|
|
44
|
-
}
|
|
45
|
-
// Omitting `type` keeps every pre-existing route on the entry type; only the
|
|
46
|
-
// reserved name flips the default.
|
|
47
|
-
return name === FIMO_LOCALE_PARAM ? 'locale' : 'entry';
|
|
48
|
-
}
|
|
49
|
-
function buildLocaleParam(name, descriptor, index, issues) {
|
|
50
|
-
if (name !== FIMO_LOCALE_PARAM) {
|
|
51
|
-
issues.push({
|
|
52
|
-
param: name,
|
|
53
|
-
message: `Param "${name}" declares \`"type": "locale"\` but "${FIMO_LOCALE_PARAM}" is the only locale parameter name.`,
|
|
54
|
-
fix: `Rename the path segment and the descriptor to ":${FIMO_LOCALE_PARAM}", or use \`"type": "entry"\`.`,
|
|
55
|
-
});
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
if (index !== 0) {
|
|
59
|
-
issues.push({
|
|
60
|
-
param: name,
|
|
61
|
-
message: `The ":${FIMO_LOCALE_PARAM}" segment must be the first segment of the path.`,
|
|
62
|
-
fix: `Move it to the front, for example "/:${FIMO_LOCALE_PARAM}/blog/:slug".`,
|
|
63
|
-
});
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
for (const field of ['schema', 'queryField', 'displayField', 'query']) {
|
|
67
|
-
if (descriptor?.[field] !== undefined) {
|
|
68
|
-
issues.push({
|
|
69
|
-
param: name,
|
|
70
|
-
message: `Locale param "${name}" must not declare "${field}" — the locale is read from the URL, never from the CMS.`,
|
|
71
|
-
fix: `Remove "${field}" from \`params.${name}\`.`,
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return { type: 'locale', name, optional: descriptor?.optional !== false };
|
|
76
|
-
}
|
|
77
|
-
function buildEntryParam(name, descriptor, issues) {
|
|
78
|
-
if (name === FIMO_LOCALE_PARAM) {
|
|
79
|
-
issues.push({
|
|
80
|
-
param: name,
|
|
81
|
-
message: `Param "${name}" is reserved for the locale segment and cannot use \`"type": "entry"\`.`,
|
|
82
|
-
fix: `Drop \`"type": "entry"\` from \`params.${name}\`, or rename the segment.`,
|
|
83
|
-
});
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
if (!descriptor) {
|
|
87
|
-
issues.push({
|
|
88
|
-
param: name,
|
|
89
|
-
message: `":${name}" needs \`params\` metadata before its URLs can be generated.`,
|
|
90
|
-
fix: `Add \`"params": { "${name}": { "schema": "SchemaName", "queryField": "${name}" } }\`.`,
|
|
91
|
-
});
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
if (descriptor.optional !== undefined) {
|
|
95
|
-
issues.push({
|
|
96
|
-
param: name,
|
|
97
|
-
message: `Entry param "${name}" cannot be optional — only the locale segment can be omitted from a URL.`,
|
|
98
|
-
fix: `Remove "optional" from \`params.${name}\`.`,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
if (!descriptor.schema || !descriptor.queryField) {
|
|
102
|
-
issues.push({
|
|
103
|
-
param: name,
|
|
104
|
-
message: `Entry param "${name}" is missing ${!descriptor.schema ? '"schema"' : '"queryField"'}.`,
|
|
105
|
-
fix: `Set \`"${name}": { "schema": "SchemaName", "queryField": "${name}" }\`.`,
|
|
106
|
-
});
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
return {
|
|
110
|
-
type: 'entry',
|
|
111
|
-
name,
|
|
112
|
-
schema: descriptor.schema,
|
|
113
|
-
queryField: descriptor.queryField,
|
|
114
|
-
...(descriptor.displayField ? { displayField: descriptor.displayField } : {}),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Normalize one declared route into the contract every consumer reads.
|
|
119
|
-
*
|
|
120
|
-
* Parameter types resolve in this order:
|
|
121
|
-
* 1. an explicit `type`;
|
|
122
|
-
* 2. the reserved name `locale`, which infers the locale type;
|
|
123
|
-
* 3. otherwise the entry type, which is what every route written before
|
|
124
|
-
* locale-aware routing existed relies on.
|
|
125
|
-
*/
|
|
126
|
-
export function resolveRouteContract(id, route) {
|
|
127
|
-
const path = normalizeRoutePath(route.path ?? '/');
|
|
128
|
-
const segments = splitSegments(path);
|
|
129
|
-
const declared = route.params ?? {};
|
|
130
|
-
const issues = [];
|
|
131
|
-
const params = [];
|
|
132
|
-
const unresolvedParams = [];
|
|
133
|
-
const seen = new Set();
|
|
134
|
-
segments.forEach((segment, index) => {
|
|
135
|
-
if (!isPlaceholder(segment)) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const placeholder = readPlaceholder(segment);
|
|
139
|
-
if (!placeholder) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
const { name } = placeholder;
|
|
143
|
-
if (seen.has(name)) {
|
|
144
|
-
issues.push({
|
|
145
|
-
param: name,
|
|
146
|
-
message: `Placeholder ":${name}" appears more than once.`,
|
|
147
|
-
fix: 'Give each placeholder a distinct name.',
|
|
148
|
-
});
|
|
149
|
-
unresolvedParams.push(name);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
seen.add(name);
|
|
153
|
-
const descriptor = declared[name];
|
|
154
|
-
const resolved = resolveParamType(name, descriptor) === 'locale'
|
|
155
|
-
? buildLocaleParam(name, descriptor, index, issues)
|
|
156
|
-
: buildEntryParam(name, descriptor, issues);
|
|
157
|
-
if (resolved) {
|
|
158
|
-
params.push(resolved);
|
|
159
|
-
}
|
|
160
|
-
else {
|
|
161
|
-
unresolvedParams.push(name);
|
|
162
|
-
}
|
|
163
|
-
});
|
|
164
|
-
for (const name of Object.keys(declared)) {
|
|
165
|
-
if (!seen.has(name)) {
|
|
166
|
-
issues.push({
|
|
167
|
-
param: name,
|
|
168
|
-
message: `Declared param "${name}" has no ":${name}" segment in the path.`,
|
|
169
|
-
fix: `Add ":${name}" to the path, or remove it from "params".`,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
const localeParam = params.find((param) => param.type === 'locale') ?? null;
|
|
174
|
-
const canonicalPath = localeParam
|
|
175
|
-
? normalizeRoutePath(`/${segments.filter((segment) => segment !== `:${FIMO_LOCALE_PARAM}`).join('/')}`)
|
|
176
|
-
: path;
|
|
177
|
-
return {
|
|
178
|
-
id,
|
|
179
|
-
config: route,
|
|
180
|
-
path,
|
|
181
|
-
canonicalPath,
|
|
182
|
-
params,
|
|
183
|
-
entryParams: params.filter((param) => param.type === 'entry'),
|
|
184
|
-
localeParam,
|
|
185
|
-
unresolvedParams,
|
|
186
|
-
issues,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
export function resolveRouteContracts(routes) {
|
|
190
|
-
return Object.entries(routes ?? {}).map(([id, route]) => resolveRouteContract(id, route));
|
|
191
|
-
}
|
|
192
|
-
function readLocaleVocabulary(config) {
|
|
193
|
-
return {
|
|
194
|
-
locales: config.i18n ? getFimoLocales(config) : null,
|
|
195
|
-
defaultLocale: getFimoDefaultLocale(config),
|
|
196
|
-
};
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Whether `segment` may stand in the locale slot of this route.
|
|
200
|
-
*
|
|
201
|
-
* An optional locale rejects the default locale: `/hello` is the canonical
|
|
202
|
-
* default-locale URL, so `/en/hello` is a different URL that this route does
|
|
203
|
-
* not serve rather than an alias of it.
|
|
204
|
-
*/
|
|
205
|
-
function acceptsLocaleSegment(segment, localeParam, vocabulary) {
|
|
206
|
-
if (!vocabulary.locales) {
|
|
207
|
-
return segment.length > 0;
|
|
208
|
-
}
|
|
209
|
-
if (!vocabulary.locales.includes(segment)) {
|
|
210
|
-
return false;
|
|
211
|
-
}
|
|
212
|
-
return localeParam.optional ? segment !== vocabulary.defaultLocale : true;
|
|
213
|
-
}
|
|
214
|
-
function matchContract(urlSegments, contract, vocabulary) {
|
|
215
|
-
const patternSegments = splitSegments(contract.path);
|
|
216
|
-
const localeParam = contract.localeParam;
|
|
217
|
-
const catchAllIndex = patternSegments.findIndex((segment) => readPlaceholder(segment)?.catchAll);
|
|
218
|
-
const hasCatchAll = catchAllIndex !== -1;
|
|
219
|
-
const hasLocaleLength = hasCatchAll
|
|
220
|
-
? urlSegments.length >= patternSegments.length
|
|
221
|
-
: urlSegments.length === patternSegments.length;
|
|
222
|
-
const withLocale = localeParam?.optional
|
|
223
|
-
? hasLocaleLength && acceptsLocaleSegment(urlSegments[0] ?? '', localeParam, vocabulary)
|
|
224
|
-
: hasLocaleLength;
|
|
225
|
-
const withoutLocale = Boolean(localeParam?.optional) &&
|
|
226
|
-
(hasCatchAll
|
|
227
|
-
? urlSegments.length >= patternSegments.length - 1
|
|
228
|
-
: urlSegments.length === patternSegments.length - 1);
|
|
229
|
-
if (!localeParam && !withLocale) {
|
|
230
|
-
return null;
|
|
231
|
-
}
|
|
232
|
-
if (localeParam && !withLocale && !withoutLocale) {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
const params = {};
|
|
236
|
-
const entryParams = {};
|
|
237
|
-
let localeFromPath = null;
|
|
238
|
-
let score = 0;
|
|
239
|
-
let offset = 0;
|
|
240
|
-
for (const [index, patternSegment] of patternSegments.entries()) {
|
|
241
|
-
if (localeParam && patternSegment === `:${FIMO_LOCALE_PARAM}`) {
|
|
242
|
-
if (!withLocale) {
|
|
243
|
-
// The optional locale is absent: every later segment shifts left.
|
|
244
|
-
offset = -1;
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
const value = urlSegments[index];
|
|
248
|
-
if (!acceptsLocaleSegment(value, localeParam, vocabulary)) {
|
|
249
|
-
return null;
|
|
250
|
-
}
|
|
251
|
-
localeFromPath = value;
|
|
252
|
-
params[FIMO_LOCALE_PARAM] = value;
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
const value = urlSegments[index + offset];
|
|
256
|
-
if (value === undefined) {
|
|
257
|
-
return null;
|
|
258
|
-
}
|
|
259
|
-
const placeholder = readPlaceholder(patternSegment);
|
|
260
|
-
if (placeholder) {
|
|
261
|
-
const { name, catchAll } = placeholder;
|
|
262
|
-
const rawValue = catchAll ? urlSegments.slice(index + offset).join('/') : value;
|
|
263
|
-
const decoded = safeDecode(rawValue);
|
|
264
|
-
params[name] = decoded;
|
|
265
|
-
entryParams[name] = decoded;
|
|
266
|
-
if (catchAll) {
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
if (patternSegment !== value) {
|
|
272
|
-
return null;
|
|
273
|
-
}
|
|
274
|
-
score += 1;
|
|
275
|
-
}
|
|
276
|
-
return {
|
|
277
|
-
contract,
|
|
278
|
-
params,
|
|
279
|
-
entryParams,
|
|
280
|
-
locale: localeFromPath ?? vocabulary.defaultLocale,
|
|
281
|
-
localeFromPath: localeFromPath !== null,
|
|
282
|
-
score,
|
|
283
|
-
};
|
|
284
|
-
}
|
|
285
|
-
/** Callers pass a pathname or a whole request URL; both must reduce to a path. */
|
|
286
|
-
function readPathname(input) {
|
|
287
|
-
try {
|
|
288
|
-
return new URL(input, 'http://fimo.invalid').pathname;
|
|
289
|
-
}
|
|
290
|
-
catch {
|
|
291
|
-
return input.split('#')[0].split('?')[0] || '/';
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
function safeDecode(value) {
|
|
295
|
-
try {
|
|
296
|
-
return decodeURIComponent(value);
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
return value;
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Match a concrete pathname against the project's routes.
|
|
304
|
-
*
|
|
305
|
-
* The truth table, for a route declared `/:locale/x/:slug` in a project whose
|
|
306
|
-
* default locale is `en`:
|
|
307
|
-
*
|
|
308
|
-
* | URL | locale param | resolved locale | entry params |
|
|
309
|
-
* | --------------- | ------------ | --------------- | ------------------- |
|
|
310
|
-
* | `/x/hello` | optional | `en` (default) | `{ slug: 'hello' }` |
|
|
311
|
-
* | `/fr/x/bonjour` | optional | `fr` | `{ slug: 'bonjour'}`|
|
|
312
|
-
* | `/en/x/hello` | optional | no match | — |
|
|
313
|
-
* | `/x/hello` | required | no match | — |
|
|
314
|
-
* | `/en/x/hello` | required | `en` | `{ slug: 'hello' }` |
|
|
315
|
-
*
|
|
316
|
-
* A route with no locale segment always resolves the default locale.
|
|
317
|
-
*/
|
|
318
|
-
export function matchFimoRoute(pathnameOrUrl, config) {
|
|
319
|
-
const urlSegments = splitSegments(normalizeRoutePath(readPathname(pathnameOrUrl)));
|
|
320
|
-
const vocabulary = readLocaleVocabulary(config);
|
|
321
|
-
let best = null;
|
|
322
|
-
for (const contract of resolveRouteContracts(config.routes)) {
|
|
323
|
-
// A route entry without a declared path describes no URL — matching it
|
|
324
|
-
// against the normalized `/` default would hijack the index route.
|
|
325
|
-
if (!contract.config.path) {
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
const match = matchContract(urlSegments, contract, vocabulary);
|
|
329
|
-
if (match && (!best || match.score > best.score)) {
|
|
330
|
-
best = match;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
return best;
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* The URL shapes a route produces, one per locale it can render.
|
|
337
|
-
*
|
|
338
|
-
* A route without a locale segment produces a single default-locale URL: under
|
|
339
|
-
* path-prefix routing it has no way to express another locale in its path.
|
|
340
|
-
*/
|
|
341
|
-
export function routeLocaleTargets(contract, config) {
|
|
342
|
-
const defaultLocale = getFimoDefaultLocale(config);
|
|
343
|
-
if (!contract.localeParam) {
|
|
344
|
-
return [{ locale: defaultLocale, prefixed: false }];
|
|
345
|
-
}
|
|
346
|
-
const optional = contract.localeParam.optional;
|
|
347
|
-
return getFimoLocales(config).map((locale) => ({
|
|
348
|
-
locale,
|
|
349
|
-
prefixed: optional ? locale !== defaultLocale : true,
|
|
350
|
-
}));
|
|
351
|
-
}
|
|
352
|
-
/**
|
|
353
|
-
* Build the concrete URL for one locale target. Entry param values are
|
|
354
|
-
* URL-encoded; the locale segment is a prefix, never a substitution, so the
|
|
355
|
-
* canonical default-locale URL carries no locale at all.
|
|
356
|
-
*/
|
|
357
|
-
export function materializeRoutePath(contract, target) {
|
|
358
|
-
const params = target.params ?? {};
|
|
359
|
-
const substituted = splitSegments(contract.canonicalPath).map((segment) => {
|
|
360
|
-
const placeholder = readPlaceholder(segment);
|
|
361
|
-
if (!placeholder) {
|
|
362
|
-
return segment;
|
|
363
|
-
}
|
|
364
|
-
const value = params[placeholder.name];
|
|
365
|
-
if (value === undefined) {
|
|
366
|
-
return segment;
|
|
367
|
-
}
|
|
368
|
-
return placeholder.catchAll
|
|
369
|
-
? value
|
|
370
|
-
.split('/')
|
|
371
|
-
.map((part) => encodeURIComponent(part))
|
|
372
|
-
.join('/')
|
|
373
|
-
: encodeURIComponent(value);
|
|
374
|
-
});
|
|
375
|
-
const prefix = target.prefixed ? [target.locale] : [];
|
|
376
|
-
return normalizeRoutePath(`/${[...prefix, ...substituted].join('/')}`);
|
|
377
|
-
}
|