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.
Files changed (66) hide show
  1. package/build-astro.ts +6 -2
  2. package/build-static.ts +8 -1
  3. package/dist/bin/cli.js +1 -1
  4. package/dist/build-static.js +5 -5
  5. package/dist/chunks/{chunk-KPU2XHOS.js → chunk-2MHDV5BF.js} +11 -1
  6. package/dist/chunks/chunk-2MHDV5BF.js.map +7 -0
  7. package/dist/chunks/{chunk-JER5NQVM.js → chunk-3KJ6SJZC.js} +5 -5
  8. package/dist/chunks/{chunk-JER5NQVM.js.map → chunk-3KJ6SJZC.js.map} +2 -2
  9. package/dist/chunks/{chunk-S2CX6HFM.js → chunk-7NIC4I3V.js} +42 -20
  10. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  11. package/dist/chunks/{chunk-EQYDSPBB.js → chunk-DM54NPEC.js} +114 -31
  12. package/dist/chunks/chunk-DM54NPEC.js.map +7 -0
  13. package/dist/chunks/{chunk-LKAGAQ3M.js → chunk-EDQSMAMP.js} +13 -2
  14. package/dist/chunks/{chunk-LKAGAQ3M.js.map → chunk-EDQSMAMP.js.map} +2 -2
  15. package/dist/chunks/{chunk-4OFZP5NQ.js → chunk-HNLUO36W.js} +15 -4
  16. package/dist/chunks/chunk-HNLUO36W.js.map +7 -0
  17. package/dist/chunks/{chunk-6IVUG7FY.js → chunk-LPVETICS.js} +19 -2
  18. package/dist/chunks/{chunk-6IVUG7FY.js.map → chunk-LPVETICS.js.map} +2 -2
  19. package/dist/chunks/{chunk-CHD5UCFF.js → chunk-V7CD7V7W.js} +149 -46
  20. package/dist/chunks/chunk-V7CD7V7W.js.map +7 -0
  21. package/dist/chunks/{configService-CCA6AIDI.js → configService-R3OGU2UD.js} +2 -2
  22. package/dist/entries/server-router.js +5 -5
  23. package/dist/lib/client/index.js +41 -15
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +12 -10
  26. package/dist/lib/server/index.js.map +2 -2
  27. package/dist/lib/shared/index.js +2 -2
  28. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  29. package/lib/client/core/ComponentBuilder.ts +25 -3
  30. package/lib/client/core/builders/embedBuilder.ts +13 -5
  31. package/lib/client/core/builders/linkNodeBuilder.ts +13 -5
  32. package/lib/client/core/builders/localeListBuilder.ts +13 -5
  33. package/lib/client/templateEngine.ts +24 -0
  34. package/lib/server/fileWatcher.test.ts +134 -0
  35. package/lib/server/fileWatcher.ts +100 -32
  36. package/lib/server/jsonLoader.ts +1 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +46 -14
  38. package/lib/server/routes/pages.ts +37 -2
  39. package/lib/server/services/cmsService.ts +21 -0
  40. package/lib/server/services/configService.ts +21 -0
  41. package/lib/server/services/fileWatcherService.ts +17 -0
  42. package/lib/server/ssr/buildErrorOverlay.ts +22 -4
  43. package/lib/server/ssr/errorOverlay.ts +11 -3
  44. package/lib/server/ssr/htmlGenerator.nonce.test.ts +165 -0
  45. package/lib/server/ssr/htmlGenerator.ts +36 -9
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +3 -1
  47. package/lib/server/ssr/metaTagGenerator.ts +35 -5
  48. package/lib/server/ssr/ssrRenderer.test.ts +258 -0
  49. package/lib/server/ssr/ssrRenderer.ts +47 -5
  50. package/lib/server/ssrRenderer.test.ts +87 -2
  51. package/lib/server/webflow/buildWebflow.ts +1 -1
  52. package/lib/server/websocketManager.test.ts +61 -6
  53. package/lib/server/websocketManager.ts +25 -1
  54. package/lib/shared/cssProperties.test.ts +28 -0
  55. package/lib/shared/cssProperties.ts +27 -1
  56. package/lib/shared/types/api.ts +10 -1
  57. package/lib/shared/types/cms.ts +18 -9
  58. package/lib/shared/validation/schemas.test.ts +93 -0
  59. package/lib/shared/validation/schemas.ts +56 -15
  60. package/package.json +1 -1
  61. package/dist/chunks/chunk-4OFZP5NQ.js.map +0 -7
  62. package/dist/chunks/chunk-CHD5UCFF.js.map +0 -7
  63. package/dist/chunks/chunk-EQYDSPBB.js.map +0 -7
  64. package/dist/chunks/chunk-KPU2XHOS.js.map +0 -7
  65. package/dist/chunks/chunk-S2CX6HFM.js.map +0 -7
  66. /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
- ? `<a href="https://meno.so" target="_blank" rel="noopener" style="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" onmouseenter="this.style.opacity='1'" onmouseleave="this.style.opacity='0.8'">Made in Meno</a>`
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
- const match = src.slice(idx).match(/<script>([\s\S]*?)<\/script>/);
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
- * Options for hreflang tag generation
43
+ * Site-wide social configuration
44
44
  */
45
- export interface HreflangOptions {
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
- hreflangOptions?: HreflangOptions
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 (hreflangOptions?.slugMappings && hreflangOptions.slugMappings.length > 0 && config.locales.length > 1) {
119
- const { slugMappings, pagePath = '/', baseUrl = '' } = hreflangOptions;
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
- let nodeAttributes = extractAttributesFromNode(node);
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