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
@@ -14,8 +14,11 @@ import type {
14
14
  EmbedNode,
15
15
  LinkNode,
16
16
  LocaleListNode,
17
+ I18nConfig,
18
+ CMSSchema,
19
+ ListNode,
17
20
  } from '../../shared/types';
18
- import type { ListNode } from '../../shared/registry/nodeTypes/ListNodeType';
21
+ import { singularize } from '../../shared/types';
19
22
  import type {
20
23
  StyleObject,
21
24
  ResponsiveStyleObject,
@@ -28,8 +31,13 @@ import { responsiveStylesToTailwind, propertyToTailwind } from './tailwindMapper
28
31
  import type { BreakpointConfig } from '../../shared/breakpoints';
29
32
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
30
33
  import { isVoidElement } from '../../shared/nodeUtils';
31
- import { NODE_TYPE } from '../../shared/constants';
34
+ import { NODE_TYPE, RAW_HTML_PREFIX } from '../../shared/constants';
32
35
  import { extractInteractiveStyleMappings, hasInteractiveStyleMappings } from '../../shared/interactiveStyleMappings';
36
+ import type { ImageMetadataMap } from '../ssr/imageMetadata';
37
+ import { isI18nValue, resolveI18nValue, DEFAULT_I18N_CONFIG, buildLocalizedPath } from '../../shared/i18n';
38
+ import { transformCMSTemplate, isTemplateExpression, transformItemTemplate, replaceItemMetaVars, rewriteItemVar } from './templateTransformer';
39
+ import type { SlugMap } from '../../shared/slugTranslator';
40
+ import { buildSlugIndex, translatePath } from '../../shared/slugTranslator';
33
41
 
34
42
  // ---------------------------------------------------------------------------
35
43
  // Types
@@ -58,6 +66,44 @@ export interface AstroEmitContext {
58
66
  breakpoints: BreakpointConfig;
59
67
  /** Dynamic tag definitions collected during traversal (for frontmatter) */
60
68
  dynamicTags?: Map<string, string>;
69
+ /** Image metadata map for responsive image generation */
70
+ imageMetadataMap?: ImageMetadataMap;
71
+ /** Page locale for resolving i18n values */
72
+ locale?: string;
73
+ /** CMS mode: transform {{cms.field}} to entry data expressions */
74
+ cmsMode?: boolean;
75
+ /** Variable name bound to the CMS entry (default: 'entry') */
76
+ cmsEntryBinding?: string;
77
+ /** Rich-text field names for CMS mode (use Fragment set:html) */
78
+ cmsRichTextFields?: Set<string>;
79
+ /** Current list item iteration variable name */
80
+ listItemBinding?: string;
81
+ /** Extra frontmatter code lines collected during emission */
82
+ frontmatterLines?: string[];
83
+ /** Astro API imports needed (e.g., 'getCollection') */
84
+ astroImports?: Set<string>;
85
+ /** Full i18n config for locale list emission */
86
+ i18nConfig?: I18nConfig;
87
+ /** locale→slug for current page (for locale list links) */
88
+ currentPageSlugMap?: Record<string, string>;
89
+ /** CMS schema for rich-text field detection */
90
+ cmsSchema?: CMSSchema;
91
+ /** Wrap function name for i18n resolution (e.g., 'r') */
92
+ cmsWrapFn?: string;
93
+ /** Default locale for i18n resolver (used in component defs) */
94
+ defaultLocale?: string;
95
+ /** Set to true during emission when an i18n object is encountered in a component def */
96
+ needsI18nResolver?: boolean;
97
+ /** Index variable name for current list .map() callback (e.g., "itemIndex") */
98
+ listIndexVar?: string;
99
+ /** Source array expression for current list (e.g., "items"), used for itemLast */
100
+ listSourceVar?: string;
101
+ /** Slug mappings for translating internal links */
102
+ slugMappings?: SlugMap[];
103
+ /** I18n default locale (for slug translation) */
104
+ i18nDefaultLocale?: string;
105
+ /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
106
+ imageFormat?: 'webp' | 'avif';
61
107
  }
62
108
 
63
109
  // ---------------------------------------------------------------------------
@@ -68,6 +114,19 @@ function ind(ctx: AstroEmitContext): string {
68
114
  return ' '.repeat(ctx.indent);
69
115
  }
70
116
 
117
+ function localizeHref(href: string, ctx: AstroEmitContext): string {
118
+ if (!href.startsWith('/') || href.startsWith('//')) return href;
119
+ const { locale, i18nDefaultLocale, slugMappings } = ctx;
120
+ if (!locale || !i18nDefaultLocale) return href;
121
+ if (slugMappings && slugMappings.length > 0) {
122
+ const slugIndex = buildSlugIndex(slugMappings);
123
+ return translatePath(href, locale, i18nDefaultLocale, i18nDefaultLocale, slugIndex);
124
+ } else if (locale !== i18nDefaultLocale) {
125
+ return buildLocalizedPath(href, locale);
126
+ }
127
+ return href;
128
+ }
129
+
71
130
  function isStyleMapping(value: unknown): value is StyleMapping {
72
131
  return (
73
132
  typeof value === 'object' &&
@@ -86,6 +145,41 @@ function isLinkMapping(value: unknown): value is LinkMapping {
86
145
  );
87
146
  }
88
147
 
148
+ /**
149
+ * Emit a single attribute key=value, applying CMS/list template transformation if needed.
150
+ */
151
+ function emitAttrValue(key: string, value: string, ctx: AstroEmitContext): string {
152
+ if (ctx.cmsMode && /\{\{cms\./.test(value)) {
153
+ const b = ctx.cmsEntryBinding || 'entry';
154
+ const w = (expr: string) => ctx.cmsWrapFn ? `${ctx.cmsWrapFn}(${expr})` : expr;
155
+ const fullMatch = value.match(/^\{\{cms\.([^}]+)\}\}$/);
156
+ if (fullMatch) {
157
+ return `${key}={${w(`${b}.data.${fullMatch[1].trim()}`)}}`;
158
+ }
159
+ const replaced = value.replace(/\{\{cms\.([^}]+)\}\}/g, (_, fp) =>
160
+ `\${${w(`${b}.data.${fp.trim()}`)}}`
161
+ );
162
+ return `${key}={\`${replaced}\`}`;
163
+ }
164
+ if (ctx.listItemBinding && /\{\{/.test(value)) {
165
+ const fullMatch = value.match(/^\{\{(.+)\}\}$/);
166
+ if (fullMatch) {
167
+ let expr = fullMatch[1].trim();
168
+ expr = rewriteItemVar(expr, ctx.listItemBinding);
169
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
170
+ return `${key}={${expr}}`;
171
+ }
172
+ const replaced = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
173
+ let trimmed = expr.trim();
174
+ trimmed = rewriteItemVar(trimmed, ctx.listItemBinding!);
175
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
176
+ return `\${${trimmed}}`;
177
+ });
178
+ return `${key}={\`${replaced}\`}`;
179
+ }
180
+ return `${key}="${escapeJSX(value)}"`;
181
+ }
182
+
89
183
  function isHtmlMapping(value: unknown): value is HtmlMapping {
90
184
  return (
91
185
  typeof value === 'object' &&
@@ -167,14 +261,15 @@ function mappingToClassListEntries(
167
261
  const cls1 = getClassForValue(property, css1, breakpointPrefix);
168
262
  const cls2 = getClassForValue(property, css2, breakpointPrefix);
169
263
  if (cls1 && cls2) {
170
- entries.push(`${propRef} === ${JSON.stringify(coerceValue(val1))} ? '${cls1}' : '${cls2}'`);
264
+ // Use String() coercion so number props match string mapping keys (e.g., size=2 matches "2")
265
+ entries.push(`String(${propRef}) === ${JSON.stringify(String(coerceValue(val1)))} ? '${cls1}' : '${cls2}'`);
171
266
  }
172
267
  } else {
173
268
  // Multiple values: use a lookup object or multiple ternaries
174
269
  for (const [val, cssValue] of values) {
175
270
  const cls = getClassForValue(property, cssValue, breakpointPrefix);
176
271
  if (cls) {
177
- entries.push(`${propRef} === ${JSON.stringify(coerceValue(val))} && '${cls}'`);
272
+ entries.push(`String(${propRef}) === ${JSON.stringify(String(coerceValue(val)))} && '${cls}'`);
178
273
  }
179
274
  }
180
275
  }
@@ -247,13 +342,23 @@ function buildClassAndStyleExpression(
247
342
  const styleParts: string[] = [];
248
343
  for (const [cssProp, value] of Object.entries(dynamicStyles)) {
249
344
  // Convert {{propName}} to Astro expression in style
250
- const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
345
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
346
+ let trimmed = expr.trim();
347
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
348
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
349
+ return `\${${trimmed}}`;
350
+ });
251
351
  styleParts.push(`${cssProp}: \${${resolved.includes('${') ? resolved.replace(/\$\{(.+?)\}/g, '$1') : `'${resolved}'`}}`);
252
352
  }
253
353
  // Build as template literal style attribute
254
354
  const entries: string[] = [];
255
355
  for (const [cssProp, value] of Object.entries(dynamicStyles)) {
256
- const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
356
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
357
+ let trimmed = expr.trim();
358
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
359
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
360
+ return `\${${trimmed}}`;
361
+ });
257
362
  entries.push(`${cssProp}: ${resolved}`);
258
363
  }
259
364
  styleAttr = ` style={\`${entries.join('; ')}\`}`;
@@ -330,10 +435,22 @@ function resolveTemplate(text: string, ctx: AstroEmitContext): string {
330
435
  // Check if entire text is a single {{expression}}
331
436
  const fullMatch = text.match(/^\{\{(.+)\}\}$/);
332
437
  if (fullMatch) {
333
- return `{${fullMatch[1].trim()}}`;
438
+ let propName = fullMatch[1].trim();
439
+ if (ctx.listItemBinding) propName = rewriteItemVar(propName, ctx.listItemBinding);
440
+ if (ctx.listIndexVar) propName = replaceItemMetaVars(propName, ctx.listIndexVar, ctx.listSourceVar);
441
+ // Rich-text props contain HTML - render unescaped via set:html
442
+ if (ctx.componentProps[propName]?.type === 'rich-text') {
443
+ return `<Fragment set:html={${propName}} />`;
444
+ }
445
+ return `{${propName}}`;
334
446
  }
335
447
  // Mixed content: replace each {{expr}} with {expr}
336
- return text.replace(/\{\{(.+?)\}\}/g, (_, expr) => `{${expr.trim()}}`);
448
+ return text.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
449
+ let trimmed = expr.trim();
450
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
451
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
452
+ return `{${trimmed}}`;
453
+ });
337
454
  }
338
455
 
339
456
  /**
@@ -376,12 +493,56 @@ function buildAttributesString(
376
493
  // Check if entire value is a single {{expression}}
377
494
  const fullMatch = strVal.match(/^\{\{(.+)\}\}$/);
378
495
  if (fullMatch) {
379
- parts.push(`${key}={${fullMatch[1].trim()}}`);
496
+ let expr = fullMatch[1].trim();
497
+ if (ctx.listItemBinding) expr = rewriteItemVar(expr, ctx.listItemBinding);
498
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
499
+ const propDef = ctx.componentProps[expr];
500
+ if (propDef && propDef.type === 'link') {
501
+ parts.push(`${key}={${expr}?.href ?? "#"}`);
502
+ } else {
503
+ parts.push(`${key}={${expr} || undefined}`);
504
+ }
380
505
  } else {
381
506
  // Mixed content: use template literal
382
- const resolved = strVal.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
507
+ const resolved = strVal.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
508
+ let trimmed = expr.trim();
509
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
510
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
511
+ const pd = ctx.componentProps[trimmed];
512
+ return pd?.type === 'link' ? `\${${trimmed}?.href ?? "#"}` : `\${${trimmed}}`;
513
+ });
383
514
  parts.push(`${key}={\`${resolved}\`}`);
384
515
  }
516
+ } else if (ctx.listItemBinding && hasTemplates(strVal)) {
517
+ // List item binding: transform {{item.field}} in attributes
518
+ const fullMatch = strVal.match(/^\{\{(.+)\}\}$/);
519
+ if (fullMatch) {
520
+ let expr = fullMatch[1].trim();
521
+ expr = rewriteItemVar(expr, ctx.listItemBinding);
522
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
523
+ parts.push(`${key}={${expr} || undefined}`);
524
+ } else {
525
+ const resolved = strVal.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
526
+ let trimmed = expr.trim();
527
+ trimmed = rewriteItemVar(trimmed, ctx.listItemBinding!);
528
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
529
+ return `\${${trimmed}}`;
530
+ });
531
+ parts.push(`${key}={\`${resolved}\`}`);
532
+ }
533
+ } else if (ctx.cmsMode && /\{\{cms\./.test(strVal)) {
534
+ // CMS mode: transform {{cms.field}} in attributes
535
+ const b = ctx.cmsEntryBinding || 'entry';
536
+ const w = (expr: string) => ctx.cmsWrapFn ? `${ctx.cmsWrapFn}(${expr})` : expr;
537
+ const fullMatch = strVal.match(/^\{\{cms\.([^}]+)\}\}$/);
538
+ if (fullMatch) {
539
+ parts.push(`${key}={${w(`${b}.data.${fullMatch[1].trim()}`)}}`);
540
+ } else {
541
+ const replaced = strVal.replace(/\{\{cms\.([^}]+)\}\}/g, (_, fieldPath) => {
542
+ return `\${${w(`${b}.data.${fieldPath.trim()}`)}}`;
543
+ });
544
+ parts.push(`${key}={\`${replaced}\`}`);
545
+ }
385
546
  } else {
386
547
  parts.push(`${key}="${escapeJSX(strVal)}"`);
387
548
  }
@@ -406,6 +567,17 @@ function formatPropValue(value: unknown): string {
406
567
  // Main recursive converter
407
568
  // ---------------------------------------------------------------------------
408
569
 
570
+ /**
571
+ * Resolve an i18n value to a locale string using the context locale.
572
+ * Returns the original value unchanged if it's not an i18n object or no locale is set.
573
+ */
574
+ function resolveI18n(value: unknown, ctx: AstroEmitContext): unknown {
575
+ if (ctx.locale && isI18nValue(value)) {
576
+ return resolveI18nValue(value, ctx.locale, DEFAULT_I18N_CONFIG);
577
+ }
578
+ return value;
579
+ }
580
+
409
581
  /**
410
582
  * Convert a ComponentNode tree to Astro template markup
411
583
  */
@@ -415,24 +587,54 @@ export function nodeToAstro(
415
587
  ): string {
416
588
  if (node === null || node === undefined) return '';
417
589
 
590
+ // Resolve i18n objects to locale strings before further processing
591
+ if (typeof node === 'object' && !Array.isArray(node) && isI18nValue(node)) {
592
+ const resolved = resolveI18n(node, ctx);
593
+ if (typeof resolved === 'string') {
594
+ return `${ind(ctx)}${escapeJSX(resolved)}\n`;
595
+ }
596
+ // In component def without locale: wrap with r() resolver
597
+ if (ctx.isComponentDef && isI18nValue(resolved)) {
598
+ ctx.needsI18nResolver = true;
599
+ return `${ind(ctx)}{r(${JSON.stringify(resolved)})}\n`;
600
+ }
601
+ // If resolution returned a non-string (e.g. array), stringify it
602
+ return `${ind(ctx)}${String(resolved ?? '')}\n`;
603
+ }
604
+
418
605
  // Text/number
419
606
  if (typeof node === 'string') {
607
+ // CMS mode: transform {{cms.field}} expressions to entry data access
608
+ if (ctx.cmsMode && isTemplateExpression(node) && /\{\{cms\./.test(node)) {
609
+ const transformed = transformCMSTemplate(node, ctx.cmsEntryBinding || 'entry', ctx.cmsRichTextFields, ctx.cmsWrapFn);
610
+ return `${ind(ctx)}${transformed}\n`;
611
+ }
612
+ // List item binding: transform {{item.field}} expressions
613
+ if (ctx.listItemBinding && isTemplateExpression(node)) {
614
+ const transformed = transformItemTemplate(node, ctx.listItemBinding, ctx.listIndexVar, ctx.listSourceVar);
615
+ return `${ind(ctx)}${transformed}\n`;
616
+ }
420
617
  if (hasTemplates(node) && ctx.isComponentDef) {
421
618
  return `${ind(ctx)}${resolveTemplate(node, ctx)}\n`;
422
619
  }
620
+ // Raw HTML marker (from rich-text fields) - render unescaped via set:html
621
+ if (node.startsWith(RAW_HTML_PREFIX)) {
622
+ const rawHtml = node.slice(RAW_HTML_PREFIX.length);
623
+ return `${ind(ctx)}<Fragment set:html={\`${escapeTemplateLiteral(rawHtml)}\`} />\n`;
624
+ }
423
625
  return `${ind(ctx)}${escapeJSX(node)}\n`;
424
626
  }
425
627
  if (typeof node === 'number') {
426
628
  return `${ind(ctx)}${node}\n`;
427
629
  }
428
630
 
429
- // Array of nodes
631
+ // Array of nodes – replace the last path segment (matches SSR renderer's top-level array convention)
430
632
  if (Array.isArray(node)) {
431
633
  let result = '';
432
634
  for (let i = 0; i < node.length; i++) {
433
635
  const child = node[i];
434
636
  const savedPath = [...ctx.elementPath];
435
- ctx.elementPath = [...ctx.elementPath, i];
637
+ ctx.elementPath = [...ctx.elementPath.slice(0, -1), i];
436
638
  result += nodeToAstro(child, ctx);
437
639
  ctx.elementPath = savedPath;
438
640
  }
@@ -452,10 +654,12 @@ export function nodeToAstro(
452
654
  case NODE_TYPE.LINK:
453
655
  return emitLinkNode(node as LinkNode, ctx);
454
656
  case NODE_TYPE.LOCALE_LIST:
455
- return emitFallback(ctx);
657
+ return emitLocaleListNode(node as LocaleListNode, ctx);
456
658
  case NODE_TYPE.LIST:
457
659
  case 'cms-list' as any:
458
- return emitFallback(ctx);
660
+ return emitListNode(node as ListNode, ctx);
661
+ case 'image' as any:
662
+ return emitImageTypeNode(node, ctx);
459
663
  default:
460
664
  return emitFallback(ctx);
461
665
  }
@@ -465,8 +669,167 @@ export function nodeToAstro(
465
669
  // Node type emitters
466
670
  // ---------------------------------------------------------------------------
467
671
 
672
+ /**
673
+ * Tailwind class prefixes that belong on the <img> element (not the <picture> wrapper).
674
+ * These correspond to image-specific visual properties.
675
+ */
676
+ const IMG_TAILWIND_PREFIXES = ['object-', 'rounded', 'border', 'shadow', '[filter', '[transform', 'mix-blend'];
677
+ const IMG_OPACITY_PATTERN = /^opacity-/;
678
+
679
+ /**
680
+ * Default sizes attribute for responsive images
681
+ */
682
+ const DEFAULT_SIZES = '100vw';
683
+
684
+ /**
685
+ * Split Tailwind classes into picture (layout) and img (visual) groups.
686
+ * Layout classes go on <picture>, image-specific classes go on <img>.
687
+ */
688
+ function splitImageClasses(allClasses: string[]): { pictureClasses: string[]; imgClasses: string[] } {
689
+ const imgClasses: string[] = [];
690
+ const pictureClasses: string[] = [];
691
+
692
+ for (const cls of allClasses) {
693
+ // Strip responsive prefix (e.g., "md:object-cover" -> "object-cover") for matching
694
+ const baseCls = cls.includes(':') ? cls.split(':').pop()! : cls;
695
+ if (IMG_TAILWIND_PREFIXES.some(p => baseCls.startsWith(p)) || IMG_OPACITY_PATTERN.test(baseCls)) {
696
+ imgClasses.push(cls);
697
+ } else {
698
+ pictureClasses.push(cls);
699
+ }
700
+ }
701
+
702
+ return { pictureClasses, imgClasses };
703
+ }
704
+
705
+ /**
706
+ * Emit an <img> node as a <picture> element with AVIF/WebP sources when
707
+ * image metadata is available, or as a plain <img> with srcset otherwise.
708
+ */
709
+ function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
710
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
711
+
712
+ // Element class for interactive styles
713
+ let elementClass: string | null = null;
714
+ if (
715
+ (node.interactiveStyles && node.interactiveStyles.length > 0) ||
716
+ node.generateElementClass
717
+ ) {
718
+ elementClass = buildElementClass(ctx, node.label);
719
+ }
720
+
721
+ const { classExpr, styleAttr } = buildClassAndStyleExpression(
722
+ style,
723
+ node.interactiveStyles as InteractiveStyles | undefined,
724
+ elementClass,
725
+ ctx
726
+ );
727
+
728
+ // Extract image-specific attributes
729
+ const attrs = node.attributes || {};
730
+ const src = attrs.src as string | undefined;
731
+ const alt = attrs.alt as string | undefined;
732
+ const loading = attrs.loading as string | undefined;
733
+ const fetchpriority = attrs.fetchpriority as string | undefined;
734
+ let width = attrs.width as string | number | undefined;
735
+ let height = attrs.height as string | number | undefined;
736
+ const sizes = attrs.sizes as string | undefined;
737
+
738
+ // Look up image metadata
739
+ const metadata = src ? ctx.imageMetadataMap?.get(String(src)) : undefined;
740
+
741
+ // Use dimensions from metadata if not explicitly set
742
+ if (metadata) {
743
+ if (width === undefined && metadata.width) width = metadata.width;
744
+ if (height === undefined && metadata.height) height = metadata.height;
745
+ }
746
+
747
+ const sizesValue = sizes || DEFAULT_SIZES;
748
+
749
+ // Build remaining attributes (exclude image-specific ones we handle manually)
750
+ const imageSpecificKeys = new Set(['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority']);
751
+ const otherAttrs: Record<string, string | number | boolean> = {};
752
+ if (node.attributes) {
753
+ for (const [k, v] of Object.entries(node.attributes)) {
754
+ if (!imageSpecificKeys.has(k)) otherAttrs[k] = v;
755
+ }
756
+ }
757
+ const otherAttrsStr = buildAttributesString(otherAttrs, ctx);
758
+
759
+ // Build core img attributes (with CMS/list template support)
760
+ let imgAttrs = '';
761
+ if (src) imgAttrs += ` ${emitAttrValue('src', String(src), ctx)}`;
762
+ if (alt !== undefined) imgAttrs += ` ${emitAttrValue('alt', String(alt), ctx)}`;
763
+ if (fetchpriority) imgAttrs += ` fetchpriority="${escapeJSX(String(fetchpriority))}"`;
764
+ if (loading) imgAttrs += ` loading="${escapeJSX(String(loading))}"`;
765
+ if (width !== undefined) imgAttrs += ` width="${escapeJSX(String(width))}"`;
766
+ if (height !== undefined) imgAttrs += ` height="${escapeJSX(String(height))}"`;
767
+
768
+ // Blur placeholder style
769
+ let blurStyle = '';
770
+ if (metadata?.blurHash) {
771
+ blurStyle = ` style="background-image: url(${escapeJSX(metadata.blurHash)}); background-size: cover;" onload="this.style.backgroundImage=''"`;
772
+ }
773
+
774
+ // Conditional rendering
775
+ const ifExpr = emitIfOpen(node, ctx);
776
+ const ifClose = emitIfClose(node, ctx);
777
+
778
+ // If we have AVIF srcset and format allows, render as <picture> with split classes
779
+ if (metadata?.avifSrcset && ctx.imageFormat !== 'webp') {
780
+ // Extract static classes from classExpr for splitting
781
+ const classMatch = classExpr.match(/class="([^"]*)"/);
782
+ const classListMatch = classExpr.match(/class:list={\[(.+)\]}/);
783
+
784
+ if (classListMatch) {
785
+ // Dynamic class:list - put it all on the picture, img gets none
786
+ // (splitting dynamic classes is too complex; layout on picture is the safer default)
787
+ return (
788
+ `${ifExpr}${ind(ctx)}<picture${classExpr}${styleAttr}>\n` +
789
+ `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata.avifSrcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
790
+ `${ind(ctx)} <source type="image/webp" srcset="${escapeJSX(metadata.srcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
791
+ `${ind(ctx)} <img${imgAttrs}${blurStyle}${otherAttrsStr} />\n` +
792
+ `${ind(ctx)}</picture>\n${ifClose}`
793
+ );
794
+ }
795
+
796
+ const allClasses = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
797
+ const { pictureClasses, imgClasses } = splitImageClasses(allClasses);
798
+
799
+ const pictureClassAttr = pictureClasses.length > 0 ? ` class="${pictureClasses.join(' ')}"` : '';
800
+ const imgClassAttr = imgClasses.length > 0 ? ` class="${imgClasses.join(' ')}"` : '';
801
+
802
+ return (
803
+ `${ifExpr}${ind(ctx)}<picture${pictureClassAttr}${styleAttr}>\n` +
804
+ `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata.avifSrcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
805
+ `${ind(ctx)} <source type="image/webp" srcset="${escapeJSX(metadata.srcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
806
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurStyle}${otherAttrsStr} />\n` +
807
+ `${ind(ctx)}</picture>\n${ifClose}`
808
+ );
809
+ }
810
+
811
+ // Fallback: regular img with WebP srcset if available
812
+ if (metadata?.srcset) {
813
+ imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
814
+ imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
815
+ }
816
+
817
+ return `${ifExpr}${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs}${blurStyle}${otherAttrsStr} />\n${ifClose}`;
818
+ }
819
+
468
820
  function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
469
821
  let tag = node.tag;
822
+
823
+ // Astro treats capitalized tags as component imports — lowercase custom tags to avoid this
824
+ if (tag && /^[A-Z]/.test(tag)) {
825
+ tag = tag.toLowerCase();
826
+ }
827
+
828
+ // Delegate img tags to the image-specific emitter when metadata is available
829
+ if (tag === 'img' && ctx.imageMetadataMap) {
830
+ return emitImageNode(node, ctx);
831
+ }
832
+
470
833
  const label = node.label;
471
834
  const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
472
835
 
@@ -528,17 +891,45 @@ function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContex
528
891
  // Build prop expressions
529
892
  const propParts: string[] = [];
530
893
  if (node.props) {
531
- for (const [key, value] of Object.entries(node.props)) {
894
+ for (const [key, rawValue] of Object.entries(node.props)) {
532
895
  if (key === 'children') continue;
896
+
897
+ // Resolve i18n values to the page locale
898
+ const value = resolveI18n(rawValue, ctx);
899
+
533
900
  // Resolve template expressions in string props when inside component def
534
901
  if (typeof value === 'string' && hasTemplates(value) && ctx.isComponentDef) {
535
902
  const fullMatch = value.match(/^\{\{(.+)\}\}$/);
536
903
  if (fullMatch) {
537
- propParts.push(`${key}={${fullMatch[1].trim()}}`);
904
+ let expr = fullMatch[1].trim();
905
+ if (ctx.listItemBinding) expr = rewriteItemVar(expr, ctx.listItemBinding);
906
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
907
+ propParts.push(`${key}={${expr}}`);
538
908
  } else {
539
- const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
909
+ const resolved = value.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
910
+ let trimmed = expr.trim();
911
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
912
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
913
+ return `\${${trimmed}}`;
914
+ });
540
915
  propParts.push(`${key}={\`${resolved}\`}`);
541
916
  }
917
+ } else if (typeof value === 'string' && ctx.cmsMode && /\{\{cms\./.test(value)) {
918
+ const b = ctx.cmsEntryBinding || 'entry';
919
+ const w = (expr: string) => ctx.cmsWrapFn ? `${ctx.cmsWrapFn}(${expr})` : expr;
920
+ const fullMatch = value.match(/^\{\{cms\.([^}]+)\}\}$/);
921
+ if (fullMatch) {
922
+ propParts.push(`${key}={${w(`${b}.data.${fullMatch[1].trim()}`)}}`);
923
+ } else {
924
+ const replaced = value.replace(/\{\{cms\.([^}]+)\}\}/g, (_, fieldPath) => {
925
+ return `\${${w(`${b}.data.${fieldPath.trim()}`)}}`;
926
+ });
927
+ propParts.push(`${key}={\`${replaced}\`}`);
928
+ }
929
+ } else if (ctx.isComponentDef && isI18nValue(value)) {
930
+ // i18n object in component def — wrap with r() resolver (resolved at runtime via Astro.currentLocale)
931
+ ctx.needsI18nResolver = true;
932
+ propParts.push(`${key}={r(${JSON.stringify(value)})}`);
542
933
  } else {
543
934
  propParts.push(`${key}=${formatPropValue(value)}`);
544
935
  }
@@ -609,6 +1000,31 @@ function emitEmbedNode(node: EmbedNode, ctx: AstroEmitContext): string {
609
1000
  }
610
1001
  }
611
1002
 
1003
+ // Handle template expressions in HTML string (e.g., "{{icon}}")
1004
+ if (typeof node.html === 'string' && hasTemplates(node.html) && ctx.isComponentDef) {
1005
+ const fullMatch = node.html.match(/^\{\{(.+)\}\}$/);
1006
+ if (fullMatch) {
1007
+ let propRef = fullMatch[1].trim();
1008
+ if (ctx.listItemBinding) propRef = rewriteItemVar(propRef, ctx.listItemBinding);
1009
+ if (ctx.listIndexVar) propRef = replaceItemMetaVars(propRef, ctx.listIndexVar, ctx.listSourceVar);
1010
+
1011
+ let tplClassExpr = classExpr;
1012
+ if (!classExpr.includes('oem')) {
1013
+ if (classExpr) {
1014
+ tplClassExpr = classExpr.replace(/class="/, 'class="oem ').replace(/class:list={\['/, "class:list={['oem ");
1015
+ } else {
1016
+ tplClassExpr = ' class="oem"';
1017
+ }
1018
+ }
1019
+
1020
+ return (
1021
+ `${ifExpr}${ind(ctx)}<div${tplClassExpr}${attrs}>\n` +
1022
+ `${ind(ctx)} <Fragment set:html={${propRef}} />\n` +
1023
+ `${ind(ctx)}</div>\n${emitIfClose(node, ctx)}`
1024
+ );
1025
+ }
1026
+ }
1027
+
612
1028
  const htmlStr = typeof node.html === 'string' ? node.html : '';
613
1029
  const escapedHtml = escapeTemplateLiteral(htmlStr);
614
1030
 
@@ -652,28 +1068,59 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
652
1068
  }
653
1069
  }
654
1070
 
1071
+ // Resolve i18n on href before processing
1072
+ const resolvedHref = resolveI18n(node.href, ctx);
1073
+ const nodeHref = resolvedHref as typeof node.href;
1074
+
655
1075
  // Handle href
656
1076
  let hrefAttr: string;
657
- if (isLinkMapping(node.href)) {
1077
+ if (isLinkMapping(nodeHref)) {
658
1078
  if (ctx.isComponentDef) {
659
- const propRef = node.href.prop;
1079
+ const propRef = (nodeHref as LinkMapping).prop;
660
1080
  // Link props are objects with {href, target?}
661
- hrefAttr = ` href={${propRef}.href}`;
1081
+ hrefAttr = ` href={${propRef}?.href ?? "#"}`;
662
1082
  } else {
663
1083
  hrefAttr = ' href="#"';
664
1084
  }
665
1085
  } else {
666
- const href = typeof node.href === 'string' ? node.href : '#';
1086
+ const href = typeof nodeHref === 'string' ? nodeHref : '#';
667
1087
  if (hasTemplates(href) && ctx.isComponentDef) {
668
1088
  const fullMatch = href.match(/^\{\{(.+)\}\}$/);
669
1089
  if (fullMatch) {
670
- hrefAttr = ` href={${fullMatch[1].trim()}}`;
1090
+ let expr = fullMatch[1].trim();
1091
+ if (ctx.listItemBinding) expr = rewriteItemVar(expr, ctx.listItemBinding);
1092
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
1093
+ const propDef = ctx.componentProps[expr];
1094
+ if (propDef && propDef.type === 'link') {
1095
+ hrefAttr = ` href={${expr}?.href ?? "#"}`;
1096
+ } else {
1097
+ hrefAttr = ` href={${expr}}`;
1098
+ }
671
1099
  } else {
672
- const resolved = href.replace(/\{\{(.+?)\}\}/g, (_, expr) => `\${${expr.trim()}}`);
1100
+ const resolved = href.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
1101
+ let trimmed = expr.trim();
1102
+ if (ctx.listItemBinding) trimmed = rewriteItemVar(trimmed, ctx.listItemBinding);
1103
+ if (ctx.listIndexVar) trimmed = replaceItemMetaVars(trimmed, ctx.listIndexVar, ctx.listSourceVar);
1104
+ const pd = ctx.componentProps[trimmed];
1105
+ return pd?.type === 'link' ? `\${${trimmed}?.href ?? "#"}` : `\${${trimmed}}`;
1106
+ });
673
1107
  hrefAttr = ` href={\`${resolved}\`}`;
674
1108
  }
1109
+ } else if (ctx.cmsMode && /\{\{cms\./.test(href)) {
1110
+ const b = ctx.cmsEntryBinding || 'entry';
1111
+ const w = (expr: string) => ctx.cmsWrapFn ? `${ctx.cmsWrapFn}(${expr})` : expr;
1112
+ const fullMatch = href.match(/^\{\{cms\.([^}]+)\}\}$/);
1113
+ if (fullMatch) {
1114
+ hrefAttr = ` href={${w(`${b}.data.${fullMatch[1].trim()}`)}}`;
1115
+ } else {
1116
+ const replaced = href.replace(/\{\{cms\.([^}]+)\}\}/g, (_, fieldPath) => {
1117
+ return `\${${w(`${b}.data.${fieldPath.trim()}`)}}`;
1118
+ });
1119
+ hrefAttr = ` href={\`${replaced}\`}`;
1120
+ }
675
1121
  } else {
676
- hrefAttr = ` href="${escapeJSX(href)}"`;
1122
+ const localizedHref = localizeHref(href, ctx);
1123
+ hrefAttr = ` href="${escapeJSX(localizedHref)}"`;
677
1124
  }
678
1125
  }
679
1126
 
@@ -691,6 +1138,329 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
691
1138
  );
692
1139
  }
693
1140
 
1141
+ /**
1142
+ * Emit an "image" type node (standalone image, not an HTML img tag)
1143
+ * These have src, alt, style directly on the node.
1144
+ */
1145
+ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1146
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
1147
+
1148
+ let elementClass: string | null = null;
1149
+ if (
1150
+ (node.interactiveStyles && node.interactiveStyles.length > 0) ||
1151
+ node.generateElementClass
1152
+ ) {
1153
+ elementClass = buildElementClass(ctx, node.label);
1154
+ }
1155
+
1156
+ const { classExpr, styleAttr } = buildClassAndStyleExpression(
1157
+ style,
1158
+ node.interactiveStyles as InteractiveStyles | undefined,
1159
+ elementClass,
1160
+ ctx
1161
+ );
1162
+
1163
+ const src = node.src as string | undefined;
1164
+ const alt = node.alt as string | undefined;
1165
+
1166
+ let imgAttrs = '';
1167
+ if (src) imgAttrs += ` src="${escapeJSX(String(src))}"`;
1168
+ if (alt !== undefined) imgAttrs += ` alt="${escapeJSX(String(alt))}"`;
1169
+
1170
+ // Check for image metadata for responsive images
1171
+ const metadata = src ? ctx.imageMetadataMap?.get(String(src)) : undefined;
1172
+
1173
+ if (metadata) {
1174
+ let width = metadata.width;
1175
+ let height = metadata.height;
1176
+ if (width !== undefined) imgAttrs += ` width="${width}"`;
1177
+ if (height !== undefined) imgAttrs += ` height="${height}"`;
1178
+
1179
+ let blurStyle = '';
1180
+ if (metadata.blurHash) {
1181
+ blurStyle = ` style="background-image: url(${escapeJSX(metadata.blurHash)}); background-size: cover;" onload="this.style.backgroundImage=''"`;
1182
+ }
1183
+
1184
+ const sizesValue = DEFAULT_SIZES;
1185
+
1186
+ if (metadata.avifSrcset && ctx.imageFormat !== 'webp') {
1187
+ const classMatch = classExpr.match(/class="([^"]*)"/);
1188
+ const allClasses = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
1189
+ const { pictureClasses, imgClasses } = splitImageClasses(allClasses);
1190
+
1191
+ const pictureClassAttr = pictureClasses.length > 0 ? ` class="${pictureClasses.join(' ')}"` : '';
1192
+ const imgClassAttr = imgClasses.length > 0 ? ` class="${imgClasses.join(' ')}"` : '';
1193
+
1194
+ return (
1195
+ `${ind(ctx)}<picture${pictureClassAttr}${styleAttr}>\n` +
1196
+ `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata.avifSrcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1197
+ `${ind(ctx)} <source type="image/webp" srcset="${escapeJSX(metadata.srcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1198
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurStyle} />\n` +
1199
+ `${ind(ctx)}</picture>\n`
1200
+ );
1201
+ }
1202
+
1203
+ if (metadata.srcset) {
1204
+ imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
1205
+ imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
1206
+ }
1207
+ }
1208
+
1209
+ return `${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs} />\n`;
1210
+ }
1211
+
1212
+ /**
1213
+ * Emit a list node as native Astro .map() call
1214
+ */
1215
+ function emitListNode(node: ListNode, ctx: AstroEmitContext): string {
1216
+ const sourceType = node.sourceType || 'prop';
1217
+ const itemAs = node.itemAs || 'item';
1218
+
1219
+ if (sourceType === 'collection') {
1220
+ return emitCollectionListNode(node, ctx);
1221
+ }
1222
+
1223
+ // Prop lists only work inside component definitions where the prop is available
1224
+ // On pages, the data source doesn't exist at template level - use SSR fallback
1225
+ if (!ctx.isComponentDef && !ctx.listItemBinding) {
1226
+ return emitFallback(ctx);
1227
+ }
1228
+
1229
+ // Prop list: emit {items.map((item, index) => (...))}
1230
+ // Resolve source: "{{features}}" → "features"
1231
+ let source = node.source || 'items';
1232
+ const templateMatch = source.match(/^\{\{(.+)\}\}$/);
1233
+ if (templateMatch) {
1234
+ source = templateMatch[1].trim();
1235
+ }
1236
+
1237
+ // Build the .map() source expression (compute before children so ctx has it)
1238
+ let mapSource = source;
1239
+ if (node.offset && node.limit) {
1240
+ mapSource = `${source}.slice(${node.offset}, ${node.offset + node.limit})`;
1241
+ } else if (node.offset) {
1242
+ mapSource = `${source}.slice(${node.offset})`;
1243
+ } else if (node.limit) {
1244
+ mapSource = `${source}.slice(0, ${node.limit})`;
1245
+ }
1246
+
1247
+ // Build children with list item binding context
1248
+ const indexVar = `${itemAs}Index`;
1249
+ const innerCtx: AstroEmitContext = {
1250
+ ...ctx,
1251
+ indent: ctx.indent + 1,
1252
+ listItemBinding: itemAs,
1253
+ listIndexVar: indexVar,
1254
+ listSourceVar: mapSource,
1255
+ elementPath: [...ctx.elementPath, 0],
1256
+ };
1257
+
1258
+ const children = node.children
1259
+ ? (node.children as any[]).map((child, i) => {
1260
+ const childCtx = { ...innerCtx, elementPath: [...ctx.elementPath, i] };
1261
+ const out = nodeToAstro(child, childCtx);
1262
+ if (childCtx.needsI18nResolver) ctx.needsI18nResolver = true;
1263
+ return out;
1264
+ }).join('')
1265
+ : '';
1266
+
1267
+ return (
1268
+ `${ind(ctx)}{${mapSource}.map((${itemAs}, ${indexVar}) => (\n` +
1269
+ children +
1270
+ `${ind(ctx)}))}\n`
1271
+ );
1272
+ }
1273
+
1274
+ /**
1275
+ * Emit a collection-sourced list node using getCollection() in frontmatter
1276
+ */
1277
+ function emitCollectionListNode(node: ListNode, ctx: AstroEmitContext): string {
1278
+ // For collection lists, the data fetching happens in frontmatter
1279
+ const source = node.source || '';
1280
+ const itemAs = node.itemAs || singularize(source);
1281
+
1282
+ // Add frontmatter lines and imports
1283
+ if (!ctx.frontmatterLines) ctx.frontmatterLines = [];
1284
+ if (!ctx.astroImports) ctx.astroImports = new Set();
1285
+
1286
+ ctx.astroImports.add('getCollection');
1287
+
1288
+ // Variable name for the collection data
1289
+ const collectionVar = `${source}List`;
1290
+
1291
+ // Build the collection query with filter/sort/limit
1292
+ let queryChain = `await getCollection('${source}')`;
1293
+
1294
+ if (node.filter) {
1295
+ // Simple filter support
1296
+ if (typeof node.filter === 'object' && !Array.isArray(node.filter) && 'field' in node.filter) {
1297
+ const f = node.filter as { field: string; operator?: string; value: unknown };
1298
+ const op = f.operator || 'eq';
1299
+ if (op === 'eq') {
1300
+ queryChain += `.then(items => items.filter(e => e.data.${f.field} === ${JSON.stringify(f.value)}))`;
1301
+ }
1302
+ }
1303
+ }
1304
+
1305
+ if (node.sort) {
1306
+ const sortConfig = Array.isArray(node.sort) ? node.sort[0] : node.sort;
1307
+ if (sortConfig) {
1308
+ const order = sortConfig.order === 'desc' ? -1 : 1;
1309
+ queryChain += `.then(items => items.sort((a, b) => a.data.${sortConfig.field} > b.data.${sortConfig.field} ? ${order} : ${-order}))`;
1310
+ }
1311
+ }
1312
+
1313
+ if (node.offset || node.limit) {
1314
+ const start = node.offset || 0;
1315
+ const end = node.limit ? start + node.limit : undefined;
1316
+ queryChain += `.then(items => items.slice(${start}${end !== undefined ? `, ${end}` : ''}))`;
1317
+ }
1318
+
1319
+ ctx.frontmatterLines.push(`const ${collectionVar} = ${queryChain};`);
1320
+
1321
+ // Build children with item binding
1322
+ const indexVar = `${itemAs}Index`;
1323
+ const innerCtx: AstroEmitContext = {
1324
+ ...ctx,
1325
+ indent: ctx.indent + 1,
1326
+ listItemBinding: itemAs,
1327
+ listIndexVar: indexVar,
1328
+ listSourceVar: collectionVar,
1329
+ cmsMode: true,
1330
+ cmsEntryBinding: itemAs,
1331
+ elementPath: [...ctx.elementPath, 0],
1332
+ };
1333
+
1334
+ const children = node.children
1335
+ ? (node.children as any[]).map((child, i) => {
1336
+ const childCtx = { ...innerCtx, elementPath: [...ctx.elementPath, i] };
1337
+ const out = nodeToAstro(child, childCtx);
1338
+ if (childCtx.needsI18nResolver) ctx.needsI18nResolver = true;
1339
+ return out;
1340
+ }).join('')
1341
+ : '';
1342
+
1343
+ return (
1344
+ `${ind(ctx)}{${collectionVar}.map((${itemAs}, ${indexVar}) => (\n` +
1345
+ children +
1346
+ `${ind(ctx)}))}\n`
1347
+ );
1348
+ }
1349
+
1350
+ /**
1351
+ * Emit a locale list node with static locale links
1352
+ * Since locales are known at build time, we emit static HTML
1353
+ */
1354
+ function emitLocaleListNode(node: LocaleListNode, ctx: AstroEmitContext): string {
1355
+ // If we don't have i18n config or slug map, fall back to SSR
1356
+ if (!ctx.i18nConfig || !ctx.currentPageSlugMap) {
1357
+ return emitFallback(ctx);
1358
+ }
1359
+
1360
+ const i18nConfig = ctx.i18nConfig;
1361
+ const slugMap = ctx.currentPageSlugMap;
1362
+ const showCurrent = node.showCurrent !== false;
1363
+ const showSeparator = node.showSeparator !== false;
1364
+ const showFlag = node.showFlag !== false;
1365
+ const displayType = node.displayType || 'nativeName';
1366
+
1367
+ // Build container classes from style
1368
+ const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
1369
+
1370
+ let elementClass: string | null = null;
1371
+ if ((node.interactiveStyles && (node.interactiveStyles as any[]).length > 0) || node.generateElementClass) {
1372
+ elementClass = buildElementClass(ctx, node.label);
1373
+ }
1374
+
1375
+ const { classExpr: containerClassExpr, styleAttr: containerStyleAttr } = buildClassAndStyleExpression(
1376
+ style,
1377
+ node.interactiveStyles as InteractiveStyles | undefined,
1378
+ elementClass,
1379
+ ctx
1380
+ );
1381
+
1382
+ // Build item classes
1383
+ const itemStyle = node.itemStyle as StyleObject | ResponsiveStyleObject | undefined;
1384
+ const itemResult = itemStyle ? responsiveStylesToTailwind(itemStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1385
+ const itemClasses = itemResult.classes;
1386
+
1387
+ // Build active item classes (item + active combined)
1388
+ const activeItemStyle = node.activeItemStyle as StyleObject | ResponsiveStyleObject | undefined;
1389
+ const activeResult = activeItemStyle ? responsiveStylesToTailwind(activeItemStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1390
+ const activeItemClasses = [...itemClasses, ...activeResult.classes];
1391
+
1392
+ // Build separator classes
1393
+ const separatorStyle = node.separatorStyle as StyleObject | ResponsiveStyleObject | undefined;
1394
+ const sepResult = separatorStyle ? responsiveStylesToTailwind(separatorStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1395
+ const separatorClasses = sepResult.classes;
1396
+
1397
+ // Build locale icon map
1398
+ const localeIconMap = new Map<string, string>();
1399
+ for (const localeConfig of i18nConfig.locales) {
1400
+ if (localeConfig.icon) {
1401
+ localeIconMap.set(localeConfig.code, localeConfig.icon);
1402
+ }
1403
+ }
1404
+
1405
+ // Flag classes
1406
+ const flagStyle = (node as any).flagStyle as StyleObject | ResponsiveStyleObject | undefined;
1407
+ const flagResult = flagStyle ? responsiveStylesToTailwind(flagStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1408
+ const flagClasses = flagResult.classes;
1409
+
1410
+ // Build links
1411
+ const links: string[] = [];
1412
+ const currentLocale = ctx.locale || i18nConfig.defaultLocale;
1413
+
1414
+ for (const localeConfig of i18nConfig.locales) {
1415
+ const code = localeConfig.code;
1416
+ const isCurrent = code === currentLocale;
1417
+
1418
+ if (!showCurrent && isCurrent) continue;
1419
+
1420
+ const path = slugMap[code] || '/';
1421
+ const classes = isCurrent ? activeItemClasses : itemClasses;
1422
+ const classAttr = classes.length > 0 ? ` class="${classes.join(' ')}"` : '';
1423
+ const currentAttr = isCurrent ? ' data-current="true"' : ' data-current="false"';
1424
+ const hreflangAttr = ` hreflang="${localeConfig.langTag}"`;
1425
+
1426
+ // Display text
1427
+ let displayText: string;
1428
+ switch (displayType) {
1429
+ case 'code': displayText = code.toUpperCase(); break;
1430
+ case 'name': displayText = localeConfig.name; break;
1431
+ case 'nativeName': default: displayText = localeConfig.nativeName; break;
1432
+ }
1433
+
1434
+ // Build link content
1435
+ let linkContent = '';
1436
+ const localeIcon = localeIconMap.get(code);
1437
+ if (showFlag && localeIcon) {
1438
+ const flagClassAttr = flagClasses.length > 0 ? ` class="${flagClasses.join(' ')}"` : '';
1439
+ linkContent += `<img src="${escapeJSX(localeIcon)}" alt="${escapeJSX(localeConfig.nativeName)} flag"${flagClassAttr}>`;
1440
+ }
1441
+ linkContent += `<div>${escapeJSX(displayText)}</div>`;
1442
+
1443
+ links.push(`${ind(ctx)} <a href="${escapeJSX(path)}"${hreflangAttr}${currentAttr} data-locale="${escapeJSX(code)}"${classAttr}>${linkContent}</a>`);
1444
+ }
1445
+
1446
+ // Join with separator
1447
+ let linksContent: string;
1448
+ if (showSeparator && links.length > 1) {
1449
+ const sepClassAttr = separatorClasses.length > 0 ? ` class="${separatorClasses.join(' ')}"` : '';
1450
+ linksContent = links.join(`\n${ind(ctx)} <span${sepClassAttr}></span>\n`);
1451
+ } else {
1452
+ linksContent = links.join('\n');
1453
+ }
1454
+
1455
+ const attrs = buildAttributesString((node as any).attributes, ctx);
1456
+
1457
+ return (
1458
+ `${ind(ctx)}<div data-locale-list="true"${containerClassExpr}${containerStyleAttr}${attrs}>\n` +
1459
+ linksContent + '\n' +
1460
+ `${ind(ctx)}</div>\n`
1461
+ );
1462
+ }
1463
+
694
1464
  /**
695
1465
  * Emit SSR fallback for complex nodes (list, locale-list)
696
1466
  */
@@ -716,15 +1486,52 @@ function emitIfOpen(node: ComponentNode, ctx: AstroEmitContext): string {
716
1486
  return ifValue ? '' : `${ind(ctx)}{/* hidden */}\n`;
717
1487
  }
718
1488
 
1489
+ // BooleanMapping → generate conditional from values map
719
1490
  if (typeof ifValue === 'object' && ifValue._mapping && ctx.isComponentDef) {
720
- return `${ind(ctx)}{${ifValue.prop} && (\n`;
1491
+ const trueValues = Object.entries(ifValue.values)
1492
+ .filter(([, v]) => v === true)
1493
+ .map(([k]) => `'${k}'`);
1494
+ if (trueValues.length === 0) return `${ind(ctx)}{/* hidden */}\n`;
1495
+ if (trueValues.length === 1) {
1496
+ return `${ind(ctx)}{${ifValue.prop} === ${trueValues[0]} && (\n`;
1497
+ }
1498
+ return `${ind(ctx)}{[${trueValues.join(', ')}].includes(${ifValue.prop}) && (\n`;
721
1499
  }
722
1500
 
723
- if (typeof ifValue === 'string' && ctx.isComponentDef) {
724
- // Check if entire string is a single {{expression}}
725
- const fullMatch = ifValue.match(/^\{\{(.+)\}\}$/);
726
- const expr = fullMatch ? fullMatch[1].trim() : ifValue.replace(/\{\{(.+?)\}\}/g, '$1');
727
- return `${ind(ctx)}{${expr} && (\n`;
1501
+ // String template
1502
+ if (typeof ifValue === 'string') {
1503
+ // CMS mode: {{cms.field}} → entry.data.field
1504
+ if (ctx.cmsMode && ifValue.includes('{{cms.')) {
1505
+ const match = ifValue.match(/^\{\{cms\.([^}]+)\}\}$/);
1506
+ if (match) {
1507
+ const binding = ctx.cmsEntryBinding || 'entry';
1508
+ return `${ind(ctx)}{${binding}.data.${match[1].trim()} && (\n`;
1509
+ }
1510
+ }
1511
+
1512
+ // List item context: {{item.field}} or {{!itemLast}} etc.
1513
+ if (ctx.listItemBinding && /\{\{/.test(ifValue)) {
1514
+ const match = ifValue.match(/^\{\{([^}]+)\}\}$/);
1515
+ if (match) {
1516
+ let expr = match[1].trim();
1517
+ expr = rewriteItemVar(expr, ctx.listItemBinding);
1518
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
1519
+ return `${ind(ctx)}{${expr} && (\n`;
1520
+ }
1521
+ }
1522
+
1523
+ // Component prop context
1524
+ if (ctx.isComponentDef) {
1525
+ const fullMatch = ifValue.match(/^\{\{(.+)\}\}$/);
1526
+ if (!fullMatch && !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(ifValue)) {
1527
+ // Plain string without template syntax and not a valid identifier — always truthy, skip conditional
1528
+ return '';
1529
+ }
1530
+ let expr = fullMatch ? fullMatch[1].trim() : ifValue.replace(/\{\{(.+?)\}\}/g, '$1');
1531
+ if (ctx.listItemBinding) expr = rewriteItemVar(expr, ctx.listItemBinding);
1532
+ if (ctx.listIndexVar) expr = replaceItemMetaVars(expr, ctx.listIndexVar, ctx.listSourceVar);
1533
+ return `${ind(ctx)}{${expr} && (\n`;
1534
+ }
728
1535
  }
729
1536
 
730
1537
  return '';
@@ -736,13 +1543,26 @@ function emitIfClose(node: ComponentNode, ctx: AstroEmitContext): string {
736
1543
 
737
1544
  if (typeof ifValue === 'boolean') return '';
738
1545
 
739
- if (
740
- (typeof ifValue === 'object' && ifValue._mapping && ctx.isComponentDef) ||
741
- (typeof ifValue === 'string' && ctx.isComponentDef)
742
- ) {
1546
+ if (typeof ifValue === 'object' && ifValue._mapping && ctx.isComponentDef) {
743
1547
  return `${ind(ctx)})}\n`;
744
1548
  }
745
1549
 
1550
+ if (typeof ifValue === 'string') {
1551
+ const hasCmsCondition = ctx.cmsMode && ifValue.includes('{{cms.');
1552
+ const hasItemCondition = ctx.listItemBinding && /\{\{/.test(ifValue);
1553
+ if (hasCmsCondition || hasItemCondition) {
1554
+ return `${ind(ctx)})}\n`;
1555
+ }
1556
+ if (ctx.isComponentDef) {
1557
+ const fullMatch = ifValue.match(/^\{\{(.+)\}\}$/);
1558
+ if (!fullMatch && !/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(ifValue)) {
1559
+ // Matches the skip in emitIfOpen — no open brace was emitted
1560
+ return '';
1561
+ }
1562
+ return `${ind(ctx)})}\n`;
1563
+ }
1564
+ }
1565
+
746
1566
  return '';
747
1567
  }
748
1568
 
@@ -759,13 +1579,25 @@ function emitChildren(
759
1579
  const innerCtx = { ...ctx, indent: ctx.indent + 1, elementPath: [...ctx.elementPath] };
760
1580
 
761
1581
  if (typeof children === 'string') {
762
- return nodeToAstro(children, innerCtx);
1582
+ const out = nodeToAstro(children, innerCtx);
1583
+ if (innerCtx.needsI18nResolver) ctx.needsI18nResolver = true;
1584
+ return out;
763
1585
  }
764
1586
 
1587
+ // Iterate children arrays inline with appended index (matches SSR renderer's
1588
+ // children iteration which uses [...parentPath, index] for each child)
765
1589
  if (Array.isArray(children)) {
766
- return nodeToAstro(children as ComponentNode[], innerCtx);
1590
+ let result = '';
1591
+ for (let i = 0; i < children.length; i++) {
1592
+ const childCtx = { ...innerCtx, elementPath: [...ctx.elementPath, i] };
1593
+ result += nodeToAstro(children[i], childCtx);
1594
+ if (childCtx.needsI18nResolver) ctx.needsI18nResolver = true;
1595
+ }
1596
+ return result;
767
1597
  }
768
1598
 
769
1599
  // Single node
770
- return nodeToAstro(children, innerCtx);
1600
+ const out = nodeToAstro(children, innerCtx);
1601
+ if (innerCtx.needsI18nResolver) ctx.needsI18nResolver = true;
1602
+ return out;
771
1603
  }