@autono/open-pages 0.1.0

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 (110) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +108 -0
  3. package/bin.js +2 -0
  4. package/dist/build-TP72kiw7.js +35 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +100 -0
  7. package/dist/config-C9or4m4J.d.ts +23 -0
  8. package/dist/config-L0IbktJ0.js +3135 -0
  9. package/dist/design-C13iz9_4.js +35 -0
  10. package/dist/dev-I2IyEh5W.js +97 -0
  11. package/dist/export-B-kBRWB1.js +4 -0
  12. package/dist/export-ic28osQP.js +131 -0
  13. package/dist/index.d.ts +56 -0
  14. package/dist/index.js +3 -0
  15. package/dist/locale/index.d.ts +24 -0
  16. package/dist/locale/index.js +881 -0
  17. package/dist/open-pages-plugin-D_DN_zjh.js +632 -0
  18. package/dist/preview-BrDuvgAN.js +21 -0
  19. package/dist/sync-Be_hxSOF.js +139 -0
  20. package/dist/sync-lKEXyPOh.js +3 -0
  21. package/dist/types-C5NCdDT-.d.ts +219 -0
  22. package/dist/update-CV62NhBT.js +37 -0
  23. package/dist/update-package-Cxt57pz7.js +152 -0
  24. package/dist/version-B6AiiGvT.js +26 -0
  25. package/dist/version-CeffgdfZ.js +3 -0
  26. package/dist/vite/index.d.ts +14 -0
  27. package/dist/vite/index.js +7 -0
  28. package/env.d.ts +59 -0
  29. package/package.json +106 -0
  30. package/skills/apply-comments/SKILL.md +86 -0
  31. package/skills/create-page/SKILL.md +92 -0
  32. package/skills/create-theme/SKILL.md +223 -0
  33. package/skills/current-page/SKILL.md +106 -0
  34. package/skills/page-authoring/SKILL.md +148 -0
  35. package/skills/page-authoring/references/assets-and-fonts.md +85 -0
  36. package/skills/page-authoring/references/html-pages.md +65 -0
  37. package/skills/page-authoring/references/interactivity.md +77 -0
  38. package/skills/page-authoring/references/layout-and-responsive.md +71 -0
  39. package/skills/page-authoring/references/typography-and-color.md +60 -0
  40. package/src/app/app.tsx +45 -0
  41. package/src/app/components/asset-view.tsx +1665 -0
  42. package/src/app/components/command/command-menu.tsx +227 -0
  43. package/src/app/components/command/command.tsx +142 -0
  44. package/src/app/components/command/home-command-menu.tsx +96 -0
  45. package/src/app/components/icon-tooltip.tsx +34 -0
  46. package/src/app/components/language-toggle.tsx +46 -0
  47. package/src/app/components/sidebar/folder-item.tsx +269 -0
  48. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  49. package/src/app/components/sidebar/sidebar-footer.tsx +139 -0
  50. package/src/app/components/sidebar/sidebar.tsx +302 -0
  51. package/src/app/components/theme-toggle.tsx +66 -0
  52. package/src/app/components/themes/theme-detail.tsx +226 -0
  53. package/src/app/components/themes/themes-gallery.tsx +104 -0
  54. package/src/app/components/ui/badge.tsx +48 -0
  55. package/src/app/components/ui/button.tsx +93 -0
  56. package/src/app/components/ui/card.tsx +92 -0
  57. package/src/app/components/ui/context-menu.tsx +247 -0
  58. package/src/app/components/ui/dialog.tsx +155 -0
  59. package/src/app/components/ui/dropdown-menu.tsx +267 -0
  60. package/src/app/components/ui/input.tsx +25 -0
  61. package/src/app/components/ui/label.tsx +21 -0
  62. package/src/app/components/ui/popover.tsx +82 -0
  63. package/src/app/components/ui/progress.tsx +33 -0
  64. package/src/app/components/ui/scroll-area.tsx +53 -0
  65. package/src/app/components/ui/select.tsx +195 -0
  66. package/src/app/components/ui/separator.tsx +26 -0
  67. package/src/app/components/ui/slider.tsx +68 -0
  68. package/src/app/components/ui/sonner.tsx +48 -0
  69. package/src/app/components/ui/tabs.tsx +79 -0
  70. package/src/app/components/ui/textarea.tsx +22 -0
  71. package/src/app/components/ui/toggle-group.tsx +84 -0
  72. package/src/app/components/ui/toggle.tsx +45 -0
  73. package/src/app/components/ui/tooltip.tsx +75 -0
  74. package/src/app/favicon.ico +0 -0
  75. package/src/app/frame/inspect.ts +140 -0
  76. package/src/app/frame/main.tsx +91 -0
  77. package/src/app/frame.html +12 -0
  78. package/src/app/index.html +13 -0
  79. package/src/app/lib/asset-filter.test.ts +176 -0
  80. package/src/app/lib/asset-filter.ts +80 -0
  81. package/src/app/lib/assets.ts +257 -0
  82. package/src/app/lib/design.ts +58 -0
  83. package/src/app/lib/folders.ts +239 -0
  84. package/src/app/lib/frame.ts +38 -0
  85. package/src/app/lib/locale-store.ts +67 -0
  86. package/src/app/lib/page-thumb.tsx +46 -0
  87. package/src/app/lib/pages.ts +28 -0
  88. package/src/app/lib/sdk.ts +43 -0
  89. package/src/app/lib/themes.ts +22 -0
  90. package/src/app/lib/use-locale.ts +8 -0
  91. package/src/app/lib/use-page-module.ts +48 -0
  92. package/src/app/lib/use-page-titles.ts +29 -0
  93. package/src/app/lib/use-restart-server.ts +78 -0
  94. package/src/app/lib/utils.test.ts +25 -0
  95. package/src/app/lib/utils.ts +6 -0
  96. package/src/app/main.tsx +14 -0
  97. package/src/app/routes/assets.tsx +9 -0
  98. package/src/app/routes/home-shell.tsx +252 -0
  99. package/src/app/routes/home.tsx +846 -0
  100. package/src/app/routes/page.tsx +329 -0
  101. package/src/app/routes/themes.tsx +34 -0
  102. package/src/app/styles.css +405 -0
  103. package/src/app/virtual.d.ts +53 -0
  104. package/src/locale/en.ts +218 -0
  105. package/src/locale/format.ts +12 -0
  106. package/src/locale/index.ts +6 -0
  107. package/src/locale/ja.ts +221 -0
  108. package/src/locale/types.ts +215 -0
  109. package/src/locale/zh-cn.ts +218 -0
  110. package/src/locale/zh-tw.ts +218 -0
@@ -0,0 +1,239 @@
1
+ import buildManifest from 'virtual:open-pages/folders';
2
+ import { useCallback, useEffect, useState } from 'react';
3
+ import type { Folder, FolderIcon, FoldersManifest } from './sdk';
4
+
5
+ const EMPTY: FoldersManifest = { folders: [], assignments: {} };
6
+
7
+ async function getManifest(): Promise<FoldersManifest> {
8
+ // In dev the manifest is mutable: read live from the plugin endpoint so
9
+ // edits made in the sidebar reflect immediately. In a static build there
10
+ // is no server, so fall back to the bundled snapshot from the virtual
11
+ // module (populated at build time from pages/.folders.json).
12
+ if (import.meta.env.DEV) {
13
+ const res = await fetch('/__folders');
14
+ if (!res.ok) throw new Error(`GET /__folders ${res.status}`);
15
+ const raw = (await res.json()) as Partial<FoldersManifest>;
16
+ return {
17
+ folders: raw.folders ?? [],
18
+ assignments: raw.assignments ?? {},
19
+ };
20
+ }
21
+ return {
22
+ folders: buildManifest.folders ?? [],
23
+ assignments: buildManifest.assignments ?? {},
24
+ };
25
+ }
26
+
27
+ async function patchDocName(pageId: string, name: string): Promise<void> {
28
+ const res = await fetch(`/__pages/${pageId}`, {
29
+ method: 'PATCH',
30
+ headers: { 'content-type': 'application/json' },
31
+ body: JSON.stringify({ name }),
32
+ });
33
+ if (!res.ok) throw new Error(`PATCH /__pages/${pageId} ${res.status}`);
34
+ }
35
+
36
+ async function duplicateDocReq(pageId: string, newId?: string): Promise<string> {
37
+ const init: RequestInit = { method: 'POST' };
38
+ if (newId !== undefined) {
39
+ init.headers = { 'content-type': 'application/json' };
40
+ init.body = JSON.stringify({ newId });
41
+ }
42
+ const res = await fetch(`/__pages/${pageId}/duplicate`, init);
43
+ if (!res.ok) throw new Error(`POST /__pages/${pageId}/duplicate ${res.status}`);
44
+ const body = (await res.json()) as { pageId?: unknown };
45
+ if (typeof body.pageId !== 'string') throw new Error('duplicate response missing pageId');
46
+ return body.pageId;
47
+ }
48
+
49
+ async function deleteDocReq(pageId: string): Promise<void> {
50
+ const res = await fetch(`/__pages/${pageId}`, { method: 'DELETE' });
51
+ if (!res.ok) throw new Error(`DELETE /__pages/${pageId} ${res.status}`);
52
+ }
53
+
54
+ async function postFolder(name: string, icon: FolderIcon): Promise<Folder> {
55
+ const res = await fetch('/__folders', {
56
+ method: 'POST',
57
+ headers: { 'content-type': 'application/json' },
58
+ body: JSON.stringify({ name, icon }),
59
+ });
60
+ if (!res.ok) throw new Error(`POST /__folders ${res.status}`);
61
+ return (await res.json()) as Folder;
62
+ }
63
+
64
+ async function patchFolder(
65
+ id: string,
66
+ patch: { name?: string; icon?: FolderIcon },
67
+ ): Promise<Folder> {
68
+ const res = await fetch(`/__folders/${id}`, {
69
+ method: 'PATCH',
70
+ headers: { 'content-type': 'application/json' },
71
+ body: JSON.stringify(patch),
72
+ });
73
+ if (!res.ok) throw new Error(`PATCH /__folders/${id} ${res.status}`);
74
+ return (await res.json()) as Folder;
75
+ }
76
+
77
+ async function deleteFolder(id: string): Promise<void> {
78
+ const res = await fetch(`/__folders/${id}`, { method: 'DELETE' });
79
+ if (!res.ok) throw new Error(`DELETE /__folders/${id} ${res.status}`);
80
+ }
81
+
82
+ async function putAssign(pageId: string, folderId: string | null): Promise<void> {
83
+ const res = await fetch('/__folders/assign', {
84
+ method: 'PUT',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({ pageId, folderId }),
87
+ });
88
+ if (!res.ok) throw new Error(`PUT /__folders/assign ${res.status}`);
89
+ }
90
+
91
+ async function putReorder(ids: string[]): Promise<void> {
92
+ const res = await fetch('/__folders/reorder', {
93
+ method: 'PUT',
94
+ headers: { 'content-type': 'application/json' },
95
+ body: JSON.stringify({ ids }),
96
+ });
97
+ if (!res.ok) throw new Error(`PUT /__folders/reorder ${res.status}`);
98
+ }
99
+
100
+ export type UseFoldersResult = {
101
+ manifest: FoldersManifest;
102
+ loading: boolean;
103
+ create: (name: string, icon: FolderIcon) => Promise<Folder>;
104
+ update: (id: string, patch: { name?: string; icon?: FolderIcon }) => Promise<void>;
105
+ remove: (id: string) => Promise<void>;
106
+ reorder: (ids: string[]) => Promise<void>;
107
+ assign: (pageId: string, folderId: string | null) => Promise<void>;
108
+ renameDoc: (pageId: string, name: string) => Promise<void>;
109
+ duplicatePage: (pageId: string, newId?: string) => Promise<string>;
110
+ deletePage: (pageId: string) => Promise<void>;
111
+ refresh: () => Promise<void>;
112
+ };
113
+
114
+ export function useFolders(): UseFoldersResult {
115
+ const [manifest, setManifest] = useState<FoldersManifest>(EMPTY);
116
+ const [loading, setLoading] = useState(true);
117
+
118
+ const refresh = useCallback(async () => {
119
+ const m = await getManifest();
120
+ setManifest(m);
121
+ }, []);
122
+
123
+ useEffect(() => {
124
+ let cancelled = false;
125
+ getManifest()
126
+ .then((m) => {
127
+ if (!cancelled) {
128
+ setManifest(m);
129
+ setLoading(false);
130
+ }
131
+ })
132
+ .catch(() => {
133
+ if (!cancelled) setLoading(false);
134
+ });
135
+ return () => {
136
+ cancelled = true;
137
+ };
138
+ }, []);
139
+
140
+ useEffect(() => {
141
+ if (!import.meta.hot) return;
142
+ const handler = () => {
143
+ refresh().catch(() => {});
144
+ };
145
+ import.meta.hot.on('open-pages:files-changed', handler);
146
+ return () => {
147
+ import.meta.hot?.off('open-pages:files-changed', handler);
148
+ };
149
+ }, [refresh]);
150
+
151
+ const create = useCallback(
152
+ async (name: string, icon: FolderIcon) => {
153
+ const folder = await postFolder(name, icon);
154
+ await refresh();
155
+ return folder;
156
+ },
157
+ [refresh],
158
+ );
159
+
160
+ const update = useCallback(
161
+ async (id: string, patch: { name?: string; icon?: FolderIcon }) => {
162
+ await patchFolder(id, patch);
163
+ await refresh();
164
+ },
165
+ [refresh],
166
+ );
167
+
168
+ const remove = useCallback(
169
+ async (id: string) => {
170
+ await deleteFolder(id);
171
+ await refresh();
172
+ },
173
+ [refresh],
174
+ );
175
+
176
+ const reorder = useCallback(
177
+ async (ids: string[]) => {
178
+ const prev = manifest;
179
+ const byId = new Map(prev.folders.map((f) => [f.id, f]));
180
+ const next = ids.map((id) => byId.get(id)).filter((f): f is Folder => Boolean(f));
181
+ if (next.length !== prev.folders.length) return;
182
+ setManifest({ ...prev, folders: next });
183
+ try {
184
+ await putReorder(ids);
185
+ } catch (err) {
186
+ setManifest(prev);
187
+ throw err;
188
+ }
189
+ },
190
+ [manifest],
191
+ );
192
+
193
+ const assign = useCallback(
194
+ async (pageId: string, folderId: string | null) => {
195
+ await putAssign(pageId, folderId);
196
+ await refresh();
197
+ },
198
+ [refresh],
199
+ );
200
+
201
+ const renameDoc = useCallback(
202
+ async (pageId: string, name: string) => {
203
+ await patchDocName(pageId, name);
204
+ await refresh();
205
+ },
206
+ [refresh],
207
+ );
208
+
209
+ const duplicatePage = useCallback(
210
+ async (pageId: string, newId?: string) => {
211
+ const duplicatedId = await duplicateDocReq(pageId, newId);
212
+ await refresh();
213
+ return duplicatedId;
214
+ },
215
+ [refresh],
216
+ );
217
+
218
+ const deletePage = useCallback(
219
+ async (pageId: string) => {
220
+ await deleteDocReq(pageId);
221
+ await refresh();
222
+ },
223
+ [refresh],
224
+ );
225
+
226
+ return {
227
+ manifest,
228
+ loading,
229
+ create,
230
+ update,
231
+ remove,
232
+ reorder,
233
+ assign,
234
+ renameDoc,
235
+ duplicatePage,
236
+ deletePage,
237
+ refresh,
238
+ };
239
+ }
@@ -0,0 +1,38 @@
1
+ import type { PageKind } from './sdk';
2
+
3
+ /** Messages the page frame posts to the workspace window. */
4
+ export type FrameMessage =
5
+ | { type: 'op:ready'; title: string }
6
+ | { type: 'op:error'; message: string }
7
+ | { type: 'op:select'; loc: string; tag: string; text: string }
8
+ | { type: 'op:key'; key: string };
9
+
10
+ /** Messages the workspace posts into the page frame. */
11
+ export type WorkspaceMessage =
12
+ | { type: 'op:inspect'; on: boolean }
13
+ | { type: 'op:select'; loc: string | null };
14
+
15
+ export type FrameSource = { pageId: string; kind?: PageKind } | { themeId: string };
16
+
17
+ const BASE = import.meta.env.BASE_URL;
18
+
19
+ /**
20
+ * URL the workspace loads into an iframe for a page or a theme demo. React
21
+ * pages mount through `frame.html`; `index.html` pages are served verbatim
22
+ * (dev middleware, or the per-page build under `__page/` in a static build).
23
+ */
24
+ export function frameUrl(source: FrameSource): string {
25
+ if ('themeId' in source) return `${BASE}frame.html?theme=${encodeURIComponent(source.themeId)}`;
26
+ if (source.kind === 'html')
27
+ return `${BASE}__page/${encodeURIComponent(source.pageId)}/index.html`;
28
+ return `${BASE}frame.html?page=${encodeURIComponent(source.pageId)}`;
29
+ }
30
+
31
+ export function isFrameMessage(data: unknown): data is FrameMessage {
32
+ return (
33
+ !!data &&
34
+ typeof data === 'object' &&
35
+ typeof (data as { type?: unknown }).type === 'string' &&
36
+ (data as { type: string }).type.startsWith('op:')
37
+ );
38
+ }
@@ -0,0 +1,67 @@
1
+ import config from 'virtual:open-pages/config';
2
+ import { useSyncExternalStore } from 'react';
3
+ import { en } from '../../locale/en';
4
+ import { ja } from '../../locale/ja';
5
+ import type { Locale } from '../../locale/types';
6
+ import { zhCN } from '../../locale/zh-cn';
7
+ import { zhTW } from '../../locale/zh-tw';
8
+
9
+ export type LocaleId = Locale['id'];
10
+
11
+ const LOCALES: Record<LocaleId, Locale> = {
12
+ en,
13
+ 'zh-TW': zhTW,
14
+ 'zh-CN': zhCN,
15
+ ja,
16
+ };
17
+
18
+ export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
19
+ { id: 'en', label: 'English' },
20
+ { id: 'zh-TW', label: '繁體中文' },
21
+ { id: 'zh-CN', label: '简体中文' },
22
+ { id: 'ja', label: '日本語' },
23
+ ];
24
+
25
+ const STORAGE_KEY = 'open-pages:locale';
26
+ const configLocale = config.locale as Locale | undefined;
27
+
28
+ function isLocaleId(value: string | null): value is LocaleId {
29
+ return value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja';
30
+ }
31
+
32
+ function readStored(): Locale {
33
+ try {
34
+ const stored = localStorage.getItem(STORAGE_KEY);
35
+ if (isLocaleId(stored)) return LOCALES[stored];
36
+ } catch {}
37
+ return configLocale ?? en;
38
+ }
39
+
40
+ // A module-level store (rather than React context) so every React root the
41
+ // runtime mounts — the app shell plus the standalone roots used for HTML/PDF
42
+ // export — shares one locale without needing a provider above each of them.
43
+ let current: Locale = readStored();
44
+ const listeners = new Set<() => void>();
45
+
46
+ function subscribe(listener: () => void): () => void {
47
+ listeners.add(listener);
48
+ return () => {
49
+ listeners.delete(listener);
50
+ };
51
+ }
52
+
53
+ function getSnapshot(): Locale {
54
+ return current;
55
+ }
56
+
57
+ export function setLocale(id: LocaleId): void {
58
+ current = LOCALES[id];
59
+ try {
60
+ localStorage.setItem(STORAGE_KEY, id);
61
+ } catch {}
62
+ for (const listener of listeners) listener();
63
+ }
64
+
65
+ export function useLocaleValue(): Locale {
66
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
67
+ }
@@ -0,0 +1,46 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { type FrameSource, frameUrl } from './frame';
3
+
4
+ const THUMB_VIEWPORT = 1280;
5
+
6
+ /**
7
+ * Live page preview for cards: the real page in an iframe laid out at a
8
+ * desktop width and scaled down to fit. Inert — pointer events go to the
9
+ * card, and the frame never takes focus.
10
+ */
11
+ export function PageThumb({ source, title }: { source: FrameSource; title: string }) {
12
+ const ref = useRef<HTMLDivElement | null>(null);
13
+ const [size, setSize] = useState({ width: 0, height: 0 });
14
+
15
+ useEffect(() => {
16
+ const el = ref.current;
17
+ if (!el) return;
18
+ const measure = () => setSize({ width: el.clientWidth, height: el.clientHeight });
19
+ measure();
20
+ const ro = new ResizeObserver(measure);
21
+ ro.observe(el);
22
+ return () => ro.disconnect();
23
+ }, []);
24
+
25
+ const scale = size.width > 0 ? size.width / THUMB_VIEWPORT : 0;
26
+
27
+ return (
28
+ <div ref={ref} className="h-full w-full overflow-hidden bg-white">
29
+ {scale > 0 && (
30
+ <iframe
31
+ title={title}
32
+ src={frameUrl(source)}
33
+ loading="lazy"
34
+ tabIndex={-1}
35
+ aria-hidden
36
+ className="pointer-events-none origin-top-left border-0"
37
+ style={{
38
+ width: THUMB_VIEWPORT,
39
+ height: size.height / scale,
40
+ transform: `scale(${scale})`,
41
+ }}
42
+ />
43
+ )}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,28 @@
1
+ import {
2
+ pageCreatedAt as createdAt,
3
+ pageIds as ids,
4
+ pageKinds as kinds,
5
+ loadPage as load,
6
+ pageThemes as themes,
7
+ } from 'virtual:open-pages/pages';
8
+ import type { PageKind, PageModule } from './sdk';
9
+
10
+ export const pageIds: string[] = ids;
11
+ export const pageKinds: Record<string, PageKind> = kinds;
12
+ export const pageThemes: Record<string, string> = themes;
13
+ export const pageCreatedAt: Record<string, number> = createdAt;
14
+
15
+ export function pagesByTheme(themeId: string): string[] {
16
+ return pageIds.filter((id) => pageThemes[id] === themeId);
17
+ }
18
+
19
+ export async function loadPage(id: string): Promise<PageModule> {
20
+ return load(id);
21
+ }
22
+
23
+ export function pageChangeIncludes(data: unknown, pageId: string): boolean {
24
+ if (!data || typeof data !== 'object') return false;
25
+ const payload = data as { pageId?: unknown; pageIds?: unknown };
26
+ if (payload.pageId === pageId) return true;
27
+ return Array.isArray(payload.pageIds) && payload.pageIds.includes(pageId);
28
+ }
@@ -0,0 +1,43 @@
1
+ import type { ComponentType } from 'react';
2
+ import type { DesignSystem } from './design.ts';
3
+
4
+ /**
5
+ * A page is one React component rendered into a real browser document. It
6
+ * owns the whole viewport: layout, styling (Tailwind via `className` or a
7
+ * sibling stylesheet), interactivity, and any client-side routing it wants.
8
+ */
9
+ export type PageComponent = ComponentType;
10
+
11
+ export type PageMeta = {
12
+ /** Document title (browser tab, workspace card). Default: the folder name. */
13
+ title?: string;
14
+ /** `<meta name="description">` in the exported HTML. */
15
+ description?: string;
16
+ /** Id of a theme under `themes/` this page was built from. */
17
+ theme?: string;
18
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the page list. */
19
+ createdAt?: string;
20
+ };
21
+
22
+ /** How a page entry is authored: a React module or a plain HTML file. */
23
+ export type PageKind = 'react' | 'html';
24
+
25
+ export type PageModule = {
26
+ /** Absent for `index.html` pages, which the workspace serves as-is. */
27
+ default?: PageComponent;
28
+ meta?: PageMeta;
29
+ design?: DesignSystem;
30
+ };
31
+
32
+ export type FolderIcon = { type: 'emoji'; value: string } | { type: 'color'; value: string };
33
+
34
+ export type Folder = {
35
+ id: string;
36
+ name: string;
37
+ icon: FolderIcon;
38
+ };
39
+
40
+ export type FoldersManifest = {
41
+ folders: Folder[];
42
+ assignments: Record<string, string>;
43
+ };
@@ -0,0 +1,22 @@
1
+ import { loadThemeDemo as load, themes as raw } from 'virtual:open-pages/themes';
2
+ import type { DesignSystem } from './design';
3
+ import type { PageComponent } from './sdk';
4
+
5
+ export type Theme = {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ body: string;
10
+ hasDemo: boolean;
11
+ };
12
+
13
+ export type ThemeDemoModule = {
14
+ default: PageComponent;
15
+ design?: DesignSystem;
16
+ };
17
+
18
+ export const themes: Theme[] = raw;
19
+
20
+ export async function loadThemeDemo(id: string): Promise<ThemeDemoModule> {
21
+ return load(id);
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { Locale } from '../../locale/types';
2
+ import { useLocaleValue } from './locale-store';
3
+
4
+ export function useLocale(): Locale {
5
+ return useLocaleValue();
6
+ }
7
+
8
+ export { format, plural } from '../../locale/format';
@@ -0,0 +1,48 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { loadPage, pageChangeIncludes } from './pages';
3
+ import type { PageModule } from './sdk';
4
+
5
+ export function usePageModule(pageId: string) {
6
+ const [page, setDoc] = useState<PageModule | null>(null);
7
+ const [error, setError] = useState<string | null>(null);
8
+ const loadSeqRef = useRef(0);
9
+
10
+ const reload = useCallback(
11
+ (reset: boolean) => {
12
+ const seq = ++loadSeqRef.current;
13
+ if (reset) setDoc(null);
14
+ setError(null);
15
+ loadPage(pageId)
16
+ .then((mod) => {
17
+ if (seq === loadSeqRef.current) setDoc(mod);
18
+ })
19
+ .catch((e) => {
20
+ if (seq === loadSeqRef.current) setError(String(e?.message ?? e));
21
+ });
22
+ },
23
+ [pageId],
24
+ );
25
+
26
+ useEffect(() => {
27
+ reload(true);
28
+ }, [reload]);
29
+
30
+ useEffect(() => {
31
+ if (!import.meta.hot) return;
32
+ let cancelled = false;
33
+ const handler = (data: unknown) => {
34
+ if (pageChangeIncludes(data, pageId)) {
35
+ queueMicrotask(() => {
36
+ if (!cancelled) reload(false);
37
+ });
38
+ }
39
+ };
40
+ import.meta.hot.on('open-pages:page-changed', handler);
41
+ return () => {
42
+ cancelled = true;
43
+ import.meta.hot?.off('open-pages:page-changed', handler);
44
+ };
45
+ }, [pageId, reload]);
46
+
47
+ return { page, error, reload };
48
+ }
@@ -0,0 +1,29 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { loadPage, pageIds } from './pages';
3
+
4
+ /**
5
+ * Resolves every deck's display title once `enabled` first turns true. The home
6
+ * grid only mounts the cards of the selected folder, so cross-folder search
7
+ * needs its own pass over the page modules — kept lazy since it imports them all.
8
+ */
9
+ export function usePageTitles(enabled: boolean): Record<string, string> {
10
+ const [titles, setTitles] = useState<Record<string, string>>({});
11
+ const startedRef = useRef(false);
12
+
13
+ useEffect(() => {
14
+ if (!enabled || startedRef.current) return;
15
+ startedRef.current = true;
16
+ void Promise.all(
17
+ pageIds.map(async (id): Promise<[string, string]> => {
18
+ try {
19
+ const mod = await loadPage(id);
20
+ return [id, mod.meta?.title ?? id];
21
+ } catch {
22
+ return [id, id];
23
+ }
24
+ }),
25
+ ).then((entries) => setTitles(Object.fromEntries(entries)));
26
+ }, [enabled]);
27
+
28
+ return titles;
29
+ }
@@ -0,0 +1,78 @@
1
+ import { useCallback, useEffect, useSyncExternalStore } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { useLocale } from '@/lib/use-locale';
4
+
5
+ type ServerStatus = { executionId: string; canRestart: boolean };
6
+
7
+ async function fetchServerStatus(): Promise<ServerStatus | null> {
8
+ const res = await fetch('/__server-status');
9
+ if (!res.ok) return null;
10
+ return (await res.json()) as ServerStatus;
11
+ }
12
+
13
+ // Module scope, not per-hook state: the sidebar footer and the command menu can
14
+ // both be mounted, and each firing its own POST plus polling loop would race.
15
+ let state = { canRestart: false, restarting: false };
16
+ let statusProbed = false;
17
+ let inFlight: Promise<boolean> | null = null;
18
+ const listeners = new Set<() => void>();
19
+
20
+ function setState(next: Partial<typeof state>): void {
21
+ state = { ...state, ...next };
22
+ for (const listener of listeners) listener();
23
+ }
24
+
25
+ function subscribe(listener: () => void): () => void {
26
+ listeners.add(listener);
27
+ return () => {
28
+ listeners.delete(listener);
29
+ };
30
+ }
31
+
32
+ function getSnapshot(): typeof state {
33
+ return state;
34
+ }
35
+
36
+ async function performRestart(): Promise<boolean> {
37
+ const before = await fetchServerStatus();
38
+ if (!before) return false;
39
+ const res = await fetch('/__restart-server', { method: 'POST' });
40
+ if (!res.ok) return false;
41
+ // A different executionId means the replacement process is serving.
42
+ for (let attempt = 0; attempt < 30; attempt++) {
43
+ await new Promise((resolve) => setTimeout(resolve, 500));
44
+ const status = await fetchServerStatus().catch(() => null);
45
+ if (status && status.executionId !== before.executionId) {
46
+ window.location.reload();
47
+ return true;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ export function useRestartServer() {
54
+ const t = useLocale();
55
+ const { canRestart, restarting } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
56
+
57
+ useEffect(() => {
58
+ if (!import.meta.env.DEV || statusProbed) return;
59
+ statusProbed = true;
60
+ fetchServerStatus()
61
+ .then((status) => {
62
+ if (status) setState({ canRestart: status.canRestart });
63
+ })
64
+ .catch(() => {});
65
+ }, []);
66
+
67
+ const restartServer = useCallback(async () => {
68
+ if (inFlight) return;
69
+ inFlight = performRestart().catch(() => false);
70
+ setState({ restarting: true });
71
+ const restarted = await inFlight;
72
+ inFlight = null;
73
+ setState({ restarting: false });
74
+ if (!restarted) toast.error(t.home.restartServerFailed);
75
+ }, [t.home.restartServerFailed]);
76
+
77
+ return { canRestart, restarting, restartServer };
78
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { cn } from './utils.ts';
3
+
4
+ describe('cn', () => {
5
+ it('joins multiple class names', () => {
6
+ expect(cn('a', 'b', 'c')).toBe('a b c');
7
+ });
8
+
9
+ it('drops falsy values', () => {
10
+ expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
11
+ });
12
+
13
+ it('flattens arrays and conditional objects from clsx', () => {
14
+ expect(cn(['a', 'b'], { c: true, d: false })).toBe('a b c');
15
+ });
16
+
17
+ it('lets later tailwind classes override earlier ones', () => {
18
+ expect(cn('p-2', 'p-4')).toBe('p-4');
19
+ expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
20
+ });
21
+
22
+ it('preserves classes that target different properties', () => {
23
+ expect(cn('p-2', 'm-4')).toBe('p-2 m-4');
24
+ });
25
+ });
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx';
2
+ import { twMerge } from 'tailwind-merge';
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }