meno-core 1.0.39 → 1.0.41
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/bin/cli.ts +33 -0
- package/build-astro.ts +172 -69
- package/dist/bin/cli.js +30 -2
- package/dist/bin/cli.js.map +2 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-WK5XLASY.js → chunk-EQOSDQS2.js} +4 -4
- package/dist/chunks/{chunk-AIXKUVNG.js → chunk-IBR2F4IL.js} +4 -5
- package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-IBR2F4IL.js.map} +2 -2
- package/dist/chunks/{chunk-NV25WXCA.js → chunk-IGVQF5GY.js} +11 -7
- package/dist/chunks/chunk-IGVQF5GY.js.map +7 -0
- package/dist/chunks/{chunk-KULPBDC7.js → chunk-LBWIHPN7.js} +9 -3
- package/dist/chunks/chunk-LBWIHPN7.js.map +7 -0
- package/dist/chunks/{chunk-A6KWUEA6.js → chunk-MKB2J6AD.js} +9 -1
- package/dist/chunks/chunk-MKB2J6AD.js.map +7 -0
- package/dist/chunks/{chunk-P3FX5HJM.js → chunk-S2HXJTAF.js} +1 -1
- package/dist/chunks/chunk-S2HXJTAF.js.map +7 -0
- package/dist/chunks/{chunk-W6HDII4T.js → chunk-SK3TLNUP.js} +140 -114
- package/dist/chunks/chunk-SK3TLNUP.js.map +7 -0
- package/dist/chunks/{chunk-HNAS6BSS.js → chunk-SNUROC7E.js} +56 -6
- package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-SNUROC7E.js.map} +3 -3
- package/dist/chunks/{configService-TXBNUBBL.js → configService-MICL4S2L.js} +2 -2
- package/dist/chunks/{constants-5CRJRQNR.js → constants-ZEU4TZCA.js} +2 -2
- package/dist/entries/server-router.js +7 -7
- package/dist/lib/client/index.js +11 -6
- package/dist/lib/client/index.js.map +2 -2
- package/dist/lib/server/index.js +507 -1587
- package/dist/lib/server/index.js.map +4 -4
- package/dist/lib/shared/index.js +3 -3
- package/dist/lib/test-utils/index.js +1 -1
- package/lib/client/core/ComponentBuilder.ts +1 -1
- package/lib/client/core/builders/embedBuilder.ts +2 -2
- package/lib/client/routing/Router.tsx +6 -0
- package/lib/client/templateEngine.test.ts +178 -0
- package/lib/client/templateEngine.ts +1 -2
- package/lib/server/astro/cmsPageEmitter.ts +420 -0
- package/lib/server/astro/componentEmitter.ts +150 -17
- package/lib/server/astro/nodeToAstro.test.ts +1101 -0
- package/lib/server/astro/nodeToAstro.ts +869 -37
- package/lib/server/astro/pageEmitter.ts +43 -3
- package/lib/server/astro/tailwindMapper.ts +69 -8
- package/lib/server/astro/templateTransformer.ts +107 -0
- package/lib/server/index.ts +26 -3
- package/lib/server/routes/api/components.ts +62 -0
- package/lib/server/routes/api/core-routes.ts +8 -0
- package/lib/server/services/configService.ts +12 -0
- package/lib/server/ssr/htmlGenerator.ts +0 -5
- package/lib/server/ssr/imageMetadata.ts +3 -3
- package/lib/server/ssr/ssrRenderer.ts +78 -29
- package/lib/server/webflow/buildWebflow.ts +415 -0
- package/lib/server/webflow/index.ts +22 -0
- package/lib/server/webflow/nodeToWebflow.ts +423 -0
- package/lib/server/webflow/styleMapper.ts +241 -0
- package/lib/server/webflow/types.ts +196 -0
- package/lib/shared/constants.ts +4 -0
- package/lib/shared/types/components.ts +9 -4
- package/lib/shared/validation/propValidator.ts +2 -1
- package/lib/shared/validation/schemas.ts +4 -1
- package/package.json +1 -1
- package/templates/index-router.html +0 -5
- package/dist/chunks/chunk-A6KWUEA6.js.map +0 -7
- package/dist/chunks/chunk-KULPBDC7.js.map +0 -7
- package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
- package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
- package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
- /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-EQOSDQS2.js.map} +0 -0
- /package/dist/chunks/{configService-TXBNUBBL.js.map → configService-MICL4S2L.js.map} +0 -0
- /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-ZEU4TZCA.js.map} +0 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-to-Webflow Converter
|
|
3
|
+
* Recursively converts Meno's ComponentNode tree into Webflow element tree.
|
|
4
|
+
* Follows the same traversal pattern as nodeToAstro.ts.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ComponentNode,
|
|
9
|
+
ComponentDefinition,
|
|
10
|
+
StructuredComponentDefinition,
|
|
11
|
+
HtmlNode,
|
|
12
|
+
ComponentInstanceNode,
|
|
13
|
+
SlotMarker,
|
|
14
|
+
EmbedNode,
|
|
15
|
+
LinkNode,
|
|
16
|
+
} from '../../shared/types';
|
|
17
|
+
import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
|
|
18
|
+
import type {
|
|
19
|
+
StyleObject,
|
|
20
|
+
ResponsiveStyleObject,
|
|
21
|
+
InteractiveStyles,
|
|
22
|
+
} from '../../shared/types/styles';
|
|
23
|
+
import type { BreakpointConfig } from '../../shared/breakpoints';
|
|
24
|
+
import { generateElementClassName } from '../../shared/elementClassName';
|
|
25
|
+
import { isVoidElement } from '../../shared/nodeUtils';
|
|
26
|
+
import { NODE_TYPE } from '../../shared/constants';
|
|
27
|
+
import type { WebflowElement, WebflowStyleClass } from './types';
|
|
28
|
+
import { mapStylesToWebflow } from './styleMapper';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Context
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export interface WebflowEmitContext {
|
|
35
|
+
/** All global components */
|
|
36
|
+
globalComponents: Record<string, ComponentDefinition>;
|
|
37
|
+
/** Current element path for class name generation */
|
|
38
|
+
elementPath: number[];
|
|
39
|
+
/** File type for element class context */
|
|
40
|
+
fileType: 'component' | 'page';
|
|
41
|
+
/** File name for element class context */
|
|
42
|
+
fileName: string;
|
|
43
|
+
/** Breakpoint config */
|
|
44
|
+
breakpoints: BreakpointConfig;
|
|
45
|
+
/** Collected style classes (side output) */
|
|
46
|
+
styleClasses: Map<string, WebflowStyleClass>;
|
|
47
|
+
/** Children passed from a component instance to fill slot markers */
|
|
48
|
+
slotChildren?: (ComponentNode | string)[] | ComponentNode | string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function buildElementClass(ctx: WebflowEmitContext, label: string | undefined): string {
|
|
56
|
+
return generateElementClassName({
|
|
57
|
+
fileType: ctx.fileType,
|
|
58
|
+
fileName: ctx.fileName,
|
|
59
|
+
label,
|
|
60
|
+
path: ctx.elementPath,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveTemplate(text: string, props?: Record<string, unknown>): string {
|
|
65
|
+
if (!props) return text;
|
|
66
|
+
return text.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
67
|
+
const trimmed = expr.trim();
|
|
68
|
+
|
|
69
|
+
// Support "expr || 'fallback'" pattern
|
|
70
|
+
const orMatch = trimmed.match(/^(.+?)\s*\|\|\s*['"](.+?)['"]$/);
|
|
71
|
+
if (orMatch) {
|
|
72
|
+
const value = resolveNestedProp(props, orMatch[1].trim());
|
|
73
|
+
return (value !== undefined && value !== '' && value !== null)
|
|
74
|
+
? String(value)
|
|
75
|
+
: orMatch[2];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Support "expr ? 'a' : 'b'" ternary pattern
|
|
79
|
+
const ternaryMatch = trimmed.match(/^(.+?)\s*\?\s*['"](.+?)['"]\s*:\s*['"](.+?)['"]$/);
|
|
80
|
+
if (ternaryMatch) {
|
|
81
|
+
const value = resolveNestedProp(props, ternaryMatch[1].trim());
|
|
82
|
+
return value ? ternaryMatch[2] : ternaryMatch[3];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Simple prop lookup with dot-notation
|
|
86
|
+
const value = resolveNestedProp(props, trimmed);
|
|
87
|
+
return value !== undefined ? String(value) : '';
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveNestedProp(obj: Record<string, unknown>, path: string): unknown {
|
|
92
|
+
const parts = path.split('.');
|
|
93
|
+
let current: unknown = obj;
|
|
94
|
+
for (const part of parts) {
|
|
95
|
+
if (current === null || current === undefined || typeof current !== 'object') return undefined;
|
|
96
|
+
current = (current as Record<string, unknown>)[part];
|
|
97
|
+
}
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function hasTemplates(text: string): boolean {
|
|
102
|
+
return /\{\{.+?\}\}/.test(text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
// Main recursive converter
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Convert a ComponentNode tree to Webflow element tree.
|
|
111
|
+
* Also collects WebflowStyleClass definitions as a side effect in ctx.styleClasses.
|
|
112
|
+
*/
|
|
113
|
+
export function nodeToWebflow(
|
|
114
|
+
node: ComponentNode | ComponentNode[] | string | number | null | undefined,
|
|
115
|
+
ctx: WebflowEmitContext,
|
|
116
|
+
instanceProps?: Record<string, unknown>
|
|
117
|
+
): WebflowElement[] {
|
|
118
|
+
if (node === null || node === undefined) return [];
|
|
119
|
+
|
|
120
|
+
// Text/number
|
|
121
|
+
if (typeof node === 'string') {
|
|
122
|
+
const text = instanceProps ? resolveTemplate(node, instanceProps) : node;
|
|
123
|
+
return [{ tag: 'span', textContent: text }];
|
|
124
|
+
}
|
|
125
|
+
if (typeof node === 'number') {
|
|
126
|
+
return [{ tag: 'span', textContent: String(node) }];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Array of nodes
|
|
130
|
+
if (Array.isArray(node)) {
|
|
131
|
+
const results: WebflowElement[] = [];
|
|
132
|
+
for (let i = 0; i < node.length; i++) {
|
|
133
|
+
const child = node[i];
|
|
134
|
+
const savedPath = [...ctx.elementPath];
|
|
135
|
+
ctx.elementPath = [...ctx.elementPath, i];
|
|
136
|
+
results.push(...nodeToWebflow(child, ctx, instanceProps));
|
|
137
|
+
ctx.elementPath = savedPath;
|
|
138
|
+
}
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Dispatch by node type
|
|
143
|
+
switch (node.type) {
|
|
144
|
+
case NODE_TYPE.NODE:
|
|
145
|
+
return [emitHtmlNode(node as HtmlNode, ctx, instanceProps)];
|
|
146
|
+
case NODE_TYPE.COMPONENT:
|
|
147
|
+
return emitComponentInstance(node as ComponentInstanceNode, ctx, instanceProps);
|
|
148
|
+
case NODE_TYPE.SLOT:
|
|
149
|
+
return emitSlotMarker(node as SlotMarker, ctx, instanceProps);
|
|
150
|
+
case NODE_TYPE.EMBED:
|
|
151
|
+
return [emitEmbedNode(node as EmbedNode, ctx, instanceProps)];
|
|
152
|
+
case NODE_TYPE.LINK:
|
|
153
|
+
return [emitLinkNode(node as LinkNode, ctx, instanceProps)];
|
|
154
|
+
case NODE_TYPE.LIST:
|
|
155
|
+
case 'cms-list' as any:
|
|
156
|
+
case NODE_TYPE.LOCALE_LIST:
|
|
157
|
+
// Complex nodes — emit a placeholder div
|
|
158
|
+
return [{ tag: 'div', attributes: { 'data-meno-type': node.type } }];
|
|
159
|
+
default:
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Node type emitters
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
function emitHtmlNode(
|
|
169
|
+
node: HtmlNode,
|
|
170
|
+
ctx: WebflowEmitContext,
|
|
171
|
+
instanceProps?: Record<string, unknown>
|
|
172
|
+
): WebflowElement {
|
|
173
|
+
const tag = hasTemplates(node.tag) && instanceProps
|
|
174
|
+
? resolveTemplate(node.tag, instanceProps)
|
|
175
|
+
: node.tag;
|
|
176
|
+
|
|
177
|
+
const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
|
|
178
|
+
const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
|
|
179
|
+
|
|
180
|
+
// Generate element class and map styles
|
|
181
|
+
const needsClass = style || (interactiveStyles && interactiveStyles.length > 0) || node.generateElementClass;
|
|
182
|
+
let className: string | undefined;
|
|
183
|
+
let comboClassNames: string[] | undefined;
|
|
184
|
+
|
|
185
|
+
if (needsClass) {
|
|
186
|
+
const elementClass = buildElementClass(ctx, node.label);
|
|
187
|
+
const { primaryClass, comboClasses } = mapStylesToWebflow(
|
|
188
|
+
elementClass, style, interactiveStyles, ctx.breakpoints
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
className = primaryClass.name;
|
|
192
|
+
ctx.styleClasses.set(primaryClass.name, primaryClass);
|
|
193
|
+
|
|
194
|
+
if (comboClasses.length > 0) {
|
|
195
|
+
comboClassNames = [];
|
|
196
|
+
for (const combo of comboClasses) {
|
|
197
|
+
ctx.styleClasses.set(combo.name, combo);
|
|
198
|
+
comboClassNames.push(combo.name);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Build attributes
|
|
204
|
+
const attributes: Record<string, string | number | boolean> = {};
|
|
205
|
+
if (node.attributes) {
|
|
206
|
+
for (const [key, value] of Object.entries(node.attributes)) {
|
|
207
|
+
if (instanceProps && typeof value === 'string' && hasTemplates(value)) {
|
|
208
|
+
attributes[key] = resolveTemplate(value, instanceProps);
|
|
209
|
+
} else {
|
|
210
|
+
attributes[key] = value;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Build children
|
|
216
|
+
let children: WebflowElement[] | undefined;
|
|
217
|
+
let textContent: string | undefined;
|
|
218
|
+
if (!isVoidElement(tag) && node.children) {
|
|
219
|
+
// Optimize: single string child becomes textContent instead of child element
|
|
220
|
+
if (typeof node.children === 'string') {
|
|
221
|
+
textContent = instanceProps ? resolveTemplate(node.children, instanceProps) : node.children;
|
|
222
|
+
} else if (
|
|
223
|
+
Array.isArray(node.children) &&
|
|
224
|
+
node.children.length === 1 &&
|
|
225
|
+
typeof node.children[0] === 'string'
|
|
226
|
+
) {
|
|
227
|
+
const text = node.children[0] as string;
|
|
228
|
+
textContent = instanceProps ? resolveTemplate(text, instanceProps) : text;
|
|
229
|
+
} else {
|
|
230
|
+
const innerCtx = { ...ctx, elementPath: [...ctx.elementPath] };
|
|
231
|
+
children = convertChildren(node.children, innerCtx, instanceProps);
|
|
232
|
+
if (children.length === 0) children = undefined;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Handle conditional rendering
|
|
237
|
+
let conditional: WebflowElement['conditional'];
|
|
238
|
+
const ifValue = (node as any).if;
|
|
239
|
+
if (ifValue !== undefined && ifValue !== true) {
|
|
240
|
+
if (typeof ifValue === 'object' && ifValue._mapping) {
|
|
241
|
+
conditional = { prop: ifValue.prop, condition: 'truthy' };
|
|
242
|
+
} else if (typeof ifValue === 'string') {
|
|
243
|
+
const match = ifValue.match(/^\{\{(.+)\}\}$/);
|
|
244
|
+
conditional = { prop: match ? match[1].trim() : ifValue, condition: 'truthy' };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const element: WebflowElement = { tag };
|
|
249
|
+
if (className) element.className = className;
|
|
250
|
+
if (comboClassNames) element.comboClasses = comboClassNames;
|
|
251
|
+
if (textContent) element.textContent = textContent;
|
|
252
|
+
if (children) element.children = children;
|
|
253
|
+
if (Object.keys(attributes).length > 0) element.attributes = attributes;
|
|
254
|
+
if (conditional) element.conditional = conditional;
|
|
255
|
+
|
|
256
|
+
return element;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function emitComponentInstance(
|
|
260
|
+
node: ComponentInstanceNode,
|
|
261
|
+
ctx: WebflowEmitContext,
|
|
262
|
+
parentProps?: Record<string, unknown>
|
|
263
|
+
): WebflowElement[] {
|
|
264
|
+
const compDef = ctx.globalComponents[node.component];
|
|
265
|
+
if (!compDef) {
|
|
266
|
+
// Unknown component — emit placeholder
|
|
267
|
+
return [{ tag: 'div', attributes: { 'data-component': node.component } }];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Resolve props: merge defaults with instance props
|
|
271
|
+
const resolvedProps: Record<string, unknown> = {};
|
|
272
|
+
const structured = compDef.component;
|
|
273
|
+
|
|
274
|
+
if (structured?.interface) {
|
|
275
|
+
for (const [key, propDef] of Object.entries(structured.interface)) {
|
|
276
|
+
resolvedProps[key] = propDef.default;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (node.props) {
|
|
281
|
+
for (const [key, value] of Object.entries(node.props)) {
|
|
282
|
+
if (key === 'children') continue;
|
|
283
|
+
if (typeof value === 'string' && hasTemplates(value) && parentProps) {
|
|
284
|
+
resolvedProps[key] = resolveTemplate(value, parentProps);
|
|
285
|
+
} else {
|
|
286
|
+
resolvedProps[key] = value;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Inline-expand the component's node tree
|
|
292
|
+
const body = structured?.structure || (compDef as any).node;
|
|
293
|
+
if (!body) return [];
|
|
294
|
+
|
|
295
|
+
const compCtx: WebflowEmitContext = {
|
|
296
|
+
...ctx,
|
|
297
|
+
fileType: 'component',
|
|
298
|
+
fileName: node.component,
|
|
299
|
+
elementPath: [0],
|
|
300
|
+
slotChildren: node.children,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return nodeToWebflow(body, compCtx, resolvedProps);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function emitSlotMarker(
|
|
307
|
+
node: SlotMarker,
|
|
308
|
+
ctx: WebflowEmitContext,
|
|
309
|
+
instanceProps?: Record<string, unknown>
|
|
310
|
+
): WebflowElement[] {
|
|
311
|
+
// Use instance children passed via context to fill the slot
|
|
312
|
+
if (ctx.slotChildren) {
|
|
313
|
+
// Switch back to parent context (page-level) for slot children
|
|
314
|
+
const parentCtx: WebflowEmitContext = {
|
|
315
|
+
...ctx,
|
|
316
|
+
slotChildren: undefined, // prevent infinite slot nesting
|
|
317
|
+
};
|
|
318
|
+
return convertChildren(ctx.slotChildren as any, parentCtx, instanceProps);
|
|
319
|
+
}
|
|
320
|
+
// Fall back to slot defaults
|
|
321
|
+
if (node.default) {
|
|
322
|
+
return convertChildren(node.default as any, ctx, instanceProps);
|
|
323
|
+
}
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function emitEmbedNode(
|
|
328
|
+
node: EmbedNode,
|
|
329
|
+
ctx: WebflowEmitContext,
|
|
330
|
+
instanceProps?: Record<string, unknown>
|
|
331
|
+
): WebflowElement {
|
|
332
|
+
const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
|
|
333
|
+
const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
|
|
334
|
+
|
|
335
|
+
let className: string | undefined;
|
|
336
|
+
if (style || (interactiveStyles && interactiveStyles.length > 0)) {
|
|
337
|
+
const elementClass = buildElementClass(ctx, node.label);
|
|
338
|
+
const { primaryClass } = mapStylesToWebflow(
|
|
339
|
+
elementClass, style, interactiveStyles, ctx.breakpoints
|
|
340
|
+
);
|
|
341
|
+
className = primaryClass.name;
|
|
342
|
+
ctx.styleClasses.set(primaryClass.name, primaryClass);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const htmlStr = typeof node.html === 'string' ? node.html : '';
|
|
346
|
+
|
|
347
|
+
const element: WebflowElement = {
|
|
348
|
+
tag: 'div',
|
|
349
|
+
rawHtml: htmlStr,
|
|
350
|
+
};
|
|
351
|
+
if (className) element.className = className;
|
|
352
|
+
|
|
353
|
+
return element;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function emitLinkNode(
|
|
357
|
+
node: LinkNode,
|
|
358
|
+
ctx: WebflowEmitContext,
|
|
359
|
+
instanceProps?: Record<string, unknown>
|
|
360
|
+
): WebflowElement {
|
|
361
|
+
const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
|
|
362
|
+
const interactiveStyles = node.interactiveStyles as InteractiveStyles | undefined;
|
|
363
|
+
|
|
364
|
+
let className: string | undefined;
|
|
365
|
+
if (style || (interactiveStyles && interactiveStyles.length > 0)) {
|
|
366
|
+
const elementClass = buildElementClass(ctx, node.label);
|
|
367
|
+
const { primaryClass } = mapStylesToWebflow(
|
|
368
|
+
elementClass, style, interactiveStyles, ctx.breakpoints
|
|
369
|
+
);
|
|
370
|
+
className = primaryClass.name;
|
|
371
|
+
ctx.styleClasses.set(primaryClass.name, primaryClass);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Resolve href
|
|
375
|
+
let href = '#';
|
|
376
|
+
if (typeof node.href === 'string') {
|
|
377
|
+
href = instanceProps && hasTemplates(node.href)
|
|
378
|
+
? resolveTemplate(node.href, instanceProps)
|
|
379
|
+
: node.href;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const attributes: Record<string, string | number | boolean> = { href };
|
|
383
|
+
if (node.attributes) {
|
|
384
|
+
for (const [key, value] of Object.entries(node.attributes)) {
|
|
385
|
+
attributes[key] = value;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let children: WebflowElement[] | undefined;
|
|
390
|
+
if (node.children) {
|
|
391
|
+
children = convertChildren(node.children, ctx, instanceProps);
|
|
392
|
+
if (children.length === 0) children = undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const element: WebflowElement = { tag: 'a', attributes };
|
|
396
|
+
if (className) element.className = className;
|
|
397
|
+
if (children) element.children = children;
|
|
398
|
+
|
|
399
|
+
return element;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Children helper
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
function convertChildren(
|
|
407
|
+
children: (ComponentNode | string)[] | string | ComponentNode | null | undefined,
|
|
408
|
+
ctx: WebflowEmitContext,
|
|
409
|
+
instanceProps?: Record<string, unknown>
|
|
410
|
+
): WebflowElement[] {
|
|
411
|
+
if (!children) return [];
|
|
412
|
+
|
|
413
|
+
if (typeof children === 'string') {
|
|
414
|
+
return nodeToWebflow(children, ctx, instanceProps);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (Array.isArray(children)) {
|
|
418
|
+
return nodeToWebflow(children as ComponentNode[], ctx, instanceProps);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Single node
|
|
422
|
+
return nodeToWebflow(children, ctx, instanceProps);
|
|
423
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webflow Style Mapper
|
|
3
|
+
* Converts Meno styles (ResponsiveStyleObject + InteractiveStyles) into
|
|
4
|
+
* Webflow named style classes with breakpoint and pseudo-state overrides.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
StyleObject,
|
|
9
|
+
ResponsiveStyleObject,
|
|
10
|
+
StyleMapping,
|
|
11
|
+
InteractiveStyles,
|
|
12
|
+
} from '../../shared/types/styles';
|
|
13
|
+
import type { BreakpointConfig } from '../../shared/breakpoints';
|
|
14
|
+
import type { WebflowStyleClass, WebflowBreakpoint, WebflowPseudoState, CSSProperties } from './types';
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Helpers
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** CSS properties that accept unitless numeric values */
|
|
21
|
+
const UNITLESS_PROPERTIES = new Set([
|
|
22
|
+
'opacity', 'z-index', 'flex-grow', 'flex-shrink', 'flex',
|
|
23
|
+
'order', 'orphans', 'widows', 'column-count', 'font-weight',
|
|
24
|
+
'tab-size',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
function isStyleMapping(value: unknown): value is StyleMapping {
|
|
28
|
+
return (
|
|
29
|
+
typeof value === 'object' &&
|
|
30
|
+
value !== null &&
|
|
31
|
+
'_mapping' in value &&
|
|
32
|
+
(value as StyleMapping)._mapping === true
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isResponsiveStyle(
|
|
37
|
+
style: StyleObject | ResponsiveStyleObject
|
|
38
|
+
): style is ResponsiveStyleObject {
|
|
39
|
+
return 'base' in style || 'tablet' in style || 'mobile' in style;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Convert a camelCase CSS property to kebab-case
|
|
44
|
+
*/
|
|
45
|
+
function toKebabCase(prop: string): string {
|
|
46
|
+
return prop.replace(/([A-Z])/g, '-$1').toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert a flat StyleObject to CSS properties, skipping StyleMappings
|
|
51
|
+
*/
|
|
52
|
+
function styleObjectToCSS(style: StyleObject): CSSProperties {
|
|
53
|
+
const css: CSSProperties = {};
|
|
54
|
+
for (const [prop, value] of Object.entries(style)) {
|
|
55
|
+
if (isStyleMapping(value)) continue;
|
|
56
|
+
if (value === '' || value === undefined || value === null) continue;
|
|
57
|
+
if (typeof value === 'boolean' || typeof value === 'object') continue;
|
|
58
|
+
const cssProp = toKebabCase(prop);
|
|
59
|
+
if (typeof value === 'number') {
|
|
60
|
+
if (isNaN(value)) continue;
|
|
61
|
+
css[cssProp] = UNITLESS_PROPERTIES.has(cssProp) ? String(value) : `${value}px`;
|
|
62
|
+
} else {
|
|
63
|
+
css[cssProp] = String(value);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return css;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extract all StyleMappings from a style object (flat or responsive)
|
|
71
|
+
*/
|
|
72
|
+
function collectStyleMappings(
|
|
73
|
+
style: StyleObject | ResponsiveStyleObject | undefined
|
|
74
|
+
): Array<{ property: string; mapping: StyleMapping }> {
|
|
75
|
+
if (!style) return [];
|
|
76
|
+
const result: Array<{ property: string; mapping: StyleMapping }> = [];
|
|
77
|
+
|
|
78
|
+
if (isResponsiveStyle(style)) {
|
|
79
|
+
// Only collect from base — mappings apply across breakpoints
|
|
80
|
+
const base = (style as ResponsiveStyleObject).base;
|
|
81
|
+
if (base) {
|
|
82
|
+
for (const [prop, value] of Object.entries(base)) {
|
|
83
|
+
if (isStyleMapping(value)) {
|
|
84
|
+
result.push({ property: prop, mapping: value });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
for (const [prop, value] of Object.entries(style)) {
|
|
90
|
+
if (isStyleMapping(value)) {
|
|
91
|
+
result.push({ property: prop, mapping: value });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Map interactive style postfix to Webflow pseudo-state
|
|
100
|
+
*/
|
|
101
|
+
function postfixToPseudoState(postfix: string): WebflowPseudoState | null {
|
|
102
|
+
if (postfix.includes(':hover')) return 'hover';
|
|
103
|
+
if (postfix.includes(':focus-visible')) return 'focus-visible';
|
|
104
|
+
if (postfix.includes(':focus')) return 'focus';
|
|
105
|
+
if (postfix.includes(':active')) return 'active';
|
|
106
|
+
if (postfix.includes(':visited')) return 'visited';
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Main Mapper
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
export interface StyleMapperResult {
|
|
115
|
+
/** The primary style class for this element */
|
|
116
|
+
primaryClass: WebflowStyleClass;
|
|
117
|
+
/** Combo classes for StyleMapping variants */
|
|
118
|
+
comboClasses: WebflowStyleClass[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Convert Meno element styles to Webflow style classes.
|
|
123
|
+
*
|
|
124
|
+
* @param className - Element class name (e.g., "c_navigation_hamburger")
|
|
125
|
+
* @param style - Element's responsive style object
|
|
126
|
+
* @param interactiveStyles - Element's interactive (hover/focus/etc.) styles
|
|
127
|
+
* @param breakpoints - Project breakpoint configuration
|
|
128
|
+
*/
|
|
129
|
+
export function mapStylesToWebflow(
|
|
130
|
+
className: string,
|
|
131
|
+
style: StyleObject | ResponsiveStyleObject | undefined,
|
|
132
|
+
interactiveStyles: InteractiveStyles | undefined,
|
|
133
|
+
breakpoints: BreakpointConfig
|
|
134
|
+
): StyleMapperResult {
|
|
135
|
+
// Convert underscores to dashes for Webflow class naming convention
|
|
136
|
+
const webflowClassName = className.replace(/_/g, '-');
|
|
137
|
+
|
|
138
|
+
const primaryClass: WebflowStyleClass = {
|
|
139
|
+
name: webflowClassName,
|
|
140
|
+
base: {},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// --- Base + breakpoint styles ---
|
|
144
|
+
if (style) {
|
|
145
|
+
if (isResponsiveStyle(style)) {
|
|
146
|
+
const responsive = style as ResponsiveStyleObject;
|
|
147
|
+
|
|
148
|
+
if (responsive.base) {
|
|
149
|
+
primaryClass.base = styleObjectToCSS(responsive.base);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (responsive.tablet) {
|
|
153
|
+
if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
|
|
154
|
+
primaryClass.breakpoints.Tablet = styleObjectToCSS(responsive.tablet);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (responsive.mobile) {
|
|
158
|
+
if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
|
|
159
|
+
primaryClass.breakpoints.MobilePortrait = styleObjectToCSS(responsive.mobile);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Handle additional custom breakpoints (map to closest Webflow breakpoint)
|
|
163
|
+
for (const [bpName, bpStyle] of Object.entries(responsive)) {
|
|
164
|
+
if (!bpStyle || bpName === 'base' || bpName === 'tablet' || bpName === 'mobile') continue;
|
|
165
|
+
// Custom breakpoints map to Tablet as closest approximation
|
|
166
|
+
if (!primaryClass.breakpoints) primaryClass.breakpoints = {};
|
|
167
|
+
primaryClass.breakpoints.Tablet = {
|
|
168
|
+
...primaryClass.breakpoints.Tablet,
|
|
169
|
+
...styleObjectToCSS(bpStyle),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Flat style object — treat as base/Desktop
|
|
174
|
+
primaryClass.base = styleObjectToCSS(style as StyleObject);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// --- Interactive styles (hover, focus, etc.) ---
|
|
179
|
+
if (interactiveStyles && interactiveStyles.length > 0) {
|
|
180
|
+
for (const rule of interactiveStyles) {
|
|
181
|
+
if (!rule.postfix) continue;
|
|
182
|
+
|
|
183
|
+
const pseudoState = postfixToPseudoState(rule.postfix);
|
|
184
|
+
if (!pseudoState) continue;
|
|
185
|
+
|
|
186
|
+
const ruleStyle = rule.style;
|
|
187
|
+
if (!primaryClass.pseudoStates) primaryClass.pseudoStates = {};
|
|
188
|
+
|
|
189
|
+
if (isResponsiveStyle(ruleStyle)) {
|
|
190
|
+
const responsive = ruleStyle as ResponsiveStyleObject;
|
|
191
|
+
// Merge base styles into the pseudo-state
|
|
192
|
+
if (responsive.base) {
|
|
193
|
+
primaryClass.pseudoStates[pseudoState] = {
|
|
194
|
+
...primaryClass.pseudoStates[pseudoState],
|
|
195
|
+
...styleObjectToCSS(responsive.base),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
primaryClass.pseudoStates[pseudoState] = {
|
|
200
|
+
...primaryClass.pseudoStates[pseudoState],
|
|
201
|
+
...styleObjectToCSS(ruleStyle as StyleObject),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Combo classes for StyleMappings ---
|
|
208
|
+
const comboClasses: WebflowStyleClass[] = [];
|
|
209
|
+
const mappings = collectStyleMappings(style);
|
|
210
|
+
|
|
211
|
+
for (const { property, mapping } of mappings) {
|
|
212
|
+
for (const [value, cssValue] of Object.entries(mapping.values)) {
|
|
213
|
+
if (cssValue === '' || cssValue === undefined) continue;
|
|
214
|
+
|
|
215
|
+
const comboName = `is-${sanitizeClassName(mapping.prop)}-${sanitizeClassName(String(value))}`;
|
|
216
|
+
const comboClass: WebflowStyleClass = {
|
|
217
|
+
name: comboName,
|
|
218
|
+
base: {
|
|
219
|
+
[toKebabCase(property)]: typeof cssValue === 'number'
|
|
220
|
+
? (UNITLESS_PROPERTIES.has(toKebabCase(property)) ? String(cssValue) : `${cssValue}px`)
|
|
221
|
+
: String(cssValue),
|
|
222
|
+
},
|
|
223
|
+
comboParent: webflowClassName,
|
|
224
|
+
};
|
|
225
|
+
comboClasses.push(comboClass);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { primaryClass, comboClasses };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Sanitize a string for use as a CSS class name segment
|
|
234
|
+
*/
|
|
235
|
+
function sanitizeClassName(name: string): string {
|
|
236
|
+
return name
|
|
237
|
+
.toLowerCase()
|
|
238
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
239
|
+
.replace(/-+/g, '-')
|
|
240
|
+
.replace(/^-|-$/g, '');
|
|
241
|
+
}
|