@uniweb/core 0.26.1 → 0.27.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 +2 -1
- 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/index.js +2 -2
- package/src/layout-name.js +61 -0
- package/src/page.js +17 -5
- package/src/website.js +34 -26
- package/src/where.js +183 -127
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
|
package/src/where.js
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
* (which translate to their native query language) or to this evaluator
|
|
8
8
|
* (which walks the object against a record).
|
|
9
9
|
*
|
|
10
|
+
* ⭐ ONE LANGUAGE, ONE MEANING PER OPERATOR, on every lane. A compiled site
|
|
11
|
+
* evaluates a query here; a host that answers queries evaluates the same
|
|
12
|
+
* language at the source. The set below is the language ruled on 2026-09-13
|
|
13
|
+
* [Diego] for a stored query and a question alike, so a query answers the same
|
|
14
|
+
* wherever it is asked. ⛔ Until then this evaluator had `like` and `nin` and none
|
|
15
|
+
* of `contains`, `starts_with`, `ends_with`, and it read `exists`, comparisons on
|
|
16
|
+
* a list and an empty `and` differently (measured).
|
|
17
|
+
*
|
|
10
18
|
* Shape:
|
|
11
19
|
*
|
|
12
20
|
* {
|
|
@@ -18,7 +26,7 @@
|
|
|
18
26
|
* // For non-equality, the value is an operator object.
|
|
19
27
|
* start_year: { gte: 2010 },
|
|
20
28
|
* rank: { in: ['associate', 'full'] },
|
|
21
|
-
* title: {
|
|
29
|
+
* title: { starts_with: 'the' },
|
|
22
30
|
*
|
|
23
31
|
* // Explicit composition keys at any nesting level.
|
|
24
32
|
* and: [{ tenured: true }, { rank: 'full' }],
|
|
@@ -28,57 +36,81 @@
|
|
|
28
36
|
*
|
|
29
37
|
* Operators (in operator-object form):
|
|
30
38
|
*
|
|
31
|
-
* eq
|
|
32
|
-
* ne
|
|
33
|
-
* gt/gte
|
|
34
|
-
* lt/lte
|
|
35
|
-
* in
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
+
* eq Equal (also implicit when the value is bare).
|
|
40
|
+
* ne Not equal.
|
|
41
|
+
* gt/gte Greater than / greater than or equal.
|
|
42
|
+
* lt/lte Less than / less than or equal.
|
|
43
|
+
* in Equal to one of the listed values.
|
|
44
|
+
* not_in Equal to none of the listed values.
|
|
45
|
+
* exists `true`: the field has a value — not missing, null, "" or [].
|
|
46
|
+
* `false`: it has none.
|
|
47
|
+
* contains On a list, holds an item equal to the value; on a text, holds
|
|
48
|
+
* the value as a piece of it (plain text, case-insensitive).
|
|
49
|
+
* starts_with A text that starts with the value (plain text, case-insensitive).
|
|
50
|
+
* ends_with A text that ends with the value (plain text, case-insensitive).
|
|
39
51
|
*
|
|
40
52
|
* Composition keys:
|
|
41
53
|
*
|
|
42
|
-
* and
|
|
43
|
-
* or
|
|
44
|
-
* not
|
|
54
|
+
* and A non-empty list of sub-predicates; all must match.
|
|
55
|
+
* or A non-empty list of sub-predicates; at least one must match.
|
|
56
|
+
* not One sub-predicate; must not match.
|
|
45
57
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
58
|
+
* ⭐ A LIST FIELD (`tags: ['news', 'rust']`) holds a condition when ANY member
|
|
59
|
+
* satisfies it: `tags: news` has `news`, `ne: news` does not have it, `not_in`
|
|
60
|
+
* has none of the values, a comparison holds for some member. Values stay typed:
|
|
61
|
+
* `'3'` does not equal `3`. A field a record has no value for satisfies `ne`,
|
|
62
|
+
* `not_in` and `exists: false`, and nothing else.
|
|
51
63
|
*
|
|
52
|
-
*
|
|
64
|
+
* ⛔ A WHERE OUTSIDE THE LANGUAGE SELECTS NO RECORDS — an unknown or retired
|
|
65
|
+
* operator (`like`, `nin`, `under`), an empty `and` / `or`, a text operator with an
|
|
66
|
+
* empty argument. It never falls back to a wider answer. `whereOutsideLanguage`
|
|
67
|
+
* says why, so a producer can refuse the declaration where the author wrote it.
|
|
53
68
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
69
|
+
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`. ⭐ A
|
|
70
|
+
* path that meets a list descends into each item, and the values it reaches are
|
|
71
|
+
* read as a list field — so `education.degree: PhD` matches a record any of whose
|
|
72
|
+
* `education` entries has that degree. (Kept on this lane by ruling 2026-09-13
|
|
73
|
+
* [Diego]; a host's records service may not answer them.)
|
|
58
74
|
*/
|
|
59
75
|
|
|
60
|
-
const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
|
|
61
76
|
const OPERATORS = new Set([
|
|
62
|
-
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', '
|
|
77
|
+
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'not_in', 'exists', 'contains', 'starts_with', 'ends_with',
|
|
63
78
|
])
|
|
64
79
|
|
|
80
|
+
/** Retired spellings, each with the sentence that names its replacement. */
|
|
81
|
+
const RETIRED_OPERATORS = {
|
|
82
|
+
nin: '`nin` is spelled `not_in`',
|
|
83
|
+
like: '`like` is retired — use `starts_with`, `ends_with` or `contains`',
|
|
84
|
+
under: '`under` is retired — a folder branch is `scope:`',
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Why a where-object is outside the language, or null when it is inside.
|
|
89
|
+
*
|
|
90
|
+
* @param {*} where
|
|
91
|
+
* @returns {string|null} the first problem found, as a sentence
|
|
92
|
+
*/
|
|
93
|
+
export function whereOutsideLanguage(where) {
|
|
94
|
+
if (where == null) return null
|
|
95
|
+
if (typeof where !== 'object' || Array.isArray(where)) return 'a where is an object of conditions'
|
|
96
|
+
for (const [key, value] of Object.entries(where)) {
|
|
97
|
+
const problem = clauseProblem(key, value)
|
|
98
|
+
if (problem) return problem
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
/**
|
|
66
104
|
* Evaluate a where-object against a single record.
|
|
67
105
|
*
|
|
68
106
|
* @param {Object} where - The where-object predicate.
|
|
69
107
|
* @param {Object} record - The record to test.
|
|
70
|
-
* @returns {boolean} true if the record matches.
|
|
108
|
+
* @returns {boolean} true if the record matches; false for a where outside the language.
|
|
71
109
|
*/
|
|
72
110
|
export function evaluate(where, record) {
|
|
73
111
|
if (where == null) return true
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Implicit AND across all top-level keys.
|
|
78
|
-
for (const key of Object.keys(where)) {
|
|
79
|
-
if (!evaluateClause(key, where[key], record)) return false
|
|
80
|
-
}
|
|
81
|
-
return true
|
|
112
|
+
if (whereOutsideLanguage(where)) return false
|
|
113
|
+
return test(where, record)
|
|
82
114
|
}
|
|
83
115
|
|
|
84
116
|
/**
|
|
@@ -86,142 +118,166 @@ export function evaluate(where, record) {
|
|
|
86
118
|
*
|
|
87
119
|
* @param {Object} where - The where-object predicate.
|
|
88
120
|
* @param {Array<Object>} records - The records to filter.
|
|
89
|
-
* @returns {Array<Object>} Records in source order for which the predicate is true
|
|
121
|
+
* @returns {Array<Object>} Records in source order for which the predicate is true;
|
|
122
|
+
* none for a where outside the language.
|
|
90
123
|
*/
|
|
91
124
|
export function match(where, records) {
|
|
92
125
|
if (!Array.isArray(records)) return []
|
|
93
126
|
if (where == null) return records.slice()
|
|
94
|
-
|
|
127
|
+
if (whereOutsideLanguage(where)) return []
|
|
128
|
+
return records.filter((r) => test(where, r))
|
|
95
129
|
}
|
|
96
130
|
|
|
97
131
|
// ─── Internals ────────────────────────────────────────────────────
|
|
98
132
|
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
133
|
+
function clauseProblem(key, value) {
|
|
134
|
+
if (key === 'and' || key === 'or') {
|
|
135
|
+
if (!Array.isArray(value) || value.length === 0) return `\`${key}\` takes a non-empty list of conditions`
|
|
136
|
+
for (const sub of value) {
|
|
137
|
+
const problem = sub && typeof sub === 'object' && !Array.isArray(sub)
|
|
138
|
+
? whereOutsideLanguage(sub)
|
|
139
|
+
: `each item of \`${key}\` is an object of conditions`
|
|
140
|
+
if (problem) return problem
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
108
143
|
}
|
|
109
144
|
if (key === 'not') {
|
|
110
|
-
|
|
145
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return '`not` takes one object of conditions'
|
|
146
|
+
return whereOutsideLanguage(value)
|
|
111
147
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
return
|
|
148
|
+
// A field clause: a bare value, null, or an operator object.
|
|
149
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
|
|
150
|
+
const ops = Object.keys(value)
|
|
151
|
+
if (ops.length === 0) return `the condition on \`${key}\` is empty`
|
|
152
|
+
for (const op of ops) {
|
|
153
|
+
if (RETIRED_OPERATORS[op]) return RETIRED_OPERATORS[op]
|
|
154
|
+
if (!OPERATORS.has(op)) return `\`${op}\` (on \`${key}\`) is not an operator`
|
|
155
|
+
const arg = value[op]
|
|
156
|
+
if ((op === 'in' || op === 'not_in') && !Array.isArray(arg)) return `\`${op}\` takes a list of values`
|
|
157
|
+
if (op === 'exists' && typeof arg !== 'boolean') return '`exists` takes true or false'
|
|
158
|
+
if ((op === 'starts_with' || op === 'ends_with') && (typeof arg !== 'string' || arg === '')) {
|
|
159
|
+
return `\`${op}\` takes non-empty text`
|
|
160
|
+
}
|
|
161
|
+
if (op === 'contains' && arg === '') return '`contains` takes a value that is not empty text'
|
|
119
162
|
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
120
165
|
|
|
121
|
-
|
|
122
|
-
|
|
166
|
+
function test(where, record) {
|
|
167
|
+
if (record == null || typeof record !== 'object') return false
|
|
168
|
+
for (const key of Object.keys(where)) {
|
|
169
|
+
if (!testClause(key, where[key], record)) return false
|
|
123
170
|
}
|
|
124
|
-
|
|
125
|
-
// Bare value (string, number, boolean, array): implicit equality.
|
|
126
|
-
return matchEqual(fieldValue, value)
|
|
171
|
+
return true
|
|
127
172
|
}
|
|
128
173
|
|
|
129
|
-
function
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// sub-predicate or a structured equality target. The latter is rare;
|
|
134
|
-
// we treat any object whose keys are all known operators as an
|
|
135
|
-
// operator-object, otherwise fall back to deep-equality matching.
|
|
136
|
-
const keys = Object.keys(value)
|
|
137
|
-
if (keys.length === 0) return false
|
|
138
|
-
return keys.every((k) => OPERATORS.has(k))
|
|
139
|
-
}
|
|
174
|
+
function testClause(key, value, record) {
|
|
175
|
+
if (key === 'and') return value.every((sub) => test(sub, record))
|
|
176
|
+
if (key === 'or') return value.some((sub) => test(sub, record))
|
|
177
|
+
if (key === 'not') return !test(value, record)
|
|
140
178
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
179
|
+
const fieldValue = getPath(record, key)
|
|
180
|
+
if (value === null) return fieldValue === null || fieldValue === undefined
|
|
181
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
182
|
+
for (const op of Object.keys(value)) {
|
|
183
|
+
if (!testOperator(op, value[op], fieldValue)) return false
|
|
184
|
+
}
|
|
185
|
+
return true
|
|
144
186
|
}
|
|
145
|
-
|
|
187
|
+
// Bare value: implicit equality.
|
|
188
|
+
return equals(fieldValue, value)
|
|
146
189
|
}
|
|
147
190
|
|
|
148
|
-
function
|
|
191
|
+
function testOperator(op, arg, fieldValue) {
|
|
149
192
|
switch (op) {
|
|
150
193
|
case 'eq':
|
|
151
|
-
return
|
|
194
|
+
return equals(fieldValue, arg)
|
|
152
195
|
case 'ne':
|
|
153
|
-
return !
|
|
196
|
+
return !equals(fieldValue, arg)
|
|
154
197
|
case 'gt':
|
|
155
|
-
return
|
|
198
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v > arg)
|
|
156
199
|
case 'gte':
|
|
157
|
-
return
|
|
200
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v >= arg)
|
|
158
201
|
case 'lt':
|
|
159
|
-
return
|
|
202
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v < arg)
|
|
160
203
|
case 'lte':
|
|
161
|
-
return
|
|
204
|
+
return someMember(fieldValue, (v) => comparable(v, arg) && v <= arg)
|
|
162
205
|
case 'in':
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (!Array.isArray(opValue)) return false
|
|
167
|
-
return !opValue.some((v) => matchEqual(fieldValue, v))
|
|
168
|
-
case 'like':
|
|
169
|
-
if (typeof fieldValue !== 'string' || typeof opValue !== 'string') return false
|
|
170
|
-
return globMatch(opValue, fieldValue)
|
|
206
|
+
return arg.some((a) => equals(fieldValue, a))
|
|
207
|
+
case 'not_in':
|
|
208
|
+
return !arg.some((a) => equals(fieldValue, a))
|
|
171
209
|
case 'exists':
|
|
172
|
-
return
|
|
210
|
+
return hasValue(fieldValue) === arg
|
|
211
|
+
case 'contains':
|
|
212
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => v === arg)
|
|
213
|
+
if (typeof fieldValue === 'string' && typeof arg === 'string') {
|
|
214
|
+
return fieldValue.toLowerCase().includes(arg.toLowerCase())
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
case 'starts_with':
|
|
218
|
+
return someMember(fieldValue, (v) => typeof v === 'string' && v.toLowerCase().startsWith(arg.toLowerCase()))
|
|
219
|
+
case 'ends_with':
|
|
220
|
+
return someMember(fieldValue, (v) => typeof v === 'string' && v.toLowerCase().endsWith(arg.toLowerCase()))
|
|
173
221
|
default:
|
|
174
|
-
// Unknown operator → fail closed.
|
|
175
222
|
return false
|
|
176
223
|
}
|
|
177
224
|
}
|
|
178
225
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// No deep equality for objects in v1 — keep the surface narrow.
|
|
190
|
-
return false
|
|
191
|
-
}
|
|
192
|
-
return false
|
|
226
|
+
/** Equality, typed; a list field equals a value when any member does. */
|
|
227
|
+
function equals(fieldValue, arg) {
|
|
228
|
+
if (Array.isArray(fieldValue)) return fieldValue.some((v) => v === arg)
|
|
229
|
+
return fieldValue === arg
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A condition on a value, or on any member when the field holds a list. */
|
|
233
|
+
function someMember(fieldValue, predicate) {
|
|
234
|
+
if (Array.isArray(fieldValue)) return fieldValue.some(predicate)
|
|
235
|
+
return predicate(fieldValue)
|
|
193
236
|
}
|
|
194
237
|
|
|
195
|
-
|
|
238
|
+
/** Numbers with numbers, text with text (ISO dates compare as text); nothing else. */
|
|
239
|
+
function comparable(a, b) {
|
|
196
240
|
if (a == null || b == null) return false
|
|
197
|
-
|
|
198
|
-
// Mixed types (string vs number) are a mismatch — return false rather
|
|
199
|
-
// than coerce.
|
|
200
|
-
return typeof a === typeof b
|
|
241
|
+
return typeof a === typeof b && typeof a !== 'object'
|
|
201
242
|
}
|
|
202
243
|
|
|
244
|
+
/** Not missing, null, "" or []. `0` and `false` are values. */
|
|
245
|
+
function hasValue(v) {
|
|
246
|
+
if (v === undefined || v === null || v === '') return false
|
|
247
|
+
if (Array.isArray(v) && v.length === 0) return false
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The value at a (possibly dotted) path. A path that meets a list before it ends
|
|
253
|
+
* descends into each item, and returns the values it reached as one list.
|
|
254
|
+
*/
|
|
203
255
|
function getPath(record, path) {
|
|
204
256
|
if (typeof path !== 'string') return undefined
|
|
205
257
|
if (path.indexOf('.') === -1) return record[path]
|
|
206
|
-
let
|
|
258
|
+
let current = [record]
|
|
259
|
+
let throughList = false
|
|
207
260
|
for (const segment of path.split('.')) {
|
|
208
|
-
|
|
209
|
-
|
|
261
|
+
const next = []
|
|
262
|
+
for (const node of current) {
|
|
263
|
+
if (node == null || typeof node !== 'object') continue
|
|
264
|
+
if (Array.isArray(node)) {
|
|
265
|
+
throughList = true
|
|
266
|
+
for (const item of node) {
|
|
267
|
+
if (item != null && typeof item === 'object' && !Array.isArray(item)) next.push(item[segment])
|
|
268
|
+
}
|
|
269
|
+
} else {
|
|
270
|
+
next.push(node[segment])
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
current = next
|
|
210
274
|
}
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
// Translate to a RegExp with anchors. Escape regex metacharacters
|
|
220
|
-
// except for our wildcards.
|
|
221
|
-
const re = '^' + pattern
|
|
222
|
-
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
223
|
-
.replace(/\*/g, '.*')
|
|
224
|
-
.replace(/\?/g, '.')
|
|
225
|
-
+ '$'
|
|
226
|
-
return new RegExp(re).test(value)
|
|
275
|
+
if (!throughList) return current[0]
|
|
276
|
+
const reached = []
|
|
277
|
+
for (const v of current) {
|
|
278
|
+
if (v === undefined) continue
|
|
279
|
+
if (Array.isArray(v)) reached.push(...v)
|
|
280
|
+
else reached.push(v)
|
|
281
|
+
}
|
|
282
|
+
return reached
|
|
227
283
|
}
|