@symbo.ls/brender 3.4.11 → 3.5.1

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 CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.4.11",
3
+ "version": "3.5.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
7
7
  "main": "./dist/cjs/index.js",
8
8
  "exports": {
9
9
  ".": {
10
- "import": "./dist/esm/index.js",
10
+ "import": "./index.js",
11
11
  "require": "./dist/cjs/index.js",
12
- "default": "./dist/esm/index.js"
12
+ "default": "./index.js"
13
13
  },
14
14
  "./hydrate": {
15
15
  "import": "./hydrate.js",
package/render.js CHANGED
@@ -4,6 +4,65 @@ import { extractMetadata, generateHeadHtml } from './metadata.js'
4
4
  import { hydrate } from './hydrate.js'
5
5
  import { parseHTML } from 'linkedom'
6
6
 
7
+ // ── Minimal uikit stubs ──────────────────────────────────────────────────────
8
+ // Lightweight versions of uikit components so DOMQL can resolve extends chains
9
+ // (tag, display, attrs) without importing the full @symbo.ls/uikit package.
10
+ const UIKIT_STUBS = {
11
+ Box: {},
12
+ Focusable: {},
13
+ Block: { display: 'block' },
14
+ Inline: { display: 'inline' },
15
+ Flex: { display: 'flex' },
16
+ InlineFlex: { display: 'inline-flex' },
17
+ Grid: { display: 'grid' },
18
+ InlineGrid: { display: 'inline-grid' },
19
+ Link: {
20
+ tag: 'a',
21
+ attr: {
22
+ href: (el) => el.props?.href,
23
+ target: (el) => el.props?.target,
24
+ rel: (el) => el.props?.rel
25
+ }
26
+ },
27
+ A: { extends: 'Link' },
28
+ RouteLink: { extends: 'Link' },
29
+ Img: {
30
+ tag: 'img',
31
+ attr: {
32
+ src: (el) => {
33
+ let src = el.props?.src
34
+ if (typeof src === 'string' && src.includes('{{')) {
35
+ src = el.call('replaceLiteralsWithObjectFields', src, el.state)
36
+ }
37
+ return src
38
+ },
39
+ alt: (el) => el.props?.alt,
40
+ loading: (el) => el.props?.loading
41
+ }
42
+ },
43
+ Image: { extends: 'Img' },
44
+ Button: { tag: 'button' },
45
+ FocusableComponent: { tag: 'button' },
46
+ Form: { tag: 'form' },
47
+ Input: { tag: 'input' },
48
+ TextArea: { tag: 'textarea' },
49
+ Textarea: { tag: 'textarea' },
50
+ Select: { tag: 'select' },
51
+ Label: { tag: 'label' },
52
+ Iframe: { tag: 'iframe' },
53
+ Video: { tag: 'video' },
54
+ Audio: { tag: 'audio' },
55
+ Canvas: { tag: 'canvas' },
56
+ Span: { tag: 'span' },
57
+ P: { tag: 'p' },
58
+ H1: { tag: 'h1' },
59
+ H2: { tag: 'h2' },
60
+ H3: { tag: 'h3' },
61
+ H4: { tag: 'h4' },
62
+ H5: { tag: 'h5' },
63
+ H6: { tag: 'h6' }
64
+ }
65
+
7
66
  /**
8
67
  * Renders a Symbols/DomQL project to HTML on the server.
9
68
  *
@@ -88,15 +147,35 @@ export const renderElement = async (elementDef, options = {}) => {
88
147
  const body = document.body
89
148
 
90
149
  const { create } = await import('@domql/element')
150
+ const domqlUtils = await import('@domql/utils')
151
+
152
+ // Merge minimal uikit stubs so DOMQL resolves extends chains
153
+ // (e.g. extends: 'Link' → tag: 'a', extends: 'Flex' → display: flex)
154
+ const components = { ...UIKIT_STUBS, ...(context.components || {}) }
155
+
156
+ // Register utility functions so element.call() can resolve them
157
+ // (e.g. replaceLiteralsWithObjectFields for {{ }} templates)
158
+ const utils = {
159
+ ...domqlUtils,
160
+ ...(context.utils || {}),
161
+ ...(context.functions || {})
162
+ }
91
163
 
92
164
  resetKeys()
93
165
 
94
- const element = create(elementDef, { node: body }, 'root', {
95
- context: { document, window, ...context }
96
- })
166
+ let element
167
+ try {
168
+ element = create(elementDef, { node: body }, 'root', {
169
+ context: { document, window, ...context, components, utils }
170
+ })
171
+ } catch (err) {
172
+ // Lifecycle events (onRender, onDone, etc.) may throw in SSR
173
+ // because they access browser-only APIs. The DOM tree is built
174
+ // before these fire, so we can still extract HTML.
175
+ }
97
176
 
98
177
  assignKeys(body)
99
- const registry = mapKeysToElements(element)
178
+ const registry = element ? mapKeysToElements(element) : {}
100
179
  const html = body.innerHTML
101
180
 
102
181
  return { html, registry, element }
@@ -198,6 +277,120 @@ ${result.html}
198
277
  return { html, route, brKeyCount: result.brKeyCount }
199
278
  }
200
279
 
280
+ // ── Design system token resolution ──────────────────────────────────────────
281
+
282
+ const LETTER_TO_INDEX = {
283
+ U: -6, V: -5, W: -4, X: -3, Y: -2, Z: -1,
284
+ A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9,
285
+ K: 10, L: 11, M: 12, N: 13, O: 14, P: 15
286
+ }
287
+
288
+ const SPACING_PROPS = new Set([
289
+ 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
290
+ 'paddingBlock', 'paddingInline', 'paddingBlockStart', 'paddingBlockEnd',
291
+ 'paddingInlineStart', 'paddingInlineEnd',
292
+ 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
293
+ 'marginBlock', 'marginInline', 'marginBlockStart', 'marginBlockEnd',
294
+ 'marginInlineStart', 'marginInlineEnd',
295
+ 'gap', 'rowGap', 'columnGap',
296
+ 'top', 'right', 'bottom', 'left',
297
+ 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight',
298
+ 'flexBasis', 'fontSize', 'lineHeight', 'letterSpacing',
299
+ 'borderWidth', 'borderRadius', 'outlineWidth', 'outlineOffset',
300
+ 'inset', 'insetBlock', 'insetInline',
301
+ 'boxSize', 'round'
302
+ ])
303
+
304
+ /**
305
+ * Resolves a spacing token like 'B2', 'A', 'E3' to a px/em value.
306
+ * Uses base * ratio^index for main steps (A=0, B=1, etc.)
307
+ * and sub-ratio interpolation for sub-steps (B1, B2, B3).
308
+ */
309
+ const resolveSpacingToken = (token, spacingConfig) => {
310
+ if (!token || typeof token !== 'string') return null
311
+ if (!spacingConfig) return null
312
+
313
+ const base = spacingConfig.base || 16
314
+ const ratio = spacingConfig.ratio || 1.618
315
+ const unit = spacingConfig.unit || 'px'
316
+ const hasSubSequence = spacingConfig.subSequence !== false
317
+
318
+ // Handle compound values like 'B2 - -' or 'A1 B C1'
319
+ if (token.includes(' ')) {
320
+ const parts = token.split(' ').map(part => {
321
+ if (part === '-' || part === '') return part
322
+ return resolveSpacingToken(part, spacingConfig) || part
323
+ })
324
+ return parts.join(' ')
325
+ }
326
+
327
+ // Skip CSS keywords and values with units
328
+ if (/^(none|auto|inherit|initial|unset|0)$/i.test(token)) return null
329
+ if (/\d+(px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr|s|ms)$/i.test(token)) return null
330
+ // Skip hex colors, rgb(), etc.
331
+ if (/^(#|rgb|hsl|var\()/i.test(token)) return null
332
+
333
+ const isNegative = token.startsWith('-')
334
+ const abs = isNegative ? token.slice(1) : token
335
+
336
+ // Match letter + optional digit: A, B, B2, E3, etc.
337
+ const m = abs.match(/^([A-Z])(\d)?$/i)
338
+ if (!m) return null
339
+
340
+ const letter = m[1].toUpperCase()
341
+ const subStep = m[2] ? parseInt(m[2]) : 0
342
+ const idx = LETTER_TO_INDEX[letter]
343
+ if (idx === undefined) return null
344
+
345
+ let value = base * Math.pow(ratio, idx)
346
+
347
+ if (subStep > 0 && hasSubSequence) {
348
+ const next = base * Math.pow(ratio, idx + 1)
349
+ const diff = next - value
350
+ const subRatio = diff / ratio
351
+ // Sub-steps: 1 = value + (diff - subRatio), 2 = midpoint, 3 = value + subRatio
352
+ const first = next - subRatio
353
+ const second = value + subRatio
354
+ const middle = (first + second) / 2
355
+ const subs = (~~next - ~~value > 16) ? [first, middle, second] : [first, second]
356
+ if (subStep <= subs.length) {
357
+ value = subs[subStep - 1]
358
+ }
359
+ }
360
+
361
+ const rounded = Math.round(value * 100) / 100
362
+ const sign = isNegative ? '-' : ''
363
+ return `${sign}${rounded}${unit}`
364
+ }
365
+
366
+ // Kebab-case versions of spacing props for post-shorthand resolution
367
+ const SPACING_PROPS_KEBAB = new Set(
368
+ [...SPACING_PROPS].map(k => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()))
369
+ )
370
+
371
+ /**
372
+ * Try to resolve a CSS value through the design system.
373
+ * Returns the resolved value or the original if not a token.
374
+ */
375
+ const resolveDSValue = (key, val, ds) => {
376
+ if (typeof val !== 'string') return val
377
+
378
+ // Color resolution
379
+ if (CSS_COLOR_PROPS.has(key)) {
380
+ const colorMap = ds?.color || {}
381
+ if (colorMap[val]) return colorMap[val]
382
+ }
383
+
384
+ // Spacing resolution (check both camelCase and kebab-case keys)
385
+ if (SPACING_PROPS.has(key) || SPACING_PROPS_KEBAB.has(key)) {
386
+ const spacing = ds?.spacing || {}
387
+ const resolved = resolveSpacingToken(val, spacing)
388
+ if (resolved) return resolved
389
+ }
390
+
391
+ return val
392
+ }
393
+
201
394
  // ── CSS helpers ─────────────────────────────────────────────────────────────
202
395
 
203
396
  const CSS_COLOR_PROPS = new Set([
@@ -210,42 +403,106 @@ const NON_CSS_PROPS = new Set([
210
403
  'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
211
404
  'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
212
405
  'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
213
- 'theme', '__element', 'update'
406
+ 'theme', '__element', 'update',
407
+ 'childrenAs', 'childExtends', 'childProps', 'children'
214
408
  ])
215
409
 
216
410
  const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
217
411
 
218
412
  const resolveShorthand = (key, val) => {
219
- if (key === 'flexAlign' && typeof val === 'string') {
413
+ if (typeof val === 'undefined' || val === null) return null
414
+
415
+ // Flex shorthands
416
+ if (key === 'flow' && typeof val === 'string') {
417
+ let [direction, wrap] = (val || 'row').split(' ')
418
+ if (val.startsWith('x') || val === 'row') direction = 'row'
419
+ if (val.startsWith('y') || val === 'column') direction = 'column'
420
+ return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
421
+ }
422
+ if (key === 'wrap') {
423
+ return { display: 'flex', 'flex-wrap': val }
424
+ }
425
+ if ((key === 'align' || key === 'flexAlign') && typeof val === 'string') {
220
426
  const [alignItems, justifyContent] = val.split(' ')
221
- return { display: 'flex', 'align-items': alignItems, 'justify-content': justifyContent }
427
+ const result = { display: 'flex', 'align-items': alignItems }
428
+ if (justifyContent) result['justify-content'] = justifyContent
429
+ return result
222
430
  }
223
431
  if (key === 'gridAlign' && typeof val === 'string') {
224
432
  const [alignItems, justifyContent] = val.split(' ')
225
- return { display: 'grid', 'align-items': alignItems, 'justify-content': justifyContent }
433
+ const result = { display: 'grid', 'align-items': alignItems }
434
+ if (justifyContent) result['justify-content'] = justifyContent
435
+ return result
436
+ }
437
+ if (key === 'flexFlow' && typeof val === 'string') {
438
+ let [direction, wrap] = (val || 'row').split(' ')
439
+ if (val.startsWith('x') || val === 'row') direction = 'row'
440
+ if (val.startsWith('y') || val === 'column') direction = 'column'
441
+ return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
442
+ }
443
+ if (key === 'flexWrap') {
444
+ return { display: 'flex', 'flex-wrap': val }
226
445
  }
227
- if (key === 'round' && val) {
446
+
447
+ // Background image shorthand
448
+ if (key === 'backgroundImage' && typeof val === 'string' && !val.startsWith('url(') && !val.startsWith('linear-gradient') && !val.startsWith('radial-gradient') && !val.startsWith('none')) {
449
+ return { 'background-image': `url(${val})` }
450
+ }
451
+
452
+ // Box/size shorthands
453
+ if (key === 'round' || (key === 'borderRadius' && val)) {
228
454
  return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
229
455
  }
230
- if (key === 'boxSize' && val) {
231
- return { width: val, height: val }
456
+ if (key === 'boxSize' && typeof val === 'string') {
457
+ const [height, width] = val.split(' ')
458
+ return { height, width: width || height }
459
+ }
460
+ if (key === 'widthRange' && typeof val === 'string') {
461
+ const [minWidth, maxWidth] = val.split(' ')
462
+ return { 'min-width': minWidth, 'max-width': maxWidth || minWidth }
232
463
  }
464
+ if (key === 'heightRange' && typeof val === 'string') {
465
+ const [minHeight, maxHeight] = val.split(' ')
466
+ return { 'min-height': minHeight, 'max-height': maxHeight || minHeight }
467
+ }
468
+
469
+ // Grid aliases
470
+ if (key === 'column') return { 'grid-column': val }
471
+ if (key === 'columns') return { 'grid-template-columns': val }
472
+ if (key === 'templateColumns') return { 'grid-template-columns': val }
473
+ if (key === 'row') return { 'grid-row': val }
474
+ if (key === 'rows') return { 'grid-template-rows': val }
475
+ if (key === 'templateRows') return { 'grid-template-rows': val }
476
+ if (key === 'area') return { 'grid-area': val }
477
+ if (key === 'template') return { 'grid-template': val }
478
+ if (key === 'templateAreas') return { 'grid-template-areas': val }
479
+ if (key === 'autoColumns') return { 'grid-auto-columns': val }
480
+ if (key === 'autoRows') return { 'grid-auto-rows': val }
481
+ if (key === 'autoFlow') return { 'grid-auto-flow': val }
482
+ if (key === 'columnStart') return { 'grid-column-start': val }
483
+ if (key === 'rowStart') return { 'grid-row-start': val }
484
+
233
485
  return null
234
486
  }
235
487
 
236
- const resolveInnerProps = (obj, colorMap) => {
488
+ const resolveInnerProps = (obj, ds) => {
237
489
  const result = {}
238
490
  for (const k in obj) {
239
491
  const v = obj[k]
240
492
  const expanded = resolveShorthand(k, v)
241
- if (expanded) { Object.assign(result, expanded); continue }
493
+ if (expanded) {
494
+ for (const ek in expanded) {
495
+ result[ek] = resolveDSValue(ek, expanded[ek], ds)
496
+ }
497
+ continue
498
+ }
242
499
  if (typeof v !== 'string' && typeof v !== 'number') continue
243
- result[camelToKebab(k)] = CSS_COLOR_PROPS.has(k) && colorMap[v] ? colorMap[v] : v
500
+ result[camelToKebab(k)] = resolveDSValue(k, v, ds)
244
501
  }
245
502
  return result
246
503
  }
247
504
 
248
- const buildCSSFromProps = (props, colorMap, mediaMap) => {
505
+ const buildCSSFromProps = (props, ds, mediaMap) => {
249
506
  const base = {}
250
507
  const mediaRules = {}
251
508
  const pseudoRules = {}
@@ -256,14 +513,14 @@ const buildCSSFromProps = (props, colorMap, mediaMap) => {
256
513
  if (key.charCodeAt(0) === 64 && typeof val === 'object') {
257
514
  const bp = mediaMap?.[key.slice(1)]
258
515
  if (bp) {
259
- const inner = resolveInnerProps(val, colorMap)
516
+ const inner = resolveInnerProps(val, ds)
260
517
  if (Object.keys(inner).length) mediaRules[bp] = inner
261
518
  }
262
519
  continue
263
520
  }
264
521
 
265
522
  if (key.charCodeAt(0) === 58 && typeof val === 'object') {
266
- const inner = resolveInnerProps(val, colorMap)
523
+ const inner = resolveInnerProps(val, ds)
267
524
  if (Object.keys(inner).length) pseudoRules[key] = inner
268
525
  continue
269
526
  }
@@ -273,9 +530,14 @@ const buildCSSFromProps = (props, colorMap, mediaMap) => {
273
530
  if (NON_CSS_PROPS.has(key)) continue
274
531
 
275
532
  const expanded = resolveShorthand(key, val)
276
- if (expanded) { Object.assign(base, expanded); continue }
533
+ if (expanded) {
534
+ for (const ek in expanded) {
535
+ base[ek] = resolveDSValue(ek, expanded[ek], ds)
536
+ }
537
+ continue
538
+ }
277
539
 
278
- base[camelToKebab(key)] = CSS_COLOR_PROPS.has(key) && colorMap[val] ? colorMap[val] : val
540
+ base[camelToKebab(key)] = resolveDSValue(key, val, ds)
279
541
  }
280
542
 
281
543
  return { base, mediaRules, pseudoRules }
@@ -300,8 +562,26 @@ const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
300
562
  return lines.join('\n')
301
563
  }
302
564
 
565
+ // Map of component names to their implicit CSS from extends
566
+ const EXTENDS_CSS = {
567
+ Flex: { display: 'flex' },
568
+ InlineFlex: { display: 'inline-flex' },
569
+ Grid: { display: 'grid' },
570
+ InlineGrid: { display: 'inline-grid' },
571
+ Block: { display: 'block' },
572
+ Inline: { display: 'inline' }
573
+ }
574
+
575
+ const getExtendsCSS = (el) => {
576
+ const exts = el.__ref?.__extends
577
+ if (!exts || !Array.isArray(exts)) return null
578
+ for (const ext of exts) {
579
+ if (EXTENDS_CSS[ext]) return EXTENDS_CSS[ext]
580
+ }
581
+ return null
582
+ }
583
+
303
584
  const extractCSS = (element, ds) => {
304
- const colorMap = ds?.color || {}
305
585
  const mediaMap = ds?.media || {}
306
586
  const animations = ds?.animation || {}
307
587
  const rules = []
@@ -315,7 +595,17 @@ const extractCSS = (element, ds) => {
315
595
  const cls = el.node.getAttribute?.('class')
316
596
  if (cls && !seen.has(cls)) {
317
597
  seen.add(cls)
318
- const cssResult = buildCSSFromProps(props, colorMap, mediaMap)
598
+ const cssResult = buildCSSFromProps(props, ds, mediaMap)
599
+
600
+ // Inject CSS from extends chain (e.g. extends: 'Flex' → display: flex)
601
+ const extsCss = getExtendsCSS(el)
602
+ if (extsCss) {
603
+ for (const [k, v] of Object.entries(extsCss)) {
604
+ const kebab = camelToKebab(k)
605
+ if (!cssResult.base[kebab]) cssResult.base[kebab] = v
606
+ }
607
+ }
608
+
319
609
  const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
320
610
  if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
321
611
 
@@ -351,6 +641,7 @@ const generateResetCSS = (reset) => {
351
641
  if (!reset) return ''
352
642
  const rules = []
353
643
  for (const [selector, props] of Object.entries(reset)) {
644
+ if (!props || typeof props !== 'object') continue
354
645
  const decls = Object.entries(props)
355
646
  .map(([k, v]) => `${camelToKebab(k)}: ${v}`)
356
647
  .join('; ')
@@ -366,6 +657,7 @@ const generateFontLinks = (ds) => {
366
657
 
367
658
  // Collect font family names from the design system
368
659
  for (const val of Object.values(families)) {
660
+ if (typeof val !== 'string') continue
369
661
  const match = val.match(/'([^']+)'/)
370
662
  if (match) fontNames.add(match[1])
371
663
  }