meno-core 1.0.48 → 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.
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
- package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
- package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
- package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
- package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
- package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
- package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
- package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
- package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
- package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
- package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
- package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
- package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
- package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
- package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
- package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
- package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
- package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
- package/dist/entries/server-router.js +9 -9
- package/dist/entries/server-router.js.map +2 -2
- package/dist/lib/client/index.js +54 -20
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +9 -9
- package/dist/lib/shared/index.js +46 -10
- package/dist/lib/shared/index.js.map +3 -3
- package/entries/server-router.tsx +6 -2
- package/lib/client/core/ComponentBuilder.ts +8 -1
- package/lib/client/core/builders/embedBuilder.ts +15 -2
- package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
- package/lib/client/core/builders/localeListBuilder.ts +17 -6
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/theme.ts +4 -4
- package/lib/server/cssGenerator.test.ts +64 -1
- package/lib/server/cssGenerator.ts +48 -9
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
- package/lib/server/routes/index.ts +1 -1
- package/lib/server/routes/pages.ts +23 -1
- package/lib/server/services/cmsService.test.ts +246 -0
- package/lib/server/services/cmsService.ts +122 -5
- package/lib/server/services/configService.ts +5 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +113 -0
- package/lib/server/ssr/htmlGenerator.ts +51 -4
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +306 -0
- package/lib/server/ssr/ssrRenderer.ts +182 -44
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +247 -1
- package/lib/shared/cssProperties.ts +196 -6
- package/lib/shared/interfaces/contentProvider.ts +39 -6
- package/lib/shared/pathSecurity.ts +16 -0
- package/lib/shared/responsiveScaling.test.ts +143 -0
- package/lib/shared/responsiveScaling.ts +253 -2
- package/lib/shared/themeDefaults.test.ts +3 -3
- package/lib/shared/themeDefaults.ts +3 -3
- package/lib/shared/types/cms.ts +28 -3
- package/lib/shared/types/index.ts +1 -0
- package/lib/shared/utilityClassConfig.ts +3 -0
- package/lib/shared/utilityClassMapper.test.ts +123 -0
- package/lib/shared/utilityClassMapper.ts +179 -8
- package/lib/shared/validation/schemas.ts +15 -1
- package/lib/shared/validation/validators.ts +26 -1
- package/package.json +1 -1
- package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
- package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
- package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
- package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
- /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
- /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
- /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
|
@@ -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
|
|
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) {
|
package/lib/client/theme.ts
CHANGED
|
@@ -77,9 +77,9 @@ const lightThemeColors: ThemeColors = {
|
|
|
77
77
|
backgroundTertiary: '#eaeef2',
|
|
78
78
|
border: '#d0d7de',
|
|
79
79
|
borderSecondary: '#d1d5da',
|
|
80
|
-
text: '#
|
|
80
|
+
text: '#1a1a1a',
|
|
81
81
|
textSecondary: '#586069',
|
|
82
|
-
textMuted: '#
|
|
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: '#
|
|
128
|
+
text: '#ebebeb',
|
|
129
129
|
textSecondary: '#cccccc',
|
|
130
|
-
textMuted: '#
|
|
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 {
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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];
|
|
@@ -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
|
});
|
|
@@ -7,10 +7,12 @@ import { existsSync, readdirSync, mkdirSync } from 'fs';
|
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import type { CMSProvider, CMSSchemaInfo } from '../../shared/interfaces/contentProvider';
|
|
9
9
|
import type { CMSSchema, CMSItem } from '../../shared/types';
|
|
10
|
-
import { validateCMSItem } from '../../shared/validation/validators';
|
|
11
|
-
import { isSafePathSegment, isValidIdentifier } from '../../shared/pathSecurity';
|
|
10
|
+
import { validateCMSItem, validateCMSDraftItem } from '../../shared/validation/validators';
|
|
11
|
+
import { isSafePathSegment, isValidIdentifier, isReservedDraftFilename, CMS_DRAFT_SUFFIX } from '../../shared/pathSecurity';
|
|
12
12
|
import { readJsonFile, fileExists } from '../runtime';
|
|
13
13
|
|
|
14
|
+
const DRAFT_FILE_SUFFIX = `${CMS_DRAFT_SUFFIX}.json`;
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
17
|
* Load JSON file content from disk
|
|
16
18
|
* Logs a warning if JSON parsing fails (helps debug malformed files)
|
|
@@ -28,14 +30,25 @@ async function loadJSONFile(filePath: string): Promise<unknown | null> {
|
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
|
-
* Normalize a raw CMS item by adding _slug and _filename fields
|
|
33
|
+
* Normalize a raw CMS item by adding _slug and _filename fields.
|
|
34
|
+
* If `isDraft` is true, also sets the transient `_isDraft` flag.
|
|
32
35
|
*/
|
|
33
|
-
function normalizeItem(content: unknown, filename: string): CMSItem {
|
|
34
|
-
|
|
36
|
+
function normalizeItem(content: unknown, filename: string, isDraft = false): CMSItem {
|
|
37
|
+
const base: CMSItem = {
|
|
35
38
|
...content as CMSItem,
|
|
36
39
|
_slug: filename,
|
|
37
40
|
_filename: filename,
|
|
38
41
|
};
|
|
42
|
+
if (isDraft) base._isDraft = true;
|
|
43
|
+
return base;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Strip transient fields that must never be persisted to disk.
|
|
48
|
+
*/
|
|
49
|
+
function stripTransient<T extends Record<string, unknown>>(item: T): Omit<T, '_slug' | '_isDraft' | '_hasDraft' | '_url'> {
|
|
50
|
+
const { _slug, _isDraft, _hasDraft, _url, ...rest } = item;
|
|
51
|
+
return rest;
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
/**
|
|
@@ -61,13 +74,16 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
61
74
|
}
|
|
62
75
|
|
|
63
76
|
/**
|
|
64
|
-
* Validate filename to prevent path traversal attacks
|
|
77
|
+
* Validate filename to prevent path traversal attacks and reserved-suffix collisions.
|
|
65
78
|
* @throws Error if filename is invalid
|
|
66
79
|
*/
|
|
67
80
|
private validateFilename(filename: string): void {
|
|
68
81
|
if (!isSafePathSegment(filename)) {
|
|
69
82
|
throw new Error(`Invalid filename: "${filename}". Filenames cannot contain path separators or traversal sequences.`);
|
|
70
83
|
}
|
|
84
|
+
if (isReservedDraftFilename(filename)) {
|
|
85
|
+
throw new Error(`Invalid filename: "${filename}". The "${CMS_DRAFT_SUFFIX}" suffix is reserved for draft files.`);
|
|
86
|
+
}
|
|
71
87
|
}
|
|
72
88
|
|
|
73
89
|
/**
|
|
@@ -131,7 +147,8 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
131
147
|
}
|
|
132
148
|
|
|
133
149
|
const files = readdirSync(collectionDir);
|
|
134
|
-
|
|
150
|
+
// Published items only — exclude `*.draft.json` files (draft-version siblings)
|
|
151
|
+
const jsonFiles = files.filter(f => f.endsWith('.json') && !f.endsWith(DRAFT_FILE_SUFFIX));
|
|
135
152
|
|
|
136
153
|
const results = await Promise.all(
|
|
137
154
|
jsonFiles.map(async file => {
|
|
@@ -241,27 +258,199 @@ export class FileSystemCMSProvider implements CMSProvider {
|
|
|
241
258
|
mkdirSync(collectionDir, { recursive: true });
|
|
242
259
|
}
|
|
243
260
|
|
|
244
|
-
//
|
|
245
|
-
const
|
|
261
|
+
// Strip transient fields (_slug, _isDraft, _hasDraft, _url) before persisting
|
|
262
|
+
const itemData = stripTransient(item as Record<string, unknown>);
|
|
246
263
|
|
|
247
264
|
const filePath = join(collectionDir, `${filename}.json`);
|
|
248
265
|
await writeFile(filePath, JSON.stringify(itemData, null, 2), 'utf-8');
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
/**
|
|
252
|
-
* Delete item by filename
|
|
269
|
+
* Delete item by filename. Removes the published file AND any draft sibling.
|
|
253
270
|
*/
|
|
254
271
|
async deleteItem(collection: string, filename: string): Promise<void> {
|
|
255
272
|
this.validateCollection(collection);
|
|
256
273
|
this.validateFilename(filename);
|
|
257
274
|
const { unlink } = await import('fs/promises');
|
|
258
|
-
const filePath = join(this.cmsDir, collection, `${filename}.json`);
|
|
259
275
|
|
|
276
|
+
const publishedPath = join(this.cmsDir, collection, `${filename}.json`);
|
|
277
|
+
if (existsSync(publishedPath)) {
|
|
278
|
+
await unlink(publishedPath);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const draftPath = this.draftPath(collection, filename);
|
|
282
|
+
if (existsSync(draftPath)) {
|
|
283
|
+
await unlink(draftPath);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- Draft helpers ----------------------------------------------------
|
|
288
|
+
|
|
289
|
+
private draftPath(collection: string, filename: string): string {
|
|
290
|
+
return join(this.cmsDir, collection, `${filename}${DRAFT_FILE_SUFFIX}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Get the draft version of an item, or null if no draft file exists.
|
|
295
|
+
* Drafts skip strict validation — they may be partial / WIP.
|
|
296
|
+
*/
|
|
297
|
+
async getDraft(collection: string, filename: string): Promise<CMSItem | null> {
|
|
298
|
+
this.validateCollection(collection);
|
|
299
|
+
this.validateFilename(filename);
|
|
300
|
+
const filePath = this.draftPath(collection, filename);
|
|
301
|
+
const content = await loadJSONFile(filePath);
|
|
302
|
+
|
|
303
|
+
if (!content || typeof content !== 'object') {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return normalizeItem(content, filename, /*isDraft*/ true);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* List all drafts in a collection. Used by the Studio item list to mark
|
|
312
|
+
* items that have an outstanding draft sibling (or are draft-only).
|
|
313
|
+
*/
|
|
314
|
+
async getAllDrafts(collection: string): Promise<CMSItem[]> {
|
|
315
|
+
this.validateCollection(collection);
|
|
316
|
+
const collectionDir = join(this.cmsDir, collection);
|
|
317
|
+
if (!existsSync(collectionDir)) return [];
|
|
318
|
+
|
|
319
|
+
const files = readdirSync(collectionDir).filter(f => f.endsWith(DRAFT_FILE_SUFFIX));
|
|
320
|
+
|
|
321
|
+
const results = await Promise.all(
|
|
322
|
+
files.map(async file => {
|
|
323
|
+
const filePath = join(collectionDir, file);
|
|
324
|
+
const content = await loadJSONFile(filePath);
|
|
325
|
+
return { file, content };
|
|
326
|
+
})
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const drafts: CMSItem[] = [];
|
|
330
|
+
for (const { file, content } of results) {
|
|
331
|
+
if (content && typeof content === 'object') {
|
|
332
|
+
const filename = file.slice(0, -DRAFT_FILE_SUFFIX.length);
|
|
333
|
+
drafts.push(normalizeItem(content, filename, /*isDraft*/ true));
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return drafts;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async hasDraft(collection: string, filename: string): Promise<boolean> {
|
|
340
|
+
this.validateCollection(collection);
|
|
341
|
+
this.validateFilename(filename);
|
|
342
|
+
return existsSync(this.draftPath(collection, filename));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Save the draft version of an item. Loose validation — drafts may have
|
|
347
|
+
* missing required fields or partial data. Strict validation only runs at
|
|
348
|
+
* publish time. The item's `_filename` determines the target file.
|
|
349
|
+
*/
|
|
350
|
+
async saveDraft(collection: string, item: CMSItem): Promise<void> {
|
|
351
|
+
this.validateCollection(collection);
|
|
352
|
+
const { writeFile } = await import('fs/promises');
|
|
353
|
+
|
|
354
|
+
const schemas = await this.getAllSchemas();
|
|
355
|
+
const schemaInfo = schemas.get(collection);
|
|
356
|
+
if (!schemaInfo) {
|
|
357
|
+
throw new Error(`Unknown collection: ${collection}`);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let filename: string;
|
|
361
|
+
if (item._filename) {
|
|
362
|
+
filename = item._filename;
|
|
363
|
+
} else {
|
|
364
|
+
const slugField = schemaInfo.schema.slugField;
|
|
365
|
+
const slugValue = item[slugField];
|
|
366
|
+
filename = typeof slugValue === 'string' ? slugValue : String(slugValue);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (!filename || filename === '[object Object]') {
|
|
370
|
+
throw new Error('Missing _filename field. Drafts must have _filename set on creation.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.validateFilename(filename);
|
|
374
|
+
|
|
375
|
+
const collectionDir = join(this.cmsDir, collection);
|
|
376
|
+
if (!existsSync(collectionDir)) {
|
|
377
|
+
mkdirSync(collectionDir, { recursive: true });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const itemData = stripTransient(item as Record<string, unknown>);
|
|
381
|
+
|
|
382
|
+
// Loose validation — drafts may be partial WIP. Strict validation
|
|
383
|
+
// (validateCMSItem) runs on Publish.
|
|
384
|
+
const validation = validateCMSDraftItem(itemData);
|
|
385
|
+
if (!validation.valid) {
|
|
386
|
+
const messages = validation.errors.map(e => `${e.path}: ${e.message}`).join(', ');
|
|
387
|
+
throw new Error(`Invalid draft: ${messages}`);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const filePath = this.draftPath(collection, filename);
|
|
391
|
+
await writeFile(filePath, JSON.stringify(itemData, null, 2), 'utf-8');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Discard the draft version of an item. No-op if no draft exists.
|
|
396
|
+
*/
|
|
397
|
+
async discardDraft(collection: string, filename: string): Promise<void> {
|
|
398
|
+
this.validateCollection(collection);
|
|
399
|
+
this.validateFilename(filename);
|
|
400
|
+
const { unlink } = await import('fs/promises');
|
|
401
|
+
const filePath = this.draftPath(collection, filename);
|
|
260
402
|
if (existsSync(filePath)) {
|
|
261
403
|
await unlink(filePath);
|
|
262
404
|
}
|
|
263
405
|
}
|
|
264
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Promote a draft to published. Reads `{filename}.draft.json`, writes the
|
|
409
|
+
* content to `{filename}.json`, then unlinks the draft. The published write
|
|
410
|
+
* happens first so a crash mid-operation leaves a valid published file plus
|
|
411
|
+
* an orphan draft (recoverable via the editor's Discard button) — never a
|
|
412
|
+
* gap with no published content.
|
|
413
|
+
*
|
|
414
|
+
* Throws if no draft exists.
|
|
415
|
+
*/
|
|
416
|
+
async publishDraft(collection: string, filename: string): Promise<CMSItem> {
|
|
417
|
+
this.validateCollection(collection);
|
|
418
|
+
this.validateFilename(filename);
|
|
419
|
+
const { writeFile, unlink } = await import('fs/promises');
|
|
420
|
+
|
|
421
|
+
const draftFilePath = this.draftPath(collection, filename);
|
|
422
|
+
const content = await loadJSONFile(draftFilePath);
|
|
423
|
+
if (!content || typeof content !== 'object') {
|
|
424
|
+
throw new Error(`No draft to publish for ${collection}/${filename}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Strict validation when going live
|
|
428
|
+
const item = normalizeItem(content, filename);
|
|
429
|
+
const validation = validateCMSItem(item);
|
|
430
|
+
if (!validation.valid) {
|
|
431
|
+
const messages = validation.errors.map(e => `${e.path}: ${e.message}`).join(', ');
|
|
432
|
+
throw new Error(`Cannot publish invalid draft: ${messages}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const collectionDir = join(this.cmsDir, collection);
|
|
436
|
+
if (!existsSync(collectionDir)) {
|
|
437
|
+
mkdirSync(collectionDir, { recursive: true });
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const itemData = stripTransient(validation.data as unknown as Record<string, unknown>);
|
|
441
|
+
const publishedPath = join(collectionDir, `${filename}.json`);
|
|
442
|
+
await writeFile(publishedPath, JSON.stringify(itemData, null, 2), 'utf-8');
|
|
443
|
+
|
|
444
|
+
// Published written successfully — now retire the draft. If unlink fails,
|
|
445
|
+
// the published copy is already in place; an orphan draft can be cleared
|
|
446
|
+
// via the editor's Discard action.
|
|
447
|
+
if (existsSync(draftFilePath)) {
|
|
448
|
+
await unlink(draftFilePath);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return normalizeItem(itemData, filename);
|
|
452
|
+
}
|
|
453
|
+
|
|
265
454
|
/**
|
|
266
455
|
* Clear schema cache (useful when pages are modified)
|
|
267
456
|
*/
|
|
@@ -141,7 +141,7 @@ export async function handleRoutes(
|
|
|
141
141
|
|
|
142
142
|
// Page routes (SSR)
|
|
143
143
|
if (url.pathname === '/' || (url.pathname.startsWith('/') && !url.pathname.includes('.'))) {
|
|
144
|
-
const response = await handlePageRoute(url, context);
|
|
144
|
+
const response = await handlePageRoute(url, context, req);
|
|
145
145
|
logResponseTime(startTime, req);
|
|
146
146
|
return response;
|
|
147
147
|
}
|