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