fimo 2.6.0-staging.4 → 2.7.0-staging.1

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.
@@ -6,7 +6,9 @@ import type { FimoPath, GetFimoPathsOptions } from './types.js';
6
6
  *
7
7
  * - Static paths in config: emitted as-is.
8
8
  * - Dynamic paths (with `:slug`-style entry params): expanded by fetching
9
- * schema entries from `VITE_API_URL`. One returned entry per expansion.
9
+ * every schema entry from `VITE_API_URL`, page by page. One path per entry.
10
+ * - A route with an external param is never emitted: the application owns
11
+ * those values and lists those URLs itself.
10
12
  * - Locales come from the route contract, not from the project locale list: a
11
13
  * route is emitted once per locale only when its path declares a `:locale`
12
14
  * segment. The default locale is always unprefixed. A route without a
@@ -11,6 +11,9 @@ const isValidationBuild = !!process.env.FIMO_VALIDATION_BUILD;
11
11
  const MAX_FETCH_ATTEMPTS = 3;
12
12
  const RETRYABLE_FETCH_STATUSES = new Set([502, 503, 504]);
13
13
  const RETRY_BASE_DELAY_MS = 250;
14
+ // The content API pages with `limit` (capped at 100) and `offset`; its
15
+ // `meta.hasMore` says whether another page follows.
16
+ const ENTRIES_PAGE_SIZE = 100;
14
17
  function readEnvValue(rootDir, key) {
15
18
  if (process.env[key]) {
16
19
  return process.env[key];
@@ -42,9 +45,6 @@ function resolveApiBaseUrl(rootDir) {
42
45
  async function fetchEntries(baseUrl, schema, rootDir, locale) {
43
46
  const url = new URL(`${baseUrl.replace(/\/$/, '')}/entries/${schema}`);
44
47
  url.searchParams.set('locale', locale);
45
- if (isValidationBuild) {
46
- url.searchParams.set('limit', '1');
47
- }
48
48
  const headers = {};
49
49
  // `FIMO_ENV` is a customer-facing variable name and keeps its spelling; the
50
50
  // header this build emits is canonical. A
@@ -54,11 +54,47 @@ async function fetchEntries(baseUrl, schema, rootDir, locale) {
54
54
  if (branch) {
55
55
  headers['X-Fimo-Branch'] = branch;
56
56
  }
57
+ // A validation build only proves the route expands, so one entry is enough.
58
+ if (isValidationBuild) {
59
+ url.searchParams.set('limit', '1');
60
+ return (await fetchEntriesPage(url, headers, schema)).data;
61
+ }
62
+ // Every entry becomes a URL, so walk the whole collection page by page.
63
+ const entries = [];
64
+ let offset = 0;
65
+ for (;;) {
66
+ const pageUrl = new URL(url);
67
+ pageUrl.searchParams.set('limit', String(ENTRIES_PAGE_SIZE));
68
+ pageUrl.searchParams.set('offset', String(offset));
69
+ const page = await fetchEntriesPage(pageUrl, headers, schema);
70
+ entries.push(...page.data);
71
+ if (!page.meta) {
72
+ // Without pagination metadata a short page is the whole collection; a
73
+ // full one may hide more entries, and a truncated list is a silent bug.
74
+ if (page.data.length >= ENTRIES_PAGE_SIZE) {
75
+ throw new Error(`Entries response for ${schema} returned ${page.data.length} rows without pagination metadata, so every path cannot be enumerated.`);
76
+ }
77
+ return entries;
78
+ }
79
+ if (!page.meta.hasMore) {
80
+ return entries;
81
+ }
82
+ const nextOffset = page.meta.offset + page.meta.limit;
83
+ if (nextOffset <= offset) {
84
+ throw new Error(`Entries pagination for ${schema} did not advance past offset ${offset}.`);
85
+ }
86
+ offset = nextOffset;
87
+ }
88
+ }
89
+ async function fetchEntriesPage(url, headers, schema) {
57
90
  for (let attempt = 1; attempt <= MAX_FETCH_ATTEMPTS; attempt += 1) {
58
91
  const res = await fetch(url, { headers });
59
92
  if (res.ok) {
60
93
  const payload = (await res.json());
61
- return Array.isArray(payload?.data) ? payload.data : [];
94
+ return {
95
+ data: Array.isArray(payload?.data) ? payload.data : [],
96
+ meta: readPageMeta(payload?.meta),
97
+ };
62
98
  }
63
99
  if (!RETRYABLE_FETCH_STATUSES.has(res.status) || attempt === MAX_FETCH_ATTEMPTS) {
64
100
  const attemptDetail = attempt > 1 ? ` after ${attempt} attempts` : '';
@@ -68,6 +104,16 @@ async function fetchEntries(baseUrl, schema, rootDir, locale) {
68
104
  }
69
105
  throw new Error(`Failed to fetch ${schema}`);
70
106
  }
107
+ function readPageMeta(meta) {
108
+ if (!meta || typeof meta !== 'object') {
109
+ return undefined;
110
+ }
111
+ const { limit, offset, hasMore } = meta;
112
+ if (typeof limit !== 'number' || typeof offset !== 'number' || typeof hasMore !== 'boolean') {
113
+ return undefined;
114
+ }
115
+ return { limit, offset, hasMore };
116
+ }
71
117
  // Glob-ish matcher for route keys: '*' matches one segment, '**' matches any.
72
118
  function compilePattern(pattern) {
73
119
  const escaped = pattern
@@ -87,7 +133,9 @@ function matchesAny(routeKey, patterns) {
87
133
  *
88
134
  * - Static paths in config: emitted as-is.
89
135
  * - Dynamic paths (with `:slug`-style entry params): expanded by fetching
90
- * schema entries from `VITE_API_URL`. One returned entry per expansion.
136
+ * every schema entry from `VITE_API_URL`, page by page. One path per entry.
137
+ * - A route with an external param is never emitted: the application owns
138
+ * those values and lists those URLs itself.
91
139
  * - Locales come from the route contract, not from the project locale list: a
92
140
  * route is emitted once per locale only when its path declares a `:locale`
93
141
  * segment. The default locale is always unprefixed. A route without a
@@ -125,6 +173,12 @@ export async function getFimoPaths(opts = {}) {
125
173
  if (contract.unresolvedParams.length > 0) {
126
174
  return false;
127
175
  }
176
+ // Values the application owns cannot be enumerated here. The app lists
177
+ // those URLs itself — React Router `prerender`, Next `generateStaticParams`
178
+ // — and concatenates them with this result.
179
+ if (contract.externalParams.length > 0) {
180
+ return false;
181
+ }
128
182
  if (opts.include && opts.include.length > 0 && !matchesAny(contract.id, opts.include)) {
129
183
  return false;
130
184
  }
@@ -11,7 +11,12 @@ export interface FimoRouteSeoConfig {
11
11
  siteName?: string;
12
12
  }
13
13
  export interface FimoRouteParamConfig<Query = unknown> {
14
- type?: 'entry' | 'locale';
14
+ /**
15
+ * Omitted means `'entry'`; the reserved name `locale` infers `'locale'`.
16
+ * `'external'` marks a placeholder whose values the application owns: it is
17
+ * matched from URLs but never looked up in the CMS and never enumerated.
18
+ */
19
+ type?: 'entry' | 'locale' | 'external';
15
20
  schema?: string;
16
21
  queryField?: string;
17
22
  displayField?: string;
@@ -67,7 +72,16 @@ export interface FimoLocaleParam {
67
72
  */
68
73
  optional: boolean;
69
74
  }
70
- export type FimoRouteParam = FimoEntryParam | FimoLocaleParam;
75
+ /**
76
+ * A placeholder whose values the application owns: a synced catalogue, a
77
+ * browser-only id, a computed segment. Never read from the CMS, never
78
+ * enumerated by Fimo.
79
+ */
80
+ export interface FimoExternalParam {
81
+ type: 'external';
82
+ name: string;
83
+ }
84
+ export type FimoRouteParam = FimoEntryParam | FimoLocaleParam | FimoExternalParam;
71
85
  export interface FimoRouteIssue {
72
86
  /** The parameter the issue is about, when it is about one. */
73
87
  param?: string;
@@ -85,6 +99,8 @@ export interface FimoRouteContract<RouteConfig extends FimoRouteConfig = FimoRou
85
99
  canonicalPath: string;
86
100
  params: FimoRouteParam[];
87
101
  entryParams: FimoEntryParam[];
102
+ /** Params the application owns. Non-empty means Fimo cannot enumerate this route's URLs. */
103
+ externalParams: FimoExternalParam[];
88
104
  localeParam: FimoLocaleParam | null;
89
105
  /** Placeholders in the path that no usable descriptor resolves. */
90
106
  unresolvedParams: string[];
@@ -120,7 +136,7 @@ export declare function hasLocaleSegment(path: string): boolean;
120
136
  * Normalize one declared route into the contract every consumer reads.
121
137
  *
122
138
  * Parameter types resolve in this order:
123
- * 1. an explicit `type`;
139
+ * 1. an explicit `type` (`entry`, `locale`, or `external`);
124
140
  * 2. the reserved name `locale`, which infers the locale type;
125
141
  * 3. otherwise the entry type, which is what every route written before
126
142
  * locale-aware routing existed relies on.
@@ -85,6 +85,26 @@ function buildLocaleParam(name, descriptor, index, issues) {
85
85
  }
86
86
  return { type: "locale", name, optional: descriptor?.optional !== false };
87
87
  }
88
+ function buildExternalParam(name, descriptor, issues) {
89
+ if (name === FIMO_LOCALE_PARAM) {
90
+ issues.push({
91
+ param: name,
92
+ message: `Param "${name}" is reserved for the locale segment and cannot use \`"type": "external"\`.`,
93
+ fix: `Drop \`"type": "external"\` from \`params.${name}\`, or rename the segment.`
94
+ });
95
+ return null;
96
+ }
97
+ for (const field of ["schema", "queryField", "displayField", "query", "optional"]) {
98
+ if (descriptor[field] !== void 0) {
99
+ issues.push({
100
+ param: name,
101
+ message: `External param "${name}" must not declare "${field}" \u2014 its values are owned by the application, never read from the CMS.`,
102
+ fix: `Remove "${field}" from \`params.${name}\`.`
103
+ });
104
+ }
105
+ }
106
+ return { type: "external", name };
107
+ }
88
108
  function buildEntryParam(name, descriptor, issues) {
89
109
  if (name === FIMO_LOCALE_PARAM) {
90
110
  issues.push({
@@ -153,7 +173,8 @@ function resolveRouteContract(id, route) {
153
173
  }
154
174
  seen.add(name);
155
175
  const descriptor = declared[name];
156
- const resolved = resolveParamType(name, descriptor) === "locale" ? buildLocaleParam(name, descriptor, index, issues) : buildEntryParam(name, descriptor, issues);
176
+ const type = resolveParamType(name, descriptor);
177
+ const resolved = type === "locale" ? buildLocaleParam(name, descriptor, index, issues) : type === "external" && descriptor ? buildExternalParam(name, descriptor, issues) : buildEntryParam(name, descriptor, issues);
157
178
  if (resolved) {
158
179
  params.push(resolved);
159
180
  } else {
@@ -178,6 +199,7 @@ function resolveRouteContract(id, route) {
178
199
  canonicalPath,
179
200
  params,
180
201
  entryParams: params.filter((param) => param.type === "entry"),
202
+ externalParams: params.filter((param) => param.type === "external"),
181
203
  localeParam,
182
204
  unresolvedParams,
183
205
  issues
@@ -217,6 +239,7 @@ function matchContract(urlSegments, contract, vocabulary) {
217
239
  }
218
240
  const params = {};
219
241
  const entryParams = {};
242
+ const entryNames = new Set(contract.entryParams.map((param) => param.name));
220
243
  let localeFromPath = null;
221
244
  let score = 0;
222
245
  let offset = 0;
@@ -244,7 +267,9 @@ function matchContract(urlSegments, contract, vocabulary) {
244
267
  const rawValue = catchAll ? urlSegments.slice(index + offset).join("/") : value;
245
268
  const decoded = safeDecode(rawValue);
246
269
  params[name] = decoded;
247
- entryParams[name] = decoded;
270
+ if (entryNames.has(name)) {
271
+ entryParams[name] = decoded;
272
+ }
248
273
  if (catchAll) {
249
274
  break;
250
275
  }
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { pageSourceParamExpressions, parsePageParamExpression, } from '@fimo/api-contracts';
3
+ import { isEntryParamBinding, pageSourceParamExpressions, parsePageParamExpression, } from '@fimo/api-contracts';
4
+ /** Only an entry binding names content; the rest declare where values come from. */
5
+ function isEntryBinding(binding) {
6
+ return isEntryParamBinding(binding);
7
+ }
4
8
  function isRecord(value) {
5
9
  return typeof value === 'object' && value !== null && !Array.isArray(value);
6
10
  }
@@ -74,7 +78,14 @@ function validateQuery(query, pointer, schema, params, schemas) {
74
78
  for (const expression of pageSourceParamExpressions(query)) {
75
79
  const parsed = parsePageParamExpression(expression);
76
80
  const binding = parsed ? params[parsed.param] : undefined;
77
- if (!parsed || !binding || typeof binding.schema !== 'string') {
81
+ if (!parsed || !binding) {
82
+ continue;
83
+ }
84
+ if (!isEntryBinding(binding)) {
85
+ errors.push(`${pointer}: ${expression} reads param "${parsed.param}", which is not a Collection entry param.`);
86
+ continue;
87
+ }
88
+ if (typeof binding.schema !== 'string') {
78
89
  continue;
79
90
  }
80
91
  const boundSchema = schemas.get(binding.schema);
@@ -99,7 +110,7 @@ export function validatePageRegistrySchemaReferences(cwd, config, displayPath) {
99
110
  const params = isRecord(rawRoute.params) ? rawRoute.params : {};
100
111
  for (const [param, binding] of Object.entries(params)) {
101
112
  const pointer = `${displayPath}#routes.${routeId}.params.${param}`;
102
- if (typeof binding.schema !== 'string') {
113
+ if (!isEntryBinding(binding) || typeof binding.schema !== 'string') {
103
114
  continue;
104
115
  }
105
116
  const schema = schemas.get(binding.schema);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fimo",
3
- "version": "2.6.0-staging.4",
3
+ "version": "2.7.0-staging.1",
4
4
  "description": "Fimo CLI - create, deploy, and manage Fimo projects",
5
5
  "keywords": [
6
6
  "astro-component"
@@ -194,13 +194,13 @@
194
194
  "devDependencies": {
195
195
  "@clack/core": "^1.2.0",
196
196
  "@clack/prompts": "^1.2.0",
197
- "@fimo/agents-bundle": "2.6.0-staging.4",
198
- "@fimo/api-contracts": "2.6.0-staging.4",
199
- "@fimo/cli-kit": "2.6.0-staging.4",
200
- "@fimo/framework-detect": "2.6.0-staging.4",
201
- "@fimo/routes": "2.6.0-staging.4",
202
- "@fimo/sdk": "2.6.0-staging.4",
203
- "@fimo/tsconfig": "2.6.0-staging.4",
197
+ "@fimo/agents-bundle": "2.7.0-staging.1",
198
+ "@fimo/api-contracts": "2.7.0-staging.1",
199
+ "@fimo/cli-kit": "2.7.0-staging.1",
200
+ "@fimo/framework-detect": "2.7.0-staging.1",
201
+ "@fimo/routes": "2.7.0-staging.1",
202
+ "@fimo/sdk": "2.7.0-staging.1",
203
+ "@fimo/tsconfig": "2.7.0-staging.1",
204
204
  "@types/node": "22.18.6",
205
205
  "agent-install": "^0.0.5",
206
206
  "astro": "^7.1.6",
@@ -9,8 +9,8 @@
9
9
  "check": "astro sync && astro check"
10
10
  },
11
11
  "dependencies": {
12
- "fimo": "2.6.0-staging.4",
13
- "fimo-astro": "2.6.0-staging.4"
12
+ "fimo": "2.7.0-staging.1",
13
+ "fimo-astro": "2.7.0-staging.1"
14
14
  },
15
15
  "devDependencies": {
16
16
  "@astrojs/check": "^0.9.10",
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@tanstack/react-query": "^5.80.6",
15
- "fimo": "2.6.0-staging.4",
16
- "fimo-next": "2.6.0-staging.4",
17
- "fimo-react": "2.6.0-staging.4",
15
+ "fimo": "2.7.0-staging.1",
16
+ "fimo-next": "2.7.0-staging.1",
17
+ "fimo-react": "2.7.0-staging.1",
18
18
  "next": "^16.3.3",
19
19
  "react": "^19.2.7",
20
20
  "react-dom": "^19.2.7"
@@ -21,10 +21,10 @@
21
21
  "cn": "0.2.4",
22
22
  "date-fns": "^4.1.0",
23
23
  "embla-carousel-react": "^8.6.0",
24
- "fimo": "2.6.0-staging.4",
25
- "fimo-react": "2.6.0-staging.4",
26
- "fimo-react-router": "2.6.0-staging.4",
27
- "fimo-vite": "2.6.0-staging.4",
24
+ "fimo": "2.7.0-staging.1",
25
+ "fimo-react": "2.7.0-staging.1",
26
+ "fimo-react-router": "2.7.0-staging.1",
27
+ "fimo-vite": "2.7.0-staging.1",
28
28
  "input-otp": "^1.4.2",
29
29
  "isbot": "^5",
30
30
  "lucide-react": "^0.577.0",
@@ -11,8 +11,8 @@
11
11
  "prepare": "svelte-kit sync"
12
12
  },
13
13
  "dependencies": {
14
- "fimo": "2.6.0-staging.4",
15
- "fimo-sveltekit": "2.6.0-staging.4"
14
+ "fimo": "2.7.0-staging.1",
15
+ "fimo-sveltekit": "2.7.0-staging.1"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@sveltejs/kit": "^2.70.2",
@@ -11,10 +11,10 @@
11
11
  },
12
12
  "dependencies": {
13
13
  "@tanstack/react-query": "^5.80.6",
14
- "fimo": "2.6.0-staging.4",
15
- "fimo-react": "2.6.0-staging.4",
16
- "fimo-react-router": "2.6.0-staging.4",
17
- "fimo-vite": "2.6.0-staging.4",
14
+ "fimo": "2.7.0-staging.1",
15
+ "fimo-react": "2.7.0-staging.1",
16
+ "fimo-react-router": "2.7.0-staging.1",
17
+ "fimo-vite": "2.7.0-staging.1",
18
18
  "react": "^19.2.7",
19
19
  "react-dom": "^19.2.7",
20
20
  "react-router": "^8.3.0"