@symbo.ls/brender 3.5.1 → 3.6.3
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/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'
|
|
@@ -60,7 +61,15 @@ const UIKIT_STUBS = {
|
|
|
60
61
|
H3: { tag: 'h3' },
|
|
61
62
|
H4: { tag: 'h4' },
|
|
62
63
|
H5: { tag: 'h5' },
|
|
63
|
-
H6: { tag: 'h6' }
|
|
64
|
+
H6: { tag: 'h6' },
|
|
65
|
+
Svg: {
|
|
66
|
+
tag: 'svg',
|
|
67
|
+
attr: {
|
|
68
|
+
xmlns: 'http://www.w3.org/2000/svg',
|
|
69
|
+
'xmlns:xlink': 'http://www.w3.org/1999/xlink'
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
Text: { tag: 'span' }
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/**
|
|
@@ -176,11 +185,208 @@ export const renderElement = async (elementDef, options = {}) => {
|
|
|
176
185
|
|
|
177
186
|
assignKeys(body)
|
|
178
187
|
const registry = element ? mapKeysToElements(element) : {}
|
|
179
|
-
const html = body.innerHTML
|
|
188
|
+
const html = fixSvgContent(body.innerHTML)
|
|
180
189
|
|
|
181
190
|
return { html, registry, element }
|
|
182
191
|
}
|
|
183
192
|
|
|
193
|
+
// ── SVG content post-processing ───────────────────────────────────────────────
|
|
194
|
+
// DOMQL's html mixin uses textContent for SVG nodes, which escapes HTML entities.
|
|
195
|
+
// This post-processor unescapes content inside <svg> tags so paths/circles render.
|
|
196
|
+
const fixSvgContent = (html) => {
|
|
197
|
+
return html.replace(
|
|
198
|
+
/(<svg\b[^>]*>)([\s\S]*?)(<\/svg>)/gi,
|
|
199
|
+
(match, open, content, close) => {
|
|
200
|
+
if (content.includes('<')) {
|
|
201
|
+
const unescaped = content
|
|
202
|
+
.replace(/</g, '<')
|
|
203
|
+
.replace(/>/g, '>')
|
|
204
|
+
.replace(/&/g, '&')
|
|
205
|
+
.replace(/"/g, '"')
|
|
206
|
+
.replace(/'/g, "'")
|
|
207
|
+
return open + unescaped + close
|
|
208
|
+
}
|
|
209
|
+
return match
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Global CSS generation ─────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Runs the scratch design-system pipeline (via esbuild bundling to work around
|
|
218
|
+
* bare-import issues) to produce CSS variables and reset styles — the same
|
|
219
|
+
* globals that the SPA runtime injects via emotion.injectGlobal.
|
|
220
|
+
*/
|
|
221
|
+
let _cachedGlobalCSS = null
|
|
222
|
+
|
|
223
|
+
const generateGlobalCSS = async (ds, config) => {
|
|
224
|
+
if (_cachedGlobalCSS) return _cachedGlobalCSS
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const { existsSync, writeFileSync, unlinkSync } = await import('fs')
|
|
228
|
+
const { tmpdir } = await import('os')
|
|
229
|
+
const { randomBytes } = await import('crypto')
|
|
230
|
+
const esbuild = await import('esbuild')
|
|
231
|
+
|
|
232
|
+
// Write a temporary script that imports scratch, runs set(), and
|
|
233
|
+
// serialises the CSS_VARS + RESET objects as JSON.
|
|
234
|
+
const dsJson = JSON.stringify(ds || {})
|
|
235
|
+
const cfgJson = JSON.stringify(config || {})
|
|
236
|
+
const tmpEntry = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}.mjs`)
|
|
237
|
+
const tmpOut = join(tmpdir(), `br_global_${randomBytes(6).toString('hex')}_out.mjs`)
|
|
238
|
+
|
|
239
|
+
writeFileSync(tmpEntry, `
|
|
240
|
+
import { set, getActiveConfig, getFontFaceString } from '@symbo.ls/scratch'
|
|
241
|
+
import { DEFAULT_CONFIG } from '@symbo.ls/default-config'
|
|
242
|
+
|
|
243
|
+
const ds = ${dsJson}
|
|
244
|
+
const cfg = ${cfgJson}
|
|
245
|
+
|
|
246
|
+
// Merge with defaults (same as initEmotion)
|
|
247
|
+
const merged = {}
|
|
248
|
+
for (const k in DEFAULT_CONFIG) merged[k] = DEFAULT_CONFIG[k]
|
|
249
|
+
for (const k in ds) {
|
|
250
|
+
if (typeof ds[k] === 'object' && !Array.isArray(ds[k]) && typeof merged[k] === 'object' && !Array.isArray(merged[k])) {
|
|
251
|
+
merged[k] = { ...merged[k], ...ds[k] }
|
|
252
|
+
} else {
|
|
253
|
+
merged[k] = ds[k]
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const conf = set({
|
|
258
|
+
useReset: true,
|
|
259
|
+
useVariable: true,
|
|
260
|
+
useFontImport: true,
|
|
261
|
+
useDocumentTheme: true,
|
|
262
|
+
useDefaultConfig: true,
|
|
263
|
+
globalTheme: 'auto',
|
|
264
|
+
...merged,
|
|
265
|
+
...cfg
|
|
266
|
+
}, { newConfig: {} })
|
|
267
|
+
|
|
268
|
+
const result = {
|
|
269
|
+
CSS_VARS: conf.CSS_VARS || {},
|
|
270
|
+
CSS_MEDIA_VARS: conf.CSS_MEDIA_VARS || {},
|
|
271
|
+
RESET: conf.RESET || conf.reset || {},
|
|
272
|
+
ANIMATION: conf.animation || conf.ANIMATION || {}
|
|
273
|
+
}
|
|
274
|
+
// Export as globalThis so we can read it
|
|
275
|
+
globalThis.__BR_GLOBAL_CSS__ = result
|
|
276
|
+
export default result
|
|
277
|
+
`)
|
|
278
|
+
|
|
279
|
+
// Resolve the monorepo root from the brender plugin location
|
|
280
|
+
// so esbuild can find @symbo.ls/* packages
|
|
281
|
+
const brenderDir = new URL('.', import.meta.url).pathname
|
|
282
|
+
const monorepoRoot = resolve(brenderDir, '../..')
|
|
283
|
+
|
|
284
|
+
// Workspace resolve plugin: maps @symbo.ls/* and @domql/* to source paths
|
|
285
|
+
const workspacePlugin = {
|
|
286
|
+
name: 'workspace-resolve',
|
|
287
|
+
setup (build) {
|
|
288
|
+
build.onResolve({ filter: /^@symbo\.ls\// }, args => {
|
|
289
|
+
const pkg = args.path.replace('@symbo.ls/', '')
|
|
290
|
+
// Try packages/ then plugins/
|
|
291
|
+
for (const dir of ['packages', 'plugins']) {
|
|
292
|
+
const src = resolve(monorepoRoot, dir, pkg, 'src', 'index.js')
|
|
293
|
+
if (existsSync(src)) return { path: src }
|
|
294
|
+
const dist = resolve(monorepoRoot, dir, pkg, 'index.js')
|
|
295
|
+
if (existsSync(dist)) return { path: dist }
|
|
296
|
+
}
|
|
297
|
+
// default-config special case
|
|
298
|
+
const blank = resolve(monorepoRoot, 'packages', 'default-config', 'blank', 'index.js')
|
|
299
|
+
if (pkg === 'default-config' && existsSync(blank)) return { path: blank }
|
|
300
|
+
})
|
|
301
|
+
build.onResolve({ filter: /^@domql\// }, args => {
|
|
302
|
+
const pkg = args.path.replace('@domql/', '')
|
|
303
|
+
const src = resolve(monorepoRoot, 'packages', 'domql', 'packages', pkg, 'src', 'index.js')
|
|
304
|
+
if (existsSync(src)) return { path: src }
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
await esbuild.build({
|
|
310
|
+
entryPoints: [tmpEntry],
|
|
311
|
+
bundle: true,
|
|
312
|
+
format: 'esm',
|
|
313
|
+
platform: 'node',
|
|
314
|
+
outfile: tmpOut,
|
|
315
|
+
write: true,
|
|
316
|
+
logLevel: 'silent',
|
|
317
|
+
plugins: [workspacePlugin],
|
|
318
|
+
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']
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const mod = await import(`file://${tmpOut}`)
|
|
322
|
+
const data = mod.default || {}
|
|
323
|
+
try { unlinkSync(tmpEntry) } catch {}
|
|
324
|
+
try { unlinkSync(tmpOut) } catch {}
|
|
325
|
+
|
|
326
|
+
const cssVars = data.CSS_VARS || {}
|
|
327
|
+
const cssMediaVars = data.CSS_MEDIA_VARS || {}
|
|
328
|
+
const reset = data.RESET || {}
|
|
329
|
+
const animations = data.ANIMATION || {}
|
|
330
|
+
|
|
331
|
+
// ── :root CSS variables ──
|
|
332
|
+
const varDecls = Object.entries(cssVars)
|
|
333
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
334
|
+
.join(';\n')
|
|
335
|
+
let rootRule = varDecls ? `:root {\n${varDecls};\n}` : ''
|
|
336
|
+
|
|
337
|
+
// ── Theme-switching CSS vars (media queries + data-theme selectors) ──
|
|
338
|
+
const themeVarRules = Object.entries(cssMediaVars)
|
|
339
|
+
.map(([key, vars]) => {
|
|
340
|
+
const decls = Object.entries(vars)
|
|
341
|
+
.map(([k, v]) => ` ${k}: ${v}`)
|
|
342
|
+
.join(';\n')
|
|
343
|
+
if (!decls) return ''
|
|
344
|
+
if (key.startsWith('@media')) {
|
|
345
|
+
// Media query — only when no data-theme forces a theme
|
|
346
|
+
return `${key} {\n :root:not([data-theme]) {\n${decls};\n }\n}`
|
|
347
|
+
}
|
|
348
|
+
// Selector ([data-theme="..."]) — apply directly
|
|
349
|
+
return `${key} {\n${decls};\n}`
|
|
350
|
+
})
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.join('\n\n')
|
|
353
|
+
if (themeVarRules) rootRule += '\n\n' + themeVarRules
|
|
354
|
+
|
|
355
|
+
// ── Reset styles ──
|
|
356
|
+
const resetRules = generateResetCSS(reset)
|
|
357
|
+
|
|
358
|
+
// ── @keyframes animations ──
|
|
359
|
+
const keyframeRules = []
|
|
360
|
+
for (const name in animations) {
|
|
361
|
+
const frames = animations[name]
|
|
362
|
+
if (!frames || typeof frames !== 'object') continue
|
|
363
|
+
const frameRules = Object.entries(frames).map(([step, p]) => {
|
|
364
|
+
if (typeof p !== 'object') return ''
|
|
365
|
+
const decls = Object.entries(p).map(([k, v]) => `${camelToKebab(k)}: ${v}`).join('; ')
|
|
366
|
+
return ` ${step} { ${decls}; }`
|
|
367
|
+
}).join('\n')
|
|
368
|
+
keyframeRules.push(`@keyframes ${name} {\n${frameRules}\n}`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
_cachedGlobalCSS = {
|
|
372
|
+
rootRule,
|
|
373
|
+
resetRules,
|
|
374
|
+
fontFaceCSS: '',
|
|
375
|
+
keyframeRules: keyframeRules.join('\n')
|
|
376
|
+
}
|
|
377
|
+
return _cachedGlobalCSS
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.warn('generateGlobalCSS failed:', err.message, err.stack)
|
|
380
|
+
_cachedGlobalCSS = { rootRule: '', resetRules: '', fontFaceCSS: '', keyframeRules: '' }
|
|
381
|
+
return _cachedGlobalCSS
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Reset the cached global CSS (useful when rendering multiple projects).
|
|
387
|
+
*/
|
|
388
|
+
export const resetGlobalCSSCache = () => { _cachedGlobalCSS = null }
|
|
389
|
+
|
|
184
390
|
// ── Route-level SSR ───────────────────────────────────────────────────────────
|
|
185
391
|
|
|
186
392
|
/**
|
|
@@ -226,10 +432,14 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
226
432
|
designSystem: ds
|
|
227
433
|
})
|
|
228
434
|
|
|
435
|
+
// Generate global CSS (variables, reset, keyframes) via scratch pipeline
|
|
436
|
+
const globalCSS = await generateGlobalCSS(ds, data.config || data.settings)
|
|
437
|
+
|
|
229
438
|
return {
|
|
230
439
|
html: cssDoc.body.innerHTML,
|
|
231
440
|
css: extractCSS(result.element, ds),
|
|
232
|
-
|
|
441
|
+
globalCSS,
|
|
442
|
+
resetCss: globalCSS.resetRules || generateResetCSS(ds.reset),
|
|
233
443
|
fontLinks: generateFontLinks(ds),
|
|
234
444
|
metadata: extractMetadata(data, route),
|
|
235
445
|
brKeyCount: Object.keys(result.registry).length
|
|
@@ -250,7 +460,7 @@ export const renderRoute = async (data, options = {}) => {
|
|
|
250
460
|
* @returns {Promise<{ html: string, route: string, brKeyCount: number }>}
|
|
251
461
|
*/
|
|
252
462
|
export const renderPage = async (data, route = '/', options = {}) => {
|
|
253
|
-
const { lang = 'en', themeColor } = options
|
|
463
|
+
const { lang = 'en', themeColor, isr } = options
|
|
254
464
|
|
|
255
465
|
const result = await renderRoute(data, { route })
|
|
256
466
|
if (!result) return null
|
|
@@ -259,18 +469,54 @@ export const renderPage = async (data, route = '/', options = {}) => {
|
|
|
259
469
|
if (themeColor) metadata['theme-color'] = themeColor
|
|
260
470
|
const headTags = generateHeadHtml(metadata)
|
|
261
471
|
|
|
472
|
+
const globalCSS = result.globalCSS || {}
|
|
473
|
+
|
|
474
|
+
// ISR: include client SPA bundle for hydration + data fetching
|
|
475
|
+
let isrBody = ''
|
|
476
|
+
if (isr && isr.clientScript) {
|
|
477
|
+
// Calculate relative path from route directory to root
|
|
478
|
+
const depth = route === '/' ? 0 : route.replace(/^\/|\/$/g, '').split('/').length
|
|
479
|
+
const prefix = depth > 0 ? '../'.repeat(depth) : './'
|
|
480
|
+
// Inline handoff script: when the SPA adds its root element to <body>,
|
|
481
|
+
// remove the pre-rendered brender content for a seamless transition.
|
|
482
|
+
isrBody = `<script type="module">
|
|
483
|
+
{
|
|
484
|
+
const brEls = document.querySelectorAll('body > :not(script):not(style)')
|
|
485
|
+
const observer = new MutationObserver((mutations) => {
|
|
486
|
+
for (const m of mutations) {
|
|
487
|
+
for (const node of m.addedNodes) {
|
|
488
|
+
if (node.nodeType === 1 && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE' && !node.hasAttribute('data-br')) {
|
|
489
|
+
brEls.forEach(el => { if (el.hasAttribute('data-br') || el.querySelector('[data-br]')) el.remove() })
|
|
490
|
+
observer.disconnect()
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
observer.observe(document.body, { childList: true })
|
|
497
|
+
}
|
|
498
|
+
</script>
|
|
499
|
+
<script type="module" src="${prefix}${isr.clientScript}"></script>`
|
|
500
|
+
}
|
|
501
|
+
|
|
262
502
|
const html = `<!DOCTYPE html>
|
|
263
503
|
<html lang="${lang}">
|
|
264
504
|
<head>
|
|
265
505
|
${headTags}
|
|
266
506
|
${result.fontLinks}
|
|
267
|
-
|
|
507
|
+
${globalCSS.fontFaceCSS ? `<style>${globalCSS.fontFaceCSS}</style>` : ''}
|
|
508
|
+
<style>
|
|
509
|
+
${globalCSS.rootRule || ''}
|
|
510
|
+
${result.resetCss}
|
|
511
|
+
${globalCSS.keyframeRules || ''}
|
|
512
|
+
</style>
|
|
268
513
|
<style data-emotion="smbls">
|
|
269
514
|
${result.css}
|
|
270
515
|
</style>
|
|
271
516
|
</head>
|
|
272
517
|
<body>
|
|
273
518
|
${result.html}
|
|
519
|
+
${isrBody}
|
|
274
520
|
</body>
|
|
275
521
|
</html>`
|
|
276
522
|
|
|
@@ -581,6 +827,41 @@ const getExtendsCSS = (el) => {
|
|
|
581
827
|
return null
|
|
582
828
|
}
|
|
583
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Resolve function-valued CSS props by evaluating them with (element, state).
|
|
832
|
+
* In SSR, this gives correct initial values (e.g. display: 'none' when no auth).
|
|
833
|
+
* Uses fallback mock state if element state is incomplete.
|
|
834
|
+
*/
|
|
835
|
+
const resolveElementProps = (el) => {
|
|
836
|
+
const { props } = el
|
|
837
|
+
if (!props) return props
|
|
838
|
+
let resolved
|
|
839
|
+
for (const key in props) {
|
|
840
|
+
if (typeof props[key] !== 'function') continue
|
|
841
|
+
// Skip non-CSS props and component children
|
|
842
|
+
if (NON_CSS_PROPS.has(key)) continue
|
|
843
|
+
if (key.charCodeAt(0) >= 65 && key.charCodeAt(0) <= 90) continue
|
|
844
|
+
if (key.startsWith('on')) continue
|
|
845
|
+
if (!resolved) resolved = { ...props }
|
|
846
|
+
let result
|
|
847
|
+
try {
|
|
848
|
+
result = props[key](el, el.state || {})
|
|
849
|
+
} catch {
|
|
850
|
+
// State prototype chain may be incomplete in SSR — try with mock
|
|
851
|
+
try {
|
|
852
|
+
const mockState = { root: {}, ...(el.state || {}) }
|
|
853
|
+
result = props[key](el, mockState)
|
|
854
|
+
} catch { /* skip prop */ }
|
|
855
|
+
}
|
|
856
|
+
if (result !== undefined && result !== null && result !== false) {
|
|
857
|
+
resolved[key] = result
|
|
858
|
+
} else {
|
|
859
|
+
delete resolved[key]
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return resolved || props
|
|
863
|
+
}
|
|
864
|
+
|
|
584
865
|
const extractCSS = (element, ds) => {
|
|
585
866
|
const mediaMap = ds?.media || {}
|
|
586
867
|
const animations = ds?.animation || {}
|
|
@@ -590,7 +871,7 @@ const extractCSS = (element, ds) => {
|
|
|
590
871
|
|
|
591
872
|
const walk = (el) => {
|
|
592
873
|
if (!el || !el.__ref) return
|
|
593
|
-
const
|
|
874
|
+
const props = resolveElementProps(el)
|
|
594
875
|
if (props && el.node) {
|
|
595
876
|
const cls = el.node.getAttribute?.('class')
|
|
596
877
|
if (cls && !seen.has(cls)) {
|
|
@@ -616,7 +897,7 @@ const extractCSS = (element, ds) => {
|
|
|
616
897
|
}
|
|
617
898
|
}
|
|
618
899
|
}
|
|
619
|
-
if (el.__ref
|
|
900
|
+
if (el.__ref?.__children) {
|
|
620
901
|
for (const ck of el.__ref.__children) {
|
|
621
902
|
if (el[ck]?.__ref) walk(el[ck])
|
|
622
903
|
}
|
|
@@ -642,10 +923,24 @@ const generateResetCSS = (reset) => {
|
|
|
642
923
|
const rules = []
|
|
643
924
|
for (const [selector, props] of Object.entries(reset)) {
|
|
644
925
|
if (!props || typeof props !== 'object') continue
|
|
645
|
-
const
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
926
|
+
const baseDecls = []
|
|
927
|
+
const mediaRules = []
|
|
928
|
+
for (const [k, v] of Object.entries(props)) {
|
|
929
|
+
if (typeof v === 'object' && v !== null) {
|
|
930
|
+
// Nested object: @media query or sub-selector
|
|
931
|
+
if (k.startsWith('@media') || k.startsWith('@')) {
|
|
932
|
+
const inner = Object.entries(v)
|
|
933
|
+
.filter(([, iv]) => typeof iv !== 'object')
|
|
934
|
+
.map(([ik, iv]) => `${camelToKebab(ik)}: ${iv}`)
|
|
935
|
+
.join('; ')
|
|
936
|
+
if (inner) mediaRules.push(`${k} { ${selector} { ${inner}; } }`)
|
|
937
|
+
}
|
|
938
|
+
continue
|
|
939
|
+
}
|
|
940
|
+
baseDecls.push(`${camelToKebab(k)}: ${v}`)
|
|
941
|
+
}
|
|
942
|
+
if (baseDecls.length) rules.push(`${selector} { ${baseDecls.join('; ')}; }`)
|
|
943
|
+
rules.push(...mediaRules)
|
|
649
944
|
}
|
|
650
945
|
return rules.join('\n')
|
|
651
946
|
}
|
package/sitemap.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a sitemap XML string from project pages.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} baseUrl - The base URL (e.g. 'https://example.com')
|
|
5
|
+
* @param {object} routes - Pages object keyed by route path
|
|
6
|
+
* @returns {string} Sitemap XML
|
|
7
|
+
*/
|
|
8
|
+
export function generateSitemap (baseUrl, routes) {
|
|
9
|
+
const urls = Object.entries(routes).map(([path, config]) => {
|
|
10
|
+
const metadata = config.metadata || {}
|
|
11
|
+
const canonical = metadata.canonical || `${baseUrl}${path === '/' ? '' : path}`
|
|
12
|
+
|
|
13
|
+
return `
|
|
14
|
+
<url>
|
|
15
|
+
<loc>${canonical}</loc>
|
|
16
|
+
<lastmod>${new Date().toISOString()}</lastmod>
|
|
17
|
+
<changefreq>weekly</changefreq>
|
|
18
|
+
<priority>${path === '/' ? '1.0' : '0.8'}</priority>
|
|
19
|
+
</url>`
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
23
|
+
<urlset
|
|
24
|
+
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
|
25
|
+
xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
26
|
+
${urls.join('\n')}
|
|
27
|
+
</urlset>`
|
|
28
|
+
}
|