@uniweb/core 0.19.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 +4 -3
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +72 -11
- package/src/entity-store.js +145 -31
- package/src/fetch-config.js +169 -4
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +7 -3
- 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 +94 -15
- package/src/request-styles/index.js +0 -59
- package/src/request-styles/json-body.js +0 -117
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
|
/**
|
|
@@ -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,32 +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
629
|
// ⛔ `as` is the binding key. This matched on `schema` alone until
|
|
595
630
|
// 2026-09-02 — which, once the alias went, would have found nothing:
|
|
596
631
|
// `items` stays `[]` and the page reports "Not found" for a record that
|
|
597
632
|
// exists. Silent, and on a visitor's page.
|
|
598
|
-
const
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
: (
|
|
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)
|
|
602
641
|
if (fetchConfig) {
|
|
603
|
-
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)
|
|
604
656
|
items = Array.isArray(cached?.data) ? cached.data : []
|
|
605
657
|
}
|
|
606
658
|
}
|
|
607
659
|
|
|
608
|
-
|
|
660
|
+
if (!currentItem) {
|
|
661
|
+
currentItem = items.find(item => String(item[paramName]) === String(paramValue)) ?? null
|
|
662
|
+
}
|
|
609
663
|
|
|
610
664
|
if (currentItem) {
|
|
611
665
|
if (currentItem.title) pageData.title = currentItem.title
|
|
@@ -624,7 +678,7 @@ export default class Website {
|
|
|
624
678
|
// { paramName, paramValue, schema }; the record reaches components via
|
|
625
679
|
// content.data, siblings via `fetch: { refine: true, detail: false }`).
|
|
626
680
|
// The local `currentItem`/`items` above drive title/description/notFound.
|
|
627
|
-
pageData._recordsLoaded = items.length > 0
|
|
681
|
+
pageData._recordsLoaded = items.length > 0 || currentItem !== null
|
|
628
682
|
}
|
|
629
683
|
|
|
630
684
|
// Create the page instance
|
|
@@ -814,6 +868,31 @@ export default class Website {
|
|
|
814
868
|
return resolvedHref
|
|
815
869
|
}
|
|
816
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
|
+
|
|
817
896
|
/**
|
|
818
897
|
* Resolve a `page:<stable_id>` detail-page reference (from a fetch config's
|
|
819
898
|
* `detailPage`) to a locale-specific route TEMPLATE, e.g. '/blog/:slug'. The
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Request style — how the default fetcher reshapes a normalized request
|
|
3
|
-
* into wire format: which operators become URL params, which go into a
|
|
4
|
-
* body, what envelope the response carries.
|
|
5
|
-
*
|
|
6
|
-
* One style ships: `json-body`, the framework's own conventions, and it
|
|
7
|
-
* is the only wire the default fetcher speaks. A backend with another
|
|
8
|
-
* dialect is reached through a named transport — shipped by the
|
|
9
|
-
* foundation, or by an extension the site selects per schema in
|
|
10
|
-
* `site.yml fetcher.transports` — never through a second built-in style.
|
|
11
|
-
* Two vendor dialects, `flat-query` and `strapi`, shipped here from
|
|
12
|
-
* `@uniweb/core` 0.7.1 and were removed: a third party's wire is not a
|
|
13
|
-
* framework concern, and core is loaded by every site and never
|
|
14
|
-
* tree-shaken.
|
|
15
|
-
*
|
|
16
|
-
* `site.yml fetcher.request.style` is still read, for one reason: a site
|
|
17
|
-
* that names a style the framework does not ship must be told. Falling
|
|
18
|
-
* back silently would send the default wire to a backend that does not
|
|
19
|
-
* speak it — the request succeeds and the data is wrong.
|
|
20
|
-
*
|
|
21
|
-
* Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { jsonBody } from './json-body.js'
|
|
25
|
-
|
|
26
|
-
const unknownStyleMessage = (name) =>
|
|
27
|
-
`[default-fetcher] unknown request style "${name}". The framework ships one wire, ` +
|
|
28
|
-
`"json-body"; a backend with a different dialect is reached through a named transport ` +
|
|
29
|
-
`(site.yml fetcher.transports), shipped by the foundation or by an extension.`
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Resolve the request style. No name, or `json-body`, returns the one
|
|
33
|
-
* shipped style. Any other name is a site declaring a wire dialect the
|
|
34
|
-
* framework does not ship: in dev this throws, so the site does not boot
|
|
35
|
-
* on the wrong wire; in production it logs an error once and falls back
|
|
36
|
-
* to `json-body`, so the site still renders.
|
|
37
|
-
*
|
|
38
|
-
* @param {string|undefined|null} name
|
|
39
|
-
* @param {{ dev?: boolean }} [options]
|
|
40
|
-
* @returns {Object} A style module.
|
|
41
|
-
* @throws {Error} in dev, on a name that is not `json-body`.
|
|
42
|
-
*/
|
|
43
|
-
export function resolveStyle(name, { dev = false } = {}) {
|
|
44
|
-
if (!name || name === jsonBody.name) return jsonBody
|
|
45
|
-
if (dev) {
|
|
46
|
-
const err = new Error(unknownStyleMessage(name))
|
|
47
|
-
err.code = 'UNKNOWN_REQUEST_STYLE'
|
|
48
|
-
throw err
|
|
49
|
-
}
|
|
50
|
-
if (!erroredUnknownStyles.has(name)) {
|
|
51
|
-
erroredUnknownStyles.add(name)
|
|
52
|
-
console.error(unknownStyleMessage(name) + ' Falling back to "json-body".')
|
|
53
|
-
}
|
|
54
|
-
return jsonBody
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const erroredUnknownStyles = new Set()
|
|
58
|
-
|
|
59
|
-
export { jsonBody }
|