octocms 0.3.2 → 0.3.4
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/admin/AdminApp.tsx +59 -0
- package/admin/ThemeProvider.test.tsx +177 -0
- package/admin/ThemeProvider.tsx +85 -0
- package/admin/actions/build.test.ts +120 -0
- package/admin/actions/build.ts +139 -0
- package/admin/actions/entries.test.ts +311 -0
- package/admin/actions/entries.ts +136 -0
- package/admin/actions/files.test.ts +785 -0
- package/admin/actions/files.ts +651 -0
- package/admin/actions/getThemeCookie.test.ts +54 -0
- package/admin/actions/getThemeCookie.ts +17 -0
- package/admin/actions/git.test.ts +177 -0
- package/admin/actions/git.ts +295 -0
- package/admin/actions/media.test.ts +528 -0
- package/admin/actions/media.ts +325 -0
- package/admin/actions/search.ts +94 -0
- package/admin/actions/status.ts +63 -0
- package/admin/actions/utils.test.ts +80 -0
- package/admin/actions/utils.ts +51 -0
- package/admin/actions.ts +7 -0
- package/admin/auth.ts +25 -0
- package/admin/consts.ts +19 -0
- package/admin/github.pointer.test.ts +43 -0
- package/admin/github.ts +548 -0
- package/admin/pages/AdminLayout.tsx +50 -0
- package/admin/pages/CollectionPage.tsx +41 -0
- package/admin/pages/DashboardPage.tsx +34 -0
- package/admin/pages/EntryPage.tsx +49 -0
- package/admin/pages/MediaPage.tsx +26 -0
- package/admin/pages/SearchPage.tsx +23 -0
- package/admin/provider.tsx +30 -0
- package/admin/store/contentStore.test.ts +323 -0
- package/admin/store/contentStore.ts +333 -0
- package/admin/store/contentStoreFetch.test.ts +217 -0
- package/admin/store/contentStoreFetch.ts +240 -0
- package/admin/store/contentStoreTypes.ts +52 -0
- package/admin/theme.ts +12 -0
- package/admin/types.ts +31 -0
- package/components/CMSSidebar/CMSSidebar.tsx +108 -0
- package/components/ContentTypes.tsx +162 -0
- package/components/CreateBranchDialog.tsx +137 -0
- package/components/Dashboard/DashboardContent.tsx +370 -0
- package/components/EditPost/EditPost.tsx +360 -0
- package/components/FieldHintAndError.tsx +20 -0
- package/components/FileExplorer/FileExplorer.tsx +99 -0
- package/components/FormBooleanField.tsx +51 -0
- package/components/FormColorField.tsx +112 -0
- package/components/FormConditionalField.tsx +383 -0
- package/components/FormDatetimeField.tsx +62 -0
- package/components/FormFields.tsx +265 -0
- package/components/FormImageField.test.tsx +153 -0
- package/components/FormImageField.tsx +281 -0
- package/components/FormJsonField.tsx +79 -0
- package/components/FormMarkdownField.tsx +83 -0
- package/components/FormNumberField.tsx +60 -0
- package/components/FormReferenceField.test.tsx +335 -0
- package/components/FormReferenceField.tsx +658 -0
- package/components/FormRichTextField.tsx +670 -0
- package/components/FormSelectField.tsx +138 -0
- package/components/FormSlugField.tsx +120 -0
- package/components/FormStringField.tsx +40 -0
- package/components/FormStringListField.tsx +192 -0
- package/components/FormTextField.tsx +54 -0
- package/components/FormUrlField.tsx +44 -0
- package/components/Header/Header.tsx +352 -0
- package/components/Header/ThemeToggle.test.tsx +98 -0
- package/components/Header/ThemeToggle.tsx +38 -0
- package/components/InlineEntryEditor/InlineEntryEditor.tsx +397 -0
- package/components/Layout/Layout.tsx +61 -0
- package/components/LinkedBySection/LinkedBySection.tsx +104 -0
- package/components/Loading.tsx +18 -0
- package/components/MediaManager/MediaManager.tsx +679 -0
- package/components/SearchPage.tsx +122 -0
- package/components/StatusBadge.tsx +65 -0
- package/components/public/MarkdownContent.tsx +49 -0
- package/components/public/RichTextContent.tsx +327 -0
- package/components/public/SearchBox.tsx +249 -0
- package/components/public/index.ts +4 -0
- package/components/richtext/ComponentEmbedEditor.tsx +201 -0
- package/components/richtext/ConditionEmbedEditor.tsx +273 -0
- package/components/richtext/ImageEmbedEditor.tsx +280 -0
- package/components/richtext/ReferenceEmbedEditor.tsx +249 -0
- package/components/richtext/SlashCommandMenu.tsx +128 -0
- package/components/richtext/VariableEmbedEditor.tsx +89 -0
- package/components/ui/avatar.tsx +43 -0
- package/components/ui/button.tsx +47 -0
- package/components/ui/dialog.tsx +97 -0
- package/components/ui/dropdown-menu.tsx +181 -0
- package/components/ui/index.ts +73 -0
- package/components/ui/input.tsx +22 -0
- package/components/ui/label.tsx +19 -0
- package/components/ui/select.tsx +145 -0
- package/components/ui/sonner.tsx +35 -0
- package/components/ui/table.tsx +77 -0
- package/components/ui/tabs.tsx +55 -0
- package/components/ui/toast.tsx +78 -0
- package/components/ui/toaster.tsx +28 -0
- package/config.ts +4 -0
- package/defineConfig.ts +149 -0
- package/dist/{agentDocs-KAVPY3J3.js → agentDocs-4DFOPJEP.js} +2 -2
- package/dist/{chunk-IGDBPZSB.js → chunk-BSCCWET6.js} +5 -5
- package/dist/chunk-BSCCWET6.js.map +1 -0
- package/dist/cli/index.js +5 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/components/public/index.cjs +452 -0
- package/dist/components/public/index.cjs.map +1 -0
- package/dist/config.cjs +101 -0
- package/dist/config.cjs.map +1 -0
- package/dist/config.d.ts +10 -1
- package/dist/config.js +4 -1
- package/dist/defineConfig.cjs +33 -0
- package/dist/defineConfig.cjs.map +1 -0
- package/dist/index.cjs +9360 -0
- package/dist/index.cjs.map +1 -0
- package/dist/{init-27Z6QIYE.js → init-W7KLXTGY.js} +2 -2
- package/dist/query.cjs +9316 -0
- package/dist/query.cjs.map +1 -0
- package/dist/types.cjs +19 -0
- package/dist/types.cjs.map +1 -0
- package/dist/{typesGen-RDG5SY5R.js → typesGen-NSSMVJVV.js} +2 -2
- package/dist/{typesGen-RDG5SY5R.js.map → typesGen-NSSMVJVV.js.map} +1 -1
- package/dist/{update-YESE5PS2.js → update-E4KTUEPW.js} +2 -2
- package/dist/withOctoCMS.cjs +92 -0
- package/dist/withOctoCMS.cjs.map +1 -0
- package/hooks/useConfig.tsx +31 -0
- package/hooks/useEntryStack.test.tsx +194 -0
- package/hooks/useEntryStack.tsx +128 -0
- package/hooks/useFileState.tsx +44 -0
- package/hooks/useToast.tsx +191 -0
- package/index.ts +24 -0
- package/lib/blogPublicPath.test.ts +27 -0
- package/lib/blogPublicPath.ts +18 -0
- package/lib/branchHistory.test.ts +164 -0
- package/lib/branchHistory.ts +122 -0
- package/lib/cmsServerLog.ts +23 -0
- package/lib/colorField.test.ts +38 -0
- package/lib/colorField.ts +45 -0
- package/lib/companionMarkdown.test.ts +77 -0
- package/lib/companionMarkdown.ts +74 -0
- package/lib/conditionalField.test.ts +212 -0
- package/lib/conditionalField.ts +139 -0
- package/lib/configStore.ts +28 -0
- package/lib/contentSourceError.test.ts +70 -0
- package/lib/contentSourceError.ts +111 -0
- package/lib/datetimeField.test.ts +77 -0
- package/lib/datetimeField.ts +91 -0
- package/lib/deploymentEnv.test.ts +67 -0
- package/lib/deploymentEnv.ts +37 -0
- package/lib/extractImageMetadata.ts +34 -0
- package/lib/githubContentMode.test.ts +27 -0
- package/lib/githubContentMode.ts +11 -0
- package/lib/initialEntryFields.ts +39 -0
- package/lib/jsonField.test.ts +59 -0
- package/lib/jsonField.ts +54 -0
- package/lib/localReader.test.ts +157 -0
- package/lib/localReader.ts +37 -0
- package/lib/numberField.test.ts +38 -0
- package/lib/numberField.ts +39 -0
- package/lib/persistedFormFields.test.ts +106 -0
- package/lib/persistedFormFields.ts +172 -0
- package/lib/referenceKeys.test.ts +57 -0
- package/lib/referenceKeys.ts +51 -0
- package/lib/richtext/parseRichText.test.ts +189 -0
- package/lib/richtext/parseRichText.ts +266 -0
- package/lib/richtextFieldConfig.ts +20 -0
- package/lib/searchIndex.test.ts +312 -0
- package/lib/searchIndex.ts +261 -0
- package/lib/selectField.test.ts +64 -0
- package/lib/selectField.ts +56 -0
- package/lib/slugField.test.ts +90 -0
- package/lib/slugField.ts +76 -0
- package/lib/stringListField.test.ts +44 -0
- package/lib/stringListField.ts +43 -0
- package/lib/suggestedMediaTitle.ts +7 -0
- package/lib/urlField.test.ts +39 -0
- package/lib/urlField.ts +31 -0
- package/lib/utils.ts +11 -0
- package/lib/validateEntryFields.test.ts +326 -0
- package/lib/validateEntryFields.ts +324 -0
- package/package.json +21 -2
- package/query.ts +666 -0
- package/types.ts +359 -0
- package/withOctoCMS.ts +16 -0
- package/dist/chunk-IGDBPZSB.js.map +0 -1
- /package/dist/{agentDocs-KAVPY3J3.js.map → agentDocs-4DFOPJEP.js.map} +0 -0
- /package/dist/{init-27Z6QIYE.js.map → init-W7KLXTGY.js.map} +0 -0
- /package/dist/{update-YESE5PS2.js.map → update-E4KTUEPW.js.map} +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { Suspense } from 'react';
|
|
2
|
+
|
|
3
|
+
import { CollectionPage } from './pages/CollectionPage';
|
|
4
|
+
import { DashboardPage } from './pages/DashboardPage';
|
|
5
|
+
import { EntryPage } from './pages/EntryPage';
|
|
6
|
+
import { MediaPage } from './pages/MediaPage';
|
|
7
|
+
import { SearchPage } from './pages/SearchPage';
|
|
8
|
+
|
|
9
|
+
type AdminAppProps = {
|
|
10
|
+
params: Promise<{ path?: string[] }>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Catch-all admin router component. Mount this as the default export of a
|
|
15
|
+
* Next.js optional catch-all route at `src/app/cms/[[...path]]/page.tsx`:
|
|
16
|
+
*
|
|
17
|
+
* export { AdminApp as default } from 'octocms/admin/AdminApp';
|
|
18
|
+
*
|
|
19
|
+
* Route segments map to admin pages:
|
|
20
|
+
* /cms → DashboardPage
|
|
21
|
+
* /cms/search → SearchPage
|
|
22
|
+
* /cms/media → MediaPage (library)
|
|
23
|
+
* /cms/media/<id> → MediaPage with that asset selected (detail panel)
|
|
24
|
+
* /cms/<type> → CollectionPage
|
|
25
|
+
* /cms/<type>/<id>→ EntryPage
|
|
26
|
+
*/
|
|
27
|
+
export function AdminApp({ params }: AdminAppProps) {
|
|
28
|
+
return (
|
|
29
|
+
<Suspense fallback={null}>
|
|
30
|
+
<AdminAppRouter params={params} />
|
|
31
|
+
</Suspense>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function AdminAppRouter({ params }: AdminAppProps) {
|
|
36
|
+
const { path } = await params;
|
|
37
|
+
const segments = path ?? [];
|
|
38
|
+
|
|
39
|
+
if (segments.length === 0) {
|
|
40
|
+
return <DashboardPage />;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (segments[0] === 'search') {
|
|
44
|
+
return <SearchPage />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (segments[0] === 'media') {
|
|
48
|
+
const initialMediaId = segments.length >= 2 ? segments[1] : undefined;
|
|
49
|
+
return <MediaPage initialMediaId={initialMediaId} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (segments.length === 1) {
|
|
53
|
+
const [type] = segments;
|
|
54
|
+
return <CollectionPage params={Promise.resolve({ type })} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [type, id] = segments;
|
|
58
|
+
return <EntryPage params={Promise.resolve({ type, id })} />;
|
|
59
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { ThemeProvider, useTheme } from './ThemeProvider';
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// matchMedia mock — must be set before ThemeProvider mounts.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
function mockMatchMedia(prefersDark: boolean) {
|
|
11
|
+
const listeners: ((e: Partial<MediaQueryListEvent>) => void)[] = [];
|
|
12
|
+
|
|
13
|
+
const mq = {
|
|
14
|
+
matches: prefersDark,
|
|
15
|
+
addEventListener: vi.fn((_: string, fn: (e: Partial<MediaQueryListEvent>) => void) => {
|
|
16
|
+
listeners.push(fn);
|
|
17
|
+
}),
|
|
18
|
+
removeEventListener: vi.fn((_: string, fn: (e: Partial<MediaQueryListEvent>) => void) => {
|
|
19
|
+
const idx = listeners.indexOf(fn);
|
|
20
|
+
if (idx !== -1) listeners.splice(idx, 1);
|
|
21
|
+
}),
|
|
22
|
+
// Helper to simulate system preference change in tests
|
|
23
|
+
_emit: (matches: boolean) => {
|
|
24
|
+
listeners.forEach((fn) => fn({ matches }));
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
29
|
+
writable: true,
|
|
30
|
+
value: vi.fn(() => mq),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return mq;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Wrapper helper
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
function wrapper(initialTheme: 'light' | 'dark' | 'system') {
|
|
40
|
+
return ({ children }: { children: React.ReactNode }) => (
|
|
41
|
+
<ThemeProvider initialTheme={initialTheme}>{children}</ThemeProvider>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
// Reset document.body classes between tests
|
|
47
|
+
document.body.className = '';
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.restoreAllMocks();
|
|
52
|
+
document.body.className = '';
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('ThemeProvider', () => {
|
|
56
|
+
it('renders children', () => {
|
|
57
|
+
mockMatchMedia(false);
|
|
58
|
+
render(
|
|
59
|
+
<ThemeProvider initialTheme="light">
|
|
60
|
+
<span>hello</span>
|
|
61
|
+
</ThemeProvider>,
|
|
62
|
+
);
|
|
63
|
+
expect(screen.getByText('hello')).toBeTruthy();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('resolvedTheme', () => {
|
|
67
|
+
it('is "light" when initialTheme is "light"', () => {
|
|
68
|
+
mockMatchMedia(false);
|
|
69
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('light') });
|
|
70
|
+
expect(result.current.resolvedTheme).toBe('light');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('is "dark" when initialTheme is "dark"', () => {
|
|
74
|
+
mockMatchMedia(false);
|
|
75
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('dark') });
|
|
76
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is "dark" when initialTheme is "system" and system prefers dark', async () => {
|
|
80
|
+
mockMatchMedia(true);
|
|
81
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('system') });
|
|
82
|
+
// useEffect runs after render; wait for state update
|
|
83
|
+
await act(async () => {});
|
|
84
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('is "light" when initialTheme is "system" and system prefers light', async () => {
|
|
88
|
+
mockMatchMedia(false);
|
|
89
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('system') });
|
|
90
|
+
await act(async () => {});
|
|
91
|
+
expect(result.current.resolvedTheme).toBe('light');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('setTheme', () => {
|
|
96
|
+
it('updates theme state', async () => {
|
|
97
|
+
mockMatchMedia(false);
|
|
98
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('light') });
|
|
99
|
+
act(() => {
|
|
100
|
+
result.current.setTheme('dark');
|
|
101
|
+
});
|
|
102
|
+
expect(result.current.theme).toBe('dark');
|
|
103
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('writes to document.cookie', () => {
|
|
107
|
+
mockMatchMedia(false);
|
|
108
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('light') });
|
|
109
|
+
act(() => {
|
|
110
|
+
result.current.setTheme('dark');
|
|
111
|
+
});
|
|
112
|
+
expect(document.cookie).toContain('cms-theme=dark');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('switches back to light', () => {
|
|
116
|
+
mockMatchMedia(false);
|
|
117
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('dark') });
|
|
118
|
+
act(() => {
|
|
119
|
+
result.current.setTheme('light');
|
|
120
|
+
});
|
|
121
|
+
expect(result.current.resolvedTheme).toBe('light');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('document.body dark class', () => {
|
|
126
|
+
it('adds dark class when resolvedTheme is dark', async () => {
|
|
127
|
+
mockMatchMedia(false);
|
|
128
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('dark') });
|
|
129
|
+
await act(async () => {});
|
|
130
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
131
|
+
expect(document.body.classList.contains('dark')).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('does not add dark class when resolvedTheme is light', async () => {
|
|
135
|
+
mockMatchMedia(false);
|
|
136
|
+
renderHook(() => useTheme(), { wrapper: wrapper('light') });
|
|
137
|
+
await act(async () => {});
|
|
138
|
+
expect(document.body.classList.contains('dark')).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('removes dark class on unmount', async () => {
|
|
142
|
+
mockMatchMedia(false);
|
|
143
|
+
const { unmount } = renderHook(() => useTheme(), { wrapper: wrapper('dark') });
|
|
144
|
+
await act(async () => {});
|
|
145
|
+
expect(document.body.classList.contains('dark')).toBe(true);
|
|
146
|
+
unmount();
|
|
147
|
+
expect(document.body.classList.contains('dark')).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('system preference change', () => {
|
|
152
|
+
it('resolvedTheme updates when system changes to dark', async () => {
|
|
153
|
+
const mq = mockMatchMedia(false);
|
|
154
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('system') });
|
|
155
|
+
await act(async () => {});
|
|
156
|
+
expect(result.current.resolvedTheme).toBe('light');
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
mq._emit(true);
|
|
160
|
+
});
|
|
161
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('resolvedTheme does not respond to system changes when theme is explicitly set', async () => {
|
|
165
|
+
const mq = mockMatchMedia(false);
|
|
166
|
+
const { result } = renderHook(() => useTheme(), { wrapper: wrapper('dark') });
|
|
167
|
+
await act(async () => {});
|
|
168
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
169
|
+
|
|
170
|
+
// System changes to light — explicit 'dark' should be unaffected
|
|
171
|
+
await act(async () => {
|
|
172
|
+
mq._emit(false);
|
|
173
|
+
});
|
|
174
|
+
expect(result.current.resolvedTheme).toBe('dark');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { Theme } from './theme';
|
|
6
|
+
import { THEME_COOKIE } from './theme';
|
|
7
|
+
|
|
8
|
+
type ThemeContextValue = {
|
|
9
|
+
/** The stored preference: 'light' | 'dark' | 'system' */
|
|
10
|
+
theme: Theme;
|
|
11
|
+
/** The applied theme after resolving 'system' via matchMedia */
|
|
12
|
+
resolvedTheme: 'light' | 'dark';
|
|
13
|
+
setTheme: (theme: Theme) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ThemeContext = createContext<ThemeContextValue>({
|
|
17
|
+
theme: 'system',
|
|
18
|
+
resolvedTheme: 'light',
|
|
19
|
+
setTheme: () => {},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Hook to access the current theme state and setter from anywhere inside the CMS.
|
|
24
|
+
*/
|
|
25
|
+
export function useTheme(): ThemeContextValue {
|
|
26
|
+
return useContext(ThemeContext);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type ThemeProviderProps = {
|
|
30
|
+
/**
|
|
31
|
+
* Initial theme value from the SSR-read cookie. Passed from AdminLayout
|
|
32
|
+
* so the first render already has the correct theme without a hydration flash
|
|
33
|
+
* for users with an explicit 'light' or 'dark' cookie.
|
|
34
|
+
*/
|
|
35
|
+
initialTheme: Theme;
|
|
36
|
+
children: React.ReactNode;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Provides theme state for the CMS admin panel.
|
|
41
|
+
*
|
|
42
|
+
* - Resolves 'system' by listening to `prefers-color-scheme`.
|
|
43
|
+
* - Persists the preference to the `cms-theme` cookie (SameSite=Lax, 1 year).
|
|
44
|
+
* - Syncs `class="dark"` onto `document.body` so that Radix UI portal
|
|
45
|
+
* elements (dropdowns, dialogs) — which are appended to `<body>` — also
|
|
46
|
+
* inherit the dark CSS variable scope.
|
|
47
|
+
*
|
|
48
|
+
* This provider is only mounted inside `src/app/cms/layout.tsx`, so the
|
|
49
|
+
* `document.body` class never leaks to public pages.
|
|
50
|
+
*/
|
|
51
|
+
export function ThemeProvider({ initialTheme, children }: ThemeProviderProps) {
|
|
52
|
+
const [theme, setThemeState] = useState<Theme>(initialTheme);
|
|
53
|
+
const [systemDark, setSystemDark] = useState(false);
|
|
54
|
+
|
|
55
|
+
// Detect system preference on the client and track changes.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
|
58
|
+
setSystemDark(mq.matches);
|
|
59
|
+
|
|
60
|
+
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
|
61
|
+
mq.addEventListener('change', handler);
|
|
62
|
+
return () => mq.removeEventListener('change', handler);
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? (systemDark ? 'dark' : 'light') : theme;
|
|
66
|
+
|
|
67
|
+
// Sync dark class to document.body so Radix portals inherit the dark scope.
|
|
68
|
+
// Safe: ThemeProvider is only mounted under /cms, never on public pages.
|
|
69
|
+
// The cleanup removes the class when the CMS layout unmounts (e.g. navigating
|
|
70
|
+
// from /cms to a public route via client-side navigation).
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
document.body.classList.toggle('dark', resolvedTheme === 'dark');
|
|
73
|
+
return () => {
|
|
74
|
+
document.body.classList.remove('dark');
|
|
75
|
+
};
|
|
76
|
+
}, [resolvedTheme]);
|
|
77
|
+
|
|
78
|
+
const setTheme = useCallback((next: Theme) => {
|
|
79
|
+
setThemeState(next);
|
|
80
|
+
// Persist to cookie for SSR reads on next page load.
|
|
81
|
+
document.cookie = `${THEME_COOKIE}=${next};path=/;max-age=31536000;SameSite=Lax`;
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return <ThemeContext.Provider value={{ theme, resolvedTheme, setTheme }}>{children}</ThemeContext.Provider>;
|
|
85
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { revalidatePath, updateTag } from 'next/cache';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { buildJsons } from './build';
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
vi.mock('next/cache', () => ({
|
|
11
|
+
revalidatePath: vi.fn(),
|
|
12
|
+
updateTag: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../github', () => ({
|
|
16
|
+
isProductionMode: vi.fn(() => false),
|
|
17
|
+
saveGitHubFile: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('fs/promises', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
readFile: vi.fn(),
|
|
23
|
+
mkdir: vi.fn(),
|
|
24
|
+
writeFile: vi.fn(),
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
vi.mock('glob', () => ({
|
|
29
|
+
glob: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const mockConfig = {
|
|
33
|
+
contentFolder: 'cms/content',
|
|
34
|
+
search: {
|
|
35
|
+
publicCollections: {
|
|
36
|
+
post: { urlPattern: '/blog/:slug' },
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
collections: {
|
|
40
|
+
post: {
|
|
41
|
+
label: 'Post',
|
|
42
|
+
fields: {
|
|
43
|
+
title: { label: 'Title', format: 'string', entryTitle: true },
|
|
44
|
+
slug: { label: 'Slug', format: 'slug' },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
} as any;
|
|
49
|
+
|
|
50
|
+
vi.mock('../../lib/configStore', () => ({ getConfig: () => mockConfig }));
|
|
51
|
+
|
|
52
|
+
vi.mock('octocms/lib/companionMarkdown', () => ({
|
|
53
|
+
companionMarkdownPathsForEntry: vi.fn(() => ({})),
|
|
54
|
+
companionRichTextPathsForEntry: vi.fn(() => ({})),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock('octocms/lib/searchIndex', () => ({
|
|
58
|
+
buildSearchIndex: vi.fn(() => '{}'),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
// ─── buildJsons ───────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe('buildJsons', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns success', async () => {
|
|
69
|
+
const result = await buildJsons();
|
|
70
|
+
expect(result).toEqual({ success: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('calls updateTag for homePage and blog cache keys', async () => {
|
|
74
|
+
await buildJsons();
|
|
75
|
+
|
|
76
|
+
expect(updateTag).toHaveBeenCalledWith('homePage');
|
|
77
|
+
expect(updateTag).toHaveBeenCalledWith('blog');
|
|
78
|
+
expect(vi.mocked(updateTag).mock.calls.length).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('revalidates the standard public page paths', async () => {
|
|
82
|
+
await buildJsons();
|
|
83
|
+
|
|
84
|
+
expect(revalidatePath).toHaveBeenCalledWith('/', 'layout');
|
|
85
|
+
expect(revalidatePath).toHaveBeenCalledWith('/blog', 'page');
|
|
86
|
+
expect(revalidatePath).toHaveBeenCalledWith('/blog/[slug]', 'page');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('revalidates slug paths when blogPaths provided', async () => {
|
|
90
|
+
await buildJsons(undefined, {
|
|
91
|
+
blogPaths: ['/blog/my-post', '/blog/old-slug'],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(revalidatePath).toHaveBeenCalledWith('/blog/my-post');
|
|
95
|
+
expect(revalidatePath).toHaveBeenCalledWith('/blog/old-slug');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('dedupes blogPaths', async () => {
|
|
99
|
+
await buildJsons(undefined, { blogPaths: ['/blog/x', '/blog/x'] });
|
|
100
|
+
|
|
101
|
+
const blogCalls = vi.mocked(revalidatePath).mock.calls.filter(([p]) => p === '/blog/x');
|
|
102
|
+
expect(blogCalls.length).toBe(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('does not call extra revalidatePath for blogPaths when undefined', async () => {
|
|
106
|
+
await buildJsons();
|
|
107
|
+
|
|
108
|
+
const calls = vi.mocked(revalidatePath).mock.calls.map(([p]) => p);
|
|
109
|
+
expect(calls.filter((p) => p.startsWith('/blog/') && p !== '/blog' && p !== '/blog/[slug]')).toEqual([]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('returns failure when updateTag throws', async () => {
|
|
113
|
+
vi.mocked(updateTag).mockImplementationOnce(() => {
|
|
114
|
+
throw new Error('tag error');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const result = await buildJsons();
|
|
118
|
+
expect(result).toEqual({ success: false, error: 'tag error' });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import fsPromises from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
import { glob } from 'glob';
|
|
7
|
+
import { revalidatePath, updateTag } from 'next/cache';
|
|
8
|
+
|
|
9
|
+
import { getConfig } from '../../lib/configStore';
|
|
10
|
+
import { companionMarkdownPathsForEntry, companionRichTextPathsForEntry } from '../../lib/companionMarkdown';
|
|
11
|
+
import { buildSearchIndex, type EntryForSearch } from '../../lib/searchIndex';
|
|
12
|
+
|
|
13
|
+
import { isProductionMode, saveGitHubFile } from '../github';
|
|
14
|
+
import { actionErr, actionOk, type ActionResult } from './utils';
|
|
15
|
+
|
|
16
|
+
/** Cache tags used by `getHomePage` / `getBlog` / `getPublishedPosts` in `src/app/cms/ssr/getPageContent.ts`. */
|
|
17
|
+
const PUBLIC_CACHE_TAGS = ['homePage', 'blog'] as const;
|
|
18
|
+
|
|
19
|
+
const SEARCH_INDEX_FILE_PATH = 'cms/__generated__/search-index.json';
|
|
20
|
+
|
|
21
|
+
export type BuildJsonsOptions = {
|
|
22
|
+
/** Slug-based `/blog/...` paths to revalidate in addition to the dynamic route segment. */
|
|
23
|
+
blogPaths?: string[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Helper: gather all searchable entries from the filesystem. */
|
|
27
|
+
async function getEntriesForPublicSearch(): Promise<EntryForSearch[]> {
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
const publicCollections = config.search?.publicCollections;
|
|
30
|
+
if (!publicCollections || Object.keys(publicCollections).length === 0) {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const files = await glob(`${config.contentFolder}/**/*.json`);
|
|
35
|
+
const entries: EntryForSearch[] = [];
|
|
36
|
+
|
|
37
|
+
for (const file of files) {
|
|
38
|
+
const normalized = file.replace(/\\/g, '/');
|
|
39
|
+
// Skip media entries
|
|
40
|
+
if (normalized.includes('/media/')) continue;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const filePath = path.join(process.cwd(), normalized);
|
|
44
|
+
const data = await fsPromises.readFile(filePath, { encoding: 'utf8' });
|
|
45
|
+
const content = JSON.parse(data) as Record<string, unknown>;
|
|
46
|
+
const sys = content.sys as { type?: string } | undefined;
|
|
47
|
+
const type = sys?.type;
|
|
48
|
+
|
|
49
|
+
// Only include entries from publicCollections
|
|
50
|
+
if (!type || !(type in publicCollections)) continue;
|
|
51
|
+
|
|
52
|
+
// Read companion markdown/richtext files
|
|
53
|
+
const companions: Record<string, string> = {};
|
|
54
|
+
if (type) {
|
|
55
|
+
const mdPaths = companionMarkdownPathsForEntry(normalized, type, config.collections);
|
|
56
|
+
for (const [fieldName, mdPath] of Object.entries(mdPaths)) {
|
|
57
|
+
try {
|
|
58
|
+
const mdFilePath = path.join(process.cwd(), mdPath);
|
|
59
|
+
companions[fieldName] = await fsPromises.readFile(mdFilePath, { encoding: 'utf8' });
|
|
60
|
+
} catch {
|
|
61
|
+
companions[fieldName] = '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const rtPaths = companionRichTextPathsForEntry(normalized, type, config.collections);
|
|
65
|
+
for (const [fieldName, rtPath] of Object.entries(rtPaths)) {
|
|
66
|
+
try {
|
|
67
|
+
const rtFilePath = path.join(process.cwd(), rtPath);
|
|
68
|
+
companions[fieldName] = await fsPromises.readFile(rtFilePath, { encoding: 'utf8' });
|
|
69
|
+
} catch {
|
|
70
|
+
companions[fieldName] = '';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
entries.push({
|
|
76
|
+
path: normalized.replace(`${config.contentFolder}/`, ''),
|
|
77
|
+
content,
|
|
78
|
+
companionContent: companions,
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
// Skip unreadable files
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build and write the public search index. */
|
|
89
|
+
async function buildAndWriteSearchIndex(): Promise<void> {
|
|
90
|
+
const config = getConfig();
|
|
91
|
+
const publicCollections = config.search?.publicCollections;
|
|
92
|
+
if (!publicCollections || Object.keys(publicCollections).length === 0) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const entries = await getEntriesForPublicSearch();
|
|
98
|
+
const indexJson = buildSearchIndex(entries, config, Object.keys(publicCollections));
|
|
99
|
+
|
|
100
|
+
if (isProductionMode()) {
|
|
101
|
+
// Write to GitHub
|
|
102
|
+
await saveGitHubFile(SEARCH_INDEX_FILE_PATH, indexJson, 'CMS: update search index');
|
|
103
|
+
} else {
|
|
104
|
+
// Write to local filesystem
|
|
105
|
+
const filePath = path.join(process.cwd(), SEARCH_INDEX_FILE_PATH);
|
|
106
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
107
|
+
await fsPromises.writeFile(filePath, indexJson, 'utf8');
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
// Silently fail search index generation to not block the build
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const buildJsons = async (_editedFileName?: string, options?: BuildJsonsOptions): Promise<ActionResult> => {
|
|
115
|
+
try {
|
|
116
|
+
for (const tag of PUBLIC_CACHE_TAGS) {
|
|
117
|
+
updateTag(tag);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
revalidatePath('/', 'layout');
|
|
121
|
+
revalidatePath('/blog', 'page');
|
|
122
|
+
revalidatePath('/blog/[slug]', 'page');
|
|
123
|
+
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
for (const p of options?.blogPaths ?? []) {
|
|
126
|
+
if (typeof p === 'string' && p.startsWith('/blog/') && !seen.has(p)) {
|
|
127
|
+
seen.add(p);
|
|
128
|
+
revalidatePath(p);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Build and write the public search index
|
|
133
|
+
await buildAndWriteSearchIndex();
|
|
134
|
+
|
|
135
|
+
return actionOk();
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return actionErr(e);
|
|
138
|
+
}
|
|
139
|
+
};
|