@symbo.ls/brender 3.14.0 → 3.14.2

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 DELETED
@@ -1,1887 +0,0 @@
1
- import { resolve, join, dirname } from 'path'
2
- import { existsSync, writeFileSync, unlinkSync, readFileSync, realpathSync } from 'fs'
3
- import { tmpdir } from 'os'
4
- import { randomBytes } from 'crypto'
5
- import { createRequire } from 'module'
6
- import { createEnv } from './env.js'
7
- import { resetKeys, assignKeys, mapKeysToElements } from './keys.js'
8
- import { extractMetadata, generateHeadHtml } from './metadata.js'
9
- import { hydrate } from './hydrate.js'
10
- import { prefetchPageData, injectPrefetchedState, fetchSSRTranslations } from './prefetch.js'
11
-
12
- // funcql plugin — enables evaluation of funcql schemas as property values
13
- // during SSR. Async-imported to avoid polluting non-funcql codepaths.
14
- let _funcqlPlugin = null
15
- const getFuncqlPlugin = async () => {
16
- if (_funcqlPlugin) return _funcqlPlugin
17
- try {
18
- const mod = await import('@symbo.ls/funcql')
19
- _funcqlPlugin = mod.funcqlPlugin
20
- return _funcqlPlugin
21
- } catch {
22
- return null
23
- }
24
- }
25
- import { parseHTML } from 'linkedom'
26
- import { css, injectGlobal, reset as resetCss } from '@symbo.ls/css'
27
-
28
- // Lightweight SSR polyglot functions — resolve translations from context
29
- // without needing the full polyglot plugin runtime
30
- const ssrResolve = (map, key) => {
31
- if (!map || !key) return undefined
32
- if (map[key] !== undefined) return map[key]
33
- // Try nested dot-path (e.g. "ui.main.latest")
34
- const parts = key.split('.')
35
- let v = map
36
- for (const p of parts) {
37
- if (v == null || typeof v !== 'object') return undefined
38
- v = v[p]
39
- }
40
- return v
41
- }
42
-
43
- const ssrTranslate = function (key, lang) {
44
- if (!key) return ''
45
- const ctx = this?.context
46
- const poly = ctx?.polyglot
47
- const activeLang = lang || poly?.defaultLang || 'ka'
48
-
49
- // Static translations from context.polyglot.translations
50
- if (poly?.translations) {
51
- const langMap = poly.translations[activeLang]
52
- if (langMap) {
53
- const val = ssrResolve(langMap, key)
54
- if (val !== undefined) return val
55
- }
56
- }
57
-
58
- // Server-loaded translations in root state
59
- const root = this?.state?.root || ctx?.state?.root
60
- if (root?.translations) {
61
- const langMap = root.translations[activeLang]
62
- if (langMap) {
63
- const val = ssrResolve(langMap, key)
64
- if (val !== undefined) return val
65
- }
66
- }
67
-
68
- // Fallback to default lang
69
- const defaultLang = poly?.defaultLang || 'en'
70
- if (defaultLang !== activeLang && poly?.translations) {
71
- const fallback = poly.translations[defaultLang]
72
- if (fallback) {
73
- const val = ssrResolve(fallback, key)
74
- if (val !== undefined) return val
75
- }
76
- }
77
-
78
- return key
79
- }
80
-
81
- const ssrGetActiveLang = function () {
82
- const ctx = this?.context
83
- return this?.state?.root?.lang || ctx?.polyglot?.defaultLang || 'ka'
84
- }
85
-
86
- // Deep clone that preserves functions and avoids circular refs
87
- const structuredCloneDeep = (obj, seen = new WeakMap()) => {
88
- if (obj === null || typeof obj !== 'object') return obj
89
- if (seen.has(obj)) return seen.get(obj)
90
- if (Array.isArray(obj)) {
91
- const arr = []
92
- seen.set(obj, arr)
93
- for (const v of obj) arr.push(typeof v === 'object' && v !== null ? structuredCloneDeep(v, seen) : v)
94
- return arr
95
- }
96
- const clone = {}
97
- seen.set(obj, clone)
98
- for (const k of Object.keys(obj)) {
99
- const v = obj[k]
100
- clone[k] = typeof v === 'object' && v !== null ? structuredCloneDeep(v, seen) : v
101
- }
102
- return clone
103
- }
104
-
105
- // JSON replacer that drops functions, circular refs, and non-serializable values
106
- const safeJsonReplacer = () => {
107
- const seen = new WeakSet()
108
- return (key, value) => {
109
- if (typeof value === 'function') return undefined
110
- if (typeof value === 'object' && value !== null) {
111
- if (seen.has(value)) return undefined
112
- seen.add(value)
113
- }
114
- return value
115
- }
116
- }
117
-
118
- // ── Workspace detection ──────────────────────────────────────────────────────
119
- // Detect whether brender is running inside the monorepo or as an installed
120
- // npm package, and resolve paths accordingly.
121
- // Guard createRequire for bundled environments (e.g. Cloudflare Workers)
122
- // where import.meta.url may be undefined after bundling.
123
- let _brenderRequire = null
124
- try {
125
- if (import.meta.url) {
126
- _brenderRequire = createRequire(import.meta.url)
127
- }
128
- } catch {
129
- // Bundled runtime — filesystem-based resolution not available
130
- }
131
-
132
- const detectWorkspace = () => {
133
- // In bundled environments (CF Workers), import.meta.url may be undefined.
134
- // Return a minimal workspace config that skips filesystem-based resolution.
135
- if (!import.meta.url || !_brenderRequire) {
136
- return { isMonorepo: false, monorepoRoot: null, resolvePackage: (pkg) => pkg }
137
- }
138
- const brenderDir = realpathSync(new URL('.', import.meta.url).pathname)
139
- const monorepoRoot = resolve(brenderDir, '../..')
140
-
141
- // Check if monorepo layout exists (packages/smbls/src/createDomql.js)
142
- const isMonorepo = existsSync(resolve(monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js'))
143
-
144
- if (isMonorepo) {
145
- return { isMonorepo: true, monorepoRoot }
146
- }
147
-
148
- // npm install context — resolve packages from node_modules
149
- // Find the smbls package root via require.resolve
150
- let smblsRoot
151
- try {
152
- const smblsPkg = _brenderRequire.resolve('smbls/package.json')
153
- smblsRoot = dirname(smblsPkg)
154
- } catch {
155
- // Fallback: walk up from brenderDir looking for node_modules/smbls
156
- let dir = brenderDir
157
- while (dir !== dirname(dir)) {
158
- const candidate = resolve(dir, 'node_modules', 'smbls')
159
- if (existsSync(resolve(candidate, 'package.json'))) {
160
- smblsRoot = candidate
161
- break
162
- }
163
- dir = dirname(dir)
164
- }
165
- }
166
-
167
- // Create a require function from smbls root so we can resolve its dependencies
168
- // (e.g. @symbo.ls/scratch, @symbo.ls/default-config which are deps of smbls, not brender)
169
- let _smblsRequire = _brenderRequire
170
- if (smblsRoot) {
171
- try {
172
- _smblsRequire = createRequire(resolve(smblsRoot, 'package.json'))
173
- } catch {} // fallback: module not found via this resolver, trying next
174
- }
175
-
176
- // Also find the project root (where the user's package.json is)
177
- let projectRoot
178
- let dir = process.cwd()
179
- while (dir !== dirname(dir)) {
180
- if (existsSync(resolve(dir, 'package.json'))) {
181
- projectRoot = dir
182
- break
183
- }
184
- dir = dirname(dir)
185
- }
186
-
187
- // Create a require from project root for hoisted deps
188
- let _projectRequire = _smblsRequire
189
- if (projectRoot) {
190
- try {
191
- _projectRequire = createRequire(resolve(projectRoot, 'package.json'))
192
- } catch {} // fallback: module not found via this resolver, trying next
193
- }
194
-
195
- return { isMonorepo: false, smblsRoot, brenderDir, projectRoot, _smblsRequire, _projectRequire }
196
- }
197
-
198
- // Try multiple require functions to resolve a package (brender scope, smbls scope, project scope)
199
- const tryRequireResolve = (ws, specifier) => {
200
- const requireFns = ws.isMonorepo
201
- ? [_brenderRequire]
202
- : [ws._smblsRequire, ws._projectRequire, _brenderRequire].filter(Boolean)
203
- for (const req of requireFns) {
204
- try {
205
- return req.resolve(specifier)
206
- } catch {} // fallback: module not found via this resolver, trying next
207
- }
208
- return null
209
- }
210
-
211
- // Resolve a package path — monorepo layout or node_modules
212
- const resolvePackagePath = (ws, pkgName, ...subpath) => {
213
- if (ws.isMonorepo) {
214
- return resolve(ws.monorepoRoot, 'packages', pkgName, ...subpath)
215
- }
216
- const pkgJson = tryRequireResolve(ws, `${pkgName}/package.json`)
217
- if (pkgJson) return resolve(dirname(pkgJson), ...subpath)
218
- return null
219
- }
220
-
221
- const resolvePluginPath = (ws, pluginName, ...subpath) => {
222
- if (ws.isMonorepo) {
223
- return resolve(ws.monorepoRoot, 'plugins', pluginName, ...subpath)
224
- }
225
- const pkgJson = tryRequireResolve(ws, `@symbo.ls/${pluginName}/package.json`)
226
- if (pkgJson) return resolve(dirname(pkgJson), ...subpath)
227
- return null
228
- }
229
-
230
- const resolveSymbolsPackage = (ws, pkg, ...subpath) => {
231
- if (ws.isMonorepo) {
232
- for (const dir of ['packages', 'plugins']) {
233
- const src = resolve(ws.monorepoRoot, dir, pkg, ...subpath)
234
- if (existsSync(src)) return src
235
- }
236
- return null
237
- }
238
- const pkgJson = tryRequireResolve(ws, `@symbo.ls/${pkg}/package.json`)
239
- if (pkgJson) return resolve(dirname(pkgJson), ...subpath)
240
- return null
241
- }
242
-
243
- const resolveDomqlPackage = (ws, pkg, ...subpath) => {
244
- if (ws.isMonorepo) {
245
- return resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, ...subpath)
246
- }
247
- const pkgJson = tryRequireResolve(ws, `@symbo.ls/${pkg}/package.json`)
248
- if (pkgJson) return resolve(dirname(pkgJson), ...subpath)
249
- return null
250
- }
251
-
252
- // ── Bundled import of createDomqlElement ──────────────────────────────────────
253
- // The smbls source tree uses extensionless/directory imports that Node.js ESM
254
- // cannot resolve natively. We bundle createDomql.js with esbuild (once, cached)
255
- // so all bare/directory specifiers are resolved at bundle time.
256
- let _cachedCreateDomql = null
257
-
258
- const bundleCreateDomql = async () => {
259
- if (_cachedCreateDomql) return _cachedCreateDomql
260
-
261
- const ws = detectWorkspace()
262
-
263
- // In bundled environments (CF Workers), use the pre-bundled createDomql.
264
- // Generated by: node scripts/prebundle-createDomql.js (or during npm prepublish)
265
- if (!_brenderRequire) {
266
- try {
267
- const mod = await import('./dist/createDomql.bundled.mjs')
268
- _cachedCreateDomql = mod
269
- return mod
270
- } catch (err) {
271
- throw new Error(`brender: pre-bundled createDomql not available in bundled runtime: ${err.message}`)
272
- }
273
- }
274
-
275
- // Resolve entry point
276
- let entry
277
- if (ws.isMonorepo) {
278
- entry = resolve(ws.monorepoRoot, 'packages', 'smbls', 'src', 'createDomql.js')
279
- } else if (ws.smblsRoot) {
280
- // Prefer src/ (shipped in smbls package), fall back to dist
281
- const srcEntry = resolve(ws.smblsRoot, 'src', 'createDomql.js')
282
- const distEntry = resolve(ws.smblsRoot, 'dist', 'esm', 'src', 'createDomql.js')
283
- entry = existsSync(srcEntry) ? srcEntry : distEntry
284
- }
285
-
286
- if (!entry || !existsSync(entry)) {
287
- throw new Error(`brender: cannot find createDomql.js (isMonorepo=${ws.isMonorepo}, entry=${entry})`)
288
- }
289
-
290
- const esbuild = await import('esbuild')
291
- const outFile = join(tmpdir(), `br_createDomql_${randomBytes(6).toString('hex')}.mjs`)
292
-
293
- // Helper to find a file trying src/ then root index.js
294
- const tryResolve = (base) => {
295
- if (!base) return null
296
- const src = resolve(base, 'src', 'index.js')
297
- if (existsSync(src)) return src
298
- const idx = resolve(base, 'index.js')
299
- if (existsSync(idx)) return idx
300
- return null
301
- }
302
-
303
- const workspacePlugin = {
304
- name: 'workspace-resolve',
305
- setup (build) {
306
- // Resolve smbls bare import
307
- build.onResolve({ filter: /^smbls/ }, (args) => {
308
- const subpath = args.path.replace(/^smbls\/?/, '')
309
- const smblsBase = ws.isMonorepo
310
- ? resolve(ws.monorepoRoot, 'packages', 'smbls')
311
- : ws.smblsRoot
312
- if (!smblsBase) return
313
- if (!subpath) {
314
- const r = tryResolve(smblsBase)
315
- if (r) return { path: r }
316
- return
317
- }
318
- const full = resolve(smblsBase, subpath)
319
- if (existsSync(full)) return { path: full }
320
- if (existsSync(full + '.js')) return { path: full + '.js' }
321
- const idx = resolve(full, 'index.js')
322
- if (existsSync(idx)) return { path: idx }
323
- })
324
- // Resolve domql bare import
325
- build.onResolve({ filter: /^domql$/ }, (args) => {
326
- if (ws.isMonorepo) {
327
- const src = resolve(ws.monorepoRoot, 'packages', 'domql', 'src', 'index.js')
328
- if (existsSync(src)) return { path: src }
329
- const dist = resolve(ws.monorepoRoot, 'packages', 'domql', 'index.js')
330
- if (existsSync(dist)) return { path: dist }
331
- } else {
332
- try {
333
- const pkgJson = _require.resolve('domql/package.json')
334
- const r = tryResolve(dirname(pkgJson))
335
- if (r) return { path: r }
336
- } catch {} // fallback: module not found via this resolver, trying next
337
- }
338
- })
339
- // Resolve @symbo.ls/* packages (skip sync — stubbed above)
340
- build.onResolve({ filter: /^@symbo\.ls\// }, args => {
341
- const pkg = args.path.replace('@symbo.ls/', '')
342
- if (pkg === 'sync') return { path: 'sync-stub', namespace: 'brender-stub' }
343
- if (ws.isMonorepo) {
344
- for (const dir of ['packages', 'plugins']) {
345
- const src = resolve(ws.monorepoRoot, dir, pkg, 'src', 'index.js')
346
- if (existsSync(src)) return { path: src }
347
- const dist = resolve(ws.monorepoRoot, dir, pkg, 'index.js')
348
- if (existsSync(dist)) return { path: dist }
349
- }
350
- const blank = resolve(ws.monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
351
- if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
352
- } else {
353
- const resolved = resolveSymbolsPackage(ws, pkg, 'src', 'index.js')
354
- if (resolved && existsSync(resolved)) return { path: resolved }
355
- const resolvedIdx = resolveSymbolsPackage(ws, pkg, 'index.js')
356
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
357
- // default-config blank
358
- if (pkg === 'default-config') {
359
- const blank = resolveSymbolsPackage(ws, 'default-config', 'blank', 'index.js')
360
- if (blank && existsSync(blank)) return { path: blank }
361
- }
362
- }
363
- })
364
- // Resolve @symbo.ls/* packages
365
- build.onResolve({ filter: /^@domql\// }, args => {
366
- const pkg = args.path.replace('@symbo.ls/', '')
367
- if (ws.isMonorepo) {
368
- const src = resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
369
- if (existsSync(src)) return { path: src }
370
- const dist = resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, 'index.js')
371
- if (existsSync(dist)) return { path: dist }
372
- } else {
373
- const resolved = resolveDomqlPackage(ws, pkg, 'src', 'index.js')
374
- if (resolved && existsSync(resolved)) return { path: resolved }
375
- const resolvedIdx = resolveDomqlPackage(ws, pkg, 'index.js')
376
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
377
- }
378
- })
379
- // Resolve css-in-props
380
- build.onResolve({ filter: /^css-in-props/ }, args => {
381
- let base
382
- if (ws.isMonorepo) {
383
- base = resolve(ws.monorepoRoot, 'packages', 'css-in-props')
384
- } else {
385
- const pkgJson = tryRequireResolve(ws, 'css-in-props/package.json')
386
- if (pkgJson) base = dirname(pkgJson)
387
- }
388
- if (!base) return
389
- const subpath = args.path.replace(/^css-in-props\/?/, '')
390
- if (subpath) {
391
- const full = resolve(base, subpath)
392
- const idx = resolve(full, 'index.js')
393
- if (existsSync(idx)) return { path: idx }
394
- if (existsSync(full + '.js')) return { path: full + '.js' }
395
- if (existsSync(full)) return { path: full }
396
- }
397
- const r = tryResolve(base)
398
- if (r) return { path: r }
399
- })
400
- // Resolve @emotion/* from node_modules
401
- build.onResolve({ filter: /^@emotion\// }, args => {
402
- let nm
403
- if (ws.isMonorepo) {
404
- nm = resolve(ws.monorepoRoot, 'node_modules', args.path)
405
- } else {
406
- const pkgJson = tryRequireResolve(ws, `${args.path}/package.json`)
407
- if (!pkgJson) return
408
- nm = dirname(pkgJson)
409
- }
410
- if (existsSync(nm)) {
411
- const pkg = resolve(nm, 'package.json')
412
- if (existsSync(pkg)) {
413
- try {
414
- const p = JSON.parse(readFileSync(pkg, 'utf8'))
415
- const main = p.module || p.main || 'dist/emotion-css.esm.js'
416
- return { path: resolve(nm, main) }
417
- } catch {} // expected: value is not a valid JSON
418
- }
419
- return { path: nm }
420
- }
421
- })
422
- // Handle JSON imports
423
- build.onResolve({ filter: /\.json$/ }, args => {
424
- if (args.resolveDir) {
425
- const full = resolve(args.resolveDir, args.path)
426
- if (existsSync(full)) return { path: full }
427
- }
428
- })
429
- // Fix options.js: replace createRequire + package.json version import
430
- // Match both src/ and dist/esm/src/ paths
431
- build.onLoad({ filter: /smbls\/.*options\.js$/ }, async (args) => {
432
- if (!args.path.includes('smbls/') || !args.path.endsWith('options.js')) return
433
- let contents = readFileSync(args.path, 'utf8')
434
- // Replace import attributes (package.json with { type: 'json' })
435
- contents = contents.replace(
436
- /import\s*\{[^}]*version[^}]*\}\s*from\s*['"][^'"]*package\.json['"][^;\n]*/,
437
- "const version = '0.0.0'"
438
- )
439
- contents = contents.replace(
440
- /import\s*\{[^}]*createRequire[^}]*\}\s*from\s*['"]module['"][^;\n]*/g,
441
- '// createRequire removed for brender build'
442
- )
443
- return { contents, loader: 'js' }
444
- })
445
- // Fix init.js: remove createRequire (match both src/ and dist/)
446
- build.onLoad({ filter: /smbls\/.*init\.js$/ }, async (args) => {
447
- if (!args.path.includes('smbls/') || !args.path.endsWith('init.js')) return
448
- let contents = readFileSync(args.path, 'utf8')
449
- contents = contents.replace(
450
- /import\s*\{[^}]*createRequire[^}]*\}\s*from\s*['"]module['"][^;\n]*/g,
451
- '// createRequire removed for brender build'
452
- )
453
- return { contents, loader: 'js' }
454
- })
455
- // Stub the Supabase adapter during brender — the prefetch system handles
456
- // data fetching separately via its own Supabase client. The DOMQL fetch
457
- // plugin's on.create handler shouldn't fire during SSR.
458
- build.onLoad({ filter: /fetch\/adapters\/supabase\.js$/ }, () => {
459
- return {
460
- contents: `export const setup = async () => null;\nexport const supabaseAdapter = () => ({name:'supabase'});\n`,
461
- loader: 'js'
462
- }
463
- })
464
- // No-op: globals.js keeps window = globalThis
465
- // We set globalThis.document/location before import to make it SSR-safe
466
- // Stub loader for brender-stub namespace (used for @symbo.ls/sync etc.)
467
- build.onLoad({ filter: /.*/, namespace: 'brender-stub' }, () => {
468
- return { contents: 'export const SyncComponent = {}; export const Inspect = {}; export const Notifications = {}; export default {}', loader: 'js' }
469
- })
470
- // Fix router.js: break circular import of Link from smbls
471
- build.onLoad({ filter: /smbls\/src\/router\.js$/ }, async (args) => {
472
- let contents = readFileSync(args.path, 'utf8')
473
- contents = contents.replace(
474
- /import\s*\{\s*Link\s*\}\s*from\s*['"]smbls['"]/,
475
- `const Link = { tag: 'a', attr: { href: (el) => el.href } }`
476
- )
477
- return { contents, loader: 'js' }
478
- })
479
- // Fix fetchOnCreate.js: guard window.location access
480
- build.onLoad({ filter: /fetchOnCreate\.js$/ }, async (args) => {
481
- if (!args.path.includes('smbls/')) return
482
- let contents = readFileSync(args.path, 'utf8')
483
- // Make window.location.host access safe for SSR
484
- contents = contents.replace(
485
- /window\s*&&\s*window\.location\s*\?\s*window\.location\.host\.includes/g,
486
- 'window && window.location && window.location.host ? window.location.host.includes'
487
- )
488
- return { contents, loader: 'js' }
489
- })
490
- // Let esbuild handle remaining npm deps via nodePaths
491
- }
492
- }
493
-
494
- await esbuild.build({
495
- entryPoints: [entry],
496
- bundle: true,
497
- format: 'esm',
498
- platform: 'node',
499
- outfile: outFile,
500
- write: true,
501
- logLevel: 'warning',
502
- plugins: [workspacePlugin],
503
- nodePaths: ws.isMonorepo
504
- ? [resolve(ws.monorepoRoot, 'node_modules')]
505
- : [
506
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, 'node_modules')] : []),
507
- ...(ws.projectRoot ? [resolve(ws.projectRoot, 'node_modules')] : []),
508
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, '..', '..', 'node_modules')] : [])
509
- ].filter(p => existsSync(p)),
510
- supported: { 'import-attributes': false },
511
- external: [
512
- 'fs', 'path', 'os', 'crypto', 'url', 'http', 'https', 'stream',
513
- 'util', 'events', 'buffer', 'child_process', 'worker_threads',
514
- 'net', 'tls', 'dns', 'dgram', 'zlib', 'assert', 'querystring',
515
- 'string_decoder', 'readline', 'perf_hooks', 'async_hooks', 'v8',
516
- 'vm', 'cluster', 'inspector', 'module', 'process', 'tty',
517
- 'color-contrast-checker', 'linkedom'
518
- ]
519
- })
520
-
521
- const mod = await import(`file://${outFile}`)
522
- // Keep temp file in debug mode for inspection
523
- if (!process.env.BRENDER_DEBUG) {
524
- try { unlinkSync(outFile) } catch {}
525
- } else {
526
- console.log('[brender] Bundle saved:', outFile)
527
- }
528
-
529
- _cachedCreateDomql = mod
530
- return mod
531
- }
532
-
533
- // ── Minimal uikit stubs ──────────────────────────────────────────────────────
534
- // Lightweight versions of uikit components so DOMQL can resolve extends chains
535
- // (tag, display, attrs) without importing the full @symbo.ls/uikit package.
536
- const UIKIT_STUBS = {
537
- Box: {},
538
- Focusable: {},
539
- Block: { display: 'block' },
540
- Inline: { display: 'inline' },
541
- Flex: { display: 'flex' },
542
- InlineFlex: { display: 'inline-flex' },
543
- Grid: { display: 'grid' },
544
- InlineGrid: { display: 'inline-grid' },
545
- Link: {
546
- tag: 'a',
547
- attr: {
548
- href: (el) => el.href,
549
- target: (el) => el.target,
550
- rel: (el) => el.rel
551
- }
552
- },
553
- A: { extends: 'Link' },
554
- RouteLink: { extends: 'Link' },
555
- Img: {
556
- tag: 'img',
557
- attr: {
558
- src: (el) => {
559
- let src = el.src
560
- if (typeof src === 'string' && src.includes('{{')) {
561
- src = el.call('replaceLiteralsWithObjectFields', src, el.state)
562
- }
563
- return src
564
- },
565
- alt: (el) => el.alt,
566
- loading: (el) => el.loading
567
- }
568
- },
569
- Image: { extends: 'Img' },
570
- Button: { tag: 'button' },
571
- FocusableComponent: { tag: 'button' },
572
- Form: { tag: 'form' },
573
- Input: { tag: 'input' },
574
- TextArea: { tag: 'textarea' },
575
- Textarea: { tag: 'textarea' },
576
- Select: { tag: 'select' },
577
- Label: { tag: 'label' },
578
- Iframe: { tag: 'iframe' },
579
- Video: { tag: 'video' },
580
- Audio: { tag: 'audio' },
581
- Canvas: { tag: 'canvas' },
582
- Span: { tag: 'span' },
583
- P: { tag: 'p' },
584
- H1: { tag: 'h1' },
585
- H2: { tag: 'h2' },
586
- H3: { tag: 'h3' },
587
- H4: { tag: 'h4' },
588
- H5: { tag: 'h5' },
589
- H6: { tag: 'h6' },
590
- Svg: {
591
- tag: 'svg',
592
- attr: {
593
- xmlns: 'http://www.w3.org/2000/svg',
594
- 'xmlns:xlink': 'http://www.w3.org/1999/xlink'
595
- }
596
- },
597
- Text: { tag: 'span' }
598
- }
599
-
600
- // ── BR path registry ─────────────────────────────────────────────────────────
601
-
602
- /**
603
- * Walks a DOMQL element tree (after mapKeysToElements has set __brKey on each
604
- * element) and returns a plain object mapping "element path" → "data-br key".
605
- *
606
- * The path is a dot-separated chain of element keys from root to leaf:
607
- * '' (root element itself) → '__root'
608
- * 'Header' → 'Header'
609
- * 'Header.Nav' → 'Header.Nav'
610
- *
611
- * The registry is serialized into the pre-rendered HTML so the browser can
612
- * assign the correct brKey to each skeleton element during true hydration.
613
- */
614
- const buildPathRegistry = (element, path = '') => {
615
- if (!element || !element.__ref) return {}
616
- const registry = {}
617
-
618
- const brKey = element.__ref.__brKey
619
- if (brKey) registry[path === '' ? '__root' : path] = brKey
620
-
621
- if (element.__ref.__children) {
622
- for (const childKey of element.__ref.__children) {
623
- const child = element[childKey]
624
- if (child && child.__ref) {
625
- const childPath = path === '' ? childKey : `${path}.${childKey}`
626
- Object.assign(registry, buildPathRegistry(child, childPath))
627
- }
628
- }
629
- }
630
-
631
- return registry
632
- }
633
-
634
- /**
635
- * Renders a Symbols/DOMQL project to HTML on the server.
636
- *
637
- * Accepts project data as a plain object (matching what ProjectDataService provides)
638
- * or as a pre-loaded smbls context. Runs DOMQL in a linkedom virtual DOM,
639
- * assigns data-br keys for hydration, and extracts page metadata for SEO.
640
- *
641
- * @param {object} data - Project data object with: pages, components, designSystem,
642
- * state, functions, methods, snippets, files, app, config/settings
643
- * @param {object} [options]
644
- * @param {string} [options.route='/'] - The route/page to render
645
- * @param {object} [options.state] - State overrides
646
- * @param {object} [options.context] - Additional context overrides
647
- * @returns {Promise<{ html: string, metadata: object, registry: object, element: object }>}
648
- */
649
- export const render = async (data, options = {}) => {
650
- const { route = '/', pathname, state: stateOverrides, context: contextOverrides, prefetch = false } = options
651
- // pathname is the actual URL path (e.g. /podcast/abc-123), route is the page pattern (e.g. /podcast/:id)
652
- const locationPath = pathname || route
653
-
654
- // ── SSR data prefetching ──
655
- // When prefetch is enabled, walk the page definition to find fetch
656
- // declarations, execute them against the DB adapter, and inject
657
- // the results into element state before rendering.
658
- // Set up globalThis.location early so fetch params that reference
659
- // window.location.pathname (e.g. to extract :id from URL) work during prefetch.
660
- const _prevLocPrefetch = globalThis.location
661
- const _prevWinPrefetch = globalThis.window
662
- if (!globalThis.location || globalThis.location.pathname !== locationPath) {
663
- globalThis.location = { pathname: locationPath, href: locationPath, search: '', hash: '', origin: '' }
664
- }
665
- if (!globalThis.window) {
666
- globalThis.window = { location: globalThis.location }
667
- }
668
- let prefetchedPages
669
- if (prefetch) {
670
- try {
671
- const pages = data.pages || {}
672
- prefetchedPages = { ...pages }
673
- const stateUpdates = await prefetchPageData(data, route)
674
- if (stateUpdates.size) {
675
- // Deep clone the page def to avoid mutating the original
676
- const pageDef = JSON.parse(JSON.stringify(pages[route], (key, value) => {
677
- if (typeof value === 'function') return undefined
678
- return value
679
- }))
680
- // Re-attach functions from original
681
- const copyFunctions = (src, dst) => {
682
- if (!src || !dst) return
683
- for (const k in src) {
684
- if (typeof src[k] === 'function') {
685
- dst[k] = src[k]
686
- } else if (typeof src[k] === 'object' && src[k] !== null && !Array.isArray(src[k]) && typeof dst[k] === 'object' && dst[k] !== null) {
687
- copyFunctions(src[k], dst[k])
688
- }
689
- }
690
- }
691
- copyFunctions(pages[route], pageDef)
692
- injectPrefetchedState(pageDef, stateUpdates)
693
- prefetchedPages[route] = pageDef
694
- }
695
- } catch (prefetchErr) {
696
- console.error('[brender] Prefetch error:', prefetchErr)
697
- prefetchedPages = data.pages
698
- }
699
- }
700
-
701
- // ── SSR polyglot translations ──
702
- // Fetch translations from the DB so polyglot resolves during render
703
- let ssrTranslations
704
- if (prefetch) {
705
- try {
706
- ssrTranslations = await fetchSSRTranslations(data)
707
- } catch (e) {
708
- console.warn('[brender] SSR translation fetch failed:', e.message)
709
- }
710
- }
711
-
712
- // Restore location/window before createEnv sets them properly
713
- if (_prevLocPrefetch !== undefined) globalThis.location = _prevLocPrefetch
714
- else delete globalThis.location
715
- if (_prevWinPrefetch !== undefined) globalThis.window = _prevWinPrefetch
716
- else delete globalThis.window
717
-
718
- const { window, document } = createEnv()
719
- const body = document.body
720
-
721
- // Set route on location so the router picks it up
722
- window.location.pathname = locationPath
723
-
724
- // Set globalThis.document/location so the bundled smbls code
725
- // (which uses `window = globalThis`) can access them during SSR.
726
- const _prevDoc = globalThis.document
727
- const _prevLoc = globalThis.location
728
- globalThis.document = document
729
- globalThis.location = window.location
730
-
731
- // Import createDomqlElement via bundled smbls source.
732
- // The smbls monorepo uses extensionless/directory imports that Node.js ESM
733
- // can't resolve natively, so we bundle it with esbuild first.
734
- const { createDomqlElement } = await bundleCreateDomql()
735
-
736
- const app = structuredCloneDeep(data.app || {})
737
-
738
- // Config fields may be nested under data.config or spread at the top level
739
- // (frank spreads config.js exports at the top level of the JSON)
740
- const config = { ...(data.config || {}) }
741
- if (data.polyglot && !config.polyglot) config.polyglot = data.polyglot
742
- if (data.fetch && !config.fetch) config.fetch = data.fetch
743
- if (data.router && !config.router) config.router = data.router
744
- for (const k of ['useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite', 'useDefaultConfig', 'useDocumentTheme']) {
745
- if (data[k] != null && config[k] == null) config[k] = data[k]
746
- }
747
-
748
- // Inject SSR translations into polyglot config and root state
749
- const polyglotConfig = config.polyglot ? { ...config.polyglot } : undefined
750
- if (ssrTranslations && polyglotConfig) {
751
- polyglotConfig.translations = {
752
- ...(polyglotConfig.translations || {}),
753
- ...ssrTranslations
754
- }
755
- }
756
-
757
- const baseState = structuredCloneDeep(data.state || {})
758
- // Ensure root state has lang and translations for polyglot resolution
759
- if (ssrTranslations || polyglotConfig) {
760
- if (!baseState.root) baseState.root = {}
761
- if (polyglotConfig) {
762
- baseState.root.lang = baseState.root.lang || polyglotConfig.defaultLang || 'en'
763
- }
764
- if (ssrTranslations) {
765
- baseState.root.translations = {
766
- ...(baseState.root.translations || {}),
767
- ...ssrTranslations
768
- }
769
- }
770
- }
771
-
772
- // Reset the atomic CSS engine for this render pass
773
- resetCss()
774
-
775
- const ctx = {
776
- state: baseState,
777
- ...(stateOverrides ? { state: { ...baseState, ...stateOverrides } } : {}),
778
- dependencies: structuredCloneDeep(data.dependencies || {}),
779
- components: structuredCloneDeep(data.components || {}),
780
- snippets: structuredCloneDeep(data.snippets || {}),
781
- pages: structuredCloneDeep(prefetchedPages || data.pages || {}),
782
- functions: {
783
- ...(data.functions || {}),
784
- // SSR polyglot functions — enable {{ key | polyglot }} resolution during render
785
- polyglot: ssrTranslate,
786
- getActiveLang: ssrGetActiveLang,
787
- getLang: ssrGetActiveLang
788
- },
789
- methods: data.methods || {},
790
- designSystem: structuredCloneDeep(data.designSystem || {}),
791
- files: data.files || {},
792
- sharedLibraries: data.sharedLibraries || [],
793
- ...config,
794
- // Override polyglot with SSR-enriched version
795
- ...(polyglotConfig ? { polyglot: polyglotConfig } : {}),
796
- // Virtual DOM environment
797
- document,
798
- window,
799
- parent: { node: body },
800
- initOptions: {},
801
- // Disable sourcemap tracking in SSR — it causes stack overflows
802
- // when state contains large data arrays (articles, events, etc.)
803
- domqlOptions: { sourcemap: false },
804
- // Caller overrides
805
- ...(contextOverrides || {})
806
- }
807
-
808
- // Add funcql plugin if available (enables funcql schema evaluation in exec())
809
- const funcql = await getFuncqlPlugin()
810
- if (funcql) {
811
- ctx.plugins = ctx.plugins || []
812
- if (!ctx.plugins.some(p => p.name === 'funcql')) {
813
- ctx.plugins.push(funcql)
814
- }
815
- }
816
-
817
- resetKeys()
818
-
819
- const element = await createDomqlElement(app, ctx)
820
-
821
- // Allow async operations (fetch callbacks, state updates, re-renders) to flush.
822
- // DOMQL's fetch plugin fires on element creation and updates state asynchronously.
823
- // With prefetch enabled, data is pre-injected but DOMQL's fetch may also fire
824
- // and trigger state updates. Give enough time for these to complete.
825
- const flushDelay = prefetch ? 2000 : 50
826
- await new Promise(r => setTimeout(r, flushDelay))
827
-
828
- // Assign data-br keys for hydration
829
- assignKeys(body)
830
-
831
- const registry = mapKeysToElements(element)
832
-
833
- // Build element path → brKey registry for client-side true hydration.
834
- // Walks the element tree after mapKeysToElements has set __brKey on each element.
835
- const brRegistry = buildPathRegistry(element)
836
-
837
- // Extract metadata for the rendered route
838
- // Pass the rendered element and its state so function-valued metadata
839
- // (e.g. detail page titles from fetched data) can be resolved
840
- const metadata = extractMetadata(data, route, element, element?.state)
841
-
842
- // Extract CSS from style tags in virtual head (atomic CSS engine writes here)
843
- const emotionCSS = []
844
- const head = document.head || document.querySelector('head')
845
- if (head) {
846
- for (const style of head.querySelectorAll('style')) {
847
- if (style.sheet && style.sheet.cssRules) {
848
- for (const rule of style.sheet.cssRules) {
849
- if (rule.cssText) emotionCSS.push(rule.cssText)
850
- }
851
- }
852
- if (!emotionCSS.length) {
853
- const content = style.textContent || ''
854
- if (content) emotionCSS.push(content)
855
- }
856
- }
857
- }
858
-
859
- let html = fixSvgContent(body.innerHTML)
860
-
861
- // Post-process: resolve any remaining {{ key | polyglot }} templates
862
- // that weren't resolved during DOMQL rendering (e.g. due to timing)
863
- if (ssrTranslations) {
864
- const defaultLang = polyglotConfig?.defaultLang || 'en'
865
- const langMap = ssrTranslations[defaultLang] || Object.values(ssrTranslations)[0] || {}
866
- html = html.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
867
- const trimmed = key.trim()
868
- return langMap[trimmed] ?? match
869
- })
870
- }
871
-
872
- // Restore globalThis after render
873
- if (_prevDoc !== undefined) globalThis.document = _prevDoc
874
- else delete globalThis.document
875
- if (_prevLoc !== undefined) globalThis.location = _prevLoc
876
- else delete globalThis.location
877
-
878
- return { html, metadata, registry, brRegistry, element, emotionCSS, document, window, ssrTranslations, prefetchedPages }
879
- }
880
-
881
- /**
882
- * Renders a single DOMQL element definition to HTML.
883
- * Useful for rendering individual components without a full project.
884
- *
885
- * @param {object} elementDef - DOMQL element definition
886
- * @param {object} [options]
887
- * @param {object} [options.context] - DOMQL context (components, designSystem, etc.)
888
- * @returns {Promise<{ html: string, registry: object, element: object }>}
889
- */
890
- export const renderElement = async (elementDef, options = {}) => {
891
- const { context = {} } = options
892
-
893
- const { window, document } = createEnv()
894
- const body = document.body
895
-
896
- const { create } = await import('@symbo.ls/element')
897
- const domqlUtils = await import('@symbo.ls/utils')
898
-
899
- // Merge minimal uikit stubs so DOMQL resolves extends chains
900
- // (e.g. extends: 'Link' → tag: 'a', extends: 'Flex' → display: flex)
901
- const components = { ...UIKIT_STUBS, ...(context.components || {}) }
902
-
903
- // Register utility functions so element.call() can resolve them
904
- // (e.g. replaceLiteralsWithObjectFields for {{ }} templates)
905
- const utils = {
906
- ...domqlUtils,
907
- ...(context.utils || {}),
908
- ...(context.functions || {})
909
- }
910
-
911
- resetKeys()
912
-
913
- let element
914
- try {
915
- element = create(elementDef, { node: body }, 'root', {
916
- context: { document, window, ...context, components, utils }
917
- })
918
- } catch (err) {
919
- // Lifecycle events (onRender, onDone, etc.) may throw in SSR
920
- // because they access browser-only APIs. The DOM tree is built
921
- // before these fire, so we can still extract HTML.
922
- }
923
-
924
- assignKeys(body)
925
- const registry = element ? mapKeysToElements(element) : {}
926
- const html = fixSvgContent(body.innerHTML)
927
-
928
- return { html, registry, element }
929
- }
930
-
931
- // ── SVG content post-processing ───────────────────────────────────────────────
932
- // DOMQL's html mixin uses textContent for SVG nodes, which escapes HTML entities.
933
- // This post-processor unescapes content inside <svg> tags so paths/circles render.
934
- const fixSvgContent = (html) => {
935
- return html.replace(
936
- /(<svg\b[^>]*>)([\s\S]*?)(<\/svg>)/gi,
937
- (match, open, content, close) => {
938
- if (content.includes('&lt;')) {
939
- const unescaped = content
940
- .replace(/&lt;/g, '<')
941
- .replace(/&gt;/g, '>')
942
- .replace(/&amp;/g, '&')
943
- .replace(/&quot;/g, '"')
944
- .replace(/&#39;/g, "'")
945
- return open + unescaped + close
946
- }
947
- return match
948
- }
949
- )
950
- }
951
-
952
- // ── Global CSS generation ─────────────────────────────────────────────────────
953
-
954
- /**
955
- * Runs the scratch design-system pipeline (via esbuild bundling to work around
956
- * bare-import issues) to produce CSS variables and reset styles — the same
957
- * globals that the SPA runtime injects via emotion.injectGlobal.
958
- */
959
- let _cachedGlobalCSS = null
960
-
961
- // Frank serializes a project's `config.js` exports at the TOP LEVEL of the
962
- // data payload (not under `data.config`), so `globalTheme` / `themeStorageKey`
963
- // / `useReset` / etc. live alongside `components` / `pages` / `designSystem`.
964
- // Both renderRoute and renderPage used to call `generateGlobalCSS(ds,
965
- // data.config || data.settings)` which resolved to undefined for any project
966
- // pushed through frank — every config flag silently dropped, including
967
- // `globalTheme: 'light'`. The hardcoded `globalTheme: 'auto'` default in
968
- // generateGlobalCSS then won, prod always rendered in matchMedia-detected
969
- // theme regardless of what the project declared. Helper picks the config
970
- // flags from `data` whichever shape they arrive in.
971
- const SCRATCH_CONFIG_FLAGS = [
972
- 'globalTheme', 'themeStorageKey', 'themeRoot',
973
- 'useReset', 'useVariable', 'useFontImport', 'useIconSprite', 'useSvgSprite',
974
- 'useDocumentTheme', 'useDefaultConfig', 'useDefaultIcons',
975
- 'useThemeSuffixedVars', 'verbose', 'semanticIcons',
976
- ]
977
- const pickProjectConfig = (data) => {
978
- if (!data || typeof data !== 'object') return null
979
- if (data.config && typeof data.config === 'object') return data.config
980
- const out = {}
981
- let any = false
982
- for (const flag of SCRATCH_CONFIG_FLAGS) {
983
- if (Object.prototype.hasOwnProperty.call(data, flag)) {
984
- out[flag] = data[flag]
985
- any = true
986
- }
987
- }
988
- if (any) return out
989
- return data.settings || null
990
- }
991
-
992
- const generateGlobalCSS = async (ds, config) => {
993
- if (_cachedGlobalCSS) return _cachedGlobalCSS
994
-
995
- try {
996
- const { existsSync, writeFileSync, unlinkSync } = await import('fs')
997
- const { tmpdir } = await import('os')
998
- const { randomBytes } = await import('crypto')
999
-
1000
- // Guard: skip if filesystem APIs aren't available (e.g. CF Workers)
1001
- try { tmpdir() } catch { return {} }
1002
-
1003
- const esbuild = await import('esbuild')
1004
-
1005
- // Write a temporary script that imports scratch, runs set(), and
1006
- // serialises the CSS_VARS + RESET objects as JSON.
1007
- const dsJson = JSON.stringify(ds || {}, safeJsonReplacer())
1008
- // Config may contain non-serializable values (e.g. Supabase client with
1009
- // circular refs, functions). Strip those for the CSS generation script.
1010
- const cfgJson = JSON.stringify(config || {}, safeJsonReplacer())
1011
- const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}.mjs`)
1012
- const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}_out.mjs`)
1013
-
1014
- writeFileSync(tmpEntry, `
1015
- import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
1016
- import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
1017
-
1018
- const ds = ${dsJson}
1019
- const cfg = ${cfgJson}
1020
-
1021
- // Merge with defaults (same as initEmotion)
1022
- const merged = {}
1023
- for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
1024
- for (const k in ds) {
1025
- if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
1026
- merged[k] = { ...merged[k], ...ds[k] }
1027
- } else {
1028
- merged[k] = ds[k]
1029
- }
1030
- }
1031
-
1032
- const conf = set({
1033
- useReset: true,
1034
- useVariable: true,
1035
- useFontImport: true,
1036
- useDocumentTheme: true,
1037
- useDefaultConfig: true,
1038
- globalTheme: 'auto',
1039
- ...merged,
1040
- ...cfg
1041
- }, { newConfig: {} })
1042
-
1043
- const result = {
1044
- CSS_VARS: conf.CSS_VARS || {},
1045
- CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
1046
- reset: conf.reset || {},
1047
- animation: conf.animation || {}
1048
- }
1049
- // Export as globalThis so we can read it
1050
- globalThis.__BR_GLOBAL_CSS__ = result
1051
- export default result
1052
- `)
1053
-
1054
- // Detect workspace layout (monorepo vs npm install)
1055
- const ws = detectWorkspace()
1056
-
1057
- // Workspace resolve plugin: maps @symbo.ls/* and @symbo.ls/* to source paths
1058
- const workspacePlugin = {
1059
- name: 'workspace-resolve',
1060
- setup (build) {
1061
- build.onResolve({ filter: /^@symbo\.ls\// }, args => {
1062
- const pkg = args.path.replace('@symbo.ls/', '')
1063
- if (ws.isMonorepo) {
1064
- for (const dir of ['packages', 'plugins']) {
1065
- const src = resolve(ws.monorepoRoot, dir, pkg, 'src', 'index.js')
1066
- if (existsSync(src)) return { path: src }
1067
- const dist = resolve(ws.monorepoRoot, dir, pkg, 'index.js')
1068
- if (existsSync(dist)) return { path: dist }
1069
- }
1070
- const blank = resolve(ws.monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
1071
- if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
1072
- } else {
1073
- const resolved = resolveSymbolsPackage(ws, pkg, 'src', 'index.js')
1074
- if (resolved && existsSync(resolved)) return { path: resolved }
1075
- const resolvedIdx = resolveSymbolsPackage(ws, pkg, 'index.js')
1076
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
1077
- if (pkg === 'default-config') {
1078
- const blank = resolveSymbolsPackage(ws, 'default-config', 'blank', 'index.js')
1079
- if (blank && existsSync(blank)) return { path: blank }
1080
- }
1081
- }
1082
- })
1083
- build.onResolve({ filter: /^@domql\// }, args => {
1084
- const pkg = args.path.replace('@symbo.ls/', '')
1085
- if (ws.isMonorepo) {
1086
- const src = resolve(ws.monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
1087
- if (existsSync(src)) return { path: src }
1088
- } else {
1089
- const resolved = resolveDomqlPackage(ws, pkg, 'src', 'index.js')
1090
- if (resolved && existsSync(resolved)) return { path: resolved }
1091
- const resolvedIdx = resolveDomqlPackage(ws, pkg, 'index.js')
1092
- if (resolvedIdx && existsSync(resolvedIdx)) return { path: resolvedIdx }
1093
- }
1094
- })
1095
- }
1096
- }
1097
-
1098
- await esbuild.build({
1099
- entryPoints: [tmpEntry],
1100
- bundle: true,
1101
- format: 'esm',
1102
- platform: 'node',
1103
- outfile: tmpOut,
1104
- write: true,
1105
- logLevel: 'silent',
1106
- plugins: [workspacePlugin],
1107
- nodePaths: ws.isMonorepo
1108
- ? [resolve(ws.monorepoRoot, 'node_modules')]
1109
- : [
1110
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, 'node_modules')] : []),
1111
- ...(ws.projectRoot ? [resolve(ws.projectRoot, 'node_modules')] : []),
1112
- ...(ws.smblsRoot ? [resolve(ws.smblsRoot, '..', '..', 'node_modules')] : [])
1113
- ].filter(p => existsSync(p)),
1114
- 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']
1115
- })
1116
-
1117
- const mod = await import(`file://${tmpOut}`)
1118
- const data = mod.default || {}
1119
- try { unlinkSync(tmpEntry) } catch {} // cleanup: ignore if temp file already removed
1120
- try { unlinkSync(tmpOut) } catch {} // cleanup: ignore if temp file already removed
1121
-
1122
- const cssVars = data.CSS_VARS || {}
1123
- const cssMediaVars = data.CSS_MEDIA_VARS || {}
1124
- const reset = data.RESET || {}
1125
- const animations = data.ANIMATION || {}
1126
-
1127
- // ── :root CSS variables ──
1128
- const varDecls = Object.entries(cssVars)
1129
- .map(([k, v]) => ` ${k}: ${v}`)
1130
- .join(';\n')
1131
- let rootRule = varDecls ? `:root {\n${varDecls};\n}` : ''
1132
-
1133
- // ── Theme-switching CSS vars (media queries + data-theme selectors) ──
1134
- const themeVarRules = Object.entries(cssMediaVars)
1135
- .map(([key, vars]) => {
1136
- const decls = Object.entries(vars)
1137
- .map(([k, v]) => ` ${k}: ${v}`)
1138
- .join(';\n')
1139
- if (!decls) return ''
1140
- if (key.startsWith('@media')) {
1141
- // Media query — only when no data-theme forces a theme
1142
- return `${key} {\n :root:not([data-theme]) {\n${decls};\n }\n}`
1143
- }
1144
- // Selector ([data-theme="..."]) — apply directly
1145
- return `${key} {\n${decls};\n}`
1146
- })
1147
- .filter(Boolean)
1148
- .join('\n\n')
1149
- if (themeVarRules) rootRule += '\n\n' + themeVarRules
1150
-
1151
- // ── Reset styles ──
1152
- const resetRules = generateResetCSS(reset)
1153
-
1154
- // ── @keyframes animations ──
1155
- const keyframeRules = []
1156
- for (const name in animations) {
1157
- const frames = animations[name]
1158
- if (!frames || typeof frames !== 'object') continue
1159
- const frameRules = Object.entries(frames).map(([step, p]) => {
1160
- if (typeof p !== 'object') return ''
1161
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1162
- return ` ${step} { ${decls}; }`
1163
- }).join('\n')
1164
- keyframeRules.push(`@keyframes ${name} {\n${frameRules}\n}`)
1165
- }
1166
-
1167
- _cachedGlobalCSS = {
1168
- rootRule,
1169
- resetRules,
1170
- fontFaceCSS: '',
1171
- keyframeRules: keyframeRules.join('\n')
1172
- }
1173
- return _cachedGlobalCSS
1174
- } catch (err) {
1175
- console.warn('generateGlobalCSS failed:', err.message, err.stack)
1176
- _cachedGlobalCSS = { rootRule: '', resetRules: '', fontFaceCSS: '', keyframeRules: '' }
1177
- return _cachedGlobalCSS
1178
- }
1179
- }
1180
-
1181
- // Accumulate emotion CSS across all page renders.
1182
- // Each page may generate unique CSS classes (e.g. page-specific components/styles).
1183
- // Emotion's singleton cache marks classes as "inserted" after the first render,
1184
- // so subsequent renders only produce NEW classes not seen before.
1185
- // We merge all CSS rules across renders to ensure every page has complete styles.
1186
- let _accumulatedEmotionCSS = new Set()
1187
-
1188
- /**
1189
- * Reset the cached global CSS and emotion CSS (useful when rendering multiple projects).
1190
- */
1191
- export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null; _accumulatedEmotionCSS = new Set() }
1192
-
1193
- /**
1194
- * Returns the complete accumulated emotion CSS from all renders so far.
1195
- * Call this after rendering ALL pages to get the full CSS needed by every page.
1196
- */
1197
- export const getAccumulatedEmotionCSS = () => Array.from(_accumulatedEmotionCSS).join('\n')
1198
-
1199
- /**
1200
- * Replace the emotion CSS in a rendered HTML page with updated CSS.
1201
- * Used in two-pass rendering: render all pages first, then inject complete CSS.
1202
- */
1203
- export const replaceEmotionCSS = (html, newCSS) => {
1204
- return html.replace(
1205
- /<style data-emotion="smbls">[\s\S]*?<\/style>/,
1206
- newCSS ? `<style data-emotion="smbls">\n${newCSS}\n</style>` : ''
1207
- )
1208
- }
1209
-
1210
- // ── Route-level SSR ───────────────────────────────────────────────────────────
1211
-
1212
- /**
1213
- * Renders a single route and returns body HTML + CSS separately.
1214
- * Designed for integration with an existing server router that manages
1215
- * its own <head>, template, and bundle injection.
1216
- *
1217
- * @param {object} data - Full project data
1218
- * @param {object} [options]
1219
- * @param {string} [options.route='/'] - Route to render
1220
- * @returns {Promise<{ html: string, css: string, resetCss: string, fontLinks: string, metadata: object, brKeyCount: number }>}
1221
- */
1222
- export const renderRoute = async (data, options = {}) => {
1223
- const { route = '/', pathname } = options
1224
-
1225
- // Use the full render pipeline which handles polyglot, prefetch, emotion, etc.
1226
- // Pass pathname so the virtual DOM location reflects the actual URL (for dynamic routes)
1227
- const result = await render(data, { route, pathname, prefetch: true })
1228
- if (!result) return null
1229
-
1230
- const ds = data.designSystem || {}
1231
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1232
-
1233
- // Extract prefetched state and language for metadata resolution
1234
- let prefetchedState = null
1235
- let activeLang = null
1236
- try {
1237
- const el = result.element
1238
- const polyglot = el?.context?.polyglot || data.polyglot || data.config?.polyglot
1239
- activeLang = el?.state?.root?.lang || polyglot?.defaultLang || 'en'
1240
-
1241
- // Get prefetched data from the injected page definitions (pre-DOMQL proxy)
1242
- if (result.prefetchedPages && result.prefetchedPages[route]) {
1243
- const pageDef = result.prefetchedPages[route]
1244
- // Collect all state entries from the page definition tree
1245
- const collectStates = (def, result = {}) => {
1246
- if (!def || typeof def !== 'object') return result
1247
- if (def.state && typeof def.state === 'object') {
1248
- for (const [k, v] of Object.entries(def.state)) {
1249
- if (v !== undefined && v !== null && typeof v !== 'function') {
1250
- result[k] = v
1251
- }
1252
- }
1253
- }
1254
- // Recurse into all child definitions (not just those with state)
1255
- for (const [key, child] of Object.entries(def)) {
1256
- if (key === 'state' || key === 'props' || key === 'attr' || key === 'on' || key === 'define' || key === '__ref' || key.startsWith('__')) continue
1257
- if (child && typeof child === 'object' && !Array.isArray(child)) {
1258
- collectStates(child, result)
1259
- }
1260
- }
1261
- return result
1262
- }
1263
- prefetchedState = collectStates(pageDef)
1264
- }
1265
- } catch (e) { /* ignore */ }
1266
-
1267
- return {
1268
- html: result.html,
1269
- css: result.emotionCSS ? result.emotionCSS.join('\n') : '',
1270
- globalCSS,
1271
- resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
1272
- fontLinks: generateFontLinks(ds),
1273
- metadata: result.metadata || extractMetadata(data, route),
1274
- brKeyCount: result.registry ? Object.keys(result.registry).length : 0,
1275
- brRegistry: result.brRegistry || {},
1276
- ssrTranslations: result.ssrTranslations,
1277
- prefetchedState,
1278
- activeLang
1279
- }
1280
- }
1281
-
1282
- // ── Full page SSR ─────────────────────────────────────────────────────────────
1283
-
1284
- /**
1285
- * Renders a complete HTML page for a route — ready to serve.
1286
- * Uses the full smbls pipeline (createDomqlElement) so the output
1287
- * matches exactly what the SPA produces in the browser.
1288
- *
1289
- * Includes head, metadata, fonts, reset CSS, component CSS, and body.
1290
- *
1291
- * @param {object} data - Full project data (from loadProject)
1292
- * @param {string} route - Route to render (e.g. '/', '/about')
1293
- * @param {object} [options]
1294
- * @param {string} [options.lang='en'] - HTML lang attribute
1295
- * @param {string} [options.themeColor] - theme-color meta
1296
- * @param {object} [options.isr] - ISR options with clientScript path
1297
- * @param {boolean} [options.hydrate=true] - Use true hydration (attach to existing DOM) instead of full SPA re-render
1298
- * @param {boolean} [options.prefetch=true] - Whether to prefetch data via DB adapter
1299
- * @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
1300
- */
1301
- export const renderPage = async (data, route = '/', options = {}) => {
1302
- const { lang, themeColor, isr, hydrate = true, prefetch = true } = options
1303
-
1304
- // Detect lang from project config, app metadata, or default
1305
- const htmlLang = lang || data.state?.lang || data.app?.metadata?.lang || 'en'
1306
-
1307
- // Use the full smbls pipeline for rendering
1308
- const result = await render(data, { route, prefetch })
1309
- if (!result) return null
1310
-
1311
- const metadata = { ...result.metadata }
1312
- if (themeColor) metadata['theme-color'] = themeColor
1313
- const headTags = generateHeadHtml(metadata)
1314
-
1315
- // Accumulate emotion CSS from each page render.
1316
- // Each page may introduce unique CSS classes not seen on previous pages.
1317
- // Emotion's singleton cache only emits NEW classes per render, so we
1318
- // collect all rules across renders to build the complete stylesheet.
1319
- if (result.emotionCSS && result.emotionCSS.length) {
1320
- for (const rule of result.emotionCSS) {
1321
- if (rule) _accumulatedEmotionCSS.add(rule)
1322
- }
1323
- }
1324
- const emotionCSS = Array.from(_accumulatedEmotionCSS).join('\n')
1325
-
1326
- // Generate global CSS (variables, reset, keyframes) via scratch pipeline
1327
- const ds = data.designSystem || {}
1328
- const globalCSS = await generateGlobalCSS(ds, pickProjectConfig(data))
1329
-
1330
- // Generate font links from design system
1331
- const fontLinks = generateFontLinks(ds)
1332
-
1333
- const brKeyCount = Object.keys(result.registry).length
1334
-
1335
- // ISR: include client SPA bundle for hydration + data fetching
1336
- let isrBody = ''
1337
- if (isr && isr.clientScript) {
1338
- // Calculate relative path from route directory to root
1339
- const depth = route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
1340
- const prefix = depth > 0 ? '../'.repeat(depth) : './'
1341
-
1342
- if (hydrate) {
1343
- // True hydration: signal the SPA to adopt existing DOM nodes
1344
- // instead of creating new ones. The SPA detects __BRENDER__ flag
1345
- // and uses onlyResolveExtends + node adoption.
1346
- //
1347
- // Seed client-side polyglot with SSR translations so that
1348
- // el.call('polyglot', key) resolves immediately during hydration
1349
- // instead of showing raw keys until the async fetch completes.
1350
- let translationSeed = ''
1351
- if (result.ssrTranslations) {
1352
- const polyglotCfg = data.polyglot || data.config?.polyglot
1353
- const storagePrefix = polyglotCfg?.storagePrefix || ''
1354
- const storageLangKey = polyglotCfg?.storageLangKey || ''
1355
- const seedEntries = []
1356
- for (const lang in result.ssrTranslations) {
1357
- const map = result.ssrTranslations[lang]
1358
- if (map && typeof map === 'object') {
1359
- seedEntries.push(`localStorage.setItem(${JSON.stringify(storagePrefix + lang)},${JSON.stringify(JSON.stringify(map))})`)
1360
- }
1361
- }
1362
- if (storageLangKey) {
1363
- const defaultLang = polyglotCfg?.defaultLang || 'en'
1364
- seedEntries.push(`if(!localStorage.getItem(${JSON.stringify(storageLangKey)}))localStorage.setItem(${JSON.stringify(storageLangKey)},${JSON.stringify(defaultLang)})`)
1365
- }
1366
- if (seedEntries.length) {
1367
- translationSeed = `<script>try{${seedEntries.join(';')}}catch(e){}</script>\n`
1368
- }
1369
- }
1370
- // Embed BR registry for true DOM-adoption hydration
1371
- const brRegistryJson = result.brRegistry && Object.keys(result.brRegistry).length
1372
- ? JSON.stringify(result.brRegistry)
1373
- : null
1374
- const brRegistryScript = brRegistryJson
1375
- ? `<script>window.__BR_REGISTRY__=${brRegistryJson}</script>\n`
1376
- : ''
1377
- isrBody = `${translationSeed}${brRegistryScript}<script>window.__BRENDER__=true</script>
1378
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1379
- } else {
1380
- // Legacy swap mode: SPA creates new DOM, MutationObserver removes brender nodes
1381
- isrBody = `<script type="module">
1382
- {
1383
- const brEls = document.querySelectorAll('body > :not(script):not(style)')
1384
- const observer = new MutationObserver((mutations) => {
1385
- for (const m of mutations) {
1386
- for (const node of m.addedNodes) {
1387
- if (node.nodeType === 1 && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE' && !node.hasAttribute('data-br')) {
1388
- brEls.forEach(el => { if (el.hasAttribute('data-br') || el.querySelector('[data-br]')) el.remove() })
1389
- observer.disconnect()
1390
- return
1391
- }
1392
- }
1393
- }
1394
- })
1395
- observer.observe(document.body, { childList: true })
1396
- }
1397
- </script>
1398
- <script type="module" src="${prefix}${isr.clientScript}"></script>`
1399
- }
1400
- }
1401
-
1402
- // Resolve any {{ key | polyglot }} templates in head tags (title, meta, etc.)
1403
- const headConfig = { ...(data.config || {}) }
1404
- if (data.polyglot && !headConfig.polyglot) headConfig.polyglot = data.polyglot
1405
- const polyglotCfg = headConfig.polyglot
1406
- let resolvedHeadTags = headTags
1407
- if (polyglotCfg) {
1408
- const defaultLang = polyglotCfg.defaultLang || 'en'
1409
- // Use SSR-fetched translations (from render result) merged with static translations
1410
- const translations = {
1411
- ...(polyglotCfg.translations || {}),
1412
- ...(result.ssrTranslations || {})
1413
- }
1414
- const langMap = translations[defaultLang] || {}
1415
- resolvedHeadTags = headTags.replace(/\{\{\s*([^|{}]+?)\s*\|\s*polyglot\s*\}\}/g, (match, key) => {
1416
- const trimmed = key.trim()
1417
- return langMap[trimmed] ?? match
1418
- })
1419
- }
1420
-
1421
- const html = `<!DOCTYPE html>
1422
- <html lang="${htmlLang}">
1423
- <head>
1424
- ${resolvedHeadTags}
1425
- ${fontLinks}
1426
- ${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ''}
1427
- <style>
1428
- ${globalCSS.rootRule || ''}
1429
- ${globalCSS.resetRules || ''}
1430
- ${globalCSS.keyframeRules || ''}
1431
- </style>
1432
- ${emotionCSS ? `<style data-emotion="smbls">\n${emotionCSS}\n</style>` : ''}
1433
- </head>
1434
- <body>
1435
- ${result.html}
1436
- ${isrBody}
1437
- </body>
1438
- </html>`
1439
-
1440
- return { html, route, brKeyCount }
1441
- }
1442
-
1443
- // ── Design system token resolution ──────────────────────────────────────────
1444
-
1445
- const LETTER_TO_INDEX = {
1446
- U: -6, V: -5, W: -4, X: -3, Y: -2, Z: -1,
1447
- A: 0, B: 1, C: 2, D: 3, E: 4, F: 5, G: 6, H: 7, I: 8, J: 9,
1448
- K: 10, L: 11, M: 12, N: 13, O: 14, P: 15
1449
- }
1450
-
1451
- const SPACING_PROPS = new Set([
1452
- 'padding', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
1453
- 'paddingBlock', 'paddingInline', 'paddingBlockStart', 'paddingBlockEnd',
1454
- 'paddingInlineStart', 'paddingInlineEnd',
1455
- 'margin', 'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
1456
- 'marginBlock', 'marginInline', 'marginBlockStart', 'marginBlockEnd',
1457
- 'marginInlineStart', 'marginInlineEnd',
1458
- 'gap', 'rowGap', 'columnGap',
1459
- 'top', 'right', 'bottom', 'left',
1460
- 'width', 'height', 'minWidth', 'maxWidth', 'minHeight', 'maxHeight',
1461
- 'flexBasis', 'fontSize', 'lineHeight', 'letterSpacing',
1462
- 'borderWidth', 'borderRadius', 'outlineWidth', 'outlineOffset',
1463
- 'inset', 'insetBlock', 'insetInline',
1464
- 'boxSize', 'round'
1465
- ])
1466
-
1467
- /**
1468
- * Resolves a spacing token like 'B2', 'A', 'E3' to a px/em value.
1469
- * Uses base * ratio^index for main steps (A=0, B=1, etc.)
1470
- * and sub-ratio interpolation for sub-steps (B1, B2, B3).
1471
- */
1472
- const resolveSpacingToken = (token, spacingConfig) => {
1473
- if (!token || typeof token !== 'string') return null
1474
- if (!spacingConfig) return null
1475
-
1476
- const base = spacingConfig.base || 16
1477
- const ratio = spacingConfig.ratio || 1.618
1478
- const unit = spacingConfig.unit || 'px'
1479
- const hasSubSequence = spacingConfig.subSequence !== false
1480
-
1481
- // Handle compound values like 'B2 - -' or 'A1 B C1'
1482
- if (token.includes(' ')) {
1483
- const parts = token.split(' ').map(part => {
1484
- if (part === '-' || part === '') return part
1485
- return resolveSpacingToken(part, spacingConfig) || part
1486
- })
1487
- return parts.join(' ')
1488
- }
1489
-
1490
- // Skip CSS keywords and values with units
1491
- if (/^(none|auto|inherit|initial|unset|0)$/i.test(token)) return null
1492
- if (/\d+(px|em|rem|%|vh|vw|vmin|vmax|ch|ex|cm|mm|in|pt|pc|fr|s|ms)$/i.test(token)) return null
1493
- // Skip hex colors, rgb(), etc.
1494
- if (/^(#|rgb|hsl|var\()/i.test(token)) return null
1495
-
1496
- const isNegative = token.startsWith('-')
1497
- const abs = isNegative ? token.slice(1) : token
1498
-
1499
- // Match letter + optional digit: A, B, B2, E3, etc.
1500
- const m = abs.match(/^([A-Z])(\d)?$/i)
1501
- if (!m) return null
1502
-
1503
- const letter = m[1].toUpperCase()
1504
- const subStep = m[2] ? parseInt(m[2]) : 0
1505
- const idx = LETTER_TO_INDEX[letter]
1506
- if (idx === undefined) return null
1507
-
1508
- let value = base * Math.pow(ratio, idx)
1509
-
1510
- if (subStep > 0 && hasSubSequence) {
1511
- const next = base * Math.pow(ratio, idx + 1)
1512
- const diff = next - value
1513
- const subRatio = diff / ratio
1514
- // Sub-steps: 1 = value + (diff - subRatio), 2 = midpoint, 3 = value + subRatio
1515
- const first = next - subRatio
1516
- const second = value + subRatio
1517
- const middle = (first + second) / 2
1518
- const subs = (~~next - ~~value > 16) ? [first, middle, second] : [first, second]
1519
- if (subStep <= subs.length) {
1520
- value = subs[subStep - 1]
1521
- }
1522
- }
1523
-
1524
- const rounded = Math.round(value * 100) / 100
1525
- const sign = isNegative ? '-' : ''
1526
- return `${sign}${rounded}${unit}`
1527
- }
1528
-
1529
- // Kebab-case versions of spacing props for post-shorthand resolution
1530
- const SPACING_PROPS_KEBAB = new Set(
1531
- [...SPACING_PROPS].map(k => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()))
1532
- )
1533
-
1534
- /**
1535
- * Try to resolve a CSS value through the design system.
1536
- * Returns the resolved value or the original if not a token.
1537
- */
1538
- const resolveDSValue = (key, val, ds) => {
1539
- if (typeof val !== 'string') return val
1540
-
1541
- // Color resolution
1542
- if (CSS_COLOR_PROPS.has(key)) {
1543
- const colorMap = ds?.color || {}
1544
- if (colorMap[val]) return colorMap[val]
1545
- }
1546
-
1547
- // Spacing resolution (check both camelCase and kebab-case keys)
1548
- if (SPACING_PROPS.has(key) || SPACING_PROPS_KEBAB.has(key)) {
1549
- const spacing = ds?.spacing || {}
1550
- const resolved = resolveSpacingToken(val, spacing)
1551
- if (resolved) return resolved
1552
- }
1553
-
1554
- return val
1555
- }
1556
-
1557
- // ── CSS helpers ─────────────────────────────────────────────────────────────
1558
-
1559
- const CSS_COLOR_PROPS = new Set([
1560
- 'color', 'background', 'backgroundColor', 'borderColor',
1561
- 'borderTopColor', 'borderRightColor', 'borderBottomColor', 'borderLeftColor',
1562
- 'outlineColor', 'fill', 'stroke'
1563
- ])
1564
-
1565
- const NON_CSS_PROPS = new Set([
1566
- 'href', 'src', 'alt', 'title', 'id', 'name', 'type', 'value', 'placeholder',
1567
- 'target', 'rel', 'loading', 'srcset', 'sizes', 'media', 'role', 'tabindex',
1568
- 'for', 'action', 'method', 'enctype', 'autocomplete', 'autofocus',
1569
- 'theme', '__element', 'update',
1570
- 'childrenAs', 'childExtends', 'childProps', 'children'
1571
- ])
1572
-
1573
- const camelToKebab = (str) => str.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
1574
-
1575
- const resolveShorthand = (key, val) => {
1576
- if (typeof val === 'undefined' || val === null) return null
1577
-
1578
- // Flex shorthands
1579
- if (key === 'flow' && typeof val === 'string') {
1580
- let [direction, wrap] = (val || 'row').split(' ')
1581
- if (val.startsWith('x') || val === 'row') direction = 'row'
1582
- if (val.startsWith('y') || val === 'column') direction = 'column'
1583
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1584
- }
1585
- if (key === 'wrap') {
1586
- return { display: 'flex', 'flex-wrap': val }
1587
- }
1588
- if ((key === 'align' || key === 'flexAlign') && typeof val === 'string') {
1589
- const [alignItems, justifyContent] = val.split(' ')
1590
- const result = { display: 'flex', 'align-items': alignItems }
1591
- if (justifyContent) result['justify-content'] = justifyContent
1592
- return result
1593
- }
1594
- if (key === 'gridAlign' && typeof val === 'string') {
1595
- const [alignItems, justifyContent] = val.split(' ')
1596
- const result = { display: 'grid', 'align-items': alignItems }
1597
- if (justifyContent) result['justify-content'] = justifyContent
1598
- return result
1599
- }
1600
- if (key === 'flexFlow' && typeof val === 'string') {
1601
- let [direction, wrap] = (val || 'row').split(' ')
1602
- if (val.startsWith('x') || val === 'row') direction = 'row'
1603
- if (val.startsWith('y') || val === 'column') direction = 'column'
1604
- return { display: 'flex', 'flex-flow': (direction || '') + ' ' + (wrap || '') }
1605
- }
1606
- if (key === 'flexWrap') {
1607
- return { display: 'flex', 'flex-wrap': val }
1608
- }
1609
-
1610
- // Background image shorthand
1611
- if (key === 'backgroundImage' && typeof val === 'string' && !val.startsWith('url(') && !val.startsWith('linear-gradient') && !val.startsWith('radial-gradient') && !val.startsWith('none')) {
1612
- return { 'background-image': `url(${val})` }
1613
- }
1614
-
1615
- // Box/size shorthands
1616
- if (key === 'round' || (key === 'borderRadius' && val)) {
1617
- return { 'border-radius': typeof val === 'number' ? val + 'px' : val }
1618
- }
1619
- if (key === 'boxSize' && typeof val === 'string') {
1620
- const [height, width] = val.split(' ')
1621
- return { height, width: width || height }
1622
- }
1623
- if (key === 'widthRange' && typeof val === 'string') {
1624
- const [minWidth, maxWidth] = val.split(' ')
1625
- return { 'min-width': minWidth, 'max-width': maxWidth || minWidth }
1626
- }
1627
- if (key === 'heightRange' && typeof val === 'string') {
1628
- const [minHeight, maxHeight] = val.split(' ')
1629
- return { 'min-height': minHeight, 'max-height': maxHeight || minHeight }
1630
- }
1631
-
1632
- // Grid aliases
1633
- if (key === 'column') return { 'grid-column': val }
1634
- if (key === 'columns') return { 'grid-template-columns': val }
1635
- if (key === 'templateColumns') return { 'grid-template-columns': val }
1636
- if (key === 'row') return { 'grid-row': val }
1637
- if (key === 'rows') return { 'grid-template-rows': val }
1638
- if (key === 'templateRows') return { 'grid-template-rows': val }
1639
- if (key === 'area') return { 'grid-area': val }
1640
- if (key === 'template') return { 'grid-template': val }
1641
- if (key === 'templateAreas') return { 'grid-template-areas': val }
1642
- if (key === 'autoColumns') return { 'grid-auto-columns': val }
1643
- if (key === 'autoRows') return { 'grid-auto-rows': val }
1644
- if (key === 'autoFlow') return { 'grid-auto-flow': val }
1645
- if (key === 'columnStart') return { 'grid-column-start': val }
1646
- if (key === 'rowStart') return { 'grid-row-start': val }
1647
-
1648
- return null
1649
- }
1650
-
1651
- const resolveInnerProps = (obj, ds) => {
1652
- const result = {}
1653
- for (const k in obj) {
1654
- const v = obj[k]
1655
- const expanded = resolveShorthand(k, v)
1656
- if (expanded) {
1657
- for (const ek in expanded) {
1658
- result[ek] = resolveDSValue(ek, expanded[ek], ds)
1659
- }
1660
- continue
1661
- }
1662
- if (typeof v !== 'string' && typeof v !== 'number') continue
1663
- result[camelToKebab(k)] = resolveDSValue(k, v, ds)
1664
- }
1665
- return result
1666
- }
1667
-
1668
- const buildCSSFromProps = (props, ds, mediaMap) => {
1669
- const base = {}
1670
- const mediaRules = {}
1671
- const pseudoRules = {}
1672
-
1673
- for (const key in props) {
1674
- const val = props[key]
1675
-
1676
- if (key.charCodeAt(0) === 64 && typeof val === 'object') {
1677
- const bp = mediaMap?.[key.slice(1)]
1678
- if (bp) {
1679
- const inner = resolveInnerProps(val, ds)
1680
- if (Object.keys(inner).length) mediaRules[bp] = inner
1681
- }
1682
- continue
1683
- }
1684
-
1685
- if (key.charCodeAt(0) === 58 && typeof val === 'object') {
1686
- const inner = resolveInnerProps(val, ds)
1687
- if (Object.keys(inner).length) pseudoRules[key] = inner
1688
- continue
1689
- }
1690
-
1691
- if (typeof val !== 'string' && typeof val !== 'number') continue
1692
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1693
- if (NON_CSS_PROPS.has(key)) continue
1694
-
1695
- const expanded = resolveShorthand(key, val)
1696
- if (expanded) {
1697
- for (const ek in expanded) {
1698
- base[ek] = resolveDSValue(ek, expanded[ek], ds)
1699
- }
1700
- continue
1701
- }
1702
-
1703
- base[camelToKebab(key)] = resolveDSValue(key, val, ds)
1704
- }
1705
-
1706
- return { base, mediaRules, pseudoRules }
1707
- }
1708
-
1709
- const renderCSSRule = (selector, { base, mediaRules, pseudoRules }) => {
1710
- const lines = []
1711
- const baseDecls = Object.entries(base).map(([k, v]) => `${k}: ${v}`).join('; ')
1712
- if (baseDecls) lines.push(`${selector} { ${baseDecls}; }`)
1713
-
1714
- for (const [pseudo, p] of Object.entries(pseudoRules)) {
1715
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1716
- if (decls) lines.push(`${selector}${pseudo} { ${decls}; }`)
1717
- }
1718
-
1719
- for (const [query, p] of Object.entries(mediaRules)) {
1720
- const decls = Object.entries(p).map(([k, v]) => `${k}: ${v}`).join('; ')
1721
- const mq = query.startsWith('@') ? query : `@media ${query}`
1722
- if (decls) lines.push(`${mq} { ${selector} { ${decls}; } }`)
1723
- }
1724
-
1725
- return lines.join('\n')
1726
- }
1727
-
1728
- // Map of component names to their implicit CSS from extends
1729
- const EXTENDS_CSS = {
1730
- Flex: { display: 'flex' },
1731
- InlineFlex: { display: 'inline-flex' },
1732
- Grid: { display: 'grid' },
1733
- InlineGrid: { display: 'inline-grid' },
1734
- Block: { display: 'block' },
1735
- Inline: { display: 'inline' }
1736
- }
1737
-
1738
- const getExtendsCSS = (el) => {
1739
- const exts = el.__ref?.__extends
1740
- if (!exts || !Array.isArray(exts)) return null
1741
- for (const ext of exts) {
1742
- if (EXTENDS_CSS[ext]) return EXTENDS_CSS[ext]
1743
- }
1744
- return null
1745
- }
1746
-
1747
- /**
1748
- * Resolve function-valued CSS props by evaluating them with (element, state).
1749
- * In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
1750
- * Uses fallback mock state if element state is incomplete.
1751
- */
1752
- const resolveElementProps = (el) => {
1753
- let resolved
1754
- for (const key in el) {
1755
- if (typeof el[key] !== 'function') continue
1756
- // Skip non-CSS props and component children
1757
- if (NON_CSS_PROPS.has(key)) continue
1758
- if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
1759
- if (key.startsWith('on')) continue
1760
- if (key.startsWith('__')) continue
1761
- if (!resolved) resolved = {}
1762
- let result
1763
- try {
1764
- result = el[key](el, el.state || {})
1765
- } catch {
1766
- // State prototype chain may be incomplete in SSR — try with mock
1767
- try {
1768
- const mockState = { root: {}, ...(el.state || {}) }
1769
- result = el[key](el, mockState)
1770
- } catch { /* skip prop */ }
1771
- }
1772
- if (result !== undefined && result !== null && result !== false) {
1773
- resolved[key] = result
1774
- }
1775
- }
1776
- return resolved || el
1777
- }
1778
-
1779
- const extractCSS = (element, ds) => {
1780
- const mediaMap = ds?.media || {}
1781
- const animations = ds?.animation || {}
1782
- const rules = []
1783
- const seen = new Set()
1784
- const usedAnimations = new Set()
1785
-
1786
- const walk = (el) => {
1787
- if (!el || !el.__ref) return
1788
- const props = resolveElementProps(el)
1789
- if (props && el.node) {
1790
- const cls = el.node.getAttribute?.('class')
1791
- if (cls && !seen.has(cls)) {
1792
- seen.add(cls)
1793
- const cssResult = buildCSSFromProps(props, ds, mediaMap)
1794
-
1795
- // Inject CSS from extends chain (e.g. extends: 'Flex' → display: flex)
1796
- const extsCss = getExtendsCSS(el)
1797
- if (extsCss) {
1798
- for (const [k, v] of Object.entries(extsCss)) {
1799
- const kebab = camelToKebab(k)
1800
- if (!cssResult.base[kebab]) cssResult.base[kebab] = v
1801
- }
1802
- }
1803
-
1804
- const has = Object.keys(cssResult.base).length || Object.keys(cssResult.mediaRules).length || Object.keys(cssResult.pseudoRules).length
1805
- if (has) rules.push(renderCSSRule('.' + cls.split(' ')[0], cssResult))
1806
-
1807
- const anim = props.animation || props.animationName
1808
- if (typeof anim === 'string') {
1809
- const name = anim.split(' ')[0]
1810
- if (animations[name]) usedAnimations.add(name)
1811
- }
1812
- }
1813
- }
1814
- if (el.__ref?.__children) {
1815
- for (const ck of el.__ref.__children) {
1816
- if (el[ck]?.__ref) walk(el[ck])
1817
- }
1818
- }
1819
- }
1820
- walk(element)
1821
-
1822
- const keyframes = []
1823
- for (const name of usedAnimations) {
1824
- const frames = animations[name]
1825
- const frameRules = Object.entries(frames).map(([step, p]) => {
1826
- const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
1827
- return ` ${step} { ${decls}; }`
1828
- }).join('\n')
1829
- keyframes.push(`@keyframes ${name} {\n${frameRules}\n}`)
1830
- }
1831
-
1832
- return [...keyframes, ...rules].join('\n')
1833
- }
1834
-
1835
- const generateResetCSS = (reset) => {
1836
- if (!reset) return ''
1837
- const rules = []
1838
- for (const [selector, props] of Object.entries(reset)) {
1839
- if (!props || typeof props !== 'object') continue
1840
- const baseDecls = []
1841
- const mediaRules = []
1842
- for (const [k, v] of Object.entries(props)) {
1843
- if (typeof v === 'object' && v !== null) {
1844
- // Nested object: @media query or sub-selector
1845
- if (k.startsWith('@media') || k.startsWith('@')) {
1846
- const inner = Object.entries(v)
1847
- .filter(([, iv]) => typeof iv !== 'object')
1848
- .map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`)
1849
- .join('; ')
1850
- if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`)
1851
- }
1852
- continue
1853
- }
1854
- baseDecls.push(`${camelToKebab(k)}: ${v}`)
1855
- }
1856
- if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join('; ')}; }`)
1857
- rules.push(...mediaRules)
1858
- }
1859
- return rules.join('\n')
1860
- }
1861
-
1862
- const generateFontLinks = (ds) => {
1863
- if (!ds) return ''
1864
- const families = ds.font_family || ds.fontFamily || {}
1865
- const fontNames = new Set()
1866
-
1867
- // Collect font family names from the design system
1868
- for (const val of Object.values(families)) {
1869
- if (typeof val !== 'string') continue
1870
- const match = val.match(/'([^']+)'/)
1871
- if (match) fontNames.add(match[1])
1872
- }
1873
-
1874
- if (!fontNames.size) return ''
1875
-
1876
- // Build Google Fonts URL
1877
- const params = [...fontNames].map(name => {
1878
- const slug = name.replace(/\s+/g, '+')
1879
- return `family=${slug}:wght@300;400;500;600;700`
1880
- }).join('&')
1881
-
1882
- return [
1883
- '<link rel="preconnect" href="https://fonts.googleapis.com">',
1884
- '<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>',
1885
- `<link href="https://fonts.googleapis.com/css2?${params}&display=swap" rel="stylesheet">`
1886
- ].join('\n')
1887
- }