@edgedev/template-engine 0.1.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/README.md +332 -0
- package/dist/hydrateValues.d.ts +11 -0
- package/dist/hydrateValues.d.ts.map +1 -0
- package/dist/hydrateValues.js +568 -0
- package/dist/hydrateValues.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +351 -0
- package/dist/index.js.map +1 -0
- package/dist/kvIndexClient.d.ts +49 -0
- package/dist/kvIndexClient.d.ts.map +1 -0
- package/dist/kvIndexClient.js +256 -0
- package/dist/kvIndexClient.js.map +1 -0
- package/dist/templateConfig.d.ts +3 -0
- package/dist/templateConfig.d.ts.map +1 -0
- package/dist/templateConfig.js +23 -0
- package/dist/templateConfig.js.map +1 -0
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/unoSsr.d.ts +21 -0
- package/dist/unoSsr.d.ts.map +1 -0
- package/dist/unoSsr.js +198 -0
- package/dist/unoSsr.js.map +1 -0
- package/package.json +34 -0
- package/src/hydrateValues.ts +753 -0
- package/src/index.ts +487 -0
- package/src/kvIndexClient.ts +374 -0
- package/src/templateConfig.ts +25 -0
- package/src/types.ts +54 -0
- package/src/unoSsr.ts +232 -0
- package/tsconfig.json +17 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import { ARRAY_BLOCK_RE, parseConfig } from './templateConfig'
|
|
2
|
+
import {
|
|
3
|
+
createKvIndexClient,
|
|
4
|
+
type KvIndexClientOptions,
|
|
5
|
+
type KvIndexRecord,
|
|
6
|
+
} from './kvIndexClient'
|
|
7
|
+
import type {
|
|
8
|
+
CollectionDefinition,
|
|
9
|
+
QueryFilter,
|
|
10
|
+
QueryItemsInput,
|
|
11
|
+
QueryOrder,
|
|
12
|
+
TemplateMeta,
|
|
13
|
+
TemplateValues,
|
|
14
|
+
} from './types'
|
|
15
|
+
|
|
16
|
+
export interface HydrateValuesOptions {
|
|
17
|
+
content: string
|
|
18
|
+
meta?: TemplateMeta
|
|
19
|
+
values?: TemplateValues
|
|
20
|
+
uniqueKey: string
|
|
21
|
+
clientOptions: KvIndexClientOptions
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RawCollectionConfig {
|
|
25
|
+
field: string
|
|
26
|
+
collection?: CollectionDefinition
|
|
27
|
+
queryItems?: QueryItemsInput
|
|
28
|
+
queryOptions?: QueryFilter[]
|
|
29
|
+
limit?: number | string | null
|
|
30
|
+
api?: string
|
|
31
|
+
apiField?: string
|
|
32
|
+
apiQuery?: unknown
|
|
33
|
+
value?: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface QueryItem {
|
|
37
|
+
searchKey: string
|
|
38
|
+
searchValue: string | string[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface NormalizedCollectionConfig {
|
|
42
|
+
field: string
|
|
43
|
+
baseKey?: string
|
|
44
|
+
apiUrl?: string
|
|
45
|
+
apiField?: string
|
|
46
|
+
queryItems: QueryItem[]
|
|
47
|
+
filters: QueryFilter[]
|
|
48
|
+
orders: QueryOrder[]
|
|
49
|
+
limit?: number
|
|
50
|
+
fallbackValue?: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isRecord = (input: unknown): input is Record<string, unknown> => {
|
|
54
|
+
return !!input && typeof input === 'object' && !Array.isArray(input)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const getValueAtPath = (record: unknown, path: string): unknown => {
|
|
58
|
+
if (!isRecord(record) || !path)
|
|
59
|
+
return undefined
|
|
60
|
+
const keys = path.split('.')
|
|
61
|
+
let current: unknown = record
|
|
62
|
+
for (const key of keys) {
|
|
63
|
+
if (!isRecord(current) || !(key in current))
|
|
64
|
+
return undefined
|
|
65
|
+
current = current[key]
|
|
66
|
+
}
|
|
67
|
+
return current
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const normalizeLimit = (value: unknown): number | undefined => {
|
|
71
|
+
if (value == null)
|
|
72
|
+
return undefined
|
|
73
|
+
const num = typeof value === 'number' ? value : Number(value)
|
|
74
|
+
if (!Number.isFinite(num) || num <= 0)
|
|
75
|
+
return undefined
|
|
76
|
+
return Math.floor(num)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const pickString = (value: unknown): string | undefined => {
|
|
80
|
+
if (typeof value !== 'string')
|
|
81
|
+
return undefined
|
|
82
|
+
const trimmed = value.trim()
|
|
83
|
+
return trimmed.length > 0 ? trimmed : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const normalizeSearchValue = (value: unknown): string | string[] | undefined => {
|
|
87
|
+
if (value == null)
|
|
88
|
+
return undefined
|
|
89
|
+
|
|
90
|
+
if (Array.isArray(value)) {
|
|
91
|
+
const normalized = value
|
|
92
|
+
.map((entry) => {
|
|
93
|
+
if (entry == null)
|
|
94
|
+
return undefined
|
|
95
|
+
if (typeof entry === 'string')
|
|
96
|
+
return entry
|
|
97
|
+
if (typeof entry === 'number' || typeof entry === 'boolean')
|
|
98
|
+
return String(entry)
|
|
99
|
+
if (typeof entry === 'object')
|
|
100
|
+
return JSON.stringify(entry)
|
|
101
|
+
return String(entry)
|
|
102
|
+
})
|
|
103
|
+
.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
|
|
104
|
+
return normalized.length > 0 ? normalized : undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (typeof value === 'string')
|
|
108
|
+
return value
|
|
109
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
110
|
+
return String(value)
|
|
111
|
+
if (typeof value === 'object')
|
|
112
|
+
return JSON.stringify(value)
|
|
113
|
+
|
|
114
|
+
return String(value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const normalizeQueryItems = (
|
|
118
|
+
input: QueryItemsInput,
|
|
119
|
+
fallbackField?: string,
|
|
120
|
+
): QueryItem[] => {
|
|
121
|
+
const items: QueryItem[] = []
|
|
122
|
+
|
|
123
|
+
const pushItem = (key: unknown, rawValue: unknown): void => {
|
|
124
|
+
if (typeof key !== 'string' || !key.trim())
|
|
125
|
+
return
|
|
126
|
+
const normalizedValue = normalizeSearchValue(rawValue)
|
|
127
|
+
if (normalizedValue == null)
|
|
128
|
+
return
|
|
129
|
+
items.push({
|
|
130
|
+
searchKey: key.trim(),
|
|
131
|
+
searchValue: normalizedValue,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!input)
|
|
136
|
+
return items
|
|
137
|
+
|
|
138
|
+
if (typeof input === 'string') {
|
|
139
|
+
if (fallbackField)
|
|
140
|
+
pushItem(fallbackField, input)
|
|
141
|
+
return items
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (Array.isArray(input)) {
|
|
145
|
+
const allStrings = input.every(entry => typeof entry === 'string')
|
|
146
|
+
if (allStrings) {
|
|
147
|
+
if (fallbackField) {
|
|
148
|
+
for (const value of input)
|
|
149
|
+
pushItem(fallbackField, value)
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
for (let i = 0; i < input.length - 1; i += 2)
|
|
153
|
+
pushItem(input[i], input[i + 1])
|
|
154
|
+
}
|
|
155
|
+
return items
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const entry of input) {
|
|
159
|
+
if (Array.isArray(entry)) {
|
|
160
|
+
const [key, value] = entry
|
|
161
|
+
pushItem(key, value)
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (typeof entry === 'string') {
|
|
165
|
+
if (fallbackField)
|
|
166
|
+
pushItem(fallbackField, entry)
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (isRecord(entry)) {
|
|
170
|
+
let key = entry.searchKey ?? entry.field ?? entry.key
|
|
171
|
+
let value = entry.searchValue ?? entry.value ?? entry.values ?? entry.options
|
|
172
|
+
|
|
173
|
+
if ((!key || typeof key !== 'string') && value === undefined) {
|
|
174
|
+
const owned = Object.entries(entry)
|
|
175
|
+
if (owned.length === 1) {
|
|
176
|
+
const [derivedKey, derivedValue] = owned[0]
|
|
177
|
+
key = derivedKey
|
|
178
|
+
value = derivedValue
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pushItem(key, value)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return items
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isRecord(input)) {
|
|
189
|
+
for (const [key, value] of Object.entries(input))
|
|
190
|
+
pushItem(key, value)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return items
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const buildApiUrl = (inputUrl: string, queryItems: QueryItem[]): string => {
|
|
197
|
+
if (!inputUrl || !queryItems.length)
|
|
198
|
+
return inputUrl
|
|
199
|
+
|
|
200
|
+
const hashIndex = inputUrl.indexOf('#')
|
|
201
|
+
const hash = hashIndex >= 0 ? inputUrl.slice(hashIndex) : ''
|
|
202
|
+
const beforeHash = hashIndex >= 0 ? inputUrl.slice(0, hashIndex) : inputUrl
|
|
203
|
+
|
|
204
|
+
const queryIndex = beforeHash.indexOf('?')
|
|
205
|
+
const path = queryIndex >= 0 ? beforeHash.slice(0, queryIndex) : beforeHash
|
|
206
|
+
const existingQuery = queryIndex >= 0 ? beforeHash.slice(queryIndex + 1) : ''
|
|
207
|
+
|
|
208
|
+
const params = new URLSearchParams(existingQuery)
|
|
209
|
+
|
|
210
|
+
for (const { searchKey, searchValue } of queryItems) {
|
|
211
|
+
if (!searchKey)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
const values = Array.isArray(searchValue) ? searchValue : [searchValue]
|
|
215
|
+
if (!values.length)
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
params.delete(searchKey)
|
|
219
|
+
for (const value of values)
|
|
220
|
+
params.append(searchKey, value)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const queryString = params.toString()
|
|
224
|
+
if (!queryString)
|
|
225
|
+
return `${path}${hash}`
|
|
226
|
+
return `${path}?${queryString}${hash}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const dedupeByCanonical = (records: KvIndexRecord[]): KvIndexRecord[] => {
|
|
230
|
+
const seen = new Set<string>()
|
|
231
|
+
const result: KvIndexRecord[] = []
|
|
232
|
+
for (const record of records) {
|
|
233
|
+
const canonical = getValueAtPath(record, 'canonical')
|
|
234
|
+
if (typeof canonical === 'string') {
|
|
235
|
+
if (seen.has(canonical))
|
|
236
|
+
continue
|
|
237
|
+
seen.add(canonical)
|
|
238
|
+
}
|
|
239
|
+
result.push(record)
|
|
240
|
+
}
|
|
241
|
+
return result
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const fetchApiRecords = async (
|
|
245
|
+
apiUrl: string,
|
|
246
|
+
queryItems: QueryItem[],
|
|
247
|
+
options: {
|
|
248
|
+
apiField?: string
|
|
249
|
+
fetchImpl?: KvIndexClientOptions['fetch']
|
|
250
|
+
},
|
|
251
|
+
): Promise<KvIndexRecord[]> => {
|
|
252
|
+
const { apiField, fetchImpl } = options
|
|
253
|
+
const urlToRequest = buildApiUrl(apiUrl, queryItems)
|
|
254
|
+
const fetchFn = fetchImpl ?? (globalThis.fetch as KvIndexClientOptions['fetch'] | undefined)
|
|
255
|
+
|
|
256
|
+
if (!fetchFn) {
|
|
257
|
+
console.warn('[hydrateValues] No fetch implementation available for API source', { url: apiUrl })
|
|
258
|
+
return []
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
const response = await fetchFn(urlToRequest)
|
|
263
|
+
if (!response.ok) {
|
|
264
|
+
console.warn('[hydrateValues] API request failed', { url: urlToRequest, status: response.status })
|
|
265
|
+
return []
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const rawData = await response.json()
|
|
269
|
+
const resolved = apiField ? (getValueAtPath(rawData, apiField) ?? rawData) : rawData
|
|
270
|
+
|
|
271
|
+
if (Array.isArray(resolved))
|
|
272
|
+
return resolved.filter(isRecord) as KvIndexRecord[]
|
|
273
|
+
|
|
274
|
+
if (isRecord(resolved))
|
|
275
|
+
return [resolved as KvIndexRecord]
|
|
276
|
+
|
|
277
|
+
return []
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
console.warn('[hydrateValues] Failed to fetch API source', { url: apiUrl, error })
|
|
281
|
+
return []
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const toComparableNumber = (value: unknown): number | null => {
|
|
286
|
+
if (typeof value === 'number')
|
|
287
|
+
return value
|
|
288
|
+
if (typeof value === 'string') {
|
|
289
|
+
const num = Number(value)
|
|
290
|
+
return Number.isFinite(num) ? num : null
|
|
291
|
+
}
|
|
292
|
+
if (typeof value === 'boolean')
|
|
293
|
+
return value ? 1 : 0
|
|
294
|
+
return null
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const compareValues = (left: unknown, right: unknown): number => {
|
|
298
|
+
if (left == null && right == null)
|
|
299
|
+
return 0
|
|
300
|
+
if (left == null)
|
|
301
|
+
return -1
|
|
302
|
+
if (right == null)
|
|
303
|
+
return 1
|
|
304
|
+
|
|
305
|
+
const leftNumber = toComparableNumber(left)
|
|
306
|
+
const rightNumber = toComparableNumber(right)
|
|
307
|
+
if (leftNumber != null && rightNumber != null)
|
|
308
|
+
return leftNumber - rightNumber
|
|
309
|
+
|
|
310
|
+
const leftString = String(left)
|
|
311
|
+
const rightString = String(right)
|
|
312
|
+
return leftString.localeCompare(rightString, undefined, { numeric: true, sensitivity: 'base' })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const ensureArray = (value: unknown): unknown[] => {
|
|
316
|
+
if (Array.isArray(value))
|
|
317
|
+
return value
|
|
318
|
+
if (value == null)
|
|
319
|
+
return []
|
|
320
|
+
return [value]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const matchesFilter = (record: KvIndexRecord, filter: QueryFilter): boolean => {
|
|
324
|
+
if (!filter || typeof filter.field !== 'string')
|
|
325
|
+
return true
|
|
326
|
+
|
|
327
|
+
const field = filter.field.trim()
|
|
328
|
+
if (!field)
|
|
329
|
+
return true
|
|
330
|
+
|
|
331
|
+
const left = getValueAtPath(record, field)
|
|
332
|
+
const operator = (filter.operator ?? '==').toLowerCase()
|
|
333
|
+
const singleValue = filter.value ?? (Array.isArray(filter.values) ? filter.values[0] : undefined)
|
|
334
|
+
const arrayValues = Array.isArray(filter.values)
|
|
335
|
+
? filter.values
|
|
336
|
+
: Array.isArray(filter.options)
|
|
337
|
+
? ensureArray(filter.options)
|
|
338
|
+
: Array.isArray(filter.value)
|
|
339
|
+
? ensureArray(filter.value)
|
|
340
|
+
: undefined
|
|
341
|
+
|
|
342
|
+
switch (operator) {
|
|
343
|
+
case '==':
|
|
344
|
+
case '=':
|
|
345
|
+
case '===':
|
|
346
|
+
case 'eq':
|
|
347
|
+
return left === singleValue
|
|
348
|
+
case '!=':
|
|
349
|
+
case '!==':
|
|
350
|
+
case '<>':
|
|
351
|
+
case 'neq':
|
|
352
|
+
return left !== singleValue
|
|
353
|
+
case '>':
|
|
354
|
+
case 'gt':
|
|
355
|
+
return compareValues(left, singleValue) > 0
|
|
356
|
+
case '>=':
|
|
357
|
+
case 'gte':
|
|
358
|
+
return compareValues(left, singleValue) >= 0
|
|
359
|
+
case '<':
|
|
360
|
+
case 'lt':
|
|
361
|
+
return compareValues(left, singleValue) < 0
|
|
362
|
+
case '<=':
|
|
363
|
+
case 'lte':
|
|
364
|
+
return compareValues(left, singleValue) <= 0
|
|
365
|
+
case 'in': {
|
|
366
|
+
const candidates = arrayValues ?? ensureArray(filter.value)
|
|
367
|
+
return candidates.some(candidate => candidate === left)
|
|
368
|
+
}
|
|
369
|
+
case 'not-in': {
|
|
370
|
+
const candidates = arrayValues ?? ensureArray(filter.value)
|
|
371
|
+
return !candidates.some(candidate => candidate === left)
|
|
372
|
+
}
|
|
373
|
+
case 'array-contains':
|
|
374
|
+
case 'contains': {
|
|
375
|
+
if (!Array.isArray(left))
|
|
376
|
+
return false
|
|
377
|
+
return left.some(entry => entry === singleValue)
|
|
378
|
+
}
|
|
379
|
+
case 'array-contains-any': {
|
|
380
|
+
if (!Array.isArray(left))
|
|
381
|
+
return false
|
|
382
|
+
const candidates = arrayValues ?? ensureArray(filter.value)
|
|
383
|
+
return candidates.some(candidate => left.some(entry => entry === candidate))
|
|
384
|
+
}
|
|
385
|
+
case 'array-contains-all': {
|
|
386
|
+
if (!Array.isArray(left))
|
|
387
|
+
return false
|
|
388
|
+
const candidates = arrayValues ?? ensureArray(filter.value)
|
|
389
|
+
return candidates.every(candidate => left.some(entry => entry === candidate))
|
|
390
|
+
}
|
|
391
|
+
case 'exists':
|
|
392
|
+
return left != null
|
|
393
|
+
case 'not-exists':
|
|
394
|
+
case 'missing':
|
|
395
|
+
return left == null
|
|
396
|
+
default:
|
|
397
|
+
return true
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const applyFilters = (records: KvIndexRecord[], filters: QueryFilter[]): KvIndexRecord[] => {
|
|
402
|
+
if (!filters.length)
|
|
403
|
+
return records
|
|
404
|
+
return records.filter(record => filters.every(filter => matchesFilter(record, filter)))
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const applyOrdering = (records: KvIndexRecord[], orders: QueryOrder[]): KvIndexRecord[] => {
|
|
408
|
+
if (!orders.length)
|
|
409
|
+
return records
|
|
410
|
+
|
|
411
|
+
const validOrders = orders
|
|
412
|
+
.filter(order => order && typeof order.field === 'string' && order.field.trim().length > 0)
|
|
413
|
+
|
|
414
|
+
if (!validOrders.length)
|
|
415
|
+
return records
|
|
416
|
+
|
|
417
|
+
const sorted = [...records]
|
|
418
|
+
sorted.sort((a, b) => {
|
|
419
|
+
for (const order of validOrders) {
|
|
420
|
+
const direction = (order.direction ?? 'asc').toLowerCase() === 'desc' ? -1 : 1
|
|
421
|
+
const comparison = compareValues(
|
|
422
|
+
getValueAtPath(a, order.field!),
|
|
423
|
+
getValueAtPath(b, order.field!),
|
|
424
|
+
)
|
|
425
|
+
if (comparison !== 0)
|
|
426
|
+
return comparison * direction
|
|
427
|
+
}
|
|
428
|
+
return 0
|
|
429
|
+
})
|
|
430
|
+
return sorted
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const applyLimit = (records: KvIndexRecord[], limit: number | undefined): KvIndexRecord[] => {
|
|
434
|
+
if (!limit || limit <= 0)
|
|
435
|
+
return records
|
|
436
|
+
return records.slice(0, limit)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const limitArrayValue = (value: unknown, limit: number | undefined): unknown => {
|
|
440
|
+
if (!Array.isArray(value))
|
|
441
|
+
return value
|
|
442
|
+
if (!limit || limit <= 0)
|
|
443
|
+
return [...value]
|
|
444
|
+
return (value as unknown[]).slice(0, limit)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const resolveBaseKey = (collection: CollectionDefinition, fallbackField: string): string | null => {
|
|
448
|
+
const candidates = [
|
|
449
|
+
typeof collection.baseKey === 'string' ? collection.baseKey.trim() : undefined,
|
|
450
|
+
typeof collection.path === 'string' ? collection.path.trim() : undefined,
|
|
451
|
+
fallbackField,
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
for (const candidate of candidates) {
|
|
455
|
+
if (candidate && candidate.length)
|
|
456
|
+
return candidate
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return null
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const extractTemplateConfigs = (content: string): Map<string, RawCollectionConfig> => {
|
|
463
|
+
const configs = new Map<string, RawCollectionConfig>()
|
|
464
|
+
if (!content)
|
|
465
|
+
return configs
|
|
466
|
+
|
|
467
|
+
const arrayBlockRegex = new RegExp(ARRAY_BLOCK_RE.source, ARRAY_BLOCK_RE.flags)
|
|
468
|
+
for (const match of content.matchAll(arrayBlockRegex)) {
|
|
469
|
+
const json = match[1]
|
|
470
|
+
const parsed = parseConfig(json) ?? {}
|
|
471
|
+
if (!isRecord(parsed))
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
const fieldRaw = parsed.field
|
|
475
|
+
if (typeof fieldRaw !== 'string' || !fieldRaw.trim())
|
|
476
|
+
continue
|
|
477
|
+
|
|
478
|
+
const field = fieldRaw.trim()
|
|
479
|
+
const config: RawCollectionConfig = { field }
|
|
480
|
+
|
|
481
|
+
if (parsed.collection && isRecord(parsed.collection))
|
|
482
|
+
config.collection = parsed.collection as CollectionDefinition
|
|
483
|
+
|
|
484
|
+
if ('queryItems' in parsed)
|
|
485
|
+
config.queryItems = parsed.queryItems as QueryItemsInput
|
|
486
|
+
|
|
487
|
+
// if (Array.isArray(parsed.queryOptions))
|
|
488
|
+
// config.queryOptions = parsed.queryOptions as QueryFilter[]
|
|
489
|
+
|
|
490
|
+
if ('limit' in parsed)
|
|
491
|
+
config.limit = parsed.limit as number | string | null
|
|
492
|
+
|
|
493
|
+
if (typeof parsed.api === 'string')
|
|
494
|
+
config.api = parsed.api
|
|
495
|
+
|
|
496
|
+
if (typeof parsed.apiField === 'string')
|
|
497
|
+
config.apiField = parsed.apiField
|
|
498
|
+
|
|
499
|
+
if ('apiQuery' in parsed)
|
|
500
|
+
config.apiQuery = parsed.apiQuery
|
|
501
|
+
|
|
502
|
+
if ('value' in parsed)
|
|
503
|
+
config.value = parsed.value
|
|
504
|
+
|
|
505
|
+
configs.set(field, config)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return configs
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const extractMetaConfigs = (meta?: TemplateMeta): Map<string, RawCollectionConfig> => {
|
|
512
|
+
const configs = new Map<string, RawCollectionConfig>()
|
|
513
|
+
if (!meta)
|
|
514
|
+
return configs
|
|
515
|
+
|
|
516
|
+
for (const [field, entry] of Object.entries(meta)) {
|
|
517
|
+
if (!entry)
|
|
518
|
+
continue
|
|
519
|
+
|
|
520
|
+
const trimmedField = field.trim()
|
|
521
|
+
if (!trimmedField)
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
const config: RawCollectionConfig = { field: trimmedField }
|
|
525
|
+
|
|
526
|
+
if (entry.collection && isRecord(entry.collection))
|
|
527
|
+
config.collection = entry.collection as CollectionDefinition
|
|
528
|
+
|
|
529
|
+
if ('queryItems' in entry)
|
|
530
|
+
config.queryItems = entry.queryItems as QueryItemsInput
|
|
531
|
+
|
|
532
|
+
// if (Array.isArray(entry.queryOptions))
|
|
533
|
+
// config.queryOptions = entry.queryOptions as QueryFilter[]
|
|
534
|
+
|
|
535
|
+
if ('limit' in entry)
|
|
536
|
+
config.limit = entry.limit as number | string | null
|
|
537
|
+
|
|
538
|
+
if (typeof entry.api === 'string')
|
|
539
|
+
config.api = entry.api
|
|
540
|
+
|
|
541
|
+
if (typeof entry.apiField === 'string')
|
|
542
|
+
config.apiField = entry.apiField
|
|
543
|
+
|
|
544
|
+
if ('apiQuery' in entry)
|
|
545
|
+
config.apiQuery = entry.apiQuery
|
|
546
|
+
|
|
547
|
+
if ('value' in entry)
|
|
548
|
+
config.value = entry.value
|
|
549
|
+
|
|
550
|
+
configs.set(trimmedField, config)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return configs
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const mergeCollections = (
|
|
557
|
+
base?: CollectionDefinition,
|
|
558
|
+
override?: CollectionDefinition,
|
|
559
|
+
): CollectionDefinition | null => {
|
|
560
|
+
if (!base && !override)
|
|
561
|
+
return null
|
|
562
|
+
return {
|
|
563
|
+
path: (override?.path ?? base?.path) ?? undefined,
|
|
564
|
+
baseKey: (override?.baseKey ?? base?.baseKey) ?? undefined,
|
|
565
|
+
query: Array.isArray(override?.query)
|
|
566
|
+
? override.query
|
|
567
|
+
: Array.isArray(base?.query)
|
|
568
|
+
? base.query
|
|
569
|
+
: undefined,
|
|
570
|
+
order: Array.isArray(override?.order)
|
|
571
|
+
? override.order
|
|
572
|
+
: Array.isArray(base?.order)
|
|
573
|
+
? base.order
|
|
574
|
+
: undefined,
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const firstOptionField = (options?: QueryFilter[]): string | undefined => {
|
|
579
|
+
if (!Array.isArray(options))
|
|
580
|
+
return undefined
|
|
581
|
+
for (const option of options) {
|
|
582
|
+
if (option && typeof option.field === 'string' && option.field.trim())
|
|
583
|
+
return option.field.trim()
|
|
584
|
+
}
|
|
585
|
+
return undefined
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const toValidFilters = (...sources: Array<QueryFilter[] | undefined>): QueryFilter[] => {
|
|
589
|
+
const filters: QueryFilter[] = []
|
|
590
|
+
for (const source of sources) {
|
|
591
|
+
if (!Array.isArray(source))
|
|
592
|
+
continue
|
|
593
|
+
for (const entry of source) {
|
|
594
|
+
if (entry && typeof entry.field === 'string' && entry.field.trim())
|
|
595
|
+
filters.push(entry)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return filters
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const toValidOrders = (orders?: QueryOrder[]): QueryOrder[] => {
|
|
602
|
+
if (!Array.isArray(orders))
|
|
603
|
+
return []
|
|
604
|
+
return orders
|
|
605
|
+
.filter(order => order && typeof order.field === 'string' && order.field.trim())
|
|
606
|
+
.map(order => ({
|
|
607
|
+
field: order.field.trim(),
|
|
608
|
+
direction: order.direction,
|
|
609
|
+
}))
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const buildNormalizedConfigs = (
|
|
613
|
+
templateConfigs: Map<string, RawCollectionConfig>,
|
|
614
|
+
metaConfigs: Map<string, RawCollectionConfig>,
|
|
615
|
+
initialValues?: TemplateValues,
|
|
616
|
+
): NormalizedCollectionConfig[] => {
|
|
617
|
+
const fields = new Set<string>([
|
|
618
|
+
...templateConfigs.keys(),
|
|
619
|
+
...metaConfigs.keys(),
|
|
620
|
+
])
|
|
621
|
+
|
|
622
|
+
const normalized: NormalizedCollectionConfig[] = []
|
|
623
|
+
|
|
624
|
+
for (const field of fields) {
|
|
625
|
+
const base = templateConfigs.get(field)
|
|
626
|
+
const override = metaConfigs.get(field)
|
|
627
|
+
|
|
628
|
+
const collection = mergeCollections(base?.collection, override?.collection)
|
|
629
|
+
const baseKey = collection ? resolveBaseKey(collection, field) ?? undefined : undefined
|
|
630
|
+
const apiUrl = pickString(override?.api ?? base?.api)
|
|
631
|
+
const apiField = pickString(override?.apiField ?? base?.apiField)
|
|
632
|
+
|
|
633
|
+
const selectedQueryItems = override?.queryItems ?? base?.queryItems
|
|
634
|
+
const fallbackField = firstOptionField(override?.queryOptions) ?? firstOptionField(base?.queryOptions)
|
|
635
|
+
const queryItems = normalizeQueryItems(selectedQueryItems, fallbackField)
|
|
636
|
+
const searchKeys = new Set(queryItems.map(item => item.searchKey))
|
|
637
|
+
const filters = collection && baseKey
|
|
638
|
+
? toValidFilters(collection.query, override?.queryOptions ?? base?.queryOptions)
|
|
639
|
+
.filter(filter => !searchKeys.has(filter.field.trim()))
|
|
640
|
+
: []
|
|
641
|
+
const orders = collection && baseKey
|
|
642
|
+
? toValidOrders(collection.order)
|
|
643
|
+
: []
|
|
644
|
+
const limit = normalizeLimit(override?.limit ?? base?.limit)
|
|
645
|
+
const fallbackValue = override?.value ?? base?.value ?? initialValues?.[field]
|
|
646
|
+
|
|
647
|
+
if (!baseKey && !apiUrl && fallbackValue === undefined)
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
normalized.push({
|
|
651
|
+
field,
|
|
652
|
+
baseKey,
|
|
653
|
+
apiUrl,
|
|
654
|
+
apiField,
|
|
655
|
+
queryItems,
|
|
656
|
+
filters,
|
|
657
|
+
orders,
|
|
658
|
+
limit,
|
|
659
|
+
fallbackValue,
|
|
660
|
+
})
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return normalized
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export const hydrateValues = async (
|
|
667
|
+
options: HydrateValuesOptions,
|
|
668
|
+
): Promise<TemplateValues> => {
|
|
669
|
+
const {
|
|
670
|
+
content,
|
|
671
|
+
meta,
|
|
672
|
+
values,
|
|
673
|
+
uniqueKey,
|
|
674
|
+
clientOptions,
|
|
675
|
+
} = options
|
|
676
|
+
const templateConfigs = extractTemplateConfigs(content)
|
|
677
|
+
const metaConfigs = extractMetaConfigs(meta)
|
|
678
|
+
const normalizedConfigs = buildNormalizedConfigs(templateConfigs, metaConfigs, values)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
const resultValues: TemplateValues = { ...(values ?? {}) }
|
|
682
|
+
|
|
683
|
+
if (!normalizedConfigs.length)
|
|
684
|
+
return resultValues
|
|
685
|
+
|
|
686
|
+
const kvClient = createKvIndexClient(clientOptions)
|
|
687
|
+
const apiFetch = clientOptions.fetch ?? (globalThis.fetch as KvIndexClientOptions['fetch'] | undefined)
|
|
688
|
+
|
|
689
|
+
for (const config of normalizedConfigs) {
|
|
690
|
+
const hasApiSource = !!(config.apiUrl && config.apiUrl.length)
|
|
691
|
+
const canQueryCollection = !!config.baseKey
|
|
692
|
+
|
|
693
|
+
if (!hasApiSource && !canQueryCollection) {
|
|
694
|
+
if (config.fallbackValue !== undefined) {
|
|
695
|
+
resultValues[config.field] = Array.isArray(config.fallbackValue)
|
|
696
|
+
? limitArrayValue(config.fallbackValue, config.limit)
|
|
697
|
+
: config.fallbackValue
|
|
698
|
+
}
|
|
699
|
+
continue
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
let records: KvIndexRecord[] = []
|
|
703
|
+
|
|
704
|
+
if (hasApiSource && config.apiUrl) {
|
|
705
|
+
records = await fetchApiRecords(config.apiUrl, config.queryItems, {
|
|
706
|
+
apiField: config.apiField,
|
|
707
|
+
fetchImpl: apiFetch,
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
else if (canQueryCollection && config.baseKey) {
|
|
711
|
+
const collected: KvIndexRecord[] = []
|
|
712
|
+
const queryTargets = config.queryItems.length
|
|
713
|
+
? config.queryItems
|
|
714
|
+
: [undefined]
|
|
715
|
+
|
|
716
|
+
for (const item of queryTargets) {
|
|
717
|
+
const enrichedParams = {
|
|
718
|
+
baseKey: config.baseKey,
|
|
719
|
+
uniqueKey,
|
|
720
|
+
...(item?.searchKey ? { searchKey: item.searchKey } : {}),
|
|
721
|
+
...(item?.searchValue !== undefined ? { searchValue: item.searchValue } : {}),
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const queried = await kvClient.queryIndex(enrichedParams)
|
|
725
|
+
|
|
726
|
+
if (Array.isArray(queried))
|
|
727
|
+
collected.push(...queried)
|
|
728
|
+
}
|
|
729
|
+
records = collected
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (!records.length) {
|
|
733
|
+
if (config.fallbackValue !== undefined) {
|
|
734
|
+
resultValues[config.field] = Array.isArray(config.fallbackValue)
|
|
735
|
+
? limitArrayValue(config.fallbackValue, config.limit)
|
|
736
|
+
: config.fallbackValue
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
resultValues[config.field] = []
|
|
740
|
+
}
|
|
741
|
+
continue
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const deduped = dedupeByCanonical(records)
|
|
745
|
+
const filtered = applyFilters(deduped, config.filters)
|
|
746
|
+
const ordered = applyOrdering(filtered, config.orders)
|
|
747
|
+
const limited = applyLimit(ordered, config.limit)
|
|
748
|
+
|
|
749
|
+
resultValues[config.field] = limited
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return resultValues
|
|
753
|
+
}
|