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
@@ -0,0 +1,23 @@
1
+ import { RAW_HTML_PREFIX } from '../../shared/constants';
2
+
3
+ export function stripRawHtmlPrefixDeep<T>(value: T): T {
4
+ if (typeof value === 'string') {
5
+ return (value.startsWith(RAW_HTML_PREFIX) ? value.slice(RAW_HTML_PREFIX.length) : value) as T;
6
+ }
7
+ if (Array.isArray(value)) {
8
+ return value.map((item) => stripRawHtmlPrefixDeep(item)) as unknown as T;
9
+ }
10
+ if (value !== null && typeof value === 'object') {
11
+ const out: Record<string, unknown> = {};
12
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
13
+ out[k] = stripRawHtmlPrefixDeep(v);
14
+ }
15
+ return out as T;
16
+ }
17
+ return value;
18
+ }
19
+
20
+ /** Ensure a component name starts with an uppercase letter so Astro treats it as a component tag, not an HTML element. */
21
+ export function astroComponentName(name: string): string {
22
+ return name.charAt(0).toUpperCase() + name.slice(1);
23
+ }
@@ -10,6 +10,11 @@ import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
10
  import { transformCMSTemplate } from './templateTransformer';
11
11
  import type { ImageMetadataMap } from '../ssr/imageMetadata';
12
12
  import type { SlugMap } from '../../shared/slugTranslator';
13
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
14
+ import type { InteractiveStyles } from '../../shared/types/styles';
15
+ import type { RemConversionConfig } from '../../shared/pxToRem';
16
+ import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
17
+ import { astroComponentName } from './astroEmitHelpers';
13
18
 
14
19
  // ---------------------------------------------------------------------------
15
20
  // Types
@@ -46,6 +51,8 @@ export interface CMSPageEmitOptions {
46
51
  pageName: string;
47
52
  /** Breakpoint config for responsive Tailwind classes */
48
53
  breakpoints?: BreakpointConfig;
54
+ /** Responsive scales config for auto-scaling at breakpoints */
55
+ responsiveScales?: ResponsiveScales;
49
56
  /** Image metadata map for responsive image generation */
50
57
  imageMetadataMap?: ImageMetadataMap;
51
58
  /** Internationalization config */
@@ -56,6 +63,14 @@ export interface CMSPageEmitOptions {
56
63
  slugMappings?: SlugMap[];
57
64
  /** Image format: 'webp' uses plain <img>, 'avif' uses <picture> */
58
65
  imageFormat?: 'webp' | 'avif';
66
+ /**
67
+ * Raw-HTML slice → processed HTML captured during the template's SSR pass.
68
+ * Ensures `<Fragment set:html>` matches SSR output for rich-text content
69
+ * (image rewriting, component expansion, link localization).
70
+ */
71
+ processedRawHtml?: Map<string, string>;
72
+ /** Rem conversion config for interactive CSS generation */
73
+ remConfig?: RemConversionConfig;
59
74
  }
60
75
 
61
76
  // ---------------------------------------------------------------------------
@@ -63,7 +78,12 @@ export interface CMSPageEmitOptions {
63
78
  // ---------------------------------------------------------------------------
64
79
 
65
80
  function escapeTemplateLiteral(s: string): string {
66
- return s.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
81
+ return s
82
+ .replace(/\\/g, '\\\\')
83
+ .replace(/`/g, '\\`')
84
+ .replace(/\$\{/g, '\\${')
85
+ .replace(/\u2028/g, '\\u2028')
86
+ .replace(/\u2029/g, '\\u2029');
67
87
  }
68
88
 
69
89
  function escapeJSX(s: string): string {
@@ -247,10 +267,13 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
247
267
  ssrFallbacks,
248
268
  pageName,
249
269
  breakpoints: breakpointsOpt,
270
+ responsiveScales,
250
271
  imageMetadataMap,
251
272
  i18nConfig,
252
273
  isMultiLocale,
253
274
  slugMappings,
275
+ processedRawHtml,
276
+ remConfig,
254
277
  } = options;
255
278
 
256
279
  const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
@@ -289,6 +312,7 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
289
312
  fileType: 'page',
290
313
  fileName: pageName,
291
314
  breakpoints,
315
+ responsiveScales,
292
316
  imageMetadataMap,
293
317
  locale,
294
318
  cmsMode: true,
@@ -298,6 +322,10 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
298
322
  slugMappings,
299
323
  i18nDefaultLocale: i18nConfig.defaultLocale,
300
324
  imageFormat: options.imageFormat,
325
+ processedRawHtml,
326
+ imageImports: new Map<string, string>(),
327
+ fileDepth,
328
+ collectedInteractiveStyles: new Map<string, InteractiveStyles>(),
301
329
  };
302
330
 
303
331
  // Emit the template body
@@ -308,11 +336,21 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
308
336
  importLines.push(`import { getCollection } from 'astro:content';`);
309
337
  importLines.push(`import BaseLayout from '${layoutImportPath}';`);
310
338
 
339
+ // Static image imports (astro:assets <Picture>). Map size is the single
340
+ // source of truth — a separate boolean would be lost by child-ctx spreads.
341
+ if (ctx.imageImports && ctx.imageImports.size > 0) {
342
+ importLines.push(`import { Picture } from 'astro:assets';`);
343
+ const sortedImages = Array.from(ctx.imageImports.entries()).sort(([a], [b]) => a.localeCompare(b));
344
+ for (const [varName, importPath] of sortedImages) {
345
+ importLines.push(`import ${varName} from '${importPath}';`);
346
+ }
347
+ }
348
+
311
349
  // Sort component imports alphabetically
312
350
  const componentImports = Array.from(ctx.imports).sort();
313
351
  for (const comp of componentImports) {
314
352
  const path = componentImportPath(fileDepth, comp);
315
- importLines.push(`import ${comp} from '${path}';`);
353
+ importLines.push(`import ${astroComponentName(comp)} from '${path}';`);
316
354
  }
317
355
 
318
356
  // Build getStaticPaths
@@ -342,6 +380,11 @@ export function emitCMSPage(options: CMSPageEmitOptions): string {
342
380
  return v ?? '';
343
381
  }`;
344
382
 
383
+ // Generate interactive style block for page-level interactive styles
384
+ const interactiveStyleSection = ctx.collectedInteractiveStyles!.size > 0
385
+ ? `\n<style is:global>\n${generateAllInteractiveCSS(ctx.collectedInteractiveStyles!, breakpoints, remConfig, responsiveScales)}\n</style>\n`
386
+ : '';
387
+
345
388
  return `---
346
389
  ${importLines.join('\n')}
347
390
 
@@ -361,7 +404,7 @@ ${resolverHelper}
361
404
  <div id="root">
362
405
  ${templateBody} </div>
363
406
  </BaseLayout>
364
- `;
407
+ ${interactiveStyleSection}`;
365
408
  }
366
409
 
367
410
  /**
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { emitAstroComponent } from './componentEmitter';
3
+ import type { ComponentDefinition } from '../../shared/types';
4
+
5
+ function makeComponent(overrides: Partial<ComponentDefinition['component']> = {}): ComponentDefinition {
6
+ return {
7
+ component: {
8
+ interface: {},
9
+ structure: { type: 'node', tag: 'div', children: [] } as unknown as ComponentDefinition['component']['structure'],
10
+ ...overrides,
11
+ },
12
+ };
13
+ }
14
+
15
+ describe('emitAstroComponent — RAW_HTML_PREFIX stripping', () => {
16
+ test('strips marker from rich-text string default in frontmatter', () => {
17
+ const def = makeComponent({
18
+ interface: {
19
+ text2: {
20
+ type: 'rich-text',
21
+ default: '<!--MENO_RAW_HTML-->SEO<br>Audit Blueprint',
22
+ } as unknown as import('../../shared/types').PropDefinition,
23
+ },
24
+ });
25
+ const out = emitAstroComponent('SeoAudit', def, {});
26
+ expect(out).not.toContain('MENO_RAW_HTML');
27
+ expect(out).toContain('text2 = "SEO<br>Audit Blueprint"');
28
+ });
29
+
30
+ test('strips marker from i18n default values', () => {
31
+ const def = makeComponent({
32
+ interface: {
33
+ text2: {
34
+ type: 'rich-text',
35
+ default: {
36
+ _i18n: true,
37
+ en: '<!--MENO_RAW_HTML-->SEO<br>Audit Blueprint',
38
+ pl: '<!--MENO_RAW_HTML-->Audyt SEO',
39
+ },
40
+ } as unknown as import('../../shared/types').PropDefinition,
41
+ },
42
+ });
43
+ const out = emitAstroComponent('SeoAudit', def, {});
44
+ expect(out).not.toContain('MENO_RAW_HTML');
45
+ });
46
+
47
+ test('leaves non-rich-text string defaults unchanged', () => {
48
+ const def = makeComponent({
49
+ interface: {
50
+ title: {
51
+ type: 'string',
52
+ default: 'Hello world',
53
+ } as unknown as import('../../shared/types').PropDefinition,
54
+ },
55
+ });
56
+ const out = emitAstroComponent('Heading', def, {});
57
+ expect(out).toContain('title = "Hello world"');
58
+ });
59
+ });
@@ -12,6 +12,11 @@ import type { BasePropDefinition, ListPropDefinition, LinkPropValue } from '../.
12
12
  import type { BreakpointConfig } from '../../shared/breakpoints';
13
13
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
14
14
  import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
15
+ import type { ResponsiveScales } from '../../shared/responsiveScaling';
16
+ import { stripRawHtmlPrefixDeep, astroComponentName } from './astroEmitHelpers';
17
+ import type { InteractiveStyles } from '../../shared/types/styles';
18
+ import type { RemConversionConfig } from '../../shared/pxToRem';
19
+ import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
15
20
 
16
21
  // ---------------------------------------------------------------------------
17
22
  // Helpers
@@ -49,7 +54,7 @@ function propDefToTSType(def: PropDefinition): string {
49
54
  */
50
55
  function formatDefault(def: PropDefinition): string | null {
51
56
  if (!('default' in def) || def.default === undefined) return null;
52
- const val = def.default;
57
+ const val = stripRawHtmlPrefixDeep(def.default);
53
58
 
54
59
  if (typeof val === 'string') return JSON.stringify(val);
55
60
  if (typeof val === 'number' || typeof val === 'boolean') return String(val);
@@ -129,7 +134,9 @@ export function emitAstroComponent(
129
134
  def: ComponentDefinition,
130
135
  allComponents: Record<string, ComponentDefinition>,
131
136
  breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
132
- defaultLocale: string = 'en'
137
+ defaultLocale: string = 'en',
138
+ responsiveScales?: ResponsiveScales,
139
+ remConfig?: RemConversionConfig
133
140
  ): string {
134
141
  const comp = def.component;
135
142
  const propDefs = comp.interface || {};
@@ -152,7 +159,11 @@ export function emitAstroComponent(
152
159
  fileType: 'component',
153
160
  fileName: name,
154
161
  breakpoints,
162
+ responsiveScales,
155
163
  defaultLocale,
164
+ imageImports: new Map<string, string>(),
165
+ fileDepth: 0, // components live at src/components/
166
+ collectedInteractiveStyles: new Map<string, InteractiveStyles>(),
156
167
  };
157
168
 
158
169
  // Emit the template body
@@ -162,15 +173,25 @@ export function emitAstroComponent(
162
173
  templateBody = mergeClassNameOntoRoot(templateBody);
163
174
 
164
175
  // Build frontmatter (includes class prop for instance style support)
165
- const frontmatter = buildFrontmatter(name, propDefs, ctx.imports, ctx.dynamicTags, ctx.needsI18nResolver ? defaultLocale : undefined);
176
+ const frontmatter = buildFrontmatter(
177
+ name,
178
+ propDefs,
179
+ ctx.imports,
180
+ ctx.dynamicTags,
181
+ ctx.needsI18nResolver ? defaultLocale : undefined,
182
+ ctx.imageImports
183
+ );
166
184
 
167
185
  // Build style/script sections
168
186
  const styleSection = comp.css ? `\n<style>\n${comp.css}\n</style>\n` : '';
187
+ const interactiveStyleSection = ctx.collectedInteractiveStyles!.size > 0
188
+ ? `\n<style is:global>\n${generateAllInteractiveCSS(ctx.collectedInteractiveStyles!, breakpoints, remConfig, responsiveScales)}\n</style>\n`
189
+ : '';
169
190
  const scriptSection = comp.javascript
170
191
  ? buildScriptSection(comp.javascript, comp, propDefs)
171
192
  : '';
172
193
 
173
- return `---\n${frontmatter}---\n${templateBody}${styleSection}${scriptSection}`;
194
+ return `---\n${frontmatter}---\n${templateBody}${styleSection}${interactiveStyleSection}${scriptSection}`;
174
195
  }
175
196
 
176
197
  /**
@@ -181,13 +202,24 @@ function buildFrontmatter(
181
202
  propDefs: Record<string, PropDefinition>,
182
203
  imports: Set<string>,
183
204
  dynamicTags?: Map<string, string>,
184
- i18nDefaultLocale?: string
205
+ i18nDefaultLocale?: string,
206
+ imageImports?: Map<string, string>
185
207
  ): string {
186
208
  const lines: string[] = [];
187
209
 
188
210
  // Component imports
189
211
  for (const imp of Array.from(imports).sort()) {
190
- lines.push(`import ${imp} from './${imp}.astro';`);
212
+ lines.push(`import ${astroComponentName(imp)} from './${imp}.astro';`);
213
+ }
214
+
215
+ // Static image imports (astro:assets <Picture>). Map size is the single
216
+ // source of truth — a separate boolean would be lost by child-ctx spreads.
217
+ if (imageImports && imageImports.size > 0) {
218
+ lines.push(`import { Picture } from 'astro:assets';`);
219
+ const sortedImages = Array.from(imageImports.entries()).sort(([a], [b]) => a.localeCompare(b));
220
+ for (const [varName, importPath] of sortedImages) {
221
+ lines.push(`import ${varName} from '${importPath}';`);
222
+ }
191
223
  }
192
224
 
193
225
  if (lines.length > 0) lines.push('');
@@ -314,16 +346,25 @@ function transformDefineVarsJS(js: string, varNames: string[]): string {
314
346
 
315
347
  /**
316
348
  * Build the script section with proper el/props initialization.
317
- * - defineVars components: use Astro's define:vars to pass props into inline script
318
- * - other components: use is:inline to avoid module bundling
319
- * Both cases use document.currentScript.previousElementSibling to get el.
349
+ * Captures el immediately via document.currentScript.previousElementSibling,
350
+ * then defers actual JS execution until DOMContentLoaded so that external
351
+ * libraries (e.g. Swiper loaded with defer) are available.
320
352
  */
321
353
  function buildScriptSection(
322
354
  js: string,
323
355
  comp: StructuredComponentDefinition,
324
356
  propDefs: Record<string, PropDefinition>
325
357
  ): string {
326
- const elInit = 'const el = document.currentScript.previousElementSibling;';
358
+ // Capture el immediately (document.currentScript is only valid during script execution)
359
+ // but defer the component init until DOMContentLoaded so deferred libraries are ready
360
+ // Remove whitespace-only text nodes so Astro's formatted HTML matches SSR's minified output.
361
+ // Without this, scripts that use firstChild/childNodes pick up whitespace between elements.
362
+ const cleanTextNodes = `(function _w(n){var c=n.firstChild,x;while(c){x=c.nextSibling;if(c.nodeType===3){if(!c.textContent.trim())n.removeChild(c)}else if(c.nodeType===1){if(c.tagName==='SCRIPT')n.removeChild(c);else _w(c)}c=x}})(el);`;
363
+
364
+ const deferWrapper = (innerJS: string) =>
365
+ `var el = document.currentScript.previousElementSibling;\n` +
366
+ `function __init__() {\n${cleanTextNodes}\n${innerJS}\n}\n` +
367
+ `if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', __init__); } else { __init__(); }`;
327
368
 
328
369
  if (comp.defineVars) {
329
370
  const vars = comp.defineVars === true
@@ -333,9 +374,9 @@ function buildScriptSection(
333
374
  if (vars.length > 0) {
334
375
  const transformedJS = transformDefineVarsJS(js, vars);
335
376
  const defineVarsObj = `{ ${vars.join(', ')} }`;
336
- return `\n<script define:vars={${defineVarsObj}}>\n${elInit}\n${transformedJS}\n</script>\n`;
377
+ return `\n<script define:vars={${defineVarsObj}}>\n(function(){\n${deferWrapper(transformedJS)}\n})();\n</script>\n`;
337
378
  }
338
379
  }
339
380
 
340
- return `\n<script is:inline>\n${elInit}\n${js}\n</script>\n`;
381
+ return `\n<script is:inline>\n(function(){\n${deferWrapper(js)}\n})();\n</script>\n`;
341
382
  }
@@ -14,6 +14,8 @@ import type {
14
14
  import type { BreakpointConfig } from '../../shared/breakpoints';
15
15
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
16
16
  import { propertyToTailwind } from './tailwindMapper';
17
+ import type { ResponsiveScales, CSSPropertyType } from '../../shared/responsiveScaling';
18
+ import { getScaleMultiplier, scalePropertyValue } from '../../shared/responsiveScaling';
17
19
 
18
20
  // ---------------------------------------------------------------------------
19
21
  // Helpers
@@ -40,7 +42,8 @@ function isResponsiveStyle(
40
42
  function collectFromStyle(
41
43
  style: StyleObject | ResponsiveStyleObject | undefined,
42
44
  classes: Set<string>,
43
- breakpoints: BreakpointConfig
45
+ breakpoints: BreakpointConfig,
46
+ responsiveScales?: ResponsiveScales
44
47
  ): void {
45
48
  if (!style) return;
46
49
 
@@ -48,24 +51,46 @@ function collectFromStyle(
48
51
  for (const [bp, bpStyle] of Object.entries(style)) {
49
52
  if (!bpStyle) continue;
50
53
  let prefix = '';
54
+ let bpName: string | undefined;
51
55
  if (bp !== 'base') {
52
56
  const bpValue = breakpoints[bp]?.breakpoint;
53
57
  if (bpValue) {
54
58
  prefix = `max-[${bpValue}px]:`;
59
+ bpName = bp;
55
60
  }
56
61
  }
57
- collectFromFlatStyle(bpStyle, prefix, classes);
62
+ collectFromFlatStyle(bpStyle, prefix, classes, breakpoints, responsiveScales, bpName);
58
63
  }
59
64
  } else {
60
- collectFromFlatStyle(style, '', classes);
65
+ collectFromFlatStyle(style, '', classes, breakpoints, responsiveScales);
61
66
  }
62
67
  }
63
68
 
64
69
  function collectFromFlatStyle(
65
70
  style: StyleObject,
66
71
  prefix: string,
67
- classes: Set<string>
72
+ classes: Set<string>,
73
+ breakpoints: BreakpointConfig,
74
+ responsiveScales?: ResponsiveScales,
75
+ sourceBreakpoint?: string
68
76
  ): void {
77
+ // Only auto-scale when mapping value lives in the base branch (or a flat
78
+ // top-level style). Mappings inside an explicit breakpoint branch already
79
+ // describe the override the author wants — don't re-scale them.
80
+ const shouldAutoScale =
81
+ responsiveScales?.enabled === true && sourceBreakpoint === undefined;
82
+
83
+ const scaleBreakpoints = shouldAutoScale
84
+ ? Object.entries(breakpoints)
85
+ .map(([name, cfg]) => ({ name, value: cfg?.breakpoint }))
86
+ .filter((bp): bp is { name: string; value: number } =>
87
+ typeof bp.value === 'number' && bp.value > 0
88
+ )
89
+ .sort((a, b) => b.value - a.value)
90
+ : [];
91
+
92
+ const baseRef = responsiveScales?.baseReference ?? 16;
93
+
69
94
  for (const [property, value] of Object.entries(style)) {
70
95
  if (!isStyleMapping(value)) continue;
71
96
 
@@ -75,6 +100,26 @@ function collectFromFlatStyle(
75
100
  if (twClass) {
76
101
  classes.add(prefix ? `${prefix}${twClass}` : twClass);
77
102
  }
103
+
104
+ // Safelist auto-scaled `max-[Npx]:` variants so Tailwind generates
105
+ // CSS for each breakpoint-scaled class the runtime might produce.
106
+ if (shouldAutoScale && responsiveScales) {
107
+ const strValue = String(cssValue);
108
+ if (strValue === '') continue;
109
+ for (const { name: bpName, value: bpPixels } of scaleBreakpoints) {
110
+ const scale = getScaleMultiplier(
111
+ responsiveScales,
112
+ property as CSSPropertyType,
113
+ bpName
114
+ );
115
+ if (scale == null) continue;
116
+ const scaledValue = scalePropertyValue(strValue, baseRef, scale);
117
+ if (scaledValue == null || scaledValue === strValue) continue;
118
+ const scaledClass = propertyToTailwind(property, scaledValue);
119
+ if (!scaledClass) continue;
120
+ classes.add(`max-[${bpPixels}px]:${scaledClass}`);
121
+ }
122
+ }
78
123
  }
79
124
  }
80
125
  }
@@ -85,27 +130,28 @@ function collectFromFlatStyle(
85
130
  function walkNode(
86
131
  node: ComponentNode | ComponentNode[] | string | number | null | undefined,
87
132
  classes: Set<string>,
88
- breakpoints: BreakpointConfig
133
+ breakpoints: BreakpointConfig,
134
+ responsiveScales?: ResponsiveScales
89
135
  ): void {
90
136
  if (!node || typeof node === 'string' || typeof node === 'number') return;
91
137
 
92
138
  if (Array.isArray(node)) {
93
139
  for (const child of node) {
94
- walkNode(child, classes, breakpoints);
140
+ walkNode(child, classes, breakpoints, responsiveScales);
95
141
  }
96
142
  return;
97
143
  }
98
144
 
99
145
  // Collect from style
100
146
  if ('style' in node && node.style) {
101
- collectFromStyle(node.style as StyleObject | ResponsiveStyleObject, classes, breakpoints);
147
+ collectFromStyle(node.style as StyleObject | ResponsiveStyleObject, classes, breakpoints, responsiveScales);
102
148
  }
103
149
 
104
150
  // Collect from interactive styles
105
151
  if ('interactiveStyles' in node && Array.isArray((node as any).interactiveStyles)) {
106
152
  for (const rule of (node as any).interactiveStyles) {
107
153
  if (rule.style) {
108
- collectFromStyle(rule.style, classes, breakpoints);
154
+ collectFromStyle(rule.style, classes, breakpoints, responsiveScales);
109
155
  }
110
156
  }
111
157
  }
@@ -114,7 +160,7 @@ function walkNode(
114
160
  if ('children' in node && node.children) {
115
161
  if (Array.isArray(node.children)) {
116
162
  for (const child of node.children) {
117
- walkNode(child as ComponentNode, classes, breakpoints);
163
+ walkNode(child as ComponentNode, classes, breakpoints, responsiveScales);
118
164
  }
119
165
  }
120
166
  }
@@ -132,14 +178,15 @@ function walkNode(
132
178
  */
133
179
  export function collectAllMappingClasses(
134
180
  componentDefs: Record<string, ComponentDefinition>,
135
- breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS
181
+ breakpoints: BreakpointConfig = DEFAULT_BREAKPOINTS,
182
+ responsiveScales?: ResponsiveScales
136
183
  ): Set<string> {
137
184
  const classes = new Set<string>();
138
185
 
139
186
  for (const def of Object.values(componentDefs)) {
140
187
  const structure = def.component?.structure;
141
188
  if (structure) {
142
- walkNode(structure, classes, breakpoints);
189
+ walkNode(structure, classes, breakpoints, responsiveScales);
143
190
  }
144
191
  }
145
192