@uniweb/core 0.20.0 → 0.22.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 +2 -3
- package/src/datastore.js +8 -8
- package/src/detail-url.js +21 -24
- package/src/entity-store.js +14 -13
- package/src/fetch-config.js +81 -80
- package/src/index.js +3 -10
- package/src/records-service.js +113 -0
- package/src/route-match.js +46 -3
- package/src/services.js +1 -1
- package/src/sort.js +3 -3
- package/src/website.js +10 -7
- package/src/query-address.js +0 -176
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./base-path": "./src/base-path.js",
|
|
9
|
-
"./
|
|
10
|
-
"./query-address": "./src/query-address.js",
|
|
9
|
+
"./records-service": "./src/records-service.js",
|
|
11
10
|
"./data-paths": "./src/data-paths.js",
|
|
12
11
|
"./datastore": "./src/datastore.js",
|
|
13
12
|
"./detail-url": "./src/detail-url.js",
|
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`
|
|
@@ -30,15 +30,15 @@
|
|
|
30
30
|
* asking for `{ path, as }` must hit the entry the page's declaration filled —
|
|
31
31
|
* that shared cache is a documented property of `useFetched`.
|
|
32
32
|
*
|
|
33
|
-
* An ADDRESS-LESS request — a QUESTION sent to
|
|
34
|
-
* the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
|
|
33
|
+
* An ADDRESS-LESS request — a QUESTION sent to the records service — is
|
|
34
|
+
* identified by the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`, `depth`.
|
|
35
35
|
* ⛔ The reason: with no per-query address, two pages
|
|
36
36
|
* binding one `as` to two queries would otherwise share an entry, and a list
|
|
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 an asked 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
|
}
|
|
@@ -89,12 +91,12 @@ function paramContext(paramName, paramValue, record) {
|
|
|
89
91
|
*/
|
|
90
92
|
/**
|
|
91
93
|
* The key a route param narrows a QUESTION by — the entry's own handle, which
|
|
92
|
-
*
|
|
94
|
+
* the records service guarantees unique among siblings (the records contract
|
|
93
95
|
* §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
|
|
94
96
|
* moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
|
|
95
|
-
* → `$name`); the
|
|
96
|
-
* to change if it moves again. Not read by the file lane
|
|
97
|
-
*
|
|
97
|
+
* → `$name`); the service is dark until a host stamps it, so this is the one
|
|
98
|
+
* place to change if it moves again. Not read by the file lane, which narrows
|
|
99
|
+
* by the route's own param (`item[paramName]`).
|
|
98
100
|
*/
|
|
99
101
|
export const ROUTE_HANDLE_KEY = '$name'
|
|
100
102
|
|
|
@@ -104,13 +106,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
104
106
|
const { paramName, paramValue, record = null } = dynamicContext
|
|
105
107
|
if (!paramName || paramValue === undefined) return null
|
|
106
108
|
|
|
107
|
-
// ⭐
|
|
108
|
-
// entry by its handle and asked in full — the list page and the detail
|
|
109
|
-
// are the same query, differing only by whether the parameter is bound
|
|
110
|
-
// (the records
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
if (queryConfig.
|
|
109
|
+
// ⭐ THE RECORDS SERVICE: the record is the list's own question, narrowed to
|
|
110
|
+
// one entry by its handle and asked in full — the list page and the detail
|
|
111
|
+
// page are the same query, differing only by whether the parameter is bound
|
|
112
|
+
// (the records contract, §1a). `sort` and `limit` are the list's and drop;
|
|
113
|
+
// `scope` and the authored `where` stay, so a scoped query cannot be escaped
|
|
114
|
+
// through the URL.
|
|
115
|
+
if (queryConfig.ask) {
|
|
114
116
|
const { sort, limit, detail: _detail, ...rest } = queryConfig
|
|
115
117
|
return {
|
|
116
118
|
...rest,
|
|
@@ -120,21 +122,16 @@ 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`.
|
|
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;
|
|
137
|
-
// `query` — the query it is one record of (identity
|
|
134
|
+
// `query` — the query it is one record of (identity when asked);
|
|
138
135
|
// `depth: 'full'` — what it asks for, and what the record index files it as;
|
|
139
136
|
// `dynamicContext` — the route param, which the default fetcher already keys
|
|
140
137
|
// a SINGLE-RECORD response on (`envelope.item`, body
|
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
|
/**
|
|
@@ -152,10 +152,11 @@ export default class EntityStore {
|
|
|
152
152
|
// after the payload key was renamed — a dead option name, silently: the
|
|
153
153
|
// resolver simply saw no queries and stopped injecting `detail:`.
|
|
154
154
|
queries: website?.config?.queries ?? null,
|
|
155
|
-
//
|
|
156
|
-
// local dev, which is why
|
|
157
|
-
// the ordinary case and reads
|
|
158
|
-
|
|
155
|
+
// The host's services; its `records` row is the live-records lane.
|
|
156
|
+
// Absent on every static site and on local dev, which is why
|
|
157
|
+
// `resolveQuerySource` treats absence as the ordinary case and reads
|
|
158
|
+
// the compiled artifact without comment.
|
|
159
|
+
services: website?.config?.services ?? null,
|
|
159
160
|
variables,
|
|
160
161
|
},
|
|
161
162
|
)
|
|
@@ -252,15 +253,15 @@ export default class EntityStore {
|
|
|
252
253
|
const { paramName, paramValue } = dynamicContext
|
|
253
254
|
const items = cached.data
|
|
254
255
|
let filtered = Array.isArray(items)
|
|
255
|
-
? items.filter((item) => String(item
|
|
256
|
+
? items.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
|
|
256
257
|
: items
|
|
257
258
|
if (order) filtered = this._sortItems(filtered, order)
|
|
258
259
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
259
260
|
} else {
|
|
260
261
|
allCached = false
|
|
261
262
|
}
|
|
262
|
-
} else if (isRouteQuery && cfg.
|
|
263
|
-
//
|
|
263
|
+
} else if (isRouteQuery && cfg.ask) {
|
|
264
|
+
// The records service: the record's own answer is cached under its own key.
|
|
264
265
|
const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
|
|
265
266
|
const detailCached = detailCfg ? dispatcher?.peek(detailCfg, ctx) : null
|
|
266
267
|
if (detailCached) {
|
|
@@ -278,7 +279,7 @@ export default class EntityStore {
|
|
|
278
279
|
const { paramName, paramValue } = dynamicContext
|
|
279
280
|
const items = cached.data
|
|
280
281
|
const match = Array.isArray(items)
|
|
281
|
-
? items.find((item) => String(item
|
|
282
|
+
? items.find((item) => String(routeParamValue(item, paramName)) === String(paramValue))
|
|
282
283
|
: null
|
|
283
284
|
if (!match) {
|
|
284
285
|
data[schema] = []
|
|
@@ -386,12 +387,12 @@ export default class EntityStore {
|
|
|
386
387
|
}
|
|
387
388
|
const { paramName, paramValue } = dynamicContext
|
|
388
389
|
let filtered = Array.isArray(records)
|
|
389
|
-
? records.filter((item) => String(item
|
|
390
|
+
? records.filter((item) => String(routeParamValue(item, paramName)) !== String(paramValue))
|
|
390
391
|
: (records ?? [])
|
|
391
392
|
if (order) filtered = this._sortItems(filtered, order)
|
|
392
393
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
393
|
-
} else if (isRouteQuery && cfg.
|
|
394
|
-
// ⭐
|
|
394
|
+
} else if (isRouteQuery && cfg.ask) {
|
|
395
|
+
// ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
|
|
395
396
|
// same question narrowed by the route's handle, so list and record are
|
|
396
397
|
// asked together — one round trip, and no client-side scan gating the
|
|
397
398
|
// fetch (F13, the live half). The list is asked too, because sections
|
|
@@ -424,7 +425,7 @@ export default class EntityStore {
|
|
|
424
425
|
}
|
|
425
426
|
|
|
426
427
|
const match = records?.find(
|
|
427
|
-
(item) => String(item
|
|
428
|
+
(item) => String(routeParamValue(item, paramName)) === String(paramValue)
|
|
428
429
|
) ?? null
|
|
429
430
|
|
|
430
431
|
if (!match) {
|
package/src/fetch-config.js
CHANGED
|
@@ -14,12 +14,30 @@
|
|
|
14
14
|
*
|
|
15
15
|
* INTENTIONALLY A LEAF: safe to load anywhere — including environments with no
|
|
16
16
|
* DOM, no filesystem, and a hard bundle-size ceiling. The rule that protects
|
|
17
|
-
* that is **
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
17
|
+
* that is **import nothing from the package root** (which pulls semantic-parser
|
|
18
|
+
* and theming) and nothing that reaches it transitively.
|
|
19
|
+
*
|
|
20
|
+
* ⚠️ **This said "and nothing that itself imports — `./data-paths.js` is the
|
|
21
|
+
* only one taken", and both halves had stopped being true.** `query-address.js`
|
|
22
|
+
* was added as an import and itself imports; on 2026-09-06 it became
|
|
23
|
+
* `records-service.js` and the chain grew again. **The admissible set, named
|
|
24
|
+
* rather than counted:**
|
|
25
|
+
*
|
|
26
|
+
* fetch-config → data-paths (leaf)
|
|
27
|
+
* → records-service → substitute-placeholders (leaf)
|
|
28
|
+
* → services → base-path (leaf)
|
|
29
|
+
*
|
|
30
|
+
* Every module in it is pure JS with no `node:*`, no DOM and no package-root
|
|
31
|
+
* import — the property that actually matters. "Depth 1" was a proxy for it
|
|
32
|
+
* that stopped holding without anything failing, which is why the rule is now
|
|
33
|
+
* stated as the property.
|
|
34
|
+
*
|
|
35
|
+
* ⛔ **Two edges are deliberate and must not be inlined**, for one reason:
|
|
36
|
+
* `data-paths.js` holds the `/data/<name>.json` convention, which has to be
|
|
37
|
+
* identical here and in the build that emits the files; `services.js` holds
|
|
38
|
+
* `readEndpoint`, the ONE rule for reading a service declaration. A second copy
|
|
39
|
+
* of either is precisely the drift this module exists to prevent. ⇒ **If you
|
|
40
|
+
* add an edge, say here why it holds.**
|
|
23
41
|
*
|
|
24
42
|
* WHAT THIS DOES NOT OWN: where the sources come from. A caller holding a live
|
|
25
43
|
* object graph reads them off the graph; a caller holding a content document
|
|
@@ -28,7 +46,7 @@
|
|
|
28
46
|
*/
|
|
29
47
|
|
|
30
48
|
import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
31
|
-
import {
|
|
49
|
+
import { resolveRecordsService } from './records-service.js'
|
|
32
50
|
|
|
33
51
|
/**
|
|
34
52
|
* Is this fetch declaration a per-instance *refinement* of an ancestor's
|
|
@@ -97,31 +115,13 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
97
115
|
* @param {Object|null} queries - the site's `config.queries` map
|
|
98
116
|
* @returns {Object} the original config, or a copy carrying `detail`
|
|
99
117
|
*/
|
|
100
|
-
function applyDeferredDetail(cfg, queries
|
|
118
|
+
function applyDeferredDetail(cfg, queries) {
|
|
101
119
|
if (cfg.detail !== undefined) return cfg
|
|
102
120
|
|
|
103
|
-
//
|
|
104
|
-
// every
|
|
105
|
-
if (cfg.
|
|
121
|
+
// The records service answers a RECORD by the same question narrowed to it,
|
|
122
|
+
// so every asked config has a detail source; `buildDetailConfig` composes it.
|
|
123
|
+
if (cfg.ask) return { ...cfg, detail: true }
|
|
106
124
|
|
|
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
125
|
|
|
126
126
|
// ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
|
|
127
127
|
// This read `cfg.schema` — the BINDING KEY, which merely defaults to the query
|
|
@@ -159,9 +159,15 @@ function applyDeferredDetail(cfg, queries, records) {
|
|
|
159
159
|
* The author names a query; this decides where its records live, and there are
|
|
160
160
|
* exactly two answers:
|
|
161
161
|
*
|
|
162
|
-
* - a host
|
|
163
|
-
*
|
|
164
|
-
*
|
|
162
|
+
* - a host offers the `records` service (`config.services.records`) → an
|
|
163
|
+
* `ask` address, and the whole query goes to it (`schema` from the
|
|
164
|
+
* payload's `config.queries`; an `ask` with no Model ref is a loud per-key
|
|
165
|
+
* error, never a fallthrough);
|
|
166
|
+
* - nobody does → the `path` of the artifact the build emitted.
|
|
167
|
+
*
|
|
168
|
+
* ⛔ There is no third answer, and there has not been one since the GET lane
|
|
169
|
+
* the runtime evaluated locally was retired (2026-09-04). `records-service.js`
|
|
170
|
+
* says why the wrapper it lived in went with it.
|
|
165
171
|
*
|
|
166
172
|
* ⭐ The second is not a fallback in the apologetic sense. It is the answer for
|
|
167
173
|
* every site with no backend, which is the framework's default rather than a
|
|
@@ -174,38 +180,37 @@ function applyDeferredDetail(cfg, queries, records) {
|
|
|
174
180
|
* `query`, ignoring any `path`), so the two agree rather than disagreeing on a
|
|
175
181
|
* shape nobody hand-writes.
|
|
176
182
|
*/
|
|
177
|
-
function resolveQuerySource(cfg,
|
|
183
|
+
function resolveQuerySource(cfg, services, { queries = null, locale = null, defaultLocale = null } = {}) {
|
|
178
184
|
if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
|
|
179
185
|
|
|
180
|
-
// ⭐ THE
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (door) {
|
|
186
|
+
// ⭐ THE SERVICE FIRST. A host that answers questions gets the whole query —
|
|
187
|
+
// `schema`, `scope`, `where`, `sort`, `limit`, `depth` — and composes no
|
|
188
|
+
// per-query address at all (the records contract, §2). It needs the query's
|
|
189
|
+
// MODEL REF, which lives on the site's `config.queries` declaration; a
|
|
190
|
+
// payload that offers the service but carries no declaration cannot ask, and
|
|
191
|
+
// says so. ⚠️ Dark until a host stamps the row; see `resolveRecordsService`.
|
|
192
|
+
const ask = resolveRecordsService(services, locale ?? defaultLocale)
|
|
193
|
+
if (ask) {
|
|
189
194
|
const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
|
|
190
195
|
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
196
|
// Drop the transitional `path`: two addresses on one request is an
|
|
206
197
|
// ambiguity the fetcher would have to break by accident of field order.
|
|
207
198
|
const { path, url, ...rest } = cfg
|
|
208
|
-
|
|
199
|
+
if (!schema) {
|
|
200
|
+
// ⛔ LOUD, not a fallthrough. A payload that offers the service and carries no
|
|
201
|
+
// Model ref for the query cannot ask, and reading the compiled file
|
|
202
|
+
// instead would turn a producer defect into a 404 that names the wrong
|
|
203
|
+
// thing. The fetcher refuses an asked request with no `schema` before any
|
|
204
|
+
// request is made, and the block's `dataError` says exactly this.
|
|
205
|
+
return { ...rest, ask, schema: null }
|
|
206
|
+
}
|
|
207
|
+
const asked = { ...rest, ask, schema }
|
|
208
|
+
// A saved query's own narrowing applies unless the fetch overrides it.
|
|
209
|
+
if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
|
|
210
|
+
if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
|
|
211
|
+
if (asked.sort === undefined && decl.sort !== undefined && decl.sort !== null) asked.sort = decl.sort
|
|
212
|
+
if (asked.limit === undefined && typeof decl.limit === 'number' && decl.limit > 0) asked.limit = decl.limit
|
|
213
|
+
return asked
|
|
209
214
|
}
|
|
210
215
|
return { ...cfg, path: queryDataUrl(cfg.query) }
|
|
211
216
|
}
|
|
@@ -261,9 +266,9 @@ function bindingKey(cfg) {
|
|
|
261
266
|
* @param {string|null} [options.locale] - the locale being rendered
|
|
262
267
|
* @param {string|null} [options.defaultLocale] - the site's default locale
|
|
263
268
|
* @param {Object|null} [options.queries] - the site's `config.queries`
|
|
264
|
-
* @param {Object|null} [options.
|
|
265
|
-
* live-records lane
|
|
266
|
-
* the whole of what a site with no backend needs.
|
|
269
|
+
* @param {Object|null} [options.services] - the site's `config.services`. The
|
|
270
|
+
* `records` row is a host's live-records lane; absent means the compiled
|
|
271
|
+
* artifact answers, which is the whole of what a site with no backend needs.
|
|
267
272
|
* @param {Object|null} [options.variables] - the route's variables on a template
|
|
268
273
|
* page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
|
|
269
274
|
* a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
|
|
@@ -276,7 +281,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
276
281
|
locale = null,
|
|
277
282
|
defaultLocale = null,
|
|
278
283
|
queries = null,
|
|
279
|
-
|
|
284
|
+
services = null,
|
|
280
285
|
variables = null,
|
|
281
286
|
} = options
|
|
282
287
|
|
|
@@ -293,10 +298,10 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
293
298
|
if (!collectAll && !schemas.includes(key)) continue
|
|
294
299
|
// Address first: localization and deferred-detail both key on `path`,
|
|
295
300
|
// which a query ref does not have until this runs.
|
|
296
|
-
const sourced = resolveQuerySource(cfg,
|
|
301
|
+
const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
|
|
297
302
|
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
298
303
|
const bound = foldScope(bindRouteVariables(localized, variables))
|
|
299
|
-
configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries
|
|
304
|
+
configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries), locale, defaultLocale))
|
|
300
305
|
}
|
|
301
306
|
}
|
|
302
307
|
|
|
@@ -318,8 +323,8 @@ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
|
|
|
318
323
|
* the list page and the detail page: `where: { tag: :dir }` narrows on
|
|
319
324
|
* `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
|
|
320
325
|
* variable bound to an empty string (`:dir` on a single-segment capture) is
|
|
321
|
-
* bound, and binds the empty value.
|
|
322
|
-
* rule from its side (the records
|
|
326
|
+
* bound, and binds the empty value. The backend answering the records service
|
|
327
|
+
* states the same rule from its side (the records contract, §6b).
|
|
323
328
|
*
|
|
324
329
|
* ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
|
|
325
330
|
* to an intentional list page. Only an authoring surface can catch that; this
|
|
@@ -385,17 +390,17 @@ function bindWhere(where, variables) {
|
|
|
385
390
|
}
|
|
386
391
|
|
|
387
392
|
/**
|
|
388
|
-
* `scope:` on a lane that cannot be ASKED — the compiled file
|
|
389
|
-
*
|
|
393
|
+
* `scope:` on a lane that cannot be ASKED — the compiled file — is the same
|
|
394
|
+
* question as `where: { path: { under: scope } }`: a record's
|
|
390
395
|
* `path` is the folder `records.yml` placed it in, and the evaluator's `under`
|
|
391
396
|
* is segment-aware containment. Folding it keeps the language the INTERSECTION
|
|
392
397
|
* of both lanes: an author
|
|
393
398
|
* writes `scope: :dir` once and it means the same branch on a static site and
|
|
394
|
-
* on a
|
|
395
|
-
*
|
|
399
|
+
* on a service that takes `scope` natively. A config carrying `ask` keeps
|
|
400
|
+
* `scope` as the service's own field.
|
|
396
401
|
*/
|
|
397
402
|
function foldScope(cfg) {
|
|
398
|
-
if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.
|
|
403
|
+
if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.ask) return cfg
|
|
399
404
|
const { scope, ...rest } = cfg
|
|
400
405
|
const under = { path: { under: scope } }
|
|
401
406
|
const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
|
|
@@ -411,24 +416,20 @@ function foldScope(cfg) {
|
|
|
411
416
|
* a list with a separate record address is a list of partial records: a live
|
|
412
417
|
* lane answers a list at brief depth and a record in full, and a `deferred:`
|
|
413
418
|
* query's compiled file is the stripped list. `full` otherwise. An explicit
|
|
414
|
-
* `depth` on the config wins (
|
|
419
|
+
* `depth` on the config wins (the records service's client sets it).
|
|
415
420
|
*
|
|
416
|
-
* `locale` — stamped on
|
|
417
|
-
* its locale (`/fr/data/…`)
|
|
418
|
-
*
|
|
419
|
-
* site with one language sees no new field.
|
|
421
|
+
* `locale` — stamped on an ASKED config only. A compiled path already carries
|
|
422
|
+
* its locale (`/fr/data/…`); the service is asked in one locale (it is in the
|
|
423
|
+
* route), and two locales' answers must not share a cache entry.
|
|
420
424
|
*/
|
|
421
425
|
function stampDepthAndLocale(cfg, locale, defaultLocale) {
|
|
422
426
|
let out = cfg
|
|
423
427
|
if (out.depth !== 'brief' && out.depth !== 'full') {
|
|
424
428
|
out = { ...out, depth: out.detail ? 'brief' : 'full' }
|
|
425
429
|
}
|
|
426
|
-
|
|
427
|
-
out = { ...out, locale }
|
|
428
|
-
}
|
|
429
|
-
// A door is asked in exactly one locale — it is in the route — so the config
|
|
430
|
+
// The service is asked in exactly one locale — it is in the route — so the config
|
|
430
431
|
// carries it whatever the locale is; two locales' answers never share an entry.
|
|
431
|
-
if (out.
|
|
432
|
+
if (out.ask && out.locale === undefined) {
|
|
432
433
|
const asked = locale ?? defaultLocale
|
|
433
434
|
if (asked) out = { ...out, locale: asked }
|
|
434
435
|
}
|
package/src/index.js
CHANGED
|
@@ -20,15 +20,8 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
20
20
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
+
// `resolveRecordsService` / `RECORDS_SERVICE` are NOT re-exported here: `./fetch-config.js`
|
|
24
|
+
// reads them and a consumer that needs them imports `@uniweb/core/records-service`.
|
|
32
25
|
export { resolveFetchConfigs } from './fetch-config.js'
|
|
33
26
|
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
34
27
|
// `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
|
|
@@ -52,7 +45,7 @@ export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
|
52
45
|
// (materialization, the `route:` bake) and by `@uniweb/runtime` (the fallback),
|
|
53
46
|
// which is what keeps the static and live lanes answering a query identically.
|
|
54
47
|
export { parseSort, sortRecords, sortToWire } from './sort.js'
|
|
55
|
-
export { fillRoutePattern, splitPathCapture, joinPathCapture } from './route-match.js'
|
|
48
|
+
export { fillRoutePattern, splitPathCapture, joinPathCapture, recordHandle, routeParamValue } from './route-match.js'
|
|
56
49
|
export { isRichSchema } from './schemas.js'
|
|
57
50
|
// ⛔ `Tracker` is NOT on the package entry. It is a FEATURE, not part of the
|
|
58
51
|
// object graph this package exists to define, and putting it here made every
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a site's queries are answered — the `records` service.
|
|
3
|
+
*
|
|
4
|
+
* ## The one idea
|
|
5
|
+
*
|
|
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:
|
|
8
|
+
*
|
|
9
|
+
* 1. **A host that answers questions** offers the `records` service — an
|
|
10
|
+
* address with a `{locale}` slot, which takes a POST carrying the whole
|
|
11
|
+
* query. It owns every segment of it; the runtime substitutes the one slot
|
|
12
|
+
* and sends the question. ⛔ The question is composed elsewhere
|
|
13
|
+
* (`fetch-config.js`); this file only says where it goes.
|
|
14
|
+
* 2. **Nobody** — and the answer is the artifact the build itself emitted,
|
|
15
|
+
* `/data/<name>.json`, which is not an address at all but a path in the
|
|
16
|
+
* site's own URL space.
|
|
17
|
+
*
|
|
18
|
+
* Absence of (1) is therefore not an error and not a decline: it falls THROUGH
|
|
19
|
+
* to (2). That is what makes a site with no backend the default rather than a
|
|
20
|
+
* special case.
|
|
21
|
+
*
|
|
22
|
+
* ## ⭐ It is a SERVICE, and that is the whole of its shape
|
|
23
|
+
*
|
|
24
|
+
* *[Diego, 2026-09-06: "`records` does sound like a service. It is the service
|
|
25
|
+
* of having a dynamic record provider."]* So it sits in `config.services`
|
|
26
|
+
* beside `search`, `submit` and `assistant`, is read with the same
|
|
27
|
+
* `readEndpoint` rule (string shorthand or `{ endpoint }`), and follows the
|
|
28
|
+
* same law: **presence is the switch.** A host that offers a records lane
|
|
29
|
+
* stamps the row; one that does not, does not.
|
|
30
|
+
*
|
|
31
|
+
* ⛔ **It did NOT used to be.** Until 2026-09-06 it was `config.records`, an
|
|
32
|
+
* object of its own whose only surviving key was `query` — a wrapper left over
|
|
33
|
+
* from the day there were two lanes to choose between. One host-stamped address
|
|
34
|
+
* read by two mechanisms was the accident; a service row is the shape it always
|
|
35
|
+
* had.
|
|
36
|
+
*
|
|
37
|
+
* ## ⛔ The HOST tier only — and the reason is mechanical, not a policy
|
|
38
|
+
*
|
|
39
|
+
* `resolveService` reads the site's authored tier first and the host's second.
|
|
40
|
+
* This does not, because it cannot: its caller `resolveFetchConfigs` is a leaf
|
|
41
|
+
* that runs in an SSR isolate over a plain content object with no `Website` to
|
|
42
|
+
* hand. It reads `config.services` directly, so the authored tier is not
|
|
43
|
+
* available here. ⇒ **A site does not declare its own record provider**, which
|
|
44
|
+
* is also the standing ruling — a third-party source is a foundation transport,
|
|
45
|
+
* not site-level config *(2026-09-04, the retired `fetcher:` vocabulary)*.
|
|
46
|
+
*
|
|
47
|
+
* ## ⛔ A pattern, not a base — and the reason is a deleted function
|
|
48
|
+
*
|
|
49
|
+
* A base assumes the layout is "root plus one segment". A pattern assumes
|
|
50
|
+
* nothing, so a host can carry a site id, a locale segment, a different root
|
|
51
|
+
* for records than for the site, or none of those, and move any of it without a
|
|
52
|
+
* framework release. This is the `config.assets.url` rule applied to records:
|
|
53
|
+
* the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's path
|
|
54
|
+
* layout, inside a published CLI — and it was deleted rather than
|
|
55
|
+
* parameterized. Substituting `{locale}` is the WHOLE of what this does.
|
|
56
|
+
*
|
|
57
|
+
* Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
|
|
58
|
+
* isolate can both import it.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
62
|
+
import { readEndpoint } from './services.js'
|
|
63
|
+
|
|
64
|
+
const warnedPatterns = new Set()
|
|
65
|
+
|
|
66
|
+
function warnOnce(key, message) {
|
|
67
|
+
if (warnedPatterns.has(key)) return
|
|
68
|
+
warnedPatterns.add(key)
|
|
69
|
+
console.warn(`[records-service] ${message}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Test seam — reset the once-per-pattern memo so suites do not leak. */
|
|
73
|
+
export function _resetRecordsServiceWarnings() {
|
|
74
|
+
warnedPatterns.clear()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The service name a host stamps to say it answers queries.
|
|
79
|
+
*
|
|
80
|
+
* One constant, changed in one place should it ever move.
|
|
81
|
+
*/
|
|
82
|
+
export const RECORDS_SERVICE = 'records'
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The address a query is asked at, substituted for one locale, or `null` when
|
|
86
|
+
* no host offers the service.
|
|
87
|
+
*
|
|
88
|
+
* A row present with no address is a host saying "not for this site" —
|
|
89
|
+
* indistinguishable, for a caller, from no row at all. Both fall through to the
|
|
90
|
+
* compiled artifact.
|
|
91
|
+
*
|
|
92
|
+
* The locale is a ROUTE SEGMENT there, never a query param: a request that
|
|
93
|
+
* cannot name one does not address the service at all.
|
|
94
|
+
*
|
|
95
|
+
* @param {Object|null} services - `config.services`
|
|
96
|
+
* @param {string|null} locale - the locale being rendered; required
|
|
97
|
+
* @returns {string|null}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveRecordsService(services, locale) {
|
|
100
|
+
if (!services || typeof services !== 'object' || Array.isArray(services)) return null
|
|
101
|
+
const endpoint = readEndpoint(services[RECORDS_SERVICE])
|
|
102
|
+
if (!endpoint) return null
|
|
103
|
+
if (typeof locale !== 'string' || locale.length === 0) return null
|
|
104
|
+
if (!endpoint.includes('{locale}')) {
|
|
105
|
+
warnOnce(
|
|
106
|
+
`records:${endpoint}`,
|
|
107
|
+
`config.services.${RECORDS_SERVICE} carries no {locale} placeholder; the service takes the ` +
|
|
108
|
+
`locale as a route segment. Ignoring it.`
|
|
109
|
+
)
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
return substitutePlaceholders(endpoint, { locale })
|
|
113
|
+
}
|
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 service
|
|
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/services.js
CHANGED
|
@@ -96,7 +96,7 @@ const ABSOLUTE_URL_RE = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i
|
|
|
96
96
|
* @param {*} declaration
|
|
97
97
|
* @returns {string} the endpoint, or '' when there is none
|
|
98
98
|
*/
|
|
99
|
-
function readEndpoint(declaration) {
|
|
99
|
+
export function readEndpoint(declaration) {
|
|
100
100
|
if (typeof declaration === 'string') return declaration.trim()
|
|
101
101
|
if (typeof declaration?.endpoint === 'string') return declaration.endpoint.trim()
|
|
102
102
|
return ''
|
package/src/sort.js
CHANGED
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
* entity store's refine-order sort — and two of them split on commas and honoured
|
|
8
8
|
* several keys while the one shipped wire dialect documented the same, so a site
|
|
9
9
|
* authoring `sort: order asc, title asc` worked on the static lane and would have
|
|
10
|
-
* been refused by the records
|
|
10
|
+
* been refused by the records service, which takes one key. The language is the
|
|
11
11
|
* INTERSECTION of what both lanes honour, so a comma is refused here rather than
|
|
12
12
|
* half-honoured somewhere.
|
|
13
13
|
*
|
|
14
|
-
* Author spelling, unchanged: `date`, `date asc`, `date desc`. The records
|
|
14
|
+
* Author spelling, unchanged: `date`, `date asc`, `date desc`. The records service's
|
|
15
15
|
* spelling is `date` / `-date`; `-date` is accepted on the way in so a value that
|
|
16
16
|
* came off the wire round-trips, and `sortToWire` produces it on the way out.
|
|
17
17
|
*
|
|
@@ -67,7 +67,7 @@ export function parseSort(sort) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* The
|
|
70
|
+
* The service's spelling of a sort: `date` ascending, `-date` descending.
|
|
71
71
|
*
|
|
72
72
|
* @param {string|{field:string, desc?:boolean}|null|undefined} sort
|
|
73
73
|
* @returns {string|null}
|
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
|
|
619
|
-
// lane that carries `
|
|
618
|
+
// path, as }` — while the store writes under the RESOLVED config: on the
|
|
619
|
+
// asked lane that carries `ask` 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
|
|
@@ -635,7 +635,7 @@ export default class Website {
|
|
|
635
635
|
locale: this.getActiveLocale(),
|
|
636
636
|
defaultLocale: this.getDefaultLocale(),
|
|
637
637
|
queries: this.config?.queries ?? null,
|
|
638
|
-
|
|
638
|
+
services: this.config?.services ?? null,
|
|
639
639
|
variables,
|
|
640
640
|
}).get(pluralSchema)
|
|
641
641
|
if (fetchConfig) {
|
|
@@ -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
|
+
// The service 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) {
|
package/src/query-address.js
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve a query request to an address the fetcher can call.
|
|
3
|
-
*
|
|
4
|
-
* ## The one idea
|
|
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:
|
|
9
|
-
*
|
|
10
|
-
* 1. **A host that serves records live** declares a pair of URL *patterns*
|
|
11
|
-
* at `config.records`. It owns every segment of them.
|
|
12
|
-
* 2. **Nobody** — and the answer is the artifact the build itself emitted,
|
|
13
|
-
* `/data/<name>.json`, which is not an address at all but a path in the
|
|
14
|
-
* site's own URL space.
|
|
15
|
-
*
|
|
16
|
-
* Absence of (1) is therefore not an error and not a decline: it falls THROUGH
|
|
17
|
-
* to (2). That is what makes a site with no backend the default rather than a
|
|
18
|
-
* special case, and it is why this does not go through `resolveService` — a
|
|
19
|
-
* service's absence means the site has no such feature and the caller draws
|
|
20
|
-
* nothing, which is right for `submit` and wrong here, where the fallback is a
|
|
21
|
-
* file the build knows it wrote.
|
|
22
|
-
*
|
|
23
|
-
* ## ⛔ Patterns, not a base — and the reason is a deleted function
|
|
24
|
-
*
|
|
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.
|
|
29
|
-
*
|
|
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.
|
|
36
|
-
*
|
|
37
|
-
* ⇒ Substituting `{path}` and `{param}` is the WHOLE of what this does.
|
|
38
|
-
*
|
|
39
|
-
* Zero-dependency beyond two sibling leaves, so the SSR pipeline and a Worker
|
|
40
|
-
* isolate can both import it.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
44
|
-
|
|
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
|
-
|
|
58
|
-
const warnedPatterns = new Set()
|
|
59
|
-
|
|
60
|
-
function warnOnce(key, message) {
|
|
61
|
-
if (warnedPatterns.has(key)) return
|
|
62
|
-
warnedPatterns.add(key)
|
|
63
|
-
console.warn(`[query-address] ${message}`)
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Test seam — reset the once-per-pattern memo so suites do not leak. */
|
|
67
|
-
export function _resetQueryAddressWarnings() {
|
|
68
|
-
warnedPatterns.clear()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Is this a usable lane declaration?
|
|
73
|
-
*
|
|
74
|
-
* A declaration present with no pattern is a host saying "not for this site" —
|
|
75
|
-
* indistinguishable, for a caller, from no declaration at all. Both fall
|
|
76
|
-
* through to the artifact.
|
|
77
|
-
*/
|
|
78
|
-
function readPattern(lane, key) {
|
|
79
|
-
if (!lane || typeof lane !== 'object' || Array.isArray(lane)) return null
|
|
80
|
-
const pattern = lane[key]
|
|
81
|
-
return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
|
|
82
|
-
}
|
|
83
|
-
|
|
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
|
-
|
|
113
|
-
/**
|
|
114
|
-
* The QUESTION door a host declares — a POST address with a `{locale}` slot —
|
|
115
|
-
* substituted for one locale, or `null` when the lane declares none.
|
|
116
|
-
*
|
|
117
|
-
* The stamp key is `config.records.query` — read here as a provisional spelling
|
|
118
|
-
* on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
|
|
119
|
-
* contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
|
|
120
|
-
* One constant, changed in one place should it ever move. Everything downstream
|
|
121
|
-
* wakes only when a host stamps it AND the payload carries the query's Model ref
|
|
122
|
-
* (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
|
|
123
|
-
* there, never a query param: a request that cannot name one does not address
|
|
124
|
-
* this door at all.
|
|
125
|
-
*
|
|
126
|
-
* @param {Object|null} lane - `config.records`
|
|
127
|
-
* @param {string|null} locale - the locale being rendered; required
|
|
128
|
-
* @returns {string|null}
|
|
129
|
-
*/
|
|
130
|
-
export const QUERY_DOOR_KEY = 'query'
|
|
131
|
-
|
|
132
|
-
export function resolveQueryDoor(lane, locale) {
|
|
133
|
-
const pattern = readPattern(lane, QUERY_DOOR_KEY)
|
|
134
|
-
if (!pattern) return null
|
|
135
|
-
if (typeof locale !== 'string' || locale.length === 0) return null
|
|
136
|
-
if (!pattern.includes('{locale}')) {
|
|
137
|
-
warnOnce(
|
|
138
|
-
`query:${pattern}`,
|
|
139
|
-
`config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
|
|
140
|
-
`locale as a route segment. Ignoring it.`
|
|
141
|
-
)
|
|
142
|
-
return null
|
|
143
|
-
}
|
|
144
|
-
return substitutePlaceholders(pattern, { locale })
|
|
145
|
-
}
|
|
146
|
-
|
|
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
|
-
}
|