@uniweb/core 0.20.0 → 0.21.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/core",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,8 +44,8 @@
44
44
  "vitest": "^4.1.7"
45
45
  },
46
46
  "dependencies": {
47
- "@uniweb/semantic-parser": "^1.4.0",
48
- "@uniweb/theming": "^0.1.15"
47
+ "@uniweb/theming": "^0.1.15",
48
+ "@uniweb/semantic-parser": "^1.4.0"
49
49
  },
50
50
  "scripts": {
51
51
  "test": "vitest run"
package/src/datastore.js CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * ⭐ TWO IDENTITIES, decided by whether the request carries an ADDRESS.
23
23
  *
24
- * An ADDRESSED request — `path`, `url` or `endpoint` — is identified by where it
24
+ * An ADDRESSED request — `path` or `url` — is identified by where it
25
25
  * goes: the address, the binding key, the unwrap, and for a POST its method and
26
26
  * body. Post-processing fields (`limit`, `sort`, `where`) are applied after the
27
27
  * fetch over one shared copy and must not split the cache; `query` and `depth`
@@ -37,8 +37,8 @@
37
37
  * (brief) and a record (full) of one query would collide — the one defect on
38
38
  * this path that delivers WRONG data rather than none.
39
39
  *
40
- * `locale` is hashed on both when present: a live lane's address does not carry
41
- * the locale, and two locales' records must not share an entry (F1).
40
+ * `locale` is hashed on both when present: two locales' answers must not share
41
+ * an entry, and a door config always carries the locale it was asked in.
42
42
  *
43
43
  * @param {Object} request - Normalized request (or fetch config)
44
44
  * @returns {string} A stable JSON string usable as a cache-Map key
@@ -47,18 +47,18 @@ export function deriveCacheKey(request) {
47
47
  // ⭐ `as` is the binding key — the name it has had since 2026-09-02, when the
48
48
  // compatibility alias for the older `schema` spelling was removed alongside
49
49
  // frontend's and hosting's.
50
- const { path, url, endpoint, transform, locale } = request || {}
50
+ const { path, url, transform, locale } = request || {}
51
51
  const as = request?.as
52
52
  const method = request?.method && request.method.toUpperCase() !== 'GET'
53
53
  ? request.method.toUpperCase()
54
54
  : undefined
55
55
  const body = method === 'POST' ? request?.body : undefined
56
- if (path || url || endpoint) {
56
+ if (path || url) {
57
57
  // ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
58
58
  // In-memory stores repopulate; a consumer with a persistent cache takes one
59
59
  // cold pass. Chosen over hashing under the old name, which would have hidden
60
60
  // the rename inside the one function whose job is to be canonical.
61
- return JSON.stringify({ path, url, endpoint, as, transform, method, body, locale })
61
+ return JSON.stringify({ path, url, as, transform, method, body, locale })
62
62
  }
63
63
  const { query, schema, scope, where, sort, limit, depth } = request || {}
64
64
  return JSON.stringify({ query, schema, scope, where, sort, limit, depth, as, transform, locale })
package/src/detail-url.js CHANGED
@@ -22,6 +22,7 @@
22
22
  * a matched route pattern says nothing about the record behind it.
23
23
  */
24
24
 
25
+ import { recordHandle } from './route-match.js'
25
26
  import { substitutePlaceholders } from './substitute-placeholders.js'
26
27
 
27
28
  /**
@@ -54,8 +55,9 @@ function paramContext(paramName, paramValue, record) {
54
55
  // `useEntityDetail` — its slug fills the name the FILE was written under. A
55
56
  // caller with no record in hand leaves `{slug}` literal rather than guessing
56
57
  // the capture is one: a visibly unresolved address beats a plausible wrong one.
57
- if (paramName !== 'slug' && record && typeof record === 'object' && record.slug != null && record.slug !== '') {
58
- context.slug = record.slug
58
+ const handle = recordHandle(record)
59
+ if (paramName !== 'slug' && handle != null && handle !== '') {
60
+ context.slug = handle
59
61
  }
60
62
  return context
61
63
  }
@@ -120,17 +122,12 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
120
122
  }
121
123
  }
122
124
 
123
- // Three address kinds now, and the detail request must come back as the SAME
124
- // kind: an `endpoint` carries remote semantics the fetcher decides on, so
125
- // returning a detail as `path` would silently drop operator pushdown and the
126
- // site's static headers for exactly the request that is one record.
127
- const baseUrl = queryConfig.endpoint || queryConfig.url || queryConfig.path
125
+ // Two address kinds, and the detail request comes back as the SAME kind as
126
+ // the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
127
+ // `path`. (A third kind, the host's address door, was retired 2026-09-04.)
128
+ const baseUrl = queryConfig.url || queryConfig.path
128
129
  if (!baseUrl) return null
129
- const addressKey = queryConfig.endpoint
130
- ? 'endpoint'
131
- : queryConfig.url
132
- ? 'url'
133
- : 'path'
130
+ const addressKey = queryConfig.url ? 'url' : 'path'
134
131
 
135
132
  // What every detail config carries beside its address:
136
133
  // `as` — the binding key, so the record lands where the list did;
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
- import { fillRoutePattern } from './route-match.js'
18
+ import { fillRoutePattern, routeParamValue } from './route-match.js'
19
19
  import { sortRecords } from './sort.js'
20
20
 
21
21
  /**
@@ -252,7 +252,7 @@ export default class EntityStore {
252
252
  const { paramName, paramValue } = dynamicContext
253
253
  const items = cached.data
254
254
  let filtered = Array.isArray(items)
255
- ? items.filter((item) => String(item[paramName]) !== String(paramValue))
255
+ ? items.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
256
256
  : items
257
257
  if (order) filtered = this._sortItems(filtered, order)
258
258
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
@@ -278,7 +278,7 @@ export default class EntityStore {
278
278
  const { paramName, paramValue } = dynamicContext
279
279
  const items = cached.data
280
280
  const match = Array.isArray(items)
281
- ? items.find((item) => String(item[paramName]) === String(paramValue))
281
+ ? items.find((item) => String(routeParamValue(item, paramName)) === String(paramValue))
282
282
  : null
283
283
  if (!match) {
284
284
  data[schema] = []
@@ -386,7 +386,7 @@ export default class EntityStore {
386
386
  }
387
387
  const { paramName, paramValue } = dynamicContext
388
388
  let filtered = Array.isArray(records)
389
- ? records.filter((item) => String(item[paramName]) !== String(paramValue))
389
+ ? records.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
390
390
  : (records ?? [])
391
391
  if (order) filtered = this._sortItems(filtered, order)
392
392
  data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
@@ -424,7 +424,7 @@ export default class EntityStore {
424
424
  }
425
425
 
426
426
  const match = records?.find(
427
- (item) => String(item[paramName]) === String(paramValue)
427
+ (item) => String(routeParamValue(item, paramName)) === String(paramValue)
428
428
  ) ?? null
429
429
 
430
430
  if (!match) {
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
31
- import { resolveQueryAddress, resolveRecordAddressPattern, resolveQueryDoor } from './query-address.js'
31
+ import { resolveQueryDoor } from './query-address.js'
32
32
 
33
33
  /**
34
34
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
@@ -104,24 +104,6 @@ function applyDeferredDetail(cfg, queries, records) {
104
104
  // every door config has a detail source; `buildDetailConfig` composes it.
105
105
  if (cfg.door) return { ...cfg, detail: true }
106
106
 
107
- // ⭐ A lane's record address is injected whenever the lane declares one —
108
- // NOT only for a `deferred:` query, and the difference is load-bearing.
109
- //
110
- // A live lane answers a list request at brief depth and a record request in
111
- // full, so a detail page that filtered the list would render the brief and
112
- // silently miss the body. And it cannot fall back to the rule below: the
113
- // `deferred:` declaration lives in `config.queries`, which a host's
114
- // projection is not obliged to carry — so on such a host that rule can never
115
- // fire, and this is the only way a detail page reaches a whole record.
116
- if (cfg.endpoint) {
117
- // ⛔ `cfg.query`, not `cfg.query ?? cfg.schema`. The `??` was unreachable:
118
- // `endpoint` is set in exactly one place (`resolveQuerySource`), which returns
119
- // early unless `cfg.query` is a non-empty string — so reaching here proves it.
120
- // It read as a tolerance for two producer shapes and was really a vestige of
121
- // the build lane not emitting `query`, which it now does.
122
- const recordPattern = resolveRecordAddressPattern(cfg.query, records)
123
- if (recordPattern) return { ...cfg, detail: recordPattern }
124
- }
125
107
 
126
108
  // ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
127
109
  // This read `cfg.schema` — the BINDING KEY, which merely defaults to the query
@@ -159,10 +141,15 @@ function applyDeferredDetail(cfg, queries, records) {
159
141
  * The author names a query; this decides where its records live, and there are
160
142
  * exactly two answers:
161
143
  *
162
- * - a host declared a live lane (`config.records`) → an `endpoint`, final on
163
- * arrival, which the fetcher calls without composing anything further;
144
+ * - a host declared a question door (`config.records.query`) → a `door`, and
145
+ * the whole query goes to it (`schema` from the payload's `config.queries`;
146
+ * a door with no Model ref is a loud per-key error, never a fallthrough);
164
147
  * - nobody did → the `path` of the artifact the build emitted.
165
148
  *
149
+ * ⛔ There is no third answer. The ADDRESS door — `config.records.list` /
150
+ * `.record`, a GET lane the runtime evaluated the query over locally — was
151
+ * retired 2026-09-04 by ruling (`query-address.js` says why).
152
+ *
166
153
  * ⭐ The second is not a fallback in the apologetic sense. It is the answer for
167
154
  * every site with no backend, which is the framework's default rather than a
168
155
  * degraded mode — so an absent lane is silent, not warned.
@@ -188,24 +175,24 @@ function resolveQuerySource(cfg, records, { queries = null, locale = null, defau
188
175
  if (door) {
189
176
  const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
190
177
  const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
191
- if (schema) {
192
- const { path, url, ...rest } = cfg
193
- const asked = { ...rest, door, schema }
194
- // A saved query's own narrowing applies unless the fetch overrides it.
195
- if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
196
- if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
197
- if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
198
- if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
199
- return asked
200
- }
201
- }
202
-
203
- const endpoint = resolveQueryAddress(cfg.query, records)
204
- if (endpoint) {
205
178
  // Drop the transitional `path`: two addresses on one request is an
206
179
  // ambiguity the fetcher would have to break by accident of field order.
207
180
  const { path, url, ...rest } = cfg
208
- return { ...rest, endpoint }
181
+ if (!schema) {
182
+ // ⛔ LOUD, not a fallthrough. A payload that stamps a door and carries no
183
+ // Model ref for the query cannot ask, and reading the compiled file
184
+ // instead would turn a producer defect into a 404 that names the wrong
185
+ // thing. The fetcher refuses a door request with no `schema` before any
186
+ // request is made, and the block's `dataError` says exactly this.
187
+ return { ...rest, door, schema: null }
188
+ }
189
+ const asked = { ...rest, door, schema }
190
+ // A saved query's own narrowing applies unless the fetch overrides it.
191
+ if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
192
+ if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
193
+ if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
194
+ if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
195
+ return asked
209
196
  }
210
197
  return { ...cfg, path: queryDataUrl(cfg.query) }
211
198
  }
@@ -413,19 +400,15 @@ function foldScope(cfg) {
413
400
  * query's compiled file is the stripped list. `full` otherwise. An explicit
414
401
  * `depth` on the config wins (a question door's client sets it).
415
402
  *
416
- * `locale` — stamped on a LIVE-lane config only. A compiled path already carries
417
- * its locale (`/fr/data/…`), but a live address does not, and two locales'
418
- * records must not share a cache entry (F1). Absent on the default locale, so a
419
- * site with one language sees no new field.
403
+ * `locale` — stamped on a DOOR config only. A compiled path already carries its
404
+ * locale (`/fr/data/…`); a door is asked in one locale (it is in the route), and
405
+ * two locales' answers must not share a cache entry.
420
406
  */
421
407
  function stampDepthAndLocale(cfg, locale, defaultLocale) {
422
408
  let out = cfg
423
409
  if (out.depth !== 'brief' && out.depth !== 'full') {
424
410
  out = { ...out, depth: out.detail ? 'brief' : 'full' }
425
411
  }
426
- if (out.endpoint && locale && locale !== defaultLocale && out.locale === undefined) {
427
- out = { ...out, locale }
428
- }
429
412
  // A door is asked in exactly one locale — it is in the route — so the config
430
413
  // carries it whatever the locale is; two locales' answers never share an entry.
431
414
  if (out.door && out.locale === undefined) {
package/src/index.js CHANGED
@@ -20,15 +20,10 @@ export { default as ObservableState } from './observable-state.js'
20
20
 
21
21
  // Utilities
22
22
  export { substitutePlaceholders } from './substitute-placeholders.js'
23
- // `resolveQueryAddress` / `resolveRecordAddressPattern` are NOT re-exported
24
- // here. They are read by `./fetch-config.js` and by nothing else in any repo
25
- // (measured 2026-09-01 over every .js/.jsx/.mjs/.ts/.tsx file in the
26
- // workspace). A consumer that needs them imports `@uniweb/core/query-address`,
27
- // which is a declared subpath and a zero-dependency leaf — the same reach
28
- // `route-match` and `section-id` already have. Re-exporting an internal from
29
- // the package entry is not free: the import-map bridge enumerates this file's
30
- // surface and emits a live named re-export for every one, so nothing here can
31
- // ever be tree-shaken on the hosted lane.
23
+ // `resolveQueryDoor` / `QUERY_DOOR_KEY` are NOT re-exported here: `./fetch-config.js`
24
+ // reads them and a consumer that needs them imports `@uniweb/core/query-address`.
25
+ // (The address door's `resolveQueryAddress` / `resolveRecordAddressPattern` were
26
+ // deleted 2026-09-04 with the lane they addressed.)
32
27
  export { resolveFetchConfigs } from './fetch-config.js'
33
28
  export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
34
29
  // `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
@@ -52,7 +47,7 @@ export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
52
47
  // (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
53
48
  // which is what keeps the static and live lanes answering a query identically.
54
49
  export { parseSort, sortRecords, sortToWire } from './sort.js'
55
- export { fillRoutePattern, splitPathCapture, joinPathCapture } from './route-match.js'
50
+ export { fillRoutePattern, splitPathCapture, joinPathCapture, recordHandle, routeParamValue } from './route-match.js'
56
51
  export { isRichSchema } from './schemas.js'
57
52
  // ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
58
53
  // object graph this package exists to define, and putting it here made every
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Resolve a query request to an address the fetcher can call.
2
+ * Resolve a query request to the one address a host can declare for it.
3
3
  *
4
4
  * ## The one idea
5
5
  *
6
- * A site names a query; it never names where its records live. Where
7
- * it lives is a **deployment** fact, and the two possible answers have different
8
- * owners:
6
+ * A site names a query; it never names where its records live. Where they live
7
+ * is a **deployment** fact, and the two possible answers have different owners:
9
8
  *
10
- * 1. **A host that serves records live** declares a pair of URL *patterns*
11
- * at `config.records`. It owns every segment of them.
9
+ * 1. **A host that answers questions** declares a QUESTION DOOR at
10
+ * `config.records.query` a POST address with a `{locale}` slot. It owns
11
+ * every segment of it; the runtime substitutes the one slot and sends the
12
+ * whole query. ⛔ The question is composed elsewhere (`fetch-config.js`);
13
+ * this file only says where it goes.
12
14
  * 2. **Nobody** — and the answer is the artifact the build itself emitted,
13
15
  * `/data/<name>.json`, which is not an address at all but a path in the
14
16
  * site's own URL space.
@@ -20,40 +22,34 @@
20
22
  * nothing, which is right for `submit` and wrong here, where the fallback is a
21
23
  * file the build knows it wrote.
22
24
  *
23
- * ## ⛔ Patterns, not a base and the reason is a deleted function
25
+ * ## ⛔ The ADDRESS door is retired (2026-09-04) do not bring it back
24
26
  *
25
- * A base assumes the layout is "root plus one segment". A pattern assumes
26
- * nothing, so a host can carry a site id, a locale segment, a different root
27
- * for records than for the list, or none of those, and move any of it
28
- * without a framework release.
27
+ * Until that day this file also read two URL PATTERNS off the same stamp —
28
+ * `list` (`{path}`) and `record` (`{param}`) for a GET lane the runtime
29
+ * evaluated the query over locally. It went by ruling, with no hosted site to
30
+ * protect: two lanes on one host answered one query two ways (an operator the
31
+ * door refuses was honoured locally on the GET lane), the precedence between
32
+ * them was where the failure lived, and the address was composed from the
33
+ * query's NAME, which is not a folder path. A query is a question; a host that
34
+ * cannot answer one is a host with no records lane, and its site reads the
35
+ * compiled file. A host's `list` / `record` / `envelope` stamps are not read.
29
36
  *
30
- * This is the `config.assets.url` rule applied to records. That pattern exists
31
- * because the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
32
- * path layout, inside a published CLI, on a release cadence the backend could
33
- * not move. It was deleted rather than parameterized. Composing a segment of
34
- * our own here would rebuild exactly that coupling, on a lane where the wrong
35
- * answer is *stale or missing content* rather than a visible 404.
37
+ * ## A pattern, not a base and the reason is a deleted function
36
38
  *
37
- * Substituting `{path}` and `{param}` is the WHOLE of what this does.
39
+ * A base assumes the layout is "root plus one segment". A pattern assumes
40
+ * nothing, so a host can carry a site id, a locale segment, a different root
41
+ * for the door than for the site, or none of those, and move any of it
42
+ * without a framework release. This is the `config.assets.url` rule applied to
43
+ * records: the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
44
+ * path layout, inside a published CLI — and it was deleted rather than
45
+ * parameterized. Substituting `{locale}` is the WHOLE of what this does.
38
46
  *
39
- * Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
47
+ * Zero-dependency beyond one sibling leaf, so the SSR pipeline and a Worker
40
48
  * isolate can both import it.
41
49
  */
42
50
 
43
51
  import { substitutePlaceholders } from './substitute-placeholders.js'
44
52
 
45
- /**
46
- * The placeholder a list pattern must carry.
47
- *
48
- * ⛔ IT IS `{path}`, NOT `{query}`, AND THAT IS NOT COSMETIC. A *query* is
49
- * framework's own build concept — a named set our build compiles to one file. A host
50
- * serving records has no such thing: it has content organised somewhere, and what we
51
- * substitute is a **path** to it. Naming the slot for our file vocabulary put that
52
- * vocabulary into a string a HOST writes, which makes them reason in our shape.
53
- */
54
- const PATH_SLOT = '{path}'
55
- /** The placeholder a record pattern must carry to address a specific record. */
56
- const PARAM_SLOT = '{param}'
57
53
 
58
54
  const warnedPatterns = new Set()
59
55
 
@@ -81,34 +77,6 @@ function readPattern(lane, key) {
81
77
  return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
82
78
  }
83
79
 
84
- /**
85
- * The address for a whole query's records, or `null` to fall through to the artifact.
86
- *
87
- * ⚠️ A pattern that does not carry `{path}` is REFUSED rather than used.
88
- * Substituting nothing would yield one identical URL for every query on the
89
- * site — every schema reading the same records, with a 200 on each request. That
90
- * is the failure this check exists for; an unusable pattern must degrade to the
91
- * artifact, which is at least correct.
92
- *
93
- * @param {string} query - the query's authored name (the wiring key).
94
- * @param {Object|null} lane - `config.records`.
95
- * @returns {string|null} the address, or null when nothing usable is declared.
96
- */
97
- export function resolveQueryAddress(query, lane) {
98
- if (typeof query !== 'string' || query.length === 0) return null
99
- const pattern = readPattern(lane, 'list')
100
- if (!pattern) return null
101
- if (!pattern.includes(PATH_SLOT)) {
102
- warnOnce(
103
- `list:${pattern}`,
104
- `config.records.list carries no ${PATH_SLOT} placeholder, so every ` +
105
- `query would resolve to the same address. Ignoring it and reading the ` +
106
- `compiled file instead.`
107
- )
108
- return null
109
- }
110
- return substitutePlaceholders(pattern, { path: query })
111
- }
112
80
 
113
81
  /**
114
82
  * The QUESTION door a host declares — a POST address with a `{locale}` slot —
@@ -144,33 +112,3 @@ export function resolveQueryDoor(lane, locale) {
144
112
  return substitutePlaceholders(pattern, { locale })
145
113
  }
146
114
 
147
- /**
148
- * The address pattern for ONE record of a query, with `{param}` left in
149
- * place for the dynamic-route substitution that happens later.
150
- *
151
- * Returning a pattern rather than a finished URL is deliberate: the route param
152
- * is not known here, and the framework already has one place that resolves it
153
- * (`buildDetailConfig` / `substitutePlaceholders` at fetch time). Resolving it
154
- * twice, in two places, is how the two copies drift.
155
- *
156
- * @param {string} query
157
- * @param {Object|null} lane - `config.records`.
158
- * @returns {string|null} a pattern still containing `{param}`, or null.
159
- */
160
- export function resolveRecordAddressPattern(query, lane) {
161
- if (typeof query !== 'string' || query.length === 0) return null
162
- const pattern = readPattern(lane, 'record')
163
- if (!pattern) return null
164
- if (!pattern.includes(PARAM_SLOT)) {
165
- warnOnce(
166
- `record:${pattern}`,
167
- `config.records.record carries no ${PARAM_SLOT} placeholder, so every record ` +
168
- `would resolve to the same address. Ignoring it and reading the per-record ` +
169
- `file instead.`
170
- )
171
- return null
172
- }
173
- // Only `{path}` is substituted here — `{param}` survives for the
174
- // dynamic-route resolution that owns it.
175
- return substitutePlaceholders(pattern, { path: query })
176
- }
@@ -238,6 +238,48 @@ export function joinPathCapture({ dir, slug } = {}) {
238
238
  return d ? `${d}/${slug}` : String(slug)
239
239
  }
240
240
 
241
+ /**
242
+ * A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
243
+ * what a `[slug]` route (or the last segment of a `[...path]` one) matches.
244
+ *
245
+ * ⭐ Two lanes spell it differently and mean one thing. A host's records door
246
+ * serves the entry's handle as `$name` — `$`-namespaced because a Model may
247
+ * declare its own `name` or `slug` field (five of eight seeded briefs do), and
248
+ * the placement must not be shadowed by one. The file lane derives it from the
249
+ * source filename and calls it `slug`. `$name` wins when present: on a live
250
+ * record a Model field named `slug` is the author's data, not the placement.
251
+ *
252
+ * @param {Object} record
253
+ * @returns {string|undefined}
254
+ */
255
+ export function recordHandle(record) {
256
+ if (!record || typeof record !== 'object') return undefined
257
+ const name = record.$name
258
+ if (typeof name === 'string' && name.length) return name
259
+ return record.slug
260
+ }
261
+
262
+ /**
263
+ * The value a record carries for a route param. `slug` — the default param, and
264
+ * the last segment of a `[...path]` route — is the placement handle
265
+ * (`recordHandle`); any other param is a field the site chose to route by.
266
+ *
267
+ * ⛔ Every reader that matches a delivered record to a route param goes through
268
+ * this — the entity store, the website's dynamic page, the kit's detail hook,
269
+ * the href encoder below. Until 2026-09-04 each read `item[paramName]` directly,
270
+ * so a record served with `$name` and no `slug` matched nothing: a template page
271
+ * on a live lane rendered `[]` and a list linked to no record.
272
+ *
273
+ * @param {Object} record
274
+ * @param {string} paramName
275
+ * @returns {*}
276
+ */
277
+ export function routeParamValue(record, paramName) {
278
+ if (!record || typeof record !== 'object') return undefined
279
+ if (paramName === 'slug') return recordHandle(record)
280
+ return record[paramName]
281
+ }
282
+
241
283
  /**
242
284
  * Fill a route pattern's params from a record — the ONE encoder for a record's href.
243
285
  *
@@ -274,16 +316,17 @@ export function fillRoutePattern(pattern, values) {
274
316
  // slashes between them kept as structure. `dir` is the placement; a record
275
317
  // carries it as `path` (the folder `records.yml` put it in), which is why
276
318
  // `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
319
+ const handle = recordHandle(values)
320
+ if (joinPathCapture({ dir: values.dir ?? values.path, slug: handle }) === null) return null
278
321
  const dir = String(values.dir ?? values.path ?? '')
279
322
  const segments = dir.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
280
323
  // The handle is ONE segment whatever it contains: a `/` inside it is a value.
281
- segments.push(encodeURIComponent(String(values.slug)))
324
+ segments.push(encodeURIComponent(String(handle)))
282
325
  tailHref = '/' + segments.join('/')
283
326
  head = pattern.slice(0, tail.index)
284
327
  }
285
328
  const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
286
- const value = values[name]
329
+ const value = routeParamValue(values, name)
287
330
  if (value === undefined || value === null || value === '') {
288
331
  missing = true
289
332
  return ''
package/src/website.js CHANGED
@@ -11,7 +11,7 @@ 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, routePatternToRegex, splitPathCapture } from './route-match.js'
14
+ import { matchDynamicRoute, decodeRouteValue, routePatternToRegex, splitPathCapture, routeParamValue } from './route-match.js'
15
15
  import { resolveFetchConfigs } from './fetch-config.js'
16
16
  import { buildDetailConfig } from './detail-url.js'
17
17
  import { resolveService } from './services.js'
@@ -615,8 +615,8 @@ export default class Website {
615
615
  //
616
616
  // ⛔ RESOLVED THE WAY THE ENTITY STORE RESOLVES IT, not the raw declaration.
617
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
618
+ // path, as }` — while the store writes under the RESOLVED config: on a door
619
+ // lane that carries `door` and no `path`, and on a non-default locale a
620
620
  // `/fr/data/…` path. Two different keys for one dataset, so the probe
621
621
  // missed on exactly those lanes: no title, no not-found, and the page was
622
622
  // never cached (`recordsLoaded` false on every visit). Silent, on a
@@ -649,8 +649,11 @@ export default class Website {
649
649
  ? buildDetailConfig(fetchConfig, { paramName, paramValue })
650
650
  : null
651
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
652
+ // A door answers the record question as a list of one (a question's
653
+ // answer is always a list); a per-record file answers the bare record.
654
+ const raw = detailCached?.data
655
+ const record = Array.isArray(raw) ? raw[0] : raw
656
+ if (record && typeof record === 'object') currentItem = record
654
657
 
655
658
  const cached = this.fetcher.peek(fetchConfig, ctx)
656
659
  items = Array.isArray(cached?.data) ? cached.data : []
@@ -658,7 +661,7 @@ export default class Website {
658
661
  }
659
662
 
660
663
  if (!currentItem) {
661
- currentItem = items.find(item => String(item[paramName]) === String(paramValue)) ?? null
664
+ currentItem = items.find(item => String(routeParamValue(item, paramName)) === String(paramValue)) ?? null
662
665
  }
663
666
 
664
667
  if (currentItem) {