meno-core 1.0.47 → 1.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-astro.ts +2 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
- package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
- package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
- package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
- package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
- package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
- package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
- package/dist/entries/server-router.js +9 -9
- package/dist/entries/server-router.js.map +2 -2
- package/dist/lib/client/index.js +64 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +1737 -296
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +50 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.test.ts +17 -0
- package/lib/client/core/ComponentBuilder.ts +25 -1
- package/lib/client/core/builders/embedBuilder.ts +15 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
- package/lib/client/core/builders/localeListBuilder.ts +17 -6
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/theme.ts +4 -4
- package/lib/server/cssGenerator.test.ts +64 -1
- package/lib/server/cssGenerator.ts +48 -9
- package/lib/server/index.ts +1 -1
- package/lib/server/jsonLoader.test.ts +0 -17
- package/lib/server/jsonLoader.ts +0 -81
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/api/variables.ts +4 -2
- package/lib/server/routes/index.ts +1 -1
- package/lib/server/routes/pages.ts +23 -1
- package/lib/server/services/cmsService.test.ts +246 -0
- package/lib/server/services/cmsService.ts +122 -5
- package/lib/server/services/configService.ts +5 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +114 -2
- package/lib/server/ssr/htmlGenerator.ts +53 -6
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +362 -1
- package/lib/server/ssr/ssrRenderer.ts +216 -72
- package/lib/server/utils/jsonLineMapper.test.ts +53 -1
- package/lib/server/utils/jsonLineMapper.ts +43 -3
- package/lib/server/webflow/buildWebflow.ts +343 -123
- package/lib/server/webflow/index.ts +1 -0
- package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
- package/lib/server/webflow/nodeToWebflow.ts +2141 -129
- package/lib/server/webflow/styleMapper.test.ts +389 -0
- package/lib/server/webflow/styleMapper.ts +517 -63
- package/lib/server/webflow/templateWrapper.ts +49 -0
- package/lib/server/webflow/types.ts +218 -18
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +247 -1
- package/lib/shared/cssProperties.ts +196 -6
- package/lib/shared/elementClassName.test.ts +15 -0
- package/lib/shared/elementClassName.ts +7 -3
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
- package/lib/shared/responsiveScaling.test.ts +143 -0
- package/lib/shared/responsiveScaling.ts +253 -2
- package/lib/shared/themeDefaults.test.ts +3 -3
- package/lib/shared/themeDefaults.ts +3 -3
- package/lib/shared/types/cms.ts +28 -3
- package/lib/shared/types/index.ts +2 -0
- package/lib/shared/types/variables.ts +37 -0
- package/lib/shared/utilityClassConfig.ts +3 -0
- package/lib/shared/utilityClassMapper.test.ts +123 -0
- package/lib/shared/utilityClassMapper.ts +179 -8
- package/lib/shared/validation/schemas.ts +15 -1
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
- package/dist/chunks/chunk-FED5MME6.js.map +0 -7
- package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
- package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
- /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
|
@@ -0,0 +1,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
|
-
/**
|
|
13
|
-
|
|
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
|
-
/**
|
|
16
|
-
|
|
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., "
|
|
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
|
-
/**
|
|
50
|
-
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
164
|
-
|
|
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
|
+
});
|