@domql/brender 3.2.7
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 +282 -0
- package/dist/cjs/env.js +57 -0
- package/dist/cjs/hydrate.js +472 -0
- package/dist/cjs/index.js +58 -0
- package/dist/cjs/keys.js +62 -0
- package/dist/cjs/load.js +82 -0
- package/dist/cjs/metadata.js +102 -0
- package/dist/cjs/render.js +341 -0
- package/dist/esm/env.js +38 -0
- package/dist/esm/hydrate.js +453 -0
- package/dist/esm/index.js +39 -0
- package/dist/esm/keys.js +43 -0
- package/dist/esm/load.js +63 -0
- package/dist/esm/metadata.js +83 -0
- package/dist/esm/render.js +311 -0
- package/env.js +43 -0
- package/hydrate.js +388 -0
- package/index.js +40 -0
- package/keys.js +54 -0
- package/load.js +81 -0
- package/metadata.js +117 -0
- package/package.json +48 -0
- package/render.js +386 -0
package/hydrate.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side hydration — reconnects pre-rendered HTML (with data-br keys)
|
|
3
|
+
* to a live DomQL element tree, attaches events, and fires lifecycle hooks.
|
|
4
|
+
*
|
|
5
|
+
* After hydration the DomQL tree owns every DOM node:
|
|
6
|
+
* - el.node points to the real DOM element
|
|
7
|
+
* - node.ref points back to the DomQL element
|
|
8
|
+
* - CSS classes are generated via emotion and applied
|
|
9
|
+
* - DOM events (click, input, etc.) are bound
|
|
10
|
+
* - on.render / on.renderRouter callbacks fire
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Collects all elements with data-br attributes from the document.
|
|
15
|
+
* Returns a map of brKey -> DOM node.
|
|
16
|
+
*/
|
|
17
|
+
export const collectBrNodes = (root) => {
|
|
18
|
+
const container = root || document
|
|
19
|
+
const nodes = container.querySelectorAll('[data-br]')
|
|
20
|
+
const map = {}
|
|
21
|
+
nodes.forEach(node => {
|
|
22
|
+
map[node.getAttribute('data-br')] = node
|
|
23
|
+
})
|
|
24
|
+
return map
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walks a DomQL element tree that was created with onlyResolveExtends.
|
|
29
|
+
* For each element with a __brKey, attaches the matching real DOM node,
|
|
30
|
+
* renders CSS via emotion, binds DOM events, and fires lifecycle hooks.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} element - Root DomQL element (from create with onlyResolveExtends)
|
|
33
|
+
* @param {object} [options]
|
|
34
|
+
* @param {Element} [options.root] - Root DOM element to scan for data-br nodes
|
|
35
|
+
* @param {boolean} [options.events=true] - Attach DOM events (click, input, etc.)
|
|
36
|
+
* @param {boolean} [options.renderEvents=true] - Fire on.render / on.renderRouter
|
|
37
|
+
* @param {object} [options.emotion] - Emotion instance for CSS class generation
|
|
38
|
+
* @param {object} [options.designSystem] - Design system with color/media/spacing definitions
|
|
39
|
+
* @returns {{ element: object, linked: number, unlinked: number }}
|
|
40
|
+
*/
|
|
41
|
+
export const hydrate = (element, options = {}) => {
|
|
42
|
+
const {
|
|
43
|
+
root,
|
|
44
|
+
events: attachEvents = true,
|
|
45
|
+
renderEvents: fireRenderEvents = true,
|
|
46
|
+
emotion,
|
|
47
|
+
designSystem
|
|
48
|
+
} = options
|
|
49
|
+
|
|
50
|
+
const brNodes = collectBrNodes(root)
|
|
51
|
+
const colorMap = designSystem?.color || {}
|
|
52
|
+
const mediaMap = designSystem?.media || {}
|
|
53
|
+
let linked = 0
|
|
54
|
+
let unlinked = 0
|
|
55
|
+
|
|
56
|
+
const walk = (el) => {
|
|
57
|
+
if (!el || !el.__ref) return
|
|
58
|
+
|
|
59
|
+
const brKey = el.__ref.__brKey
|
|
60
|
+
if (brKey) {
|
|
61
|
+
const node = brNodes[brKey]
|
|
62
|
+
if (node) {
|
|
63
|
+
el.node = node
|
|
64
|
+
node.ref = el
|
|
65
|
+
|
|
66
|
+
if (emotion) {
|
|
67
|
+
renderCSS(el, emotion, colorMap, mediaMap)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (attachEvents) {
|
|
71
|
+
bindEvents(el)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
linked++
|
|
75
|
+
} else {
|
|
76
|
+
unlinked++
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (el.__ref.__children) {
|
|
81
|
+
for (const childKey of el.__ref.__children) {
|
|
82
|
+
const child = el[childKey]
|
|
83
|
+
if (child && child.__ref) walk(child)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
walk(element)
|
|
89
|
+
|
|
90
|
+
if (fireRenderEvents) {
|
|
91
|
+
fireLifecycle(element)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { element, linked, unlinked }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Renders CSS for an element: resolves props into a CSS object,
|
|
99
|
+
* resolves design system values (colors, media queries, pseudo-classes),
|
|
100
|
+
* generates emotion class name, and applies it to the DOM node.
|
|
101
|
+
*/
|
|
102
|
+
const renderCSS = (el, emotion, colorMap, mediaMap) => {
|
|
103
|
+
const { node, props } = el
|
|
104
|
+
if (!node || !props) return
|
|
105
|
+
|
|
106
|
+
const css = {}
|
|
107
|
+
let hasCss = false
|
|
108
|
+
|
|
109
|
+
for (const key in props) {
|
|
110
|
+
const val = props[key]
|
|
111
|
+
|
|
112
|
+
// @media breakpoint objects: @mobile, @tablet, etc.
|
|
113
|
+
if (key.charCodeAt(0) === 64) {
|
|
114
|
+
const breakpoint = mediaMap[key.slice(1)]
|
|
115
|
+
if (breakpoint && typeof val === 'object') {
|
|
116
|
+
const mediaCss = resolvePropsToCSS(val, colorMap)
|
|
117
|
+
if (Object.keys(mediaCss).length) {
|
|
118
|
+
css[breakpoint] = mediaCss
|
|
119
|
+
hasCss = true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// :pseudo-class objects: :hover, :focus, :active, etc.
|
|
126
|
+
if (key.charCodeAt(0) === 58) {
|
|
127
|
+
if (typeof val === 'object') {
|
|
128
|
+
const pseudoCss = resolvePropsToCSS(val, colorMap)
|
|
129
|
+
if (Object.keys(pseudoCss).length) {
|
|
130
|
+
css['&' + key] = pseudoCss
|
|
131
|
+
hasCss = true
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve DomQL shorthands (flexAlign, round, boxSize, etc.)
|
|
138
|
+
const expanded = resolveShorthand(key, val)
|
|
139
|
+
if (expanded) {
|
|
140
|
+
for (const ek in expanded) {
|
|
141
|
+
css[ek] = resolveValue(ek, expanded[ek], colorMap)
|
|
142
|
+
}
|
|
143
|
+
hasCss = true
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Skip non-CSS props
|
|
148
|
+
if (!isCSS(key)) continue
|
|
149
|
+
|
|
150
|
+
// Resolve design system color values
|
|
151
|
+
css[key] = resolveValue(key, val, colorMap)
|
|
152
|
+
hasCss = true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Handle element.style object
|
|
156
|
+
if (el.style && typeof el.style === 'object') {
|
|
157
|
+
Object.assign(css, el.style)
|
|
158
|
+
hasCss = true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasCss) return
|
|
162
|
+
|
|
163
|
+
// Generate emotion class
|
|
164
|
+
const emotionClass = emotion.css(css)
|
|
165
|
+
|
|
166
|
+
// Build final class string
|
|
167
|
+
const classes = []
|
|
168
|
+
if (emotionClass) classes.push(emotionClass)
|
|
169
|
+
|
|
170
|
+
// Preserve key-based classname (keys starting with _ become class names)
|
|
171
|
+
if (typeof el.key === 'string' && el.key.charCodeAt(0) === 95 && el.key.charCodeAt(1) !== 95) {
|
|
172
|
+
classes.push(el.key.slice(1))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Preserve explicit class from props/attr
|
|
176
|
+
if (props.class) classes.push(props.class)
|
|
177
|
+
if (el.attr?.class) classes.push(el.attr.class)
|
|
178
|
+
|
|
179
|
+
// Handle classlist
|
|
180
|
+
const classlist = el.classlist
|
|
181
|
+
if (classlist) {
|
|
182
|
+
if (typeof classlist === 'string') classes.push(classlist)
|
|
183
|
+
else if (typeof classlist === 'object') {
|
|
184
|
+
for (const k in classlist) {
|
|
185
|
+
const v = classlist[k]
|
|
186
|
+
if (typeof v === 'boolean' && v) classes.push(k)
|
|
187
|
+
else if (typeof v === 'string') classes.push(v)
|
|
188
|
+
else if (typeof v === 'object' && v) classes.push(emotion.css(v))
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (classes.length) {
|
|
194
|
+
node.setAttribute('class', classes.join(' '))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clean up CSS prop attributes that leaked into HTML
|
|
198
|
+
for (const key in props) {
|
|
199
|
+
if (isCSS(key) && node.hasAttribute(key)) {
|
|
200
|
+
node.removeAttribute(key)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const resolvePropsToCSS = (propsObj, colorMap) => {
|
|
206
|
+
const css = {}
|
|
207
|
+
for (const key in propsObj) {
|
|
208
|
+
const expanded = resolveShorthand(key, propsObj[key])
|
|
209
|
+
if (expanded) {
|
|
210
|
+
for (const ek in expanded) css[ek] = resolveValue(ek, expanded[ek], colorMap)
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
if (!isCSS(key)) continue
|
|
214
|
+
css[key] = resolveValue(key, propsObj[key], colorMap)
|
|
215
|
+
}
|
|
216
|
+
return css
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const COLOR_PROPS = new Set([
|
|
220
|
+
'color', 'background', 'backgroundColor', 'borderColor',
|
|
221
|
+
'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
|
|
222
|
+
'outlineColor', 'fill', 'stroke', 'caretColor', 'columnRuleColor',
|
|
223
|
+
'textDecorationColor', 'boxShadow', 'textShadow'
|
|
224
|
+
])
|
|
225
|
+
|
|
226
|
+
const resolveValue = (key, val, colorMap) => {
|
|
227
|
+
if (typeof val !== 'string') return val
|
|
228
|
+
if (COLOR_PROPS.has(key) && colorMap[val]) return colorMap[val]
|
|
229
|
+
return val
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const NON_CSS_PROPS = new Set([
|
|
233
|
+
'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
|
|
234
|
+
'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
|
|
235
|
+
'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
|
|
236
|
+
'theme', '__element', 'update'
|
|
237
|
+
])
|
|
238
|
+
|
|
239
|
+
// DomQL shorthand props that expand to multiple CSS properties
|
|
240
|
+
const resolveShorthand = (key, val) => {
|
|
241
|
+
if (key === 'flexAlign' && typeof val === 'string') {
|
|
242
|
+
const [alignItems, justifyContent] = val.split(' ')
|
|
243
|
+
return { display: 'flex', alignItems, justifyContent }
|
|
244
|
+
}
|
|
245
|
+
if (key === 'gridAlign' && typeof val === 'string') {
|
|
246
|
+
const [alignItems, justifyContent] = val.split(' ')
|
|
247
|
+
return { display: 'grid', alignItems, justifyContent }
|
|
248
|
+
}
|
|
249
|
+
if (key === 'round' && val) {
|
|
250
|
+
return { borderRadius: typeof val === 'number' ? val + 'px' : val }
|
|
251
|
+
}
|
|
252
|
+
if (key === 'boxSize' && val) {
|
|
253
|
+
return { width: val, height: val }
|
|
254
|
+
}
|
|
255
|
+
return null
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const isCSS = (key) => {
|
|
259
|
+
const ch = key.charCodeAt(0)
|
|
260
|
+
if (ch === 95 || ch === 64 || ch === 58) return false
|
|
261
|
+
if (ch >= 65 && ch <= 90) return false
|
|
262
|
+
if (NON_CSS_PROPS.has(key)) return false
|
|
263
|
+
return CSS_PROPERTIES.has(key)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const CSS_PROPERTIES = new Set([
|
|
267
|
+
'display', 'position', 'top', 'right', 'bottom', 'left',
|
|
268
|
+
'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight',
|
|
269
|
+
'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
|
|
270
|
+
'marginBlock', 'marginInline',
|
|
271
|
+
'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
272
|
+
'paddingBlock', 'paddingInline',
|
|
273
|
+
'border', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
|
|
274
|
+
'borderRadius', 'borderColor', 'borderWidth', 'borderStyle',
|
|
275
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
|
276
|
+
'borderTopStyle', 'borderRightStyle', 'borderBottomStyle', 'borderLeftStyle',
|
|
277
|
+
'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
|
|
278
|
+
'borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius',
|
|
279
|
+
'background', 'backgroundColor', 'backgroundImage', 'backgroundSize', 'backgroundPosition',
|
|
280
|
+
'backgroundRepeat', 'backgroundAttachment',
|
|
281
|
+
'color', 'fontSize', 'fontWeight', 'fontFamily', 'fontStyle',
|
|
282
|
+
'lineHeight', 'letterSpacing', 'textAlign', 'textDecoration', 'textTransform',
|
|
283
|
+
'textIndent', 'textOverflow', 'textShadow',
|
|
284
|
+
'opacity', 'overflow', 'overflowX', 'overflowY',
|
|
285
|
+
'zIndex', 'cursor', 'pointerEvents', 'userSelect',
|
|
286
|
+
'flex', 'flexDirection', 'flexWrap', 'flexFlow', 'flexGrow', 'flexShrink', 'flexBasis',
|
|
287
|
+
'alignItems', 'alignContent', 'alignSelf',
|
|
288
|
+
'justifyContent', 'justifyItems', 'justifySelf',
|
|
289
|
+
'gap', 'rowGap', 'columnGap',
|
|
290
|
+
'gridTemplateColumns', 'gridTemplateRows', 'gridColumn', 'gridRow',
|
|
291
|
+
'gridArea', 'gridAutoFlow', 'gridAutoColumns', 'gridAutoRows',
|
|
292
|
+
'transform', 'transformOrigin', 'transition', 'animation', 'animationDelay',
|
|
293
|
+
'boxShadow', 'outline', 'outlineColor', 'outlineWidth', 'outlineStyle', 'outlineOffset',
|
|
294
|
+
'whiteSpace', 'wordBreak', 'wordWrap', 'overflowWrap',
|
|
295
|
+
'visibility', 'boxSizing', 'objectFit', 'objectPosition',
|
|
296
|
+
'filter', 'backdropFilter', 'mixBlendMode',
|
|
297
|
+
'fill', 'stroke', 'strokeWidth',
|
|
298
|
+
'listStyle', 'listStyleType', 'listStylePosition',
|
|
299
|
+
'counterReset', 'counterIncrement', 'content',
|
|
300
|
+
'aspectRatio', 'resize', 'appearance',
|
|
301
|
+
'scrollBehavior', 'scrollMargin', 'scrollPadding',
|
|
302
|
+
'willChange', 'contain', 'isolation',
|
|
303
|
+
'caretColor', 'accentColor',
|
|
304
|
+
'columnCount', 'columnGap', 'columnRuleColor', 'columnRuleStyle', 'columnRuleWidth',
|
|
305
|
+
'textDecorationColor', 'textDecorationStyle', 'textDecorationThickness',
|
|
306
|
+
'clipPath', 'shapeOutside'
|
|
307
|
+
])
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Binds DOM events from element.on and element.props onto the real node.
|
|
311
|
+
*/
|
|
312
|
+
const DOMQL_LIFECYCLE = new Set([
|
|
313
|
+
'render', 'create', 'init', 'start', 'complete', 'done',
|
|
314
|
+
'beforeClassAssign', 'attachNode', 'stateInit', 'stateCreated',
|
|
315
|
+
'renderRouter', 'lazyLoad', 'error'
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
const bindEvents = (el) => {
|
|
319
|
+
const { node, on, props } = el
|
|
320
|
+
if (!node) return
|
|
321
|
+
|
|
322
|
+
const handled = new Set()
|
|
323
|
+
|
|
324
|
+
if (on) {
|
|
325
|
+
for (const param in on) {
|
|
326
|
+
if (DOMQL_LIFECYCLE.has(param)) continue
|
|
327
|
+
if (typeof on[param] !== 'function') continue
|
|
328
|
+
handled.add(param)
|
|
329
|
+
addListener(node, param, on[param], el)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (props) {
|
|
334
|
+
for (const key in props) {
|
|
335
|
+
if (key.length <= 2 || key[0] !== 'o' || key[1] !== 'n') continue
|
|
336
|
+
if (typeof props[key] !== 'function') continue
|
|
337
|
+
const third = key[2]
|
|
338
|
+
if (third !== third.toUpperCase()) continue
|
|
339
|
+
const eventName = third.toLowerCase() + key.slice(3)
|
|
340
|
+
if (handled.has(eventName) || DOMQL_LIFECYCLE.has(eventName)) continue
|
|
341
|
+
addListener(node, eventName, props[key], el)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const addListener = (node, eventName, handler, el) => {
|
|
347
|
+
node.addEventListener(eventName, (event) => {
|
|
348
|
+
const result = handler.call(el, event, el, el.state, el.context)
|
|
349
|
+
if (result && typeof result.then === 'function') {
|
|
350
|
+
result.catch(() => {})
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Walks the tree and fires on.render, on.renderRouter, on.done, on.create
|
|
357
|
+
* lifecycle events — the same ones that fire during normal DomQL create.
|
|
358
|
+
*/
|
|
359
|
+
const fireLifecycle = (el) => {
|
|
360
|
+
if (!el || !el.__ref || !el.node) return
|
|
361
|
+
|
|
362
|
+
const on = el.on
|
|
363
|
+
if (on) {
|
|
364
|
+
fireEvent(on.render, el)
|
|
365
|
+
fireEvent(on.renderRouter, el)
|
|
366
|
+
fireEvent(on.done, el)
|
|
367
|
+
fireEvent(on.create, el)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (el.__ref.__children) {
|
|
371
|
+
for (const childKey of el.__ref.__children) {
|
|
372
|
+
const child = el[childKey]
|
|
373
|
+
if (child && child.__ref) fireLifecycle(child)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const fireEvent = (fn, el) => {
|
|
379
|
+
if (typeof fn !== 'function') return
|
|
380
|
+
try {
|
|
381
|
+
const result = fn.call(el, el, el.state, el.context)
|
|
382
|
+
if (result && typeof result.then === 'function') {
|
|
383
|
+
result.catch(() => {})
|
|
384
|
+
}
|
|
385
|
+
} catch (e) {
|
|
386
|
+
console.warn('[brender hydrate]', el.key, e.message)
|
|
387
|
+
}
|
|
388
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createEnv } from './env.js'
|
|
2
|
+
import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
|
|
3
|
+
import { loadProject, loadAndRenderAll } from './load.js'
|
|
4
|
+
import { render, renderElement, renderRoute, renderPage } from './render.js'
|
|
5
|
+
import { extractMetadata, generateHeadHtml } from './metadata.js'
|
|
6
|
+
import { collectBrNodes, hydrate } from './hydrate.js'
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
createEnv,
|
|
10
|
+
resetKeys,
|
|
11
|
+
assignKeys,
|
|
12
|
+
mapKeysToElements,
|
|
13
|
+
loadProject,
|
|
14
|
+
loadAndRenderAll,
|
|
15
|
+
render,
|
|
16
|
+
renderElement,
|
|
17
|
+
renderRoute,
|
|
18
|
+
renderPage,
|
|
19
|
+
extractMetadata,
|
|
20
|
+
generateHeadHtml,
|
|
21
|
+
collectBrNodes,
|
|
22
|
+
hydrate
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default {
|
|
26
|
+
createEnv,
|
|
27
|
+
resetKeys,
|
|
28
|
+
assignKeys,
|
|
29
|
+
mapKeysToElements,
|
|
30
|
+
loadProject,
|
|
31
|
+
loadAndRenderAll,
|
|
32
|
+
render,
|
|
33
|
+
renderElement,
|
|
34
|
+
renderRoute,
|
|
35
|
+
renderPage,
|
|
36
|
+
extractMetadata,
|
|
37
|
+
generateHeadHtml,
|
|
38
|
+
collectBrNodes,
|
|
39
|
+
hydrate
|
|
40
|
+
}
|
package/keys.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
let _keyCounter = 0
|
|
2
|
+
|
|
3
|
+
export const resetKeys = () => {
|
|
4
|
+
_keyCounter = 0
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Recursively assigns `data-br` attributes to all element nodes.
|
|
9
|
+
* These keys allow qsql to remap static HTML back onto DomQL elements.
|
|
10
|
+
*/
|
|
11
|
+
export const assignKeys = (node) => {
|
|
12
|
+
if (!node) return
|
|
13
|
+
|
|
14
|
+
if (node.nodeType === 1) {
|
|
15
|
+
const key = `br-${_keyCounter++}`
|
|
16
|
+
node.setAttribute('data-br', key)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const children = node.childNodes
|
|
20
|
+
if (children) {
|
|
21
|
+
for (let i = 0; i < children.length; i++) {
|
|
22
|
+
assignKeys(children[i])
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walks a DomQL element tree and builds a registry
|
|
29
|
+
* mapping data-br keys to DomQL elements.
|
|
30
|
+
*/
|
|
31
|
+
export const mapKeysToElements = (element, registry = {}) => {
|
|
32
|
+
if (!element) return registry
|
|
33
|
+
|
|
34
|
+
const node = element.node
|
|
35
|
+
if (node && node.getAttribute) {
|
|
36
|
+
const brKey = node.getAttribute('data-br')
|
|
37
|
+
if (brKey) {
|
|
38
|
+
if (!element.__ref) element.__ref = {}
|
|
39
|
+
element.__ref.__brKey = brKey
|
|
40
|
+
registry[brKey] = element
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (element.__ref && element.__ref.__children) {
|
|
45
|
+
for (const childKey of element.__ref.__children) {
|
|
46
|
+
const child = element[childKey]
|
|
47
|
+
if (child && child.__ref) {
|
|
48
|
+
mapKeysToElements(child, registry)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return registry
|
|
54
|
+
}
|
package/load.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve, join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads a Symbols project from a filesystem path.
|
|
5
|
+
* Expects the standard symbols/ directory structure.
|
|
6
|
+
*
|
|
7
|
+
* Used for prebuild scenarios where brender runs locally
|
|
8
|
+
* against a project directory (e.g. `smbls build --prerender`).
|
|
9
|
+
*
|
|
10
|
+
* For server runtime rendering, pass the project data
|
|
11
|
+
* directly to render() instead.
|
|
12
|
+
*/
|
|
13
|
+
export const loadProject = async (projectPath) => {
|
|
14
|
+
const symbolsDir = resolve(projectPath, 'symbols')
|
|
15
|
+
|
|
16
|
+
const tryImport = async (modulePath) => {
|
|
17
|
+
try {
|
|
18
|
+
return await import(modulePath)
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [
|
|
25
|
+
appModule,
|
|
26
|
+
stateModule,
|
|
27
|
+
configModule,
|
|
28
|
+
depsModule,
|
|
29
|
+
componentsModule,
|
|
30
|
+
snippetsModule,
|
|
31
|
+
pagesModule,
|
|
32
|
+
functionsModule,
|
|
33
|
+
methodsModule,
|
|
34
|
+
designSystemModule,
|
|
35
|
+
filesModule
|
|
36
|
+
] = await Promise.all([
|
|
37
|
+
tryImport(join(symbolsDir, 'app.js')),
|
|
38
|
+
tryImport(join(symbolsDir, 'state.js')),
|
|
39
|
+
tryImport(join(symbolsDir, 'config.js')),
|
|
40
|
+
tryImport(join(symbolsDir, 'dependencies.js')),
|
|
41
|
+
tryImport(join(symbolsDir, 'components', 'index.js')),
|
|
42
|
+
tryImport(join(symbolsDir, 'snippets', 'index.js')),
|
|
43
|
+
tryImport(join(symbolsDir, 'pages', 'index.js')),
|
|
44
|
+
tryImport(join(symbolsDir, 'functions', 'index.js')),
|
|
45
|
+
tryImport(join(symbolsDir, 'methods', 'index.js')),
|
|
46
|
+
tryImport(join(symbolsDir, 'designSystem', 'index.js')),
|
|
47
|
+
tryImport(join(symbolsDir, 'files', 'index.js'))
|
|
48
|
+
])
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
app: appModule?.default || {},
|
|
52
|
+
state: stateModule?.default || {},
|
|
53
|
+
dependencies: depsModule?.default || {},
|
|
54
|
+
components: componentsModule || {},
|
|
55
|
+
snippets: snippetsModule || {},
|
|
56
|
+
pages: pagesModule?.default || {},
|
|
57
|
+
functions: functionsModule || {},
|
|
58
|
+
methods: methodsModule || {},
|
|
59
|
+
designSystem: designSystemModule?.default || {},
|
|
60
|
+
files: filesModule?.default || {},
|
|
61
|
+
config: configModule?.default || {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Renders all routes from a project directory and returns
|
|
67
|
+
* a map of route -> { html, metadata }.
|
|
68
|
+
* Useful for static prebuilding.
|
|
69
|
+
*/
|
|
70
|
+
export const loadAndRenderAll = async (projectPath, renderFn) => {
|
|
71
|
+
const data = await loadProject(projectPath)
|
|
72
|
+
const pages = data.pages || {}
|
|
73
|
+
const routes = Object.keys(pages)
|
|
74
|
+
const results = {}
|
|
75
|
+
|
|
76
|
+
for (const route of routes) {
|
|
77
|
+
results[route] = await renderFn(data, { route })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return results
|
|
81
|
+
}
|
package/metadata.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts metadata for a given route from project data.
|
|
3
|
+
* Compatible with the server's seo.js getPageMetadata/generateMetaTags.
|
|
4
|
+
*
|
|
5
|
+
* Pages can define metadata via:
|
|
6
|
+
* - page.metadata (standard)
|
|
7
|
+
* - page.helmet (legacy)
|
|
8
|
+
* - page.state (fallback: state-level title/description)
|
|
9
|
+
*
|
|
10
|
+
* Global SEO is merged from data.integrations.seo
|
|
11
|
+
*/
|
|
12
|
+
export const extractMetadata = (data, route = '/') => {
|
|
13
|
+
const pages = data.pages || {}
|
|
14
|
+
const page = pages[route]
|
|
15
|
+
|
|
16
|
+
let metadata = {}
|
|
17
|
+
|
|
18
|
+
// Merge global SEO settings first (lower priority)
|
|
19
|
+
if (data.integrations?.seo) {
|
|
20
|
+
metadata = { ...data.integrations.seo }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (page) {
|
|
24
|
+
// Page-level metadata (highest priority)
|
|
25
|
+
const pageMeta = page.metadata || page.helmet || {}
|
|
26
|
+
metadata = { ...metadata, ...pageMeta }
|
|
27
|
+
|
|
28
|
+
// Fallback: extract title/description from page state if not set
|
|
29
|
+
if (!metadata.title && page.state?.title) {
|
|
30
|
+
metadata.title = page.state.title
|
|
31
|
+
}
|
|
32
|
+
if (!metadata.description && page.state?.description) {
|
|
33
|
+
metadata.description = page.state.description
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Ensure title always exists
|
|
38
|
+
if (!metadata.title) {
|
|
39
|
+
metadata.title = data.name || 'Symbols'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return metadata
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Generates an HTML <head> string from metadata.
|
|
47
|
+
* Can be used standalone or alongside the server's existing generateMetaTags.
|
|
48
|
+
*/
|
|
49
|
+
export const generateHeadHtml = (metadata) => {
|
|
50
|
+
const esc = (text) => {
|
|
51
|
+
if (text === null || text === undefined) return ''
|
|
52
|
+
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
53
|
+
return text.toString().replace(/[&<>"']/g, (m) => map[m])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const tags = [
|
|
57
|
+
'<meta charset="UTF-8">',
|
|
58
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">'
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
62
|
+
if (!value && value !== 0 && value !== false) continue
|
|
63
|
+
|
|
64
|
+
if (key === 'title') {
|
|
65
|
+
tags.push(`<title>${esc(value)}</title>`)
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (key === 'canonical') {
|
|
70
|
+
tags.push(`<link rel="canonical" href="${esc(value)}">`)
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (key === 'alternate' && Array.isArray(value)) {
|
|
75
|
+
value.forEach(alt => {
|
|
76
|
+
if (typeof alt === 'object') {
|
|
77
|
+
const attrs = Object.entries(alt)
|
|
78
|
+
.map(([k, v]) => `${k}="${esc(v)}"`)
|
|
79
|
+
.join(' ')
|
|
80
|
+
tags.push(`<link rel="alternate" ${attrs}>`)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Prefixed property tags (og:, twitter:, article:, etc.)
|
|
87
|
+
const propertyPrefixes = ['og:', 'article:', 'product:', 'fb:', 'profile:', 'book:', 'business:', 'music:', 'video:']
|
|
88
|
+
const namePrefixes = ['twitter:', 'DC:', 'DCTERMS:']
|
|
89
|
+
const isProperty = propertyPrefixes.some(p => key.startsWith(p))
|
|
90
|
+
const isName = namePrefixes.some(p => key.startsWith(p))
|
|
91
|
+
|
|
92
|
+
if (key.startsWith('http-equiv:')) {
|
|
93
|
+
const httpKey = key.replace('http-equiv:', '')
|
|
94
|
+
tags.push(`<meta http-equiv="${esc(httpKey)}" content="${esc(value)}">`)
|
|
95
|
+
} else if (key.startsWith('itemprop:')) {
|
|
96
|
+
const itemKey = key.replace('itemprop:', '')
|
|
97
|
+
tags.push(`<meta itemprop="${esc(itemKey)}" content="${esc(value)}">`)
|
|
98
|
+
} else if (isProperty) {
|
|
99
|
+
if (Array.isArray(value)) {
|
|
100
|
+
value.forEach(v => tags.push(`<meta property="${esc(key)}" content="${esc(v)}">`))
|
|
101
|
+
} else {
|
|
102
|
+
tags.push(`<meta property="${esc(key)}" content="${esc(value)}">`)
|
|
103
|
+
}
|
|
104
|
+
} else if (isName) {
|
|
105
|
+
tags.push(`<meta name="${esc(key)}" content="${esc(value)}">`)
|
|
106
|
+
} else if (key !== 'favicon' && key !== 'favicons') {
|
|
107
|
+
// Standard meta name tag
|
|
108
|
+
if (Array.isArray(value)) {
|
|
109
|
+
value.forEach(v => tags.push(`<meta name="${esc(key)}" content="${esc(v)}">`))
|
|
110
|
+
} else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
111
|
+
tags.push(`<meta name="${esc(key)}" content="${esc(value)}">`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return tags.join('\n')
|
|
117
|
+
}
|