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
|
@@ -15,7 +15,7 @@ import { ScreenshotProvider } from '../iframe/ScreenshotProvider.js';
|
|
|
15
15
|
import { getSafePreviewNavigationPath, isPreviewRuntimeEnabled } from '../preview/bridge.js';
|
|
16
16
|
import { getPreviewLocationChange, getPreviewParentOrigin } from '../preview/location.js';
|
|
17
17
|
import { getActiveRouterRoutes } from '../react-router/active-routes.js';
|
|
18
|
-
import { matchFimoRoute } from '../routes/
|
|
18
|
+
import { matchFimoRoute } from '../routes/index.js';
|
|
19
19
|
import { OptionalTracking } from '../t/OptionalTracking.js';
|
|
20
20
|
export { getPreviewLocationChange, getPreviewLocationPath } from '../preview/location.js';
|
|
21
21
|
const titleCase = (seg) => seg
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @ts-ignore - virtual module provided by fimoConfigPlugin in fimo/vite
|
|
2
2
|
import fimoConfig from 'virtual:fimo-config';
|
|
3
3
|
import { resolveFimoLocale } from '../i18n/locale.js';
|
|
4
|
-
import { matchFimoRoute } from '../routes/
|
|
4
|
+
import { matchFimoRoute } from '../routes/index.js';
|
|
5
5
|
export function resolveFimoLocaleForUrl(url) {
|
|
6
6
|
return resolveFimoLocale(url, fimoConfig);
|
|
7
7
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { getFimoDefaultLocale, getFimoLocales, normalizeFimoLocale } from '../routes/index.js';
|
|
1
2
|
import type { FimoConfig } from '../shared/fimo-config.types.js';
|
|
3
|
+
export { getFimoDefaultLocale, getFimoLocales, normalizeFimoLocale };
|
|
2
4
|
export interface FimoLocaleResolution {
|
|
3
5
|
locale: string;
|
|
4
6
|
defaultLocale: string;
|
|
@@ -13,8 +15,6 @@ export interface FimoLocaleRequestLike {
|
|
|
13
15
|
url?: string;
|
|
14
16
|
pathname?: string;
|
|
15
17
|
}
|
|
16
|
-
export declare function getFimoDefaultLocale(config: Pick<FimoConfig, 'i18n'> | undefined): string;
|
|
17
|
-
export declare function getFimoLocales(config: Pick<FimoConfig, 'i18n'> | undefined): string[];
|
|
18
18
|
export declare function resolveFimoLocale(requestLike: string | URL | FimoLocaleRequestLike, config: Pick<FimoConfig, 'i18n'> | undefined): FimoLocaleResolution;
|
|
19
19
|
/**
|
|
20
20
|
* Rewrite a path so it renders in `locale`. The default locale is always
|
|
@@ -23,5 +23,4 @@ export declare function resolveFimoLocale(requestLike: string | URL | FimoLocale
|
|
|
23
23
|
export declare function localizeFimoPath(path: string, locale: string, config: Pick<FimoConfig, 'i18n'> | undefined): string;
|
|
24
24
|
export declare function getFimoTextDirection(locale: string): 'ltr' | 'rtl';
|
|
25
25
|
export declare function toHtmlLang(locale: string): string;
|
|
26
|
-
export declare function normalizeFimoLocale(locale: string | null | undefined): string | null;
|
|
27
26
|
//# sourceMappingURL=locale.d.ts.map
|
|
@@ -1,17 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
4
|
-
export function getFimoLocales(config) {
|
|
5
|
-
const defaultLocale = getFimoDefaultLocale(config);
|
|
6
|
-
const locales = new Set([defaultLocale]);
|
|
7
|
-
for (const locale of config?.i18n?.locales ?? []) {
|
|
8
|
-
const clean = normalizeFimoLocale(locale);
|
|
9
|
-
if (clean) {
|
|
10
|
-
locales.add(clean);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
return [...locales];
|
|
14
|
-
}
|
|
1
|
+
import { getFimoDefaultLocale, getFimoLocales, normalizeFimoLocale } from '../routes/index.js';
|
|
2
|
+
export { getFimoDefaultLocale, getFimoLocales, normalizeFimoLocale };
|
|
15
3
|
export function resolveFimoLocale(requestLike, config) {
|
|
16
4
|
const pathname = normalizePathname(readPathname(requestLike));
|
|
17
5
|
const defaultLocale = getFimoDefaultLocale(config);
|
|
@@ -77,19 +65,6 @@ export function getFimoTextDirection(locale) {
|
|
|
77
65
|
export function toHtmlLang(locale) {
|
|
78
66
|
return locale.replace('_', '-');
|
|
79
67
|
}
|
|
80
|
-
export function normalizeFimoLocale(locale) {
|
|
81
|
-
const trimmed = locale?.trim();
|
|
82
|
-
if (!trimmed) {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
const candidate = trimmed.replace(/_/g, '-');
|
|
86
|
-
try {
|
|
87
|
-
return Intl.getCanonicalLocales(candidate)[0] ?? null;
|
|
88
|
-
}
|
|
89
|
-
catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
68
|
function normalizePathname(pathname) {
|
|
94
69
|
const withSlash = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
|
95
70
|
const normalized = withSlash.replace(/\/{2,}/g, '/');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// dynamic routes are expanded by fetching schema entries from the content API.
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import { resolve } from 'node:path';
|
|
6
|
-
import { materializeRoutePath, resolveRouteContracts, routeLocaleTargets } from '../routes/
|
|
6
|
+
import { materializeRoutePath, resolveRouteContracts, routeLocaleTargets } from '../routes/index.js';
|
|
7
7
|
import { resolveSeoForPath } from '../seo/resolver.js';
|
|
8
8
|
import { getConfigServer } from '../shared/fimo-config.server.js';
|
|
9
9
|
import { resolveLocalRuntimeEnv } from '../shared/local-runtime-env.js';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getFimoDefaultLocale, getFimoLocales } from '../../i18n/locale.js';
|
|
2
|
-
import { hasLocaleSegment } from '../../routes/
|
|
2
|
+
import { hasLocaleSegment } from '../../routes/index.js';
|
|
3
3
|
import { getConfigServer } from '../../shared/fimo-config.server.js';
|
|
4
4
|
/**
|
|
5
5
|
* @deprecated New React Router projects should declare optional `:locale?`
|
|
@@ -1,3 +1,162 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
1
|
+
export interface FimoRouteSeoConfig {
|
|
2
|
+
title?: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
image?: string;
|
|
6
|
+
favicon?: string;
|
|
7
|
+
twitterSite?: string;
|
|
8
|
+
robots?: string;
|
|
9
|
+
themeColor?: string;
|
|
10
|
+
locale?: string;
|
|
11
|
+
siteName?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface FimoRouteParamConfig<Query = unknown> {
|
|
14
|
+
type?: 'entry' | 'locale';
|
|
15
|
+
schema?: string;
|
|
16
|
+
queryField?: string;
|
|
17
|
+
displayField?: string;
|
|
18
|
+
query?: Query;
|
|
19
|
+
optional?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface FimoRouteConfig<Sources = unknown, Query = unknown, Seo = FimoRouteSeoConfig> {
|
|
22
|
+
label?: string;
|
|
23
|
+
path?: string;
|
|
24
|
+
group?: string;
|
|
25
|
+
content?: {
|
|
26
|
+
labels?: string[];
|
|
27
|
+
singles?: string[];
|
|
28
|
+
};
|
|
29
|
+
sources?: Sources;
|
|
30
|
+
icon?: string;
|
|
31
|
+
params?: Record<string, FimoRouteParamConfig<Query>>;
|
|
32
|
+
seo?: Seo;
|
|
33
|
+
}
|
|
34
|
+
export interface FimoRoutesI18nConfig {
|
|
35
|
+
defaultLocale?: string;
|
|
36
|
+
locales?: string[];
|
|
37
|
+
routing?: {
|
|
38
|
+
strategy?: 'path-prefix';
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
export interface FimoRoutesConfig<RouteConfig extends FimoRouteConfig = FimoRouteConfig> {
|
|
42
|
+
i18n?: FimoRoutesI18nConfig;
|
|
43
|
+
routes?: Record<string, RouteConfig>;
|
|
44
|
+
}
|
|
45
|
+
export declare function normalizeFimoLocale(locale: string | null | undefined): string | null;
|
|
46
|
+
export declare function getFimoDefaultLocale(config: Pick<FimoRoutesConfig, 'i18n'> | undefined): string;
|
|
47
|
+
export declare function getFimoLocales(config: Pick<FimoRoutesConfig, 'i18n'> | undefined): string[];
|
|
48
|
+
/**
|
|
49
|
+
* The reserved parameter name for the locale segment. A `:locale` placeholder
|
|
50
|
+
* is always the locale, whether or not the route declares a descriptor for it.
|
|
51
|
+
*/
|
|
52
|
+
export declare const FIMO_LOCALE_PARAM = "locale";
|
|
53
|
+
export interface FimoEntryParam {
|
|
54
|
+
type: 'entry';
|
|
55
|
+
name: string;
|
|
56
|
+
schema: string;
|
|
57
|
+
queryField: string;
|
|
58
|
+
displayField?: string;
|
|
59
|
+
}
|
|
60
|
+
export interface FimoLocaleParam {
|
|
61
|
+
type: 'locale';
|
|
62
|
+
name: string;
|
|
63
|
+
/**
|
|
64
|
+
* True (the default) means the default locale renders at the unprefixed
|
|
65
|
+
* path and only other locales carry a `/<locale>` prefix. False means every
|
|
66
|
+
* locale, the default included, renders behind the prefix.
|
|
67
|
+
*/
|
|
68
|
+
optional: boolean;
|
|
69
|
+
}
|
|
70
|
+
export type FimoRouteParam = FimoEntryParam | FimoLocaleParam;
|
|
71
|
+
export interface FimoRouteIssue {
|
|
72
|
+
/** The parameter the issue is about, when it is about one. */
|
|
73
|
+
param?: string;
|
|
74
|
+
message: string;
|
|
75
|
+
/** A concrete edit that resolves the issue. */
|
|
76
|
+
fix: string;
|
|
77
|
+
}
|
|
78
|
+
export interface FimoRouteContract<RouteConfig extends FimoRouteConfig = FimoRouteConfig> {
|
|
79
|
+
id: string;
|
|
80
|
+
/** The declared route entry, kept so callers can reach `label` / `seo`. */
|
|
81
|
+
config: RouteConfig;
|
|
82
|
+
/** The declared path, locale placeholder included (`/:locale/blog/:slug`). */
|
|
83
|
+
path: string;
|
|
84
|
+
/** The declared path without the locale placeholder (`/blog/:slug`). */
|
|
85
|
+
canonicalPath: string;
|
|
86
|
+
params: FimoRouteParam[];
|
|
87
|
+
entryParams: FimoEntryParam[];
|
|
88
|
+
localeParam: FimoLocaleParam | null;
|
|
89
|
+
/** Placeholders in the path that no usable descriptor resolves. */
|
|
90
|
+
unresolvedParams: string[];
|
|
91
|
+
issues: FimoRouteIssue[];
|
|
92
|
+
}
|
|
93
|
+
export interface FimoRouteMatch<RouteConfig extends FimoRouteConfig = FimoRouteConfig> {
|
|
94
|
+
contract: FimoRouteContract<RouteConfig>;
|
|
95
|
+
/** Every resolved path parameter, the locale segment included. */
|
|
96
|
+
params: Record<string, string>;
|
|
97
|
+
/** Entry parameters only — the subset a content lookup may use. */
|
|
98
|
+
entryParams: Record<string, string>;
|
|
99
|
+
/** The locale this URL renders. The project default when the path omits it. */
|
|
100
|
+
locale: string;
|
|
101
|
+
/** True when the locale came from a path segment rather than the default. */
|
|
102
|
+
localeFromPath: boolean;
|
|
103
|
+
/** Static segments matched. Higher wins when several routes match. */
|
|
104
|
+
score: number;
|
|
105
|
+
}
|
|
106
|
+
/** One URL shape a route produces for one locale. */
|
|
107
|
+
export interface FimoRouteLocaleTarget {
|
|
108
|
+
locale: string;
|
|
109
|
+
/** Whether this locale renders behind a `/<locale>` prefix. */
|
|
110
|
+
prefixed: boolean;
|
|
111
|
+
}
|
|
112
|
+
export declare function normalizeRoutePath(path: string): string;
|
|
113
|
+
/**
|
|
114
|
+
* True when the path declares a locale segment, in either spelling: the config
|
|
115
|
+
* writes `:locale`, a React Router registration writes `:locale?` when the
|
|
116
|
+
* segment may be omitted.
|
|
117
|
+
*/
|
|
118
|
+
export declare function hasLocaleSegment(path: string): boolean;
|
|
119
|
+
/**
|
|
120
|
+
* Normalize one declared route into the contract every consumer reads.
|
|
121
|
+
*
|
|
122
|
+
* Parameter types resolve in this order:
|
|
123
|
+
* 1. an explicit `type`;
|
|
124
|
+
* 2. the reserved name `locale`, which infers the locale type;
|
|
125
|
+
* 3. otherwise the entry type, which is what every route written before
|
|
126
|
+
* locale-aware routing existed relies on.
|
|
127
|
+
*/
|
|
128
|
+
export declare function resolveRouteContract<RouteConfig extends FimoRouteConfig>(id: string, route: RouteConfig): FimoRouteContract<RouteConfig>;
|
|
129
|
+
export declare function resolveRouteContracts<RouteConfig extends FimoRouteConfig>(routes: Record<string, RouteConfig> | undefined): FimoRouteContract<RouteConfig>[];
|
|
130
|
+
/**
|
|
131
|
+
* Match a concrete pathname against the project's routes.
|
|
132
|
+
*
|
|
133
|
+
* The truth table, for a route declared `/:locale/x/:slug` in a project whose
|
|
134
|
+
* default locale is `en`:
|
|
135
|
+
*
|
|
136
|
+
* | URL | locale param | resolved locale | entry params |
|
|
137
|
+
* | --------------- | ------------ | --------------- | ------------------- |
|
|
138
|
+
* | `/x/hello` | optional | `en` (default) | `{ slug: 'hello' }` |
|
|
139
|
+
* | `/fr/x/bonjour` | optional | `fr` | `{ slug: 'bonjour'}`|
|
|
140
|
+
* | `/en/x/hello` | optional | no match | — |
|
|
141
|
+
* | `/x/hello` | required | no match | — |
|
|
142
|
+
* | `/en/x/hello` | required | `en` | `{ slug: 'hello' }` |
|
|
143
|
+
*
|
|
144
|
+
* A route with no locale segment always resolves the default locale.
|
|
145
|
+
*/
|
|
146
|
+
export declare function matchFimoRoute<RouteConfig extends FimoRouteConfig>(pathnameOrUrl: string, config: Pick<FimoRoutesConfig<RouteConfig>, 'routes' | 'i18n'>): FimoRouteMatch<RouteConfig> | null;
|
|
147
|
+
/**
|
|
148
|
+
* The URL shapes a route produces, one per locale it can render.
|
|
149
|
+
*
|
|
150
|
+
* A route without a locale segment produces a single default-locale URL: under
|
|
151
|
+
* path-prefix routing it has no way to express another locale in its path.
|
|
152
|
+
*/
|
|
153
|
+
export declare function routeLocaleTargets(contract: FimoRouteContract, config: Pick<FimoRoutesConfig, 'i18n'>): FimoRouteLocaleTarget[];
|
|
154
|
+
/**
|
|
155
|
+
* Build the concrete URL for one locale target. Entry param values are
|
|
156
|
+
* URL-encoded; the locale segment is a prefix, never a substitution, so the
|
|
157
|
+
* canonical default-locale URL carries no locale at all.
|
|
158
|
+
*/
|
|
159
|
+
export declare function materializeRoutePath(contract: FimoRouteContract, target: FimoRouteLocaleTarget & {
|
|
160
|
+
params?: Record<string, string>;
|
|
161
|
+
}): string;
|
|
3
162
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,3 +1,335 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// ../../packages/routes/src/index.ts
|
|
2
|
+
function normalizeFimoLocale(locale) {
|
|
3
|
+
const trimmed = locale?.trim();
|
|
4
|
+
if (!trimmed) {
|
|
5
|
+
return null;
|
|
6
|
+
}
|
|
7
|
+
const candidate = trimmed.replace(/_/g, "-");
|
|
8
|
+
try {
|
|
9
|
+
return Intl.getCanonicalLocales(candidate)[0] ?? null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function getFimoDefaultLocale(config) {
|
|
15
|
+
return normalizeFimoLocale(config?.i18n?.defaultLocale) ?? "en";
|
|
16
|
+
}
|
|
17
|
+
function getFimoLocales(config) {
|
|
18
|
+
const defaultLocale = getFimoDefaultLocale(config);
|
|
19
|
+
const locales = /* @__PURE__ */ new Set([defaultLocale]);
|
|
20
|
+
for (const locale of config?.i18n?.locales ?? []) {
|
|
21
|
+
const clean = normalizeFimoLocale(locale);
|
|
22
|
+
if (clean) {
|
|
23
|
+
locales.add(clean);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return [...locales];
|
|
27
|
+
}
|
|
28
|
+
var FIMO_LOCALE_PARAM = "locale";
|
|
29
|
+
function normalizeRoutePath(path) {
|
|
30
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
31
|
+
const collapsed = withSlash.replace(/\/{2,}/g, "/");
|
|
32
|
+
return collapsed.length > 1 ? collapsed.replace(/\/+$/, "") : collapsed;
|
|
33
|
+
}
|
|
34
|
+
function splitSegments(path) {
|
|
35
|
+
return path.split("/").filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
function isPlaceholder(segment) {
|
|
38
|
+
return segment.startsWith(":") && segment.length > 1;
|
|
39
|
+
}
|
|
40
|
+
function readPlaceholder(segment) {
|
|
41
|
+
if (!isPlaceholder(segment)) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: segment.slice(1).replace(/[?*]$/, ""),
|
|
46
|
+
catchAll: segment.endsWith("*")
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function hasLocaleSegment(path) {
|
|
50
|
+
return splitSegments(normalizeRoutePath(path)).some(
|
|
51
|
+
(segment) => segment === `:${FIMO_LOCALE_PARAM}` || segment === `:${FIMO_LOCALE_PARAM}?`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
function resolveParamType(name, descriptor) {
|
|
55
|
+
if (descriptor?.type) {
|
|
56
|
+
return descriptor.type;
|
|
57
|
+
}
|
|
58
|
+
return name === FIMO_LOCALE_PARAM ? "locale" : "entry";
|
|
59
|
+
}
|
|
60
|
+
function buildLocaleParam(name, descriptor, index, issues) {
|
|
61
|
+
if (name !== FIMO_LOCALE_PARAM) {
|
|
62
|
+
issues.push({
|
|
63
|
+
param: name,
|
|
64
|
+
message: `Param "${name}" declares \`"type": "locale"\` but "${FIMO_LOCALE_PARAM}" is the only locale parameter name.`,
|
|
65
|
+
fix: `Rename the path segment and the descriptor to ":${FIMO_LOCALE_PARAM}", or use \`"type": "entry"\`.`
|
|
66
|
+
});
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (index !== 0) {
|
|
70
|
+
issues.push({
|
|
71
|
+
param: name,
|
|
72
|
+
message: `The ":${FIMO_LOCALE_PARAM}" segment must be the first segment of the path.`,
|
|
73
|
+
fix: `Move it to the front, for example "/:${FIMO_LOCALE_PARAM}/blog/:slug".`
|
|
74
|
+
});
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
for (const field of ["schema", "queryField", "displayField", "query"]) {
|
|
78
|
+
if (descriptor?.[field] !== void 0) {
|
|
79
|
+
issues.push({
|
|
80
|
+
param: name,
|
|
81
|
+
message: `Locale param "${name}" must not declare "${field}" \u2014 the locale is read from the URL, never from the CMS.`,
|
|
82
|
+
fix: `Remove "${field}" from \`params.${name}\`.`
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { type: "locale", name, optional: descriptor?.optional !== false };
|
|
87
|
+
}
|
|
88
|
+
function buildEntryParam(name, descriptor, issues) {
|
|
89
|
+
if (name === FIMO_LOCALE_PARAM) {
|
|
90
|
+
issues.push({
|
|
91
|
+
param: name,
|
|
92
|
+
message: `Param "${name}" is reserved for the locale segment and cannot use \`"type": "entry"\`.`,
|
|
93
|
+
fix: `Drop \`"type": "entry"\` from \`params.${name}\`, or rename the segment.`
|
|
94
|
+
});
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
if (!descriptor) {
|
|
98
|
+
issues.push({
|
|
99
|
+
param: name,
|
|
100
|
+
message: `":${name}" needs \`params\` metadata before its URLs can be generated.`,
|
|
101
|
+
fix: `Add \`"params": { "${name}": { "schema": "SchemaName", "queryField": "${name}" } }\`.`
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (descriptor.optional !== void 0) {
|
|
106
|
+
issues.push({
|
|
107
|
+
param: name,
|
|
108
|
+
message: `Entry param "${name}" cannot be optional \u2014 only the locale segment can be omitted from a URL.`,
|
|
109
|
+
fix: `Remove "optional" from \`params.${name}\`.`
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (!descriptor.schema || !descriptor.queryField) {
|
|
113
|
+
issues.push({
|
|
114
|
+
param: name,
|
|
115
|
+
message: `Entry param "${name}" is missing ${!descriptor.schema ? '"schema"' : '"queryField"'}.`,
|
|
116
|
+
fix: `Set \`"${name}": { "schema": "SchemaName", "queryField": "${name}" }\`.`
|
|
117
|
+
});
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
type: "entry",
|
|
122
|
+
name,
|
|
123
|
+
schema: descriptor.schema,
|
|
124
|
+
queryField: descriptor.queryField,
|
|
125
|
+
...descriptor.displayField ? { displayField: descriptor.displayField } : {}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function resolveRouteContract(id, route) {
|
|
129
|
+
const path = normalizeRoutePath(route.path ?? "/");
|
|
130
|
+
const segments = splitSegments(path);
|
|
131
|
+
const declared = route.params ?? {};
|
|
132
|
+
const issues = [];
|
|
133
|
+
const params = [];
|
|
134
|
+
const unresolvedParams = [];
|
|
135
|
+
const seen = /* @__PURE__ */ new Set();
|
|
136
|
+
segments.forEach((segment, index) => {
|
|
137
|
+
if (!isPlaceholder(segment)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const placeholder = readPlaceholder(segment);
|
|
141
|
+
if (!placeholder) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { name } = placeholder;
|
|
145
|
+
if (seen.has(name)) {
|
|
146
|
+
issues.push({
|
|
147
|
+
param: name,
|
|
148
|
+
message: `Placeholder ":${name}" appears more than once.`,
|
|
149
|
+
fix: "Give each placeholder a distinct name."
|
|
150
|
+
});
|
|
151
|
+
unresolvedParams.push(name);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
seen.add(name);
|
|
155
|
+
const descriptor = declared[name];
|
|
156
|
+
const resolved = resolveParamType(name, descriptor) === "locale" ? buildLocaleParam(name, descriptor, index, issues) : buildEntryParam(name, descriptor, issues);
|
|
157
|
+
if (resolved) {
|
|
158
|
+
params.push(resolved);
|
|
159
|
+
} else {
|
|
160
|
+
unresolvedParams.push(name);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
for (const name of Object.keys(declared)) {
|
|
164
|
+
if (!seen.has(name)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
param: name,
|
|
167
|
+
message: `Declared param "${name}" has no ":${name}" segment in the path.`,
|
|
168
|
+
fix: `Add ":${name}" to the path, or remove it from "params".`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const localeParam = params.find((param) => param.type === "locale") ?? null;
|
|
173
|
+
const canonicalPath = localeParam ? normalizeRoutePath(`/${segments.filter((segment) => segment !== `:${FIMO_LOCALE_PARAM}`).join("/")}`) : path;
|
|
174
|
+
return {
|
|
175
|
+
id,
|
|
176
|
+
config: route,
|
|
177
|
+
path,
|
|
178
|
+
canonicalPath,
|
|
179
|
+
params,
|
|
180
|
+
entryParams: params.filter((param) => param.type === "entry"),
|
|
181
|
+
localeParam,
|
|
182
|
+
unresolvedParams,
|
|
183
|
+
issues
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function resolveRouteContracts(routes) {
|
|
187
|
+
return Object.entries(routes ?? {}).map(([id, route]) => resolveRouteContract(id, route));
|
|
188
|
+
}
|
|
189
|
+
function readLocaleVocabulary(config) {
|
|
190
|
+
return {
|
|
191
|
+
locales: config.i18n ? getFimoLocales(config) : null,
|
|
192
|
+
defaultLocale: getFimoDefaultLocale(config)
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function acceptsLocaleSegment(segment, localeParam, vocabulary) {
|
|
196
|
+
if (!vocabulary.locales) {
|
|
197
|
+
return segment.length > 0;
|
|
198
|
+
}
|
|
199
|
+
if (!vocabulary.locales.includes(segment)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
return localeParam.optional ? segment !== vocabulary.defaultLocale : true;
|
|
203
|
+
}
|
|
204
|
+
function matchContract(urlSegments, contract, vocabulary) {
|
|
205
|
+
const patternSegments = splitSegments(contract.path);
|
|
206
|
+
const localeParam = contract.localeParam;
|
|
207
|
+
const catchAllIndex = patternSegments.findIndex((segment) => readPlaceholder(segment)?.catchAll);
|
|
208
|
+
const hasCatchAll = catchAllIndex !== -1;
|
|
209
|
+
const hasLocaleLength = hasCatchAll ? urlSegments.length >= patternSegments.length : urlSegments.length === patternSegments.length;
|
|
210
|
+
const withLocale = localeParam?.optional ? hasLocaleLength && acceptsLocaleSegment(urlSegments[0] ?? "", localeParam, vocabulary) : hasLocaleLength;
|
|
211
|
+
const withoutLocale = Boolean(localeParam?.optional) && (hasCatchAll ? urlSegments.length >= patternSegments.length - 1 : urlSegments.length === patternSegments.length - 1);
|
|
212
|
+
if (!localeParam && !withLocale) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (localeParam && !withLocale && !withoutLocale) {
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
const params = {};
|
|
219
|
+
const entryParams = {};
|
|
220
|
+
let localeFromPath = null;
|
|
221
|
+
let score = 0;
|
|
222
|
+
let offset = 0;
|
|
223
|
+
for (const [index, patternSegment] of patternSegments.entries()) {
|
|
224
|
+
if (localeParam && patternSegment === `:${FIMO_LOCALE_PARAM}`) {
|
|
225
|
+
if (!withLocale) {
|
|
226
|
+
offset = -1;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const value2 = urlSegments[index];
|
|
230
|
+
if (!acceptsLocaleSegment(value2, localeParam, vocabulary)) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
localeFromPath = value2;
|
|
234
|
+
params[FIMO_LOCALE_PARAM] = value2;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const value = urlSegments[index + offset];
|
|
238
|
+
if (value === void 0) {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
const placeholder = readPlaceholder(patternSegment);
|
|
242
|
+
if (placeholder) {
|
|
243
|
+
const { name, catchAll } = placeholder;
|
|
244
|
+
const rawValue = catchAll ? urlSegments.slice(index + offset).join("/") : value;
|
|
245
|
+
const decoded = safeDecode(rawValue);
|
|
246
|
+
params[name] = decoded;
|
|
247
|
+
entryParams[name] = decoded;
|
|
248
|
+
if (catchAll) {
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (patternSegment !== value) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
score += 1;
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
contract,
|
|
260
|
+
params,
|
|
261
|
+
entryParams,
|
|
262
|
+
locale: localeFromPath ?? vocabulary.defaultLocale,
|
|
263
|
+
localeFromPath: localeFromPath !== null,
|
|
264
|
+
score
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
function readPathname(input) {
|
|
268
|
+
try {
|
|
269
|
+
return new URL(input, "http://fimo.invalid").pathname;
|
|
270
|
+
} catch {
|
|
271
|
+
return input.split("#")[0].split("?")[0] || "/";
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function safeDecode(value) {
|
|
275
|
+
try {
|
|
276
|
+
return decodeURIComponent(value);
|
|
277
|
+
} catch {
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function matchFimoRoute(pathnameOrUrl, config) {
|
|
282
|
+
const urlSegments = splitSegments(normalizeRoutePath(readPathname(pathnameOrUrl)));
|
|
283
|
+
const vocabulary = readLocaleVocabulary(config);
|
|
284
|
+
let best = null;
|
|
285
|
+
for (const contract of resolveRouteContracts(config.routes)) {
|
|
286
|
+
if (!contract.config.path) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
const match = matchContract(urlSegments, contract, vocabulary);
|
|
290
|
+
if (match && (!best || match.score > best.score)) {
|
|
291
|
+
best = match;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return best;
|
|
295
|
+
}
|
|
296
|
+
function routeLocaleTargets(contract, config) {
|
|
297
|
+
const defaultLocale = getFimoDefaultLocale(config);
|
|
298
|
+
if (!contract.localeParam) {
|
|
299
|
+
return [{ locale: defaultLocale, prefixed: false }];
|
|
300
|
+
}
|
|
301
|
+
const optional = contract.localeParam.optional;
|
|
302
|
+
return getFimoLocales(config).map((locale) => ({
|
|
303
|
+
locale,
|
|
304
|
+
prefixed: optional ? locale !== defaultLocale : true
|
|
305
|
+
}));
|
|
306
|
+
}
|
|
307
|
+
function materializeRoutePath(contract, target) {
|
|
308
|
+
const params = target.params ?? {};
|
|
309
|
+
const substituted = splitSegments(contract.canonicalPath).map((segment) => {
|
|
310
|
+
const placeholder = readPlaceholder(segment);
|
|
311
|
+
if (!placeholder) {
|
|
312
|
+
return segment;
|
|
313
|
+
}
|
|
314
|
+
const value = params[placeholder.name];
|
|
315
|
+
if (value === void 0) {
|
|
316
|
+
return segment;
|
|
317
|
+
}
|
|
318
|
+
return placeholder.catchAll ? value.split("/").map((part) => encodeURIComponent(part)).join("/") : encodeURIComponent(value);
|
|
319
|
+
});
|
|
320
|
+
const prefix = target.prefixed ? [target.locale] : [];
|
|
321
|
+
return normalizeRoutePath(`/${[...prefix, ...substituted].join("/")}`);
|
|
322
|
+
}
|
|
323
|
+
export {
|
|
324
|
+
FIMO_LOCALE_PARAM,
|
|
325
|
+
getFimoDefaultLocale,
|
|
326
|
+
getFimoLocales,
|
|
327
|
+
hasLocaleSegment,
|
|
328
|
+
matchFimoRoute,
|
|
329
|
+
materializeRoutePath,
|
|
330
|
+
normalizeFimoLocale,
|
|
331
|
+
normalizeRoutePath,
|
|
332
|
+
resolveRouteContract,
|
|
333
|
+
resolveRouteContracts,
|
|
334
|
+
routeLocaleTargets
|
|
335
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { FimoRouteConfig as SharedFimoRouteConfig, FimoRouteParamConfig as SharedFimoRouteParamConfig } from '../routes/index.js';
|
|
1
2
|
export type FimoFramework = 'astro' | 'nextjs' | 'nuxtjs' | 'react-router' | 'remix' | 'sveltekit' | 'vite' | 'vue';
|
|
2
3
|
export interface FimoRuntimeCommands {
|
|
3
4
|
dev?: string;
|
|
@@ -44,38 +45,12 @@ export interface RouteSEO {
|
|
|
44
45
|
* A path parameter descriptor as it appears in `.fimo/config.json`.
|
|
45
46
|
*
|
|
46
47
|
* Deliberately permissive — it mirrors hand-written JSON. `resolveRouteContract`
|
|
47
|
-
*
|
|
48
|
+
* from the public `fimo/routes` entry narrows it into the discriminated
|
|
48
49
|
* `FimoRouteParam` union and reports whatever does not add up; `fimo validate`
|
|
49
50
|
* surfaces those issues.
|
|
50
51
|
*/
|
|
51
|
-
export
|
|
52
|
-
|
|
53
|
-
* Omitted means `'entry'`, which is what every route written before
|
|
54
|
-
* locale-aware routing relies on. The reserved name `locale` infers
|
|
55
|
-
* `'locale'` instead.
|
|
56
|
-
*/
|
|
57
|
-
type?: 'entry' | 'locale';
|
|
58
|
-
/** Entry params only, and required for them. */
|
|
59
|
-
schema?: string;
|
|
60
|
-
queryField?: string;
|
|
61
|
-
displayField?: string;
|
|
62
|
-
query?: import('@fimo/api-contracts').PageCollectionSourceQuery;
|
|
63
|
-
/** Locale params only. Defaults to true: the default locale is unprefixed. */
|
|
64
|
-
optional?: boolean;
|
|
65
|
-
}
|
|
66
|
-
export interface FimoRouteConfig {
|
|
67
|
-
label?: string;
|
|
68
|
-
path?: string;
|
|
69
|
-
group?: string;
|
|
70
|
-
content?: {
|
|
71
|
-
labels?: string[];
|
|
72
|
-
singles?: string[];
|
|
73
|
-
};
|
|
74
|
-
sources?: import('@fimo/api-contracts').PageSources;
|
|
75
|
-
icon?: string;
|
|
76
|
-
params?: Record<string, FimoRouteParamConfig>;
|
|
77
|
-
seo?: RouteSEO;
|
|
78
|
-
}
|
|
52
|
+
export type FimoRouteParamConfig = SharedFimoRouteParamConfig<import('@fimo/api-contracts').PageCollectionSourceQuery>;
|
|
53
|
+
export type FimoRouteConfig = SharedFimoRouteConfig<import('@fimo/api-contracts').PageSources, import('@fimo/api-contracts').PageCollectionSourceQuery, RouteSEO>;
|
|
79
54
|
export interface FimoI18nRoutingConfig {
|
|
80
55
|
/**
|
|
81
56
|
* Legacy compatibility for projects that still carry a global routing
|
|
@@ -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
|