@uniweb/core 0.27.0 → 0.28.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 +3 -3
- package/src/field-path.js +31 -0
- package/src/sort.js +96 -12
- package/src/where.js +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"vitest": "^4.1.7"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
48
|
+
"@uniweb/theming": "^0.1.15",
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"test": "vitest run"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Field paths — the ONE rule for which dotted keys the query language admits.
|
|
3
|
+
*
|
|
4
|
+
* A `where` key and a `sort` field name a field of the record, or a path into it:
|
|
5
|
+
* `tenure.start`, `education.degree`. Every `.` is a step. ⭐ Two shapes are outside
|
|
6
|
+
* the language on both lanes (the records service's rule, stated by backend on
|
|
7
|
+
* 2026-09-14): a path with an **empty step** (`a..b`, `.a`, `a.`), and a path whose first
|
|
8
|
+
* step starts with **`$`** (`$meta.x`). A single `$` field — `$name`, `$uuid` — is not a
|
|
9
|
+
* path and stays a field. A `where` key outside the language selects no records; a
|
|
10
|
+
* `sort` field outside it is refused where it is authored.
|
|
11
|
+
*
|
|
12
|
+
* Zero-dependency leaf: the where evaluator and the sort evaluator both read it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Why a field key is outside the language, or null when it is inside.
|
|
17
|
+
*
|
|
18
|
+
* @param {string} key
|
|
19
|
+
* @returns {string|null} the problem, as a sentence
|
|
20
|
+
*/
|
|
21
|
+
export function fieldPathProblem(key) {
|
|
22
|
+
if (typeof key !== 'string' || key.indexOf('.') === -1) return null
|
|
23
|
+
const steps = key.split('.')
|
|
24
|
+
if (steps.some((step) => step === '')) {
|
|
25
|
+
return `\`${key}\` has an empty step — a path is field names joined by single dots`
|
|
26
|
+
}
|
|
27
|
+
if (steps[0].startsWith('$')) {
|
|
28
|
+
return `\`${key}\` starts with \`$\` — a path starts at one of the record's own fields`
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
package/src/sort.js
CHANGED
|
@@ -18,12 +18,14 @@
|
|
|
18
18
|
* Dotted paths descend into nested objects (`tenure.start`) — kept, like the
|
|
19
19
|
* predicate evaluator's.
|
|
20
20
|
*
|
|
21
|
-
* Zero-dependency leaf: `@uniweb/build` reads it to materialize `/data/<name>.json`
|
|
21
|
+
* Zero-dependency beyond the `field-path.js` leaf: `@uniweb/build` reads it to materialize `/data/<name>.json`
|
|
22
22
|
* and `@uniweb/runtime` reads it as the fallback over a fetched array, so the two
|
|
23
23
|
* lanes cannot drift on the one thing a conformance test would otherwise have to
|
|
24
24
|
* catch by luck.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { fieldPathProblem } from './field-path.js'
|
|
28
|
+
|
|
27
29
|
/**
|
|
28
30
|
* Parse an authored `sort:` into `{ field, desc }`.
|
|
29
31
|
*
|
|
@@ -39,6 +41,7 @@ export function parseSort(sort) {
|
|
|
39
41
|
if (sort === undefined || sort === null || sort === '') return null
|
|
40
42
|
if (typeof sort === 'object') {
|
|
41
43
|
if (typeof sort.field !== 'string' || sort.field.length === 0) return null
|
|
44
|
+
refuseFieldPath(sort.field, sort.field)
|
|
42
45
|
return { field: sort.field, desc: sort.desc === true }
|
|
43
46
|
}
|
|
44
47
|
const text = String(sort).trim()
|
|
@@ -52,6 +55,7 @@ export function parseSort(sort) {
|
|
|
52
55
|
if (text.startsWith('-')) {
|
|
53
56
|
const field = text.slice(1).trim()
|
|
54
57
|
if (!field || /\s/.test(field)) throw new Error(`[uniweb] sort: "${text}" is not a field name.`)
|
|
58
|
+
refuseFieldPath(field, text)
|
|
55
59
|
return { field, desc: true }
|
|
56
60
|
}
|
|
57
61
|
const parts = text.split(/\s+/)
|
|
@@ -63,9 +67,16 @@ export function parseSort(sort) {
|
|
|
63
67
|
if (lower !== 'asc' && lower !== 'desc') {
|
|
64
68
|
throw new Error(`[uniweb] sort: "${text}" — direction must be \`asc\` or \`desc\`, not "${dir}".`)
|
|
65
69
|
}
|
|
70
|
+
refuseFieldPath(field, text)
|
|
66
71
|
return { field, desc: lower === 'desc' }
|
|
67
72
|
}
|
|
68
73
|
|
|
74
|
+
/** A sort field outside the language is refused where it is written (`field-path.js`). */
|
|
75
|
+
function refuseFieldPath(field, text) {
|
|
76
|
+
const problem = fieldPathProblem(field)
|
|
77
|
+
if (problem) throw new Error(`[uniweb] sort: "${text}" — ${problem}.`)
|
|
78
|
+
}
|
|
79
|
+
|
|
69
80
|
/**
|
|
70
81
|
* The service's spelling of a sort: `date` ascending, `-date` descending.
|
|
71
82
|
*
|
|
@@ -81,29 +92,102 @@ export function sortToWire(sort) {
|
|
|
81
92
|
/**
|
|
82
93
|
* Sort records by one key. Returns a new array; the input is not mutated.
|
|
83
94
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
95
|
+
* ⭐ TEXT SORTS BY THE COLLATION OF THE PAGE'S LOCALE — ruled 2026-09-14 [Diego]: *"collation
|
|
96
|
+
* in the page's locale is the correct answer."* So `apple` sorts before `Banana`, and
|
|
97
|
+
* `Álvarez` before `Zamora` on a Spanish page, the way a reader of that language expects;
|
|
98
|
+
* the records service collates in the question's locale. The collation is the locale's
|
|
99
|
+
* default, with no options: no numeric ordering (`item 10` before `item 2`), and case and
|
|
100
|
+
* accents only break ties between the same letters. ⛔ Until then a text compared with
|
|
101
|
+
* `localeCompare` and no locale — the JavaScript runtime's own — so a browser and a host's
|
|
102
|
+
* prerender could order one list differently. With no locale, or one the runtime cannot
|
|
103
|
+
* collate, `en` stands in: the CLDR root order, which English does not tailor.
|
|
104
|
+
* Numbers, ISO dates and booleans compare as values.
|
|
105
|
+
*
|
|
106
|
+
* ⭐ A RECORD WITH NO VALUE FOR THE KEY SORTS LAST, IN EITHER DIRECTION, and records
|
|
107
|
+
* that compare equal keep their order — the language both lanes answer
|
|
108
|
+
* (`kb/frontend/contracts/site-queries.md` §1.4, as the records service built it on
|
|
109
|
+
* 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
|
|
110
|
+
* list, since nothing says which member orders the record (a dotted path that meets
|
|
111
|
+
* a list reads no value). ⛔ Until 2026-09-14 a missing value sorted as the empty
|
|
112
|
+
* string — first ascending, last descending — so a static site and a hosted one put
|
|
113
|
+
* the same records in different places.
|
|
88
114
|
*
|
|
89
115
|
* @param {Array<Object>} items
|
|
90
116
|
* @param {string|{field:string, desc?:boolean}|null|undefined} sort
|
|
117
|
+
* @param {Object} [options]
|
|
118
|
+
* @param {string|null} [options.locale] - the page's locale, which texts are collated in
|
|
91
119
|
* @returns {Array<Object>}
|
|
92
120
|
*/
|
|
93
|
-
export function sortRecords(items, sort) {
|
|
121
|
+
export function sortRecords(items, sort, { locale = null } = {}) {
|
|
94
122
|
const spec = parseSort(sort)
|
|
95
123
|
if (!spec || !Array.isArray(items) || items.length === 0) return items
|
|
96
124
|
const { field, desc } = spec
|
|
125
|
+
const collate = collatorFor(locale)
|
|
126
|
+
const compareValues = (a, b) => compareValuesIn(a, b, collate)
|
|
97
127
|
return [...items].sort((a, b) => {
|
|
98
|
-
const av = readPath(a, field)
|
|
99
|
-
const bv = readPath(b, field)
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
128
|
+
const av = readPath(a, field)
|
|
129
|
+
const bv = readPath(b, field)
|
|
130
|
+
const aNone = !hasSortValue(av)
|
|
131
|
+
const bNone = !hasSortValue(bv)
|
|
132
|
+
// Last in either direction — so this is decided before `desc` flips anything.
|
|
133
|
+
if (aNone || bNone) return aNone === bNone ? 0 : (aNone ? 1 : -1)
|
|
134
|
+
return desc ? -compareValues(av, bv) : compareValues(av, bv)
|
|
104
135
|
})
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
/** A value a record can be ordered by: not missing, `null`, `""` or a list. */
|
|
139
|
+
function hasSortValue(v) {
|
|
140
|
+
if (v === undefined || v === null || v === '') return false
|
|
141
|
+
return !Array.isArray(v)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* ⭐ TWO KINDS IN ONE FIELD ARE ORDERED BY KIND, THEN WITHIN THE KIND — booleans,
|
|
146
|
+
* numbers, texts, then anything else — the records service's comparator, stated by
|
|
147
|
+
* backend on 2026-09-14. A total order, so a sort never fails; `where` still holds no
|
|
148
|
+
* comparison across kinds. ⛔ Until then `<` / `>` coerced a number against a text.
|
|
149
|
+
*
|
|
150
|
+
* Two texts compare with `collate` — the page's locale (`sortRecords`).
|
|
151
|
+
*/
|
|
152
|
+
function compareValuesIn(a, b, collate) {
|
|
153
|
+
const ka = kindRank(a)
|
|
154
|
+
const kb = kindRank(b)
|
|
155
|
+
if (ka !== kb) return ka < kb ? -1 : 1
|
|
156
|
+
if (typeof a === 'string') return collate(a, b)
|
|
157
|
+
if (ka === KIND_OTHER) return 0
|
|
158
|
+
return a > b ? 1 : a < b ? -1 : 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One `Intl.Collator` per locale, made on first use. */
|
|
162
|
+
const collators = new Map()
|
|
163
|
+
const FALLBACK_LOCALE = 'en'
|
|
164
|
+
|
|
165
|
+
function collatorFor(locale) {
|
|
166
|
+
const wanted = typeof locale === 'string' && locale ? locale : FALLBACK_LOCALE
|
|
167
|
+
let compare = collators.get(wanted)
|
|
168
|
+
if (!compare) {
|
|
169
|
+
let collator
|
|
170
|
+
try {
|
|
171
|
+
collator = new Intl.Collator(wanted)
|
|
172
|
+
} catch {
|
|
173
|
+
// A tag the runtime cannot collate in (malformed, or unknown to its ICU data).
|
|
174
|
+
collator = new Intl.Collator(FALLBACK_LOCALE)
|
|
175
|
+
}
|
|
176
|
+
compare = collator.compare
|
|
177
|
+
collators.set(wanted, compare)
|
|
178
|
+
}
|
|
179
|
+
return compare
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const KIND_OTHER = 3
|
|
183
|
+
|
|
184
|
+
function kindRank(v) {
|
|
185
|
+
if (typeof v === 'boolean') return 0
|
|
186
|
+
if (typeof v === 'number') return 1
|
|
187
|
+
if (typeof v === 'string') return 2
|
|
188
|
+
return KIND_OTHER
|
|
189
|
+
}
|
|
190
|
+
|
|
107
191
|
function readPath(record, path) {
|
|
108
192
|
if (!record || typeof record !== 'object') return undefined
|
|
109
193
|
if (path.indexOf('.') === -1) return record[path]
|
package/src/where.js
CHANGED
|
@@ -66,13 +66,16 @@
|
|
|
66
66
|
* empty argument. It never falls back to a wider answer. `whereOutsideLanguage`
|
|
67
67
|
* says why, so a producer can refuse the declaration where the author wrote it.
|
|
68
68
|
*
|
|
69
|
-
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }
|
|
69
|
+
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }` — never one with
|
|
70
|
+
* an empty step, nor one starting at a `$` field (`field-path.js`). ⭐ A
|
|
70
71
|
* path that meets a list descends into each item, and the values it reaches are
|
|
71
72
|
* read as a list field — so `education.degree: PhD` matches a record any of whose
|
|
72
73
|
* `education` entries has that degree. (Kept on this lane by ruling 2026-09-13
|
|
73
74
|
* [Diego]; a host's records service may not answer them.)
|
|
74
75
|
*/
|
|
75
76
|
|
|
77
|
+
import { fieldPathProblem } from './field-path.js'
|
|
78
|
+
|
|
76
79
|
const OPERATORS = new Set([
|
|
77
80
|
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'not_in', 'exists', 'contains', 'starts_with', 'ends_with',
|
|
78
81
|
])
|
|
@@ -145,7 +148,10 @@ function clauseProblem(key, value) {
|
|
|
145
148
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return '`not` takes one object of conditions'
|
|
146
149
|
return whereOutsideLanguage(value)
|
|
147
150
|
}
|
|
148
|
-
// A field clause: a bare value, null, or an operator object
|
|
151
|
+
// A field clause: a bare value, null, or an operator object — under a key that is a
|
|
152
|
+
// field or a path the language admits (`field-path.js`).
|
|
153
|
+
const pathProblem = fieldPathProblem(key)
|
|
154
|
+
if (pathProblem) return pathProblem
|
|
149
155
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
|
|
150
156
|
const ops = Object.keys(value)
|
|
151
157
|
if (ops.length === 0) return `the condition on \`${key}\` is empty`
|