@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/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
- * 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 (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 cmp = typeof av === 'string' && typeof bv === 'string'
101
- ? av.localeCompare(bv)
102
- : (av > bv ? 1 : av < bv ? -1 : 0)
103
- return desc ? -cmp : cmp
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, routeQuery, pageRouteQuery, routeSelection, sectionFetches, siteReaches } from './fetch-config.js'
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._routeKey`), from
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
- // ⛔ 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.
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 records are loaded but this ID isn't among them — definitive not found
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
- * The parametric page that renders ONE record of a query — `{ route, paramName }`
879
- * for the page whose route query (`routeQuery`) lands under `name`, or null when
880
- * the site routes no such page.
881
- *
882
- * ⭐ This is how a caller outside a parametric page learns which record field
883
- * the site's URL is built on: `kit`'s `useEntityDetail` asks it so a hover
884
- * card and the page it links to address the record by the SAME field. It
885
- * hardcoded `slug` until 2026-09-04, which was quietly wrong on any site
886
- * routing `[id]`.
887
- *
888
- * `name` may be the binding key (`content.data.<key>`) or the query's name:
889
- * `useEntityDetail` holds the query name, and the two differ under an `as:`
890
- * override — which this matched by binding key only until 2026-09-11. The first
891
- * page found wins, and only a page whose own folder is the parameter (a page
892
- * nested inside one is not the record's page).
893
- *
894
- * @param {string} name - a binding key or a query name
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
- detailTemplateFor(name) {
898
- if (!name) return null
899
- for (const data of this._dynamicPageData.values()) {
900
- if (!data?.route || !/\/:[A-Za-z0-9_-]+\*?$/.test(data.route)) continue
901
- const page = this.pages.find((p) => p.route === data.route)
902
- const route = routeQuery({
903
- page: data.fetch,
904
- parent: page?.parent?.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,
907
- sections: sectionFetches(data.sections),
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
- return null
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 }`. ⭐ 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`