@uniweb/core 0.21.0 → 0.23.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/README.md +1 -1
- package/package.json +4 -5
- package/src/datastore.js +3 -3
- package/src/detail-url.js +13 -13
- package/src/entity-store.js +9 -8
- package/src/fetch-config.js +69 -51
- package/src/index.js +2 -4
- package/src/records-service.js +113 -0
- package/src/route-match.js +1 -1
- package/src/services.js +1 -1
- package/src/sort.js +3 -3
- package/src/website.js +4 -22
- package/src/query-address.js +0 -114
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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",
|
|
@@ -44,8 +43,8 @@
|
|
|
44
43
|
"vitest": "^4.1.7"
|
|
45
44
|
},
|
|
46
45
|
"dependencies": {
|
|
47
|
-
"@uniweb/
|
|
48
|
-
"@uniweb/
|
|
46
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
47
|
+
"@uniweb/theming": "^0.1.15"
|
|
49
48
|
},
|
|
50
49
|
"scripts": {
|
|
51
50
|
"test": "vitest run"
|
package/src/datastore.js
CHANGED
|
@@ -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
40
|
* `locale` is hashed on both when present: two locales' answers must not share
|
|
41
|
-
* an entry, and
|
|
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
|
package/src/detail-url.js
CHANGED
|
@@ -91,12 +91,12 @@ function paramContext(paramName, paramValue, record) {
|
|
|
91
91
|
*/
|
|
92
92
|
/**
|
|
93
93
|
* The key a route param narrows a QUESTION by — the entry's own handle, which
|
|
94
|
-
*
|
|
94
|
+
* the records service guarantees unique among siblings (the records contract
|
|
95
95
|
* §1b: `$name`, "addressed AND filtered"). ⚠️ One constant, because the spelling
|
|
96
96
|
* moved four times in one day (`path_segment` → `$slug` → `$name` → `meta::name`
|
|
97
|
-
* → `$name`); the
|
|
98
|
-
* to change if it moves again. Not read by the file lane
|
|
99
|
-
*
|
|
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]`).
|
|
100
100
|
*/
|
|
101
101
|
export const ROUTE_HANDLE_KEY = '$name'
|
|
102
102
|
|
|
@@ -106,13 +106,13 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
106
106
|
const { paramName, paramValue, record = null } = dynamicContext
|
|
107
107
|
if (!paramName || paramValue === undefined) return null
|
|
108
108
|
|
|
109
|
-
// ⭐
|
|
110
|
-
// entry by its handle and asked in full — the list page and the detail
|
|
111
|
-
// are the same query, differing only by whether the parameter is bound
|
|
112
|
-
// (the records
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
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) {
|
|
116
116
|
const { sort, limit, detail: _detail, ...rest } = queryConfig
|
|
117
117
|
return {
|
|
118
118
|
...rest,
|
|
@@ -124,14 +124,14 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
124
124
|
|
|
125
125
|
// Two address kinds, and the detail request comes back as the SAME kind as
|
|
126
126
|
// the list: a `url` the author wrote stays a `url`, a compiled `path` stays a
|
|
127
|
-
// `path`.
|
|
127
|
+
// `path`.
|
|
128
128
|
const baseUrl = queryConfig.url || queryConfig.path
|
|
129
129
|
if (!baseUrl) return null
|
|
130
130
|
const addressKey = queryConfig.url ? 'url' : 'path'
|
|
131
131
|
|
|
132
132
|
// What every detail config carries beside its address:
|
|
133
133
|
// `as` — the binding key, so the record lands where the list did;
|
|
134
|
-
// `query` — the query it is one record of (identity
|
|
134
|
+
// `query` — the query it is one record of (identity when asked);
|
|
135
135
|
// `depth: 'full'` — what it asks for, and what the record index files it as;
|
|
136
136
|
// `dynamicContext` — the route param, which the default fetcher already keys
|
|
137
137
|
// a SINGLE-RECORD response on (`envelope.item`, body
|
package/src/entity-store.js
CHANGED
|
@@ -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
|
)
|
|
@@ -259,8 +260,8 @@ export default class EntityStore {
|
|
|
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) {
|
|
@@ -390,8 +391,8 @@ export default class EntityStore {
|
|
|
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
|
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,12 +115,12 @@ 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
125
|
|
|
108
126
|
// ⛔ **`config.queries` is keyed by QUERY NAME, so look it up by the query.**
|
|
@@ -141,14 +159,15 @@ function applyDeferredDetail(cfg, queries, records) {
|
|
|
141
159
|
* The author names a query; this decides where its records live, and there are
|
|
142
160
|
* exactly two answers:
|
|
143
161
|
*
|
|
144
|
-
* - a host
|
|
145
|
-
* the whole query goes to it (`schema` from the
|
|
146
|
-
*
|
|
147
|
-
*
|
|
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.
|
|
148
167
|
*
|
|
149
|
-
* ⛔ There is no third answer
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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.
|
|
152
171
|
*
|
|
153
172
|
* ⭐ The second is not a fallback in the apologetic sense. It is the answer for
|
|
154
173
|
* every site with no backend, which is the framework's default rather than a
|
|
@@ -161,32 +180,31 @@ function applyDeferredDetail(cfg, queries, records) {
|
|
|
161
180
|
* `query`, ignoring any `path`), so the two agree rather than disagreeing on a
|
|
162
181
|
* shape nobody hand-writes.
|
|
163
182
|
*/
|
|
164
|
-
function resolveQuerySource(cfg,
|
|
183
|
+
function resolveQuerySource(cfg, services, { queries = null, locale = null, defaultLocale = null } = {}) {
|
|
165
184
|
if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
|
|
166
185
|
|
|
167
|
-
// ⭐ THE
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
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) {
|
|
176
194
|
const decl = queries && typeof queries === 'object' ? queries[cfg.query] : null
|
|
177
195
|
const schema = typeof decl?.schema === 'string' && decl.schema ? decl.schema : null
|
|
178
196
|
// Drop the transitional `path`: two addresses on one request is an
|
|
179
197
|
// ambiguity the fetcher would have to break by accident of field order.
|
|
180
198
|
const { path, url, ...rest } = cfg
|
|
181
199
|
if (!schema) {
|
|
182
|
-
// ⛔ LOUD, not a fallthrough. A payload that
|
|
200
|
+
// ⛔ LOUD, not a fallthrough. A payload that offers the service and carries no
|
|
183
201
|
// Model ref for the query cannot ask, and reading the compiled file
|
|
184
202
|
// instead would turn a producer defect into a 404 that names the wrong
|
|
185
|
-
// thing. The fetcher refuses
|
|
203
|
+
// thing. The fetcher refuses an asked request with no `schema` before any
|
|
186
204
|
// request is made, and the block's `dataError` says exactly this.
|
|
187
|
-
return { ...rest,
|
|
205
|
+
return { ...rest, ask, schema: null }
|
|
188
206
|
}
|
|
189
|
-
const asked = { ...rest,
|
|
207
|
+
const asked = { ...rest, ask, schema }
|
|
190
208
|
// A saved query's own narrowing applies unless the fetch overrides it.
|
|
191
209
|
if (asked.scope === undefined && typeof decl.scope === 'string') asked.scope = decl.scope
|
|
192
210
|
if (asked.where === undefined && decl.where && typeof decl.where === 'object') asked.where = decl.where
|
|
@@ -248,9 +266,9 @@ function bindingKey(cfg) {
|
|
|
248
266
|
* @param {string|null} [options.locale] - the locale being rendered
|
|
249
267
|
* @param {string|null} [options.defaultLocale] - the site's default locale
|
|
250
268
|
* @param {Object|null} [options.queries] - the site's `config.queries`
|
|
251
|
-
* @param {Object|null} [options.
|
|
252
|
-
* live-records lane
|
|
253
|
-
* 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.
|
|
254
272
|
* @param {Object|null} [options.variables] - the route's variables on a template
|
|
255
273
|
* page (`{ path, dir, slug }` under `[...path]`, the capture under `[slug]`);
|
|
256
274
|
* a `:path` / `:dir` / `:slug` placeholder in `where:` or `scope:` binds to
|
|
@@ -263,7 +281,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
263
281
|
locale = null,
|
|
264
282
|
defaultLocale = null,
|
|
265
283
|
queries = null,
|
|
266
|
-
|
|
284
|
+
services = null,
|
|
267
285
|
variables = null,
|
|
268
286
|
} = options
|
|
269
287
|
|
|
@@ -280,10 +298,10 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
280
298
|
if (!collectAll && !schemas.includes(key)) continue
|
|
281
299
|
// Address first: localization and deferred-detail both key on `path`,
|
|
282
300
|
// which a query ref does not have until this runs.
|
|
283
|
-
const sourced = resolveQuerySource(cfg,
|
|
301
|
+
const sourced = resolveQuerySource(cfg, services, { queries, locale, defaultLocale })
|
|
284
302
|
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
285
303
|
const bound = foldScope(bindRouteVariables(localized, variables))
|
|
286
|
-
configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries
|
|
304
|
+
configs.set(key, stampDepthAndLocale(applyDeferredDetail(bound, queries), locale, defaultLocale))
|
|
287
305
|
}
|
|
288
306
|
}
|
|
289
307
|
|
|
@@ -305,8 +323,8 @@ const ROUTE_VARIABLE = /^:(path|dir|slug)$/
|
|
|
305
323
|
* the list page and the detail page: `where: { tag: :dir }` narrows on
|
|
306
324
|
* `/blog/rust/my-post` and vanishes on `/blog`, where there is no `:dir`. A
|
|
307
325
|
* variable bound to an empty string (`:dir` on a single-segment capture) is
|
|
308
|
-
* bound, and binds the empty value.
|
|
309
|
-
* 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).
|
|
310
328
|
*
|
|
311
329
|
* ⚠️ The price, stated where it is paid: a MISSPELLED variable is byte-identical
|
|
312
330
|
* to an intentional list page. Only an authoring surface can catch that; this
|
|
@@ -372,17 +390,17 @@ function bindWhere(where, variables) {
|
|
|
372
390
|
}
|
|
373
391
|
|
|
374
392
|
/**
|
|
375
|
-
* `scope:` on a lane that cannot be ASKED — the compiled file
|
|
376
|
-
*
|
|
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
|
|
377
395
|
* `path` is the folder `records.yml` placed it in, and the evaluator's `under`
|
|
378
396
|
* is segment-aware containment. Folding it keeps the language the INTERSECTION
|
|
379
397
|
* of both lanes: an author
|
|
380
398
|
* writes `scope: :dir` once and it means the same branch on a static site and
|
|
381
|
-
* on a
|
|
382
|
-
*
|
|
399
|
+
* on a service that takes `scope` natively. A config carrying `ask` keeps
|
|
400
|
+
* `scope` as the service's own field.
|
|
383
401
|
*/
|
|
384
402
|
function foldScope(cfg) {
|
|
385
|
-
if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.
|
|
403
|
+
if (typeof cfg.scope !== 'string' || cfg.scope === '' || cfg.ask) return cfg
|
|
386
404
|
const { scope, ...rest } = cfg
|
|
387
405
|
const under = { path: { under: scope } }
|
|
388
406
|
const where = cfg.where && typeof cfg.where === 'object' && Object.keys(cfg.where).length
|
|
@@ -398,20 +416,20 @@ function foldScope(cfg) {
|
|
|
398
416
|
* a list with a separate record address is a list of partial records: a live
|
|
399
417
|
* lane answers a list at brief depth and a record in full, and a `deferred:`
|
|
400
418
|
* query's compiled file is the stripped list. `full` otherwise. An explicit
|
|
401
|
-
* `depth` on the config wins (
|
|
419
|
+
* `depth` on the config wins (the records service's client sets it).
|
|
402
420
|
*
|
|
403
|
-
* `locale` — stamped on
|
|
404
|
-
* locale (`/fr/data/…`);
|
|
405
|
-
* two locales' answers must not share a cache entry.
|
|
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.
|
|
406
424
|
*/
|
|
407
425
|
function stampDepthAndLocale(cfg, locale, defaultLocale) {
|
|
408
426
|
let out = cfg
|
|
409
427
|
if (out.depth !== 'brief' && out.depth !== 'full') {
|
|
410
428
|
out = { ...out, depth: out.detail ? 'brief' : 'full' }
|
|
411
429
|
}
|
|
412
|
-
//
|
|
430
|
+
// The service is asked in exactly one locale — it is in the route — so the config
|
|
413
431
|
// carries it whatever the locale is; two locales' answers never share an entry.
|
|
414
|
-
if (out.
|
|
432
|
+
if (out.ask && out.locale === undefined) {
|
|
415
433
|
const asked = locale ?? defaultLocale
|
|
416
434
|
if (asked) out = { ...out, locale: asked }
|
|
417
435
|
}
|
package/src/index.js
CHANGED
|
@@ -20,10 +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
|
-
// reads them and a consumer that needs them imports `@uniweb/core/
|
|
25
|
-
// (The address door's `resolveQueryAddress` / `resolveRecordAddressPattern` were
|
|
26
|
-
// deleted 2026-09-04 with the lane they addressed.)
|
|
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`.
|
|
27
25
|
export { resolveFetchConfigs } from './fetch-config.js'
|
|
28
26
|
export { buildDetailConfig, ROUTE_HANDLE_KEY } from './detail-url.js'
|
|
29
27
|
// `isWildcardLanguages` is likewise internal — `./locale-config.js` reads it
|
|
@@ -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
|
@@ -242,7 +242,7 @@ export function joinPathCapture({ dir, slug } = {}) {
|
|
|
242
242
|
* A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
|
|
243
243
|
* what a `[slug]` route (or the last segment of a `[...path]` one) matches.
|
|
244
244
|
*
|
|
245
|
-
* ⭐ Two lanes spell it differently and mean one thing. A host's records
|
|
245
|
+
* ⭐ Two lanes spell it differently and mean one thing. A host's records service
|
|
246
246
|
* serves the entry's handle as `$name` — `$`-namespaced because a Model may
|
|
247
247
|
* declare its own `name` or `slug` field (five of eight seeded briefs do), and
|
|
248
248
|
* the placement must not be shadowed by one. The file lane derives it from the
|
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
|
@@ -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,7 +649,7 @@ 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
|
-
//
|
|
652
|
+
// The service answers the record question as a list of one (a question's
|
|
653
653
|
// answer is always a list); a per-record file answers the bare record.
|
|
654
654
|
const raw = detailCached?.data
|
|
655
655
|
const record = Array.isArray(raw) ? raw[0] : raw
|
|
@@ -1231,24 +1231,6 @@ export default class Website {
|
|
|
1231
1231
|
return `${this.basePath || ''}${path}`
|
|
1232
1232
|
}
|
|
1233
1233
|
|
|
1234
|
-
/**
|
|
1235
|
-
* Get search data for all pages
|
|
1236
|
-
* @deprecated Use getSearchConfig() and fetch the search index instead
|
|
1237
|
-
*/
|
|
1238
|
-
getSearchData() {
|
|
1239
|
-
return this.pages.map((page) => ({
|
|
1240
|
-
id: page.id,
|
|
1241
|
-
title: page.title,
|
|
1242
|
-
href: page.route,
|
|
1243
|
-
route: page.route,
|
|
1244
|
-
description: page.description,
|
|
1245
|
-
content: page
|
|
1246
|
-
.getPageBlocks()
|
|
1247
|
-
.map((b) => b.title)
|
|
1248
|
-
.filter(Boolean)
|
|
1249
|
-
.join('\n')
|
|
1250
|
-
}))
|
|
1251
|
-
}
|
|
1252
1234
|
|
|
1253
1235
|
// ─────────────────────────────────────────────────────────────────
|
|
1254
1236
|
// Page Hierarchy API (for navigation components)
|
package/src/query-address.js
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Resolve a query request to the one address a host can declare for it.
|
|
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** 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.
|
|
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, and it is why this does not go through `resolveService` — a
|
|
21
|
-
* service's absence means the site has no such feature and the caller draws
|
|
22
|
-
* nothing, which is right for `submit` and wrong here, where the fallback is a
|
|
23
|
-
* file the build knows it wrote.
|
|
24
|
-
*
|
|
25
|
-
* ## ⛔ The ADDRESS door is retired (2026-09-04) — do not bring it back
|
|
26
|
-
*
|
|
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.
|
|
36
|
-
*
|
|
37
|
-
* ## ⛔ A pattern, not a base — and the reason is a deleted function
|
|
38
|
-
*
|
|
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.
|
|
46
|
-
*
|
|
47
|
-
* Zero-dependency beyond one sibling leaf, so the SSR pipeline and a Worker
|
|
48
|
-
* isolate can both import it.
|
|
49
|
-
*/
|
|
50
|
-
|
|
51
|
-
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const warnedPatterns = new Set()
|
|
55
|
-
|
|
56
|
-
function warnOnce(key, message) {
|
|
57
|
-
if (warnedPatterns.has(key)) return
|
|
58
|
-
warnedPatterns.add(key)
|
|
59
|
-
console.warn(`[query-address] ${message}`)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Test seam — reset the once-per-pattern memo so suites do not leak. */
|
|
63
|
-
export function _resetQueryAddressWarnings() {
|
|
64
|
-
warnedPatterns.clear()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Is this a usable lane declaration?
|
|
69
|
-
*
|
|
70
|
-
* A declaration present with no pattern is a host saying "not for this site" —
|
|
71
|
-
* indistinguishable, for a caller, from no declaration at all. Both fall
|
|
72
|
-
* through to the artifact.
|
|
73
|
-
*/
|
|
74
|
-
function readPattern(lane, key) {
|
|
75
|
-
if (!lane || typeof lane !== 'object' || Array.isArray(lane)) return null
|
|
76
|
-
const pattern = lane[key]
|
|
77
|
-
return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* The QUESTION door a host declares — a POST address with a `{locale}` slot —
|
|
83
|
-
* substituted for one locale, or `null` when the lane declares none.
|
|
84
|
-
*
|
|
85
|
-
* The stamp key is `config.records.query` — read here as a provisional spelling
|
|
86
|
-
* on 2026-09-04 and NAMED THE SAME DAY by the door's owner (their site-records
|
|
87
|
-
* contract, §11.4: stamped since 2026-09-04, value `/_records/_query/{locale}`).
|
|
88
|
-
* One constant, changed in one place should it ever move. Everything downstream
|
|
89
|
-
* wakes only when a host stamps it AND the payload carries the query's Model ref
|
|
90
|
-
* (`config.queries`), and stays dark otherwise. The locale is a ROUTE SEGMENT
|
|
91
|
-
* there, never a query param: a request that cannot name one does not address
|
|
92
|
-
* this door at all.
|
|
93
|
-
*
|
|
94
|
-
* @param {Object|null} lane - `config.records`
|
|
95
|
-
* @param {string|null} locale - the locale being rendered; required
|
|
96
|
-
* @returns {string|null}
|
|
97
|
-
*/
|
|
98
|
-
export const QUERY_DOOR_KEY = 'query'
|
|
99
|
-
|
|
100
|
-
export function resolveQueryDoor(lane, locale) {
|
|
101
|
-
const pattern = readPattern(lane, QUERY_DOOR_KEY)
|
|
102
|
-
if (!pattern) return null
|
|
103
|
-
if (typeof locale !== 'string' || locale.length === 0) return null
|
|
104
|
-
if (!pattern.includes('{locale}')) {
|
|
105
|
-
warnOnce(
|
|
106
|
-
`query:${pattern}`,
|
|
107
|
-
`config.records.${QUERY_DOOR_KEY} carries no {locale} placeholder; the door takes the ` +
|
|
108
|
-
`locale as a route segment. Ignoring it.`
|
|
109
|
-
)
|
|
110
|
-
return null
|
|
111
|
-
}
|
|
112
|
-
return substitutePlaceholders(pattern, { locale })
|
|
113
|
-
}
|
|
114
|
-
|