meno-core 1.0.45 → 1.0.47

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 (63) hide show
  1. package/build-astro.ts +211 -124
  2. package/dist/bin/cli.js +2 -2
  3. package/dist/build-static.js +7 -7
  4. package/dist/chunks/{chunk-NZTSJS5C.js → chunk-2QK6U5UK.js} +3 -2
  5. package/dist/chunks/{chunk-NZTSJS5C.js.map → chunk-2QK6U5UK.js.map} +2 -2
  6. package/dist/chunks/{chunk-TVH3TC2T.js → chunk-47UNLQUU.js} +6 -6
  7. package/dist/chunks/{chunk-F7MA62WG.js → chunk-BCLGRZ3U.js} +5 -5
  8. package/dist/chunks/{chunk-F7MA62WG.js.map → chunk-BCLGRZ3U.js.map} +2 -2
  9. package/dist/chunks/{chunk-5ZASE4IG.js → chunk-FED5MME6.js} +234 -11
  10. package/dist/chunks/{chunk-5ZASE4IG.js.map → chunk-FED5MME6.js.map} +3 -3
  11. package/dist/chunks/{chunk-BZQKEJQY.js → chunk-FGUZOYJX.js} +49 -30
  12. package/dist/chunks/chunk-FGUZOYJX.js.map +7 -0
  13. package/dist/chunks/{chunk-5Z5VQRTJ.js → chunk-I7YIGZXT.js} +4 -4
  14. package/dist/chunks/{chunk-5Z5VQRTJ.js.map → chunk-I7YIGZXT.js.map} +2 -2
  15. package/dist/chunks/{chunk-OUNJ76QM.js → chunk-LJFB5EBT.js} +5 -5
  16. package/dist/chunks/{chunk-GYF3ABI3.js → chunk-UUA5LEWF.js} +3 -3
  17. package/dist/chunks/{chunk-GYF3ABI3.js.map → chunk-UUA5LEWF.js.map} +2 -2
  18. package/dist/chunks/{chunk-WQSG5WHC.js → chunk-ZTKHJQ2Z.js} +2 -2
  19. package/dist/chunks/{configService-6KTT6GRT.js → configService-DYCUEURL.js} +3 -3
  20. package/dist/chunks/{constants-L5IKLB6U.js → constants-GWBAD66U.js} +2 -2
  21. package/dist/entries/server-router.js +7 -7
  22. package/dist/lib/client/index.js +7 -5
  23. package/dist/lib/client/index.js.map +2 -2
  24. package/dist/lib/server/index.js +631 -208
  25. package/dist/lib/server/index.js.map +3 -3
  26. package/dist/lib/shared/index.js +7 -3
  27. package/dist/lib/shared/index.js.map +2 -2
  28. package/dist/lib/test-utils/index.js +1 -1
  29. package/lib/client/core/ComponentBuilder.test.ts +21 -0
  30. package/lib/client/core/ComponentBuilder.ts +8 -1
  31. package/lib/client/templateEngine.test.ts +64 -0
  32. package/lib/server/astro/astroEmitHelpers.ts +23 -0
  33. package/lib/server/astro/cmsPageEmitter.ts +46 -3
  34. package/lib/server/astro/componentEmitter.test.ts +59 -0
  35. package/lib/server/astro/componentEmitter.ts +53 -12
  36. package/lib/server/astro/cssCollector.ts +58 -11
  37. package/lib/server/astro/nodeToAstro.test.ts +397 -5
  38. package/lib/server/astro/nodeToAstro.ts +494 -65
  39. package/lib/server/astro/pageEmitter.ts +46 -3
  40. package/lib/server/astro/tailwindMapper.test.ts +119 -0
  41. package/lib/server/astro/tailwindMapper.ts +67 -1
  42. package/lib/server/runtime/httpServer.ts +12 -4
  43. package/lib/server/ssr/htmlGenerator.test.ts +3 -2
  44. package/lib/server/ssr/htmlGenerator.ts +6 -1
  45. package/lib/server/ssr/imageMetadata.ts +15 -9
  46. package/lib/server/ssr/jsCollector.ts +2 -2
  47. package/lib/server/ssr/ssrRenderer.test.ts +79 -0
  48. package/lib/server/ssr/ssrRenderer.ts +35 -20
  49. package/lib/shared/constants.ts +1 -0
  50. package/lib/shared/cssGeneration.test.ts +109 -3
  51. package/lib/shared/cssGeneration.ts +98 -13
  52. package/lib/shared/cssNamedColors.ts +47 -0
  53. package/lib/shared/cssProperties.ts +2 -2
  54. package/lib/shared/index.ts +1 -0
  55. package/lib/shared/styleNodeUtils.test.ts +47 -1
  56. package/lib/shared/styleNodeUtils.ts +7 -7
  57. package/package.json +1 -1
  58. package/dist/chunks/chunk-BZQKEJQY.js.map +0 -7
  59. /package/dist/chunks/{chunk-TVH3TC2T.js.map → chunk-47UNLQUU.js.map} +0 -0
  60. /package/dist/chunks/{chunk-OUNJ76QM.js.map → chunk-LJFB5EBT.js.map} +0 -0
  61. /package/dist/chunks/{chunk-WQSG5WHC.js.map → chunk-ZTKHJQ2Z.js.map} +0 -0
  62. /package/dist/chunks/{configService-6KTT6GRT.js.map → configService-DYCUEURL.js.map} +0 -0
  63. /package/dist/chunks/{constants-L5IKLB6U.js.map → constants-GWBAD66U.js.map} +0 -0
@@ -29,6 +29,7 @@ import type {
29
29
  } from '../../shared/types/styles';
30
30
  import { responsiveStylesToTailwind, propertyToTailwind } from './tailwindMapper';
31
31
  import type { BreakpointConfig } from '../../shared/breakpoints';
32
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
32
33
  import { generateElementClassName, type ElementClassContext } from '../../shared/elementClassName';
33
34
  import { isVoidElement, hasIf, hasChildren, isSlotContent as isSlotContentNode } from '../../shared/nodeUtils';
34
35
  import { NODE_TYPE, RAW_HTML_PREFIX } from '../../shared/constants';
@@ -38,6 +39,7 @@ import { isI18nValue, resolveI18nValue, DEFAULT_I18N_CONFIG, buildLocalizedPath
38
39
  import { transformCMSTemplate, isTemplateExpression, transformItemTemplate, replaceItemMetaVars, rewriteItemVar } from './templateTransformer';
39
40
  import type { SlugMap } from '../../shared/slugTranslator';
40
41
  import { buildSlugIndex, translatePath } from '../../shared/slugTranslator';
42
+ import { stripRawHtmlPrefixDeep, astroComponentName } from './astroEmitHelpers';
41
43
 
42
44
  // ---------------------------------------------------------------------------
43
45
  // Types
@@ -64,6 +66,8 @@ export interface AstroEmitContext {
64
66
  fileName: string;
65
67
  /** Breakpoint config for responsive Tailwind classes */
66
68
  breakpoints: BreakpointConfig;
69
+ /** Responsive scales config for auto-scaling spacing/typography/sizing */
70
+ responsiveScales?: ResponsiveScales;
67
71
  /** Dynamic tag definitions collected during traversal (for frontmatter) */
68
72
  dynamicTags?: Map<string, string>;
69
73
  /** Image metadata map for responsive image generation */
@@ -102,8 +106,29 @@ export interface AstroEmitContext {
102
106
  slugMappings?: SlugMap[];
103
107
  /** I18n default locale (for slug translation) */
104
108
  i18nDefaultLocale?: string;
109
+ /**
110
+ * Raw-HTML slice → processed HTML captured during the SSR pass for this page.
111
+ * Used by the RAW_HTML_PREFIX branch to emit rich-text content that has had
112
+ * image rewriting, component expansion, and link localization applied.
113
+ */
114
+ processedRawHtml?: Map<string, string>;
105
115
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
106
116
  imageFormat?: 'webp' | 'avif';
117
+ /**
118
+ * Static image imports collected during emission: varName → import path
119
+ * relative to the emitting file. Drives `import foo from '...'` in
120
+ * frontmatter and unlocks astro:assets <Picture> for static local images.
121
+ *
122
+ * Note: this is a Map (ref-shared across child ctx spreads), so mutations
123
+ * inside list/dynamic inner contexts remain visible to the parent. A
124
+ * separate boolean flag would be dropped by `{ ...ctx }` shallow copies —
125
+ * the presence of any entry here implies `import { Picture } from 'astro:assets'`.
126
+ */
127
+ imageImports?: Map<string, string>;
128
+ /** File depth (relative to src/pages or src/components) for import path computation */
129
+ fileDepth?: number;
130
+ /** Collected interactive styles during emission: elementClass → InteractiveStyles */
131
+ collectedInteractiveStyles?: Map<string, InteractiveStyles>;
107
132
  }
108
133
 
109
134
  // ---------------------------------------------------------------------------
@@ -206,7 +231,12 @@ function escapeJSX(s: string): string {
206
231
  * Escape a string for use inside a JS template literal
207
232
  */
208
233
  function escapeTemplateLiteral(s: string): string {
209
- return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
234
+ return s
235
+ .replace(/\\/g, '\\\\')
236
+ .replace(/`/g, '\\`')
237
+ .replace(/\$\{/g, '\\${')
238
+ .replace(/\u2028/g, '\\u2028')
239
+ .replace(/\u2029/g, '\\u2029');
210
240
  }
211
241
 
212
242
  /**
@@ -311,7 +341,7 @@ function buildClassAndStyleExpression(
311
341
  ): { classExpr: string; styleAttr: string } {
312
342
  // Static Tailwind classes from non-mapping styles
313
343
  const result = style
314
- ? responsiveStylesToTailwind(style, ctx.breakpoints)
344
+ ? responsiveStylesToTailwind(style, ctx.breakpoints, ctx.responsiveScales)
315
345
  : { classes: [], dynamicStyles: {} };
316
346
  const staticClasses = result.classes;
317
347
  const dynamicStyles = result.dynamicStyles;
@@ -439,7 +469,7 @@ function resolveTemplate(text: string, ctx: AstroEmitContext): string {
439
469
  if (ctx.listItemBinding) propName = rewriteItemVar(propName, ctx.listItemBinding);
440
470
  if (ctx.listIndexVar) propName = replaceItemMetaVars(propName, ctx.listIndexVar, ctx.listSourceVar);
441
471
  // Rich-text props contain HTML - render unescaped via set:html
442
- if (ctx.componentProps[propName]?.type === 'rich-text') {
472
+ if (ctx.componentProps[propName]?.type === 'rich-text' || ctx.componentProps[propName]?.type === 'embed') {
443
473
  return `<Fragment set:html={${propName}} />`;
444
474
  }
445
475
  return `{${propName}}`;
@@ -475,6 +505,7 @@ function buildElementClass(
475
505
  });
476
506
  }
477
507
 
508
+
478
509
  /**
479
510
  * Build HTML attributes string from node attributes
480
511
  */
@@ -555,6 +586,7 @@ function buildAttributesString(
555
586
  * Format a prop value for Astro template usage
556
587
  */
557
588
  function formatPropValue(value: unknown): string {
589
+ value = stripRawHtmlPrefixDeep(value);
558
590
  if (typeof value === 'string') return `"${escapeJSX(value)}"`;
559
591
  if (typeof value === 'number') return `{${value}}`;
560
592
  if (typeof value === 'boolean') return `{${value}}`;
@@ -591,12 +623,13 @@ export function nodeToAstro(
591
623
  if (typeof node === 'object' && !Array.isArray(node) && isI18nValue(node)) {
592
624
  const resolved = resolveI18n(node, ctx);
593
625
  if (typeof resolved === 'string') {
594
- return `${ind(ctx)}${escapeJSX(resolved)}\n`;
626
+ // Delegate to the text-node path so RAW_HTML_PREFIX gets emitted via <Fragment set:html>.
627
+ return nodeToAstro(resolved, ctx);
595
628
  }
596
629
  // In component def without locale: wrap with r() resolver
597
630
  if (ctx.isComponentDef && isI18nValue(resolved)) {
598
631
  ctx.needsI18nResolver = true;
599
- return `${ind(ctx)}{r(${JSON.stringify(resolved)})}\n`;
632
+ return `${ind(ctx)}{r(${JSON.stringify(stripRawHtmlPrefixDeep(resolved))})}\n`;
600
633
  }
601
634
  // If resolution returned a non-string (e.g. array), stringify it
602
635
  return `${ind(ctx)}${String(resolved ?? '')}\n`;
@@ -617,9 +650,12 @@ export function nodeToAstro(
617
650
  if (hasTemplates(node) && ctx.isComponentDef) {
618
651
  return `${ind(ctx)}${resolveTemplate(node, ctx)}\n`;
619
652
  }
620
- // Raw HTML marker (from rich-text fields) - render unescaped via set:html
653
+ // Raw HTML marker (from rich-text fields) - render unescaped via set:html.
654
+ // Prefer the SSR-processed HTML when available so image rewriting, component
655
+ // expansion, and link localization match what meno-core renders at runtime.
621
656
  if (node.startsWith(RAW_HTML_PREFIX)) {
622
- const rawHtml = node.slice(RAW_HTML_PREFIX.length);
657
+ const rawSlice = node.slice(RAW_HTML_PREFIX.length);
658
+ const rawHtml = ctx.processedRawHtml?.get(rawSlice) ?? rawSlice;
623
659
  return `${ind(ctx)}<Fragment set:html={\`${escapeTemplateLiteral(rawHtml)}\`} />\n`;
624
660
  }
625
661
  return `${ind(ctx)}${escapeJSX(node)}\n`;
@@ -681,6 +717,12 @@ const IMG_OPACITY_PATTERN = /^opacity-/;
681
717
  */
682
718
  const DEFAULT_SIZES = '100vw';
683
719
 
720
+ /**
721
+ * Tailwind classes applied to the inner <img> so it fills its wrapper (<picture>)
722
+ * when a responsive/blurred image wraps the element.
723
+ */
724
+ const IMG_FILL_CLASSES = ['block', 'w-full', 'h-full'];
725
+
684
726
  /**
685
727
  * Split Tailwind classes into picture (layout) and img (visual) groups.
686
728
  * Layout classes go on <picture>, image-specific classes go on <img>.
@@ -702,10 +744,283 @@ function splitImageClasses(allClasses: string[]): { pictureClasses: string[]; im
702
744
  return { pictureClasses, imgClasses };
703
745
  }
704
746
 
747
+ /**
748
+ * Extract a pixel width from a style value. Returns null for %, vw, auto,
749
+ * or any other non-px value we can't translate into a sizes hint.
750
+ */
751
+ function extractPxWidth(val: unknown): number | null {
752
+ if (typeof val === 'number' && Number.isFinite(val) && val > 0) return val;
753
+ if (typeof val !== 'string') return null;
754
+ const match = val.trim().match(/^(\d+(?:\.\d+)?)px$/);
755
+ if (!match) return null;
756
+ const n = parseFloat(match[1]);
757
+ return n > 0 ? n : null;
758
+ }
759
+
760
+ /**
761
+ * Compute a `sizes` attribute from a node's responsive style + breakpoint config.
762
+ * Produces something like `(max-width: 540px) 400px, (max-width: 1024px) 500px, 700px`
763
+ * so the browser can pick the smallest srcset variant that covers the rendered width
764
+ * at each viewport. Falls back to `100vw` when no usable px width is available.
765
+ */
766
+ function computeSizesAttribute(
767
+ style: StyleObject | ResponsiveStyleObject | undefined,
768
+ breakpoints: BreakpointConfig
769
+ ): string {
770
+ if (!style) return DEFAULT_SIZES;
771
+
772
+ const responsive = isResponsiveStyle(style as any);
773
+ const baseStyle: StyleObject | undefined = responsive
774
+ ? (style as ResponsiveStyleObject).base
775
+ : (style as StyleObject);
776
+
777
+ // Base (desktop) width — the biggest viewport
778
+ const baseWidth = baseStyle ? extractPxWidth((baseStyle as StyleObject).width) : null;
779
+ // If even the base width is unknown, nothing we can do — use 100vw.
780
+ if (baseWidth == null) return DEFAULT_SIZES;
781
+
782
+ // Walk breakpoints from smallest to largest so the emitted media queries
783
+ // are ordered from narrowest to widest, with the unconditional base last.
784
+ const bpEntries = Object.entries(breakpoints).sort(
785
+ (a, b) => a[1].breakpoint - b[1].breakpoint
786
+ );
787
+
788
+ const parts: string[] = [];
789
+ // Track the "effective" width as we walk up: if a breakpoint doesn't set its
790
+ // own width, it inherits from the next step up, and ultimately from base.
791
+ // We walk ascending, so we need to resolve by looking from smallest bp up to base.
792
+ for (const [name, entry] of bpEntries) {
793
+ let effective: number | null = null;
794
+ if (responsive) {
795
+ // Look at this bp, then larger bps, then base
796
+ const bpStyle = (style as ResponsiveStyleObject)[name];
797
+ effective = bpStyle ? extractPxWidth((bpStyle as StyleObject).width) : null;
798
+ if (effective == null) {
799
+ // Inherit from next-larger breakpoint(s)
800
+ for (const [largerName, largerEntry] of bpEntries) {
801
+ if (largerEntry.breakpoint <= entry.breakpoint) continue;
802
+ const largerStyle = (style as ResponsiveStyleObject)[largerName];
803
+ const larger = largerStyle ? extractPxWidth((largerStyle as StyleObject).width) : null;
804
+ if (larger != null) {
805
+ effective = larger;
806
+ break;
807
+ }
808
+ }
809
+ }
810
+ if (effective == null) effective = baseWidth;
811
+ } else {
812
+ effective = baseWidth;
813
+ }
814
+ parts.push(`(max-width: ${entry.breakpoint}px) ${effective}px`);
815
+ }
816
+ parts.push(`${baseWidth}px`);
817
+ return parts.join(', ');
818
+ }
819
+
820
+ /**
821
+ * Merge additional CSS declarations into an existing ` style="..."` attribute
822
+ * string. If no style attribute exists yet, builds one. The inputs are raw CSS
823
+ * already escaped for HTML attribute context.
824
+ */
825
+ function injectInlineStyle(styleAttr: string, extraCss: string): string {
826
+ if (!extraCss) return styleAttr;
827
+ if (!styleAttr) return ` style="${extraCss}"`;
828
+ return styleAttr.replace(/style="([^"]*)"/, (_, existing: string) => {
829
+ const trimmed = existing.trimEnd();
830
+ const sep = trimmed.length > 0 && !trimmed.endsWith(';') ? ';' : '';
831
+ return `style="${existing}${sep}${extraCss}"`;
832
+ });
833
+ }
834
+
705
835
  /**
706
836
  * Emit an <img> node as a <picture> element with AVIF/WebP sources when
707
837
  * image metadata is available, or as a plain <img> with srcset otherwise.
708
838
  */
839
+ // Widths used for <Picture> srcset generation. Mirrors
840
+ // RESPONSIVE_WIDTHS in imageMetadata.ts.
841
+ const PICTURE_WIDTHS = [500, 800, 1080, 1600, 2400];
842
+
843
+ /**
844
+ * Turn an image path like `/images/sub/hero-photo.jpg` into a stable JS
845
+ * identifier (`imgSubHeroPhoto`) for use as an ESM import variable.
846
+ */
847
+ function imagePathToVarName(srcPath: string): string {
848
+ const stripped = srcPath.replace(/^\/+/, '').replace(/^images\//, '').replace(/\.[^.]+$/, '');
849
+ const parts = stripped.split(/[/_\-\s.]+/).filter(Boolean);
850
+ if (parts.length === 0) return 'imgAsset';
851
+ const camel = parts
852
+ .map((p, i) => {
853
+ const clean = p.replace(/[^a-zA-Z0-9]/g, '');
854
+ if (!clean) return '';
855
+ if (i === 0) return clean.toLowerCase();
856
+ return clean[0].toUpperCase() + clean.slice(1).toLowerCase();
857
+ })
858
+ .join('');
859
+ if (!camel) return 'imgAsset';
860
+ return 'img' + camel[0].toUpperCase() + camel.slice(1);
861
+ }
862
+
863
+ /**
864
+ * Compute an ESM import path relative to the emitting file for a Meno image
865
+ * URL. For file depth N (e.g. `src/pages/en/about.astro` is depth 1), the
866
+ * path is `'../'.repeat(N + 1) + 'assets/images/<rest>'`.
867
+ */
868
+ function imageImportPath(srcPath: string, fileDepth: number): string {
869
+ const rest = srcPath.replace(/^\/+/, '').replace(/^images\//, '');
870
+ const ups = '../'.repeat(Math.max(0, fileDepth) + 1);
871
+ return `${ups}assets/images/${rest}`;
872
+ }
873
+
874
+ /**
875
+ * Decide whether an image src can be switched to <Picture>. Only plain
876
+ * literal local paths (`/images/...`) with known metadata qualify — CMS
877
+ * templates, list-item bindings, component props, and remote URLs take the
878
+ * legacy <img>/<picture> path.
879
+ */
880
+ function isStaticImageSrc(src: string | undefined, ctx: AstroEmitContext): src is string {
881
+ if (!src) return false;
882
+ if (typeof src !== 'string') return false;
883
+ if (hasTemplates(src)) return false;
884
+ if (!src.startsWith('/images/')) return false;
885
+ if (!ctx.imageMetadataMap?.has(src)) return false;
886
+ return true;
887
+ }
888
+
889
+ /**
890
+ * Register (or reuse) an ESM image import on the emit context. Returns the
891
+ * JS variable name to reference in the generated template.
892
+ */
893
+ function registerStaticImageImport(src: string, ctx: AstroEmitContext): string {
894
+ if (!ctx.imageImports) ctx.imageImports = new Map();
895
+ const depth = ctx.fileDepth ?? 0;
896
+ const importPath = imageImportPath(src, depth);
897
+
898
+ for (const [existingName, existingPath] of ctx.imageImports) {
899
+ if (existingPath === importPath) return existingName;
900
+ }
901
+
902
+ const base = imagePathToVarName(src);
903
+ let name = base;
904
+ let counter = 2;
905
+ while (ctx.imageImports.has(name)) {
906
+ name = `${base}${counter++}`;
907
+ }
908
+ ctx.imageImports.set(name, importPath);
909
+ return name;
910
+ }
911
+
912
+ /**
913
+ * Split a static `classExpr` into the outer layout classes (for <picture> /
914
+ * wrapper) and the inner image classes (for the emitted <img> inside
915
+ * <Picture>). Mirrors `splitImageClasses()` used by the legacy emitter so
916
+ * object-cover / opacity-* keep landing on the <img>. Returns null when the
917
+ * class expression is dynamic (class:list) — callers fall back to div wrap.
918
+ */
919
+ function splitStaticClassExpr(
920
+ classExpr: string
921
+ ): { outerClasses: string[]; innerClasses: string[] } | null {
922
+ if (classExpr.includes('class:list=')) return null;
923
+ const classMatch = classExpr.match(/class="([^"]*)"/);
924
+ if (!classMatch) return { outerClasses: [], innerClasses: [] };
925
+ const all = classMatch[1].split(/\s+/).filter(Boolean);
926
+ const { pictureClasses, imgClasses } = splitImageClasses(all);
927
+ return { outerClasses: pictureClasses, innerClasses: imgClasses };
928
+ }
929
+
930
+ /**
931
+ * Format a static class+style pair as an Astro `pictureAttributes={{...}}`
932
+ * prop. `classValue` comes pre-split (outer layout classes only).
933
+ */
934
+ function formatPictureAttributesProp(
935
+ classValue: string,
936
+ styleAttr: string
937
+ ): string {
938
+ const styleMatch = styleAttr.match(/style="([^"]*)"/);
939
+ const parts: string[] = [];
940
+ if (classValue) parts.push(`class: ${JSON.stringify(classValue)}`);
941
+ if (styleMatch) parts.push(`style: ${JSON.stringify(styleMatch[1])}`);
942
+ if (parts.length === 0) return '';
943
+ return ` pictureAttributes={{${parts.join(', ')}}}`;
944
+ }
945
+
946
+ /**
947
+ * Emit a static local image as an astro:assets <Picture>. Called only after
948
+ * isStaticImageSrc() has passed. Non-static paths should never reach here.
949
+ */
950
+ function emitStaticPictureImage(
951
+ src: string,
952
+ alt: string | undefined,
953
+ loading: string | undefined,
954
+ fetchpriority: string | undefined,
955
+ sizesValue: string,
956
+ classExpr: string,
957
+ styleAttr: string,
958
+ ifExpr: string,
959
+ ifClose: string,
960
+ blurHash: string | undefined,
961
+ ctx: AstroEmitContext
962
+ ): string {
963
+ const varName = registerStaticImageImport(src, ctx);
964
+
965
+ const widthsLiteral = `[${PICTURE_WIDTHS.join(', ')}]`;
966
+ const altAttr = alt !== undefined ? ` alt="${escapeJSX(String(alt))}"` : ' alt=""';
967
+ const loadingAttr = loading ? ` loading="${escapeJSX(loading)}"` : '';
968
+ const fetchpriorityAttr = fetchpriority ? ` fetchpriority="${escapeJSX(fetchpriority)}"` : '';
969
+
970
+ // Split classes so object-cover / opacity land on the <img>, not the
971
+ // outer <picture>/wrapper. splitStaticClassExpr returns null for dynamic
972
+ // class:list — callers fall back to a <div> wrapper without splitting.
973
+ const split = splitStaticClassExpr(classExpr);
974
+
975
+ if (blurHash) {
976
+ // Blur placeholder: put the blur background-image + layout classes on
977
+ // the <picture> element via pictureAttributes. This mirrors the legacy
978
+ // path where <picture> was both the blur host and layout container.
979
+ // The inner <img> gets fill + img-specific classes and clears the blur
980
+ // on load via this.parentElement (direct parent = <picture>).
981
+ const blurCss = `background-image:url(${escapeJSX(blurHash)});background-size:cover`;
982
+ const blurStyleAttr = injectInlineStyle(styleAttr, blurCss);
983
+ const onloadAttr = ` onload="this.parentElement.style.backgroundImage=''"`;
984
+
985
+ if (split) {
986
+ const outerClassValue = split.outerClasses.join(' ');
987
+ const pictureAttrs = formatPictureAttributesProp(outerClassValue, blurStyleAttr);
988
+ const innerClasses = [...split.innerClasses, ...IMG_FILL_CLASSES];
989
+ const innerClassAttr = ` class="${innerClasses.join(' ')}"`;
990
+ return `${ifExpr}${ind(ctx)}<Picture${pictureAttrs} src={${varName}}${altAttr}${innerClassAttr} formats={['avif','webp']} widths={${widthsLiteral}} sizes="${escapeJSX(sizesValue)}"${loadingAttr}${fetchpriorityAttr}${onloadAttr} />\n${ifClose}`;
991
+ }
992
+
993
+ // Dynamic class:list: fall back to a <div> wrapper since we can't
994
+ // convert class:list to a pictureAttributes JS object.
995
+ const wrapperClassExpr = classExpr;
996
+ const wrapperStyleAttr = injectInlineStyle(styleAttr, blurCss);
997
+ const fillClassAttr = ` class="${IMG_FILL_CLASSES.join(' ')}"`;
998
+ return (
999
+ `${ifExpr}${ind(ctx)}<div${wrapperClassExpr}${wrapperStyleAttr}>\n` +
1000
+ `${ind(ctx)} <Picture pictureAttributes={{class: "${IMG_FILL_CLASSES.join(' ')}"}} src={${varName}}${altAttr}${fillClassAttr} formats={['avif','webp']} widths={${widthsLiteral}} sizes="${escapeJSX(sizesValue)}"${loadingAttr}${fetchpriorityAttr} onload="this.parentElement.parentElement.style.backgroundImage=''" />\n` +
1001
+ `${ind(ctx)}</div>\n${ifClose}`
1002
+ );
1003
+ }
1004
+
1005
+ // Non-blur case: forward outer classes via pictureAttributes so they land
1006
+ // on the <picture>, inner img-specific classes as `class` on <Picture>.
1007
+ if (split) {
1008
+ const outerClassValue = split.outerClasses.join(' ');
1009
+ const innerClassAttr = split.innerClasses.length > 0
1010
+ ? ` class="${split.innerClasses.join(' ')}"`
1011
+ : '';
1012
+ const pictureAttrs = formatPictureAttributesProp(outerClassValue, styleAttr);
1013
+ return `${ifExpr}${ind(ctx)}<Picture${pictureAttrs} src={${varName}}${altAttr}${innerClassAttr} formats={['avif','webp']} widths={${widthsLiteral}} sizes="${escapeJSX(sizesValue)}"${loadingAttr}${fetchpriorityAttr} />\n${ifClose}`;
1014
+ }
1015
+
1016
+ // Dynamic class:list: wrap in a <div> so the dynamic expression survives.
1017
+ return (
1018
+ `${ifExpr}${ind(ctx)}<div${classExpr}${styleAttr}>\n` +
1019
+ `${ind(ctx)} <Picture src={${varName}}${altAttr} formats={['avif','webp']} widths={${widthsLiteral}} sizes="${escapeJSX(sizesValue)}"${loadingAttr}${fetchpriorityAttr} />\n` +
1020
+ `${ind(ctx)}</div>\n${ifClose}`
1021
+ );
1022
+ }
1023
+
709
1024
  function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
710
1025
  const style = node.style as StyleObject | ResponsiveStyleObject | undefined;
711
1026
 
@@ -716,6 +1031,9 @@ function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
716
1031
  node.generateElementClass
717
1032
  ) {
718
1033
  elementClass = buildElementClass(ctx, node.label);
1034
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1035
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1036
+ }
719
1037
  }
720
1038
 
721
1039
  const { classExpr, styleAttr } = buildClassAndStyleExpression(
@@ -744,7 +1062,26 @@ function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
744
1062
  if (height === undefined && metadata.height) height = metadata.height;
745
1063
  }
746
1064
 
747
- const sizesValue = sizes || DEFAULT_SIZES;
1065
+ // Compute real sizes from responsive style widths; user override wins.
1066
+ const sizesValue = sizes || computeSizesAttribute(style, ctx.breakpoints);
1067
+
1068
+ // Static local image → astro:assets <Picture> (ESM import, Astro-managed
1069
+ // optimization). Predicate rejects CMS/list/template/remote src.
1070
+ if (isStaticImageSrc(src, ctx)) {
1071
+ return emitStaticPictureImage(
1072
+ src,
1073
+ alt,
1074
+ loading,
1075
+ fetchpriority,
1076
+ sizesValue,
1077
+ classExpr,
1078
+ styleAttr,
1079
+ emitIfOpen(node, ctx),
1080
+ emitIfClose(node, ctx),
1081
+ metadata?.blurHash,
1082
+ ctx
1083
+ );
1084
+ }
748
1085
 
749
1086
  // Build remaining attributes (exclude image-specific ones we handle manually)
750
1087
  const imageSpecificKeys = new Set(['src', 'alt', 'loading', 'width', 'height', 'sizes', 'srcset', 'fetchpriority']);
@@ -765,56 +1102,82 @@ function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
765
1102
  if (width !== undefined) imgAttrs += ` width="${escapeJSX(String(width))}"`;
766
1103
  if (height !== undefined) imgAttrs += ` height="${escapeJSX(String(height))}"`;
767
1104
 
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
- }
1105
+ const hasAvif = !!(metadata?.avifSrcset && ctx.imageFormat !== 'webp');
1106
+ const hasBlur = !!metadata?.blurHash;
1107
+ // Any time we need a wrapper to host the blur placeholder or <source> tags.
1108
+ const useWrapper = hasAvif || hasBlur;
1109
+
1110
+ // Blur placeholder is painted on the wrapper element (NOT the <img>, which
1111
+ // is a replaced element and doesn't paint CSS backgrounds reliably).
1112
+ // Once the real image loads we clear the background on the wrapper so the
1113
+ // low-quality placeholder disappears.
1114
+ const blurWrapperCss = hasBlur
1115
+ ? `background-image:url(${escapeJSX(metadata!.blurHash!)});background-size:cover`
1116
+ : '';
1117
+ const blurOnload = hasBlur
1118
+ ? ` onload="this.parentElement.style.backgroundImage=''"`
1119
+ : '';
773
1120
 
774
1121
  // Conditional rendering
775
1122
  const ifExpr = emitIfOpen(node, ctx);
776
1123
  const ifClose = emitIfClose(node, ctx);
777
1124
 
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="([^"]*)"/);
1125
+ if (useWrapper) {
1126
+ // The inner <img> must fill the picture wrapper; otherwise it renders at
1127
+ // its intrinsic size and the wrapper's layout (w/h/position) is wasted.
1128
+ const imgFillClasses = IMG_FILL_CLASSES.slice();
1129
+
782
1130
  const classListMatch = classExpr.match(/class:list={\[(.+)\]}/);
1131
+ const classMatch = classExpr.match(/class="([^"]*)"/);
783
1132
 
1133
+ let pictureClassExpr = '';
1134
+ let imgClassAttr = '';
784
1135
  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)
1136
+ // Dynamic class:list - put it all on the picture. The inner img only
1137
+ // needs fill classes (splitting dynamic class lists isn't worth it).
1138
+ pictureClassExpr = classExpr;
1139
+ imgClassAttr = ` class="${imgFillClasses.join(' ')}"`;
1140
+ } else {
1141
+ const allClasses = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
1142
+ const { pictureClasses, imgClasses } = splitImageClasses(allClasses);
1143
+ const fullImgClasses = [...imgClasses, ...imgFillClasses];
1144
+ pictureClassExpr = pictureClasses.length > 0 ? ` class="${pictureClasses.join(' ')}"` : '';
1145
+ imgClassAttr = fullImgClasses.length > 0 ? ` class="${fullImgClasses.join(' ')}"` : '';
1146
+ }
1147
+
1148
+ const wrapperStyleAttr = injectInlineStyle(styleAttr, blurWrapperCss);
1149
+
1150
+ // With AVIF: full <picture> + <source> elements
1151
+ if (hasAvif) {
787
1152
  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` +
1153
+ `${ifExpr}${ind(ctx)}<picture${pictureClassExpr}${wrapperStyleAttr}>\n` +
1154
+ `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata!.avifSrcset!)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1155
+ `${ind(ctx)} <source type="image/webp" srcset="${escapeJSX(metadata!.srcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1156
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurOnload}${otherAttrsStr} />\n` +
792
1157
  `${ind(ctx)}</picture>\n${ifClose}`
793
1158
  );
794
1159
  }
795
1160
 
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
-
1161
+ // No AVIF, but we still need the wrapper for blur. Put srcset on the <img>
1162
+ // itself since we're not using <source>.
1163
+ if (metadata?.srcset) {
1164
+ imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
1165
+ imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
1166
+ }
802
1167
  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` +
1168
+ `${ifExpr}${ind(ctx)}<picture${pictureClassExpr}${wrapperStyleAttr}>\n` +
1169
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurOnload}${otherAttrsStr} />\n` +
807
1170
  `${ind(ctx)}</picture>\n${ifClose}`
808
1171
  );
809
1172
  }
810
1173
 
811
- // Fallback: regular img with WebP srcset if available
1174
+ // No wrapper needed: plain <img> with optional srcset/sizes
812
1175
  if (metadata?.srcset) {
813
1176
  imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
814
1177
  imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
815
1178
  }
816
1179
 
817
- return `${ifExpr}${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs}${blurStyle}${otherAttrsStr} />\n${ifClose}`;
1180
+ return `${ifExpr}${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs}${otherAttrsStr} />\n${ifClose}`;
818
1181
  }
819
1182
 
820
1183
  function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
@@ -857,6 +1220,9 @@ function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
857
1220
  node.generateElementClass
858
1221
  ) {
859
1222
  elementClass = buildElementClass(ctx, label);
1223
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1224
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1225
+ }
860
1226
  }
861
1227
 
862
1228
  const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
@@ -883,8 +1249,8 @@ function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
883
1249
  }
884
1250
 
885
1251
  function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContext): string {
886
- const name = node.component;
887
- ctx.imports.add(name);
1252
+ const name = astroComponentName(node.component);
1253
+ ctx.imports.add(node.component);
888
1254
 
889
1255
  const ifExpr = emitIfOpen(node, ctx);
890
1256
 
@@ -929,16 +1295,24 @@ function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContex
929
1295
  } else if (ctx.isComponentDef && isI18nValue(value)) {
930
1296
  // i18n object in component def — wrap with r() resolver (resolved at runtime via Astro.currentLocale)
931
1297
  ctx.needsI18nResolver = true;
932
- propParts.push(`${key}={r(${JSON.stringify(value)})}`);
1298
+ propParts.push(`${key}={r(${JSON.stringify(stripRawHtmlPrefixDeep(value))})}`);
933
1299
  } else {
934
- propParts.push(`${key}=${formatPropValue(value)}`);
1300
+ // Rich-text / embed props contain HTML/SVG — pass as template literal
1301
+ // so the component can render via set:html without entity escaping
1302
+ const targetInterface = ctx.globalComponents[name]?.component?.interface;
1303
+ const propDef = targetInterface?.[key];
1304
+ if (typeof value === 'string' && (propDef?.type === 'rich-text' || propDef?.type === 'embed')) {
1305
+ propParts.push(`${key}={\`${escapeTemplateLiteral(stripRawHtmlPrefixDeep(value) as string)}\`}`);
1306
+ } else {
1307
+ propParts.push(`${key}=${formatPropValue(value)}`);
1308
+ }
935
1309
  }
936
1310
  }
937
1311
  }
938
1312
 
939
1313
  // Instance-level style overrides as className (Tailwind)
940
1314
  if (node.style) {
941
- const { classes: instanceClasses } = responsiveStylesToTailwind(node.style as StyleObject | ResponsiveStyleObject, ctx.breakpoints);
1315
+ const { classes: instanceClasses } = responsiveStylesToTailwind(node.style as StyleObject | ResponsiveStyleObject, ctx.breakpoints, ctx.responsiveScales);
942
1316
  if (instanceClasses.length > 0) {
943
1317
  propParts.push(`class="${instanceClasses.join(' ')}"`);
944
1318
  }
@@ -946,7 +1320,11 @@ function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContex
946
1320
 
947
1321
  const propsStr = propParts.length > 0 ? ' ' + propParts.join(' ') : '';
948
1322
 
949
- const children = emitChildren(node.children, ctx);
1323
+ // Reset elementPath to [0] when entering a component instance, matching SSR's
1324
+ // renderComponent behavior. This ensures hash-based element class names for
1325
+ // slot content are identical between SSR (CSS) and Astro (HTML).
1326
+ const childCtx = { ...ctx, elementPath: [0] };
1327
+ const children = emitChildren(node.children, childCtx);
950
1328
 
951
1329
  if (!children.trim()) {
952
1330
  return `${ifExpr}${ind(ctx)}<${name}${propsStr} />\n${emitIfClose(node, ctx)}`;
@@ -983,6 +1361,9 @@ function emitEmbedNode(node: EmbedNode, ctx: AstroEmitContext): string {
983
1361
  node.generateElementClass
984
1362
  ) {
985
1363
  elementClass = buildElementClass(ctx, node.label);
1364
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1365
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1366
+ }
986
1367
  }
987
1368
 
988
1369
  const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
@@ -1055,6 +1436,9 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
1055
1436
  node.generateElementClass
1056
1437
  ) {
1057
1438
  elementClass = buildElementClass(ctx, node.label);
1439
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1440
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1441
+ }
1058
1442
  }
1059
1443
 
1060
1444
  // Build class expression with olink base class
@@ -1151,6 +1535,9 @@ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1151
1535
  node.generateElementClass
1152
1536
  ) {
1153
1537
  elementClass = buildElementClass(ctx, node.label);
1538
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1539
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1540
+ }
1154
1541
  }
1155
1542
 
1156
1543
  const { classExpr, styleAttr } = buildClassAndStyleExpression(
@@ -1163,6 +1550,24 @@ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1163
1550
  const src = node.src as string | undefined;
1164
1551
  const alt = node.alt as string | undefined;
1165
1552
 
1553
+ // Static local image → astro:assets <Picture>
1554
+ if (isStaticImageSrc(src, ctx)) {
1555
+ const staticMeta = ctx.imageMetadataMap!.get(src)!;
1556
+ return emitStaticPictureImage(
1557
+ src,
1558
+ alt,
1559
+ undefined,
1560
+ undefined,
1561
+ computeSizesAttribute(style, ctx.breakpoints),
1562
+ classExpr,
1563
+ styleAttr,
1564
+ '',
1565
+ '',
1566
+ staticMeta.blurHash,
1567
+ ctx
1568
+ );
1569
+ }
1570
+
1166
1571
  let imgAttrs = '';
1167
1572
  if (src) imgAttrs += ` src="${escapeJSX(String(src))}"`;
1168
1573
  if (alt !== undefined) imgAttrs += ` alt="${escapeJSX(String(alt))}"`;
@@ -1170,32 +1575,43 @@ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1170
1575
  // Check for image metadata for responsive images
1171
1576
  const metadata = src ? ctx.imageMetadataMap?.get(String(src)) : undefined;
1172
1577
 
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
- }
1578
+ if (!metadata) {
1579
+ return `${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs} />\n`;
1580
+ }
1183
1581
 
1184
- const sizesValue = DEFAULT_SIZES;
1582
+ if (metadata.width !== undefined) imgAttrs += ` width="${metadata.width}"`;
1583
+ if (metadata.height !== undefined) imgAttrs += ` height="${metadata.height}"`;
1185
1584
 
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);
1585
+ const sizesValue = computeSizesAttribute(style, ctx.breakpoints);
1190
1586
 
1191
- const pictureClassAttr = pictureClasses.length > 0 ? ` class="${pictureClasses.join(' ')}"` : '';
1192
- const imgClassAttr = imgClasses.length > 0 ? ` class="${imgClasses.join(' ')}"` : '';
1587
+ const hasAvif = !!(metadata.avifSrcset && ctx.imageFormat !== 'webp');
1588
+ const hasBlur = !!metadata.blurHash;
1589
+ const useWrapper = hasAvif || hasBlur;
1193
1590
 
1591
+ const blurWrapperCss = hasBlur
1592
+ ? `background-image:url(${escapeJSX(metadata.blurHash!)});background-size:cover`
1593
+ : '';
1594
+ const blurOnload = hasBlur
1595
+ ? ` onload="this.parentElement.style.backgroundImage=''"`
1596
+ : '';
1597
+
1598
+ if (useWrapper) {
1599
+ const imgFillClasses = IMG_FILL_CLASSES.slice();
1600
+ const classMatch = classExpr.match(/class="([^"]*)"/);
1601
+ const allClasses = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
1602
+ const { pictureClasses, imgClasses } = splitImageClasses(allClasses);
1603
+ const fullImgClasses = [...imgClasses, ...imgFillClasses];
1604
+
1605
+ const pictureClassAttr = pictureClasses.length > 0 ? ` class="${pictureClasses.join(' ')}"` : '';
1606
+ const imgClassAttr = fullImgClasses.length > 0 ? ` class="${fullImgClasses.join(' ')}"` : '';
1607
+ const wrapperStyleAttr = injectInlineStyle(styleAttr, blurWrapperCss);
1608
+
1609
+ if (hasAvif) {
1194
1610
  return (
1195
- `${ind(ctx)}<picture${pictureClassAttr}${styleAttr}>\n` +
1196
- `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata.avifSrcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1611
+ `${ind(ctx)}<picture${pictureClassAttr}${wrapperStyleAttr}>\n` +
1612
+ `${ind(ctx)} <source type="image/avif" srcset="${escapeJSX(metadata.avifSrcset!)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1197
1613
  `${ind(ctx)} <source type="image/webp" srcset="${escapeJSX(metadata.srcset)}" sizes="${escapeJSX(sizesValue)}" />\n` +
1198
- `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurStyle} />\n` +
1614
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurOnload} />\n` +
1199
1615
  `${ind(ctx)}</picture>\n`
1200
1616
  );
1201
1617
  }
@@ -1204,6 +1620,16 @@ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1204
1620
  imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
1205
1621
  imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
1206
1622
  }
1623
+ return (
1624
+ `${ind(ctx)}<picture${pictureClassAttr}${wrapperStyleAttr}>\n` +
1625
+ `${ind(ctx)} <img${imgClassAttr}${imgAttrs}${blurOnload} />\n` +
1626
+ `${ind(ctx)}</picture>\n`
1627
+ );
1628
+ }
1629
+
1630
+ if (metadata.srcset) {
1631
+ imgAttrs += ` srcset="${escapeJSX(metadata.srcset)}"`;
1632
+ imgAttrs += ` sizes="${escapeJSX(sizesValue)}"`;
1207
1633
  }
1208
1634
 
1209
1635
  return `${ind(ctx)}<img${classExpr}${styleAttr}${imgAttrs} />\n`;
@@ -1370,6 +1796,9 @@ function emitLocaleListNode(node: LocaleListNode, ctx: AstroEmitContext): string
1370
1796
  let elementClass: string | null = null;
1371
1797
  if ((node.interactiveStyles && (node.interactiveStyles as InteractiveStyles).length > 0) || node.generateElementClass) {
1372
1798
  elementClass = buildElementClass(ctx, node.label);
1799
+ if (elementClass && (node.interactiveStyles as InteractiveStyles)?.length && ctx.collectedInteractiveStyles) {
1800
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1801
+ }
1373
1802
  }
1374
1803
 
1375
1804
  const { classExpr: containerClassExpr, styleAttr: containerStyleAttr } = buildClassAndStyleExpression(
@@ -1381,17 +1810,17 @@ function emitLocaleListNode(node: LocaleListNode, ctx: AstroEmitContext): string
1381
1810
 
1382
1811
  // Build item classes
1383
1812
  const itemStyle = node.itemStyle as StyleObject | ResponsiveStyleObject | undefined;
1384
- const itemResult = itemStyle ? responsiveStylesToTailwind(itemStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1813
+ const itemResult = itemStyle ? responsiveStylesToTailwind(itemStyle, ctx.breakpoints, ctx.responsiveScales) : { classes: [], dynamicStyles: {} };
1385
1814
  const itemClasses = itemResult.classes;
1386
1815
 
1387
1816
  // Build active item classes (item + active combined)
1388
1817
  const activeItemStyle = node.activeItemStyle as StyleObject | ResponsiveStyleObject | undefined;
1389
- const activeResult = activeItemStyle ? responsiveStylesToTailwind(activeItemStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1818
+ const activeResult = activeItemStyle ? responsiveStylesToTailwind(activeItemStyle, ctx.breakpoints, ctx.responsiveScales) : { classes: [], dynamicStyles: {} };
1390
1819
  const activeItemClasses = [...itemClasses, ...activeResult.classes];
1391
1820
 
1392
1821
  // Build separator classes
1393
1822
  const separatorStyle = node.separatorStyle as StyleObject | ResponsiveStyleObject | undefined;
1394
- const sepResult = separatorStyle ? responsiveStylesToTailwind(separatorStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1823
+ const sepResult = separatorStyle ? responsiveStylesToTailwind(separatorStyle, ctx.breakpoints, ctx.responsiveScales) : { classes: [], dynamicStyles: {} };
1395
1824
  const separatorClasses = sepResult.classes;
1396
1825
 
1397
1826
  // Build locale icon map
@@ -1404,7 +1833,7 @@ function emitLocaleListNode(node: LocaleListNode, ctx: AstroEmitContext): string
1404
1833
 
1405
1834
  // Flag classes
1406
1835
  const flagStyle = node.flagStyle as StyleObject | ResponsiveStyleObject | undefined;
1407
- const flagResult = flagStyle ? responsiveStylesToTailwind(flagStyle, ctx.breakpoints) : { classes: [], dynamicStyles: {} };
1836
+ const flagResult = flagStyle ? responsiveStylesToTailwind(flagStyle, ctx.breakpoints, ctx.responsiveScales) : { classes: [], dynamicStyles: {} };
1408
1837
  const flagClasses = flagResult.classes;
1409
1838
 
1410
1839
  // Build links