meno-core 1.0.47 → 1.0.49

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 (97) hide show
  1. package/build-astro.ts +2 -2
  2. package/dist/build-static.js +7 -7
  3. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  4. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  5. package/dist/chunks/{chunk-XSWR3QLI.js → chunk-AZQYF6KE.js} +261 -130
  6. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  7. package/dist/chunks/{chunk-47UNLQUU.js → chunk-CHD5UCFF.js} +57 -12
  8. package/dist/chunks/chunk-CHD5UCFF.js.map +7 -0
  9. package/dist/chunks/{chunk-FGUZOYJX.js → chunk-EQYDSPBB.js} +435 -131
  10. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  11. package/dist/chunks/{chunk-IF3RATBY.js → chunk-H4JSCDNW.js} +2 -2
  12. package/dist/chunks/{chunk-KITQJYZV.js → chunk-J23ZX5AP.js} +40 -4
  13. package/dist/chunks/chunk-J23ZX5AP.js.map +7 -0
  14. package/dist/chunks/{chunk-LJFB5EBT.js → chunk-JER5NQVM.js} +5 -5
  15. package/dist/chunks/{chunk-ZTKHJQ2Z.js → chunk-KPU2XHOS.js} +5 -2
  16. package/dist/chunks/{chunk-ZTKHJQ2Z.js.map → chunk-KPU2XHOS.js.map} +2 -2
  17. package/dist/chunks/{chunk-BCLGRZ3U.js → chunk-LKAGAQ3M.js} +2 -2
  18. package/dist/chunks/{chunk-FED5MME6.js → chunk-S2CX6HFM.js} +262 -26
  19. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  20. package/dist/chunks/{configService-DYCUEURL.js → configService-CCA6AIDI.js} +3 -3
  21. package/dist/entries/server-router.js +9 -9
  22. package/dist/entries/server-router.js.map +2 -2
  23. package/dist/lib/client/index.js +64 -20
  24. package/dist/lib/client/index.js.map +3 -3
  25. package/dist/lib/server/index.js +1737 -296
  26. package/dist/lib/server/index.js.map +4 -4
  27. package/dist/lib/shared/index.js +50 -10
  28. package/dist/lib/shared/index.js.map +3 -3
  29. package/entries/server-router.tsx +6 -2
  30. package/lib/client/core/ComponentBuilder.test.ts +17 -0
  31. package/lib/client/core/ComponentBuilder.ts +25 -1
  32. package/lib/client/core/builders/embedBuilder.ts +15 -2
  33. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  34. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  35. package/lib/client/styles/StyleInjector.ts +3 -2
  36. package/lib/client/theme.ts +4 -4
  37. package/lib/server/cssGenerator.test.ts +64 -1
  38. package/lib/server/cssGenerator.ts +48 -9
  39. package/lib/server/index.ts +1 -1
  40. package/lib/server/jsonLoader.test.ts +0 -17
  41. package/lib/server/jsonLoader.ts +0 -81
  42. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  43. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  44. package/lib/server/routes/api/variables.ts +4 -2
  45. package/lib/server/routes/index.ts +1 -1
  46. package/lib/server/routes/pages.ts +23 -1
  47. package/lib/server/services/cmsService.test.ts +246 -0
  48. package/lib/server/services/cmsService.ts +122 -5
  49. package/lib/server/services/configService.ts +5 -0
  50. package/lib/server/ssr/attributeBuilder.ts +41 -0
  51. package/lib/server/ssr/htmlGenerator.test.ts +114 -2
  52. package/lib/server/ssr/htmlGenerator.ts +53 -6
  53. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  54. package/lib/server/ssr/ssrRenderer.test.ts +362 -1
  55. package/lib/server/ssr/ssrRenderer.ts +216 -72
  56. package/lib/server/utils/jsonLineMapper.test.ts +53 -1
  57. package/lib/server/utils/jsonLineMapper.ts +43 -3
  58. package/lib/server/webflow/buildWebflow.ts +343 -123
  59. package/lib/server/webflow/index.ts +1 -0
  60. package/lib/server/webflow/nodeToWebflow.test.ts +3170 -0
  61. package/lib/server/webflow/nodeToWebflow.ts +2141 -129
  62. package/lib/server/webflow/styleMapper.test.ts +389 -0
  63. package/lib/server/webflow/styleMapper.ts +517 -63
  64. package/lib/server/webflow/templateWrapper.ts +49 -0
  65. package/lib/server/webflow/types.ts +218 -18
  66. package/lib/shared/cssGeneration.test.ts +267 -1
  67. package/lib/shared/cssGeneration.ts +240 -18
  68. package/lib/shared/cssProperties.test.ts +247 -1
  69. package/lib/shared/cssProperties.ts +196 -6
  70. package/lib/shared/elementClassName.test.ts +15 -0
  71. package/lib/shared/elementClassName.ts +7 -3
  72. package/lib/shared/interfaces/contentProvider.ts +39 -6
  73. package/lib/shared/pathSecurity.ts +16 -0
  74. package/lib/shared/registry/nodeTypes/ListNodeType.ts +1 -1
  75. package/lib/shared/responsiveScaling.test.ts +143 -0
  76. package/lib/shared/responsiveScaling.ts +253 -2
  77. package/lib/shared/themeDefaults.test.ts +3 -3
  78. package/lib/shared/themeDefaults.ts +3 -3
  79. package/lib/shared/types/cms.ts +28 -3
  80. package/lib/shared/types/index.ts +2 -0
  81. package/lib/shared/types/variables.ts +37 -0
  82. package/lib/shared/utilityClassConfig.ts +3 -0
  83. package/lib/shared/utilityClassMapper.test.ts +123 -0
  84. package/lib/shared/utilityClassMapper.ts +179 -8
  85. package/lib/shared/validation/schemas.ts +15 -1
  86. package/lib/shared/validation/validators.ts +26 -1
  87. package/package.json +1 -1
  88. package/dist/chunks/chunk-47UNLQUU.js.map +0 -7
  89. package/dist/chunks/chunk-FED5MME6.js.map +0 -7
  90. package/dist/chunks/chunk-FGUZOYJX.js.map +0 -7
  91. package/dist/chunks/chunk-KITQJYZV.js.map +0 -7
  92. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  93. package/dist/chunks/chunk-XSWR3QLI.js.map +0 -7
  94. /package/dist/chunks/{chunk-IF3RATBY.js.map → chunk-H4JSCDNW.js.map} +0 -0
  95. /package/dist/chunks/{chunk-LJFB5EBT.js.map → chunk-JER5NQVM.js.map} +0 -0
  96. /package/dist/chunks/{chunk-BCLGRZ3U.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  97. /package/dist/chunks/{configService-DYCUEURL.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -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
  }
@@ -169,10 +180,10 @@ export function buildLocaleList(
169
180
  const currentLocaleCode = locale || i18nConfig?.defaultLocale || 'en';
170
181
 
171
182
  // 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) : [];
183
+ const itemClasses = node.itemStyle ? responsiveStylesToClasses(node.itemStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
184
+ const activeItemClasses = node.activeItemStyle ? responsiveStylesToClasses(node.activeItemStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
185
+ const separatorClasses = node.separatorStyle ? responsiveStylesToClasses(node.separatorStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
186
+ const flagClasses = node.flagStyle ? responsiveStylesToClasses(node.flagStyle as StyleObject | ResponsiveStyleObject, { fluidActive, responsiveScales: scales ?? undefined }) : [];
176
187
  UtilityClassCollector.collect(itemClasses);
177
188
  UtilityClassCollector.collect(activeItemClasses);
178
189
  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) {
@@ -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];
@@ -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
@@ -341,5 +341,168 @@ describe('FileSystemCMSProvider', () => {
341
341
  const items = await provider.getItems('valid-collection');
342
342
  expect(items).toEqual([]);
343
343
  });
344
+
345
+ it('should reject filenames ending in reserved .draft suffix', async () => {
346
+ const item = { _id: '99', _filename: 'foo.draft', title: 'Sneaky', slug: 'foo' };
347
+ await expect(provider.saveItem('blog-posts', item))
348
+ .rejects.toThrow(/\.draft.*reserved/);
349
+ await expect(provider.deleteItem('blog-posts', 'foo.draft'))
350
+ .rejects.toThrow(/\.draft.*reserved/);
351
+ await expect(provider.getItemByFilename('blog-posts', 'foo.draft'))
352
+ .rejects.toThrow(/\.draft.*reserved/);
353
+ });
344
354
  });
355
+
356
+ describe('drafts', () => {
357
+ it('saveDraft writes a sibling .draft.json file and getDraft reads it back', async () => {
358
+ const draft = {
359
+ _id: '1',
360
+ _filename: 'hello-world',
361
+ title: 'Hello World — WIP edits',
362
+ slug: 'hello-world',
363
+ content: 'Updated content (not yet published)',
364
+ };
365
+
366
+ await provider.saveDraft('blog-posts', draft);
367
+
368
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(true);
369
+
370
+ const read = await provider.getDraft('blog-posts', 'hello-world');
371
+ expect(read?.title).toBe('Hello World — WIP edits');
372
+ expect(read?._isDraft).toBe(true);
373
+ });
374
+
375
+ it('drafts do not appear in published-only queries (getItems, getItemByFilename)', async () => {
376
+ // Pre-existing item: hello-world (published). Add a draft for it.
377
+ await provider.saveDraft('blog-posts', {
378
+ _id: '1',
379
+ _filename: 'hello-world',
380
+ title: 'Draft title',
381
+ slug: 'hello-world',
382
+ });
383
+
384
+ // Add a draft-only item (no published file)
385
+ await provider.saveDraft('blog-posts', {
386
+ _id: 'new-1',
387
+ _filename: 'brand-new',
388
+ title: 'Brand new draft',
389
+ slug: 'brand-new',
390
+ });
391
+
392
+ const items = await provider.getItems('blog-posts');
393
+ // Still only the two original published items
394
+ expect(items).toHaveLength(2);
395
+ expect(items.find(i => i._filename === 'brand-new')).toBeUndefined();
396
+ // hello-world's published title is unchanged
397
+ expect(items.find(i => i._filename === 'hello-world')?.title).toBe('Hello World');
398
+
399
+ const single = await provider.getItemByFilename('blog-posts', 'brand-new');
400
+ expect(single).toBeNull();
401
+ });
402
+
403
+ it('getAllDrafts returns drafts with _isDraft transient flag', async () => {
404
+ await provider.saveDraft('blog-posts', {
405
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
406
+ });
407
+ await provider.saveDraft('blog-posts', {
408
+ _id: 'new', _filename: 'brand-new', title: 'New', slug: 'brand-new',
409
+ });
410
+
411
+ const drafts = await provider.getAllDrafts('blog-posts');
412
+ expect(drafts).toHaveLength(2);
413
+ expect(drafts.every(d => d._isDraft === true)).toBe(true);
414
+ expect(drafts.map(d => d._filename).sort()).toEqual(['brand-new', 'hello-world']);
415
+ });
416
+
417
+ it('hasDraft reflects whether a draft file exists', async () => {
418
+ expect(await provider.hasDraft('blog-posts', 'hello-world')).toBe(false);
419
+ await provider.saveDraft('blog-posts', {
420
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
421
+ });
422
+ expect(await provider.hasDraft('blog-posts', 'hello-world')).toBe(true);
423
+ });
424
+
425
+ it('discardDraft removes the draft file and is a no-op when none exists', async () => {
426
+ await expect(provider.discardDraft('blog-posts', 'hello-world')).resolves.toBeUndefined();
427
+
428
+ await provider.saveDraft('blog-posts', {
429
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
430
+ });
431
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(true);
432
+
433
+ await provider.discardDraft('blog-posts', 'hello-world');
434
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
435
+ // Published file is untouched
436
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.json'))).toBe(true);
437
+ });
438
+
439
+ it('publishDraft promotes draft to published and removes the draft file', async () => {
440
+ await provider.saveDraft('blog-posts', {
441
+ _id: '1',
442
+ _filename: 'hello-world',
443
+ title: 'Updated Title',
444
+ slug: 'hello-world',
445
+ content: 'New content',
446
+ });
447
+
448
+ const result = await provider.publishDraft('blog-posts', 'hello-world');
449
+ expect(result.title).toBe('Updated Title');
450
+
451
+ // Published file now has the new content
452
+ const item = await provider.getItemByFilename('blog-posts', 'hello-world');
453
+ expect(item?.title).toBe('Updated Title');
454
+ expect(item?.content).toBe('New content');
455
+
456
+ // Draft file is gone
457
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
458
+ });
459
+
460
+ it('publishDraft of a draft-only item creates the published file', async () => {
461
+ await provider.saveDraft('blog-posts', {
462
+ _id: 'new', _filename: 'brand-new', title: 'New post', slug: 'brand-new',
463
+ });
464
+
465
+ const result = await provider.publishDraft('blog-posts', 'brand-new');
466
+ expect(result._filename).toBe('brand-new');
467
+
468
+ const items = await provider.getItems('blog-posts');
469
+ expect(items.find(i => i._filename === 'brand-new')?.title).toBe('New post');
470
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'brand-new.draft.json'))).toBe(false);
471
+ });
472
+
473
+ it('publishDraft throws when no draft exists', async () => {
474
+ await expect(provider.publishDraft('blog-posts', 'hello-world'))
475
+ .rejects.toThrow(/No draft to publish/);
476
+ });
477
+
478
+ it('deleteItem removes both published and draft files', async () => {
479
+ await provider.saveDraft('blog-posts', {
480
+ _id: '1', _filename: 'hello-world', title: 'WIP', slug: 'hello-world',
481
+ });
482
+
483
+ await provider.deleteItem('blog-posts', 'hello-world');
484
+
485
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.json'))).toBe(false);
486
+ expect(existsSync(join(CMS_DIR, 'blog-posts', 'hello-world.draft.json'))).toBe(false);
487
+ });
488
+
489
+ it('saveItem strips transient _isDraft / _hasDraft fields before writing', async () => {
490
+ const item = {
491
+ _id: '1',
492
+ _filename: 'hello-world',
493
+ title: 'Hello',
494
+ slug: 'hello-world',
495
+ _isDraft: true,
496
+ _hasDraft: true,
497
+ };
498
+ await provider.saveItem('blog-posts', item as any);
499
+
500
+ const { readFile } = await import('fs/promises');
501
+ const raw = await readFile(join(CMS_DIR, 'blog-posts', 'hello-world.json'), 'utf-8');
502
+ const parsed = JSON.parse(raw);
503
+ expect(parsed._isDraft).toBeUndefined();
504
+ expect(parsed._hasDraft).toBeUndefined();
505
+ });
506
+ });
507
+
345
508
  });