@symbo.ls/brender 3.5.1 → 3.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,4 @@
1
+ import { resolve, join } from "path";
1
2
  import { createEnv } from "./env.js";
2
3
  import { resetKeys, assignKeys, mapKeysToElements } from "./keys.js";
3
4
  import { extractMetadata, generateHeadHtml } from "./metadata.js";
@@ -56,7 +57,15 @@ const UIKIT_STUBS = {
56
57
  H3: { tag: "h3" },
57
58
  H4: { tag: "h4" },
58
59
  H5: { tag: "h5" },
59
- H6: { tag: "h6" }
60
+ H6: { tag: "h6" },
61
+ Svg: {
62
+ tag: "svg",
63
+ attr: {
64
+ xmlns: "http://www.w3.org/2000/svg",
65
+ "xmlns:xlink": "http://www.w3.org/1999/xlink"
66
+ }
67
+ },
68
+ Text: { tag: "span" }
60
69
  };
61
70
  const render = async (data, options = {}) => {
62
71
  const { route = "/", state: stateOverrides, context: contextOverrides } = options;
@@ -114,9 +123,169 @@ const renderElement = async (elementDef, options = {}) => {
114
123
  }
115
124
  assignKeys(body);
116
125
  const registry = element ? mapKeysToElements(element) : {};
117
- const html = body.innerHTML;
126
+ const html = fixSvgContent(body.innerHTML);
118
127
  return { html, registry, element };
119
128
  };
129
+ const fixSvgContent = (html) => {
130
+ return html.replace(
131
+ /(<svg\b[^>]*>)([\s\S]*?)(<\/svg>)/gi,
132
+ (match, open, content, close) => {
133
+ if (content.includes("&lt;")) {
134
+ const unescaped = content.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'");
135
+ return open + unescaped + close;
136
+ }
137
+ return match;
138
+ }
139
+ );
140
+ };
141
+ let _cachedGlobalCSS = null;
142
+ const generateGlobalCSS = async (ds, config) => {
143
+ if (_cachedGlobalCSS) return _cachedGlobalCSS;
144
+ try {
145
+ const { existsSync, writeFileSync, unlinkSync } = await import("fs");
146
+ const { tmpdir } = await import("os");
147
+ const { randomBytes } = await import("crypto");
148
+ const esbuild = await import("esbuild");
149
+ const dsJson = JSON.stringify(ds || {});
150
+ const cfgJson = JSON.stringify(config || {});
151
+ const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString("hex")}.mjs`);
152
+ const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString("hex")}_out.mjs`);
153
+ writeFileSync(tmpEntry, `
154
+ import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
155
+ import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
156
+
157
+ const ds = ${dsJson}
158
+ const cfg = ${cfgJson}
159
+
160
+ // Merge with defaults (same as initEmotion)
161
+ const merged = {}
162
+ for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
163
+ for (const k in ds) {
164
+ if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
165
+ merged[k] = { ...merged[k], ...ds[k] }
166
+ } else {
167
+ merged[k] = ds[k]
168
+ }
169
+ }
170
+
171
+ const conf = set({
172
+ useReset: true,
173
+ useVariable: true,
174
+ useFontImport: true,
175
+ useDocumentTheme: true,
176
+ useDefaultConfig: true,
177
+ globalTheme: 'auto',
178
+ ...merged,
179
+ ...cfg
180
+ }, { newConfig: {} })
181
+
182
+ const result = {
183
+ CSS_VARS: conf.CSS_VARS || {},
184
+ CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
185
+ RESET: conf.RESET || conf.reset || {},
186
+ ANIMATION: conf.animation || conf.ANIMATION || {}
187
+ }
188
+ // Export as globalThis so we can read it
189
+ globalThis.__BR_GLOBAL_CSS__ = result
190
+ export default result
191
+ `);
192
+ const brenderDir = new URL(".", import.meta.url).pathname;
193
+ const monorepoRoot = resolve(brenderDir, "../..");
194
+ const workspacePlugin = {
195
+ name: "workspace-resolve",
196
+ setup(build) {
197
+ build.onResolve({ filter: /^@symbo\.ls\// }, (args) => {
198
+ const pkg = args.path.replace("@symbo.ls/", "");
199
+ for (const dir of ["packages", "plugins"]) {
200
+ const src = resolve(monorepoRoot, dir, pkg, "src", "index.js");
201
+ if (existsSync(src)) return { path: src };
202
+ const dist = resolve(monorepoRoot, dir, pkg, "index.js");
203
+ if (existsSync(dist)) return { path: dist };
204
+ }
205
+ const blank = resolve(monorepoRoot, "packages", "default-config", "blank", "index.js");
206
+ if (pkg === "default-config" && existsSync(blank)) return { path: blank };
207
+ });
208
+ build.onResolve({ filter: /^@domql\// }, (args) => {
209
+ const pkg = args.path.replace("@domql/", "");
210
+ const src = resolve(monorepoRoot, "packages", "domql", "packages", pkg, "src", "index.js");
211
+ if (existsSync(src)) return { path: src };
212
+ });
213
+ }
214
+ };
215
+ await esbuild.build({
216
+ entryPoints: [tmpEntry],
217
+ bundle: true,
218
+ format: "esm",
219
+ platform: "node",
220
+ outfile: tmpOut,
221
+ write: true,
222
+ logLevel: "silent",
223
+ plugins: [workspacePlugin],
224
+ 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"]
225
+ });
226
+ const mod = await import(`file://${tmpOut}`);
227
+ const data = mod.default || {};
228
+ try {
229
+ unlinkSync(tmpEntry);
230
+ } catch {
231
+ }
232
+ try {
233
+ unlinkSync(tmpOut);
234
+ } catch {
235
+ }
236
+ const cssVars = data.CSS_VARS || {};
237
+ const cssMediaVars = data.CSS_MEDIA_VARS || {};
238
+ const reset = data.RESET || {};
239
+ const animations = data.ANIMATION || {};
240
+ const varDecls = Object.entries(cssVars).map(([k, v]) => ` ${k}: ${v}`).join(";\n");
241
+ let rootRule = varDecls ? `:root {
242
+ ${varDecls};
243
+ }` : "";
244
+ const themeVarRules = Object.entries(cssMediaVars).map(([key, vars]) => {
245
+ const decls = Object.entries(vars).map(([k, v]) => ` ${k}: ${v}`).join(";\n");
246
+ if (!decls) return "";
247
+ if (key.startsWith("@media")) {
248
+ return `${key} {
249
+ :root:not([data-theme]) {
250
+ ${decls};
251
+ }
252
+ }`;
253
+ }
254
+ return `${key} {
255
+ ${decls};
256
+ }`;
257
+ }).filter(Boolean).join("\n\n");
258
+ if (themeVarRules) rootRule += "\n\n" + themeVarRules;
259
+ const resetRules = generateResetCSS(reset);
260
+ const keyframeRules = [];
261
+ for (const name in animations) {
262
+ const frames = animations[name];
263
+ if (!frames || typeof frames !== "object") continue;
264
+ const frameRules = Object.entries(frames).map(([step, p]) => {
265
+ if (typeof p !== "object") return "";
266
+ const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join("; ");
267
+ return ` ${step} { ${decls}; }`;
268
+ }).join("\n");
269
+ keyframeRules.push(`@keyframes ${name} {
270
+ ${frameRules}
271
+ }`);
272
+ }
273
+ _cachedGlobalCSS = {
274
+ rootRule,
275
+ resetRules,
276
+ fontFaceCSS: "",
277
+ keyframeRules: keyframeRules.join("\n")
278
+ };
279
+ return _cachedGlobalCSS;
280
+ } catch (err) {
281
+ console.warn("generateGlobalCSS failed:", err.message, err.stack);
282
+ _cachedGlobalCSS = { rootRule: "", resetRules: "", fontFaceCSS: "", keyframeRules: "" };
283
+ return _cachedGlobalCSS;
284
+ }
285
+ };
286
+ const resetGlobalCSSCache = () => {
287
+ _cachedGlobalCSS = null;
288
+ };
120
289
  const renderRoute = async (data, options = {}) => {
121
290
  const { route = "/" } = options;
122
291
  const ds = data.designSystem || {};
@@ -146,34 +315,66 @@ const renderRoute = async (data, options = {}) => {
146
315
  emotion: emotionInstance,
147
316
  designSystem: ds
148
317
  });
318
+ const globalCSS = await generateGlobalCSS(ds, data.config || data.settings);
149
319
  return {
150
320
  html: cssDoc.body.innerHTML,
151
321
  css: extractCSS(result.element, ds),
152
- resetCss: generateResetCSS(ds.reset),
322
+ globalCSS,
323
+ resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
153
324
  fontLinks: generateFontLinks(ds),
154
325
  metadata: extractMetadata(data, route),
155
326
  brKeyCount: Object.keys(result.registry).length
156
327
  };
157
328
  };
158
329
  const renderPage = async (data, route = "/", options = {}) => {
159
- const { lang = "en", themeColor } = options;
330
+ const { lang = "en", themeColor, isr } = options;
160
331
  const result = await renderRoute(data, { route });
161
332
  if (!result) return null;
162
333
  const metadata = { ...result.metadata };
163
334
  if (themeColor) metadata["theme-color"] = themeColor;
164
335
  const headTags = generateHeadHtml(metadata);
336
+ const globalCSS = result.globalCSS || {};
337
+ let isrBody = "";
338
+ if (isr && isr.clientScript) {
339
+ const depth = route === "/" ? 0 : route.replace(/^\/|\/$/g, "").split("/").length;
340
+ const prefix = depth > 0 ? "../".repeat(depth) : "./";
341
+ isrBody = `<script type="module">
342
+ {
343
+ const brEls = document.querySelectorAll('body > :not(script):not(style)')
344
+ const observer = new MutationObserver((mutations) => {
345
+ for (const m of mutations) {
346
+ for (const node of m.addedNodes) {
347
+ if (node.nodeType === 1 && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE' && !node.hasAttribute('data-br')) {
348
+ brEls.forEach(el => { if (el.hasAttribute('data-br') || el.querySelector('[data-br]')) el.remove() })
349
+ observer.disconnect()
350
+ return
351
+ }
352
+ }
353
+ }
354
+ })
355
+ observer.observe(document.body, { childList: true })
356
+ }
357
+ <\/script>
358
+ <script type="module" src="${prefix}${isr.clientScript}"><\/script>`;
359
+ }
165
360
  const html = `<!DOCTYPE html>
166
361
  <html lang="${lang}">
167
362
  <head>
168
363
  ${headTags}
169
364
  ${result.fontLinks}
170
- <style>${result.resetCss}</style>
365
+ ${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ""}
366
+ <style>
367
+ ${globalCSS.rootRule || ""}
368
+ ${result.resetCss}
369
+ ${globalCSS.keyframeRules || ""}
370
+ </style>
171
371
  <style data-emotion="smbls">
172
372
  ${result.css}
173
373
  </style>
174
374
  </head>
175
375
  <body>
176
376
  ${result.html}
377
+ ${isrBody}
177
378
  </body>
178
379
  </html>`;
179
380
  return { html, route, brKeyCount: result.brKeyCount };
@@ -502,6 +703,34 @@ const getExtendsCSS = (el) => {
502
703
  }
503
704
  return null;
504
705
  };
706
+ const resolveElementProps = (el) => {
707
+ const { props } = el;
708
+ if (!props) return props;
709
+ let resolved;
710
+ for (const key in props) {
711
+ if (typeof props[key] !== "function") continue;
712
+ if (NON_CSS_PROPS.has(key)) continue;
713
+ if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue;
714
+ if (key.startsWith("on")) continue;
715
+ if (!resolved) resolved = { ...props };
716
+ let result;
717
+ try {
718
+ result = props[key](el, el.state || {});
719
+ } catch {
720
+ try {
721
+ const mockState = { root: {}, ...el.state || {} };
722
+ result = props[key](el, mockState);
723
+ } catch {
724
+ }
725
+ }
726
+ if (result !== void 0 && result !== null && result !== false) {
727
+ resolved[key] = result;
728
+ } else {
729
+ delete resolved[key];
730
+ }
731
+ }
732
+ return resolved || props;
733
+ };
505
734
  const extractCSS = (element, ds) => {
506
735
  const mediaMap = ds?.media || {};
507
736
  const animations = ds?.animation || {};
@@ -510,7 +739,7 @@ const extractCSS = (element, ds) => {
510
739
  const usedAnimations = /* @__PURE__ */ new Set();
511
740
  const walk = (el) => {
512
741
  if (!el || !el.__ref) return;
513
- const { props } = el;
742
+ const props = resolveElementProps(el);
514
743
  if (props && el.node) {
515
744
  const cls = el.node.getAttribute?.("class");
516
745
  if (cls && !seen.has(cls)) {
@@ -532,7 +761,7 @@ const extractCSS = (element, ds) => {
532
761
  }
533
762
  }
534
763
  }
535
- if (el.__ref.__children) {
764
+ if (el.__ref?.__children) {
536
765
  for (const ck of el.__ref.__children) {
537
766
  if (el[ck]?.__ref) walk(el[ck]);
538
767
  }
@@ -557,8 +786,20 @@ const generateResetCSS = (reset) => {
557
786
  const rules = [];
558
787
  for (const [selector, props] of Object.entries(reset)) {
559
788
  if (!props || typeof props !== "object") continue;
560
- const decls = Object.entries(props).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join("; ");
561
- if (decls) rules.push(`${selector} { ${decls}; }`);
789
+ const baseDecls = [];
790
+ const mediaRules = [];
791
+ for (const [k, v] of Object.entries(props)) {
792
+ if (typeof v === "object" && v !== null) {
793
+ if (k.startsWith("@media") || k.startsWith("@")) {
794
+ const inner = Object.entries(v).filter(([, iv]) => typeof iv !== "object").map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`).join("; ");
795
+ if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`);
796
+ }
797
+ continue;
798
+ }
799
+ baseDecls.push(`${camelToKebab(k)}: ${v}`);
800
+ }
801
+ if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join("; ")}; }`);
802
+ rules.push(...mediaRules);
562
803
  }
563
804
  return rules.join("\n");
564
805
  };
@@ -586,5 +827,6 @@ export {
586
827
  render,
587
828
  renderElement,
588
829
  renderPage,
589
- renderRoute
830
+ renderRoute,
831
+ resetGlobalCSSCache
590
832
  };
@@ -0,0 +1,22 @@
1
+ function generateSitemap(baseUrl, routes) {
2
+ const urls = Object.entries(routes).map(([path, config]) => {
3
+ const metadata = config.metadata || {};
4
+ const canonical = metadata.canonical || `${baseUrl}${path === "/" ? "" : path}`;
5
+ return `
6
+ <url>
7
+ <loc>${canonical}</loc>
8
+ <lastmod>${(/* @__PURE__ */ new Date()).toISOString()}</lastmod>
9
+ <changefreq>weekly</changefreq>
10
+ <priority>${path === "/" ? "1.0" : "0.8"}</priority>
11
+ </url>`;
12
+ });
13
+ return `<?xml version="1.0" encoding="UTF-8"?>
14
+ <urlset
15
+ xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
16
+ xmlns:xhtml="http://www.w3.org/1999/xhtml">
17
+ ${urls.join("\n")}
18
+ </urlset>`;
19
+ }
20
+ export {
21
+ generateSitemap
22
+ };
package/index.js CHANGED
@@ -4,6 +4,7 @@ import { loadProject, loadAndRenderAll } from './load.js'
4
4
  import { render, renderElement, renderRoute, renderPage } from './render.js'
5
5
  import { extractMetadata, generateHeadHtml } from './metadata.js'
6
6
  import { collectBrNodes, hydrate } from './hydrate.js'
7
+ import { generateSitemap } from './sitemap.js'
7
8
 
8
9
  export {
9
10
  createEnv,
@@ -19,7 +20,8 @@ export {
19
20
  extractMetadata,
20
21
  generateHeadHtml,
21
22
  collectBrNodes,
22
- hydrate
23
+ hydrate,
24
+ generateSitemap
23
25
  }
24
26
 
25
27
  export default {
@@ -36,5 +38,6 @@ export default {
36
38
  extractMetadata,
37
39
  generateHeadHtml,
38
40
  collectBrNodes,
39
- hydrate
41
+ hydrate,
42
+ generateSitemap
40
43
  }
package/metadata.js CHANGED
@@ -1,117 +1,5 @@
1
1
  /**
2
- * Extracts metadata for a given route from project data.
3
- * Compatible with the server's seo.js getPageMetadata/generateMetaTags.
4
- *
5
- * Pages can define metadata via:
6
- * - page.metadata (standard)
7
- * - page.helmet (legacy)
8
- * - page.state (fallback: state-level title/description)
9
- *
10
- * Global SEO is merged from data.integrations.seo
2
+ * Re-exports metadata utilities from the shared helmet plugin.
3
+ * Brender uses these for SSR head generation.
11
4
  */
12
- export const extractMetadata = (data, route = '/') => {
13
- const pages = data.pages || {}
14
- const page = pages[route]
15
-
16
- let metadata = {}
17
-
18
- // Merge global SEO settings first (lower priority)
19
- if (data.integrations?.seo) {
20
- metadata = { ...data.integrations.seo }
21
- }
22
-
23
- if (page) {
24
- // Page-level metadata (highest priority)
25
- const pageMeta = page.metadata || page.helmet || {}
26
- metadata = { ...metadata, ...pageMeta }
27
-
28
- // Fallback: extract title/description from page state if not set
29
- if (!metadata.title && page.state?.title) {
30
- metadata.title = page.state.title
31
- }
32
- if (!metadata.description && page.state?.description) {
33
- metadata.description = page.state.description
34
- }
35
- }
36
-
37
- // Ensure title always exists
38
- if (!metadata.title) {
39
- metadata.title = data.name || 'Symbols'
40
- }
41
-
42
- return metadata
43
- }
44
-
45
- /**
46
- * Generates an HTML <head> string from metadata.
47
- * Can be used standalone or alongside the server's existing generateMetaTags.
48
- */
49
- export const generateHeadHtml = (metadata) => {
50
- const esc = (text) => {
51
- if (text === null || text === undefined) return ''
52
- const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
53
- return text.toString().replace(/[&<>"']/g, (m) => map[m])
54
- }
55
-
56
- const tags = [
57
- '<meta charset="UTF-8">',
58
- '<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">'
59
- ]
60
-
61
- for (const [key, value] of Object.entries(metadata)) {
62
- if (!value && value !== 0 && value !== false) continue
63
-
64
- if (key === 'title') {
65
- tags.push(`<title>${esc(value)}</title>`)
66
- continue
67
- }
68
-
69
- if (key === 'canonical') {
70
- tags.push(`<link rel="canonical" href="${esc(value)}">`)
71
- continue
72
- }
73
-
74
- if (key === 'alternate' && Array.isArray(value)) {
75
- value.forEach(alt => {
76
- if (typeof alt === 'object') {
77
- const attrs = Object.entries(alt)
78
- .map(([k, v]) => `${k}="${esc(v)}"`)
79
- .join(' ')
80
- tags.push(`<link rel="alternate" ${attrs}>`)
81
- }
82
- })
83
- continue
84
- }
85
-
86
- // Prefixed property tags (og:, twitter:, article:, etc.)
87
- const propertyPrefixes = ['og:', 'article:', 'product:', 'fb:', 'profile:', 'book:', 'business:', 'music:', 'video:']
88
- const namePrefixes = ['twitter:', 'DC:', 'DCTERMS:']
89
- const isProperty = propertyPrefixes.some(p => key.startsWith(p))
90
- const isName = namePrefixes.some(p => key.startsWith(p))
91
-
92
- if (key.startsWith('http-equiv:')) {
93
- const httpKey = key.replace('http-equiv:', '')
94
- tags.push(`<meta http-equiv="${esc(httpKey)}" content="${esc(value)}">`)
95
- } else if (key.startsWith('itemprop:')) {
96
- const itemKey = key.replace('itemprop:', '')
97
- tags.push(`<meta itemprop="${esc(itemKey)}" content="${esc(value)}">`)
98
- } else if (isProperty) {
99
- if (Array.isArray(value)) {
100
- value.forEach(v => tags.push(`<meta property="${esc(key)}" content="${esc(v)}">`))
101
- } else {
102
- tags.push(`<meta property="${esc(key)}" content="${esc(value)}">`)
103
- }
104
- } else if (isName) {
105
- tags.push(`<meta name="${esc(key)}" content="${esc(value)}">`)
106
- } else if (key !== 'favicon' && key !== 'favicons') {
107
- // Standard meta name tag
108
- if (Array.isArray(value)) {
109
- value.forEach(v => tags.push(`<meta name="${esc(key)}" content="${esc(v)}">`))
110
- } else if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
111
- tags.push(`<meta name="${esc(key)}" content="${esc(value)}">`)
112
- }
113
- }
114
- }
115
-
116
- return tags.join('\n')
117
- }
5
+ export { extractMetadata, generateHeadHtml, resolveMetadata, applyMetadata } from '@symbo.ls/helmet'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbo.ls/brender",
3
- "version": "3.5.1",
3
+ "version": "3.6.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "module": "./dist/esm/index.js",
@@ -8,16 +8,13 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./index.js",
11
- "require": "./dist/cjs/index.js",
12
- "default": "./index.js"
11
+ "require": "./dist/cjs/index.js"
13
12
  },
14
13
  "./hydrate": {
15
- "import": "./hydrate.js",
16
- "default": "./hydrate.js"
14
+ "import": "./hydrate.js"
17
15
  },
18
16
  "./load": {
19
- "import": "./load.js",
20
- "default": "./load.js"
17
+ "import": "./load.js"
21
18
  }
22
19
  },
23
20
  "source": "index.js",
@@ -39,10 +36,14 @@
39
36
  "dev:rita": "node examples/serve-rita.js"
40
37
  },
41
38
  "dependencies": {
39
+ "@symbo.ls/helmet": "^3.6.1",
42
40
  "linkedom": "^0.16.8"
43
41
  },
44
42
  "devDependencies": {
45
43
  "@babel/core": "^7.26.0"
46
44
  },
47
- "sideEffects": false
45
+ "sideEffects": false,
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
48
49
  }