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.
Files changed (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /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
+ });