meno-core 1.0.45 → 1.0.46

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 (56) hide show
  1. package/build-astro.ts +214 -63
  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-BZQKEJQY.js → chunk-77ZB6353.js} +29 -18
  7. package/dist/chunks/chunk-77ZB6353.js.map +7 -0
  8. package/dist/chunks/{chunk-TVH3TC2T.js → chunk-C6U5T5S5.js} +6 -6
  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-5Z5VQRTJ.js → chunk-I7YIGZXT.js} +4 -4
  12. package/dist/chunks/{chunk-5Z5VQRTJ.js.map → chunk-I7YIGZXT.js.map} +2 -2
  13. package/dist/chunks/{chunk-OUNJ76QM.js → chunk-ORN7S4AP.js} +5 -5
  14. package/dist/chunks/{chunk-GYF3ABI3.js → chunk-UUA5LEWF.js} +3 -3
  15. package/dist/chunks/{chunk-GYF3ABI3.js.map → chunk-UUA5LEWF.js.map} +2 -2
  16. package/dist/chunks/{chunk-WQSG5WHC.js → chunk-ZTKHJQ2Z.js} +2 -2
  17. package/dist/chunks/{chunk-F7MA62WG.js → chunk-ZWYDT3QJ.js} +3 -3
  18. package/dist/chunks/{configService-6KTT6GRT.js → configService-DYCUEURL.js} +3 -3
  19. package/dist/chunks/{constants-L5IKLB6U.js → constants-GWBAD66U.js} +2 -2
  20. package/dist/entries/server-router.js +7 -7
  21. package/dist/lib/client/index.js +4 -4
  22. package/dist/lib/server/index.js +586 -142
  23. package/dist/lib/server/index.js.map +3 -3
  24. package/dist/lib/shared/index.js +7 -3
  25. package/dist/lib/shared/index.js.map +2 -2
  26. package/dist/lib/test-utils/index.js +1 -1
  27. package/lib/client/templateEngine.test.ts +64 -0
  28. package/lib/server/astro/astroEmitHelpers.ts +18 -0
  29. package/lib/server/astro/cmsPageEmitter.ts +31 -1
  30. package/lib/server/astro/componentEmitter.test.ts +59 -0
  31. package/lib/server/astro/componentEmitter.ts +43 -10
  32. package/lib/server/astro/cssCollector.ts +58 -11
  33. package/lib/server/astro/nodeToAstro.test.ts +397 -5
  34. package/lib/server/astro/nodeToAstro.ts +478 -63
  35. package/lib/server/astro/pageEmitter.ts +31 -1
  36. package/lib/server/astro/tailwindMapper.test.ts +119 -0
  37. package/lib/server/astro/tailwindMapper.ts +67 -1
  38. package/lib/server/runtime/httpServer.ts +12 -4
  39. package/lib/server/ssr/htmlGenerator.ts +1 -1
  40. package/lib/server/ssr/jsCollector.ts +2 -2
  41. package/lib/server/ssr/ssrRenderer.test.ts +32 -0
  42. package/lib/server/ssr/ssrRenderer.ts +26 -11
  43. package/lib/shared/constants.ts +1 -0
  44. package/lib/shared/cssGeneration.test.ts +109 -3
  45. package/lib/shared/cssGeneration.ts +98 -13
  46. package/lib/shared/cssNamedColors.ts +47 -0
  47. package/lib/shared/cssProperties.ts +2 -2
  48. package/lib/shared/index.ts +1 -0
  49. package/package.json +1 -1
  50. package/dist/chunks/chunk-BZQKEJQY.js.map +0 -7
  51. /package/dist/chunks/{chunk-TVH3TC2T.js.map → chunk-C6U5T5S5.js.map} +0 -0
  52. /package/dist/chunks/{chunk-OUNJ76QM.js.map → chunk-ORN7S4AP.js.map} +0 -0
  53. /package/dist/chunks/{chunk-WQSG5WHC.js.map → chunk-ZTKHJQ2Z.js.map} +0 -0
  54. /package/dist/chunks/{chunk-F7MA62WG.js.map → chunk-ZWYDT3QJ.js.map} +0 -0
  55. /package/dist/chunks/{configService-6KTT6GRT.js.map → configService-DYCUEURL.js.map} +0 -0
  56. /package/dist/chunks/{constants-L5IKLB6U.js.map → constants-GWBAD66U.js.map} +0 -0
@@ -9,6 +9,7 @@ import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
9
  import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
10
  import type { ImageMetadataMap } from '../ssr/imageMetadata';
11
11
  import type { SlugMap } from '../../shared/slugTranslator';
12
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
12
13
 
13
14
  // ---------------------------------------------------------------------------
14
15
  // Types
@@ -43,6 +44,8 @@ export interface PageEmitOptions {
43
44
  pageName: string;
44
45
  /** Breakpoint config for responsive Tailwind classes */
45
46
  breakpoints?: BreakpointConfig;
47
+ /** Responsive scales config for auto-scaling at breakpoints */
48
+ responsiveScales?: ResponsiveScales;
46
49
  /** Image metadata map for responsive image generation */
47
50
  imageMetadataMap?: ImageMetadataMap;
48
51
  /** I18n config for locale list rendering */
@@ -53,6 +56,12 @@ export interface PageEmitOptions {
53
56
  slugMappings?: SlugMap[];
54
57
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> */
55
58
  imageFormat?: 'webp' | 'avif';
59
+ /**
60
+ * Raw-HTML slice → processed HTML captured during the page's SSR pass.
61
+ * Ensures `<Fragment set:html>` matches SSR output for rich-text content
62
+ * (image rewriting, component expansion, link localization).
63
+ */
64
+ processedRawHtml?: Map<string, string>;
56
65
  }
57
66
 
58
67
  // ---------------------------------------------------------------------------
@@ -60,7 +69,12 @@ export interface PageEmitOptions {
60
69
  // ---------------------------------------------------------------------------
61
70
 
62
71
  function escapeTemplateLiteral(s: string): string {
63
- return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
72
+ return s
73
+ .replace(/\\/g, '\\\\')
74
+ .replace(/`/g, '\\`')
75
+ .replace(/\$\{/g, '\\${')
76
+ .replace(/\u2028/g, '\\u2028')
77
+ .replace(/\u2029/g, '\\u2029');
64
78
  }
65
79
 
66
80
  function escapeJSX(s: string): string {
@@ -100,11 +114,13 @@ export function emitAstroPage(options: PageEmitOptions): string {
100
114
  ssrFallbacks,
101
115
  pageName,
102
116
  breakpoints: breakpointsOpt,
117
+ responsiveScales,
103
118
  imageMetadataMap,
104
119
  i18nConfig,
105
120
  currentPageSlugMap,
106
121
  slugMappings,
107
122
  imageFormat,
123
+ processedRawHtml,
108
124
  } = options;
109
125
 
110
126
  const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
@@ -127,6 +143,7 @@ export function emitAstroPage(options: PageEmitOptions): string {
127
143
  fileType: 'page',
128
144
  fileName: pageName,
129
145
  breakpoints,
146
+ responsiveScales,
130
147
  imageMetadataMap,
131
148
  locale,
132
149
  i18nConfig,
@@ -136,6 +153,9 @@ export function emitAstroPage(options: PageEmitOptions): string {
136
153
  slugMappings,
137
154
  i18nDefaultLocale: i18nConfig?.defaultLocale,
138
155
  imageFormat,
156
+ processedRawHtml,
157
+ imageImports: new Map<string, string>(),
158
+ fileDepth,
139
159
  };
140
160
 
141
161
  // Emit the template body
@@ -152,6 +172,16 @@ export function emitAstroPage(options: PageEmitOptions): string {
152
172
 
153
173
  importLines.push(`import BaseLayout from '${layoutImportPath}';`);
154
174
 
175
+ // Static image imports (astro:assets <Picture>). Map size is the single
176
+ // source of truth — a separate boolean would be lost by child-ctx spreads.
177
+ if (ctx.imageImports && ctx.imageImports.size > 0) {
178
+ importLines.push(`import { Picture } from 'astro:assets';`);
179
+ const sortedImages = Array.from(ctx.imageImports.entries()).sort(([a], [b]) => a.localeCompare(b));
180
+ for (const [varName, importPath] of sortedImages) {
181
+ importLines.push(`import ${varName} from '${importPath}';`);
182
+ }
183
+ }
184
+
155
185
  // Sort component imports alphabetically
156
186
  const componentImports = Array.from(ctx.imports).sort();
157
187
  for (const comp of componentImports) {
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Tests for tailwindMapper.ts — specifically the auto-responsive scaling
3
+ * pass in `responsiveStylesToTailwind`.
4
+ */
5
+
6
+ import { describe, test, expect } from 'bun:test';
7
+ import { responsiveStylesToTailwind } from './tailwindMapper';
8
+ import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
9
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
10
+
11
+ const scalesEnabled: ResponsiveScales = {
12
+ enabled: true,
13
+ baseReference: 16,
14
+ padding: { tablet: 0.75, mobile: 0.5 },
15
+ fontSize: { tablet: 0.88, mobile: 0.75 },
16
+ gap: { tablet: 0.65, mobile: 0.4 },
17
+ };
18
+
19
+ const scalesDisabled: ResponsiveScales = {
20
+ ...scalesEnabled,
21
+ enabled: false,
22
+ };
23
+
24
+ describe('responsiveStylesToTailwind — auto-responsive scaling', () => {
25
+ test('emits scaled max-[Npx]: variants for base scalable property', () => {
26
+ const { classes } = responsiveStylesToTailwind(
27
+ { base: { padding: '40px' } },
28
+ DEFAULT_BREAKPOINTS,
29
+ scalesEnabled
30
+ );
31
+ // base: 40px; tablet: 40 + (40-16)*(0.75-1) = 34px; mobile: 40 + (40-16)*(0.5-1) = 28px
32
+ expect(classes).toEqual([
33
+ 'p-[40px]',
34
+ 'max-[1024px]:p-[34px]',
35
+ 'max-[540px]:p-[28px]',
36
+ ]);
37
+ });
38
+
39
+ test('explicit breakpoint override wins — no auto-scaled variant for that breakpoint/property pair', () => {
40
+ const { classes } = responsiveStylesToTailwind(
41
+ { base: { padding: '40px' }, tablet: { padding: '30px' } },
42
+ DEFAULT_BREAKPOINTS,
43
+ scalesEnabled
44
+ );
45
+ expect(classes).toContain('p-[40px]');
46
+ expect(classes).toContain('max-[1024px]:p-[30px]');
47
+ // no max-[1024px]:p-[34px] auto-scaled variant
48
+ expect(classes).not.toContain('max-[1024px]:p-[34px]');
49
+ // mobile still gets auto-scaled from base since it has no explicit override
50
+ expect(classes).toContain('max-[540px]:p-[28px]');
51
+ });
52
+
53
+ test('scaling disabled → output identical to no-scaling path', () => {
54
+ const withScales = responsiveStylesToTailwind(
55
+ { base: { padding: '40px' } },
56
+ DEFAULT_BREAKPOINTS,
57
+ scalesDisabled
58
+ );
59
+ const withoutScales = responsiveStylesToTailwind(
60
+ { base: { padding: '40px' } },
61
+ DEFAULT_BREAKPOINTS
62
+ );
63
+ expect(withScales.classes).toEqual(withoutScales.classes);
64
+ expect(withScales.classes).toEqual(['p-[40px]']);
65
+ });
66
+
67
+ test('values at or below baseReference produce no scaled variants', () => {
68
+ const { classes } = responsiveStylesToTailwind(
69
+ { base: { padding: '8px' } }, // 8 < 16 → stays unchanged → skipped
70
+ DEFAULT_BREAKPOINTS,
71
+ scalesEnabled
72
+ );
73
+ expect(classes).toEqual(['p-[8px]']);
74
+ });
75
+
76
+ test('non-scalable property (e.g. color) is left untouched', () => {
77
+ const { classes } = responsiveStylesToTailwind(
78
+ { base: { color: '#abc' } },
79
+ DEFAULT_BREAKPOINTS,
80
+ scalesEnabled
81
+ );
82
+ // Just the base color class — no max-[Npx] prefix because color isn't in any scale category
83
+ expect(classes.filter((c) => c.startsWith('max-['))).toEqual([]);
84
+ });
85
+
86
+ test('multi-value properties scale every numeric part', () => {
87
+ const { classes } = responsiveStylesToTailwind(
88
+ { base: { padding: '40px 80px' } },
89
+ DEFAULT_BREAKPOINTS,
90
+ scalesEnabled
91
+ );
92
+ // tablet: 40→34, 80→64; mobile: 40→28, 80→48
93
+ expect(classes).toContain('max-[1024px]:p-[34px_64px]');
94
+ expect(classes).toContain('max-[540px]:p-[28px_48px]');
95
+ });
96
+
97
+ test('flat style object (no `base`) gets no auto-scaling', () => {
98
+ const { classes } = responsiveStylesToTailwind(
99
+ { padding: '40px' },
100
+ DEFAULT_BREAKPOINTS,
101
+ scalesEnabled
102
+ );
103
+ // A flat style is treated as base with no breakpoints — nothing to
104
+ // auto-scale *into*. The scaling pass only runs on the responsive branch.
105
+ expect(classes.filter((c) => c.startsWith('max-['))).toEqual([]);
106
+ });
107
+
108
+ test('fontSize scales using the fontSize category', () => {
109
+ const { classes } = responsiveStylesToTailwind(
110
+ { base: { fontSize: '100px' } },
111
+ DEFAULT_BREAKPOINTS,
112
+ scalesEnabled
113
+ );
114
+ // tablet: 100 + (100-16)*(0.88-1) = 100 - 10.08 = round(89.92) = 90
115
+ // mobile: 100 + (100-16)*(0.75-1) = 100 - 21 = 79
116
+ expect(classes).toContain('max-[1024px]:text-[length:90px]');
117
+ expect(classes).toContain('max-[540px]:text-[length:79px]');
118
+ });
119
+ });
@@ -10,6 +10,8 @@ import type {
10
10
  StyleMapping,
11
11
  } from '../../shared/types/styles';
12
12
  import type { BreakpointConfig } from '../../shared/breakpoints';
13
+ import type { ResponsiveScales, CSSPropertyType } from '../../shared/responsiveScaling';
14
+ import { getScaleMultiplier, scalePropertyValue } from '../../shared/responsiveScaling';
13
15
 
14
16
  // ---------------------------------------------------------------------------
15
17
  // Helpers
@@ -551,7 +553,8 @@ export function stylesToTailwind(
551
553
  */
552
554
  export function responsiveStylesToTailwind(
553
555
  style: StyleObject | ResponsiveStyleObject | null | undefined,
554
- breakpoints: BreakpointConfig
556
+ breakpoints: BreakpointConfig,
557
+ responsiveScales?: ResponsiveScales
555
558
  ): { classes: string[]; dynamicStyles: Record<string, string> } {
556
559
  if (!style) return { classes: [], dynamicStyles: {} };
557
560
 
@@ -597,6 +600,13 @@ export function responsiveStylesToTailwind(
597
600
  allClasses.push(...classes.map(cls => `${prefix}${cls}`));
598
601
  Object.assign(allDynamicStyles, dynamicStyles);
599
602
  }
603
+
604
+ // Auto-responsive scaling: for each scalable base property, emit scaled
605
+ // max-[Npx]: variants for every enabled breakpoint that doesn't already
606
+ // have an explicit override for that property.
607
+ if (responsiveScales?.enabled === true && responsive.base) {
608
+ appendAutoScaledClasses(responsive, breakpoints, responsiveScales, allClasses);
609
+ }
600
610
  } else {
601
611
  // Flat style object — treat as base
602
612
  const { classes, dynamicStyles } = stylesToTailwind(style as StyleObject);
@@ -606,3 +616,59 @@ export function responsiveStylesToTailwind(
606
616
 
607
617
  return { classes: allClasses, dynamicStyles: allDynamicStyles };
608
618
  }
619
+
620
+ /**
621
+ * Emit `max-[Npx]:` class variants with pre-scaled values for every base
622
+ * property in a scale category, skipping breakpoints where the author set
623
+ * an explicit override for the same property.
624
+ *
625
+ * Breakpoints are processed in descending pixel order so the emitted class
626
+ * order matches the cascade order Tailwind's variant compiler expects.
627
+ */
628
+ function appendAutoScaledClasses(
629
+ responsive: ResponsiveStyleObject,
630
+ breakpoints: BreakpointConfig,
631
+ responsiveScales: ResponsiveScales,
632
+ out: string[]
633
+ ): void {
634
+ const base = responsive.base;
635
+ if (!base) return;
636
+
637
+ const baseRef = responsiveScales.baseReference ?? 16;
638
+
639
+ const sortedBps = Object.entries(breakpoints)
640
+ .map(([name, cfg]) => ({ name, value: cfg?.breakpoint }))
641
+ .filter((bp): bp is { name: string; value: number } =>
642
+ typeof bp.value === 'number' && bp.value > 0
643
+ )
644
+ .sort((a, b) => b.value - a.value);
645
+
646
+ for (const [property, value] of Object.entries(base)) {
647
+ if (isStyleMapping(value)) continue;
648
+ if (value == null) continue;
649
+
650
+ const strValue = String(value);
651
+ if (strValue === '' || hasTemplateExpression(strValue)) continue;
652
+
653
+ for (const { name: bpName, value: bpPixels } of sortedBps) {
654
+ // Explicit override at this breakpoint wins — skip auto-scaling.
655
+ const bpBranch = responsive[bpName] as StyleObject | undefined;
656
+ if (bpBranch && property in bpBranch) continue;
657
+
658
+ const scale = getScaleMultiplier(
659
+ responsiveScales,
660
+ property as CSSPropertyType,
661
+ bpName
662
+ );
663
+ if (scale == null) continue;
664
+
665
+ const scaledValue = scalePropertyValue(strValue, baseRef, scale);
666
+ if (scaledValue == null || scaledValue === strValue) continue;
667
+
668
+ const scaledClass = propertyToTailwind(property, scaledValue);
669
+ if (!scaledClass) continue;
670
+
671
+ out.push(`max-[${bpPixels}px]:${scaledClass}`);
672
+ }
673
+ }
674
+ }
@@ -7,7 +7,12 @@ const isBun = typeof globalThis.Bun !== 'undefined';
7
7
 
8
8
  export interface RuntimeServer {
9
9
  port: number;
10
- stop(): void;
10
+ /**
11
+ * Stop the server. When `force` is true, active connections are
12
+ * closed immediately (Bun: closeActiveConnections=true). Defaults to
13
+ * a graceful stop that lets in-flight requests finish.
14
+ */
15
+ stop(force?: boolean): void;
11
16
  }
12
17
 
13
18
  export interface RuntimeWSClient {
@@ -76,8 +81,8 @@ function createBunServer(options: ServerOptions): RuntimeServer {
76
81
 
77
82
  return {
78
83
  port: server.port ?? port,
79
- stop() {
80
- server.stop();
84
+ stop(force?: boolean) {
85
+ server.stop(force === true);
81
86
  },
82
87
  };
83
88
  }
@@ -219,7 +224,10 @@ async function createNodeServer(options: ServerOptions): Promise<RuntimeServer>
219
224
 
220
225
  resolve({
221
226
  port: actualPort,
222
- stop() {
227
+ stop(_force?: boolean) {
228
+ // Node http.Server.close is graceful by default; the force
229
+ // flag is accepted for API parity with Bun but currently has
230
+ // no additional effect on the Node path.
223
231
  server.close();
224
232
  },
225
233
  });
@@ -388,7 +388,7 @@ export async function generateSSRHTML(
388
388
 
389
389
  // Generate interactive styles CSS from map collected during render
390
390
  const interactiveCSS = rendered.interactiveStylesMap.size > 0
391
- ? generateAllInteractiveCSS(rendered.interactiveStylesMap, breakpointConfig, remConversionConfig)
391
+ ? generateAllInteractiveCSS(rendered.interactiveStylesMap, breakpointConfig, remConversionConfig, responsiveScalesConfig)
392
392
  : '';
393
393
 
394
394
  // Print warnings for any unmapped styles found during build
@@ -128,8 +128,8 @@ __meno.initComponent("${name}", function(el, props) {
128
128
  ${js}
129
129
  });`);
130
130
  } else {
131
- // No defineVars - emit as-is (backward compatible)
132
- jsCodeBlocks.push(`// Component: ${name}\n${js}`);
131
+ // No defineVars - wrap in IIFE to prevent variable conflicts between components
132
+ jsCodeBlocks.push(`// Component: ${name}\n(function(){\n${js}\n})();`);
133
133
  }
134
134
  };
135
135
 
@@ -1891,6 +1891,38 @@ describe('ssrRenderer', () => {
1891
1891
  const html = await render(node);
1892
1892
  expect(html).toContain('<p>Rich <strong>text</strong></p>');
1893
1893
  });
1894
+
1895
+ test('exposes processedRawHtmlCollector mapping raw slice → processed HTML', async () => {
1896
+ const { RAW_HTML_PREFIX } = await import('../../shared/constants');
1897
+ const rawSlice = '<p>Rich <strong>text</strong></p>';
1898
+ const node = {
1899
+ type: 'node',
1900
+ tag: 'div',
1901
+ children: `${RAW_HTML_PREFIX}${rawSlice}`,
1902
+ };
1903
+ const result = await buildComponentHTML(node as any);
1904
+ expect(result.processedRawHtmlCollector).toBeInstanceOf(Map);
1905
+ expect(result.processedRawHtmlCollector.get(rawSlice)).toBe(rawSlice);
1906
+ });
1907
+
1908
+ test('collector records localized links for non-default locale rich text', async () => {
1909
+ const { RAW_HTML_PREFIX } = await import('../../shared/constants');
1910
+ const rawSlice = '<p>See <a href="/about">story</a></p>';
1911
+ const node = {
1912
+ type: 'node',
1913
+ tag: 'div',
1914
+ children: `${RAW_HTML_PREFIX}${rawSlice}`,
1915
+ };
1916
+ const result = await buildComponentHTML(
1917
+ node as any,
1918
+ {},
1919
+ {},
1920
+ 'fr',
1921
+ { defaultLocale: 'en', locales: [{ code: 'en' }, { code: 'fr' }] } as any,
1922
+ );
1923
+ const processed = result.processedRawHtmlCollector.get(rawSlice);
1924
+ expect(processed).toContain('href="/fr/about"');
1925
+ });
1894
1926
  });
1895
1927
 
1896
1928
  // -----------------------------------------------------------------------
@@ -119,6 +119,12 @@ interface SSRContext {
119
119
  isProductionBuild?: boolean;
120
120
  /** Collector for SSR fallback HTML of complex nodes (list, locale-list) keyed by element path */
121
121
  ssrFallbackCollector?: Map<string, string>;
122
+ /**
123
+ * Collector mapping the raw slice of a RAW_HTML_PREFIX string to its fully processed HTML
124
+ * (after image rewriting, component expansion, and link localization). Consumed by the
125
+ * Astro exporter so its `<Fragment set:html>` output matches SSR exactly.
126
+ */
127
+ processedRawHtmlCollector?: Map<string, string>;
122
128
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> with AVIF+WebP sources */
123
129
  imageFormat?: 'webp' | 'avif';
124
130
  }
@@ -369,7 +375,7 @@ export async function buildComponentHTML(
369
375
  cmsContext?: CMSContext,
370
376
  cmsService?: CMSService,
371
377
  isProductionBuild?: boolean
372
- ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
378
+ ): Promise<{ html: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
373
379
  // Create map to collect interactive styles during render
374
380
  const interactiveStylesMap = new Map<string, InteractiveStyles>();
375
381
  // Create array to collect high-priority images for preloading
@@ -378,8 +384,10 @@ export async function buildComponentHTML(
378
384
  const neededCollections = new Set<string>();
379
385
  // Create map to collect SSR fallback HTML for complex nodes (list, locale-list)
380
386
  const ssrFallbackCollector = new Map<string, string>();
387
+ // Create map to collect raw-HTML → processed-HTML for Astro exporter parity
388
+ const processedRawHtmlCollector = new Map<string, string>();
381
389
 
382
- if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
390
+ if (!node) return { html: '', interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
383
391
 
384
392
  // Register components for this render
385
393
  ssrComponentRegistry.merge(globalComponents);
@@ -411,12 +419,13 @@ export async function buildComponentHTML(
411
419
  neededCollections, // Track collections that need client-side data
412
420
  isProductionBuild,
413
421
  ssrFallbackCollector, // Collect SSR fallback HTML for complex nodes
422
+ processedRawHtmlCollector, // Collect raw→processed HTML for Astro exporter
414
423
  imageFormat: configService.getImageFormat(),
415
424
  };
416
425
 
417
426
  const html = await renderNode(node, ctx);
418
427
 
419
- return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector };
428
+ return { html, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector };
420
429
  }
421
430
 
422
431
  /**
@@ -803,10 +812,12 @@ async function renderNode(
803
812
  }
804
813
  // Check for raw HTML marker (from rich-text fields) - don't escape
805
814
  if (text.startsWith(RAW_HTML_PREFIX)) {
806
- let rawHtml = text.slice(RAW_HTML_PREFIX.length);
815
+ const rawSlice = text.slice(RAW_HTML_PREFIX.length);
816
+ let rawHtml = rawSlice;
807
817
  if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
808
818
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
809
819
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
820
+ ctx.processedRawHtmlCollector?.set(rawSlice, rawHtml);
810
821
  return rawHtml;
811
822
  }
812
823
  return escapeHtml(text);
@@ -823,10 +834,12 @@ async function renderNode(
823
834
  }
824
835
  // Check for raw HTML marker (from rich-text fields) - don't escape
825
836
  if (text.startsWith(RAW_HTML_PREFIX)) {
826
- let rawHtml = text.slice(RAW_HTML_PREFIX.length);
837
+ const rawSlice = text.slice(RAW_HTML_PREFIX.length);
838
+ let rawHtml = rawSlice;
827
839
  if (ctx.imageMetadataMap) rawHtml = rewriteRichTextImages(rawHtml, ctx.imageMetadataMap, ctx.imageFormat);
828
840
  rawHtml = await expandRichTextComponents(rawHtml, ctx);
829
841
  rawHtml = localizeRichTextLinks(rawHtml, ctx);
842
+ ctx.processedRawHtmlCollector?.set(rawSlice, rawHtml);
830
843
  return rawHtml;
831
844
  }
832
845
  return escapeHtml(text);
@@ -1428,7 +1441,7 @@ async function renderHtmlElement(
1428
1441
  if (voidElements.includes(tag.toLowerCase())) {
1429
1442
  // Special handling for img tags - inject srcset and render as <picture> when AVIF available
1430
1443
  if (tag.toLowerCase() === 'img') {
1431
- return renderImageElement(propsWithStyleAndAttrs, classAttr, attrs, ctx);
1444
+ return renderImageElement(propsWithStyleAndAttrs, classAttr, styleAttr, attrs, ctx);
1432
1445
  }
1433
1446
 
1434
1447
  return `<${tag}${classAttr}${styleAttr}${attrs} />`;
@@ -1443,6 +1456,7 @@ async function renderHtmlElement(
1443
1456
  function renderImageElement(
1444
1457
  propsWithStyleAndAttrs: Record<string, unknown>,
1445
1458
  classAttr: string,
1459
+ styleAttr: string,
1446
1460
  attrs: string,
1447
1461
  ctx: SSRContext
1448
1462
  ): string {
@@ -1525,7 +1539,7 @@ function renderImageElement(
1525
1539
  ? ` class="${escapeHtml(imgClasses.join(' '))}"`
1526
1540
  : '';
1527
1541
 
1528
- return `<picture${pictureClassAttr}>` +
1542
+ return `<picture${pictureClassAttr}${styleAttr}>` +
1529
1543
  `<source type="image/avif" srcset="${escapeHtml(metadata.avifSrcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1530
1544
  `<source type="image/webp" srcset="${escapeHtml(metadata.srcset)}" sizes="${escapeHtml(sizesAttr)}" />` +
1531
1545
  `<img${imgClassAttr}${imgAttrs}${attrs} />` +
@@ -1538,7 +1552,7 @@ function renderImageElement(
1538
1552
  imgAttrs += ` sizes="${escapeHtml(sizesAttr)}"`;
1539
1553
  }
1540
1554
 
1541
- return `<img${classAttr}${imgAttrs}${attrs} />`;
1555
+ return `<img${classAttr}${styleAttr}${imgAttrs}${attrs} />`;
1542
1556
  }
1543
1557
 
1544
1558
  /**
@@ -1699,7 +1713,7 @@ export async function renderPageSSR(
1699
1713
  cmsContext?: CMSContext,
1700
1714
  cmsService?: CMSService,
1701
1715
  isProductionBuild?: boolean
1702
- ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string> }> {
1716
+ ): Promise<{ html: string; meta: string; title: string; javascript: string; componentCSS?: string; locale: string; interactiveStylesMap: Map<string, InteractiveStyles>; preloadImages: PreloadImage[]; neededCollections: Set<string>; ssrFallbackCollector: Map<string, string>; processedRawHtmlCollector: Map<string, string> }> {
1703
1717
  // Extract page content
1704
1718
  const rootNode = pageData?.root || undefined;
1705
1719
  if (!rootNode) {
@@ -1735,9 +1749,9 @@ export async function renderPageSSR(
1735
1749
 
1736
1750
  // Render the component tree to HTML with i18n and CMS support
1737
1751
  // Also collect interactive styles, preload images, and needed collections during render
1738
- const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector } = rootNode
1752
+ const { html: contentHTML, interactiveStylesMap, preloadImages, neededCollections, ssrFallbackCollector, processedRawHtmlCollector } = rootNode
1739
1753
  ? await buildComponentHTML(rootNode, globalComponents, pageComponents, effectiveLocale, config, slugMappings, pagePath, cmsContext, cmsService, isProductionBuild)
1740
- : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>() };
1754
+ : { html: '', interactiveStylesMap: new Map<string, InteractiveStyles>(), preloadImages: [], neededCollections: new Set<string>(), ssrFallbackCollector: new Map<string, string>(), processedRawHtmlCollector: new Map<string, string>() };
1741
1755
 
1742
1756
  // Collect JavaScript and CSS from all components
1743
1757
  const javascript = await collectComponentJavaScript(globalComponents, pageComponents);
@@ -1769,5 +1783,6 @@ export async function renderPageSSR(
1769
1783
  preloadImages,
1770
1784
  neededCollections,
1771
1785
  ssrFallbackCollector,
1786
+ processedRawHtmlCollector,
1772
1787
  };
1773
1788
  }
@@ -139,6 +139,7 @@ export const IFRAME_MESSAGE_TYPES = {
139
139
  DUPLICATE_ELEMENT: 'DUPLICATE_ELEMENT',
140
140
  MAKE_COMPONENT: 'MAKE_COMPONENT',
141
141
  SWAP_TO_COMPONENT: 'SWAP_TO_COMPONENT',
142
+ SMART_CONNECT: 'SMART_CONNECT',
142
143
  } as const;
143
144
 
144
145
  // Component node type constants
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { generateRuleForClass, generateUtilityCSS, extractUtilityClassesFromHTML } from './cssGeneration';
2
+ import { generateRuleForClass, generateUtilityCSS, generateSingleClassCSS, extractUtilityClassesFromHTML, generateInteractiveCSS } from './cssGeneration';
3
+ import { DEFAULT_BREAKPOINTS } from './breakpoints';
4
+ import type { ResponsiveScales } from './responsiveScaling';
5
+ import type { InteractiveStyles } from './types/styles';
3
6
 
4
7
  describe('extractUtilityClassesFromHTML', () => {
5
8
  test('extracts ins- classes', () => {
@@ -102,9 +105,33 @@ describe('cssGeneration', () => {
102
105
  expect(rule).toBe('border: 1px solid var(--primary);');
103
106
  });
104
107
 
105
- test('border-color generates correctly', () => {
108
+ test('border-color with named CSS color outputs directly', () => {
106
109
  const rule = generateRuleForClass('bc-red');
107
- expect(rule).toBe('border-color: var(--red);');
110
+ expect(rule).toBe('border-color: red;');
111
+ });
112
+
113
+ test('border-color with variable uses var()', () => {
114
+ const rule = generateRuleForClass('bc-primary');
115
+ expect(rule).toBe('border-color: var(--primary);');
116
+ });
117
+ });
118
+
119
+ describe('CSS named colors vs variables', () => {
120
+ test('color with named color outputs directly', () => {
121
+ expect(generateRuleForClass('c-red')).toBe('color: red;');
122
+ expect(generateRuleForClass('c-blue')).toBe('color: blue;');
123
+ expect(generateRuleForClass('c-transparent')).toBe('color: transparent;');
124
+ });
125
+
126
+ test('background-color with named color outputs directly', () => {
127
+ expect(generateRuleForClass('bgc-green')).toBe('background-color: green;');
128
+ expect(generateRuleForClass('bgc-white')).toBe('background-color: white;');
129
+ });
130
+
131
+ test('color with variable name still uses var()', () => {
132
+ expect(generateRuleForClass('c-primary')).toBe('color: var(--primary);');
133
+ expect(generateRuleForClass('c-text')).toBe('color: var(--text);');
134
+ expect(generateRuleForClass('bgc-background')).toBe('background-color: var(--background);');
108
135
  });
109
136
  });
110
137
 
@@ -190,3 +217,82 @@ describe('cssGeneration', () => {
190
217
  });
191
218
  });
192
219
  });
220
+
221
+ describe('generateSingleClassCSS - responsive classes', () => {
222
+ test('generates correct media query for tablet-prefixed class', () => {
223
+ const css = generateSingleClassCSS('t-fs-132px', DEFAULT_BREAKPOINTS);
224
+ expect(css).toContain('@media (max-width: 1024px)');
225
+ expect(css).toContain('font-size: 132px');
226
+ expect(css).toContain('.t-fs-132px');
227
+ });
228
+
229
+ test('generates correct media query for mobile-prefixed class', () => {
230
+ const css = generateSingleClassCSS('mob-fs-24px', DEFAULT_BREAKPOINTS);
231
+ expect(css).toContain('@media (max-width: 540px)');
232
+ expect(css).toContain('font-size: 24px');
233
+ expect(css).toContain('.mob-fs-24px');
234
+ });
235
+
236
+ test('generates base rule for non-prefixed class', () => {
237
+ const css = generateSingleClassCSS('fs-48px', DEFAULT_BREAKPOINTS);
238
+ expect(css).toContain('.fs-48px');
239
+ expect(css).toContain('font-size: 48px');
240
+ expect(css).not.toContain('@media');
241
+ });
242
+ });
243
+
244
+ describe('generateInteractiveCSS — auto-responsive scaling', () => {
245
+ const scalesEnabled: ResponsiveScales = {
246
+ enabled: true,
247
+ baseReference: 16,
248
+ padding: { tablet: 0.75, mobile: 0.5 },
249
+ fontSize: { tablet: 0.88, mobile: 0.75 },
250
+ };
251
+
252
+ test('flat interactive style gets scaled @media overrides', () => {
253
+ const styles: InteractiveStyles = [
254
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
255
+ ];
256
+ const css = generateInteractiveCSS('c_heading_text', styles, DEFAULT_BREAKPOINTS, undefined, scalesEnabled);
257
+ expect(css).toContain('.c_heading_text:hover { font-size: 100px; }');
258
+ // tablet: 100 + (100-16)*(0.88-1) = 89.92 → 90
259
+ expect(css).toContain('@media (max-width: 1024px) { .c_heading_text:hover { font-size: 90px; } }');
260
+ // mobile: 100 + (100-16)*(0.75-1) = 79
261
+ expect(css).toContain('@media (max-width: 540px) { .c_heading_text:hover { font-size: 79px; } }');
262
+ });
263
+
264
+ test('responsive style auto-scales properties not explicitly overridden', () => {
265
+ const styles: InteractiveStyles = [
266
+ {
267
+ prefix: '',
268
+ postfix: ':hover',
269
+ style: {
270
+ base: { padding: '40px', fontSize: '100px' },
271
+ tablet: { fontSize: '50px' }, // explicit tablet fontSize wins
272
+ },
273
+ },
274
+ ];
275
+ const css = generateInteractiveCSS('c_btn', styles, DEFAULT_BREAKPOINTS, undefined, scalesEnabled);
276
+ // Tablet @media must contain the explicit fontSize AND the auto-scaled padding
277
+ // padding tablet: 40 + (40-16)*(0.75-1) = 34
278
+ expect(css).toMatch(/@media \(max-width: 1024px\) \{[^}]*font-size:\s*50px[^}]*\}/);
279
+ expect(css).toMatch(/@media \(max-width: 1024px\) \{[^}]*padding:\s*34px[^}]*\}/);
280
+ // Mobile: no explicit branch — both padding and fontSize auto-scale from base
281
+ // padding mobile: 40 + (40-16)*(-0.5) = 28; fontSize mobile: 79
282
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*padding:\s*28px[^}]*\}/);
283
+ expect(css).toMatch(/@media \(max-width: 540px\) \{[^}]*font-size:\s*79px[^}]*\}/);
284
+ });
285
+
286
+ test('scaling disabled is a no-op', () => {
287
+ const styles: InteractiveStyles = [
288
+ { prefix: '', postfix: ':hover', style: { fontSize: '100px' } },
289
+ ];
290
+ const cssNoScales = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS);
291
+ const cssDisabled = generateInteractiveCSS('c_heading', styles, DEFAULT_BREAKPOINTS, undefined, {
292
+ ...scalesEnabled,
293
+ enabled: false,
294
+ });
295
+ expect(cssNoScales).toBe(cssDisabled);
296
+ expect(cssNoScales).not.toContain('@media');
297
+ });
298
+ });