meno-core 1.0.46 → 1.0.48

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 (68) hide show
  1. package/build-astro.ts +13 -77
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-FED5MME6.js → chunk-3FHJUHAS.js} +5 -4
  4. package/dist/chunks/chunk-3FHJUHAS.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-B2RTLDXY.js} +130 -130
  6. package/dist/chunks/chunk-B2RTLDXY.js.map +7 -0
  7. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-BJRKEPMP.js} +2 -2
  8. package/dist/chunks/{chunk-ORN7S4AP.js → chunk-D5E3OKSL.js} +5 -5
  9. package/dist/chunks/{chunk-KITQJYZV.js → chunk-EK4KESLU.js} +33 -3
  10. package/dist/chunks/chunk-EK4KESLU.js.map +7 -0
  11. package/dist/chunks/{chunk-C6U5T5S5.js → chunk-NKUV77SR.js} +43 -10
  12. package/dist/chunks/chunk-NKUV77SR.js.map +7 -0
  13. package/dist/chunks/{chunk-ZWYDT3QJ.js → chunk-NP76N4HQ.js} +4 -4
  14. package/dist/chunks/{chunk-ZWYDT3QJ.js.map → chunk-NP76N4HQ.js.map} +2 -2
  15. package/dist/chunks/{chunk-IF3RATBY.js → chunk-RQSTH2BS.js} +2 -2
  16. package/dist/chunks/{chunk-77ZB6353.js → chunk-TPQ7APVQ.js} +42 -86
  17. package/dist/chunks/chunk-TPQ7APVQ.js.map +7 -0
  18. package/dist/chunks/{configService-DYCUEURL.js → configService-IGJEC3MC.js} +3 -3
  19. package/dist/entries/server-router.js +8 -8
  20. package/dist/lib/client/index.js +16 -4
  21. package/dist/lib/client/index.js.map +2 -2
  22. package/dist/lib/server/index.js +1803 -383
  23. package/dist/lib/server/index.js.map +4 -4
  24. package/dist/lib/shared/index.js +8 -4
  25. package/dist/lib/shared/index.js.map +1 -1
  26. package/lib/client/core/ComponentBuilder.test.ts +38 -0
  27. package/lib/client/core/ComponentBuilder.ts +25 -1
  28. package/lib/server/astro/astroEmitHelpers.ts +5 -0
  29. package/lib/server/astro/cmsPageEmitter.ts +15 -2
  30. package/lib/server/astro/componentEmitter.ts +13 -5
  31. package/lib/server/astro/nodeToAstro.ts +23 -9
  32. package/lib/server/astro/pageEmitter.ts +15 -2
  33. package/lib/server/index.ts +1 -1
  34. package/lib/server/jsonLoader.test.ts +0 -17
  35. package/lib/server/jsonLoader.ts +0 -81
  36. package/lib/server/routes/api/variables.ts +4 -2
  37. package/lib/server/ssr/htmlGenerator.test.ts +4 -4
  38. package/lib/server/ssr/htmlGenerator.ts +7 -2
  39. package/lib/server/ssr/imageMetadata.ts +15 -9
  40. package/lib/server/ssr/ssrRenderer.test.ts +103 -1
  41. package/lib/server/ssr/ssrRenderer.ts +41 -35
  42. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  43. package/lib/server/utils/jsonLineMapper.ts +43 -3
  44. package/lib/server/webflow/buildWebflow.ts +343 -123
  45. package/lib/server/webflow/index.ts +1 -0
  46. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  47. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  48. package/lib/server/webflow/styleMapper.test.ts +389 -0
  49. package/lib/server/webflow/styleMapper.ts +517 -63
  50. package/lib/server/webflow/templateWrapper.ts +49 -0
  51. package/lib/server/webflow/types.ts +218 -18
  52. package/lib/shared/elementClassName.test.ts +15 -0
  53. package/lib/shared/elementClassName.ts +7 -3
  54. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  55. package/lib/shared/styleNodeUtils.test.ts +47 -1
  56. package/lib/shared/styleNodeUtils.ts +7 -7
  57. package/lib/shared/types/index.ts +1 -0
  58. package/lib/shared/types/variables.ts +37 -0
  59. package/package.json +1 -1
  60. package/dist/chunks/chunk-77ZB6353.js.map +0 -7
  61. package/dist/chunks/chunk-C6U5T5S5.js.map +0 -7
  62. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  63. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  64. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  65. /package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-BJRKEPMP.js.map} +0 -0
  66. /package/dist/chunks/{chunk-ORN7S4AP.js.map → chunk-D5E3OKSL.js.map} +0 -0
  67. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-RQSTH2BS.js.map} +0 -0
  68. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-IGJEC3MC.js.map} +0 -0
@@ -39,7 +39,7 @@ import { isI18nValue, resolveI18nValue, DEFAULT_I18N_CONFIG, buildLocalizedPath
39
39
  import { transformCMSTemplate, isTemplateExpression, transformItemTemplate, replaceItemMetaVars, rewriteItemVar } from './templateTransformer';
40
40
  import type { SlugMap } from '../../shared/slugTranslator';
41
41
  import { buildSlugIndex, translatePath } from '../../shared/slugTranslator';
42
- import { stripRawHtmlPrefixDeep } from './astroEmitHelpers';
42
+ import { stripRawHtmlPrefixDeep, astroComponentName } from './astroEmitHelpers';
43
43
 
44
44
  // ---------------------------------------------------------------------------
45
45
  // Types
@@ -127,6 +127,8 @@ export interface AstroEmitContext {
127
127
  imageImports?: Map<string, string>;
128
128
  /** File depth (relative to src/pages or src/components) for import path computation */
129
129
  fileDepth?: number;
130
+ /** Collected interactive styles during emission: elementClass → InteractiveStyles */
131
+ collectedInteractiveStyles?: Map<string, InteractiveStyles>;
130
132
  }
131
133
 
132
134
  // ---------------------------------------------------------------------------
@@ -1029,7 +1031,9 @@ function emitImageNode(node: HtmlNode, ctx: AstroEmitContext): string {
1029
1031
  node.generateElementClass
1030
1032
  ) {
1031
1033
  elementClass = buildElementClass(ctx, node.label);
1032
-
1034
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1035
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1036
+ }
1033
1037
  }
1034
1038
 
1035
1039
  const { classExpr, styleAttr } = buildClassAndStyleExpression(
@@ -1216,7 +1220,9 @@ function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
1216
1220
  node.generateElementClass
1217
1221
  ) {
1218
1222
  elementClass = buildElementClass(ctx, label);
1219
-
1223
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1224
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1225
+ }
1220
1226
  }
1221
1227
 
1222
1228
  const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
@@ -1243,8 +1249,8 @@ function emitHtmlNode(node: HtmlNode, ctx: AstroEmitContext): string {
1243
1249
  }
1244
1250
 
1245
1251
  function emitComponentInstance(node: ComponentInstanceNode, ctx: AstroEmitContext): string {
1246
- const name = node.component;
1247
- ctx.imports.add(name);
1252
+ const name = astroComponentName(node.component);
1253
+ ctx.imports.add(node.component);
1248
1254
 
1249
1255
  const ifExpr = emitIfOpen(node, ctx);
1250
1256
 
@@ -1355,7 +1361,9 @@ function emitEmbedNode(node: EmbedNode, ctx: AstroEmitContext): string {
1355
1361
  node.generateElementClass
1356
1362
  ) {
1357
1363
  elementClass = buildElementClass(ctx, node.label);
1358
-
1364
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1365
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1366
+ }
1359
1367
  }
1360
1368
 
1361
1369
  const { classExpr, styleAttr } = buildClassAndStyleExpression(style, node.interactiveStyles as InteractiveStyles | undefined, elementClass, ctx);
@@ -1428,7 +1436,9 @@ function emitLinkNode(node: LinkNode, ctx: AstroEmitContext): string {
1428
1436
  node.generateElementClass
1429
1437
  ) {
1430
1438
  elementClass = buildElementClass(ctx, node.label);
1431
-
1439
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1440
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1441
+ }
1432
1442
  }
1433
1443
 
1434
1444
  // Build class expression with olink base class
@@ -1525,7 +1535,9 @@ function emitImageTypeNode(node: any, ctx: AstroEmitContext): string {
1525
1535
  node.generateElementClass
1526
1536
  ) {
1527
1537
  elementClass = buildElementClass(ctx, node.label);
1528
-
1538
+ if (elementClass && node.interactiveStyles?.length && ctx.collectedInteractiveStyles) {
1539
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1540
+ }
1529
1541
  }
1530
1542
 
1531
1543
  const { classExpr, styleAttr } = buildClassAndStyleExpression(
@@ -1784,7 +1796,9 @@ function emitLocaleListNode(node: LocaleListNode, ctx: AstroEmitContext): string
1784
1796
  let elementClass: string | null = null;
1785
1797
  if ((node.interactiveStyles && (node.interactiveStyles as InteractiveStyles).length > 0) || node.generateElementClass) {
1786
1798
  elementClass = buildElementClass(ctx, node.label);
1787
-
1799
+ if (elementClass && (node.interactiveStyles as InteractiveStyles)?.length && ctx.collectedInteractiveStyles) {
1800
+ ctx.collectedInteractiveStyles.set(elementClass, node.interactiveStyles as InteractiveStyles);
1801
+ }
1788
1802
  }
1789
1803
 
1790
1804
  const { classExpr: containerClassExpr, styleAttr: containerStyleAttr } = buildClassAndStyleExpression(
@@ -10,6 +10,10 @@ import { nodeToAstro, type AstroEmitContext } from './nodeToAstro';
10
10
  import type { ImageMetadataMap } from '../ssr/imageMetadata';
11
11
  import type { SlugMap } from '../../shared/slugTranslator';
12
12
  import type { ResponsiveScales } from '../../shared/responsiveScaling';
13
+ import type { InteractiveStyles } from '../../shared/types/styles';
14
+ import type { RemConversionConfig } from '../../shared/pxToRem';
15
+ import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
16
+ import { astroComponentName } from './astroEmitHelpers';
13
17
 
14
18
  // ---------------------------------------------------------------------------
15
19
  // Types
@@ -62,6 +66,8 @@ export interface PageEmitOptions {
62
66
  * (image rewriting, component expansion, link localization).
63
67
  */
64
68
  processedRawHtml?: Map<string, string>;
69
+ /** Rem conversion config for interactive CSS generation */
70
+ remConfig?: RemConversionConfig;
65
71
  }
66
72
 
67
73
  // ---------------------------------------------------------------------------
@@ -121,6 +127,7 @@ export function emitAstroPage(options: PageEmitOptions): string {
121
127
  slugMappings,
122
128
  imageFormat,
123
129
  processedRawHtml,
130
+ remConfig,
124
131
  } = options;
125
132
 
126
133
  const breakpoints = breakpointsOpt ?? DEFAULT_BREAKPOINTS;
@@ -156,6 +163,7 @@ export function emitAstroPage(options: PageEmitOptions): string {
156
163
  processedRawHtml,
157
164
  imageImports: new Map<string, string>(),
158
165
  fileDepth,
166
+ collectedInteractiveStyles: new Map<string, InteractiveStyles>(),
159
167
  };
160
168
 
161
169
  // Emit the template body
@@ -186,7 +194,7 @@ export function emitAstroPage(options: PageEmitOptions): string {
186
194
  const componentImports = Array.from(ctx.imports).sort();
187
195
  for (const comp of componentImports) {
188
196
  const path = componentImportPath(fileDepth, comp);
189
- importLines.push(`import ${comp} from '${path}';`);
197
+ importLines.push(`import ${astroComponentName(comp)} from '${path}';`);
190
198
  }
191
199
 
192
200
  // Build script paths array
@@ -205,6 +213,11 @@ export function emitAstroPage(options: PageEmitOptions): string {
205
213
  ? '\n' + ctx.frontmatterLines.join('\n')
206
214
  : '';
207
215
 
216
+ // Generate interactive style block for page-level interactive styles
217
+ const interactiveStyleSection = ctx.collectedInteractiveStyles!.size > 0
218
+ ? `\n<style is:global>\n${generateAllInteractiveCSS(ctx.collectedInteractiveStyles!, breakpoints, remConfig, responsiveScales)}\n</style>\n`
219
+ : '';
220
+
208
221
  return `---
209
222
  ${importLines.join('\n')}${extraFrontmatter}
210
223
  ---
@@ -220,7 +233,7 @@ ${importLines.join('\n')}${extraFrontmatter}
220
233
  <div id="root">
221
234
  ${templateBody} </div>
222
235
  </BaseLayout>
223
- `;
236
+ ${interactiveStyleSection}`;
224
237
  }
225
238
 
226
239
  /**
@@ -66,7 +66,7 @@ export { buildStaticPages } from '../../build-static';
66
66
  export { buildAstroProject } from '../../build-astro';
67
67
 
68
68
  // Webflow export
69
- export { buildWebflowPayload } from './webflow';
69
+ export { buildWebflowPayload, wrapInWebflowTemplate } from './webflow';
70
70
  export type {
71
71
  WebflowExportPayload,
72
72
  WebflowPage,
@@ -5,13 +5,10 @@ import {
5
5
  mapPathToPageName,
6
6
  getBreakpointConfig,
7
7
  setBreakpointConfig,
8
- getResponsiveScalesConfig,
9
- setResponsiveScalesConfig,
10
8
  getI18nConfig,
11
9
  setI18nConfig,
12
10
  } from './jsonLoader';
13
11
  import { DEFAULT_BREAKPOINTS } from '../shared/breakpoints';
14
- import { DEFAULT_RESPONSIVE_SCALES } from '../shared/responsiveScaling';
15
12
  import { DEFAULT_I18N_CONFIG } from '../shared/i18n';
16
13
 
17
14
  describe('jsonLoader', () => {
@@ -76,20 +73,6 @@ describe('jsonLoader', () => {
76
73
  });
77
74
  });
78
75
 
79
- describe('responsive scales config cache', () => {
80
- test('returns default config initially', () => {
81
- const config = getResponsiveScalesConfig();
82
- expect(config.enabled).toBe(DEFAULT_RESPONSIVE_SCALES.enabled);
83
- });
84
-
85
- test('returns cached config after set', () => {
86
- const customConfig = { enabled: true, baseReference: 20 };
87
- setResponsiveScalesConfig(customConfig);
88
- const config = getResponsiveScalesConfig();
89
- expect(config.baseReference).toBe(20);
90
- });
91
- });
92
-
93
76
  describe('i18n config cache', () => {
94
77
  test('returns default config initially', () => {
95
78
  const config = getI18nConfig();
@@ -9,8 +9,6 @@ import type { ComponentDefinition } from '../shared/types';
9
9
  import type { BreakpointConfig, BreakpointConfigInput, BreakpointEntry } from '../shared/breakpoints';
10
10
  import { readTextFile, fileExists } from './runtime';
11
11
  import { DEFAULT_BREAKPOINTS, normalizeBreakpointConfig } from '../shared/breakpoints';
12
- import type { ResponsiveScales, BreakpointScales } from '../shared/responsiveScaling';
13
- import { DEFAULT_RESPONSIVE_SCALES } from '../shared/responsiveScaling';
14
12
  import type { I18nConfig } from '../shared/types/components';
15
13
  import { DEFAULT_I18N_CONFIG, migrateI18nConfig } from '../shared/i18n';
16
14
  import type { PrefetchConfig } from '../shared/types/prefetch';
@@ -385,85 +383,6 @@ export function setBreakpointConfig(config: BreakpointConfig): void {
385
383
  cachedBreakpoints = config;
386
384
  }
387
385
 
388
- /**
389
- * Deep merge scale categories, preserving user-defined breakpoints
390
- * while filling in missing values from defaults
391
- */
392
- function mergeScaleCategory(
393
- userScales: BreakpointScales | undefined,
394
- defaultScales: BreakpointScales | undefined
395
- ): BreakpointScales | undefined {
396
- if (!userScales && !defaultScales) return undefined;
397
- if (!userScales) return defaultScales ? { ...defaultScales } : undefined;
398
- if (!defaultScales) return { ...userScales };
399
-
400
- // User scales take precedence, but include defaults for breakpoints not specified
401
- return {
402
- ...defaultScales,
403
- ...userScales,
404
- };
405
- }
406
-
407
- /**
408
- * Load and validate responsive scales configuration from project.config.json
409
- * Supports dynamic breakpoints - scales are keyed by breakpoint name
410
- */
411
- export async function loadResponsiveScalesConfig(): Promise<ResponsiveScales> {
412
- try {
413
- const configContent = await loadJSONFile(projectPaths.config());
414
- if (configContent) {
415
- const config = parseJSON<{ responsiveScales?: Partial<ResponsiveScales> }>(configContent);
416
-
417
- if (config.responsiveScales && typeof config.responsiveScales === 'object') {
418
- // Deep merge scale categories to preserve user breakpoint definitions
419
- // while filling in missing values from defaults
420
- const scales: ResponsiveScales = {
421
- enabled: config.responsiveScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
422
- baseReference: config.responsiveScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
423
- fontSize: mergeScaleCategory(
424
- config.responsiveScales.fontSize as BreakpointScales | undefined,
425
- DEFAULT_RESPONSIVE_SCALES.fontSize
426
- ),
427
- padding: mergeScaleCategory(
428
- config.responsiveScales.padding as BreakpointScales | undefined,
429
- DEFAULT_RESPONSIVE_SCALES.padding
430
- ),
431
- margin: mergeScaleCategory(
432
- config.responsiveScales.margin as BreakpointScales | undefined,
433
- DEFAULT_RESPONSIVE_SCALES.margin
434
- ),
435
- gap: mergeScaleCategory(
436
- config.responsiveScales.gap as BreakpointScales | undefined,
437
- DEFAULT_RESPONSIVE_SCALES.gap
438
- ),
439
- };
440
-
441
- return scales;
442
- }
443
- }
444
- } catch (error) {
445
- }
446
-
447
- return { ...DEFAULT_RESPONSIVE_SCALES };
448
- }
449
-
450
- /**
451
- * Get responsive scales config synchronously (for cases where async is not available)
452
- * Uses cached value if available, otherwise defaults
453
- */
454
- let cachedResponsiveScales: ResponsiveScales | null = null;
455
-
456
- export function getResponsiveScalesConfig(): ResponsiveScales {
457
- if (cachedResponsiveScales) {
458
- return cachedResponsiveScales;
459
- }
460
- return { ...DEFAULT_RESPONSIVE_SCALES };
461
- }
462
-
463
- export function setResponsiveScalesConfig(config: ResponsiveScales): void {
464
- cachedResponsiveScales = config;
465
- }
466
-
467
386
  /**
468
387
  * Load and validate i18n configuration from project.config.json
469
388
  * Automatically migrates old string[] format to new LocaleConfig[] format
@@ -5,7 +5,8 @@
5
5
 
6
6
  import { variableService } from '../../services/VariableService';
7
7
  import { generateVariablesCSS } from '../../cssGenerator';
8
- import { loadBreakpointConfig, loadResponsiveScalesConfig } from '../../jsonLoader';
8
+ import { loadBreakpointConfig } from '../../jsonLoader';
9
+ import { configService } from '../../services/configService';
9
10
  import { jsonResponse } from './shared';
10
11
 
11
12
  /**
@@ -22,9 +23,10 @@ export async function handleVariablesStatusRoute(): Promise<Response> {
22
23
  * Returns generated CSS custom properties from variables.json
23
24
  */
24
25
  export async function handleVariablesCSSRoute(): Promise<Response> {
26
+ await configService.load();
25
27
  const variablesConfig = await variableService.loadConfig();
26
28
  const breakpointConfig = await loadBreakpointConfig();
27
- const responsiveScalesConfig = await loadResponsiveScalesConfig();
29
+ const responsiveScalesConfig = configService.getResponsiveScales();
28
30
  const css = generateVariablesCSS(variablesConfig, breakpointConfig, responsiveScalesConfig);
29
31
  return new Response(css, {
30
32
  headers: { 'Content-Type': 'text/css' }
@@ -27,6 +27,7 @@ mock.module('./ssrRenderer', () => ({
27
27
  const mockConfigService = {
28
28
  load: mock(async () => {}),
29
29
  getLibraries: mock(() => undefined),
30
+ getResponsiveScales: mock(() => ({ enabled: false, baseReference: 16 })),
30
31
  };
31
32
 
32
33
  mock.module('../services/configService', () => ({
@@ -34,13 +35,11 @@ mock.module('../services/configService', () => ({
34
35
  }));
35
36
 
36
37
  const mockLoadBreakpointConfig = mock(async () => ({ breakpoints: [] }));
37
- const mockLoadResponsiveScalesConfig = mock(async () => ({}));
38
38
  const mockLoadIconsConfig = mock(async () => ({ favicon: '', appleTouchIcon: '' }));
39
39
  const mockLoadPrefetchConfig = mock(async () => ({ enabled: false }));
40
40
 
41
41
  mock.module('../jsonLoader', () => ({
42
42
  loadBreakpointConfig: mockLoadBreakpointConfig,
43
- loadResponsiveScalesConfig: mockLoadResponsiveScalesConfig,
44
43
  loadIconsConfig: mockLoadIconsConfig,
45
44
  loadPrefetchConfig: mockLoadPrefetchConfig,
46
45
  }));
@@ -976,10 +975,11 @@ describe('generateSSRHTML', () => {
976
975
  expect(result).toContain('cursor: pointer');
977
976
  });
978
977
 
979
- test('should include img width/height 100% reset', async () => {
978
+ test('should include img max-width/height auto reset', async () => {
980
979
  const result = (await generateSSRHTML(minimalPage)) as string;
981
980
  expect(result).toContain('img {');
982
- expect(result).toContain('width: 100%');
981
+ expect(result).toContain('max-width: 100%');
982
+ expect(result).toContain('height: auto');
983
983
  });
984
984
 
985
985
  test('should include olink and oem utility classes', async () => {
@@ -8,7 +8,7 @@ import type { ComponentDefinition, JSONPage, PageLibrariesConfig, CustomCodeConf
8
8
  import type { SlugMap } from '../../shared/slugTranslator';
9
9
  import type { CMSService } from '../services/cmsService';
10
10
  import { configService } from '../services/configService';
11
- import { loadBreakpointConfig, loadResponsiveScalesConfig, loadIconsConfig, loadPrefetchConfig } from '../jsonLoader';
11
+ import { loadBreakpointConfig, loadIconsConfig, loadPrefetchConfig } from '../jsonLoader';
12
12
  import { generateFontCSS, generateFontPreloadTags, loadProjectConfig } from '../../shared/fontLoader';
13
13
  import { colorService } from '../services/ColorService';
14
14
  import { generateThemeColorVariablesCSS, generateVariablesCSS } from '../cssGenerator';
@@ -378,7 +378,7 @@ export async function generateSSRHTML(
378
378
  // Extract and generate utility CSS from rendered HTML
379
379
  const usedUtilityClasses = extractUtilityClassesFromHTML(rendered.html);
380
380
  const breakpointConfig = await loadBreakpointConfig();
381
- const responsiveScalesConfig = await loadResponsiveScalesConfig();
381
+ const responsiveScalesConfig = configService.getResponsiveScales();
382
382
 
383
383
  // Load and generate CSS custom properties from variables.json
384
384
  const variablesConfig = await variableService.loadConfig();
@@ -411,12 +411,17 @@ button {
411
411
  cursor: pointer;
412
412
  outline: inherit;
413
413
  }
414
+ img {
415
+ max-width: 100%;
416
+ height: auto;
417
+ }
414
418
  picture {
415
419
  display: block;
416
420
  }
417
421
  .olink {
418
422
  text-decoration: none;
419
423
  display: block;
424
+ color: inherit;
420
425
  }
421
426
  .oem {
422
427
  display: inline-block;
@@ -88,15 +88,21 @@ export async function buildImageMetadataMap(): Promise<ImageMetadataMap> {
88
88
  }
89
89
  }
90
90
 
91
- // Add main image as largest option (use original width or 2400 as fallback)
92
- const mainWidth = entry.width || 2400;
93
- if (entry.formats.webp) {
94
- webpSrcsetParts.push(`/images/${encodeURIComponent(`${baseName}.webp`)} ${mainWidth}w`);
95
- } else {
96
- webpSrcsetParts.push(`/images/${encodeURIComponent(filename)} ${mainWidth}w`);
97
- }
98
- if (entry.formats.avif) {
99
- avifSrcsetParts.push(`/images/${encodeURIComponent(`${baseName}.avif`)} ${mainWidth}w`);
91
+ // Add main image as largest option (use original width or 2400 as fallback).
92
+ // Only do this when at least one responsive variant exists — a single-entry
93
+ // srcset paired with sizes="100vw" makes the browser scale tiny images
94
+ // (e.g. 24×24 icons) up to full viewport width.
95
+ const hasVariants = entry.variants.length > 0;
96
+ if (hasVariants) {
97
+ const mainWidth = entry.width || 2400;
98
+ if (entry.formats.webp) {
99
+ webpSrcsetParts.push(`/images/${encodeURIComponent(`${baseName}.webp`)} ${mainWidth}w`);
100
+ } else {
101
+ webpSrcsetParts.push(`/images/${encodeURIComponent(filename)} ${mainWidth}w`);
102
+ }
103
+ if (entry.formats.avif) {
104
+ avifSrcsetParts.push(`/images/${encodeURIComponent(`${baseName}.avif`)} ${mainWidth}w`);
105
+ }
100
106
  }
101
107
 
102
108
  const metadata: ImageMetadata = {
@@ -31,7 +31,6 @@ mock.module('../jsonLoader', () => ({
31
31
  ],
32
32
  }),
33
33
  loadIconsConfig: async () => ({ favicon: '', appleTouchIcon: '' }),
34
- loadResponsiveScalesConfig: async () => ({}),
35
34
  loadPrefetchConfig: async () => ({ enabled: false }),
36
35
  }));
37
36
 
@@ -717,6 +716,20 @@ describe('ssrRenderer', () => {
717
716
  expect(html).toBe('');
718
717
  });
719
718
 
719
+ test('skips node with unresolved string-template if', async () => {
720
+ // {{buttonText}} stays unresolved when the referenced prop is undefined
721
+ // (evaluateTemplate's backward-compat fallback returns the original template).
722
+ // Treat unresolved templates as falsy so the node is hidden.
723
+ const node = {
724
+ type: 'link',
725
+ href: '/page',
726
+ children: 'link',
727
+ if: '{{buttonText}}',
728
+ };
729
+ const html = await render(node);
730
+ expect(html).toBe('');
731
+ });
732
+
720
733
  test('evaluates boolean mapping if condition - renders when mapped to true', async () => {
721
734
  // Component renders within buildComponentHTML with componentResolvedProps
722
735
  // We need to use a component that sets up the mapping context
@@ -1478,6 +1491,95 @@ describe('ssrRenderer', () => {
1478
1491
  expect(result.html).toContain('Submit');
1479
1492
  });
1480
1493
 
1494
+ test('applies instance styles when component root is a LinkNode', async () => {
1495
+ // Mirrors the figmaexport Button: component structure rooted in a LinkNode.
1496
+ // Instance style overrides must be merged onto the rendered <a>.
1497
+ const buttonDef: ComponentDefinition = {
1498
+ component: {
1499
+ structure: {
1500
+ type: 'link',
1501
+ href: '{{link}}',
1502
+ style: { base: { backgroundColor: '#ffffff', color: '#000000' } },
1503
+ children: ['{{text}}'],
1504
+ },
1505
+ interface: {
1506
+ text: { type: 'string', default: 'Buy Tickets' },
1507
+ link: { type: 'link', default: { href: '/tickets' } },
1508
+ },
1509
+ acceptsStyles: true,
1510
+ } as any,
1511
+ };
1512
+
1513
+ const pageData: JSONPage = {
1514
+ root: {
1515
+ type: 'node',
1516
+ tag: 'div',
1517
+ children: [
1518
+ {
1519
+ type: 'component',
1520
+ component: 'Button',
1521
+ props: { text: 'Partner with us', link: '/partner' },
1522
+ style: {
1523
+ base: { backgroundColor: '#252525', color: '#ffffff' },
1524
+ tablet: {},
1525
+ mobile: {},
1526
+ },
1527
+ },
1528
+ ],
1529
+ } as any,
1530
+ };
1531
+
1532
+ const result = await renderPageSSR(pageData, { Button: buttonDef });
1533
+ // The <a> should exist with the component's default class plus the instance override classes.
1534
+ expect(result.html).toMatch(/<a\s+href="\/partner"[^>]*class="[^"]*olink[^"]*"/);
1535
+ // Instance style for backgroundColor "#252525" produces the bgc-#252525 utility class.
1536
+ expect(result.html).toContain('bgc-#252525');
1537
+ // Instance style for color "#ffffff" (the override) produces c-#ffffff.
1538
+ expect(result.html).toContain('c-#ffffff');
1539
+ });
1540
+
1541
+ test('instance style overrides structure style on the same property', async () => {
1542
+ // Regression: previously the structure's mb-0 and the instance's mb-32px both
1543
+ // ended up as same-specificity utility classes on the rendered element, and
1544
+ // the structure's class won by source order — opposite of client behavior.
1545
+ // Instance styles must replace structure styles per-property at merge time.
1546
+ const buttonDef: ComponentDefinition = {
1547
+ component: {
1548
+ structure: {
1549
+ type: 'node',
1550
+ tag: 'button',
1551
+ style: { base: { marginBottom: 0, padding: '8px' } },
1552
+ children: ['{{text}}'],
1553
+ },
1554
+ interface: {
1555
+ text: { type: 'string', default: 'Click' },
1556
+ },
1557
+ acceptsStyles: true,
1558
+ } as any,
1559
+ };
1560
+
1561
+ const pageData: JSONPage = {
1562
+ root: {
1563
+ type: 'node',
1564
+ tag: 'div',
1565
+ children: [
1566
+ {
1567
+ type: 'component',
1568
+ component: 'Button',
1569
+ props: { text: 'Hi' },
1570
+ style: { base: { marginBottom: '32px' } },
1571
+ },
1572
+ ],
1573
+ } as any,
1574
+ };
1575
+
1576
+ const result = await renderPageSSR(pageData, { Button: buttonDef });
1577
+ expect(result.html).toContain('mb-32px');
1578
+ expect(result.html).not.toContain('mb-0');
1579
+ // Untouched structure properties remain.
1580
+ expect(result.html).toContain('p-8px');
1581
+ });
1582
+
1481
1583
  test('extracts locale from path when not explicitly provided', async () => {
1482
1584
  const i18nConfig = {
1483
1585
  defaultLocale: 'en',