fimo 0.16.0 → 0.18.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 +5 -2
- package/assets/skills/fimo-cli/references/content.md +2 -2
- package/assets/skills/fimo-cli/references/deploy.md +2 -0
- package/assets/skills/fimo-cli/references/init.md +11 -4
- 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/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/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 +76 -78
- 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 +4 -0
- package/dist/runtime/i18n/locale.js +14 -14
- 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/contract.d.ts +116 -0
- package/dist/runtime/routes/contract.js +377 -0
- package/dist/runtime/routes/index.d.ts +3 -0
- package/dist/runtime/routes/index.js +3 -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 +34 -8
- 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/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 +7 -3
- 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/package.json +1 -1
- 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/package.json +1 -1
- 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 +2 -2
- 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/package.json +1 -1
- 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
|
@@ -0,0 +1,377 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { FIMO_LOCALE_PARAM, hasLocaleSegment, materializeRoutePath, matchFimoRoute, normalizeRoutePath, resolveRouteContract, resolveRouteContracts, routeLocaleTargets, } from './contract.js';
|
|
2
|
+
export type { FimoEntryParam, FimoLocaleParam, FimoRouteContract, FimoRouteIssue, FimoRouteLocaleTarget, FimoRouteMatch, FimoRouteParam, } from './contract.js';
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// `fimo/routes` — the shared route contract read by every consumer of
|
|
2
|
+
// `.fimo/config.json#routes`.
|
|
3
|
+
export { FIMO_LOCALE_PARAM, hasLocaleSegment, materializeRoutePath, matchFimoRoute, normalizeRoutePath, resolveRouteContract, resolveRouteContracts, routeLocaleTargets, } from './contract.js';
|
|
@@ -8,7 +8,7 @@ import { AppErrorBoundary, onError } from '../core/AppErrorBoundary.js';
|
|
|
8
8
|
import { ViteLifecycles } from '../core/ViteLifecycles.js';
|
|
9
9
|
import { ScreenshotProvider } from '../iframe/ScreenshotProvider.js';
|
|
10
10
|
import { getSafePreviewNavigationPath, isPreviewRuntimeEnabled } from '../preview/bridge.js';
|
|
11
|
-
import { getPreviewLocationChange, getPreviewParentOrigin } from '../preview/location.js';
|
|
11
|
+
import { getDocumentLocale, getPreviewLocationChange, getPreviewParentOrigin } from '../preview/location.js';
|
|
12
12
|
import { OptionalTracking } from '../t/OptionalTracking.js';
|
|
13
13
|
import { AppResetScrollOnNavigate } from './AppResetScrollOnNavigate.js';
|
|
14
14
|
const titleCase = (seg) => seg
|
|
@@ -63,15 +63,16 @@ export function AppRoutesProvider({ appRoutes, children }) {
|
|
|
63
63
|
}
|
|
64
64
|
window.parent.postMessage({ type: 'nav/routes', payload: { routes: displayRoutes } }, getPreviewParentOrigin() ?? '*');
|
|
65
65
|
}, [appRoutes, displayRoutes, previewEnabled]);
|
|
66
|
+
// This runtime predates locale-aware routing and has no fimo-config to read,
|
|
67
|
+
// so the document's own `lang` is the only locale the app has declared.
|
|
66
68
|
const sendLocationChange = useCallback(() => {
|
|
67
69
|
if (!previewEnabled) {
|
|
68
70
|
return;
|
|
69
71
|
}
|
|
70
|
-
const locationChange = getPreviewLocationChange(location);
|
|
71
72
|
const parentOrigin = getPreviewParentOrigin();
|
|
72
73
|
window.parent.postMessage({
|
|
73
74
|
type: 'nav/locationChange',
|
|
74
|
-
payload:
|
|
75
|
+
payload: getPreviewLocationChange(location, getDocumentLocale(document, 'en'), parentOrigin),
|
|
75
76
|
}, parentOrigin ?? '*');
|
|
76
77
|
}, [location.hash, location.pathname, location.search, previewEnabled]);
|
|
77
78
|
useEffect(sendRoutes, [sendRoutes]);
|
|
@@ -28,6 +28,11 @@ import type { FimoConfig, FimoRouteConfig, FimoSEO } from '../shared/fimo-config
|
|
|
28
28
|
*/
|
|
29
29
|
export declare const SEO_FIELD_KEYS: readonly ["title", "description", "siteName", "url", "image", "favicon", "twitterSite", "robots", "themeColor", "locale"];
|
|
30
30
|
export declare function normalizePathname(pathname: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Whether one pattern matches one path, and how specific the match is (the
|
|
33
|
+
* number of static segments it pinned down). Locale-aware: a pattern with a
|
|
34
|
+
* `:locale` segment matches both the prefixed and the canonical URL.
|
|
35
|
+
*/
|
|
31
36
|
export declare function matchRoutePath(pathname: string, pathPattern: string): {
|
|
32
37
|
matched: boolean;
|
|
33
38
|
score: number;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { matchFimoRoute } from '../routes/contract.js';
|
|
1
2
|
/**
|
|
2
3
|
* Shared SEO resolver for the generated app.
|
|
3
4
|
*
|
|
@@ -45,53 +46,20 @@ export function normalizePathname(pathname) {
|
|
|
45
46
|
}
|
|
46
47
|
return normalized;
|
|
47
48
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
return normalized.slice(1).split('/');
|
|
54
|
-
}
|
|
49
|
+
/**
|
|
50
|
+
* Whether one pattern matches one path, and how specific the match is (the
|
|
51
|
+
* number of static segments it pinned down). Locale-aware: a pattern with a
|
|
52
|
+
* `:locale` segment matches both the prefixed and the canonical URL.
|
|
53
|
+
*/
|
|
55
54
|
export function matchRoutePath(pathname, pathPattern) {
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
if (pathSegments.length !== patternSegments.length) {
|
|
59
|
-
return { matched: false, score: -1 };
|
|
60
|
-
}
|
|
61
|
-
let staticSegments = 0;
|
|
62
|
-
for (let i = 0; i < patternSegments.length; i += 1) {
|
|
63
|
-
const routeSegment = patternSegments[i];
|
|
64
|
-
const inputSegment = pathSegments[i];
|
|
65
|
-
if (routeSegment.startsWith(':')) {
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
if (routeSegment !== inputSegment) {
|
|
69
|
-
return { matched: false, score: -1 };
|
|
70
|
-
}
|
|
71
|
-
staticSegments += 1;
|
|
72
|
-
}
|
|
73
|
-
return { matched: true, score: staticSegments };
|
|
55
|
+
const match = matchFimoRoute(pathname, { routes: { route: { path: pathPattern } } });
|
|
56
|
+
return match ? { matched: true, score: match.score } : { matched: false, score: -1 };
|
|
74
57
|
}
|
|
75
58
|
export function resolveRouteConfig(pathname, routes) {
|
|
76
59
|
if (!routes) {
|
|
77
60
|
return undefined;
|
|
78
61
|
}
|
|
79
|
-
|
|
80
|
-
let bestMatch;
|
|
81
|
-
for (const routeConfig of Object.values(routes)) {
|
|
82
|
-
const pathPattern = routeConfig.path;
|
|
83
|
-
if (!pathPattern) {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
86
|
-
const result = matchRoutePath(normalizedPath, pathPattern);
|
|
87
|
-
if (!result.matched) {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
if (!bestMatch || result.score > bestMatch.score) {
|
|
91
|
-
bestMatch = { score: result.score, route: routeConfig };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
return bestMatch?.route;
|
|
62
|
+
return matchFimoRoute(pathname, { routes })?.contract.config;
|
|
95
63
|
}
|
|
96
64
|
export function getNestedValue(input, path) {
|
|
97
65
|
if (!input || typeof input !== 'object') {
|
|
@@ -124,7 +92,7 @@ function resolveSeoField(routeValue, globalValue, context) {
|
|
|
124
92
|
}
|
|
125
93
|
export function resolveSeoForPath(pathname, config, routeData) {
|
|
126
94
|
const globalSeo = config.seo ?? {};
|
|
127
|
-
const routeConfig =
|
|
95
|
+
const routeConfig = matchFimoRoute(pathname, config)?.contract.config;
|
|
128
96
|
const routeSeo = routeConfig?.seo;
|
|
129
97
|
if (!routeSeo) {
|
|
130
98
|
return Object.keys(globalSeo).length > 0 ? globalSeo : undefined;
|
|
@@ -40,20 +40,47 @@ export interface RouteSEO {
|
|
|
40
40
|
locale?: string;
|
|
41
41
|
siteName?: string;
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* A path parameter descriptor as it appears in `.fimo/config.json`.
|
|
45
|
+
*
|
|
46
|
+
* Deliberately permissive — it mirrors hand-written JSON. `resolveRouteContract`
|
|
47
|
+
* in `runtime/routes/contract.ts` narrows it into the discriminated
|
|
48
|
+
* `FimoRouteParam` union and reports whatever does not add up; `fimo validate`
|
|
49
|
+
* surfaces those issues.
|
|
50
|
+
*/
|
|
51
|
+
export interface FimoRouteParamConfig {
|
|
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
|
+
}
|
|
43
66
|
export interface FimoRouteConfig {
|
|
44
67
|
label?: string;
|
|
45
68
|
path?: string;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
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>;
|
|
51
77
|
seo?: RouteSEO;
|
|
52
78
|
}
|
|
53
79
|
export interface FimoI18nRoutingConfig {
|
|
54
80
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
81
|
+
* Legacy compatibility for projects that still carry a global routing
|
|
82
|
+
* setting. New projects declare locale ownership on each route with a
|
|
83
|
+
* `:locale` segment; the scaffold intentionally omits this block.
|
|
57
84
|
*
|
|
58
85
|
* Strategies we may support later:
|
|
59
86
|
* - domain: `example.es/pricing`
|
|
@@ -63,7 +90,6 @@ export interface FimoI18nRoutingConfig {
|
|
|
63
90
|
* - custom: framework code resolves locale and passes it to `<FimoProviders locale={...}>`
|
|
64
91
|
*/
|
|
65
92
|
strategy?: 'path-prefix';
|
|
66
|
-
prefixDefaultLocale?: boolean;
|
|
67
93
|
}
|
|
68
94
|
export interface FimoI18nConfig {
|
|
69
95
|
defaultLocale?: string;
|
|
@@ -1,23 +1,2 @@
|
|
|
1
|
-
export
|
|
2
|
-
pathname: string;
|
|
3
|
-
search?: string;
|
|
4
|
-
hash?: string;
|
|
5
|
-
}
|
|
6
|
-
export interface PreviewLocationChange {
|
|
7
|
-
/** Safe workspace focus value; query/hash state stays navigation-only. */
|
|
8
|
-
path: string;
|
|
9
|
-
/** Exact app location used to keep the parent's iframe URL in sync. */
|
|
10
|
-
navigationPath: string;
|
|
11
|
-
}
|
|
12
|
-
export interface PreviewBrowserContext {
|
|
13
|
-
readonly window: Pick<Window, 'location' | 'sessionStorage'>;
|
|
14
|
-
readonly document: Pick<Document, 'referrer'>;
|
|
15
|
-
}
|
|
16
|
-
/** Prefer the immediate embedding document over a potentially stale referrer. */
|
|
17
|
-
export declare function resolvePreviewParentOrigin(referrer: string | undefined, ancestorOrigins: readonly string[] | undefined): string | null;
|
|
18
|
-
/** Resolve and remember the trusted Studio origin across full-page navigation. */
|
|
19
|
-
export declare function getPreviewParentOrigin(browser?: PreviewBrowserContext): string | null;
|
|
20
|
-
/** Keep sandbox credentials and the iframe cache-buster out of app navigation state. */
|
|
21
|
-
export declare function getPreviewLocationPath(location: PreviewLocationLike): string;
|
|
22
|
-
export declare function getPreviewLocationChange(location: PreviewLocationLike): PreviewLocationChange;
|
|
1
|
+
export * from '../preview/location.js';
|
|
23
2
|
//# sourceMappingURL=preview-location.d.ts.map
|