@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/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@domql/brender",
3
+ "version": "3.2.7",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "module": "./dist/esm/index.js",
7
+ "main": "./dist/cjs/index.js",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/esm/index.js",
11
+ "require": "./dist/cjs/index.js",
12
+ "default": "./dist/esm/index.js"
13
+ },
14
+ "./hydrate": {
15
+ "import": "./hydrate.js",
16
+ "default": "./hydrate.js"
17
+ },
18
+ "./load": {
19
+ "import": "./load.js",
20
+ "default": "./load.js"
21
+ }
22
+ },
23
+ "source": "index.js",
24
+ "files": [
25
+ "dist",
26
+ "*.js"
27
+ ],
28
+ "scripts": {
29
+ "copy:package:cjs": "cp ../../build/package-cjs.json dist/cjs/package.json",
30
+ "build:esm": "cross-env NODE_ENV=$NODE_ENV esbuild *.js --target=es2020 --format=esm --outdir=dist/esm --define:process.env.NODE_ENV=process.env.NODE_ENV",
31
+ "build:cjs": "cross-env NODE_ENV=$NODE_ENV esbuild *.js --target=node18 --format=cjs --outdir=dist/cjs --define:process.env.NODE_ENV=process.env.NODE_ENV",
32
+ "build": "node ../../build/build.js",
33
+ "prepublish": "npm run build && npm run copy:package:cjs",
34
+ "render:survey": "node scripts/render.js survey",
35
+ "render:rita": "node scripts/render.js rita",
36
+ "render:all": "npm run render:survey && npm run render:rita",
37
+ "liquidate:survey": "node scripts/liquidate.js survey",
38
+ "liquidate:rita": "node scripts/liquidate.js rita",
39
+ "dev:rita": "node examples/serve-rita.js"
40
+ },
41
+ "dependencies": {
42
+ "linkedom": "^0.16.8"
43
+ },
44
+ "devDependencies": {
45
+ "@babel/core": "^7.26.0"
46
+ },
47
+ "sideEffects": false
48
+ }
package/render.js ADDED
@@ -0,0 +1,386 @@
1
+ import { createEnv } from './env.js'
2
+ import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
3
+ import { extractMetadata, generateHeadHtml } from './metadata.js'
4
+ import { hydrate } from './hydrate.js'
5
+ import { parseHTML } from 'linkedom'
6
+
7
+ /**
8
+ * Renders a Symbols/DomQL project to HTML on the server.
9
+ *
10
+ * Accepts project data as a plain object (matching what ProjectDataService provides)
11
+ * or as a pre-loaded smbls context. Runs DomQL in a linkedom virtual DOM,
12
+ * assigns data-br keys for hydration, and extracts page metadata for SEO.
13
+ *
14
+ * @param {object} data - Project data object with: pages, components, designSystem,
15
+ * state, functions, methods, snippets, files, app, config/settings
16
+ * @param {object} [options]
17
+ * @param {string} [options.route='/'] - The route/page to render
18
+ * @param {object} [options.state] - State overrides
19
+ * @param {object} [options.context] - Additional context overrides
20
+ * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
21
+ */
22
+ export const render = async (data, options = {}) => {
23
+ const { route = '/', state: stateOverrides, context: contextOverrides } = options
24
+
25
+ const { window, document } = createEnv()
26
+ const body = document.body
27
+
28
+ // Set route on location so the router picks it up
29
+ window.location.pathname = route
30
+
31
+ // Lazily import smbls createDomqlElement — this avoids requiring
32
+ // the whole smbls package at module load time
33
+ // Import from source directly — the smbls package doesn't export this subpath
34
+ const smblsSrc = new URL('../../packages/smbls/src/createDomql.js', import.meta.url)
35
+ const { createDomqlElement } = await import(smblsSrc.href)
36
+
37
+ const app = data.app || {}
38
+
39
+ const ctx = {
40
+ state: { ...data.state, ...(stateOverrides || {}) },
41
+ dependencies: data.dependencies || {},
42
+ components: data.components || {},
43
+ snippets: data.snippets || {},
44
+ pages: data.pages || {},
45
+ functions: data.functions || {},
46
+ methods: data.methods || {},
47
+ designSystem: data.designSystem || {},
48
+ files: data.files || {},
49
+ ...(data.config || data.settings || {}),
50
+ // Virtual DOM environment
51
+ document,
52
+ window,
53
+ parent: { node: body },
54
+ // Caller overrides
55
+ ...(contextOverrides || {})
56
+ }
57
+
58
+ resetKeys()
59
+
60
+ const element = await createDomqlElement(app, ctx)
61
+
62
+ // Assign data-br keys for hydration
63
+ assignKeys(body)
64
+
65
+ const registry = mapKeysToElements(element)
66
+
67
+ // Extract metadata for the rendered route
68
+ const metadata = extractMetadata(data, route)
69
+
70
+ const html = body.innerHTML
71
+
72
+ return { html, metadata, registry, element }
73
+ }
74
+
75
+ /**
76
+ * Renders a single DomQL element definition to HTML.
77
+ * Useful for rendering individual components without a full project.
78
+ *
79
+ * @param {object} elementDef - DomQL element definition
80
+ * @param {object} [options]
81
+ * @param {object} [options.context] - DomQL context (components, designSystem, etc.)
82
+ * @returns {Promise<{ html: string, registry: object, element: object }>}
83
+ */
84
+ export const renderElement = async (elementDef, options = {}) => {
85
+ const { context = {} } = options
86
+
87
+ const { window, document } = createEnv()
88
+ const body = document.body
89
+
90
+ const { create } = await import('@domql/element')
91
+
92
+ resetKeys()
93
+
94
+ const element = create(elementDef, { node: body }, 'root', {
95
+ context: { document, window, ...context }
96
+ })
97
+
98
+ assignKeys(body)
99
+ const registry = mapKeysToElements(element)
100
+ const html = body.innerHTML
101
+
102
+ return { html, registry, element }
103
+ }
104
+
105
+ // ── Route-level SSR ───────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Renders a single route and returns body HTML + CSS separately.
109
+ * Designed for integration with an existing server router that manages
110
+ * its own <head>, template, and bundle injection.
111
+ *
112
+ * @param {object} data - Full project data
113
+ * @param {object} [options]
114
+ * @param {string} [options.route='/'] - Route to render
115
+ * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
116
+ */
117
+ export const renderRoute = async (data, options = {}) => {
118
+ const { route = '/' } = options
119
+ const ds = data.designSystem || {}
120
+ const pageDef = (data.pages || {})[route]
121
+ if (!pageDef) return null
122
+
123
+ const result = await renderElement(pageDef, {
124
+ context: {
125
+ components: data.components || {},
126
+ snippets: data.snippets || {},
127
+ designSystem: ds,
128
+ state: data.state || {},
129
+ functions: data.functions || {},
130
+ methods: data.methods || {}
131
+ }
132
+ })
133
+
134
+ // Hydrate with emotion → CSS classes on nodes
135
+ const { document: cssDoc } = parseHTML(`<html><head></head><body>${result.html}</body></html>`)
136
+ let emotionInstance
137
+ try {
138
+ const { default: createInstance } = await import('@emotion/css/create-instance')
139
+ emotionInstance = createInstance({ key: 'smbls', container: cssDoc.head })
140
+ } catch {}
141
+
142
+ hydrate(result.element, {
143
+ root: cssDoc.body,
144
+ renderEvents: false,
145
+ events: false,
146
+ emotion: emotionInstance,
147
+ designSystem: ds
148
+ })
149
+
150
+ return {
151
+ html: cssDoc.body.innerHTML,
152
+ css: extractCSS(result.element, ds),
153
+ resetCss: generateResetCSS(ds.reset),
154
+ fontLinks: generateFontLinks(ds),
155
+ metadata: extractMetadata(data, route),
156
+ brKeyCount: Object.keys(result.registry).length
157
+ }
158
+ }
159
+
160
+ // ── Full page SSR ─────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * Renders a complete HTML page for a route — ready to serve.
164
+ * Includes head, metadata, fonts, reset CSS, component CSS, and body.
165
+ *
166
+ * @param {object} data - Full project data (from loadProject)
167
+ * @param {string} route - Route to render (e.g. '/', '/about')
168
+ * @param {object} [options]
169
+ * @param {string} [options.lang='en'] - HTML lang attribute
170
+ * @param {string} [options.themeColor] - theme-color meta
171
+ * @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
172
+ */
173
+ export const renderPage = async (data, route = '/', options = {}) => {
174
+ const { lang = 'en', themeColor } = options
175
+
176
+ const result = await renderRoute(data, { route })
177
+ if (!result) return null
178
+
179
+ const metadata = { ...result.metadata }
180
+ if (themeColor) metadata['theme-color'] = themeColor
181
+ const headTags = generateHeadHtml(metadata)
182
+
183
+ const html = `<!DOCTYPE html>
184
+ <html lang="${lang}">
185
+ <head>
186
+ ${headTags}
187
+ ${result.fontLinks}
188
+ <style>${result.resetCss}</style>
189
+ <style data-emotion="smbls">
190
+ ${result.css}
191
+ </style>
192
+ </head>
193
+ <body>
194
+ ${result.html}
195
+ </body>
196
+ </html>`
197
+
198
+ return { html, route, brKeyCount: result.brKeyCount }
199
+ }
200
+
201
+ // ── CSS helpers ─────────────────────────────────────────────────────────────
202
+
203
+ const CSS_COLOR_PROPS = new Set([
204
+ 'color', 'background', 'backgroundColor', 'borderColor',
205
+ 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
206
+ 'outlineColor', 'fill', 'stroke'
207
+ ])
208
+
209
+ const NON_CSS_PROPS = new Set([
210
+ 'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
211
+ 'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
212
+ 'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
213
+ 'theme', '__element', 'update'
214
+ ])
215
+
216
+ const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
217
+
218
+ const resolveShorthand = (key, val) => {
219
+ if (key === 'flexAlign' && typeof val === 'string') {
220
+ const [alignItems, justifyContent] = val.split(' ')
221
+ return { display: 'flex', 'align-items': alignItems, 'justify-content': justifyContent }
222
+ }
223
+ if (key === 'gridAlign' && typeof val === 'string') {
224
+ const [alignItems, justifyContent] = val.split(' ')
225
+ return { display: 'grid', 'align-items': alignItems, 'justify-content': justifyContent }
226
+ }
227
+ if (key === 'round' && val) {
228
+ return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
229
+ }
230
+ if (key === 'boxSize' && val) {
231
+ return { width: val, height: val }
232
+ }
233
+ return null
234
+ }
235
+
236
+ const resolveInnerProps = (obj, colorMap) => {
237
+ const result = {}
238
+ for (const k in obj) {
239
+ const v = obj[k]
240
+ const expanded = resolveShorthand(k, v)
241
+ if (expanded) { Object.assign(result, expanded); continue }
242
+ if (typeof v !== 'string' && typeof v !== 'number') continue
243
+ result[camelToKebab(k)] = CSS_COLOR_PROPS.has(k) && colorMap[v] ? colorMap[v] : v
244
+ }
245
+ return result
246
+ }
247
+
248
+ const buildCSSFromProps = (props, colorMap, mediaMap) => {
249
+ const base = {}
250
+ const mediaRules = {}
251
+ const pseudoRules = {}
252
+
253
+ for (const key in props) {
254
+ const val = props[key]
255
+
256
+ if (key.charCodeAt(0) === 64 && typeof val === 'object') {
257
+ const bp = mediaMap?.[key.slice(1)]
258
+ if (bp) {
259
+ const inner = resolveInnerProps(val, colorMap)
260
+ if (Object.keys(inner).length) mediaRules[bp] = inner
261
+ }
262
+ continue
263
+ }
264
+
265
+ if (key.charCodeAt(0) === 58 && typeof val === 'object') {
266
+ const inner = resolveInnerProps(val, colorMap)
267
+ if (Object.keys(inner).length) pseudoRules[key] = inner
268
+ continue
269
+ }
270
+
271
+ if (typeof val !== 'string' && typeof val !== 'number') continue
272
+ if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
273
+ if (NON_CSS_PROPS.has(key)) continue
274
+
275
+ const expanded = resolveShorthand(key, val)
276
+ if (expanded) { Object.assign(base, expanded); continue }
277
+
278
+ base[camelToKebab(key)] = CSS_COLOR_PROPS.has(key) && colorMap[val] ? colorMap[val] : val
279
+ }
280
+
281
+ return { base, mediaRules, pseudoRules }
282
+ }
283
+
284
+ const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
285
+ const lines = []
286
+ const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
287
+ if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
288
+
289
+ for (const [pseudo, p] of Object.entries(pseudoRules)) {
290
+ const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
291
+ if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
292
+ }
293
+
294
+ for (const [query, p] of Object.entries(mediaRules)) {
295
+ const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
296
+ const mq = query.startsWith('@') ? query : `@media ${query}`
297
+ if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
298
+ }
299
+
300
+ return lines.join('\n')
301
+ }
302
+
303
+ const extractCSS = (element, ds) => {
304
+ const colorMap = ds?.color || {}
305
+ const mediaMap = ds?.media || {}
306
+ const animations = ds?.animation || {}
307
+ const rules = []
308
+ const seen = new Set()
309
+ const usedAnimations = new Set()
310
+
311
+ const walk = (el) => {
312
+ if (!el || !el.__ref) return
313
+ const { props } = el
314
+ if (props && el.node) {
315
+ const cls = el.node.getAttribute?.('class')
316
+ if (cls && !seen.has(cls)) {
317
+ seen.add(cls)
318
+ const cssResult = buildCSSFromProps(props, colorMap, mediaMap)
319
+ const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
320
+ if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
321
+
322
+ const anim = props.animation || props.animationName
323
+ if (typeof anim === 'string') {
324
+ const name = anim.split(' ')[0]
325
+ if (animations[name]) usedAnimations.add(name)
326
+ }
327
+ }
328
+ }
329
+ if (el.__ref.__children) {
330
+ for (const ck of el.__ref.__children) {
331
+ if (el[ck]?.__ref) walk(el[ck])
332
+ }
333
+ }
334
+ }
335
+ walk(element)
336
+
337
+ const keyframes = []
338
+ for (const name of usedAnimations) {
339
+ const frames = animations[name]
340
+ const frameRules = Object.entries(frames).map(([step, p]) => {
341
+ const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
342
+ return ` ${step} { ${decls}; }`
343
+ }).join('\n')
344
+ keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
345
+ }
346
+
347
+ return [...keyframes, ...rules].join('\n')
348
+ }
349
+
350
+ const generateResetCSS = (reset) => {
351
+ if (!reset) return ''
352
+ const rules = []
353
+ for (const [selector, props] of Object.entries(reset)) {
354
+ const decls = Object.entries(props)
355
+ .map(([k, v]) => `${camelToKebab(k)}: ${v}`)
356
+ .join('; ')
357
+ if (decls) rules.push(`${selector} { ${decls}; }`)
358
+ }
359
+ return rules.join('\n')
360
+ }
361
+
362
+ const generateFontLinks = (ds) => {
363
+ if (!ds) return ''
364
+ const families = ds.font_family || ds.fontFamily || {}
365
+ const fontNames = new Set()
366
+
367
+ // Collect font family names from the design system
368
+ for (const val of Object.values(families)) {
369
+ const match = val.match(/'([^']+)'/)
370
+ if (match) fontNames.add(match[1])
371
+ }
372
+
373
+ if (!fontNames.size) return ''
374
+
375
+ // Build Google Fonts URL
376
+ const params = [...fontNames].map(name => {
377
+ const slug = name.replace(/\s+/g, '+')
378
+ return `family=${slug}:wght@300;400;500;600;700`
379
+ }).join('&')
380
+
381
+ return [
382
+ '<link rel="preconnect" href="https://fonts.googleapis.com">',
383
+ '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
384
+ `<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
385
+ ].join('\n')
386
+ }