fimo 0.17.0 → 0.19.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 (164) 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 +8 -6
  6. package/assets/skills/fimo-cli/references/branches.md +2 -2
  7. package/assets/skills/fimo-cli/references/content.md +2 -2
  8. package/assets/skills/fimo-cli/references/deploy.md +17 -9
  9. package/assets/skills/fimo-cli/references/hosting.md +114 -0
  10. package/assets/skills/fimo-cli/references/init.md +13 -6
  11. package/assets/skills/fimo-cli/references/integrations/firecrawl.md +2 -0
  12. package/assets/skills/fimo-cli/references/locales.md +38 -14
  13. package/assets/skills/fimo-cli/references/pages.md +70 -0
  14. package/assets/skills/fimo-cli/references/preview.md +26 -20
  15. package/assets/skills/fimo-cli/references/webhooks.md +1 -1
  16. package/assets/skills/fimo-code/SKILL.md +11 -0
  17. package/assets/skills/fimo-code/assets/react-router-locales/LocaleLayout.tsx +12 -0
  18. package/assets/skills/fimo-code/assets/react-router-locales/routes.ts +7 -6
  19. package/assets/skills/fimo-code/references/best-practices/astro.md +47 -0
  20. package/assets/skills/fimo-code/references/best-practices/next.md +42 -0
  21. package/assets/skills/fimo-code/references/best-practices/sveltekit.md +17 -0
  22. package/assets/skills/fimo-code/references/content/richtext.md +5 -0
  23. package/assets/skills/fimo-code/references/content/schema.md +4 -1
  24. package/assets/skills/fimo-code/references/frameworks/astro.md +5 -3
  25. package/assets/skills/fimo-code/references/frameworks/hosting.md +5 -2
  26. package/assets/skills/fimo-code/references/frameworks/next/app-router.md +6 -0
  27. package/assets/skills/fimo-code/references/frameworks/next/locales.md +12 -2
  28. package/assets/skills/fimo-code/references/frameworks/next.md +7 -0
  29. package/assets/skills/fimo-code/references/frameworks/react-router.md +15 -7
  30. package/assets/skills/fimo-code/references/frameworks/sveltekit.md +15 -8
  31. package/assets/skills/fimo-code/references/labels.md +9 -1
  32. package/assets/skills/fimo-code/references/locales.md +8 -2
  33. package/assets/skills/fimo-code/references/migrations/react-router-route-ownership.md +93 -0
  34. package/assets/skills/fimo-code/references/ui.md +21 -2
  35. package/assets/skills/fimo-migration/SKILL.md +276 -51
  36. package/assets/skills/fimo-migration/references/content.md +119 -35
  37. package/assets/skills/fimo-migration/references/decision.md +72 -0
  38. package/assets/skills/fimo-migration/references/forms.md +38 -6
  39. package/assets/skills/fimo-migration/references/frameworks/nextjs.md +179 -23
  40. package/assets/skills/fimo-migration/references/import-contract.md +71 -19
  41. package/assets/skills/fimo-migration/references/manifest.md +11 -161
  42. package/assets/skills/fimo-migration/references/media.md +33 -1
  43. package/assets/skills/fimo-migration/references/platforms.md +26 -2
  44. package/assets/skills/fimo-migration/references/qa.md +167 -13
  45. package/assets/skills/fimo-migration/references/workers/content-execution.md +39 -0
  46. package/assets/skills/fimo-migration/references/workers/content-planner.md +64 -0
  47. package/assets/skills/fimo-migration/references/workers/framework.md +37 -0
  48. package/assets/skills/fimo-migration/references/workers/validator.md +25 -0
  49. package/assets/skills/fimo-migration/references/workers.md +82 -0
  50. package/assets/skills/fimo-migration/references/workflow.md +140 -192
  51. package/assets/skills/fimo-migration/scripts/apply-decision.mjs +534 -0
  52. package/assets/skills/fimo-migration/scripts/build-canonical-manifest.mjs +7 -4
  53. package/assets/skills/fimo-migration/scripts/write-migration-report.mjs +191 -4
  54. package/assets/skills/fimo-studio/references/ai-chat.md +1 -1
  55. package/assets/skills/fimo-studio/references/cms.md +25 -7
  56. package/assets/skills/fimo-studio/references/webhooks.md +1 -1
  57. package/assets/sveltekit/components/DateTime.svelte +35 -0
  58. package/assets/sveltekit/components/FimoProvider.svelte +2 -2
  59. package/assets/sveltekit/components/index.d.ts +1 -0
  60. package/assets/sveltekit/components/index.js +1 -0
  61. package/dist/build/next/config.d.ts +4 -1
  62. package/dist/build/next/config.js +21 -5
  63. package/dist/build/sveltekit/vite.d.ts +3 -4
  64. package/dist/build/sveltekit/vite.js +3 -4
  65. package/dist/build/vite/plugins/runtime-env.d.ts +2 -1
  66. package/dist/build/vite/plugins/runtime-env.js +14 -10
  67. package/dist/cli/bundle.json +2 -2
  68. package/dist/cli/index.js +113 -108
  69. package/dist/runtime/app/FimoProviders.js +25 -14
  70. package/dist/runtime/app/prefetch.d.ts +3 -1
  71. package/dist/runtime/app/prefetch.js +11 -55
  72. package/dist/runtime/astro/preview.js +7 -9
  73. package/dist/runtime/content/client/internal.js +1 -0
  74. package/dist/runtime/i18n/locale.d.ts +6 -3
  75. package/dist/runtime/i18n/locale.js +16 -41
  76. package/dist/runtime/next/client-runtime.js +5 -1
  77. package/dist/runtime/next/client.d.ts +3 -2
  78. package/dist/runtime/next/client.js +2 -1
  79. package/dist/runtime/next/labels.d.ts +2 -2
  80. package/dist/runtime/next/labels.js +2 -2
  81. package/dist/runtime/next/preview.d.ts +13 -0
  82. package/dist/runtime/next/preview.js +34 -0
  83. package/dist/runtime/next/primitives/server.d.ts +2 -0
  84. package/dist/runtime/next/primitives/server.js +8 -0
  85. package/dist/runtime/next/provider.d.ts +5 -0
  86. package/dist/runtime/next/provider.js +17 -3
  87. package/dist/runtime/next/server-runtime.js +4 -1
  88. package/dist/runtime/next/server.d.ts +1 -1
  89. package/dist/runtime/next/server.js +1 -1
  90. package/dist/runtime/paths/get-fimo-paths.d.ts +6 -5
  91. package/dist/runtime/paths/get-fimo-paths.js +43 -42
  92. package/dist/runtime/preview/location.d.ts +19 -2
  93. package/dist/runtime/preview/location.js +16 -2
  94. package/dist/runtime/primitives/index.d.ts +1 -0
  95. package/dist/runtime/primitives/index.js +1 -0
  96. package/dist/runtime/react/primitives/editable/DateTime.d.ts +4 -0
  97. package/dist/runtime/react/primitives/editable/DateTime.js +13 -0
  98. package/dist/runtime/react/primitives/renderers/DateRenderer.d.ts +7 -1
  99. package/dist/runtime/react/primitives/renderers/DateRenderer.js +11 -8
  100. package/dist/runtime/react-router/{routes.d.ts → legacy/routes.d.ts} +6 -1
  101. package/dist/runtime/react-router/{routes.js → legacy/routes.js} +22 -7
  102. package/dist/runtime/routes/index.d.ts +162 -0
  103. package/dist/runtime/routes/index.js +335 -0
  104. package/dist/runtime/routing/AppRoutesProvider.js +4 -3
  105. package/dist/runtime/seo/resolver.d.ts +5 -0
  106. package/dist/runtime/seo/resolver.js +10 -42
  107. package/dist/runtime/shared/fimo-config.types.d.ts +14 -13
  108. package/dist/runtime/shared/preview-location.d.ts +1 -22
  109. package/dist/runtime/shared/preview-location.js +5 -93
  110. package/dist/runtime/sveltekit/preview.d.ts +12 -3
  111. package/dist/runtime/sveltekit/preview.js +13 -8
  112. package/dist/runtime/templates.d.ts +19 -0
  113. package/dist/runtime/templates.js +33 -0
  114. package/dist/scripts/sync-forms.js +1 -1
  115. package/dist/scripts/sync-schemas.js +1 -4
  116. package/dist/scripts/validate-fimo-config.js +5 -1
  117. package/dist/scripts/validate-page-registry.d.ts +2 -0
  118. package/dist/scripts/validate-page-registry.js +167 -0
  119. package/dist/scripts/validate-route-metadata.js +132 -41
  120. package/package.json +10 -5
  121. package/templates/astro/.fimo/config.json +13 -5
  122. package/templates/astro/AGENTS.md +5 -1
  123. package/templates/astro/astro.config.mjs +7 -0
  124. package/templates/astro/pnpm-workspace.yaml +0 -3
  125. package/templates/astro/src/components/ProjectOverview.astro +9 -6
  126. package/templates/astro/src/layouts/Base.astro +1 -1
  127. package/templates/astro/src/locales.ts +4 -0
  128. package/templates/astro/src/pages/[locale]/index.astro +19 -0
  129. package/templates/astro/src/pages/[locale]/projects/[slug].astro +35 -0
  130. package/templates/astro/src/pages/[locale]/projects/index.astro +19 -0
  131. package/templates/astro/src/pages/index.astro +5 -2
  132. package/templates/astro/src/pages/projects/[slug].astro +4 -2
  133. package/templates/astro/src/pages/projects/index.astro +5 -2
  134. package/templates/nextjs/.fimo/config.json +2 -1
  135. package/templates/nextjs/AGENTS.md +5 -0
  136. package/templates/nextjs/src/app/(default)/layout.tsx +10 -0
  137. package/templates/nextjs/src/app/[locale]/layout.tsx +22 -0
  138. package/templates/nextjs/src/app/[locale]/page.tsx +1 -0
  139. package/templates/nextjs/src/components/Document.tsx +26 -0
  140. package/templates/nextjs/src/components/FimoPreviewBridge.tsx +13 -0
  141. package/templates/nextjs/src/locales.ts +13 -0
  142. package/templates/react-router/.fimo/config.json +3 -6
  143. package/templates/react-router/AGENTS.md +4 -0
  144. package/templates/react-router/package.json +1 -1
  145. package/templates/react-router/pnpm-workspace.yaml +1 -3
  146. package/templates/react-router/react-router.config.ts +4 -10
  147. package/templates/react-router/src/pages/LocaleLayout.tsx +12 -0
  148. package/templates/react-router/src/root.tsx +2 -4
  149. package/templates/react-router/src/routes.ts +5 -3
  150. package/templates/sveltekit/.fimo/config.json +2 -1
  151. package/templates/sveltekit/AGENTS.md +5 -0
  152. package/templates/sveltekit/src/app.html +1 -1
  153. package/templates/sveltekit/src/hooks.server.ts +11 -0
  154. package/templates/sveltekit/src/locales.ts +8 -0
  155. package/templates/sveltekit/src/params/locale.ts +5 -0
  156. package/templates/sveltekit/src/routes/+layout.server.ts +6 -1
  157. package/templates/sveltekit/src/routes/+layout.svelte +5 -0
  158. package/assets/skills/fimo-migration/references/inventory/coordinator.md +0 -40
  159. package/assets/skills/fimo-migration/references/inventory/locales/next-intl.md +0 -39
  160. package/assets/skills/fimo-migration/references/inventory/locales.md +0 -41
  161. package/assets/skills/fimo-migration/scripts/inventory/locales/next-intl.mjs +0 -459
  162. package/templates/nextjs/src/app/layout.tsx +0 -17
  163. /package/templates/nextjs/src/app/{page.tsx → (default)/page.tsx} +0 -0
  164. /package/templates/sveltekit/src/routes/{+page.svelte → [[locale=locale]]/+page.svelte} +0 -0
@@ -1,4 +1,4 @@
1
- import { getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
1
+ import { getDocumentLocale, getPreviewLocationChange, getPreviewParentOrigin } from '../shared/preview-location.js';
2
2
  /** Load the platform preview script once. Empty configuration keeps preview disabled. */
3
3
  export function ensurePreviewScript(src) {
4
4
  if (!src) {
@@ -13,14 +13,19 @@ export function ensurePreviewScript(src) {
13
13
  }
14
14
  return true;
15
15
  }
16
- /** Report where the app is now. Studio keeps its URL and route selector in sync from this. */
17
- export function sendPreviewLocation() {
16
+ /**
17
+ * Report where the app is now, and in which locale. Studio keeps its URL,
18
+ * route selector and content locale in sync from this.
19
+ *
20
+ * `locale` defaults to the locale the SvelteKit shell rendered into
21
+ * `<html lang>`; pass one explicitly when the app resolves it somewhere the
22
+ * document does not show, for example a cookie or a user preference.
23
+ */
24
+ export function sendPreviewLocation(locale) {
18
25
  const parentOrigin = getPreviewParentOrigin();
19
- const locationChange = getPreviewLocationChange(window.location);
20
26
  window.parent.postMessage({
21
27
  type: 'nav/locationChange',
22
- // Query and hash state only travels to an established parent origin.
23
- payload: parentOrigin ? locationChange : { path: locationChange.path },
28
+ payload: getPreviewLocationChange(window.location, locale ?? getDocumentLocale(document, 'en'), parentOrigin),
24
29
  }, parentOrigin ?? '*');
25
30
  }
26
31
  /** Reliable-delivery reply: the parent retries an unacknowledged message forever. */
@@ -48,7 +53,7 @@ async function refreshContent(message, invalidateAll) {
48
53
  * Only messages from the established parent origin are honored. History stays
49
54
  * native, so Studio's back/forward controls need no protocol of their own.
50
55
  */
51
- export function connectPreview({ goto, invalidateAll }) {
56
+ export function connectPreview({ goto, invalidateAll, locale }) {
52
57
  const onMessage = (event) => {
53
58
  const parentOrigin = getPreviewParentOrigin();
54
59
  if (event.source !== window.parent || !parentOrigin || event.origin !== parentOrigin) {
@@ -63,7 +68,7 @@ export function connectPreview({ goto, invalidateAll }) {
63
68
  return;
64
69
  case 'nav/read-routes':
65
70
  // The app owns its route table; answer with where it actually is.
66
- sendPreviewLocation();
71
+ sendPreviewLocation(locale?.());
67
72
  return;
68
73
  case 'content/refresh':
69
74
  void refreshContent(message, invalidateAll);
@@ -62,6 +62,25 @@ interface PkgWithDeps {
62
62
  dependencies?: Record<string, string>;
63
63
  devDependencies?: Record<string, string>;
64
64
  }
65
+ interface PkgWithScripts {
66
+ scripts?: Record<string, string>;
67
+ }
68
+ /**
69
+ * Point the build script's `fimo validate` at the exact CLI that scaffolded the
70
+ * project.
71
+ *
72
+ * Inside an npm script a bare `fimo` resolves to whatever is installed in the
73
+ * project, which is normally the pinned dependency — but only when the install
74
+ * is current. The hosted build installs the tree itself and then runs this
75
+ * script, and `validate` is an API client: resolving it from install state
76
+ * means a project can be checked by a different version than the one it pins,
77
+ * which is how a project can look clean locally and fail on publish.
78
+ *
79
+ * Only an exact npm version is pinned. A source checkout resolves to
80
+ * `file:<path>`, which is not an npx spec — and there a bare `fimo` already
81
+ * runs the very binary being developed, which is the point of that flow.
82
+ */
83
+ export declare function rewriteFimoValidateCommand(pkg: PkgWithScripts, pin?: string | null): boolean;
65
84
  /**
66
85
  * Rewrite `fimo` (in dependencies + devDependencies) to the current pin. Only
67
86
  * touches entries that look like a value we own: the template's dist-tag, an old
@@ -164,6 +164,39 @@ function findDevJson(startPath) {
164
164
  current = parent;
165
165
  }
166
166
  }
167
+ /**
168
+ * Point the build script's `fimo validate` at the exact CLI that scaffolded the
169
+ * project.
170
+ *
171
+ * Inside an npm script a bare `fimo` resolves to whatever is installed in the
172
+ * project, which is normally the pinned dependency — but only when the install
173
+ * is current. The hosted build installs the tree itself and then runs this
174
+ * script, and `validate` is an API client: resolving it from install state
175
+ * means a project can be checked by a different version than the one it pins,
176
+ * which is how a project can look clean locally and fail on publish.
177
+ *
178
+ * Only an exact npm version is pinned. A source checkout resolves to
179
+ * `file:<path>`, which is not an npx spec — and there a bare `fimo` already
180
+ * runs the very binary being developed, which is the point of that flow.
181
+ */
182
+ export function rewriteFimoValidateCommand(pkg, pin = resolveFimoPin()) {
183
+ if (!pin || !/^\d+\.\d+\.\d+/.test(pin)) {
184
+ return false;
185
+ }
186
+ const build = pkg.scripts?.build;
187
+ if (typeof build !== 'string') {
188
+ return false;
189
+ }
190
+ // Only the validate invocation. Anything else the script runs through `fimo`
191
+ // is local file work, not an API client, and pinning it would add a fetch for
192
+ // no reason.
193
+ const pinned = build.replace(/\bfimo validate\b/, `npx fimo@${pin} validate`);
194
+ if (pinned === build) {
195
+ return false;
196
+ }
197
+ pkg.scripts.build = pinned;
198
+ return true;
199
+ }
167
200
  /**
168
201
  * Rewrite `fimo` (in dependencies + devDependencies) to the current pin. Only
169
202
  * touches entries that look like a value we own: the template's dist-tag, an old
@@ -41,7 +41,7 @@ function frameworkFormsTarget(framework) {
41
41
  return 'next';
42
42
  if (framework === 'astro')
43
43
  return 'astro';
44
- if (framework === 'sveltekit' || framework === 'sveltekit-1')
44
+ if (framework === 'sveltekit')
45
45
  return 'sveltekit';
46
46
  return null;
47
47
  }
@@ -120,10 +120,7 @@ function contentRuntimeImportFor(framework) {
120
120
  if (framework === 'astro') {
121
121
  return 'fimo/astro';
122
122
  }
123
- // @vercel/frameworks reports SvelteKit as `sveltekit-1`, while Fimo's
124
- // public runtime profile is `sveltekit`. Both describe the same codegen
125
- // target; callers may supply either the detector evidence or the profile.
126
- if (framework === 'sveltekit' || framework === 'sveltekit-1') {
123
+ if (framework === 'sveltekit') {
127
124
  return 'fimo/sveltekit';
128
125
  }
129
126
  return 'fimo/ui';
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { FimoAppConfigSchema } from '@fimo/api-contracts';
4
4
  import { resolveFimoConfigPath } from '../runtime/shared/fimo-config.server.js';
5
+ import { validatePageRegistrySchemaReferences } from './validate-page-registry.js';
5
6
  export function validateFimoConfig(cwd) {
6
7
  const configPath = resolveFimoConfigPath(cwd);
7
8
  if (!fs.existsSync(configPath)) {
@@ -17,7 +18,10 @@ export function validateFimoConfig(cwd) {
17
18
  }
18
19
  const parsed = FimoAppConfigSchema.safeParse(value);
19
20
  if (parsed.success) {
20
- return { errors: [], configChecked: true };
21
+ return {
22
+ errors: validatePageRegistrySchemaReferences(cwd, value, displayPath),
23
+ configChecked: true,
24
+ };
21
25
  }
22
26
  return {
23
27
  errors: parsed.error.issues.map((issue) => {
@@ -0,0 +1,2 @@
1
+ /** Cross-check Page config references against the project's schema source files. */
2
+ export declare function validatePageRegistrySchemaReferences(cwd: string, config: Record<string, unknown>, displayPath: string): string[];
@@ -0,0 +1,167 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pageSourceParamExpressions, parsePageParamExpression, } from '@fimo/api-contracts';
4
+ function isRecord(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ function fieldList(value) {
8
+ if (typeof value === 'string') {
9
+ return [value];
10
+ }
11
+ return Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [];
12
+ }
13
+ function queryFields(query) {
14
+ if (!isRecord(query)) {
15
+ return [];
16
+ }
17
+ const fields = new Set();
18
+ if (isRecord(query.where)) {
19
+ for (const field of Object.keys(query.where)) {
20
+ if (!field.startsWith('$')) {
21
+ fields.add(field.split('.')[0]);
22
+ }
23
+ }
24
+ }
25
+ for (const sort of fieldList(query.sort)) {
26
+ fields.add(sort.replace(/^-/, '').split('.')[0]);
27
+ }
28
+ for (const field of [...fieldList(query.fields), ...fieldList(query.populate)]) {
29
+ fields.add(field.split('.')[0]);
30
+ }
31
+ return [...fields];
32
+ }
33
+ function readSchemas(cwd) {
34
+ const directory = path.join(cwd, 'src/schemas');
35
+ if (!fs.existsSync(directory)) {
36
+ return null;
37
+ }
38
+ const schemas = new Map();
39
+ for (const file of fs.readdirSync(directory).filter((name) => name.endsWith('.json'))) {
40
+ const fallbackUid = path.basename(file, '.json');
41
+ let value;
42
+ try {
43
+ value = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
44
+ }
45
+ catch {
46
+ schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
47
+ continue;
48
+ }
49
+ if (!isRecord(value)) {
50
+ schemas.set(fallbackUid, { fields: {}, isSingleton: false, valid: false });
51
+ continue;
52
+ }
53
+ const uid = typeof value.uid === 'string' && value.uid ? value.uid : fallbackUid;
54
+ schemas.set(uid, {
55
+ fields: isRecord(value.fields) ? value.fields : {},
56
+ isSingleton: value.isSingleton === true,
57
+ valid: true,
58
+ });
59
+ }
60
+ return schemas;
61
+ }
62
+ function schemaFields(schema) {
63
+ return new Set([...Object.keys(schema.fields), 'id', 'slug']);
64
+ }
65
+ function queryableFields(schema) {
66
+ return new Set([...schemaFields(schema), 'createdAt', 'updatedAt']);
67
+ }
68
+ function validateQuery(query, pointer, schema, params, schemas) {
69
+ const errors = [];
70
+ const unknownField = queryFields(query).find((field) => !queryableFields(schema).has(field));
71
+ if (unknownField) {
72
+ errors.push(`${pointer}: Query field "${unknownField}" does not exist on the referenced schema.`);
73
+ }
74
+ for (const expression of pageSourceParamExpressions(query)) {
75
+ const parsed = parsePageParamExpression(expression);
76
+ const binding = parsed ? params[parsed.param] : undefined;
77
+ if (!parsed || !binding || typeof binding.schema !== 'string') {
78
+ continue;
79
+ }
80
+ const boundSchema = schemas.get(binding.schema);
81
+ const field = parsed.field.split('.')[0];
82
+ if (boundSchema?.valid && !schemaFields(boundSchema).has(field)) {
83
+ errors.push(`${pointer}: ${expression} reads missing field "${field}" from schema "${binding.schema}".`);
84
+ }
85
+ }
86
+ return errors;
87
+ }
88
+ /** Cross-check Page config references against the project's schema source files. */
89
+ export function validatePageRegistrySchemaReferences(cwd, config, displayPath) {
90
+ const schemas = readSchemas(cwd);
91
+ if (!schemas || !isRecord(config.routes)) {
92
+ return [];
93
+ }
94
+ const errors = [];
95
+ for (const [routeId, rawRoute] of Object.entries(config.routes)) {
96
+ if (!isRecord(rawRoute)) {
97
+ continue;
98
+ }
99
+ const params = isRecord(rawRoute.params) ? rawRoute.params : {};
100
+ for (const [param, binding] of Object.entries(params)) {
101
+ const pointer = `${displayPath}#routes.${routeId}.params.${param}`;
102
+ if (typeof binding.schema !== 'string') {
103
+ continue;
104
+ }
105
+ const schema = schemas.get(binding.schema);
106
+ if (!schema) {
107
+ errors.push(`${pointer}.schema: Unknown schema "${binding.schema}".`);
108
+ continue;
109
+ }
110
+ if (!schema.valid) {
111
+ continue;
112
+ }
113
+ if (schema.isSingleton) {
114
+ errors.push(`${pointer}.schema: Page params require a Collection, but "${binding.schema}" is a Single.`);
115
+ }
116
+ const fields = schemaFields(schema);
117
+ for (const key of ['queryField', 'displayField']) {
118
+ const field = binding[key];
119
+ if (typeof field === 'string' && !fields.has(field)) {
120
+ errors.push(`${pointer}.${key}: Field "${field}" does not exist on schema "${binding.schema}".`);
121
+ }
122
+ }
123
+ if (typeof binding.queryField === 'string' && !['id', 'slug'].includes(binding.queryField)) {
124
+ const definition = schema.fields[binding.queryField];
125
+ if (isRecord(definition)) {
126
+ if (definition.required !== true) {
127
+ errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "required": true.`);
128
+ }
129
+ if (definition.unique !== true) {
130
+ errors.push(`${pointer}.queryField: Custom URL field "${binding.queryField}" must set "unique": true.`);
131
+ }
132
+ }
133
+ }
134
+ errors.push(...validateQuery(binding.query, `${pointer}.query`, schema, params, schemas));
135
+ }
136
+ if (!isRecord(rawRoute.sources)) {
137
+ continue;
138
+ }
139
+ for (const [sourceName, source] of Object.entries(rawRoute.sources)) {
140
+ if (!isRecord(source) || (source.type !== 'single' && source.type !== 'collection')) {
141
+ continue;
142
+ }
143
+ const pointer = `${displayPath}#routes.${routeId}.sources.${sourceName}`;
144
+ if (typeof source.schema !== 'string') {
145
+ continue;
146
+ }
147
+ const schema = schemas.get(source.schema);
148
+ if (!schema) {
149
+ errors.push(`${pointer}.schema: Unknown schema "${source.schema}".`);
150
+ continue;
151
+ }
152
+ if (!schema.valid) {
153
+ continue;
154
+ }
155
+ if (source.type === 'single' && !schema.isSingleton) {
156
+ errors.push(`${pointer}.schema: Source expects a Single, but "${source.schema}" is a Collection.`);
157
+ }
158
+ if (source.type === 'collection' && schema.isSingleton) {
159
+ errors.push(`${pointer}.schema: Source expects a Collection, but "${source.schema}" is a Single.`);
160
+ }
161
+ if (source.type === 'collection') {
162
+ errors.push(...validateQuery(source.query, `${pointer}.query`, schema, params, schemas));
163
+ }
164
+ }
165
+ }
166
+ return errors;
167
+ }
@@ -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/index.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;