@symbo.ls/brender 3.5.1 → 3.6.3

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/render.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolve, join } from 'path'
1
2
  import { createEnv } from './env.js'
2
3
  import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
3
4
  import { extractMetadata, generateHeadHtml } from './metadata.js'
@@ -60,7 +61,15 @@ const UIKIT_STUBS = {
60
61
  H3: { tag: 'h3' },
61
62
  H4: { tag: 'h4' },
62
63
  H5: { tag: 'h5' },
63
- H6: { tag: 'h6' }
64
+ H6: { tag: 'h6' },
65
+ Svg: {
66
+ tag: 'svg',
67
+ attr: {
68
+ xmlns: 'http://www.w3.org/2000/svg',
69
+ 'xmlns:xlink': 'http://www.w3.org/1999/xlink'
70
+ }
71
+ },
72
+ Text: { tag: 'span' }
64
73
  }
65
74
 
66
75
  /**
@@ -176,11 +185,208 @@ export const renderElement = async (elementDef, options = {}) => {
176
185
 
177
186
  assignKeys(body)
178
187
  const registry = element ? mapKeysToElements(element) : {}
179
- const html = body.innerHTML
188
+ const html = fixSvgContent(body.innerHTML)
180
189
 
181
190
  return { html, registry, element }
182
191
  }
183
192
 
193
+ // ── SVG content post-processing ───────────────────────────────────────────────
194
+ // DOMQL's html mixin uses textContent for SVG nodes, which escapes HTML entities.
195
+ // This post-processor unescapes content inside <svg> tags so paths/circles render.
196
+ const fixSvgContent = (html) => {
197
+ return html.replace(
198
+ /(<svg\b[^>]*>)([\s\S]*?)(<\/svg>)/gi,
199
+ (match, open, content, close) => {
200
+ if (content.includes('&lt;')) {
201
+ const unescaped = content
202
+ .replace(/&lt;/g, '<')
203
+ .replace(/&gt;/g, '>')
204
+ .replace(/&amp;/g, '&')
205
+ .replace(/&quot;/g, '"')
206
+ .replace(/&#39;/g, "'")
207
+ return open + unescaped + close
208
+ }
209
+ return match
210
+ }
211
+ )
212
+ }
213
+
214
+ // ── Global CSS generation ─────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * Runs the scratch design-system pipeline (via esbuild bundling to work around
218
+ * bare-import issues) to produce CSS variables and reset styles — the same
219
+ * globals that the SPA runtime injects via emotion.injectGlobal.
220
+ */
221
+ let _cachedGlobalCSS = null
222
+
223
+ const generateGlobalCSS = async (ds, config) => {
224
+ if (_cachedGlobalCSS) return _cachedGlobalCSS
225
+
226
+ try {
227
+ const { existsSync, writeFileSync, unlinkSync } = await import('fs')
228
+ const { tmpdir } = await import('os')
229
+ const { randomBytes } = await import('crypto')
230
+ const esbuild = await import('esbuild')
231
+
232
+ // Write a temporary script that imports scratch, runs set(), and
233
+ // serialises the CSS_VARS + RESET objects as JSON.
234
+ const dsJson = JSON.stringify(ds || {})
235
+ const cfgJson = JSON.stringify(config || {})
236
+ const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}.mjs`)
237
+ const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}_out.mjs`)
238
+
239
+ writeFileSync(tmpEntry, `
240
+ import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
241
+ import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
242
+
243
+ const ds = ${dsJson}
244
+ const cfg = ${cfgJson}
245
+
246
+ // Merge with defaults (same as initEmotion)
247
+ const merged = {}
248
+ for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
249
+ for (const k in ds) {
250
+ if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
251
+ merged[k] = { ...merged[k], ...ds[k] }
252
+ } else {
253
+ merged[k] = ds[k]
254
+ }
255
+ }
256
+
257
+ const conf = set({
258
+ useReset: true,
259
+ useVariable: true,
260
+ useFontImport: true,
261
+ useDocumentTheme: true,
262
+ useDefaultConfig: true,
263
+ globalTheme: 'auto',
264
+ ...merged,
265
+ ...cfg
266
+ }, { newConfig: {} })
267
+
268
+ const result = {
269
+ CSS_VARS: conf.CSS_VARS || {},
270
+ CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
271
+ RESET: conf.RESET || conf.reset || {},
272
+ ANIMATION: conf.animation || conf.ANIMATION || {}
273
+ }
274
+ // Export as globalThis so we can read it
275
+ globalThis.__BR_GLOBAL_CSS__ = result
276
+ export default result
277
+ `)
278
+
279
+ // Resolve the monorepo root from the brender plugin location
280
+ // so esbuild can find @symbo.ls/* packages
281
+ const brenderDir = new URL('.', import.meta.url).pathname
282
+ const monorepoRoot = resolve(brenderDir, '../..')
283
+
284
+ // Workspace resolve plugin: maps @symbo.ls/* and @domql/* to source paths
285
+ const workspacePlugin = {
286
+ name: 'workspace-resolve',
287
+ setup (build) {
288
+ build.onResolve({ filter: /^@symbo\.ls\// }, args => {
289
+ const pkg = args.path.replace('@symbo.ls/', '')
290
+ // Try packages/ then plugins/
291
+ for (const dir of ['packages', 'plugins']) {
292
+ const src = resolve(monorepoRoot, dir, pkg, 'src', 'index.js')
293
+ if (existsSync(src)) return { path: src }
294
+ const dist = resolve(monorepoRoot, dir, pkg, 'index.js')
295
+ if (existsSync(dist)) return { path: dist }
296
+ }
297
+ // default-config special case
298
+ const blank = resolve(monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
299
+ if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
300
+ })
301
+ build.onResolve({ filter: /^@domql\// }, args => {
302
+ const pkg = args.path.replace('@domql/', '')
303
+ const src = resolve(monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
304
+ if (existsSync(src)) return { path: src }
305
+ })
306
+ }
307
+ }
308
+
309
+ await esbuild.build({
310
+ entryPoints: [tmpEntry],
311
+ bundle: true,
312
+ format: 'esm',
313
+ platform: 'node',
314
+ outfile: tmpOut,
315
+ write: true,
316
+ logLevel: 'silent',
317
+ plugins: [workspacePlugin],
318
+ external: ['fs', 'path', 'os', 'crypto', 'url', 'http', 'https', 'stream', 'util', 'events', 'buffer', 'child_process', 'worker_threads', 'net', 'tls', 'dns', 'dgram', 'zlib', 'assert', 'querystring', 'string_decoder', 'readline', 'perf_hooks', 'async_hooks', 'v8', 'vm', 'cluster', 'inspector', 'module', 'process', 'tty', 'color-contrast-checker']
319
+ })
320
+
321
+ const mod = await import(`file://${tmpOut}`)
322
+ const data = mod.default || {}
323
+ try { unlinkSync(tmpEntry) } catch {}
324
+ try { unlinkSync(tmpOut) } catch {}
325
+
326
+ const cssVars = data.CSS_VARS || {}
327
+ const cssMediaVars = data.CSS_MEDIA_VARS || {}
328
+ const reset = data.RESET || {}
329
+ const animations = data.ANIMATION || {}
330
+
331
+ // ── :root CSS variables ──
332
+ const varDecls = Object.entries(cssVars)
333
+ .map(([k, v]) => ` ${k}: ${v}`)
334
+ .join(';\n')
335
+ let rootRule = varDecls ? `:root {\n${varDecls};\n}` : ''
336
+
337
+ // ── Theme-switching CSS vars (media queries + data-theme selectors) ──
338
+ const themeVarRules = Object.entries(cssMediaVars)
339
+ .map(([key, vars]) => {
340
+ const decls = Object.entries(vars)
341
+ .map(([k, v]) => ` ${k}: ${v}`)
342
+ .join(';\n')
343
+ if (!decls) return ''
344
+ if (key.startsWith('@media')) {
345
+ // Media query — only when no data-theme forces a theme
346
+ return `${key} {\n :root:not([data-theme]) {\n${decls};\n }\n}`
347
+ }
348
+ // Selector ([data-theme="..."]) — apply directly
349
+ return `${key} {\n${decls};\n}`
350
+ })
351
+ .filter(Boolean)
352
+ .join('\n\n')
353
+ if (themeVarRules) rootRule += '\n\n' + themeVarRules
354
+
355
+ // ── Reset styles ──
356
+ const resetRules = generateResetCSS(reset)
357
+
358
+ // ── @keyframes animations ──
359
+ const keyframeRules = []
360
+ for (const name in animations) {
361
+ const frames = animations[name]
362
+ if (!frames || typeof frames !== 'object') continue
363
+ const frameRules = Object.entries(frames).map(([step, p]) => {
364
+ if (typeof p !== 'object') return ''
365
+ const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
366
+ return ` ${step} { ${decls}; }`
367
+ }).join('\n')
368
+ keyframeRules.push(`@keyframes ${name} {\n${frameRules}\n}`)
369
+ }
370
+
371
+ _cachedGlobalCSS = {
372
+ rootRule,
373
+ resetRules,
374
+ fontFaceCSS: '',
375
+ keyframeRules: keyframeRules.join('\n')
376
+ }
377
+ return _cachedGlobalCSS
378
+ } catch (err) {
379
+ console.warn('generateGlobalCSS failed:', err.message, err.stack)
380
+ _cachedGlobalCSS = { rootRule: '', resetRules: '', fontFaceCSS: '', keyframeRules: '' }
381
+ return _cachedGlobalCSS
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Reset the cached global CSS (useful when rendering multiple projects).
387
+ */
388
+ export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null }
389
+
184
390
  // ── Route-level SSR ───────────────────────────────────────────────────────────
185
391
 
186
392
  /**
@@ -226,10 +432,14 @@ export const renderRoute = async (data, options = {}) => {
226
432
  designSystem: ds
227
433
  })
228
434
 
435
+ // Generate global CSS (variables, reset, keyframes) via scratch pipeline
436
+ const globalCSS = await generateGlobalCSS(ds, data.config || data.settings)
437
+
229
438
  return {
230
439
  html: cssDoc.body.innerHTML,
231
440
  css: extractCSS(result.element, ds),
232
- resetCss: generateResetCSS(ds.reset),
441
+ globalCSS,
442
+ resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
233
443
  fontLinks: generateFontLinks(ds),
234
444
  metadata: extractMetadata(data, route),
235
445
  brKeyCount: Object.keys(result.registry).length
@@ -250,7 +460,7 @@ export const renderRoute = async (data, options = {}) => {
250
460
  * @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
251
461
  */
252
462
  export const renderPage = async (data, route = '/', options = {}) => {
253
- const { lang = 'en', themeColor } = options
463
+ const { lang = 'en', themeColor, isr } = options
254
464
 
255
465
  const result = await renderRoute(data, { route })
256
466
  if (!result) return null
@@ -259,18 +469,54 @@ export const renderPage = async (data, route = '/', options = {}) => {
259
469
  if (themeColor) metadata['theme-color'] = themeColor
260
470
  const headTags = generateHeadHtml(metadata)
261
471
 
472
+ const globalCSS = result.globalCSS || {}
473
+
474
+ // ISR: include client SPA bundle for hydration + data fetching
475
+ let isrBody = ''
476
+ if (isr && isr.clientScript) {
477
+ // Calculate relative path from route directory to root
478
+ const depth = route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
479
+ const prefix = depth > 0 ? '../'.repeat(depth) : './'
480
+ // Inline handoff script: when the SPA adds its root element to <body>,
481
+ // remove the pre-rendered brender content for a seamless transition.
482
+ isrBody = `<script type="module">
483
+ {
484
+ const brEls = document.querySelectorAll('body > :not(script):not(style)')
485
+ const observer = new MutationObserver((mutations) => {
486
+ for (const m of mutations) {
487
+ for (const node of m.addedNodes) {
488
+ if (node.nodeType === 1 && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE' && !node.hasAttribute('data-br')) {
489
+ brEls.forEach(el => { if (el.hasAttribute('data-br') || el.querySelector('[data-br]')) el.remove() })
490
+ observer.disconnect()
491
+ return
492
+ }
493
+ }
494
+ }
495
+ })
496
+ observer.observe(document.body, { childList: true })
497
+ }
498
+ </script>
499
+ <script type="module" src="${prefix}${isr.clientScript}"></script>`
500
+ }
501
+
262
502
  const html = `<!DOCTYPE html>
263
503
  <html lang="${lang}">
264
504
  <head>
265
505
  ${headTags}
266
506
  ${result.fontLinks}
267
- <style>${result.resetCss}</style>
507
+ ${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ''}
508
+ <style>
509
+ ${globalCSS.rootRule || ''}
510
+ ${result.resetCss}
511
+ ${globalCSS.keyframeRules || ''}
512
+ </style>
268
513
  <style data-emotion="smbls">
269
514
  ${result.css}
270
515
  </style>
271
516
  </head>
272
517
  <body>
273
518
  ${result.html}
519
+ ${isrBody}
274
520
  </body>
275
521
  </html>`
276
522
 
@@ -581,6 +827,41 @@ const getExtendsCSS = (el) => {
581
827
  return null
582
828
  }
583
829
 
830
+ /**
831
+ * Resolve function-valued CSS props by evaluating them with (element, state).
832
+ * In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
833
+ * Uses fallback mock state if element state is incomplete.
834
+ */
835
+ const resolveElementProps = (el) => {
836
+ const { props } = el
837
+ if (!props) return props
838
+ let resolved
839
+ for (const key in props) {
840
+ if (typeof props[key] !== 'function') continue
841
+ // Skip non-CSS props and component children
842
+ if (NON_CSS_PROPS.has(key)) continue
843
+ if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
844
+ if (key.startsWith('on')) continue
845
+ if (!resolved) resolved = { ...props }
846
+ let result
847
+ try {
848
+ result = props[key](el, el.state || {})
849
+ } catch {
850
+ // State prototype chain may be incomplete in SSR — try with mock
851
+ try {
852
+ const mockState = { root: {}, ...(el.state || {}) }
853
+ result = props[key](el, mockState)
854
+ } catch { /* skip prop */ }
855
+ }
856
+ if (result !== undefined && result !== null && result !== false) {
857
+ resolved[key] = result
858
+ } else {
859
+ delete resolved[key]
860
+ }
861
+ }
862
+ return resolved || props
863
+ }
864
+
584
865
  const extractCSS = (element, ds) => {
585
866
  const mediaMap = ds?.media || {}
586
867
  const animations = ds?.animation || {}
@@ -590,7 +871,7 @@ const extractCSS = (element, ds) => {
590
871
 
591
872
  const walk = (el) => {
592
873
  if (!el || !el.__ref) return
593
- const { props } = el
874
+ const props = resolveElementProps(el)
594
875
  if (props && el.node) {
595
876
  const cls = el.node.getAttribute?.('class')
596
877
  if (cls && !seen.has(cls)) {
@@ -616,7 +897,7 @@ const extractCSS = (element, ds) => {
616
897
  }
617
898
  }
618
899
  }
619
- if (el.__ref.__children) {
900
+ if (el.__ref?.__children) {
620
901
  for (const ck of el.__ref.__children) {
621
902
  if (el[ck]?.__ref) walk(el[ck])
622
903
  }
@@ -642,10 +923,24 @@ const generateResetCSS = (reset) => {
642
923
  const rules = []
643
924
  for (const [selector, props] of Object.entries(reset)) {
644
925
  if (!props || typeof props !== 'object') continue
645
- const decls = Object.entries(props)
646
- .map(([k, v]) => `${camelToKebab(k)}: ${v}`)
647
- .join('; ')
648
- if (decls) rules.push(`${selector} { ${decls}; }`)
926
+ const baseDecls = []
927
+ const mediaRules = []
928
+ for (const [k, v] of Object.entries(props)) {
929
+ if (typeof v === 'object' && v !== null) {
930
+ // Nested object: @media query or sub-selector
931
+ if (k.startsWith('@media') || k.startsWith('@')) {
932
+ const inner = Object.entries(v)
933
+ .filter(([, iv]) => typeof iv !== 'object')
934
+ .map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`)
935
+ .join('; ')
936
+ if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`)
937
+ }
938
+ continue
939
+ }
940
+ baseDecls.push(`${camelToKebab(k)}: ${v}`)
941
+ }
942
+ if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join('; ')}; }`)
943
+ rules.push(...mediaRules)
649
944
  }
650
945
  return rules.join('\n')
651
946
  }
package/sitemap.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Generate a sitemap XML string from project pages.
3
+ *
4
+ * @param {string} baseUrl - The base URL (e.g. 'https://example.com')
5
+ * @param {object} routes - Pages object keyed by route path
6
+ * @returns {string} Sitemap XML
7
+ */
8
+ export function generateSitemap (baseUrl, routes) {
9
+ const urls = Object.entries(routes).map(([path, config]) => {
10
+ const metadata = config.metadata || {}
11
+ const canonical = metadata.canonical || `${baseUrl}${path === '/' ? '' : path}`
12
+
13
+ return `
14
+ <url>
15
+ <loc>${canonical}</loc>
16
+ <lastmod>${new Date().toISOString()}</lastmod>
17
+ <changefreq>weekly</changefreq>
18
+ <priority>${path === '/' ? '1.0' : '0.8'}</priority>
19
+ </url>`
20
+ })
21
+
22
+ return `<?xml version="1.0" encoding="UTF-8"?>
23
+ <urlset
24
+ xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
25
+ xmlns:xhtml="http://www.w3.org/1999/xhtml">
26
+ ${urls.join('\n')}
27
+ </urlset>`
28
+ }