@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 +3 -3
- package/src/datastore.js +6 -6
- package/src/detail-url.js +9 -12
- package/src/entity-store.js +5 -5
- package/src/fetch-config.js +26 -43
- package/src/index.js +5 -10
- package/src/query-address.js +27 -89
- package/src/route-match.js +46 -3
- package/src/website.js +9 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "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/
|
|
48
|
-
"@uniweb/
|
|
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
|
|
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:
|
|
41
|
-
*
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
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.
|
|
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;
|
package/src/entity-store.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
427
|
+
(item) => String(routeParamValue(item, paramName)) === String(paramValue)
|
|
428
428
|
) ?? null
|
|
429
429
|
|
|
430
430
|
if (!match) {
|
package/src/fetch-config.js
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
31
|
-
import {
|
|
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
|
|
163
|
-
*
|
|
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
|
-
|
|
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
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// (
|
|
26
|
-
//
|
|
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
|
package/src/query-address.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve a query request to
|
|
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
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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
|
-
* ## ⛔
|
|
25
|
+
* ## ⛔ The ADDRESS door is retired (2026-09-04) — do not bring it back
|
|
24
26
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
}
|
package/src/route-match.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
|
619
|
-
// lane that carries `
|
|
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
|
-
|
|
653
|
-
|
|
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
|
|
664
|
+
currentItem = items.find(item => String(routeParamValue(item, paramName)) === String(paramValue)) ?? null
|
|
662
665
|
}
|
|
663
666
|
|
|
664
667
|
if (currentItem) {
|