@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.6.2",
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/semantic-parser": "1.1.9",
34
- "@uniweb/theming": "0.1.3"
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"
@@ -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
- configs.set(cfg.schema, this._localizeConfig(cfg, website))
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
+ }