@uniweb/core 0.19.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +79 -21
- package/src/entity-store.js +149 -35
- package/src/fetch-config.js +175 -27
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +11 -12
- package/src/page.js +6 -0
- package/src/query-address.js +51 -80
- package/src/route-match.js +196 -18
- package/src/sort.js +116 -0
- package/src/website.js +97 -15
- package/src/request-styles/index.js +0 -59
- package/src/request-styles/json-body.js +0 -117
package/src/query-address.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve a query request to
|
|
2
|
+
* Resolve a query request to the one address a host can declare for it.
|
|
3
3
|
*
|
|
4
4
|
* ## The one idea
|
|
5
5
|
*
|
|
6
|
-
* A site names a query; it never names where its records live. Where
|
|
7
|
-
*
|
|
8
|
-
* owners:
|
|
6
|
+
* A site names a query; it never names where its records live. Where they live
|
|
7
|
+
* is a **deployment** fact, and the two possible answers have different owners:
|
|
9
8
|
*
|
|
10
|
-
* 1. **A host that
|
|
11
|
-
*
|
|
9
|
+
* 1. **A host that answers questions** declares a QUESTION DOOR at
|
|
10
|
+
* `config.records.query` — a POST address with a `{locale}` slot. It owns
|
|
11
|
+
* every segment of it; the runtime substitutes the one slot and sends the
|
|
12
|
+
* whole query. ⛔ The question is composed elsewhere (`fetch-config.js`);
|
|
13
|
+
* this file only says where it goes.
|
|
12
14
|
* 2. **Nobody** — and the answer is the artifact the build itself emitted,
|
|
13
15
|
* `/data/<name>.json`, which is not an address at all but a path in the
|
|
14
16
|
* site's own URL space.
|
|
@@ -20,41 +22,34 @@
|
|
|
20
22
|
* nothing, which is right for `submit` and wrong here, where the fallback is a
|
|
21
23
|
* file the build knows it wrote.
|
|
22
24
|
*
|
|
23
|
-
* ## ⛔
|
|
25
|
+
* ## ⛔ The ADDRESS door is retired (2026-09-04) — do not bring it back
|
|
24
26
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Until that day this file also read two URL PATTERNS off the same stamp —
|
|
28
|
+
* `list` (`{path}`) and `record` (`{param}`) — for a GET lane the runtime
|
|
29
|
+
* evaluated the query over locally. It went by ruling, with no hosted site to
|
|
30
|
+
* protect: two lanes on one host answered one query two ways (an operator the
|
|
31
|
+
* door refuses was honoured locally on the GET lane), the precedence between
|
|
32
|
+
* them was where the failure lived, and the address was composed from the
|
|
33
|
+
* query's NAME, which is not a folder path. A query is a question; a host that
|
|
34
|
+
* cannot answer one is a host with no records lane, and its site reads the
|
|
35
|
+
* compiled file. A host's `list` / `record` / `envelope` stamps are not read.
|
|
29
36
|
*
|
|
30
|
-
*
|
|
31
|
-
* because the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
|
|
32
|
-
* path layout, inside a published CLI, on a release cadence the backend could
|
|
33
|
-
* not move. It was deleted rather than parameterized. Composing a segment of
|
|
34
|
-
* our own here would rebuild exactly that coupling, on a lane where the wrong
|
|
35
|
-
* answer is *stale or missing content* rather than a visible 404.
|
|
37
|
+
* ## ⛔ A pattern, not a base — and the reason is a deleted function
|
|
36
38
|
*
|
|
37
|
-
*
|
|
39
|
+
* A base assumes the layout is "root plus one segment". A pattern assumes
|
|
40
|
+
* nothing, so a host can carry a site id, a locale segment, a different root
|
|
41
|
+
* for the door than for the site, or none of those, and move any of it
|
|
42
|
+
* without a framework release. This is the `config.assets.url` rule applied to
|
|
43
|
+
* records: the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
|
|
44
|
+
* path layout, inside a published CLI — and it was deleted rather than
|
|
45
|
+
* parameterized. Substituting `{locale}` is the WHOLE of what this does.
|
|
38
46
|
*
|
|
39
|
-
* Zero-dependency beyond
|
|
47
|
+
* Zero-dependency beyond one sibling leaf, so the SSR pipeline and a Worker
|
|
40
48
|
* isolate can both import it.
|
|
41
49
|
*/
|
|
42
50
|
|
|
43
51
|
import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
44
52
|
|
|
45
|
-
/**
|
|
46
|
-
* The placeholder a list pattern must carry.
|
|
47
|
-
*
|
|
48
|
-
* ⛔ IT IS `{path}`, NOT `{query}`, AND THAT IS NOT COSMETIC. A *query* is
|
|
49
|
-
* framework's own build concept — a named set our build compiles to one file. A host
|
|
50
|
-
* serving records has no such thing: it has content organised somewhere, and what we
|
|
51
|
-
* substitute is a **path** to it. Naming the slot for our file vocabulary put that
|
|
52
|
-
* vocabulary into a string a HOST writes, which makes them reason in our shape.
|
|
53
|
-
* See `kb/framework/architecture/backend-boundary.md` §2.
|
|
54
|
-
*/
|
|
55
|
-
const PATH_SLOT = '{path}'
|
|
56
|
-
/** The placeholder a record pattern must carry to address a specific record. */
|
|
57
|
-
const PARAM_SLOT = '{param}'
|
|
58
53
|
|
|
59
54
|
const warnedPatterns = new Set()
|
|
60
55
|
|
|
@@ -82,62 +77,38 @@ function readPattern(lane, key) {
|
|
|
82
77
|
return typeof pattern === 'string' && pattern.length > 0 ? pattern : null
|
|
83
78
|
}
|
|
84
79
|
|
|
85
|
-
/**
|
|
86
|
-
* The address for a whole query's records, or `null` to fall through to the artifact.
|
|
87
|
-
*
|
|
88
|
-
* ⚠️ A pattern that does not carry `{path}` is REFUSED rather than used.
|
|
89
|
-
* Substituting nothing would yield one identical URL for every query on the
|
|
90
|
-
* site — every schema reading the same records, with a 200 on each request. That
|
|
91
|
-
* is the failure this check exists for; an unusable pattern must degrade to the
|
|
92
|
-
* artifact, which is at least correct.
|
|
93
|
-
*
|
|
94
|
-
* @param {string} query - the query's authored name (the wiring key).
|
|
95
|
-
* @param {Object|null} lane - `config.records`.
|
|
96
|
-
* @returns {string|null} the address, or null when nothing usable is declared.
|
|
97
|
-
*/
|
|
98
|
-
export function resolveQueryAddress(query, lane) {
|
|
99
|
-
if (typeof query !== 'string' || query.length === 0) return null
|
|
100
|
-
const pattern = readPattern(lane, 'list')
|
|
101
|
-
if (!pattern) return null
|
|
102
|
-
if (!pattern.includes(PATH_SLOT)) {
|
|
103
|
-
warnOnce(
|
|
104
|
-
`list:${pattern}`,
|
|
105
|
-
`config.records.list carries no ${PATH_SLOT} placeholder, so every ` +
|
|
106
|
-
`query would resolve to the same address. Ignoring it and reading the ` +
|
|
107
|
-
`compiled file instead.`
|
|
108
|
-
)
|
|
109
|
-
return null
|
|
110
|
-
}
|
|
111
|
-
return substitutePlaceholders(pattern, { path: query })
|
|
112
|
-
}
|
|
113
80
|
|
|
114
81
|
/**
|
|
115
|
-
* The
|
|
116
|
-
*
|
|
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.
|
|
117
84
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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.
|
|
122
93
|
*
|
|
123
|
-
* @param {
|
|
124
|
-
* @param {
|
|
125
|
-
* @returns {string|null}
|
|
94
|
+
* @param {Object|null} lane - `config.records`
|
|
95
|
+
* @param {string|null} locale - the locale being rendered; required
|
|
96
|
+
* @returns {string|null}
|
|
126
97
|
*/
|
|
127
|
-
export
|
|
128
|
-
|
|
129
|
-
|
|
98
|
+
export const QUERY_DOOR_KEY = 'query'
|
|
99
|
+
|
|
100
|
+
export function resolveQueryDoor(lane, locale) {
|
|
101
|
+
const pattern = readPattern(lane, QUERY_DOOR_KEY)
|
|
130
102
|
if (!pattern) return null
|
|
131
|
-
if (
|
|
103
|
+
if (typeof locale !== 'string' || locale.length === 0) return null
|
|
104
|
+
if (!pattern.includes('{locale}')) {
|
|
132
105
|
warnOnce(
|
|
133
|
-
`
|
|
134
|
-
`config.records
|
|
135
|
-
`
|
|
136
|
-
`file instead.`
|
|
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.`
|
|
137
109
|
)
|
|
138
110
|
return null
|
|
139
111
|
}
|
|
140
|
-
|
|
141
|
-
// dynamic-route resolution that owns it.
|
|
142
|
-
return substitutePlaceholders(pattern, { path: query })
|
|
112
|
+
return substitutePlaceholders(pattern, { locale })
|
|
143
113
|
}
|
|
114
|
+
|
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,159 @@ 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
|
+
* A record's PLACEMENT HANDLE — the segment its folder entry is named by, which is
|
|
243
|
+
* what a `[slug]` route (or the last segment of a `[...path]` one) matches.
|
|
244
|
+
*
|
|
245
|
+
* ⭐ Two lanes spell it differently and mean one thing. A host's records door
|
|
246
|
+
* serves the entry's handle as `$name` — `$`-namespaced because a Model may
|
|
247
|
+
* declare its own `name` or `slug` field (five of eight seeded briefs do), and
|
|
248
|
+
* the placement must not be shadowed by one. The file lane derives it from the
|
|
249
|
+
* source filename and calls it `slug`. `$name` wins when present: on a live
|
|
250
|
+
* record a Model field named `slug` is the author's data, not the placement.
|
|
251
|
+
*
|
|
252
|
+
* @param {Object} record
|
|
253
|
+
* @returns {string|undefined}
|
|
254
|
+
*/
|
|
255
|
+
export function recordHandle(record) {
|
|
256
|
+
if (!record || typeof record !== 'object') return undefined
|
|
257
|
+
const name = record.$name
|
|
258
|
+
if (typeof name === 'string' && name.length) return name
|
|
259
|
+
return record.slug
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The value a record carries for a route param. `slug` — the default param, and
|
|
264
|
+
* the last segment of a `[...path]` route — is the placement handle
|
|
265
|
+
* (`recordHandle`); any other param is a field the site chose to route by.
|
|
266
|
+
*
|
|
267
|
+
* ⛔ Every reader that matches a delivered record to a route param goes through
|
|
268
|
+
* this — the entity store, the website's dynamic page, the kit's detail hook,
|
|
269
|
+
* the href encoder below. Until 2026-09-04 each read `item[paramName]` directly,
|
|
270
|
+
* so a record served with `$name` and no `slug` matched nothing: a template page
|
|
271
|
+
* on a live lane rendered `[]` and a list linked to no record.
|
|
272
|
+
*
|
|
273
|
+
* @param {Object} record
|
|
274
|
+
* @param {string} paramName
|
|
275
|
+
* @returns {*}
|
|
276
|
+
*/
|
|
277
|
+
export function routeParamValue(record, paramName) {
|
|
278
|
+
if (!record || typeof record !== 'object') return undefined
|
|
279
|
+
if (paramName === 'slug') return recordHandle(record)
|
|
280
|
+
return record[paramName]
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Fill a route pattern's params from a record — the ONE encoder for a record's href.
|
|
285
|
+
*
|
|
286
|
+
* `/blog/:slug` + `{ slug: 'a post' }` → `/blog/a%20post`. Every value is
|
|
287
|
+
* `encodeURIComponent`-ed, because the output is a URL: it is compared against
|
|
288
|
+
* `location.pathname` (`isActive(item.route)`) and matched back through
|
|
289
|
+
* `matchDynamicRoute`, which decodes what it captures. A raw interpolation and an
|
|
290
|
+
* encoded one compare unequal on the first slug with a space — and they used to
|
|
291
|
+
* both exist: the build baked `${base}/${item.slug}` raw into `/data/*.json` while
|
|
292
|
+
* the runtime interpolated with encoding, and which one a site got was
|
|
293
|
+
* lane-dependent (measured 2026-09-04). Two producers of one field now call this.
|
|
294
|
+
*
|
|
295
|
+
* ⛔ NOT for a file path. The SSG writes `dist/<route>/index.html` from the DECODED
|
|
296
|
+
* value on purpose — a server decodes the request path before looking a file up,
|
|
297
|
+
* so `Ada%20Lovelace` on disk would 404 for `/team/Ada%20Lovelace`. A URL and a
|
|
298
|
+
* filesystem path are different jobs that are supposed to encode differently.
|
|
299
|
+
*
|
|
300
|
+
* Returns `null` — never a partial href — when a param has no value on the
|
|
301
|
+
* record, so a caller degrades to "no link" rather than emitting a broken one.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} pattern - a route pattern with `:param` placeholders
|
|
304
|
+
* @param {Object} values - a record, read by param name
|
|
305
|
+
* @returns {string|null}
|
|
306
|
+
*/
|
|
307
|
+
export function fillRoutePattern(pattern, values) {
|
|
308
|
+
if (typeof pattern !== 'string' || !values || typeof values !== 'object') return null
|
|
309
|
+
let missing = false
|
|
310
|
+
let head = pattern
|
|
311
|
+
let tailHref = ''
|
|
312
|
+
const tail = pattern.match(CATCH_ALL)
|
|
313
|
+
if (tail) {
|
|
314
|
+
// A catch-all is filled from the record's placement and handle — the split
|
|
315
|
+
// rule in reverse (`joinPathCapture`) — with each SEGMENT encoded and the
|
|
316
|
+
// slashes between them kept as structure. `dir` is the placement; a record
|
|
317
|
+
// carries it as `path` (the folder `records.yml` put it in), which is why
|
|
318
|
+
// `path` here is read as the DIRECTORY and never as a composed capture.
|
|
319
|
+
const handle = recordHandle(values)
|
|
320
|
+
if (joinPathCapture({ dir: values.dir ?? values.path, slug: handle }) === null) return null
|
|
321
|
+
const dir = String(values.dir ?? values.path ?? '')
|
|
322
|
+
const segments = dir.split('/').filter(Boolean).map((seg) => encodeURIComponent(seg))
|
|
323
|
+
// The handle is ONE segment whatever it contains: a `/` inside it is a value.
|
|
324
|
+
segments.push(encodeURIComponent(String(handle)))
|
|
325
|
+
tailHref = '/' + segments.join('/')
|
|
326
|
+
head = pattern.slice(0, tail.index)
|
|
327
|
+
}
|
|
328
|
+
const href = head.replace(new RegExp(`:(${PARAM_NAME})`, 'g'), (_, name) => {
|
|
329
|
+
const value = routeParamValue(values, name)
|
|
330
|
+
if (value === undefined || value === null || value === '') {
|
|
331
|
+
missing = true
|
|
332
|
+
return ''
|
|
333
|
+
}
|
|
334
|
+
return encodeURIComponent(String(value))
|
|
335
|
+
})
|
|
336
|
+
return missing ? null : href + tailHref
|
|
337
|
+
}
|
|
338
|
+
|
|
161
339
|
/**
|
|
162
340
|
* Strip a locale prefix from a route.
|
|
163
341
|
*
|
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
|
+
}
|