@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/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/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 (`routeQuery`) — the page's own query, its parent's, the
593
- // site's, or its sections' shared key — read off the same parent the entity
594
- // store reads (`templatePage.parent`, linked by the one parent rule). ⛔ Until
595
- // 2026-09-11 this probed only a parent found by stripping the route down to
596
- // `/` (so a top-level page probed the homepage) and only that parent's fetch,
597
- // so a page whose route query was its own or the site's never got its title.
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 = routeQuery({
600
- page: originalData.fetch,
601
- parent: parentPage?.fetch,
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 declaring section's config when the key came from the sections.
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, this.config?.fetch, route.level === 'sections' ? route.config : null],
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
- const cached = this.fetcher.peek(fetchConfig, ctx)
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, siblings via
678
- // `fetch: { refine: true, detail: false }`). The local
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
- if (layoutName && config.layouts[layoutName]) {
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
- if (layoutName && this._layoutSets[layoutName]) {
757
- return this._layoutSets[layoutName][areaName]?.bodyBlocks || null
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 setName = layoutName || 'default'
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?.[layoutName] || null
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: this.config?.fetch,
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