@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/src/index.ts ADDED
@@ -0,0 +1,487 @@
1
+ // TODO: ADD AS SEPERATE FILE/ FUNCTION THE QUERYING OF DATA FROM FROM EITHER THE API OR THE KV STORE... BUT IT WOUDLN'T BE CALLED SEPERATELY
2
+ // IT WOULD BE CALLED AS PART OF THE RENDERING PROCESS... IF API OR KV IS SPECIFIED IN THE META OR TEMPLATE CONFIG,
3
+ // THIS, WOULD GET THE VALUES THEN PASS THEM TO THE RENDER FUNCTION BELOW
4
+ // ALSO HAVE HELPER FUNCTIONS THIS USES AND WE CAN USE ELSEWHERE TO DO GET KV DATA AND QUERY KV DATA BASED ON OUR INDEX STRUCTURE
5
+ // ALSO TAKE A LOOK AT OUR BLOCKS WE ARE GETTING... ALSO MAKE SURE THAT THE META WE PASS IN AS A VAR IS AN OVERRIDE OF THE TEMPLATE META...
6
+ // MAKE SURE THE META IN THE TEMPLATE IS BEING USED. MAKE SURE SAME
7
+
8
+ import { ARRAY_BLOCK_RE, parseConfig } from './templateConfig'
9
+ import { hydrateValues, type HydrateValuesOptions } from './hydrateValues'
10
+ import { unoCssFromHtml } from './unoSsr'
11
+ import type { MetaEntry, SchemaMap, TemplateMeta, TemplateValues } from './types'
12
+
13
+ export { hydrateValues }
14
+ export type { HydrateValuesOptions }
15
+ export type {
16
+ SchemaValue,
17
+ SchemaArrayEntry,
18
+ SchemaMap,
19
+ MetaEntry,
20
+ TemplateMeta,
21
+ TemplateValues,
22
+ CollectionDefinition,
23
+ QueryFilter,
24
+ QueryOrder,
25
+ QueryItemsInput,
26
+ } from './types'
27
+
28
+ export {
29
+ createKvIndexClient,
30
+ type KvIndexClient,
31
+ type KvIndexClientOptions,
32
+ type KvIndexQueryParams,
33
+ type KVNamespaceLike,
34
+ type KvIndexRecord,
35
+ } from './kvIndexClient'
36
+
37
+ export {
38
+ normalizeTheme,
39
+ buildCssVarsBlock,
40
+ } from './unoSsr'
41
+ export { unoCssFromHtml }
42
+
43
+ type Primitive = string | number | boolean | null | undefined
44
+
45
+ const PLACEHOLDER_RE = /(?<!\{)\{\{\s*({[\s\S]*?})\s*\}\}(?!\})/g
46
+ const SUBARRAY_BLOCK_RE = /\{\{\{\s*#subarray(?::([A-Za-z_][A-Za-z0-9_-]*))?\s*(?:({[\s\S]*?}))?\s*\}\}\}([\s\S]*?)\{\{\{\s*\/subarray\s*\}\}\}/g
47
+ const SIMPLE_BLOCK_RE = /\{\{\{\s*#(text|image|textarea|richtext)\s*({[\s\S]*?})\s*\}\}\}/g
48
+ const IF_BLOCK_RE = /\{\{\{\s*#if\s*({[\s\S]*?})\s*\}\}\}([\s\S]*?)(?:\{\{\{\s*#else\s*\}\}\}([\s\S]*?))?\{\{\{\s*\/if\s*\}\}\}/g
49
+
50
+ const getByPath = (obj: unknown, path: string): unknown => {
51
+ if (!path || typeof path !== 'string')
52
+ return obj
53
+ return path.split('.').reduce<unknown>((acc, key) => {
54
+ if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>))
55
+ return (acc as Record<string, unknown>)[key]
56
+ return undefined
57
+ }, obj)
58
+ }
59
+
60
+ const escapeHtml = (value: Primitive): string => {
61
+ return String(value ?? '')
62
+ .replace(/&/g, '&amp;')
63
+ .replace(/</g, '&lt;')
64
+ .replace(/>/g, '&gt;')
65
+ .replace(/"/g, '&quot;')
66
+ }
67
+
68
+ const normalizeNumber = (value: unknown): number | null => {
69
+ if (value == null || value === '')
70
+ return null
71
+ const n = Number(value)
72
+ return Number.isFinite(n) ? n : null
73
+ }
74
+
75
+ const formatters: Record<string, (value: unknown) => string> = {
76
+ text: v => (v == null ? '' : String(v)),
77
+ textarea: v => (v == null ? '' : String(v)),
78
+ number: (v) => {
79
+ const n = normalizeNumber(v)
80
+ return typeof n === 'number' ? n.toLocaleString('en-US') : (v == null ? '' : String(v))
81
+ },
82
+ integer: (v) => {
83
+ const n = normalizeNumber(v)
84
+ return typeof n === 'number' ? String(Math.trunc(n)) : (v == null ? '' : String(v))
85
+ },
86
+ money: (v) => {
87
+ const n = normalizeNumber(v)
88
+ return typeof n === 'number'
89
+ ? n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
90
+ : (v == null ? '' : String(v))
91
+ },
92
+ richtext: v => (v == null ? '' : String(v)),
93
+ }
94
+
95
+ const getFieldType = (schemaMap: SchemaMap | undefined, fieldKey: string | undefined): string | undefined => {
96
+ if (!schemaMap || !fieldKey)
97
+ return undefined
98
+
99
+ const baseKey = String(fieldKey).split('.')[0]
100
+
101
+ if (Array.isArray(schemaMap)) {
102
+ const hit = schemaMap.find(entry => entry?.field === baseKey)
103
+ return hit?.type ?? hit?.value
104
+ }
105
+
106
+ return schemaMap[baseKey]
107
+ }
108
+
109
+ const applySchemaFormat = (fieldKey: string | undefined, value: unknown, schemaMap: SchemaMap | undefined): string => {
110
+ if (!schemaMap || !fieldKey)
111
+ return value == null ? '' : String(value)
112
+
113
+ const type = getFieldType(schemaMap, fieldKey)
114
+ const formatter = type ? formatters[type] : undefined
115
+ return formatter ? formatter(value) : (value == null ? '' : String(value))
116
+ }
117
+
118
+ const coerceList = (input: unknown): unknown[] => {
119
+ return Array.isArray(input) ? input : []
120
+ }
121
+
122
+ const sliceWithLimit = (list: unknown[], limit: unknown): unknown[] => {
123
+ const n = Number(limit)
124
+ return Number.isFinite(n) && n > 0 ? list.slice(0, n) : list
125
+ }
126
+
127
+ const getCommonList = (source: Record<string, unknown>): unknown => {
128
+ for (const key of ['agents', 'items', 'children']) {
129
+ const candidate = source[key]
130
+ if (Array.isArray(candidate))
131
+ return candidate
132
+ }
133
+ return undefined
134
+ }
135
+
136
+ const hasOwn = (obj: Record<string, unknown>, key: string): boolean => {
137
+ return Object.prototype.hasOwnProperty.call(obj, key)
138
+ }
139
+
140
+ const aliasRegex = (alias: string): RegExp => {
141
+ const esc = alias.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
142
+ return new RegExp(`\\{\\{\\s*${esc}\\.([a-zA-Z0-9_.$]+)\\s*\\}\\}`, 'g')
143
+ }
144
+
145
+ export const renderTemplate = (
146
+ content: string,
147
+ values: TemplateValues = {},
148
+ meta: TemplateMeta = {},
149
+ ): string => {
150
+ if (!content || typeof content !== 'string')
151
+ return ''
152
+ const renderSection = (
153
+ template: string,
154
+ data: unknown,
155
+ alias?: string,
156
+ schemaCtx?: SchemaMap,
157
+ ): string => {
158
+ if (!template)
159
+ return ''
160
+
161
+ const scopedValues = typeof data === 'object' && data !== null
162
+ ? (data as Record<string, unknown>)
163
+ : {}
164
+
165
+ let expanded = template.replace(SUBARRAY_BLOCK_RE, (_match, tagAlias, json, innerTpl) => {
166
+ const cfg = parseConfig(json || '{}') ?? {}
167
+ const fieldRaw = typeof cfg.field === 'string' ? cfg.field.trim() : ''
168
+ let list: unknown
169
+
170
+ if (fieldRaw) {
171
+ if (fieldRaw.startsWith('item.')) {
172
+ list = getByPath(scopedValues, fieldRaw.slice(5))
173
+ }
174
+ else {
175
+ list = scopedValues[fieldRaw] ?? getByPath(scopedValues, fieldRaw)
176
+ if (!Array.isArray(list))
177
+ list = getByPath(values, fieldRaw)
178
+ }
179
+ }
180
+ else if (Array.isArray(cfg.value)) {
181
+ list = cfg.value
182
+ }
183
+
184
+ if (!Array.isArray(list)) {
185
+ const fallback = getCommonList(scopedValues)
186
+ if (Array.isArray(fallback))
187
+ list = fallback
188
+ }
189
+
190
+ let normalized = coerceList(list)
191
+ normalized = sliceWithLimit(normalized, cfg.limit)
192
+
193
+ const childAlias = typeof cfg.as === 'string' && cfg.as.trim()
194
+ ? cfg.as.trim()
195
+ : (tagAlias?.trim() || undefined)
196
+
197
+ const childSchema = typeof cfg.schema === 'object' ? (cfg.schema as SchemaMap) : schemaCtx
198
+ return normalized.map(entry => renderSection(innerTpl, entry, childAlias, childSchema)).join('')
199
+ })
200
+
201
+ expanded = expanded.replace(ARRAY_BLOCK_RE, (_m, json, innerTpl) => {
202
+ const cfg = parseConfig(json || '{}') ?? {}
203
+ const fieldRaw = typeof cfg.field === 'string' ? cfg.field.trim() : ''
204
+ let list: unknown
205
+
206
+ if (fieldRaw) {
207
+ if (fieldRaw.startsWith('item.')) {
208
+ list = getByPath(scopedValues, fieldRaw.slice(5))
209
+ }
210
+ else {
211
+ list = scopedValues[fieldRaw] ?? getByPath(scopedValues, fieldRaw)
212
+ if (!Array.isArray(list))
213
+ list = getByPath(values, fieldRaw)
214
+ }
215
+ }
216
+ else if (Array.isArray(cfg.value)) {
217
+ list = cfg.value
218
+ }
219
+
220
+ if (!Array.isArray(list)) {
221
+ const fallback = getCommonList(scopedValues)
222
+ if (Array.isArray(fallback))
223
+ list = fallback
224
+ }
225
+
226
+ let normalized = coerceList(list)
227
+ normalized = sliceWithLimit(normalized, cfg.limit)
228
+
229
+ const childAlias = typeof cfg.as === 'string' && cfg.as.trim() ? cfg.as.trim() : undefined
230
+ const childSchema = typeof cfg.schema === 'object' ? (cfg.schema as SchemaMap) : schemaCtx
231
+ return normalized.map(entry => renderSection(innerTpl, entry, childAlias, childSchema)).join('')
232
+ })
233
+
234
+ if (alias && typeof alias === 'string') {
235
+ expanded = expanded.replace(aliasRegex(alias), (_match, path) => {
236
+ const value = getByPath(scopedValues, path)
237
+ const type = getFieldType(schemaCtx, path)
238
+ const formatted = applySchemaFormat(path, value, schemaCtx)
239
+ return type === 'richtext'
240
+ ? (formatted ?? '')
241
+ : escapeHtml(formatted)
242
+ })
243
+
244
+ expanded = expanded.replace(new RegExp(`\\{\\{\\s*${alias}\\s*\\}\\}`, 'g'), () => {
245
+ return data == null ? '' : escapeHtml(String(data))
246
+ })
247
+ }
248
+
249
+ expanded = expanded.replace(/\{\{\s*item\.([a-zA-Z0-9_.$]+)\s*\}\}/g, (_match, path) => {
250
+ const value = getByPath(scopedValues, path)
251
+ const type = getFieldType(schemaCtx, path)
252
+ const formatted = applySchemaFormat(path, value, schemaCtx)
253
+ if (type === 'richtext')
254
+ return formatted ?? ''
255
+ return formatted === '' ? '' : escapeHtml(formatted)
256
+ })
257
+
258
+ expanded = expanded.replace(/\{\{\s*item\s*\}\}/g, () => {
259
+ return data == null ? '' : escapeHtml(String(data))
260
+ })
261
+
262
+ expanded = expanded.replace(IF_BLOCK_RE, (_match, json, trueTpl, falseTpl) => {
263
+ const cfg = parseConfig(json || '{}') ?? {}
264
+ const cond = typeof cfg.cond === 'string' ? cfg.cond.trim() : ''
265
+ const comparison = cond.match(/^item\.([a-zA-Z0-9_.$]+)\s*(==|!=|>|<|>=|<=)\s*['"]?([^'"]+)['"]?$/)
266
+ let result = false
267
+
268
+ if (comparison) {
269
+ const [, path, op, valueRaw] = comparison
270
+ const leftValue = getByPath(scopedValues, path)
271
+ const normalize = (input: unknown): string | number => {
272
+ const num = Number(input)
273
+ if (Number.isFinite(num) && `${input}`.trim() !== '')
274
+ return num
275
+ return String(input ?? '')
276
+ }
277
+
278
+ const left = normalize(leftValue)
279
+ const right = normalize(valueRaw)
280
+
281
+ switch (op) {
282
+ case '==':
283
+ result = left === right
284
+ break
285
+ case '!=':
286
+ result = left !== right
287
+ break
288
+ case '>':
289
+ result = left > right
290
+ break
291
+ case '<':
292
+ result = left < right
293
+ break
294
+ case '>=':
295
+ result = left >= right
296
+ break
297
+ case '<=':
298
+ result = left <= right
299
+ break
300
+ }
301
+ }
302
+
303
+ return result
304
+ ? renderSection(trueTpl, data, alias, schemaCtx)
305
+ : renderSection(falseTpl ?? '', data, alias, schemaCtx)
306
+ })
307
+
308
+ return expanded
309
+ }
310
+
311
+ let output = content
312
+
313
+ output = output.replace(SIMPLE_BLOCK_RE, (_match, kind, json) => {
314
+ const cfg = parseConfig(json) ?? {}
315
+ const field = typeof cfg.field === 'string' ? cfg.field : undefined
316
+ const fallback = cfg.value
317
+ const raw = field && hasOwn(values, field)
318
+ ? values[field]
319
+ : fallback
320
+ const value = raw == null ? '' : raw
321
+ if (kind === 'text' || kind === 'textarea')
322
+ return escapeHtml(String(value))
323
+ if (kind === 'richtext')
324
+ return String(value)
325
+ return String(value)
326
+ })
327
+
328
+ output = output.replace(ARRAY_BLOCK_RE, (full, json, innerTpl) => {
329
+ const cfg = parseConfig(json) ?? {}
330
+ const field = typeof cfg.field === 'string' ? cfg.field : undefined
331
+
332
+ if (field?.startsWith('item.'))
333
+ return full
334
+
335
+ let list: unknown = field ? values[field] : undefined
336
+ if (!Array.isArray(list) && Array.isArray(cfg.value))
337
+ list = cfg.value
338
+
339
+ let normalized = coerceList(list)
340
+ normalized = sliceWithLimit(normalized, cfg.limit)
341
+
342
+ const alias = typeof cfg.as === 'string' && cfg.as.trim() ? cfg.as.trim() : undefined
343
+ const schemaContext = field && meta[field]?.schema
344
+ ? meta[field]?.schema
345
+ : (typeof cfg.schema === 'object' ? (cfg.schema as SchemaMap) : undefined)
346
+
347
+ return normalized.map(entry => renderSection(innerTpl, entry, alias, schemaContext)).join('')
348
+ })
349
+
350
+ output = output.replace(PLACEHOLDER_RE, (_match, json) => {
351
+ const cfg = parseConfig(json) ?? {}
352
+ const field = typeof cfg.field === 'string' ? cfg.field : undefined
353
+ const type = typeof cfg.type === 'string' ? cfg.type : undefined
354
+ const raw = field && hasOwn(values, field)
355
+ ? values[field]
356
+ : cfg.value
357
+ const value = raw == null ? '' : raw
358
+
359
+ if (type === 'text')
360
+ return escapeHtml(String(value))
361
+ if (type === 'richtext')
362
+ return String(value)
363
+ return String(value)
364
+ })
365
+
366
+ output = output.replace(/\{\{\s*loading\s*\}\}/g, 'loading')
367
+ output = output.replace(/\{\{\s*loaded\s*\}\}/g, 'loaded')
368
+
369
+ return output
370
+ }
371
+
372
+ export interface PageRenderBlock {
373
+ name?: string | null
374
+ content: string
375
+ values?: TemplateValues | null
376
+ meta?: TemplateMeta | null
377
+ }
378
+
379
+ export interface PageRenderResultBlock {
380
+ name?: string
381
+ html: string
382
+ }
383
+
384
+ export interface PageRenderResult {
385
+ blocks: PageRenderResultBlock[]
386
+ css: string
387
+ }
388
+
389
+ export type PageRenderHydrateOptions = Pick<HydrateValuesOptions, 'uniqueKey' | 'clientOptions'>
390
+
391
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
392
+ return !!value && typeof value === 'object' && !Array.isArray(value)
393
+ }
394
+
395
+ const coerceTemplateValues = (input: TemplateValues | null | undefined): TemplateValues | undefined => {
396
+ return isPlainObject(input) ? (input as TemplateValues) : undefined
397
+ }
398
+
399
+ const coerceTemplateMeta = (input: TemplateMeta | null | undefined): TemplateMeta | undefined => {
400
+ return isPlainObject(input) ? (input as TemplateMeta) : undefined
401
+ }
402
+
403
+ const buildHydrateOptions = (
404
+ input?: PageRenderHydrateOptions | null,
405
+ ): PageRenderHydrateOptions => {
406
+ const fallbackFetch = typeof globalThis.fetch === 'function'
407
+ ? (globalThis.fetch.bind(globalThis) as HydrateValuesOptions['clientOptions']['fetch'])
408
+ : undefined
409
+
410
+ const safeUniqueKey = typeof input?.uniqueKey === 'string' ? input.uniqueKey : ''
411
+ const providedClientOptions = input?.clientOptions
412
+
413
+ const safeClientOptions = providedClientOptions && typeof providedClientOptions === 'object'
414
+ ? {
415
+ ...providedClientOptions,
416
+ fetch: providedClientOptions.fetch ?? fallbackFetch,
417
+ }
418
+ : {
419
+ binding: null,
420
+ accountId: '',
421
+ namespaceId: '',
422
+ apiToken: '',
423
+ fetch: fallbackFetch,
424
+ }
425
+
426
+ if (!safeClientOptions.fetch)
427
+ throw new Error('pageRender requires a fetch implementation (globalThis.fetch or clientOptions.fetch)')
428
+
429
+ return {
430
+ uniqueKey: safeUniqueKey,
431
+ clientOptions: safeClientOptions,
432
+ }
433
+ }
434
+
435
+ export const pageRender = async (
436
+ blocks: PageRenderBlock[],
437
+ theme: Record<string, unknown> | null | undefined,
438
+ extraHtml: string | null | undefined = '',
439
+ hydrateOptions?: PageRenderHydrateOptions | null,
440
+ ): Promise<PageRenderResult> => {
441
+ const renderedBlocks: PageRenderResultBlock[] = []
442
+ const htmlFragments: string[] = []
443
+ const hydrationConfig = buildHydrateOptions(hydrateOptions)
444
+
445
+ for (const block of blocks) {
446
+ if (!block?.content) {
447
+ const blockName = typeof block?.name === 'string' ? block.name : undefined
448
+ renderedBlocks.push({ name: blockName, html: '' })
449
+ continue
450
+ }
451
+
452
+ const meta = coerceTemplateMeta(block.meta)
453
+ const initialValues = coerceTemplateValues(block.values)
454
+ const resolvedValues = await hydrateValues({
455
+ content: block.content,
456
+ values: initialValues,
457
+ meta,
458
+ uniqueKey: hydrationConfig.uniqueKey,
459
+ clientOptions: hydrationConfig.clientOptions,
460
+ })
461
+
462
+ const html = renderTemplate(
463
+ block.content,
464
+ resolvedValues ?? undefined,
465
+ meta,
466
+ )
467
+
468
+ const blockName = typeof block.name === 'string' ? block.name : undefined
469
+ renderedBlocks.push({ name: blockName, html })
470
+ if (html)
471
+ htmlFragments.push(html)
472
+ }
473
+
474
+ if (extraHtml && typeof extraHtml === 'string')
475
+ htmlFragments.push(extraHtml)
476
+
477
+ const combinedHtml = htmlFragments.join('\n')
478
+ const safeTheme = theme && typeof theme === 'object' ? theme : {}
479
+ const { css } = await unoCssFromHtml(combinedHtml, safeTheme)
480
+
481
+ return {
482
+ blocks: renderedBlocks,
483
+ css,
484
+ }
485
+ }
486
+
487
+ export default renderTemplate