meno-core 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Webflow Designer Extension template wrapper.
3
+ *
4
+ * The Webflow Designer injects the `webflow` API global only when extension
5
+ * HTML is wrapped in a template fetched from `webflow-ext.com`. This module
6
+ * fetches the template once per process, caches it, then splices the
7
+ * extension's <head>/<body> content into the `{{ui}}` placeholder.
8
+ *
9
+ * Used by both the studio's mounted `/webflow-extension/*` route and the
10
+ * standalone serve.ts on port 1337, so they stay in sync automatically.
11
+ */
12
+
13
+ import { readFile } from 'fs/promises';
14
+
15
+ let cachedTemplate: string | null = null;
16
+
17
+ async function getWebflowTemplate(appName: string): Promise<string> {
18
+ if (cachedTemplate) return cachedTemplate;
19
+ const url = `https://webflow-ext.com/template/v2?name=${encodeURIComponent(appName)}`;
20
+ const res = await fetch(url);
21
+ if (!res.ok) throw new Error(`Failed to fetch Webflow template: ${res.status}`);
22
+ cachedTemplate = await res.text();
23
+ return cachedTemplate;
24
+ }
25
+
26
+ /**
27
+ * Wrap extension HTML in the Webflow Designer template.
28
+ * `manifestPath` points to the extension's `webflow.json` — its `name` field
29
+ * is forwarded as the `?name=` query param to webflow-ext.com.
30
+ *
31
+ * On any failure (manifest read, template fetch) we log a warning and return
32
+ * the raw HTML, matching the prior behavior of the two duplicate copies.
33
+ */
34
+ export async function wrapInWebflowTemplate(html: string, manifestPath: string): Promise<string> {
35
+ try {
36
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf-8'));
37
+ const template = await getWebflowTemplate(manifest.name || 'Meno Import');
38
+
39
+ const headMatch = html.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
40
+ const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
41
+ const headContent = headMatch ? headMatch[1] : '';
42
+ const bodyContent = bodyMatch ? bodyMatch[1] : '';
43
+
44
+ return template.replace('{{ui}}', headContent + bodyContent);
45
+ } catch (err: any) {
46
+ console.warn('Could not fetch Webflow wrapper template:', err.message);
47
+ return html;
48
+ }
49
+ }
@@ -9,18 +9,49 @@ import type { CMSFieldType } from '../../shared/types/cms';
9
9
  // Style Classes
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- /** Webflow breakpoint identifiers */
13
- export type WebflowBreakpoint = 'Desktop' | 'Tablet' | 'MobilePortrait';
12
+ /**
13
+ * Webflow Designer API breakpoint identifiers.
14
+ * `main` is the desktop / default tier; `xxl|xl|large` are above-desktop;
15
+ * `medium|small|tiny` are below-desktop.
16
+ * Reference: https://developers.webflow.com/designer/reference/set-style-properties
17
+ */
18
+ export type WebflowBreakpoint =
19
+ | 'xxl'
20
+ | 'xl'
21
+ | 'large'
22
+ | 'main'
23
+ | 'medium'
24
+ | 'small'
25
+ | 'tiny';
14
26
 
15
- /** Webflow pseudo-state identifiers */
16
- export type WebflowPseudoState = 'hover' | 'focus' | 'active' | 'visited' | 'focus-visible';
27
+ /**
28
+ * Webflow Designer API pseudo-state identifiers.
29
+ * Reference: https://developers.webflow.com/designer/reference/set-style-properties
30
+ */
31
+ export type WebflowPseudoState =
32
+ | 'noPseudo'
33
+ | 'hover'
34
+ | 'focus'
35
+ | 'focus-visible'
36
+ | 'focus-within'
37
+ | 'active'
38
+ | 'visited'
39
+ | 'pressed'
40
+ | 'before'
41
+ | 'after'
42
+ | 'placeholder'
43
+ | 'empty'
44
+ | 'first-child'
45
+ | 'last-child'
46
+ | 'nth-child(odd)'
47
+ | 'nth-child(even)';
17
48
 
18
49
  /** CSS properties as a flat record */
19
50
  export type CSSProperties = Record<string, string>;
20
51
 
21
52
  /** A named Webflow style class with responsive + pseudo-state overrides */
22
53
  export interface WebflowStyleClass {
23
- /** Unique class name (e.g., "c-navigation-hamburger") */
54
+ /** Unique class name (e.g., "navigation-hamburger" for components, "p-about-grid5" for pages) */
24
55
  name: string;
25
56
  /** Base (Desktop) CSS properties */
26
57
  base: CSSProperties;
@@ -46,20 +77,113 @@ export interface WebflowElement {
46
77
  comboClasses?: string[];
47
78
  /** Inline text content (for text nodes) */
48
79
  textContent?: string;
49
- /** Child elements */
50
- children?: WebflowElement[];
80
+ /**
81
+ * Child elements. Plain strings are inline text runs (Webflow String nodes)
82
+ * — used for mixed content like `<h1>foo <span>bar</span> baz</h1>`.
83
+ */
84
+ children?: Array<WebflowElement | string>;
51
85
  /** HTML attributes (src, alt, href, target, etc.) */
52
86
  attributes?: Record<string, string | number | boolean>;
53
- /** Raw HTML content (for embed nodes) */
54
- rawHtml?: string;
55
- /** Whether this element is conditionally rendered */
56
- conditional?: {
57
- prop: string;
58
- condition: 'truthy' | 'equals';
59
- value?: string;
87
+ /**
88
+ * Inline SVG markup for an embed node. The Webflow extension uploads this
89
+ * as an `image/svg+xml` asset and emits an Image element — Webflow's
90
+ * Designer API has no public method to set HtmlEmbed code content.
91
+ */
92
+ svgSource?: string;
93
+ /** Source URL for an `<img>`-rooted embed; uploaded into Webflow as an asset. */
94
+ imageSrc?: string;
95
+ /** Alt text paired with `svgSource` / `imageSrc`. */
96
+ imageAlt?: string;
97
+ /**
98
+ * Pre-fetched image bytes for `<img>` / embed elements whose source URL
99
+ * the Webflow Designer iframe can't reach (e.g. project-relative paths
100
+ * like `/images/foo.webp`, which would resolve against `designer.webflow.com`).
101
+ * The extension uploads these directly via `createAsset` instead of
102
+ * `fetch`-ing them.
103
+ */
104
+ imageDataBase64?: string;
105
+ imageDataMime?: string;
106
+ imageDataFileName?: string;
107
+ /**
108
+ * Embed payload that is neither SVG nor a single `<img>`. The extension
109
+ * logs it and skips insertion (no equivalent Webflow API exists).
110
+ */
111
+ unsupportedEmbed?: { reason: string; preview: string; label?: string };
112
+ /**
113
+ * When set, this element is an instance of a Webflow Component registered
114
+ * via `payload.components`. The extension appends the registered Component
115
+ * (looked up by name) instead of building children. `tag`/`className`/etc.
116
+ * remain set as a fallback for older extensions that don't understand
117
+ * `componentRef`.
118
+ */
119
+ componentRef?: string;
120
+ /**
121
+ * Inline-expanded element tree to use when the Webflow Designer API can't
122
+ * register Components (older runtime, missing `canCreateComponents`
123
+ * permission). Mirrors the pre-promotion expansion of the Meno component so
124
+ * the extension can render the same markup it does today.
125
+ */
126
+ inlineFallback?: WebflowElement[];
127
+ /**
128
+ * Bound CMS list marker. When set, this element is the synthetic wrapper
129
+ * for `<list sourceType="collection">` emitted in bound mode (see
130
+ * `buildWebflowPayload({ bindCollectionLists: true })`). Value is the Meno
131
+ * collection slug; the extension translates that to a Webflow collection
132
+ * ID via the v1 sync ID map and inserts a `DynamoWrapper` preset.
133
+ * Children are the *single* rendered item template (not N copies), with
134
+ * field references preserved as `menoBind` markers below.
135
+ */
136
+ menoCollectionRef?: string;
137
+ /**
138
+ * Per-element CMS field binding markers. Captured at server emit time from
139
+ * the source `{{field}}` template; the extension translates them into
140
+ * `data-meno-bind-*` custom attributes on the inserted element and surfaces
141
+ * a manual-bind checklist. Webflow's Designer API has no `setBinding` /
142
+ * `setCollection` write surface yet (Apr 2026 — confirmed by Webflow
143
+ * staff Plata + Selser on the developer forum), so the user does the
144
+ * actual field binding once in the Designer UI after insertion.
145
+ */
146
+ menoBind?: {
147
+ /** Bind this element's text content to this CMS field slug. */
148
+ textField?: string;
149
+ /** Map of attribute name → CMS field slug to bind. */
150
+ attrFields?: Record<string, string>;
60
151
  };
61
152
  }
62
153
 
154
+ // ---------------------------------------------------------------------------
155
+ // Bound-list sentinel (server-side internal — extension consumes `menoBind`)
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * String sentinel inserted in place of a real CMS field value when emitting a
160
+ * bound Collection List. The existing template engine is reused unchanged: a
161
+ * synthetic placeholder item maps every schema field to
162
+ * `${SENTINEL_PREFIX}${fieldSlug}${SENTINEL_SUFFIX}`, so `{{post.title}}`
163
+ * resolves to that string. A post-walk then converts those strings into
164
+ * `WebflowElement.menoBind` markers and replaces them with a readable
165
+ * placeholder so the user can see what each child is meant to display.
166
+ */
167
+ export const MENO_BIND_SENTINEL_PREFIX = '__MENO_BIND__:';
168
+ export const MENO_BIND_SENTINEL_SUFFIX = ':__';
169
+ export const MENO_BIND_SENTINEL_RE = /__MENO_BIND__:([^:]+):__/g;
170
+ export const MENO_BIND_SENTINEL_EXACT_RE = /^__MENO_BIND__:([^:]+):__$/;
171
+
172
+ /** Synthetic tag for the bound Collection List wrapper. */
173
+ export const COLLECTION_LIST_TAG = '__collection_list__';
174
+
175
+ /**
176
+ * A Meno component promoted to a Webflow Component. The extension calls
177
+ * `webflow.registerComponent` once per entry, then `parent.append(component)`
178
+ * for every element in the page tree that has a matching `componentRef`.
179
+ */
180
+ export interface WebflowComponentDef {
181
+ /** Component name as it appears in Webflow's Components panel. */
182
+ name: string;
183
+ /** Element tree that becomes the Component's body. */
184
+ elements: WebflowElement[];
185
+ }
186
+
63
187
  // ---------------------------------------------------------------------------
64
188
  // Pages
65
189
  // ---------------------------------------------------------------------------
@@ -71,7 +195,9 @@ export interface WebflowPage {
71
195
  /** URL slug (e.g., "about", "blog/post-1") */
72
196
  slug: string;
73
197
  /** Meta description */
74
- metaDescription?: string;
198
+ description?: string;
199
+ /** Comma-separated keywords from page meta */
200
+ keywords?: string;
75
201
  /** Open Graph title */
76
202
  ogTitle?: string;
77
203
  /** Open Graph description */
@@ -84,6 +210,21 @@ export interface WebflowPage {
84
210
  locale?: string;
85
211
  }
86
212
 
213
+ /**
214
+ * A component-level script bundled into the export. The Webflow extension
215
+ * concatenates these into a single `<script>` injected at the end of `<body>`
216
+ * so interactive Meno components (FAQ accordion, dropdowns, mobile menu) work
217
+ * once published.
218
+ */
219
+ export interface WebflowScript {
220
+ /** Component name (used by the runtime to find element instances). */
221
+ componentName: string;
222
+ /** Component's JavaScript code (verbatim from `Component.js`). */
223
+ code: string;
224
+ /** Whether the source defined defineVars (true / explicit list / undefined). */
225
+ defineVars?: true | string[];
226
+ }
227
+
87
228
  // ---------------------------------------------------------------------------
88
229
  // CMS
89
230
  // ---------------------------------------------------------------------------
@@ -112,12 +253,25 @@ export interface WebflowCMSField {
112
253
  required?: boolean;
113
254
  /** Options for Option type fields */
114
255
  options?: string[];
256
+ /** Help text shown to editors in the Webflow CMS UI */
257
+ helpText?: string;
258
+ /**
259
+ * For Reference fields: Meno collection slug of the target collection.
260
+ * The sync orchestrator resolves this to a Webflow collection ID at create
261
+ * time (pass 2 of the two-pass collection create — references can only be
262
+ * added once their target exists).
263
+ */
264
+ referenceCollection?: string;
265
+ /** True for multi-reference (array of IDs); false / undefined for single ref. */
266
+ multiReference?: boolean;
115
267
  }
116
268
 
117
269
  /** A Webflow CMS collection definition */
118
270
  export interface WebflowCMSCollection {
119
- /** Collection display name */
271
+ /** Collection display name (plural) */
120
272
  name: string;
273
+ /** Singular form of the display name (Webflow requires this for new collections) */
274
+ singularName: string;
121
275
  /** Collection slug */
122
276
  slug: string;
123
277
  /** URL pattern for detail pages */
@@ -146,6 +300,14 @@ export interface WebflowAssetRef {
146
300
  // Export Payload
147
301
  // ---------------------------------------------------------------------------
148
302
 
303
+ /** Per-locale slug mapping for a single source page */
304
+ export interface WebflowSlugMap {
305
+ /** The page's canonical id (e.g., "about", "blog/post-1") */
306
+ pageId: string;
307
+ /** Locale code → translated slug */
308
+ slugs: Record<string, string>;
309
+ }
310
+
149
311
  /** Complete Webflow export payload */
150
312
  export interface WebflowExportPayload {
151
313
  /** Export format version */
@@ -160,8 +322,46 @@ export interface WebflowExportPayload {
160
322
  cms: WebflowCMSCollection[];
161
323
  /** Asset references (images, fonts, etc.) */
162
324
  assets: WebflowAssetRef[];
163
- /** CSS variables used in styles (for global embed) */
164
- cssVariables?: Record<string, string>;
325
+ /** Per-locale slug translations so the consumer can route between locales */
326
+ slugMappings?: WebflowSlugMap[];
327
+ /** Component scripts — bundled at body end so components stay interactive. */
328
+ scripts?: WebflowScript[];
329
+ /**
330
+ * Meno components promoted to Webflow Components (currently `Navigation`
331
+ * and `Footer`). The extension registers each one before inserting page
332
+ * elements; pages reference them via `WebflowElement.componentRef`.
333
+ */
334
+ components?: WebflowComponentDef[];
335
+ /**
336
+ * Concatenated `Component.css` sidecars (raw CSS) for components that ship
337
+ * hand-written styles. Component-scoped rules — data-attribute selectors,
338
+ * runtime state classes (e.g. `.is-open`), `:checked ~` siblings — aren't
339
+ * representable in Webflow's class system and are silently lost when only
340
+ * the per-element classes are emitted. The user pastes this into Site
341
+ * Settings → Custom Code → Head Code along with the combo-class overrides.
342
+ */
343
+ componentCss?: string;
344
+ /**
345
+ * Raw CSS for `interactiveStyles` rules that Webflow's class system can't
346
+ * represent natively — anything with a `prefix` (descendant/sibling
347
+ * selector built from a state class on an ancestor), a class-style
348
+ * `postfix` like `.is-open`, or breakpoint-divided pseudo states.
349
+ * Pseudo-only rules with empty prefix continue to flow through Webflow's
350
+ * `Style.setProperties({ pseudo })`. Pasted into Site Settings → Custom
351
+ * Code → Head Code along with `componentCss`.
352
+ */
353
+ interactiveCss?: string;
354
+ /**
355
+ * Project i18n summary so the extension can show a locale picker. Always
356
+ * present; single-locale projects still receive their lone locale here.
357
+ * `selectedLocale` reflects which locale's pages were emitted in this
358
+ * payload — the extension uses it to round-trip the picker selection.
359
+ */
360
+ i18n?: {
361
+ defaultLocale: string;
362
+ locales: Array<{ code: string; name: string; nativeName?: string }>;
363
+ selectedLocale: string;
364
+ };
165
365
  }
166
366
 
167
367
  // ---------------------------------------------------------------------------
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS } from './cssGeneration';
2
+ import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS, applyContainerPattern } from './cssGeneration';
3
3
  import { DEFAULT_BREAKPOINTS } from './breakpoints';
4
4
  import type { ResponsiveScales } from './responsiveScaling';
5
5
  import type { InteractiveStyles } from './types/styles';
6
+ import { registerStyleValue, clearRegistry } from './styleValueRegistry';
6
7
 
7
8
  describe('extractUtilityClassesFromHTML', () => {
8
9
  test('extracts ins- classes', () => {
@@ -135,6 +136,20 @@ describe('cssGeneration', () => {
135
136
  });
136
137
  });
137
138
 
139
+ describe('display (d-) prefix for non-special values', () => {
140
+ test('d-contents generates display: contents', () => {
141
+ expect(generateRuleForClass('d-contents')).toBe('display: contents;');
142
+ });
143
+
144
+ test('d-flow-root generates display: flow-root', () => {
145
+ expect(generateRuleForClass('d-flow-root')).toBe('display: flow-root;');
146
+ });
147
+
148
+ test('d-table generates display: table', () => {
149
+ expect(generateRuleForClass('d-table')).toBe('display: table;');
150
+ });
151
+ });
152
+
138
153
  describe('bc- and bt- combination', () => {
139
154
  test('bc- controls color independently from bt-', () => {
140
155
  // bt- should NOT set color, allowing bc- to control it
@@ -296,3 +311,254 @@ describe('generateInteractiveCSS — auto-responsive scaling', () => {
296
311
  expect(cssNoScales).not.toContain('@media');
297
312
  });
298
313
  });
314
+
315
+ describe('fluid mode — clamp() emission', () => {
316
+ const fluidScales: ResponsiveScales = {
317
+ enabled: true,
318
+ mode: 'fluid',
319
+ baseReference: 16,
320
+ fluidRange: { min: 320, max: 1440 },
321
+ fontSize: { tablet: 0.88, mobile: 0.75 },
322
+ padding: { tablet: 0.75, mobile: 0.5 },
323
+ };
324
+
325
+ test('generateUtilityCSS emits a single clamp() rule with no @media for auto-responsive class', () => {
326
+ const classes = new Set(['fs-32px']);
327
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidScales);
328
+ // mobile.breakpoint=540 is the smallest in DEFAULT_BREAKPOINTS, mobile scale=0.75
329
+ // so MIN = 32 + (32-16)*(0.75-1) = 32 - 4 = 28; MAX = 32
330
+ expect(css).toContain('.fs-32px');
331
+ expect(css).toContain('clamp(28px,');
332
+ expect(css).toContain(', 32px)');
333
+ expect(css).not.toContain('@media');
334
+ });
335
+
336
+ test('generateUtilityCSS leaves non-scalable class unchanged in fluid mode', () => {
337
+ const classes = new Set(['z-2']);
338
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidScales);
339
+ expect(css).toContain('z-index: 2');
340
+ expect(css).not.toContain('clamp(');
341
+ });
342
+
343
+ test('generateSingleClassCSS emits clamp() in fluid mode', () => {
344
+ const css = generateSingleClassCSS('p-40px', DEFAULT_BREAKPOINTS, fluidScales);
345
+ // mobile padding scale=0.5 → MIN = 40 + (40-16)*(0.5-1) = 40 - 12 = 28
346
+ expect(css).toContain('clamp(28px,');
347
+ expect(css).toContain(', 40px)');
348
+ expect(css).not.toContain('@media');
349
+ });
350
+
351
+ test('breakpoints mode (default when mode is undefined) keeps existing @media behavior', () => {
352
+ const breakpointScales: ResponsiveScales = { ...fluidScales, mode: undefined };
353
+ const classes = new Set(['fs-32px']);
354
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, breakpointScales);
355
+ expect(css).toContain('@media (max-width: 1024px)');
356
+ expect(css).toContain('@media (max-width: 540px)');
357
+ expect(css).not.toContain('clamp(');
358
+ });
359
+
360
+ test('generateInteractiveCSS encodes scaling as clamp() on base rule, no @media', () => {
361
+ const styles: InteractiveStyles = [
362
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
363
+ ];
364
+ const css = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
365
+ // mobile fontSize scale=0.75 → MIN = 100 + (100-16)*(0.75-1) = 79
366
+ expect(css).toContain('clamp(79px,');
367
+ expect(css).toContain(', 100px)');
368
+ expect(css).not.toContain('@media');
369
+ });
370
+
371
+ test('size category (max-width / width / height) IS fluidly scaled in fluid mode', () => {
372
+ const fluidWithSize: ResponsiveScales = {
373
+ ...fluidScales,
374
+ size: { tablet: 0.9, mobile: 0.75 },
375
+ };
376
+ const classes = new Set(['mw-1200px']);
377
+ const css = generateUtilityCSS(classes, DEFAULT_BREAKPOINTS, fluidWithSize);
378
+ // mobile size scale=0.75 → MIN = 1200 + (1200-16)*(0.75-1) = 1200 - 296 = 904
379
+ expect(css).toContain('clamp(904px,');
380
+ expect(css).toContain(', 1200px)');
381
+ expect(css).not.toContain('@media');
382
+ });
383
+
384
+ test('generateInteractiveCSS keeps explicit per-breakpoint override as @media in fluid mode', () => {
385
+ const styles: InteractiveStyles = [
386
+ {
387
+ prefix: '',
388
+ postfix: ':hover',
389
+ style: {
390
+ base: { fontSize: '100px' },
391
+ mobile: { fontSize: '60px' },
392
+ },
393
+ },
394
+ ];
395
+ const css = generateInteractiveCSS('c_btn', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
396
+ // base rule has clamp from auto-scale
397
+ expect(css).toContain('clamp(79px,');
398
+ // explicit mobile override stays as a media-query rule with the user's value
399
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*font-size:\s*60px/);
400
+ });
401
+
402
+ // Hash-fallback utility classes (e.g. `fs-h1glej9a` for `222.3px` — the `.`
403
+ // is illegal in CSS selectors, so utilityClassMapper generates a hashed name
404
+ // and stores the real value in styleValueRegistry). The scaling pipeline
405
+ // previously bailed on these because extractPropertyAndValue returns null
406
+ // for hashed classes. resolveScalablePropertyValue now consults the registry.
407
+ describe('hash-fallback classes', () => {
408
+ test('fluid mode: hashed fs- class with px value gets clamp()', () => {
409
+ clearRegistry();
410
+ registerStyleValue('fs-h1glej9a', '222.3px');
411
+ const css = generateUtilityCSS(new Set(['fs-h1glej9a']), DEFAULT_BREAKPOINTS, fluidScales);
412
+ // mobile fontSize scale=0.75, baseRef=16
413
+ // MIN = 222.3 + (222.3 - 16)*(0.75 - 1) = 222.3 - 51.575 = 170.725 → round 171
414
+ expect(css).toMatch(/\.fs-h1glej9a\s*\{\s*font-size:\s*clamp\(171px,/);
415
+ expect(css).toContain(', 222.3px)');
416
+ expect(css).not.toContain('@media');
417
+ });
418
+
419
+ test('fluid mode: hashed class at baseReference renders plain (MIN===MAX, no clamp)', () => {
420
+ clearRegistry();
421
+ registerStyleValue('fs-hatref', '16px');
422
+ const css = generateUtilityCSS(new Set(['fs-hatref']), DEFAULT_BREAKPOINTS, fluidScales);
423
+ // 16 === baseReference → calculateResponsiveValue returns 16 → MIN===MAX → no clamp
424
+ expect(css).toContain('font-size: 16px');
425
+ expect(css).not.toContain('clamp(');
426
+ });
427
+
428
+ test('fluid mode: hashed class with fractional px value above baseReference clamps with rounded MIN', () => {
429
+ clearRegistry();
430
+ registerStyleValue('fs-hfrac', '14.4px');
431
+ const css = generateUtilityCSS(new Set(['fs-hfrac']), DEFAULT_BREAKPOINTS, fluidScales);
432
+ // 14.4 < baseRef (16) → calculateResponsiveValue floors to round(14.4) = 14
433
+ // MIN=14, MAX=14.4 → small clamp emitted
434
+ expect(css).toMatch(/font-size:\s*clamp\(14px,/);
435
+ expect(css).toContain(', 14.4px)');
436
+ });
437
+
438
+ test('fluid mode: hashed class with already-clamped Webflow value passes through unchanged', () => {
439
+ clearRegistry();
440
+ const webflowClamp = 'clamp(2*1rem, ((2 - 1)/70*20)*1rem + ((2 - 1)/70)*100vw, 4*1rem)';
441
+ registerStyleValue('p-hwebflow', webflowClamp);
442
+ const css = generateUtilityCSS(new Set(['p-hwebflow']), DEFAULT_BREAKPOINTS, fluidScales);
443
+ // buildFluidPropertyValue returns null for unparseable inputs → rule stays as-is
444
+ expect(css).toContain(webflowClamp);
445
+ // no double-clamping
446
+ const clampCount = (css.match(/clamp\(/g) || []).length;
447
+ expect(clampCount).toBe(1);
448
+ });
449
+
450
+ test('breakpoints mode: hashed class with px value gets @media-scaled', () => {
451
+ clearRegistry();
452
+ registerStyleValue('p-h1glej9a', '40px');
453
+ const breakpointScales: ResponsiveScales = { ...fluidScales, mode: 'breakpoints' };
454
+ const css = generateUtilityCSS(new Set(['p-h1glej9a']), DEFAULT_BREAKPOINTS, breakpointScales);
455
+ // Base rule
456
+ expect(css).toContain('padding: 40px');
457
+ // mobile padding scale=0.5 → 40 + (40-16)*(0.5-1) = 28
458
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*padding:\s*28px/);
459
+ });
460
+
461
+ test('generateSingleClassCSS handles hashed class in fluid mode', () => {
462
+ clearRegistry();
463
+ registerStyleValue('fs-htest1', '100px');
464
+ const css = generateSingleClassCSS('fs-htest1', DEFAULT_BREAKPOINTS, fluidScales);
465
+ // mobile fontSize 0.75 → MIN = 100 + (100-16)*(0.75-1) = 79
466
+ expect(css).toContain('clamp(79px,');
467
+ expect(css).toContain(', 100px)');
468
+ });
469
+ });
470
+ });
471
+
472
+ describe('applyContainerPattern', () => {
473
+ test('width === maxWidth in fluid mode → calc + auto margins, maxWidth retained', () => {
474
+ const result = applyContainerPattern({ width: '1200px', maxWidth: '1200px' }, true);
475
+ expect(result.width).toBe('calc(100% - var(--site-margin) * 2)');
476
+ expect(result.maxWidth).toBe('1200px');
477
+ expect(result.marginLeft).toBe('auto');
478
+ expect(result.marginRight).toBe('auto');
479
+ });
480
+
481
+ test('width === maxWidth in breakpoints mode → unchanged', () => {
482
+ const input = { width: '1200px', maxWidth: '1200px' };
483
+ const result = applyContainerPattern(input, false);
484
+ expect(result).toBe(input);
485
+ });
486
+
487
+ test('width !== maxWidth → unchanged', () => {
488
+ const input = { width: '1200px', maxWidth: '800px' };
489
+ const result = applyContainerPattern(input, true);
490
+ expect(result).toBe(input);
491
+ });
492
+
493
+ test('only width set, no maxWidth → unchanged', () => {
494
+ const input = { width: '1200px' };
495
+ const result = applyContainerPattern(input, true);
496
+ expect(result).toBe(input);
497
+ });
498
+
499
+ test('width === maxWidth === "auto" → unchanged (RESERVED)', () => {
500
+ const input = { width: 'auto', maxWidth: 'auto' };
501
+ const result = applyContainerPattern(input, true);
502
+ expect(result).toBe(input);
503
+ });
504
+
505
+ test('width === maxWidth === "100%" → trigger fires (legitimate use)', () => {
506
+ const result = applyContainerPattern({ width: '100%', maxWidth: '100%' }, true);
507
+ expect(result.width).toBe('calc(100% - var(--site-margin) * 2)');
508
+ expect(result.marginLeft).toBe('auto');
509
+ expect(result.marginRight).toBe('auto');
510
+ });
511
+
512
+ test('explicit marginLeft is overwritten with auto (per spec)', () => {
513
+ const result = applyContainerPattern(
514
+ { width: '1200px', maxWidth: '1200px', marginLeft: '0', marginRight: '12px' },
515
+ true
516
+ );
517
+ expect(result.marginLeft).toBe('auto');
518
+ expect(result.marginRight).toBe('auto');
519
+ });
520
+
521
+ test('preserves unrelated properties (padding, font-size)', () => {
522
+ const result = applyContainerPattern(
523
+ { width: '1200px', maxWidth: '1200px', padding: '20px', fontSize: '16px' },
524
+ true
525
+ );
526
+ expect(result.padding).toBe('20px');
527
+ expect(result.fontSize).toBe('16px');
528
+ });
529
+ });
530
+
531
+ describe('generateInteractiveCSS — container pattern integration', () => {
532
+ test('flat hover style with width === maxWidth in fluid mode emits container CSS', () => {
533
+ const fluidScales: ResponsiveScales = {
534
+ enabled: true,
535
+ mode: 'fluid',
536
+ baseReference: 16,
537
+ fluidRange: { min: 320, max: 1440 },
538
+ siteMargin: { min: 16, max: 32 },
539
+ };
540
+ const styles: InteractiveStyles = [
541
+ { prefix: '', postfix: ':hover', style: { width: '1200px', maxWidth: '1200px' } },
542
+ ];
543
+ const css = generateInteractiveCSS('c_card', styles, DEFAULT_BREAKPOINTS, undefined, fluidScales);
544
+ expect(css).toContain('width: calc(100% - var(--site-margin) * 2)');
545
+ expect(css).toContain('max-width: 1200px');
546
+ expect(css).toContain('margin-left: auto');
547
+ expect(css).toContain('margin-right: auto');
548
+ });
549
+
550
+ test('breakpoints mode emits raw width and maxWidth, no container pattern', () => {
551
+ const breakpointsScales: ResponsiveScales = {
552
+ enabled: true,
553
+ mode: 'breakpoints',
554
+ baseReference: 16,
555
+ };
556
+ const styles: InteractiveStyles = [
557
+ { prefix: '', postfix: ':hover', style: { width: '1200px', maxWidth: '1200px' } },
558
+ ];
559
+ const css = generateInteractiveCSS('c_card', styles, DEFAULT_BREAKPOINTS, undefined, breakpointsScales);
560
+ expect(css).not.toContain('calc(');
561
+ expect(css).toContain('width: 1200px');
562
+ expect(css).toContain('max-width: 1200px');
563
+ });
564
+ });