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,3170 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
|
|
3
|
+
import { tmpdir } from 'os';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { nodeToWebflow, normalizeListChildren, type WebflowEmitContext } from './nodeToWebflow';
|
|
6
|
+
import type { WebflowStyleClass, WebflowComponentDef, WebflowElement } from './types';
|
|
7
|
+
import type { ComponentDefinition } from '../../shared/types/components';
|
|
8
|
+
import type { ComponentNode } from '../../shared/types';
|
|
9
|
+
import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
|
|
10
|
+
import { DEFAULT_RESPONSIVE_SCALES, type ResponsiveScales } from '../../shared/responsiveScaling';
|
|
11
|
+
import { setProjectRoot, getProjectRoot } from '../projectContext';
|
|
12
|
+
|
|
13
|
+
const layoutDef: ComponentDefinition = {
|
|
14
|
+
component: {
|
|
15
|
+
structure: {
|
|
16
|
+
type: 'node',
|
|
17
|
+
tag: 'div',
|
|
18
|
+
children: [{ type: 'slot' }],
|
|
19
|
+
} as ComponentNode,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function makeCtx(
|
|
24
|
+
fileName: string,
|
|
25
|
+
styleClasses: Map<string, WebflowStyleClass>,
|
|
26
|
+
overrides?: Partial<WebflowEmitContext>
|
|
27
|
+
): WebflowEmitContext {
|
|
28
|
+
return {
|
|
29
|
+
globalComponents: { Layout: layoutDef },
|
|
30
|
+
elementPath: [0],
|
|
31
|
+
fileType: 'page',
|
|
32
|
+
fileName,
|
|
33
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
34
|
+
styleClasses,
|
|
35
|
+
comboIdentityByName: new Map(),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SCALES_ON: ResponsiveScales = { ...DEFAULT_RESPONSIVE_SCALES, enabled: true };
|
|
41
|
+
|
|
42
|
+
function pageRoot(firstChild: ComponentNode): ComponentNode {
|
|
43
|
+
return {
|
|
44
|
+
type: 'component',
|
|
45
|
+
component: 'Layout',
|
|
46
|
+
children: [firstChild],
|
|
47
|
+
} as ComponentNode;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('nodeToWebflow — heading margin defaults', () => {
|
|
51
|
+
test('h1 with no margin gets margin-top: 0 and margin-bottom: 0', async () => {
|
|
52
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
53
|
+
const els = await nodeToWebflow(
|
|
54
|
+
{ type: 'node', tag: 'h1', children: 'Hi' } as ComponentNode,
|
|
55
|
+
makeCtx('index', styleClasses)
|
|
56
|
+
);
|
|
57
|
+
const h1 = els[0]! as { className?: string };
|
|
58
|
+
expect(h1.className).toBeTruthy();
|
|
59
|
+
const cls = styleClasses.get(h1.className!);
|
|
60
|
+
expect(cls?.base['margin-top']).toBe('0px');
|
|
61
|
+
expect(cls?.base['margin-bottom']).toBe('0px');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('preserves explicit margin-bottom and only fills missing margin-top', async () => {
|
|
65
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
66
|
+
const els = await nodeToWebflow(
|
|
67
|
+
{
|
|
68
|
+
type: 'node',
|
|
69
|
+
tag: 'h2',
|
|
70
|
+
style: { base: { marginBottom: '12px' } },
|
|
71
|
+
children: 'Hi',
|
|
72
|
+
} as ComponentNode,
|
|
73
|
+
makeCtx('index', styleClasses)
|
|
74
|
+
);
|
|
75
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
76
|
+
expect(cls?.base['margin-bottom']).toBe('12px');
|
|
77
|
+
expect(cls?.base['margin-top']).toBe('0px');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('expands margin shorthand into per-side longhands', async () => {
|
|
81
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
82
|
+
const els = await nodeToWebflow(
|
|
83
|
+
{
|
|
84
|
+
type: 'node',
|
|
85
|
+
tag: 'h3',
|
|
86
|
+
style: { base: { margin: '24px auto' } },
|
|
87
|
+
children: 'Hi',
|
|
88
|
+
} as ComponentNode,
|
|
89
|
+
makeCtx('index', styleClasses)
|
|
90
|
+
);
|
|
91
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
92
|
+
expect(cls?.base.margin).toBeUndefined();
|
|
93
|
+
expect(cls?.base['margin-top']).toBe('24px');
|
|
94
|
+
expect(cls?.base['margin-right']).toBe('auto');
|
|
95
|
+
expect(cls?.base['margin-bottom']).toBe('24px');
|
|
96
|
+
expect(cls?.base['margin-left']).toBe('auto');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('other tags (e.g. <div>) are not modified', async () => {
|
|
100
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
101
|
+
const els = await nodeToWebflow(
|
|
102
|
+
{ type: 'node', tag: 'div', style: { base: { color: 'red' } }, children: 'Hi' } as ComponentNode,
|
|
103
|
+
makeCtx('index', styleClasses)
|
|
104
|
+
);
|
|
105
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
106
|
+
expect(cls?.base['margin-top']).toBeUndefined();
|
|
107
|
+
expect(cls?.base['margin-bottom']).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('nodeToWebflow — paragraph margin defaults', () => {
|
|
112
|
+
test('<p> with no margin gets margin-bottom: 0 and leaves margin-top alone', async () => {
|
|
113
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
114
|
+
const els = await nodeToWebflow(
|
|
115
|
+
{ type: 'node', tag: 'p', children: 'Hi' } as ComponentNode,
|
|
116
|
+
makeCtx('index', styleClasses)
|
|
117
|
+
);
|
|
118
|
+
const p = els[0]! as { className?: string };
|
|
119
|
+
expect(p.className).toBeTruthy();
|
|
120
|
+
const cls = styleClasses.get(p.className!);
|
|
121
|
+
expect(cls?.base['margin-bottom']).toBe('0px');
|
|
122
|
+
expect(cls?.base['margin-top']).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('<p> preserves explicit margin-bottom', async () => {
|
|
126
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
127
|
+
const els = await nodeToWebflow(
|
|
128
|
+
{
|
|
129
|
+
type: 'node',
|
|
130
|
+
tag: 'p',
|
|
131
|
+
style: { base: { marginBottom: '12px' } },
|
|
132
|
+
children: 'Hi',
|
|
133
|
+
} as ComponentNode,
|
|
134
|
+
makeCtx('index', styleClasses)
|
|
135
|
+
);
|
|
136
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
137
|
+
expect(cls?.base['margin-bottom']).toBe('12px');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('<p> margin shorthand expands into per-side longhands', async () => {
|
|
141
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
142
|
+
const els = await nodeToWebflow(
|
|
143
|
+
{
|
|
144
|
+
type: 'node',
|
|
145
|
+
tag: 'p',
|
|
146
|
+
style: { base: { margin: '24px auto' } },
|
|
147
|
+
children: 'Hi',
|
|
148
|
+
} as ComponentNode,
|
|
149
|
+
makeCtx('index', styleClasses)
|
|
150
|
+
);
|
|
151
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
152
|
+
expect(cls?.base.margin).toBeUndefined();
|
|
153
|
+
expect(cls?.base['margin-top']).toBe('24px');
|
|
154
|
+
expect(cls?.base['margin-right']).toBe('auto');
|
|
155
|
+
expect(cls?.base['margin-bottom']).toBe('24px');
|
|
156
|
+
expect(cls?.base['margin-left']).toBe('auto');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('nodeToWebflow — list margin defaults', () => {
|
|
161
|
+
test('<ul> with no margin gets margin-bottom: 0', async () => {
|
|
162
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
163
|
+
const els = await nodeToWebflow(
|
|
164
|
+
{ type: 'node', tag: 'ul', children: [{ type: 'node', tag: 'li', children: 'x' }] } as ComponentNode,
|
|
165
|
+
makeCtx('index', styleClasses)
|
|
166
|
+
);
|
|
167
|
+
const ul = els[0]! as { className?: string };
|
|
168
|
+
expect(ul.className).toBeTruthy();
|
|
169
|
+
const cls = styleClasses.get(ul.className!);
|
|
170
|
+
expect(cls?.base['margin-bottom']).toBe('0px');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('<ol> with no margin gets margin-bottom: 0', async () => {
|
|
174
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
175
|
+
const els = await nodeToWebflow(
|
|
176
|
+
{ type: 'node', tag: 'ol', children: [{ type: 'node', tag: 'li', children: 'x' }] } as ComponentNode,
|
|
177
|
+
makeCtx('index', styleClasses)
|
|
178
|
+
);
|
|
179
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
180
|
+
expect(cls?.base['margin-bottom']).toBe('0px');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('<ul> preserves explicit margin-bottom', async () => {
|
|
184
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
185
|
+
const els = await nodeToWebflow(
|
|
186
|
+
{
|
|
187
|
+
type: 'node',
|
|
188
|
+
tag: 'ul',
|
|
189
|
+
style: { base: { marginBottom: '20px' } },
|
|
190
|
+
children: [{ type: 'node', tag: 'li', children: 'x' }],
|
|
191
|
+
} as ComponentNode,
|
|
192
|
+
makeCtx('index', styleClasses)
|
|
193
|
+
);
|
|
194
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
195
|
+
expect(cls?.base['margin-bottom']).toBe('20px');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('nodeToWebflow — grid rows default', () => {
|
|
200
|
+
test('display: grid with no grid-template-rows gets 1fr', async () => {
|
|
201
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
202
|
+
const els = await nodeToWebflow(
|
|
203
|
+
{
|
|
204
|
+
type: 'node',
|
|
205
|
+
tag: 'div',
|
|
206
|
+
style: { base: { display: 'grid', gridTemplateColumns: '1fr 1fr' } },
|
|
207
|
+
children: 'x',
|
|
208
|
+
} as ComponentNode,
|
|
209
|
+
makeCtx('index', styleClasses)
|
|
210
|
+
);
|
|
211
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
212
|
+
expect(cls?.base['grid-template-rows']).toBe('1fr');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('display: inline-grid is also defaulted', async () => {
|
|
216
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
217
|
+
const els = await nodeToWebflow(
|
|
218
|
+
{
|
|
219
|
+
type: 'node',
|
|
220
|
+
tag: 'div',
|
|
221
|
+
style: { base: { display: 'inline-grid' } },
|
|
222
|
+
children: 'x',
|
|
223
|
+
} as ComponentNode,
|
|
224
|
+
makeCtx('index', styleClasses)
|
|
225
|
+
);
|
|
226
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
227
|
+
expect(cls?.base['grid-template-rows']).toBe('1fr');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('explicit grid-template-rows is preserved', async () => {
|
|
231
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
232
|
+
const els = await nodeToWebflow(
|
|
233
|
+
{
|
|
234
|
+
type: 'node',
|
|
235
|
+
tag: 'div',
|
|
236
|
+
style: { base: { display: 'grid', gridTemplateRows: 'auto auto' } },
|
|
237
|
+
children: 'x',
|
|
238
|
+
} as ComponentNode,
|
|
239
|
+
makeCtx('index', styleClasses)
|
|
240
|
+
);
|
|
241
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
242
|
+
expect(cls?.base['grid-template-rows']).toBe('auto auto');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('grid shorthand opts out of default', async () => {
|
|
246
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
247
|
+
const els = await nodeToWebflow(
|
|
248
|
+
{
|
|
249
|
+
type: 'node',
|
|
250
|
+
tag: 'div',
|
|
251
|
+
style: { base: { display: 'grid', grid: 'auto-flow / 1fr 1fr' } },
|
|
252
|
+
children: 'x',
|
|
253
|
+
} as ComponentNode,
|
|
254
|
+
makeCtx('index', styleClasses)
|
|
255
|
+
);
|
|
256
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
257
|
+
expect(cls?.base['grid-template-rows']).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('non-grid display does not get grid-template-rows', async () => {
|
|
261
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
262
|
+
const els = await nodeToWebflow(
|
|
263
|
+
{
|
|
264
|
+
type: 'node',
|
|
265
|
+
tag: 'div',
|
|
266
|
+
style: { base: { display: 'flex' } },
|
|
267
|
+
children: 'x',
|
|
268
|
+
} as ComponentNode,
|
|
269
|
+
makeCtx('index', styleClasses)
|
|
270
|
+
);
|
|
271
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
272
|
+
expect(cls?.base['grid-template-rows']).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('nodeToWebflow — link defaults', () => {
|
|
277
|
+
test('unstyled <a> still gets a class with text-decoration: none', async () => {
|
|
278
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
279
|
+
const els = await nodeToWebflow(
|
|
280
|
+
{ type: 'link', href: '/about', children: 'About' } as ComponentNode,
|
|
281
|
+
makeCtx('index', styleClasses)
|
|
282
|
+
);
|
|
283
|
+
const link = els[0] as any;
|
|
284
|
+
expect(link.tag).toBe('a');
|
|
285
|
+
expect(link.className).toBeTruthy();
|
|
286
|
+
const cls = styleClasses.get(link.className!);
|
|
287
|
+
expect(cls?.base['text-decoration']).toBe('none');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('unstyled <button> gets text-decoration: none (it is rendered via LinkBlock)', async () => {
|
|
291
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
292
|
+
const els = await nodeToWebflow(
|
|
293
|
+
{ type: 'node', tag: 'button', children: 'Toggle' } as ComponentNode,
|
|
294
|
+
makeCtx('index', styleClasses)
|
|
295
|
+
);
|
|
296
|
+
const btn = els[0] as any;
|
|
297
|
+
expect(btn.tag).toBe('button');
|
|
298
|
+
expect(btn.className).toBeTruthy();
|
|
299
|
+
const cls = styleClasses.get(btn.className!);
|
|
300
|
+
expect(cls?.base['text-decoration']).toBe('none');
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('explicit text-decoration on <a> is preserved', async () => {
|
|
304
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
305
|
+
const els = await nodeToWebflow(
|
|
306
|
+
{
|
|
307
|
+
type: 'link',
|
|
308
|
+
href: '#',
|
|
309
|
+
style: { base: { textDecoration: 'underline' } },
|
|
310
|
+
children: 'Click',
|
|
311
|
+
} as ComponentNode,
|
|
312
|
+
makeCtx('index', styleClasses)
|
|
313
|
+
);
|
|
314
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
315
|
+
expect(cls?.base['text-decoration']).toBe('underline');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('nodeToWebflow — link href rewriting', () => {
|
|
320
|
+
const i18nConfig = {
|
|
321
|
+
defaultLocale: 'en',
|
|
322
|
+
locales: [
|
|
323
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
|
|
324
|
+
{ code: 'fr', name: 'French', nativeName: 'Français', langTag: 'fr-FR' },
|
|
325
|
+
],
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const slugMappings = [
|
|
329
|
+
{ pageId: 'about', slugs: { en: 'about', fr: 'à-propos' } },
|
|
330
|
+
{ pageId: 'index', slugs: { en: '', fr: '' } },
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
function linkCtx(locale: string) {
|
|
334
|
+
return makeCtx('about', new Map<string, WebflowStyleClass>(), {
|
|
335
|
+
i18nConfig,
|
|
336
|
+
locale,
|
|
337
|
+
slugMappings,
|
|
338
|
+
pagePath: locale === 'en' ? '/about' : `/${locale}/à-propos`,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function emitHref(node: ComponentNode, locale: string): Promise<string> {
|
|
343
|
+
const els = await nodeToWebflow(node, linkCtx(locale));
|
|
344
|
+
return (els[0] as any).attributes.href as string;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
test('external https URL passes through unchanged', async () => {
|
|
348
|
+
const href = await emitHref(
|
|
349
|
+
{ type: 'link', href: 'https://example.com/x', children: 'x' } as ComponentNode,
|
|
350
|
+
'fr'
|
|
351
|
+
);
|
|
352
|
+
expect(href).toBe('https://example.com/x');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test('protocol-relative URL passes through unchanged', async () => {
|
|
356
|
+
const href = await emitHref(
|
|
357
|
+
{ type: 'link', href: '//cdn.example.com/a.js', children: 'x' } as ComponentNode,
|
|
358
|
+
'fr'
|
|
359
|
+
);
|
|
360
|
+
expect(href).toBe('//cdn.example.com/a.js');
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test('mailto/tel/anchor pass through unchanged', async () => {
|
|
364
|
+
expect(await emitHref(
|
|
365
|
+
{ type: 'link', href: 'mailto:hi@example.com', children: 'x' } as ComponentNode, 'fr'
|
|
366
|
+
)).toBe('mailto:hi@example.com');
|
|
367
|
+
expect(await emitHref(
|
|
368
|
+
{ type: 'link', href: 'tel:+15551234', children: 'x' } as ComponentNode, 'fr'
|
|
369
|
+
)).toBe('tel:+15551234');
|
|
370
|
+
expect(await emitHref(
|
|
371
|
+
{ type: 'link', href: '#section', children: 'x' } as ComponentNode, 'fr'
|
|
372
|
+
)).toBe('#section');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test('internal /about translates to localized slug for non-default locale', async () => {
|
|
376
|
+
const href = await emitHref(
|
|
377
|
+
{ type: 'link', href: '/about', children: 'About' } as ComponentNode,
|
|
378
|
+
'fr'
|
|
379
|
+
);
|
|
380
|
+
expect(href).toBe('/fr/à-propos');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('internal /about stays canonical on default-locale page', async () => {
|
|
384
|
+
const href = await emitHref(
|
|
385
|
+
{ type: 'link', href: '/about', children: 'About' } as ComponentNode,
|
|
386
|
+
'en'
|
|
387
|
+
);
|
|
388
|
+
expect(href).toBe('/about');
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('internal href with #fragment preserves fragment after rewrite', async () => {
|
|
392
|
+
const href = await emitHref(
|
|
393
|
+
{ type: 'link', href: '/about#team', children: 'Team' } as ComponentNode,
|
|
394
|
+
'fr'
|
|
395
|
+
);
|
|
396
|
+
expect(href).toBe('/fr/à-propos#team');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test('internal href with ?query preserves query after rewrite', async () => {
|
|
400
|
+
const href = await emitHref(
|
|
401
|
+
{ type: 'link', href: '/about?ref=hero', children: 'About' } as ComponentNode,
|
|
402
|
+
'fr'
|
|
403
|
+
);
|
|
404
|
+
expect(href).toBe('/fr/à-propos?ref=hero');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('unknown internal path passes through silently with locale prefix', async () => {
|
|
408
|
+
// /missing has no slugMapping entry — translatePath falls back to
|
|
409
|
+
// applying the locale prefix to the same slug. No warnings emitted.
|
|
410
|
+
const href = await emitHref(
|
|
411
|
+
{ type: 'link', href: '/missing', children: 'x' } as ComponentNode,
|
|
412
|
+
'fr'
|
|
413
|
+
);
|
|
414
|
+
expect(href).toBe('/fr/missing');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test('without i18n context, internal hrefs pass through unchanged', async () => {
|
|
418
|
+
const els = await nodeToWebflow(
|
|
419
|
+
{ type: 'link', href: '/about', children: 'About' } as ComponentNode,
|
|
420
|
+
makeCtx('index', new Map<string, WebflowStyleClass>())
|
|
421
|
+
);
|
|
422
|
+
expect((els[0] as any).attributes.href).toBe('/about');
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe('nodeToWebflow — bare color tokens resolve to theme hex', () => {
|
|
427
|
+
test('button with bare color token gets wrapped and resolved against themeVars', async () => {
|
|
428
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
429
|
+
const els = await nodeToWebflow(
|
|
430
|
+
{
|
|
431
|
+
type: 'node',
|
|
432
|
+
tag: 'button',
|
|
433
|
+
style: { base: { backgroundColor: 'text', color: 'background' } },
|
|
434
|
+
children: 'Submit',
|
|
435
|
+
} as ComponentNode,
|
|
436
|
+
makeCtx('index', styleClasses, {
|
|
437
|
+
themeVars: { light: { '--text': '#050505', '--background': '#ffffff' } },
|
|
438
|
+
defaultTheme: 'light',
|
|
439
|
+
})
|
|
440
|
+
);
|
|
441
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
442
|
+
expect(cls?.base['background-color']).toBe('#050505');
|
|
443
|
+
expect(cls?.base.color).toBe('#ffffff');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('link with var(--token) resolves to theme hex', async () => {
|
|
447
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
448
|
+
const els = await nodeToWebflow(
|
|
449
|
+
{
|
|
450
|
+
type: 'link',
|
|
451
|
+
href: '/about',
|
|
452
|
+
style: { base: { color: 'var(--text)' } },
|
|
453
|
+
children: 'About',
|
|
454
|
+
} as ComponentNode,
|
|
455
|
+
makeCtx('index', styleClasses, {
|
|
456
|
+
themeVars: { light: { '--text': '#050505' } },
|
|
457
|
+
defaultTheme: 'light',
|
|
458
|
+
})
|
|
459
|
+
);
|
|
460
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
461
|
+
expect(cls?.base.color).toBe('#050505');
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('unknown currentTheme falls back to defaultTheme so var(--text) still resolves', async () => {
|
|
465
|
+
// Mirrors a project whose colors.json defines `light/muted/dark` but a
|
|
466
|
+
// component's interface defaults `theme: "primary"`. At runtime
|
|
467
|
+
// `[theme="primary"]` has no rule, so the browser inherits `:root`'s
|
|
468
|
+
// default-theme vars; the exporter must do the same instead of leaving
|
|
469
|
+
// `var(--text)` literal.
|
|
470
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
471
|
+
const els = await nodeToWebflow(
|
|
472
|
+
{
|
|
473
|
+
type: 'node',
|
|
474
|
+
tag: 'div',
|
|
475
|
+
attributes: { theme: 'primary' },
|
|
476
|
+
style: { base: { backgroundColor: 'var(--text)' } },
|
|
477
|
+
} as ComponentNode,
|
|
478
|
+
makeCtx('index', styleClasses, {
|
|
479
|
+
themeVars: { light: { '--text': '#050505' } },
|
|
480
|
+
defaultTheme: 'light',
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
484
|
+
expect(cls?.base['background-color']).toBe('#050505');
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
test('CSS-named colors are not wrapped (transparent stays transparent)', async () => {
|
|
488
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
489
|
+
const els = await nodeToWebflow(
|
|
490
|
+
{
|
|
491
|
+
type: 'node',
|
|
492
|
+
tag: 'div',
|
|
493
|
+
style: { base: { backgroundColor: 'transparent' } },
|
|
494
|
+
} as ComponentNode,
|
|
495
|
+
makeCtx('index', styleClasses, {
|
|
496
|
+
themeVars: { light: { '--transparent': '#ff0000' } },
|
|
497
|
+
defaultTheme: 'light',
|
|
498
|
+
})
|
|
499
|
+
);
|
|
500
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
501
|
+
expect(cls?.base['background-color']).toBe('transparent');
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
describe('nodeToWebflow — image inlining for relative srcs', () => {
|
|
506
|
+
const PNG_1X1 = Buffer.from(
|
|
507
|
+
'89504e470d0a1a0a0000000d49484452000000010000000108020000009077' +
|
|
508
|
+
'53de0000000c4944415478da6364f8ffff3f0005fe02fea636e1a30000000049454e44ae426082',
|
|
509
|
+
'hex'
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
let prevRoot: string;
|
|
513
|
+
let tmpDir: string;
|
|
514
|
+
|
|
515
|
+
function setupProject(): void {
|
|
516
|
+
prevRoot = getProjectRoot();
|
|
517
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'meno-webflow-test-'));
|
|
518
|
+
mkdirSync(join(tmpDir, 'images'), { recursive: true });
|
|
519
|
+
writeFileSync(join(tmpDir, 'images', 'foo.png'), PNG_1X1);
|
|
520
|
+
setProjectRoot(tmpDir);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function teardownProject(): void {
|
|
524
|
+
setProjectRoot(prevRoot);
|
|
525
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
test('relative <img> src reads file and stashes base64 bytes', async () => {
|
|
529
|
+
setupProject();
|
|
530
|
+
try {
|
|
531
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
532
|
+
const els = await nodeToWebflow(
|
|
533
|
+
{
|
|
534
|
+
type: 'node',
|
|
535
|
+
tag: 'img',
|
|
536
|
+
attributes: { src: '/images/foo.png', alt: 'Foo' },
|
|
537
|
+
} as ComponentNode,
|
|
538
|
+
makeCtx('index', styleClasses)
|
|
539
|
+
);
|
|
540
|
+
const img = els[0] as any;
|
|
541
|
+
expect(img.tag).toBe('img');
|
|
542
|
+
expect(img.attributes?.src).toBe('/images/foo.png');
|
|
543
|
+
expect(img.imageDataMime).toBe('image/png');
|
|
544
|
+
expect(img.imageDataFileName).toBe('foo.png');
|
|
545
|
+
const decoded = Buffer.from(img.imageDataBase64!, 'base64');
|
|
546
|
+
expect(decoded.equals(PNG_1X1)).toBe(true);
|
|
547
|
+
} finally {
|
|
548
|
+
teardownProject();
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('absolute https <img> src is left alone (no inlining)', async () => {
|
|
553
|
+
setupProject();
|
|
554
|
+
try {
|
|
555
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
556
|
+
const els = await nodeToWebflow(
|
|
557
|
+
{
|
|
558
|
+
type: 'node',
|
|
559
|
+
tag: 'img',
|
|
560
|
+
attributes: { src: 'https://example.com/x.png' },
|
|
561
|
+
} as ComponentNode,
|
|
562
|
+
makeCtx('index', styleClasses)
|
|
563
|
+
);
|
|
564
|
+
const img = els[0] as any;
|
|
565
|
+
expect(img.attributes?.src).toBe('https://example.com/x.png');
|
|
566
|
+
expect(img.imageDataBase64).toBeUndefined();
|
|
567
|
+
expect(img.imageDataMime).toBeUndefined();
|
|
568
|
+
} finally {
|
|
569
|
+
teardownProject();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test('missing local file leaves attributes.src alone', async () => {
|
|
574
|
+
setupProject();
|
|
575
|
+
try {
|
|
576
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
577
|
+
const els = await nodeToWebflow(
|
|
578
|
+
{
|
|
579
|
+
type: 'node',
|
|
580
|
+
tag: 'img',
|
|
581
|
+
attributes: { src: '/images/missing.webp' },
|
|
582
|
+
} as ComponentNode,
|
|
583
|
+
makeCtx('index', styleClasses)
|
|
584
|
+
);
|
|
585
|
+
const img = els[0] as any;
|
|
586
|
+
expect(img.attributes?.src).toBe('/images/missing.webp');
|
|
587
|
+
expect(img.imageDataBase64).toBeUndefined();
|
|
588
|
+
} finally {
|
|
589
|
+
teardownProject();
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
describe('nodeToWebflow — i18n rich-text props through nested {{template}}', () => {
|
|
595
|
+
// Reproduces the Activy hero bug: a section component receives an i18n
|
|
596
|
+
// rich-text prop, then forwards it via `{{title}}` into a nested Heading
|
|
597
|
+
// (which renders as `<h{{size}}>{{text}}</h{{size}}>`). The nested
|
|
598
|
+
// Heading must end up with the flattened locale string as `textContent`,
|
|
599
|
+
// not an empty element.
|
|
600
|
+
const i18nConfig = {
|
|
601
|
+
defaultLocale: 'en',
|
|
602
|
+
locales: [
|
|
603
|
+
{ code: 'en', name: 'English', nativeName: 'English', langTag: 'en-US' },
|
|
604
|
+
{ code: 'pl', name: 'Polish', nativeName: 'Polski', langTag: 'pl-PL' },
|
|
605
|
+
],
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const heading: ComponentDefinition = {
|
|
609
|
+
component: {
|
|
610
|
+
structure: {
|
|
611
|
+
type: 'node',
|
|
612
|
+
tag: 'h{{size}}',
|
|
613
|
+
children: ['{{text}}'],
|
|
614
|
+
} as ComponentNode,
|
|
615
|
+
interface: {
|
|
616
|
+
text: { type: 'rich-text', default: 'Heading' },
|
|
617
|
+
size: { type: 'string', default: '1' },
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const paragraph: ComponentDefinition = {
|
|
623
|
+
component: {
|
|
624
|
+
structure: {
|
|
625
|
+
type: 'node',
|
|
626
|
+
tag: 'p',
|
|
627
|
+
children: ['{{text}}'],
|
|
628
|
+
} as ComponentNode,
|
|
629
|
+
interface: {
|
|
630
|
+
text: { type: 'rich-text', default: 'Text' },
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const hero: ComponentDefinition = {
|
|
636
|
+
component: {
|
|
637
|
+
structure: {
|
|
638
|
+
type: 'node',
|
|
639
|
+
tag: 'section',
|
|
640
|
+
children: [
|
|
641
|
+
{
|
|
642
|
+
type: 'component',
|
|
643
|
+
component: 'Heading',
|
|
644
|
+
props: { text: '{{title}}', size: '1' },
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
type: 'component',
|
|
648
|
+
component: 'Paragraph',
|
|
649
|
+
props: { text: '{{text}}' },
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
} as ComponentNode,
|
|
653
|
+
interface: {
|
|
654
|
+
title: { type: 'rich-text', default: 'Title' },
|
|
655
|
+
text: { type: 'rich-text', default: 'Body' },
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// Slot host: a `<Stack>` whose body contains a single `<slot/>`. Mirrors
|
|
661
|
+
// the activy `Stack` / `Section` / `SplitContent` components — none of them
|
|
662
|
+
// declare a `title` or `text` prop. Without slotInstanceProps, a Heading
|
|
663
|
+
// placed inside Stack would look up `{{title}}` on Stack's resolved props
|
|
664
|
+
// and emit an empty element.
|
|
665
|
+
const stack: ComponentDefinition = {
|
|
666
|
+
component: {
|
|
667
|
+
structure: {
|
|
668
|
+
type: 'node',
|
|
669
|
+
tag: 'div',
|
|
670
|
+
children: [{ type: 'slot' }],
|
|
671
|
+
} as ComponentNode,
|
|
672
|
+
interface: {},
|
|
673
|
+
},
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Hero variant: nests its slotted children INSIDE a Stack instance to
|
|
677
|
+
// reproduce the real Activy structure (`Section > SplitContent > Stack >
|
|
678
|
+
// Heading`). Templates inside Heading must still resolve against Hero's
|
|
679
|
+
// props, not Stack's.
|
|
680
|
+
const heroWithSlotHost: ComponentDefinition = {
|
|
681
|
+
component: {
|
|
682
|
+
structure: {
|
|
683
|
+
type: 'node',
|
|
684
|
+
tag: 'section',
|
|
685
|
+
children: [
|
|
686
|
+
{
|
|
687
|
+
type: 'component',
|
|
688
|
+
component: 'Stack',
|
|
689
|
+
children: [
|
|
690
|
+
{
|
|
691
|
+
type: 'component',
|
|
692
|
+
component: 'Heading',
|
|
693
|
+
props: { text: '{{title}}', size: '1' },
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
type: 'component',
|
|
697
|
+
component: 'Paragraph',
|
|
698
|
+
props: { text: '{{text}}' },
|
|
699
|
+
},
|
|
700
|
+
],
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
} as ComponentNode,
|
|
704
|
+
interface: {
|
|
705
|
+
title: { type: 'rich-text', default: 'Title' },
|
|
706
|
+
text: { type: 'rich-text', default: 'Body' },
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
test('i18n rich-text title piped through {{title}} survives as flattened textContent', async () => {
|
|
712
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
713
|
+
const heroInstance: ComponentNode = {
|
|
714
|
+
type: 'component',
|
|
715
|
+
component: 'Hero',
|
|
716
|
+
props: {
|
|
717
|
+
title: {
|
|
718
|
+
_i18n: true,
|
|
719
|
+
en: 'Boost your team\'s <span class="span">health</span> and <span class="span is-orange">culture</span>',
|
|
720
|
+
pl: 'Wzmocnij <span class="span">zdrowie</span> i <span class="span is-orange">kulturę</span> twojego zespołu',
|
|
721
|
+
},
|
|
722
|
+
text: {
|
|
723
|
+
_i18n: true,
|
|
724
|
+
en: 'Increase employee engagement and productivity through personalized sports challenges.',
|
|
725
|
+
pl: 'Zwiększ zaangażowanie i wydajność pracowników dzięki spersonalizowanym wyzwaniom sportowym.',
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
} as ComponentNode;
|
|
729
|
+
|
|
730
|
+
const els = await nodeToWebflow(
|
|
731
|
+
pageRoot(heroInstance),
|
|
732
|
+
makeCtx('index', styleClasses, {
|
|
733
|
+
globalComponents: { Layout: layoutDef, Hero: hero, Heading: heading, Paragraph: paragraph },
|
|
734
|
+
i18nConfig,
|
|
735
|
+
locale: 'en',
|
|
736
|
+
})
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
// Layout wraps Hero (section) which wraps Heading (h1) and Paragraph (p).
|
|
740
|
+
const layoutEl = els[0]! as WebflowElement;
|
|
741
|
+
const sectionEl = layoutEl.children![0] as WebflowElement;
|
|
742
|
+
const h1 = sectionEl.children![0] as WebflowElement;
|
|
743
|
+
const p = sectionEl.children![1] as WebflowElement;
|
|
744
|
+
|
|
745
|
+
expect(h1.tag).toBe('h1');
|
|
746
|
+
expect(h1.textContent).toBe("Boost your team's health and culture");
|
|
747
|
+
expect(p.tag).toBe('p');
|
|
748
|
+
expect(p.textContent).toBe('Increase employee engagement and productivity through personalized sports challenges.');
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test('i18n plain string title piped through {{title}} survives as textContent', async () => {
|
|
752
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
753
|
+
const heroInstance: ComponentNode = {
|
|
754
|
+
type: 'component',
|
|
755
|
+
component: 'Hero',
|
|
756
|
+
props: {
|
|
757
|
+
title: { _i18n: true, en: 'Plain English title', pl: 'Polski tytuł' },
|
|
758
|
+
text: { _i18n: true, en: 'Plain English body', pl: 'Polski tekst' },
|
|
759
|
+
},
|
|
760
|
+
} as ComponentNode;
|
|
761
|
+
|
|
762
|
+
const els = await nodeToWebflow(
|
|
763
|
+
pageRoot(heroInstance),
|
|
764
|
+
makeCtx('index', styleClasses, {
|
|
765
|
+
globalComponents: { Layout: layoutDef, Hero: hero, Heading: heading, Paragraph: paragraph },
|
|
766
|
+
i18nConfig,
|
|
767
|
+
locale: 'pl',
|
|
768
|
+
})
|
|
769
|
+
);
|
|
770
|
+
|
|
771
|
+
const layoutEl = els[0]! as WebflowElement;
|
|
772
|
+
const sectionEl = layoutEl.children![0] as WebflowElement;
|
|
773
|
+
const h1 = sectionEl.children![0] as WebflowElement;
|
|
774
|
+
const p = sectionEl.children![1] as WebflowElement;
|
|
775
|
+
|
|
776
|
+
expect(h1.textContent).toBe('Polski tytuł');
|
|
777
|
+
expect(p.textContent).toBe('Polski tekst');
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test('Heading nested inside a Stack <slot/> still resolves {{title}} from Hero', async () => {
|
|
781
|
+
// Reproduces the real Activy bug: `Hero > Section > SplitContent > Stack
|
|
782
|
+
// > Heading`. Without `slotInstanceProps` on the emit context, the
|
|
783
|
+
// Heading's `{{title}}` template would resolve against Stack's resolved
|
|
784
|
+
// props (which has no `title`) and emit an empty <h1>.
|
|
785
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
786
|
+
const heroInstance: ComponentNode = {
|
|
787
|
+
type: 'component',
|
|
788
|
+
component: 'Hero',
|
|
789
|
+
props: {
|
|
790
|
+
title: {
|
|
791
|
+
_i18n: true,
|
|
792
|
+
en: 'Boost your team\'s <span class="span">health</span> and <span class="span is-orange">culture</span>',
|
|
793
|
+
pl: 'Wzmocnij zdrowie i kulturę',
|
|
794
|
+
},
|
|
795
|
+
text: { _i18n: true, en: 'Long English body.', pl: 'Polskie ciało.' },
|
|
796
|
+
},
|
|
797
|
+
} as ComponentNode;
|
|
798
|
+
|
|
799
|
+
const els = await nodeToWebflow(
|
|
800
|
+
pageRoot(heroInstance),
|
|
801
|
+
makeCtx('index', styleClasses, {
|
|
802
|
+
globalComponents: {
|
|
803
|
+
Layout: layoutDef,
|
|
804
|
+
Hero: heroWithSlotHost,
|
|
805
|
+
Stack: stack,
|
|
806
|
+
Heading: heading,
|
|
807
|
+
Paragraph: paragraph,
|
|
808
|
+
},
|
|
809
|
+
i18nConfig,
|
|
810
|
+
locale: 'en',
|
|
811
|
+
})
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
// Layout > section > stack-div > [h1, p]
|
|
815
|
+
const layoutEl = els[0]! as WebflowElement;
|
|
816
|
+
const sectionEl = layoutEl.children![0] as WebflowElement;
|
|
817
|
+
const stackDiv = sectionEl.children![0] as WebflowElement;
|
|
818
|
+
const h1 = stackDiv.children![0] as WebflowElement;
|
|
819
|
+
const p = stackDiv.children![1] as WebflowElement;
|
|
820
|
+
|
|
821
|
+
expect(h1.tag).toBe('h1');
|
|
822
|
+
expect(h1.textContent).toBe("Boost your team's health and culture");
|
|
823
|
+
expect(p.tag).toBe('p');
|
|
824
|
+
expect(p.textContent).toBe('Long English body.');
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test('img src="{{image}}" inside a Stack <slot/> resolves Hero.image and inlines bytes', async () => {
|
|
828
|
+
// Companion to the heading test: the SplitContent right-column `<Image>`
|
|
829
|
+
// (with `attributes.src = "{{image}}"`) is also nested inside a slot
|
|
830
|
+
// host. Without the fix, src resolves to "" and the Webflow extension
|
|
831
|
+
// emits an empty Image preset (the dashed-corner placeholder users see).
|
|
832
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
833
|
+
|
|
834
|
+
// Set up a project root with a fixture image so maybeInlineLocalImage
|
|
835
|
+
// can read it from disk.
|
|
836
|
+
const PNG_1X1 = Buffer.from(
|
|
837
|
+
'89504e470d0a1a0a0000000d49484452000000010000000108020000009077' +
|
|
838
|
+
'53de0000000c4944415478da6364f8ffff3f0005fe02fea636e1a30000000049454e44ae426082',
|
|
839
|
+
'hex'
|
|
840
|
+
);
|
|
841
|
+
const prevRoot = getProjectRoot();
|
|
842
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'meno-webflow-slot-test-'));
|
|
843
|
+
mkdirSync(join(tmpDir, 'images'), { recursive: true });
|
|
844
|
+
writeFileSync(join(tmpDir, 'images', 'hero.png'), PNG_1X1);
|
|
845
|
+
setProjectRoot(tmpDir);
|
|
846
|
+
|
|
847
|
+
try {
|
|
848
|
+
const heroWithImage: ComponentDefinition = {
|
|
849
|
+
component: {
|
|
850
|
+
structure: {
|
|
851
|
+
type: 'node',
|
|
852
|
+
tag: 'section',
|
|
853
|
+
children: [
|
|
854
|
+
{
|
|
855
|
+
type: 'component',
|
|
856
|
+
component: 'Stack',
|
|
857
|
+
children: [
|
|
858
|
+
{
|
|
859
|
+
type: 'node',
|
|
860
|
+
tag: 'img',
|
|
861
|
+
attributes: { src: '{{image}}', alt: 'Hero' },
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
},
|
|
865
|
+
],
|
|
866
|
+
} as ComponentNode,
|
|
867
|
+
interface: {
|
|
868
|
+
image: { type: 'file', default: '/images/placeholder.png' },
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const heroInstance: ComponentNode = {
|
|
874
|
+
type: 'component',
|
|
875
|
+
component: 'Hero',
|
|
876
|
+
props: { image: '/images/hero.png' },
|
|
877
|
+
} as ComponentNode;
|
|
878
|
+
|
|
879
|
+
const els = await nodeToWebflow(
|
|
880
|
+
pageRoot(heroInstance),
|
|
881
|
+
makeCtx('index', styleClasses, {
|
|
882
|
+
globalComponents: {
|
|
883
|
+
Layout: layoutDef,
|
|
884
|
+
Hero: heroWithImage,
|
|
885
|
+
Stack: stack,
|
|
886
|
+
},
|
|
887
|
+
i18nConfig,
|
|
888
|
+
locale: 'en',
|
|
889
|
+
})
|
|
890
|
+
);
|
|
891
|
+
|
|
892
|
+
const layoutEl = els[0]! as WebflowElement;
|
|
893
|
+
const sectionEl = layoutEl.children![0] as WebflowElement;
|
|
894
|
+
const stackDiv = sectionEl.children![0] as WebflowElement;
|
|
895
|
+
const img = stackDiv.children![0] as any;
|
|
896
|
+
|
|
897
|
+
expect(img.tag).toBe('img');
|
|
898
|
+
expect(img.attributes?.src).toBe('/images/hero.png');
|
|
899
|
+
expect(img.imageDataMime).toBe('image/png');
|
|
900
|
+
expect(img.imageDataFileName).toBe('hero.png');
|
|
901
|
+
const decoded = Buffer.from(img.imageDataBase64!, 'base64');
|
|
902
|
+
expect(decoded.equals(PNG_1X1)).toBe(true);
|
|
903
|
+
} finally {
|
|
904
|
+
setProjectRoot(prevRoot);
|
|
905
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
describe('nodeToWebflow — slot context restoration', () => {
|
|
911
|
+
test('slot children from different pages do not collide on class names', async () => {
|
|
912
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
913
|
+
|
|
914
|
+
const heroH1: ComponentNode = {
|
|
915
|
+
type: 'node',
|
|
916
|
+
tag: 'h1',
|
|
917
|
+
style: { base: { fontSize: '60px', fontWeight: '600' } },
|
|
918
|
+
children: 'Hero',
|
|
919
|
+
} as ComponentNode;
|
|
920
|
+
|
|
921
|
+
const subtitleSpan: ComponentNode = {
|
|
922
|
+
type: 'node',
|
|
923
|
+
tag: 'span',
|
|
924
|
+
style: { base: { fontSize: '14px', fontWeight: '700', textTransform: 'uppercase' } },
|
|
925
|
+
children: 'Subtitle',
|
|
926
|
+
} as ComponentNode;
|
|
927
|
+
|
|
928
|
+
const indexEls = await nodeToWebflow(pageRoot(heroH1), makeCtx('index', styleClasses));
|
|
929
|
+
const blogEls = await nodeToWebflow(pageRoot(subtitleSpan), makeCtx('blog', styleClasses));
|
|
930
|
+
|
|
931
|
+
const indexH1 = (indexEls[0]!.children![0] as any) as { tag: string; className?: string };
|
|
932
|
+
const blogSpan = (blogEls[0]!.children![0] as any) as { tag: string; className?: string };
|
|
933
|
+
|
|
934
|
+
expect(indexH1.tag).toBe('h1');
|
|
935
|
+
expect(blogSpan.tag).toBe('span');
|
|
936
|
+
expect(indexH1.className).toBeTruthy();
|
|
937
|
+
expect(blogSpan.className).toBeTruthy();
|
|
938
|
+
// Distinct keys → both styles survive in the shared Map.
|
|
939
|
+
expect(indexH1.className).not.toBe(blogSpan.className);
|
|
940
|
+
|
|
941
|
+
const h1Class = styleClasses.get(indexH1.className!);
|
|
942
|
+
const spanClass = styleClasses.get(blogSpan.className!);
|
|
943
|
+
expect(h1Class?.base['font-size']).toBe('60px');
|
|
944
|
+
expect(h1Class?.base['font-weight']).toBe('600');
|
|
945
|
+
expect(spanClass?.base['font-size']).toBe('14px');
|
|
946
|
+
expect(spanClass?.base['text-transform']).toBe('uppercase');
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test('slot child class name is derived from the page that supplied it', async () => {
|
|
950
|
+
const indexClasses = new Map<string, WebflowStyleClass>();
|
|
951
|
+
const blogClasses = new Map<string, WebflowStyleClass>();
|
|
952
|
+
|
|
953
|
+
const sameShape: ComponentNode = {
|
|
954
|
+
type: 'node',
|
|
955
|
+
tag: 'h1',
|
|
956
|
+
style: { base: { fontSize: '60px' } },
|
|
957
|
+
children: 'Hero',
|
|
958
|
+
} as ComponentNode;
|
|
959
|
+
|
|
960
|
+
const indexEls = await nodeToWebflow(pageRoot(sameShape), makeCtx('index', indexClasses));
|
|
961
|
+
const blogEls = await nodeToWebflow(pageRoot(sameShape), makeCtx('blog', blogClasses));
|
|
962
|
+
|
|
963
|
+
const indexH1 = (indexEls[0]!.children![0] as any) as { className?: string };
|
|
964
|
+
const blogH1 = (blogEls[0]!.children![0] as any) as { className?: string };
|
|
965
|
+
|
|
966
|
+
// Same shape under different pages → page name flows into the class name.
|
|
967
|
+
expect(indexH1.className).toContain('index');
|
|
968
|
+
expect(blogH1.className).toContain('blog');
|
|
969
|
+
expect(indexH1.className).not.toBe(blogH1.className);
|
|
970
|
+
});
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
describe('nodeToWebflow — slot forwarding through wrapper components', () => {
|
|
974
|
+
// Mirrors the real Asseco bug: HeaderSimpleCentered's body places `<Section>`
|
|
975
|
+
// (a wrapper component) whose own structure delegates to a `<Container>` with
|
|
976
|
+
// `children: [{type:'slot'}]`. The inner slot marker references Section's
|
|
977
|
+
// outer slot, NOT Container's — so it must be substituted with Section's
|
|
978
|
+
// instance children before Container is expanded. Without forwarding, the
|
|
979
|
+
// `[Stack]` slot content gets lost when it crosses the Container wrapper.
|
|
980
|
+
const containerForwarder: ComponentDefinition = {
|
|
981
|
+
component: {
|
|
982
|
+
structure: {
|
|
983
|
+
type: 'node',
|
|
984
|
+
tag: 'div',
|
|
985
|
+
label: 'Container',
|
|
986
|
+
children: [{ type: 'slot' }],
|
|
987
|
+
} as ComponentNode,
|
|
988
|
+
interface: {},
|
|
989
|
+
},
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const sectionWithContainerSlot: ComponentDefinition = {
|
|
993
|
+
component: {
|
|
994
|
+
structure: {
|
|
995
|
+
type: 'node',
|
|
996
|
+
tag: 'div',
|
|
997
|
+
label: 'Section',
|
|
998
|
+
children: [
|
|
999
|
+
{
|
|
1000
|
+
type: 'component',
|
|
1001
|
+
component: 'Container',
|
|
1002
|
+
children: [{ type: 'slot' }],
|
|
1003
|
+
},
|
|
1004
|
+
],
|
|
1005
|
+
} as ComponentNode,
|
|
1006
|
+
interface: {},
|
|
1007
|
+
},
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
const headerSimple: ComponentDefinition = {
|
|
1011
|
+
component: {
|
|
1012
|
+
structure: {
|
|
1013
|
+
type: 'component',
|
|
1014
|
+
component: 'Section',
|
|
1015
|
+
children: [
|
|
1016
|
+
{
|
|
1017
|
+
type: 'component',
|
|
1018
|
+
component: 'Stack',
|
|
1019
|
+
children: [
|
|
1020
|
+
{ type: 'node', tag: 'h1', children: '{{title}}' },
|
|
1021
|
+
{ type: 'node', tag: 'p', children: '{{text}}' },
|
|
1022
|
+
],
|
|
1023
|
+
},
|
|
1024
|
+
],
|
|
1025
|
+
} as ComponentNode,
|
|
1026
|
+
interface: {
|
|
1027
|
+
title: { type: 'string', default: 'Default Title' },
|
|
1028
|
+
text: { type: 'string', default: 'Default Body' },
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
const stack: ComponentDefinition = {
|
|
1034
|
+
component: {
|
|
1035
|
+
structure: {
|
|
1036
|
+
type: 'node',
|
|
1037
|
+
tag: 'div',
|
|
1038
|
+
label: 'Stack',
|
|
1039
|
+
children: [{ type: 'slot' }],
|
|
1040
|
+
} as ComponentNode,
|
|
1041
|
+
interface: {},
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
|
|
1045
|
+
test('slot inside a wrapper component\'s children is forwarded through', async () => {
|
|
1046
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1047
|
+
const headerInstance: ComponentNode = {
|
|
1048
|
+
type: 'component',
|
|
1049
|
+
component: 'HeaderSimpleCentered',
|
|
1050
|
+
props: {
|
|
1051
|
+
title: 'Real Title',
|
|
1052
|
+
text: 'Real Body',
|
|
1053
|
+
},
|
|
1054
|
+
} as ComponentNode;
|
|
1055
|
+
|
|
1056
|
+
const els = await nodeToWebflow(
|
|
1057
|
+
headerInstance,
|
|
1058
|
+
makeCtx('index', styleClasses, {
|
|
1059
|
+
globalComponents: {
|
|
1060
|
+
HeaderSimpleCentered: headerSimple,
|
|
1061
|
+
Section: sectionWithContainerSlot,
|
|
1062
|
+
Container: containerForwarder,
|
|
1063
|
+
Stack: stack,
|
|
1064
|
+
},
|
|
1065
|
+
})
|
|
1066
|
+
);
|
|
1067
|
+
|
|
1068
|
+
// section > container > stack > [h1, p]
|
|
1069
|
+
const sectionEl = els[0]! as WebflowElement;
|
|
1070
|
+
expect(sectionEl.tag).toBe('div');
|
|
1071
|
+
const containerEl = sectionEl.children![0] as WebflowElement;
|
|
1072
|
+
expect(containerEl.tag).toBe('div');
|
|
1073
|
+
expect(containerEl.children?.length).toBeGreaterThan(0);
|
|
1074
|
+
const stackEl = containerEl.children![0] as WebflowElement;
|
|
1075
|
+
expect(stackEl.tag).toBe('div');
|
|
1076
|
+
const h1 = stackEl.children![0] as WebflowElement;
|
|
1077
|
+
const p = stackEl.children![1] as WebflowElement;
|
|
1078
|
+
expect(h1.tag).toBe('h1');
|
|
1079
|
+
expect(h1.textContent).toBe('Real Title');
|
|
1080
|
+
expect(p.tag).toBe('p');
|
|
1081
|
+
expect(p.textContent).toBe('Real Body');
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('forwarded slot children resolve templates against the OUTER component\'s props', async () => {
|
|
1085
|
+
// The Stack inside HeaderSimpleCentered is forwarded through Section >
|
|
1086
|
+
// Container, but its h1's `{{title}}` template must still resolve against
|
|
1087
|
+
// HeaderSimpleCentered's props — not Container's (which is empty).
|
|
1088
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1089
|
+
const headerInstance: ComponentNode = {
|
|
1090
|
+
type: 'component',
|
|
1091
|
+
component: 'HeaderSimpleCentered',
|
|
1092
|
+
// Use defaults — if the template resolves against the wrong scope, we'd
|
|
1093
|
+
// get an empty string instead of the default value.
|
|
1094
|
+
} as ComponentNode;
|
|
1095
|
+
|
|
1096
|
+
const els = await nodeToWebflow(
|
|
1097
|
+
headerInstance,
|
|
1098
|
+
makeCtx('index', styleClasses, {
|
|
1099
|
+
globalComponents: {
|
|
1100
|
+
HeaderSimpleCentered: headerSimple,
|
|
1101
|
+
Section: sectionWithContainerSlot,
|
|
1102
|
+
Container: containerForwarder,
|
|
1103
|
+
Stack: stack,
|
|
1104
|
+
},
|
|
1105
|
+
})
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
const sectionEl = els[0]! as WebflowElement;
|
|
1109
|
+
const containerEl = sectionEl.children![0] as WebflowElement;
|
|
1110
|
+
const stackEl = containerEl.children![0] as WebflowElement;
|
|
1111
|
+
const h1 = stackEl.children![0] as WebflowElement;
|
|
1112
|
+
expect(h1.textContent).toBe('Default Title');
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
test('slot forwarding falls back to slot default when no outer slot children exist', async () => {
|
|
1116
|
+
// A wrapper with `{type:'slot', default: [...]}` and no outer slot children
|
|
1117
|
+
// should fall back to the default content when forwarded.
|
|
1118
|
+
const containerWithDefault: ComponentDefinition = {
|
|
1119
|
+
component: {
|
|
1120
|
+
structure: {
|
|
1121
|
+
type: 'node',
|
|
1122
|
+
tag: 'div',
|
|
1123
|
+
label: 'Container',
|
|
1124
|
+
children: [
|
|
1125
|
+
{
|
|
1126
|
+
type: 'slot',
|
|
1127
|
+
default: [{ type: 'node', tag: 'p', children: 'Empty container' }],
|
|
1128
|
+
} as unknown as ComponentNode,
|
|
1129
|
+
],
|
|
1130
|
+
} as ComponentNode,
|
|
1131
|
+
interface: {},
|
|
1132
|
+
},
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
const sectionWrapper: ComponentDefinition = {
|
|
1136
|
+
component: {
|
|
1137
|
+
structure: {
|
|
1138
|
+
type: 'node',
|
|
1139
|
+
tag: 'div',
|
|
1140
|
+
children: [
|
|
1141
|
+
{
|
|
1142
|
+
type: 'component',
|
|
1143
|
+
component: 'Container',
|
|
1144
|
+
children: [
|
|
1145
|
+
{
|
|
1146
|
+
type: 'slot',
|
|
1147
|
+
default: [{ type: 'node', tag: 'span', children: 'fallback' }],
|
|
1148
|
+
} as unknown as ComponentNode,
|
|
1149
|
+
],
|
|
1150
|
+
},
|
|
1151
|
+
],
|
|
1152
|
+
} as ComponentNode,
|
|
1153
|
+
interface: {},
|
|
1154
|
+
},
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1158
|
+
const els = await nodeToWebflow(
|
|
1159
|
+
{ type: 'component', component: 'Section' } as ComponentNode,
|
|
1160
|
+
makeCtx('index', styleClasses, {
|
|
1161
|
+
globalComponents: { Section: sectionWrapper, Container: containerWithDefault },
|
|
1162
|
+
})
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const sectionEl = els[0]! as WebflowElement;
|
|
1166
|
+
const containerEl = sectionEl.children![0] as WebflowElement;
|
|
1167
|
+
// Section's slot marker (with default 'fallback') is forwarded to Container's
|
|
1168
|
+
// children. Container's outer-slot has no actual children at the page level,
|
|
1169
|
+
// so the forwarder falls back to Section's slot default ('fallback').
|
|
1170
|
+
const fallback = containerEl.children![0] as WebflowElement;
|
|
1171
|
+
expect(fallback.tag).toBe('span');
|
|
1172
|
+
expect(fallback.textContent).toBe('fallback');
|
|
1173
|
+
});
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
describe('nodeToWebflow — responsive auto-scaling', () => {
|
|
1177
|
+
test('raw font-size scales into per-breakpoint entries when scales are enabled', async () => {
|
|
1178
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1179
|
+
const els = await nodeToWebflow(
|
|
1180
|
+
{ type: 'node', tag: 'div', style: { base: { fontSize: '48px' } }, children: 'Hi' } as ComponentNode,
|
|
1181
|
+
makeCtx('index', styleClasses, { responsiveScales: SCALES_ON })
|
|
1182
|
+
);
|
|
1183
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1184
|
+
expect(cls?.base['font-size']).toBe('48px');
|
|
1185
|
+
// 48 + (48 - 16) * (0.88 - 1) = 44.16 → 44
|
|
1186
|
+
expect(cls?.breakpoints?.medium?.['font-size']).toBe('44px');
|
|
1187
|
+
// 48 + (48 - 16) * (0.75 - 1) = 40
|
|
1188
|
+
expect(cls?.breakpoints?.small?.['font-size']).toBe('40px');
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test('raw multi-value padding shorthand expands and scales each side', async () => {
|
|
1192
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1193
|
+
const els = await nodeToWebflow(
|
|
1194
|
+
{ type: 'node', tag: 'div', style: { base: { padding: '40px 80px' } }, children: 'Hi' } as ComponentNode,
|
|
1195
|
+
makeCtx('index', styleClasses, { responsiveScales: SCALES_ON })
|
|
1196
|
+
);
|
|
1197
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1198
|
+
expect(cls?.base['padding']).toBeUndefined();
|
|
1199
|
+
expect(cls?.base['padding-top']).toBe('40px');
|
|
1200
|
+
expect(cls?.base['padding-right']).toBe('80px');
|
|
1201
|
+
expect(cls?.base['padding-bottom']).toBe('40px');
|
|
1202
|
+
expect(cls?.base['padding-left']).toBe('80px');
|
|
1203
|
+
// padding tablet scale 0.75: 40+(40-16)*(0.75-1)=34, 80+(80-16)*(0.75-1)=64
|
|
1204
|
+
expect(cls?.breakpoints?.medium?.['padding-top']).toBe('34px');
|
|
1205
|
+
expect(cls?.breakpoints?.medium?.['padding-right']).toBe('64px');
|
|
1206
|
+
expect(cls?.breakpoints?.medium?.['padding-bottom']).toBe('34px');
|
|
1207
|
+
expect(cls?.breakpoints?.medium?.['padding-left']).toBe('64px');
|
|
1208
|
+
// padding mobile scale 0.5: 40+(40-16)*(0.5-1)=28, 80+(80-16)*(0.5-1)=48
|
|
1209
|
+
expect(cls?.breakpoints?.small?.['padding-top']).toBe('28px');
|
|
1210
|
+
expect(cls?.breakpoints?.small?.['padding-right']).toBe('48px');
|
|
1211
|
+
expect(cls?.breakpoints?.small?.['padding-bottom']).toBe('28px');
|
|
1212
|
+
expect(cls?.breakpoints?.small?.['padding-left']).toBe('48px');
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
test('explicit per-breakpoint override beats auto-scaling at that tier only', async () => {
|
|
1216
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1217
|
+
const els = await nodeToWebflow(
|
|
1218
|
+
{
|
|
1219
|
+
type: 'node',
|
|
1220
|
+
tag: 'div',
|
|
1221
|
+
style: {
|
|
1222
|
+
base: { fontSize: '48px' },
|
|
1223
|
+
tablet: { fontSize: '32px' },
|
|
1224
|
+
},
|
|
1225
|
+
children: 'Hi',
|
|
1226
|
+
} as ComponentNode,
|
|
1227
|
+
makeCtx('index', styleClasses, { responsiveScales: SCALES_ON })
|
|
1228
|
+
);
|
|
1229
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1230
|
+
// Author override wins at medium.
|
|
1231
|
+
expect(cls?.breakpoints?.medium?.['font-size']).toBe('32px');
|
|
1232
|
+
// Mobile still gets auto-scaled from base (48 → 40).
|
|
1233
|
+
expect(cls?.breakpoints?.small?.['font-size']).toBe('40px');
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
test('non-scalable properties are not auto-scaled', async () => {
|
|
1237
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1238
|
+
const els = await nodeToWebflow(
|
|
1239
|
+
{
|
|
1240
|
+
type: 'node',
|
|
1241
|
+
tag: 'div',
|
|
1242
|
+
style: { base: { color: 'red', display: 'flex', opacity: '0.5' } },
|
|
1243
|
+
children: 'Hi',
|
|
1244
|
+
} as ComponentNode,
|
|
1245
|
+
makeCtx('index', styleClasses, { responsiveScales: SCALES_ON })
|
|
1246
|
+
);
|
|
1247
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1248
|
+
expect(cls?.base['color']).toBe('red');
|
|
1249
|
+
expect(cls?.breakpoints?.medium?.['color']).toBeUndefined();
|
|
1250
|
+
expect(cls?.breakpoints?.medium?.['display']).toBeUndefined();
|
|
1251
|
+
expect(cls?.breakpoints?.medium?.['opacity']).toBeUndefined();
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
test('disabled responsiveScales preserves existing single-value behavior', async () => {
|
|
1255
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1256
|
+
const els = await nodeToWebflow(
|
|
1257
|
+
{ type: 'node', tag: 'div', style: { base: { fontSize: '48px' } }, children: 'Hi' } as ComponentNode,
|
|
1258
|
+
makeCtx('index', styleClasses, { responsiveScales: { ...SCALES_ON, enabled: false } })
|
|
1259
|
+
);
|
|
1260
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1261
|
+
expect(cls?.base['font-size']).toBe('48px');
|
|
1262
|
+
expect(cls?.breakpoints).toBeUndefined();
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
test('var(--h1-fs) is inlined per-breakpoint via projectVars map', async () => {
|
|
1266
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1267
|
+
const projectVars = {
|
|
1268
|
+
base: { '--h1-fs': '48px' },
|
|
1269
|
+
tablet: { '--h1-fs': '44px' },
|
|
1270
|
+
mobile: { '--h1-fs': '40px' },
|
|
1271
|
+
};
|
|
1272
|
+
const els = await nodeToWebflow(
|
|
1273
|
+
{ type: 'node', tag: 'div', style: { base: { fontSize: 'var(--h1-fs)' } }, children: 'Hi' } as ComponentNode,
|
|
1274
|
+
makeCtx('index', styleClasses, { projectVars, responsiveScales: SCALES_ON })
|
|
1275
|
+
);
|
|
1276
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1277
|
+
expect(cls?.base['font-size']).toBe('48px');
|
|
1278
|
+
expect(cls?.breakpoints?.medium?.['font-size']).toBe('44px');
|
|
1279
|
+
expect(cls?.breakpoints?.small?.['font-size']).toBe('40px');
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
test('variable explicit per-breakpoint override wins over global category scaling', async () => {
|
|
1283
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1284
|
+
// Simulates buildProjectVarMaps output for a variable with scales: { tablet: '42px' }.
|
|
1285
|
+
const projectVars = {
|
|
1286
|
+
base: { '--h1-fs': '48px' },
|
|
1287
|
+
tablet: { '--h1-fs': '42px' }, // explicit override
|
|
1288
|
+
mobile: { '--h1-fs': '40px' }, // global scale (0.75)
|
|
1289
|
+
};
|
|
1290
|
+
const els = await nodeToWebflow(
|
|
1291
|
+
{ type: 'node', tag: 'div', style: { base: { fontSize: 'var(--h1-fs)' } }, children: 'Hi' } as ComponentNode,
|
|
1292
|
+
makeCtx('index', styleClasses, { projectVars, responsiveScales: SCALES_ON })
|
|
1293
|
+
);
|
|
1294
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1295
|
+
expect(cls?.breakpoints?.medium?.['font-size']).toBe('42px');
|
|
1296
|
+
expect(cls?.breakpoints?.small?.['font-size']).toBe('40px');
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
test('var-driven per-bp value coexists with raw-value auto-scaling on the same class', async () => {
|
|
1300
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1301
|
+
const projectVars = {
|
|
1302
|
+
base: { '--h1-fs': '48px' },
|
|
1303
|
+
tablet: { '--h1-fs': '44px' },
|
|
1304
|
+
mobile: { '--h1-fs': '40px' },
|
|
1305
|
+
};
|
|
1306
|
+
const els = await nodeToWebflow(
|
|
1307
|
+
{
|
|
1308
|
+
type: 'node',
|
|
1309
|
+
tag: 'div',
|
|
1310
|
+
// fontSize comes from a var; padding is a raw value.
|
|
1311
|
+
style: { base: { fontSize: 'var(--h1-fs)', padding: '40px' } },
|
|
1312
|
+
children: 'Hi',
|
|
1313
|
+
} as ComponentNode,
|
|
1314
|
+
makeCtx('index', styleClasses, { projectVars, responsiveScales: SCALES_ON })
|
|
1315
|
+
);
|
|
1316
|
+
const cls = styleClasses.get((els[0] as any).className!);
|
|
1317
|
+
// var route
|
|
1318
|
+
expect(cls?.breakpoints?.medium?.['font-size']).toBe('44px');
|
|
1319
|
+
expect(cls?.breakpoints?.small?.['font-size']).toBe('40px');
|
|
1320
|
+
// raw scaling route (padding 0.75/0.5) — shorthand expanded to longhands
|
|
1321
|
+
expect(cls?.breakpoints?.medium?.['padding-top']).toBe('34px');
|
|
1322
|
+
expect(cls?.breakpoints?.medium?.['padding-right']).toBe('34px');
|
|
1323
|
+
expect(cls?.breakpoints?.medium?.['padding-bottom']).toBe('34px');
|
|
1324
|
+
expect(cls?.breakpoints?.medium?.['padding-left']).toBe('34px');
|
|
1325
|
+
expect(cls?.breakpoints?.small?.['padding-top']).toBe('28px');
|
|
1326
|
+
expect(cls?.breakpoints?.small?.['padding-right']).toBe('28px');
|
|
1327
|
+
expect(cls?.breakpoints?.small?.['padding-bottom']).toBe('28px');
|
|
1328
|
+
expect(cls?.breakpoints?.small?.['padding-left']).toBe('28px');
|
|
1329
|
+
});
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
describe('nodeToWebflow — promoted components (Navigation / Footer)', () => {
|
|
1333
|
+
// A bare Navigation component: a <nav> with one <a> child. Default props
|
|
1334
|
+
// are exercised by referencing {{title}} so we can assert the registered
|
|
1335
|
+
// body uses defaults rather than per-instance overrides.
|
|
1336
|
+
const navigationDef: ComponentDefinition = {
|
|
1337
|
+
component: {
|
|
1338
|
+
structure: {
|
|
1339
|
+
type: 'node',
|
|
1340
|
+
tag: 'nav',
|
|
1341
|
+
children: [
|
|
1342
|
+
{ type: 'node', tag: 'a', children: '{{title}}' } as ComponentNode,
|
|
1343
|
+
],
|
|
1344
|
+
} as ComponentNode,
|
|
1345
|
+
interface: {
|
|
1346
|
+
title: { type: 'string' as any, default: 'Home' },
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
const footerDef: ComponentDefinition = {
|
|
1352
|
+
component: {
|
|
1353
|
+
structure: {
|
|
1354
|
+
type: 'node',
|
|
1355
|
+
tag: 'footer',
|
|
1356
|
+
children: [{ type: 'node', tag: 'span', children: 'fine print' } as ComponentNode],
|
|
1357
|
+
} as ComponentNode,
|
|
1358
|
+
},
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
// A non-promoted component (BasicCard) that should still inline.
|
|
1362
|
+
const basicCardDef: ComponentDefinition = {
|
|
1363
|
+
component: {
|
|
1364
|
+
structure: {
|
|
1365
|
+
type: 'node',
|
|
1366
|
+
tag: 'article',
|
|
1367
|
+
children: [{ type: 'node', tag: 'h3', children: 'card' } as ComponentNode],
|
|
1368
|
+
} as ComponentNode,
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
function makePromotedCtx(
|
|
1373
|
+
fileName: string,
|
|
1374
|
+
styleClasses: Map<string, WebflowStyleClass>,
|
|
1375
|
+
promotedComponents: Map<string, WebflowComponentDef>,
|
|
1376
|
+
overrides?: Partial<WebflowEmitContext>
|
|
1377
|
+
): WebflowEmitContext {
|
|
1378
|
+
return {
|
|
1379
|
+
globalComponents: {
|
|
1380
|
+
Layout: layoutDef,
|
|
1381
|
+
Navigation: navigationDef,
|
|
1382
|
+
Footer: footerDef,
|
|
1383
|
+
BasicCard: basicCardDef,
|
|
1384
|
+
},
|
|
1385
|
+
elementPath: [0],
|
|
1386
|
+
fileType: 'page',
|
|
1387
|
+
fileName,
|
|
1388
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1389
|
+
styleClasses,
|
|
1390
|
+
comboIdentityByName: new Map(),
|
|
1391
|
+
promotedComponents,
|
|
1392
|
+
...overrides,
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
test('two Navigation instances register one definition and emit componentRef each', async () => {
|
|
1397
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1398
|
+
const promoted = new Map<string, WebflowComponentDef>();
|
|
1399
|
+
const ctx = makePromotedCtx('home', styleClasses, promoted);
|
|
1400
|
+
|
|
1401
|
+
const els = await nodeToWebflow(
|
|
1402
|
+
pageRoot({
|
|
1403
|
+
type: 'node',
|
|
1404
|
+
tag: 'main',
|
|
1405
|
+
children: [
|
|
1406
|
+
{ type: 'component', component: 'Navigation' } as ComponentNode,
|
|
1407
|
+
{ type: 'component', component: 'Navigation', props: { title: 'Override' } } as ComponentNode,
|
|
1408
|
+
],
|
|
1409
|
+
} as ComponentNode),
|
|
1410
|
+
ctx
|
|
1411
|
+
);
|
|
1412
|
+
|
|
1413
|
+
// Walk to the <main> children that hold the two refs.
|
|
1414
|
+
const layout = els[0] as WebflowElement;
|
|
1415
|
+
const main = layout.children![0] as WebflowElement;
|
|
1416
|
+
const refs = (main.children || []) as WebflowElement[];
|
|
1417
|
+
expect(refs).toHaveLength(2);
|
|
1418
|
+
expect(refs[0]!.componentRef).toBe('Navigation');
|
|
1419
|
+
expect(refs[1]!.componentRef).toBe('Navigation');
|
|
1420
|
+
expect(Array.isArray(refs[0]!.inlineFallback)).toBe(true);
|
|
1421
|
+
|
|
1422
|
+
expect(promoted.size).toBe(1);
|
|
1423
|
+
expect(promoted.has('Navigation')).toBe(true);
|
|
1424
|
+
|
|
1425
|
+
// Body uses default props ('Home'), not the per-instance override.
|
|
1426
|
+
const navBody = promoted.get('Navigation')!.elements[0] as WebflowElement;
|
|
1427
|
+
const link = navBody.children![0] as WebflowElement;
|
|
1428
|
+
expect(link.textContent).toBe('Home');
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
test('Footer is also promoted and registers separately', async () => {
|
|
1432
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1433
|
+
const promoted = new Map<string, WebflowComponentDef>();
|
|
1434
|
+
const ctx = makePromotedCtx('home', styleClasses, promoted);
|
|
1435
|
+
|
|
1436
|
+
const els = await nodeToWebflow(
|
|
1437
|
+
pageRoot({
|
|
1438
|
+
type: 'node',
|
|
1439
|
+
tag: 'div',
|
|
1440
|
+
children: [
|
|
1441
|
+
{ type: 'component', component: 'Navigation' } as ComponentNode,
|
|
1442
|
+
{ type: 'component', component: 'Footer' } as ComponentNode,
|
|
1443
|
+
],
|
|
1444
|
+
} as ComponentNode),
|
|
1445
|
+
ctx
|
|
1446
|
+
);
|
|
1447
|
+
|
|
1448
|
+
const wrapper = ((els[0] as WebflowElement).children![0] as WebflowElement);
|
|
1449
|
+
const refs = (wrapper.children || []) as WebflowElement[];
|
|
1450
|
+
expect(refs[0]!.componentRef).toBe('Navigation');
|
|
1451
|
+
expect(refs[1]!.componentRef).toBe('Footer');
|
|
1452
|
+
expect(promoted.size).toBe(2);
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
test('non-promoted component (BasicCard) still inlines', async () => {
|
|
1456
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1457
|
+
const promoted = new Map<string, WebflowComponentDef>();
|
|
1458
|
+
const ctx = makePromotedCtx('home', styleClasses, promoted);
|
|
1459
|
+
|
|
1460
|
+
const els = await nodeToWebflow(
|
|
1461
|
+
pageRoot({ type: 'component', component: 'BasicCard' } as ComponentNode),
|
|
1462
|
+
ctx
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
// BasicCard should expand into <article>, not a componentRef.
|
|
1466
|
+
const layout = els[0] as WebflowElement;
|
|
1467
|
+
const card = layout.children![0] as WebflowElement;
|
|
1468
|
+
expect(card.componentRef).toBeUndefined();
|
|
1469
|
+
expect(card.tag).toBe('article');
|
|
1470
|
+
expect(promoted.size).toBe(0);
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
test('without promotedComponents in context, Navigation still inlines (back-compat)', async () => {
|
|
1474
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1475
|
+
const ctx: WebflowEmitContext = {
|
|
1476
|
+
globalComponents: {
|
|
1477
|
+
Layout: layoutDef,
|
|
1478
|
+
Navigation: navigationDef,
|
|
1479
|
+
},
|
|
1480
|
+
elementPath: [0],
|
|
1481
|
+
fileType: 'page',
|
|
1482
|
+
fileName: 'home',
|
|
1483
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1484
|
+
styleClasses,
|
|
1485
|
+
comboIdentityByName: new Map(),
|
|
1486
|
+
// No promotedComponents — undefined disables the feature entirely.
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const els = await nodeToWebflow(
|
|
1490
|
+
pageRoot({ type: 'component', component: 'Navigation' } as ComponentNode),
|
|
1491
|
+
ctx
|
|
1492
|
+
);
|
|
1493
|
+
|
|
1494
|
+
const layout = els[0] as WebflowElement;
|
|
1495
|
+
const navOrRef = layout.children![0] as WebflowElement;
|
|
1496
|
+
expect(navOrRef.componentRef).toBeUndefined();
|
|
1497
|
+
expect(navOrRef.tag).toBe('nav');
|
|
1498
|
+
});
|
|
1499
|
+
|
|
1500
|
+
test('Footer nested inside Navigation inlines (no second registration)', async () => {
|
|
1501
|
+
// A Navigation whose body contains a Footer. Both names are promoted on
|
|
1502
|
+
// the page level, but inside the registered Navigation body the nested
|
|
1503
|
+
// Footer must inline — registering it would cause an authored "Footer"
|
|
1504
|
+
// Component to live two places at once in Webflow.
|
|
1505
|
+
const navWithFooterDef: ComponentDefinition = {
|
|
1506
|
+
component: {
|
|
1507
|
+
structure: {
|
|
1508
|
+
type: 'node',
|
|
1509
|
+
tag: 'nav',
|
|
1510
|
+
children: [
|
|
1511
|
+
{ type: 'component', component: 'Footer' } as ComponentNode,
|
|
1512
|
+
],
|
|
1513
|
+
} as ComponentNode,
|
|
1514
|
+
},
|
|
1515
|
+
};
|
|
1516
|
+
|
|
1517
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1518
|
+
const promoted = new Map<string, WebflowComponentDef>();
|
|
1519
|
+
const ctx: WebflowEmitContext = {
|
|
1520
|
+
globalComponents: {
|
|
1521
|
+
Layout: layoutDef,
|
|
1522
|
+
Navigation: navWithFooterDef,
|
|
1523
|
+
Footer: footerDef,
|
|
1524
|
+
},
|
|
1525
|
+
elementPath: [0],
|
|
1526
|
+
fileType: 'page',
|
|
1527
|
+
fileName: 'home',
|
|
1528
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1529
|
+
styleClasses,
|
|
1530
|
+
comboIdentityByName: new Map(),
|
|
1531
|
+
promotedComponents: promoted,
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
const els = await nodeToWebflow(
|
|
1535
|
+
pageRoot({ type: 'component', component: 'Navigation' } as ComponentNode),
|
|
1536
|
+
ctx
|
|
1537
|
+
);
|
|
1538
|
+
|
|
1539
|
+
// Only Navigation registered — the nested Footer inlines inside its body.
|
|
1540
|
+
expect(promoted.size).toBe(1);
|
|
1541
|
+
expect(promoted.has('Navigation')).toBe(true);
|
|
1542
|
+
expect(promoted.has('Footer')).toBe(false);
|
|
1543
|
+
|
|
1544
|
+
const layout = els[0] as WebflowElement;
|
|
1545
|
+
const ref = layout.children![0] as WebflowElement;
|
|
1546
|
+
expect(ref.componentRef).toBe('Navigation');
|
|
1547
|
+
|
|
1548
|
+
// The registered body's nested <Footer> inlines as a <footer> element.
|
|
1549
|
+
const body = promoted.get('Navigation')!.elements[0] as WebflowElement;
|
|
1550
|
+
const innerFooter = body.children![0] as WebflowElement;
|
|
1551
|
+
expect(innerFooter.tag).toBe('footer');
|
|
1552
|
+
expect(innerFooter.componentRef).toBeUndefined();
|
|
1553
|
+
});
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
describe('nodeToWebflow — rich-text prop substitution', () => {
|
|
1557
|
+
const RAW_HTML_PREFIX = '<!--MENO_RAW_HTML-->';
|
|
1558
|
+
|
|
1559
|
+
// A component whose body uses a prop both standalone and embedded in
|
|
1560
|
+
// surrounding literal text — the latter is the case the `startsWith` check
|
|
1561
|
+
// at the text-content site can't catch on its own.
|
|
1562
|
+
const heroDef: ComponentDefinition = {
|
|
1563
|
+
component: {
|
|
1564
|
+
structure: {
|
|
1565
|
+
type: 'node',
|
|
1566
|
+
tag: 'section',
|
|
1567
|
+
children: [
|
|
1568
|
+
{ type: 'node', tag: 'h1', children: 'Hello {{title}}!' } as ComponentNode,
|
|
1569
|
+
{ type: 'node', tag: 'p', children: '{{title}}' } as ComponentNode,
|
|
1570
|
+
],
|
|
1571
|
+
} as ComponentNode,
|
|
1572
|
+
interface: {
|
|
1573
|
+
title: { type: 'string' as any, default: 'world' },
|
|
1574
|
+
},
|
|
1575
|
+
},
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
test('flattens spans from prop value when interpolated mid-string', async () => {
|
|
1579
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1580
|
+
const ctx: WebflowEmitContext = {
|
|
1581
|
+
globalComponents: { Layout: layoutDef, Hero: heroDef },
|
|
1582
|
+
elementPath: [0],
|
|
1583
|
+
fileType: 'page',
|
|
1584
|
+
fileName: 'home',
|
|
1585
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1586
|
+
styleClasses,
|
|
1587
|
+
comboIdentityByName: new Map(),
|
|
1588
|
+
};
|
|
1589
|
+
|
|
1590
|
+
const els = await nodeToWebflow(
|
|
1591
|
+
pageRoot({
|
|
1592
|
+
type: 'component',
|
|
1593
|
+
component: 'Hero',
|
|
1594
|
+
props: { title: `${RAW_HTML_PREFIX}<span class="x">foo</span>` },
|
|
1595
|
+
} as ComponentNode),
|
|
1596
|
+
ctx
|
|
1597
|
+
);
|
|
1598
|
+
|
|
1599
|
+
const layout = els[0] as WebflowElement;
|
|
1600
|
+
const section = layout.children![0] as WebflowElement;
|
|
1601
|
+
const h1 = section.children![0] as WebflowElement;
|
|
1602
|
+
const p = section.children![1] as WebflowElement;
|
|
1603
|
+
|
|
1604
|
+
expect(h1.textContent).toBe('Hello foo!');
|
|
1605
|
+
expect(h1.textContent).not.toContain('<span');
|
|
1606
|
+
expect(h1.textContent).not.toContain('MENO_RAW_HTML');
|
|
1607
|
+
|
|
1608
|
+
// Standalone {{title}} body: existing startsWith path still strips, so
|
|
1609
|
+
// this stays plain text too.
|
|
1610
|
+
expect(p.textContent).toBe('foo');
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
test('flattens spans from un-prefixed HTML in heading/paragraph textContent', async () => {
|
|
1614
|
+
// Rich-text props on static pages aren't sentinel-prefixed — they're
|
|
1615
|
+
// stored as plain HTML by the editor. Heading/Paragraph elements still
|
|
1616
|
+
// can't host inline markup in Webflow, so we flatten any tags we find.
|
|
1617
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1618
|
+
const ctx: WebflowEmitContext = {
|
|
1619
|
+
globalComponents: { Layout: layoutDef, Hero: heroDef },
|
|
1620
|
+
elementPath: [0],
|
|
1621
|
+
fileType: 'page',
|
|
1622
|
+
fileName: 'home',
|
|
1623
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1624
|
+
styleClasses,
|
|
1625
|
+
comboIdentityByName: new Map(),
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
const els = await nodeToWebflow(
|
|
1629
|
+
pageRoot({
|
|
1630
|
+
type: 'component',
|
|
1631
|
+
component: 'Hero',
|
|
1632
|
+
props: { title: 'Everything you <span class="x">need</span> to build' },
|
|
1633
|
+
} as ComponentNode),
|
|
1634
|
+
ctx
|
|
1635
|
+
);
|
|
1636
|
+
|
|
1637
|
+
const layout = els[0] as WebflowElement;
|
|
1638
|
+
const section = layout.children![0] as WebflowElement;
|
|
1639
|
+
const h1 = section.children![0] as WebflowElement;
|
|
1640
|
+
const p = section.children![1] as WebflowElement;
|
|
1641
|
+
|
|
1642
|
+
expect(h1.textContent).toBe('Hello Everything you need to build!');
|
|
1643
|
+
expect(h1.textContent).not.toContain('<span');
|
|
1644
|
+
expect(p.textContent).toBe('Everything you need to build');
|
|
1645
|
+
expect(p.textContent).not.toContain('<span');
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
test('preserves plain prop values without the sentinel', async () => {
|
|
1649
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1650
|
+
const ctx: WebflowEmitContext = {
|
|
1651
|
+
globalComponents: { Layout: layoutDef, Hero: heroDef },
|
|
1652
|
+
elementPath: [0],
|
|
1653
|
+
fileType: 'page',
|
|
1654
|
+
fileName: 'home',
|
|
1655
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1656
|
+
styleClasses,
|
|
1657
|
+
comboIdentityByName: new Map(),
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const els = await nodeToWebflow(
|
|
1661
|
+
pageRoot({
|
|
1662
|
+
type: 'component',
|
|
1663
|
+
component: 'Hero',
|
|
1664
|
+
props: { title: 'plain' },
|
|
1665
|
+
} as ComponentNode),
|
|
1666
|
+
ctx
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1669
|
+
const layout = els[0] as WebflowElement;
|
|
1670
|
+
const section = layout.children![0] as WebflowElement;
|
|
1671
|
+
const h1 = section.children![0] as WebflowElement;
|
|
1672
|
+
expect(h1.textContent).toBe('Hello plain!');
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
test('keeps raw rich-text in data-props for runtime JS', async () => {
|
|
1676
|
+
const heroWithJsDef: ComponentDefinition = {
|
|
1677
|
+
component: {
|
|
1678
|
+
structure: heroDef.component!.structure,
|
|
1679
|
+
interface: heroDef.component!.interface,
|
|
1680
|
+
defineVars: true,
|
|
1681
|
+
javascript: '/* placeholder so data-props is emitted */',
|
|
1682
|
+
},
|
|
1683
|
+
};
|
|
1684
|
+
|
|
1685
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1686
|
+
const ctx: WebflowEmitContext = {
|
|
1687
|
+
globalComponents: { Layout: layoutDef, Hero: heroWithJsDef },
|
|
1688
|
+
elementPath: [0],
|
|
1689
|
+
fileType: 'page',
|
|
1690
|
+
fileName: 'home',
|
|
1691
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1692
|
+
styleClasses,
|
|
1693
|
+
comboIdentityByName: new Map(),
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
const richTitle = `${RAW_HTML_PREFIX}<span class="x">foo</span>`;
|
|
1697
|
+
const els = await nodeToWebflow(
|
|
1698
|
+
pageRoot({
|
|
1699
|
+
type: 'component',
|
|
1700
|
+
component: 'Hero',
|
|
1701
|
+
props: { title: richTitle },
|
|
1702
|
+
} as ComponentNode),
|
|
1703
|
+
ctx
|
|
1704
|
+
);
|
|
1705
|
+
|
|
1706
|
+
const layout = els[0] as WebflowElement;
|
|
1707
|
+
const section = layout.children![0] as WebflowElement;
|
|
1708
|
+
const dataProps = section.attributes!['data-props'] as string;
|
|
1709
|
+
const parsed = JSON.parse(decodeURIComponent(dataProps));
|
|
1710
|
+
expect(parsed.Hero.title).toBe(richTitle);
|
|
1711
|
+
});
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
describe('nodeToWebflow — acceptsStyles instance overrides', () => {
|
|
1715
|
+
// Component that opts in to instance-level style overrides. Body root is
|
|
1716
|
+
// an HTML <section> so the inline-expanded root has its own primary class
|
|
1717
|
+
// for combos to attach to.
|
|
1718
|
+
const cardWithAcceptsStyles: ComponentDefinition = {
|
|
1719
|
+
component: {
|
|
1720
|
+
acceptsStyles: true,
|
|
1721
|
+
structure: {
|
|
1722
|
+
type: 'node',
|
|
1723
|
+
tag: 'section',
|
|
1724
|
+
children: [{ type: 'node', tag: 'p', children: 'body' } as ComponentNode],
|
|
1725
|
+
} as ComponentNode,
|
|
1726
|
+
},
|
|
1727
|
+
};
|
|
1728
|
+
|
|
1729
|
+
const cardWithoutAcceptsStyles: ComponentDefinition = {
|
|
1730
|
+
component: {
|
|
1731
|
+
structure: {
|
|
1732
|
+
type: 'node',
|
|
1733
|
+
tag: 'section',
|
|
1734
|
+
children: [{ type: 'node', tag: 'p', children: 'body' } as ComponentNode],
|
|
1735
|
+
} as ComponentNode,
|
|
1736
|
+
},
|
|
1737
|
+
};
|
|
1738
|
+
|
|
1739
|
+
function makeAcceptsCtx(
|
|
1740
|
+
fileName: string,
|
|
1741
|
+
styleClasses: Map<string, WebflowStyleClass>,
|
|
1742
|
+
overrides?: Partial<WebflowEmitContext>
|
|
1743
|
+
): WebflowEmitContext {
|
|
1744
|
+
return {
|
|
1745
|
+
globalComponents: {
|
|
1746
|
+
Layout: layoutDef,
|
|
1747
|
+
Card: cardWithAcceptsStyles,
|
|
1748
|
+
PlainCard: cardWithoutAcceptsStyles,
|
|
1749
|
+
},
|
|
1750
|
+
elementPath: [0],
|
|
1751
|
+
fileType: 'page',
|
|
1752
|
+
fileName,
|
|
1753
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1754
|
+
styleClasses,
|
|
1755
|
+
comboIdentityByName: new Map(),
|
|
1756
|
+
...overrides,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
test('instance with multiple style overrides produces ONE combo holding all CSS', async () => {
|
|
1761
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1762
|
+
const els = await nodeToWebflow(
|
|
1763
|
+
pageRoot({
|
|
1764
|
+
type: 'component',
|
|
1765
|
+
component: 'Card',
|
|
1766
|
+
style: { fontSize: '40px', color: 'red' },
|
|
1767
|
+
} as ComponentNode),
|
|
1768
|
+
makeAcceptsCtx('index', styleClasses)
|
|
1769
|
+
);
|
|
1770
|
+
|
|
1771
|
+
const layout = els[0] as WebflowElement;
|
|
1772
|
+
const section = layout.children![0] as WebflowElement;
|
|
1773
|
+
expect(section.tag).toBe('section');
|
|
1774
|
+
expect(section.className).toBeTruthy();
|
|
1775
|
+
expect(section.comboClasses).toBeDefined();
|
|
1776
|
+
expect(section.comboClasses!).toHaveLength(1);
|
|
1777
|
+
|
|
1778
|
+
const comboName = section.comboClasses![0]!;
|
|
1779
|
+
// New format: `is-<5char base36 hash>`. No page/component name segment —
|
|
1780
|
+
// the hash already encodes identity, and dropping the prefix keeps the
|
|
1781
|
+
// class short in Webflow's panel.
|
|
1782
|
+
expect(comboName).toMatch(/^is-[a-z0-9]{5}$/);
|
|
1783
|
+
|
|
1784
|
+
const combo = styleClasses.get(comboName);
|
|
1785
|
+
expect(combo).toBeDefined();
|
|
1786
|
+
expect(combo!.comboParent).toBe(section.className);
|
|
1787
|
+
expect(combo!.base['font-size']).toBe('40px');
|
|
1788
|
+
expect(combo!.base.color).toBe('red');
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
test('instance with breakpoint and pseudo-state overrides folds them into the same combo', async () => {
|
|
1792
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1793
|
+
const els = await nodeToWebflow(
|
|
1794
|
+
pageRoot({
|
|
1795
|
+
type: 'component',
|
|
1796
|
+
component: 'Card',
|
|
1797
|
+
style: { base: { color: 'red' }, mobile: { color: 'blue' } },
|
|
1798
|
+
interactiveStyles: [
|
|
1799
|
+
{ postfix: ':hover', style: { base: { color: 'green' } } },
|
|
1800
|
+
],
|
|
1801
|
+
} as ComponentNode),
|
|
1802
|
+
makeAcceptsCtx('index', styleClasses)
|
|
1803
|
+
);
|
|
1804
|
+
|
|
1805
|
+
const section = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1806
|
+
expect(section.comboClasses!).toHaveLength(1);
|
|
1807
|
+
|
|
1808
|
+
const combo = styleClasses.get(section.comboClasses![0]!);
|
|
1809
|
+
expect(combo).toBeDefined();
|
|
1810
|
+
expect(combo!.base.color).toBe('red');
|
|
1811
|
+
expect(combo!.breakpoints?.small?.color).toBe('blue');
|
|
1812
|
+
expect(combo!.pseudoStates?.hover?.color).toBe('green');
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
test('two instances of the same component on the same page get distinct combo names', async () => {
|
|
1816
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1817
|
+
const els = await nodeToWebflow(
|
|
1818
|
+
pageRoot({
|
|
1819
|
+
type: 'node',
|
|
1820
|
+
tag: 'main',
|
|
1821
|
+
children: [
|
|
1822
|
+
{ type: 'component', component: 'Card', style: { fontSize: '40px' } } as ComponentNode,
|
|
1823
|
+
{ type: 'component', component: 'Card', style: { fontSize: '60px' } } as ComponentNode,
|
|
1824
|
+
],
|
|
1825
|
+
} as ComponentNode),
|
|
1826
|
+
makeAcceptsCtx('index', styleClasses)
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
const main = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1830
|
+
const cards = main.children as WebflowElement[];
|
|
1831
|
+
const a = cards[0]!.comboClasses![0]!;
|
|
1832
|
+
const b = cards[1]!.comboClasses![0]!;
|
|
1833
|
+
expect(a).not.toBe(b);
|
|
1834
|
+
expect(styleClasses.get(a)!.base['font-size']).toBe('40px');
|
|
1835
|
+
expect(styleClasses.get(b)!.base['font-size']).toBe('60px');
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
test('component without acceptsStyles ignores instance style overrides (regression guard)', async () => {
|
|
1839
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1840
|
+
const els = await nodeToWebflow(
|
|
1841
|
+
pageRoot({
|
|
1842
|
+
type: 'component',
|
|
1843
|
+
component: 'PlainCard',
|
|
1844
|
+
style: { fontSize: '40px' },
|
|
1845
|
+
} as ComponentNode),
|
|
1846
|
+
makeAcceptsCtx('index', styleClasses)
|
|
1847
|
+
);
|
|
1848
|
+
|
|
1849
|
+
const section = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1850
|
+
expect(section.comboClasses ?? []).toHaveLength(0);
|
|
1851
|
+
});
|
|
1852
|
+
|
|
1853
|
+
test('body root with StyleMapping combo + acceptsStyles instance overrides merge into ONE combo', async () => {
|
|
1854
|
+
// Component opts in to acceptsStyles AND its body root uses a StyleMapping
|
|
1855
|
+
// bound to the `version` prop. Without merging, the body root would carry
|
|
1856
|
+
// two combos: the StyleMapping delta (`is-version-secondary`) and the
|
|
1857
|
+
// instance-override combo (`is-…`). Webflow's class system only honors
|
|
1858
|
+
// a single combo, so the two must collapse.
|
|
1859
|
+
const buttonWithBoth: ComponentDefinition = {
|
|
1860
|
+
component: {
|
|
1861
|
+
acceptsStyles: true,
|
|
1862
|
+
interface: {
|
|
1863
|
+
version: { type: 'string' as any, default: 'default' },
|
|
1864
|
+
},
|
|
1865
|
+
structure: {
|
|
1866
|
+
type: 'node',
|
|
1867
|
+
tag: 'section',
|
|
1868
|
+
style: {
|
|
1869
|
+
backgroundColor: {
|
|
1870
|
+
_mapping: true,
|
|
1871
|
+
prop: 'version',
|
|
1872
|
+
values: { default: '#000', secondary: '#eee' },
|
|
1873
|
+
},
|
|
1874
|
+
color: {
|
|
1875
|
+
_mapping: true,
|
|
1876
|
+
prop: 'version',
|
|
1877
|
+
values: { default: '#fff', secondary: '#222' },
|
|
1878
|
+
},
|
|
1879
|
+
},
|
|
1880
|
+
children: [{ type: 'node', tag: 'p', children: 'body' } as ComponentNode],
|
|
1881
|
+
} as ComponentNode,
|
|
1882
|
+
},
|
|
1883
|
+
};
|
|
1884
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1885
|
+
const els = await nodeToWebflow(
|
|
1886
|
+
pageRoot({
|
|
1887
|
+
type: 'component',
|
|
1888
|
+
component: 'Button',
|
|
1889
|
+
props: { version: 'secondary' },
|
|
1890
|
+
// Instance override: padding (new key) + color (collides with mapping
|
|
1891
|
+
// delta — outer override must win on conflicts).
|
|
1892
|
+
style: { padding: '20px', color: 'red' },
|
|
1893
|
+
interactiveStyles: [
|
|
1894
|
+
{ postfix: ':hover', style: { base: { backgroundColor: 'green' } } },
|
|
1895
|
+
],
|
|
1896
|
+
} as ComponentNode),
|
|
1897
|
+
{
|
|
1898
|
+
globalComponents: { Layout: layoutDef, Button: buttonWithBoth },
|
|
1899
|
+
elementPath: [0],
|
|
1900
|
+
fileType: 'page',
|
|
1901
|
+
fileName: 'index',
|
|
1902
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1903
|
+
styleClasses,
|
|
1904
|
+
comboIdentityByName: new Map(),
|
|
1905
|
+
}
|
|
1906
|
+
);
|
|
1907
|
+
|
|
1908
|
+
const section = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1909
|
+
expect(section.tag).toBe('section');
|
|
1910
|
+
expect(section.comboClasses!).toHaveLength(1);
|
|
1911
|
+
|
|
1912
|
+
const merged = styleClasses.get(section.comboClasses![0]!)!;
|
|
1913
|
+
expect(merged).toBeDefined();
|
|
1914
|
+
expect(merged.comboParent).toBe(section.className);
|
|
1915
|
+
// Inner StyleMapping delta survives.
|
|
1916
|
+
expect(merged.base['background-color']).toBe('#eee');
|
|
1917
|
+
// Instance override wins on conflicts.
|
|
1918
|
+
expect(merged.base.color).toBe('red');
|
|
1919
|
+
// Instance-only key passes through (shorthand expanded by styleMapper).
|
|
1920
|
+
expect(merged.base['padding-top']).toBe('20px');
|
|
1921
|
+
expect(merged.base['padding-right']).toBe('20px');
|
|
1922
|
+
// Pseudo-state from the instance override comes along.
|
|
1923
|
+
expect(merged.pseudoStates?.hover?.['background-color']).toBe('green');
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
test('promoted component (Navigation) with acceptsStyles + instance style skips promotion and emits a combo', async () => {
|
|
1927
|
+
const navWithAccepts: ComponentDefinition = {
|
|
1928
|
+
component: {
|
|
1929
|
+
acceptsStyles: true,
|
|
1930
|
+
structure: {
|
|
1931
|
+
type: 'node',
|
|
1932
|
+
tag: 'nav',
|
|
1933
|
+
children: [{ type: 'node', tag: 'a', children: 'Home' } as ComponentNode],
|
|
1934
|
+
} as ComponentNode,
|
|
1935
|
+
},
|
|
1936
|
+
};
|
|
1937
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
1938
|
+
const promoted = new Map<string, WebflowComponentDef>();
|
|
1939
|
+
|
|
1940
|
+
const els = await nodeToWebflow(
|
|
1941
|
+
pageRoot({
|
|
1942
|
+
type: 'component',
|
|
1943
|
+
component: 'Navigation',
|
|
1944
|
+
style: { fontSize: '40px' },
|
|
1945
|
+
} as ComponentNode),
|
|
1946
|
+
{
|
|
1947
|
+
globalComponents: { Layout: layoutDef, Navigation: navWithAccepts },
|
|
1948
|
+
elementPath: [0],
|
|
1949
|
+
fileType: 'page',
|
|
1950
|
+
fileName: 'home',
|
|
1951
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
1952
|
+
styleClasses,
|
|
1953
|
+
comboIdentityByName: new Map(),
|
|
1954
|
+
promotedComponents: promoted,
|
|
1955
|
+
}
|
|
1956
|
+
);
|
|
1957
|
+
|
|
1958
|
+
// Promotion was skipped → no entry in `promoted`, no componentRef wrapper.
|
|
1959
|
+
expect(promoted.has('Navigation')).toBe(false);
|
|
1960
|
+
const nav = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1961
|
+
expect(nav.componentRef).toBeUndefined();
|
|
1962
|
+
expect(nav.tag).toBe('nav');
|
|
1963
|
+
expect(nav.comboClasses!).toHaveLength(1);
|
|
1964
|
+
const combo = styleClasses.get(nav.comboClasses![0]!)!;
|
|
1965
|
+
expect(combo.base['font-size']).toBe('40px');
|
|
1966
|
+
});
|
|
1967
|
+
|
|
1968
|
+
test('combo name collision triggers re-hash when two identities collapse to the same 5-char slice', async () => {
|
|
1969
|
+
// First emit: discover the combo name this Card placement naturally gets.
|
|
1970
|
+
const baselineStyles = new Map<string, WebflowStyleClass>();
|
|
1971
|
+
const baselineEls = await nodeToWebflow(
|
|
1972
|
+
pageRoot({
|
|
1973
|
+
type: 'component',
|
|
1974
|
+
component: 'Card',
|
|
1975
|
+
style: { fontSize: '40px' },
|
|
1976
|
+
} as ComponentNode),
|
|
1977
|
+
makeAcceptsCtx('index', baselineStyles)
|
|
1978
|
+
);
|
|
1979
|
+
const baselineSection = (baselineEls[0] as WebflowElement).children![0] as WebflowElement;
|
|
1980
|
+
const baselineCombo = baselineSection.comboClasses![0]!;
|
|
1981
|
+
|
|
1982
|
+
// Re-emit with the combo slot pre-claimed by a different identity.
|
|
1983
|
+
// mintInstanceComboName must detect the collision and bump to a fresh hash.
|
|
1984
|
+
const styles = new Map<string, WebflowStyleClass>();
|
|
1985
|
+
const comboIdentityByName = new Map<string, string>([[baselineCombo, 'stranger:identity']]);
|
|
1986
|
+
const els = await nodeToWebflow(
|
|
1987
|
+
pageRoot({
|
|
1988
|
+
type: 'component',
|
|
1989
|
+
component: 'Card',
|
|
1990
|
+
style: { fontSize: '40px' },
|
|
1991
|
+
} as ComponentNode),
|
|
1992
|
+
makeAcceptsCtx('index', styles, { comboIdentityByName })
|
|
1993
|
+
);
|
|
1994
|
+
const section = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
1995
|
+
const combo = section.comboClasses![0]!;
|
|
1996
|
+
expect(combo).not.toBe(baselineCombo);
|
|
1997
|
+
expect(combo).toMatch(/^is-[a-z0-9]{5}$/);
|
|
1998
|
+
// The pre-existing claim survives — the regenerator picked a fresh slot.
|
|
1999
|
+
expect(comboIdentityByName.get(baselineCombo)).toBe('stranger:identity');
|
|
2000
|
+
// Our placement registered under the new name.
|
|
2001
|
+
expect(comboIdentityByName.has(combo)).toBe(true);
|
|
2002
|
+
expect(styles.get(combo)!.base['font-size']).toBe('40px');
|
|
2003
|
+
});
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
describe('nodeToWebflow — CMS template context inside component body', () => {
|
|
2007
|
+
// Mirrors the pro-1 BlogListCard pattern: a component declares an empty
|
|
2008
|
+
// interface and references a CMS iteration variable (`{{post.title}}`) from
|
|
2009
|
+
// its body. SSR leaks `ctx.templateContext` into the body so this resolves;
|
|
2010
|
+
// the Webflow exporter must do the same or the cards render empty.
|
|
2011
|
+
const cardDef: ComponentDefinition = {
|
|
2012
|
+
component: {
|
|
2013
|
+
structure: {
|
|
2014
|
+
type: 'link',
|
|
2015
|
+
href: '{{post._url}}',
|
|
2016
|
+
children: [
|
|
2017
|
+
{ type: 'node', tag: 'h2', children: '{{post.title}}' } as ComponentNode,
|
|
2018
|
+
],
|
|
2019
|
+
} as ComponentNode,
|
|
2020
|
+
interface: {},
|
|
2021
|
+
},
|
|
2022
|
+
};
|
|
2023
|
+
|
|
2024
|
+
test('component with empty interface still resolves {{post.*}} templates from list iteration context', async () => {
|
|
2025
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2026
|
+
const ctx: WebflowEmitContext = {
|
|
2027
|
+
globalComponents: { Layout: layoutDef, BlogListCard: cardDef },
|
|
2028
|
+
elementPath: [0],
|
|
2029
|
+
fileType: 'page',
|
|
2030
|
+
fileName: 'home',
|
|
2031
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2032
|
+
styleClasses,
|
|
2033
|
+
comboIdentityByName: new Map(),
|
|
2034
|
+
// Simulates the templateContext expandListItems sets per item.
|
|
2035
|
+
templateContext: { post: { _url: '/blog/hello', title: 'Hello, world' } },
|
|
2036
|
+
};
|
|
2037
|
+
|
|
2038
|
+
const els = await nodeToWebflow(
|
|
2039
|
+
pageRoot({ type: 'component', component: 'BlogListCard' } as ComponentNode),
|
|
2040
|
+
ctx
|
|
2041
|
+
);
|
|
2042
|
+
|
|
2043
|
+
const layout = els[0] as WebflowElement;
|
|
2044
|
+
const link = layout.children![0] as WebflowElement;
|
|
2045
|
+
const h2 = link.children![0] as WebflowElement;
|
|
2046
|
+
|
|
2047
|
+
expect(link.tag).toBe('a');
|
|
2048
|
+
expect(link.attributes?.href).toBe('/blog/hello');
|
|
2049
|
+
expect(h2.textContent).toBe('Hello, world');
|
|
2050
|
+
});
|
|
2051
|
+
});
|
|
2052
|
+
|
|
2053
|
+
describe('nodeToWebflow — embed SVG currentColor resolution', () => {
|
|
2054
|
+
const SVG = '<svg viewBox="0 0 10 10"><path fill="currentColor" stroke="currentColor" d="M0 0h10v10H0z"/></svg>';
|
|
2055
|
+
|
|
2056
|
+
test('SVG currentColor inlines the embed node\'s own color', async () => {
|
|
2057
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2058
|
+
const els = await nodeToWebflow(
|
|
2059
|
+
{
|
|
2060
|
+
type: 'embed',
|
|
2061
|
+
html: SVG,
|
|
2062
|
+
style: { base: { color: '#ff0000' } },
|
|
2063
|
+
} as ComponentNode,
|
|
2064
|
+
makeCtx('index', styleClasses)
|
|
2065
|
+
);
|
|
2066
|
+
const embed = els[0] as WebflowElement;
|
|
2067
|
+
expect(embed.svgSource).toContain('fill="#ff0000"');
|
|
2068
|
+
expect(embed.svgSource).toContain('stroke="#ff0000"');
|
|
2069
|
+
expect(embed.svgSource).not.toContain('currentColor');
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
test('SVG currentColor inherits ancestor color when embed has none', async () => {
|
|
2073
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2074
|
+
const els = await nodeToWebflow(
|
|
2075
|
+
{
|
|
2076
|
+
type: 'node',
|
|
2077
|
+
tag: 'div',
|
|
2078
|
+
style: { base: { color: 'rgb(0, 128, 255)' } },
|
|
2079
|
+
children: [
|
|
2080
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2081
|
+
],
|
|
2082
|
+
} as ComponentNode,
|
|
2083
|
+
makeCtx('index', styleClasses)
|
|
2084
|
+
);
|
|
2085
|
+
const wrapper = els[0] as WebflowElement;
|
|
2086
|
+
const embed = wrapper.children![0] as WebflowElement;
|
|
2087
|
+
expect(embed.svgSource).toContain('fill="rgb(0, 128, 255)"');
|
|
2088
|
+
expect(embed.svgSource).not.toContain('currentColor');
|
|
2089
|
+
});
|
|
2090
|
+
|
|
2091
|
+
test('embed color overrides ancestor color', async () => {
|
|
2092
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2093
|
+
const els = await nodeToWebflow(
|
|
2094
|
+
{
|
|
2095
|
+
type: 'node',
|
|
2096
|
+
tag: 'div',
|
|
2097
|
+
style: { base: { color: 'red' } },
|
|
2098
|
+
children: [
|
|
2099
|
+
{
|
|
2100
|
+
type: 'embed',
|
|
2101
|
+
html: SVG,
|
|
2102
|
+
style: { base: { color: 'blue' } },
|
|
2103
|
+
} as ComponentNode,
|
|
2104
|
+
],
|
|
2105
|
+
} as ComponentNode,
|
|
2106
|
+
makeCtx('index', styleClasses)
|
|
2107
|
+
);
|
|
2108
|
+
const wrapper = els[0] as WebflowElement;
|
|
2109
|
+
const embed = wrapper.children![0] as WebflowElement;
|
|
2110
|
+
expect(embed.svgSource).toContain('fill="blue"');
|
|
2111
|
+
expect(embed.svgSource).not.toContain('red');
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
test('SVG without currentColor is left untouched', async () => {
|
|
2115
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2116
|
+
const original = '<svg viewBox="0 0 10 10"><path fill="#abcdef" d="M0 0h10v10H0z"/></svg>';
|
|
2117
|
+
const els = await nodeToWebflow(
|
|
2118
|
+
{
|
|
2119
|
+
type: 'embed',
|
|
2120
|
+
html: original,
|
|
2121
|
+
style: { base: { color: 'red' } },
|
|
2122
|
+
} as ComponentNode,
|
|
2123
|
+
makeCtx('index', styleClasses)
|
|
2124
|
+
);
|
|
2125
|
+
const embed = els[0] as WebflowElement;
|
|
2126
|
+
expect(embed.svgSource).toBe(original);
|
|
2127
|
+
});
|
|
2128
|
+
|
|
2129
|
+
test('with no resolvable color, currentColor is left as-is', async () => {
|
|
2130
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2131
|
+
const els = await nodeToWebflow(
|
|
2132
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2133
|
+
makeCtx('index', styleClasses)
|
|
2134
|
+
);
|
|
2135
|
+
const embed = els[0] as WebflowElement;
|
|
2136
|
+
expect(embed.svgSource).toBe(SVG);
|
|
2137
|
+
});
|
|
2138
|
+
|
|
2139
|
+
test('color: inherit on embed falls through to ancestor color', async () => {
|
|
2140
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2141
|
+
const els = await nodeToWebflow(
|
|
2142
|
+
{
|
|
2143
|
+
type: 'node',
|
|
2144
|
+
tag: 'div',
|
|
2145
|
+
style: { base: { color: '#123456' } },
|
|
2146
|
+
children: [
|
|
2147
|
+
{
|
|
2148
|
+
type: 'embed',
|
|
2149
|
+
html: SVG,
|
|
2150
|
+
style: { base: { color: 'inherit' } },
|
|
2151
|
+
} as ComponentNode,
|
|
2152
|
+
],
|
|
2153
|
+
} as ComponentNode,
|
|
2154
|
+
makeCtx('index', styleClasses)
|
|
2155
|
+
);
|
|
2156
|
+
const wrapper = els[0] as WebflowElement;
|
|
2157
|
+
const embed = wrapper.children![0] as WebflowElement;
|
|
2158
|
+
expect(embed.svgSource).toContain('fill="#123456"');
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
test('link ancestor color flows to nested embed', async () => {
|
|
2162
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2163
|
+
const els = await nodeToWebflow(
|
|
2164
|
+
{
|
|
2165
|
+
type: 'link',
|
|
2166
|
+
href: '/x',
|
|
2167
|
+
style: { base: { color: 'orange' } },
|
|
2168
|
+
children: [
|
|
2169
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2170
|
+
],
|
|
2171
|
+
} as ComponentNode,
|
|
2172
|
+
makeCtx('index', styleClasses)
|
|
2173
|
+
);
|
|
2174
|
+
const link = els[0] as WebflowElement;
|
|
2175
|
+
const embed = link.children![0] as WebflowElement;
|
|
2176
|
+
expect(embed.svgSource).toContain('fill="orange"');
|
|
2177
|
+
});
|
|
2178
|
+
|
|
2179
|
+
test('variant-mapped link color resolves through componentDefaults for nested embed', async () => {
|
|
2180
|
+
// Mirrors Button.json: link.color is a StyleMapping keyed on `variant`,
|
|
2181
|
+
// and a child embed's SVG uses `currentColor`. Without StyleMapping
|
|
2182
|
+
// resolution the arrow ships with `currentColor` intact and Webflow
|
|
2183
|
+
// paints the standalone SVG asset black.
|
|
2184
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2185
|
+
const els = await nodeToWebflow(
|
|
2186
|
+
{
|
|
2187
|
+
type: 'link',
|
|
2188
|
+
href: '/x',
|
|
2189
|
+
style: {
|
|
2190
|
+
base: {
|
|
2191
|
+
color: {
|
|
2192
|
+
_mapping: true,
|
|
2193
|
+
prop: 'variant',
|
|
2194
|
+
values: { primary: '#ffffff', secondary: '#000000' },
|
|
2195
|
+
},
|
|
2196
|
+
},
|
|
2197
|
+
},
|
|
2198
|
+
children: [
|
|
2199
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2200
|
+
],
|
|
2201
|
+
} as ComponentNode,
|
|
2202
|
+
makeCtx('index', styleClasses, { componentDefaults: { variant: 'primary' } })
|
|
2203
|
+
);
|
|
2204
|
+
const link = els[0] as WebflowElement;
|
|
2205
|
+
const embed = link.children![0] as WebflowElement;
|
|
2206
|
+
expect(embed.svgSource).toContain('fill="#ffffff"');
|
|
2207
|
+
expect(embed.svgSource).not.toContain('currentColor');
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
test('variant-mapped link color: instance prop wins over componentDefaults', async () => {
|
|
2211
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2212
|
+
// A wrapper component instantiates a Button-like link with variant=secondary
|
|
2213
|
+
const innerComponentDef: ComponentDefinition = {
|
|
2214
|
+
component: {
|
|
2215
|
+
interface: {
|
|
2216
|
+
variant: { type: 'string' as any, default: 'primary' },
|
|
2217
|
+
},
|
|
2218
|
+
structure: {
|
|
2219
|
+
type: 'link',
|
|
2220
|
+
href: '#',
|
|
2221
|
+
style: {
|
|
2222
|
+
base: {
|
|
2223
|
+
color: {
|
|
2224
|
+
_mapping: true,
|
|
2225
|
+
prop: 'variant',
|
|
2226
|
+
values: { primary: '#ffffff', secondary: '#000000' },
|
|
2227
|
+
},
|
|
2228
|
+
},
|
|
2229
|
+
},
|
|
2230
|
+
children: [
|
|
2231
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2232
|
+
],
|
|
2233
|
+
},
|
|
2234
|
+
},
|
|
2235
|
+
};
|
|
2236
|
+
const els = await nodeToWebflow(
|
|
2237
|
+
pageRoot({
|
|
2238
|
+
type: 'component',
|
|
2239
|
+
component: 'BtnLike',
|
|
2240
|
+
props: { variant: 'secondary' },
|
|
2241
|
+
} as ComponentNode),
|
|
2242
|
+
makeCtx('index', styleClasses, {
|
|
2243
|
+
globalComponents: { Layout: layoutDef, BtnLike: innerComponentDef },
|
|
2244
|
+
})
|
|
2245
|
+
);
|
|
2246
|
+
// Walk down to the embed
|
|
2247
|
+
const layout = els[0] as WebflowElement;
|
|
2248
|
+
const link = layout.children![0] as WebflowElement;
|
|
2249
|
+
const embed = link.children![0] as WebflowElement;
|
|
2250
|
+
expect(embed.svgSource).toContain('fill="#000000"');
|
|
2251
|
+
expect(embed.svgSource).not.toContain('currentColor');
|
|
2252
|
+
});
|
|
2253
|
+
|
|
2254
|
+
test('mapped color carrying var(--…) is resolved against project vars', async () => {
|
|
2255
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2256
|
+
const els = await nodeToWebflow(
|
|
2257
|
+
{
|
|
2258
|
+
type: 'link',
|
|
2259
|
+
href: '/x',
|
|
2260
|
+
style: {
|
|
2261
|
+
base: {
|
|
2262
|
+
color: {
|
|
2263
|
+
_mapping: true,
|
|
2264
|
+
prop: 'variant',
|
|
2265
|
+
values: { primary: 'var(--bg)' },
|
|
2266
|
+
},
|
|
2267
|
+
},
|
|
2268
|
+
},
|
|
2269
|
+
children: [
|
|
2270
|
+
{ type: 'embed', html: SVG } as ComponentNode,
|
|
2271
|
+
],
|
|
2272
|
+
} as ComponentNode,
|
|
2273
|
+
makeCtx('index', styleClasses, {
|
|
2274
|
+
componentDefaults: { variant: 'primary' },
|
|
2275
|
+
projectVars: { base: { '--bg': '#fefefe' } },
|
|
2276
|
+
})
|
|
2277
|
+
);
|
|
2278
|
+
const link = els[0] as WebflowElement;
|
|
2279
|
+
const embed = link.children![0] as WebflowElement;
|
|
2280
|
+
expect(embed.svgSource).toContain('fill="#fefefe"');
|
|
2281
|
+
expect(embed.svgSource).not.toContain('var(--bg)');
|
|
2282
|
+
});
|
|
2283
|
+
});
|
|
2284
|
+
|
|
2285
|
+
describe('nodeToWebflow — embed html mapping resolution', () => {
|
|
2286
|
+
// Mirrors `Icon.json`: an embed whose `html` is an HtmlMapping bound to the
|
|
2287
|
+
// `icon` enum prop. The exporter must resolve the mapping against the
|
|
2288
|
+
// component instance props, otherwise the SVG silently exports as empty.
|
|
2289
|
+
const STAR_SVG = '<svg viewBox="0 0 10 10"><path d="M5 0l1 4h4l-3 3 1 4-3-2-3 2 1-4-3-3h4z"/></svg>';
|
|
2290
|
+
const X_SVG = '<svg viewBox="0 0 10 10"><path d="M0 0L10 10M10 0L0 10"/></svg>';
|
|
2291
|
+
|
|
2292
|
+
const iconDef: ComponentDefinition = {
|
|
2293
|
+
component: {
|
|
2294
|
+
interface: {
|
|
2295
|
+
icon: { type: 'string' as any, default: 'star' },
|
|
2296
|
+
},
|
|
2297
|
+
structure: {
|
|
2298
|
+
type: 'embed',
|
|
2299
|
+
html: {
|
|
2300
|
+
_mapping: true,
|
|
2301
|
+
prop: 'icon',
|
|
2302
|
+
values: { star: STAR_SVG, x: X_SVG },
|
|
2303
|
+
},
|
|
2304
|
+
} as unknown as ComponentNode,
|
|
2305
|
+
},
|
|
2306
|
+
};
|
|
2307
|
+
|
|
2308
|
+
test('resolves HtmlMapping against instance props', async () => {
|
|
2309
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2310
|
+
const els = await nodeToWebflow(
|
|
2311
|
+
pageRoot({
|
|
2312
|
+
type: 'component',
|
|
2313
|
+
component: 'Icon',
|
|
2314
|
+
props: { icon: 'x' },
|
|
2315
|
+
} as ComponentNode),
|
|
2316
|
+
makeCtx('index', styleClasses, {
|
|
2317
|
+
globalComponents: { Layout: layoutDef, Icon: iconDef },
|
|
2318
|
+
})
|
|
2319
|
+
);
|
|
2320
|
+
const embed = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
2321
|
+
expect(embed.svgSource).toContain('M0 0L10 10');
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
test('falls back to interface default when prop is omitted', async () => {
|
|
2325
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2326
|
+
const els = await nodeToWebflow(
|
|
2327
|
+
pageRoot({
|
|
2328
|
+
type: 'component',
|
|
2329
|
+
component: 'Icon',
|
|
2330
|
+
} as ComponentNode),
|
|
2331
|
+
makeCtx('index', styleClasses, {
|
|
2332
|
+
globalComponents: { Layout: layoutDef, Icon: iconDef },
|
|
2333
|
+
})
|
|
2334
|
+
);
|
|
2335
|
+
const embed = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
2336
|
+
expect(embed.svgSource).toContain('M5 0l1 4h4');
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
test('unknown enum value yields empty embed (matches SSR semantics)', async () => {
|
|
2340
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2341
|
+
const els = await nodeToWebflow(
|
|
2342
|
+
pageRoot({
|
|
2343
|
+
type: 'component',
|
|
2344
|
+
component: 'Icon',
|
|
2345
|
+
props: { icon: 'does-not-exist' },
|
|
2346
|
+
} as ComponentNode),
|
|
2347
|
+
makeCtx('index', styleClasses, {
|
|
2348
|
+
globalComponents: { Layout: layoutDef, Icon: iconDef },
|
|
2349
|
+
})
|
|
2350
|
+
);
|
|
2351
|
+
const embed = (els[0] as WebflowElement).children![0] as WebflowElement;
|
|
2352
|
+
expect(embed.svgSource).toBeUndefined();
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
// Mirrors `FaqAccordion → list iteration → FaqItem → Icon`: the icon prop
|
|
2356
|
+
// is a literal but Icon's body lives two component-inlining layers below a
|
|
2357
|
+
// list expansion. Each step has to forward the resolved instance props down
|
|
2358
|
+
// to the embed so the mapping can resolve. A regression here returns an
|
|
2359
|
+
// empty embed for every chevron, which is what the user reported.
|
|
2360
|
+
test('icon mapping resolves through nested components inside a list iteration', async () => {
|
|
2361
|
+
const faqItemDef: ComponentDefinition = {
|
|
2362
|
+
component: {
|
|
2363
|
+
interface: {
|
|
2364
|
+
question: { type: 'string' as any, default: '' },
|
|
2365
|
+
},
|
|
2366
|
+
structure: {
|
|
2367
|
+
type: 'node',
|
|
2368
|
+
tag: 'div',
|
|
2369
|
+
children: [
|
|
2370
|
+
{
|
|
2371
|
+
type: 'component',
|
|
2372
|
+
component: 'Icon',
|
|
2373
|
+
props: { icon: 'x', size: 20 },
|
|
2374
|
+
} as ComponentNode,
|
|
2375
|
+
],
|
|
2376
|
+
} as ComponentNode,
|
|
2377
|
+
},
|
|
2378
|
+
};
|
|
2379
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2380
|
+
const els = await nodeToWebflow(
|
|
2381
|
+
pageRoot({
|
|
2382
|
+
type: 'list',
|
|
2383
|
+
sourceType: 'prop',
|
|
2384
|
+
source: [{ q: 'a' }, { q: 'b' }],
|
|
2385
|
+
itemAs: 'item',
|
|
2386
|
+
children: [
|
|
2387
|
+
{
|
|
2388
|
+
type: 'component',
|
|
2389
|
+
component: 'FaqItem',
|
|
2390
|
+
props: { question: '{{item.q}}' },
|
|
2391
|
+
} as ComponentNode,
|
|
2392
|
+
],
|
|
2393
|
+
} as unknown as ComponentNode),
|
|
2394
|
+
makeCtx('index', styleClasses, {
|
|
2395
|
+
globalComponents: { Layout: layoutDef, Icon: iconDef, FaqItem: faqItemDef },
|
|
2396
|
+
})
|
|
2397
|
+
);
|
|
2398
|
+
// Layout > [FaqItem(div) x 2] — each FaqItem holds an Icon which inlines
|
|
2399
|
+
// to a single embed child.
|
|
2400
|
+
const layoutChildren = (els[0] as WebflowElement).children!;
|
|
2401
|
+
expect(layoutChildren).toHaveLength(2);
|
|
2402
|
+
for (const item of layoutChildren) {
|
|
2403
|
+
const itemEl = item as WebflowElement;
|
|
2404
|
+
const iconWrapper = itemEl.children![0] as WebflowElement;
|
|
2405
|
+
expect(iconWrapper.svgSource).toContain('M0 0L10 10');
|
|
2406
|
+
}
|
|
2407
|
+
});
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
describe('normalizeListChildren', () => {
|
|
2411
|
+
test('wraps anchor child of <ul> in synthetic <li>', () => {
|
|
2412
|
+
const tree: WebflowElement[] = [
|
|
2413
|
+
{
|
|
2414
|
+
tag: 'ul',
|
|
2415
|
+
children: [
|
|
2416
|
+
{ tag: 'a', attributes: { href: '/about' }, textContent: 'About' },
|
|
2417
|
+
{ tag: 'a', attributes: { href: '/blog' }, textContent: 'Blog' },
|
|
2418
|
+
],
|
|
2419
|
+
},
|
|
2420
|
+
];
|
|
2421
|
+
normalizeListChildren(tree);
|
|
2422
|
+
const ul = tree[0]!;
|
|
2423
|
+
expect(ul.children).toHaveLength(2);
|
|
2424
|
+
for (const child of ul.children!) {
|
|
2425
|
+
expect((child as WebflowElement).tag).toBe('li');
|
|
2426
|
+
const inner = (child as WebflowElement).children![0] as WebflowElement;
|
|
2427
|
+
expect(inner.tag).toBe('a');
|
|
2428
|
+
}
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
test('wraps text-string child of <ul> in synthetic <li>', () => {
|
|
2432
|
+
const tree: WebflowElement[] = [
|
|
2433
|
+
{
|
|
2434
|
+
tag: 'ol',
|
|
2435
|
+
children: ['stray text', { tag: 'li', textContent: 'real item' }],
|
|
2436
|
+
},
|
|
2437
|
+
];
|
|
2438
|
+
normalizeListChildren(tree);
|
|
2439
|
+
const ol = tree[0]!;
|
|
2440
|
+
expect(ol.children).toHaveLength(2);
|
|
2441
|
+
expect((ol.children![0] as WebflowElement).tag).toBe('li');
|
|
2442
|
+
expect((ol.children![0] as WebflowElement).children![0]).toBe('stray text');
|
|
2443
|
+
expect((ol.children![1] as WebflowElement).tag).toBe('li');
|
|
2444
|
+
expect((ol.children![1] as WebflowElement).textContent).toBe('real item');
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
test('mixed children — passes <li> through, wraps non-<li>', () => {
|
|
2448
|
+
const tree: WebflowElement[] = [
|
|
2449
|
+
{
|
|
2450
|
+
tag: 'ul',
|
|
2451
|
+
children: [
|
|
2452
|
+
{ tag: 'li', children: [{ tag: 'a', textContent: 'Real' }] },
|
|
2453
|
+
{ tag: 'a', textContent: 'Stray' },
|
|
2454
|
+
{ tag: 'div', textContent: 'Also stray' },
|
|
2455
|
+
],
|
|
2456
|
+
},
|
|
2457
|
+
];
|
|
2458
|
+
normalizeListChildren(tree);
|
|
2459
|
+
const ul = tree[0]!;
|
|
2460
|
+
expect(ul.children).toHaveLength(3);
|
|
2461
|
+
expect((ul.children![0] as WebflowElement).tag).toBe('li');
|
|
2462
|
+
// First was already an <li> with anchor inside — passes through.
|
|
2463
|
+
expect(((ul.children![0] as WebflowElement).children![0] as WebflowElement).tag).toBe('a');
|
|
2464
|
+
// Second got wrapped.
|
|
2465
|
+
expect((ul.children![1] as WebflowElement).tag).toBe('li');
|
|
2466
|
+
expect(((ul.children![1] as WebflowElement).children![0] as WebflowElement).tag).toBe('a');
|
|
2467
|
+
// Third got wrapped.
|
|
2468
|
+
expect((ul.children![2] as WebflowElement).tag).toBe('li');
|
|
2469
|
+
expect(((ul.children![2] as WebflowElement).children![0] as WebflowElement).tag).toBe('div');
|
|
2470
|
+
});
|
|
2471
|
+
|
|
2472
|
+
test('already-correct <ul><li> tree is left unchanged', () => {
|
|
2473
|
+
const tree: WebflowElement[] = [
|
|
2474
|
+
{
|
|
2475
|
+
tag: 'ul',
|
|
2476
|
+
children: [
|
|
2477
|
+
{ tag: 'li', children: [{ tag: 'a', textContent: 'A' }] },
|
|
2478
|
+
{ tag: 'li', children: [{ tag: 'a', textContent: 'B' }] },
|
|
2479
|
+
],
|
|
2480
|
+
},
|
|
2481
|
+
];
|
|
2482
|
+
const before = JSON.stringify(tree);
|
|
2483
|
+
normalizeListChildren(tree);
|
|
2484
|
+
expect(JSON.stringify(tree)).toBe(before);
|
|
2485
|
+
});
|
|
2486
|
+
|
|
2487
|
+
test('recurses into nested <ul> inside an <li>', () => {
|
|
2488
|
+
const tree: WebflowElement[] = [
|
|
2489
|
+
{
|
|
2490
|
+
tag: 'ul',
|
|
2491
|
+
children: [
|
|
2492
|
+
{
|
|
2493
|
+
tag: 'li',
|
|
2494
|
+
children: [
|
|
2495
|
+
{
|
|
2496
|
+
tag: 'ul',
|
|
2497
|
+
children: [{ tag: 'a', textContent: 'Nested' }],
|
|
2498
|
+
},
|
|
2499
|
+
],
|
|
2500
|
+
},
|
|
2501
|
+
],
|
|
2502
|
+
},
|
|
2503
|
+
];
|
|
2504
|
+
normalizeListChildren(tree);
|
|
2505
|
+
const innerUl = ((tree[0]!.children![0] as WebflowElement).children![0]) as WebflowElement;
|
|
2506
|
+
expect(innerUl.tag).toBe('ul');
|
|
2507
|
+
expect((innerUl.children![0] as WebflowElement).tag).toBe('li');
|
|
2508
|
+
expect(((innerUl.children![0] as WebflowElement).children![0] as WebflowElement).tag).toBe('a');
|
|
2509
|
+
});
|
|
2510
|
+
|
|
2511
|
+
test('recurses into inlineFallback', () => {
|
|
2512
|
+
const tree: WebflowElement[] = [
|
|
2513
|
+
{
|
|
2514
|
+
tag: 'div',
|
|
2515
|
+
componentRef: 'Navigation',
|
|
2516
|
+
inlineFallback: [
|
|
2517
|
+
{
|
|
2518
|
+
tag: 'ul',
|
|
2519
|
+
children: [{ tag: 'a', textContent: 'Home' }],
|
|
2520
|
+
},
|
|
2521
|
+
],
|
|
2522
|
+
},
|
|
2523
|
+
];
|
|
2524
|
+
normalizeListChildren(tree);
|
|
2525
|
+
const fallbackUl = tree[0]!.inlineFallback![0]!;
|
|
2526
|
+
expect((fallbackUl.children![0] as WebflowElement).tag).toBe('li');
|
|
2527
|
+
expect(((fallbackUl.children![0] as WebflowElement).children![0] as WebflowElement).tag).toBe('a');
|
|
2528
|
+
});
|
|
2529
|
+
|
|
2530
|
+
test('non-list parents are unaffected', () => {
|
|
2531
|
+
const tree: WebflowElement[] = [
|
|
2532
|
+
{
|
|
2533
|
+
tag: 'div',
|
|
2534
|
+
children: [
|
|
2535
|
+
{ tag: 'a', textContent: 'Link' },
|
|
2536
|
+
{ tag: 'span', textContent: 'Text' },
|
|
2537
|
+
],
|
|
2538
|
+
},
|
|
2539
|
+
];
|
|
2540
|
+
const before = JSON.stringify(tree);
|
|
2541
|
+
normalizeListChildren(tree);
|
|
2542
|
+
expect(JSON.stringify(tree)).toBe(before);
|
|
2543
|
+
});
|
|
2544
|
+
});
|
|
2545
|
+
|
|
2546
|
+
describe('nodeToWebflow — prop-bound list inside component body', () => {
|
|
2547
|
+
// Mirrors example/components/ListSection.json: a component whose body iterates
|
|
2548
|
+
// an array prop (`items`) and renders one card per element with `{{item.x}}`
|
|
2549
|
+
// templates. The page (example/pages/list-demo.json) passes the array as an
|
|
2550
|
+
// instance prop. The Webflow exporter must statically expand the list into
|
|
2551
|
+
// one rendered child per item, with templates resolved against the per-item
|
|
2552
|
+
// context — otherwise the Webflow Designer receives an empty wrapper.
|
|
2553
|
+
const listSectionDef: ComponentDefinition = {
|
|
2554
|
+
component: {
|
|
2555
|
+
structure: {
|
|
2556
|
+
type: 'node',
|
|
2557
|
+
tag: 'section',
|
|
2558
|
+
children: [
|
|
2559
|
+
{
|
|
2560
|
+
type: 'list',
|
|
2561
|
+
sourceType: 'prop',
|
|
2562
|
+
source: 'items',
|
|
2563
|
+
itemAs: 'item',
|
|
2564
|
+
children: [
|
|
2565
|
+
{
|
|
2566
|
+
type: 'node',
|
|
2567
|
+
tag: 'div',
|
|
2568
|
+
children: [
|
|
2569
|
+
{ type: 'node', tag: 'h3', children: '{{item.title}}' } as ComponentNode,
|
|
2570
|
+
{ type: 'node', tag: 'p', children: '{{item.description}}' } as ComponentNode,
|
|
2571
|
+
],
|
|
2572
|
+
} as ComponentNode,
|
|
2573
|
+
],
|
|
2574
|
+
} as unknown as ComponentNode,
|
|
2575
|
+
],
|
|
2576
|
+
} as ComponentNode,
|
|
2577
|
+
interface: {
|
|
2578
|
+
items: { type: 'list' as any, default: [] },
|
|
2579
|
+
},
|
|
2580
|
+
},
|
|
2581
|
+
};
|
|
2582
|
+
|
|
2583
|
+
test('expands children once per array item with templates resolved', async () => {
|
|
2584
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2585
|
+
const ctx: WebflowEmitContext = {
|
|
2586
|
+
globalComponents: { Layout: layoutDef, ListSection: listSectionDef },
|
|
2587
|
+
elementPath: [0],
|
|
2588
|
+
fileType: 'page',
|
|
2589
|
+
fileName: 'list-demo',
|
|
2590
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2591
|
+
styleClasses,
|
|
2592
|
+
comboIdentityByName: new Map(),
|
|
2593
|
+
};
|
|
2594
|
+
|
|
2595
|
+
const els = await nodeToWebflow(
|
|
2596
|
+
pageRoot({
|
|
2597
|
+
type: 'component',
|
|
2598
|
+
component: 'ListSection',
|
|
2599
|
+
props: {
|
|
2600
|
+
items: [
|
|
2601
|
+
{ title: 'Fast', description: 'Built for speed' },
|
|
2602
|
+
{ title: 'Simple', description: 'Easy to use' },
|
|
2603
|
+
],
|
|
2604
|
+
},
|
|
2605
|
+
} as ComponentNode),
|
|
2606
|
+
ctx
|
|
2607
|
+
);
|
|
2608
|
+
|
|
2609
|
+
const layout = els[0] as WebflowElement;
|
|
2610
|
+
const section = layout.children![0] as WebflowElement;
|
|
2611
|
+
|
|
2612
|
+
// The list renders one wrapper div per item — no synthetic list wrapper.
|
|
2613
|
+
expect(section.children).toBeDefined();
|
|
2614
|
+
expect(section.children!.length).toBe(2);
|
|
2615
|
+
|
|
2616
|
+
const card1 = section.children![0] as WebflowElement;
|
|
2617
|
+
const card2 = section.children![1] as WebflowElement;
|
|
2618
|
+
|
|
2619
|
+
expect(card1.tag).toBe('div');
|
|
2620
|
+
expect(card2.tag).toBe('div');
|
|
2621
|
+
|
|
2622
|
+
const h3a = card1.children![0] as WebflowElement;
|
|
2623
|
+
const pa = card1.children![1] as WebflowElement;
|
|
2624
|
+
const h3b = card2.children![0] as WebflowElement;
|
|
2625
|
+
const pb = card2.children![1] as WebflowElement;
|
|
2626
|
+
|
|
2627
|
+
expect(h3a.textContent).toBe('Fast');
|
|
2628
|
+
expect(pa.textContent).toBe('Built for speed');
|
|
2629
|
+
expect(h3b.textContent).toBe('Simple');
|
|
2630
|
+
expect(pb.textContent).toBe('Easy to use');
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
test('emits zero children when prop is missing and interface default is []', async () => {
|
|
2634
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2635
|
+
const ctx: WebflowEmitContext = {
|
|
2636
|
+
globalComponents: { Layout: layoutDef, ListSection: listSectionDef },
|
|
2637
|
+
elementPath: [0],
|
|
2638
|
+
fileType: 'page',
|
|
2639
|
+
fileName: 'list-demo',
|
|
2640
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2641
|
+
styleClasses,
|
|
2642
|
+
comboIdentityByName: new Map(),
|
|
2643
|
+
};
|
|
2644
|
+
|
|
2645
|
+
const els = await nodeToWebflow(
|
|
2646
|
+
pageRoot({ type: 'component', component: 'ListSection' } as ComponentNode),
|
|
2647
|
+
ctx
|
|
2648
|
+
);
|
|
2649
|
+
|
|
2650
|
+
const layout = els[0] as WebflowElement;
|
|
2651
|
+
const section = layout.children![0] as WebflowElement;
|
|
2652
|
+
expect(section.children === undefined || section.children.length === 0).toBe(true);
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
test('expands children when locale + i18nConfig are set (mirrors buildWebflowPayload ctx)', async () => {
|
|
2656
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2657
|
+
const ctx: WebflowEmitContext = {
|
|
2658
|
+
globalComponents: { Layout: layoutDef, ListSection: listSectionDef },
|
|
2659
|
+
elementPath: [0],
|
|
2660
|
+
fileType: 'page',
|
|
2661
|
+
fileName: 'list-demo',
|
|
2662
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2663
|
+
styleClasses,
|
|
2664
|
+
comboIdentityByName: new Map(),
|
|
2665
|
+
locale: 'en',
|
|
2666
|
+
i18nConfig: {
|
|
2667
|
+
defaultLocale: 'en',
|
|
2668
|
+
locales: [
|
|
2669
|
+
{ code: 'en', name: 'EN', nativeName: 'EN', langTag: 'en-US' } as any,
|
|
2670
|
+
{ code: 'pl', name: 'PL', nativeName: 'PL', langTag: 'pl-PL' } as any,
|
|
2671
|
+
],
|
|
2672
|
+
} as any,
|
|
2673
|
+
};
|
|
2674
|
+
|
|
2675
|
+
const els = await nodeToWebflow(
|
|
2676
|
+
pageRoot({
|
|
2677
|
+
type: 'component',
|
|
2678
|
+
component: 'ListSection',
|
|
2679
|
+
props: {
|
|
2680
|
+
items: [
|
|
2681
|
+
{ title: 'Fast', description: 'Built for speed' },
|
|
2682
|
+
{ title: 'Simple', description: 'Easy to use' },
|
|
2683
|
+
],
|
|
2684
|
+
},
|
|
2685
|
+
} as ComponentNode),
|
|
2686
|
+
ctx
|
|
2687
|
+
);
|
|
2688
|
+
|
|
2689
|
+
const layout = els[0] as WebflowElement;
|
|
2690
|
+
const section = layout.children![0] as WebflowElement;
|
|
2691
|
+
expect(section.children!.length).toBe(2);
|
|
2692
|
+
const card1 = section.children![0] as WebflowElement;
|
|
2693
|
+
const h3a = card1.children![0] as WebflowElement;
|
|
2694
|
+
expect(h3a.textContent).toBe('Fast');
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
test('templated source `{{items}}` referring to the component\'s own prop', async () => {
|
|
2698
|
+
// Real user pattern from adv-1's StatsSimpleGrid / TeamGrid / etc: the list
|
|
2699
|
+
// source is the templated form `{{items}}` (matching the editor's emitted
|
|
2700
|
+
// shape) rather than the bare prop name `items`. SSR side-steps this via
|
|
2701
|
+
// `processStructure` pre-substituting `{{items}}` to the array before the
|
|
2702
|
+
// list node is reached; the Webflow exporter walks the raw JSON and must
|
|
2703
|
+
// resolve the template against `instanceProps` itself.
|
|
2704
|
+
const def: ComponentDefinition = {
|
|
2705
|
+
component: {
|
|
2706
|
+
structure: {
|
|
2707
|
+
type: 'list',
|
|
2708
|
+
sourceType: 'prop',
|
|
2709
|
+
source: '{{items}}',
|
|
2710
|
+
itemAs: 'item',
|
|
2711
|
+
children: [
|
|
2712
|
+
{ type: 'node', tag: 'div', children: '{{item.label}}' } as ComponentNode,
|
|
2713
|
+
],
|
|
2714
|
+
} as unknown as ComponentNode,
|
|
2715
|
+
interface: {
|
|
2716
|
+
items: { type: 'list' as any, default: [] },
|
|
2717
|
+
},
|
|
2718
|
+
},
|
|
2719
|
+
};
|
|
2720
|
+
|
|
2721
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2722
|
+
const ctx: WebflowEmitContext = {
|
|
2723
|
+
globalComponents: { Layout: layoutDef, Stats: def },
|
|
2724
|
+
elementPath: [0],
|
|
2725
|
+
fileType: 'page',
|
|
2726
|
+
fileName: 'home',
|
|
2727
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2728
|
+
styleClasses,
|
|
2729
|
+
comboIdentityByName: new Map(),
|
|
2730
|
+
};
|
|
2731
|
+
|
|
2732
|
+
const els = await nodeToWebflow(
|
|
2733
|
+
pageRoot({
|
|
2734
|
+
type: 'component',
|
|
2735
|
+
component: 'Stats',
|
|
2736
|
+
props: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
|
|
2737
|
+
} as ComponentNode),
|
|
2738
|
+
ctx
|
|
2739
|
+
);
|
|
2740
|
+
|
|
2741
|
+
const layout = els[0] as WebflowElement;
|
|
2742
|
+
expect(layout.children!.length).toBe(3);
|
|
2743
|
+
const card1 = layout.children![0] as WebflowElement;
|
|
2744
|
+
const card2 = layout.children![1] as WebflowElement;
|
|
2745
|
+
const card3 = layout.children![2] as WebflowElement;
|
|
2746
|
+
expect(card1.textContent).toBe('A');
|
|
2747
|
+
expect(card2.textContent).toBe('B');
|
|
2748
|
+
expect(card3.textContent).toBe('C');
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
test('templated source `{{outer.items}}` resolves through outer list iteration', async () => {
|
|
2752
|
+
// Inner component reads its own list from a template expression that points
|
|
2753
|
+
// into the outer list's iteration variable — exercises the `{{...}}` branch
|
|
2754
|
+
// of getPropItemsForExport (nodeToWebflow.ts:1734-1741).
|
|
2755
|
+
const innerDef: ComponentDefinition = {
|
|
2756
|
+
component: {
|
|
2757
|
+
structure: {
|
|
2758
|
+
type: 'list',
|
|
2759
|
+
sourceType: 'prop',
|
|
2760
|
+
source: '{{category.items}}',
|
|
2761
|
+
itemAs: 'item',
|
|
2762
|
+
children: [
|
|
2763
|
+
{ type: 'node', tag: 'span', children: '{{item.title}}' } as ComponentNode,
|
|
2764
|
+
],
|
|
2765
|
+
} as unknown as ComponentNode,
|
|
2766
|
+
interface: {},
|
|
2767
|
+
},
|
|
2768
|
+
};
|
|
2769
|
+
|
|
2770
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2771
|
+
const ctx: WebflowEmitContext = {
|
|
2772
|
+
globalComponents: { Layout: layoutDef, Inner: innerDef },
|
|
2773
|
+
elementPath: [0],
|
|
2774
|
+
fileType: 'page',
|
|
2775
|
+
fileName: 'home',
|
|
2776
|
+
breakpoints: DEFAULT_BREAKPOINTS,
|
|
2777
|
+
styleClasses,
|
|
2778
|
+
comboIdentityByName: new Map(),
|
|
2779
|
+
};
|
|
2780
|
+
|
|
2781
|
+
const outer: ComponentNode = {
|
|
2782
|
+
type: 'list',
|
|
2783
|
+
sourceType: 'prop',
|
|
2784
|
+
source: 'categories',
|
|
2785
|
+
itemAs: 'category',
|
|
2786
|
+
children: [
|
|
2787
|
+
{ type: 'component', component: 'Inner' } as ComponentNode,
|
|
2788
|
+
],
|
|
2789
|
+
} as unknown as ComponentNode;
|
|
2790
|
+
|
|
2791
|
+
const els = await nodeToWebflow(
|
|
2792
|
+
{
|
|
2793
|
+
type: 'node',
|
|
2794
|
+
tag: 'main',
|
|
2795
|
+
children: [outer],
|
|
2796
|
+
} as ComponentNode,
|
|
2797
|
+
ctx,
|
|
2798
|
+
{
|
|
2799
|
+
categories: [
|
|
2800
|
+
{ items: [{ title: 'A1' }, { title: 'A2' }] },
|
|
2801
|
+
{ items: [{ title: 'B1' }] },
|
|
2802
|
+
],
|
|
2803
|
+
}
|
|
2804
|
+
);
|
|
2805
|
+
|
|
2806
|
+
const main = els[0] as WebflowElement;
|
|
2807
|
+
const flat = (main.children || []) as WebflowElement[];
|
|
2808
|
+
const texts = flat.map(c => c.textContent);
|
|
2809
|
+
expect(texts).toEqual(['A1', 'A2', 'B1']);
|
|
2810
|
+
});
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
describe('nodeToWebflow — tag template resolution (parity with SSR)', () => {
|
|
2814
|
+
// Regression for the `h{{size}}` -> `<h>` bug: template tags must resolve
|
|
2815
|
+
// through the same context layers meno-core's runtime walks, not just the
|
|
2816
|
+
// current instance's props.
|
|
2817
|
+
|
|
2818
|
+
test('component with default size renders h2 from h{{size}}', async () => {
|
|
2819
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2820
|
+
const heading: ComponentDefinition = {
|
|
2821
|
+
component: {
|
|
2822
|
+
structure: { type: 'node', tag: 'h{{size}}', children: ['{{text}}'] } as ComponentNode,
|
|
2823
|
+
interface: {
|
|
2824
|
+
text: { type: 'string', default: 'Heading' },
|
|
2825
|
+
size: { type: 'string', default: '2' },
|
|
2826
|
+
},
|
|
2827
|
+
},
|
|
2828
|
+
};
|
|
2829
|
+
const instance: ComponentNode = {
|
|
2830
|
+
type: 'component',
|
|
2831
|
+
component: 'Heading',
|
|
2832
|
+
props: { text: 'Hi' },
|
|
2833
|
+
} as ComponentNode;
|
|
2834
|
+
const els = await nodeToWebflow(
|
|
2835
|
+
pageRoot(instance),
|
|
2836
|
+
makeCtx('index', styleClasses, { globalComponents: { Layout: layoutDef, Heading: heading } })
|
|
2837
|
+
);
|
|
2838
|
+
const layout = els[0] as WebflowElement;
|
|
2839
|
+
const h = layout.children![0] as WebflowElement;
|
|
2840
|
+
expect(h.tag).toBe('h2');
|
|
2841
|
+
expect(h.textContent).toBe('Hi');
|
|
2842
|
+
});
|
|
2843
|
+
|
|
2844
|
+
test('instance prop overrides interface default in tag template', async () => {
|
|
2845
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2846
|
+
const heading: ComponentDefinition = {
|
|
2847
|
+
component: {
|
|
2848
|
+
structure: { type: 'node', tag: 'h{{size}}', children: ['{{text}}'] } as ComponentNode,
|
|
2849
|
+
interface: {
|
|
2850
|
+
text: { type: 'string', default: 'Heading' },
|
|
2851
|
+
size: { type: 'string', default: '2' },
|
|
2852
|
+
},
|
|
2853
|
+
},
|
|
2854
|
+
};
|
|
2855
|
+
const instance: ComponentNode = {
|
|
2856
|
+
type: 'component',
|
|
2857
|
+
component: 'Heading',
|
|
2858
|
+
props: { text: 'Hi', size: '4' },
|
|
2859
|
+
} as ComponentNode;
|
|
2860
|
+
const els = await nodeToWebflow(
|
|
2861
|
+
pageRoot(instance),
|
|
2862
|
+
makeCtx('index', styleClasses, { globalComponents: { Layout: layoutDef, Heading: heading } })
|
|
2863
|
+
);
|
|
2864
|
+
const layout = els[0] as WebflowElement;
|
|
2865
|
+
const h = layout.children![0] as WebflowElement;
|
|
2866
|
+
expect(h.tag).toBe('h4');
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
test('tag template resolves through slotInstanceProps when heading is slotted', async () => {
|
|
2870
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2871
|
+
const heading: ComponentDefinition = {
|
|
2872
|
+
component: {
|
|
2873
|
+
structure: { type: 'node', tag: 'h{{size}}', children: ['{{text}}'] } as ComponentNode,
|
|
2874
|
+
interface: {
|
|
2875
|
+
text: { type: 'string', default: 'Heading' },
|
|
2876
|
+
size: { type: 'string', default: '1' },
|
|
2877
|
+
},
|
|
2878
|
+
},
|
|
2879
|
+
};
|
|
2880
|
+
// Slot host with no interface props of its own — the slotted Heading
|
|
2881
|
+
// gets its `text` from the OUTER hero's props via `slotInstanceProps`.
|
|
2882
|
+
const stack: ComponentDefinition = {
|
|
2883
|
+
component: {
|
|
2884
|
+
structure: { type: 'node', tag: 'div', children: [{ type: 'slot' }] } as ComponentNode,
|
|
2885
|
+
interface: {},
|
|
2886
|
+
},
|
|
2887
|
+
};
|
|
2888
|
+
const hero: ComponentDefinition = {
|
|
2889
|
+
component: {
|
|
2890
|
+
structure: {
|
|
2891
|
+
type: 'node',
|
|
2892
|
+
tag: 'section',
|
|
2893
|
+
children: [{
|
|
2894
|
+
type: 'component',
|
|
2895
|
+
component: 'Stack',
|
|
2896
|
+
children: [{
|
|
2897
|
+
type: 'component',
|
|
2898
|
+
component: 'Heading',
|
|
2899
|
+
props: { text: '{{title}}', size: '3' },
|
|
2900
|
+
}],
|
|
2901
|
+
}],
|
|
2902
|
+
} as ComponentNode,
|
|
2903
|
+
interface: {
|
|
2904
|
+
title: { type: 'string', default: 'Hero title' },
|
|
2905
|
+
},
|
|
2906
|
+
},
|
|
2907
|
+
};
|
|
2908
|
+
const els = await nodeToWebflow(
|
|
2909
|
+
pageRoot({ type: 'component', component: 'Hero', props: { title: 'Hello' } } as ComponentNode),
|
|
2910
|
+
makeCtx('index', styleClasses, {
|
|
2911
|
+
globalComponents: { Layout: layoutDef, Hero: hero, Stack: stack, Heading: heading },
|
|
2912
|
+
})
|
|
2913
|
+
);
|
|
2914
|
+
const layout = els[0] as WebflowElement;
|
|
2915
|
+
const section = layout.children![0] as WebflowElement;
|
|
2916
|
+
const stackEl = section.children![0] as WebflowElement;
|
|
2917
|
+
const h = stackEl.children![0] as WebflowElement;
|
|
2918
|
+
expect(h.tag).toBe('h3');
|
|
2919
|
+
expect(h.textContent).toBe('Hello');
|
|
2920
|
+
});
|
|
2921
|
+
|
|
2922
|
+
test('tag template references {{item.size}} via list iteration context', async () => {
|
|
2923
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2924
|
+
const list: ComponentDefinition = {
|
|
2925
|
+
component: {
|
|
2926
|
+
structure: {
|
|
2927
|
+
type: 'node',
|
|
2928
|
+
tag: 'section',
|
|
2929
|
+
children: [
|
|
2930
|
+
{
|
|
2931
|
+
type: 'list',
|
|
2932
|
+
sourceType: 'prop',
|
|
2933
|
+
source: 'posts',
|
|
2934
|
+
itemAs: 'item',
|
|
2935
|
+
children: [
|
|
2936
|
+
{ type: 'node', tag: 'h{{item.size}}', children: '{{item.title}}' } as ComponentNode,
|
|
2937
|
+
],
|
|
2938
|
+
} as unknown as ComponentNode,
|
|
2939
|
+
],
|
|
2940
|
+
} as ComponentNode,
|
|
2941
|
+
interface: {
|
|
2942
|
+
posts: { type: 'list' as any, default: [] },
|
|
2943
|
+
},
|
|
2944
|
+
},
|
|
2945
|
+
};
|
|
2946
|
+
const els = await nodeToWebflow(
|
|
2947
|
+
pageRoot({
|
|
2948
|
+
type: 'component',
|
|
2949
|
+
component: 'List',
|
|
2950
|
+
props: {
|
|
2951
|
+
posts: [
|
|
2952
|
+
{ title: 'A', size: '2' },
|
|
2953
|
+
{ title: 'B', size: '4' },
|
|
2954
|
+
],
|
|
2955
|
+
},
|
|
2956
|
+
} as ComponentNode),
|
|
2957
|
+
makeCtx('index', styleClasses, {
|
|
2958
|
+
globalComponents: { Layout: layoutDef, List: list },
|
|
2959
|
+
})
|
|
2960
|
+
);
|
|
2961
|
+
const layout = els[0] as WebflowElement;
|
|
2962
|
+
const section = layout.children![0] as WebflowElement;
|
|
2963
|
+
const items = (section.children || []) as WebflowElement[];
|
|
2964
|
+
expect(items.length).toBe(2);
|
|
2965
|
+
expect(items[0].tag).toBe('h2');
|
|
2966
|
+
expect(items[0].textContent).toBe('A');
|
|
2967
|
+
expect(items[1].tag).toBe('h4');
|
|
2968
|
+
expect(items[1].textContent).toBe('B');
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
test('attribute templates resolve from instance props', async () => {
|
|
2972
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
2973
|
+
const card: ComponentDefinition = {
|
|
2974
|
+
component: {
|
|
2975
|
+
structure: {
|
|
2976
|
+
type: 'node',
|
|
2977
|
+
tag: 'div',
|
|
2978
|
+
attributes: { 'data-variant': '{{variant}}' },
|
|
2979
|
+
children: ['hi'],
|
|
2980
|
+
} as ComponentNode,
|
|
2981
|
+
interface: {
|
|
2982
|
+
variant: { type: 'string', default: 'primary' },
|
|
2983
|
+
},
|
|
2984
|
+
},
|
|
2985
|
+
};
|
|
2986
|
+
const els = await nodeToWebflow(
|
|
2987
|
+
pageRoot({ type: 'component', component: 'Card', props: { variant: 'ghost' } } as ComponentNode),
|
|
2988
|
+
makeCtx('index', styleClasses, { globalComponents: { Layout: layoutDef, Card: card } })
|
|
2989
|
+
);
|
|
2990
|
+
const layout = els[0] as WebflowElement;
|
|
2991
|
+
const div = layout.children![0] as WebflowElement;
|
|
2992
|
+
expect(div.attributes?.['data-variant']).toBe('ghost');
|
|
2993
|
+
});
|
|
2994
|
+
});
|
|
2995
|
+
|
|
2996
|
+
// Mirrors `Text.json` / `Heading.json`: a free-form string prop drives a CSS
|
|
2997
|
+
// value via `"{{maxWidth}}"`. Each instance must get a distinct combo so two
|
|
2998
|
+
// instances on the same page render at different widths — without this they
|
|
2999
|
+
// collapse onto the same primary class and the last write wins.
|
|
3000
|
+
describe('nodeToWebflow — single-prop style template combos', () => {
|
|
3001
|
+
const textDef: ComponentDefinition = {
|
|
3002
|
+
component: {
|
|
3003
|
+
interface: {
|
|
3004
|
+
maxWidth: { type: 'string' as any, default: '100%' },
|
|
3005
|
+
},
|
|
3006
|
+
structure: {
|
|
3007
|
+
type: 'node',
|
|
3008
|
+
tag: 'p',
|
|
3009
|
+
style: {
|
|
3010
|
+
base: {
|
|
3011
|
+
color: '#000',
|
|
3012
|
+
maxWidth: '{{maxWidth}}',
|
|
3013
|
+
},
|
|
3014
|
+
},
|
|
3015
|
+
children: 'Text',
|
|
3016
|
+
} as ComponentNode,
|
|
3017
|
+
},
|
|
3018
|
+
};
|
|
3019
|
+
|
|
3020
|
+
test('default-prop instance bakes the default into the primary class with no combo', async () => {
|
|
3021
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
3022
|
+
const els = await nodeToWebflow(
|
|
3023
|
+
pageRoot({ type: 'component', component: 'Text' } as ComponentNode),
|
|
3024
|
+
makeCtx('index', styleClasses, {
|
|
3025
|
+
globalComponents: { Layout: layoutDef, Text: textDef },
|
|
3026
|
+
})
|
|
3027
|
+
);
|
|
3028
|
+
const layout = els[0] as WebflowElement;
|
|
3029
|
+
const p = layout.children![0] as WebflowElement;
|
|
3030
|
+
expect(p.tag).toBe('p');
|
|
3031
|
+
expect(p.comboClasses ?? []).toHaveLength(0);
|
|
3032
|
+
const primary = styleClasses.get(p.className!)!;
|
|
3033
|
+
expect(primary.base['max-width']).toBe('100%');
|
|
3034
|
+
});
|
|
3035
|
+
|
|
3036
|
+
test('non-default instance value emits a combo holding the resolved CSS', async () => {
|
|
3037
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
3038
|
+
const els = await nodeToWebflow(
|
|
3039
|
+
pageRoot({
|
|
3040
|
+
type: 'component',
|
|
3041
|
+
component: 'Text',
|
|
3042
|
+
props: { maxWidth: '640px' },
|
|
3043
|
+
} as ComponentNode),
|
|
3044
|
+
makeCtx('index', styleClasses, {
|
|
3045
|
+
globalComponents: { Layout: layoutDef, Text: textDef },
|
|
3046
|
+
})
|
|
3047
|
+
);
|
|
3048
|
+
const layout = els[0] as WebflowElement;
|
|
3049
|
+
const p = layout.children![0] as WebflowElement;
|
|
3050
|
+
expect(p.comboClasses).toHaveLength(1);
|
|
3051
|
+
const comboName = p.comboClasses![0]!;
|
|
3052
|
+
expect(comboName).toBe('is-maxwidth-640px');
|
|
3053
|
+
const combo = styleClasses.get(comboName)!;
|
|
3054
|
+
expect(combo.comboParent).toBe(p.className);
|
|
3055
|
+
expect(combo.base['max-width']).toBe('640px');
|
|
3056
|
+
// Primary still carries the default (`100%`) so a sibling default-prop
|
|
3057
|
+
// instance keeps rendering correctly.
|
|
3058
|
+
const primary = styleClasses.get(p.className!)!;
|
|
3059
|
+
expect(primary.base['max-width']).toBe('100%');
|
|
3060
|
+
});
|
|
3061
|
+
|
|
3062
|
+
test('two instances with different prop values share one primary, get distinct combos', async () => {
|
|
3063
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
3064
|
+
const els = await nodeToWebflow(
|
|
3065
|
+
pageRoot({
|
|
3066
|
+
type: 'node',
|
|
3067
|
+
tag: 'div',
|
|
3068
|
+
children: [
|
|
3069
|
+
{
|
|
3070
|
+
type: 'component',
|
|
3071
|
+
component: 'Text',
|
|
3072
|
+
props: { maxWidth: '640px' },
|
|
3073
|
+
} as ComponentNode,
|
|
3074
|
+
{
|
|
3075
|
+
type: 'component',
|
|
3076
|
+
component: 'Text',
|
|
3077
|
+
props: { maxWidth: '320px' },
|
|
3078
|
+
} as ComponentNode,
|
|
3079
|
+
],
|
|
3080
|
+
} as ComponentNode),
|
|
3081
|
+
makeCtx('index', styleClasses, {
|
|
3082
|
+
globalComponents: { Layout: layoutDef, Text: textDef },
|
|
3083
|
+
})
|
|
3084
|
+
);
|
|
3085
|
+
const layout = els[0] as WebflowElement;
|
|
3086
|
+
const wrapper = layout.children![0] as WebflowElement;
|
|
3087
|
+
const p1 = wrapper.children![0] as WebflowElement;
|
|
3088
|
+
const p2 = wrapper.children![1] as WebflowElement;
|
|
3089
|
+
// Same primary class — the StyleMapping pipeline keeps the element class
|
|
3090
|
+
// location-stable; the variants live on combos.
|
|
3091
|
+
expect(p1.className).toBe(p2.className);
|
|
3092
|
+
expect(p1.comboClasses![0]).toBe('is-maxwidth-640px');
|
|
3093
|
+
expect(p2.comboClasses![0]).toBe('is-maxwidth-320px');
|
|
3094
|
+
expect(styleClasses.get('is-maxwidth-640px')!.base['max-width']).toBe('640px');
|
|
3095
|
+
expect(styleClasses.get('is-maxwidth-320px')!.base['max-width']).toBe('320px');
|
|
3096
|
+
});
|
|
3097
|
+
|
|
3098
|
+
test('template wrapped in literal CSS (`calc({{maxWidth}} + 1rem)`) emits a combo with the full resolved expression', async () => {
|
|
3099
|
+
const wrappedDef: ComponentDefinition = {
|
|
3100
|
+
component: {
|
|
3101
|
+
interface: {
|
|
3102
|
+
maxWidth: { type: 'string' as any, default: '640px' },
|
|
3103
|
+
},
|
|
3104
|
+
structure: {
|
|
3105
|
+
type: 'node',
|
|
3106
|
+
tag: 'p',
|
|
3107
|
+
style: { base: { width: 'calc({{maxWidth}} + 1rem)' } },
|
|
3108
|
+
children: 'x',
|
|
3109
|
+
} as ComponentNode,
|
|
3110
|
+
},
|
|
3111
|
+
};
|
|
3112
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
3113
|
+
const els = await nodeToWebflow(
|
|
3114
|
+
pageRoot({
|
|
3115
|
+
type: 'component',
|
|
3116
|
+
component: 'Wrapped',
|
|
3117
|
+
props: { maxWidth: '900px' },
|
|
3118
|
+
} as ComponentNode),
|
|
3119
|
+
makeCtx('index', styleClasses, {
|
|
3120
|
+
globalComponents: { Layout: layoutDef, Wrapped: wrappedDef },
|
|
3121
|
+
})
|
|
3122
|
+
);
|
|
3123
|
+
const layout = els[0] as WebflowElement;
|
|
3124
|
+
const p = layout.children![0] as WebflowElement;
|
|
3125
|
+
expect(p.comboClasses).toHaveLength(1);
|
|
3126
|
+
expect(p.comboClasses![0]).toBe('is-maxwidth-900px');
|
|
3127
|
+
const combo = styleClasses.get(p.comboClasses![0]!)!;
|
|
3128
|
+
expect(combo.base.width).toBe('calc(900px + 1rem)');
|
|
3129
|
+
const primary = styleClasses.get(p.className!)!;
|
|
3130
|
+
expect(primary.base.width).toBe('calc(640px + 1rem)');
|
|
3131
|
+
});
|
|
3132
|
+
|
|
3133
|
+
test('multi-template fallback bakes resolved value into primary, no combo', async () => {
|
|
3134
|
+
// Two distinct props in one value can't be reduced to a single mapping
|
|
3135
|
+
// without combinatorial fan-out, so we deliberately fall back to the
|
|
3136
|
+
// pre-existing resolve-and-bake behavior.
|
|
3137
|
+
const multiDef: ComponentDefinition = {
|
|
3138
|
+
component: {
|
|
3139
|
+
interface: {
|
|
3140
|
+
a: { type: 'string' as any, default: '10px' },
|
|
3141
|
+
b: { type: 'string' as any, default: '5px' },
|
|
3142
|
+
},
|
|
3143
|
+
structure: {
|
|
3144
|
+
type: 'node',
|
|
3145
|
+
tag: 'p',
|
|
3146
|
+
style: { base: { padding: '{{a}} {{b}}' } },
|
|
3147
|
+
children: 'x',
|
|
3148
|
+
} as ComponentNode,
|
|
3149
|
+
},
|
|
3150
|
+
};
|
|
3151
|
+
const styleClasses = new Map<string, WebflowStyleClass>();
|
|
3152
|
+
const els = await nodeToWebflow(
|
|
3153
|
+
pageRoot({
|
|
3154
|
+
type: 'component',
|
|
3155
|
+
component: 'Multi',
|
|
3156
|
+
props: { a: '20px', b: '15px' },
|
|
3157
|
+
} as ComponentNode),
|
|
3158
|
+
makeCtx('index', styleClasses, {
|
|
3159
|
+
globalComponents: { Layout: layoutDef, Multi: multiDef },
|
|
3160
|
+
})
|
|
3161
|
+
);
|
|
3162
|
+
const layout = els[0] as WebflowElement;
|
|
3163
|
+
const p = layout.children![0] as WebflowElement;
|
|
3164
|
+
expect(p.comboClasses ?? []).toHaveLength(0);
|
|
3165
|
+
const primary = styleClasses.get(p.className!)!;
|
|
3166
|
+
// shorthand expanded by styleMapper
|
|
3167
|
+
expect(primary.base['padding-top']).toBe('20px');
|
|
3168
|
+
expect(primary.base['padding-right']).toBe('15px');
|
|
3169
|
+
});
|
|
3170
|
+
});
|