@uniweb/build 0.37.1 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.37.1",
3
+ "version": "0.39.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -23,9 +23,7 @@
23
23
  "./uwx": "./src/uwx/index.js",
24
24
  "./import-map-plugin": "./src/import-map-plugin.js"
25
25
  },
26
- "bin": {
27
- "uniweb-dev-backend": "./src/dev-backend.js"
28
- },
26
+ "bin": {},
29
27
  "files": [
30
28
  "src"
31
29
  ],
@@ -60,14 +58,14 @@
60
58
  "sharp": "^0.35.3",
61
59
  "yaml": "^2.5.0",
62
60
  "@uniweb/content-writer": "^0.3.4",
63
- "@uniweb/content-reader": "^1.2.4",
64
61
  "@uniweb/semantic-parser": "^1.4.0",
65
- "@uniweb/projections": "^0.5.7",
66
- "@uniweb/schemas": "^0.2.13",
67
- "@uniweb/theming": "^0.1.15"
62
+ "@uniweb/theming": "^0.1.15",
63
+ "@uniweb/content-reader": "^1.2.4",
64
+ "@uniweb/projections": "^0.5.9",
65
+ "@uniweb/schemas": "^0.2.13"
68
66
  },
69
67
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.14.2"
68
+ "@uniweb/runtime": "^0.16.0"
71
69
  },
72
70
  "peerDependencies": {
73
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +74,7 @@
76
74
  "@tailwindcss/vite": "^4.0.0",
77
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
76
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.19.0"
77
+ "@uniweb/core": "^0.21.0"
80
78
  },
81
79
  "peerDependenciesMeta": {
82
80
  "vite": {
package/src/prerender.js CHANGED
@@ -11,8 +11,8 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
11
11
  import { existsSync, readdirSync, statSync } from 'node:fs'
12
12
  import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
- import { resolveDefaultLocale, isDataUrl } from '@uniweb/core'
15
- import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
14
+ import { resolveDefaultLocale, resolveFetchConfigs, joinPathCapture, splitPathCapture } from '@uniweb/core'
15
+ import { executeFetch, mergeDataIntoContent, toFetchList, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
16
16
  import { shouldSplitContent } from './site/split-content.js'
17
17
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
18
18
  import { getAdapter } from './hosts/index.js'
@@ -78,37 +78,50 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
78
78
  * @param {string} [localeInfo.distDir] - Path to dist directory (where locale-specific data lives)
79
79
  * @returns {Object} { pageFetchedData, fetchedData } - Fetched data for dynamic route expansion and DataStore pre-population
80
80
  */
81
- async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
81
+ export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
82
82
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
83
83
  const fetchedData = [] // Collected for DataStore pre-population
84
84
 
85
85
  // For non-default locales, translated collection data lives in dist/{locale}/data/
86
- // instead of public/data/. Create a localized fetch helper.
86
+ // instead of public/data/.
87
87
  const isNonDefaultLocale = localeInfo &&
88
88
  localeInfo.locale !== localeInfo.defaultLocale &&
89
89
  localeInfo.distDir
90
90
 
91
- function localizeFetch(config) {
92
- if (!isNonDefaultLocale || !isDataUrl(config.path)) return config
93
- return { ...config, path: `/${localeInfo.locale}${config.path}` }
91
+ // RESOLVED THE WAY THE RUNTIME RESOLVES IT — the one rule in
92
+ // `@uniweb/core/fetch-config`, which is what makes the entries below hydrate:
93
+ // the SPA looks each one up under `deriveCacheKey(config)` of ITS resolved
94
+ // config, so the config baked here must be the same object — the same
95
+ // localized path (this used to prefix `/{locale}` by hand), the same `depth`
96
+ // (a `deferred:` query's compiled file is a list of briefs), the same detail
97
+ // pattern. A hand-built copy of that rule is how a prerendered site came to
98
+ // refetch on boot without anyone noticing.
99
+ const resolveOptions = {
100
+ locale: localeInfo?.locale ?? null,
101
+ defaultLocale: localeInfo?.defaultLocale ?? null,
102
+ queries: siteContent.config?.queries ?? null,
103
+ records: null, // the build lane: no live records; the compiled file answers
94
104
  }
105
+ const resolveForBuild = (oneFetch) =>
106
+ resolveFetchConfigs([oneFetch], resolveOptions).get(oneFetch.as) ?? oneFetch
95
107
 
96
108
  // Fetch options pointing to dist/ for localized data
97
109
  const localizedFetchOptions = isNonDefaultLocale
98
110
  ? { siteRoot: localeInfo.distDir, publicDir: '.' }
99
111
  : fetchOptions
112
+ const optionsFor = (cfg, oneFetch) => (cfg.path !== oneFetch.path ? localizedFetchOptions : fetchOptions)
113
+ const entry = (cfg, data, scope) => ({ config: cfg, data, meta: { depth: cfg.depth }, _scope: scope })
100
114
 
101
115
  // 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
102
116
  // or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
103
117
  // `undefined` — which passes the `!== false` test and then fetches nothing.
104
118
  for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
105
119
  if (oneFetch.prerender === false) continue
106
- const cfg = localizeFetch(oneFetch)
107
- const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
120
+ const cfg = resolveForBuild(oneFetch)
108
121
  onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
109
- const result = await executeFetch(cfg, opts)
122
+ const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
110
123
  if (result.data && !result.error) {
111
- fetchedData.push({ config: cfg, data: result.data, _scope: '__site__' })
124
+ fetchedData.push(entry(cfg, result.data, '__site__'))
112
125
  }
113
126
  }
114
127
 
@@ -119,12 +132,11 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
119
132
  // Page-level fetch — every declaration on the page.
120
133
  for (const oneFetch of toFetchList(page.fetch)) {
121
134
  if (oneFetch.prerender === false) continue
122
- const cfg = localizeFetch(oneFetch)
123
- const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
135
+ const cfg = resolveForBuild(oneFetch)
124
136
  onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
125
- const result = await executeFetch(cfg, opts)
137
+ const result = await executeFetch(cfg, optionsFor(cfg, oneFetch))
126
138
  if (result.data && !result.error) {
127
- fetchedData.push({ config: cfg, data: result.data, _scope: page.route })
139
+ fetchedData.push(entry(cfg, result.data, page.route))
128
140
  // ⚖️ Dynamic-route expansion consumes ONE query — a `[slug]` template
129
141
  // expands over a single record set. With several declared, the first
130
142
  // that prerenders is the route query, matching `parentSchema` in the
@@ -190,7 +202,8 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
190
202
  return (website.basePath || '') + withSlash
191
203
  }
192
204
 
193
- export function expandDynamicPages(pages, pageFetchedData, onProgress) {
205
+ export function expandDynamicPages(pages, pageFetchedData, onProgress = () => {}, stats = { unrouted: {} }) {
206
+ if (!stats.unrouted) stats.unrouted = {}
194
207
  const expandedPages = []
195
208
 
196
209
  // Static pages win over the dynamic `[slug]` catch-all, matching the SPA's
@@ -221,8 +234,9 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
221
234
  }
222
235
 
223
236
  // Find the parent's data
224
- // The parent route is the route without the :param suffix
225
- const parentRoute = page.route.replace(/\/:[\w]+$/, '') || '/'
237
+ // The parent route is the route without the :param (or :path*) suffix
238
+ const catchAll = /\/:path\*$/.test(page.route)
239
+ const parentRoute = page.route.replace(/\/:[\w]+\*?$/, '') || '/'
226
240
  const parentData = pageFetchedData.get(parentRoute)
227
241
 
228
242
  if (!parentData || !Array.isArray(parentData.data)) {
@@ -238,17 +252,30 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
238
252
 
239
253
  onProgress(` Expanding ${page.route} → ${items.length} pages from ${schema}`)
240
254
 
255
+ // ⛔ COUNTED, not only logged per record. A record with no value for the
256
+ // route's param gets no page — correct — but "Skipping item without slug"
257
+ // once per record is a line nobody reads: an author who files twenty
258
+ // records and names three gets three pages and no idea why. The total is
259
+ // said once at the end, and handed back on `stats` for a caller to assert.
260
+ let unrouted = 0
261
+
241
262
  // Create a concrete page for each item
242
263
  for (const item of items) {
243
264
  // Get the param value from the item (e.g., item.slug for :slug)
244
265
  const paramValue = item[paramName]
245
266
  if (!paramValue) {
246
- onProgress(` Skipping item without ${paramName}`)
267
+ unrouted += 1
247
268
  continue
248
269
  }
249
270
 
250
- // Create concrete route: /blog/:slug → /blog/my-post
251
- const concreteRoute = page.route.replace(`:${paramName}`, paramValue)
271
+ // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
272
+ // record's URL is its placement (the folder `records.yml` put it in, carried
273
+ // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
274
+ // decoded: the server decodes the request before looking the file up.
275
+ const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
276
+ const concreteRoute = catchAll
277
+ ? page.route.replace(/:path\*$/, capture)
278
+ : page.route.replace(`:${paramName}`, paramValue)
252
279
 
253
280
  // Static sibling wins: skip a record whose concrete route collides with
254
281
  // an existing static page rather than overwriting its HTML at write time.
@@ -277,6 +304,9 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
277
304
  paramName,
278
305
  paramValue,
279
306
  schema, // Plural: 'articles'
307
+ // A catch-all page carries its three variables, so a query binding
308
+ // `:dir` or `:path` resolves the same way it does in the browser.
309
+ ...(catchAll ? { params: splitPathCapture(capture) } : {}),
280
310
  }
281
311
 
282
312
  // Use item data for page metadata if available
@@ -285,6 +315,14 @@ export function expandDynamicPages(pages, pageFetchedData, onProgress) {
285
315
 
286
316
  expandedPages.push(concretePage)
287
317
  }
318
+
319
+ if (unrouted > 0) {
320
+ stats.unrouted[page.route] = unrouted
321
+ onProgress(
322
+ ` ⚠️ ${unrouted} of ${items.length} ${schema} records have no "${paramName}" — no page was ` +
323
+ `generated for them under ${page.route}`
324
+ )
325
+ }
288
326
  }
289
327
 
290
328
  return expandedPages
@@ -626,7 +664,7 @@ export async function prerenderSite(siteDir, options = {}) {
626
664
  onProgress(`\nRendering ${isDefault ? 'default' : locale} locale...`)
627
665
 
628
666
  // Load locale-specific content
629
- const siteContent = JSON.parse(await readFile(localeContentPath, 'utf8'))
667
+ let siteContent = JSON.parse(await readFile(localeContentPath, 'utf8'))
630
668
 
631
669
  // Set the active locale in the content
632
670
  siteContent.config = siteContent.config || {}
@@ -644,6 +682,11 @@ export async function prerenderSite(siteDir, options = {}) {
644
682
  // Store fetchedData on siteContent for runtime DataStore pre-population
645
683
  siteContent.fetchedData = fetchedData
646
684
 
685
+ // The build has consumed every build-only fetch key by now (`merge`, read
686
+ // by the section fetches above); what ships in `__SITE_CONTENT__` is the
687
+ // runtime's payload and carries none of them.
688
+ siteContent = stripBuildOnlyFetchKeys(siteContent)
689
+
647
690
  // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
648
691
  if (siteContent.pages?.some(p => p.isDynamic)) {
649
692
  onProgress('Expanding dynamic routes...')
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { isRichSchema } from '@uniweb/core'
31
+ import { flatRecordFields } from '@uniweb/schemas/conform'
31
32
 
32
33
  /**
33
34
  * Parse data string into structured object
@@ -211,8 +212,22 @@ function leanDataSchema(value, dataSchemaMap) {
211
212
  : (value && typeof value.schema === 'string' ? value.schema : null)
212
213
  if (ref) {
213
214
  const resolved = dataSchemaMap[ref]
214
- if (!resolved?.fields) return null
215
- const lean = extractSchemaFields(resolved.fields)
215
+ // ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
216
+ // schema AS AUTHORED — resolution and lowering are different steps, and only
217
+ // the second normalizes the two authoring forms. `fields:` at the top is the
218
+ // SUGAR for a one-section model; a sections-form schema has no such key, so
219
+ // reading it directly returned null for every `@std/*` binding and the
220
+ // section's `data:` declaration supplied no field defaults at all. Silent:
221
+ // the data still arrived, just without its defaults.
222
+ //
223
+ // This helper is the reader that takes both forms (`if (schema.fields)
224
+ // return schema.fields`, else walk the single sections), and
225
+ // `site/queries-config.js` already used it for exactly this reason.
226
+ // Measured 2026-09-03: `@std/article` → undefined here, 15 fields through
227
+ // the helper; `@/member` (sugar) → 4 either way.
228
+ const fields = flatRecordFields(resolved)
229
+ if (!fields) return null
230
+ const lean = extractSchemaFields(fields)
216
231
  return Object.keys(lean).length > 0 ? lean : null
217
232
  }
218
233
 
package/src/schema.js CHANGED
@@ -67,6 +67,74 @@ export async function loadComponentMeta(componentDir) {
67
67
  }
68
68
  }
69
69
 
70
+ /**
71
+ * Normalize `package.json`'s `uniweb.supports` — the host services this
72
+ * foundation is BUILT AGAINST.
73
+ *
74
+ * A foundation states what it is prepared to honour; a host states what it
75
+ * offers (`config.services.<name>`, read by `@uniweb/core/services`). The two
76
+ * declarations point in opposite directions across the same seam, which is why
77
+ * this one is spelled `supports` and never `services`.
78
+ *
79
+ * ## ⭐ THREE STATES, AND COLLAPSING THE FIRST TWO IS THE WHOLE POINT
80
+ *
81
+ * absent UNKNOWN — an older CLI, or a developer who never met this key
82
+ * `[]` an explicit NONE
83
+ * `['search']` these, and only these
84
+ *
85
+ * Same lift as `info.runtime` (`uwx/registry-package.js::buildInfo`), whose
86
+ * comment states the rule this follows: *"a consumer must read the omission as
87
+ * UNKNOWN rather than unconstrained, since a floor nobody stated cannot be shown
88
+ * to be satisfied."* An unstated support set is not a refusal, and a reader that
89
+ * treats it as one blocks every foundation built before this existed.
90
+ *
91
+ * ⛔ **So this returns `{}` for absent and `{ supports: [] }` for empty**, and
92
+ * they must not be flattened downstream. A default of any kind here — assuming
93
+ * search is present because most foundations have one — would claim a service on
94
+ * behalf of the developer who never heard of the feature, which is the same
95
+ * person as the developer who forgot to declare it. That trades a loud failure
96
+ * (a capability reported unknown) for a silent one (an operator paying for
97
+ * something their site will not render).
98
+ *
99
+ * ⚖️ Whether an unknown service is still worth offering is a POLICY question
100
+ * about that service, and policy on this seam is not the framework's: this
101
+ * package is public, and it encodes no tiers, no plan names and no entitlement
102
+ * (`core/src/services.js`). We report faithfully, including reporting that we do
103
+ * not know.
104
+ *
105
+ * Sorted and de-duplicated so a re-export of one foundation version is
106
+ * byte-identical — the same reason `buildRefs` sorts.
107
+ *
108
+ * @param {*} value - the raw `uniweb.supports` value, if any
109
+ * @param {string} packagePath - for the warning message
110
+ * @returns {{supports?: string[]}} `{}` when undeclared
111
+ */
112
+ function normalizeSupports(value, packagePath) {
113
+ if (value === undefined || value === null) return {}
114
+
115
+ if (!Array.isArray(value)) {
116
+ console.warn(
117
+ `Warning: ${packagePath} declares \`uniweb.supports\` as ${typeof value}; expected an array of service names. Ignoring it.\n` +
118
+ ` An ignored declaration reads downstream as UNKNOWN, not as "supports nothing".`,
119
+ )
120
+ return {}
121
+ }
122
+
123
+ const names = []
124
+ const dropped = []
125
+ for (const entry of value) {
126
+ if (typeof entry === 'string' && entry.trim()) names.push(entry.trim())
127
+ else dropped.push(entry)
128
+ }
129
+ if (dropped.length > 0) {
130
+ console.warn(
131
+ `Warning: ${packagePath} has ${dropped.length} non-name entr${dropped.length === 1 ? 'y' : 'ies'} in \`uniweb.supports\`. Ignoring ${dropped.length === 1 ? 'it' : 'them'}.`,
132
+ )
133
+ }
134
+
135
+ return { supports: [...new Set(names)].sort() }
136
+ }
137
+
70
138
  /**
71
139
  * Load package.json from a foundation's root.
72
140
  * Extracts identity fields: name, version, description.
@@ -104,6 +172,11 @@ export async function loadPackageJson(srcDir) {
104
172
  name: pkg.uniweb?.id || pkg.name,
105
173
  version: pkg.version,
106
174
  description: pkg.description,
175
+ // `uniweb.supports` joins them because it is the same KIND of fact: static,
176
+ // publish-time, read by whoever resolves a whole site, never executed here.
177
+ // Spread rather than assigned so an absent declaration stays ABSENT — see
178
+ // normalizeSupports for why that is load-bearing.
179
+ ...normalizeSupports(pkg.uniweb?.supports, packagePath),
107
180
  }
108
181
  } catch (error) {
109
182
  console.warn(`Warning: Failed to load package.json:`, error.message)
@@ -24,6 +24,7 @@ import { join, resolve, dirname } from 'node:path'
24
24
  import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
25
25
  import { collectSiteContent } from './content-collector.js'
26
26
  import { processQueries, writeQueryFiles } from './query-processor.js'
27
+ import { stripBuildOnlyFetchKeys } from './data-fetcher.js'
27
28
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
28
29
  import { processAdvancedAssets } from './advanced-processors.js'
29
30
  import {
@@ -235,6 +236,10 @@ export async function buildSiteData({
235
236
  finalContent = { ...finalContent, config: configWithoutIcons }
236
237
  }
237
238
 
239
+ // Build-only fetch keys (`merge`) never reach a shipped payload; this lane runs
240
+ // no prerender, so nothing here reads them either.
241
+ finalContent = stripBuildOnlyFetchKeys(finalContent)
242
+
238
243
  // 4. Write `dist/site-content.json` with FULL sections inlined.
239
244
  // Important: do NOT strip sections per the split-content rule here.
240
245
  // Stripping would silently break split-mode sites on any consumer that
@@ -57,20 +57,69 @@ try {
57
57
  }
58
58
 
59
59
  /**
60
- * Check if a folder name represents a dynamic route (e.g., [slug], [id])
60
+ * The multi-segment route folder ONE fixed spelling, ruled 2026-09-04 [Diego].
61
+ * `[...slug]`, `[...rest]` and a bare `[...]` are NOT the marker: an author does
62
+ * not name how the URL is parsed. The capture is split by the runtime into the
63
+ * standard `:path` / `:dir` / `:slug` variables (`@uniweb/core/route-match`).
64
+ */
65
+ const CATCH_ALL_FOLDER = '[...path]'
66
+
67
+ /**
68
+ * The site-level `fetcher:` vocabulary the default fetcher used to read —
69
+ * `baseUrl`, `headers`, `envelope`, `supports`, `request` — was RETIRED on
70
+ * 2026-09-04 [Diego]: a third-party endpoint is a foundation TRANSPORT, never a
71
+ * knob on the runtime every site loads. What stays under `fetcher:` is the
72
+ * site's SELECTION of transports (`transports:`) and the binding config a named
73
+ * transport reads (`fetcher.<name>:`). A retired key is dropped from the payload
74
+ * and said out loud, once, because silently ignoring it is the failure class
75
+ * this seam exists to remove: the author's backend simply stops being reached.
76
+ */
77
+ const RETIRED_FETCHER_KEYS = ['baseUrl', 'headers', 'envelope', 'supports', 'request']
78
+ let warnedRetiredFetcher = false
79
+ function warnRetiredFetcherKeys(fetcher) {
80
+ if (!fetcher || typeof fetcher !== 'object' || Array.isArray(fetcher)) return fetcher
81
+ const retired = RETIRED_FETCHER_KEYS.filter((k) => k in fetcher)
82
+ if (retired.length === 0) return fetcher
83
+ if (!warnedRetiredFetcher) {
84
+ warnedRetiredFetcher = true
85
+ console.warn(
86
+ `[uniweb] site.yml fetcher: ${retired.map((k) => `\`${k}\``).join(', ')} ` +
87
+ `${retired.length === 1 ? 'is' : 'are'} retired and ignored. The default fetcher reads a ` +
88
+ `site's own files and a host's records lane; a backend of your own is reached through a ` +
89
+ `foundation transport (docs: development/connecting-a-backend.md). Kept: ` +
90
+ `\`fetcher.transports\` and a transport's own binding config.`
91
+ )
92
+ }
93
+ const kept = {}
94
+ for (const [k, v] of Object.entries(fetcher)) if (!RETIRED_FETCHER_KEYS.includes(k)) kept[k] = v
95
+ return kept
96
+ }
97
+
98
+ /**
99
+ * Check if a folder name represents a dynamic route (e.g., [slug], [id], [...path])
61
100
  * @param {string} folderName - The folder name to check
62
101
  * @returns {boolean}
63
102
  */
64
103
  function isDynamicRoute(folderName) {
65
- return /^\[(\w+)\]$/.test(folderName)
104
+ return folderName === CATCH_ALL_FOLDER || /^\[(\w+)\]$/.test(folderName)
105
+ }
106
+
107
+ /** Is this the multi-segment route folder? */
108
+ function isCatchAllRoute(folderName) {
109
+ return folderName === CATCH_ALL_FOLDER
66
110
  }
67
111
 
68
112
  /**
69
- * Extract the parameter name from a dynamic route folder (e.g., [slug] → slug)
113
+ * Extract the parameter name from a dynamic route folder (e.g., [slug] → slug).
114
+ *
115
+ * `[...path]` yields `slug`: the record is delivered by its handle — the LAST
116
+ * segment of the capture — exactly as under `[slug]`, so a query written for
117
+ * one route kind behaves the same under the other.
70
118
  * @param {string} folderName - The folder name (e.g., "[slug]")
71
119
  * @returns {string|null} The parameter name or null if not a dynamic route
72
120
  */
73
121
  function extractRouteParam(folderName) {
122
+ if (isCatchAllRoute(folderName)) return 'slug'
74
123
  const match = folderName.match(/^\[(\w+)\]$/)
75
124
  return match ? match[1] : null
76
125
  }
@@ -1389,8 +1438,10 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1389
1438
  // First, calculate the folder-based route (what the route would be without index handling)
1390
1439
  let folderRoute
1391
1440
  if (isDynamic) {
1392
- // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching)
1393
- folderRoute = parentRoute === '/' ? `/:${paramName}` : `${parentRoute}/:${paramName}`
1441
+ // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
1442
+ // /blog/[...path] /blog/:path* the one multi-segment token the matcher knows.
1443
+ const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
1444
+ folderRoute = parentRoute === '/' ? `/${token}` : `${parentRoute}/${token}`
1394
1445
  } else {
1395
1446
  // Normal pages get parent + their name
1396
1447
  folderRoute = parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
@@ -2056,8 +2107,8 @@ export async function loadFoundationInfo(foundationPath) {
2056
2107
  // rich per-section declaration a visual editor needs to render parameter forms
2057
2108
  // and component pickers — and it is not a source of truth for a site build.
2058
2109
  // The architecture is explicit that the declaration is emitted in two shapes
2059
- // for two audiences (`kb/framework/architecture/site-foundation-runtime-model.md`
2060
- // § The two-audience schema): the lean runtime half ships INSIDE `dist/entry.js`
2110
+ // for two audiences (the site / foundation / runtime model, § The two-audience
2111
+ // schema): the lean runtime half ships INSIDE `dist/entry.js`
2061
2112
  // as `capabilities`, and the rich half is for authoring tools only.
2062
2113
  //
2063
2114
  // Reading the editor's copy here was wrong twice over. It made a site build
@@ -2563,6 +2614,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2563
2614
  ? { languages: publishable }
2564
2615
  : {}),
2565
2616
  fetch: parseFetchConfig(siteConfig.fetch),
2617
+ fetcher: warnRetiredFetcherKeys(siteConfig.fetcher),
2566
2618
  // NOTE: `intelligence.yml` was read here and emitted as `config.intelligence`.
2567
2619
  // Removed 2026-08-12 — the assistant surface is `site.yml::assistant`, which
2568
2620
  // needs no line at all on this lane (`config` spreads all of site.yml) and one