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
@@ -9,9 +9,17 @@ import type {
9
9
  ResponsiveStyleObject,
10
10
  StyleMapping,
11
11
  InteractiveStyles,
12
+ InteractiveStyleRule,
12
13
  } from '../../shared/types/styles';
13
14
  import type { BreakpointConfig } from '../../shared/breakpoints';
14
15
  import type { WebflowStyleClass, WebflowBreakpoint, WebflowPseudoState, CSSProperties } from './types';
16
+ import {
17
+ type ResponsiveScales,
18
+ type CSSPropertyType,
19
+ getScaleMultiplier,
20
+ scalePropertyValue,
21
+ } from '../../shared/responsiveScaling';
22
+ import { isCssNamedColor } from '../../shared/cssNamedColors';
15
23
 
16
24
  // ---------------------------------------------------------------------------
17
25
  // Helpers
@@ -24,6 +32,57 @@ const UNITLESS_PROPERTIES = new Set([
24
32
  'tab-size',
25
33
  ]);
26
34
 
35
+ /**
36
+ * CSS properties that accept time values (`0`, `0s`, `0ms`). Excluded from
37
+ * the bare-zero → `0px` normalization below — `transition-duration: 0px`
38
+ * would be invalid and silently dropped.
39
+ */
40
+ const TIME_PROPERTIES = new Set([
41
+ 'transition-duration', 'transition-delay',
42
+ 'animation-duration', 'animation-delay',
43
+ ]);
44
+
45
+ /**
46
+ * Webflow's class system rejects bare `0` for length properties — it expects
47
+ * a unit. Numeric `0` already flows through as `0px` (see `styleObjectToCSS`),
48
+ * but string `'0'` (from authored `marginTop: '0'`, shorthand expansion of
49
+ * `margin: 0 auto`, etc.) needs the same treatment. Unitless props (opacity,
50
+ * z-index) and time props (transition-duration) keep the bare `0`.
51
+ */
52
+ function normalizeZero(cssProp: string, cssValue: string): string {
53
+ if (cssValue !== '0') return cssValue;
54
+ if (UNITLESS_PROPERTIES.has(cssProp)) return cssValue;
55
+ if (TIME_PROPERTIES.has(cssProp)) return cssValue;
56
+ return '0px';
57
+ }
58
+
59
+ /**
60
+ * Color properties whose authored value can be a bare token (e.g. `"primary"`)
61
+ * that Meno's runtime auto-wraps into `var(--primary)` (see
62
+ * `cssGeneration.ts` `styleObjectToCSS`). Mirrored here so the Webflow exporter
63
+ * doesn't ship literal `"background-color: primary"` to the Designer — that
64
+ * value is invalid CSS and Webflow renders nothing. After wrapping, the
65
+ * second-pass `substituteVarsInStyleClass` in `nodeToWebflow` resolves the
66
+ * `var(--…)` against the project's theme/variable maps.
67
+ */
68
+ const COLOR_PROPS_CAMEL = new Set(['color', 'backgroundColor', 'borderColor']);
69
+
70
+ /**
71
+ * If `value` is a bare Meno color token on a color-accepting property, wrap it
72
+ * in `var(--…)`. Hex / rgb / hsl / any functional notation / already-wrapped
73
+ * `var(...)` / CSS-named keywords (red, transparent, currentColor, inherit, …)
74
+ * pass through unchanged.
75
+ */
76
+ function maybeWrapColorVar(camelProp: string, value: string): string {
77
+ if (!COLOR_PROPS_CAMEL.has(camelProp)) return value;
78
+ if (!value) return value;
79
+ if (value.startsWith('#')) return value;
80
+ if (value.startsWith('var(')) return value;
81
+ if (value.includes('(')) return value;
82
+ if (isCssNamedColor(value)) return value;
83
+ return `var(--${value})`;
84
+ }
85
+
27
86
  function isStyleMapping(value: unknown): value is StyleMapping {
28
87
  return (
29
88
  typeof value === 'object' &&
@@ -47,7 +106,87 @@ function toKebabCase(prop: string): string {
47
106
  }
48
107
 
49
108
  /**
50
- * Convert a flat StyleObject to CSS properties, skipping StyleMappings
109
+ * Split a CSS value at top-level whitespace, leaving parenthesised groups
110
+ * (`var(--x, 16px)`, `calc(1rem + 2px)`) intact as a single token.
111
+ */
112
+ function splitTopLevel(value: string): string[] {
113
+ const out: string[] = [];
114
+ let depth = 0;
115
+ let buf = '';
116
+ for (const ch of value.trim()) {
117
+ if (ch === '(') depth++;
118
+ else if (ch === ')') depth--;
119
+ if (depth === 0 && /\s/.test(ch)) {
120
+ if (buf) { out.push(buf); buf = ''; }
121
+ continue;
122
+ }
123
+ buf += ch;
124
+ }
125
+ if (buf) out.push(buf);
126
+ return out;
127
+ }
128
+
129
+ /**
130
+ * Expand `margin` / `padding` / `gap` shorthand values into the longhand
131
+ * properties Webflow's class system models directly (per-side margin/padding,
132
+ * per-axis row-gap/column-gap). Webflow drops or mis-renders these shorthands
133
+ * when written via `Style.setProperties`, so every Webflow-bound style flows
134
+ * through this expansion at the CSS-conversion boundary.
135
+ *
136
+ * Returns `null` when the property isn't a handled shorthand or the value
137
+ * shape doesn't match (1–4 tokens for margin/padding, 1–2 for gap). Callers
138
+ * fall through to passing the value as-is in those cases.
139
+ */
140
+ function expandShorthand(cssProp: string, cssValue: string): CSSProperties | null {
141
+ if (cssProp !== 'margin' && cssProp !== 'padding' && cssProp !== 'gap') {
142
+ return null;
143
+ }
144
+ const parts = splitTopLevel(cssValue);
145
+
146
+ if (cssProp === 'gap') {
147
+ if (parts.length === 1) {
148
+ const v = normalizeZero('row-gap', parts[0]!);
149
+ return { 'row-gap': v, 'column-gap': v };
150
+ }
151
+ if (parts.length === 2) {
152
+ return {
153
+ 'row-gap': normalizeZero('row-gap', parts[0]!),
154
+ 'column-gap': normalizeZero('column-gap', parts[1]!),
155
+ };
156
+ }
157
+ return null;
158
+ }
159
+
160
+ // margin / padding
161
+ let top: string, right: string, bottom: string, left: string;
162
+ if (parts.length === 1) {
163
+ top = right = bottom = left = parts[0]!;
164
+ } else if (parts.length === 2) {
165
+ top = bottom = parts[0]!;
166
+ right = left = parts[1]!;
167
+ } else if (parts.length === 3) {
168
+ top = parts[0]!;
169
+ right = left = parts[1]!;
170
+ bottom = parts[2]!;
171
+ } else if (parts.length === 4) {
172
+ [top, right, bottom, left] = parts as [string, string, string, string];
173
+ } else {
174
+ return null;
175
+ }
176
+ return {
177
+ [`${cssProp}-top`]: normalizeZero(`${cssProp}-top`, top),
178
+ [`${cssProp}-right`]: normalizeZero(`${cssProp}-right`, right),
179
+ [`${cssProp}-bottom`]: normalizeZero(`${cssProp}-bottom`, bottom),
180
+ [`${cssProp}-left`]: normalizeZero(`${cssProp}-left`, left),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Convert a flat StyleObject to CSS properties, skipping StyleMappings.
186
+ * `margin`, `padding`, and `gap` shorthands are expanded in place (see
187
+ * `expandShorthand`) so Webflow's class system receives only longhands.
188
+ * Iteration order follows CSS cascade — a longhand declared after a
189
+ * shorthand wins; declared before, the shorthand's expansion clobbers it.
51
190
  */
52
191
  function styleObjectToCSS(style: StyleObject): CSSProperties {
53
192
  const css: CSSProperties = {};
@@ -56,11 +195,18 @@ function styleObjectToCSS(style: StyleObject): CSSProperties {
56
195
  if (value === '' || value === undefined || value === null) continue;
57
196
  if (typeof value === 'boolean' || typeof value === 'object') continue;
58
197
  const cssProp = toKebabCase(prop);
198
+ let cssValue: string;
59
199
  if (typeof value === 'number') {
60
200
  if (isNaN(value)) continue;
61
- css[cssProp] = UNITLESS_PROPERTIES.has(cssProp) ? String(value) : `${value}px`;
201
+ cssValue = UNITLESS_PROPERTIES.has(cssProp) ? String(value) : `${value}px`;
202
+ } else {
203
+ cssValue = maybeWrapColorVar(prop, String(value));
204
+ }
205
+ const expanded = expandShorthand(cssProp, cssValue);
206
+ if (expanded) {
207
+ Object.assign(css, expanded);
62
208
  } else {
63
- css[cssProp] = String(value);
209
+ css[cssProp] = normalizeZero(cssProp, cssValue);
64
210
  }
65
211
  }
66
212
  return css;
@@ -96,17 +242,154 @@ function collectStyleMappings(
96
242
  }
97
243
 
98
244
  /**
99
- * Map interactive style postfix to Webflow pseudo-state
245
+ * Map interactive style postfix to a Webflow pseudo-state.
246
+ * Order matters — longer suffixes (`:focus-visible`, `:focus-within`,
247
+ * `:nth-child(odd)`) must be tested before their substring matches.
100
248
  */
101
- function postfixToPseudoState(postfix: string): WebflowPseudoState | null {
102
- if (postfix.includes(':hover')) return 'hover';
249
+ export function postfixToPseudoState(postfix: string): WebflowPseudoState | null {
250
+ // Pseudo-class style: ':hover', ':focus-visible', etc.
103
251
  if (postfix.includes(':focus-visible')) return 'focus-visible';
252
+ if (postfix.includes(':focus-within')) return 'focus-within';
253
+ if (postfix.includes(':nth-child(odd)')) return 'nth-child(odd)';
254
+ if (postfix.includes(':nth-child(even)')) return 'nth-child(even)';
255
+ if (postfix.includes(':first-child')) return 'first-child';
256
+ if (postfix.includes(':last-child')) return 'last-child';
257
+ if (postfix.includes(':placeholder')) return 'placeholder';
258
+ if (postfix.includes(':empty')) return 'empty';
259
+ if (postfix.includes(':before')) return 'before';
260
+ if (postfix.includes(':after')) return 'after';
261
+ if (postfix.includes(':hover')) return 'hover';
104
262
  if (postfix.includes(':focus')) return 'focus';
105
263
  if (postfix.includes(':active')) return 'active';
106
264
  if (postfix.includes(':visited')) return 'visited';
265
+ if (postfix.includes(':pressed')) return 'pressed';
266
+ // Pseudo-element style: '::before', '::after', '::placeholder'
267
+ if (postfix.includes('::before')) return 'before';
268
+ if (postfix.includes('::after')) return 'after';
269
+ if (postfix.includes('::placeholder')) return 'placeholder';
107
270
  return null;
108
271
  }
109
272
 
273
+ /**
274
+ * Whether an interactive-styles rule fits Webflow's class-system surface.
275
+ * "Yes" means: empty `prefix`, a known pseudo-state `postfix`, and a
276
+ * non-responsive `style` (no breakpoint subdivision). Webflow's
277
+ * `Style.setProperties({ pseudo })` covers exactly this case.
278
+ *
279
+ * Anything else (descendant selectors via `prefix`, class-style postfixes
280
+ * like `.is-open`, responsive pseudos that need media queries) gets routed
281
+ * to the manual-paste `interactiveCss` bundle via `generateInteractiveCSS`.
282
+ */
283
+ export function isWebflowHandledRule(rule: InteractiveStyleRule): boolean {
284
+ if (rule.prefix && rule.prefix.trim().length > 0) return false;
285
+ if (!rule.postfix) return false;
286
+ if (postfixToPseudoState(rule.postfix) === null) return false;
287
+ const s = rule.style as ResponsiveStyleObject;
288
+ if (!s || typeof s !== 'object') return false;
289
+ const responsiveKeys = Object.keys(s).filter((k) => k !== 'base');
290
+ return responsiveKeys.length === 0;
291
+ }
292
+
293
+ /**
294
+ * Pick the Webflow breakpoint tier closest to a Meno breakpoint by its
295
+ * max-width threshold. Meno's responsive cascade is max-width based, and
296
+ * Webflow's tiers below `main` are also max-width — so we map the threshold
297
+ * into Webflow's bucket.
298
+ *
299
+ * Webflow tiers (max-width values per Webflow's defaults):
300
+ * tiny < 480, small 480-767, medium 768-991, main 992-1279,
301
+ * large 1280-1439, xl 1440-1919, xxl ≥ 1920.
302
+ */
303
+ function widthToWebflowBreakpoint(maxWidthPx: number): WebflowBreakpoint {
304
+ if (maxWidthPx < 480) return 'tiny';
305
+ if (maxWidthPx < 768) return 'small';
306
+ if (maxWidthPx < 992) return 'medium';
307
+ if (maxWidthPx < 1280) return 'main';
308
+ if (maxWidthPx < 1440) return 'large';
309
+ if (maxWidthPx < 1920) return 'xl';
310
+ return 'xxl';
311
+ }
312
+
313
+ /**
314
+ * Resolve a Meno breakpoint name to its Webflow tier. The two well-known
315
+ * names (`tablet`, `mobile`) map to fixed tiers; custom names route by their
316
+ * configured numeric width via `widthToWebflowBreakpoint`. Defaults if a
317
+ * project's breakpoints config omits the entry.
318
+ */
319
+ function menoBreakpointToWebflow(
320
+ bpName: string,
321
+ breakpoints: BreakpointConfig
322
+ ): WebflowBreakpoint {
323
+ if (bpName === 'tablet') return 'medium';
324
+ if (bpName === 'mobile') return 'small';
325
+ const entry = breakpoints[bpName];
326
+ if (entry && typeof entry.breakpoint === 'number') {
327
+ return widthToWebflowBreakpoint(entry.breakpoint);
328
+ }
329
+ // Unknown name with no width info — default to `main` (no-op).
330
+ return 'main';
331
+ }
332
+
333
+ /**
334
+ * Merge a CSS map into the breakpoints record on a WebflowStyleClass under
335
+ * the given Webflow tier, layering on top of anything already there.
336
+ */
337
+ function mergeIntoBreakpoint(
338
+ cls: WebflowStyleClass,
339
+ tier: WebflowBreakpoint,
340
+ css: CSSProperties
341
+ ): void {
342
+ if (!cls.breakpoints) cls.breakpoints = {};
343
+ cls.breakpoints[tier] = { ...cls.breakpoints[tier], ...css };
344
+ }
345
+
346
+ /** Convert a kebab-case CSS property to camelCase for scale-category lookup. */
347
+ function kebabToCamel(s: string): string {
348
+ return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
349
+ }
350
+
351
+ /**
352
+ * For each scalable property in the class's base map, fill in auto-scaled
353
+ * values at every Meno breakpoint that doesn't already carry an explicit
354
+ * value for that property. Mirrors what Meno's runtime CSS generator does
355
+ * for utility classes, so the Webflow site renders the same numbers per
356
+ * breakpoint as the Meno preview.
357
+ *
358
+ * Skips values that still contain `var(--…)` — the variable-aware pass in
359
+ * `nodeToWebflow` handles those before this runs (it expands per-breakpoint
360
+ * variable values into authored breakpoint entries, which take precedence
361
+ * over global category scaling here).
362
+ */
363
+ export function applyAutoScaling(
364
+ cls: WebflowStyleClass,
365
+ breakpoints: BreakpointConfig,
366
+ responsiveScales: ResponsiveScales | undefined
367
+ ): void {
368
+ if (!responsiveScales?.enabled) return;
369
+ const baseRef = responsiveScales.baseReference || 16;
370
+
371
+ for (const [prop, baseValue] of Object.entries(cls.base)) {
372
+ if (!baseValue || baseValue.includes('var(--')) continue;
373
+ const camelProp = kebabToCamel(prop) as CSSPropertyType;
374
+
375
+ for (const [bpName, bpEntry] of Object.entries(breakpoints)) {
376
+ if (!bpEntry) continue;
377
+ const scale = getScaleMultiplier(responsiveScales, camelProp, bpName);
378
+ if (scale === null) continue;
379
+ const scaled = scalePropertyValue(baseValue, baseRef, scale);
380
+ if (scaled === null || scaled === baseValue) continue;
381
+
382
+ const tier = menoBreakpointToWebflow(bpName, breakpoints);
383
+ if (!cls.breakpoints) cls.breakpoints = {};
384
+ const bucket = cls.breakpoints[tier] || {};
385
+ // Author override wins — only fill missing entries.
386
+ if (bucket[prop] !== undefined) continue;
387
+ bucket[prop] = scaled;
388
+ cls.breakpoints[tier] = bucket;
389
+ }
390
+ }
391
+ }
392
+
110
393
  // ---------------------------------------------------------------------------
111
394
  // Main Mapper
112
395
  // ---------------------------------------------------------------------------
@@ -114,10 +397,41 @@ function postfixToPseudoState(postfix: string): WebflowPseudoState | null {
114
397
  export interface StyleMapperResult {
115
398
  /** The primary style class for this element */
116
399
  primaryClass: WebflowStyleClass;
117
- /** Combo classes for StyleMapping variants */
400
+ /**
401
+ * Combo classes carrying *only* the deltas for the current instance's
402
+ * non-default StyleMapping prop values. Already filtered by the mapper —
403
+ * the caller attaches every entry to the element verbatim.
404
+ */
118
405
  comboClasses: WebflowStyleClass[];
119
406
  }
120
407
 
408
+ export interface MapStylesOptions {
409
+ /**
410
+ * Resolved props for the current component instance. Only the value the
411
+ * mapping actually resolves to (`instanceProps[mapping.prop]`) becomes a
412
+ * combo on this element; other values are ignored. When omitted, no
413
+ * combos are emitted (page elements without a prop context).
414
+ */
415
+ instanceProps?: Record<string, unknown>;
416
+ /**
417
+ * Default values from the enclosing component's interface. When provided,
418
+ * each StyleMapping's default-value entry is baked into `primaryClass.base`
419
+ * so the primary represents the default-prop visual; combos cover only the
420
+ * non-default deltas. Omit for page-level elements (defaults unavailable).
421
+ */
422
+ componentDefaults?: Record<string, unknown>;
423
+ /**
424
+ * Suffix appended to combo class names so two placements that resolve their
425
+ * `var(--…)` refs against different ancestor themes occupy distinct map
426
+ * slots in `ctx.styleClasses`. Without this, the default theme's combo and a
427
+ * non-default theme's combo collapse onto the same name and the last-written
428
+ * one wins — silently corrupting whichever placement was processed earlier.
429
+ * Caller passes e.g. `'-theme-dark'`; empty/undefined means default theme,
430
+ * no suffix added. Caller is responsible for sanitizing the theme name.
431
+ */
432
+ themeSuffix?: string;
433
+ }
434
+
121
435
  /**
122
436
  * Convert Meno element styles to Webflow style classes.
123
437
  *
@@ -125,13 +439,22 @@ export interface StyleMapperResult {
125
439
  * @param style - Element's responsive style object
126
440
  * @param interactiveStyles - Element's interactive (hover/focus/etc.) styles
127
441
  * @param breakpoints - Project breakpoint configuration
442
+ * @param responsiveScales - When `enabled`, auto-fill scaled per-breakpoint
443
+ * values for scalable properties not explicitly authored at that
444
+ * breakpoint (mirrors Meno's runtime CSS generator).
445
+ * @param options - Instance-aware combo emission. See `MapStylesOptions`.
128
446
  */
129
447
  export function mapStylesToWebflow(
130
448
  className: string,
131
449
  style: StyleObject | ResponsiveStyleObject | undefined,
132
450
  interactiveStyles: InteractiveStyles | undefined,
133
- breakpoints: BreakpointConfig
451
+ breakpoints: BreakpointConfig,
452
+ responsiveScales?: ResponsiveScales,
453
+ options?: MapStylesOptions
134
454
  ): StyleMapperResult {
455
+ const instanceProps = options?.instanceProps;
456
+ const componentDefaults = options?.componentDefaults;
457
+ const themeSuffix = options?.themeSuffix ?? '';
135
458
  // Convert underscores to dashes for Webflow class naming convention
136
459
  const webflowClassName = className.replace(/_/g, '-');
137
460
 
@@ -141,6 +464,10 @@ export function mapStylesToWebflow(
141
464
  };
142
465
 
143
466
  // --- Base + breakpoint styles ---
467
+ // Meno's `base` is the desktop default → Webflow's `main` tier (set as
468
+ // `primaryClass.base` so the consumer writes it without a breakpoint
469
+ // option). Named tiers (`tablet`, `mobile`) and custom breakpoints route
470
+ // to Webflow tiers via `menoBreakpointToWebflow`.
144
471
  if (style) {
145
472
  if (isResponsiveStyle(style)) {
146
473
  const responsive = style as ResponsiveStyleObject;
@@ -149,93 +476,220 @@ export function mapStylesToWebflow(
149
476
  primaryClass.base = styleObjectToCSS(responsive.base);
150
477
  }
151
478
 
152
- if (responsive.tablet) {
153
- if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
154
- primaryClass.breakpoints.Tablet = styleObjectToCSS(responsive.tablet);
155
- }
156
-
157
- if (responsive.mobile) {
158
- if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
159
- primaryClass.breakpoints.MobilePortrait = styleObjectToCSS(responsive.mobile);
160
- }
161
-
162
- // Handle additional custom breakpoints (map to closest Webflow breakpoint)
163
479
  for (const [bpName, bpStyle] of Object.entries(responsive)) {
164
- if (!bpStyle || bpName === 'base' || bpName === 'tablet' || bpName === 'mobile') continue;
165
- // Custom breakpoints map to Tablet as closest approximation
166
- if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
167
- primaryClass.breakpoints.Tablet = {
168
- ...primaryClass.breakpoints.Tablet,
169
- ...styleObjectToCSS(bpStyle),
170
- };
480
+ if (!bpStyle || bpName === 'base') continue;
481
+ const css = styleObjectToCSS(bpStyle);
482
+ if (Object.keys(css).length === 0) continue;
483
+ const tier = menoBreakpointToWebflow(bpName, breakpoints);
484
+ mergeIntoBreakpoint(primaryClass, tier, css);
171
485
  }
172
486
  } else {
173
- // Flat style object — treat as base/Desktop
487
+ // Flat style object — treat as base/main.
174
488
  primaryClass.base = styleObjectToCSS(style as StyleObject);
175
489
  }
176
490
  }
177
491
 
178
- // --- Interactive styles (hover, focus, etc.) ---
492
+ // --- Interactive styles (hover, focus, ) ---
493
+ // Pseudo-state postfixes with empty prefix and non-responsive ruleStyle go
494
+ // to `primaryClass.pseudoStates` so Webflow's class system applies them via
495
+ // `Style.setProperties({ pseudo })`. Anything else (prefix-built selectors,
496
+ // class-style postfixes, breakpoint-divided pseudos) is collected into
497
+ // `interactiveCss` server-side via `generateInteractiveCSS` — see
498
+ // `buildWebflow.ts` and `isWebflowHandledRule` below.
179
499
  if (interactiveStyles && interactiveStyles.length > 0) {
180
500
  for (const rule of interactiveStyles) {
181
- if (!rule.postfix) continue;
501
+ if (!isWebflowHandledRule(rule)) continue;
502
+
503
+ const baseProps: CSSProperties = isResponsiveStyle(rule.style as StyleObject | ResponsiveStyleObject)
504
+ ? styleObjectToCSS(((rule.style as ResponsiveStyleObject).base) || {})
505
+ : styleObjectToCSS(rule.style as StyleObject);
182
506
 
183
- const pseudoState = postfixToPseudoState(rule.postfix);
507
+ // Skip empty rules — writing an empty pseudoStates entry triggers a
508
+ // destructive wipe in `applyStyleScope` on re-import (it diffs
509
+ // `existing` against `next={}` and removes every property).
510
+ if (Object.keys(baseProps).length === 0) continue;
511
+
512
+ const pseudoState = postfixToPseudoState(rule.postfix!);
184
513
  if (!pseudoState) continue;
185
514
 
186
- const ruleStyle = rule.style;
187
515
  if (!primaryClass.pseudoStates) primaryClass.pseudoStates = {};
188
-
189
- if (isResponsiveStyle(ruleStyle)) {
190
- const responsive = ruleStyle as ResponsiveStyleObject;
191
- // Merge base styles into the pseudo-state
192
- if (responsive.base) {
193
- primaryClass.pseudoStates[pseudoState] = {
194
- ...primaryClass.pseudoStates[pseudoState],
195
- ...styleObjectToCSS(responsive.base),
196
- };
197
- }
198
- } else {
199
- primaryClass.pseudoStates[pseudoState] = {
200
- ...primaryClass.pseudoStates[pseudoState],
201
- ...styleObjectToCSS(ruleStyle as StyleObject),
202
- };
203
- }
516
+ primaryClass.pseudoStates[pseudoState] = {
517
+ ...primaryClass.pseudoStates[pseudoState],
518
+ ...baseProps,
519
+ };
204
520
  }
205
521
  }
206
522
 
207
- // --- Combo classes for StyleMappings ---
523
+ // --- Default-prop bake + single consolidated combo for the instance ---
524
+ // Webflow combo classes only make sense as deltas: the primary carries the
525
+ // default-prop visual; one combo per element holds the merged deltas for
526
+ // every non-default StyleMapping prop value the instance authored. Folding
527
+ // every mapped delta into one combo (rather than one combo per prop) means
528
+ // each element wears exactly one extra class regardless of how many props
529
+ // its component declares — simpler in the Webflow Designer and avoids
530
+ // fan-out where two unrelated instances accidentally share a delta combo.
208
531
  const comboClasses: WebflowStyleClass[] = [];
209
532
  const mappings = collectStyleMappings(style);
210
533
 
534
+ const comboCss: CSSProperties = {};
535
+ const comboNameParts: string[] = [];
536
+
211
537
  for (const { property, mapping } of mappings) {
212
- for (const [value, cssValue] of Object.entries(mapping.values)) {
213
- if (cssValue === '' || cssValue === undefined) continue;
214
-
215
- const comboName = `is-${sanitizeClassName(mapping.prop)}-${sanitizeClassName(String(value))}`;
216
- const comboClass: WebflowStyleClass = {
217
- name: comboName,
218
- base: {
219
- [toKebabCase(property)]: typeof cssValue === 'number'
220
- ? (UNITLESS_PROPERTIES.has(toKebabCase(property)) ? String(cssValue) : `${cssValue}px`)
221
- : String(cssValue),
222
- },
223
- comboParent: webflowClassName,
224
- };
225
- comboClasses.push(comboClass);
538
+ const defaultValue = componentDefaults?.[mapping.prop];
539
+ const defaultKey = defaultValue != null ? String(defaultValue) : undefined;
540
+
541
+ // Bake the default-value resolution into the primary class's base so the
542
+ // primary stands on its own for default-prop instances. Values authored
543
+ // outside the mapping (declared on `style.fontSize` directly) already
544
+ // landed in `primaryClass.base` via `styleObjectToCSS`.
545
+ if (defaultKey !== undefined && defaultKey in mapping.values) {
546
+ const defaultCss = mappingValueToCSS(property, mapping.values[defaultKey]);
547
+ if (defaultCss) {
548
+ // StyleMapping bakes feed into the primary's `main` tier (its `base`),
549
+ // matching how `styleObjectToCSS` handles non-mapped properties.
550
+ primaryClass.base = { ...primaryClass.base, ...defaultCss };
551
+ }
226
552
  }
553
+
554
+ if (!instanceProps) continue;
555
+ const instanceValue = instanceProps[mapping.prop];
556
+ if (instanceValue == null) continue;
557
+ const instanceKey = String(instanceValue);
558
+ if (instanceKey === defaultKey) continue; // covered by primary
559
+ if (!(instanceKey in mapping.values)) continue; // unknown value
560
+ const css = mappingValueToCSS(property, mapping.values[instanceKey]);
561
+ if (!css) continue;
562
+
563
+ Object.assign(comboCss, css);
564
+ const part = `${sanitizeClassName(mapping.prop)}-${sanitizeClassName(instanceKey)}`;
565
+ // Same prop appears once per mapping (e.g. `version` on both
566
+ // `backgroundColor` and `color` Button mappings) — dedupe so the combo
567
+ // name stays `is-version-secondary` rather than repeating the segment.
568
+ if (!comboNameParts.includes(part)) comboNameParts.push(part);
569
+ }
570
+
571
+ if (comboNameParts.length > 0 && Object.keys(comboCss).length > 0) {
572
+ // Sort so different declaration orders of the same prop set produce the
573
+ // same combo name — instances with identical variants share one class.
574
+ comboNameParts.sort();
575
+ comboClasses.push({
576
+ name: `is-${comboNameParts.join('-')}${themeSuffix}`,
577
+ base: comboCss,
578
+ comboParent: webflowClassName,
579
+ });
580
+ }
581
+
582
+ // --- Auto-scaling: fill in scaled values at each Meno breakpoint for any
583
+ // scalable base property the author hasn't already overridden. Variable
584
+ // refs (`var(--…)`) are skipped here — `nodeToWebflow` expands them with
585
+ // breakpoint awareness before this runs, so authored breakpoint values
586
+ // already capture the variable's per-breakpoint scaling.
587
+ applyAutoScaling(primaryClass, breakpoints, responsiveScales);
588
+ for (const combo of comboClasses) {
589
+ applyAutoScaling(combo, breakpoints, responsiveScales);
227
590
  }
228
591
 
229
592
  return { primaryClass, comboClasses };
230
593
  }
231
594
 
595
+ /**
596
+ * Convert a single StyleMapping value (already looked up from `mapping.values`)
597
+ * into a CSSProperties entry, mirroring `styleObjectToCSS`'s unit handling
598
+ * and shorthand expansion. Returns `null` when the value is empty/missing.
599
+ */
600
+ function mappingValueToCSS(
601
+ property: string,
602
+ rawValue: string | number | undefined | null
603
+ ): CSSProperties | null {
604
+ if (rawValue === '' || rawValue === undefined || rawValue === null) return null;
605
+ const cssProp = toKebabCase(property);
606
+ const cssValueStr = typeof rawValue === 'number'
607
+ ? (UNITLESS_PROPERTIES.has(cssProp) ? String(rawValue) : `${rawValue}px`)
608
+ : maybeWrapColorVar(property, String(rawValue));
609
+ return expandShorthand(cssProp, cssValueStr) ?? { [cssProp]: normalizeZero(cssProp, cssValueStr) };
610
+ }
611
+
232
612
  /**
233
613
  * Sanitize a string for use as a CSS class name segment
234
614
  */
235
- function sanitizeClassName(name: string): string {
615
+ export function sanitizeClassName(name: string): string {
236
616
  return name
237
617
  .toLowerCase()
238
618
  .replace(/[^a-z0-9-]/g, '-')
239
619
  .replace(/-+/g, '-')
240
620
  .replace(/^-|-$/g, '');
241
621
  }
622
+
623
+ /**
624
+ * Build a single combo class from a component instance's style /
625
+ * interactiveStyles overrides — the `acceptsStyles` analogue of the
626
+ * prop-mapped combo in `mapStylesToWebflow`.
627
+ *
628
+ * Every override the instance authored — flat or responsive base values,
629
+ * per-breakpoint values, Webflow-handled pseudo-state values — is folded
630
+ * into one combo's `.base` / `.breakpoints` / `.pseudoStates`. The caller
631
+ * supplies a stable name (typically derived from the instance's outer
632
+ * location so two distinct instances don't collide) and the body root's
633
+ * primary class for `comboParent`. Returns `null` if no overrides survive
634
+ * the filters.
635
+ */
636
+ export function buildInstanceStyleCombo(
637
+ comboName: string,
638
+ rootClassName: string,
639
+ style: StyleObject | ResponsiveStyleObject | undefined,
640
+ interactiveStyles: InteractiveStyles | undefined,
641
+ breakpoints: BreakpointConfig,
642
+ responsiveScales?: ResponsiveScales,
643
+ ): WebflowStyleClass | null {
644
+ const base: CSSProperties = {};
645
+ const bps: Partial<Record<WebflowBreakpoint, CSSProperties>> = {};
646
+ const pseudos: Partial<Record<WebflowPseudoState, CSSProperties>> = {};
647
+
648
+ if (style) {
649
+ if (isResponsiveStyle(style)) {
650
+ const responsive = style as ResponsiveStyleObject;
651
+ if (responsive.base) Object.assign(base, styleObjectToCSS(responsive.base));
652
+ for (const [bpName, bpStyle] of Object.entries(responsive)) {
653
+ if (!bpStyle || bpName === 'base') continue;
654
+ const css = styleObjectToCSS(bpStyle);
655
+ if (Object.keys(css).length === 0) continue;
656
+ const tier = menoBreakpointToWebflow(bpName, breakpoints);
657
+ bps[tier] = { ...bps[tier], ...css };
658
+ }
659
+ } else {
660
+ Object.assign(base, styleObjectToCSS(style as StyleObject));
661
+ }
662
+ }
663
+
664
+ if (interactiveStyles && interactiveStyles.length > 0) {
665
+ for (const rule of interactiveStyles) {
666
+ if (!isWebflowHandledRule(rule)) continue;
667
+ const ruleStyle = rule.style as StyleObject | ResponsiveStyleObject;
668
+ const flat = isResponsiveStyle(ruleStyle)
669
+ ? ((ruleStyle as ResponsiveStyleObject).base as StyleObject | undefined)
670
+ : (ruleStyle as StyleObject);
671
+ if (!flat) continue;
672
+ const css = styleObjectToCSS(flat);
673
+ if (Object.keys(css).length === 0) continue;
674
+ const pseudo = postfixToPseudoState(rule.postfix!);
675
+ if (!pseudo) continue;
676
+ pseudos[pseudo] = { ...pseudos[pseudo], ...css };
677
+ }
678
+ }
679
+
680
+ if (
681
+ Object.keys(base).length === 0
682
+ && Object.keys(bps).length === 0
683
+ && Object.keys(pseudos).length === 0
684
+ ) return null;
685
+
686
+ const cls: WebflowStyleClass = {
687
+ name: comboName,
688
+ base,
689
+ comboParent: rootClassName,
690
+ };
691
+ if (Object.keys(bps).length > 0) cls.breakpoints = bps;
692
+ if (Object.keys(pseudos).length > 0) cls.pseudoStates = pseudos;
693
+ applyAutoScaling(cls, breakpoints, responsiveScales);
694
+ return cls;
695
+ }