@symbo.ls/brender 3.14.7 → 3.14.8

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