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.
Files changed (162) 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 +5 -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/package.json +1 -1
  120. package/templates/astro/pnpm-workspace.yaml +0 -3
  121. package/templates/astro/src/components/ProjectOverview.astro +9 -6
  122. package/templates/astro/src/layouts/Base.astro +1 -1
  123. package/templates/astro/src/locales.ts +4 -0
  124. package/templates/astro/src/pages/[locale]/index.astro +19 -0
  125. package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
  126. package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
  127. package/templates/astro/src/pages/index.astro +5 -2
  128. package/templates/astro/src/pages/projects/[slug].astro +4 -2
  129. package/templates/astro/src/pages/projects/index.astro +5 -2
  130. package/templates/nextjs/.fimo/config.json +2 -1
  131. package/templates/nextjs/AGENTS.md +5 -0
  132. package/templates/nextjs/package.json +1 -1
  133. package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
  134. package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
  135. package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
  136. package/templates/nextjs/src/components/Document.tsx +26 -0
  137. package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
  138. package/templates/nextjs/src/locales.ts +13 -0
  139. package/templates/react-router/.fimo/config.json +3 -6
  140. package/templates/react-router/AGENTS.md +4 -0
  141. package/templates/react-router/package.json +2 -2
  142. package/templates/react-router/pnpm-workspace.yaml +1 -3
  143. package/templates/react-router/react-router.config.ts +4 -10
  144. package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
  145. package/templates/react-router/src/root.tsx +2 -4
  146. package/templates/react-router/src/routes.ts +5 -3
  147. package/templates/sveltekit/.fimo/config.json +2 -1
  148. package/templates/sveltekit/AGENTS.md +5 -0
  149. package/templates/sveltekit/package.json +1 -1
  150. package/templates/sveltekit/src/app.html +1 -1
  151. package/templates/sveltekit/src/hooks.server.ts +11 -0
  152. package/templates/sveltekit/src/locales.ts +8 -0
  153. package/templates/sveltekit/src/params/locale.ts +5 -0
  154. package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
  155. package/templates/sveltekit/src/routes/+layout.svelte +5 -0
  156. package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
  157. package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
  158. package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
  159. package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
  160. package/templates/nextjs/src/app/layout.tsx +0 -17
  161. /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
  162. /package/templates/sveltekit/src/routes/{+page.svelte → [[locale=locale]]/+page.svelte} +0 -0
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { pageSourceParamExpressions } from '@fimo/api-contracts';
4
+ import { FIMO_LOCALE_PARAM, resolveRouteContract } from '../runtime/routes/contract.js';
3
5
  import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
4
6
  import { parseRoutesSource } from './lib/parse-routes-file.js';
5
7
  function readJson(filePath) {
@@ -10,20 +12,15 @@ function readJson(filePath) {
10
12
  return {};
11
13
  }
12
14
  }
13
- function getParamNamesFromPath(routePath) {
14
- const normalized = routePath === '/' ? '/' : routePath.replace(/\/+$/, '');
15
- const segments = normalized.split('/').filter(Boolean);
16
- return segments.filter((segment) => segment.startsWith(':')).map((segment) => segment.slice(1));
17
- }
18
15
  function loadConfigRoutes(config) {
19
16
  const configRoutes = config.routes ?? {};
20
17
  return Object.entries(configRoutes).map(([id, route]) => ({
21
18
  id,
22
19
  path: route.path ?? '/',
23
20
  label: route.label ?? null,
24
- paramNames: getParamNamesFromPath(route.path ?? '/'),
25
- params: route.params ?? null,
21
+ sources: route.sources ?? null,
26
22
  seo: route.seo ?? null,
23
+ contract: resolveRouteContract(id, route),
27
24
  }));
28
25
  }
29
26
  export function validateRouteMetadata(options = {}) {
@@ -47,26 +44,26 @@ export function validateRouteMetadata(options = {}) {
47
44
  const pathToFile = new Map();
48
45
  for (const fr of fileRoutes) {
49
46
  if (fr.path && fr.file) {
50
- pathToFile.set(fr.path, fr.file);
47
+ pathToFile.set(normalizeLocaleSpelling(fr.path), fr.file);
51
48
  }
52
49
  }
53
50
  const i18n = validateI18nConfig(config);
54
51
  errors.push(...i18n.errors);
55
52
  const configRoutes = loadConfigRoutes(config);
56
53
  // Cross-validate: non-layout routes in routes.ts should have matching fimo-config entries
57
- const configPaths = new Set(configRoutes.map((r) => r.path));
54
+ const configPaths = new Set(configRoutes.flatMap(registrablePaths));
58
55
  for (const fileRoute of fileRoutes) {
59
56
  if (fileRoute.isLayout) {
60
57
  continue;
61
58
  }
62
- if (fileRoute.path && !configPaths.has(fileRoute.path)) {
59
+ if (fileRoute.path && !configPaths.has(normalizeLocaleSpelling(fileRoute.path))) {
63
60
  errors.push(`Route with path "${fileRoute.path}" (file: "${fileRoute.file}") exists in src/routes.ts but has no matching entry in ${configDisplayPath}.\nFix: add a route entry to ${configDisplayPath} "routes" with a matching "path".`);
64
61
  }
65
62
  }
66
63
  // Cross-validate: fimo-config routes should have matching routes.ts entries
67
- const filePaths = new Set(fileRoutes.filter((r) => !r.isLayout && r.path).map((r) => r.path));
64
+ const filePaths = new Set(fileRoutes.filter((r) => !r.isLayout && r.path).map((r) => normalizeLocaleSpelling(r.path)));
68
65
  for (const route of configRoutes) {
69
- if (!filePaths.has(route.path)) {
66
+ if (!registrablePaths(route).some((candidate) => filePaths.has(candidate))) {
70
67
  errors.push(`Route "${route.id}" (${route.path}) exists in ${configDisplayPath} but is not registered in src/routes.ts.\nFix: add the route to src/routes.ts. Example: route('${route.path.replace(/^\//, '')}', './pages/YourPage.tsx')`);
71
68
  }
72
69
  }
@@ -76,7 +73,7 @@ export function validateRouteMetadata(options = {}) {
76
73
  fileRoutes,
77
74
  i18n,
78
75
  configDisplayPath,
79
- routesSourceUsesFimoRoutes: sourceUsesFimoRoutes(fileRoutes, routesFilePath),
76
+ routesSourceUsesLegacyHelper: sourceUsesLegacyFimoRoutes(routesFilePath),
80
77
  }));
81
78
  }
82
79
  const routeById = new Map();
@@ -88,9 +85,18 @@ export function validateRouteMetadata(options = {}) {
88
85
  }
89
86
  routeById.set(route.id, route);
90
87
  }
88
+ for (const route of configRoutes) {
89
+ for (const issue of route.contract.issues) {
90
+ errors.push(`Route "${route.id}" (${route.path}): ${issue.message}\nFix: ${issue.fix}`);
91
+ }
92
+ errors.push(...validatePageSources(route));
93
+ }
94
+ errors.push(...validateRouteShapeCollisions(configRoutes));
91
95
  // Validate route modules (exports, loaders, meta)
92
96
  for (const route of configRoutes) {
93
- const file = pathToFile.get(route.path);
97
+ const file = registrablePaths(route)
98
+ .map((candidate) => pathToFile.get(candidate))
99
+ .find(Boolean);
94
100
  if (!file) {
95
101
  continue;
96
102
  }
@@ -99,22 +105,6 @@ export function validateRouteMetadata(options = {}) {
99
105
  errors.push(`Route "${route.id}" (${route.path}) references missing module "${file}".\nFix: create the file at src/${file}.`);
100
106
  continue;
101
107
  }
102
- if (route.paramNames.length > 0) {
103
- if (!route.params) {
104
- errors.push(`Dynamic route "${route.id}" (${route.path}) needs \`params\` metadata to generate prerender URLs.\nFix: add to the route in ${configDisplayPath}: \`"params": { "${route.paramNames[0] || 'param'}": { "schema": "SchemaName", "queryField": "${route.paramNames[0] || 'slug'}", "displayField": "title" } }\``);
105
- }
106
- else {
107
- for (const paramName of route.paramNames) {
108
- const paramInfo = route.params[paramName];
109
- if (!paramInfo || typeof paramInfo !== 'object') {
110
- errors.push(`Dynamic route "${route.id}" (${route.path}): param "${paramName}" must be an object with "schema" and "queryField".\nFix: \`"params": { "${paramName}": { "schema": "SchemaName", "queryField": "${paramName}" } }\``);
111
- }
112
- else if (!paramInfo.schema || !paramInfo.queryField) {
113
- errors.push(`Dynamic route "${route.id}" (${route.path}): param "${paramName}" is missing ${!paramInfo.schema ? '"schema"' : '"queryField"'}.\nFix: \`"${paramName}": { "schema": "SchemaName", "queryField": "${paramName}" }\``);
114
- }
115
- }
116
- }
117
- }
118
108
  if (route.seo) {
119
109
  const GLOBAL_VARIABLES = new Set(['siteName', 'label']);
120
110
  const seoFields = [
@@ -132,15 +122,17 @@ export function validateRouteMetadata(options = {}) {
132
122
  if (GLOBAL_VARIABLES.has(variable)) {
133
123
  continue;
134
124
  }
125
+ // Only entry params carry data a template can read; the locale
126
+ // segment resolves to a tag, never to an entry.
127
+ const entryParamNames = route.contract.entryParams.map((param) => param.name);
128
+ const availableParams = entryParamNames.length > 0 ? entryParamNames.join(', ') : 'none';
135
129
  if (variable.includes('.')) {
136
130
  const paramKey = variable.split('.')[0];
137
- if (!route.params || !route.params[paramKey]) {
138
- const availableParams = route.params ? Object.keys(route.params).join(', ') : 'none';
139
- errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" but "${paramKey}" is not a declared param. Available params: ${availableParams}.`);
131
+ if (!entryParamNames.includes(paramKey)) {
132
+ errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" but "${paramKey}" is not a declared entry param. Available params: ${availableParams}.`);
140
133
  }
141
134
  }
142
135
  else {
143
- const availableParams = route.params ? Object.keys(route.params).join(', ') : 'none';
144
136
  errors.push(`Route "${route.id}" (${route.path}): SEO ${field.name} references "{{${variable}}}" which is not a global variable (siteName, label) and not a dotted param reference (e.g., "{{param.field}}"). Available params: ${availableParams}.`);
145
137
  }
146
138
  }
@@ -149,6 +141,52 @@ export function validateRouteMetadata(options = {}) {
149
141
  }
150
142
  return { errors, routesChecked: configRoutes.length };
151
143
  }
144
+ function validatePageSources(route) {
145
+ if (!route.sources) {
146
+ return [];
147
+ }
148
+ const errors = [];
149
+ const entryParamNames = new Set(route.contract.entryParams.map((param) => param.name));
150
+ for (const [sourceName, rawSource] of Object.entries(route.sources)) {
151
+ if (!/^[A-Za-z][\w-]*$/.test(sourceName) || !isRecord(rawSource)) {
152
+ errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" is not a valid named source.`);
153
+ continue;
154
+ }
155
+ if (rawSource.type === 'labels') {
156
+ const match = rawSource.match;
157
+ if (!Array.isArray(match) ||
158
+ match.length === 0 ||
159
+ match.some((pattern) => {
160
+ if (typeof pattern !== 'string')
161
+ return true;
162
+ const wildcard = pattern.indexOf('*');
163
+ return wildcard !== -1 && !(pattern.length > 2 && pattern.endsWith('.*') && wildcard === pattern.length - 1);
164
+ })) {
165
+ errors.push(`Route "${route.id}" (${route.path}): Labels source "${sourceName}" needs exact keys or prefixes ending in .*.`);
166
+ }
167
+ continue;
168
+ }
169
+ if (rawSource.type !== 'single' && rawSource.type !== 'collection') {
170
+ errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" type must be labels, single, or collection.`);
171
+ continue;
172
+ }
173
+ if (typeof rawSource.schema !== 'string' || !rawSource.schema) {
174
+ errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" needs a schema.`);
175
+ }
176
+ if (rawSource.type === 'collection' && rawSource.query !== undefined && !isRecord(rawSource.query)) {
177
+ errors.push(`Route "${route.id}" (${route.path}): Collection source "${sourceName}" query must be an object.`);
178
+ continue;
179
+ }
180
+ for (const expression of pageSourceParamExpressions(rawSource.query)) {
181
+ const match = /^\$params\.([A-Za-z_][\w-]*)\.([A-Za-z_][\w.-]*)$/.exec(expression);
182
+ const param = match?.[1];
183
+ if (!param || !entryParamNames.has(param)) {
184
+ errors.push(`Route "${route.id}" (${route.path}): source "${sourceName}" references undeclared param "${param ?? expression}". Fix: use $params.<declared-entry-param>.<field>.`);
185
+ }
186
+ }
187
+ }
188
+ return errors;
189
+ }
152
190
  function validateI18nConfig(config) {
153
191
  const rawI18n = isRecord(config.i18n) ? config.i18n : {};
154
192
  const errors = [];
@@ -192,37 +230,90 @@ function validateI18nConfig(config) {
192
230
  defaultLocale: effectiveDefaultLocale,
193
231
  locales,
194
232
  routingStrategy,
195
- prefixDefaultLocale: rawRouting.prefixDefaultLocale === true,
196
233
  errors,
197
234
  };
198
235
  }
236
+ /**
237
+ * Two routes that produce the same URL are only detectable once the locale
238
+ * segment is stripped: `/pricing` and `/:locale/pricing` both answer
239
+ * `/pricing`, and nothing downstream can decide which one owns it.
240
+ */
241
+ function validateRouteShapeCollisions(configRoutes) {
242
+ const errors = [];
243
+ const byShape = new Map();
244
+ for (const route of configRoutes) {
245
+ const shape = route.contract.canonicalPath
246
+ .split('/')
247
+ .map((segment) => (segment.startsWith(':') ? ':param' : segment))
248
+ .join('/');
249
+ const previous = byShape.get(shape);
250
+ if (previous && (previous.contract.localeParam || route.contract.localeParam)) {
251
+ errors.push(`Routes "${previous.id}" (${previous.path}) and "${route.id}" (${route.path}) both answer "${route.contract.canonicalPath}".\nFix: keep one of them, or give them distinct paths — a locale segment does not make two routes distinct.`);
252
+ continue;
253
+ }
254
+ if (!previous) {
255
+ byShape.set(shape, route);
256
+ }
257
+ }
258
+ return errors;
259
+ }
199
260
  function validateLocaleRouteCollisions(args) {
200
261
  const errors = [];
201
262
  const reservedPrefixes = new Set(args.i18n.locales.map((locale) => locale.toLowerCase()));
202
263
  for (const route of args.configRoutes) {
203
264
  const first = firstPathSegment(route.path);
204
265
  if (first && reservedPrefixes.has(first.toLowerCase())) {
205
- errors.push(`Route "${route.id}" (${route.path}) uses reserved locale prefix "${first}". Keep ${args.configDisplayPath} routes canonical and unlocalized, for example "/pricing"; let fimoRoutes() or custom locale routing produce "/${first}/...".`);
266
+ errors.push(`Route "${route.id}" (${route.path}) uses reserved locale prefix "${first}". Declare the locale as "/:locale/..." in ${args.configDisplayPath} and as ":locale?/..." in React Router so the default locale remains unprefixed.`);
206
267
  }
207
268
  }
208
- if (!args.routesSourceUsesFimoRoutes) {
269
+ if (!args.routesSourceUsesLegacyHelper) {
209
270
  for (const route of args.fileRoutes) {
210
271
  if (!route.path || route.isLayout) {
211
272
  continue;
212
273
  }
213
274
  const first = firstPathSegment(route.path);
275
+ // `:locale` is the declared locale segment, not an accidental catch-all.
276
+ if (first && normalizeLocaleSpelling(first) === `:${FIMO_LOCALE_PARAM}`) {
277
+ continue;
278
+ }
214
279
  if (first?.startsWith(':') || first === '*') {
215
- errors.push(`Route "${route.path}" is a top-level dynamic route in a multi-locale path-prefix project. It can capture locale URLs like "/${args.i18n.locales[0]}". Use fimoRoutes(routes) from "fimo/react-router/routes" or implement explicit custom locale routing.`);
280
+ errors.push(`Route "${route.path}" is a top-level dynamic route in a multi-locale project. It can capture locale URLs like "/${args.i18n.locales[0]}". Put an explicit optional ":locale?" segment first in the React Router path, or keep this route outside the localized route boundary.`);
216
281
  }
217
282
  }
218
283
  }
219
284
  return errors;
220
285
  }
221
- function sourceUsesFimoRoutes(fileRoutes, routesFilePath) {
222
- if (fileRoutes.length === 0 || !fs.existsSync(routesFilePath)) {
286
+ function sourceUsesLegacyFimoRoutes(routesFilePath) {
287
+ if (!fs.existsSync(routesFilePath)) {
223
288
  return false;
224
289
  }
225
- return fs.readFileSync(routesFilePath, 'utf-8').includes('fimoRoutes(');
290
+ const source = fs.readFileSync(routesFilePath, 'utf-8');
291
+ const importMatch = source.match(/import\s*\{(?<imports>[^}]*)\}\s*from\s*['"]fimo\/react-router\/routes['"]/s);
292
+ const importedName = importMatch?.groups?.imports
293
+ ?.split(',')
294
+ .map((specifier) => specifier.trim().match(/^fimoRoutes(?:\s+as\s+(?<alias>[A-Za-z_$][\w$]*))?$/)?.groups?.alias ??
295
+ (specifier.trim() === 'fimoRoutes' ? 'fimoRoutes' : null))
296
+ .find((name) => Boolean(name));
297
+ return importedName ? new RegExp(`\\b${importedName}\\s*\\(`).test(source) : false;
298
+ }
299
+ /**
300
+ * React Router spells an omittable segment `:locale?`; the config spells the
301
+ * same thing `:locale`. Compare the two worlds in one spelling.
302
+ */
303
+ function normalizeLocaleSpelling(routePath) {
304
+ return routePath.replace(`:${FIMO_LOCALE_PARAM}?`, `:${FIMO_LOCALE_PARAM}`);
305
+ }
306
+ /**
307
+ * The `src/routes.ts` paths that legitimately register one config route. A
308
+ * route with an optional locale answers two URL shapes, so an app may register
309
+ * one optional-segment route or one route per shape.
310
+ */
311
+ function registrablePaths(route) {
312
+ const declared = normalizeLocaleSpelling(route.contract.path);
313
+ if (!route.contract.localeParam?.optional) {
314
+ return [declared];
315
+ }
316
+ return [declared, route.contract.canonicalPath];
226
317
  }
227
318
  function firstPathSegment(routePath) {
228
319
  return routePath.split('/').filter(Boolean)[0] ?? null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Fimo CLI - create, deploy, and manage Fimo projects",
5
5
  "bin": {
6
6
  "fimo": "dist/cli/index.js"
@@ -64,8 +64,8 @@
64
64
  "import": "./dist/runtime/react-router/index.js"
65
65
  },
66
66
  "./react-router/routes": {
67
- "types": "./dist/runtime/react-router/routes.d.ts",
68
- "import": "./dist/runtime/react-router/routes.js"
67
+ "types": "./dist/runtime/react-router/legacy/routes.d.ts",
68
+ "import": "./dist/runtime/react-router/legacy/routes.js"
69
69
  },
70
70
  "./react-router/preset": {
71
71
  "types": "./dist/runtime/react-router/preset.d.ts",
@@ -96,6 +96,10 @@
96
96
  "types": "./dist/runtime/paths/index.d.ts",
97
97
  "import": "./dist/runtime/paths/index.js"
98
98
  },
99
+ "./routes": {
100
+ "types": "./dist/runtime/routes/index.d.ts",
101
+ "import": "./dist/runtime/routes/index.js"
102
+ },
99
103
  "./vite": {
100
104
  "types": "./dist/build/vite/index.d.ts",
101
105
  "import": "./dist/build/vite/index.js"
@@ -2,16 +2,24 @@
2
2
  "runtime": { "framework": "astro" },
3
3
  "i18n": {
4
4
  "defaultLocale": "en",
5
- "locales": ["en"],
6
- "routing": { "strategy": "path-prefix" }
5
+ "locales": ["en"]
7
6
  },
8
7
  "routes": {
9
- "home": { "label": "Home", "path": "/" },
10
- "projects": { "label": "Projects", "path": "/projects" },
8
+ "home": {
9
+ "label": "Home",
10
+ "path": "/:locale",
11
+ "params": { "locale": { "type": "locale", "optional": true } }
12
+ },
13
+ "projects": {
14
+ "label": "Projects",
15
+ "path": "/:locale/projects",
16
+ "params": { "locale": { "type": "locale", "optional": true } }
17
+ },
11
18
  "project": {
12
19
  "label": "Project",
13
- "path": "/projects/:slug",
20
+ "path": "/:locale/projects/:slug",
14
21
  "params": {
22
+ "locale": { "type": "locale", "optional": true },
15
23
  "slug": { "schema": "Project", "queryField": "slug", "displayField": "title" }
16
24
  }
17
25
  }
@@ -27,7 +27,11 @@ a code-only task, or use `npx fimo@latest` for project-local code generation and
27
27
  ## Project rules
28
28
 
29
29
  - **Deploy is on-demand**, not a save step. Batch related edits into one deploy.
30
- - The application owns routes, locale resolution, links, HTML language/direction, and SEO. Fimo connects to those decisions.
30
+ - With pnpm, run `pnpm install --config.dangerouslyAllowAllBuilds=true`; a bare install can skip the build scripts required by Fimo and the framework toolchain.
31
+ - Treat the user's language request as part of setup. The first explicitly requested locale is the primary locale; put it first and set it as `i18n.defaultLocale` in `.fimo/config.json`. If no language is requested, keep `en` and state that choice. Astro reads this configuration directly, keeps the default locale unprefixed, and prefixes the others.
32
+ - Preserve both the unprefixed default pages and the `[locale]` page tree. Astro has no optional single-segment file route, so the paired native pages keep one-locale URLs clean and allow another locale without changing the route shape. Use `Astro.currentLocale` for Fimo content reads, `getLabels`, and document language.
33
+ - Keep `.fimo/config.json#routes` as Studio metadata aligned with Astro's page files. It does not configure Astro routing.
34
+ - Astro owns routes, locale resolution, links, HTML language/direction, and SEO. Fimo connects to those decisions.
31
35
  - Use generated schema clients, `getLabels({ locale })`, and native components from `fimo/astro/components` for editable values.
32
36
  - Do not add React or a second deployment platform.
33
37
  - Never edit `.fimo/project.json`; it links this directory to the remote project.
@@ -1,7 +1,14 @@
1
1
  import { defineConfig } from 'astro/config';
2
2
  import { fimo } from 'fimo/astro/config';
3
3
 
4
+ import fimoConfig from './.fimo/config.json' with { type: 'json' };
5
+
4
6
  export default defineConfig({
5
7
  integrations: [fimo()],
8
+ i18n: {
9
+ defaultLocale: fimoConfig.i18n.defaultLocale,
10
+ locales: fimoConfig.i18n.locales,
11
+ routing: { prefixDefaultLocale: false },
12
+ },
6
13
  output: 'server',
7
14
  });
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "astro dev",
8
- "build": "astro build",
8
+ "build": "fimo validate --json && astro build",
9
9
  "check": "astro sync && astro check"
10
10
  },
11
11
  "dependencies": {
@@ -2,6 +2,3 @@
2
2
  # package-level allowlist because Fimo installs with dangerouslyAllowAllBuilds.
3
3
 
4
4
  packages: []
5
- allowBuilds:
6
- esbuild: set this to true or false
7
- fimo: set this to true or false
@@ -6,11 +6,14 @@ import * as Project from '../schemas/Project';
6
6
  import * as SiteSettings from '../schemas/SiteSettings';
7
7
 
8
8
  interface Props {
9
- locale: 'en';
9
+ locale: string;
10
+ localePrefix?: string;
10
11
  rendering: 'static' | 'server';
11
12
  }
12
13
 
13
- const { locale, rendering } = Astro.props;
14
+ const { locale, localePrefix, rendering } = Astro.props;
15
+ const prefix = localePrefix ? `/${localePrefix}` : '';
16
+ const localize = (path: string) => `${prefix}${path}`;
14
17
  const [settings, projects, labels] = await Promise.all([
15
18
  SiteSettings.get({ locale }),
16
19
  Project.list({ locale, limit: 12 }),
@@ -19,13 +22,13 @@ const [settings, projects, labels] = await Promise.all([
19
22
  ---
20
23
 
21
24
  <main data-framework="astro" data-rendering={rendering} data-locale={locale} data-fimo-starter="projects">
22
- <nav aria-label="Language">
23
- <a href="/">English</a>
25
+ <nav aria-label="Primary">
26
+ <a href={localize('/')}>Home</a>
24
27
  </nav>
25
28
  <header>
26
29
  <Text as="h1" value={settings.studioName} />
27
30
  <Text as="p" value={settings.intro} />
28
- <a href="/projects"><Text as="span" value={labels.t('navigation.projects')} /></a>
31
+ <a href={localize('/projects')}><Text as="span" value={labels.t('navigation.projects')} /></a>
29
32
  </header>
30
33
  <section>
31
34
  <Text as="h2" value={labels.t('projects.heading')} />
@@ -34,7 +37,7 @@ const [settings, projects, labels] = await Promise.all([
34
37
  <article data-project-slug={String(project.slug)}>
35
38
  <Text as="h3" value={project.title} />
36
39
  <Text as="p" value={project.summary} />
37
- <a href={`/projects/${String(project.slug)}`}><Text as="span" value={project.title} /></a>
40
+ <a href={localize(`/projects/${String(project.slug)}`)}><Text as="span" value={project.title} /></a>
38
41
  </article>
39
42
  ))
40
43
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  interface Props {
3
- lang: 'en';
3
+ lang: string;
4
4
  title: string;
5
5
  }
6
6
 
@@ -0,0 +1,4 @@
1
+ import fimoConfig from '../.fimo/config.json';
2
+
3
+ export const defaultLocale: string = fimoConfig.i18n.defaultLocale;
4
+ export const prefixedLocales = new Set(fimoConfig.i18n.locales.filter((locale) => locale !== defaultLocale));
@@ -0,0 +1,19 @@
1
+ ---
2
+ import ProjectOverview from '../../components/ProjectOverview.astro';
3
+ import Base from '../../layouts/Base.astro';
4
+ import { prefixedLocales } from '../../locales';
5
+
6
+ export const prerender = false;
7
+
8
+ const routeLocale = Astro.params.locale;
9
+
10
+ if (!routeLocale || !prefixedLocales.has(routeLocale)) {
11
+ return new Response(null, { status: 404, statusText: 'Not found' });
12
+ }
13
+
14
+ const locale = Astro.currentLocale ?? routeLocale;
15
+ ---
16
+
17
+ <Base lang={locale} title="Fimo + Astro">
18
+ <ProjectOverview {locale} localePrefix={locale} rendering="server" />
19
+ </Base>
@@ -0,0 +1,35 @@
1
+ ---
2
+ import { getLabels } from 'fimo/astro';
3
+ import { Text } from 'fimo/astro/components';
4
+
5
+ import Base from '../../../layouts/Base.astro';
6
+ import { prefixedLocales } from '../../../locales';
7
+ import * as Project from '../../../schemas/Project';
8
+
9
+ export const prerender = false;
10
+
11
+ const routeLocale = Astro.params.locale;
12
+ const slug = Astro.params.slug ?? '';
13
+
14
+ if (!routeLocale || !prefixedLocales.has(routeLocale)) {
15
+ return new Response(null, { status: 404, statusText: 'Not found' });
16
+ }
17
+
18
+ const locale = Astro.currentLocale ?? routeLocale;
19
+
20
+ const [project, labels] = await Promise.all([Project.getBySlug(slug, { locale }), getLabels({ locale })]);
21
+
22
+ if (!project) {
23
+ return Astro.redirect('/404');
24
+ }
25
+ ---
26
+
27
+ <Base lang={locale} title={String(project.title)}>
28
+ <main data-framework="astro" data-rendering="server" data-project-slug={slug}>
29
+ <a href={`/${locale}/projects`}><Text as="span" value={labels.t('navigation.projects')} /></a>
30
+ <article>
31
+ <Text as="h1" value={project.title} />
32
+ <Text as="p" value={project.summary} />
33
+ </article>
34
+ </main>
35
+ </Base>
@@ -0,0 +1,19 @@
1
+ ---
2
+ import ProjectOverview from '../../../components/ProjectOverview.astro';
3
+ import Base from '../../../layouts/Base.astro';
4
+ import { prefixedLocales } from '../../../locales';
5
+
6
+ export const prerender = false;
7
+
8
+ const routeLocale = Astro.params.locale;
9
+
10
+ if (!routeLocale || !prefixedLocales.has(routeLocale)) {
11
+ return new Response(null, { status: 404, statusText: 'Not found' });
12
+ }
13
+
14
+ const locale = Astro.currentLocale ?? routeLocale;
15
+ ---
16
+
17
+ <Base lang={locale} title="Projects">
18
+ <ProjectOverview {locale} localePrefix={locale} rendering="server" />
19
+ </Base>
@@ -1,10 +1,13 @@
1
1
  ---
2
2
  import ProjectOverview from '../components/ProjectOverview.astro';
3
3
  import Base from '../layouts/Base.astro';
4
+ import { defaultLocale } from '../locales';
4
5
 
5
6
  export const prerender = true;
7
+
8
+ const locale = Astro.currentLocale ?? defaultLocale;
6
9
  ---
7
10
 
8
- <Base lang="en" title="Fimo + Astro">
9
- <ProjectOverview locale="en" rendering="static" />
11
+ <Base lang={locale} title="Fimo + Astro">
12
+ <ProjectOverview {locale} rendering="static" />
10
13
  </Base>
@@ -3,19 +3,21 @@ import { getLabels } from 'fimo/astro';
3
3
  import { Text } from 'fimo/astro/components';
4
4
 
5
5
  import Base from '../../layouts/Base.astro';
6
+ import { defaultLocale } from '../../locales';
6
7
  import * as Project from '../../schemas/Project';
7
8
 
8
9
  export const prerender = false;
9
10
 
11
+ const locale = Astro.currentLocale ?? defaultLocale;
10
12
  const slug = Astro.params.slug ?? '';
11
- const [project, labels] = await Promise.all([Project.getBySlug(slug, { locale: 'en' }), getLabels({ locale: 'en' })]);
13
+ const [project, labels] = await Promise.all([Project.getBySlug(slug, { locale }), getLabels({ locale })]);
12
14
 
13
15
  if (!project) {
14
16
  return Astro.redirect('/404');
15
17
  }
16
18
  ---
17
19
 
18
- <Base lang="en" title={String(project.title)}>
20
+ <Base lang={locale} title={String(project.title)}>
19
21
  <main data-framework="astro" data-rendering="server" data-project-slug={slug}>
20
22
  <a href="/projects"><Text as="span" value={labels.t('navigation.projects')} /></a>
21
23
  <article>
@@ -1,10 +1,13 @@
1
1
  ---
2
2
  import ProjectOverview from '../../components/ProjectOverview.astro';
3
3
  import Base from '../../layouts/Base.astro';
4
+ import { defaultLocale } from '../../locales';
4
5
 
5
6
  export const prerender = false;
7
+
8
+ const locale = Astro.currentLocale ?? defaultLocale;
6
9
  ---
7
10
 
8
- <Base lang="en" title="Projects">
9
- <ProjectOverview locale="en" rendering="server" />
11
+ <Base lang={locale} title="Projects">
12
+ <ProjectOverview {locale} rendering="server" />
10
13
  </Base>
@@ -7,7 +7,8 @@
7
7
  "routes": {
8
8
  "home": {
9
9
  "label": "Home",
10
- "path": "/"
10
+ "path": "/:locale",
11
+ "params": { "locale": { "type": "locale", "optional": true } }
11
12
  }
12
13
  },
13
14
  "version": "4",
@@ -35,6 +35,11 @@ The scaffolded homepage is disposable. Replace it on the first real generation.
35
35
  ## Project rules
36
36
 
37
37
  - **Deploy is on-demand**, not a save step. Batch related edits into one deploy.
38
+ - With pnpm, run `pnpm install --config.dangerouslyAllowAllBuilds=true`; a bare install can skip the build scripts required by Fimo and the framework toolchain.
39
+ - Treat the user's language request as part of setup. The first explicitly requested locale is the primary locale; put it first and set it as `i18n.defaultLocale` in `.fimo/config.json`, with every requested locale in `i18n.locales`. If no language is requested, keep `en` and state that choice. That file is the only place to change: `src/locales.ts` reads it, so the served language, `<html lang>`, and the prefixed URLs follow. Never introduce a default-locale environment variable.
40
+ - Preserve the unprefixed `app/(default)` route group and the native `app/[locale]` boundary. Each is its own root layout, so each renders `src/components/Document.tsx` with the locale that tree serves; that is what makes `<html lang>` and the Fimo locale context correct on both. Mirror new routes in both groups when they are localized. Next.js has no optional single-segment folder, so do not replace this small pattern with middleware, rewrites, or a catch-all locale router.
41
+ - Keep the client-only `src/components/FimoPreviewBridge.tsx` inside `Document`. It reports the native Next pathname and settled locale to Studio and sends Studio navigation through Next's router; keep Next imports out of Fimo's shared runtime.
42
+ - Keep `.fimo/config.json#routes` as Studio metadata, not as the Next.js router. In that metadata, `params.locale.optional: true` means omit the configured default locale from the URL; use `optional: false` only when every locale, including the default, must be prefixed.
38
43
  - The application owns routes, locale resolution, links, HTML language/direction, and SEO. Fimo connects to those decisions.
39
44
  - Do not add a second deployment platform.
40
45
  - Never edit `.fimo/project.json`; it links this directory to the remote project.
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "next dev",
8
- "build": "next build",
8
+ "build": "fimo validate --json && next build",
9
9
  "start": "next start",
10
10
  "lint": "oxlint --deny-warnings",
11
11
  "typecheck": "next typegen && tsc --noEmit"
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ import Document from '../../components/Document';
4
+ import { defaultLocale } from '../../locales';
5
+
6
+ import '../globals.css';
7
+
8
+ export default function DefaultLocaleLayout({ children }: Readonly<{ children: ReactNode }>) {
9
+ return <Document locale={defaultLocale}>{children}</Document>;
10
+ }