@uniweb/core 0.27.0 → 0.29.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 +7 -0
- package/src/data-keys.js +155 -0
- package/src/datastore.js +28 -13
- package/src/detail-url.js +22 -14
- package/src/entity-store.js +215 -136
- package/src/fetch-config.js +255 -81
- package/src/fetcher-dispatcher.js +9 -1
- package/src/field-path.js +31 -0
- package/src/index.js +5 -1
- package/src/query-evaluation.js +87 -0
- package/src/route-match.js +3 -2
- package/src/sort.js +95 -12
- package/src/website.js +70 -41
- package/src/where.js +8 -2
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,101 @@ 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 (as a host's
|
|
108
|
+
* records service answers it since 2026-09-13). "No value" is what `exists: false` means: missing, `null`, `""` — and a
|
|
109
|
+
* list, since nothing says which member orders the record (a dotted path that meets
|
|
110
|
+
* a list reads no value). ⛔ Until 2026-09-14 a missing value sorted as the empty
|
|
111
|
+
* string — first ascending, last descending — so a static site and a hosted one put
|
|
112
|
+
* the same records in different places.
|
|
88
113
|
*
|
|
89
114
|
* @param {Array<Object>} items
|
|
90
115
|
* @param {string|{field:string, desc?:boolean}|null|undefined} sort
|
|
116
|
+
* @param {Object} [options]
|
|
117
|
+
* @param {string|null} [options.locale] - the page's locale, which texts are collated in
|
|
91
118
|
* @returns {Array<Object>}
|
|
92
119
|
*/
|
|
93
|
-
export function sortRecords(items, sort) {
|
|
120
|
+
export function sortRecords(items, sort, { locale = null } = {}) {
|
|
94
121
|
const spec = parseSort(sort)
|
|
95
122
|
if (!spec || !Array.isArray(items) || items.length === 0) return items
|
|
96
123
|
const { field, desc } = spec
|
|
124
|
+
const collate = collatorFor(locale)
|
|
125
|
+
const compareValues = (a, b) => compareValuesIn(a, b, collate)
|
|
97
126
|
return [...items].sort((a, b) => {
|
|
98
|
-
const av = readPath(a, field)
|
|
99
|
-
const bv = readPath(b, field)
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
127
|
+
const av = readPath(a, field)
|
|
128
|
+
const bv = readPath(b, field)
|
|
129
|
+
const aNone = !hasSortValue(av)
|
|
130
|
+
const bNone = !hasSortValue(bv)
|
|
131
|
+
// Last in either direction — so this is decided before `desc` flips anything.
|
|
132
|
+
if (aNone || bNone) return aNone === bNone ? 0 : (aNone ? 1 : -1)
|
|
133
|
+
return desc ? -compareValues(av, bv) : compareValues(av, bv)
|
|
104
134
|
})
|
|
105
135
|
}
|
|
106
136
|
|
|
137
|
+
/** A value a record can be ordered by: not missing, `null`, `""` or a list. */
|
|
138
|
+
function hasSortValue(v) {
|
|
139
|
+
if (v === undefined || v === null || v === '') return false
|
|
140
|
+
return !Array.isArray(v)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* ⭐ TWO KINDS IN ONE FIELD ARE ORDERED BY KIND, THEN WITHIN THE KIND — booleans,
|
|
145
|
+
* numbers, texts, then anything else — the records service's comparator, stated by
|
|
146
|
+
* backend on 2026-09-14. A total order, so a sort never fails; `where` still holds no
|
|
147
|
+
* comparison across kinds. ⛔ Until then `<` / `>` coerced a number against a text.
|
|
148
|
+
*
|
|
149
|
+
* Two texts compare with `collate` — the page's locale (`sortRecords`).
|
|
150
|
+
*/
|
|
151
|
+
function compareValuesIn(a, b, collate) {
|
|
152
|
+
const ka = kindRank(a)
|
|
153
|
+
const kb = kindRank(b)
|
|
154
|
+
if (ka !== kb) return ka < kb ? -1 : 1
|
|
155
|
+
if (typeof a === 'string') return collate(a, b)
|
|
156
|
+
if (ka === KIND_OTHER) return 0
|
|
157
|
+
return a > b ? 1 : a < b ? -1 : 0
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One `Intl.Collator` per locale, made on first use. */
|
|
161
|
+
const collators = new Map()
|
|
162
|
+
const FALLBACK_LOCALE = 'en'
|
|
163
|
+
|
|
164
|
+
function collatorFor(locale) {
|
|
165
|
+
const wanted = typeof locale === 'string' && locale ? locale : FALLBACK_LOCALE
|
|
166
|
+
let compare = collators.get(wanted)
|
|
167
|
+
if (!compare) {
|
|
168
|
+
let collator
|
|
169
|
+
try {
|
|
170
|
+
collator = new Intl.Collator(wanted)
|
|
171
|
+
} catch {
|
|
172
|
+
// A tag the runtime cannot collate in (malformed, or unknown to its ICU data).
|
|
173
|
+
collator = new Intl.Collator(FALLBACK_LOCALE)
|
|
174
|
+
}
|
|
175
|
+
compare = collator.compare
|
|
176
|
+
collators.set(wanted, compare)
|
|
177
|
+
}
|
|
178
|
+
return compare
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const KIND_OTHER = 3
|
|
182
|
+
|
|
183
|
+
function kindRank(v) {
|
|
184
|
+
if (typeof v === 'boolean') return 0
|
|
185
|
+
if (typeof v === 'number') return 1
|
|
186
|
+
if (typeof v === 'string') return 2
|
|
187
|
+
return KIND_OTHER
|
|
188
|
+
}
|
|
189
|
+
|
|
107
190
|
function readPath(record, path) {
|
|
108
191
|
if (!record || typeof record !== 'object') return undefined
|
|
109
192
|
if (path.indexOf('.') === -1) return record[path]
|
package/src/website.js
CHANGED
|
@@ -12,7 +12,8 @@ import ObservableState from './observable-state.js'
|
|
|
12
12
|
import { normalizeSeo } from './seo.js'
|
|
13
13
|
import { resolveDefaultLocale, localeLabel } from './locale-config.js'
|
|
14
14
|
import { matchDynamicRoute, decodeRouteValue, matchesRouteParam, routeBinding, routeParamName, parentRouteOf } from './route-match.js'
|
|
15
|
-
import { resolveFetchConfigs,
|
|
15
|
+
import { resolveFetchConfigs, pageRouteQuery, recordPages, routeSelection, siteReaches } from './fetch-config.js'
|
|
16
|
+
import { declaredKeys } from './data-keys.js'
|
|
16
17
|
import { buildDetailConfig } from './detail-url.js'
|
|
17
18
|
import { findLayoutEntry } from './layout-name.js'
|
|
18
19
|
import { resolveService } from './services.js'
|
|
@@ -87,6 +88,8 @@ export default class Website {
|
|
|
87
88
|
this.notFoundPage = null
|
|
88
89
|
this._dynamicPageData = new Map()
|
|
89
90
|
this._dynamicPageCache = new Map()
|
|
91
|
+
// query name → the page its records link to (`recordPageFor`), worked out on first ask
|
|
92
|
+
this._recordPages = null
|
|
90
93
|
this.pages = []
|
|
91
94
|
this.activePage = null
|
|
92
95
|
this.pageRoutes = []
|
|
@@ -161,6 +164,7 @@ export default class Website {
|
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
this._dynamicPageCache = new Map()
|
|
167
|
+
this._recordPages = null
|
|
164
168
|
|
|
165
169
|
this.pages = regularPages.map((page, index) => new Page(page, index, this))
|
|
166
170
|
this.buildPageHierarchy()
|
|
@@ -573,7 +577,7 @@ export default class Website {
|
|
|
573
577
|
const { paramName, paramValue, variables } = routeBinding(templatePage.route, params, originalData.paramName)
|
|
574
578
|
|
|
575
579
|
// Store dynamic context for components to access. ⛔ No `schema`: the key the
|
|
576
|
-
// URL narrows is worked out where it is read (`EntityStore.
|
|
580
|
+
// URL narrows is worked out where it is read (`EntityStore._route`), from
|
|
577
581
|
// the page, never stored beside it (deleted 2026-09-11).
|
|
578
582
|
pageData.dynamicContext = {
|
|
579
583
|
templateRoute: templatePage.route,
|
|
@@ -622,6 +626,10 @@ export default class Website {
|
|
|
622
626
|
// visitor's page — the "write key ≠ read key" failure.
|
|
623
627
|
let items = []
|
|
624
628
|
let currentItem = null
|
|
629
|
+
// ⭐ On the records service the record's own question checks the route query's
|
|
630
|
+
// set (`buildDetailConfig`), so once it has answered, its answer is definitive —
|
|
631
|
+
// the record, or `[]` for not found — with no list to consult.
|
|
632
|
+
let recordAnswered = false
|
|
625
633
|
|
|
626
634
|
if (this.fetcher) {
|
|
627
635
|
// The same sources the store walks for a section that declares nothing,
|
|
@@ -654,10 +662,13 @@ export default class Website {
|
|
|
654
662
|
const raw = detailCached?.data
|
|
655
663
|
const record = Array.isArray(raw) ? raw[0] : raw
|
|
656
664
|
if (record && typeof record === 'object') currentItem = record
|
|
665
|
+
if (fetchConfig.ask && detailCached && Array.isArray(raw)) recordAnswered = true
|
|
657
666
|
|
|
658
|
-
//
|
|
659
|
-
//
|
|
660
|
-
//
|
|
667
|
+
// ⭐ The route query's set (`routeSelection`) — the query as saved, its `limit`
|
|
668
|
+
// included, never a list a fetch narrowed: a record past a fetch's `limit` is a
|
|
669
|
+
// record, and one past the query's is not (ruled 2026-09-14 [Diego]). ⛔ Until
|
|
670
|
+
// 2026-09-13 this read the cut list and declared a record past a list's
|
|
671
|
+
// `limit` "Not found".
|
|
661
672
|
const cached = this.fetcher.peek(routeSelection(fetchConfig), ctx)
|
|
662
673
|
items = Array.isArray(cached?.data) ? cached.data : []
|
|
663
674
|
}
|
|
@@ -672,8 +683,9 @@ export default class Website {
|
|
|
672
683
|
if (currentItem.description || currentItem.excerpt) {
|
|
673
684
|
pageData.description = currentItem.description || currentItem.excerpt
|
|
674
685
|
}
|
|
675
|
-
} else if (items.length > 0) {
|
|
676
|
-
// The
|
|
686
|
+
} else if (items.length > 0 || recordAnswered) {
|
|
687
|
+
// The set is loaded and this ID isn't in it, or the record question answered
|
|
688
|
+
// `[]` — definitive not found
|
|
677
689
|
pageData.title = 'Not found'
|
|
678
690
|
pageData.notFound = true
|
|
679
691
|
}
|
|
@@ -685,7 +697,7 @@ export default class Website {
|
|
|
685
697
|
// components via content.data, the others via
|
|
686
698
|
// `fetch: { query, current: exclude }`). The local
|
|
687
699
|
// `currentItem`/`items` above drive title/description/notFound.
|
|
688
|
-
pageData._recordsLoaded = items.length > 0 || currentItem !== null
|
|
700
|
+
pageData._recordsLoaded = items.length > 0 || currentItem !== null || recordAnswered
|
|
689
701
|
}
|
|
690
702
|
|
|
691
703
|
// Create the page instance
|
|
@@ -875,43 +887,60 @@ export default class Website {
|
|
|
875
887
|
}
|
|
876
888
|
|
|
877
889
|
/**
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
890
|
+
* ⭐ THE `content.data` KEYS A COMPONENT RECEIVES — its `data:` (`meta.data`, as the build
|
|
891
|
+
* leans it), then its foundation's `main.js` `data:`, each with its schema ref
|
|
892
|
+
* (`declaredKeys`, `./data-keys.js`; ruled 2026-09-14 [Diego]). A component with no
|
|
893
|
+
* `data:` receives only its foundation's keys, which reach every section because its
|
|
894
|
+
* handlers run for every section.
|
|
895
|
+
*
|
|
896
|
+
* @param {Object|null} meta - the component's runtime meta (`uniweb.getComponentMeta`)
|
|
897
|
+
* @returns {Array<[string, string|null]>}
|
|
898
|
+
*/
|
|
899
|
+
declaredKeys(meta) {
|
|
900
|
+
return declaredKeys(meta?.data, this._foundation?.default?.capabilities?.data)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* ⭐ THE PAGE A QUERY'S RECORDS LINK TO — `{ route, paramName }`: the parametric page
|
|
905
|
+
* whose route query IS the query, by name, the first in page order (`recordPages`,
|
|
906
|
+
* `./fetch-config.js`), with its route translated for the active locale. Null when the
|
|
907
|
+
* site has no such page, and then a record of the query has no link.
|
|
908
|
+
*
|
|
909
|
+
* This is what fills a delivered record's `$route` (`EntityStore`) when its fetch picks
|
|
910
|
+
* no `detailPage`, and how a caller outside a parametric page learns which record field
|
|
911
|
+
* the page's URL is built on: `kit`'s `useEntityDetail` asks it so a hover card and the
|
|
912
|
+
* page it links to address the record by the SAME field.
|
|
913
|
+
*
|
|
914
|
+
* ⛔ Replaces `detailTemplateFor(name)` (2026-09-14), which matched a binding key OR a
|
|
915
|
+
* query name — so a page whose route query merely landed under the same key answered
|
|
916
|
+
* for another query's records.
|
|
917
|
+
*
|
|
918
|
+
* @param {string} query - a query's name
|
|
895
919
|
* @returns {{ route: string, paramName: string } | null}
|
|
896
920
|
*/
|
|
897
|
-
|
|
898
|
-
if (!
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
921
|
+
recordPageFor(query) {
|
|
922
|
+
if (typeof query !== 'string' || !query) return null
|
|
923
|
+
if (!this._recordPages) {
|
|
924
|
+
// ⭐ Every page, by the route PATTERN it answers to — the entity store's reading
|
|
925
|
+
// (`_route`). A static build replaces a parametric page with one concrete page per
|
|
926
|
+
// record (`expandDynamicPages`), each carrying its template's route as
|
|
927
|
+
// `dynamicContext.templateRoute`; the payload it prerenders and hydrates holds those,
|
|
928
|
+
// and no template. ⛔ Read from the templates alone, this found no page there, and
|
|
929
|
+
// no record of a prerendered site had a link (measured on the `dynamic` template).
|
|
930
|
+
this._recordPages = recordPages(this.pages, {
|
|
931
|
+
routeOf: (page) => page?.dynamicContext?.templateRoute ?? page?.route,
|
|
932
|
+
parentOf: (page) => page?.parent ?? null,
|
|
933
|
+
fetchOf: (page) => page?.fetch,
|
|
934
|
+
sectionsOf: (page) => page?._bodySections,
|
|
935
|
+
site: this.config?.fetch,
|
|
908
936
|
})
|
|
909
|
-
if (!route) continue
|
|
910
|
-
if (route.key === name || route.config?.query === name) {
|
|
911
|
-
return { route: data.route, paramName: routeParamName(data.route, data.paramName) }
|
|
912
|
-
}
|
|
913
937
|
}
|
|
914
|
-
|
|
938
|
+
const found = this._recordPages.get(query)
|
|
939
|
+
if (!found) return null
|
|
940
|
+
return {
|
|
941
|
+
route: this.translateRoute(found.route),
|
|
942
|
+
paramName: routeParamName(found.route, found.page?.dynamicContext?.paramName ?? found.page?.paramName),
|
|
943
|
+
}
|
|
915
944
|
}
|
|
916
945
|
|
|
917
946
|
/**
|
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`
|