meno-core 1.0.48 → 1.0.49

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 (74) hide show
  1. package/dist/build-static.js +7 -7
  2. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  3. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  4. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  5. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  6. package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
  7. package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
  9. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  10. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  11. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  12. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  13. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
  14. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
  15. package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
  16. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
  17. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
  18. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  19. package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
  20. package/dist/entries/server-router.js +9 -9
  21. package/dist/entries/server-router.js.map +2 -2
  22. package/dist/lib/client/index.js +54 -20
  23. package/dist/lib/client/index.js.map +3 -3
  24. package/dist/lib/server/index.js +9 -9
  25. package/dist/lib/shared/index.js +46 -10
  26. package/dist/lib/shared/index.js.map +3 -3
  27. package/entries/server-router.tsx +6 -2
  28. package/lib/client/core/ComponentBuilder.ts +8 -1
  29. package/lib/client/core/builders/embedBuilder.ts +15 -2
  30. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  31. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  32. package/lib/client/styles/StyleInjector.ts +3 -2
  33. package/lib/client/theme.ts +4 -4
  34. package/lib/server/cssGenerator.test.ts +64 -1
  35. package/lib/server/cssGenerator.ts +48 -9
  36. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  38. package/lib/server/routes/index.ts +1 -1
  39. package/lib/server/routes/pages.ts +23 -1
  40. package/lib/server/services/cmsService.test.ts +246 -0
  41. package/lib/server/services/cmsService.ts +122 -5
  42. package/lib/server/services/configService.ts +5 -0
  43. package/lib/server/ssr/attributeBuilder.ts +41 -0
  44. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  45. package/lib/server/ssr/htmlGenerator.ts +51 -4
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  47. package/lib/server/ssr/ssrRenderer.test.ts +306 -0
  48. package/lib/server/ssr/ssrRenderer.ts +182 -44
  49. package/lib/shared/cssGeneration.test.ts +267 -1
  50. package/lib/shared/cssGeneration.ts +240 -18
  51. package/lib/shared/cssProperties.test.ts +247 -1
  52. package/lib/shared/cssProperties.ts +196 -6
  53. package/lib/shared/interfaces/contentProvider.ts +39 -6
  54. package/lib/shared/pathSecurity.ts +16 -0
  55. package/lib/shared/responsiveScaling.test.ts +143 -0
  56. package/lib/shared/responsiveScaling.ts +253 -2
  57. package/lib/shared/themeDefaults.test.ts +3 -3
  58. package/lib/shared/themeDefaults.ts +3 -3
  59. package/lib/shared/types/cms.ts +28 -3
  60. package/lib/shared/types/index.ts +1 -0
  61. package/lib/shared/utilityClassConfig.ts +3 -0
  62. package/lib/shared/utilityClassMapper.test.ts +123 -0
  63. package/lib/shared/utilityClassMapper.ts +179 -8
  64. package/lib/shared/validation/schemas.ts +15 -1
  65. package/lib/shared/validation/validators.ts +26 -1
  66. package/package.json +1 -1
  67. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  68. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  69. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  70. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  71. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  72. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
  73. /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  74. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -28,6 +28,9 @@ const mockConfigService = {
28
28
  load: mock(async () => {}),
29
29
  getLibraries: mock(() => undefined),
30
30
  getResponsiveScales: mock(() => ({ enabled: false, baseReference: 16 })),
31
+ getCustomCode: mock(() => ({})),
32
+ getShowMenoBadge: mock(() => false),
33
+ getRemConversion: mock(() => ({ enabled: false, baseFontSize: 16 })),
31
34
  };
32
35
 
33
36
  mock.module('../services/configService', () => ({
@@ -673,6 +676,116 @@ describe('generateSSRHTML', () => {
673
676
  const result = (await generateSSRHTML(minimalPage)) as string;
674
677
  expect(result).not.toContain('GET_SCROLL_POSITION');
675
678
  });
679
+
680
+ // Content-aware hotReload: each section should only be rebuilt when its
681
+ // serialized content actually differs from the freshly-fetched HTML. The
682
+ // `/_scripts/{hash}.js` URL is content-addressed, so an unchanged hash is
683
+ // the explicit signal that user JS has not changed — in that case the
684
+ // script tag is left in place and DOMContentLoaded is NOT re-dispatched,
685
+ // which is what preserves runtime JS state (e.g. open dropdowns) across
686
+ // pure style edits in the editor.
687
+ describe('hotReload is content-aware', () => {
688
+ test('compares meno-styles textContent before replacing', async () => {
689
+ const result = (await generateSSRHTML({
690
+ pageData: minimalPage as any,
691
+ injectLiveReload: true,
692
+ })) as string;
693
+ expect(result).toContain('os.textContent!==ns.textContent');
694
+ });
695
+
696
+ test('compares /_scripts/ src (cache-buster stripped) before reloading', async () => {
697
+ const result = (await generateSSRHTML({
698
+ pageData: minimalPage as any,
699
+ injectLiveReload: true,
700
+ })) as string;
701
+ // strip() removes ?_r=... so reloads triggered only by content-hash change
702
+ expect(result).toContain('strip(oscr.getAttribute');
703
+ expect(result).toContain('strip(nscr.getAttribute');
704
+ expect(result).toContain('oss===nss');
705
+ });
706
+
707
+ test('falls back to innerHTML replace only on structural changes', async () => {
708
+ const result = (await generateSSRHTML({
709
+ pageData: minimalPage as any,
710
+ injectLiveReload: true,
711
+ })) as string;
712
+ // smartUpdate's fallback branch (different element counts or
713
+ // unmatched data-element-path) is the only place innerHTML is touched.
714
+ expect(result).toContain('curR.innerHTML!==srvR.innerHTML');
715
+ });
716
+
717
+ test('patches #root in place via smartUpdate (not innerHTML replace)', async () => {
718
+ const result = (await generateSSRHTML({
719
+ pageData: minimalPage as any,
720
+ injectLiveReload: true,
721
+ })) as string;
722
+ // smartUpdate is what runs on every hot reload; it walks the tree by
723
+ // data-element-path and updates attrs in place so event handlers and
724
+ // DOM identity survive.
725
+ expect(result).toContain('smartUpdate(or,nr,lastSrvRoot)');
726
+ expect(result).toContain('querySelectorAll(\'[data-element-path]\')');
727
+ });
728
+
729
+ test('preserves runtime-added classes against cached server snapshot', async () => {
730
+ const result = (await generateSSRHTML({
731
+ pageData: minimalPage as any,
732
+ injectLiveReload: true,
733
+ })) as string;
734
+ // Runtime classes = current classes that weren't in the previous
735
+ // server snapshot. They get re-applied on top of the new server class
736
+ // list so JS-controlled state (e.g. `nav-dropdown-open`) survives.
737
+ expect(result).toContain('cc.filter(function(c){return !oc.has(c)})');
738
+ });
739
+
740
+ test('caches root snapshot at script init for first-HMR class diff', async () => {
741
+ const result = (await generateSSRHTML({
742
+ pageData: minimalPage as any,
743
+ injectLiveReload: true,
744
+ })) as string;
745
+ // Without this seed the very first HMR has no oldRoot to diff against,
746
+ // so any class JS added before the first edit would be wiped.
747
+ expect(result).toContain("var iR=document.getElementById('root');if(iR)lastSrvRoot=iR.cloneNode(true)");
748
+ });
749
+
750
+ test('preserves server-side attribute removals (does not treat them as runtime)', async () => {
751
+ const result = (await generateSSRHTML({
752
+ pageData: minimalPage as any,
753
+ injectLiveReload: true,
754
+ })) as string;
755
+ // For non-class attrs: only remove from current DOM if the attr was
756
+ // present in the previous server snapshot — that way we don't strip
757
+ // runtime-added attrs (e.g. `data-nav-dropdown-initialized`).
758
+ expect(result).toContain('!srv.hasAttribute(a.name)&&cur.hasAttribute(a.name)');
759
+ });
760
+
761
+ test('compares CMS inline scripts before replacing', async () => {
762
+ const result = (await generateSSRHTML({
763
+ pageData: minimalPage as any,
764
+ injectLiveReload: true,
765
+ })) as string;
766
+ expect(result).toContain('script[id^="meno-cms-"]');
767
+ expect(result).toContain('ock!==nck');
768
+ });
769
+
770
+ test('compares /libraries/ script srcs before replacing', async () => {
771
+ const result = (await generateSSRHTML({
772
+ pageData: minimalPage as any,
773
+ injectLiveReload: true,
774
+ })) as string;
775
+ expect(result).toContain('script[src^="/libraries/"]');
776
+ expect(result).toContain('olk!==nlk');
777
+ });
778
+
779
+ test('does NOT unconditionally remove and re-append /_scripts/ tag', async () => {
780
+ const result = (await generateSSRHTML({
781
+ pageData: minimalPage as any,
782
+ injectLiveReload: true,
783
+ })) as string;
784
+ // Must not contain the old unconditional pattern where oscr is removed
785
+ // outside of a hash-mismatch branch.
786
+ expect(result).not.toContain('if(nscr){var src=nscr.getAttribute(\'src\');if(oscr)oscr.remove()');
787
+ });
788
+ });
676
789
  });
677
790
 
678
791
  describe('Options object vs legacy positional args', () => {
@@ -47,8 +47,16 @@ function minifyCSS(code: string): string {
47
47
  return code
48
48
  // Remove CSS comments
49
49
  .replace(/\/\*[\s\S]*?\*\//g, '')
50
- // Remove whitespace around special characters
51
- .replace(/\s*([{};:,>~+])\s*/g, '$1')
50
+ // Remove whitespace around block / declaration delimiters and selector
51
+ // combinators. We deliberately exclude `+` and `-` from this set: they
52
+ // are selector combinators (e.g. `a + b`, but `~` is also rare; siblings)
53
+ // AND arithmetic operators inside `calc()` / `clamp()` / `min()` / `max()`,
54
+ // where they REQUIRE surrounding spaces. Stripping there produces invalid
55
+ // CSS like `padding-top: clamp(.5*16px,((.5 - .5)/(90 - 20))*16px+(...))`,
56
+ // which the browser silently drops, collapsing paddings/margins to 0.
57
+ // The cost of leaving spaces around `+` selectors is a few extra bytes;
58
+ // the cost of stripping them is broken layout.
59
+ .replace(/\s*([{};:,>~])\s*/g, '$1')
52
60
  // Collapse multiple spaces/newlines into single space
53
61
  .replace(/\s+/g, ' ')
54
62
  // Remove space after opening brace
@@ -126,6 +134,15 @@ export interface GenerateSSRHTMLOptions {
126
134
  * When true, adds a WebSocket client that reloads the page on HMR messages.
127
135
  */
128
136
  injectLiveReload?: boolean;
137
+ /**
138
+ * Emit selection-tracking attributes (data-element-path, data-cms-item-index,
139
+ * data-cms-context, data-component-root, data-parent-component, data-component-context)
140
+ * so XRayOverlay and click-to-select can hook into the SSR DOM.
141
+ * Should ONLY be true when the request originates from the editor preview iframe
142
+ * (the studio's /__static__/ proxy sends an `x-meno-editor` header).
143
+ * Direct browser access to the SSR preview server must produce clean output.
144
+ */
145
+ injectEditorAttrs?: boolean;
129
146
  /** Whether this is the visual editor (studio). Affects library filtering. */
130
147
  isEditor?: boolean;
131
148
  /** Actual bound server port for live reload WS (connects directly to SSR server) */
@@ -214,11 +231,16 @@ export async function generateSSRHTML(
214
231
  pageCustomCode,
215
232
  clientDataCollections,
216
233
  injectLiveReload = false,
234
+ injectEditorAttrs = false,
217
235
  isEditor = false,
218
236
  isProductionBuild = false,
219
237
  serverPort,
220
238
  } = options;
221
- const rendered = await renderPageSSR(pageData, components, path, base, loc, undefined, slugs, cms, cmsServ, isProductionBuild);
239
+ // Editor selection attributes (data-element-path, data-cms-context, ...) are gated
240
+ // on injectEditorAttrs — set ONLY when the request comes from the editor preview iframe
241
+ // via the studio's /__static__/ proxy (which sends an `x-meno-editor` header).
242
+ // Direct hits to the SSR preview server (e.g., http://localhost:8082/) get clean output.
243
+ const rendered = await renderPageSSR(pageData, components, path, base, loc, undefined, slugs, cms, cmsServ, isProductionBuild, injectEditorAttrs);
222
244
 
223
245
  // Auto-inject data for nested collections (detected during SSR)
224
246
  // This enables client-side hydration for nested cms-list placeholders
@@ -489,8 +511,33 @@ picture {
489
511
  const wsUrl = serverPort
490
512
  ? `'ws://localhost:${serverPort}/hmr'`
491
513
  : `location.origin.replace('http','ws')+'/hmr'`;
514
+ // True hot reload for the studio's static preview iframe.
515
+ //
516
+ // hotReload() is content-aware on every level — sections are rebuilt only
517
+ // when their serialized content actually differs from the freshly fetched
518
+ // HTML, and #root is patched in place via smartUpdate() instead of being
519
+ // wholesale-replaced. That means:
520
+ // * DOM elements keep their identity across edits, so attached event
521
+ // listeners (e.g. NavDropdown's click handler) survive.
522
+ // * Classes/attributes that user JS added at runtime (e.g.
523
+ // `nav-dropdown-open`) are preserved by diffing the live DOM against
524
+ // a cached snapshot of the previous server HTML — anything in current
525
+ // that wasn't in that snapshot is treated as a runtime addition and
526
+ // re-applied on top of the new server attrs.
527
+ // * The `/_scripts/{hash}.js` URL is content-addressed (see
528
+ // scriptCache.hashContent), so an unchanged hash means user JS hasn't
529
+ // changed — we skip both the script reload and the DOMContentLoaded
530
+ // re-dispatch, leaving JS module state (closures, isOpen flags, etc.)
531
+ // intact.
532
+ //
533
+ // Element identity for the smartUpdate() walk relies on
534
+ // `data-element-path`, which the SSR pipeline emits on every element when
535
+ // the request comes through the studio's `/__static__/` proxy (which sets
536
+ // `x-meno-editor: 1`). On structural changes that smartUpdate can't safely
537
+ // diff (different element counts or unknown paths) it falls back to a
538
+ // straight innerHTML replace.
492
539
  const liveReloadScript = injectLiveReload
493
- ? `<script>(function(){var ws,timer,gen=0;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');var nr=d.getElementById('root');if(or&&nr)or.innerHTML=nr.innerHTML;var os=document.getElementById('meno-styles');var ns=d.getElementById('meno-styles');if(os&&ns){os.parentNode.replaceChild(ns.cloneNode(true),os)}var nh=d.documentElement;if(nh){document.documentElement.setAttribute('lang',nh.getAttribute('lang')||'en');document.documentElement.setAttribute('theme',nh.getAttribute('theme')||'light')}document.querySelectorAll('script[id^="meno-cms-"]').forEach(function(s){s.remove()});d.querySelectorAll('script[id^="meno-cms-"]').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;document.querySelectorAll('body > script[src^="/libraries/"]').forEach(function(o){o.remove()});d.querySelectorAll('body > script[src^="/libraries/"]').forEach(function(n){var ls=document.createElement('script');ls.src=n.getAttribute('src')+(n.getAttribute('src').indexOf('?')>-1?'&':'?')+'_r='+Date.now();document.body.appendChild(ls)});var oscr=document.querySelector('script[src^="/_scripts/"]');var nscr=d.querySelector('script[src^="/_scripts/"]');if(nscr){var src=nscr.getAttribute('src');if(oscr)oscr.remove();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{if(oscr)oscr.remove();document.dispatchEvent(new Event('DOMContentLoaded'));window.scrollTo(sx,sy)}}).catch(function(){location.reload()})}connect()})()</script>`
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>`
494
541
  : '';
495
542
 
496
543
  // Scroll position handlers for preview mode iframe switching
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Behavioural tests for the inlined live-reload IIFE in htmlGenerator.ts.
3
+ *
4
+ * These tests load the script source out of the file, undo the TS template
5
+ * literal escaping (so `\\s+` reads back as `\s+`), and execute the IIFE inside
6
+ * a happy-dom window with stubbed WebSocket / fetch / location. They verify the
7
+ * end-to-end hotReload() behaviour rather than just asserting on substrings of
8
+ * the generated HTML.
9
+ */
10
+
11
+ import { test, expect, describe } from 'bun:test';
12
+ import { Window } from 'happy-dom';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+
16
+ function loadLiveReloadScript(): string {
17
+ const src = fs.readFileSync(path.join(import.meta.dir, 'htmlGenerator.ts'), 'utf8');
18
+ const idx = src.indexOf('const liveReloadScript');
19
+ if (idx === -1) throw new Error('liveReloadScript not found');
20
+ const match = src.slice(idx).match(/<script>([\s\S]*?)<\/script>/);
21
+ if (!match) throw new Error('liveReloadScript script tag not found');
22
+ // The script lives inside a TS template literal; reverse the template's
23
+ // runtime transformations so the executable form matches what a browser sees.
24
+ return match[1]
25
+ .split('\\\\').join('\\')
26
+ .replace(/\$\{wsUrl\}/g, "'ws://localhost:3000/hmr'");
27
+ }
28
+
29
+ interface Harness {
30
+ window: Window;
31
+ document: Document;
32
+ capturedHandler: ((e: { data: string }) => void) | null;
33
+ setFetch(fn: () => Promise<{ text: () => Promise<string> }>): void;
34
+ triggerHmr(): void;
35
+ flush(): Promise<void>;
36
+ reloadCalls: number;
37
+ dispatchedDOMContentLoaded: number;
38
+ }
39
+
40
+ function setupHarness(initialBodyHtml: string): Harness {
41
+ const script = loadLiveReloadScript();
42
+ const window = new Window({ url: 'http://localhost/' });
43
+ const document = window.document as unknown as Document;
44
+ document.documentElement.setAttribute('lang', 'en');
45
+ document.documentElement.setAttribute('theme', 'light');
46
+ (document.body as unknown as HTMLElement).innerHTML = initialBodyHtml;
47
+
48
+ const harness: Harness = {
49
+ window,
50
+ document,
51
+ capturedHandler: null,
52
+ setFetch: (fn) => { (window as any).fetch = fn; },
53
+ triggerHmr: () => {
54
+ if (!harness.capturedHandler) throw new Error('WebSocket handler not captured');
55
+ harness.capturedHandler({ data: JSON.stringify({ type: 'hmr:update' }) });
56
+ },
57
+ flush: () => new Promise(r => setTimeout(r, 100)),
58
+ reloadCalls: 0,
59
+ dispatchedDOMContentLoaded: 0,
60
+ };
61
+
62
+ (window as any).WebSocket = class {
63
+ constructor() {}
64
+ set onmessage(fn: any) { harness.capturedHandler = fn; }
65
+ get onmessage() { return harness.capturedHandler; }
66
+ onclose: any = null;
67
+ };
68
+
69
+ // Track dispatched DOMContentLoaded (the IIFE fires this on document)
70
+ document.addEventListener('DOMContentLoaded', () => { harness.dispatchedDOMContentLoaded += 1; });
71
+
72
+ const dynamicFetch = (...args: any[]) => (window as any).fetch(...args);
73
+ const fn = new Function(
74
+ 'document', 'WebSocket', 'fetch', 'setTimeout', 'clearTimeout',
75
+ 'DOMParser', 'Event', 'window', 'location',
76
+ script
77
+ );
78
+ fn(
79
+ document, (window as any).WebSocket, dynamicFetch,
80
+ setTimeout, clearTimeout,
81
+ (window as any).DOMParser, (window as any).Event, window,
82
+ {
83
+ href: 'http://localhost/',
84
+ reload: () => { harness.reloadCalls += 1; },
85
+ }
86
+ );
87
+
88
+ return harness;
89
+ }
90
+
91
+ function makeServerHtml(rootInner: string, scriptHash = 'abc'): string {
92
+ return [
93
+ '<!DOCTYPE html><html lang="en" theme="light"><head></head><body>',
94
+ '<div id="root">', rootInner, '</div>',
95
+ '<style id="meno-styles">.dummy{color:red}</style>',
96
+ `<script src="/_scripts/${scriptHash}.js" defer></script>`,
97
+ '</body></html>',
98
+ ].join('');
99
+ }
100
+
101
+ describe('live reload IIFE', () => {
102
+ test('preserves dropdown DOM identity, runtime classes, and runtime attrs across a style edit', async () => {
103
+ const initialRootInner = `
104
+ <header data-element-path="0">
105
+ <div data-element-path="0,0" class="nav-dropdown t-bg-red" data-nav-dropdown="container">
106
+ <span data-element-path="0,0,0">Menu</span>
107
+ <ul data-element-path="0,0,1" data-nav-dropdown="menu" class="nav-dropdown-menu">
108
+ <li data-element-path="0,0,1,0">Item 1</li>
109
+ </ul>
110
+ </div>
111
+ </header>
112
+ `;
113
+ const harness = setupHarness(`<div id="root">${initialRootInner}</div><style id="meno-styles">.t-bg-red{background:red}</style><script src="/_scripts/v1.js" defer></script>`);
114
+
115
+ // User opens dropdown via JS — adds runtime class + initialization marker.
116
+ const container = harness.document.querySelector('[data-element-path="0,0"]')!;
117
+ container.classList.add('nav-dropdown-open');
118
+ container.setAttribute('data-nav-dropdown-initialized', 'true');
119
+ const menu = harness.document.querySelector('[data-element-path="0,0,1"]')!;
120
+ menu.classList.add('nav-dropdown-menu-visible');
121
+
122
+ const beforeContainerRef = container;
123
+ const beforeMenuRef = menu;
124
+
125
+ // Editor edit: t-bg-red -> t-bg-blue. Same /_scripts/ hash (user JS unchanged).
126
+ const newRootInner = `<header data-element-path="0"><div data-element-path="0,0" class="nav-dropdown t-bg-blue" data-nav-dropdown="container"><span data-element-path="0,0,0">Menu</span><ul data-element-path="0,0,1" data-nav-dropdown="menu" class="nav-dropdown-menu"><li data-element-path="0,0,1,0">Item 1</li></ul></div></header>`;
127
+ harness.setFetch(() => Promise.resolve({ text: () => Promise.resolve(makeServerHtml(newRootInner, 'v1')) }));
128
+
129
+ harness.triggerHmr();
130
+ await harness.flush();
131
+
132
+ const containerAfter = harness.document.querySelector('[data-element-path="0,0"]')!;
133
+ const menuAfter = harness.document.querySelector('[data-element-path="0,0,1"]')!;
134
+
135
+ expect(containerAfter).toBe(beforeContainerRef);
136
+ expect(containerAfter.classList.contains('nav-dropdown-open')).toBe(true);
137
+ expect(containerAfter.getAttribute('data-nav-dropdown-initialized')).toBe('true');
138
+ expect(containerAfter.classList.contains('t-bg-blue')).toBe(true);
139
+ expect(containerAfter.classList.contains('t-bg-red')).toBe(false);
140
+ expect(menuAfter).toBe(beforeMenuRef);
141
+ expect(menuAfter.classList.contains('nav-dropdown-menu-visible')).toBe(true);
142
+ // /_scripts/ hash unchanged → no DOMContentLoaded re-dispatch
143
+ expect(harness.dispatchedDOMContentLoaded).toBe(0);
144
+ expect(harness.reloadCalls).toBe(0);
145
+ });
146
+
147
+ test('re-dispatches DOMContentLoaded only when /_scripts/ hash changes', async () => {
148
+ const initialRootInner = `<header data-element-path="0"><span data-element-path="0,0">hi</span></header>`;
149
+ const harness = setupHarness(`<div id="root">${initialRootInner}</div><style id="meno-styles">.x{}</style><script src="/_scripts/v1.js" defer></script>`);
150
+
151
+ // Edit that changes user JS → new content-hash on /_scripts/
152
+ const newRootInner = `<header data-element-path="0"><span data-element-path="0,0">hi</span></header>`;
153
+ harness.setFetch(() => Promise.resolve({ text: () => Promise.resolve(makeServerHtml(newRootInner, 'v2')) }));
154
+
155
+ harness.triggerHmr();
156
+ await harness.flush();
157
+ // Wait a beat for the dynamically-appended script's onload to (try to) fire.
158
+ await new Promise(r => setTimeout(r, 50));
159
+
160
+ // The hash changed, so the IIFE removed the old /_scripts/ tag and appended
161
+ // a new one. happy-dom's appended <script src> won't actually load over the
162
+ // network in a unit test, so onload doesn't fire and DOMContentLoaded isn't
163
+ // dispatched here. The behavioural guarantee we care about — that the IIFE
164
+ // *would* dispatch on load — is covered by the structural test in
165
+ // htmlGenerator.test.ts ("compares /_scripts/ src ... before reloading").
166
+ // This test asserts the negative side: the old script tag is gone and a
167
+ // new one was appended.
168
+ const scripts = Array.from(harness.document.querySelectorAll('script[src^="/_scripts/"]'));
169
+ expect(scripts.length).toBe(1);
170
+ expect(scripts[0].getAttribute('src') || '').toContain('/_scripts/v2.js');
171
+ expect(harness.reloadCalls).toBe(0);
172
+ });
173
+
174
+ test('falls back to innerHTML replace on structural changes (different element count)', async () => {
175
+ const initialRootInner = `<header data-element-path="0"><span data-element-path="0,0">a</span></header>`;
176
+ const harness = setupHarness(`<div id="root">${initialRootInner}</div><style id="meno-styles">.x{}</style><script src="/_scripts/v1.js" defer></script>`);
177
+
178
+ // New HTML has an additional element (different count of [data-element-path])
179
+ const newRootInner = `<header data-element-path="0"><span data-element-path="0,0">a</span><span data-element-path="0,1">b</span></header>`;
180
+ harness.setFetch(() => Promise.resolve({ text: () => Promise.resolve(makeServerHtml(newRootInner, 'v1')) }));
181
+
182
+ harness.triggerHmr();
183
+ await harness.flush();
184
+
185
+ const root = harness.document.getElementById('root')!;
186
+ expect(root.querySelectorAll('[data-element-path]').length).toBe(3);
187
+ expect(root.querySelector('[data-element-path="0,1"]')?.textContent).toBe('b');
188
+ expect(harness.reloadCalls).toBe(0);
189
+ });
190
+
191
+ test('updates lang/theme only when they actually differ', async () => {
192
+ const initialRootInner = `<span data-element-path="0">hi</span>`;
193
+ const harness = setupHarness(`<div id="root">${initialRootInner}</div><style id="meno-styles">.x{}</style><script src="/_scripts/v1.js" defer></script>`);
194
+ const beforeLang = harness.document.documentElement.getAttribute('lang');
195
+
196
+ // Same lang/theme as initial document
197
+ harness.setFetch(() => Promise.resolve({ text: () => Promise.resolve(makeServerHtml(initialRootInner, 'v1')) }));
198
+ harness.triggerHmr();
199
+ await harness.flush();
200
+ expect(harness.document.documentElement.getAttribute('lang')).toBe(beforeLang);
201
+
202
+ // Now change theme → should update
203
+ const themeChangedHtml = makeServerHtml(initialRootInner, 'v1').replace('theme="light"', 'theme="dark"');
204
+ harness.setFetch(() => Promise.resolve({ text: () => Promise.resolve(themeChangedHtml) }));
205
+ harness.triggerHmr();
206
+ await harness.flush();
207
+ expect(harness.document.documentElement.getAttribute('theme')).toBe('dark');
208
+ });
209
+ });