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