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.
Files changed (67) hide show
  1. package/bin/cli.ts +33 -0
  2. package/build-astro.ts +172 -69
  3. package/dist/bin/cli.js +30 -2
  4. package/dist/bin/cli.js.map +2 -2
  5. package/dist/build-static.js +7 -7
  6. package/dist/chunks/{chunk-WK5XLASY.js → chunk-EQOSDQS2.js} +4 -4
  7. package/dist/chunks/{chunk-AIXKUVNG.js → chunk-IBR2F4IL.js} +4 -5
  8. package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-IBR2F4IL.js.map} +2 -2
  9. package/dist/chunks/{chunk-NV25WXCA.js → chunk-IGVQF5GY.js} +11 -7
  10. package/dist/chunks/chunk-IGVQF5GY.js.map +7 -0
  11. package/dist/chunks/{chunk-KULPBDC7.js → chunk-LBWIHPN7.js} +9 -3
  12. package/dist/chunks/chunk-LBWIHPN7.js.map +7 -0
  13. package/dist/chunks/{chunk-A6KWUEA6.js → chunk-MKB2J6AD.js} +9 -1
  14. package/dist/chunks/chunk-MKB2J6AD.js.map +7 -0
  15. package/dist/chunks/{chunk-P3FX5HJM.js → chunk-S2HXJTAF.js} +1 -1
  16. package/dist/chunks/chunk-S2HXJTAF.js.map +7 -0
  17. package/dist/chunks/{chunk-W6HDII4T.js → chunk-SK3TLNUP.js} +140 -114
  18. package/dist/chunks/chunk-SK3TLNUP.js.map +7 -0
  19. package/dist/chunks/{chunk-HNAS6BSS.js → chunk-SNUROC7E.js} +56 -6
  20. package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-SNUROC7E.js.map} +3 -3
  21. package/dist/chunks/{configService-TXBNUBBL.js → configService-MICL4S2L.js} +2 -2
  22. package/dist/chunks/{constants-5CRJRQNR.js → constants-ZEU4TZCA.js} +2 -2
  23. package/dist/entries/server-router.js +7 -7
  24. package/dist/lib/client/index.js +11 -6
  25. package/dist/lib/client/index.js.map +2 -2
  26. package/dist/lib/server/index.js +507 -1587
  27. package/dist/lib/server/index.js.map +4 -4
  28. package/dist/lib/shared/index.js +3 -3
  29. package/dist/lib/test-utils/index.js +1 -1
  30. package/lib/client/core/ComponentBuilder.ts +1 -1
  31. package/lib/client/core/builders/embedBuilder.ts +2 -2
  32. package/lib/client/routing/Router.tsx +6 -0
  33. package/lib/client/templateEngine.test.ts +178 -0
  34. package/lib/client/templateEngine.ts +1 -2
  35. package/lib/server/astro/cmsPageEmitter.ts +420 -0
  36. package/lib/server/astro/componentEmitter.ts +150 -17
  37. package/lib/server/astro/nodeToAstro.test.ts +1101 -0
  38. package/lib/server/astro/nodeToAstro.ts +869 -37
  39. package/lib/server/astro/pageEmitter.ts +43 -3
  40. package/lib/server/astro/tailwindMapper.ts +69 -8
  41. package/lib/server/astro/templateTransformer.ts +107 -0
  42. package/lib/server/index.ts +26 -3
  43. package/lib/server/routes/api/components.ts +62 -0
  44. package/lib/server/routes/api/core-routes.ts +8 -0
  45. package/lib/server/services/configService.ts +12 -0
  46. package/lib/server/ssr/htmlGenerator.ts +0 -5
  47. package/lib/server/ssr/imageMetadata.ts +3 -3
  48. package/lib/server/ssr/ssrRenderer.ts +78 -29
  49. package/lib/server/webflow/buildWebflow.ts +415 -0
  50. package/lib/server/webflow/index.ts +22 -0
  51. package/lib/server/webflow/nodeToWebflow.ts +423 -0
  52. package/lib/server/webflow/styleMapper.ts +241 -0
  53. package/lib/server/webflow/types.ts +196 -0
  54. package/lib/shared/constants.ts +4 -0
  55. package/lib/shared/types/components.ts +9 -4
  56. package/lib/shared/validation/propValidator.ts +2 -1
  57. package/lib/shared/validation/schemas.ts +4 -1
  58. package/package.json +1 -1
  59. package/templates/index-router.html +0 -5
  60. package/dist/chunks/chunk-A6KWUEA6.js.map +0 -7
  61. package/dist/chunks/chunk-KULPBDC7.js.map +0 -7
  62. package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
  63. package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
  64. package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
  65. /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-EQOSDQS2.js.map} +0 -0
  66. /package/dist/chunks/{configService-TXBNUBBL.js.map → configService-MICL4S2L.js.map} +0 -0
  67. /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-ZEU4TZCA.js.map} +0 -0
@@ -12,6 +12,7 @@ import type { BreakpointConfig } from '../../shared/breakpoints';
12
12
  import { evaluateTemplate, processStructure, isResponsiveStyle, isHtmlMapping, resolveHtmlMapping } from '../../client/templateEngine';
13
13
  import { resolvePropsFromDefinition, isRichTextMarker, richTextMarkerToHtml } from '../../shared/propResolver';
14
14
  import { loadBreakpointConfig, loadI18nConfig } from '../jsonLoader';
15
+ import { configService } from '../services/configService';
15
16
  import type { I18nConfig } from '../../shared/types/components';
16
17
  import { extractLocaleFromPath, DEFAULT_I18N_CONFIG, resolveI18nValue, buildLocalizedPath, isI18nValue } from '../../shared/i18n';
17
18
  import { NODE_TYPE } from '../../shared/constants';
@@ -116,6 +117,10 @@ interface SSRContext {
116
117
  neededCollections?: Set<string>;
117
118
  /** When true, draft items are filtered out from CMS lists */
118
119
  isProductionBuild?: boolean;
120
+ /** Collector for SSR fallback HTML of complex nodes (list, locale-list) keyed by element path */
121
+ ssrFallbackCollector?: Map<string, string>;
122
+ /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
123
+ imageFormat?: 'webp' | 'avif';
119
124
  }
120
125
 
121
126
  /**
@@ -364,15 +369,17 @@ export async function buildComponentHTML(
364
369
  cmsContext?: CMSContext,
365
370
  cmsService?: CMSService,
366
371
  isProductionBuild?: boolean
367
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string> }> {
372
+ ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
368
373
  // Create map to collect interactive styles during render
369
374
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
370
375
  // Create array to collect high-priority images for preloading
371
376
  const preloadImages: PreloadImage[] = [];
372
377
  // Create set to track collections that need client-side data injection
373
378
  const neededCollections = new Set<string>();
379
+ // Create map to collect SSR fallback HTML for complex nodes (list, locale-list)
380
+ const ssrFallbackCollector = new Map<string, string>();
374
381
 
375
- if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections };
382
+ if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
376
383
 
377
384
  // Register components for this render
378
385
  ssrComponentRegistry.merge(globalComponents);
@@ -403,11 +410,13 @@ export async function buildComponentHTML(
403
410
  preloadImages, // Collect high-priority images for preloading
404
411
  neededCollections, // Track collections that need client-side data
405
412
  isProductionBuild,
413
+ ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
414
+ imageFormat: configService.getImageFormat(),
406
415
  };
407
416
 
408
417
  const html = await renderNode(node, ctx);
409
418
 
410
- return { html, interactiveStylesMap, preloadImages, neededCollections };
419
+ return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
411
420
  }
412
421
 
413
422
  /**
@@ -581,7 +590,14 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
581
590
  }
582
591
 
583
592
  // List is a pure repeater - no container element, just children + optional template
584
- return childrenHTML + templateHtml;
593
+ const listResult = childrenHTML + templateHtml;
594
+
595
+ // Store SSR fallback for Astro export (list nodes can't be expressed as static Astro components)
596
+ if (ctx.ssrFallbackCollector && ctx.elementPath) {
597
+ ctx.ssrFallbackCollector.set(ctx.elementPath.join('.'), listResult);
598
+ }
599
+
600
+ return listResult;
585
601
  }
586
602
 
587
603
  /**
@@ -788,7 +804,7 @@ async function renderNode(
788
804
  // Check for raw HTML marker (from rich-text fields) - don't escape
789
805
  if (text.startsWith(RAW_HTML_PREFIX)) {
790
806
  let rawHtml = text.slice(RAW_HTML_PREFIX.length);
791
- if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap);
807
+ if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
792
808
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
793
809
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
794
810
  return rawHtml;
@@ -808,7 +824,7 @@ async function renderNode(
808
824
  // Check for raw HTML marker (from rich-text fields) - don't escape
809
825
  if (text.startsWith(RAW_HTML_PREFIX)) {
810
826
  let rawHtml = text.slice(RAW_HTML_PREFIX.length);
811
- if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap);
827
+ if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
812
828
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
813
829
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
814
830
  return rawHtml;
@@ -875,12 +891,12 @@ async function renderNode(
875
891
  // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements (same as client)
876
892
  const purify = getDOMPurify();
877
893
  const sanitizedHtml = purify ? purify.sanitize(htmlContent, {
878
- ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
879
- ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title'],
894
+ ALLOWED_TAGS: ['svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g', 'text', 'tspan', 'image', 'defs', 'use', 'linearGradient', 'radialGradient', 'stop', 'clipPath', 'mask', 'pattern', 'marker', 'symbol', 'a', 'div', 'span', 'p', 'br', 'button', 'img', 'iframe', 'video', 'audio', 'source', 'canvas', 'b', 'i', 'u', 'strong', 'em', 'sub', 'sup', 'mark', 's', 'small', 'del', 'ins', 'q', 'abbr', 'code', 'pre', 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'style', 'animate', 'animateTransform', 'animateMotion', 'set'],
895
+ ALLOWED_ATTR: ['class', 'id', 'style', 'width', 'height', 'viewBox', 'xmlns', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'd', 'cx', 'cy', 'r', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'points', 'href', 'src', 'alt', 'target', 'rel', 'data-*', 'aria-*', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity', 'font-size', 'font-family', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity', 'frameborder', 'allowfullscreen', 'allow', 'title', 'attributeName', 'values', 'dur', 'begin', 'end', 'repeatCount', 'repeatDur', 'keyTimes', 'keySplines', 'calcMode', 'from', 'to', 'by', 'additive', 'accumulate', 'type', 'rotate', 'keyPoints', 'path'],
880
896
  KEEP_CONTENT: true
881
897
  }) : htmlContent;
882
898
  const optimizedHtml = ctx.imageMetadataMap
883
- ? rewriteRichTextImages(sanitizedHtml, ctx.imageMetadataMap)
899
+ ? rewriteRichTextImages(sanitizedHtml, ctx.imageMetadataMap, ctx.imageFormat)
884
900
  : sanitizedHtml;
885
901
 
886
902
  // Extract attributes from node
@@ -1215,27 +1231,52 @@ async function renderComponent(
1215
1231
  // Merge instance style overrides, className, and attributes
1216
1232
  // processedStructure is typically an HTML node (the component's root element)
1217
1233
  // Handle both component nodes and HTML nodes
1218
- const rootNode = processedStructure as ComponentNode & { props?: Record<string, unknown> };
1234
+ const rootNode = processedStructure as ComponentNode & { props?: Record<string, unknown>; style?: any; attributes?: Record<string, string | number | boolean> };
1219
1235
  if (isComponentNode(rootNode) || isHtmlNode(rootNode)) {
1220
1236
  if (!rootNode.props) {
1221
1237
  rootNode.props = {};
1222
1238
  }
1223
1239
 
1224
- if (rootNode.props.style && typeof rootNode.props.style === 'object') {
1225
- rootNode.props.style = {
1226
- ...(rootNode.props.style as Record<string, unknown>),
1227
- ...(propsWithStyleAndAttrs.style as Record<string, unknown> || {})
1228
- };
1229
- } else if (propsWithStyleAndAttrs.style) {
1230
- rootNode.props.style = propsWithStyleAndAttrs.style;
1240
+ // For HTML root nodes, merge styles into node.style (where the renderer reads them)
1241
+ if (isHtmlNode(rootNode)) {
1242
+ if (propsWithStyleAndAttrs.style) {
1243
+ const existingStyle = rootNode.style;
1244
+ if (existingStyle && typeof existingStyle === 'object') {
1245
+ rootNode.style = {
1246
+ ...(existingStyle as Record<string, any>),
1247
+ ...(propsWithStyleAndAttrs.style as Record<string, any>)
1248
+ };
1249
+ } else {
1250
+ rootNode.style = propsWithStyleAndAttrs.style as any;
1251
+ }
1252
+ }
1253
+ } else {
1254
+ // For component root nodes, merge into props.style
1255
+ if (rootNode.props.style && typeof rootNode.props.style === 'object') {
1256
+ rootNode.props.style = {
1257
+ ...(rootNode.props.style as Record<string, unknown>),
1258
+ ...(propsWithStyleAndAttrs.style as Record<string, unknown> || {})
1259
+ };
1260
+ } else if (propsWithStyleAndAttrs.style) {
1261
+ rootNode.props.style = propsWithStyleAndAttrs.style;
1262
+ }
1231
1263
  }
1232
1264
 
1233
1265
  // Merge className from component instance (includes responsive utility classes)
1234
1266
  if (propsWithStyleAndAttrs.className) {
1235
- const existingClassName = rootNode.props.className || '';
1236
- rootNode.props.className = existingClassName
1237
- ? `${existingClassName} ${propsWithStyleAndAttrs.className}`
1238
- : propsWithStyleAndAttrs.className;
1267
+ if (isHtmlNode(rootNode)) {
1268
+ // For HTML root nodes, merge into attributes where the renderer reads them
1269
+ if (!rootNode.attributes) rootNode.attributes = {};
1270
+ const existingClass = (rootNode.attributes.class || '') as string;
1271
+ rootNode.attributes.class = existingClass
1272
+ ? `${existingClass} ${propsWithStyleAndAttrs.className}`
1273
+ : propsWithStyleAndAttrs.className as string;
1274
+ } else {
1275
+ const existingClassName = rootNode.props.className || '';
1276
+ rootNode.props.className = existingClassName
1277
+ ? `${existingClassName} ${propsWithStyleAndAttrs.className}`
1278
+ : propsWithStyleAndAttrs.className;
1279
+ }
1239
1280
  }
1240
1281
 
1241
1282
  // Merge attributes into props
@@ -1428,8 +1469,8 @@ function renderImageElement(
1428
1469
 
1429
1470
  // Collect high-priority images for preloading in head
1430
1471
  if (fetchpriority === 'high' && metadata && ctx.preloadImages) {
1431
- // Prefer AVIF, fallback to WebP
1432
- if (metadata.avifSrcset) {
1472
+ // Prefer AVIF when format allows, fallback to WebP
1473
+ if (metadata.avifSrcset && ctx.imageFormat !== 'webp') {
1433
1474
  ctx.preloadImages.push({
1434
1475
  srcset: metadata.avifSrcset,
1435
1476
  type: 'image/avif',
@@ -1460,9 +1501,9 @@ function renderImageElement(
1460
1501
  blurStyle = ` style="background-image: url(${escapeHtml(metadata.blurHash)}); background-size: cover;" onload="this.style.backgroundImage=''"`;
1461
1502
  }
1462
1503
 
1463
- // Render as <picture> element if AVIF is available
1504
+ // Render as <picture> element if AVIF is available and imageFormat is 'avif'
1464
1505
  // Layout classes go on <picture> (block container), image-specific classes on <img>
1465
- if (metadata?.avifSrcset) {
1506
+ if (metadata?.avifSrcset && ctx.imageFormat !== 'webp') {
1466
1507
  // Image-specific class prefixes that should stay on <img>
1467
1508
  const imgClassPrefixes = [
1468
1509
  'objf-', 'objp-', 'flt-', 'tm-', 'bs-',
@@ -1639,7 +1680,14 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1639
1680
  const nodeAttributes = extractAttributesFromNode(node as any);
1640
1681
  const attrsStr = buildAttributes(nodeAttributes);
1641
1682
 
1642
- return `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1683
+ const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1684
+
1685
+ // Store SSR fallback for Astro export (locale-list nodes can't be expressed as static Astro components)
1686
+ if (ctx.ssrFallbackCollector && ctx.elementPath) {
1687
+ ctx.ssrFallbackCollector.set(ctx.elementPath.join('.'), localeListResult);
1688
+ }
1689
+
1690
+ return localeListResult;
1643
1691
  }
1644
1692
  // If context is missing, return empty div
1645
1693
  return '<div data-locale-list="true"></div>';
@@ -1659,7 +1707,7 @@ export async function renderPageSSR(
1659
1707
  cmsContext?: CMSContext,
1660
1708
  cmsService?: CMSService,
1661
1709
  isProductionBuild?: boolean
1662
- ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string> }> {
1710
+ ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
1663
1711
  // Extract page content
1664
1712
  const rootNode = pageData?.root || undefined;
1665
1713
  if (!rootNode) {
@@ -1695,9 +1743,9 @@ export async function renderPageSSR(
1695
1743
 
1696
1744
  // Render the component tree to HTML with i18n and CMS support
1697
1745
  // Also collect interactive styles, preload images, and needed collections during render
1698
- const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections } = rootNode
1746
+ const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector } = rootNode
1699
1747
  ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1700
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>() };
1748
+ : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>() };
1701
1749
 
1702
1750
  // Collect JavaScript and CSS from all components
1703
1751
  const javascript = await collectComponentJavaScript(globalComponents, pageComponents);
@@ -1728,5 +1776,6 @@ export async function renderPageSSR(
1728
1776
  interactiveStylesMap,
1729
1777
  preloadImages,
1730
1778
  neededCollections,
1779
+ ssrFallbackCollector,
1731
1780
  };
1732
1781
  }
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Webflow Export Build Orchestrator
3
+ * Loads the project, renders all pages, and converts to Webflow payload.
4
+ * Mirrors the pattern of build-astro.ts.
5
+ */
6
+
7
+ import { existsSync, readdirSync } from 'fs';
8
+ import { readFile } from 'fs/promises';
9
+ import { join } from 'path';
10
+ import {
11
+ loadJSONFile,
12
+ loadComponentDirectory,
13
+ mapPageNameToPath,
14
+ parseJSON,
15
+ loadI18nConfig,
16
+ loadBreakpointConfig,
17
+ loadResponsiveScalesConfig,
18
+ } from '../jsonLoader';
19
+ import { renderPageSSR } from '../ssr/ssrRenderer';
20
+ import { projectPaths } from '../projectContext';
21
+ import { loadProjectConfig } from '../../shared/fontLoader';
22
+ import { FileSystemCMSProvider } from '../providers/fileSystemCMSProvider';
23
+ import { CMSService } from '../services/cmsService';
24
+ import { isI18nValue, resolveI18nValue } from '../../shared/i18n';
25
+ import { configService } from '../services/configService';
26
+ import { colorService } from '../services/ColorService';
27
+ import { variableService } from '../services/VariableService';
28
+ import { migrateTemplatesDirectory } from '../migrateTemplates';
29
+ import type {
30
+ ComponentDefinition,
31
+ JSONPage,
32
+ CMSSchema,
33
+ CMSItem,
34
+ I18nConfig,
35
+ } from '../../shared/types';
36
+ import { isItemDraftForLocale } from '../../shared/types';
37
+ import type { SlugMap } from '../../shared/slugTranslator';
38
+ import type {
39
+ WebflowExportPayload,
40
+ WebflowPage,
41
+ WebflowStyleClass,
42
+ WebflowCMSCollection,
43
+ WebflowCMSField,
44
+ WebflowAssetRef,
45
+ } from './types';
46
+ import { mapCMSFieldType } from './types';
47
+ import { nodeToWebflow, type WebflowEmitContext } from './nodeToWebflow';
48
+ import { generateThemeColorVariablesCSS, generateVariablesCSS } from '../cssGenerator';
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Helpers
52
+ // ---------------------------------------------------------------------------
53
+
54
+ function scanJSONFiles(dir: string, prefix: string = ''): string[] {
55
+ const results: string[] = [];
56
+ if (!existsSync(dir)) return results;
57
+ const entries = readdirSync(dir, { withFileTypes: true });
58
+ for (const entry of entries) {
59
+ if (entry.isFile() && entry.name.endsWith('.json')) {
60
+ results.push(prefix ? `${prefix}/${entry.name}` : entry.name);
61
+ } else if (entry.isDirectory()) {
62
+ results.push(...scanJSONFiles(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name));
63
+ }
64
+ }
65
+ return results;
66
+ }
67
+
68
+ function isCMSPage(pageData: JSONPage): boolean {
69
+ return pageData.meta?.source === 'cms' && !!pageData.meta?.cms;
70
+ }
71
+
72
+ function buildCMSItemPath(
73
+ urlPattern: string,
74
+ item: CMSItem,
75
+ slugField: string,
76
+ locale: string,
77
+ 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;
82
+ }
83
+ return urlPattern.replace('{{slug}}', String(slug));
84
+ }
85
+
86
+ function scanAssets(projectRoot: string): WebflowAssetRef[] {
87
+ const assets: WebflowAssetRef[] = [];
88
+ const assetDirs: Array<{ dir: string; type: WebflowAssetRef['type'] }> = [
89
+ { dir: 'images', type: 'image' },
90
+ { dir: 'fonts', type: 'font' },
91
+ { dir: 'videos', type: 'video' },
92
+ { dir: 'assets', type: 'file' },
93
+ ];
94
+
95
+ for (const { dir, type } of assetDirs) {
96
+ const fullDir = join(projectRoot, dir);
97
+ if (!existsSync(fullDir)) continue;
98
+ const files = scanJSONFiles(fullDir).map(f => f.replace('.json', '')); // scanJSONFiles works for any extension
99
+ // Re-scan properly for all file types
100
+ const allFiles = scanAllFiles(fullDir);
101
+ for (const file of allFiles) {
102
+ assets.push({
103
+ localPath: `${dir}/${file}`,
104
+ type,
105
+ fileName: file.split('/').pop()!,
106
+ });
107
+ }
108
+ }
109
+
110
+ return assets;
111
+ }
112
+
113
+ function scanAllFiles(dir: string, prefix: string = ''): string[] {
114
+ const results: string[] = [];
115
+ if (!existsSync(dir)) return results;
116
+ const entries = readdirSync(dir, { withFileTypes: true });
117
+ for (const entry of entries) {
118
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
119
+ if (entry.isFile()) {
120
+ results.push(relativePath);
121
+ } else if (entry.isDirectory()) {
122
+ results.push(...scanAllFiles(join(dir, entry.name), relativePath));
123
+ }
124
+ }
125
+ return results;
126
+ }
127
+
128
+ /**
129
+ * Extract CSS variables from theme and variables config
130
+ */
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();
142
+ }
143
+ }
144
+
145
+ return vars;
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // Main export
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export async function buildWebflowPayload(
153
+ projectRoot?: string
154
+ ): Promise<WebflowExportPayload> {
155
+ // 1. Setup: load project configuration
156
+ configService.reset();
157
+
158
+ const projectConfig = await loadProjectConfig();
159
+ const siteUrl = (projectConfig as { siteUrl?: string }).siteUrl?.replace(/\/$/, '') || '';
160
+ const i18nConfig = await loadI18nConfig();
161
+
162
+ await migrateTemplatesDirectory();
163
+
164
+ const { components } = await loadComponentDirectory(projectPaths.components());
165
+ const globalComponents: Record<string, ComponentDefinition> = {};
166
+ components.forEach((value, key) => { globalComponents[key] = value; });
167
+
168
+ const cmsProvider = new FileSystemCMSProvider(projectPaths.templates(), projectPaths.cms());
169
+ const cmsService = new CMSService(cmsProvider);
170
+ await cmsService.initialize();
171
+
172
+ const themeConfig = await colorService.loadThemeConfig();
173
+ const variablesConfig = await variableService.loadConfig();
174
+ const breakpoints = await loadBreakpointConfig();
175
+ const responsiveScales = await loadResponsiveScalesConfig();
176
+
177
+ await configService.load();
178
+
179
+ // 2. Scan pages
180
+ const pagesDir = projectPaths.pages();
181
+ if (!existsSync(pagesDir)) {
182
+ return emptyPayload();
183
+ }
184
+
185
+ const pageFiles = scanJSONFiles(pagesDir);
186
+ if (pageFiles.length === 0) {
187
+ return emptyPayload();
188
+ }
189
+
190
+ // Collect slug mappings
191
+ const slugMappings: SlugMap[] = [];
192
+ for (const file of pageFiles) {
193
+ const pageName = file.replace('.json', '');
194
+ const basePath = mapPageNameToPath(pageName);
195
+ const pageContent = await loadJSONFile(join(pagesDir, file));
196
+ if (!pageContent) continue;
197
+ try {
198
+ const pageData = parseJSON<JSONPage>(pageContent);
199
+ if (pageData.meta?.slugs) {
200
+ const pageId = basePath === '/' ? 'index' : basePath.substring(1);
201
+ slugMappings.push({ pageId, slugs: pageData.meta.slugs });
202
+ }
203
+ } catch { /* ignore */ }
204
+ }
205
+
206
+ // 3. Render and convert pages
207
+ const allPages: WebflowPage[] = [];
208
+ const allStyleClasses = new Map<string, WebflowStyleClass>();
209
+
210
+ // Regular pages
211
+ for (const file of pageFiles) {
212
+ const pageName = file.replace('.json', '');
213
+ const basePath = mapPageNameToPath(pageName);
214
+ const pageContent = await loadJSONFile(join(pagesDir, file));
215
+ if (!pageContent) continue;
216
+
217
+ try {
218
+ const pageData = parseJSON<JSONPage>(pageContent);
219
+ if (pageData.meta?.draft === true) continue;
220
+
221
+ const slugs = pageData.meta?.slugs;
222
+
223
+ for (const localeConfig of i18nConfig.locales) {
224
+ const locale = localeConfig.code;
225
+ const isDefault = locale === i18nConfig.defaultLocale;
226
+
227
+ let slug: string;
228
+ if (slugs && slugs[locale]) {
229
+ slug = slugs[locale];
230
+ } else if (basePath === '/') {
231
+ slug = '';
232
+ } else {
233
+ slug = basePath.substring(1);
234
+ }
235
+
236
+ const urlPath = isDefault
237
+ ? (slug === '' ? '/' : `/${slug}`)
238
+ : (slug === '' ? `/${locale}` : `/${locale}/${slug}`);
239
+
240
+ // Render via SSR to resolve all props
241
+ const result = await renderPageSSR(
242
+ pageData,
243
+ globalComponents,
244
+ urlPath,
245
+ siteUrl,
246
+ locale,
247
+ i18nConfig,
248
+ slugMappings,
249
+ undefined,
250
+ cmsService,
251
+ true
252
+ );
253
+
254
+ // Convert node tree to Webflow elements
255
+ const ctx: WebflowEmitContext = {
256
+ globalComponents,
257
+ elementPath: [0],
258
+ fileType: 'page',
259
+ fileName: pageName,
260
+ breakpoints,
261
+ styleClasses: allStyleClasses,
262
+ };
263
+
264
+ const body = pageData.root || (pageData as any).node;
265
+ const elements = body ? nodeToWebflow(body, ctx) : [];
266
+
267
+ allPages.push({
268
+ title: result.title,
269
+ slug: slug || 'index',
270
+ metaDescription: typeof pageData.meta?.description === 'string' ? pageData.meta.description : undefined,
271
+ elements,
272
+ locale,
273
+ });
274
+ }
275
+ } catch (error: any) {
276
+ console.error(`Error processing ${basePath}:`, error?.message);
277
+ }
278
+ }
279
+
280
+ // CMS template pages
281
+ const templatesDir = projectPaths.templates();
282
+ const cmsCollections: WebflowCMSCollection[] = [];
283
+
284
+ if (existsSync(templatesDir)) {
285
+ const templateFiles = readdirSync(templatesDir).filter(f => f.endsWith('.json'));
286
+
287
+ for (const file of templateFiles) {
288
+ const templateContent = await loadJSONFile(join(templatesDir, file));
289
+ if (!templateContent) continue;
290
+
291
+ try {
292
+ const pageData = parseJSON<JSONPage>(templateContent);
293
+ if (pageData.meta?.draft === true) continue;
294
+ if (!isCMSPage(pageData)) continue;
295
+
296
+ const cmsSchema = pageData.meta!.cms as CMSSchema;
297
+ 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
+ });
378
+ }
379
+ }
380
+ } catch (error: any) {
381
+ console.error(`Error processing template ${file}:`, error?.message);
382
+ }
383
+ }
384
+ }
385
+
386
+ // 4. Collect CSS variables
387
+ const themeColorCSS = generateThemeColorVariablesCSS(themeConfig);
388
+ const variablesCSS = generateVariablesCSS(variablesConfig, breakpoints, responsiveScales);
389
+ const cssVariables = extractCSSVariables(themeColorCSS, variablesCSS);
390
+
391
+ // 5. Scan assets
392
+ const assets = scanAssets(projectPaths.project);
393
+
394
+ return {
395
+ version: 1,
396
+ exportedAt: new Date().toISOString(),
397
+ pages: allPages,
398
+ styles: Array.from(allStyleClasses.values()),
399
+ cms: cmsCollections,
400
+ assets,
401
+ cssVariables: Object.keys(cssVariables).length > 0 ? cssVariables : undefined,
402
+ };
403
+ }
404
+
405
+ function emptyPayload(): WebflowExportPayload {
406
+ return {
407
+ version: 1,
408
+ exportedAt: new Date().toISOString(),
409
+ pages: [],
410
+ styles: [],
411
+ cms: [],
412
+ assets: [],
413
+ };
414
+ }
415
+
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Webflow Export Module
3
+ * Re-exports the main build function and types.
4
+ */
5
+
6
+ export { buildWebflowPayload } from './buildWebflow';
7
+ export { nodeToWebflow } from './nodeToWebflow';
8
+ export { mapStylesToWebflow } from './styleMapper';
9
+ export type {
10
+ WebflowExportPayload,
11
+ WebflowPage,
12
+ WebflowElement,
13
+ WebflowStyleClass,
14
+ WebflowCMSCollection,
15
+ WebflowCMSField,
16
+ WebflowAssetRef,
17
+ WebflowBreakpoint,
18
+ WebflowPseudoState,
19
+ WebflowFieldType,
20
+ CSSProperties,
21
+ } from './types';
22
+ export { mapCMSFieldType } from './types';