@uniweb/core 0.6.2 → 0.7.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 +3 -3
- package/src/entity-store.js +33 -1
- package/src/index.js +1 -0
- package/src/where.js +223 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"jest": "^29.7.0"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@uniweb/
|
|
34
|
-
"@uniweb/
|
|
33
|
+
"@uniweb/theming": "0.1.3",
|
|
34
|
+
"@uniweb/semantic-parser": "1.1.9"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
|
package/src/entity-store.js
CHANGED
|
@@ -137,13 +137,45 @@ export default class EntityStore {
|
|
|
137
137
|
if (!cfg.schema) continue
|
|
138
138
|
if (configs.has(cfg.schema)) continue
|
|
139
139
|
if (collectAll || requested.includes(cfg.schema)) {
|
|
140
|
-
|
|
140
|
+
const localized = this._localizeConfig(cfg, website)
|
|
141
|
+
const withDetail = this._applyDeferredDetail(localized, website)
|
|
142
|
+
configs.set(cfg.schema, withDetail)
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
return configs
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Auto-inject `detail:` on collection refs whose collection has
|
|
151
|
+
* `deferred:` declared. The build emits per-record files at
|
|
152
|
+
* `/data/<name>/<slug>.json` for those collections; this populates
|
|
153
|
+
* the detail-fetch URL so the existing dynamic-route singular flow
|
|
154
|
+
* uses the per-record file (with deferred fields) instead of the
|
|
155
|
+
* matched-item-from-cascade-collection (without).
|
|
156
|
+
*
|
|
157
|
+
* Conventions:
|
|
158
|
+
* - Per-record files are keyed by `item.slug`. The injected pattern
|
|
159
|
+
* uses the `{slug}` placeholder; substitution works when the
|
|
160
|
+
* dynamic route's paramName is 'slug' (the documented convention).
|
|
161
|
+
* Routes using other param names need an explicit author-written
|
|
162
|
+
* `detail:` value.
|
|
163
|
+
* - Author-supplied `cfg.detail` always wins. This helper only fills
|
|
164
|
+
* in the default for collections that have declared deferred fields.
|
|
165
|
+
* - Per-record files are not currently localized; sites needing
|
|
166
|
+
* localized deferred collections write their own `detail:` URL.
|
|
167
|
+
*/
|
|
168
|
+
_applyDeferredDetail(cfg, website) {
|
|
169
|
+
if (cfg.detail !== undefined) return cfg
|
|
170
|
+
const schema = cfg.schema
|
|
171
|
+
if (!schema) return cfg
|
|
172
|
+
const collConfig = website?.config?.collections?.[schema]
|
|
173
|
+
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
174
|
+
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
175
|
+
if (!deferred || deferred.length === 0) return cfg
|
|
176
|
+
return { ...cfg, detail: `/data/${schema}/{slug}.json` }
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
/**
|
|
148
180
|
* Build a detail-URL fetch config from a collection config + dynamic context.
|
|
149
181
|
*
|
package/src/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { default as singularize } from './singularize.js'
|
|
23
23
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
24
|
+
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* The singleton Uniweb instance.
|
package/src/where.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where-object evaluator.
|
|
3
|
+
*
|
|
4
|
+
* A where-object is a structured JSON predicate. The format is small,
|
|
5
|
+
* additive, and YAML/JSON-native — there is no DSL or parser. The same
|
|
6
|
+
* predicate travels from author YAML, through transports, to backends
|
|
7
|
+
* (which translate to their native query language) or to this evaluator
|
|
8
|
+
* (which walks the object against a record).
|
|
9
|
+
*
|
|
10
|
+
* Architecture: see kb/framework/architecture/data-fetching.md.
|
|
11
|
+
*
|
|
12
|
+
* Shape:
|
|
13
|
+
*
|
|
14
|
+
* {
|
|
15
|
+
* // Top-level keys are field names; values are the values to match.
|
|
16
|
+
* // Implicit AND across keys.
|
|
17
|
+
* department: 'biology',
|
|
18
|
+
* tenured: true,
|
|
19
|
+
*
|
|
20
|
+
* // For non-equality, the value is an operator object.
|
|
21
|
+
* start_year: { gte: 2010 },
|
|
22
|
+
* rank: { in: ['associate', 'full'] },
|
|
23
|
+
* title: { like: 'Origin*' },
|
|
24
|
+
*
|
|
25
|
+
* // Explicit composition keys at any nesting level.
|
|
26
|
+
* and: [{ tenured: true }, { rank: 'full' }],
|
|
27
|
+
* or: [{ rank: 'full' }, { years_in_role: { gte: 10 } }],
|
|
28
|
+
* not: { department: 'emeritus' },
|
|
29
|
+
* }
|
|
30
|
+
*
|
|
31
|
+
* Operators (in operator-object form):
|
|
32
|
+
*
|
|
33
|
+
* eq Equal (also implicit when the value is bare, non-object, non-null).
|
|
34
|
+
* ne Not equal.
|
|
35
|
+
* gt/gte Greater than / greater than or equal.
|
|
36
|
+
* lt/lte Less than / less than or equal.
|
|
37
|
+
* in Value is in the listed array.
|
|
38
|
+
* nin Value is not in the listed array.
|
|
39
|
+
* like Glob match (`*` any run, `?` one char). String fields only.
|
|
40
|
+
* exists Field is truthy (boolean toggle).
|
|
41
|
+
*
|
|
42
|
+
* Composition keys:
|
|
43
|
+
*
|
|
44
|
+
* and Array of sub-predicates; all must match.
|
|
45
|
+
* or Array of sub-predicates; at least one must match.
|
|
46
|
+
* not Single sub-predicate; must not match.
|
|
47
|
+
*
|
|
48
|
+
* Dotted paths descend into nested objects: `tenure.start: { gte: 2015 }`.
|
|
49
|
+
*
|
|
50
|
+
* Type safety: type mismatches return `false` rather than throwing
|
|
51
|
+
* (e.g., comparing a string to a number with `gt`). Missing fields
|
|
52
|
+
* return `false` for equality and most operators; `exists: false` matches
|
|
53
|
+
* missing/falsy fields.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
const COMPOSITION_KEYS = new Set(['and', 'or', 'not'])
|
|
57
|
+
const OPERATORS = new Set([
|
|
58
|
+
'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'nin', 'like', 'exists',
|
|
59
|
+
])
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a where-object against a single record.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} where - The where-object predicate.
|
|
65
|
+
* @param {Object} record - The record to test.
|
|
66
|
+
* @returns {boolean} true if the record matches.
|
|
67
|
+
*/
|
|
68
|
+
export function evaluate(where, record) {
|
|
69
|
+
if (where == null) return true
|
|
70
|
+
if (typeof where !== 'object' || Array.isArray(where)) return false
|
|
71
|
+
if (record == null || typeof record !== 'object') return false
|
|
72
|
+
|
|
73
|
+
// Implicit AND across all top-level keys.
|
|
74
|
+
for (const key of Object.keys(where)) {
|
|
75
|
+
if (!evaluateClause(key, where[key], record)) return false
|
|
76
|
+
}
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Filter an array of records by a where-object predicate.
|
|
82
|
+
*
|
|
83
|
+
* @param {Object} where - The where-object predicate.
|
|
84
|
+
* @param {Array<Object>} records - The records to filter.
|
|
85
|
+
* @returns {Array<Object>} Records in source order for which the predicate is true.
|
|
86
|
+
*/
|
|
87
|
+
export function match(where, records) {
|
|
88
|
+
if (!Array.isArray(records)) return []
|
|
89
|
+
if (where == null) return records.slice()
|
|
90
|
+
return records.filter((r) => evaluate(where, r))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Internals ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function evaluateClause(key, value, record) {
|
|
96
|
+
// Composition keys.
|
|
97
|
+
if (key === 'and') {
|
|
98
|
+
if (!Array.isArray(value)) return false
|
|
99
|
+
return value.every((sub) => evaluate(sub, record))
|
|
100
|
+
}
|
|
101
|
+
if (key === 'or') {
|
|
102
|
+
if (!Array.isArray(value)) return false
|
|
103
|
+
return value.some((sub) => evaluate(sub, record))
|
|
104
|
+
}
|
|
105
|
+
if (key === 'not') {
|
|
106
|
+
return !evaluate(value, record)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Field clause: key is a (possibly dotted) field name; value is either
|
|
110
|
+
// a bare value (implicit eq) or an operator-object.
|
|
111
|
+
const fieldValue = getPath(record, key)
|
|
112
|
+
|
|
113
|
+
if (value === null) {
|
|
114
|
+
return fieldValue === null || fieldValue === undefined
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (typeof value === 'object' && !Array.isArray(value) && isOperatorObject(value)) {
|
|
118
|
+
return evaluateOperatorObject(value, fieldValue)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bare value (string, number, boolean, array): implicit equality.
|
|
122
|
+
return matchEqual(fieldValue, value)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isOperatorObject(value) {
|
|
126
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) return false
|
|
127
|
+
// An operator-object's keys are all in OPERATORS. If even one key isn't
|
|
128
|
+
// an operator, it's not an operator-object — it might be a nested
|
|
129
|
+
// sub-predicate or a structured equality target. The latter is rare;
|
|
130
|
+
// we treat any object whose keys are all known operators as an
|
|
131
|
+
// operator-object, otherwise fall back to deep-equality matching.
|
|
132
|
+
const keys = Object.keys(value)
|
|
133
|
+
if (keys.length === 0) return false
|
|
134
|
+
return keys.every((k) => OPERATORS.has(k))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function evaluateOperatorObject(opObject, fieldValue) {
|
|
138
|
+
for (const op of Object.keys(opObject)) {
|
|
139
|
+
if (!evaluateOperator(op, opObject[op], fieldValue)) return false
|
|
140
|
+
}
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function evaluateOperator(op, opValue, fieldValue) {
|
|
145
|
+
switch (op) {
|
|
146
|
+
case 'eq':
|
|
147
|
+
return matchEqual(fieldValue, opValue)
|
|
148
|
+
case 'ne':
|
|
149
|
+
return !matchEqual(fieldValue, opValue)
|
|
150
|
+
case 'gt':
|
|
151
|
+
return compareCanRun(fieldValue, opValue) && fieldValue > opValue
|
|
152
|
+
case 'gte':
|
|
153
|
+
return compareCanRun(fieldValue, opValue) && fieldValue >= opValue
|
|
154
|
+
case 'lt':
|
|
155
|
+
return compareCanRun(fieldValue, opValue) && fieldValue < opValue
|
|
156
|
+
case 'lte':
|
|
157
|
+
return compareCanRun(fieldValue, opValue) && fieldValue <= opValue
|
|
158
|
+
case 'in':
|
|
159
|
+
if (!Array.isArray(opValue)) return false
|
|
160
|
+
return opValue.some((v) => matchEqual(fieldValue, v))
|
|
161
|
+
case 'nin':
|
|
162
|
+
if (!Array.isArray(opValue)) return false
|
|
163
|
+
return !opValue.some((v) => matchEqual(fieldValue, v))
|
|
164
|
+
case 'like':
|
|
165
|
+
if (typeof fieldValue !== 'string' || typeof opValue !== 'string') return false
|
|
166
|
+
return globMatch(opValue, fieldValue)
|
|
167
|
+
case 'exists':
|
|
168
|
+
return Boolean(fieldValue) === Boolean(opValue)
|
|
169
|
+
default:
|
|
170
|
+
// Unknown operator → fail closed.
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function matchEqual(a, b) {
|
|
176
|
+
if (a === b) return true
|
|
177
|
+
if (a == null || b == null) return false
|
|
178
|
+
// Array-on-either-side: if `a` is an array (record's field), match if
|
|
179
|
+
// any element equals b. This makes `tags: 'featured'` match a record
|
|
180
|
+
// with `tags: ['featured', 'sale']`.
|
|
181
|
+
if (Array.isArray(a) && !Array.isArray(b)) {
|
|
182
|
+
return a.some((v) => v === b)
|
|
183
|
+
}
|
|
184
|
+
if (typeof a === 'object' || typeof b === 'object') {
|
|
185
|
+
// No deep equality for objects in v1 — keep the surface narrow.
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
return false
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function compareCanRun(a, b) {
|
|
192
|
+
if (a == null || b == null) return false
|
|
193
|
+
// Numbers and ISO-date strings (which compare correctly with </>=) are fine.
|
|
194
|
+
// Mixed types (string vs number) are a mismatch — return false rather
|
|
195
|
+
// than coerce.
|
|
196
|
+
return typeof a === typeof b
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function getPath(record, path) {
|
|
200
|
+
if (typeof path !== 'string') return undefined
|
|
201
|
+
if (path.indexOf('.') === -1) return record[path]
|
|
202
|
+
let cursor = record
|
|
203
|
+
for (const segment of path.split('.')) {
|
|
204
|
+
if (cursor == null || typeof cursor !== 'object') return undefined
|
|
205
|
+
cursor = cursor[segment]
|
|
206
|
+
}
|
|
207
|
+
return cursor
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Shell-glob match: `*` matches any run of characters, `?` matches one char.
|
|
212
|
+
* Anchored — the pattern must match the whole string.
|
|
213
|
+
*/
|
|
214
|
+
function globMatch(pattern, value) {
|
|
215
|
+
// Translate to a RegExp with anchors. Escape regex metacharacters
|
|
216
|
+
// except for our wildcards.
|
|
217
|
+
const re = '^' + pattern
|
|
218
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
219
|
+
.replace(/\*/g, '.*')
|
|
220
|
+
.replace(/\?/g, '.')
|
|
221
|
+
+ '$'
|
|
222
|
+
return new RegExp(re).test(value)
|
|
223
|
+
}
|