@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.
@@ -1,14 +1,16 @@
1
1
  /**
2
- * Resolve a query request to an address the fetcher can call.
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
- * it lives is a **deployment** fact, and the two possible answers have different
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 serves records live** declares a pair of URL *patterns*
11
- * at `config.records`. It owns every segment of them.
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
- * ## ⛔ Patterns, not a base and the reason is a deleted function
25
+ * ## ⛔ The ADDRESS door is retired (2026-09-04) do not bring it back
24
26
  *
25
- * A base assumes the layout is "root plus one segment". A pattern assumes
26
- * nothing, so a host can carry a site id, a locale segment, a different root
27
- * for records than for the list, or none of those, and move any of it
28
- * without a framework release.
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
- * This is the `config.assets.url` rule applied to records. That pattern exists
31
- * because the CLI once composed `{assetBase}dist/{id}/base.{ext}` — a backend's
32
- * path layout, inside a published CLI, on a release cadence the backend could
33
- * not move. It was deleted rather than parameterized. Composing a segment of
34
- * our own here would rebuild exactly that coupling, on a lane where the wrong
35
- * answer is *stale or missing content* rather than a visible 404.
37
+ * ## A pattern, not a base and the reason is a deleted function
36
38
  *
37
- * Substituting `{path}` and `{param}` is the WHOLE of what this does.
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 two sibling leaves, so the SSR pipeline and a Worker
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 address pattern for ONE record of a query, with `{param}` left in
116
- * place for the dynamic-route substitution that happens later.
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
- * Returning a pattern rather than a finished URL is deliberate: the route param
119
- * is not known here, and the framework already has one place that resolves it
120
- * (`buildDetailConfig` / `substitutePlaceholders` at fetch time). Resolving it
121
- * twice, in two places, is how the two copies drift.
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 {string} query
124
- * @param {Object|null} lane - `config.records`.
125
- * @returns {string|null} a pattern still containing `{param}`, or 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 function resolveRecordAddressPattern(query, lane) {
128
- if (typeof query !== 'string' || query.length === 0) return null
129
- const pattern = readPattern(lane, 'record')
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 (!pattern.includes(PARAM_SLOT)) {
103
+ if (typeof locale !== 'string' || locale.length === 0) return null
104
+ if (!pattern.includes('{locale}')) {
132
105
  warnOnce(
133
- `record:${pattern}`,
134
- `config.records.record carries no ${PARAM_SLOT} placeholder, so every record ` +
135
- `would resolve to the same address. Ignoring it and reading the per-record ` +
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
- // Only `{path}` is substituted here — `{param}` survives for the
141
- // dynamic-route resolution that owns it.
142
- return substitutePlaceholders(pattern, { path: query })
112
+ return substitutePlaceholders(pattern, { locale })
143
113
  }
114
+
@@ -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: a host that
14
- * renders a page server-side has to decide *which* page a path names, and the
15
- * runtime then hydrates over that decision in the browser. If the two matchers
16
- * disagree by a single route, the server renders page A and hydration replaces
17
- * it with page B — silently, and only on the paths that have a pattern, which
18
- * are exactly the interesting ones. So this is a cross-boundary contract, not
19
- * an implementation detail, and it is exported rather than merely shared.
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` is the only construct. There are deliberately **no** catch-alls
28
- * (`*`), **no** optional segments (`?`), and **no** regex constraints — a
29
- * pattern is not a regular expression, and regex metacharacters in a route are
30
- * escaped to literals before any substitution happens. Matching is anchored,
31
- * case-sensitive, and a param captures exactly one non-empty path segment.
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
- * @param {string} pattern - e.g. `/blog/:id`
87
- * @returns {{ regex: RegExp, paramNames: string[] }}
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
- const source = normalizeRoute(pattern)
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
- params[name] = decodeRouteValue(match[i + 1])
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
+ }