meno-core 1.0.47 → 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/build-astro.ts +2 -2
- 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-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
- package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
- package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
- package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
- package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-DYCUEURL.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 +64 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +1737 -296
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +50 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.test.ts +17 -0
- package/lib/client/core/ComponentBuilder.ts +25 -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/index.ts +1 -1
- package/lib/server/jsonLoader.test.ts +0 -17
- package/lib/server/jsonLoader.ts +0 -81
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/api/variables.ts +4 -2
- 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 +114 -2
- package/lib/server/ssr/htmlGenerator.ts +53 -6
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +362 -1
- package/lib/server/ssr/ssrRenderer.ts +216 -72
- package/lib/server/utils/jsonLineMapper.test.ts +53 -1
- package/lib/server/utils/jsonLineMapper.ts +43 -3
- package/lib/server/webflow/buildWebflow.ts +343 -123
- package/lib/server/webflow/index.ts +1 -0
- package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
- package/lib/server/webflow/nodeToWebflow.ts +2141 -129
- package/lib/server/webflow/styleMapper.test.ts +389 -0
- package/lib/server/webflow/styleMapper.ts +517 -63
- package/lib/server/webflow/templateWrapper.ts +49 -0
- package/lib/server/webflow/types.ts +218 -18
- 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/elementClassName.test.ts +15 -0
- package/lib/shared/elementClassName.ts +7 -3
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
- 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 +2 -0
- package/lib/shared/types/variables.ts +37 -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-47UNLQUU.js.map +0 -7
- package/dist/chunks/chunk-FED5MME6.js.map +0 -7
- package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
- package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
- /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
|
@@ -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
|
+
});
|
|
@@ -31,7 +31,6 @@ mock.module('../jsonLoader', () => ({
|
|
|
31
31
|
],
|
|
32
32
|
}),
|
|
33
33
|
loadIconsConfig: async () => ({ favicon: '', appleTouchIcon: '' }),
|
|
34
|
-
loadResponsiveScalesConfig: async () => ({}),
|
|
35
34
|
loadPrefetchConfig: async () => ({ enabled: false }),
|
|
36
35
|
}));
|
|
37
36
|
|
|
@@ -44,6 +43,7 @@ mock.module('./imageMetadata', () => ({
|
|
|
44
43
|
mock.module('../services/configService', () => ({
|
|
45
44
|
configService: {
|
|
46
45
|
getImageFormat: () => mockImageFormat,
|
|
46
|
+
getResponsiveScales: () => ({ enabled: false, baseReference: 16 }),
|
|
47
47
|
load: async () => {},
|
|
48
48
|
isLoaded: () => true,
|
|
49
49
|
reset: () => {},
|
|
@@ -717,6 +717,20 @@ describe('ssrRenderer', () => {
|
|
|
717
717
|
expect(html).toBe('');
|
|
718
718
|
});
|
|
719
719
|
|
|
720
|
+
test('skips node with unresolved string-template if', async () => {
|
|
721
|
+
// {{buttonText}} stays unresolved when the referenced prop is undefined
|
|
722
|
+
// (evaluateTemplate's backward-compat fallback returns the original template).
|
|
723
|
+
// Treat unresolved templates as falsy so the node is hidden.
|
|
724
|
+
const node = {
|
|
725
|
+
type: 'link',
|
|
726
|
+
href: '/page',
|
|
727
|
+
children: 'link',
|
|
728
|
+
if: '{{buttonText}}',
|
|
729
|
+
};
|
|
730
|
+
const html = await render(node);
|
|
731
|
+
expect(html).toBe('');
|
|
732
|
+
});
|
|
733
|
+
|
|
720
734
|
test('evaluates boolean mapping if condition - renders when mapped to true', async () => {
|
|
721
735
|
// Component renders within buildComponentHTML with componentResolvedProps
|
|
722
736
|
// We need to use a component that sets up the mapping context
|
|
@@ -1525,6 +1539,48 @@ describe('ssrRenderer', () => {
|
|
|
1525
1539
|
expect(result.html).toContain('c-#ffffff');
|
|
1526
1540
|
});
|
|
1527
1541
|
|
|
1542
|
+
test('instance style overrides structure style on the same property', async () => {
|
|
1543
|
+
// Regression: previously the structure's mb-0 and the instance's mb-32px both
|
|
1544
|
+
// ended up as same-specificity utility classes on the rendered element, and
|
|
1545
|
+
// the structure's class won by source order — opposite of client behavior.
|
|
1546
|
+
// Instance styles must replace structure styles per-property at merge time.
|
|
1547
|
+
const buttonDef: ComponentDefinition = {
|
|
1548
|
+
component: {
|
|
1549
|
+
structure: {
|
|
1550
|
+
type: 'node',
|
|
1551
|
+
tag: 'button',
|
|
1552
|
+
style: { base: { marginBottom: 0, padding: '8px' } },
|
|
1553
|
+
children: ['{{text}}'],
|
|
1554
|
+
},
|
|
1555
|
+
interface: {
|
|
1556
|
+
text: { type: 'string', default: 'Click' },
|
|
1557
|
+
},
|
|
1558
|
+
acceptsStyles: true,
|
|
1559
|
+
} as any,
|
|
1560
|
+
};
|
|
1561
|
+
|
|
1562
|
+
const pageData: JSONPage = {
|
|
1563
|
+
root: {
|
|
1564
|
+
type: 'node',
|
|
1565
|
+
tag: 'div',
|
|
1566
|
+
children: [
|
|
1567
|
+
{
|
|
1568
|
+
type: 'component',
|
|
1569
|
+
component: 'Button',
|
|
1570
|
+
props: { text: 'Hi' },
|
|
1571
|
+
style: { base: { marginBottom: '32px' } },
|
|
1572
|
+
},
|
|
1573
|
+
],
|
|
1574
|
+
} as any,
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
const result = await renderPageSSR(pageData, { Button: buttonDef });
|
|
1578
|
+
expect(result.html).toContain('mb-32px');
|
|
1579
|
+
expect(result.html).not.toContain('mb-0');
|
|
1580
|
+
// Untouched structure properties remain.
|
|
1581
|
+
expect(result.html).toContain('p-8px');
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1528
1584
|
test('extracts locale from path when not explicitly provided', async () => {
|
|
1529
1585
|
const i18nConfig = {
|
|
1530
1586
|
defaultLocale: 'en',
|
|
@@ -3054,6 +3110,203 @@ describe('ssrRenderer', () => {
|
|
|
3054
3110
|
expect(html).toContain('Author a1');
|
|
3055
3111
|
expect(html).toContain('Author a2');
|
|
3056
3112
|
});
|
|
3113
|
+
|
|
3114
|
+
test('inner list items dotted-ref resolves against host component prop', async () => {
|
|
3115
|
+
// {{config.0.tagIds}} is a dotted template — processStructure leaves it literal
|
|
3116
|
+
// (hasItemTemplates returns true for `\w+\.\w+`). The SSR items resolver must
|
|
3117
|
+
// see the host component's `config` prop in its scope.
|
|
3118
|
+
const calledWith: string[][] = [];
|
|
3119
|
+
const mockCmsService = {
|
|
3120
|
+
getItemsByIds: async (_collection: string, ids: string[]) => {
|
|
3121
|
+
calledWith.push(ids);
|
|
3122
|
+
return ids.map(id => ({ _id: id, name: `Tag ${id}` }));
|
|
3123
|
+
},
|
|
3124
|
+
getSchema: () => undefined,
|
|
3125
|
+
};
|
|
3126
|
+
|
|
3127
|
+
const Card: ComponentDefinition = {
|
|
3128
|
+
component: {
|
|
3129
|
+
interface: {
|
|
3130
|
+
config: { type: 'list' as any, default: [] },
|
|
3131
|
+
},
|
|
3132
|
+
structure: {
|
|
3133
|
+
type: 'list',
|
|
3134
|
+
sourceType: 'collection',
|
|
3135
|
+
source: 'tags',
|
|
3136
|
+
itemAs: 'tag',
|
|
3137
|
+
items: '{{config.0.tagIds}}',
|
|
3138
|
+
children: [
|
|
3139
|
+
{ type: 'node', tag: 'span', children: '{{tag.name}}' },
|
|
3140
|
+
],
|
|
3141
|
+
},
|
|
3142
|
+
},
|
|
3143
|
+
};
|
|
3144
|
+
|
|
3145
|
+
const node = {
|
|
3146
|
+
type: 'component',
|
|
3147
|
+
component: 'Card',
|
|
3148
|
+
props: {
|
|
3149
|
+
config: [{ tagIds: ['t1', 't2'] }],
|
|
3150
|
+
},
|
|
3151
|
+
};
|
|
3152
|
+
|
|
3153
|
+
const html = await render(node, {
|
|
3154
|
+
globalComponents: { Card },
|
|
3155
|
+
cmsService: mockCmsService,
|
|
3156
|
+
});
|
|
3157
|
+
|
|
3158
|
+
expect(calledWith).toEqual([['t1', 't2']]);
|
|
3159
|
+
expect(html).toContain('Tag t1');
|
|
3160
|
+
expect(html).toContain('Tag t2');
|
|
3161
|
+
});
|
|
3162
|
+
|
|
3163
|
+
test('inner list filter dotted-ref resolves against host component prop', async () => {
|
|
3164
|
+
// {{config.0.collection}} is a dotted template — processStructure leaves it literal.
|
|
3165
|
+
// SSR's resolveFilterTemplates must see the host component's `config` prop.
|
|
3166
|
+
const queries: any[] = [];
|
|
3167
|
+
const mockCmsService = {
|
|
3168
|
+
queryItems: async (q: any) => {
|
|
3169
|
+
queries.push(q);
|
|
3170
|
+
return [{ _id: 'p1', title: 'Hello' }];
|
|
3171
|
+
},
|
|
3172
|
+
getSchema: () => undefined,
|
|
3173
|
+
};
|
|
3174
|
+
|
|
3175
|
+
const Card: ComponentDefinition = {
|
|
3176
|
+
component: {
|
|
3177
|
+
interface: {
|
|
3178
|
+
config: { type: 'list' as any, default: [] },
|
|
3179
|
+
},
|
|
3180
|
+
structure: {
|
|
3181
|
+
type: 'list',
|
|
3182
|
+
sourceType: 'collection',
|
|
3183
|
+
source: 'posts',
|
|
3184
|
+
itemAs: 'post',
|
|
3185
|
+
filter: { category: '{{config.0.collection}}' },
|
|
3186
|
+
children: [
|
|
3187
|
+
{ type: 'node', tag: 'div', children: '{{post.title}}' },
|
|
3188
|
+
],
|
|
3189
|
+
},
|
|
3190
|
+
},
|
|
3191
|
+
};
|
|
3192
|
+
|
|
3193
|
+
const node = {
|
|
3194
|
+
type: 'component',
|
|
3195
|
+
component: 'Card',
|
|
3196
|
+
props: {
|
|
3197
|
+
config: [{ collection: 'news' }],
|
|
3198
|
+
},
|
|
3199
|
+
};
|
|
3200
|
+
|
|
3201
|
+
const html = await render(node, {
|
|
3202
|
+
globalComponents: { Card },
|
|
3203
|
+
cmsService: mockCmsService,
|
|
3204
|
+
});
|
|
3205
|
+
|
|
3206
|
+
expect(queries.length).toBe(1);
|
|
3207
|
+
expect(queries[0].filter).toEqual({ category: 'news' });
|
|
3208
|
+
expect(html).toContain('Hello');
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
test('parent list loop variable wins over a colliding host component prop', async () => {
|
|
3212
|
+
// Outer CMS list iterates with itemAs: 'tag', producing a templateContext entry
|
|
3213
|
+
// named `tag` set to the current item. The host Card also receives a prop named
|
|
3214
|
+
// `tag` set to a fake value. The inner list's items: {{tag.id}} must resolve to
|
|
3215
|
+
// the parent loop variable's id (precedence: parent loop variable > component props).
|
|
3216
|
+
const calledWith: string[][] = [];
|
|
3217
|
+
const mockCmsService = {
|
|
3218
|
+
// Outer iteration: itemAs: 'tag' makes templateContext.tag = item, so the
|
|
3219
|
+
// inner template `{{tag.id}}` looks up item.id directly.
|
|
3220
|
+
queryItems: async () => [
|
|
3221
|
+
{ _id: 'p1', id: 'real-1' },
|
|
3222
|
+
{ _id: 'p2', id: 'real-2' },
|
|
3223
|
+
],
|
|
3224
|
+
getItemsByIds: async (_collection: string, ids: string[]) => {
|
|
3225
|
+
calledWith.push(ids);
|
|
3226
|
+
return ids.map(id => ({ _id: id, name: `Item ${id}` }));
|
|
3227
|
+
},
|
|
3228
|
+
getSchema: () => undefined,
|
|
3229
|
+
};
|
|
3230
|
+
|
|
3231
|
+
const Card: ComponentDefinition = {
|
|
3232
|
+
component: {
|
|
3233
|
+
interface: {
|
|
3234
|
+
// type: 'list' so the array prop value passes validation. The prop
|
|
3235
|
+
// `tag` will land in componentResolvedProps; the parent loop var
|
|
3236
|
+
// `tag` (set to the current outer item) will land in templateContext.
|
|
3237
|
+
tag: { type: 'list' as any, default: [] },
|
|
3238
|
+
},
|
|
3239
|
+
structure: {
|
|
3240
|
+
type: 'list',
|
|
3241
|
+
sourceType: 'collection',
|
|
3242
|
+
source: 'tags',
|
|
3243
|
+
itemAs: 'inner',
|
|
3244
|
+
items: '{{tag.id}}',
|
|
3245
|
+
children: [
|
|
3246
|
+
{ type: 'node', tag: 'span', children: '{{inner.name}}' },
|
|
3247
|
+
],
|
|
3248
|
+
},
|
|
3249
|
+
},
|
|
3250
|
+
};
|
|
3251
|
+
|
|
3252
|
+
const node = {
|
|
3253
|
+
type: 'list',
|
|
3254
|
+
sourceType: 'collection',
|
|
3255
|
+
source: 'posts',
|
|
3256
|
+
itemAs: 'tag',
|
|
3257
|
+
children: [
|
|
3258
|
+
{
|
|
3259
|
+
type: 'component',
|
|
3260
|
+
component: 'Card',
|
|
3261
|
+
props: {
|
|
3262
|
+
// Static fake value for the colliding prop; the inner template
|
|
3263
|
+
// must resolve to the parent loop var, not this.
|
|
3264
|
+
tag: [{ id: 'fake-prop-id' }],
|
|
3265
|
+
},
|
|
3266
|
+
},
|
|
3267
|
+
],
|
|
3268
|
+
};
|
|
3269
|
+
|
|
3270
|
+
await render(node, {
|
|
3271
|
+
globalComponents: { Card },
|
|
3272
|
+
cmsService: mockCmsService,
|
|
3273
|
+
});
|
|
3274
|
+
|
|
3275
|
+
expect(calledWith).toEqual([['real-1'], ['real-2']]);
|
|
3276
|
+
});
|
|
3277
|
+
|
|
3278
|
+
test('page-root filter does not silently resolve {{cms.X}} (regression)', async () => {
|
|
3279
|
+
// Without a host component, ctx.componentResolvedProps is undefined and
|
|
3280
|
+
// ctx.templateContext is undefined → buildListResolutionScope returns
|
|
3281
|
+
// undefined → resolveFilterTemplates leaves filter values literal.
|
|
3282
|
+
// This pins down the documented behavior: the cmsContext namespace does
|
|
3283
|
+
// NOT silently merge into filter scope.
|
|
3284
|
+
const queries: any[] = [];
|
|
3285
|
+
const mockCmsService = {
|
|
3286
|
+
queryItems: async (q: any) => {
|
|
3287
|
+
queries.push(q);
|
|
3288
|
+
return [];
|
|
3289
|
+
},
|
|
3290
|
+
getSchema: () => undefined,
|
|
3291
|
+
};
|
|
3292
|
+
|
|
3293
|
+
const node = {
|
|
3294
|
+
type: 'list',
|
|
3295
|
+
sourceType: 'collection',
|
|
3296
|
+
source: 'posts',
|
|
3297
|
+
filter: { category: '{{cms.something}}' },
|
|
3298
|
+
children: [{ type: 'node', tag: 'div', children: '{{post.title}}' }],
|
|
3299
|
+
};
|
|
3300
|
+
|
|
3301
|
+
// cmsContext is provided, but page-root filter must not pull from it.
|
|
3302
|
+
await render(node, {
|
|
3303
|
+
cmsService: mockCmsService,
|
|
3304
|
+
cmsContext: { cms: { something: 'shouldNotBeUsed' } },
|
|
3305
|
+
});
|
|
3306
|
+
|
|
3307
|
+
expect(queries.length).toBe(1);
|
|
3308
|
+
expect(queries[0].filter).toEqual({ category: '{{cms.something}}' });
|
|
3309
|
+
});
|
|
3057
3310
|
});
|
|
3058
3311
|
|
|
3059
3312
|
// -----------------------------------------------------------------------
|
|
@@ -3964,4 +4217,112 @@ describe('ssrRenderer', () => {
|
|
|
3964
4217
|
expect(html).toContain('href="/fr/a-propos"');
|
|
3965
4218
|
});
|
|
3966
4219
|
});
|
|
4220
|
+
|
|
4221
|
+
// Editor attrs are emitted only when buildComponentHTML's injectEditorAttrs flag is on.
|
|
4222
|
+
// These attributes (data-element-path, data-cms-item-index, data-cms-context,
|
|
4223
|
+
// data-component-root, data-parent-component, data-component-context) drive
|
|
4224
|
+
// XRayOverlay and the click-to-select handler in static (SSR) preview mode.
|
|
4225
|
+
describe('buildComponentHTML - editor attrs (preview-only)', () => {
|
|
4226
|
+
test('omits data-element-path by default', async () => {
|
|
4227
|
+
const node = { type: 'node', tag: 'div', children: ['hello'] };
|
|
4228
|
+
const result = await buildComponentHTML(node as any);
|
|
4229
|
+
expect(result.html).not.toContain('data-element-path');
|
|
4230
|
+
});
|
|
4231
|
+
|
|
4232
|
+
test('emits data-element-path on every element when injectEditorAttrs is true', async () => {
|
|
4233
|
+
const node = {
|
|
4234
|
+
type: 'node',
|
|
4235
|
+
tag: 'div',
|
|
4236
|
+
children: [
|
|
4237
|
+
{ type: 'node', tag: 'span', children: ['hi'] },
|
|
4238
|
+
{ type: 'node', tag: 'a', attributes: { href: '/x' }, children: ['link'] },
|
|
4239
|
+
],
|
|
4240
|
+
};
|
|
4241
|
+
const result = await buildComponentHTML(
|
|
4242
|
+
node as any,
|
|
4243
|
+
{}, {}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
|
|
4244
|
+
);
|
|
4245
|
+
expect(result.html).toContain('data-element-path="0"');
|
|
4246
|
+
expect(result.html).toContain('data-element-path="0,0"');
|
|
4247
|
+
expect(result.html).toContain('data-element-path="0,1"');
|
|
4248
|
+
});
|
|
4249
|
+
|
|
4250
|
+
test('emits data-cms-item-index and data-cms-context for elements inside a list', async () => {
|
|
4251
|
+
// List in prop mode under a component resolves items from componentResolvedProps.
|
|
4252
|
+
const Card: ComponentDefinition = {
|
|
4253
|
+
component: {
|
|
4254
|
+
interface: {
|
|
4255
|
+
items: { type: 'list', default: [{ label: 'A' }, { label: 'B' }] } as any,
|
|
4256
|
+
},
|
|
4257
|
+
structure: {
|
|
4258
|
+
type: 'node',
|
|
4259
|
+
tag: 'div',
|
|
4260
|
+
children: [{
|
|
4261
|
+
type: 'list',
|
|
4262
|
+
sourceType: 'prop',
|
|
4263
|
+
source: 'items',
|
|
4264
|
+
children: [{ type: 'node', tag: 'p', children: '{{item.label}}' }],
|
|
4265
|
+
}],
|
|
4266
|
+
},
|
|
4267
|
+
},
|
|
4268
|
+
};
|
|
4269
|
+
const result = await buildComponentHTML(
|
|
4270
|
+
{ type: 'component', component: 'Card' } as any,
|
|
4271
|
+
{ Card },
|
|
4272
|
+
{}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
|
|
4273
|
+
);
|
|
4274
|
+
expect(result.html).toContain('data-cms-item-index="0"');
|
|
4275
|
+
expect(result.html).toContain('data-cms-item-index="1"');
|
|
4276
|
+
expect(result.html).toContain('data-cms-context=');
|
|
4277
|
+
});
|
|
4278
|
+
|
|
4279
|
+
test('marks the component instance root with data-component-root', async () => {
|
|
4280
|
+
const Card: ComponentDefinition = {
|
|
4281
|
+
component: {
|
|
4282
|
+
interface: {},
|
|
4283
|
+
structure: { type: 'node', tag: 'div', children: ['inner'] },
|
|
4284
|
+
},
|
|
4285
|
+
};
|
|
4286
|
+
const result = await buildComponentHTML(
|
|
4287
|
+
{ type: 'component', component: 'Card' } as any,
|
|
4288
|
+
{ Card },
|
|
4289
|
+
{}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
|
|
4290
|
+
);
|
|
4291
|
+
expect(result.html).toContain('data-component-root="true"');
|
|
4292
|
+
expect(result.html).toContain('data-component-context="Card"');
|
|
4293
|
+
});
|
|
4294
|
+
|
|
4295
|
+
test('emits page-absolute data-element-path for elements inside a component', async () => {
|
|
4296
|
+
// Component has a single wrapper <div> with a <span> child. The page hosts
|
|
4297
|
+
// the component as the only child of its root <div>, so:
|
|
4298
|
+
// page root <div> → [0]
|
|
4299
|
+
// Card root <div> → [0,0]
|
|
4300
|
+
// inner <span> → [0,0,0]
|
|
4301
|
+
// Before the fix SSR reset elementPath inside components, so the inner
|
|
4302
|
+
// span landed at [0] (component-relative) and XRay couldn't find it.
|
|
4303
|
+
const Card: ComponentDefinition = {
|
|
4304
|
+
component: {
|
|
4305
|
+
interface: {},
|
|
4306
|
+
structure: {
|
|
4307
|
+
type: 'node',
|
|
4308
|
+
tag: 'div',
|
|
4309
|
+
children: [{ type: 'node', tag: 'span', children: 'inner' }],
|
|
4310
|
+
},
|
|
4311
|
+
},
|
|
4312
|
+
};
|
|
4313
|
+
const page = {
|
|
4314
|
+
type: 'node',
|
|
4315
|
+
tag: 'div',
|
|
4316
|
+
children: [{ type: 'component', component: 'Card' }],
|
|
4317
|
+
};
|
|
4318
|
+
const result = await buildComponentHTML(
|
|
4319
|
+
page as any,
|
|
4320
|
+
{ Card },
|
|
4321
|
+
{}, undefined, undefined, undefined, undefined, undefined, undefined, undefined, true,
|
|
4322
|
+
);
|
|
4323
|
+
expect(result.html).toContain('data-element-path="0"');
|
|
4324
|
+
expect(result.html).toContain('data-element-path="0,0"');
|
|
4325
|
+
expect(result.html).toContain('data-element-path="0,0,0"');
|
|
4326
|
+
});
|
|
4327
|
+
});
|
|
3967
4328
|
});
|