@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/CHANGELOG.md +12 -0
- package/dist/esm/env.js +1 -80
- package/dist/esm/hydrate.js +1 -535
- package/dist/esm/index.js +1 -53
- package/dist/esm/keys.js +1 -43
- package/dist/esm/load.js +1 -114
- package/dist/esm/metadata.js +1 -7
- package/dist/esm/prefetch.js +1 -235
- package/dist/esm/render.js +50 -1512
- package/dist/esm/scripts/liquidate.js +11 -0
- package/dist/esm/scripts/render.js +12 -0
- package/dist/esm/sitemap.js +10 -19
- package/package.json +27 -17
- package/dist/cjs/env.js +0 -99
- package/dist/cjs/hydrate.js +0 -554
- package/dist/cjs/index.js +0 -72
- package/dist/cjs/keys.js +0 -62
- package/dist/cjs/load.js +0 -143
- package/dist/cjs/metadata.js +0 -26
- package/dist/cjs/prefetch.js +0 -264
- package/dist/cjs/render.js +0 -1602
- package/dist/cjs/sitemap.js +0 -41
- package/env.js +0 -76
- package/hydrate.js +0 -462
- package/index.js +0 -54
- package/keys.js +0 -54
- package/load.js +0 -141
- package/metadata.js +0 -5
- package/prefetch.js +0 -363
- package/render.js +0 -1887
- package/sitemap.js +0 -28
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('<')) {
|
|
939
|
-
const unescaped = content
|
|
940
|
-
.replace(/</g, '<')
|
|
941
|
-
.replace(/>/g, '>')
|
|
942
|
-
.replace(/&/g, '&')
|
|
943
|
-
.replace(/"/g, '"')
|
|
944
|
-
.replace(/'/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
|
-
}
|