meno-core 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -86,6 +86,16 @@ export function buildLineMap(jsonText: string): LineMap {
86
86
  return { start: startPos, end: pos };
87
87
  }
88
88
 
89
+ function recordTrackedRoot(path: number[]): void {
90
+ // Parse the root element of the tracked structure and record its own line range
91
+ // under the empty-string key, so callers can resolve the root selection itself.
92
+ const { start, end } = parseValue(path, true);
93
+ lineMap.set('', {
94
+ startLine: charToLine[start],
95
+ endLine: charToLine[end - 1] || charToLine[start],
96
+ });
97
+ }
98
+
89
99
  function parseObject(path: number[], trackChildren: boolean): void {
90
100
  pos++; // skip {
91
101
  skipWhitespace();
@@ -98,10 +108,13 @@ export function buildLineMap(jsonText: string): LineMap {
98
108
  skipWhitespace();
99
109
 
100
110
  if (key === 'root' && path.length === 0) {
101
- // Track root object - children inside will be tracked
102
- parseValue(path, true);
111
+ // Page format: track from `root`.
112
+ recordTrackedRoot(path);
113
+ } else if (key === 'component' && path.length === 0) {
114
+ // Component format: descend into the component object looking for `structure`.
115
+ parseComponentWrapper(path);
103
116
  } else if (key === 'children' && trackChildren) {
104
- // This is a children array - track each child with indices
117
+ // Children array track each element with indices
105
118
  parseValue(path, true);
106
119
  } else {
107
120
  // Regular property - don't track individual items
@@ -115,6 +128,33 @@ export function buildLineMap(jsonText: string): LineMap {
115
128
  pos++; // skip }
116
129
  }
117
130
 
131
+ function parseComponentWrapper(path: number[]): void {
132
+ // We expect an object value for the `component` key. If it isn't one, just skip it.
133
+ if (jsonText[pos] !== '{') {
134
+ parseValue(path, false);
135
+ return;
136
+ }
137
+ pos++; // skip {
138
+ skipWhitespace();
139
+ while (pos < jsonText.length && jsonText[pos] !== '}') {
140
+ skipWhitespace();
141
+ const key = parseString();
142
+ skipWhitespace();
143
+ pos++; // skip :
144
+ skipWhitespace();
145
+ if (key === 'structure') {
146
+ // The component's structure is the root of what we track — same as `root` for pages.
147
+ recordTrackedRoot(path);
148
+ } else {
149
+ parseValue(path, false);
150
+ }
151
+ skipWhitespace();
152
+ if (jsonText[pos] === ',') pos++;
153
+ skipWhitespace();
154
+ }
155
+ pos++; // skip }
156
+ }
157
+
118
158
  function parseArray(path: number[], trackChildren: boolean): void {
119
159
  pos++; // skip [
120
160
  skipWhitespace();
@@ -14,7 +14,6 @@ import {
14
14
  parseJSON,
15
15
  loadI18nConfig,
16
16
  loadBreakpointConfig,
17
- loadResponsiveScalesConfig,
18
17
  } from '../jsonLoader';
19
18
  import { renderPageSSR } from '../ssr/ssrRenderer';
20
19
  import { projectPaths } from '../projectContext';
@@ -22,6 +21,8 @@ import { loadProjectConfig } from '../../shared/fontLoader';
22
21
  import { FileSystemCMSProvider } from '../providers/fileSystemCMSProvider';
23
22
  import { CMSService } from '../services/cmsService';
24
23
  import { isI18nValue, resolveI18nValue } from '../../shared/i18n';
24
+ import { RAW_HTML_PREFIX } from '../../shared/constants';
25
+ import { extractPageMeta, type PageMeta } from '../ssr/metaTagGenerator';
25
26
  import { configService } from '../services/configService';
26
27
  import { colorService } from '../services/ColorService';
27
28
  import { variableService } from '../services/VariableService';
@@ -32,20 +33,27 @@ import type {
32
33
  CMSSchema,
33
34
  CMSItem,
34
35
  I18nConfig,
36
+ ThemeConfig,
35
37
  } from '../../shared/types';
36
- import { isItemDraftForLocale } from '../../shared/types';
38
+ import { resolvePaletteColor } from '../../shared/types/colors';
37
39
  import type { SlugMap } from '../../shared/slugTranslator';
38
40
  import type {
39
41
  WebflowExportPayload,
40
42
  WebflowPage,
41
43
  WebflowStyleClass,
42
- WebflowCMSCollection,
43
- WebflowCMSField,
44
44
  WebflowAssetRef,
45
+ WebflowScript,
46
+ WebflowComponentDef,
45
47
  } from './types';
46
- import { mapCMSFieldType } from './types';
47
- import { nodeToWebflow, type WebflowEmitContext } from './nodeToWebflow';
48
- import { generateThemeColorVariablesCSS, generateVariablesCSS } from '../cssGenerator';
48
+ import { nodeToWebflow, normalizeListChildren, type WebflowEmitContext } from './nodeToWebflow';
49
+ import { isWebflowHandledRule } from './styleMapper';
50
+ import { generateInteractiveCSS } from '../../shared/cssGeneration';
51
+ import { generateVariablesCSS } from '../cssGenerator';
52
+ import type { InteractiveStyles } from '../../shared/types/styles';
53
+ import { resolveVariableValueAtBreakpoint } from '../../shared/responsiveScaling';
54
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
55
+ import type { VariablesConfig } from '../../shared/types';
56
+ import type { BreakpointConfig } from '../../shared/breakpoints';
49
57
 
50
58
  // ---------------------------------------------------------------------------
51
59
  // Helpers
@@ -69,18 +77,73 @@ function isCMSPage(pageData: JSONPage): boolean {
69
77
  return pageData.meta?.source === 'cms' && !!pageData.meta?.cms;
70
78
  }
71
79
 
72
- function buildCMSItemPath(
73
- urlPattern: string,
80
+ /**
81
+ * Flatten a CMS item for the Webflow emit path: resolve i18n values to the
82
+ * current locale and unwrap rich-text markers (`{ __richtext__, html }`) into
83
+ * `RAW_HTML_PREFIX + html` so `nodeToWebflow`'s `resolveTemplate` can flatten
84
+ * inline HTML to plain text. Mirrors what SSR's `processCMSTemplate`
85
+ * (`cmsSSRProcessor.ts`) does inline at every interpolation site — but applied
86
+ * once up front so the simpler Webflow `resolveTemplate` (which only does
87
+ * `String(value)`) renders meaningful text instead of `[object Object]` /
88
+ * empty strings.
89
+ */
90
+ function flattenCMSItemForLocale(
74
91
  item: CMSItem,
75
- slugField: string,
76
92
  locale: string,
77
93
  i18nConfig: I18nConfig
78
- ): string {
79
- let slug = item[slugField] ?? item._slug ?? item._id;
80
- if (isI18nValue(slug)) {
81
- slug = resolveI18nValue(slug, locale, i18nConfig) as string;
94
+ ): Record<string, unknown> {
95
+ const flatten = (value: unknown): unknown => {
96
+ if (value === null || value === undefined) return value;
97
+ if (isI18nValue(value)) {
98
+ return flatten(resolveI18nValue(value, locale, i18nConfig));
99
+ }
100
+ if (typeof value === 'object' && '__richtext__' in (value as object)) {
101
+ const html = (value as { html?: unknown }).html;
102
+ if (typeof html === 'string') return RAW_HTML_PREFIX + html;
103
+ }
104
+ return value;
105
+ };
106
+ const out: Record<string, unknown> = {};
107
+ for (const [key, value] of Object.entries(item)) {
108
+ out[key] = flatten(value);
82
109
  }
83
- return urlPattern.replace('{{slug}}', String(slug));
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * Resolve a page-meta value (which may be an i18n object) to a string for the
115
+ * given locale, or undefined when the field is empty.
116
+ */
117
+ function resolveMetaString(
118
+ value: unknown,
119
+ locale: string,
120
+ i18nConfig: I18nConfig
121
+ ): string | undefined {
122
+ if (value === undefined || value === null || value === '') return undefined;
123
+ const resolved = isI18nValue(value)
124
+ ? resolveI18nValue(value, locale, i18nConfig)
125
+ : value;
126
+ if (resolved === undefined || resolved === null || resolved === '') return undefined;
127
+ return String(resolved);
128
+ }
129
+
130
+ /**
131
+ * Lift the structured meta from a page (title, description, OG fields,
132
+ * keywords) into the per-locale fields of a `WebflowPage`. SSR's `result.title`
133
+ * is preserved as the canonical title; the rest comes from `extractPageMeta`.
134
+ */
135
+ function buildPageMetaForLocale(
136
+ meta: PageMeta,
137
+ locale: string,
138
+ i18nConfig: I18nConfig
139
+ ): Pick<WebflowPage, 'description' | 'keywords' | 'ogTitle' | 'ogDescription' | 'ogImage'> {
140
+ return {
141
+ description: resolveMetaString(meta.description, locale, i18nConfig),
142
+ keywords: resolveMetaString(meta.keywords, locale, i18nConfig),
143
+ ogTitle: resolveMetaString(meta.ogTitle, locale, i18nConfig),
144
+ ogDescription: resolveMetaString(meta.ogDescription, locale, i18nConfig),
145
+ ogImage: resolveMetaString(meta.ogImage, locale, i18nConfig),
146
+ };
84
147
  }
85
148
 
86
149
  function scanAssets(projectRoot: string): WebflowAssetRef[] {
@@ -126,23 +189,59 @@ function scanAllFiles(dir: string, prefix: string = ''): string[] {
126
189
  }
127
190
 
128
191
  /**
129
- * Extract CSS variables from theme and variables config
192
+ * Build per-theme `--var` maps so the walker can resolve `var(--bg)` against
193
+ * whatever theme an element actually inherits. Each theme key holds its full
194
+ * resolved palette (text, bg, border, …); `defaultThemeName` is what we fall
195
+ * back to when an element has no `theme` ancestor.
130
196
  */
131
- function extractCSSVariables(
132
- themeColorCSS: string,
133
- variablesCSS: string
134
- ): Record<string, string> {
135
- const vars: Record<string, string> = {};
136
- const regex = /--([\w-]+)\s*:\s*([^;]+)/g;
137
-
138
- for (const css of [themeColorCSS, variablesCSS]) {
139
- let match;
140
- while ((match = regex.exec(css)) !== null) {
141
- vars[`--${match[1]}`] = match[2].trim();
197
+ function buildThemeVarMaps(
198
+ themeConfig: ThemeConfig
199
+ ): { byTheme: Record<string, Record<string, string>>; defaultTheme: string } {
200
+ const palette = themeConfig.palette;
201
+ const byTheme: Record<string, Record<string, string>> = {};
202
+ for (const [themeName, theme] of Object.entries(themeConfig.themes)) {
203
+ const vars: Record<string, string> = {};
204
+ for (const [name, value] of Object.entries(theme.colors)) {
205
+ vars[`--${name}`] = resolvePaletteColor(value, palette);
142
206
  }
207
+ byTheme[themeName] = vars;
143
208
  }
209
+ return { byTheme, defaultTheme: themeConfig.default };
210
+ }
144
211
 
145
- return vars;
212
+ /**
213
+ * Build a per-breakpoint `--var → value` map directly from the variables
214
+ * config. Each breakpoint key (`base`, `tablet`, `mobile`, …) holds the full
215
+ * resolved variable map at that breakpoint, with per-variable `scales`
216
+ * overrides and global category scaling already applied
217
+ * (`resolveVariableValueAtBreakpoint` mirrors `generateVariablesCSS` and the
218
+ * runtime CSS resolver).
219
+ *
220
+ * The walker uses these maps to inline `var(--x)` references in element
221
+ * styles per-breakpoint, so the Webflow class system receives concrete
222
+ * scaled values at every tier instead of a single flat base value.
223
+ */
224
+ function buildProjectVarMaps(
225
+ variablesConfig: VariablesConfig,
226
+ breakpoints: BreakpointConfig,
227
+ responsiveScales: ResponsiveScales | undefined
228
+ ): Record<string, Record<string, string>> {
229
+ const out: Record<string, Record<string, string>> = { base: {} };
230
+ for (const bpName of Object.keys(breakpoints)) out[bpName] = {};
231
+
232
+ for (const variable of variablesConfig.variables) {
233
+ if (!variable.cssVar) continue;
234
+ out.base[variable.cssVar] = variable.value;
235
+ for (const bpName of Object.keys(breakpoints)) {
236
+ const resolved = resolveVariableValueAtBreakpoint(
237
+ { value: variable.value, type: variable.type, scales: variable.scales },
238
+ bpName,
239
+ responsiveScales
240
+ );
241
+ out[bpName][variable.cssVar] = resolved;
242
+ }
243
+ }
244
+ return out;
146
245
  }
147
246
 
148
247
  // ---------------------------------------------------------------------------
@@ -150,8 +249,12 @@ function extractCSSVariables(
150
249
  // ---------------------------------------------------------------------------
151
250
 
152
251
  export async function buildWebflowPayload(
153
- projectRoot?: string
252
+ options?: { bindCollectionLists?: boolean; promotedComponentNames?: string[]; locale?: string }
154
253
  ): Promise<WebflowExportPayload> {
254
+ const bindCollectionLists = options?.bindCollectionLists === true;
255
+ const promotedComponentNames = Array.isArray(options?.promotedComponentNames)
256
+ ? new Set(options!.promotedComponentNames.filter((n): n is string => typeof n === 'string' && n.length > 0))
257
+ : undefined;
155
258
  // 1. Setup: load project configuration
156
259
  configService.reset();
157
260
 
@@ -159,6 +262,17 @@ export async function buildWebflowPayload(
159
262
  const siteUrl = (projectConfig as { siteUrl?: string }).siteUrl?.replace(/\/$/, '') || '';
160
263
  const i18nConfig = await loadI18nConfig();
161
264
 
265
+ // Pick which locale to render. Multi-locale projects export one locale at a
266
+ // time so the Webflow site mirrors a single language; the extension surfaces
267
+ // a picker that defaults to `i18nConfig.defaultLocale`. An invalid/missing
268
+ // request falls back to the default locale (silent — the picker is the
269
+ // source of truth here, not the URL).
270
+ const requestedLocale = options?.locale;
271
+ const selectedLocale = (requestedLocale && i18nConfig.locales.some(l => l.code === requestedLocale))
272
+ ? requestedLocale
273
+ : i18nConfig.defaultLocale;
274
+ const localesToBuild = i18nConfig.locales.filter(l => l.code === selectedLocale);
275
+
162
276
  await migrateTemplatesDirectory();
163
277
 
164
278
  const { components } = await loadComponentDirectory(projectPaths.components());
@@ -172,9 +286,9 @@ export async function buildWebflowPayload(
172
286
  const themeConfig = await colorService.loadThemeConfig();
173
287
  const variablesConfig = await variableService.loadConfig();
174
288
  const breakpoints = await loadBreakpointConfig();
175
- const responsiveScales = await loadResponsiveScalesConfig();
176
289
 
177
290
  await configService.load();
291
+ const responsiveScales = configService.getResponsiveScales();
178
292
 
179
293
  // 2. Scan pages
180
294
  const pagesDir = projectPaths.pages();
@@ -206,6 +320,24 @@ export async function buildWebflowPayload(
206
320
  // 3. Render and convert pages
207
321
  const allPages: WebflowPage[] = [];
208
322
  const allStyleClasses = new Map<string, WebflowStyleClass>();
323
+ // elementClass → interactiveStyles, populated during the node walk and
324
+ // drained at the end into raw CSS (see step 6 below).
325
+ const allInteractiveStylesMap = new Map<string, InteractiveStyles>();
326
+ // Combo class name → owning identity, shared across every page emit so
327
+ // collisions are visible project-wide. Drives `mintInstanceComboName` in
328
+ // nodeToWebflow.ts.
329
+ const allComboIdentityByName = new Map<string, string>();
330
+ // First-encounter snapshot of each promoted component (Navigation, Footer)
331
+ // shared across every page so the extension registers each definition once.
332
+ const promotedComponents = new Map<string, WebflowComponentDef>();
333
+
334
+ // Theme + project-level CSS variables are resolved during the walk so the
335
+ // ancestor `theme` attribute can pick the right palette per element. The
336
+ // project var map is per-breakpoint so `var(--x)` refs in element styles
337
+ // can be inlined at each tier with the right scaled value.
338
+ const variablesCSS = generateVariablesCSS(variablesConfig, breakpoints, responsiveScales);
339
+ const projectVars = buildProjectVarMaps(variablesConfig, breakpoints, responsiveScales);
340
+ const { byTheme: themeVars, defaultTheme } = buildThemeVarMaps(themeConfig);
209
341
 
210
342
  // Regular pages
211
343
  for (const file of pageFiles) {
@@ -220,7 +352,7 @@ export async function buildWebflowPayload(
220
352
 
221
353
  const slugs = pageData.meta?.slugs;
222
354
 
223
- for (const localeConfig of i18nConfig.locales) {
355
+ for (const localeConfig of localesToBuild) {
224
356
  const locale = localeConfig.code;
225
357
  const isDefault = locale === i18nConfig.defaultLocale;
226
358
 
@@ -259,17 +391,33 @@ export async function buildWebflowPayload(
259
391
  fileName: pageName,
260
392
  breakpoints,
261
393
  styleClasses: allStyleClasses,
394
+ comboIdentityByName: allComboIdentityByName,
395
+ interactiveStylesMap: allInteractiveStylesMap,
396
+ cmsService,
397
+ i18nConfig,
398
+ locale,
399
+ slugMappings,
400
+ pagePath: urlPath,
401
+ themeVars,
402
+ projectVars,
403
+ defaultTheme,
404
+ responsiveScales,
405
+ promotedComponents,
406
+ promotedComponentNames,
407
+ bindCollectionLists,
262
408
  };
263
409
 
264
410
  const body = pageData.root || (pageData as any).node;
265
- const elements = body ? nodeToWebflow(body, ctx) : [];
411
+ const elements = body ? await nodeToWebflow(body, ctx) : [];
412
+ normalizeListChildren(elements);
266
413
 
414
+ const pageMeta = extractPageMeta(pageData);
267
415
  allPages.push({
268
416
  title: result.title,
269
417
  slug: slug || 'index',
270
- metaDescription: typeof pageData.meta?.description === 'string' ? pageData.meta.description : undefined,
271
418
  elements,
272
419
  locale,
420
+ ...buildPageMetaForLocale(pageMeta, locale, i18nConfig),
273
421
  });
274
422
  }
275
423
  } catch (error: any) {
@@ -277,9 +425,11 @@ export async function buildWebflowPayload(
277
425
  }
278
426
  }
279
427
 
280
- // CMS template pages
428
+ // CMS template pages — imported as a single regular Webflow page per locale,
429
+ // bound to the FIRST CMS item's content. Webflow can't natively bind a page
430
+ // to a Meno-side collection, and a separate "Sync CMS" flow in the extension
431
+ // handles collection schema/items, so this code path stays page-only.
281
432
  const templatesDir = projectPaths.templates();
282
- const cmsCollections: WebflowCMSCollection[] = [];
283
433
 
284
434
  if (existsSync(templatesDir)) {
285
435
  const templateFiles = readdirSync(templatesDir).filter(f => f.endsWith('.json'));
@@ -295,87 +445,95 @@ export async function buildWebflowPayload(
295
445
 
296
446
  const cmsSchema = pageData.meta!.cms as CMSSchema;
297
447
  const items = await cmsService.queryItems({ collection: cmsSchema.id });
298
-
299
- // Build Webflow CMS collection
300
- const fields: WebflowCMSField[] = [];
301
- if (cmsSchema.fields) {
302
- for (const [fieldName, fieldDef] of Object.entries(cmsSchema.fields)) {
303
- fields.push({
304
- name: fieldDef.label || fieldName,
305
- slug: fieldName,
306
- type: mapCMSFieldType(fieldDef.type),
307
- required: fieldDef.required,
308
- options: fieldDef.options,
309
- });
310
- }
311
- }
312
-
313
- // Resolve i18n values in items for default locale
314
- const resolvedItems: Record<string, unknown>[] = [];
315
- for (const item of items) {
316
- const resolved: Record<string, unknown> = {};
317
- for (const [key, value] of Object.entries(item)) {
318
- if (isI18nValue(value)) {
319
- resolved[key] = resolveI18nValue(value, i18nConfig.defaultLocale, i18nConfig);
320
- } else {
321
- resolved[key] = value;
322
- }
323
- }
324
- resolvedItems.push(resolved);
325
- }
326
-
327
- cmsCollections.push({
328
- name: cmsSchema.id,
329
- slug: cmsSchema.id,
330
- urlPattern: cmsSchema.urlPattern,
331
- fields,
332
- items: resolvedItems,
333
- });
334
-
335
- // Render CMS item pages
336
- for (const item of items) {
337
- for (const localeConfig of i18nConfig.locales) {
338
- const locale = localeConfig.code;
339
- if (isItemDraftForLocale(item, locale)) continue;
340
-
341
- const itemPath = buildCMSItemPath(cmsSchema.urlPattern, item, cmsSchema.slugField, locale, i18nConfig);
342
- const itemWithUrl: CMSItem = { ...item, _url: itemPath };
343
-
344
- const result = await renderPageSSR(
345
- pageData,
346
- globalComponents,
347
- itemPath,
348
- siteUrl,
349
- locale,
350
- i18nConfig,
351
- slugMappings,
352
- { cms: itemWithUrl },
353
- cmsService,
354
- true
355
- );
356
-
357
- const ctx: WebflowEmitContext = {
358
- globalComponents,
359
- elementPath: [0],
360
- fileType: 'page',
361
- fileName: file.replace('.json', ''),
362
- breakpoints,
363
- styleClasses: allStyleClasses,
364
- };
365
-
366
- const body = pageData.root || (pageData as any).node;
367
- // Pass CMS item data as props so {{cms.field}} templates resolve
368
- const cmsProps = { cms: itemWithUrl };
369
- const elements = body ? nodeToWebflow(body, ctx, cmsProps) : [];
370
-
371
- const slug = itemPath.startsWith('/') ? itemPath.substring(1) : itemPath;
372
- allPages.push({
373
- title: result.title,
374
- slug,
375
- elements,
376
- locale,
377
- });
448
+ if (items.length === 0) continue;
449
+
450
+ const item = items[0];
451
+ const pageName = file.replace('.json', '');
452
+
453
+ for (const localeConfig of localesToBuild) {
454
+ const locale = localeConfig.code;
455
+ // Use the first item's resolved URL (from cmsSchema.urlPattern) as
456
+ // the slug, so a template like `templates/blog.json` lands at
457
+ // `/blog/<first-slug>` and doesn't collide with a sibling listing
458
+ // page like `pages/blog.json`.
459
+ let itemSlug = item[cmsSchema.slugField] ?? item._slug ?? item._id;
460
+ if (isI18nValue(itemSlug)) {
461
+ itemSlug = resolveI18nValue(itemSlug, locale, i18nConfig) as string;
378
462
  }
463
+ const resolvedItemPath = cmsSchema.urlPattern.replace('{{slug}}', String(itemSlug));
464
+ const isDefault = locale === i18nConfig.defaultLocale;
465
+ const localizedItemPath = isDefault
466
+ ? resolvedItemPath
467
+ : `/${locale}${resolvedItemPath.startsWith('/') ? '' : '/'}${resolvedItemPath}`;
468
+ const slug = localizedItemPath.startsWith('/')
469
+ ? localizedItemPath.substring(1)
470
+ : localizedItemPath;
471
+ const urlPath = localizedItemPath;
472
+ // SSR (`renderPageSSR`) gets the raw item — its `processCMSTemplate`
473
+ // resolves i18n + richtext at each interpolation. The Webflow path
474
+ // re-walks the same `pageData.root` with the simpler `resolveTemplate`
475
+ // (no i18n / richtext awareness), so we hand it a pre-flattened item.
476
+ const flatItem = flattenCMSItemForLocale(item, locale, i18nConfig);
477
+ const itemWithUrl: CMSItem = { ...flatItem, _url: urlPath } as CMSItem;
478
+ const ssrItem: CMSItem = { ...item, _url: urlPath };
479
+
480
+ const result = await renderPageSSR(
481
+ pageData,
482
+ globalComponents,
483
+ urlPath,
484
+ siteUrl,
485
+ locale,
486
+ i18nConfig,
487
+ slugMappings,
488
+ { cms: ssrItem },
489
+ cmsService,
490
+ true
491
+ );
492
+
493
+ const ctx: WebflowEmitContext = {
494
+ globalComponents,
495
+ elementPath: [0],
496
+ fileType: 'page',
497
+ fileName: pageName,
498
+ breakpoints,
499
+ styleClasses: allStyleClasses,
500
+ comboIdentityByName: allComboIdentityByName,
501
+ interactiveStylesMap: allInteractiveStylesMap,
502
+ cmsService,
503
+ i18nConfig,
504
+ locale,
505
+ slugMappings,
506
+ pagePath: urlPath,
507
+ cmsContext: { cms: itemWithUrl },
508
+ // Seed `templateContext` with the bound item so descendants inside
509
+ // a component slot still see `cms`. `emitInlineComponentBody`
510
+ // merges `ctx.templateContext` into the body's `instanceProps`
511
+ // (nodeToWebflow.ts:1140-1143), and `<slot>` rendering forwards
512
+ // those merged props to slot children — without this, anything
513
+ // wrapped in (e.g.) Section loses the page-level CMS scope and
514
+ // `{{cms.title}}` resolves to empty.
515
+ templateContext: { cms: itemWithUrl } as Record<string, unknown>,
516
+ themeVars,
517
+ projectVars,
518
+ defaultTheme,
519
+ responsiveScales,
520
+ promotedComponents,
521
+ promotedComponentNames,
522
+ bindCollectionLists,
523
+ };
524
+
525
+ const body = pageData.root || (pageData as any).node;
526
+ const elements = body ? await nodeToWebflow(body, ctx, { cms: itemWithUrl }) : [];
527
+ normalizeListChildren(elements);
528
+
529
+ const cmsPageMeta = extractPageMeta(pageData);
530
+ allPages.push({
531
+ title: result.title,
532
+ slug,
533
+ elements,
534
+ locale,
535
+ ...buildPageMetaForLocale(cmsPageMeta, locale, i18nConfig),
536
+ });
379
537
  }
380
538
  } catch (error: any) {
381
539
  console.error(`Error processing template ${file}:`, error?.message);
@@ -383,22 +541,84 @@ export async function buildWebflowPayload(
383
541
  }
384
542
  }
385
543
 
386
- // 4. Collect CSS variables
387
- const themeColorCSS = generateThemeColorVariablesCSS(themeConfig);
388
- const variablesCSS = generateVariablesCSS(variablesConfig, breakpoints, responsiveScales);
389
- const cssVariables = extractCSSVariables(themeColorCSS, variablesCSS);
544
+ // Promoted components (Navigation, Footer) are emitted via the same
545
+ // `nodeToWebflow` walk as pages, so they share the same `<ul>`-with-
546
+ // non-`<li>`-children pitfall. Normalize their element trees too before
547
+ // they leave the build.
548
+ for (const def of promotedComponents.values()) {
549
+ normalizeListChildren(def.elements);
550
+ }
390
551
 
391
- // 5. Scan assets
552
+ // 4. Scan assets
553
+ const styleClasses = Array.from(allStyleClasses.values());
392
554
  const assets = scanAssets(projectPaths.project);
393
555
 
556
+ // 5. Collect component scripts and component-scoped CSS sidecars. Both get
557
+ // bundled into the manual-paste UI: scripts as a `<script>` at body end,
558
+ // CSS as part of the `<style>` block at head. CSS in particular is
559
+ // critical for components that drive visibility through hand-written
560
+ // rules (e.g. NavDropdown's `[data-nav-dropdown="container"].open ...`)
561
+ // — those don't translate to Webflow's class system at all.
562
+ const scripts: WebflowScript[] = [];
563
+ const cssBlocks: string[] = [];
564
+ for (const [name, def] of Object.entries(globalComponents)) {
565
+ const code = def.component?.javascript;
566
+ if (typeof code === 'string' && code.trim().length > 0) {
567
+ scripts.push({
568
+ componentName: name,
569
+ code,
570
+ defineVars: def.component?.defineVars,
571
+ });
572
+ }
573
+ const css = def.component?.css;
574
+ if (typeof css === 'string' && css.trim().length > 0) {
575
+ cssBlocks.push(`/* ${name}.css */\n${css.trim()}`);
576
+ }
577
+ }
578
+ const componentCss = cssBlocks.length > 0 ? cssBlocks.join('\n\n') : undefined;
579
+
580
+ // 6. Per-element interactiveStyles → raw CSS for rules Webflow's class
581
+ // system can't represent (anything with `prefix`, class-style postfix,
582
+ // or breakpoint-divided pseudos). Pseudo-only rules with empty prefix
583
+ // have already been written into `primaryClass.pseudoStates` upstream
584
+ // via `Style.setProperties({ pseudo })`; we filter those out here so
585
+ // the bundle doesn't double-emit them.
586
+ const interactiveCssBlocks: string[] = [];
587
+ for (const [elementClass, rules] of allInteractiveStylesMap) {
588
+ const customRules = rules.filter((r) => !isWebflowHandledRule(r));
589
+ if (customRules.length === 0) continue;
590
+ const css = generateInteractiveCSS(elementClass, customRules, breakpoints, undefined, responsiveScales);
591
+ if (css && css.trim().length > 0) {
592
+ interactiveCssBlocks.push(css);
593
+ }
594
+ }
595
+ const interactiveCss = interactiveCssBlocks.length > 0
596
+ ? interactiveCssBlocks.join('\n\n')
597
+ : undefined;
598
+
394
599
  return {
395
600
  version: 1,
396
601
  exportedAt: new Date().toISOString(),
397
602
  pages: allPages,
398
- styles: Array.from(allStyleClasses.values()),
399
- cms: cmsCollections,
603
+ styles: styleClasses,
604
+ cms: [],
400
605
  assets,
401
- cssVariables: Object.keys(cssVariables).length > 0 ? cssVariables : undefined,
606
+ slugMappings: slugMappings.length > 0 ? slugMappings : undefined,
607
+ scripts: scripts.length > 0 ? scripts : undefined,
608
+ components: promotedComponents.size > 0
609
+ ? Array.from(promotedComponents.values())
610
+ : undefined,
611
+ componentCss,
612
+ interactiveCss,
613
+ i18n: {
614
+ defaultLocale: i18nConfig.defaultLocale,
615
+ locales: i18nConfig.locales.map(l => ({
616
+ code: l.code,
617
+ name: l.name,
618
+ nativeName: l.nativeName,
619
+ })),
620
+ selectedLocale,
621
+ },
402
622
  };
403
623
  }
404
624
 
@@ -6,6 +6,7 @@
6
6
  export { buildWebflowPayload } from './buildWebflow';
7
7
  export { nodeToWebflow } from './nodeToWebflow';
8
8
  export { mapStylesToWebflow } from './styleMapper';
9
+ export { wrapInWebflowTemplate } from './templateWrapper';
9
10
  export type {
10
11
  WebflowExportPayload,
11
12
  WebflowPage,