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.
Files changed (74) hide show
  1. package/dist/build-static.js +7 -7
  2. package/dist/chunks/{chunk-UUA5LEWF.js → chunk-6IVUG7FY.js} +138 -7
  3. package/dist/chunks/chunk-6IVUG7FY.js.map +7 -0
  4. package/dist/chunks/{chunk-B2RTLDXY.js → chunk-AZQYF6KE.js} +132 -1
  5. package/dist/chunks/chunk-AZQYF6KE.js.map +7 -0
  6. package/dist/chunks/{chunk-NKUV77SR.js → chunk-CHD5UCFF.js} +21 -9
  7. package/dist/chunks/{chunk-NKUV77SR.js.map → chunk-CHD5UCFF.js.map} +2 -2
  8. package/dist/chunks/{chunk-TPQ7APVQ.js → chunk-EQYDSPBB.js} +418 -62
  9. package/dist/chunks/chunk-EQYDSPBB.js.map +7 -0
  10. package/dist/chunks/{chunk-RQSTH2BS.js → chunk-H4JSCDNW.js} +2 -2
  11. package/dist/chunks/{chunk-EK4KESLU.js → chunk-J23ZX5AP.js} +8 -2
  12. package/dist/chunks/{chunk-EK4KESLU.js.map → chunk-J23ZX5AP.js.map} +2 -2
  13. package/dist/chunks/{chunk-D5E3OKSL.js → chunk-JER5NQVM.js} +5 -5
  14. package/dist/chunks/{chunk-BJRKEPMP.js → chunk-KPU2XHOS.js} +5 -2
  15. package/dist/chunks/{chunk-BJRKEPMP.js.map → chunk-KPU2XHOS.js.map} +2 -2
  16. package/dist/chunks/{chunk-NP76N4HQ.js → chunk-LKAGAQ3M.js} +2 -2
  17. package/dist/chunks/{chunk-3FHJUHAS.js → chunk-S2CX6HFM.js} +260 -25
  18. package/dist/chunks/chunk-S2CX6HFM.js.map +7 -0
  19. package/dist/chunks/{configService-IGJEC3MC.js → configService-CCA6AIDI.js} +3 -3
  20. package/dist/entries/server-router.js +9 -9
  21. package/dist/entries/server-router.js.map +2 -2
  22. package/dist/lib/client/index.js +54 -20
  23. package/dist/lib/client/index.js.map +3 -3
  24. package/dist/lib/server/index.js +9 -9
  25. package/dist/lib/shared/index.js +46 -10
  26. package/dist/lib/shared/index.js.map +3 -3
  27. package/entries/server-router.tsx +6 -2
  28. package/lib/client/core/ComponentBuilder.ts +8 -1
  29. package/lib/client/core/builders/embedBuilder.ts +15 -2
  30. package/lib/client/core/builders/linkNodeBuilder.ts +15 -2
  31. package/lib/client/core/builders/localeListBuilder.ts +17 -6
  32. package/lib/client/styles/StyleInjector.ts +3 -2
  33. package/lib/client/theme.ts +4 -4
  34. package/lib/server/cssGenerator.test.ts +64 -1
  35. package/lib/server/cssGenerator.ts +48 -9
  36. package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
  37. package/lib/server/providers/fileSystemCMSProvider.ts +200 -11
  38. package/lib/server/routes/index.ts +1 -1
  39. package/lib/server/routes/pages.ts +23 -1
  40. package/lib/server/services/cmsService.test.ts +246 -0
  41. package/lib/server/services/cmsService.ts +122 -5
  42. package/lib/server/services/configService.ts +5 -0
  43. package/lib/server/ssr/attributeBuilder.ts +41 -0
  44. package/lib/server/ssr/htmlGenerator.test.ts +113 -0
  45. package/lib/server/ssr/htmlGenerator.ts +51 -4
  46. package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
  47. package/lib/server/ssr/ssrRenderer.test.ts +306 -0
  48. package/lib/server/ssr/ssrRenderer.ts +182 -44
  49. package/lib/shared/cssGeneration.test.ts +267 -1
  50. package/lib/shared/cssGeneration.ts +240 -18
  51. package/lib/shared/cssProperties.test.ts +247 -1
  52. package/lib/shared/cssProperties.ts +196 -6
  53. package/lib/shared/interfaces/contentProvider.ts +39 -6
  54. package/lib/shared/pathSecurity.ts +16 -0
  55. package/lib/shared/responsiveScaling.test.ts +143 -0
  56. package/lib/shared/responsiveScaling.ts +253 -2
  57. package/lib/shared/themeDefaults.test.ts +3 -3
  58. package/lib/shared/themeDefaults.ts +3 -3
  59. package/lib/shared/types/cms.ts +28 -3
  60. package/lib/shared/types/index.ts +1 -0
  61. package/lib/shared/utilityClassConfig.ts +3 -0
  62. package/lib/shared/utilityClassMapper.test.ts +123 -0
  63. package/lib/shared/utilityClassMapper.ts +179 -8
  64. package/lib/shared/validation/schemas.ts +15 -1
  65. package/lib/shared/validation/validators.ts +26 -1
  66. package/package.json +1 -1
  67. package/dist/chunks/chunk-3FHJUHAS.js.map +0 -7
  68. package/dist/chunks/chunk-B2RTLDXY.js.map +0 -7
  69. package/dist/chunks/chunk-TPQ7APVQ.js.map +0 -7
  70. package/dist/chunks/chunk-UUA5LEWF.js.map +0 -7
  71. /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
  72. /package/dist/chunks/{chunk-D5E3OKSL.js.map → chunk-JER5NQVM.js.map} +0 -0
  73. /package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-LKAGAQ3M.js.map} +0 -0
  74. /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-CCA6AIDI.js.map} +0 -0
@@ -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
  });
@@ -7,6 +7,7 @@ import type { CMSProvider, CMSSchemaInfo } from '../../shared/interfaces/content
7
7
  import type {
8
8
  CMSSchema,
9
9
  CMSItem,
10
+ CMSItemVersions,
10
11
  CMSRouteMatch,
11
12
  CMSListQuery,
12
13
  CMSFilterCondition,
@@ -30,6 +31,19 @@ interface CachedItems {
30
31
  timestamp: number;
31
32
  }
32
33
 
34
+ export interface CMSServiceOptions {
35
+ /**
36
+ * When true, the SSR-facing read methods (`queryItems`, `getItemsByIds`,
37
+ * `matchRoute`) merge drafts over published content so the editor preview /
38
+ * dev server reflects unpublished edits. The editor management methods
39
+ * (`getItemVersions`, `listItemsWithDraftFlag`) keep strict published
40
+ * semantics regardless of this flag — they need to surface both versions
41
+ * separately. Static builds and production runtimes leave this off so
42
+ * deployed sites only ever serve published content.
43
+ */
44
+ previewMode?: boolean;
45
+ }
46
+
33
47
  /**
34
48
  * CMS Service
35
49
  * Manages CMS schemas, route matching, and content querying
@@ -38,6 +52,7 @@ export class CMSService {
38
52
  private schemaCache = new Map<string, CMSSchemaInfo>();
39
53
  private routePatterns: RoutePattern[] = [];
40
54
  private provider?: CMSProvider;
55
+ private readonly previewMode: boolean;
41
56
 
42
57
  /** Item cache with TTL-based expiration */
43
58
  private itemsCache = new Map<string, CachedItems>();
@@ -47,9 +62,11 @@ export class CMSService {
47
62
  /**
48
63
  * Creates a new CMSService instance
49
64
  * @param provider - Optional CMSProvider for loading data (enables DI for testing)
65
+ * @param options - Service-level flags (preview mode for dev server)
50
66
  */
51
- constructor(provider?: CMSProvider) {
67
+ constructor(provider?: CMSProvider, options: CMSServiceOptions = {}) {
52
68
  this.provider = provider;
69
+ this.previewMode = options.previewMode === true;
53
70
  }
54
71
 
55
72
  /**
@@ -62,9 +79,14 @@ export class CMSService {
62
79
  }
63
80
 
64
81
  /**
65
- * Get items with caching
66
- * Returns cached items if available and not expired, otherwise fetches fresh data
67
- * Rich-text fields are automatically preprocessed to HTML for template interpolation
82
+ * Get items with caching, used by SSR-facing read methods.
83
+ *
84
+ * In preview mode (dev server) drafts are merged over published — drafts
85
+ * win on a per-`_filename` basis and draft-only items are included — so
86
+ * the editor preview reflects unpublished edits. Returns published-only
87
+ * otherwise. Rich-text fields are preprocessed to HTML for template
88
+ * interpolation either way.
89
+ *
68
90
  * @param collection - Collection ID to fetch items for
69
91
  * @returns Array of CMSItems with rich-text fields converted to HTML markers
70
92
  */
@@ -78,7 +100,21 @@ export class CMSService {
78
100
  }
79
101
 
80
102
  // Fetch fresh items
81
- const rawItems = await this.provider!.getItems(collection);
103
+ let rawItems = await this.provider!.getItems(collection);
104
+
105
+ if (this.previewMode) {
106
+ const drafts = await this.provider!.getAllDrafts(collection);
107
+ if (drafts.length > 0) {
108
+ const byFilename = new Map<string, CMSItem>();
109
+ for (const item of rawItems) {
110
+ if (item._filename) byFilename.set(item._filename, item);
111
+ }
112
+ for (const draft of drafts) {
113
+ if (draft._filename) byFilename.set(draft._filename, draft);
114
+ }
115
+ rawItems = Array.from(byFilename.values());
116
+ }
117
+ }
82
118
 
83
119
  // Preprocess rich-text fields for template interpolation
84
120
  const items = this.preprocessRichTextFields(collection, rawItems);
@@ -405,6 +441,87 @@ export class CMSService {
405
441
  * Only clears items cache and provider cache before re-initializing.
406
442
  * Schema/route caches are swapped atomically inside initialize().
407
443
  */
444
+ // ----------------------------------------------------------------------
445
+ // Draft-version methods (Studio-only — never used by SSR / static export)
446
+ // ----------------------------------------------------------------------
447
+
448
+ /**
449
+ * Load both published and draft versions of an item, used by the editor.
450
+ * Returns `{}` when neither version exists.
451
+ */
452
+ async getItemVersions(collection: string, filename: string): Promise<CMSItemVersions> {
453
+ if (!this.provider) return {};
454
+ const [published, draft] = await Promise.all([
455
+ this.provider.getItemByFilename(collection, filename),
456
+ this.provider.getDraft(collection, filename),
457
+ ]);
458
+ const result: CMSItemVersions = {};
459
+ if (published) result.published = published;
460
+ if (draft) result.draft = draft;
461
+ return result;
462
+ }
463
+
464
+ /**
465
+ * List all items in a collection for the Studio item list:
466
+ * - Published items annotated with `_hasDraft: true` when a draft sibling exists.
467
+ * - Draft-only items (no published file yet) returned with `_isDraft: true`.
468
+ */
469
+ async listItemsWithDraftFlag(collection: string): Promise<CMSItem[]> {
470
+ if (!this.provider) return [];
471
+
472
+ const [published, drafts] = await Promise.all([
473
+ this.provider.getItems(collection),
474
+ this.provider.getAllDrafts(collection),
475
+ ]);
476
+
477
+ const draftFilenames = new Set(drafts.map(d => d._filename).filter(Boolean) as string[]);
478
+
479
+ const annotatedPublished: CMSItem[] = published.map(item => {
480
+ if (item._filename && draftFilenames.has(item._filename)) {
481
+ return { ...item, _hasDraft: true };
482
+ }
483
+ return item;
484
+ });
485
+
486
+ const publishedFilenames = new Set(published.map(i => i._filename).filter(Boolean) as string[]);
487
+ const draftOnly = drafts.filter(d => d._filename && !publishedFilenames.has(d._filename));
488
+
489
+ return [...annotatedPublished, ...draftOnly];
490
+ }
491
+
492
+ /** Pass-through to provider; no caching (drafts are read fresh in the editor). */
493
+ async getDraft(collection: string, filename: string): Promise<CMSItem | null> {
494
+ if (!this.provider) return null;
495
+ return this.provider.getDraft(collection, filename);
496
+ }
497
+
498
+ async hasDraft(collection: string, filename: string): Promise<boolean> {
499
+ if (!this.provider) return false;
500
+ return this.provider.hasDraft(collection, filename);
501
+ }
502
+
503
+ async saveDraft(collection: string, item: CMSItem): Promise<void> {
504
+ if (!this.provider) throw new Error('CMS provider not configured');
505
+ await this.provider.saveDraft(collection, item);
506
+ // Drafts don't enter the published items cache, but we invalidate it anyway
507
+ // so listItemsWithDraftFlag reflects the new draft on the next read.
508
+ this.itemsCache.delete(collection);
509
+ }
510
+
511
+ async discardDraft(collection: string, filename: string): Promise<void> {
512
+ if (!this.provider) throw new Error('CMS provider not configured');
513
+ await this.provider.discardDraft(collection, filename);
514
+ this.itemsCache.delete(collection);
515
+ }
516
+
517
+ async publishDraft(collection: string, filename: string): Promise<CMSItem> {
518
+ if (!this.provider) throw new Error('CMS provider not configured');
519
+ const item = await this.provider.publishDraft(collection, filename);
520
+ // Promotion changes the published file → invalidate cached items.
521
+ this.itemsCache.delete(collection);
522
+ return item;
523
+ }
524
+
408
525
  async refreshSchemas(): Promise<void> {
409
526
  if (!this.provider) {
410
527
  return;
@@ -175,7 +175,12 @@ export class ConfigService {
175
175
 
176
176
  return {
177
177
  enabled: userScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
178
+ mode: (userScales as { mode?: 'breakpoints' | 'fluid' }).mode ?? DEFAULT_RESPONSIVE_SCALES.mode,
178
179
  baseReference: userScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
180
+ fluidRange: (userScales as { fluidRange?: { min: number; max: number } }).fluidRange
181
+ ?? (DEFAULT_RESPONSIVE_SCALES.fluidRange ? { ...DEFAULT_RESPONSIVE_SCALES.fluidRange } : undefined),
182
+ siteMargin: (userScales as { siteMargin?: { min: number; max: number } }).siteMargin
183
+ ?? (DEFAULT_RESPONSIVE_SCALES.siteMargin ? { ...DEFAULT_RESPONSIVE_SCALES.siteMargin } : undefined),
179
184
  fontSize: this.mergeScaleCategory(
180
185
  userScales.fontSize as BreakpointScales | undefined,
181
186
  DEFAULT_RESPONSIVE_SCALES.fontSize
@@ -75,6 +75,47 @@ export function buildAttributes(props: Record<string, unknown>, exclude: string[
75
75
  return attrs.length > 0 ? ' ' + attrs.join(' ') : '';
76
76
  }
77
77
 
78
+ /**
79
+ * Build the editor-only attribute string used by XRay and selection sync.
80
+ * Mirrors what client-side ComponentBuilder.ts writes via ref callbacks.
81
+ * Returns a leading-space-prefixed attribute string, or '' when there's
82
+ * nothing to emit (or no elementPath supplied).
83
+ */
84
+ export function buildEditorAttrs(opts: {
85
+ elementPath?: number[];
86
+ cmsItemIndexPath?: number[];
87
+ cmsListPaths?: number[][];
88
+ componentContext?: string;
89
+ parentComponentName?: string;
90
+ isComponentRoot?: boolean;
91
+ isSlotContent?: boolean;
92
+ isCMSListContainer?: boolean;
93
+ }): string {
94
+ const { elementPath } = opts;
95
+ if (!elementPath) return '';
96
+
97
+ const parts: string[] = [`data-element-path="${escapeHtml(elementPath.join(','))}"`];
98
+
99
+ if (opts.cmsItemIndexPath && opts.cmsItemIndexPath.length > 0) {
100
+ parts.push(`data-cms-item-index="${escapeHtml(opts.cmsItemIndexPath.join('.'))}"`);
101
+ if (opts.cmsListPaths && opts.cmsListPaths.length === opts.cmsItemIndexPath.length) {
102
+ const ctx = JSON.stringify({ itemIndexPath: opts.cmsItemIndexPath, listPaths: opts.cmsListPaths });
103
+ parts.push(`data-cms-context="${escapeHtml(ctx)}"`);
104
+ }
105
+ }
106
+
107
+ if (opts.isCMSListContainer) parts.push(`data-cms-list="true"`);
108
+ if (opts.isComponentRoot) parts.push(`data-component-root="true"`);
109
+ if (opts.parentComponentName) {
110
+ parts.push(`data-parent-component="${escapeHtml(opts.parentComponentName)}"`);
111
+ }
112
+ if (opts.componentContext && !opts.isSlotContent) {
113
+ parts.push(`data-component-context="${escapeHtml(opts.componentContext)}"`);
114
+ }
115
+
116
+ return ' ' + parts.join(' ');
117
+ }
118
+
78
119
  /**
79
120
  * Convert a style object to inline CSS string
80
121
  * Handles CSS variables (--is-0: value) and regular properties