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