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
@@ -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
- return {
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
- const jsonFiles = files.filter(f => f.endsWith('.json'));
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
- // Remove internal _slug field before saving (keep _filename in file for persistence)
245
- const { _slug, ...itemData } = item;
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 (or slug for backward compat)
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
  */
@@ -5,7 +5,8 @@
5
5
 
6
6
  import { variableService } from '../../services/VariableService';
7
7
  import { generateVariablesCSS } from '../../cssGenerator';
8
- import { loadBreakpointConfig, loadResponsiveScalesConfig } from '../../jsonLoader';
8
+ import { loadBreakpointConfig } from '../../jsonLoader';
9
+ import { configService } from '../../services/configService';
9
10
  import { jsonResponse } from './shared';
10
11
 
11
12
  /**
@@ -22,9 +23,10 @@ export async function handleVariablesStatusRoute(): Promise<Response> {
22
23
  * Returns generated CSS custom properties from variables.json
23
24
  */
24
25
  export async function handleVariablesCSSRoute(): Promise<Response> {
26
+ await configService.load();
25
27
  const variablesConfig = await variableService.loadConfig();
26
28
  const breakpointConfig = await loadBreakpointConfig();
27
- const responsiveScalesConfig = await loadResponsiveScalesConfig();
29
+ const responsiveScalesConfig = configService.getResponsiveScales();
28
30
  const css = generateVariablesCSS(variablesConfig, breakpointConfig, responsiveScalesConfig);
29
31
  return new Response(css, {
30
32
  headers: { 'Content-Type': 'text/css' }
@@ -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
  }
@@ -15,15 +15,23 @@ import { generateErrorPage } from '../ssr/errorOverlay';
15
15
  import { cacheScript, hashContent } from '../scriptCache';
16
16
  import { readTextFile, fileExists, serveFile } from '../runtime';
17
17
 
18
+ /** HTTP header sent by the studio's /__static__/ proxy to flag editor-preview requests. */
19
+ const EDITOR_HEADER = 'x-meno-editor';
20
+
18
21
  /**
19
22
  * Handle page route requests
20
23
  */
21
24
  export async function handlePageRoute(
22
25
  url: URL,
23
- context: RouteContext
26
+ context: RouteContext,
27
+ req?: Request
24
28
  ): Promise<Response | undefined> {
25
29
  const { pageService, componentService, cmsService, injectLiveReload, isEditor, serverPort } = context;
26
30
  const pagePath = url.pathname;
31
+ // Editor selection attributes (data-element-path, data-cms-context, ...) are emitted
32
+ // only when the request is proxied from the studio editor (it sets `x-meno-editor: 1`).
33
+ // Direct access to the SSR preview server (e.g., http://localhost:8082/) gets clean output.
34
+ const injectEditorAttrs = req?.headers.get(EDITOR_HEADER) === '1';
27
35
 
28
36
  // Load i18n config for locale extraction
29
37
  const i18nConfig = await loadI18nConfig();
@@ -40,6 +48,16 @@ export async function handlePageRoute(
40
48
  const cmsMatch = await cmsService.matchRoute(pathWithoutLocale, locale);
41
49
 
42
50
  if (cmsMatch) {
51
+ // Editor-only draft preview: when the studio editor requests a CMS page
52
+ // with `?previewDraft=1` and a draft exists for that item, swap the
53
+ // matched item for the draft. SSR continues to render normally; the
54
+ // live site (no editor header, no query param) is unaffected.
55
+ if (injectEditorAttrs && url.searchParams.get('previewDraft') === '1' && cmsMatch.item._filename) {
56
+ const draft = await cmsService.getDraft(cmsMatch.collection, cmsMatch.item._filename);
57
+ if (draft) {
58
+ cmsMatch.item = draft;
59
+ }
60
+ }
43
61
  // Load template page content by file path
44
62
  const templatePageContent = await loadPageByFilePath(cmsMatch.pagePath);
45
63
 
@@ -79,6 +97,7 @@ export async function handlePageRoute(
79
97
  pageLibraries: typedPageData.meta?.libraries,
80
98
  pageCustomCode: typedPageData.meta?.customCode,
81
99
  injectLiveReload,
100
+ injectEditorAttrs,
82
101
  isEditor,
83
102
  serverPort,
84
103
  returnSeparateJS: true,
@@ -115,6 +134,7 @@ export async function handlePageRoute(
115
134
  cmsTemplatePath,
116
135
  pageLibraries: typedPageData.meta?.libraries,
117
136
  pageCustomCode: typedPageData.meta?.customCode,
137
+ injectEditorAttrs,
118
138
  isEditor,
119
139
  });
120
140
 
@@ -200,6 +220,7 @@ export async function handlePageRoute(
200
220
  pageLibraries: pageData.meta?.libraries,
201
221
  pageCustomCode: pageData.meta?.customCode,
202
222
  injectLiveReload,
223
+ injectEditorAttrs,
203
224
  isEditor,
204
225
  serverPort,
205
226
  returnSeparateJS: true,
@@ -234,6 +255,7 @@ export async function handlePageRoute(
234
255
  cmsService,
235
256
  pageLibraries: pageData.meta?.libraries,
236
257
  pageCustomCode: pageData.meta?.customCode,
258
+ injectEditorAttrs,
237
259
  isEditor,
238
260
  });
239
261
 
@@ -122,6 +122,13 @@ describe('CMSService', () => {
122
122
  },
123
123
  saveItem: async () => {},
124
124
  deleteItem: async () => {},
125
+ // Draft methods — overridden per test where needed; default stubs
126
+ getDraft: async () => null,
127
+ getAllDrafts: async () => [],
128
+ hasDraft: async () => false,
129
+ saveDraft: async () => {},
130
+ discardDraft: async () => {},
131
+ publishDraft: async () => { throw new Error('publishDraft not implemented in default mock'); },
125
132
  };
126
133
  service = new CMSService(mockProvider);
127
134
  await service.initialize();
@@ -641,6 +648,12 @@ describe('CMSService', () => {
641
648
  getItemById: async () => null,
642
649
  saveItem: async () => {},
643
650
  deleteItem: async () => {},
651
+ getDraft: async () => null,
652
+ getAllDrafts: async () => [],
653
+ hasDraft: async () => false,
654
+ saveDraft: async () => {},
655
+ discardDraft: async () => {},
656
+ publishDraft: async () => { throw new Error('not implemented'); },
644
657
  };
645
658
 
646
659
  const richService = new CMSService(richTextProvider);
@@ -690,6 +703,12 @@ describe('CMSService', () => {
690
703
  getItemById: async () => null,
691
704
  saveItem: async () => {},
692
705
  deleteItem: async () => {},
706
+ getDraft: async () => null,
707
+ getAllDrafts: async () => [],
708
+ hasDraft: async () => false,
709
+ saveDraft: async () => {},
710
+ discardDraft: async () => {},
711
+ publishDraft: async () => { throw new Error('not implemented'); },
693
712
  };
694
713
 
695
714
  const svc = new CMSService(provider);
@@ -729,6 +748,12 @@ describe('CMSService', () => {
729
748
  getItemById: async () => null,
730
749
  saveItem: async () => {},
731
750
  deleteItem: async () => {},
751
+ getDraft: async () => null,
752
+ getAllDrafts: async () => [],
753
+ hasDraft: async () => false,
754
+ saveDraft: async () => {},
755
+ discardDraft: async () => {},
756
+ publishDraft: async () => { throw new Error('not implemented'); },
732
757
  };
733
758
 
734
759
  const svc = new CMSService(provider);
@@ -767,6 +792,12 @@ describe('CMSService', () => {
767
792
  getItemById: async () => null,
768
793
  saveItem: async () => {},
769
794
  deleteItem: async () => {},
795
+ getDraft: async () => null,
796
+ getAllDrafts: async () => [],
797
+ hasDraft: async () => false,
798
+ saveDraft: async () => {},
799
+ discardDraft: async () => {},
800
+ publishDraft: async () => { throw new Error('not implemented'); },
770
801
  };
771
802
 
772
803
  const svc = new CMSService(provider);
@@ -777,4 +808,219 @@ describe('CMSService', () => {
777
808
  expect(items[1].content).toBeUndefined();
778
809
  });
779
810
  });
811
+
812
+ describe('drafts', () => {
813
+ function makeService(opts: {
814
+ published?: CMSItem[];
815
+ drafts?: CMSItem[];
816
+ }): CMSService {
817
+ const published = opts.published ?? [];
818
+ const drafts = opts.drafts ?? [];
819
+ const findByFilename = (list: CMSItem[], f: string) =>
820
+ list.find(i => i._filename === f) || null;
821
+
822
+ const provider: CMSProvider = {
823
+ getAllSchemas: async () => new Map([['blog-posts', mockSchemaInfo]]),
824
+ getItems: async () => published,
825
+ getItemBySlug: async (_c, s) => findByFilename(published, s),
826
+ getItemByFilename: async (_c, f) => findByFilename(published, f),
827
+ getItemById: async (_c, id) => published.find(i => i._id === id) ?? null,
828
+ saveItem: async () => {},
829
+ deleteItem: async () => {},
830
+ getDraft: async (_c, f) => findByFilename(drafts, f),
831
+ getAllDrafts: async () => drafts,
832
+ hasDraft: async (_c, f) => drafts.some(d => d._filename === f),
833
+ saveDraft: async () => {},
834
+ discardDraft: async () => {},
835
+ publishDraft: async (_c, f) => {
836
+ const d = findByFilename(drafts, f);
837
+ if (!d) throw new Error('no draft');
838
+ return { ...d, _isDraft: undefined };
839
+ },
840
+ };
841
+ const s = new CMSService(provider);
842
+ return s;
843
+ }
844
+
845
+ it('getItemVersions returns published + draft when both exist', async () => {
846
+ const svc = makeService({
847
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
848
+ drafts: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true }],
849
+ });
850
+ await svc.initialize();
851
+ const versions = await svc.getItemVersions('blog-posts', 'hello');
852
+ expect(versions.published?.title).toBe('Hello');
853
+ expect(versions.draft?.title).toBe('Hello (WIP)');
854
+ });
855
+
856
+ it('getItemVersions returns draft-only when no published file exists', async () => {
857
+ const svc = makeService({
858
+ published: [],
859
+ drafts: [{ _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'New', _isDraft: true }],
860
+ });
861
+ await svc.initialize();
862
+ const versions = await svc.getItemVersions('blog-posts', 'brand-new');
863
+ expect(versions.published).toBeUndefined();
864
+ expect(versions.draft?.title).toBe('New');
865
+ });
866
+
867
+ it('listItemsWithDraftFlag annotates published items that have drafts', async () => {
868
+ const svc = makeService({
869
+ published: [
870
+ { _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' },
871
+ { _id: '2', _filename: 'second', slug: 'second', title: 'Second' },
872
+ ],
873
+ drafts: [
874
+ { _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true },
875
+ ],
876
+ });
877
+ await svc.initialize();
878
+ const list = await svc.listItemsWithDraftFlag('blog-posts');
879
+ expect(list).toHaveLength(2);
880
+ const hello = list.find(i => i._filename === 'hello');
881
+ const second = list.find(i => i._filename === 'second');
882
+ expect(hello?._hasDraft).toBe(true);
883
+ expect(second?._hasDraft).toBeUndefined();
884
+ });
885
+
886
+ it('listItemsWithDraftFlag includes draft-only items at end', async () => {
887
+ const svc = makeService({
888
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
889
+ drafts: [
890
+ { _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'New', _isDraft: true },
891
+ ],
892
+ });
893
+ await svc.initialize();
894
+ const list = await svc.listItemsWithDraftFlag('blog-posts');
895
+ expect(list).toHaveLength(2);
896
+ const draftOnly = list.find(i => i._filename === 'brand-new');
897
+ expect(draftOnly?._isDraft).toBe(true);
898
+ });
899
+
900
+ it('queryItems is unaffected by drafts (still published-only)', async () => {
901
+ const svc = makeService({
902
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
903
+ drafts: [
904
+ { _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'New', _isDraft: true },
905
+ ],
906
+ });
907
+ await svc.initialize();
908
+ const items = await svc.queryItems({ collection: 'blog-posts' });
909
+ expect(items).toHaveLength(1);
910
+ expect(items[0]._filename).toBe('hello');
911
+ });
912
+
913
+ it('publishDraft delegates to provider and invalidates cache', async () => {
914
+ const svc = makeService({
915
+ published: [],
916
+ drafts: [{ _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'New', _isDraft: true }],
917
+ });
918
+ await svc.initialize();
919
+ const result = await svc.publishDraft('blog-posts', 'brand-new');
920
+ expect(result.title).toBe('New');
921
+ });
922
+ });
923
+
924
+ describe('preview mode', () => {
925
+ function makeService(opts: {
926
+ previewMode?: boolean;
927
+ published?: CMSItem[];
928
+ drafts?: CMSItem[];
929
+ }): CMSService {
930
+ const published = opts.published ?? [];
931
+ const drafts = opts.drafts ?? [];
932
+ const findByFilename = (list: CMSItem[], f: string) =>
933
+ list.find(i => i._filename === f) || null;
934
+
935
+ const provider: CMSProvider = {
936
+ getAllSchemas: async () => new Map([['blog-posts', mockSchemaInfo]]),
937
+ getItems: async () => published,
938
+ getItemBySlug: async (_c, s) => findByFilename(published, s),
939
+ getItemByFilename: async (_c, f) => findByFilename(published, f),
940
+ getItemById: async (_c, id) => published.find(i => i._id === id) ?? null,
941
+ saveItem: async () => {},
942
+ deleteItem: async () => {},
943
+ getDraft: async (_c, f) => findByFilename(drafts, f),
944
+ getAllDrafts: async () => drafts,
945
+ hasDraft: async (_c, f) => drafts.some(d => d._filename === f),
946
+ saveDraft: async () => {},
947
+ discardDraft: async () => {},
948
+ publishDraft: async () => { throw new Error('not used'); },
949
+ };
950
+ return new CMSService(provider, { previewMode: opts.previewMode });
951
+ }
952
+
953
+ it('queryItems merges drafts over published when previewMode is true', async () => {
954
+ const svc = makeService({
955
+ previewMode: true,
956
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
957
+ drafts: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true }],
958
+ });
959
+ await svc.initialize();
960
+ const items = await svc.queryItems({ collection: 'blog-posts' });
961
+ expect(items).toHaveLength(1);
962
+ expect(items[0].title).toBe('Hello (WIP)');
963
+ expect(items[0]._isDraft).toBe(true);
964
+ });
965
+
966
+ it('queryItems includes draft-only items in preview mode', async () => {
967
+ const svc = makeService({
968
+ previewMode: true,
969
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
970
+ drafts: [{ _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'Draft Only', _isDraft: true }],
971
+ });
972
+ await svc.initialize();
973
+ const items = await svc.queryItems({ collection: 'blog-posts' });
974
+ expect(items).toHaveLength(2);
975
+ expect(items.find(i => i._filename === 'brand-new')?._isDraft).toBe(true);
976
+ });
977
+
978
+ it('getItemsByIds merges drafts in preview mode', async () => {
979
+ const svc = makeService({
980
+ previewMode: true,
981
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
982
+ drafts: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true }],
983
+ });
984
+ await svc.initialize();
985
+ const items = await svc.getItemsByIds('blog-posts', ['hello']);
986
+ expect(items[0]?.title).toBe('Hello (WIP)');
987
+ expect(items[0]?._isDraft).toBe(true);
988
+ });
989
+
990
+ it('preview mode does not affect editor management endpoints', async () => {
991
+ // getItemVersions and listItemsWithDraftFlag must keep strict
992
+ // published/draft separation regardless of previewMode — the editor
993
+ // needs to surface both versions individually.
994
+ const svc = makeService({
995
+ previewMode: true,
996
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
997
+ drafts: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true }],
998
+ });
999
+ await svc.initialize();
1000
+ const versions = await svc.getItemVersions('blog-posts', 'hello');
1001
+ expect(versions.published?.title).toBe('Hello');
1002
+ expect(versions.published?._isDraft).toBeUndefined();
1003
+ expect(versions.draft?.title).toBe('Hello (WIP)');
1004
+
1005
+ const list = await svc.listItemsWithDraftFlag('blog-posts');
1006
+ const item = list.find(i => i._filename === 'hello');
1007
+ expect(item?.title).toBe('Hello');
1008
+ expect(item?._hasDraft).toBe(true);
1009
+ });
1010
+
1011
+ it('default (production) mode keeps queryItems published-only', async () => {
1012
+ const svc = makeService({
1013
+ published: [{ _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello' }],
1014
+ drafts: [
1015
+ { _id: '1', _filename: 'hello', slug: 'hello', title: 'Hello (WIP)', _isDraft: true },
1016
+ { _id: 'new', _filename: 'brand-new', slug: 'brand-new', title: 'Draft Only', _isDraft: true },
1017
+ ],
1018
+ });
1019
+ await svc.initialize();
1020
+ const items = await svc.queryItems({ collection: 'blog-posts' });
1021
+ expect(items).toHaveLength(1);
1022
+ expect(items[0].title).toBe('Hello');
1023
+ expect(items[0]._isDraft).toBeUndefined();
1024
+ });
1025
+ });
780
1026
  });