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
@@ -13,19 +13,51 @@ import type {
13
13
  SlotMarker,
14
14
  EmbedNode,
15
15
  LinkNode,
16
+ LocaleListNode,
17
+ CMSItem,
18
+ CMSSchema,
19
+ I18nConfig,
20
+ CMSFilterCondition,
16
21
  } from '../../shared/types';
17
22
  import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
18
23
  import type {
19
24
  StyleObject,
20
25
  ResponsiveStyleObject,
21
26
  InteractiveStyles,
27
+ InteractiveStyleRule,
28
+ StyleValue,
29
+ StyleMapping,
22
30
  } from '../../shared/types/styles';
23
31
  import type { BreakpointConfig } from '../../shared/breakpoints';
24
- import { generateElementClassName } from '../../shared/elementClassName';
25
- import { isVoidElement } from '../../shared/nodeUtils';
26
- import { NODE_TYPE } from '../../shared/constants';
27
- import type { WebflowElement, WebflowStyleClass } from './types';
28
- import { mapStylesToWebflow } from './styleMapper';
32
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
33
+ import { generateElementClassName, shortHash } from '../../shared/elementClassName';
34
+ import { isVoidElement, hasIf, isBooleanMapping } from '../../shared/nodeUtils';
35
+ import { NODE_TYPE, RAW_HTML_PREFIX } from '../../shared/constants';
36
+ import { isI18nValue, resolveI18nValue, resolveI18nInProps } from '../../shared/i18n';
37
+ import { isItemDraftForLocale } from '../../shared/types';
38
+ import {
39
+ buildTemplateContext,
40
+ resolveItemsTemplate,
41
+ getNestedValue,
42
+ addItemUrl,
43
+ type ValueResolver,
44
+ } from '../../shared/itemTemplateUtils';
45
+ import { buildSlugIndex, getLocaleLinks, translatePath, type SlugMap } from '../../shared/slugTranslator';
46
+ import type { CMSService } from '../services/cmsService';
47
+ import type { WebflowElement, WebflowStyleClass, WebflowComponentDef } from './types';
48
+ import {
49
+ COLLECTION_LIST_TAG,
50
+ MENO_BIND_SENTINEL_PREFIX,
51
+ MENO_BIND_SENTINEL_SUFFIX,
52
+ MENO_BIND_SENTINEL_RE,
53
+ MENO_BIND_SENTINEL_EXACT_RE,
54
+ } from './types';
55
+ import { mapStylesToWebflow, buildInstanceStyleCombo } from './styleMapper';
56
+ import { processCodeTemplates, hasTemplates as hasCodeTemplates, resolveHtmlMapping, isHtmlMapping } from '../../client/templateEngine';
57
+ import { resolvePropsFromDefinition } from '../../shared/propResolver';
58
+ import { readFile } from 'fs/promises';
59
+ import { join, basename, extname } from 'path';
60
+ import { getProjectRoot } from '../projectContext';
29
61
 
30
62
  // ---------------------------------------------------------------------------
31
63
  // Context
@@ -44,62 +76,967 @@ export interface WebflowEmitContext {
44
76
  breakpoints: BreakpointConfig;
45
77
  /** Collected style classes (side output) */
46
78
  styleClasses: Map<string, WebflowStyleClass>;
79
+ /**
80
+ * Combo class name → identity that owns it. Lets `mintInstanceComboName`
81
+ * detect when two different element identities hash to the same 5-char
82
+ * slice and re-hash with a salt until unique. Shared by reference across
83
+ * every nested context in a single export so collisions are visible
84
+ * across pages/components, not just within one subtree.
85
+ */
86
+ comboIdentityByName: Map<string, string>;
47
87
  /** Children passed from a component instance to fill slot markers */
48
88
  slotChildren?: (ComponentNode | string)[] | ComponentNode | string;
89
+ /**
90
+ * Caller's identity at the moment a component instance was emitted. When the
91
+ * component body hits a `<slot/>`, slot children are restored to this
92
+ * context so their generated class names hash against the page (or outer
93
+ * component) that supplied them — not against the slot host. Without this,
94
+ * every page routed through the same wrapper component (e.g. Layout) would
95
+ * collide on identical (fileName, elementPath) pairs in `styleClasses`.
96
+ */
97
+ slotEmitContext?: {
98
+ fileType: 'component' | 'page';
99
+ fileName: string;
100
+ elementPath: number[];
101
+ };
102
+ /**
103
+ * Resolved props of the OUTER component instance whose body authored these
104
+ * slot children. Templates inside slot children (`{{title}}` on a Heading
105
+ * placed inside a `<Stack>` slot of a Hero section) refer to that outer
106
+ * component's interface, not the slot host's. Captured in
107
+ * `emitInlineComponentBody` as `parentProps`, consumed by `emitSlotMarker`
108
+ * when filling the slot.
109
+ */
110
+ slotInstanceProps?: Record<string, unknown>;
111
+ /** CMS service for resolving collection-sourced lists. */
112
+ cmsService?: CMSService;
113
+ /** i18n config so list items / locale-list can resolve translations. */
114
+ i18nConfig?: I18nConfig;
115
+ /** Locale this page is being exported under. */
116
+ locale?: string;
117
+ /** Slug mappings for cross-locale URL generation in locale-lists. */
118
+ slugMappings?: SlugMap[];
119
+ /**
120
+ * Lazily-built reverse slug index, shared by `emitLinkNode` (rewriting
121
+ * internal `/path` hrefs to the current locale's Webflow URL) and
122
+ * `emitLocaleListNode` so we don't rebuild the same Map per link.
123
+ */
124
+ slugIndex?: ReturnType<typeof buildSlugIndex>;
125
+ /** URL path of the page being exported (used by locale-list). */
126
+ pagePath?: string;
127
+ /** Currently-rendered CMS item for CMS template pages. */
128
+ cmsContext?: { cms: CMSItem };
129
+ /** Cumulative template context for nested lists. */
130
+ templateContext?: Record<string, unknown>;
131
+ /** Per-theme map: themeName → { '--bg': '#fff', … } from colors.json. */
132
+ themeVars?: Record<string, Record<string, string>>;
133
+ /**
134
+ * Project-wide vars from variables.json (not theme-dependent), keyed by
135
+ * Meno breakpoint name (`base`, `tablet`, `mobile`, …). Each map holds the
136
+ * full `--name → resolved value` set at that breakpoint, with per-variable
137
+ * `scales` overrides and global category scaling already baked in. The
138
+ * walker substitutes `var(--x)` per-breakpoint so the Webflow class system
139
+ * receives concrete scaled values at every tier.
140
+ */
141
+ projectVars?: Record<string, Record<string, string>>;
142
+ /** Default theme name from colors.json (used when no ancestor theme). */
143
+ defaultTheme?: string;
144
+ /**
145
+ * Responsive auto-scaling config. When `enabled`, raw scalable values
146
+ * (`font-size: 48px`, `padding: 40px`, …) are auto-filled per breakpoint
147
+ * by `mapStylesToWebflow` even when the author didn't write a per-bp
148
+ * override.
149
+ */
150
+ responsiveScales?: ResponsiveScales;
151
+ /**
152
+ * The theme inherited from an ancestor's `theme` attribute. Set by
153
+ * `emitHtmlNode` when a node carries `theme="…"`, then forwarded to its
154
+ * descendants so `var(--bg)` resolves to the right palette per element.
155
+ */
156
+ currentTheme?: string;
157
+ /**
158
+ * elementClass → its `interactiveStyles`, collected during the walk so
159
+ * `buildWebflowPayload` can render them into raw CSS afterwards via
160
+ * `generateInteractiveCSS`. Pseudo-only rules with empty prefix and
161
+ * non-responsive style continue to flow through `primaryClass.pseudoStates`
162
+ * (Webflow's class-system path); everything else is filtered to the
163
+ * manual-paste `<style>` block.
164
+ */
165
+ interactiveStylesMap?: Map<string, InteractiveStyles>;
166
+ /**
167
+ * Meno components promoted to Webflow Components (`Navigation`, `Footer`).
168
+ * The first encounter of each name registers a definition here; subsequent
169
+ * encounters emit a `componentRef` element instead of inlining markup.
170
+ * `undefined` inside a component body disables further promotion (so a
171
+ * Navigation that nests another promoted name doesn't recurse).
172
+ */
173
+ promotedComponents?: Map<string, WebflowComponentDef> | null;
174
+ /**
175
+ * Component names eligible for promotion. When omitted, the built-in
176
+ * `PROMOTED_TO_WEBFLOW_COMPONENT` defaults (`Navigation`, `Footer`) apply.
177
+ * Configurable from the Webflow extension UI so users can promote their own
178
+ * shared components (e.g. `Sidebar`) without forking core.
179
+ */
180
+ promotedComponentNames?: Set<string>;
181
+ /**
182
+ * Opt-in: emit `<list sourceType="collection">` as a single bound
183
+ * Collection List shell (`tag: COLLECTION_LIST_TAG`) carrying field-binding
184
+ * markers, instead of the default static N-times expansion. The extension
185
+ * inserts a Webflow `DynamoWrapper` and tags children with
186
+ * `data-meno-bind-*` attributes so the user can finish binding in the
187
+ * Designer UI (no programmatic binding API exists yet — Apr 2026).
188
+ */
189
+ bindCollectionLists?: boolean;
190
+ /**
191
+ * Default values from the enclosing component's `interface` (`{ size: '1',
192
+ * variant: 'default', … }`). Used by `mapStylesToWebflow` to bake
193
+ * default-prop StyleMapping values into the primary class's base, so the
194
+ * primary represents the default-prop visual and combos carry only the
195
+ * deltas for non-default values. Set in `emitInlineComponentBody` when
196
+ * entering a component body; absent for page-level walks.
197
+ */
198
+ componentDefaults?: Record<string, unknown>;
199
+ /**
200
+ * Most recent ancestor's resolved CSS `color` value, threaded down so that
201
+ * `<embed>` SVG markup can substitute `currentColor` with a concrete color
202
+ * before being uploaded as an `image/svg+xml` asset. SVG assets render
203
+ * standalone in Webflow — `currentColor` no longer inherits from the
204
+ * surrounding DOM, so it would otherwise paint as black.
205
+ */
206
+ inheritedColor?: string;
49
207
  }
50
208
 
209
+ /**
210
+ * Meno component names that should become Webflow Components instead of
211
+ * inline markup. Exact, case-sensitive match — e.g. `NavLink` and
212
+ * `NavDropdown` continue to inline today.
213
+ */
214
+ export const PROMOTED_TO_WEBFLOW_COMPONENT = new Set(['Navigation', 'Footer']);
215
+
51
216
  // ---------------------------------------------------------------------------
52
217
  // Helpers
53
218
  // ---------------------------------------------------------------------------
54
219
 
55
220
  function buildElementClass(ctx: WebflowEmitContext, label: string | undefined): string {
56
- return generateElementClassName({
221
+ const generated = generateElementClassName({
57
222
  fileType: ctx.fileType,
58
223
  fileName: ctx.fileName,
59
224
  label,
60
225
  path: ctx.elementPath,
61
226
  });
227
+ // Drop the `c_` component prefix for the Webflow output — the file name is
228
+ // already in the class. Pages keep `p_` so page-scoped classes can't collide
229
+ // with component class names that happen to share the same file name.
230
+ if (ctx.fileType !== 'component') return generated;
231
+ const stripped = generated.replace(/^c_/, '');
232
+ // Collapse `heading_heading` → `heading` when component and element names
233
+ // match — the duplication carries no extra info.
234
+ return stripped.replace(/^([a-z0-9-]+)_\1$/, '$1');
235
+ }
236
+
237
+ /**
238
+ * Suffix used to keep two placements of the same element under different
239
+ * ancestor `theme="…"` values from collapsing onto one entry in
240
+ * `ctx.styleClasses`. Theme `var(--…)` refs are baked to concrete colors at
241
+ * export time, so a `button` rendered under `theme="light"` and one under
242
+ * `theme="dark"` produce different resolved CSS — but absent this suffix they
243
+ * share the class name `button`, and the last write wins (silently
244
+ * mis-rendering the earlier instance). Returns `''` for the project's default
245
+ * theme (or no theme), so default-theme exports stay byte-identical.
246
+ *
247
+ * Callers append this AFTER any other suffixes they compose (sub-classes like
248
+ * `…-item`, identity hashes, etc.) so the theme suffix consistently lives at
249
+ * the tail of the name. Sanitization mirrors `sanitizeClassName` in styleMapper
250
+ * so unusual theme names from `colors.json` produce valid CSS segments.
251
+ */
252
+ function themedClassSuffix(ctx: WebflowEmitContext): string {
253
+ const t = ctx.currentTheme;
254
+ if (!t) return '';
255
+ if (ctx.defaultTheme && t === ctx.defaultTheme) return '';
256
+ const safe = t.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
257
+ if (!safe) return '';
258
+ return `-theme-${safe}`;
62
259
  }
63
260
 
64
- function resolveTemplate(text: string, props?: Record<string, unknown>): string {
65
- if (!props) return text;
66
- return text.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
67
- const trimmed = expr.trim();
261
+ /** Append `themedClassSuffix(ctx)` to a class name, or return it unchanged. */
262
+ function withThemeSuffix(name: string, ctx: WebflowEmitContext): string {
263
+ return name + themedClassSuffix(ctx);
264
+ }
68
265
 
69
- // Support "expr || 'fallback'" pattern
70
- const orMatch = trimmed.match(/^(.+?)\s*\|\|\s*['"](.+?)['"]$/);
71
- if (orMatch) {
72
- const value = resolveNestedProp(props, orMatch[1].trim());
73
- return (value !== undefined && value !== '' && value !== null)
74
- ? String(value)
75
- : orMatch[2];
266
+ /**
267
+ * Mint a per-instance combo class name of the form `is-<5char>`. The hash is
268
+ * derived from the placement's full element identity (page + path or label),
269
+ * so two placements at different positions get different combos and the same
270
+ * placement re-derives the same combo across re-imports.
271
+ *
272
+ * Webflow stores classes in a single project-wide stylesheet, so the 5-char
273
+ * base36 space (~60M) is birthday-vulnerable across large exports. When the
274
+ * computed name is already claimed by a *different* identity, we re-hash with
275
+ * an attempt suffix until the slot is free or matches our identity.
276
+ */
277
+ function mintInstanceComboName(ctx: WebflowEmitContext, identity: string): string {
278
+ let attempt = 0;
279
+ let name = `is-${shortHash(identity)}`;
280
+ while (true) {
281
+ const claimedBy = ctx.comboIdentityByName.get(name);
282
+ if (!claimedBy || claimedBy === identity) {
283
+ ctx.comboIdentityByName.set(name, identity);
284
+ return name;
76
285
  }
286
+ attempt++;
287
+ name = `is-${shortHash(`${identity}#${attempt}`)}`;
288
+ }
289
+ }
77
290
 
78
- // Support "expr ? 'a' : 'b'" ternary pattern
79
- const ternaryMatch = trimmed.match(/^(.+?)\s*\?\s*['"](.+?)['"]\s*:\s*['"](.+?)['"]$/);
80
- if (ternaryMatch) {
81
- const value = resolveNestedProp(props, ternaryMatch[1].trim());
82
- return value ? ternaryMatch[2] : ternaryMatch[3];
291
+ /**
292
+ * Pull `{ propName: defaultValue }` from a component's `interface` definition.
293
+ * Returns undefined when the interface is absent or contributes no defaults,
294
+ * so callers can leave `componentDefaults` off the context (page-level walks
295
+ * skip default-baking entirely).
296
+ */
297
+ function extractInterfaceDefaults(
298
+ iface: Record<string, { default?: unknown }> | undefined
299
+ ): Record<string, unknown> | undefined {
300
+ if (!iface) return undefined;
301
+ const out: Record<string, unknown> = {};
302
+ let any = false;
303
+ for (const [key, def] of Object.entries(iface)) {
304
+ if (def && 'default' in def) {
305
+ out[key] = def.default;
306
+ any = true;
83
307
  }
308
+ }
309
+ return any ? out : undefined;
310
+ }
84
311
 
85
- // Simple prop lookup with dot-notation
86
- const value = resolveNestedProp(props, trimmed);
87
- return value !== undefined ? String(value) : '';
88
- });
312
+ /**
313
+ * Layered template-resolution context. Mirrors the namespaces meno-core's
314
+ * `templateEngine.buildEvalContext` walks: instance props win over slot props,
315
+ * which win over component defaults, with CMS / list iteration data underneath.
316
+ * Page-level walks (no enclosing component) still get a usable context — we
317
+ * just won't have `instanceProps` to layer on top.
318
+ */
319
+ function buildTemplateProps(
320
+ ctx: WebflowEmitContext,
321
+ instanceProps?: Record<string, unknown>
322
+ ): Record<string, unknown> {
323
+ return {
324
+ ...(ctx.templateContext ?? {}),
325
+ ...(ctx.cmsContext ?? {}),
326
+ ...(ctx.componentDefaults ?? {}),
327
+ ...(ctx.slotInstanceProps ?? {}),
328
+ ...(instanceProps ?? {}),
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Resolve `{{...}}` templates in a string using the same engine as meno-core's
334
+ * runtime. Returns `text` unchanged when it carries no templates so we don't
335
+ * thrash `flattenInlineHtmlToText` over rich-text strings without placeholders.
336
+ * `RAW_HTML_PREFIX`-marked rich-text values get flattened to visible text after
337
+ * substitution so the sentinel + inline HTML can't leak into a tag/attribute.
338
+ */
339
+ function resolveStringTemplate(
340
+ text: string,
341
+ ctx: WebflowEmitContext,
342
+ instanceProps?: Record<string, unknown>
343
+ ): string {
344
+ if (!hasCodeTemplates(text)) return text;
345
+ const out = processCodeTemplates(text, buildTemplateProps(ctx, instanceProps));
346
+ return out.startsWith(RAW_HTML_PREFIX)
347
+ ? flattenInlineHtmlToText(out.slice(RAW_HTML_PREFIX.length))
348
+ : out;
349
+ }
350
+
351
+ const HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']);
352
+ // Tags whose Webflow preset is a clickable wrapper (`LinkBlock`) — they
353
+ // inherit the browser-default `<a>` underline unless we override it.
354
+ const LINK_LIKE_TAGS = new Set(['button']);
355
+ // Tags whose Webflow preset ships with a default `margin-bottom: 10px`.
356
+ const LIST_TAGS = new Set(['ul', 'ol']);
357
+
358
+ /**
359
+ * Zero out a heading's vertical margins when the source styles don't set them.
360
+ * Webflow (and browser UA stylesheets) ship h1–h6 with non-trivial
361
+ * margin-top/bottom; designs in Meno almost never account for that. Shorthand
362
+ * `margin` is expanded into the four sides upstream (see `styleMapper.ts`),
363
+ * so by the time we get here every author-set margin is already a longhand.
364
+ */
365
+ function applyHeadingMarginDefaults(cls: WebflowStyleClass): void {
366
+ const base = cls.base;
367
+ if (!('margin-top' in base)) base['margin-top'] = '0px';
368
+ if (!('margin-bottom' in base)) base['margin-bottom'] = '0px';
89
369
  }
90
370
 
91
- function resolveNestedProp(obj: Record<string, unknown>, path: string): unknown {
92
- const parts = path.split('.');
93
- let current: unknown = obj;
94
- for (const part of parts) {
95
- if (current === null || current === undefined || typeof current !== 'object') return undefined;
96
- current = (current as Record<string, unknown>)[part];
371
+ /**
372
+ * Zero out a paragraph's bottom margin when the source styles don't set it.
373
+ * Webflow's `<p>` preset ships with a default `margin-bottom`; designs in Meno
374
+ * almost never account for that. Only `margin-bottom` is touched — paragraphs
375
+ * commonly want their natural top spacing.
376
+ */
377
+ function applyParagraphMarginDefaults(cls: WebflowStyleClass): void {
378
+ const base = cls.base;
379
+ if (!('margin-bottom' in base)) base['margin-bottom'] = '0px';
380
+ }
381
+
382
+ /**
383
+ * Zero out a list's bottom margin when the source styles don't set it.
384
+ * Webflow's `<ul>`/`<ol>` preset ships with `margin-bottom: 10px`; designs in
385
+ * Meno almost never account for that.
386
+ */
387
+ function applyListMarginDefaults(cls: WebflowStyleClass): void {
388
+ const base = cls.base;
389
+ if (!('margin-bottom' in base)) base['margin-bottom'] = '0px';
390
+ }
391
+
392
+ /**
393
+ * For `display: grid` (or `inline-grid`) elements, default
394
+ * `grid-template-rows` to `1fr` (a single row) when the source styles don't
395
+ * specify rows. Webflow's grid preset ships with two rows; designs in Meno
396
+ * almost never want that. The `grid` / `grid-template` shorthands opt out.
397
+ */
398
+ function applyGridRowsDefault(cls: WebflowStyleClass): void {
399
+ const base = cls.base;
400
+ const display = base['display'];
401
+ if (display !== 'grid' && display !== 'inline-grid') return;
402
+ if ('grid' in base) return;
403
+ if ('grid-template' in base) return;
404
+ if ('grid-template-rows' in base) return;
405
+ base['grid-template-rows'] = '1fr';
406
+ }
407
+
408
+ /**
409
+ * Default `<a>` to `text-decoration: none`. Browser UA stylesheets underline
410
+ * links; source designs almost never opt out explicitly. If the author set
411
+ * `text-decoration` (or `text-decoration-line`) themselves, leave it.
412
+ */
413
+ function applyLinkTextDecorationDefault(cls: WebflowStyleClass): void {
414
+ const base = cls.base;
415
+ if ('text-decoration' in base || 'text-decoration-line' in base) return;
416
+ base['text-decoration'] = 'none';
417
+ }
418
+
419
+ /**
420
+ * Fold two combo classes into one. Webflow's class system permits at most a
421
+ * single combo on top of the primary, so when an `acceptsStyles` instance
422
+ * override would stack a second combo on a body root that already carries a
423
+ * StyleMapping-driven combo, we merge into a fresh class instead. `outer`'s
424
+ * properties win on key conflicts at every scope (base, breakpoints,
425
+ * pseudo-states), mirroring runtime SSR's outer-overrides-inner order.
426
+ *
427
+ * The merged name concatenates both combo names so it stays deterministic
428
+ * across re-imports and is unique to this exact body root (the outer combo
429
+ * name is keyed by the instance's outer location).
430
+ */
431
+ function mergeComboClasses(
432
+ inner: WebflowStyleClass,
433
+ outer: WebflowStyleClass,
434
+ parentName: string,
435
+ ): WebflowStyleClass {
436
+ const innerSuffix = inner.name.startsWith('is-') ? inner.name.slice(3) : inner.name;
437
+ const merged: WebflowStyleClass = {
438
+ name: `${outer.name}_${innerSuffix}`,
439
+ base: { ...inner.base, ...outer.base },
440
+ comboParent: parentName,
441
+ };
442
+
443
+ if (inner.breakpoints || outer.breakpoints) {
444
+ const bps: NonNullable<WebflowStyleClass['breakpoints']> = {};
445
+ const tiers = new Set<keyof typeof bps>([
446
+ ...(Object.keys(inner.breakpoints || {}) as Array<keyof typeof bps>),
447
+ ...(Object.keys(outer.breakpoints || {}) as Array<keyof typeof bps>),
448
+ ]);
449
+ for (const tier of tiers) {
450
+ bps[tier] = { ...(inner.breakpoints?.[tier] || {}), ...(outer.breakpoints?.[tier] || {}) };
451
+ }
452
+ merged.breakpoints = bps;
453
+ }
454
+
455
+ if (inner.pseudoStates || outer.pseudoStates) {
456
+ const ps: NonNullable<WebflowStyleClass['pseudoStates']> = {};
457
+ const states = new Set<keyof typeof ps>([
458
+ ...(Object.keys(inner.pseudoStates || {}) as Array<keyof typeof ps>),
459
+ ...(Object.keys(outer.pseudoStates || {}) as Array<keyof typeof ps>),
460
+ ]);
461
+ for (const state of states) {
462
+ ps[state] = { ...(inner.pseudoStates?.[state] || {}), ...(outer.pseudoStates?.[state] || {}) };
463
+ }
464
+ merged.pseudoStates = ps;
465
+ }
466
+
467
+ return merged;
468
+ }
469
+
470
+ const IMAGE_EXT_MIME: Record<string, string> = {
471
+ '.png': 'image/png',
472
+ '.jpg': 'image/jpeg',
473
+ '.jpeg': 'image/jpeg',
474
+ '.gif': 'image/gif',
475
+ '.webp': 'image/webp',
476
+ '.avif': 'image/avif',
477
+ '.svg': 'image/svg+xml',
478
+ '.bmp': 'image/bmp',
479
+ '.ico': 'image/x-icon',
480
+ };
481
+
482
+ function isAbsoluteUrl(s: string): boolean {
483
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) || s.startsWith('data:');
484
+ }
485
+
486
+ /**
487
+ * If `src` points at a file inside the project (project-relative path like
488
+ * `/images/foo.webp`), read it from disk and stash the bytes onto `element`
489
+ * for the Webflow extension to upload. Absolute http(s)/data URLs are left
490
+ * alone — the extension fetches those itself.
491
+ */
492
+ async function maybeInlineLocalImage(element: WebflowElement, src: string): Promise<void> {
493
+ if (!src || isAbsoluteUrl(src)) return;
494
+ const projectRoot = getProjectRoot();
495
+ // Strip leading slash so `join` doesn't escape the project root, and
496
+ // verify the resolved path stays inside it (path-traversal guard).
497
+ const rel = src.replace(/^\/+/, '');
498
+ const abs = join(projectRoot, rel);
499
+ if (!abs.startsWith(projectRoot)) return;
500
+ try {
501
+ const buf = await readFile(abs);
502
+ const ext = extname(abs).toLowerCase();
503
+ const mime = IMAGE_EXT_MIME[ext] || 'application/octet-stream';
504
+ element.imageDataBase64 = buf.toString('base64');
505
+ element.imageDataMime = mime;
506
+ element.imageDataFileName = basename(abs);
507
+ } catch {
508
+ // File missing / unreadable — leave `attributes.src` alone. The extension
509
+ // will then drop the image with a warning, same observable behavior as
510
+ // before this change.
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Strip a small subset of inline HTML — `<span>`, `<em>`, `<strong>`, `<br>`,
516
+ * etc. — and return the concatenated visible text. Used for heading /
517
+ * paragraph content that ships with the `<!--MENO_RAW_HTML-->` sentinel.
518
+ *
519
+ * We previously emitted structured children to preserve inline emphasis
520
+ * (`<span class="custom-span">need</span>`), but Webflow's Designer API for
521
+ * Heading / Paragraph elements does not reliably accept mixed string +
522
+ * element children, and `parent.append(string)` interprets the string as a
523
+ * tag name (so "need" became `<need></need>`). For now we drop inline
524
+ * styling and keep visible text — the user explicitly accepts this
525
+ * trade-off and we'll revisit once we have a working inline-rich-text path.
526
+ *
527
+ * `<br>` collapses to `\n`; HTML entities (`&amp;`/`&lt;`/`&gt;`/`&quot;`)
528
+ * are decoded; comments are stripped.
529
+ */
530
+ function flattenInlineHtmlToText(raw: string): string {
531
+ const decode = (s: string) => s
532
+ .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
533
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ');
534
+
535
+ let out = '';
536
+ const tagRe = /<!--[\s\S]*?-->|<\/?([a-zA-Z][a-zA-Z0-9]*)[^>]*>/g;
537
+ let lastIndex = 0;
538
+ let match: RegExpExecArray | null;
539
+ while ((match = tagRe.exec(raw)) !== null) {
540
+ if (match.index > lastIndex) {
541
+ out += decode(raw.slice(lastIndex, match.index));
542
+ }
543
+ lastIndex = match.index + match[0].length;
544
+ if (match[0].startsWith('<!--')) continue;
545
+ if ((match[1] || '').toLowerCase() === 'br') out += '\n';
546
+ }
547
+ if (lastIndex < raw.length) out += decode(raw.slice(lastIndex));
548
+ return out;
549
+ }
550
+
551
+ /**
552
+ * Substitute `var(--name[, fallback])` in a CSS value with literals from the
553
+ * walker context, resolving project vars at the requested breakpoint. Theme
554
+ * vars are resolved per the active theme (the nearest ancestor's `theme`
555
+ * attribute, or the project default) and don't vary by breakpoint. Anything
556
+ * we can't resolve is left intact.
557
+ *
558
+ * Webflow style classes don't share Meno's `:root` declarations and we don't
559
+ * inject a `<style>` block, so each element has to receive concrete values
560
+ * in its style class — and with theme awareness, identical `var(--bg)`
561
+ * references in different theme contexts produce different colours. The
562
+ * breakpoint parameter lets us materialize the same `var(--h1-fs)` reference
563
+ * to its scaled value at every Meno breakpoint.
564
+ */
565
+ function resolveVarsInValue(
566
+ value: string,
567
+ ctx: WebflowEmitContext,
568
+ breakpoint: string = 'base'
569
+ ): string {
570
+ if (!value || (!ctx.themeVars && !ctx.projectVars)) return value;
571
+ // If `currentTheme` names a theme that doesn't exist in this project's
572
+ // colors.json, fall back to the project default — that mirrors runtime
573
+ // behaviour, where `[theme="primary"]` without a matching selector inherits
574
+ // `:root`'s default-theme vars. Without the fallback, `var(--text)` etc.
575
+ // would survive the substitution pass and ship as literal `var(--…)` to
576
+ // Webflow.
577
+ const themeMap = ctx.themeVars
578
+ ? (
579
+ (ctx.currentTheme && ctx.themeVars[ctx.currentTheme])
580
+ ?? (ctx.defaultTheme ? ctx.themeVars[ctx.defaultTheme] : undefined)
581
+ )
582
+ : undefined;
583
+ const projectMap = ctx.projectVars
584
+ ? (ctx.projectVars[breakpoint] || ctx.projectVars.base)
585
+ : undefined;
586
+ const pattern = /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g;
587
+
588
+ let current = value;
589
+ for (let pass = 0; pass < 3; pass++) {
590
+ let changed = false;
591
+ current = current.replace(pattern, (match, name: string, fallback: string | undefined) => {
592
+ const resolved = (themeMap && themeMap[name]) ?? (projectMap && projectMap[name]);
593
+ if (resolved !== undefined) {
594
+ changed = true;
595
+ return resolved;
596
+ }
597
+ if (fallback !== undefined) {
598
+ changed = true;
599
+ return fallback.trim();
600
+ }
601
+ return match;
602
+ });
603
+ if (!changed) break;
97
604
  }
98
605
  return current;
99
606
  }
100
607
 
101
- function hasTemplates(text: string): boolean {
102
- return /\{\{.+?\}\}/.test(text);
608
+ function substituteVarsInStyleObject(
609
+ style: StyleObject,
610
+ ctx: WebflowEmitContext,
611
+ breakpoint: string = 'base'
612
+ ): StyleObject {
613
+ const out: StyleObject = {};
614
+ for (const [k, v] of Object.entries(style)) {
615
+ out[k] = typeof v === 'string' ? resolveVarsInValue(v, ctx, breakpoint) : v;
616
+ }
617
+ return out;
618
+ }
619
+
620
+ /**
621
+ * Names of project breakpoints whose `var(--x)` resolutions might differ
622
+ * from base — i.e., every breakpoint in the project config. Returns an empty
623
+ * list when no projectVars map is present (e.g., test contexts).
624
+ */
625
+ function projectBreakpointNames(ctx: WebflowEmitContext): string[] {
626
+ return Object.keys(ctx.breakpoints);
627
+ }
628
+
629
+ /**
630
+ * For each base property containing a `var(--x)` reference, expand to a
631
+ * per-breakpoint entry on the responsive style if its resolved value differs
632
+ * from base. Author-provided per-breakpoint overrides win. Mirrors what
633
+ * Meno's runtime does via `:root { --x }` + `@media { :root { --x } }`,
634
+ * except materialized into class-level breakpoint entries since Webflow's
635
+ * class system reads concrete values per tier.
636
+ */
637
+ function expandResponsiveVarsInto(
638
+ baseStyle: StyleObject,
639
+ responsive: ResponsiveStyleObject,
640
+ ctx: WebflowEmitContext
641
+ ): void {
642
+ if (!ctx.projectVars) return;
643
+ const bps = projectBreakpointNames(ctx);
644
+ if (bps.length === 0) return;
645
+
646
+ for (const [prop, rawVal] of Object.entries(baseStyle)) {
647
+ if (typeof rawVal !== 'string' || !rawVal.includes('var(--')) continue;
648
+ const baseResolved = resolveVarsInValue(rawVal, ctx, 'base');
649
+
650
+ for (const bp of bps) {
651
+ const bpResolved = resolveVarsInValue(rawVal, ctx, bp);
652
+ if (bpResolved === baseResolved) continue;
653
+ const bucket = (responsive[bp] as StyleObject | undefined) || {};
654
+ // Author override wins.
655
+ if (bucket[prop] !== undefined) continue;
656
+ bucket[prop] = bpResolved;
657
+ responsive[bp] = bucket;
658
+ }
659
+ }
660
+ }
661
+
662
+ /**
663
+ * Apply theme/project `var(--x)` substitution across a flat or responsive
664
+ * style object. Skipped for StyleMappings — those are passed through and
665
+ * resolved later when the styleMapper turns them into combo classes (each
666
+ * combo's value gets its own substitution then).
667
+ *
668
+ * For each authored breakpoint, vars are resolved using that breakpoint's
669
+ * project var map. Then the base properties are scanned for `var(--x)` refs
670
+ * whose resolved value differs at any project breakpoint — those get
671
+ * synthesized into the responsive style so `mapStylesToWebflow` can route
672
+ * them into Webflow's per-breakpoint buckets.
673
+ */
674
+ function substituteVarsInStyle(
675
+ style: StyleObject | ResponsiveStyleObject | undefined,
676
+ ctx: WebflowEmitContext
677
+ ): StyleObject | ResponsiveStyleObject | undefined {
678
+ if (!style) return style;
679
+ if (isResponsiveStyleObject(style)) {
680
+ const out: ResponsiveStyleObject = {};
681
+ for (const [bp, obj] of Object.entries(style)) {
682
+ if (!obj || typeof obj !== 'object') continue;
683
+ out[bp] = substituteVarsInStyleObject(obj as StyleObject, ctx, bp);
684
+ }
685
+ const baseObj = (style as ResponsiveStyleObject).base;
686
+ if (baseObj) expandResponsiveVarsInto(baseObj as StyleObject, out, ctx);
687
+ return out;
688
+ }
689
+ // Flat style: substitute base, then expand per-breakpoint values into a
690
+ // responsive wrapper so var-driven scaling reaches the breakpoint buckets.
691
+ const flatStyle = style as StyleObject;
692
+ const baseSubstituted = substituteVarsInStyleObject(flatStyle, ctx, 'base');
693
+ const responsive: ResponsiveStyleObject = { base: baseSubstituted };
694
+ expandResponsiveVarsInto(flatStyle, responsive, ctx);
695
+ // If no per-breakpoint expansion happened, return the flat shape to keep
696
+ // downstream behavior identical for projects without project vars.
697
+ if (Object.keys(responsive).length === 1) return baseSubstituted;
698
+ return responsive;
699
+ }
700
+
701
+ function substituteVarsInInteractive(
702
+ rules: InteractiveStyles | undefined,
703
+ ctx: WebflowEmitContext
704
+ ): InteractiveStyles | undefined {
705
+ if (!rules) return rules;
706
+ return rules.map((rule): InteractiveStyleRule => ({
707
+ ...rule,
708
+ style: (substituteVarsInStyle(rule.style as StyleObject | ResponsiveStyleObject, ctx) ?? rule.style) as StyleValue,
709
+ }));
710
+ }
711
+
712
+ /**
713
+ * In-place pass over a finished WebflowStyleClass to inline `var(--x)` refs
714
+ * that survived an upstream substitution. Combo classes built from
715
+ * StyleMappings carry their values as plain strings — we couldn't substitute
716
+ * before `mapStylesToWebflow` ran (the mapping shape is opaque to our
717
+ * resolver), so we sweep them here. Also expands any base property that
718
+ * still contains a `var(--x)` ref into per-breakpoint entries so combo
719
+ * classes inherit the same variable-driven responsive scaling as the
720
+ * primary class.
721
+ */
722
+ function substituteVarsInStyleClass(cls: WebflowStyleClass, ctx: WebflowEmitContext): void {
723
+ // Capture which base props still hold a var() ref BEFORE substitution so
724
+ // we can expand them per-breakpoint after.
725
+ const varBaseProps: Array<{ prop: string; raw: string }> = [];
726
+ for (const [k, v] of Object.entries(cls.base)) {
727
+ if (typeof v === 'string' && v.includes('var(--')) {
728
+ varBaseProps.push({ prop: k, raw: v });
729
+ }
730
+ }
731
+
732
+ for (const k of Object.keys(cls.base)) {
733
+ cls.base[k] = resolveVarsInValue(cls.base[k], ctx, 'base');
734
+ }
735
+ if (cls.breakpoints) {
736
+ for (const [tier, bp] of Object.entries(cls.breakpoints)) {
737
+ if (!bp) continue;
738
+ for (const k of Object.keys(bp)) {
739
+ bp[k] = resolveVarsInValue(bp[k], ctx, tier);
740
+ }
741
+ }
742
+ }
743
+ if (cls.pseudoStates) {
744
+ for (const ps of Object.values(cls.pseudoStates)) {
745
+ if (!ps) continue;
746
+ for (const k of Object.keys(ps)) ps[k] = resolveVarsInValue(ps[k], ctx, 'base');
747
+ }
748
+ }
749
+
750
+ // Per-breakpoint expansion for combo classes whose base values were vars.
751
+ if (varBaseProps.length === 0 || !ctx.projectVars) return;
752
+ for (const bpName of Object.keys(ctx.breakpoints)) {
753
+ const tier = menoBreakpointToWebflowTier(bpName, ctx.breakpoints);
754
+ for (const { prop, raw } of varBaseProps) {
755
+ const baseResolved = cls.base[prop];
756
+ const bpResolved = resolveVarsInValue(raw, ctx, bpName);
757
+ if (bpResolved === baseResolved) continue;
758
+ if (!cls.breakpoints) cls.breakpoints = {};
759
+ const bucket = cls.breakpoints[tier as keyof typeof cls.breakpoints] || {};
760
+ if (bucket[prop] !== undefined) continue;
761
+ bucket[prop] = bpResolved;
762
+ (cls.breakpoints as Record<string, Record<string, string>>)[tier] = bucket;
763
+ }
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Local copy of the Meno→Webflow breakpoint mapping used by styleMapper.
769
+ * Kept private so we don't widen styleMapper's public surface just for the
770
+ * combo-class expansion above.
771
+ */
772
+ function menoBreakpointToWebflowTier(
773
+ bpName: string,
774
+ breakpoints: BreakpointConfig
775
+ ): string {
776
+ if (bpName === 'tablet') return 'medium';
777
+ if (bpName === 'mobile') return 'small';
778
+ const entry = breakpoints[bpName];
779
+ const w = entry && typeof entry.breakpoint === 'number' ? entry.breakpoint : 992;
780
+ if (w < 480) return 'tiny';
781
+ if (w < 768) return 'small';
782
+ if (w < 992) return 'medium';
783
+ if (w < 1280) return 'main';
784
+ if (w < 1440) return 'large';
785
+ if (w < 1920) return 'xl';
786
+ return 'xxl';
787
+ }
788
+
789
+ /**
790
+ * A responsive style object nests StyleObjects under breakpoint keys (base/tablet/mobile/...).
791
+ * A flat style object has CSS property values directly (strings, numbers, or StyleMappings).
792
+ */
793
+ function isResponsiveStyleObject(style: StyleObject | ResponsiveStyleObject): style is ResponsiveStyleObject {
794
+ return 'base' in style || 'tablet' in style || 'mobile' in style;
795
+ }
796
+
797
+ /**
798
+ * Pull the base-breakpoint `color` value from a flat or responsive style.
799
+ * Returns undefined if absent. Values like `currentColor` / `inherit` are
800
+ * returned as-is so callers can decide to skip them.
801
+ *
802
+ * StyleMapping colors (e.g. `Button.variant → primary|secondary`) are
803
+ * resolved using the active instance prop, falling back to the enclosing
804
+ * component's default. Without this, variant-mapped buttons fail to thread
805
+ * a color into nested SVG `<embed>` children — those ship with
806
+ * `currentColor` intact and Webflow paints the standalone SVG asset black.
807
+ * Resolved mapping values may still hold `var(--…)`; we run them through
808
+ * `resolveVarsInValue` so the SVG asset bakes a concrete color.
809
+ */
810
+ function extractBaseColor(
811
+ style: StyleObject | ResponsiveStyleObject | undefined,
812
+ ctx?: WebflowEmitContext,
813
+ instanceProps?: Record<string, unknown>
814
+ ): string | undefined {
815
+ if (!style) return undefined;
816
+ const flat: StyleObject | undefined = isResponsiveStyleObject(style)
817
+ ? ((style as ResponsiveStyleObject).base as StyleObject | undefined)
818
+ : (style as StyleObject);
819
+ const c = flat?.color;
820
+ if (typeof c === 'string') return c;
821
+ if (c && typeof c === 'object' && (c as StyleMapping)._mapping === true) {
822
+ const mapping = c as StyleMapping;
823
+ const propValue = instanceProps?.[mapping.prop] ?? ctx?.componentDefaults?.[mapping.prop];
824
+ if (propValue == null) return undefined;
825
+ const key = String(propValue);
826
+ const raw = mapping.values[key];
827
+ if (typeof raw !== 'string' || raw === '') return undefined;
828
+ return ctx ? resolveVarsInValue(raw, ctx) : raw;
829
+ }
830
+ return undefined;
831
+ }
832
+
833
+ /**
834
+ * Returns a concrete color (something we can substitute into SVG markup) or
835
+ * undefined for keywords that don't carry one (`currentColor`, `inherit`,
836
+ * unset). Caller should fall back to ancestor color when undefined.
837
+ */
838
+ function concreteColor(c: string | undefined): string | undefined {
839
+ if (!c) return undefined;
840
+ const lower = c.trim().toLowerCase();
841
+ if (lower === 'currentcolor' || lower === 'inherit' || lower === 'unset' || lower === 'initial') {
842
+ return undefined;
843
+ }
844
+ return c;
845
+ }
846
+
847
+ /**
848
+ * Replace `currentColor` references in inline SVG markup with the given color.
849
+ * Webflow uploads `<embed>` SVGs as standalone `image/svg+xml` assets, so the
850
+ * normal `currentColor` → ancestor `color` inheritance is broken — we resolve
851
+ * it here at export time. Match is case-insensitive.
852
+ */
853
+ function inlineCurrentColorInSvg(svg: string, color: string): string {
854
+ return svg.replace(/currentColor/gi, color);
855
+ }
856
+
857
+ /**
858
+ * Resolve `{{prop}}` templates in a flat style object's values using component instance
859
+ * props. StyleMapping objects (`{_mapping: true, ...}`) are preserved so that
860
+ * styleMapper can still emit them as Webflow combo classes. Values without
861
+ * templates pass through unchanged.
862
+ */
863
+ function resolveTemplatesInStyleObject(
864
+ style: StyleObject,
865
+ props: Record<string, unknown>
866
+ ): StyleObject {
867
+ const result: StyleObject = {};
868
+ for (const [key, value] of Object.entries(style)) {
869
+ if (typeof value === 'string' && hasCodeTemplates(value)) {
870
+ result[key] = processCodeTemplates(value, props);
871
+ } else {
872
+ result[key] = value;
873
+ }
874
+ }
875
+ return result;
876
+ }
877
+
878
+ /**
879
+ * Resolve templates inside a flat or responsive style object. Uses meno-core's
880
+ * template engine so Webflow receives concrete values (e.g. `1280px`) instead of
881
+ * `{{maxWidth}}` placeholders.
882
+ */
883
+ function resolveStyleTemplates(
884
+ style: StyleObject | ResponsiveStyleObject | undefined,
885
+ props: Record<string, unknown> | undefined
886
+ ): StyleObject | ResponsiveStyleObject | undefined {
887
+ if (!style || !props) return style;
888
+ if (isResponsiveStyleObject(style)) {
889
+ const result: ResponsiveStyleObject = {};
890
+ for (const [bp, styleObj] of Object.entries(style)) {
891
+ if (styleObj && typeof styleObj === 'object') {
892
+ result[bp] = resolveTemplatesInStyleObject(styleObj as StyleObject, props);
893
+ }
894
+ }
895
+ return result;
896
+ }
897
+ return resolveTemplatesInStyleObject(style as StyleObject, props);
898
+ }
899
+
900
+ /**
901
+ * Match a style value that is a single bare `{{prop}}` template — optionally
902
+ * wrapped by literal text (`"calc({{maxWidth}} + 1rem)"`). The captured prop
903
+ * name must be a plain identifier (no dotted paths like `{{user.name}}`, no
904
+ * expressions). Returns the prop name, or null when the value isn't a
905
+ * single-prop template we can convert.
906
+ */
907
+ const SINGLE_TEMPLATE_RE = /^([^{]*)\{\{\s*([A-Za-z_$][\w$]*)\s*\}\}([^{]*)$/;
908
+ function extractSingleTemplateProp(value: string): { prop: string; prefix: string; suffix: string } | null {
909
+ const m = SINGLE_TEMPLATE_RE.exec(value);
910
+ if (!m) return null;
911
+ return { prefix: m[1] ?? '', prop: m[2]!, suffix: m[3] ?? '' };
912
+ }
913
+
914
+ /**
915
+ * Convert single-prop string templates (`"{{maxWidth}}"`,
916
+ * `"calc({{maxWidth}} + 1rem)"`) inside a style object into synthetic
917
+ * `StyleMapping` entries so the downstream Webflow combo emitter treats them
918
+ * the same as authored mappings: bake the default-prop resolution into the
919
+ * primary class and emit a combo for non-default instance values.
920
+ *
921
+ * The synthetic mapping carries the values for *both* the component default
922
+ * and the current instance — `mapStylesToWebflow` looks the right one up via
923
+ * `componentDefaults` / `instanceProps`. Multi-template strings, dotted paths,
924
+ * and props without an interface default fall through unchanged so today's
925
+ * resolve-and-bake behavior still applies.
926
+ */
927
+ function templatesToSyntheticMappings(
928
+ style: StyleObject,
929
+ componentDefaults: Record<string, unknown>,
930
+ instanceProps: Record<string, unknown>
931
+ ): StyleObject {
932
+ const out: StyleObject = {};
933
+ for (const [key, value] of Object.entries(style)) {
934
+ if (typeof value !== 'string' || !hasCodeTemplates(value)) {
935
+ out[key] = value;
936
+ continue;
937
+ }
938
+ const parsed = extractSingleTemplateProp(value);
939
+ if (!parsed) {
940
+ out[key] = value;
941
+ continue;
942
+ }
943
+ const { prop } = parsed;
944
+ const defaultPropValue = componentDefaults[prop];
945
+ if (defaultPropValue == null) {
946
+ out[key] = value;
947
+ continue;
948
+ }
949
+ const instancePropValue = instanceProps[prop] ?? defaultPropValue;
950
+ // Resolve the full template string (literals + placeholder) once with the
951
+ // default prop value substituted and once with the instance value. The
952
+ // resulting strings are full CSS values — `mappingValueToCSS` downstream
953
+ // consumes them verbatim.
954
+ const defaultResolved = processCodeTemplates(value, { ...instanceProps, [prop]: defaultPropValue });
955
+ const instanceResolved = processCodeTemplates(value, { ...instanceProps, [prop]: instancePropValue });
956
+ out[key] = {
957
+ _mapping: true,
958
+ prop,
959
+ values: {
960
+ [String(defaultPropValue)]: defaultResolved,
961
+ [String(instancePropValue)]: instanceResolved,
962
+ },
963
+ } as StyleMapping;
964
+ }
965
+ return out;
966
+ }
967
+
968
+ /**
969
+ * Apply `templatesToSyntheticMappings` across a flat or responsive style.
970
+ * Returns a new style object; original is left untouched. Skipped entirely
971
+ * when no `componentDefaults` / `instanceProps` are available.
972
+ */
973
+ function convertStyleTemplatesToMappings(
974
+ style: StyleObject | ResponsiveStyleObject | undefined,
975
+ componentDefaults: Record<string, unknown> | undefined,
976
+ instanceProps: Record<string, unknown> | undefined
977
+ ): StyleObject | ResponsiveStyleObject | undefined {
978
+ if (!style || !componentDefaults || !instanceProps) return style;
979
+ if (isResponsiveStyleObject(style)) {
980
+ const result: ResponsiveStyleObject = {};
981
+ for (const [bp, styleObj] of Object.entries(style)) {
982
+ if (styleObj && typeof styleObj === 'object') {
983
+ result[bp] = templatesToSyntheticMappings(styleObj as StyleObject, componentDefaults, instanceProps);
984
+ }
985
+ }
986
+ return result;
987
+ }
988
+ return templatesToSyntheticMappings(style as StyleObject, componentDefaults, instanceProps);
989
+ }
990
+
991
+ /**
992
+ * Evaluate a node's `if` condition at export time. Mirrors meno-core's SSR semantics:
993
+ * boolean passes through, BooleanMapping is resolved against instance props,
994
+ * and string templates are evaluated with meno-core's template engine. When no
995
+ * context is available to decide, default to rendering (true) so we don't drop
996
+ * nodes that would otherwise be shown.
997
+ */
998
+ function shouldRenderNode(
999
+ node: ComponentNode,
1000
+ instanceProps?: Record<string, unknown>
1001
+ ): boolean {
1002
+ if (!hasIf(node)) return true;
1003
+ const ifValue = node.if;
1004
+
1005
+ if (typeof ifValue === 'boolean') return ifValue;
1006
+
1007
+ if (isBooleanMapping(ifValue)) {
1008
+ if (!instanceProps) return true;
1009
+ const propValue = instanceProps[ifValue.prop];
1010
+ const mapped = ifValue.values[String(propValue)];
1011
+ return mapped !== undefined ? Boolean(mapped) : true;
1012
+ }
1013
+
1014
+ if (typeof ifValue === 'string') {
1015
+ if (!instanceProps) return true;
1016
+ const resolved = processCodeTemplates(ifValue, instanceProps);
1017
+ return (
1018
+ Boolean(resolved) &&
1019
+ resolved !== 'false' &&
1020
+ resolved !== '0' &&
1021
+ resolved !== ''
1022
+ );
1023
+ }
1024
+
1025
+ return true;
1026
+ }
1027
+
1028
+ /**
1029
+ * Resolve templates in the inner `style` of each interactive-style rule.
1030
+ */
1031
+ function resolveInteractiveStyleTemplates(
1032
+ rules: InteractiveStyles | undefined,
1033
+ props: Record<string, unknown> | undefined
1034
+ ): InteractiveStyles | undefined {
1035
+ if (!rules || !props) return rules;
1036
+ return rules.map((rule): InteractiveStyleRule => ({
1037
+ ...rule,
1038
+ style: (resolveStyleTemplates(rule.style as StyleObject | ResponsiveStyleObject, props) ?? rule.style) as StyleValue,
1039
+ }));
103
1040
  }
104
1041
 
105
1042
  // ---------------------------------------------------------------------------
@@ -109,17 +1046,23 @@ function hasTemplates(text: string): boolean {
109
1046
  /**
110
1047
  * Convert a ComponentNode tree to Webflow element tree.
111
1048
  * Also collects WebflowStyleClass definitions as a side effect in ctx.styleClasses.
1049
+ *
1050
+ * Async because list expansion may query the CMS service. Sync paths still
1051
+ * resolve in a single tick — only collection-sourced lists actually `await`.
112
1052
  */
113
- export function nodeToWebflow(
1053
+ export async function nodeToWebflow(
114
1054
  node: ComponentNode | ComponentNode[] | string | number | null | undefined,
115
1055
  ctx: WebflowEmitContext,
116
1056
  instanceProps?: Record<string, unknown>
117
- ): WebflowElement[] {
1057
+ ): Promise<WebflowElement[]> {
118
1058
  if (node === null || node === undefined) return [];
119
1059
 
120
1060
  // Text/number
121
1061
  if (typeof node === 'string') {
122
- const text = instanceProps ? resolveTemplate(node, instanceProps) : node;
1062
+ const text = resolveStringTemplate(node, ctx, instanceProps);
1063
+ // Empty/whitespace-only strings render as nothing in Meno's SSR, but a
1064
+ // bare `<span>` becomes a selectable Text Block in Webflow — skip them.
1065
+ if (text === '' || text.trim() === '') return [];
123
1066
  return [{ tag: 'span', textContent: text }];
124
1067
  }
125
1068
  if (typeof node === 'number') {
@@ -133,29 +1076,33 @@ export function nodeToWebflow(
133
1076
  const child = node[i];
134
1077
  const savedPath = [...ctx.elementPath];
135
1078
  ctx.elementPath = [...ctx.elementPath, i];
136
- results.push(...nodeToWebflow(child, ctx, instanceProps));
1079
+ results.push(...(await nodeToWebflow(child, ctx, instanceProps)));
137
1080
  ctx.elementPath = savedPath;
138
1081
  }
139
1082
  return results;
140
1083
  }
141
1084
 
1085
+ // Skip nodes whose `if` evaluates to false — meno-core's rendering treats
1086
+ // `if: false` (or a falsy mapping/template) as "don't render".
1087
+ if (!shouldRenderNode(node, instanceProps)) return [];
1088
+
142
1089
  // Dispatch by node type
143
1090
  switch (node.type) {
144
1091
  case NODE_TYPE.NODE:
145
- return [emitHtmlNode(node as HtmlNode, ctx, instanceProps)];
1092
+ return [await emitHtmlNode(node as HtmlNode, ctx, instanceProps)];
146
1093
  case NODE_TYPE.COMPONENT:
147
1094
  return emitComponentInstance(node as ComponentInstanceNode, ctx, instanceProps);
148
1095
  case NODE_TYPE.SLOT:
149
1096
  return emitSlotMarker(node as SlotMarker, ctx, instanceProps);
150
1097
  case NODE_TYPE.EMBED:
151
- return [emitEmbedNode(node as EmbedNode, ctx, instanceProps)];
1098
+ return [await emitEmbedNode(node as EmbedNode, ctx, instanceProps)];
152
1099
  case NODE_TYPE.LINK:
153
- return [emitLinkNode(node as LinkNode, ctx, instanceProps)];
1100
+ return [await emitLinkNode(node as LinkNode, ctx, instanceProps)];
154
1101
  case NODE_TYPE.LIST:
155
1102
  case 'cms-list' as any:
1103
+ return emitListNode(node as ListNode, ctx, instanceProps);
156
1104
  case NODE_TYPE.LOCALE_LIST:
157
- // Complex nodes emit a placeholder div
158
- return [{ tag: 'div', attributes: { 'data-meno-type': node.type } }];
1105
+ return emitLocaleListNode(node as LocaleListNode, ctx, instanceProps);
159
1106
  default:
160
1107
  return [];
161
1108
  }
@@ -165,84 +1112,142 @@ export function nodeToWebflow(
165
1112
  // Node type emitters
166
1113
  // ---------------------------------------------------------------------------
167
1114
 
168
- function emitHtmlNode(
1115
+ async function emitHtmlNode(
169
1116
  node: HtmlNode,
170
1117
  ctx: WebflowEmitContext,
171
1118
  instanceProps?: Record<string, unknown>
172
- ): WebflowElement {
173
- const tag = hasTemplates(node.tag) && instanceProps
174
- ? resolveTemplate(node.tag, instanceProps)
175
- : node.tag;
1119
+ ): Promise<WebflowElement> {
1120
+ // Resolve `{{...}}` in the tag against the same layered context meno-core
1121
+ // walks at runtime — page-level / slot-injected nodes don't have
1122
+ // `instanceProps`, so the helper falls back to slot props, component
1123
+ // defaults, and the active CMS / list iteration context.
1124
+ const tag = resolveStringTemplate(node.tag, ctx, instanceProps);
1125
+
1126
+ // Build attributes early so we can check for a `theme="…"` override and
1127
+ // propagate it to the descendant context BEFORE resolving CSS variables.
1128
+ const attributes: Record<string, string | number | boolean> = {};
1129
+ if (node.attributes) {
1130
+ for (const [key, value] of Object.entries(node.attributes)) {
1131
+ attributes[key] = (typeof value === 'string')
1132
+ ? resolveStringTemplate(value, ctx, instanceProps)
1133
+ : value;
1134
+ }
1135
+ }
1136
+
1137
+ const themedCtx: WebflowEmitContext = typeof attributes.theme === 'string' && attributes.theme
1138
+ ? { ...ctx, currentTheme: attributes.theme as string }
1139
+ : ctx;
176
1140
 
177
- const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
178
- const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
1141
+ const rawStyle = node.style as StyleObject | ResponsiveStyleObject | undefined;
1142
+ const rawInteractiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
1143
+ // Convert single-prop string templates (`"{{maxWidth}}"`) into synthetic
1144
+ // StyleMappings before resolution, so values driven by an interface prop
1145
+ // emit per-instance combo classes (`is-maxwidth-640px`) instead of
1146
+ // collapsing into a single primary class shared by every instance.
1147
+ const styleWithMappings = convertStyleTemplatesToMappings(
1148
+ rawStyle,
1149
+ ctx.componentDefaults,
1150
+ instanceProps,
1151
+ );
1152
+ const style = substituteVarsInStyle(
1153
+ resolveStyleTemplates(styleWithMappings, instanceProps),
1154
+ themedCtx
1155
+ );
1156
+ const interactiveStyles = substituteVarsInInteractive(
1157
+ resolveInteractiveStyleTemplates(rawInteractiveStyles, instanceProps),
1158
+ themedCtx
1159
+ );
179
1160
 
180
- // Generate element class and map styles
181
- const needsClass = style || (interactiveStyles && interactiveStyles.length > 0) || node.generateElementClass;
1161
+ // Generate element class and map styles.
1162
+ // - Headings always get a class so we can zero out the default heading
1163
+ // margins Webflow ships with h1–h6.
1164
+ // - `<button>` always gets a class so we can default `text-decoration: none`:
1165
+ // we route `<button>` through the `LinkBlock` preset (Webflow's
1166
+ // `FormButton` preset is form-only and gets rejected outside `<form>`),
1167
+ // which ships with the browser-default `<a>` underline carrier.
1168
+ const tagLower = tag.toLowerCase();
1169
+ const isHeading = HEADING_TAGS.has(tagLower);
1170
+ const isLinkLike = LINK_LIKE_TAGS.has(tagLower);
1171
+ const isParagraph = tagLower === 'p';
1172
+ const isList = LIST_TAGS.has(tagLower);
1173
+ const needsClass = style || (interactiveStyles && interactiveStyles.length > 0) || node.generateElementClass || isHeading || isLinkLike || isParagraph || isList;
182
1174
  let className: string | undefined;
183
1175
  let comboClassNames: string[] | undefined;
184
1176
 
185
1177
  if (needsClass) {
186
- const elementClass = buildElementClass(ctx, node.label);
1178
+ const elementClass = withThemeSuffix(buildElementClass(ctx, node.label), themedCtx);
187
1179
  const { primaryClass, comboClasses } = mapStylesToWebflow(
188
- elementClass, style, interactiveStyles, ctx.breakpoints
1180
+ elementClass, style, interactiveStyles, ctx.breakpoints, ctx.responsiveScales,
1181
+ { instanceProps, componentDefaults: ctx.componentDefaults, themeSuffix: themedClassSuffix(themedCtx) }
189
1182
  );
190
1183
 
1184
+ substituteVarsInStyleClass(primaryClass, themedCtx);
1185
+ if (isHeading) applyHeadingMarginDefaults(primaryClass);
1186
+ if (isParagraph) applyParagraphMarginDefaults(primaryClass);
1187
+ if (isList) applyListMarginDefaults(primaryClass);
1188
+ if (isLinkLike) applyLinkTextDecorationDefault(primaryClass);
1189
+ applyGridRowsDefault(primaryClass);
191
1190
  className = primaryClass.name;
192
1191
  ctx.styleClasses.set(primaryClass.name, primaryClass);
193
1192
 
194
1193
  if (comboClasses.length > 0) {
195
1194
  comboClassNames = [];
196
1195
  for (const combo of comboClasses) {
1196
+ substituteVarsInStyleClass(combo, themedCtx);
197
1197
  ctx.styleClasses.set(combo.name, combo);
198
1198
  comboClassNames.push(combo.name);
199
1199
  }
200
1200
  }
201
- }
202
1201
 
203
- // Build attributes
204
- const attributes: Record<string, string | number | boolean> = {};
205
- if (node.attributes) {
206
- for (const [key, value] of Object.entries(node.attributes)) {
207
- if (instanceProps && typeof value === 'string' && hasTemplates(value)) {
208
- attributes[key] = resolveTemplate(value, instanceProps);
209
- } else {
210
- attributes[key] = value;
211
- }
1202
+ // Capture the raw interactiveStyles per element so `buildWebflowPayload`
1203
+ // can later run them through `generateInteractiveCSS` for any rules
1204
+ // Webflow's class system can't represent (descendant selectors via
1205
+ // `prefix`, class-style postfixes, breakpoint-divided pseudos).
1206
+ if (
1207
+ ctx.interactiveStylesMap
1208
+ && Array.isArray(interactiveStyles)
1209
+ && interactiveStyles.length > 0
1210
+ ) {
1211
+ ctx.interactiveStylesMap.set(primaryClass.name, interactiveStyles);
212
1212
  }
213
1213
  }
214
1214
 
215
- // Build children
216
- let children: WebflowElement[] | undefined;
1215
+ // Build children — use the themed context so descendants resolve `var()`
1216
+ // against the theme this element introduces.
1217
+ let children: Array<WebflowElement | string> | undefined;
217
1218
  let textContent: string | undefined;
218
1219
  if (!isVoidElement(tag) && node.children) {
219
1220
  // Optimize: single string child becomes textContent instead of child element
220
1221
  if (typeof node.children === 'string') {
221
- textContent = instanceProps ? resolveTemplate(node.children, instanceProps) : node.children;
1222
+ textContent = resolveStringTemplate(node.children, ctx, instanceProps);
222
1223
  } else if (
223
1224
  Array.isArray(node.children) &&
224
1225
  node.children.length === 1 &&
225
1226
  typeof node.children[0] === 'string'
226
1227
  ) {
227
1228
  const text = node.children[0] as string;
228
- textContent = instanceProps ? resolveTemplate(text, instanceProps) : text;
1229
+ textContent = resolveStringTemplate(text, ctx, instanceProps);
229
1230
  } else {
230
- const innerCtx = { ...ctx, elementPath: [...ctx.elementPath] };
231
- children = convertChildren(node.children, innerCtx, instanceProps);
1231
+ const innerCtx = { ...themedCtx, elementPath: [...themedCtx.elementPath] };
1232
+ const ownColor = concreteColor(extractBaseColor(style, themedCtx, instanceProps));
1233
+ if (ownColor) innerCtx.inheritedColor = ownColor;
1234
+ children = await convertChildren(node.children, innerCtx, instanceProps);
232
1235
  if (children.length === 0) children = undefined;
233
1236
  }
234
1237
  }
235
1238
 
236
- // Handle conditional rendering
237
- let conditional: WebflowElement['conditional'];
238
- const ifValue = (node as any).if;
239
- if (ifValue !== undefined && ifValue !== true) {
240
- if (typeof ifValue === 'object' && ifValue._mapping) {
241
- conditional = { prop: ifValue.prop, condition: 'truthy' };
242
- } else if (typeof ifValue === 'string') {
243
- const match = ifValue.match(/^\{\{(.+)\}\}$/);
244
- conditional = { prop: match ? match[1].trim() : ifValue, condition: 'truthy' };
245
- }
1239
+ // Rich-text headings/paragraphs ship with the RAW_HTML_PREFIX sentinel.
1240
+ // Strip inline HTML and keep the concatenated visible text — Webflow's
1241
+ // Heading/Paragraph elements don't accept inline span children reliably
1242
+ // and `parent.append(string)` produces custom-tag elements, not text.
1243
+ // Inline emphasis styling is dropped for now (deliberate trade-off).
1244
+ if (textContent && textContent.startsWith(RAW_HTML_PREFIX)) {
1245
+ textContent = flattenInlineHtmlToText(textContent.slice(RAW_HTML_PREFIX.length));
1246
+ } else if (textContent && (isHeading || isParagraph) && /<[a-zA-Z!]/.test(textContent)) {
1247
+ // Rich-text props on static pages don't carry the sentinel — they're stored
1248
+ // as plain HTML by the editor. Headings/paragraphs still can't host inline
1249
+ // markup in Webflow, so flatten any tags we find to visible text.
1250
+ textContent = flattenInlineHtmlToText(textContent);
246
1251
  }
247
1252
 
248
1253
  const element: WebflowElement = { tag };
@@ -251,71 +1256,394 @@ function emitHtmlNode(
251
1256
  if (textContent) element.textContent = textContent;
252
1257
  if (children) element.children = children;
253
1258
  if (Object.keys(attributes).length > 0) element.attributes = attributes;
254
- if (conditional) element.conditional = conditional;
1259
+
1260
+ // For project-relative `<img>` srcs the Webflow extension can't `fetch`
1261
+ // them (its iframe runs on `designer.webflow.com`). Inline the file bytes
1262
+ // here so the extension uploads via `createAsset` instead.
1263
+ if (tag.toLowerCase() === 'img' && typeof attributes.src === 'string') {
1264
+ await maybeInlineLocalImage(element, attributes.src);
1265
+ }
255
1266
 
256
1267
  return element;
257
1268
  }
258
1269
 
259
- function emitComponentInstance(
1270
+ async function emitComponentInstance(
260
1271
  node: ComponentInstanceNode,
261
1272
  ctx: WebflowEmitContext,
262
1273
  parentProps?: Record<string, unknown>
263
- ): WebflowElement[] {
1274
+ ): Promise<WebflowElement[]> {
264
1275
  const compDef = ctx.globalComponents[node.component];
265
1276
  if (!compDef) {
266
1277
  // Unknown component — emit placeholder
267
1278
  return [{ tag: 'div', attributes: { 'data-component': node.component } }];
268
1279
  }
269
1280
 
270
- // Resolve props: merge defaults with instance props
271
- const resolvedProps: Record<string, unknown> = {};
272
1281
  const structured = compDef.component;
273
1282
 
274
- if (structured?.interface) {
275
- for (const [key, propDef] of Object.entries(structured.interface)) {
276
- resolvedProps[key] = propDef.default;
277
- }
278
- }
279
-
1283
+ // Resolve template placeholders in incoming instance props against the
1284
+ // PARENT scope first (e.g. `<Heading size="{{level}}"/>` placed in a Hero
1285
+ // body — `level` belongs to Hero's interface, not Heading's). Then hand off
1286
+ // to meno-core's `resolvePropsFromDefinition` so defaults / validation /
1287
+ // rich-text wrapping / i18n match what SSR does. Without this parity, a
1288
+ // template like `tag: 'h{{size}}'` can read different values here than at
1289
+ // runtime — exactly the bug that produced literal `<h>` for `h{{size}}`
1290
+ // when SSR was happily rendering `<h2>`.
1291
+ const passedProps: Record<string, unknown> = {};
280
1292
  if (node.props) {
1293
+ const parentTemplateProps = parentProps ?? buildTemplateProps(ctx);
281
1294
  for (const [key, value] of Object.entries(node.props)) {
282
1295
  if (key === 'children') continue;
283
- if (typeof value === 'string' && hasTemplates(value) && parentProps) {
284
- resolvedProps[key] = resolveTemplate(value, parentProps);
285
- } else {
286
- resolvedProps[key] = value;
287
- }
1296
+ passedProps[key] = (typeof value === 'string' && hasCodeTemplates(value))
1297
+ ? processCodeTemplates(value, parentTemplateProps)
1298
+ : value;
288
1299
  }
289
1300
  }
290
1301
 
1302
+ const resolvedProps: Record<string, unknown> = structured
1303
+ ? resolvePropsFromDefinition(
1304
+ structured,
1305
+ passedProps,
1306
+ node.children as Array<ComponentNode | string> | string | ComponentNode | null | undefined,
1307
+ ctx.locale,
1308
+ ctx.i18nConfig,
1309
+ )
1310
+ : { ...passedProps };
1311
+ // `resolvePropsFromDefinition` doesn't carry the children prop into its
1312
+ // output unless the interface declares it. The Webflow body walk reads
1313
+ // children via `slotChildren`, not props, so dropping it here is safe and
1314
+ // matches SSR (`ssrRenderer.ts:1217-1220` passes children separately too).
1315
+ delete resolvedProps.children;
1316
+
291
1317
  // Inline-expand the component's node tree
292
1318
  const body = structured?.structure || (compDef as any).node;
293
1319
  if (!body) return [];
294
1320
 
1321
+ // Promote `Navigation` / `Footer` to Webflow Components. Render the body
1322
+ // ONCE with default props only — no slot children, no instance prop
1323
+ // overrides — so every page references the same registered Component. The
1324
+ // inline expansion is still emitted as `inlineFallback` so the extension
1325
+ // can render today's markup when the Designer API can't register
1326
+ // Components (older runtime, missing `canCreateComponents` permission).
1327
+ //
1328
+ // `acceptsStyles` opt-out: when the instance carries its own style /
1329
+ // interactiveStyles overrides, a single shared registered Component can't
1330
+ // represent them (combos would need a per-instance parent). Skip
1331
+ // promotion for that instance and fall through to inline expansion so
1332
+ // `emitInlineComponentBody` attaches the per-property combos directly.
1333
+ const hasInstanceStyleOverrides =
1334
+ Boolean(structured?.acceptsStyles)
1335
+ && (
1336
+ (node.style && Object.keys(node.style as object).length > 0)
1337
+ || (Array.isArray(node.interactiveStyles) && node.interactiveStyles.length > 0)
1338
+ );
1339
+ const promotedNames = ctx.promotedComponentNames || PROMOTED_TO_WEBFLOW_COMPONENT;
1340
+ if (
1341
+ promotedNames.has(node.component)
1342
+ && ctx.promotedComponents
1343
+ && !hasInstanceStyleOverrides
1344
+ ) {
1345
+ const promoted = ctx.promotedComponents;
1346
+ if (!promoted.has(node.component)) {
1347
+ const defaultProps: Record<string, unknown> = {};
1348
+ if (structured?.interface) {
1349
+ for (const [k, p] of Object.entries(structured.interface)) {
1350
+ defaultProps[k] = p.default;
1351
+ }
1352
+ }
1353
+ const compBodyCtx: WebflowEmitContext = {
1354
+ ...ctx,
1355
+ fileType: 'component',
1356
+ fileName: node.component,
1357
+ elementPath: [0],
1358
+ slotChildren: undefined,
1359
+ slotEmitContext: undefined,
1360
+ // Disable further promotion inside the component body — a Navigation
1361
+ // that nests Footer (or itself) would loop. Nested promoted names
1362
+ // inline within the registered Component's body.
1363
+ promotedComponents: null,
1364
+ componentDefaults: defaultProps,
1365
+ };
1366
+ const elements = await nodeToWebflow(body, compBodyCtx, defaultProps);
1367
+ promoted.set(node.component, { name: node.component, elements });
1368
+ }
1369
+ // The fallback represents an all-inline world; disable promotion inside
1370
+ // it so a self-nested promoted component doesn't recurse forever (each
1371
+ // call would emit yet another fallback under the same promoted name).
1372
+ const fallback = await emitInlineComponentBody(
1373
+ node,
1374
+ compDef,
1375
+ resolvedProps,
1376
+ body,
1377
+ { ...ctx, promotedComponents: null },
1378
+ parentProps,
1379
+ );
1380
+ return [{ tag: 'div', componentRef: node.component, inlineFallback: fallback }];
1381
+ }
1382
+
1383
+ return emitInlineComponentBody(node, compDef, resolvedProps, body, ctx, parentProps);
1384
+ }
1385
+
1386
+ /**
1387
+ * Inline-expand a component instance into Webflow elements. Used for both
1388
+ * the regular (non-promoted) path and the `inlineFallback` carried alongside
1389
+ * a `componentRef` element so older extensions can render today's markup.
1390
+ */
1391
+ async function emitInlineComponentBody(
1392
+ node: ComponentInstanceNode,
1393
+ compDef: ComponentDefinition,
1394
+ resolvedProps: Record<string, unknown>,
1395
+ body: ComponentNode,
1396
+ ctx: WebflowEmitContext,
1397
+ parentProps?: Record<string, unknown>,
1398
+ ): Promise<WebflowElement[]> {
1399
+ const structured = compDef.component;
1400
+
1401
+ // Slot forwarding: when `node.children` contains `<slot/>` markers, those
1402
+ // markers reference the OUTER (current) component's slot — substitute them
1403
+ // with `ctx.slotChildren` now so this nested component receives the actual
1404
+ // slot content. Mirrors SSR's `processStructure`, which substitutes slot
1405
+ // markers throughout the parent's structure tree before walking. Without
1406
+ // this, a wrapper like `<Section>` whose body places `<Container>{slot}</Container>`
1407
+ // forwards the bare `{slot}` marker to Container as its slotChildren — when
1408
+ // Container's body fires its own slot, the marker is the only thing
1409
+ // available, has no slot context left, and renders nothing.
1410
+ let effectiveChildren = node.children;
1411
+ let inheritedSlotEmitContext = ctx.slotEmitContext;
1412
+ let inheritedSlotInstanceProps: Record<string, unknown> | undefined = undefined;
1413
+ let didForwardSlot = false;
1414
+ if (node.children != null && typeof node.children !== 'string') {
1415
+ const childArr = Array.isArray(node.children) ? node.children : [node.children];
1416
+ if (childArr.some((c) => c && typeof c === 'object' && (c as { type?: string }).type === NODE_TYPE.SLOT)) {
1417
+ const out: typeof childArr = [];
1418
+ for (const c of childArr) {
1419
+ if (c && typeof c === 'object' && (c as { type?: string }).type === NODE_TYPE.SLOT) {
1420
+ didForwardSlot = true;
1421
+ if (ctx.slotChildren !== undefined) {
1422
+ const sc = ctx.slotChildren;
1423
+ const subArr = Array.isArray(sc) ? sc : [sc];
1424
+ out.push(...subArr);
1425
+ } else {
1426
+ const def = (c as SlotMarker).default;
1427
+ if (def !== undefined) {
1428
+ const defArr = Array.isArray(def) ? def : [def];
1429
+ out.push(...defArr);
1430
+ }
1431
+ }
1432
+ } else {
1433
+ out.push(c);
1434
+ }
1435
+ }
1436
+ if (didForwardSlot) {
1437
+ effectiveChildren = out;
1438
+ // Forwarded slot children were authored in the OUTER scope — preserve
1439
+ // their original `slotInstanceProps` (so templates resolve against the
1440
+ // outer component's props) and `slotEmitContext` (so generated class
1441
+ // names hash against the outer authoring identity).
1442
+ inheritedSlotInstanceProps = ctx.slotInstanceProps;
1443
+ }
1444
+ }
1445
+ }
1446
+
295
1447
  const compCtx: WebflowEmitContext = {
296
1448
  ...ctx,
297
1449
  fileType: 'component',
298
1450
  fileName: node.component,
299
1451
  elementPath: [0],
300
- slotChildren: node.children,
1452
+ slotChildren: effectiveChildren,
1453
+ slotEmitContext: didForwardSlot && inheritedSlotEmitContext
1454
+ ? inheritedSlotEmitContext
1455
+ : {
1456
+ fileType: ctx.fileType,
1457
+ fileName: ctx.fileName,
1458
+ elementPath: [...ctx.elementPath],
1459
+ },
1460
+ // Slot children authored alongside this instance refer to the OUTER
1461
+ // component's interface (the one whose body contains `<Component …>{slot
1462
+ // children}</Component>`). When the body hits its `<slot/>`, those
1463
+ // children must resolve `{{title}}` etc. against `parentProps` — not
1464
+ // against this component's resolved props. For forwarded slot children,
1465
+ // use the original outer slot's `slotInstanceProps` instead so they keep
1466
+ // their original authoring scope through the wrapper.
1467
+ slotInstanceProps: didForwardSlot ? inheritedSlotInstanceProps : parentProps,
1468
+ componentDefaults: extractInterfaceDefaults(structured?.interface),
301
1469
  };
302
1470
 
303
- return nodeToWebflow(body, compCtx, resolvedProps);
1471
+ // Layer the active CMS template context (e.g. `post` from a `<list>`
1472
+ // iteration) underneath the component's resolved props so descendants
1473
+ // inside the body can resolve `{{post.title}}` etc. Mirrors SSR's
1474
+ // ssrRenderer.ts:1231 which passes `itemContext: ctx.templateContext`
1475
+ // alongside resolved props. resolvedProps wins on key conflicts so
1476
+ // explicit instance props still override.
1477
+ const ctxTemplate = ctx.templateContext as Record<string, unknown> | undefined;
1478
+ const bodyProps = ctxTemplate
1479
+ ? { ...ctxTemplate, ...resolvedProps }
1480
+ : resolvedProps;
1481
+ const emitted = await nodeToWebflow(body, compCtx, bodyProps);
1482
+
1483
+ // `acceptsStyles`: forward instance-level style / interactiveStyles
1484
+ // overrides as ONE combo class on the body's emitted root, mirroring how
1485
+ // SSR merges them onto the root node (ssrRenderer.ts:1244). All authored
1486
+ // overrides — base, breakpoint, pseudo-state — fold into a single combo
1487
+ // named after the instance's outer location so two instances of the same
1488
+ // component on the same page don't share a single class.
1489
+ if (
1490
+ structured?.acceptsStyles
1491
+ && emitted.length > 0
1492
+ && (
1493
+ (node.style && Object.keys(node.style as object).length > 0)
1494
+ || (Array.isArray(node.interactiveStyles) && node.interactiveStyles.length > 0)
1495
+ )
1496
+ ) {
1497
+ const root = emitted[0];
1498
+ if (root && typeof root === 'object') {
1499
+ // The body root may not have needed a class on its own (e.g. a plain
1500
+ // <section> without authored styles). Mint one so the combo has a
1501
+ // primary class to attach to — uses the same `buildElementClass`
1502
+ // identity emitHtmlNode would have produced for this root.
1503
+ if (!root.className) {
1504
+ const minted = withThemeSuffix(buildElementClass(compCtx, undefined), compCtx);
1505
+ ctx.styleClasses.set(minted, { name: minted, base: {} });
1506
+ root.className = minted;
1507
+ }
1508
+
1509
+ const rawStyle = node.style as StyleObject | ResponsiveStyleObject | undefined;
1510
+ const rawInteractive = node.interactiveStyles as InteractiveStyles | undefined;
1511
+ const resolvedStyle = substituteVarsInStyle(
1512
+ resolveStyleTemplates(rawStyle, parentProps),
1513
+ ctx,
1514
+ );
1515
+ const resolvedInteractive = substituteVarsInInteractive(
1516
+ resolveInteractiveStyleTemplates(rawInteractive, parentProps),
1517
+ ctx,
1518
+ );
1519
+
1520
+ // Stable per-instance combo name: `is-<5char hash>` where the hash is
1521
+ // derived from the placement's full element-class identity in the OUTER
1522
+ // tree. Two instances of the same component at different positions get
1523
+ // distinct combos; the same instance produces the same name across
1524
+ // re-imports. `mintInstanceComboName` resolves the rare case where two
1525
+ // identities collapse to the same hash slice within one export.
1526
+ const instanceLocClass = withThemeSuffix(buildElementClass(ctx, node.label), ctx);
1527
+ const comboName = mintInstanceComboName(ctx, instanceLocClass);
1528
+
1529
+ const combo = buildInstanceStyleCombo(
1530
+ comboName,
1531
+ root.className,
1532
+ resolvedStyle,
1533
+ resolvedInteractive,
1534
+ ctx.breakpoints,
1535
+ ctx.responsiveScales,
1536
+ );
1537
+ if (combo) {
1538
+ substituteVarsInStyleClass(combo, ctx);
1539
+ // Webflow only honors a single combo class on top of the primary. If
1540
+ // the body root already wears a StyleMapping-driven combo from
1541
+ // `mapStylesToWebflow` (e.g. `is-size-small`), fold this instance
1542
+ // override into a fresh merged class instead of stacking. Outer
1543
+ // instance overrides land last so they win on key conflicts,
1544
+ // mirroring SSR's merge order in `ssrRenderer.ts:1244`.
1545
+ const existingComboName = Array.isArray(root.comboClasses) && root.comboClasses.length > 0
1546
+ ? root.comboClasses[0]
1547
+ : undefined;
1548
+ const inner = existingComboName ? ctx.styleClasses.get(existingComboName) : undefined;
1549
+ if (inner) {
1550
+ const merged = mergeComboClasses(inner, combo, root.className);
1551
+ ctx.styleClasses.set(merged.name, merged);
1552
+ root.comboClasses = [merged.name];
1553
+ } else {
1554
+ ctx.styleClasses.set(combo.name, combo);
1555
+ root.comboClasses = [combo.name];
1556
+ }
1557
+ }
1558
+ }
1559
+ }
1560
+
1561
+ // Mark the first emitted element as the component root so bundled scripts
1562
+ // can find their instances at runtime via [data-component~="…"] and read
1563
+ // their props via data-props. Mirrors ssrRenderer.ts:1311-1345 — same shape
1564
+ // (`data-component` space-separated, `data-props` JSON keyed by component
1565
+ // name) so component code runs unmodified in the published Webflow page.
1566
+ // Only tag if the component actually ships JavaScript; otherwise the marker
1567
+ // is dead weight.
1568
+ if (emitted.length > 0 && structured?.javascript) {
1569
+ const root = emitted[0];
1570
+ if (root && typeof root === 'object') {
1571
+ root.attributes = root.attributes || {};
1572
+ const existing = root.attributes['data-component'];
1573
+ root.attributes['data-component'] = existing
1574
+ ? `${existing} ${node.component}`
1575
+ : node.component;
1576
+
1577
+ // Serialize defineVars-exposed props into data-props, keyed by component
1578
+ // name so nested components don't clobber each other on a shared root.
1579
+ const defineVars = structured.defineVars;
1580
+ if (defineVars) {
1581
+ const varsToExpose = defineVars === true
1582
+ ? Object.keys(structured.interface || {})
1583
+ : defineVars;
1584
+ const propsForJS: Record<string, unknown> = {};
1585
+ for (const varName of varsToExpose) {
1586
+ if (resolvedProps[varName] !== undefined) {
1587
+ propsForJS[varName] = resolvedProps[varName];
1588
+ }
1589
+ }
1590
+
1591
+ let existingPropsMap: Record<string, unknown> = {};
1592
+ const existingPropsStr = root.attributes['data-props'];
1593
+ if (typeof existingPropsStr === 'string' && existingPropsStr) {
1594
+ try {
1595
+ // Stored URL-encoded (see comment below) — decode before parsing.
1596
+ const parsed = JSON.parse(decodeURIComponent(existingPropsStr));
1597
+ if (parsed && typeof parsed === 'object') {
1598
+ existingPropsMap = parsed as Record<string, unknown>;
1599
+ }
1600
+ } catch { /* ignore malformed */ }
1601
+ }
1602
+ existingPropsMap[node.component] = propsForJS;
1603
+ // URL-encode the serialized JSON. Webflow's `<DOM>`-type element
1604
+ // (used for `<input>`, `<label>` etc.) rejects attribute values
1605
+ // containing double quotes via setAttribute. Encoding sidesteps that
1606
+ // by removing literal `"` characters — the runtime IIFE in the JS
1607
+ // bundle decodes via decodeURIComponent before JSON.parse.
1608
+ root.attributes['data-props'] = encodeURIComponent(JSON.stringify(existingPropsMap));
1609
+ }
1610
+ }
1611
+ }
1612
+
1613
+ return emitted;
304
1614
  }
305
1615
 
306
- function emitSlotMarker(
1616
+ async function emitSlotMarker(
307
1617
  node: SlotMarker,
308
1618
  ctx: WebflowEmitContext,
309
1619
  instanceProps?: Record<string, unknown>
310
- ): WebflowElement[] {
1620
+ ): Promise<WebflowElement[]> {
311
1621
  // Use instance children passed via context to fill the slot
312
1622
  if (ctx.slotChildren) {
313
- // Switch back to parent context (page-level) for slot children
1623
+ // Switch back to caller context so slot children's generated class names
1624
+ // hash against the page that supplied them, not the slot-host component.
1625
+ const restored = ctx.slotEmitContext;
314
1626
  const parentCtx: WebflowEmitContext = {
315
1627
  ...ctx,
1628
+ ...(restored
1629
+ ? {
1630
+ fileType: restored.fileType,
1631
+ fileName: restored.fileName,
1632
+ elementPath: [...restored.elementPath],
1633
+ }
1634
+ : {}),
316
1635
  slotChildren: undefined, // prevent infinite slot nesting
1636
+ slotEmitContext: undefined, // consumed
1637
+ slotInstanceProps: undefined, // consumed
317
1638
  };
318
- return convertChildren(ctx.slotChildren as any, parentCtx, instanceProps);
1639
+ // Slot children's `{{prop}}` templates reference the OUTER component's
1640
+ // interface (the component whose body authored these children), not the
1641
+ // slot host's. `slotInstanceProps` captures that outer prop set in
1642
+ // `emitInlineComponentBody`. Without this, a Heading with `text="{{title}}"`
1643
+ // placed inside a `<Stack>` slot would look up `title` on Stack's props
1644
+ // (where it doesn't exist) and emit an empty heading.
1645
+ const slotProps = ctx.slotInstanceProps ?? instanceProps;
1646
+ return convertChildren(ctx.slotChildren as any, parentCtx, slotProps);
319
1647
  }
320
1648
  // Fall back to slot defaults
321
1649
  if (node.default) {
@@ -324,90 +1652,774 @@ function emitSlotMarker(
324
1652
  return [];
325
1653
  }
326
1654
 
327
- function emitEmbedNode(
1655
+ async function emitEmbedNode(
328
1656
  node: EmbedNode,
329
1657
  ctx: WebflowEmitContext,
330
1658
  instanceProps?: Record<string, unknown>
331
- ): WebflowElement {
332
- const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
333
- const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
1659
+ ): Promise<WebflowElement> {
1660
+ const style = substituteVarsInStyle(
1661
+ resolveStyleTemplates(
1662
+ node.style as StyleObject | ResponsiveStyleObject | undefined,
1663
+ instanceProps
1664
+ ),
1665
+ ctx
1666
+ );
1667
+ const interactiveStyles = substituteVarsInInteractive(
1668
+ resolveInteractiveStyleTemplates(
1669
+ node.interactiveStyles as InteractiveStyles | undefined,
1670
+ instanceProps
1671
+ ),
1672
+ ctx
1673
+ );
334
1674
 
335
1675
  let className: string | undefined;
336
1676
  if (style || (interactiveStyles && interactiveStyles.length > 0)) {
337
- const elementClass = buildElementClass(ctx, node.label);
1677
+ const elementClass = withThemeSuffix(buildElementClass(ctx, node.label), ctx);
338
1678
  const { primaryClass } = mapStylesToWebflow(
339
- elementClass, style, interactiveStyles, ctx.breakpoints
1679
+ elementClass, style, interactiveStyles, ctx.breakpoints, ctx.responsiveScales
340
1680
  );
1681
+ substituteVarsInStyleClass(primaryClass, ctx);
341
1682
  className = primaryClass.name;
342
1683
  ctx.styleClasses.set(primaryClass.name, primaryClass);
343
1684
  }
344
1685
 
345
- const htmlStr = typeof node.html === 'string' ? node.html : '';
1686
+ // `node.html` may be an HtmlMapping (`{_mapping: true, prop, values}`) bound
1687
+ // to a component prop — e.g. an `Icon` component switches its inline SVG by
1688
+ // the `icon` enum. Resolve it against the same layered prop context the
1689
+ // runtime SSR uses, otherwise the embed silently exports as empty.
1690
+ const props = buildTemplateProps(ctx, instanceProps);
1691
+ let htmlStr: string;
1692
+ if (isHtmlMapping(node.html)) {
1693
+ htmlStr = resolveHtmlMapping(node.html, props) ?? '';
1694
+ } else if (typeof node.html === 'string') {
1695
+ htmlStr = hasCodeTemplates(node.html) ? processCodeTemplates(node.html, props) : node.html;
1696
+ } else {
1697
+ htmlStr = '';
1698
+ }
346
1699
 
347
- const element: WebflowElement = {
348
- tag: 'div',
349
- rawHtml: htmlStr,
350
- };
1700
+ const element: WebflowElement = { tag: 'div' };
351
1701
  if (className) element.className = className;
352
1702
 
1703
+ const trimmed = htmlStr.trim();
1704
+ if (/^<svg\b/i.test(trimmed) && /<\/svg\s*>\s*$/i.test(trimmed)) {
1705
+ let svg = trimmed;
1706
+ if (/currentColor/i.test(svg)) {
1707
+ const resolved = concreteColor(extractBaseColor(style, ctx, instanceProps)) ?? ctx.inheritedColor;
1708
+ if (resolved) svg = inlineCurrentColorInSvg(svg, resolved);
1709
+ }
1710
+ element.svgSource = svg;
1711
+ if (node.label) element.imageAlt = node.label;
1712
+ return element;
1713
+ }
1714
+
1715
+ const imgMatch = trimmed.match(/^<img\b([^>]*)\/?>\s*$/i);
1716
+ if (imgMatch) {
1717
+ const attrs = imgMatch[1];
1718
+ const src = /\bsrc\s*=\s*"([^"]*)"|\bsrc\s*=\s*'([^']*)'/i.exec(attrs);
1719
+ const alt = /\balt\s*=\s*"([^"]*)"|\balt\s*=\s*'([^']*)'/i.exec(attrs);
1720
+ if (src) {
1721
+ element.imageSrc = src[1] ?? src[2] ?? '';
1722
+ element.imageAlt = (alt?.[1] ?? alt?.[2] ?? node.label) || '';
1723
+ await maybeInlineLocalImage(element, element.imageSrc);
1724
+ return element;
1725
+ }
1726
+ }
1727
+
1728
+ if (trimmed.length) {
1729
+ element.unsupportedEmbed = {
1730
+ reason: 'embed-not-svg-or-image',
1731
+ preview: trimmed.slice(0, 120),
1732
+ label: node.label,
1733
+ };
1734
+ }
353
1735
  return element;
354
1736
  }
355
1737
 
356
- function emitLinkNode(
1738
+ /**
1739
+ * Rewrite an internal `/path` href to the current-locale Webflow URL. Other
1740
+ * link forms (external, protocol-relative, anchor, mailto, tel, javascript,
1741
+ * data, relative, empty) pass through unchanged.
1742
+ *
1743
+ * Webflow has no Designer API for binding `<a>` to a Page entity (Apr 2026),
1744
+ * so this is a pure string rewrite that mirrors the URL structure
1745
+ * `buildWebflow.ts` already produces for imported pages
1746
+ * (`/`, `/${slug}`, `/${locale}`, `/${locale}/${slug}`).
1747
+ */
1748
+ function resolveLinkHref(rawHref: string, ctx: WebflowEmitContext): string {
1749
+ if (!rawHref) return rawHref;
1750
+ // Only `^/` paths are candidates. `//` is protocol-relative — leave alone.
1751
+ if (!rawHref.startsWith('/') || rawHref.startsWith('//')) return rawHref;
1752
+ // No locale machinery wired in this context — nothing to translate against.
1753
+ if (!ctx.i18nConfig || !ctx.locale || !ctx.slugMappings) return rawHref;
1754
+
1755
+ // Split off `?query` and `#fragment` so they survive translation, then
1756
+ // reattach. Whichever comes first marks the boundary of the path.
1757
+ const hashIdx = rawHref.indexOf('#');
1758
+ const queryIdx = rawHref.indexOf('?');
1759
+ let cut = -1;
1760
+ if (hashIdx >= 0 && queryIdx >= 0) cut = Math.min(hashIdx, queryIdx);
1761
+ else if (hashIdx >= 0) cut = hashIdx;
1762
+ else if (queryIdx >= 0) cut = queryIdx;
1763
+
1764
+ const path = cut >= 0 ? rawHref.slice(0, cut) : rawHref;
1765
+ const suffix = cut >= 0 ? rawHref.slice(cut) : '';
1766
+
1767
+ if (!ctx.slugIndex) {
1768
+ ctx.slugIndex = buildSlugIndex(ctx.slugMappings);
1769
+ }
1770
+
1771
+ // Meno hrefs are authored in canonical (default-locale) form. Translate
1772
+ // from default-locale → ctx.locale; `translatePath` falls back to the same
1773
+ // slug + locale prefix for paths without an explicit slugMapping entry,
1774
+ // which is the correct behavior for unlocalized pages.
1775
+ const translated = translatePath(
1776
+ path,
1777
+ ctx.locale,
1778
+ ctx.i18nConfig.defaultLocale,
1779
+ ctx.i18nConfig.defaultLocale,
1780
+ ctx.slugIndex
1781
+ );
1782
+
1783
+ return translated + suffix;
1784
+ }
1785
+
1786
+ async function emitLinkNode(
357
1787
  node: LinkNode,
358
1788
  ctx: WebflowEmitContext,
359
1789
  instanceProps?: Record<string, unknown>
360
- ): WebflowElement {
361
- const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
362
- const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
1790
+ ): Promise<WebflowElement> {
1791
+ const style = substituteVarsInStyle(
1792
+ resolveStyleTemplates(
1793
+ node.style as StyleObject | ResponsiveStyleObject | undefined,
1794
+ instanceProps
1795
+ ),
1796
+ ctx
1797
+ );
1798
+ const interactiveStyles = substituteVarsInInteractive(
1799
+ resolveInteractiveStyleTemplates(
1800
+ node.interactiveStyles as InteractiveStyles | undefined,
1801
+ instanceProps
1802
+ ),
1803
+ ctx
1804
+ );
363
1805
 
1806
+ // Always assign a class so we can default `text-decoration: none` —
1807
+ // browsers underline `<a>` by default and source designs almost never
1808
+ // override it. Mirrors the heading-margin default for `h1`–`h6`.
364
1809
  let className: string | undefined;
365
- if (style || (interactiveStyles && interactiveStyles.length > 0)) {
366
- const elementClass = buildElementClass(ctx, node.label);
367
- const { primaryClass } = mapStylesToWebflow(
368
- elementClass, style, interactiveStyles, ctx.breakpoints
1810
+ let comboClassNames: string[] | undefined;
1811
+ {
1812
+ const elementClass = withThemeSuffix(buildElementClass(ctx, node.label), ctx);
1813
+ const { primaryClass, comboClasses } = mapStylesToWebflow(
1814
+ elementClass, style, interactiveStyles, ctx.breakpoints, ctx.responsiveScales,
1815
+ { instanceProps, componentDefaults: ctx.componentDefaults, themeSuffix: themedClassSuffix(ctx) }
369
1816
  );
1817
+ substituteVarsInStyleClass(primaryClass, ctx);
1818
+ applyLinkTextDecorationDefault(primaryClass);
1819
+ applyGridRowsDefault(primaryClass);
370
1820
  className = primaryClass.name;
371
1821
  ctx.styleClasses.set(primaryClass.name, primaryClass);
1822
+
1823
+ if (comboClasses.length > 0) {
1824
+ comboClassNames = [];
1825
+ for (const combo of comboClasses) {
1826
+ substituteVarsInStyleClass(combo, ctx);
1827
+ ctx.styleClasses.set(combo.name, combo);
1828
+ comboClassNames.push(combo.name);
1829
+ }
1830
+ }
1831
+
1832
+ if (
1833
+ ctx.interactiveStylesMap
1834
+ && Array.isArray(interactiveStyles)
1835
+ && interactiveStyles.length > 0
1836
+ ) {
1837
+ ctx.interactiveStylesMap.set(primaryClass.name, interactiveStyles);
1838
+ }
372
1839
  }
373
1840
 
374
- // Resolve href
1841
+ // Resolve href, then rewrite internal page paths to the current-locale
1842
+ // Webflow URL so links survive localized slugs and non-default-locale
1843
+ // prefixes (`/about` → `/fr/à-propos`). External / anchor / mailto / tel
1844
+ // / protocol-relative hrefs pass through unchanged.
375
1845
  let href = '#';
376
1846
  if (typeof node.href === 'string') {
377
- href = instanceProps && hasTemplates(node.href)
378
- ? resolveTemplate(node.href, instanceProps)
379
- : node.href;
1847
+ href = resolveStringTemplate(node.href, ctx, instanceProps);
380
1848
  }
1849
+ href = resolveLinkHref(href, ctx);
381
1850
 
382
1851
  const attributes: Record<string, string | number | boolean> = { href };
383
1852
  if (node.attributes) {
384
1853
  for (const [key, value] of Object.entries(node.attributes)) {
385
- attributes[key] = value;
1854
+ attributes[key] = (typeof value === 'string')
1855
+ ? resolveStringTemplate(value, ctx, instanceProps)
1856
+ : value;
386
1857
  }
387
1858
  }
388
1859
 
389
1860
  let children: WebflowElement[] | undefined;
390
1861
  if (node.children) {
391
- children = convertChildren(node.children, ctx, instanceProps);
1862
+ const ownColor = concreteColor(extractBaseColor(style, ctx, instanceProps));
1863
+ const childCtx = ownColor ? { ...ctx, inheritedColor: ownColor } : ctx;
1864
+ children = await convertChildren(node.children, childCtx, instanceProps);
392
1865
  if (children.length === 0) children = undefined;
393
1866
  }
394
1867
 
395
1868
  const element: WebflowElement = { tag: 'a', attributes };
396
1869
  if (className) element.className = className;
1870
+ if (comboClassNames) element.comboClasses = comboClassNames;
397
1871
  if (children) element.children = children;
398
1872
 
399
1873
  return element;
400
1874
  }
401
1875
 
1876
+ // ---------------------------------------------------------------------------
1877
+ // List nodes (prop + collection) — static expansion
1878
+ // ---------------------------------------------------------------------------
1879
+
1880
+ function singularize(name: string): string {
1881
+ if (name.endsWith('ies') && name.length > 3) return name.slice(0, -3) + 'y';
1882
+ if (name.endsWith('s') && name.length > 1) return name.slice(0, -1);
1883
+ return name;
1884
+ }
1885
+
1886
+ function makeI18nResolver(ctx: WebflowEmitContext): ValueResolver | undefined {
1887
+ if (!ctx.locale || !ctx.i18nConfig) return undefined;
1888
+ const { locale, i18nConfig } = ctx;
1889
+ return (value: unknown) => resolveI18nValue(value, locale, i18nConfig);
1890
+ }
1891
+
1892
+ /**
1893
+ * Resolve i18n values inside a CMS item so list children see the right locale.
1894
+ * Mirrors the pre-render shaping the SSR list does via templateContext.
1895
+ */
1896
+ function resolveItemI18n(item: CMSItem, ctx: WebflowEmitContext): CMSItem {
1897
+ if (!ctx.locale || !ctx.i18nConfig) return item;
1898
+ const out: Record<string, unknown> = {};
1899
+ for (const [k, v] of Object.entries(item)) {
1900
+ out[k] = isI18nValue(v) ? resolveI18nValue(v, ctx.locale, ctx.i18nConfig) : v;
1901
+ }
1902
+ return out as CMSItem;
1903
+ }
1904
+
1905
+ async function getCollectionItemsForExport(
1906
+ node: ListNode,
1907
+ source: string,
1908
+ ctx: WebflowEmitContext
1909
+ ): Promise<CMSItem[]> {
1910
+ if (!ctx.cmsService) return [];
1911
+
1912
+ let items: CMSItem[] = [];
1913
+
1914
+ // Reference list (`items: "{{cms.tagIds}}"` or `items: ["id1", "id2"]`)
1915
+ if ((node as any).items) {
1916
+ const itemsField = (node as any).items;
1917
+ let resolvedIds: string | string[] | undefined;
1918
+
1919
+ if (typeof itemsField === 'string' && itemsField.startsWith('{{')) {
1920
+ if (itemsField.startsWith('{{cms.') && ctx.cmsContext?.cms) {
1921
+ const fieldPath = itemsField.slice(6, -2);
1922
+ let value: unknown = ctx.cmsContext.cms;
1923
+ for (const part of fieldPath.split('.')) {
1924
+ if (value && typeof value === 'object' && part in (value as object)) {
1925
+ value = (value as Record<string, unknown>)[part];
1926
+ } else { value = undefined; break; }
1927
+ }
1928
+ if (value !== null && value !== undefined) {
1929
+ resolvedIds = Array.isArray(value) ? value.map(v => String(v)) : String(value);
1930
+ }
1931
+ } else {
1932
+ resolvedIds = resolveItemsTemplate(
1933
+ itemsField,
1934
+ (ctx.templateContext || { _type: 'template' }) as any
1935
+ );
1936
+ }
1937
+ if (!resolvedIds) return [];
1938
+ const ids = Array.isArray(resolvedIds) ? resolvedIds : [resolvedIds];
1939
+ items = await ctx.cmsService.getItemsByIds(source, ids);
1940
+ } else {
1941
+ const ids = Array.isArray(itemsField) ? itemsField : [itemsField];
1942
+ items = await ctx.cmsService.getItemsByIds(source, ids);
1943
+ }
1944
+ if (ctx.locale) {
1945
+ items = items.filter(it => !isItemDraftForLocale(it, ctx.locale!));
1946
+ }
1947
+ } else {
1948
+ items = await ctx.cmsService.queryItems({
1949
+ collection: source,
1950
+ filter: node.filter as CMSFilterCondition | CMSFilterCondition[] | Record<string, unknown> | undefined,
1951
+ sort: node.sort,
1952
+ limit: node.limit,
1953
+ offset: node.offset,
1954
+ excludeDraftLocale: ctx.locale,
1955
+ });
1956
+ }
1957
+
1958
+ if ((node as any).excludeCurrentItem && ctx.cmsContext?.cms?._id) {
1959
+ const currentId = ctx.cmsContext.cms._id as string;
1960
+ items = items.filter(it => it._id !== currentId);
1961
+ }
1962
+
1963
+ return items;
1964
+ }
1965
+
1966
+ function getPropItemsForExport(source: string, ctx: WebflowEmitContext, instanceProps?: Record<string, unknown>): unknown[] {
1967
+ // Template expression like "{{items}}" or "{{category.items}}". SSR pre-
1968
+ // substitutes these against the merged prop scope via `processStructure`
1969
+ // before the list node is reached, so its `getPropItems` only needs to look
1970
+ // at templateContext. The Webflow exporter walks the raw JSON, so we
1971
+ // resolve here against both scopes: `templateContext` carries outer-list
1972
+ // iteration variables (`{{category.items}}`); `instanceProps` carries the
1973
+ // enclosing component's resolved props (`{{items}}` referring to the
1974
+ // component's own `items` prop, the form generated by the editor).
1975
+ if (source.startsWith('{{') && source.endsWith('}}')) {
1976
+ const path = source.slice(2, -2).trim();
1977
+ const ctxObj = ctx.templateContext as Record<string, unknown> | undefined;
1978
+ if (ctxObj) {
1979
+ const resolved = getNestedValue(ctxObj, path);
1980
+ if (Array.isArray(resolved)) return resolved;
1981
+ }
1982
+ if (instanceProps) {
1983
+ const resolved = getNestedValue(instanceProps, path);
1984
+ if (Array.isArray(resolved)) return resolved;
1985
+ }
1986
+ return [];
1987
+ }
1988
+ // Direct prop name — resolve from current component scope, then CMS context.
1989
+ if (instanceProps && Array.isArray(instanceProps[source])) {
1990
+ return instanceProps[source] as unknown[];
1991
+ }
1992
+ if (ctx.cmsContext?.cms) {
1993
+ const cmsValue = (ctx.cmsContext.cms as Record<string, unknown>)[source];
1994
+ if (Array.isArray(cmsValue)) return cmsValue;
1995
+ }
1996
+ return [];
1997
+ }
1998
+
1999
+ /**
2000
+ * Build a synthetic CMSItem whose every field is the binding sentinel string
2001
+ * for that field slug. Used in bound mode so the existing template engine
2002
+ * resolves `{{post.title}}` to a sentinel that the post-walk can rewrite into
2003
+ * a `menoBind.textField` marker.
2004
+ */
2005
+ function buildBindingPlaceholderItem(schema: CMSSchema | undefined): CMSItem {
2006
+ const item: Record<string, unknown> = {};
2007
+ if (schema?.fields) {
2008
+ for (const fieldSlug of Object.keys(schema.fields)) {
2009
+ item[fieldSlug] = `${MENO_BIND_SENTINEL_PREFIX}${fieldSlug}${MENO_BIND_SENTINEL_SUFFIX}`;
2010
+ }
2011
+ }
2012
+ // System fields commonly referenced in templates (`{{post._url}}` for links,
2013
+ // `{{post._slug}}` for hrefs). Webflow's binding panel exposes these as
2014
+ // "Current item URL" / "Slug" — map back to the same names so the manual
2015
+ // binding step is unambiguous.
2016
+ item._id = `${MENO_BIND_SENTINEL_PREFIX}_id${MENO_BIND_SENTINEL_SUFFIX}`;
2017
+ item._slug = `${MENO_BIND_SENTINEL_PREFIX}_slug${MENO_BIND_SENTINEL_SUFFIX}`;
2018
+ item._url = `${MENO_BIND_SENTINEL_PREFIX}_url${MENO_BIND_SENTINEL_SUFFIX}`;
2019
+ return item as CMSItem;
2020
+ }
2021
+
2022
+ /**
2023
+ * Walk a rendered element subtree and convert binding sentinels into
2024
+ * `menoBind` markers so the extension can surface them to the user. Sentinels
2025
+ * appear in `textContent` (from `<h2>{{post.title}}</h2>`) or in attribute
2026
+ * values (from `<a href="{{post._url}}">` / `<img src="{{post.cover}}">`).
2027
+ *
2028
+ * Exact-match sentinels become a clean `{textField}` / `{attrFields}` entry;
2029
+ * partial matches (`/blog/__MENO_BIND__:_url:__`) record the binding hint but
2030
+ * keep the literal text so the user can see the URL pattern context.
2031
+ *
2032
+ * Side effect: image inlining done by `maybeInlineLocalImage` for sentinel
2033
+ * srcs leaks file bytes that point at no real asset. Strip them so the
2034
+ * extension doesn't try to upload garbage.
2035
+ */
2036
+ function applyBindingMarkers(el: WebflowElement): void {
2037
+ // Text content
2038
+ if (typeof el.textContent === 'string') {
2039
+ const exact = el.textContent.match(MENO_BIND_SENTINEL_EXACT_RE);
2040
+ if (exact) {
2041
+ const field = exact[1];
2042
+ el.menoBind = { ...(el.menoBind || {}), textField: field };
2043
+ el.textContent = `{${field}}`;
2044
+ } else if (MENO_BIND_SENTINEL_RE.test(el.textContent)) {
2045
+ MENO_BIND_SENTINEL_RE.lastIndex = 0;
2046
+ const fields: string[] = [];
2047
+ el.textContent = el.textContent.replace(MENO_BIND_SENTINEL_RE, (_m, f) => {
2048
+ fields.push(f);
2049
+ return `{${f}}`;
2050
+ });
2051
+ if (fields.length) {
2052
+ el.menoBind = { ...(el.menoBind || {}), textField: fields[0] };
2053
+ }
2054
+ }
2055
+ MENO_BIND_SENTINEL_RE.lastIndex = 0;
2056
+ }
2057
+
2058
+ // Attributes
2059
+ if (el.attributes) {
2060
+ const attrFields: Record<string, string> = el.menoBind?.attrFields || {};
2061
+ for (const [key, rawValue] of Object.entries(el.attributes)) {
2062
+ if (typeof rawValue !== 'string') continue;
2063
+ const exact = rawValue.match(MENO_BIND_SENTINEL_EXACT_RE);
2064
+ if (exact) {
2065
+ attrFields[key] = exact[1];
2066
+ el.attributes[key] = `{${exact[1]}}`;
2067
+ continue;
2068
+ }
2069
+ MENO_BIND_SENTINEL_RE.lastIndex = 0;
2070
+ if (MENO_BIND_SENTINEL_RE.test(rawValue)) {
2071
+ MENO_BIND_SENTINEL_RE.lastIndex = 0;
2072
+ let firstField: string | undefined;
2073
+ const replaced = rawValue.replace(MENO_BIND_SENTINEL_RE, (_m, f) => {
2074
+ if (!firstField) firstField = f;
2075
+ return `{${f}}`;
2076
+ });
2077
+ if (firstField) attrFields[key] = firstField;
2078
+ el.attributes[key] = replaced;
2079
+ }
2080
+ MENO_BIND_SENTINEL_RE.lastIndex = 0;
2081
+ }
2082
+ if (Object.keys(attrFields).length > 0) {
2083
+ el.menoBind = { ...(el.menoBind || {}), attrFields };
2084
+ }
2085
+ }
2086
+
2087
+ // Image inlining produced bytes for a sentinel src — drop them; user will
2088
+ // re-bind the image to a real CMS field after insertion.
2089
+ if (
2090
+ el.menoBind?.attrFields?.src
2091
+ && (el.imageDataBase64 || el.imageSrc)
2092
+ ) {
2093
+ delete el.imageDataBase64;
2094
+ delete el.imageDataMime;
2095
+ delete el.imageDataFileName;
2096
+ delete el.imageSrc;
2097
+ }
2098
+
2099
+ // Recurse
2100
+ if (Array.isArray(el.children)) {
2101
+ for (const child of el.children) {
2102
+ if (typeof child !== 'string') applyBindingMarkers(child);
2103
+ }
2104
+ }
2105
+ if (Array.isArray(el.inlineFallback)) {
2106
+ for (const child of el.inlineFallback) applyBindingMarkers(child);
2107
+ }
2108
+ }
2109
+
2110
+ /**
2111
+ * Wrap any non-`<li>` child of a `<ul>`/`<ol>` element in a synthetic `<li>`.
2112
+ * Webflow's Designer API enforces that the `List` preset only accepts
2113
+ * `ListItem` children — so an `<a>` or `<div>` directly under `<ul>` (a common
2114
+ * Meno pattern when a `list` node iterates a link-style item template inside
2115
+ * a `<ul>` wrapper) is rejected at insert time with
2116
+ * "Non-List Item can not be placed in a List." This pass corrects the structure
2117
+ * at the source so the exported tree is structurally valid Webflow markup.
2118
+ *
2119
+ * Walks recursively, in-place. Idempotent: existing `<li>` children pass
2120
+ * through untouched.
2121
+ */
2122
+ export function normalizeListChildren(
2123
+ elements: Array<WebflowElement | string> | undefined
2124
+ ): void {
2125
+ if (!Array.isArray(elements)) return;
2126
+ for (const el of elements) {
2127
+ if (typeof el === 'string') continue;
2128
+ const tag = (el.tag || '').toLowerCase();
2129
+ if ((tag === 'ul' || tag === 'ol') && Array.isArray(el.children)) {
2130
+ el.children = el.children.map((child): WebflowElement | string => {
2131
+ if (typeof child === 'string') return { tag: 'li', children: [child] };
2132
+ if ((child.tag || '').toLowerCase() === 'li') return child;
2133
+ return { tag: 'li', children: [child] };
2134
+ });
2135
+ }
2136
+ if (Array.isArray(el.children)) normalizeListChildren(el.children);
2137
+ if (Array.isArray(el.inlineFallback)) normalizeListChildren(el.inlineFallback);
2138
+ }
2139
+ }
2140
+
2141
+ /**
2142
+ * Static expansion of a List/CMS-list node. Mirrors SSR's `processList`:
2143
+ * resolves the source (CMS query, prop array, or template expression),
2144
+ * applies filter/sort/limit/offset, and emits children once per item with
2145
+ * the per-item template context layered onto `instanceProps`.
2146
+ */
2147
+ async function emitListNode(
2148
+ node: ListNode,
2149
+ ctx: WebflowEmitContext,
2150
+ instanceProps?: Record<string, unknown>
2151
+ ): Promise<WebflowElement[]> {
2152
+ const nodeType = node.type as string;
2153
+ const isLegacyCMSList = nodeType === 'cms-list';
2154
+ const sourceType = isLegacyCMSList ? 'collection' : (node.sourceType || 'prop');
2155
+
2156
+ if (sourceType === 'collection' && !ctx.cmsService) return [];
2157
+
2158
+ const rawSource = (node as any).source || (node as any).collection;
2159
+ const source = typeof rawSource === 'string' ? rawSource : '';
2160
+ const sourceIsResolved = Array.isArray(rawSource);
2161
+
2162
+ let variableName: string;
2163
+ if (node.itemAs) variableName = node.itemAs;
2164
+ else if (sourceType === 'collection') variableName = singularize(source);
2165
+ else variableName = 'item';
2166
+
2167
+ // Bound mode: emit a single-item synthetic Collection List wrapper instead
2168
+ // of expanding statically. Children are rendered ONCE against a sentinel
2169
+ // placeholder item; sentinels are then converted to `menoBind` markers the
2170
+ // extension picks up. Only reached for `sourceType === 'collection'`.
2171
+ if (sourceType === 'collection' && ctx.bindCollectionLists && ctx.cmsService && source) {
2172
+ const schema = ctx.cmsService.getSchema(source) || undefined;
2173
+ const placeholder = buildBindingPlaceholderItem(schema);
2174
+ const templateContext = buildTemplateContext(
2175
+ variableName,
2176
+ placeholder,
2177
+ 0,
2178
+ 1,
2179
+ ctx.templateContext as any
2180
+ );
2181
+ const childCtx: WebflowEmitContext = {
2182
+ ...ctx,
2183
+ templateContext: templateContext as Record<string, unknown>,
2184
+ };
2185
+ const mergedProps: Record<string, unknown> = {
2186
+ ...(instanceProps || {}),
2187
+ ...templateContext,
2188
+ };
2189
+ const renderedChildren = node.children
2190
+ ? await convertChildren(node.children as any, childCtx, mergedProps)
2191
+ : [];
2192
+ for (const child of renderedChildren) {
2193
+ if (typeof child !== 'string') applyBindingMarkers(child);
2194
+ }
2195
+ return [{
2196
+ tag: COLLECTION_LIST_TAG,
2197
+ menoCollectionRef: source,
2198
+ children: renderedChildren,
2199
+ }];
2200
+ }
2201
+
2202
+ let items: unknown[];
2203
+ if (sourceType === 'collection') {
2204
+ items = await getCollectionItemsForExport(node, source, ctx);
2205
+ } else if (sourceIsResolved) {
2206
+ items = rawSource as unknown[];
2207
+ } else if (source) {
2208
+ items = getPropItemsForExport(source, ctx, instanceProps);
2209
+ if (node.offset) items = items.slice(node.offset);
2210
+ if (node.limit) items = items.slice(0, node.limit);
2211
+ } else {
2212
+ items = [];
2213
+ }
2214
+
2215
+ return expandListItems(node, ctx, instanceProps, items, variableName, source);
2216
+ }
2217
+
2218
+ async function expandListItems(
2219
+ node: ListNode,
2220
+ ctx: WebflowEmitContext,
2221
+ instanceProps: Record<string, unknown> | undefined,
2222
+ items: unknown[],
2223
+ variableName: string,
2224
+ source: string
2225
+ ): Promise<WebflowElement[]> {
2226
+ if (items.length === 0) return [];
2227
+
2228
+ const schema: CMSSchema | undefined = ctx.cmsService
2229
+ ? (ctx.cmsService.getSchema(source) || undefined)
2230
+ : undefined;
2231
+
2232
+ const out: WebflowElement[] = [];
2233
+ for (let i = 0; i < items.length; i++) {
2234
+ const rawItem = items[i] as Record<string, unknown>;
2235
+ const enriched = schema && ctx.locale && ctx.i18nConfig
2236
+ ? addItemUrl(rawItem as CMSItem, schema, ctx.locale, ctx.i18nConfig)
2237
+ : (rawItem as CMSItem);
2238
+ const item = resolveItemI18n(enriched, ctx);
2239
+
2240
+ const templateContext = buildTemplateContext(
2241
+ variableName,
2242
+ item,
2243
+ i,
2244
+ items.length,
2245
+ (ctx.templateContext as any)
2246
+ );
2247
+
2248
+ const childCtx: WebflowEmitContext = {
2249
+ ...ctx,
2250
+ templateContext: templateContext as Record<string, unknown>,
2251
+ };
2252
+
2253
+ // Merge per-item context onto the instance prop scope so templates
2254
+ // ({{item.title}}, {{post.field}}, {{itemIndex}}) resolve via the
2255
+ // existing template helpers in this file.
2256
+ const mergedProps: Record<string, unknown> = {
2257
+ ...(instanceProps || {}),
2258
+ ...templateContext,
2259
+ };
2260
+
2261
+ if (node.children) {
2262
+ const savedPath = [...childCtx.elementPath];
2263
+ childCtx.elementPath = [...savedPath, i];
2264
+ out.push(...(await convertChildren(node.children as any, childCtx, mergedProps)));
2265
+ childCtx.elementPath = savedPath;
2266
+ }
2267
+ }
2268
+ return out;
2269
+ }
2270
+
2271
+ // ---------------------------------------------------------------------------
2272
+ // LocaleList — static link list per configured locale
2273
+ // ---------------------------------------------------------------------------
2274
+
2275
+ async function emitLocaleListNode(
2276
+ node: LocaleListNode,
2277
+ ctx: WebflowEmitContext,
2278
+ instanceProps?: Record<string, unknown>
2279
+ ): Promise<WebflowElement[]> {
2280
+ if (!ctx.slugMappings || !ctx.pagePath || !ctx.i18nConfig || !ctx.locale) {
2281
+ return [{ tag: 'div', attributes: { 'data-locale-list': 'true' } }];
2282
+ }
2283
+
2284
+ if (!ctx.slugIndex) {
2285
+ ctx.slugIndex = buildSlugIndex(ctx.slugMappings);
2286
+ }
2287
+ const localeLinks = getLocaleLinks(ctx.pagePath, ctx.locale, ctx.i18nConfig, ctx.slugIndex);
2288
+
2289
+ const showCurrent = node.showCurrent !== false;
2290
+ const showSeparator = node.showSeparator !== false;
2291
+ const showFlag = node.showFlag !== false;
2292
+ const displayType = node.displayType || 'nativeName';
2293
+
2294
+ const localeIconMap = new Map<string, string>();
2295
+ for (const lc of ctx.i18nConfig.locales) {
2296
+ if (lc.icon) localeIconMap.set(lc.code, lc.icon);
2297
+ }
2298
+
2299
+ // Build container style class
2300
+ const containerStyle = substituteVarsInStyle(
2301
+ resolveStyleTemplates(
2302
+ node.style as StyleObject | ResponsiveStyleObject | undefined,
2303
+ instanceProps
2304
+ ),
2305
+ ctx
2306
+ );
2307
+ const containerInteractive = substituteVarsInInteractive(
2308
+ resolveInteractiveStyleTemplates(
2309
+ node.interactiveStyles as InteractiveStyles | undefined,
2310
+ instanceProps
2311
+ ),
2312
+ ctx
2313
+ );
2314
+
2315
+ let containerClass: string | undefined;
2316
+ if (containerStyle || (containerInteractive && containerInteractive.length > 0)) {
2317
+ const elementClass = withThemeSuffix(buildElementClass(ctx, node.label), ctx);
2318
+ const { primaryClass } = mapStylesToWebflow(
2319
+ elementClass, containerStyle, containerInteractive, ctx.breakpoints, ctx.responsiveScales
2320
+ );
2321
+ substituteVarsInStyleClass(primaryClass, ctx);
2322
+ containerClass = primaryClass.name;
2323
+ ctx.styleClasses.set(primaryClass.name, primaryClass);
2324
+ }
2325
+
2326
+ const buildSubClass = (
2327
+ style: unknown,
2328
+ suffix: string
2329
+ ): string | undefined => {
2330
+ const resolved = substituteVarsInStyle(
2331
+ resolveStyleTemplates(
2332
+ style as StyleObject | ResponsiveStyleObject | undefined,
2333
+ instanceProps
2334
+ ),
2335
+ ctx
2336
+ );
2337
+ if (!resolved) return undefined;
2338
+ const className = withThemeSuffix(`${buildElementClass(ctx, node.label)}-${suffix}`, ctx);
2339
+ const { primaryClass } = mapStylesToWebflow(className, resolved, undefined, ctx.breakpoints, ctx.responsiveScales);
2340
+ substituteVarsInStyleClass(primaryClass, ctx);
2341
+ if (Object.keys(primaryClass.base).length === 0
2342
+ && !primaryClass.breakpoints
2343
+ && !primaryClass.pseudoStates) return undefined;
2344
+ ctx.styleClasses.set(primaryClass.name, primaryClass);
2345
+ return primaryClass.name;
2346
+ };
2347
+
2348
+ const itemClassName = buildSubClass((node as any).itemStyle, 'item');
2349
+ const activeItemClassName = buildSubClass((node as any).activeItemStyle, 'item-active');
2350
+ const separatorClassName = buildSubClass((node as any).separatorStyle, 'separator');
2351
+ const flagClassName = buildSubClass((node as any).flagStyle, 'flag');
2352
+
2353
+ const linkChildren: WebflowElement[] = [];
2354
+ for (let i = 0; i < localeLinks.length; i++) {
2355
+ const link = localeLinks[i];
2356
+ if (!showCurrent && link.isCurrent) continue;
2357
+
2358
+ if (i > 0 && showSeparator) {
2359
+ const sep: WebflowElement = { tag: 'span' };
2360
+ if (separatorClassName) sep.className = separatorClassName;
2361
+ linkChildren.push(sep);
2362
+ }
2363
+
2364
+ const anchor: WebflowElement = {
2365
+ tag: 'a',
2366
+ attributes: {
2367
+ href: link.path,
2368
+ hreflang: link.langTag,
2369
+ 'data-current': link.isCurrent ? 'true' : 'false',
2370
+ 'data-locale': link.locale,
2371
+ },
2372
+ };
2373
+ if (link.isCurrent && (activeItemClassName || itemClassName)) {
2374
+ anchor.className = itemClassName;
2375
+ if (activeItemClassName) anchor.comboClasses = [activeItemClassName];
2376
+ } else if (itemClassName) {
2377
+ anchor.className = itemClassName;
2378
+ }
2379
+
2380
+ const innerChildren: WebflowElement[] = [];
2381
+ const icon = localeIconMap.get(link.locale);
2382
+ if (showFlag && icon) {
2383
+ const img: WebflowElement = {
2384
+ tag: 'img',
2385
+ attributes: { src: icon, alt: `${link.nativeName} flag` },
2386
+ };
2387
+ if (flagClassName) img.className = flagClassName;
2388
+ innerChildren.push(img);
2389
+ }
2390
+
2391
+ let displayText: string;
2392
+ switch (displayType) {
2393
+ case 'code': displayText = link.locale.toUpperCase(); break;
2394
+ case 'name': displayText = link.name; break;
2395
+ case 'nativeName':
2396
+ default: displayText = link.nativeName; break;
2397
+ }
2398
+ innerChildren.push({ tag: 'div', textContent: displayText });
2399
+ anchor.children = innerChildren;
2400
+
2401
+ linkChildren.push(anchor);
2402
+ }
2403
+
2404
+ const wrapper: WebflowElement = {
2405
+ tag: 'div',
2406
+ attributes: { 'data-locale-list': 'true' },
2407
+ children: linkChildren,
2408
+ };
2409
+ if (containerClass) wrapper.className = containerClass;
2410
+
2411
+ return [wrapper];
2412
+ }
2413
+
402
2414
  // ---------------------------------------------------------------------------
403
2415
  // Children helper
404
2416
  // ---------------------------------------------------------------------------
405
2417
 
406
- function convertChildren(
2418
+ async function convertChildren(
407
2419
  children: (ComponentNode | string)[] | string | ComponentNode | null | undefined,
408
2420
  ctx: WebflowEmitContext,
409
2421
  instanceProps?: Record<string, unknown>
410
- ): WebflowElement[] {
2422
+ ): Promise<WebflowElement[]> {
411
2423
  if (!children) return [];
412
2424
 
413
2425
  if (typeof children === 'string') {