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.
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
- package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
- package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
- package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
- package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
- package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
- package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
- package/dist/entries/server-router.js +9 -9
- package/dist/entries/server-router.js.map +2 -2
- package/dist/lib/client/index.js +54 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +9 -9
- package/dist/lib/shared/index.js +46 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.ts +8 -1
- package/lib/client/core/builders/embedBuilder.ts +15 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
- package/lib/client/core/builders/localeListBuilder.ts +17 -6
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/theme.ts +4 -4
- package/lib/server/cssGenerator.test.ts +64 -1
- package/lib/server/cssGenerator.ts +48 -9
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/index.ts +1 -1
- package/lib/server/routes/pages.ts +23 -1
- package/lib/server/services/cmsService.test.ts +246 -0
- package/lib/server/services/cmsService.ts +122 -5
- package/lib/server/services/configService.ts +5 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +113 -0
- package/lib/server/ssr/htmlGenerator.ts +51 -4
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +306 -0
- package/lib/server/ssr/ssrRenderer.ts +182 -44
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +247 -1
- package/lib/shared/cssProperties.ts +196 -6
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/responsiveScaling.test.ts +143 -0
- package/lib/shared/responsiveScaling.ts +253 -2
- package/lib/shared/themeDefaults.test.ts +3 -3
- package/lib/shared/themeDefaults.ts +3 -3
- package/lib/shared/types/cms.ts +28 -3
- package/lib/shared/types/index.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +3 -0
- package/lib/shared/utilityClassMapper.test.ts +123 -0
- package/lib/shared/utilityClassMapper.ts +179 -8
- package/lib/shared/validation/schemas.ts +15 -1
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
- package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
- package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /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
|
|
51
|
-
.
|
|
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
|
-
|
|
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')
|
|
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
|
+
});
|