@uniweb/core 0.26.2 → 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 +4 -3
- package/src/data-paths.js +2 -2
- package/src/detail-url.js +60 -119
- package/src/entity-store.js +79 -142
- package/src/fetch-config.js +275 -58
- package/src/fetcher-dispatcher.js +3 -3
- package/src/field-path.js +31 -0
- package/src/index.js +2 -2
- package/src/layout-name.js +61 -0
- package/src/sort.js +96 -12
- package/src/website.js +34 -26
- package/src/where.js +189 -127
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/website.js
CHANGED
|
@@ -12,8 +12,9 @@ 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, routeQuery, sectionFetches } from './fetch-config.js'
|
|
15
|
+
import { resolveFetchConfigs, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches } from './fetch-config.js'
|
|
16
16
|
import { buildDetailConfig } from './detail-url.js'
|
|
17
|
+
import { findLayoutEntry } from './layout-name.js'
|
|
17
18
|
import { resolveService } from './services.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -589,18 +590,21 @@ export default class Website {
|
|
|
589
590
|
}
|
|
590
591
|
|
|
591
592
|
// Try to resolve page metadata from DataStore: the record the page is about,
|
|
592
|
-
// from its ROUTE QUERY
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
//
|
|
596
|
-
//
|
|
597
|
-
//
|
|
593
|
+
// from its ROUTE QUERY — the page's own query, its parent's, the site's, or its
|
|
594
|
+
// sections' shared key, chosen at the page that captured the URL's variable
|
|
595
|
+
// (`pageRouteQuery`; a nested page's is its parametric ancestor's) — read off the
|
|
596
|
+
// same parents the entity store reads (`page.parent`, linked by the one parent
|
|
597
|
+
// rule). ⛔ Until 2026-09-11 this probed only a parent found by stripping the
|
|
598
|
+
// route down to `/` (so a top-level page probed the homepage) and only that
|
|
599
|
+
// parent's fetch, so a page whose route query was its own or the site's never
|
|
600
|
+
// got its title.
|
|
598
601
|
const parentPage = templatePage.parent
|
|
599
|
-
const route =
|
|
600
|
-
|
|
601
|
-
|
|
602
|
+
const route = pageRouteQuery(templatePage, {
|
|
603
|
+
routeOf: (p) => p?.route,
|
|
604
|
+
parentOf: (p) => p?.parent ?? null,
|
|
605
|
+
fetchOf: (p) => (p === templatePage ? originalData.fetch : p?.fetch),
|
|
606
|
+
sectionsOf: (p) => (p === templatePage ? originalData.sections : p?._bodySections),
|
|
602
607
|
site: this.config?.fetch,
|
|
603
|
-
sections: sectionFetches(originalData.sections),
|
|
604
608
|
})
|
|
605
609
|
|
|
606
610
|
if (route && paramValue !== undefined) {
|
|
@@ -621,9 +625,10 @@ export default class Website {
|
|
|
621
625
|
|
|
622
626
|
if (this.fetcher) {
|
|
623
627
|
// The same sources the store walks for a section that declares nothing,
|
|
624
|
-
// plus the
|
|
628
|
+
// plus the route binding itself — which sits above the parent on a nested
|
|
629
|
+
// page, or on a section when the key came from the sections.
|
|
625
630
|
const fetchConfig = resolveFetchConfigs(
|
|
626
|
-
[originalData.fetch, parentPage?.fetch,
|
|
631
|
+
[originalData.fetch, parentPage?.fetch, route.config, siteReaches(parentPage) ? this.config?.fetch : null],
|
|
627
632
|
{
|
|
628
633
|
schemas: [route.key],
|
|
629
634
|
locale: this.getActiveLocale(),
|
|
@@ -650,7 +655,10 @@ export default class Website {
|
|
|
650
655
|
const record = Array.isArray(raw) ? raw[0] : raw
|
|
651
656
|
if (record && typeof record === 'object') currentItem = record
|
|
652
657
|
|
|
653
|
-
|
|
658
|
+
// ⛔ The route query's whole selection (`routeSelection`), never a list its
|
|
659
|
+
// `limit` cut: a record past the limit is a record, and until 2026-09-13
|
|
660
|
+
// this declared its page "Not found" because the cut list was loaded.
|
|
661
|
+
const cached = this.fetcher.peek(routeSelection(fetchConfig), ctx)
|
|
654
662
|
items = Array.isArray(cached?.data) ? cached.data : []
|
|
655
663
|
}
|
|
656
664
|
}
|
|
@@ -674,8 +682,8 @@ export default class Website {
|
|
|
674
682
|
// Note: the matched record and the sibling list are intentionally NOT
|
|
675
683
|
// stored on dynamicContext — nothing reads them (its shape is
|
|
676
684
|
// { templateRoute, params, paramName, paramValue }; the record reaches
|
|
677
|
-
// components via content.data,
|
|
678
|
-
// `fetch: {
|
|
685
|
+
// components via content.data, the others via
|
|
686
|
+
// `fetch: { query, current: exclude }`). The local
|
|
679
687
|
// `currentItem`/`items` above drive title/description/notFound.
|
|
680
688
|
pageData._recordsLoaded = items.length > 0 || currentItem !== null
|
|
681
689
|
}
|
|
@@ -721,10 +729,7 @@ export default class Website {
|
|
|
721
729
|
getRemoteLayout(layoutName) {
|
|
722
730
|
const config = globalThis.uniweb?.foundationConfig
|
|
723
731
|
if (!config?.layouts) return null
|
|
724
|
-
|
|
725
|
-
return config.layouts[layoutName]
|
|
726
|
-
}
|
|
727
|
-
return null
|
|
732
|
+
return findLayoutEntry(config.layouts, layoutName) ?? null
|
|
728
733
|
}
|
|
729
734
|
|
|
730
735
|
/**
|
|
@@ -753,8 +758,11 @@ export default class Website {
|
|
|
753
758
|
* @returns {Block[]|null}
|
|
754
759
|
*/
|
|
755
760
|
getAreaBlocks(areaName, layoutName) {
|
|
756
|
-
|
|
757
|
-
|
|
761
|
+
// A named layout's areas are its own: an area it lacks is absent, never
|
|
762
|
+
// borrowed from the default layout.
|
|
763
|
+
const named = findLayoutEntry(this._layoutSets, layoutName)
|
|
764
|
+
if (named) {
|
|
765
|
+
return named[areaName]?.bodyBlocks || null
|
|
758
766
|
}
|
|
759
767
|
// Fallback to 'default' layout
|
|
760
768
|
if (this._layoutSets.default) {
|
|
@@ -769,8 +777,7 @@ export default class Website {
|
|
|
769
777
|
* @returns {Object} Map of areaName -> Block[]
|
|
770
778
|
*/
|
|
771
779
|
getLayoutAreas(layoutName) {
|
|
772
|
-
const
|
|
773
|
-
const layoutSet = this._layoutSets[setName] || this._layoutSets.default
|
|
780
|
+
const layoutSet = findLayoutEntry(this._layoutSets, layoutName || 'default') || this._layoutSets.default
|
|
774
781
|
if (!layoutSet) return {}
|
|
775
782
|
|
|
776
783
|
const areas = {}
|
|
@@ -788,7 +795,7 @@ export default class Website {
|
|
|
788
795
|
* @returns {Object|null} Layout meta { areas, transitions, defaults }
|
|
789
796
|
*/
|
|
790
797
|
getLayoutMeta(layoutName) {
|
|
791
|
-
return globalThis.uniweb?.foundationConfig?.layoutMeta
|
|
798
|
+
return findLayoutEntry(globalThis.uniweb?.foundationConfig?.layoutMeta, layoutName) || null
|
|
792
799
|
}
|
|
793
800
|
|
|
794
801
|
/**
|
|
@@ -895,7 +902,8 @@ export default class Website {
|
|
|
895
902
|
const route = routeQuery({
|
|
896
903
|
page: data.fetch,
|
|
897
904
|
parent: page?.parent?.fetch,
|
|
898
|
-
site
|
|
905
|
+
// the site's binding is a route query for a top-level page only (`siteReaches`)
|
|
906
|
+
site: siteReaches(page?.parent) ? this.config?.fetch : null,
|
|
899
907
|
sections: sectionFetches(data.sections),
|
|
900
908
|
})
|
|
901
909
|
if (!route) continue
|