fimo 0.17.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.
Files changed (159) hide show
  1. package/assets/astro/components/DateTime.astro +22 -0
  2. package/assets/astro/components/index.d.ts +1 -0
  3. package/assets/astro/components/index.js +1 -0
  4. package/assets/content-templates/shared/types.eta +2 -0
  5. package/assets/skills/fimo-cli/SKILL.md +3 -2
  6. package/assets/skills/fimo-cli/references/content.md +2 -2
  7. package/assets/skills/fimo-cli/references/deploy.md +2 -0
  8. package/assets/skills/fimo-cli/references/init.md +11 -4
  9. package/assets/skills/fimo-cli/references/locales.md +38 -14
  10. package/assets/skills/fimo-cli/references/pages.md +70 -0
  11. package/assets/skills/fimo-cli/references/webhooks.md +1 -1
  12. package/assets/skills/fimo-code/SKILL.md +11 -0
  13. package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
  14. package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
  15. package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
  16. package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
  17. package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
  18. package/assets/skills/fimo-code/references/content/richtext.md +5 -0
  19. package/assets/skills/fimo-code/references/content/schema.md +4 -1
  20. package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
  21. package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
  22. package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
  23. package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
  24. package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
  25. package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
  26. package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
  27. package/assets/skills/fimo-code/references/labels.md +9 -1
  28. package/assets/skills/fimo-code/references/locales.md +8 -2
  29. package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
  30. package/assets/skills/fimo-code/references/ui.md +21 -2
  31. package/assets/skills/fimo-migration/SKILL.md +276 -51
  32. package/assets/skills/fimo-migration/references/content.md +119 -35
  33. package/assets/skills/fimo-migration/references/decision.md +72 -0
  34. package/assets/skills/fimo-migration/references/forms.md +38 -6
  35. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
  36. package/assets/skills/fimo-migration/references/import-contract.md +71 -19
  37. package/assets/skills/fimo-migration/references/manifest.md +11 -161
  38. package/assets/skills/fimo-migration/references/media.md +33 -1
  39. package/assets/skills/fimo-migration/references/platforms.md +26 -2
  40. package/assets/skills/fimo-migration/references/qa.md +167 -13
  41. package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
  42. package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
  43. package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
  44. package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
  45. package/assets/skills/fimo-migration/references/workers.md +82 -0
  46. package/assets/skills/fimo-migration/references/workflow.md +140 -192
  47. package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
  48. package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
  49. package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
  50. package/assets/skills/fimo-studio/references/cms.md +25 -7
  51. package/assets/skills/fimo-studio/references/webhooks.md +1 -1
  52. package/assets/sveltekit/components/DateTime.svelte +35 -0
  53. package/assets/sveltekit/components/FimoProvider.svelte +2 -2
  54. package/assets/sveltekit/components/index.d.ts +1 -0
  55. package/assets/sveltekit/components/index.js +1 -0
  56. package/dist/build/next/config.d.ts +4 -1
  57. package/dist/build/next/config.js +21 -5
  58. package/dist/build/sveltekit/vite.d.ts +3 -4
  59. package/dist/build/sveltekit/vite.js +3 -4
  60. package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
  61. package/dist/build/vite/plugins/runtime-env.js +14 -10
  62. package/dist/cli/bundle.json +2 -2
  63. package/dist/cli/index.js +76 -78
  64. package/dist/runtime/app/FimoProviders.js +25 -14
  65. package/dist/runtime/app/prefetch.d.ts +3 -1
  66. package/dist/runtime/app/prefetch.js +11 -55
  67. package/dist/runtime/astro/preview.js +7 -9
  68. package/dist/runtime/content/client/internal.js +1 -0
  69. package/dist/runtime/i18n/locale.d.ts +4 -0
  70. package/dist/runtime/i18n/locale.js +14 -14
  71. package/dist/runtime/next/client-runtime.js +5 -1
  72. package/dist/runtime/next/client.d.ts +3 -2
  73. package/dist/runtime/next/client.js +2 -1
  74. package/dist/runtime/next/labels.d.ts +2 -2
  75. package/dist/runtime/next/labels.js +2 -2
  76. package/dist/runtime/next/preview.d.ts +13 -0
  77. package/dist/runtime/next/preview.js +34 -0
  78. package/dist/runtime/next/primitives/server.d.ts +2 -0
  79. package/dist/runtime/next/primitives/server.js +8 -0
  80. package/dist/runtime/next/provider.d.ts +5 -0
  81. package/dist/runtime/next/provider.js +17 -3
  82. package/dist/runtime/next/server-runtime.js +4 -1
  83. package/dist/runtime/next/server.d.ts +1 -1
  84. package/dist/runtime/next/server.js +1 -1
  85. package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
  86. package/dist/runtime/paths/get-fimo-paths.js +43 -42
  87. package/dist/runtime/preview/location.d.ts +19 -2
  88. package/dist/runtime/preview/location.js +16 -2
  89. package/dist/runtime/primitives/index.d.ts +1 -0
  90. package/dist/runtime/primitives/index.js +1 -0
  91. package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
  92. package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
  93. package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
  94. package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
  95. package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
  96. package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
  97. package/dist/runtime/routes/contract.d.ts +116 -0
  98. package/dist/runtime/routes/contract.js +377 -0
  99. package/dist/runtime/routes/index.d.ts +3 -0
  100. package/dist/runtime/routes/index.js +3 -0
  101. package/dist/runtime/routing/AppRoutesProvider.js +4 -3
  102. package/dist/runtime/seo/resolver.d.ts +5 -0
  103. package/dist/runtime/seo/resolver.js +10 -42
  104. package/dist/runtime/shared/fimo-config.types.d.ts +34 -8
  105. package/dist/runtime/shared/preview-location.d.ts +1 -22
  106. package/dist/runtime/shared/preview-location.js +5 -93
  107. package/dist/runtime/sveltekit/preview.d.ts +12 -3
  108. package/dist/runtime/sveltekit/preview.js +13 -8
  109. package/dist/scripts/sync-forms.js +1 -1
  110. package/dist/scripts/sync-schemas.js +1 -4
  111. package/dist/scripts/validate-fimo-config.js +5 -1
  112. package/dist/scripts/validate-page-registry.d.ts +2 -0
  113. package/dist/scripts/validate-page-registry.js +167 -0
  114. package/dist/scripts/validate-route-metadata.js +132 -41
  115. package/package.json +7 -3
  116. package/templates/astro/.fimo/config.json +13 -5
  117. package/templates/astro/AGENTS.md +5 -1
  118. package/templates/astro/astro.config.mjs +7 -0
  119. package/templates/astro/pnpm-workspace.yaml +0 -3
  120. package/templates/astro/src/components/ProjectOverview.astro +9 -6
  121. package/templates/astro/src/layouts/Base.astro +1 -1
  122. package/templates/astro/src/locales.ts +4 -0
  123. package/templates/astro/src/pages/[locale]/index.astro +19 -0
  124. package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
  125. package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
  126. package/templates/astro/src/pages/index.astro +5 -2
  127. package/templates/astro/src/pages/projects/[slug].astro +4 -2
  128. package/templates/astro/src/pages/projects/index.astro +5 -2
  129. package/templates/nextjs/.fimo/config.json +2 -1
  130. package/templates/nextjs/AGENTS.md +5 -0
  131. package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
  132. package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
  133. package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
  134. package/templates/nextjs/src/components/Document.tsx +26 -0
  135. package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
  136. package/templates/nextjs/src/locales.ts +13 -0
  137. package/templates/react-router/.fimo/config.json +3 -6
  138. package/templates/react-router/AGENTS.md +4 -0
  139. package/templates/react-router/package.json +1 -1
  140. package/templates/react-router/pnpm-workspace.yaml +1 -3
  141. package/templates/react-router/react-router.config.ts +4 -10
  142. package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
  143. package/templates/react-router/src/root.tsx +2 -4
  144. package/templates/react-router/src/routes.ts +5 -3
  145. package/templates/sveltekit/.fimo/config.json +2 -1
  146. package/templates/sveltekit/AGENTS.md +5 -0
  147. package/templates/sveltekit/src/app.html +1 -1
  148. package/templates/sveltekit/src/hooks.server.ts +11 -0
  149. package/templates/sveltekit/src/locales.ts +8 -0
  150. package/templates/sveltekit/src/params/locale.ts +5 -0
  151. package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
  152. package/templates/sveltekit/src/routes/+layout.svelte +5 -0
  153. package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
  154. package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
  155. package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
  156. package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
  157. package/templates/nextjs/src/app/layout.tsx +0 -17
  158. /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
  159. /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: parentOrigin ? locationChange : { path: locationChange.path },
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
- function splitSegments(pathname) {
49
- const normalized = normalizePathname(pathname);
50
- if (normalized === '/') {
51
- return [];
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 pathSegments = splitSegments(pathname);
57
- const patternSegments = splitSegments(pathPattern);
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
- const normalizedPath = normalizePathname(pathname);
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 = resolveRouteConfig(pathname, config.routes);
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
- params?: Record<string, {
47
- schema: string;
48
- queryField: string;
49
- displayField: string;
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
- * Supported now:
56
- * - path-prefix: default locale renders at `/pricing`, non-default locales at `/es/pricing`.
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 interface PreviewLocationLike {
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