@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.
- package/README.md +18 -2
- package/dist/cjs/index.js +4 -1
- package/dist/cjs/metadata.js +5 -81
- package/dist/cjs/render.js +252 -10
- package/dist/cjs/sitemap.js +41 -0
- package/dist/esm/index.js +4 -1
- package/dist/esm/metadata.js +4 -80
- package/dist/esm/render.js +252 -10
- package/dist/esm/sitemap.js +22 -0
- package/index.js +5 -2
- package/metadata.js +3 -115
- package/package.json +9 -8
- package/render.js +306 -11
- package/sitemap.js +28 -0
package/dist/esm/render.js
CHANGED
|
@@ -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("<")) {
|
|
134
|
+
const unescaped = content.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
561
|
-
|
|
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
|
-
*
|
|
3
|
-
*
|
|
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
|
|
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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
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.
|
|
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
|
}
|