meno-core 1.0.39 → 1.0.40

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 (49) hide show
  1. package/build-astro.ts +195 -68
  2. package/dist/bin/cli.js +1 -1
  3. package/dist/build-static.js +6 -6
  4. package/dist/chunks/{chunk-WK5XLASY.js → chunk-3NOZVNM4.js} +3 -3
  5. package/dist/chunks/{chunk-W6HDII4T.js → chunk-GKICS7CF.js} +27 -14
  6. package/dist/chunks/chunk-GKICS7CF.js.map +7 -0
  7. package/dist/chunks/{chunk-P3FX5HJM.js → chunk-LOJLO2EY.js} +1 -1
  8. package/dist/chunks/chunk-LOJLO2EY.js.map +7 -0
  9. package/dist/chunks/{chunk-HNAS6BSS.js → chunk-MOCRENNU.js} +55 -5
  10. package/dist/chunks/{chunk-HNAS6BSS.js.map → chunk-MOCRENNU.js.map} +3 -3
  11. package/dist/chunks/{chunk-NV25WXCA.js → chunk-OJ5SROQN.js} +5 -3
  12. package/dist/chunks/chunk-OJ5SROQN.js.map +7 -0
  13. package/dist/chunks/{chunk-AIXKUVNG.js → chunk-V4SVSX3X.js} +3 -3
  14. package/dist/chunks/{chunk-KULPBDC7.js → chunk-Z7SAOCDG.js} +5 -2
  15. package/dist/chunks/{chunk-KULPBDC7.js.map → chunk-Z7SAOCDG.js.map} +2 -2
  16. package/dist/chunks/{constants-5CRJRQNR.js → constants-L75FR445.js} +2 -2
  17. package/dist/entries/server-router.js +6 -6
  18. package/dist/lib/client/index.js +5 -5
  19. package/dist/lib/client/index.js.map +2 -2
  20. package/dist/lib/server/index.js +2007 -197
  21. package/dist/lib/server/index.js.map +4 -4
  22. package/dist/lib/shared/index.js +3 -3
  23. package/dist/lib/test-utils/index.js +1 -1
  24. package/lib/client/core/builders/embedBuilder.ts +2 -2
  25. package/lib/server/astro/cmsPageEmitter.ts +417 -0
  26. package/lib/server/astro/componentEmitter.ts +90 -5
  27. package/lib/server/astro/nodeToAstro.ts +830 -37
  28. package/lib/server/astro/pageEmitter.ts +39 -3
  29. package/lib/server/astro/tailwindMapper.ts +69 -8
  30. package/lib/server/astro/templateTransformer.ts +107 -0
  31. package/lib/server/index.ts +9 -0
  32. package/lib/server/routes/api/components.ts +62 -0
  33. package/lib/server/routes/api/core-routes.ts +8 -0
  34. package/lib/server/ssr/ssrRenderer.ts +30 -10
  35. package/lib/server/webflow/buildWebflow.ts +415 -0
  36. package/lib/server/webflow/index.ts +22 -0
  37. package/lib/server/webflow/nodeToWebflow.ts +423 -0
  38. package/lib/server/webflow/styleMapper.ts +241 -0
  39. package/lib/server/webflow/types.ts +196 -0
  40. package/lib/shared/constants.ts +2 -0
  41. package/lib/shared/types/components.ts +1 -0
  42. package/lib/shared/validation/schemas.ts +1 -0
  43. package/package.json +1 -1
  44. package/dist/chunks/chunk-NV25WXCA.js.map +0 -7
  45. package/dist/chunks/chunk-P3FX5HJM.js.map +0 -7
  46. package/dist/chunks/chunk-W6HDII4T.js.map +0 -7
  47. /package/dist/chunks/{chunk-WK5XLASY.js.map → chunk-3NOZVNM4.js.map} +0 -0
  48. /package/dist/chunks/{chunk-AIXKUVNG.js.map → chunk-V4SVSX3X.js.map} +0 -0
  49. /package/dist/chunks/{constants-5CRJRQNR.js.map → constants-L75FR445.js.map} +0 -0
@@ -3,10 +3,12 @@
3
3
  * Generates .astro page files that import and compose components
4
4
  */
5
5
 
6
- import type { JSONPage, ComponentDefinition } from '../../shared/types';
6
+ import type { JSONPage, ComponentDefinition, I18nConfig } from '../../shared/types';
7
7
  import type { BreakpointConfig } from '../../shared/breakpoints';
8
8
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
9
  import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
+ import type { ImageMetadataMap } from '../ssr/imageMetadata';
11
+ import type { SlugMap } from '../../shared/slugTranslator';
10
12
 
11
13
  // ---------------------------------------------------------------------------
12
14
  // Types
@@ -41,6 +43,14 @@ export interface PageEmitOptions {
41
43
  pageName: string;
42
44
  /** Breakpoint config for responsive Tailwind classes */
43
45
  breakpoints?: BreakpointConfig;
46
+ /** Image metadata map for responsive image generation */
47
+ imageMetadataMap?: ImageMetadataMap;
48
+ /** I18n config for locale list rendering */
49
+ i18nConfig?: I18nConfig;
50
+ /** Locale→slug map for current page (for locale list links) */
51
+ currentPageSlugMap?: Record<string, string>;
52
+ /** Slug mappings for translating internal link hrefs */
53
+ slugMappings?: SlugMap[];
44
54
  }
45
55
 
46
56
  // ---------------------------------------------------------------------------
@@ -88,6 +98,10 @@ export function emitAstroPage(options: PageEmitOptions): string {
88
98
  ssrFallbacks,
89
99
  pageName,
90
100
  breakpoints: breakpointsOpt,
101
+ imageMetadataMap,
102
+ i18nConfig,
103
+ currentPageSlugMap,
104
+ slugMappings,
91
105
  } = options;
92
106
 
93
107
  const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
@@ -110,6 +124,14 @@ export function emitAstroPage(options: PageEmitOptions): string {
110
124
  fileType: 'page',
111
125
  fileName: pageName,
112
126
  breakpoints,
127
+ imageMetadataMap,
128
+ locale,
129
+ i18nConfig,
130
+ currentPageSlugMap,
131
+ frontmatterLines: [],
132
+ astroImports: new Set<string>(),
133
+ slugMappings,
134
+ i18nDefaultLocale: i18nConfig?.defaultLocale,
113
135
  };
114
136
 
115
137
  // Emit the template body
@@ -117,6 +139,13 @@ export function emitAstroPage(options: PageEmitOptions): string {
117
139
 
118
140
  // Build frontmatter with imports
119
141
  const importLines: string[] = [];
142
+
143
+ // Add Astro API imports (e.g., getCollection) if collected during emission
144
+ if (ctx.astroImports && ctx.astroImports.size > 0) {
145
+ const astroImports = Array.from(ctx.astroImports);
146
+ importLines.push(`import { ${astroImports.join(', ')} } from 'astro:content';`);
147
+ }
148
+
120
149
  importLines.push(`import BaseLayout from '${layoutImportPath}';`);
121
150
 
122
151
  // Sort component imports alphabetically
@@ -137,8 +166,13 @@ export function emitAstroPage(options: PageEmitOptions): string {
137
166
  const escapedMeta = escapeTemplateLiteral(meta);
138
167
  const escapedFontPreloads = escapeTemplateLiteral(fontPreloads);
139
168
 
169
+ // Collect frontmatter lines from list emission (e.g., getCollection calls)
170
+ const extraFrontmatter = ctx.frontmatterLines && ctx.frontmatterLines.length > 0
171
+ ? '\n' + ctx.frontmatterLines.join('\n')
172
+ : '';
173
+
140
174
  return `---
141
- ${importLines.join('\n')}
175
+ ${importLines.join('\n')}${extraFrontmatter}
142
176
  ---
143
177
  <BaseLayout
144
178
  title="${escapeJSX(title)}"
@@ -149,7 +183,9 @@ ${importLines.join('\n')}
149
183
  fontPreloads={\`${escapedFontPreloads}\`}
150
184
  libraryTags={${libraryTagsLiteral}}
151
185
  >
152
- ${templateBody}</BaseLayout>
186
+ <div id="root">
187
+ ${templateBody} </div>
188
+ </BaseLayout>
153
189
  `;
154
190
  }
155
191
 
@@ -263,6 +263,10 @@ const singleValueMatches: Record<string, string> = {
263
263
  'right:0': 'right-0',
264
264
  'bottom:0': 'bottom-0',
265
265
  'left:0': 'left-0',
266
+ 'outline:none': '[outline:none]',
267
+ 'background:none': '[background:none]',
268
+ 'background:transparent': '[background:transparent]',
269
+ 'backgroundColor:transparent': 'bg-transparent',
266
270
  };
267
271
 
268
272
  // ---------------------------------------------------------------------------
@@ -331,7 +335,7 @@ const arbitraryPrefixMap: Record<string, string> = {
331
335
  backdropFilter: 'backdrop',
332
336
  transform: '[transform]',
333
337
  transformOrigin: 'origin',
334
- transition: 'transition',
338
+ transition: '[transition]',
335
339
  mixBlendMode: 'mix-blend',
336
340
  clipPath: '[clip-path]',
337
341
 
@@ -408,16 +412,44 @@ export function propertyToTailwind(
408
412
  }
409
413
 
410
414
  // Color variable handling:
411
- // "var(--text)" → "text-[var(--text)]"
412
- // Bare color name like "text" → "text-[var(--text)]"
415
+ // "var(--text)" → "text-[color:var(--text)]" (type hint avoids ambiguity with font-size)
416
+ // Bare color name like "text" → "text-[color:var(--text)]"
413
417
  if (property === 'color' || property === 'backgroundColor' || property === 'borderColor') {
414
418
  const prefix = property === 'color' ? 'text' : property === 'backgroundColor' ? 'bg' : 'border';
415
419
  if (strValue.includes('var(')) {
416
- return `${prefix}-[${strValue}]`;
420
+ return `${prefix}-[color:${strValue}]`;
417
421
  }
418
422
  // Bare name (not a hex, rgb, or number-starting value)
419
423
  if (!strValue.match(/^[#\d]/) && !strValue.includes('rgb') && !strValue.includes('hsl')) {
420
- return `${prefix}-[var(--${strValue})]`;
424
+ return `${prefix}-[color:var(--${strValue})]`;
425
+ }
426
+ }
427
+
428
+ // Color properties with actual color values need type hints to avoid ambiguity
429
+ if (property === 'borderColor' || property === 'color') {
430
+ const prefix = property === 'color' ? 'text' : 'border';
431
+ const sanitized = strValue.replace(/\s+/g, '_');
432
+ return `${prefix}-[color:${sanitized}]`;
433
+ }
434
+
435
+ // Border shorthand (e.g., "1px solid", "2px solid var(--border)") needs arbitrary property syntax
436
+ // because Tailwind's border-[...] only accepts width values, not compound shorthands
437
+ if (property === 'border' || property === 'borderTop' || property === 'borderRight' ||
438
+ property === 'borderBottom' || property === 'borderLeft') {
439
+ if (strValue.includes('solid') || strValue.includes('dashed') || strValue.includes('dotted') || strValue.includes('none')) {
440
+ const cssProp = property.replace(/([A-Z])/g, '-$1').toLowerCase();
441
+ const sanitized = strValue.replace(/\s+/g, '_');
442
+ return `[${cssProp}:${sanitized}]`;
443
+ }
444
+ }
445
+
446
+ // `background` is a CSS shorthand — Tailwind's bg-[...] maps to background-color, not background.
447
+ // For non-color values (none, transparent, gradients, etc.), use arbitrary property syntax.
448
+ if (property === 'background') {
449
+ const isSimpleColor = /^(#[0-9a-fA-F]{3,8}|rgb|hsl|var\()/.test(strValue);
450
+ if (!isSimpleColor) {
451
+ const sanitized = strValue.replace(/\s+/g, '_');
452
+ return `[background:${sanitized}]`;
421
453
  }
422
454
  }
423
455
 
@@ -439,9 +471,16 @@ export function propertyToTailwind(
439
471
  // Standard arbitrary value: prefix-[value]
440
472
  const sanitized = strValue.replace(/\s+/g, '_');
441
473
 
442
- // Font family names need quotes in arbitrary syntax for Tailwind to parse correctly
443
- if (property === 'fontFamily' && !sanitized.includes(',') && !sanitized.startsWith("'")) {
444
- return `${twPrefix}-['${sanitized}']`;
474
+ // Disambiguate properties that share a Tailwind prefix with another CSS property.
475
+ // Without type hints, Tailwind guesses wrong (e.g., text-[var(--x)] color instead of font-size).
476
+ if (property === 'fontSize') {
477
+ return `text-[length:${sanitized}]`;
478
+ }
479
+ if (property === 'fontFamily') {
480
+ return `font-[family-name:${sanitized}]`;
481
+ }
482
+ if (property === 'fontWeight') {
483
+ return `font-[number:${sanitized}]`;
445
484
  }
446
485
 
447
486
  return `${twPrefix}-[${sanitized}]`;
@@ -459,6 +498,16 @@ export function stylesToTailwind(
459
498
  const classes: string[] = [];
460
499
  const dynamicStyles: Record<string, string> = {};
461
500
 
501
+ // When borderColor is present alongside a border shorthand, Tailwind's CSS
502
+ // ordering can cause the shorthand's arbitrary property `[border:...]` to
503
+ // override the longhand `border-[color:...]`. To avoid this, decompose
504
+ // border shorthands into width + style only (dropping the color part) so
505
+ // borderColor can take effect without conflicts.
506
+ const hasBorderColor = 'borderColor' in style && !isStyleMapping(style.borderColor);
507
+ const borderShorthands = new Set([
508
+ 'border', 'borderTop', 'borderRight', 'borderBottom', 'borderLeft',
509
+ ]);
510
+
462
511
  for (const [prop, value] of Object.entries(style)) {
463
512
  if (isStyleMapping(value)) continue;
464
513
 
@@ -471,6 +520,18 @@ export function stylesToTailwind(
471
520
  continue;
472
521
  }
473
522
 
523
+ // Decompose border shorthands when borderColor is separately specified
524
+ if (hasBorderColor && borderShorthands.has(prop)) {
525
+ const parts = strValue.split(/\s+/);
526
+ // Parse width and style from shorthand (e.g. "1px solid #ccc" → "1px", "solid")
527
+ const width = parts.find(p => /^\d/.test(p) || p === '0');
528
+ const borderStyle = parts.find(p => /^(solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)$/.test(p));
529
+ const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
530
+ if (width) classes.push(`[${cssProp}-width:${width}]`);
531
+ if (borderStyle) classes.push(`[${cssProp}-style:${borderStyle}]`);
532
+ continue;
533
+ }
534
+
474
535
  const twClass = propertyToTailwind(prop, value);
475
536
  if (twClass) {
476
537
  classes.push(twClass);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * CMS Template Expression Transformer for Astro Export
3
+ * Converts {{cms.field}} and {{item.field}} template expressions to Astro expressions
4
+ */
5
+
6
+ // CMS template pattern (from cmsSSRProcessor.ts)
7
+ const CMS_TEMPLATE_PATTERN = /\{\{cms\.([^}]+)\}\}/g;
8
+ // Item template pattern for list iteration
9
+ const ITEM_TEMPLATE_PATTERN = /\{\{([^}]+)\}\}/g;
10
+
11
+ export function isTemplateExpression(text: string): boolean {
12
+ return /\{\{.+?\}\}/.test(text);
13
+ }
14
+
15
+ export function transformCMSTemplate(
16
+ text: string,
17
+ binding: string = 'entry',
18
+ richTextFields?: Set<string>,
19
+ wrapFn?: string
20
+ ): string {
21
+ const w = (expr: string) => wrapFn ? `${wrapFn}(${expr})` : expr;
22
+
23
+ // Check if entire text is a single {{cms.field}} expression
24
+ const fullMatch = text.match(/^\{\{cms\.([^}]+)\}\}$/);
25
+ if (fullMatch) {
26
+ const fieldPath = fullMatch[1].trim();
27
+ const topLevelField = fieldPath.split('.')[0];
28
+
29
+ // Rich-text fields use Fragment set:html
30
+ if (richTextFields?.has(topLevelField)) {
31
+ return `<Fragment set:html={${w(`${binding}.data.${fieldPath}`)}} />`;
32
+ }
33
+
34
+ return `{${w(`${binding}.data.${fieldPath}`)}}`;
35
+ }
36
+
37
+ // Mixed content: "Hello {{cms.name}}" → {`Hello ${entry.data.name}`}
38
+ if (CMS_TEMPLATE_PATTERN.test(text)) {
39
+ CMS_TEMPLATE_PATTERN.lastIndex = 0;
40
+ const replaced = text.replace(CMS_TEMPLATE_PATTERN, (_, fieldPath) => {
41
+ return `\${${w(`${binding}.data.${fieldPath.trim()}`)}}`;
42
+ });
43
+ return `{\`${replaced}\`}`;
44
+ }
45
+
46
+ return text;
47
+ }
48
+
49
+ /**
50
+ * Replace legacy itemIndex/itemFirst/itemLast meta-variables with
51
+ * the actual .map() callback index parameter name.
52
+ */
53
+ /**
54
+ * Rewrite the default `item.` prefix to the actual .map() parameter name
55
+ * when itemAs differs from 'item' (e.g., itemAs='link' → item.text → link.text).
56
+ */
57
+ export function rewriteItemVar(expr: string, itemVar: string): string {
58
+ if (itemVar === 'item') return expr;
59
+ return expr.replace(/\bitem\./g, `${itemVar}.`);
60
+ }
61
+
62
+ export function replaceItemMetaVars(expr: string, indexVar: string, sourceVar?: string, itemVar?: string): string {
63
+ const lastExpr = sourceVar
64
+ ? `(${indexVar} === ${sourceVar}.length - 1)`
65
+ : `false /* itemLast not supported */`;
66
+ let result = expr
67
+ .replace(/\bitemIndex\b/g, indexVar)
68
+ .replace(/\bitemFirst\b/g, `(${indexVar} === 0)`)
69
+ .replace(/\bitemLast\b/g, lastExpr);
70
+ if (itemVar) result = rewriteItemVar(result, itemVar);
71
+ return result;
72
+ }
73
+
74
+ export function transformItemTemplate(
75
+ text: string,
76
+ itemVar: string = 'item',
77
+ indexVar?: string,
78
+ sourceVar?: string
79
+ ): string {
80
+ // Check if entire text is a single {{expression}}
81
+ const fullMatch = text.match(/^\{\{(.+)\}\}$/);
82
+ if (fullMatch) {
83
+ let expr = fullMatch[1].trim();
84
+ expr = rewriteItemVar(expr, itemVar);
85
+ if (indexVar) expr = replaceItemMetaVars(expr, indexVar, sourceVar);
86
+ // If it contains a dot, it's item.field
87
+ if (expr.startsWith(`${itemVar}.`)) {
88
+ return `{${expr}}`;
89
+ }
90
+ // Bare expression like {{features}} - keep as prop reference
91
+ return `{${expr}}`;
92
+ }
93
+
94
+ // Mixed content: replace each {{expr}}
95
+ if (ITEM_TEMPLATE_PATTERN.test(text)) {
96
+ ITEM_TEMPLATE_PATTERN.lastIndex = 0;
97
+ const replaced = text.replace(ITEM_TEMPLATE_PATTERN, (_, expr) => {
98
+ let trimmed = expr.trim();
99
+ trimmed = rewriteItemVar(trimmed, itemVar);
100
+ if (indexVar) trimmed = replaceItemMetaVars(trimmed, indexVar, sourceVar);
101
+ return `\${${trimmed}}`;
102
+ });
103
+ return `{\`${replaced}\`}`;
104
+ }
105
+
106
+ return text;
107
+ }
@@ -65,6 +65,15 @@ export { buildStaticPages } from '../../build-static';
65
65
  // Astro export
66
66
  export { buildAstroProject } from '../../build-astro';
67
67
 
68
+ // Webflow export
69
+ export { buildWebflowPayload } from './webflow';
70
+ export type {
71
+ WebflowExportPayload,
72
+ WebflowPage,
73
+ WebflowElement,
74
+ WebflowStyleClass,
75
+ } from './webflow';
76
+
68
77
  // Utilities
69
78
  export * from './utils';
70
79
 
@@ -4,6 +4,8 @@
4
4
  */
5
5
 
6
6
  import type { ComponentService } from '../../services/componentService';
7
+ import type { PageService } from '../../services/pageService';
8
+ import type { ComponentNode } from '../../../shared/types';
7
9
  import { createCorsHeaders } from '../../middleware/cors';
8
10
  import { jsonResponse } from './shared';
9
11
  import { formatJsonErrorMessage } from '../../../shared/jsonRepair';
@@ -96,6 +98,66 @@ export async function handleComponentJavaScriptRoute(
96
98
  });
97
99
  }
98
100
 
101
+ /**
102
+ * Handle component usage API endpoint - GET /api/component-usage
103
+ * Returns how many times each component is used across all pages and other components
104
+ */
105
+ export function handleComponentUsageRoute(
106
+ pageService: PageService,
107
+ componentService: ComponentService
108
+ ): Response {
109
+ const usageMap = new Map<string, { count: number; files: Map<string, { name: string; path: string; type: 'page' | 'component' }> }>();
110
+
111
+ function walkTree(node: ComponentNode | undefined | null, fileName: string, filePath: string, fileType: 'page' | 'component') {
112
+ if (!node || typeof node !== 'object') return;
113
+ if (node.type === 'component' && 'component' in node && (node as any).component) {
114
+ const compName = (node as any).component as string;
115
+ if (!usageMap.has(compName)) {
116
+ usageMap.set(compName, { count: 0, files: new Map() });
117
+ }
118
+ const entry = usageMap.get(compName)!;
119
+ entry.count++;
120
+ entry.files.set(filePath, { name: fileName, path: filePath, type: fileType });
121
+ }
122
+ if ('children' in node && Array.isArray((node as any).children)) {
123
+ for (const child of (node as any).children) {
124
+ if (child && typeof child === 'object') {
125
+ walkTree(child as ComponentNode, fileName, filePath, fileType);
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ // Walk all pages
132
+ const pagePaths = pageService.getAllPagePaths();
133
+ for (const path of pagePaths) {
134
+ const pageData = pageService.getPageData(path);
135
+ if (pageData && 'root' in pageData && pageData.root) {
136
+ const name = path === '/' ? 'index' : path.replace(/^\//, '').replace(/\//g, '-');
137
+ walkTree(pageData.root, name, path, 'page');
138
+ }
139
+ }
140
+
141
+ // Walk all component structures
142
+ const allComponents = componentService.getAllComponents();
143
+ for (const [compName, compDef] of Object.entries(allComponents)) {
144
+ if (compDef.component?.structure) {
145
+ walkTree(compDef.component.structure, compName, compName, 'component');
146
+ }
147
+ }
148
+
149
+ // Convert to plain object
150
+ const result: Record<string, { count: number; files: Array<{ name: string; path: string; type: 'page' | 'component' }> }> = {};
151
+ for (const [name, data] of usageMap) {
152
+ result[name] = {
153
+ count: data.count,
154
+ files: Array.from(data.files.values()),
155
+ };
156
+ }
157
+
158
+ return jsonResponse(result);
159
+ }
160
+
99
161
  // Note: Write handlers (handleSaveComponentRoute, handleSaveComponentJavaScriptRoute,
100
162
  // handleSaveComponentCSSRoute, handleComponentCategoryRoute) moved to @meno/studio
101
163
 
@@ -201,6 +201,14 @@ export async function handleCoreApiRoutes(
201
201
  );
202
202
  }
203
203
 
204
+ // Component usage API route (read-only)
205
+ if (url.pathname === API_ROUTES.COMPONENT_USAGE && req.method === 'GET') {
206
+ return await handleRouteError(
207
+ () => Promise.resolve(componentsRoutes.handleComponentUsageRoute(pageService, componentService)),
208
+ 'Failed to fetch component usage'
209
+ );
210
+ }
211
+
204
212
  // Slug mappings API route (read-only)
205
213
  if (url.pathname === '/api/slug-mappings' && req.method === 'GET') {
206
214
  return await handleRouteError(
@@ -116,6 +116,8 @@ interface SSRContext {
116
116
  neededCollections?: Set<string>;
117
117
  /** When true, draft items are filtered out from CMS lists */
118
118
  isProductionBuild?: boolean;
119
+ /** Collector for SSR fallback HTML of complex nodes (list, locale-list) keyed by element path */
120
+ ssrFallbackCollector?: Map<string, string>;
119
121
  }
120
122
 
121
123
  /**
@@ -364,15 +366,17 @@ export async function buildComponentHTML(
364
366
  cmsContext?: CMSContext,
365
367
  cmsService?: CMSService,
366
368
  isProductionBuild?: boolean
367
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string> }> {
369
+ ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
368
370
  // Create map to collect interactive styles during render
369
371
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
370
372
  // Create array to collect high-priority images for preloading
371
373
  const preloadImages: PreloadImage[] = [];
372
374
  // Create set to track collections that need client-side data injection
373
375
  const neededCollections = new Set<string>();
376
+ // Create map to collect SSR fallback HTML for complex nodes (list, locale-list)
377
+ const ssrFallbackCollector = new Map<string, string>();
374
378
 
375
- if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections };
379
+ if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
376
380
 
377
381
  // Register components for this render
378
382
  ssrComponentRegistry.merge(globalComponents);
@@ -403,11 +407,12 @@ export async function buildComponentHTML(
403
407
  preloadImages, // Collect high-priority images for preloading
404
408
  neededCollections, // Track collections that need client-side data
405
409
  isProductionBuild,
410
+ ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
406
411
  };
407
412
 
408
413
  const html = await renderNode(node, ctx);
409
414
 
410
- return { html, interactiveStylesMap, preloadImages, neededCollections };
415
+ return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
411
416
  }
412
417
 
413
418
  /**
@@ -581,7 +586,14 @@ async function processList(node: ListNode, ctx: SSRContext): Promise<string> {
581
586
  }
582
587
 
583
588
  // List is a pure repeater - no container element, just children + optional template
584
- return childrenHTML + templateHtml;
589
+ const listResult = childrenHTML + templateHtml;
590
+
591
+ // Store SSR fallback for Astro export (list nodes can't be expressed as static Astro components)
592
+ if (ctx.ssrFallbackCollector && ctx.elementPath) {
593
+ ctx.ssrFallbackCollector.set(ctx.elementPath.join('.'), listResult);
594
+ }
595
+
596
+ return listResult;
585
597
  }
586
598
 
587
599
  /**
@@ -875,8 +887,8 @@ async function renderNode(
875
887
  // Sanitize HTML with allowlist for SVG, rich-text formatting, and common elements (same as client)
876
888
  const purify = getDOMPurify();
877
889
  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'],
890
+ 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'],
891
+ 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
892
  KEEP_CONTENT: true
881
893
  }) : htmlContent;
882
894
  const optimizedHtml = ctx.imageMetadataMap
@@ -1639,7 +1651,14 @@ function renderLocaleList(node: import('../../shared/types').LocaleListNode, ctx
1639
1651
  const nodeAttributes = extractAttributesFromNode(node as any);
1640
1652
  const attrsStr = buildAttributes(nodeAttributes);
1641
1653
 
1642
- return `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1654
+ const localeListResult = `<div data-locale-list="true"${containerClassAttr}${localeListStyleAttr}${attrsStr}>${linksHTML}</div>`;
1655
+
1656
+ // Store SSR fallback for Astro export (locale-list nodes can't be expressed as static Astro components)
1657
+ if (ctx.ssrFallbackCollector && ctx.elementPath) {
1658
+ ctx.ssrFallbackCollector.set(ctx.elementPath.join('.'), localeListResult);
1659
+ }
1660
+
1661
+ return localeListResult;
1643
1662
  }
1644
1663
  // If context is missing, return empty div
1645
1664
  return '<div data-locale-list="true"></div>';
@@ -1659,7 +1678,7 @@ export async function renderPageSSR(
1659
1678
  cmsContext?: CMSContext,
1660
1679
  cmsService?: CMSService,
1661
1680
  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> }> {
1681
+ ): 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
1682
  // Extract page content
1664
1683
  const rootNode = pageData?.root || undefined;
1665
1684
  if (!rootNode) {
@@ -1695,9 +1714,9 @@ export async function renderPageSSR(
1695
1714
 
1696
1715
  // Render the component tree to HTML with i18n and CMS support
1697
1716
  // Also collect interactive styles, preload images, and needed collections during render
1698
- const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections } = rootNode
1717
+ const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector } = rootNode
1699
1718
  ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1700
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>() };
1719
+ : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>() };
1701
1720
 
1702
1721
  // Collect JavaScript and CSS from all components
1703
1722
  const javascript = await collectComponentJavaScript(globalComponents, pageComponents);
@@ -1728,5 +1747,6 @@ export async function renderPageSSR(
1728
1747
  interactiveStylesMap,
1729
1748
  preloadImages,
1730
1749
  neededCollections,
1750
+ ssrFallbackCollector,
1731
1751
  };
1732
1752
  }