@uniweb/core 0.19.0 → 0.20.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.
@@ -10,13 +10,22 @@
10
10
  * literal, while the second consumed the whole name. Two answers to one
11
11
  * question, neither wrong on the routes anyone had tried.
12
12
  *
13
- * That is already bad inside one repo. It is worse across them: a host that
14
- * renders a page server-side has to decide *which* page a path names, and the
15
- * runtime then hydrates over that decision in the browser. If the two matchers
16
- * disagree by a single route, the server renders page A and hydration replaces
17
- * it with page B — silently, and only on the paths that have a pattern, which
18
- * are exactly the interesting ones. So this is a cross-boundary contract, not
19
- * an implementation detail, and it is exported rather than merely shared.
13
+ * That is already bad inside one repo. It is worse across them, because the
14
+ * matcher answers a question more than one lane asks: *which page does this
15
+ * path name?* A consumer outside this repo routes with these patterns
16
+ * `hosting/framework-surface.json` declares `routePatternToRegex`,
17
+ * `isDynamicRoute` and `normalizeRoute` read by its `src/routes.js`. Two copies
18
+ * that disagree by a single route give two answers to page identity, silently,
19
+ * and only on the paths that have a pattern which are exactly the interesting
20
+ * ones. So this is a cross-boundary contract, not an implementation detail, and
21
+ * it is exported rather than merely shared.
22
+ *
23
+ * ⛔ This paragraph used to justify itself with a server-rendering story — "the
24
+ * server renders page A and hydration replaces it with page B". That premise is
25
+ * wrong (Diego, 2026-09-04: *the server does not render*) and the argument never
26
+ * needed it: two answers to page identity are a defect wherever the second
27
+ * answer is formed. Do not reintroduce a rendering narrative here; what this
28
+ * module guarantees is that everyone matching a path agrees on the page.
20
29
  *
21
30
  * Zero-dependency leaf, like `./data-paths.js` and `./locale-config.js`, so a
22
31
  * consumer that must not pull core's graph — an edge worker, a build step —
@@ -24,11 +33,19 @@
24
33
  *
25
34
  * ## The syntax, in full
26
35
  *
27
- * `:param` is the only construct. There are deliberately **no** catch-alls
28
- * (`*`), **no** optional segments (`?`), and **no** regex constraints — a
29
- * pattern is not a regular expression, and regex metacharacters in a route are
30
- * escaped to literals before any substitution happens. Matching is anchored,
31
- * case-sensitive, and a param captures exactly one non-empty path segment.
36
+ * `:param` captures exactly one non-empty path segment. `:param*` the ONE
37
+ * multi-segment construct, admitted 2026-09-04 by ruling [Diego] for the
38
+ * `[...path]` route folder captures one or more segments, slashes intact, and
39
+ * only as the FINAL segment of a pattern; anywhere else the `*` is the literal it
40
+ * always was. There are still **no** optional segments (`?`) and **no** regex
41
+ * constraints — a pattern is not a regular expression, and regex metacharacters in
42
+ * a route are escaped to literals before any substitution happens. Matching is
43
+ * anchored and case-sensitive.
44
+ *
45
+ * ⚖️ This module said "deliberately no catch-alls" until 2026-09-04. The reversal
46
+ * is considered, announced to the consumer that imports this leaf before it
47
+ * landed, and narrow: one construct, final segment only, nothing author-named —
48
+ * the build emits `:path*` and nothing else.
32
49
  *
33
50
  * ## What this module does NOT decide
34
51
  *
@@ -76,6 +93,9 @@ export function isDynamicRoute(route) {
76
93
  return typeof route === 'string' && route.includes(':')
77
94
  }
78
95
 
96
+ /** The catch-all token, only as a pattern's final segment: `/:path*`. */
97
+ const CATCH_ALL = new RegExp(`/:(${PARAM_NAME})\\*$`)
98
+
79
99
  /**
80
100
  * Compile a route pattern to an anchored regex plus its param names.
81
101
  *
@@ -83,12 +103,22 @@ export function isDynamicRoute(route) {
83
103
  * compile once — an edge worker checking every request against a site's
84
104
  * patterns, for instance.
85
105
  *
86
- * @param {string} pattern - e.g. `/blog/:id`
87
- * @returns {{ regex: RegExp, paramNames: string[] }}
106
+ * `catchAll` names the `:name*` param when the pattern ends in one, else null —
107
+ * a caller decoding captures needs to know which one may hold slashes.
108
+ *
109
+ * @param {string} pattern - e.g. `/blog/:id`, `/docs/:path*`
110
+ * @returns {{ regex: RegExp, paramNames: string[], catchAll: string|null }}
88
111
  */
89
112
  export function routePatternToRegex(pattern) {
90
113
  const paramNames = []
91
- const source = normalizeRoute(pattern)
114
+ let head = normalizeRoute(pattern)
115
+ let catchAll = null
116
+ const tail = head.match(CATCH_ALL)
117
+ if (tail) {
118
+ catchAll = tail[1]
119
+ head = head.slice(0, tail.index)
120
+ }
121
+ let source = head
92
122
  // Escape first: a `.` in a route is a literal `.`, not "any character".
93
123
  .replace(REGEX_SPECIALS, '\\$&')
94
124
  // Then each `:name` becomes one non-empty segment capture.
@@ -96,8 +126,14 @@ export function routePatternToRegex(pattern) {
96
126
  paramNames.push(name)
97
127
  return '([^/]+)'
98
128
  })
129
+ if (catchAll) {
130
+ paramNames.push(catchAll)
131
+ // One or more segments; the segments are separated by literal slashes, and
132
+ // an empty segment (`//`) is not a segment.
133
+ source += '/([^/]+(?:/[^/]+)*)'
134
+ }
99
135
 
100
- return { regex: new RegExp(`^${source}$`), paramNames }
136
+ return { regex: new RegExp(`^${source}$`), paramNames, catchAll }
101
137
  }
102
138
 
103
139
  /**
@@ -147,17 +183,116 @@ export function decodeRouteValue(value) {
147
183
  * @returns {{ params: Record<string,string> } | null}
148
184
  */
149
185
  export function matchDynamicRoute(pattern, path) {
150
- const { regex, paramNames } = routePatternToRegex(pattern)
186
+ const { regex, paramNames, catchAll } = routePatternToRegex(pattern)
151
187
  const match = normalizeRoute(path).match(regex)
152
188
  if (!match) return null
153
189
 
154
190
  const params = {}
155
191
  paramNames.forEach((name, i) => {
156
- params[name] = decodeRouteValue(match[i + 1])
192
+ const raw = match[i + 1]
193
+ // A catch-all is decoded PER SEGMENT: an encoded slash inside one segment
194
+ // (`members%2Fada`) stays a value, while the slashes between segments stay
195
+ // structure. Decoding the whole capture at once would conflate the two.
196
+ params[name] = name === catchAll
197
+ ? raw.split('/').map(decodeRouteValue).join('/')
198
+ : decodeRouteValue(raw)
157
199
  })
158
200
  return { params }
159
201
  }
160
202
 
203
+ /**
204
+ * The three standard variables a multi-segment capture yields — the split rule,
205
+ * ruled 2026-09-04 [Diego]:
206
+ *
207
+ * /blog/rust/2025/my-post → path = rust/2025/my-post the whole capture
208
+ * dir = rust/2025 everything before the last segment
209
+ * slug = my-post the last segment — the record's handle
210
+ *
211
+ * `slug` means the same thing in both route kinds — in `[slug]` it is the whole
212
+ * segment — and `dir` is empty for a single segment, so a query written against
213
+ * one behaves the same under the other.
214
+ *
215
+ * @param {string} capture - a decoded `:path*` value
216
+ * @returns {{ path: string, dir: string, slug: string }}
217
+ */
218
+ export function splitPathCapture(capture) {
219
+ const path = typeof capture === 'string' ? capture.replace(/^\/+|\/+$/g, '') : ''
220
+ const segments = path ? path.split('/') : []
221
+ return {
222
+ path,
223
+ dir: segments.slice(0, -1).join('/'),
224
+ slug: segments.length ? segments[segments.length - 1] : '',
225
+ }
226
+ }
227
+
228
+ /**
229
+ * The inverse of `splitPathCapture` — a record's own URL path under a
230
+ * `[...path]` template, from its placement and its handle. `dir` may be empty.
231
+ *
232
+ * @param {{ dir?: string|null, slug?: string|null }} parts
233
+ * @returns {string|null} null when there is no slug to name the record by
234
+ */
235
+ export function joinPathCapture({ dir, slug } = {}) {
236
+ if (slug === undefined || slug === null || slug === '') return null
237
+ const d = typeof dir === 'string' ? dir.replace(/^\/+|\/+$/g, '') : ''
238
+ return d ? `${d}/${slug}` : String(slug)
239
+ }
240
+
241
+ /**
242
+ * Fill a route pattern's params from a record — the ONE encoder for a record's href.
243
+ *
244
+ * `/blog/:slug` + `{ slug: 'a post' }` → `/blog/a%20post`. Every value is
245
+ * `encodeURIComponent`-ed, because the output is a URL: it is compared against
246
+ * `location.pathname` (`isActive(item.route)`) and matched back through
247
+ * `matchDynamicRoute`, which decodes what it captures. A raw interpolation and an
248
+ * encoded one compare unequal on the first slug with a space — and they used to
249
+ * both exist: the build baked `${base}/${item.slug}` raw into `/data/*.json` while
250
+ * the runtime interpolated with encoding, and which one a site got was
251
+ * lane-dependent (measured 2026-09-04). Two producers of one field now call this.
252
+ *
253
+ * ⛔ NOT for a file path. The SSG writes `dist/<route>/index.html` from the DECODED
254
+ * value on purpose — a server decodes the request path before looking a file up,
255
+ * so `Ada%20Lovelace` on disk would 404 for `/team/Ada%20Lovelace`. A URL and a
256
+ * filesystem path are different jobs that are supposed to encode differently.
257
+ *
258
+ * Returns `null` — never a partial href — when a param has no value on the
259
+ * record, so a caller degrades to "no link" rather than emitting a broken one.
260
+ *
261
+ * @param {string} pattern - a route pattern with `:param` placeholders
262
+ * @param {Object} values - a record, read by param name
263
+ * @returns {string|null}
264
+ */
265
+ export function fillRoutePattern(pattern, values) {
266
+ if (typeof pattern !== 'string' || !values || typeof values !== 'object') return null
267
+ let missing = false
268
+ let head = pattern
269
+ let tailHref = ''
270
+ const tail = pattern.match(CATCH_ALL)
271
+ if (tail) {
272
+ // A catch-all is filled from the record's placement and handle — the split
273
+ // rule in reverse (`joinPathCapture`) — with each SEGMENT encoded and the
274
+ // slashes between them kept as structure. `dir` is the placement; a record
275
+ // carries it as `path` (the folder `records.yml` put it in), which is why
276
+ // `path` here is read as the DIRECTORY and never as a composed capture.
277
+ if (joinPathCapture({ dir: values.dir ?? values.path, slug: values.slug }) === null) return null
278
+ const dir = String(values.dir ?? values.path ?? '')
279
+ const segments = dir.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
280
+ // The handle is ONE segment whatever it contains: a `/` inside it is a value.
281
+ segments.push(encodeURIComponent(String(values.slug)))
282
+ tailHref = '/' + segments.join('/')
283
+ head = pattern.slice(0, tail.index)
284
+ }
285
+ const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
286
+ const value = values[name]
287
+ if (value === undefined || value === null || value === '') {
288
+ missing = true
289
+ return ''
290
+ }
291
+ return encodeURIComponent(String(value))
292
+ })
293
+ return missing ? null : href + tailHref
294
+ }
295
+
161
296
  /**
162
297
  * Strip a locale prefix from a route.
163
298
  *
package/src/sort.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Sort — ONE evaluator for a query's `sort:`, and the wire spelling it becomes.
3
+ *
4
+ * ⛔ SINGLE-KEY, BY RULING [Diego, 2026-09-04]: "I don't think we need multi-key
5
+ * sorting. We can drop that." Until this module existed `sort:` had THREE
6
+ * evaluators — the build's `applySort`, the runtime fetcher's fallback, and the
7
+ * entity store's refine-order sort — and two of them split on commas and honoured
8
+ * several keys while the one shipped wire dialect documented the same, so a site
9
+ * authoring `sort: order asc, title asc` worked on the static lane and would have
10
+ * been refused by the records door, which takes one key. The language is the
11
+ * INTERSECTION of what both lanes honour, so a comma is refused here rather than
12
+ * half-honoured somewhere.
13
+ *
14
+ * Author spelling, unchanged: `date`, `date asc`, `date desc`. The records door's
15
+ * spelling is `date` / `-date`; `-date` is accepted on the way in so a value that
16
+ * came off the wire round-trips, and `sortToWire` produces it on the way out.
17
+ *
18
+ * Dotted paths descend into nested objects (`tenure.start`) — kept, like the
19
+ * predicate evaluator's.
20
+ *
21
+ * Zero-dependency leaf: `@uniweb/build` reads it to materialize `/data/<name>.json`
22
+ * and `@uniweb/runtime` reads it as the fallback over a fetched array, so the two
23
+ * lanes cannot drift on the one thing a conformance test would otherwise have to
24
+ * catch by luck.
25
+ */
26
+
27
+ /**
28
+ * Parse an authored `sort:` into `{ field, desc }`.
29
+ *
30
+ * Throws on a comma (multi-key) and on a direction word that is neither `asc`
31
+ * nor `desc`, because both were silently mis-honoured before: the extra keys
32
+ * were sorted by on one lane and ignored on another, and an unknown direction
33
+ * sorted ascending. A query with a wrong `sort:` should fail where it is written.
34
+ *
35
+ * @param {string|{field:string, desc?:boolean}|null|undefined} sort
36
+ * @returns {{ field: string, desc: boolean } | null}
37
+ */
38
+ export function parseSort(sort) {
39
+ if (sort === undefined || sort === null || sort === '') return null
40
+ if (typeof sort === 'object') {
41
+ if (typeof sort.field !== 'string' || sort.field.length === 0) return null
42
+ return { field: sort.field, desc: sort.desc === true }
43
+ }
44
+ const text = String(sort).trim()
45
+ if (!text) return null
46
+ if (text.includes(',')) {
47
+ throw new Error(
48
+ `[uniweb] sort: "${text}" names more than one key. A query sorts by ONE key ` +
49
+ `(\`sort: date desc\`); multi-key sorting is not supported on either lane.`
50
+ )
51
+ }
52
+ if (text.startsWith('-')) {
53
+ const field = text.slice(1).trim()
54
+ if (!field || /\s/.test(field)) throw new Error(`[uniweb] sort: "${text}" is not a field name.`)
55
+ return { field, desc: true }
56
+ }
57
+ const parts = text.split(/\s+/)
58
+ if (parts.length > 2) {
59
+ throw new Error(`[uniweb] sort: "${text}" is not \`<field>\` or \`<field> asc|desc\`.`)
60
+ }
61
+ const [field, dir] = parts
62
+ const lower = dir ? dir.toLowerCase() : 'asc'
63
+ if (lower !== 'asc' && lower !== 'desc') {
64
+ throw new Error(`[uniweb] sort: "${text}" — direction must be \`asc\` or \`desc\`, not "${dir}".`)
65
+ }
66
+ return { field, desc: lower === 'desc' }
67
+ }
68
+
69
+ /**
70
+ * The door's spelling of a sort: `date` ascending, `-date` descending.
71
+ *
72
+ * @param {string|{field:string, desc?:boolean}|null|undefined} sort
73
+ * @returns {string|null}
74
+ */
75
+ export function sortToWire(sort) {
76
+ const spec = parseSort(sort)
77
+ if (!spec) return null
78
+ return spec.desc ? `-${spec.field}` : spec.field
79
+ }
80
+
81
+ /**
82
+ * Sort records by one key. Returns a new array; the input is not mutated.
83
+ *
84
+ * Strings compare with `localeCompare` so `apple` sorts before `Banana`; anything
85
+ * else compares with `<`/`>`, which is right for numbers and ISO date strings. A
86
+ * record with no value for the key sorts as the empty string — first ascending,
87
+ * last descending — which is what every previous evaluator did.
88
+ *
89
+ * @param {Array<Object>} items
90
+ * @param {string|{field:string, desc?:boolean}|null|undefined} sort
91
+ * @returns {Array<Object>}
92
+ */
93
+ export function sortRecords(items, sort) {
94
+ const spec = parseSort(sort)
95
+ if (!spec || !Array.isArray(items) || items.length === 0) return items
96
+ const { field, desc } = spec
97
+ return [...items].sort((a, b) => {
98
+ const av = readPath(a, field) ?? ''
99
+ const bv = readPath(b, field) ?? ''
100
+ const cmp = typeof av === 'string' && typeof bv === 'string'
101
+ ? av.localeCompare(bv)
102
+ : (av > bv ? 1 : av < bv ? -1 : 0)
103
+ return desc ? -cmp : cmp
104
+ })
105
+ }
106
+
107
+ function readPath(record, path) {
108
+ if (!record || typeof record !== 'object') return undefined
109
+ if (path.indexOf('.') === -1) return record[path]
110
+ let cursor = record
111
+ for (const segment of path.split('.')) {
112
+ if (cursor == null || typeof cursor !== 'object') return undefined
113
+ cursor = cursor[segment]
114
+ }
115
+ return cursor
116
+ }
package/src/website.js CHANGED
@@ -11,7 +11,9 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
- import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
14
+ import { matchDynamicRoute, decodeRouteValue, routePatternToRegex, splitPathCapture } from './route-match.js'
15
+ import { resolveFetchConfigs } from './fetch-config.js'
16
+ import { buildDetailConfig } from './detail-url.js'
15
17
  import { resolveService } from './services.js'
16
18
 
17
19
  /**
@@ -558,14 +560,36 @@ export default class Website {
558
560
  pageData.route = concreteRoute
559
561
  pageData.isDynamic = false // No longer a template
560
562
 
561
- const paramName = Object.keys(params)[0]
562
- const paramValue = Object.values(params)[0]
563
+ // The route's variables, and the param the record is delivered by.
564
+ //
565
+ // `[slug]` — the one capture, under the folder's own label: `paramName` is
566
+ // that label and the record is matched on `item[paramName]`.
567
+ //
568
+ // `[...path]` — the capture is split by the rule in `route-match.js`
569
+ // (`:path` the whole capture · `:dir` everything before the last segment ·
570
+ // `:slug` the last segment); the record is delivered by `slug`, its handle,
571
+ // exactly as under `[slug]`, and `path` / `dir` exist for a query to bind
572
+ // (`scope: :dir`, `where: { tag: :dir }`). Ruled 2026-09-04 [Diego]: the
573
+ // variables are standard, never author-named.
574
+ const { catchAll } = routePatternToRegex(templatePage.route)
575
+ let variables = { ...params }
576
+ let paramName
577
+ let paramValue
578
+ if (catchAll && params[catchAll] !== undefined) {
579
+ const parts = splitPathCapture(params[catchAll])
580
+ variables = { ...params, ...parts }
581
+ paramName = originalData.paramName || 'slug'
582
+ paramValue = parts.slug
583
+ } else {
584
+ paramName = originalData.paramName || Object.keys(params)[0]
585
+ paramValue = params[paramName]
586
+ }
563
587
  const pluralSchema = originalData.parentSchema // e.g., 'articles'
564
588
 
565
589
  // Store dynamic context for components to access
566
590
  pageData.dynamicContext = {
567
591
  templateRoute: templatePage.route,
568
- params,
592
+ params: variables,
569
593
  paramName,
570
594
  paramValue,
571
595
  schema: pluralSchema,
@@ -580,32 +604,62 @@ export default class Website {
580
604
 
581
605
  // Try to resolve page metadata from DataStore
582
606
  // Look up the parent page's fetch config to find data in the store
583
- const parentRoute = templatePage.route.replace(/\/:[\w]+$/, '') || '/'
607
+ // The template's parent: the route without its `:param` — or `:path*` — tail.
608
+ const parentRoute = templatePage.route.replace(/\/:[\w-]+\*?$/, '') || '/'
584
609
  const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
585
610
 
586
611
  if (parentPage && pluralSchema) {
587
- // Find the records from the parent's fetch config via the dispatcher's
588
- // peek (sync cache probe). Used to populate the page title / notFound
589
- // flag on dynamic pages before the page instance is constructed.
612
+ // Find the record the page is ABOUT via the dispatcher's peek (a sync
613
+ // cache probe), to set the page title / description / notFound flag
614
+ // before the page instance is constructed.
615
+ //
616
+ // ⛔ RESOLVED THE WAY THE ENTITY STORE RESOLVES IT, not the raw declaration.
617
+ // Until 2026-09-04 this peeked `parentPage.fetch` as authored — `{ query,
618
+ // path, as }` — while the store writes under the RESOLVED config: on a live
619
+ // lane that carries `endpoint` and no `path`, and on a non-default locale a
620
+ // `/fr/data/…` path. Two different keys for one dataset, so the probe
621
+ // missed on exactly those lanes: no title, no not-found, and the page was
622
+ // never cached (`recordsLoaded` false on every visit). Silent, on a
623
+ // visitor's page — the "write key ≠ read key" failure.
590
624
  const parentFetch = parentPage.fetch
591
625
  let items = []
626
+ let currentItem = null
592
627
 
593
628
  if (parentFetch && this.fetcher) {
594
629
  // ⛔ `as` is the binding key. This matched on `schema` alone until
595
630
  // 2026-09-02 — which, once the alias went, would have found nothing:
596
631
  // `items` stays `[]` and the page reports "Not found" for a record that
597
632
  // exists. Silent, and on a visitor's page.
598
- const keyOf = (f) => f?.as
599
- const fetchConfig = Array.isArray(parentFetch)
600
- ? parentFetch.find((f) => keyOf(f) === pluralSchema)
601
- : (keyOf(parentFetch) === pluralSchema ? parentFetch : null)
633
+ const fetchConfig = resolveFetchConfigs([parentFetch], {
634
+ schemas: [pluralSchema],
635
+ locale: this.getActiveLocale(),
636
+ defaultLocale: this.getDefaultLocale(),
637
+ queries: this.config?.queries ?? null,
638
+ records: this.config?.records ?? null,
639
+ variables,
640
+ }).get(pluralSchema)
602
641
  if (fetchConfig) {
603
- const cached = this.fetcher.peek(fetchConfig, { website: this })
642
+ const ctx = { website: this }
643
+ // ⭐ The page is about ONE record, so ask for that record first: a
644
+ // cached detail fetch (a live lane's record address, a deferred
645
+ // query's per-record file) carries the title even when the list was
646
+ // never fetched — a cold load on a detail URL — where a scan of the
647
+ // list finds nothing and silently sets no title (F3, 2026-09-04).
648
+ const detailCfg = fetchConfig.detail
649
+ ? buildDetailConfig(fetchConfig, { paramName, paramValue })
650
+ : null
651
+ const detailCached = detailCfg ? this.fetcher.peek(detailCfg, ctx) : null
652
+ const record = detailCached?.data
653
+ if (record && typeof record === 'object' && !Array.isArray(record)) currentItem = record
654
+
655
+ const cached = this.fetcher.peek(fetchConfig, ctx)
604
656
  items = Array.isArray(cached?.data) ? cached.data : []
605
657
  }
606
658
  }
607
659
 
608
- const currentItem = items.find(item => String(item[paramName]) === String(paramValue))
660
+ if (!currentItem) {
661
+ currentItem = items.find(item => String(item[paramName]) === String(paramValue)) ?? null
662
+ }
609
663
 
610
664
  if (currentItem) {
611
665
  if (currentItem.title) pageData.title = currentItem.title
@@ -624,7 +678,7 @@ export default class Website {
624
678
  // { paramName, paramValue, schema }; the record reaches components via
625
679
  // content.data, siblings via `fetch: { refine: true, detail: false }`).
626
680
  // The local `currentItem`/`items` above drive title/description/notFound.
627
- pageData._recordsLoaded = items.length > 0
681
+ pageData._recordsLoaded = items.length > 0 || currentItem !== null
628
682
  }
629
683
 
630
684
  // Create the page instance
@@ -814,6 +868,31 @@ export default class Website {
814
868
  return resolvedHref
815
869
  }
816
870
 
871
+ /**
872
+ * The route template that renders ONE record of a binding key — `{ route,
873
+ * paramName }` for the `[param]` page whose parent query lands under `key`,
874
+ * or null when the site routes no detail page over it.
875
+ *
876
+ * ⭐ This is how a caller outside a template page learns which record field
877
+ * the site's URL is built on: `kit`'s `useEntityDetail` asks it so a hover
878
+ * card and the page it links to address the record by the SAME field. It
879
+ * hardcoded `slug` until 2026-09-04, which was quietly wrong on any site
880
+ * routing `[id]`. The first template found
881
+ * wins, matching `parentSchema`'s own rule that one key indexes one template.
882
+ *
883
+ * @param {string} key - a binding key (`content.data.<key>`)
884
+ * @returns {{ route: string, paramName: string } | null}
885
+ */
886
+ detailTemplateFor(key) {
887
+ if (!key) return null
888
+ for (const data of this._dynamicPageData.values()) {
889
+ if (data?.parentSchema === key && data.paramName) {
890
+ return { route: data.route, paramName: data.paramName }
891
+ }
892
+ }
893
+ return null
894
+ }
895
+
817
896
  /**
818
897
  * Resolve a `page:<stable_id>` detail-page reference (from a fetch config's
819
898
  * `detailPage`) to a locale-specific route TEMPLATE, e.g. '/blog/:slug'. The
@@ -1,59 +0,0 @@
1
- /**
2
- * Request style — how the default fetcher reshapes a normalized request
3
- * into wire format: which operators become URL params, which go into a
4
- * body, what envelope the response carries.
5
- *
6
- * One style ships: `json-body`, the framework's own conventions, and it
7
- * is the only wire the default fetcher speaks. A backend with another
8
- * dialect is reached through a named transport — shipped by the
9
- * foundation, or by an extension the site selects per schema in
10
- * `site.yml fetcher.transports` — never through a second built-in style.
11
- * Two vendor dialects, `flat-query` and `strapi`, shipped here from
12
- * `@uniweb/core` 0.7.1 and were removed: a third party's wire is not a
13
- * framework concern, and core is loaded by every site and never
14
- * tree-shaken.
15
- *
16
- * `site.yml fetcher.request.style` is still read, for one reason: a site
17
- * that names a style the framework does not ship must be told. Falling
18
- * back silently would send the default wire to a backend that does not
19
- * speak it — the request succeeds and the data is wrong.
20
- *
21
- * Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
22
- */
23
-
24
- import { jsonBody } from './json-body.js'
25
-
26
- const unknownStyleMessage = (name) =>
27
- `[default-fetcher] unknown request style "${name}". The framework ships one wire, ` +
28
- `"json-body"; a backend with a different dialect is reached through a named transport ` +
29
- `(site.yml fetcher.transports), shipped by the foundation or by an extension.`
30
-
31
- /**
32
- * Resolve the request style. No name, or `json-body`, returns the one
33
- * shipped style. Any other name is a site declaring a wire dialect the
34
- * framework does not ship: in dev this throws, so the site does not boot
35
- * on the wrong wire; in production it logs an error once and falls back
36
- * to `json-body`, so the site still renders.
37
- *
38
- * @param {string|undefined|null} name
39
- * @param {{ dev?: boolean }} [options]
40
- * @returns {Object} A style module.
41
- * @throws {Error} in dev, on a name that is not `json-body`.
42
- */
43
- export function resolveStyle(name, { dev = false } = {}) {
44
- if (!name || name === jsonBody.name) return jsonBody
45
- if (dev) {
46
- const err = new Error(unknownStyleMessage(name))
47
- err.code = 'UNKNOWN_REQUEST_STYLE'
48
- throw err
49
- }
50
- if (!erroredUnknownStyles.has(name)) {
51
- erroredUnknownStyles.add(name)
52
- console.error(unknownStyleMessage(name) + ' Falling back to "json-body".')
53
- }
54
- return jsonBody
55
- }
56
-
57
- const erroredUnknownStyles = new Set()
58
-
59
- export { jsonBody }