@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/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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
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
+ }