@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.27.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/semantic-parser": "^1.4.0",
49
- "@uniweb/theming": "^0.1.15"
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
- * 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.
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 cmp = typeof av === 'string' && typeof bv === 'string'
101
- ? av.localeCompare(bv)
102
- : (av > bv ? 1 : av < bv ? -1 : 0)
103
- return desc ? -cmp : cmp
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 }`. ⭐ A
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`