meno-core 1.0.48 → 1.0.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build-astro.ts +6 -2
- package/dist/build-static.js +7 -7
- package/dist/chunks/{chunk-D5E3OKSL.js → chunk-56EUSC6D.js} +5 -5
- package/dist/chunks/{chunk-3FHJUHAS.js → chunk-7NIC4I3V.js} +300 -43
- package/dist/chunks/chunk-7NIC4I3V.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-TPQ7APVQ.js → chunk-CVLFID6V.js} +473 -73
- package/dist/chunks/chunk-CVLFID6V.js.map +7 -0
- package/dist/chunks/{chunk-NP76N4HQ.js → chunk-EDQSMAMP.js} +13 -2
- package/dist/chunks/{chunk-NP76N4HQ.js.map → chunk-EDQSMAMP.js.map} +2 -2
- 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-UUA5LEWF.js → chunk-LPVETICS.js} +156 -8
- package/dist/chunks/chunk-LPVETICS.js.map +7 -0
- package/dist/chunks/{chunk-BJRKEPMP.js → chunk-PQ2HRXDR.js} +5 -2
- package/dist/chunks/chunk-PQ2HRXDR.js.map +7 -0
- package/dist/chunks/{chunk-NKUV77SR.js → chunk-YWJJD5D6.js} +133 -37
- package/dist/chunks/chunk-YWJJD5D6.js.map +7 -0
- package/dist/chunks/{configService-IGJEC3MC.js → configService-VOY2MY2K.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 +92 -32
- package/dist/lib/client/index.js.map +3 -3
- package/dist/lib/server/index.js +14 -12
- package/dist/lib/server/index.js.map +2 -2
- 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.test.ts +34 -0
- package/lib/client/core/ComponentBuilder.ts +33 -4
- package/lib/client/core/builders/embedBuilder.ts +28 -7
- package/lib/client/core/builders/linkNodeBuilder.ts +28 -7
- package/lib/client/core/builders/localeListBuilder.ts +30 -11
- package/lib/client/styles/StyleInjector.ts +3 -2
- package/lib/client/templateEngine.ts +24 -0
- 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/fileWatcher.test.ts +134 -0
- package/lib/server/fileWatcher.ts +100 -32
- package/lib/server/jsonLoader.ts +1 -0
- package/lib/server/providers/fileSystemCMSProvider.test.ts +163 -0
- package/lib/server/providers/fileSystemCMSProvider.ts +240 -19
- 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 +6 -0
- package/lib/server/services/fileWatcherService.ts +17 -0
- package/lib/server/ssr/attributeBuilder.ts +41 -0
- package/lib/server/ssr/htmlGenerator.test.ts +113 -0
- package/lib/server/ssr/htmlGenerator.ts +62 -7
- package/lib/server/ssr/liveReloadIntegration.test.ts +209 -0
- package/lib/server/ssr/ssrRenderer.test.ts +564 -0
- package/lib/server/ssr/ssrRenderer.ts +228 -49
- package/lib/server/webflow/buildWebflow.ts +1 -1
- package/lib/server/websocketManager.test.ts +61 -6
- package/lib/server/websocketManager.ts +25 -1
- package/lib/shared/cssGeneration.test.ts +267 -1
- package/lib/shared/cssGeneration.ts +240 -18
- package/lib/shared/cssProperties.test.ts +275 -1
- package/lib/shared/cssProperties.ts +223 -7
- 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/api.ts +10 -1
- package/lib/shared/types/cms.ts +46 -12
- 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.test.ts +93 -0
- package/lib/shared/validation/schemas.ts +71 -16
- 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-BJRKEPMP.js.map +0 -7
- package/dist/chunks/chunk-NKUV77SR.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-D5E3OKSL.js.map → chunk-56EUSC6D.js.map} +0 -0
- /package/dist/chunks/{chunk-RQSTH2BS.js.map → chunk-H4JSCDNW.js.map} +0 -0
- /package/dist/chunks/{configService-IGJEC3MC.js.map → configService-VOY2MY2K.js.map} +0 -0
|
@@ -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
|
-
*
|
|
67
|
-
*
|
|
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
|
-
|
|
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;
|
|
@@ -25,6 +25,7 @@ import { readTextFile, fileExists } from '../runtime';
|
|
|
25
25
|
*/
|
|
26
26
|
export interface IconsConfig {
|
|
27
27
|
favicon?: string;
|
|
28
|
+
faviconDark?: string;
|
|
28
29
|
appleTouchIcon?: string;
|
|
29
30
|
}
|
|
30
31
|
|
|
@@ -175,7 +176,12 @@ export class ConfigService {
|
|
|
175
176
|
|
|
176
177
|
return {
|
|
177
178
|
enabled: userScales.enabled ?? DEFAULT_RESPONSIVE_SCALES.enabled,
|
|
179
|
+
mode: (userScales as { mode?: 'breakpoints' | 'fluid' }).mode ?? DEFAULT_RESPONSIVE_SCALES.mode,
|
|
178
180
|
baseReference: userScales.baseReference ?? DEFAULT_RESPONSIVE_SCALES.baseReference,
|
|
181
|
+
fluidRange: (userScales as { fluidRange?: { min: number; max: number } }).fluidRange
|
|
182
|
+
?? (DEFAULT_RESPONSIVE_SCALES.fluidRange ? { ...DEFAULT_RESPONSIVE_SCALES.fluidRange } : undefined),
|
|
183
|
+
siteMargin: (userScales as { siteMargin?: { min: number; max: number } }).siteMargin
|
|
184
|
+
?? (DEFAULT_RESPONSIVE_SCALES.siteMargin ? { ...DEFAULT_RESPONSIVE_SCALES.siteMargin } : undefined),
|
|
179
185
|
fontSize: this.mergeScaleCategory(
|
|
180
186
|
userScales.fontSize as BreakpointScales | undefined,
|
|
181
187
|
DEFAULT_RESPONSIVE_SCALES.fontSize
|
|
@@ -14,6 +14,7 @@ import { PageCache } from '../pageCache';
|
|
|
14
14
|
import { colorService } from './ColorService';
|
|
15
15
|
import { variableService } from './VariableService';
|
|
16
16
|
import { enumService } from './EnumService';
|
|
17
|
+
import { configService } from './configService';
|
|
17
18
|
import { loadJSONFile, mapPageNameToPath } from '../jsonLoader';
|
|
18
19
|
import { buildLineMap } from '../utils/jsonLineMapper';
|
|
19
20
|
import { projectPaths } from '../projectContext';
|
|
@@ -71,9 +72,14 @@ export class FileWatcherService {
|
|
|
71
72
|
clearTimeout(this.refreshSchemasTimer);
|
|
72
73
|
}
|
|
73
74
|
const cmsService = this.cmsService;
|
|
75
|
+
const wsManager = this.wsManager;
|
|
74
76
|
this.refreshSchemasTimer = setTimeout(async () => {
|
|
75
77
|
this.refreshSchemasTimer = null;
|
|
76
78
|
await cmsService.refreshSchemas();
|
|
79
|
+
// Tell connected studio clients to re-fetch the collections list.
|
|
80
|
+
// Without this, the CMS panel only refreshes after a dev-server
|
|
81
|
+
// restart even though the server cache is now correct.
|
|
82
|
+
wsManager.broadcastCollectionsUpdate();
|
|
77
83
|
}, 50);
|
|
78
84
|
}
|
|
79
85
|
},
|
|
@@ -96,6 +102,9 @@ export class FileWatcherService {
|
|
|
96
102
|
this.wsManager.broadcastEnumsUpdate();
|
|
97
103
|
},
|
|
98
104
|
onCMSChange: async (collection: string) => {
|
|
105
|
+
// Drop the items cache for this collection so the next read returns
|
|
106
|
+
// fresh data immediately, not the previous TTL-cached snapshot.
|
|
107
|
+
this.cmsService?.clearItemsCache(collection);
|
|
99
108
|
// Broadcast to all clients to refresh CMS content
|
|
100
109
|
this.wsManager.broadcastCMSUpdate(collection);
|
|
101
110
|
},
|
|
@@ -103,6 +112,14 @@ export class FileWatcherService {
|
|
|
103
112
|
onLibraryChange: async () => {
|
|
104
113
|
this.wsManager.broadcastLibrariesUpdate();
|
|
105
114
|
},
|
|
115
|
+
onProjectConfigChange: async () => {
|
|
116
|
+
// Reset the singleton config cache so the next read pulls the new
|
|
117
|
+
// i18n locales / breakpoints / icons from disk. Without this, the
|
|
118
|
+
// config API would keep serving the stale snapshot.
|
|
119
|
+
configService.reset();
|
|
120
|
+
await configService.load();
|
|
121
|
+
this.wsManager.broadcastConfigUpdate();
|
|
122
|
+
},
|
|
106
123
|
});
|
|
107
124
|
|
|
108
125
|
this.fileWatcher.watchAll();
|
|
@@ -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
|