meno-core 1.0.48 → 1.0.50

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 (89) hide show
  1. package/build-astro.ts +6 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
  4. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
  5. package/dist/chunks/chunk-7NIC4I3V.js.map +7 -0
  6. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  7. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
  9. package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
  10. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
  11. package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
  12. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  13. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  14. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  15. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
  16. package/dist/chunks/chunk-LPVETICS.js.map +7 -0
  17. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
  18. package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
  19. package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
  20. package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
  21. package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.js} +3 -3
  22. package/dist/entries/server-router.js +9 -9
  23. package/dist/entries/server-router.js.map +2 -2
  24. package/dist/lib/client/index.js +92 -32
  25. package/dist/lib/client/index.js.map +3 -3
  26. package/dist/lib/server/index.js +14 -12
  27. package/dist/lib/server/index.js.map +2 -2
  28. package/dist/lib/shared/index.js +46 -10
  29. package/dist/lib/shared/index.js.map +3 -3
  30. package/entries/server-router.tsx +6 -2
  31. package/lib/client/core/ComponentBuilder.test.ts +34 -0
  32. package/lib/client/core/ComponentBuilder.ts +33 -4
  33. package/lib/client/core/builders/embedBuilder.ts +28 -7
  34. package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
  35. package/lib/client/core/builders/localeListBuilder.ts +30 -11
  36. package/lib/client/styles/StyleInjector.ts +3 -2
  37. package/lib/client/templateEngine.ts +24 -0
  38. package/lib/client/theme.ts +4 -4
  39. package/lib/server/cssGenerator.test.ts +64 -1
  40. package/lib/server/cssGenerator.ts +48 -9
  41. package/lib/server/fileWatcher.test.ts +134 -0
  42. package/lib/server/fileWatcher.ts +100 -32
  43. package/lib/server/jsonLoader.ts +1 -0
  44. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  45. package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
  46. package/lib/server/routes/index.ts +1 -1
  47. package/lib/server/routes/pages.ts +23 -1
  48. package/lib/server/services/cmsService.test.ts +246 -0
  49. package/lib/server/services/cmsService.ts +122 -5
  50. package/lib/server/services/configService.ts +6 -0
  51. package/lib/server/services/fileWatcherService.ts +17 -0
  52. package/lib/server/ssr/attributeBuilder.ts +41 -0
  53. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  54. package/lib/server/ssr/htmlGenerator.ts +62 -7
  55. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  56. package/lib/server/ssr/ssrRenderer.test.ts +564 -0
  57. package/lib/server/ssr/ssrRenderer.ts +228 -49
  58. package/lib/server/webflow/buildWebflow.ts +1 -1
  59. package/lib/server/websocketManager.test.ts +61 -6
  60. package/lib/server/websocketManager.ts +25 -1
  61. package/lib/shared/cssGeneration.test.ts +267 -1
  62. package/lib/shared/cssGeneration.ts +240 -18
  63. package/lib/shared/cssProperties.test.ts +275 -1
  64. package/lib/shared/cssProperties.ts +223 -7
  65. package/lib/shared/interfaces/contentProvider.ts +39 -6
  66. package/lib/shared/pathSecurity.ts +16 -0
  67. package/lib/shared/responsiveScaling.test.ts +143 -0
  68. package/lib/shared/responsiveScaling.ts +253 -2
  69. package/lib/shared/themeDefaults.test.ts +3 -3
  70. package/lib/shared/themeDefaults.ts +3 -3
  71. package/lib/shared/types/api.ts +10 -1
  72. package/lib/shared/types/cms.ts +46 -12
  73. package/lib/shared/types/index.ts +1 -0
  74. package/lib/shared/utilityClassConfig.ts +3 -0
  75. package/lib/shared/utilityClassMapper.test.ts +123 -0
  76. package/lib/shared/utilityClassMapper.ts +179 -8
  77. package/lib/shared/validation/schemas.test.ts +93 -0
  78. package/lib/shared/validation/schemas.ts +71 -16
  79. package/lib/shared/validation/validators.ts +26 -1
  80. package/package.json +1 -1
  81. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  82. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  83. package/dist/chunks/chunk-BJRKEPMP.js.map +0 -7
  84. package/dist/chunks/chunk-NKUV77SR.js.map +0 -7
  85. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  86. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  87. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
  88. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  89. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-VOY2MY2K.js.map} +0 -0
@@ -8,6 +8,7 @@ import type { ReactElement } from "react";
8
8
  import type { EmbedNode, StyleObject, ResponsiveStyleObject, InteractiveStyles } from "../../../shared/types";
9
9
  import { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
10
10
  import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
+ import { getCachedResponsiveScalesConfig } from "../../responsiveStyleResolver";
11
12
  import { pathToString } from "../../../shared/pathArrayUtils";
12
13
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
14
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
@@ -126,7 +127,13 @@ export function buildEmbed(
126
127
  i18nResolver
127
128
  ) as StyleObject | ResponsiveStyleObject;
128
129
  }
129
- const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
130
+ const scales = getCachedResponsiveScalesConfig();
131
+ const fluidActive =
132
+ scales?.enabled === true && scales?.mode === 'fluid';
133
+ const utilityClasses = responsiveStylesToClasses(
134
+ processedStyle as StyleObject | ResponsiveStyleObject,
135
+ { fluidActive, responsiveScales: scales ?? undefined }
136
+ );
130
137
  UtilityClassCollector.collect(utilityClasses);
131
138
  classNames.push(...utilityClasses);
132
139
  }
@@ -174,7 +181,13 @@ export function buildEmbed(
174
181
  const previewClasses: string[] = [];
175
182
  for (const rule of nodeInteractiveStyles) {
176
183
  if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
177
- const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
184
+ const previewScales = getCachedResponsiveScalesConfig();
185
+ const previewFluidActive =
186
+ previewScales?.enabled === true && previewScales?.mode === 'fluid';
187
+ const styleClasses = responsiveStylesToClasses(
188
+ rule.style as StyleObject | ResponsiveStyleObject,
189
+ { fluidActive: previewFluidActive, responsiveScales: previewScales ?? undefined }
190
+ );
178
191
  UtilityClassCollector.collect(styleClasses);
179
192
  previewClasses.push(...styleClasses);
180
193
  }
@@ -186,11 +199,19 @@ export function buildEmbed(
186
199
  }
187
200
  }
188
201
 
189
- // Add extracted attributes className if present
190
- if (extractedAttributes.className) {
191
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
192
- classNames.push(...attrClasses);
193
- delete extractedAttributes.className;
202
+ // Add extracted attributes class/className if present
203
+ // JSON uses "class"; React uses "className". Without merging, the raw "class"
204
+ // would be spread onto the React element below and override the className that
205
+ // holds the utility classes derived from node.style.
206
+ if ('class' in extractedAttributes || 'className' in extractedAttributes) {
207
+ const attrClass = ((extractedAttributes as Record<string, unknown>).class
208
+ ?? (extractedAttributes as Record<string, unknown>).className
209
+ ?? '') as string;
210
+ if (attrClass) {
211
+ classNames.push(...attrClass.split(/\s+/));
212
+ }
213
+ delete (extractedAttributes as Record<string, unknown>).class;
214
+ delete (extractedAttributes as Record<string, unknown>).className;
194
215
  }
195
216
 
196
217
  // Set final className
@@ -8,6 +8,7 @@ import type { ReactElement } from "react";
8
8
  import type { LinkNode, StyleObject, ResponsiveStyleObject, InteractiveStyles } from "../../../shared/types";
9
9
  import { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
10
10
  import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
+ import { getCachedResponsiveScalesConfig } from "../../responsiveStyleResolver";
11
12
  import { pathToString } from "../../../shared/pathArrayUtils";
12
13
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
14
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
@@ -101,7 +102,13 @@ export function buildLinkNode(
101
102
  i18nResolver
102
103
  ) as StyleObject | ResponsiveStyleObject;
103
104
  }
104
- const utilityClasses = responsiveStylesToClasses(processedStyle as StyleObject | ResponsiveStyleObject);
105
+ const scales = getCachedResponsiveScalesConfig();
106
+ const fluidActive =
107
+ scales?.enabled === true && scales?.mode === 'fluid';
108
+ const utilityClasses = responsiveStylesToClasses(
109
+ processedStyle as StyleObject | ResponsiveStyleObject,
110
+ { fluidActive, responsiveScales: scales ?? undefined }
111
+ );
105
112
  UtilityClassCollector.collect(utilityClasses);
106
113
  classNames.push(...utilityClasses);
107
114
  }
@@ -149,7 +156,13 @@ export function buildLinkNode(
149
156
  const previewClasses: string[] = [];
150
157
  for (const rule of nodeInteractiveStyles) {
151
158
  if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
152
- const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
159
+ const previewScales = getCachedResponsiveScalesConfig();
160
+ const previewFluidActive =
161
+ previewScales?.enabled === true && previewScales?.mode === 'fluid';
162
+ const styleClasses = responsiveStylesToClasses(
163
+ rule.style as StyleObject | ResponsiveStyleObject,
164
+ { fluidActive: previewFluidActive, responsiveScales: previewScales ?? undefined }
165
+ );
153
166
  UtilityClassCollector.collect(styleClasses);
154
167
  previewClasses.push(...styleClasses);
155
168
  }
@@ -161,11 +174,19 @@ export function buildLinkNode(
161
174
  }
162
175
  }
163
176
 
164
- // Add extracted attributes className if present
165
- if (extractedAttributes.className) {
166
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
167
- classNames.push(...attrClasses);
168
- delete extractedAttributes.className;
177
+ // Add extracted attributes class/className if present
178
+ // JSON uses "class"; React uses "className". Without merging, the raw "class"
179
+ // would be spread onto the React element below and override the className that
180
+ // holds the utility classes derived from node.style.
181
+ if ('class' in extractedAttributes || 'className' in extractedAttributes) {
182
+ const attrClass = ((extractedAttributes as Record<string, unknown>).class
183
+ ?? (extractedAttributes as Record<string, unknown>).className
184
+ ?? '') as string;
185
+ if (attrClass) {
186
+ classNames.push(...attrClass.split(/\s+/));
187
+ }
188
+ delete (extractedAttributes as Record<string, unknown>).class;
189
+ delete (extractedAttributes as Record<string, unknown>).className;
169
190
  }
170
191
 
171
192
  // Add is-current class when link href matches current page path
@@ -8,6 +8,7 @@ import type { ReactElement } from "react";
8
8
  import type { LocaleListNode, StyleObject, ResponsiveStyleObject, InteractiveStyles } from "../../../shared/types";
9
9
  import { extractAttributesFromNode } from "../../../shared/attributeNodeUtils";
10
10
  import { responsiveStylesToClasses } from "../../../shared/utilityClassMapper";
11
+ import { getCachedResponsiveScalesConfig } from "../../responsiveStyleResolver";
11
12
  import { pathToString } from "../../../shared/pathArrayUtils";
12
13
  import { generateElementClassName, type ElementClassContext } from "../../../shared/elementClassName";
13
14
  import { InteractiveStylesRegistry } from "../../InteractiveStylesRegistry";
@@ -73,9 +74,16 @@ export function buildLocaleList(
73
74
  // Start building className
74
75
  let classNames: string[] = [];
75
76
 
77
+ // Resolve fluid mode once for all conversions in this builder
78
+ const scales = getCachedResponsiveScalesConfig();
79
+ const fluidActive = scales?.enabled === true && scales?.mode === 'fluid';
80
+
76
81
  // Convert container styles to utility classes
77
82
  if (node.style) {
78
- const utilityClasses = responsiveStylesToClasses(node.style as StyleObject | ResponsiveStyleObject);
83
+ const utilityClasses = responsiveStylesToClasses(
84
+ node.style as StyleObject | ResponsiveStyleObject,
85
+ { fluidActive, responsiveScales: scales ?? undefined }
86
+ );
79
87
  UtilityClassCollector.collect(utilityClasses);
80
88
  classNames.push(...utilityClasses);
81
89
  }
@@ -123,7 +131,10 @@ export function buildLocaleList(
123
131
  const previewClasses: string[] = [];
124
132
  for (const rule of nodeInteractiveStyles) {
125
133
  if (rule.previewProp && ctx.componentResolvedProps[rule.previewProp] === true) {
126
- const styleClasses = responsiveStylesToClasses(rule.style as StyleObject | ResponsiveStyleObject);
134
+ const styleClasses = responsiveStylesToClasses(
135
+ rule.style as StyleObject | ResponsiveStyleObject,
136
+ { fluidActive, responsiveScales: scales ?? undefined }
137
+ );
127
138
  UtilityClassCollector.collect(styleClasses);
128
139
  previewClasses.push(...styleClasses);
129
140
  }
@@ -135,11 +146,19 @@ export function buildLocaleList(
135
146
  }
136
147
  }
137
148
 
138
- // Add extracted attributes className if present
139
- if (extractedAttributes.className) {
140
- const attrClasses = (extractedAttributes.className as string).split(/\s+/);
141
- classNames.push(...attrClasses);
142
- delete extractedAttributes.className;
149
+ // Add extracted attributes class/className if present
150
+ // JSON uses "class"; React uses "className". Without merging, the raw "class"
151
+ // would be spread onto the React element below and override the className that
152
+ // holds the utility classes derived from node.style.
153
+ if ('class' in extractedAttributes || 'className' in extractedAttributes) {
154
+ const attrClass = ((extractedAttributes as Record<string, unknown>).class
155
+ ?? (extractedAttributes as Record<string, unknown>).className
156
+ ?? '') as string;
157
+ if (attrClass) {
158
+ classNames.push(...attrClass.split(/\s+/));
159
+ }
160
+ delete (extractedAttributes as Record<string, unknown>).class;
161
+ delete (extractedAttributes as Record<string, unknown>).className;
143
162
  }
144
163
 
145
164
  // Set final className
@@ -169,10 +188,10 @@ export function buildLocaleList(
169
188
  const currentLocaleCode = locale || i18nConfig?.defaultLocale || 'en';
170
189
 
171
190
  // Convert item styles, active item styles, separator styles, and flag styles to utility classes
172
- const itemClasses = node.itemStyle ? responsiveStylesToClasses(node.itemStyle as StyleObject | ResponsiveStyleObject) : [];
173
- const activeItemClasses = node.activeItemStyle ? responsiveStylesToClasses(node.activeItemStyle as StyleObject | ResponsiveStyleObject) : [];
174
- const separatorClasses = node.separatorStyle ? responsiveStylesToClasses(node.separatorStyle as StyleObject | ResponsiveStyleObject) : [];
175
- const flagClasses = node.flagStyle ? responsiveStylesToClasses(node.flagStyle as StyleObject | ResponsiveStyleObject) : [];
191
+ const itemClasses = node.itemStyle ? responsiveStylesToClasses(node.itemStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
192
+ const activeItemClasses = node.activeItemStyle ? responsiveStylesToClasses(node.activeItemStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
193
+ const separatorClasses = node.separatorStyle ? responsiveStylesToClasses(node.separatorStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
194
+ const flagClasses = node.flagStyle ? responsiveStylesToClasses(node.flagStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
176
195
  UtilityClassCollector.collect(itemClasses);
177
196
  UtilityClassCollector.collect(activeItemClasses);
178
197
  UtilityClassCollector.collect(separatorClasses);
@@ -12,7 +12,7 @@ import type { ComponentRegistry } from '../componentRegistry';
12
12
  import type { ElementRegistry } from '../elementRegistry';
13
13
  import { hasTemplates, processCodeTemplates } from '../templateEngine';
14
14
  import { generateAllInteractiveCSS } from '../../shared/cssGeneration';
15
- import { getCachedBreakpointConfig, getCachedRemConversionConfig } from '../responsiveStyleResolver';
15
+ import { getCachedBreakpointConfig, getCachedRemConversionConfig, getCachedResponsiveScalesConfig } from '../responsiveStyleResolver';
16
16
  import { DEFAULT_BREAKPOINTS } from '../../shared/breakpoints';
17
17
  import { InteractiveStylesRegistry } from '../InteractiveStylesRegistry';
18
18
  import { UtilityClassCollector } from './UtilityClassCollector';
@@ -153,7 +153,8 @@ export class StyleInjector {
153
153
 
154
154
  const breakpointConfig = getCachedBreakpointConfig() || DEFAULT_BREAKPOINTS;
155
155
  const remConversionConfig = getCachedRemConversionConfig() || undefined;
156
- const interactiveCSS = generateAllInteractiveCSS(interactiveStylesMap, breakpointConfig, remConversionConfig);
156
+ const responsiveScalesConfig = getCachedResponsiveScalesConfig() || undefined;
157
+ const interactiveCSS = generateAllInteractiveCSS(interactiveStylesMap, breakpointConfig, remConversionConfig, responsiveScalesConfig);
157
158
  if (!interactiveCSS) return;
158
159
 
159
160
  if (document.head) {
@@ -797,6 +797,30 @@ export function processStructure(
797
797
  // Special handling for interactiveStyles - preserve as-is without mangling
798
798
  // Interactive styles contain StyleMapping objects that shouldn't be converted to nodes
799
799
  (processed as unknown as Record<string, unknown>).interactiveStyles = value;
800
+ } else if (key === 'source' && preservedType === NODE_TYPE.LIST) {
801
+ // Pre-resolve a bare prop-name source against the current component's
802
+ // props. Without this, a list with `sourceType: "prop"` placed inside
803
+ // another component's slot would fail to resolve at SSR time, because
804
+ // by then `ctx.componentResolvedProps` belongs to the slot host (not
805
+ // the component whose prop this references). The {{template}} form is
806
+ // already handled by the default-recursion branch below.
807
+ const structureRec = structure as Record<string, unknown>;
808
+ const sourceType = (structureRec.sourceType as string | undefined) ?? 'prop';
809
+ if (
810
+ sourceType === 'prop' &&
811
+ typeof value === 'string' &&
812
+ value !== '' &&
813
+ !hasTemplates(value) &&
814
+ context.props &&
815
+ Array.isArray((context.props as Record<string, unknown>)[value])
816
+ ) {
817
+ (processed as unknown as Record<string, unknown>).source = (context.props as Record<string, unknown>)[value];
818
+ } else {
819
+ const processedValue = processStructure(value as ComponentNode | ComponentNode[] | string | number | null | undefined, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
820
+ if (processedValue !== null && processedValue !== undefined) {
821
+ (processed as unknown as Record<string, unknown>).source = processedValue;
822
+ }
823
+ }
800
824
  } else if (key !== 'type' && key !== 'children' && key !== 'style' && key !== 'props') {
801
825
  const processedValue = processStructure(value as ComponentNode | ComponentNode[] | string | number | null | undefined, context, viewportWidth, instanceChildren, preserveResponsiveStyles, depth + 1);
802
826
  // Only assign if it's a valid value
@@ -77,9 +77,9 @@ const lightThemeColors: ThemeColors = {
77
77
  backgroundTertiary: '#eaeef2',
78
78
  border: '#d0d7de',
79
79
  borderSecondary: '#d1d5da',
80
- text: '#24292e',
80
+ text: '#1a1a1a',
81
81
  textSecondary: '#586069',
82
- textMuted: '#6a737d',
82
+ textMuted: '#525a63',
83
83
  codeString: '#032f62',
84
84
  codeNumber: '#005cc5',
85
85
  codeKey: '#005cc5',
@@ -125,9 +125,9 @@ const darkThemeColors: ThemeColors = {
125
125
  backgroundTertiary: '#252525',
126
126
  border: '#333333',
127
127
  borderSecondary: '#444444',
128
- text: '#cccccc',
128
+ text: '#ebebeb',
129
129
  textSecondary: '#cccccc',
130
- textMuted: '#888888',
130
+ textMuted: '#b0b0b0',
131
131
  codeString: '#ffffff',
132
132
  codeNumber: '#b5cea8',
133
133
  codeKey: '#9cdcfe',
@@ -1,6 +1,8 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { generateColorVariablesCSS, generateThemeColorVariablesCSS } from './cssGenerator';
2
+ import { generateColorVariablesCSS, generateThemeColorVariablesCSS, generateVariablesCSS } from './cssGenerator';
3
3
  import type { ColorVariables, ThemeConfig } from '../shared/types/colors';
4
+ import type { ResponsiveScales } from '../shared/responsiveScaling';
5
+ import { DEFAULT_BREAKPOINTS } from '../shared/breakpoints';
4
6
 
5
7
  describe('cssGenerator', () => {
6
8
  describe('generateColorVariablesCSS', () => {
@@ -169,4 +171,65 @@ describe('cssGenerator', () => {
169
171
  expect(css).toContain('\n\n');
170
172
  });
171
173
  });
174
+
175
+ describe('generateVariablesCSS — fluid mode', () => {
176
+ const fluidScales: ResponsiveScales = {
177
+ enabled: true,
178
+ mode: 'fluid',
179
+ baseReference: 16,
180
+ fluidRange: { min: 320, max: 1440 },
181
+ siteMargin: { min: 16, max: 32 },
182
+ fontSize: { tablet: 0.88, mobile: 0.75 },
183
+ };
184
+
185
+ test('emits --site-margin clamp on :root when fluid mode is enabled (no user vars)', () => {
186
+ const css = generateVariablesCSS(
187
+ { variables: [] },
188
+ DEFAULT_BREAKPOINTS,
189
+ fluidScales
190
+ );
191
+ expect(css).toContain('--site-margin: clamp(16px,');
192
+ expect(css).toContain(', 32px)');
193
+ expect(css).not.toContain('@media');
194
+ });
195
+
196
+
197
+ test('emits user variables with clamp() in fluid mode (no @media)', () => {
198
+ const css = generateVariablesCSS(
199
+ {
200
+ variables: [
201
+ { name: 'h1-fs', cssVar: '--h1-fs', value: '32px', type: 'fontSize' },
202
+ ],
203
+ },
204
+ DEFAULT_BREAKPOINTS,
205
+ fluidScales
206
+ );
207
+ // mobile fontSize scale=0.75 → MIN=28
208
+ expect(css).toContain('--h1-fs: clamp(28px,');
209
+ expect(css).toContain(', 32px)');
210
+ expect(css).not.toContain('@media');
211
+ // and the site-margin var still emitted
212
+ expect(css).toContain('--site-margin: clamp(16px,');
213
+ });
214
+
215
+ test('breakpoints mode (default) keeps @media blocks for vars and DOES NOT emit --site-margin', () => {
216
+ const css = generateVariablesCSS(
217
+ {
218
+ variables: [
219
+ { name: 'h1-fs', cssVar: '--h1-fs', value: '32px', type: 'fontSize' },
220
+ ],
221
+ },
222
+ DEFAULT_BREAKPOINTS,
223
+ { ...fluidScales, mode: 'breakpoints' }
224
+ );
225
+ expect(css).not.toContain('--site-margin');
226
+ expect(css).toContain('@media (max-width: 1024px)');
227
+ expect(css).not.toContain('clamp(');
228
+ });
229
+
230
+ test('returns empty string when no user vars AND fluid disabled', () => {
231
+ const css = generateVariablesCSS({ variables: [] }, DEFAULT_BREAKPOINTS);
232
+ expect(css).toBe('');
233
+ });
234
+ });
172
235
  });
@@ -9,7 +9,14 @@ import { resolvePaletteColor } from '../shared/types/colors';
9
9
  import type { VariablesConfig, CSSVariable } from '../shared/types/variables';
10
10
  import type { BreakpointConfig } from '../shared/breakpoints';
11
11
  import type { ResponsiveScales, BreakpointScales } from '../shared/responsiveScaling';
12
- import { scalePropertyValue } from '../shared/responsiveScaling';
12
+ import {
13
+ scalePropertyValue,
14
+ buildSiteMarginClamp,
15
+ buildFluidPropertyValue,
16
+ getSmallestBreakpointName,
17
+ DEFAULT_FLUID_RANGE,
18
+ DEFAULT_SITE_MARGIN,
19
+ } from '../shared/responsiveScaling';
13
20
 
14
21
  /**
15
22
  * Generate CSS color variable declarations from color variables
@@ -75,18 +82,50 @@ export function generateVariablesCSS(
75
82
  breakpoints?: BreakpointConfig,
76
83
  responsiveScales?: ResponsiveScales
77
84
  ): string {
78
- if (!config.variables || config.variables.length === 0) {
79
- return '';
85
+ const cssBlocks: string[] = [];
86
+ const fluidActive =
87
+ responsiveScales?.enabled === true && responsiveScales?.mode === 'fluid';
88
+
89
+ // Build base :root block: user-defined variables + (in fluid mode) --site-margin.
90
+ // In fluid mode user variables get clamp() values directly on :root rather
91
+ // than @media overrides — so we run them through buildFluidPropertyValue here.
92
+ const fluidRange = responsiveScales?.fluidRange ?? DEFAULT_FLUID_RANGE;
93
+ const fluidBaseRef = responsiveScales?.baseReference || 16;
94
+ const smallestBp = fluidActive ? getSmallestBreakpointName(breakpoints) : null;
95
+
96
+ const baseVars: string[] = (config.variables ?? []).map(v => {
97
+ if (fluidActive && smallestBp && v.type !== 'none') {
98
+ const categoryScales = responsiveScales?.[v.type] as BreakpointScales | undefined;
99
+ const scale = categoryScales?.[smallestBp];
100
+ if (scale != null && scale !== 1) {
101
+ const fluid = buildFluidPropertyValue(
102
+ v.value,
103
+ scale,
104
+ fluidRange.min,
105
+ fluidRange.max,
106
+ fluidBaseRef
107
+ );
108
+ if (fluid) return ` ${v.cssVar}: ${fluid};`;
109
+ }
110
+ }
111
+ return ` ${v.cssVar}: ${v.value};`;
112
+ });
113
+
114
+ if (fluidActive) {
115
+ const siteMargin = responsiveScales?.siteMargin ?? DEFAULT_SITE_MARGIN;
116
+ baseVars.push(` --site-margin: ${buildSiteMarginClamp(siteMargin, fluidRange)};`);
80
117
  }
81
118
 
82
- const cssBlocks: string[] = [];
119
+ if (baseVars.length === 0) {
120
+ return '';
121
+ }
83
122
 
84
- // Base :root block with all variables
85
- const baseVars = config.variables.map(v => ` ${v.cssVar}: ${v.value};`);
86
123
  cssBlocks.push(`:root {\n${baseVars.join('\n')}\n}`);
87
124
 
88
- // Generate @media blocks for responsive scaling
89
- if (breakpoints && responsiveScales?.enabled) {
125
+ // Generate @media blocks for responsive scaling — only in breakpoints mode.
126
+ // In fluid mode all scaling is encoded in clamp() on the base :root block.
127
+ const userVariables = config.variables ?? [];
128
+ if (breakpoints && responsiveScales?.enabled && !fluidActive && userVariables.length > 0) {
90
129
  const baseRef = responsiveScales.baseReference || 16;
91
130
 
92
131
  // Sort breakpoints by value descending (largest first)
@@ -96,7 +135,7 @@ export function generateVariablesCSS(
96
135
  for (const [bpName, bpEntry] of sortedBreakpoints) {
97
136
  const scaledVars: string[] = [];
98
137
 
99
- for (const variable of config.variables) {
138
+ for (const variable of userVariables) {
100
139
  // Per-variable override takes priority — stored string is the CSS value
101
140
  if (variable.scales && variable.scales[bpName]) {
102
141
  const overrideValue = variable.scales[bpName];
@@ -0,0 +1,134 @@
1
+ import { describe, test, expect, afterEach, beforeEach } from 'bun:test';
2
+ import { mkdirSync, rmSync, writeFileSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { FileWatcher } from './fileWatcher';
6
+ import { setProjectRoot, getProjectRoot } from './projectContext';
7
+
8
+ /**
9
+ * Regression test for the "blank project" case: when `templates/` (or `cms/`)
10
+ * doesn't exist when the dev server starts, the watcher used to silently
11
+ * early-out and never fire onPageChange / onCMSChange even after the
12
+ * directory was created by an external tool. The deferred-attach path in
13
+ * fileWatcher.ts fixes this — the test asserts that callbacks now fire after
14
+ * a target directory is created post-startup.
15
+ *
16
+ * fs.watch on different OSes coalesces events differently, so the test waits
17
+ * generously and only asserts that the callback fires at least once.
18
+ */
19
+ async function settle(ms = 300): Promise<void> {
20
+ await new Promise(r => setTimeout(r, ms));
21
+ }
22
+
23
+ describe('FileWatcher deferred attach', () => {
24
+ let projectRoot: string;
25
+
26
+ beforeEach(() => {
27
+ projectRoot = join(tmpdir(), `meno-fw-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
28
+ mkdirSync(projectRoot, { recursive: true });
29
+ });
30
+
31
+ afterEach(() => {
32
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
33
+ });
34
+
35
+ test('watchTemplates fires onPageChange after templates/ is created post-startup', async () => {
36
+ const seen: string[] = [];
37
+ const watcher = new FileWatcher({
38
+ onPageChange: async (p) => { seen.push(p); },
39
+ });
40
+
41
+ // templates/ does NOT exist yet — pre-fix this returned silently.
42
+ const templatesDir = join(projectRoot, 'templates');
43
+ watcher.watchTemplates(templatesDir);
44
+
45
+ // Simulate the external write that triggers the bug: create templates/
46
+ // and drop a JSON file into it.
47
+ await settle(50);
48
+ mkdirSync(templatesDir);
49
+ await settle(100);
50
+ writeFileSync(join(templatesDir, 'blog.json'), '{}');
51
+ await settle(300);
52
+
53
+ watcher.stopAll();
54
+ expect(seen.length).toBeGreaterThanOrEqual(1);
55
+ });
56
+
57
+ test('watchCMS fires onCMSChange after cms/ is created post-startup', async () => {
58
+ const seen: string[] = [];
59
+ const watcher = new FileWatcher({
60
+ onCMSChange: async (collection) => { seen.push(collection); },
61
+ });
62
+
63
+ const cmsDir = join(projectRoot, 'cms');
64
+ watcher.watchCMS(cmsDir);
65
+
66
+ await settle(50);
67
+ mkdirSync(join(cmsDir, 'blog'), { recursive: true });
68
+ await settle(100);
69
+ writeFileSync(join(cmsDir, 'blog', 'post.json'), '{}');
70
+ await settle(300);
71
+
72
+ watcher.stopAll();
73
+ expect(seen.length).toBeGreaterThanOrEqual(1);
74
+ });
75
+
76
+ test('returns silently when parent dir also does not exist', () => {
77
+ const watcher = new FileWatcher({});
78
+ expect(() => watcher.watchTemplates(join(projectRoot, 'nonexistent-parent', 'templates'))).not.toThrow();
79
+ watcher.stopAll();
80
+ });
81
+ });
82
+
83
+ describe('FileWatcher project.config.json', () => {
84
+ let projectRoot: string;
85
+ let prevRoot: string;
86
+
87
+ beforeEach(() => {
88
+ prevRoot = getProjectRoot();
89
+ projectRoot = join(tmpdir(), `meno-fw-cfg-${Date.now()}-${Math.random().toString(36).slice(2)}`);
90
+ mkdirSync(projectRoot, { recursive: true });
91
+ // watchProjectConfig reads getProjectRoot() — point it at the temp dir.
92
+ setProjectRoot(projectRoot);
93
+ });
94
+
95
+ afterEach(() => {
96
+ setProjectRoot(prevRoot);
97
+ try { rmSync(projectRoot, { recursive: true, force: true }); } catch { /* ignore */ }
98
+ });
99
+
100
+ test('fires onProjectConfigChange when project.config.json is written', async () => {
101
+ let fired = 0;
102
+ const watcher = new FileWatcher({
103
+ onProjectConfigChange: async () => { fired++; },
104
+ });
105
+
106
+ watcher.watchProjectConfig();
107
+ await settle(50);
108
+
109
+ // Simulate AI / external tool editing project.config.json to add a locale.
110
+ writeFileSync(join(projectRoot, 'project.config.json'), JSON.stringify({
111
+ i18n: { defaultLocale: 'en', locales: [{ code: 'en' }, { code: 'fr' }] }
112
+ }));
113
+ await settle(300);
114
+
115
+ watcher.stopAll();
116
+ expect(fired).toBeGreaterThanOrEqual(1);
117
+ });
118
+
119
+ test('ignores writes to unrelated files at the project root', async () => {
120
+ let fired = 0;
121
+ const watcher = new FileWatcher({
122
+ onProjectConfigChange: async () => { fired++; },
123
+ });
124
+
125
+ watcher.watchProjectConfig();
126
+ await settle(50);
127
+
128
+ writeFileSync(join(projectRoot, 'README.md'), 'hello');
129
+ await settle(200);
130
+
131
+ watcher.stopAll();
132
+ expect(fired).toBe(0);
133
+ });
134
+ });