meno-core 1.0.49 → 1.0.51
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/build-astro.ts +6 -2
- package/build-static.ts +8 -1
- package/dist/bin/cli.js +1 -1
- package/dist/build-static.js +5 -5
- package/dist/chunks/{chunk-KPU2XHOS.js → chunk-2MHDV5BF.js} +11 -1
- package/dist/chunks/chunk-2MHDV5BF.js.map +7 -0
- package/dist/chunks/{chunk-JER5NQVM.js → chunk-3KJ6SJZC.js} +5 -5
- package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-3KJ6SJZC.js.map} +2 -2
- package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
- package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
- package/dist/chunks/{chunk-EQYDSPBB.js → chunk-DM54NPEC.js} +114 -31
- package/dist/chunks/chunk-DM54NPEC.js.map +7 -0
- package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
- package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
- package/dist/chunks/{chunk-4OFZP5NQ.js → chunk-HNLUO36W.js} +15 -4
- package/dist/chunks/chunk-HNLUO36W.js.map +7 -0
- package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
- package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
- package/dist/chunks/{chunk-CHD5UCFF.js → chunk-V7CD7V7W.js} +149 -46
- package/dist/chunks/chunk-V7CD7V7W.js.map +7 -0
- package/dist/chunks/{configService-CCA6AIDI.js → configService-R3OGU2UD.js} +2 -2
- package/dist/entries/server-router.js +5 -5
- package/dist/lib/client/index.js +41 -15
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +12 -10
- package/dist/lib/server/index.js.map +2 -2
- package/dist/lib/shared/index.js +2 -2
- package/lib/client/core/ComponentBuilder.test.ts +34 -0
- package/lib/client/core/ComponentBuilder.ts +25 -3
- package/lib/client/core/builders/embedBuilder.ts +13 -5
- package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
- package/lib/client/core/builders/localeListBuilder.ts +13 -5
- package/lib/client/templateEngine.ts +24 -0
- package/lib/server/fileWatcher.test.ts +134 -0
- package/lib/server/fileWatcher.ts +100 -32
- package/lib/server/jsonLoader.ts +1 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
- package/lib/server/routes/pages.ts +37 -2
- package/lib/server/services/cmsService.ts +21 -0
- package/lib/server/services/configService.ts +21 -0
- package/lib/server/services/fileWatcherService.ts +17 -0
- package/lib/server/ssr/buildErrorOverlay.ts +22 -4
- package/lib/server/ssr/errorOverlay.ts +11 -3
- package/lib/server/ssr/htmlGenerator.nonce.test.ts +165 -0
- package/lib/server/ssr/htmlGenerator.ts +36 -9
- package/lib/server/ssr/liveReloadIntegration.test.ts +3 -1
- package/lib/server/ssr/metaTagGenerator.ts +35 -5
- package/lib/server/ssr/ssrRenderer.test.ts +258 -0
- package/lib/server/ssr/ssrRenderer.ts +47 -5
- package/lib/server/ssrRenderer.test.ts +87 -2
- package/lib/server/webflow/buildWebflow.ts +1 -1
- package/lib/server/websocketManager.test.ts +61 -6
- package/lib/server/websocketManager.ts +25 -1
- package/lib/shared/cssProperties.test.ts +28 -0
- package/lib/shared/cssProperties.ts +27 -1
- package/lib/shared/types/api.ts +10 -1
- package/lib/shared/types/cms.ts +18 -9
- package/lib/shared/validation/schemas.test.ts +93 -0
- package/lib/shared/validation/schemas.ts +56 -15
- package/package.json +1 -1
- package/dist/chunks/chunk-4OFZP5NQ.js.map +0 -7
- package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
- package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
- package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
- package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
- /package/dist/chunks/{configService-CCA6AIDI.js.map → configService-R3OGU2UD.js.map} +0 -0
|
@@ -147,6 +147,15 @@ export interface GenerateSSRHTMLOptions {
|
|
|
147
147
|
isEditor?: boolean;
|
|
148
148
|
/** Actual bound server port for live reload WS (connects directly to SSR server) */
|
|
149
149
|
serverPort?: number;
|
|
150
|
+
/**
|
|
151
|
+
* Per-request CSP nonce. When set, every inline `<script>` emitted by this
|
|
152
|
+
* generator carries `nonce="${nonce}"`. The HTTP route handler must send
|
|
153
|
+
* the same nonce in the `script-src` directive of the response CSP header
|
|
154
|
+
* (and the Electron main process splices it into the BrowserWindow CSP).
|
|
155
|
+
* When unset, scripts are emitted without a nonce attribute — callers that
|
|
156
|
+
* still rely on `'unsafe-inline'` (legacy / direct CLI usage) keep working.
|
|
157
|
+
*/
|
|
158
|
+
cspNonce?: string;
|
|
150
159
|
}
|
|
151
160
|
|
|
152
161
|
/**
|
|
@@ -235,7 +244,12 @@ export async function generateSSRHTML(
|
|
|
235
244
|
isEditor = false,
|
|
236
245
|
isProductionBuild = false,
|
|
237
246
|
serverPort,
|
|
247
|
+
cspNonce,
|
|
238
248
|
} = options;
|
|
249
|
+
|
|
250
|
+
// Attribute fragment for stamping nonce on inline <script> tags. Empty when
|
|
251
|
+
// no nonce is supplied so legacy ('unsafe-inline') paths keep working.
|
|
252
|
+
const nonceAttr = cspNonce ? ` nonce="${cspNonce}"` : '';
|
|
239
253
|
// Editor selection attributes (data-element-path, data-cms-context, ...) are gated
|
|
240
254
|
// on injectEditorAttrs — set ONLY when the request comes from the editor preview iframe
|
|
241
255
|
// via the studio's /__static__/ proxy (which sends an `x-meno-editor` header).
|
|
@@ -285,8 +299,13 @@ export async function generateSSRHTML(
|
|
|
285
299
|
await configService.load();
|
|
286
300
|
const globalLibraries = configService.getLibraries() || { js: [], css: [] };
|
|
287
301
|
const globalCustomCode = configService.getCustomCode();
|
|
302
|
+
// The Meno badge previously used inline event handlers (onmouseenter /
|
|
303
|
+
// onmouseleave) to drive its opacity hover, which CSP with `'nonce-…'` and
|
|
304
|
+
// no `'unsafe-inline'` rejects. Move the hover to a stylesheet rule using a
|
|
305
|
+
// dedicated class so nonces aren't needed for this purely-presentational
|
|
306
|
+
// effect.
|
|
288
307
|
const menoBadgeHtml = configService.getShowMenoBadge()
|
|
289
|
-
? `<
|
|
308
|
+
? `<style>.meno-badge{position:fixed;bottom:12px;left:12px;z-index:9999;background:#000;color:#fff;padding:4px 10px;border-radius:6px;font-size:12px;font-family:system-ui,sans-serif;text-decoration:none;opacity:0.8;transition:opacity 0.2s}.meno-badge:hover,.meno-badge:focus{opacity:1}</style><a class="meno-badge" href="https://meno.so" target="_blank" rel="noopener">Made in Meno</a>`
|
|
290
309
|
: '';
|
|
291
310
|
const mergedCustomCode = {
|
|
292
311
|
head: [globalCustomCode.head, pageCustomCode?.head].filter(Boolean).join('\n'),
|
|
@@ -378,7 +397,7 @@ export async function generateSSRHTML(
|
|
|
378
397
|
// Legacy inline mode (dev server)
|
|
379
398
|
// Escape </script> sequences to prevent premature script tag closure
|
|
380
399
|
const escapedJavaScript = allJavaScript.replace(/<\/script>/gi, '<\\/script>');
|
|
381
|
-
componentScript = `\n <script>\n${escapedJavaScript}\n </script>`;
|
|
400
|
+
componentScript = `\n <script${nonceAttr}>\n${escapedJavaScript}\n </script>`;
|
|
382
401
|
}
|
|
383
402
|
}
|
|
384
403
|
|
|
@@ -472,7 +491,7 @@ picture {
|
|
|
472
491
|
// Config script - inline for dev, include in external JS for static build
|
|
473
492
|
const hasConfig = Object.keys(menoConfig).length > 0;
|
|
474
493
|
const configInlineScript = hasConfig && !extScriptPath && !returnSeparateJS
|
|
475
|
-
? `<script>window.__MENO_CONFIG__=${JSON.stringify(menoConfig)}</script>\n `
|
|
494
|
+
? `<script${nonceAttr}>window.__MENO_CONFIG__=${JSON.stringify(menoConfig)}</script>\n `
|
|
476
495
|
: '';
|
|
477
496
|
// Add config to external JS if using external scripts
|
|
478
497
|
if (hasConfig && externalJavaScript !== null) {
|
|
@@ -482,7 +501,7 @@ picture {
|
|
|
482
501
|
// CMS context script - needed when a client-side Router is present (dev mode or studio preview)
|
|
483
502
|
// Production builds are pure HTML with no client routing, so excluded
|
|
484
503
|
const cmsInlineScript = cmsTemplatePath && cms && (!useBundled || injectLiveReload)
|
|
485
|
-
? `<script>window.__MENO_CMS__=${JSON.stringify({ item: cms.cms, templatePath: cmsTemplatePath })}</script>\n `
|
|
504
|
+
? `<script${nonceAttr}>window.__MENO_CMS__=${JSON.stringify({ item: cms.cms, templatePath: cmsTemplatePath })}</script>\n `
|
|
486
505
|
: '';
|
|
487
506
|
|
|
488
507
|
// Client data scripts - inline JSON for MenoFilter (production builds only)
|
|
@@ -490,14 +509,22 @@ picture {
|
|
|
490
509
|
? generateAllInlineDataScripts(finalClientDataCollections) + '\n '
|
|
491
510
|
: '';
|
|
492
511
|
|
|
493
|
-
// Generate favicon and apple touch icon link tags
|
|
512
|
+
// Generate favicon and apple touch icon link tags.
|
|
513
|
+
// When a dark-mode favicon is configured alongside the default, scope the
|
|
514
|
+
// default to `(prefers-color-scheme: light)` and emit a second tag scoped to
|
|
515
|
+
// `(prefers-color-scheme: dark)` so the browser swaps automatically.
|
|
516
|
+
// Note: apple-touch-icon does not honor prefers-color-scheme on iOS.
|
|
517
|
+
const hasDarkFavicon = !!(iconsConfig.favicon && iconsConfig.faviconDark);
|
|
494
518
|
const faviconTag = iconsConfig.favicon
|
|
495
|
-
? `<link rel="icon" href="${escapeHtml(iconsConfig.favicon)}" />`
|
|
519
|
+
? `<link rel="icon" href="${escapeHtml(iconsConfig.favicon)}"${hasDarkFavicon ? ' media="(prefers-color-scheme: light)"' : ''} />`
|
|
520
|
+
: '';
|
|
521
|
+
const faviconDarkTag = iconsConfig.faviconDark
|
|
522
|
+
? `<link rel="icon" href="${escapeHtml(iconsConfig.faviconDark)}" media="(prefers-color-scheme: dark)" />`
|
|
496
523
|
: '';
|
|
497
524
|
const appleTouchIconTag = iconsConfig.appleTouchIcon
|
|
498
525
|
? `<link rel="apple-touch-icon" href="${escapeHtml(iconsConfig.appleTouchIcon)}" />`
|
|
499
526
|
: '';
|
|
500
|
-
const iconTags = [faviconTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
527
|
+
const iconTags = [faviconTag, faviconDarkTag, appleTouchIconTag].filter(Boolean).join('\n ');
|
|
501
528
|
|
|
502
529
|
// Script preload tag - eliminates critical request chain by discovering script early
|
|
503
530
|
const scriptPreloadTag = extScriptPath
|
|
@@ -537,12 +564,12 @@ picture {
|
|
|
537
564
|
// diff (different element counts or unknown paths) it falls back to a
|
|
538
565
|
// straight innerHTML replace.
|
|
539
566
|
const liveReloadScript = injectLiveReload
|
|
540
|
-
? `<script>(function(){var ws,timer,gen=0,lastSrvRoot=null;function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function smartUpdate(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]'),se=srvR.querySelectorAll('[data-element-path]');if(ce.length!==se.length){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}var sbp={};for(var i=0;i<se.length;i++)sbp[se[i].getAttribute('data-element-path')]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[oe[i].getAttribute('data-element-path')]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=c.getAttribute('data-element-path'),s=sbp[p];if(!s){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');if(or&&nr)smartUpdate(or,nr,lastSrvRoot);if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.parentNode.replaceChild(ns.cloneNode(true),os);var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';if(oss===nss){window.scrollTo(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
|
|
567
|
+
? `<script${nonceAttr}>(function(){var ws,timer,gen=0,lastSrvRoot=null;function strip(s){return s?s.replace(/[?&]_r=\\d+/,''):''}function classList(el){return (el.getAttribute('class')||'').split(/\\s+/).filter(Boolean)}function syncEl(cur,srv,old){var cc=classList(cur),sc=classList(srv),oc=old?new Set(classList(old)):new Set();var rt=cc.filter(function(c){return !oc.has(c)});var seen=new Set(),fin=[];sc.concat(rt).forEach(function(c){if(!seen.has(c)){seen.add(c);fin.push(c)}});var fs=fin.join(' ');if((cur.getAttribute('class')||'')!==fs){if(fs)cur.setAttribute('class',fs);else cur.removeAttribute('class')}for(var i=0;i<srv.attributes.length;i++){var a=srv.attributes[i];if(a.name==='class')continue;if(cur.getAttribute(a.name)!==a.value)cur.setAttribute(a.name,a.value)}if(old){for(var i=0;i<old.attributes.length;i++){var a=old.attributes[i];if(a.name==='class')continue;if(!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name))cur.removeAttribute(a.name)}}}function syncText(cur,srv){var cc=cur.childNodes,sc=srv.childNodes;for(var i=0;i<sc.length;i++){var s=sc[i],c=cc[i];if(s.nodeType===3&&c&&c.nodeType===3){if(c.textContent!==s.textContent)c.textContent=s.textContent}}}function smartUpdate(curR,srvR,oldR){var ce=curR.querySelectorAll('[data-element-path]'),se=srvR.querySelectorAll('[data-element-path]');if(ce.length!==se.length){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}var sbp={};for(var i=0;i<se.length;i++)sbp[se[i].getAttribute('data-element-path')]=se[i];var obp={};if(oldR){var oe=oldR.querySelectorAll('[data-element-path]');for(var i=0;i<oe.length;i++)obp[oe[i].getAttribute('data-element-path')]=oe[i]}for(var i=0;i<ce.length;i++){var c=ce[i],p=c.getAttribute('data-element-path'),s=sbp[p];if(!s){if(curR.innerHTML!==srvR.innerHTML)curR.innerHTML=srvR.innerHTML;return}syncEl(c,s,obp[p]);syncText(c,s)}syncText(curR,srvR)}function connect(){ws=new WebSocket(${wsUrl});ws.onmessage=function(e){var d=JSON.parse(e.data);if(d.type==='hmr:libraries-update'){location.reload()}else if(d.type==='hmr:update'||d.type==='hmr:cms-update'||d.type==='hmr:colors-update'||d.type==='hmr:variables-update')hotReload()};ws.onclose=function(){clearTimeout(timer);timer=setTimeout(connect,1000)}}function hotReload(){var g=++gen;var sx=window.scrollX,sy=window.scrollY;fetch(location.href,{cache:'no-store'}).then(function(r){return r.text()}).then(function(html){if(g!==gen)return;var p=new DOMParser();var d=p.parseFromString(html,'text/html');var or=document.getElementById('root'),nr=d.getElementById('root');if(or&&nr)smartUpdate(or,nr,lastSrvRoot);if(nr)lastSrvRoot=nr.cloneNode(true);var os=document.getElementById('meno-styles'),ns=d.getElementById('meno-styles');if(os&&ns&&os.textContent!==ns.textContent)os.parentNode.replaceChild(ns.cloneNode(true),os);var nh=d.documentElement;if(nh){var nl=nh.getAttribute('lang')||'en',nt=nh.getAttribute('theme')||'light';if(document.documentElement.getAttribute('lang')!==nl)document.documentElement.setAttribute('lang',nl);if(document.documentElement.getAttribute('theme')!==nt)document.documentElement.setAttribute('theme',nt)}var ocms=document.querySelectorAll('script[id^="meno-cms-"]'),ncms=d.querySelectorAll('script[id^="meno-cms-"]');var ock=JSON.stringify(Array.prototype.map.call(ocms,function(s){return [s.id,s.textContent]}));var nck=JSON.stringify(Array.prototype.map.call(ncms,function(s){return [s.id,s.textContent]}));if(ock!==nck){ocms.forEach(function(s){s.remove()});ncms.forEach(function(s){var c=document.createElement('script');c.type=s.type;c.id=s.id;c.textContent=s.textContent;document.head.appendChild(c)})}window.__menoHotReload=true;var olib=document.querySelectorAll('body > script[src^="/libraries/"]'),nlib=d.querySelectorAll('body > script[src^="/libraries/"]');var olk=JSON.stringify(Array.prototype.map.call(olib,function(s){return strip(s.getAttribute('src'))}).sort());var nlk=JSON.stringify(Array.prototype.map.call(nlib,function(s){return strip(s.getAttribute('src'))}).sort());if(olk!==nlk){olib.forEach(function(o){o.remove()});nlib.forEach(function(n){var src=n.getAttribute('src');var ls=document.createElement('script');ls.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)})}var oscr=document.querySelector('script[src^="/_scripts/"]'),nscr=d.querySelector('script[src^="/_scripts/"]');var oss=oscr?strip(oscr.getAttribute('src')):'',nss=nscr?strip(nscr.getAttribute('src')):'';if(oss===nss){window.scrollTo(sx,sy)}else{if(oscr)oscr.remove();if(nscr){var src=nscr.getAttribute('src');var s=document.createElement('script');s.src=src+(src.indexOf('?')>-1?'&':'?')+'_r='+Date.now();s.onload=function(){document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)};s.onerror=function(){window.scrollTo(sx,sy)};document.body.appendChild(s)}else{document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}}).catch(function(){location.reload()})}var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true);connect()})()</script>`
|
|
541
568
|
: '';
|
|
542
569
|
|
|
543
570
|
// Scroll position handlers for preview mode iframe switching
|
|
544
571
|
const scrollHandlerScript = injectLiveReload
|
|
545
|
-
? `<script>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){window.scrollTo(e.data.scrollX,e.data.scrollY)}})})()</script>`
|
|
572
|
+
? `<script${nonceAttr}>(function(){window.addEventListener('message',function(e){if(e.data.type==='GET_SCROLL_POSITION'){window.parent.postMessage({type:'SCROLL_POSITION_RESPONSE',scrollX:window.scrollX,scrollY:window.scrollY},'*')}else if(e.data.type==='SET_SCROLL_POSITION'){window.scrollTo(e.data.scrollX,e.data.scrollY)}})})()</script>`
|
|
546
573
|
: '';
|
|
547
574
|
|
|
548
575
|
// In production, output minified CSS on single line; in dev, preserve formatting
|
|
@@ -17,7 +17,9 @@ function loadLiveReloadScript(): string {
|
|
|
17
17
|
const src = fs.readFileSync(path.join(import.meta.dir, 'htmlGenerator.ts'), 'utf8');
|
|
18
18
|
const idx = src.indexOf('const liveReloadScript');
|
|
19
19
|
if (idx === -1) throw new Error('liveReloadScript not found');
|
|
20
|
-
|
|
20
|
+
// The live-reload script tag may carry a per-request CSP nonce attribute
|
|
21
|
+
// — `<script${nonceAttr}>` in the source. Match either form.
|
|
22
|
+
const match = src.slice(idx).match(/<script(?:\$\{nonceAttr\})?>([\s\S]*?)<\/script>/);
|
|
21
23
|
if (!match) throw new Error('liveReloadScript script tag not found');
|
|
22
24
|
// The script lives inside a TS template literal; reverse the template's
|
|
23
25
|
// runtime transformations so the executable form matches what a browser sees.
|
|
@@ -40,12 +40,20 @@ export function extractPageMeta(pageData: JSONPage): PageMeta {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
43
|
+
* Site-wide social configuration
|
|
44
44
|
*/
|
|
45
|
-
export interface
|
|
45
|
+
export interface SocialOptions {
|
|
46
|
+
twitterHandle?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Options for meta tag generation (hreflang + social)
|
|
51
|
+
*/
|
|
52
|
+
export interface MetaTagOptions {
|
|
46
53
|
slugMappings?: SlugMap[];
|
|
47
54
|
pagePath?: string;
|
|
48
55
|
baseUrl?: string;
|
|
56
|
+
social?: SocialOptions;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -58,7 +66,7 @@ export function generateMetaTags(
|
|
|
58
66
|
url: string = '',
|
|
59
67
|
locale: string = 'en',
|
|
60
68
|
config: I18nConfig = DEFAULT_I18N_CONFIG,
|
|
61
|
-
|
|
69
|
+
options?: MetaTagOptions
|
|
62
70
|
): string {
|
|
63
71
|
const tags: string[] = [];
|
|
64
72
|
|
|
@@ -109,14 +117,36 @@ export function generateMetaTags(
|
|
|
109
117
|
tags.push(`<meta property="og:url" content="${escapeHtml(url)}" />`);
|
|
110
118
|
}
|
|
111
119
|
|
|
120
|
+
// Twitter Card tags - Twitter falls back to og:image/og:title/og:description when twitter:* is absent
|
|
121
|
+
const hasAnyMeta = title || description || ogImage || ogTitle || ogDescription;
|
|
122
|
+
if (hasAnyMeta) {
|
|
123
|
+
const cardType = ogImage ? 'summary_large_image' : 'summary';
|
|
124
|
+
tags.push(`<meta name="twitter:card" content="${cardType}" />`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (ogTitle) {
|
|
128
|
+
tags.push(`<meta name="twitter:title" content="${escapeHtml(ogTitle)}" />`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (ogDescription) {
|
|
132
|
+
tags.push(`<meta name="twitter:description" content="${escapeHtml(ogDescription)}" />`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawHandle = options?.social?.twitterHandle?.trim();
|
|
136
|
+
if (rawHandle) {
|
|
137
|
+
const handle = rawHandle.startsWith('@') ? rawHandle : `@${rawHandle}`;
|
|
138
|
+
tags.push(`<meta name="twitter:site" content="${escapeHtml(handle)}" />`);
|
|
139
|
+
tags.push(`<meta name="twitter:creator" content="${escapeHtml(handle)}" />`);
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
// Canonical URL
|
|
113
143
|
if (url) {
|
|
114
144
|
tags.push(`<link rel="canonical" href="${escapeHtml(url)}" />`);
|
|
115
145
|
}
|
|
116
146
|
|
|
117
147
|
// Hreflang tags for multilingual pages
|
|
118
|
-
if (
|
|
119
|
-
const { slugMappings, pagePath = '/', baseUrl = '' } =
|
|
148
|
+
if (options?.slugMappings && options.slugMappings.length > 0 && config.locales.length > 1) {
|
|
149
|
+
const { slugMappings, pagePath = '/', baseUrl = '' } = options;
|
|
120
150
|
const slugIndex = buildSlugIndex(slugMappings);
|
|
121
151
|
const localeLinks = getLocaleLinks(pagePath, locale, config, slugIndex);
|
|
122
152
|
|
|
@@ -441,6 +441,82 @@ describe('ssrRenderer', () => {
|
|
|
441
441
|
});
|
|
442
442
|
});
|
|
443
443
|
|
|
444
|
+
// -----------------------------------------------------------------------
|
|
445
|
+
// 6b. _i18n value resolution on node children + attributes
|
|
446
|
+
// -----------------------------------------------------------------------
|
|
447
|
+
describe('buildComponentHTML - _i18n on children and attributes', () => {
|
|
448
|
+
const en = { _i18n: true, en: 'Hello', pl: 'Cześć' };
|
|
449
|
+
const i18nConfig = {
|
|
450
|
+
defaultLocale: 'en',
|
|
451
|
+
locales: [
|
|
452
|
+
{ code: 'en', name: 'EN', nativeName: 'English', langTag: 'en-US' },
|
|
453
|
+
{ code: 'pl', name: 'PL', nativeName: 'Polski', langTag: 'pl-PL' },
|
|
454
|
+
],
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
test('resolves _i18n object as direct children for the default locale', async () => {
|
|
458
|
+
const node = { type: 'node', tag: 'h1', children: en };
|
|
459
|
+
const html = await render(node, { i18nConfig });
|
|
460
|
+
expect(html).toContain('Hello');
|
|
461
|
+
expect(html).not.toContain('Cześć');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('resolves _i18n object as direct children for an explicit locale', async () => {
|
|
465
|
+
const node = { type: 'node', tag: 'h1', children: en };
|
|
466
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
467
|
+
expect(html).toContain('Cześć');
|
|
468
|
+
expect(html).not.toContain('Hello');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('resolves _i18n objects inside an array of children, preserving siblings', async () => {
|
|
472
|
+
const node = {
|
|
473
|
+
type: 'node',
|
|
474
|
+
tag: 'p',
|
|
475
|
+
children: [
|
|
476
|
+
en,
|
|
477
|
+
' literal ',
|
|
478
|
+
{ type: 'node', tag: 'span', children: 'static' },
|
|
479
|
+
],
|
|
480
|
+
};
|
|
481
|
+
const html = await render(node, { i18nConfig });
|
|
482
|
+
expect(html).toContain('Hello');
|
|
483
|
+
expect(html).toContain(' literal ');
|
|
484
|
+
expect(html).toContain('<span');
|
|
485
|
+
expect(html).toContain('static');
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test('resolves _i18n object on attribute values', async () => {
|
|
489
|
+
const node = {
|
|
490
|
+
type: 'node',
|
|
491
|
+
tag: 'img',
|
|
492
|
+
attributes: {
|
|
493
|
+
src: '/x.png',
|
|
494
|
+
alt: { _i18n: true, en: 'Photo', pl: 'Zdjęcie' },
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
498
|
+
expect(html).toContain('alt="Zdjęcie"');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test('falls back to defaultLocale when the active locale key is missing', async () => {
|
|
502
|
+
const valueWithoutDe = { _i18n: true, en: 'Hello', pl: 'Cześć' };
|
|
503
|
+
const node = { type: 'node', tag: 'h1', children: valueWithoutDe };
|
|
504
|
+
const html = await render(node, { locale: 'de', i18nConfig });
|
|
505
|
+
// resolveTranslation falls back to defaultLocale (en)
|
|
506
|
+
expect(html).toContain('Hello');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('precedence: _i18n: true wins over a stray type/tag on the same object', async () => {
|
|
510
|
+
// If someone accidentally writes both _i18n and type on the same object,
|
|
511
|
+
// i18n wins — the renderer treats it as a localized string, not a node.
|
|
512
|
+
const ambiguous = { _i18n: true, type: 'node', tag: 'div', en: 'X', pl: 'Y' };
|
|
513
|
+
const node = { type: 'node', tag: 'h1', children: ambiguous };
|
|
514
|
+
const html = await render(node, { locale: 'pl', i18nConfig });
|
|
515
|
+
expect(html).toContain('Y');
|
|
516
|
+
expect(html).not.toContain('<div');
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
444
520
|
// -----------------------------------------------------------------------
|
|
445
521
|
// 7. Link nodes
|
|
446
522
|
// -----------------------------------------------------------------------
|
|
@@ -4060,6 +4136,188 @@ describe('ssrRenderer', () => {
|
|
|
4060
4136
|
});
|
|
4061
4137
|
});
|
|
4062
4138
|
|
|
4139
|
+
// ---------------------------------------------------------------------------
|
|
4140
|
+
// List with sourceType: 'prop' placed inside another component's slot
|
|
4141
|
+
// ---------------------------------------------------------------------------
|
|
4142
|
+
describe('buildComponentHTML - prop-source list inside slotted component', () => {
|
|
4143
|
+
test('list with bare prop-name source resolves against host props when inside a slot', async () => {
|
|
4144
|
+
// Section: a simple slot host (mirrors the user's Section component)
|
|
4145
|
+
const Section: ComponentDefinition = {
|
|
4146
|
+
component: {
|
|
4147
|
+
interface: {
|
|
4148
|
+
theme: { type: 'string', default: 'blue' },
|
|
4149
|
+
},
|
|
4150
|
+
structure: {
|
|
4151
|
+
type: 'node',
|
|
4152
|
+
tag: 'section',
|
|
4153
|
+
children: [
|
|
4154
|
+
{ type: 'slot' },
|
|
4155
|
+
],
|
|
4156
|
+
},
|
|
4157
|
+
},
|
|
4158
|
+
};
|
|
4159
|
+
|
|
4160
|
+
// Host: defines `testimonials`, renders a Section whose slot contains
|
|
4161
|
+
// a list bound to the host's `testimonials` prop with a bare name.
|
|
4162
|
+
const Host: ComponentDefinition = {
|
|
4163
|
+
component: {
|
|
4164
|
+
interface: {
|
|
4165
|
+
testimonials: { type: 'list' as any, default: [] },
|
|
4166
|
+
},
|
|
4167
|
+
structure: {
|
|
4168
|
+
type: 'node',
|
|
4169
|
+
tag: 'div',
|
|
4170
|
+
children: [
|
|
4171
|
+
{
|
|
4172
|
+
type: 'component',
|
|
4173
|
+
component: 'Section',
|
|
4174
|
+
props: { theme: 'blue' },
|
|
4175
|
+
children: [
|
|
4176
|
+
{
|
|
4177
|
+
type: 'list',
|
|
4178
|
+
sourceType: 'prop',
|
|
4179
|
+
source: 'testimonials',
|
|
4180
|
+
children: [
|
|
4181
|
+
{ type: 'node', tag: 'p', children: '{{item.name}}' },
|
|
4182
|
+
],
|
|
4183
|
+
},
|
|
4184
|
+
],
|
|
4185
|
+
},
|
|
4186
|
+
],
|
|
4187
|
+
},
|
|
4188
|
+
},
|
|
4189
|
+
};
|
|
4190
|
+
|
|
4191
|
+
const node = {
|
|
4192
|
+
type: 'component',
|
|
4193
|
+
component: 'Host',
|
|
4194
|
+
props: {
|
|
4195
|
+
testimonials: [
|
|
4196
|
+
{ name: 'Alice' },
|
|
4197
|
+
{ name: 'Bob' },
|
|
4198
|
+
{ name: 'Carol' },
|
|
4199
|
+
],
|
|
4200
|
+
},
|
|
4201
|
+
};
|
|
4202
|
+
|
|
4203
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4204
|
+
expect(html).toContain('<section');
|
|
4205
|
+
expect(html).toContain('Alice');
|
|
4206
|
+
expect(html).toContain('Bob');
|
|
4207
|
+
expect(html).toContain('Carol');
|
|
4208
|
+
});
|
|
4209
|
+
|
|
4210
|
+
test('list with {{template}} source also resolves inside a slot', async () => {
|
|
4211
|
+
const Section: ComponentDefinition = {
|
|
4212
|
+
component: {
|
|
4213
|
+
interface: {},
|
|
4214
|
+
structure: {
|
|
4215
|
+
type: 'node',
|
|
4216
|
+
tag: 'section',
|
|
4217
|
+
children: [{ type: 'slot' }],
|
|
4218
|
+
},
|
|
4219
|
+
},
|
|
4220
|
+
};
|
|
4221
|
+
|
|
4222
|
+
const Host: ComponentDefinition = {
|
|
4223
|
+
component: {
|
|
4224
|
+
interface: {
|
|
4225
|
+
testimonials: { type: 'list' as any, default: [] },
|
|
4226
|
+
},
|
|
4227
|
+
structure: {
|
|
4228
|
+
type: 'component',
|
|
4229
|
+
component: 'Section',
|
|
4230
|
+
children: [
|
|
4231
|
+
{
|
|
4232
|
+
type: 'list',
|
|
4233
|
+
sourceType: 'prop',
|
|
4234
|
+
source: '{{testimonials}}',
|
|
4235
|
+
children: [
|
|
4236
|
+
{ type: 'node', tag: 'p', children: '{{item.name}}' },
|
|
4237
|
+
],
|
|
4238
|
+
},
|
|
4239
|
+
],
|
|
4240
|
+
},
|
|
4241
|
+
},
|
|
4242
|
+
};
|
|
4243
|
+
|
|
4244
|
+
const node = {
|
|
4245
|
+
type: 'component',
|
|
4246
|
+
component: 'Host',
|
|
4247
|
+
props: { testimonials: [{ name: 'Dana' }] },
|
|
4248
|
+
};
|
|
4249
|
+
|
|
4250
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4251
|
+
expect(html).toContain('Dana');
|
|
4252
|
+
});
|
|
4253
|
+
|
|
4254
|
+
test('bare prop-name source still works when list is in component own structure (no slot)', async () => {
|
|
4255
|
+
const Direct: ComponentDefinition = {
|
|
4256
|
+
component: {
|
|
4257
|
+
interface: {
|
|
4258
|
+
items: { type: 'list' as any, default: [] },
|
|
4259
|
+
},
|
|
4260
|
+
structure: {
|
|
4261
|
+
type: 'list',
|
|
4262
|
+
sourceType: 'prop',
|
|
4263
|
+
source: 'items',
|
|
4264
|
+
children: [
|
|
4265
|
+
{ type: 'node', tag: 'li', children: '{{item.label}}' },
|
|
4266
|
+
],
|
|
4267
|
+
},
|
|
4268
|
+
},
|
|
4269
|
+
};
|
|
4270
|
+
|
|
4271
|
+
const node = {
|
|
4272
|
+
type: 'component',
|
|
4273
|
+
component: 'Direct',
|
|
4274
|
+
props: { items: [{ label: 'Eve' }, { label: 'Frank' }] },
|
|
4275
|
+
};
|
|
4276
|
+
|
|
4277
|
+
const html = await render(node, { globalComponents: { Direct } });
|
|
4278
|
+
expect(html).toContain('Eve');
|
|
4279
|
+
expect(html).toContain('Frank');
|
|
4280
|
+
});
|
|
4281
|
+
|
|
4282
|
+
test('list source stays a string and renders empty when host has no matching prop', async () => {
|
|
4283
|
+
const Section: ComponentDefinition = {
|
|
4284
|
+
component: {
|
|
4285
|
+
interface: {},
|
|
4286
|
+
structure: {
|
|
4287
|
+
type: 'node',
|
|
4288
|
+
tag: 'section',
|
|
4289
|
+
children: [{ type: 'slot' }],
|
|
4290
|
+
},
|
|
4291
|
+
},
|
|
4292
|
+
};
|
|
4293
|
+
|
|
4294
|
+
const Host: ComponentDefinition = {
|
|
4295
|
+
component: {
|
|
4296
|
+
interface: {},
|
|
4297
|
+
structure: {
|
|
4298
|
+
type: 'component',
|
|
4299
|
+
component: 'Section',
|
|
4300
|
+
children: [
|
|
4301
|
+
{
|
|
4302
|
+
type: 'list',
|
|
4303
|
+
sourceType: 'prop',
|
|
4304
|
+
source: 'missing',
|
|
4305
|
+
children: [
|
|
4306
|
+
{ type: 'node', tag: 'p', children: '{{item}}' },
|
|
4307
|
+
],
|
|
4308
|
+
},
|
|
4309
|
+
],
|
|
4310
|
+
},
|
|
4311
|
+
},
|
|
4312
|
+
};
|
|
4313
|
+
|
|
4314
|
+
const node = { type: 'component', component: 'Host' };
|
|
4315
|
+
const html = await render(node, { globalComponents: { Host, Section } });
|
|
4316
|
+
// No items - list renders empty
|
|
4317
|
+
expect(html).not.toContain('<p');
|
|
4318
|
+
});
|
|
4319
|
+
});
|
|
4320
|
+
|
|
4063
4321
|
describe('CMS link localization', () => {
|
|
4064
4322
|
const i18nConfig = {
|
|
4065
4323
|
defaultLocale: 'en',
|
|
@@ -72,6 +72,30 @@ export interface PreloadImage {
|
|
|
72
72
|
|
|
73
73
|
// Re-export types for external consumers
|
|
74
74
|
export type { CMSContext } from './cmsSSRProcessor';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Resolve any `_i18n` value objects inside an attributes record into the
|
|
78
|
+
* active locale's string. Authors can write `attributes: { alt: { _i18n:
|
|
79
|
+
* true, en: "Photo", pl: "Zdjęcie" } }` and the runtime substitutes a single
|
|
80
|
+
* locale-appropriate string here, before `buildAttributes` (which silently
|
|
81
|
+
* drops object-typed values to avoid `[object Object]` in HTML).
|
|
82
|
+
*/
|
|
83
|
+
function resolveI18nAttrs<T extends Record<string, unknown>>(
|
|
84
|
+
attrs: T,
|
|
85
|
+
locale: string | undefined,
|
|
86
|
+
i18nConfig: I18nConfig | undefined
|
|
87
|
+
): T {
|
|
88
|
+
let mutated: Record<string, unknown> | null = null;
|
|
89
|
+
const config = i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
90
|
+
const effectiveLocale = locale || config.defaultLocale;
|
|
91
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
92
|
+
if (isI18nValue(value)) {
|
|
93
|
+
mutated = mutated ?? { ...attrs };
|
|
94
|
+
mutated[key] = resolveI18nValue(value, effectiveLocale, config);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return (mutated ?? attrs) as T;
|
|
98
|
+
}
|
|
75
99
|
export type { PageMeta } from './metaTagGenerator';
|
|
76
100
|
export { extractPageMeta, generateMetaTags } from './metaTagGenerator';
|
|
77
101
|
|
|
@@ -968,6 +992,21 @@ async function renderNode(
|
|
|
968
992
|
|
|
969
993
|
if (typeof node !== 'object') return '';
|
|
970
994
|
|
|
995
|
+
// Resolve `_i18n` value objects to a single string before node-shape
|
|
996
|
+
// dispatch. Authors can write a localized string anywhere `children` is
|
|
997
|
+
// accepted — on `type: "node"` elements, list templates, link children, etc.
|
|
998
|
+
// — and the runtime resolves it to the active locale here. Precedence rule:
|
|
999
|
+
// an object with both `_i18n: true` and `type`/`tag` resolves as i18n.
|
|
1000
|
+
if (isI18nValue(node)) {
|
|
1001
|
+
const i18nResolveConfig = i18nConfig ?? DEFAULT_I18N_CONFIG;
|
|
1002
|
+
const i18nEffectiveLocale = locale || i18nResolveConfig.defaultLocale;
|
|
1003
|
+
const resolved = resolveI18nValue(node, i18nEffectiveLocale, i18nResolveConfig);
|
|
1004
|
+
return renderNode(
|
|
1005
|
+
resolved as ComponentNode | string | number | null | undefined,
|
|
1006
|
+
ctx
|
|
1007
|
+
);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
971
1010
|
// Check if condition - skip rendering if false
|
|
972
1011
|
if (!evaluateIfCondition(node as ComponentNode, ctx)) {
|
|
973
1012
|
return '';
|
|
@@ -1027,7 +1066,7 @@ async function renderNode(
|
|
|
1027
1066
|
: sanitizedHtml;
|
|
1028
1067
|
|
|
1029
1068
|
// Extract attributes from node
|
|
1030
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1069
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1031
1070
|
|
|
1032
1071
|
// Build className array
|
|
1033
1072
|
const classNames: string[] = ['oem'];
|
|
@@ -1120,7 +1159,7 @@ async function renderNode(
|
|
|
1120
1159
|
href = localizeHref(href, ctx);
|
|
1121
1160
|
|
|
1122
1161
|
// Extract attributes from node
|
|
1123
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1162
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1124
1163
|
|
|
1125
1164
|
// Build className array - start with olink base class
|
|
1126
1165
|
const classNames: string[] = ['olink'];
|
|
@@ -1201,8 +1240,10 @@ async function renderNode(
|
|
|
1201
1240
|
nodeProps = processItemPropsTemplate(nodeProps, templateCtx, i18nResolver);
|
|
1202
1241
|
}
|
|
1203
1242
|
|
|
1204
|
-
// Extract attributes from node
|
|
1205
|
-
|
|
1243
|
+
// Extract attributes from node. Resolve any `_i18n` value objects on
|
|
1244
|
+
// attribute values to the active locale's string before downstream
|
|
1245
|
+
// processing (templates, CMS substitution, kebab-casing).
|
|
1246
|
+
let nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1206
1247
|
const originalAttributes = { ...nodeAttributes };
|
|
1207
1248
|
|
|
1208
1249
|
// Process CMS templates in attributes (e.g., href="{{cms.link}}")
|
|
@@ -1826,7 +1867,7 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
|
|
|
1826
1867
|
: links.join('');
|
|
1827
1868
|
|
|
1828
1869
|
// Extract attributes from node
|
|
1829
|
-
const nodeAttributes = extractAttributesFromNode(node);
|
|
1870
|
+
const nodeAttributes = resolveI18nAttrs(extractAttributesFromNode(node), locale, i18nConfig);
|
|
1830
1871
|
const attrsStr = buildAttributes(nodeAttributes);
|
|
1831
1872
|
|
|
1832
1873
|
const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}${editorAttrs(ctx)}>${linksHTML}</div>`;
|
|
@@ -1909,6 +1950,7 @@ export async function renderPageSSR(
|
|
|
1909
1950
|
slugMappings,
|
|
1910
1951
|
pagePath,
|
|
1911
1952
|
baseUrl,
|
|
1953
|
+
social: configService.getSocial(),
|
|
1912
1954
|
});
|
|
1913
1955
|
|
|
1914
1956
|
// Resolve title for use in HTML template
|