create-starlight-cms 0.9.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 (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +18 -0
  3. package/THIRD_PARTY_NOTICES.md +4 -0
  4. package/bin/index.js +147 -0
  5. package/package.json +46 -0
  6. package/template/.nvmrc +1 -0
  7. package/template/LICENSE +21 -0
  8. package/template/README.md +68 -0
  9. package/template/THIRD_PARTY_NOTICES.md +12 -0
  10. package/template/_gitignore +14 -0
  11. package/template/astro.config.mjs +26 -0
  12. package/template/migrations/0001_schema.sql +73 -0
  13. package/template/migrations/0002_publish_delivery.sql +16 -0
  14. package/template/package-lock.json.template +9123 -0
  15. package/template/package.json +89 -0
  16. package/template/scripts/build-admin.mjs +38 -0
  17. package/template/scripts/check-linux-bindings.mjs +18 -0
  18. package/template/scripts/dev.mjs +94 -0
  19. package/template/src/admin/admin.scss +182 -0
  20. package/template/src/admin/app.ts +130 -0
  21. package/template/src/admin/client.tsx +684 -0
  22. package/template/src/admin/components/tiptap-extension/node-background-extension.ts +150 -0
  23. package/template/src/admin/components/tiptap-icons/align-center-icon.tsx +38 -0
  24. package/template/src/admin/components/tiptap-icons/align-justify-icon.tsx +38 -0
  25. package/template/src/admin/components/tiptap-icons/align-left-icon.tsx +38 -0
  26. package/template/src/admin/components/tiptap-icons/align-right-icon.tsx +38 -0
  27. package/template/src/admin/components/tiptap-icons/arrow-left-icon.tsx +24 -0
  28. package/template/src/admin/components/tiptap-icons/arrow-right-icon.tsx +26 -0
  29. package/template/src/admin/components/tiptap-icons/ban-icon.tsx +26 -0
  30. package/template/src/admin/components/tiptap-icons/blockquote-icon.tsx +44 -0
  31. package/template/src/admin/components/tiptap-icons/bold-icon.tsx +26 -0
  32. package/template/src/admin/components/tiptap-icons/case-sensitive-icon.tsx +32 -0
  33. package/template/src/admin/components/tiptap-icons/check-icon.tsx +26 -0
  34. package/template/src/admin/components/tiptap-icons/chevron-down-icon.tsx +26 -0
  35. package/template/src/admin/components/tiptap-icons/chevron-up-icon.tsx +22 -0
  36. package/template/src/admin/components/tiptap-icons/close-icon.tsx +24 -0
  37. package/template/src/admin/components/tiptap-icons/code-block-icon.tsx +38 -0
  38. package/template/src/admin/components/tiptap-icons/code2-icon.tsx +32 -0
  39. package/template/src/admin/components/tiptap-icons/corner-down-left-icon.tsx +26 -0
  40. package/template/src/admin/components/tiptap-icons/external-link-icon.tsx +28 -0
  41. package/template/src/admin/components/tiptap-icons/heading-five-icon.tsx +28 -0
  42. package/template/src/admin/components/tiptap-icons/heading-four-icon.tsx +28 -0
  43. package/template/src/admin/components/tiptap-icons/heading-icon.tsx +24 -0
  44. package/template/src/admin/components/tiptap-icons/heading-one-icon.tsx +28 -0
  45. package/template/src/admin/components/tiptap-icons/heading-six-icon.tsx +30 -0
  46. package/template/src/admin/components/tiptap-icons/heading-three-icon.tsx +36 -0
  47. package/template/src/admin/components/tiptap-icons/heading-two-icon.tsx +28 -0
  48. package/template/src/admin/components/tiptap-icons/highlighter-icon.tsx +26 -0
  49. package/template/src/admin/components/tiptap-icons/image-plus-icon.tsx +26 -0
  50. package/template/src/admin/components/tiptap-icons/italic-icon.tsx +24 -0
  51. package/template/src/admin/components/tiptap-icons/link-icon.tsx +28 -0
  52. package/template/src/admin/components/tiptap-icons/list-icon.tsx +56 -0
  53. package/template/src/admin/components/tiptap-icons/list-ordered-icon.tsx +56 -0
  54. package/template/src/admin/components/tiptap-icons/list-todo-icon.tsx +50 -0
  55. package/template/src/admin/components/tiptap-icons/moon-star-icon.tsx +30 -0
  56. package/template/src/admin/components/tiptap-icons/redo2-icon.tsx +26 -0
  57. package/template/src/admin/components/tiptap-icons/search-icon.tsx +26 -0
  58. package/template/src/admin/components/tiptap-icons/strike-icon.tsx +28 -0
  59. package/template/src/admin/components/tiptap-icons/subscript-icon.tsx +38 -0
  60. package/template/src/admin/components/tiptap-icons/sun-icon.tsx +58 -0
  61. package/template/src/admin/components/tiptap-icons/superscript-icon.tsx +38 -0
  62. package/template/src/admin/components/tiptap-icons/trash-icon.tsx +26 -0
  63. package/template/src/admin/components/tiptap-icons/underline-icon.tsx +26 -0
  64. package/template/src/admin/components/tiptap-icons/undo2-icon.tsx +26 -0
  65. package/template/src/admin/components/tiptap-icons/whole-word-icon.tsx +36 -0
  66. package/template/src/admin/components/tiptap-node/blockquote-node/blockquote-node.scss +37 -0
  67. package/template/src/admin/components/tiptap-node/code-block-node/code-block-node.scss +54 -0
  68. package/template/src/admin/components/tiptap-node/heading-node/heading-node.scss +45 -0
  69. package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts +14 -0
  70. package/template/src/admin/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss +25 -0
  71. package/template/src/admin/components/tiptap-node/image-node/image-node.scss +35 -0
  72. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node-extension.ts +161 -0
  73. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.scss +249 -0
  74. package/template/src/admin/components/tiptap-node/image-upload-node/image-upload-node.tsx +554 -0
  75. package/template/src/admin/components/tiptap-node/image-upload-node/index.tsx +1 -0
  76. package/template/src/admin/components/tiptap-node/list-node/list-node.scss +208 -0
  77. package/template/src/admin/components/tiptap-node/paragraph-node/paragraph-node.scss +217 -0
  78. package/template/src/admin/components/tiptap-templates/simple/NOTICE.md +14 -0
  79. package/template/src/admin/components/tiptap-templates/simple/data/content.json +477 -0
  80. package/template/src/admin/components/tiptap-templates/simple/simple-editor.scss +97 -0
  81. package/template/src/admin/components/tiptap-templates/simple/simple-editor.tsx +348 -0
  82. package/template/src/admin/components/tiptap-templates/simple/theme-toggle.tsx +44 -0
  83. package/template/src/admin/components/tiptap-ui/blockquote-button/blockquote-button.tsx +122 -0
  84. package/template/src/admin/components/tiptap-ui/blockquote-button/index.tsx +2 -0
  85. package/template/src/admin/components/tiptap-ui/blockquote-button/use-blockquote.ts +273 -0
  86. package/template/src/admin/components/tiptap-ui/code-block-button/code-block-button.tsx +122 -0
  87. package/template/src/admin/components/tiptap-ui/code-block-button/index.tsx +2 -0
  88. package/template/src/admin/components/tiptap-ui/code-block-button/use-code-block.ts +283 -0
  89. package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.scss +49 -0
  90. package/template/src/admin/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx +170 -0
  91. package/template/src/admin/components/tiptap-ui/color-highlight-button/index.tsx +2 -0
  92. package/template/src/admin/components/tiptap-ui/color-highlight-button/use-color-highlight.ts +380 -0
  93. package/template/src/admin/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx +231 -0
  94. package/template/src/admin/components/tiptap-ui/color-highlight-popover/index.tsx +1 -0
  95. package/template/src/admin/components/tiptap-ui/heading-button/heading-button.tsx +124 -0
  96. package/template/src/admin/components/tiptap-ui/heading-button/index.tsx +2 -0
  97. package/template/src/admin/components/tiptap-ui/heading-button/use-heading.ts +348 -0
  98. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx +128 -0
  99. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/index.tsx +2 -0
  100. package/template/src/admin/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts +132 -0
  101. package/template/src/admin/components/tiptap-ui/image-upload-button/image-upload-button.tsx +130 -0
  102. package/template/src/admin/components/tiptap-ui/image-upload-button/index.tsx +2 -0
  103. package/template/src/admin/components/tiptap-ui/image-upload-button/use-image-upload.ts +197 -0
  104. package/template/src/admin/components/tiptap-ui/link-popover/index.tsx +2 -0
  105. package/template/src/admin/components/tiptap-ui/link-popover/link-popover.scss +16 -0
  106. package/template/src/admin/components/tiptap-ui/link-popover/link-popover.tsx +315 -0
  107. package/template/src/admin/components/tiptap-ui/link-popover/use-link-popover.ts +297 -0
  108. package/template/src/admin/components/tiptap-ui/list-button/index.tsx +2 -0
  109. package/template/src/admin/components/tiptap-ui/list-button/list-button.tsx +120 -0
  110. package/template/src/admin/components/tiptap-ui/list-button/use-list.ts +353 -0
  111. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/index.tsx +2 -0
  112. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx +126 -0
  113. package/template/src/admin/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts +220 -0
  114. package/template/src/admin/components/tiptap-ui/mark-button/index.tsx +2 -0
  115. package/template/src/admin/components/tiptap-ui/mark-button/mark-button.tsx +122 -0
  116. package/template/src/admin/components/tiptap-ui/mark-button/use-mark.ts +219 -0
  117. package/template/src/admin/components/tiptap-ui/search-and-replace/index.tsx +2 -0
  118. package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.scss +300 -0
  119. package/template/src/admin/components/tiptap-ui/search-and-replace/search-and-replace.tsx +718 -0
  120. package/template/src/admin/components/tiptap-ui/search-and-replace/use-search-and-replace.ts +528 -0
  121. package/template/src/admin/components/tiptap-ui/text-align-button/index.tsx +2 -0
  122. package/template/src/admin/components/tiptap-ui/text-align-button/text-align-button.tsx +144 -0
  123. package/template/src/admin/components/tiptap-ui/text-align-button/use-text-align.ts +237 -0
  124. package/template/src/admin/components/tiptap-ui/undo-redo-button/index.tsx +2 -0
  125. package/template/src/admin/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx +125 -0
  126. package/template/src/admin/components/tiptap-ui/undo-redo-button/use-undo-redo.ts +200 -0
  127. package/template/src/admin/components/tiptap-ui-primitive/badge/badge-colors.scss +395 -0
  128. package/template/src/admin/components/tiptap-ui-primitive/badge/badge-group.scss +16 -0
  129. package/template/src/admin/components/tiptap-ui-primitive/badge/badge.scss +99 -0
  130. package/template/src/admin/components/tiptap-ui-primitive/badge/badge.tsx +44 -0
  131. package/template/src/admin/components/tiptap-ui-primitive/badge/index.tsx +1 -0
  132. package/template/src/admin/components/tiptap-ui-primitive/button/button-colors.scss +613 -0
  133. package/template/src/admin/components/tiptap-ui-primitive/button/button.scss +390 -0
  134. package/template/src/admin/components/tiptap-ui-primitive/button/button.tsx +158 -0
  135. package/template/src/admin/components/tiptap-ui-primitive/button/index.tsx +1 -0
  136. package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.scss +89 -0
  137. package/template/src/admin/components/tiptap-ui-primitive/button-group/button-group.tsx +72 -0
  138. package/template/src/admin/components/tiptap-ui-primitive/button-group/index.tsx +1 -0
  139. package/template/src/admin/components/tiptap-ui-primitive/card/card.scss +74 -0
  140. package/template/src/admin/components/tiptap-ui-primitive/card/card.tsx +79 -0
  141. package/template/src/admin/components/tiptap-ui-primitive/card/index.tsx +1 -0
  142. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss +152 -0
  143. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx +262 -0
  144. package/template/src/admin/components/tiptap-ui-primitive/dropdown-menu/index.tsx +1 -0
  145. package/template/src/admin/components/tiptap-ui-primitive/input/index.tsx +1 -0
  146. package/template/src/admin/components/tiptap-ui-primitive/input/input.scss +41 -0
  147. package/template/src/admin/components/tiptap-ui-primitive/input/input.tsx +22 -0
  148. package/template/src/admin/components/tiptap-ui-primitive/input-group/index.tsx +1 -0
  149. package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.scss +225 -0
  150. package/template/src/admin/components/tiptap-ui-primitive/input-group/input-group.tsx +135 -0
  151. package/template/src/admin/components/tiptap-ui-primitive/popover/index.tsx +1 -0
  152. package/template/src/admin/components/tiptap-ui-primitive/popover/popover.scss +64 -0
  153. package/template/src/admin/components/tiptap-ui-primitive/popover/popover.tsx +35 -0
  154. package/template/src/admin/components/tiptap-ui-primitive/separator/index.tsx +1 -0
  155. package/template/src/admin/components/tiptap-ui-primitive/separator/separator.scss +23 -0
  156. package/template/src/admin/components/tiptap-ui-primitive/separator/separator.tsx +28 -0
  157. package/template/src/admin/components/tiptap-ui-primitive/spacer/index.tsx +1 -0
  158. package/template/src/admin/components/tiptap-ui-primitive/spacer/spacer.tsx +24 -0
  159. package/template/src/admin/components/tiptap-ui-primitive/switch/index.tsx +1 -0
  160. package/template/src/admin/components/tiptap-ui-primitive/switch/switch.scss +86 -0
  161. package/template/src/admin/components/tiptap-ui-primitive/switch/switch.tsx +29 -0
  162. package/template/src/admin/components/tiptap-ui-primitive/textarea/index.tsx +1 -0
  163. package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.scss +42 -0
  164. package/template/src/admin/components/tiptap-ui-primitive/textarea/textarea.tsx +16 -0
  165. package/template/src/admin/components/tiptap-ui-primitive/toolbar/index.tsx +1 -0
  166. package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.scss +98 -0
  167. package/template/src/admin/components/tiptap-ui-primitive/toolbar/toolbar.tsx +203 -0
  168. package/template/src/admin/components/tiptap-ui-primitive/tooltip/index.tsx +1 -0
  169. package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.scss +43 -0
  170. package/template/src/admin/components/tiptap-ui-primitive/tooltip/tooltip.tsx +241 -0
  171. package/template/src/admin/hooks/use-composed-ref.ts +46 -0
  172. package/template/src/admin/hooks/use-cursor-visibility.ts +69 -0
  173. package/template/src/admin/hooks/use-element-rect.ts +166 -0
  174. package/template/src/admin/hooks/use-is-breakpoint.ts +35 -0
  175. package/template/src/admin/hooks/use-menu-navigation.ts +204 -0
  176. package/template/src/admin/hooks/use-scrolling.ts +75 -0
  177. package/template/src/admin/hooks/use-throttled-callback.ts +48 -0
  178. package/template/src/admin/hooks/use-tiptap-editor.ts +69 -0
  179. package/template/src/admin/hooks/use-unmount.ts +21 -0
  180. package/template/src/admin/hooks/use-window-size.ts +93 -0
  181. package/template/src/admin/html.ts +17 -0
  182. package/template/src/admin/lib/tiptap-utils.ts +652 -0
  183. package/template/src/admin/navigation-tree.tsx +82 -0
  184. package/template/src/admin/preview.ts +73 -0
  185. package/template/src/admin/styles/_cms-theme.scss +58 -0
  186. package/template/src/admin/styles/_keyframe-animations.scss +91 -0
  187. package/template/src/admin/styles/_variables.scss +298 -0
  188. package/template/src/assets/favicon.svg +1 -0
  189. package/template/src/assets/logo.svg +1 -0
  190. package/template/src/astro-env.d.ts +1 -0
  191. package/template/src/content.config.ts +12 -0
  192. package/template/src/db/bootstrap.ts +36 -0
  193. package/template/src/db/client.ts +7 -0
  194. package/template/src/db/schema.ts +45 -0
  195. package/template/src/documents/service.ts +178 -0
  196. package/template/src/documents/validation.ts +9 -0
  197. package/template/src/env.ts +9 -0
  198. package/template/src/index.ts +10 -0
  199. package/template/src/locales.ts +10 -0
  200. package/template/src/media/service.ts +120 -0
  201. package/template/src/navigation/service.ts +171 -0
  202. package/template/src/pages/[locale]/cms-preview-shell.astro +12 -0
  203. package/template/src/pages/cms-preview-shell.astro +5 -0
  204. package/template/src/publish/deploy-hook.ts +42 -0
  205. package/template/src/publish/record.ts +20 -0
  206. package/template/src/publish/service.ts +135 -0
  207. package/template/src/scss.d.ts +5 -0
  208. package/template/src/site.config.ts +7 -0
  209. package/template/src/sql.d.ts +4 -0
  210. package/template/src/starlight/cms-origin.ts +4 -0
  211. package/template/src/starlight/loader.ts +21 -0
  212. package/template/src/starlight/preview-render.ts +162 -0
  213. package/template/src/starlight/preview-shell.astro +14 -0
  214. package/template/src/starlight/render.ts +59 -0
  215. package/template/src/starlight/schema.ts +34 -0
  216. package/template/src/starlight/sidebar.ts +35 -0
  217. package/template/src/starlight/snapshot.ts +47 -0
  218. package/template/src/starlight/source.ts +26 -0
  219. package/template/src/starlight/tiptap.ts +29 -0
  220. package/template/src/styles/starlight.css +12 -0
  221. package/template/test-files/content.test.template.ts +82 -0
  222. package/template/test-files/deploy-hook.test.template.ts +32 -0
  223. package/template/test-files/media.test.template.ts +39 -0
  224. package/template/test-files/publishing.integration.test.template.ts +156 -0
  225. package/template/tsconfig.admin.json +18 -0
  226. package/template/tsconfig.json +23 -0
  227. package/template/worker-configuration.d.ts +15389 -0
  228. package/template/wrangler.jsonc +26 -0
@@ -0,0 +1,684 @@
1
+ import { siteConfig } from '../site.config.ts';
2
+ import { Node, type JSONContent } from '@tiptap/core';
3
+ import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
4
+ import type { Editor, Extensions } from '@tiptap/react';
5
+ import { useCallback, useEffect, useState } from 'react';
6
+ import { createRoot } from 'react-dom/client';
7
+ import { SimpleEditor } from './components/tiptap-templates/simple/simple-editor';
8
+ import { NavigationTree, type NavigationItem } from './navigation-tree';
9
+ import { localeLabel, defaultLocale, isSupportedLocale, supportedLocales, type SupportedLocale } from '../locales';
10
+ import logoUrl from '../assets/logo.svg';
11
+ import './styles/_variables.scss';
12
+ import './styles/_keyframe-animations.scss';
13
+ import './styles/_cms-theme.scss';
14
+ import './admin.scss';
15
+
16
+ type DocumentRecord = {
17
+ id: string; locale: SupportedLocale;
18
+ title: string;
19
+ slug: string;
20
+ description: string;
21
+ folderId: string | null;
22
+ order: number;
23
+ contentJson: JSONContent;
24
+ status: 'draft' | 'published';
25
+ version: number;
26
+ };
27
+ type Media = { id: string; fileName: string; contentType: string; url: string; isUsed: boolean };
28
+ type Revision = { id: string; revision: number; createdAt: number };
29
+ type Folder = { id: string; name: string; slug: string; parentId: string | null; order: number };
30
+ type PublishDelivery = {
31
+ id: string;
32
+ status: 'pending' | 'accepted' | 'failed' | 'skipped';
33
+ attempts: number;
34
+ buildId: string | null;
35
+ alreadyExists: boolean;
36
+ lastError: string | null;
37
+ requestedAt: number;
38
+ acceptedAt: number | null;
39
+ nextRetryAt: number | null;
40
+ };
41
+ type PublishDocumentResponse = { document: DocumentRecord; delivery: PublishDelivery };
42
+ type PublishSiteResponse = { delivery: PublishDelivery };
43
+ type DocumentFields = Pick<DocumentRecord, 'title' | 'slug' | 'description' | 'folderId' | 'order'>;
44
+ type ThemePreference = 'system' | 'light' | 'dark';
45
+
46
+ const emptyContent: JSONContent = { type: 'doc', content: [{ type: 'paragraph' }] };
47
+ const emptyDocument: DocumentRecord = {
48
+ id: '', locale: defaultLocale, title: '', slug: '', description: '', folderId: null, order: 0,
49
+ contentJson: emptyContent, status: 'draft', version: 0,
50
+ };
51
+ const mediaTypes = 'image/png,image/jpeg,image/webp,image/avif,video/mp4,video/webm';
52
+ const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
53
+
54
+ function SunIcon() {
55
+ return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" /></svg>;
56
+ }
57
+
58
+ function MoonIcon() {
59
+ return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" /></svg>;
60
+ }
61
+
62
+ function ChevronRightIcon() {
63
+ return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="m9 18 6-6-6-6" /></svg>;
64
+ }
65
+
66
+ function HistoryIcon() {
67
+ return <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true"><path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l2.5 1.5M3.1 12a8.9 8.9 0 1 0 2.1-5.8M3.1 4.8v4.1h4.1" /></svg>;
68
+ }
69
+
70
+ function slugify(value: string) {
71
+ return value
72
+ .normalize('NFKD')
73
+ .replace(/[\u0300-\u036f]/g, '')
74
+ .toLowerCase()
75
+ .replace(/[^a-z0-9]+/g, '-')
76
+ .replace(/^-+|-+$/g, '');
77
+ }
78
+
79
+ function normalizeSlugInput(value: string) {
80
+ return value.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').slice(0, 120);
81
+ }
82
+
83
+ const Callout = Node.create({
84
+ name: 'callout', group: 'block', content: 'block+',
85
+ addAttributes: () => ({ title: { default: 'Note' } }),
86
+ parseHTML: () => [{ tag: 'aside[data-callout]' }],
87
+ renderHTML: ({ HTMLAttributes }) => ['aside', { 'data-callout': '', ...HTMLAttributes }, 0],
88
+ });
89
+ const Steps = Node.create({
90
+ name: 'steps', group: 'block', content: 'listItem+',
91
+ parseHTML: () => [{ tag: 'ol[data-steps]' }],
92
+ renderHTML: () => ['ol', { 'data-steps': '' }, 0],
93
+ });
94
+ const Tabs = Node.create({
95
+ name: 'tabs', group: 'block', content: 'tab+',
96
+ parseHTML: () => [{ tag: 'section[data-tabs]' }],
97
+ renderHTML: () => ['section', { 'data-tabs': '' }, 0],
98
+ });
99
+ const Tab = Node.create({
100
+ name: 'tab', content: 'block+',
101
+ addAttributes: () => ({ label: { default: 'Tab' } }),
102
+ parseHTML: () => [{ tag: 'section[data-tab]' }],
103
+ renderHTML: ({ HTMLAttributes }) => ['section', { 'data-tab': '', ...HTMLAttributes }, 0],
104
+ });
105
+ const Video = Node.create({
106
+ name: 'video', group: 'block', atom: true,
107
+ addAttributes: () => ({ src: { default: null }, mediaId: { default: null } }),
108
+ parseHTML: () => [{ tag: 'video' }],
109
+ renderHTML: ({ HTMLAttributes }) => ['video', { controls: '', ...HTMLAttributes }],
110
+ });
111
+
112
+ const documentExtensions: Extensions = [
113
+ Table.configure({ resizable: true }), TableRow, TableHeader, TableCell,
114
+ Callout, Steps, Tabs, Tab, Video,
115
+ ];
116
+
117
+ async function api<T>(path: string, init: RequestInit = {}, locale?: SupportedLocale): Promise<T> {
118
+ const isJson = Boolean(init.body && !(init.body instanceof FormData));
119
+ const url = new URL(`/admin/api${path}`, window.location.origin);
120
+ if (locale) url.searchParams.set('locale', locale);
121
+ const response = await fetch(url, {
122
+ ...init,
123
+ headers: {
124
+ 'X-Requested-With': 'cloudflare-starlight-cms',
125
+ ...(isJson ? { 'Content-Type': 'application/json' } : {}),
126
+ ...init.headers,
127
+ },
128
+ });
129
+ if (response.status === 204) return undefined as T;
130
+ const text = await response.text();
131
+ if (!text || !response.headers.get('content-type')?.includes('application/json')) {
132
+ throw new Error(`API ${path} returned an invalid response (${response.status}). Restart the local dev server and reload.`);
133
+ }
134
+ const payload = JSON.parse(text) as T & { error?: string };
135
+ if (!response.ok) throw new Error(payload.error ?? `Request failed (${response.status})`);
136
+ return payload;
137
+ }
138
+
139
+ function documentFields(document: DocumentRecord): DocumentFields {
140
+ const { title, slug, description, folderId, order } = document;
141
+ return { title, slug, description, folderId, order };
142
+ }
143
+
144
+ function setEditorDocument(editor: Editor | null, content: JSONContent) {
145
+ if (!editor || editor.isDestroyed) return;
146
+ editor.commands.setContent(content || emptyContent, { emitUpdate: false });
147
+ }
148
+
149
+ function App() {
150
+ const [documents, setDocuments] = useState<DocumentRecord[]>([]);
151
+ const [treeItems, setTreeItems] = useState<NavigationItem[]>([]);
152
+ const [media, setMedia] = useState<Media[]>([]);
153
+ const [current, setCurrent] = useState<DocumentRecord | null>(null);
154
+ const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
155
+ const [folderName, setFolderName] = useState('');
156
+ const [folderSlug, setFolderSlug] = useState('');
157
+ const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false);
158
+ const [folderDraft, setFolderDraft] = useState({ name: '', slug: '' });
159
+ const [fields, setFields] = useState<DocumentFields>(documentFields(emptyDocument));
160
+ const [revisions, setRevisions] = useState<Revision[]>([]);
161
+ const [notice, setNotice] = useState('Loading documents…');
162
+ const [noticeIsError, setNoticeIsError] = useState(false);
163
+ const [isSaving, setIsSaving] = useState(false);
164
+ const [isMediaPickerOpen, setIsMediaPickerOpen] = useState(false);
165
+ const [isRevisionOpen, setIsRevisionOpen] = useState(false);
166
+ const [isDirty, setIsDirty] = useState(false);
167
+ const [search, setSearch] = useState('');
168
+ const [editor, setEditor] = useState<Editor | null>(null);
169
+ const [locale, setLocale] = useState<SupportedLocale>(defaultLocale);
170
+ const [missingDocumentId, setMissingDocumentId] = useState<string | null>(null);
171
+ const [missingTranslationSource, setMissingTranslationSource] = useState<SupportedLocale | null>(null);
172
+ const [latestDelivery, setLatestDelivery] = useState<PublishDelivery | null>(null);
173
+ const [theme, setTheme] = useState<ThemePreference>(() => {
174
+ const saved = window.localStorage.getItem('docs-cms-theme');
175
+ return saved === 'light' || saved === 'dark' || saved === 'system' ? saved : 'system';
176
+ });
177
+ const [systemPrefersDark, setSystemPrefersDark] = useState(() => window.matchMedia('(prefers-color-scheme: dark)').matches);
178
+
179
+ const showNotice = useCallback((message: string, error = false) => {
180
+ setNotice(message);
181
+ setNoticeIsError(error);
182
+ }, []);
183
+ const refreshDocuments = useCallback(async () => {
184
+ setDocuments(await api<DocumentRecord[]>('/documents'));
185
+ }, []);
186
+ const refreshMedia = useCallback(async () => {
187
+ setMedia(await api<Media[]>('/media'));
188
+ }, []);
189
+ const refreshTree = useCallback(async () => { setTreeItems(await api<NavigationItem[]>('/tree')); }, []);
190
+ const refreshDeliveries = useCallback(async () => {
191
+ const deliveries = await api<PublishDelivery[]>('/publish/deliveries');
192
+ setLatestDelivery(deliveries.find((item) => item.status === 'failed' || (item.status === 'pending' && (!item.nextRetryAt || item.nextRetryAt <= Date.now()))) ?? deliveries[0] ?? null);
193
+ }, []);
194
+ useEffect(() => {
195
+ const refresh = () => { void refreshDeliveries().catch((error) => showNotice(String(error), true)); };
196
+ window.addEventListener('focus', refresh);
197
+ return () => window.removeEventListener('focus', refresh);
198
+ }, [refreshDeliveries, showNotice]);
199
+
200
+ const selectedFolder = treeItems.find((item) => item.kind === 'folder' && item.id === `folder:${selectedFolderId}`) ?? null;
201
+
202
+ useEffect(() => {
203
+ Promise.all([refreshDocuments(), refreshMedia(), refreshTree(), refreshDeliveries()])
204
+ .then(() => showNotice('Ready.'))
205
+ .catch((error: unknown) => showNotice(String(error), true));
206
+ }, [refreshDeliveries, refreshDocuments, refreshMedia, refreshTree, showNotice]);
207
+
208
+ useEffect(() => {
209
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
210
+ const applyTheme = () => {
211
+ const isDark = theme === 'dark' || (theme === 'system' && mediaQuery.matches);
212
+ setSystemPrefersDark(mediaQuery.matches);
213
+ document.documentElement.classList.toggle('dark', isDark);
214
+ document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
215
+ };
216
+ applyTheme();
217
+ mediaQuery.addEventListener('change', applyTheme);
218
+ window.localStorage.setItem('docs-cms-theme', theme);
219
+ return () => mediaQuery.removeEventListener('change', applyTheme);
220
+ }, [theme]);
221
+
222
+ const displayedTheme = theme === 'system' ? (systemPrefersDark ? 'dark' : 'light') : theme;
223
+
224
+ useEffect(() => {
225
+ if (!current || !editor) return;
226
+ setEditorDocument(editor, current.contentJson);
227
+ }, [current, editor]);
228
+
229
+ useEffect(() => {
230
+ if (!isRevisionOpen) return;
231
+ const closeOnEscape = (event: KeyboardEvent) => {
232
+ if (event.key === 'Escape') setIsRevisionOpen(false);
233
+ };
234
+ window.addEventListener('keydown', closeOnEscape);
235
+ return () => window.removeEventListener('keydown', closeOnEscape);
236
+ }, [isRevisionOpen]);
237
+
238
+ const selectDocument = useCallback(async (document: DocumentRecord, targetLocale: SupportedLocale = document.locale) => {
239
+ if (isDirty && !window.confirm('Discard unsaved changes?')) return;
240
+ try {
241
+ const loaded = document.id ? await api<DocumentRecord>(`/documents/${encodeURIComponent(document.id)}`, {}, targetLocale) : document;
242
+ setCurrent(loaded);
243
+ setLocale(loaded.locale);
244
+ setMissingDocumentId(null);
245
+ setMissingTranslationSource(null);
246
+ setSelectedFolderId(null);
247
+ setFields(documentFields(loaded));
248
+ setRevisions([]);
249
+ setIsRevisionOpen(false);
250
+ setIsDirty(false);
251
+ showNotice(`${loaded.status === 'published' ? 'Published' : 'Draft'} · revision ${loaded.version}`);
252
+ } catch (error) {
253
+ showNotice(String(error), true);
254
+ }
255
+ }, [editor, isDirty, showNotice]);
256
+
257
+ const selectFolder = useCallback((folderId: string) => {
258
+ if (isDirty && !window.confirm('Discard unsaved changes?')) return;
259
+ const folder = treeItems.find((item) => item.id === `folder:${folderId}`);
260
+ if (!folder) return;
261
+ setEditor(null);
262
+ setCurrent(null);
263
+ setLocale(defaultLocale);
264
+ setMissingDocumentId(null);
265
+ setMissingTranslationSource(null);
266
+ setSelectedFolderId(folderId);
267
+ setFolderName(folder.name);
268
+ setFolderSlug(folder.slug);
269
+ setIsDirty(false);
270
+ showNotice('');
271
+ }, [isDirty, showNotice, treeItems]);
272
+ const selectNavigationRoot = () => {
273
+ if (isDirty && !window.confirm('Discard unsaved changes?')) return;
274
+ setEditor(null); setCurrent(null); setSelectedFolderId(null); setMissingDocumentId(null); setMissingTranslationSource(null);
275
+ setLocale(defaultLocale); setIsDirty(false); showNotice('');
276
+ };
277
+
278
+ const selectTreeDocument = (id: string) => {
279
+ const treeItem = treeItems.find((item) => item.documentId === id);
280
+ const source = treeItem?.translationLocales.includes(defaultLocale) ? defaultLocale : treeItem?.translationLocales[0] as SupportedLocale | undefined;
281
+ if (source) { void selectDocument({ ...emptyDocument, id, locale: source }, source); return; }
282
+ setEditor(null); setCurrent(null); setSelectedFolderId(null); setMissingDocumentId(id); setMissingTranslationSource(null); setIsDirty(false);
283
+ showNotice('No translation exists for this page.');
284
+ };
285
+ const selectDocumentLocale = (targetLocale: SupportedLocale) => {
286
+ if (!current?.id) return;
287
+ const treeItem = treeItems.find((item) => item.documentId === current.id);
288
+ if (treeItem?.translationLocales.includes(targetLocale)) {
289
+ void selectDocument({ ...current, locale: targetLocale }, targetLocale);
290
+ return;
291
+ }
292
+ const source = treeItem?.translationLocales.includes(defaultLocale) ? defaultLocale : treeItem?.translationLocales[0] as SupportedLocale | undefined;
293
+ setEditor(null); setCurrent(null); setSelectedFolderId(null); setLocale(targetLocale);
294
+ setMissingDocumentId(current.id); setMissingTranslationSource(source ?? null); setIsDirty(false);
295
+ showNotice(`No ${targetLocale} translation yet.`);
296
+ };
297
+ const selectMissingDocumentLocale = (targetLocale: SupportedLocale) => {
298
+ if (!missingDocumentId) return;
299
+ const treeItem = treeItems.find((item) => item.documentId === missingDocumentId);
300
+ if (treeItem?.translationLocales.includes(targetLocale)) {
301
+ void selectDocument({ ...emptyDocument, id: missingDocumentId, locale: targetLocale }, targetLocale);
302
+ return;
303
+ }
304
+ const source = treeItem?.translationLocales.includes(defaultLocale) ? defaultLocale : treeItem?.translationLocales[0] as SupportedLocale | undefined;
305
+ setLocale(targetLocale);
306
+ setMissingTranslationSource(source ?? null);
307
+ showNotice(`No ${targetLocale} translation yet.`);
308
+ };
309
+ const createDocumentTranslation = async () => {
310
+ if (!missingDocumentId) return;
311
+ setIsSaving(true);
312
+ try {
313
+ if (!missingTranslationSource) throw new Error('No source translation is available.');
314
+ const created = await api<DocumentRecord>(`/documents/${encodeURIComponent(missingDocumentId)}/translations`, { method: 'POST', body: JSON.stringify({ sourceLocale: missingTranslationSource }) }, locale);
315
+ await Promise.all([refreshDocuments(), refreshTree(), refreshDeliveries()]);
316
+ await selectDocument(created);
317
+ showNotice(`${locale} translation created from ${missingTranslationSource}.`);
318
+ } catch (error) { showNotice(String(error), true); } finally { setIsSaving(false); }
319
+ };
320
+ const nextOrder = useCallback((parentId: string | null) => Math.max(-1, ...treeItems.filter((item) => item.parentId === (parentId ? `folder:${parentId}` : null)).map((item) => item.order)) + 1, [treeItems]);
321
+ const startNewDocument = useCallback((folderId = selectedFolderId) => {
322
+ setLocale(defaultLocale);
323
+ void selectDocument({ ...emptyDocument, folderId, order: nextOrder(folderId) });
324
+ }, [nextOrder, selectDocument, selectedFolderId]);
325
+ const openNewFolder = useCallback((parentId = selectedFolderId) => {
326
+ setSelectedFolderId(parentId);
327
+ setFolderDraft({ name: '', slug: '' });
328
+ setIsFolderDialogOpen(true);
329
+ }, [selectedFolderId]);
330
+ const createFolder = async () => {
331
+ const name = folderDraft.name.trim();
332
+ const slug = folderDraft.slug || slugify(name);
333
+ if (!name || !slug) return showNotice('Folder name is required.', true);
334
+ setIsSaving(true);
335
+ try {
336
+ const created = await api<Folder>('/folders', { method: 'POST', body: JSON.stringify({ name, slug, parentId: selectedFolderId, order: nextOrder(selectedFolderId) }) });
337
+ await refreshTree();
338
+ setIsFolderDialogOpen(false);
339
+ setCurrent(null);
340
+ setSelectedFolderId(created.id);
341
+ setFolderName(created.name);
342
+ setFolderSlug(created.slug);
343
+ showNotice('Folder created.');
344
+ } catch (error) { showNotice(String(error), true); } finally { setIsSaving(false); }
345
+ };
346
+ const saveFolder = async () => {
347
+ if (!selectedFolder) return;
348
+ const name = folderName.trim(); const slug = folderSlug || slugify(name);
349
+ if (!name || !slug) return showNotice('Folder name is required.', true);
350
+ setIsSaving(true);
351
+ try {
352
+ await api<Folder>(`/folders/${encodeURIComponent(selectedFolderId!)}`, { method: 'PUT', body: JSON.stringify({ name, slug, parentId: selectedFolder.parentId ? selectedFolder.parentId.slice('folder:'.length) : null, order: selectedFolder.order }) });
353
+ await refreshTree(); showNotice('Folder saved.');
354
+ } catch (error) { showNotice(String(error), true); } finally { setIsSaving(false); }
355
+ };
356
+ const deleteFolder = async () => {
357
+ if (!selectedFolderId || !selectedFolder || !window.confirm(`Delete empty folder “${selectedFolder.name}”?`)) return;
358
+ setIsSaving(true);
359
+ try {
360
+ await api<void>(`/folders/${encodeURIComponent(selectedFolderId)}`, { method: 'DELETE' });
361
+ setSelectedFolderId(null); await refreshTree(); showNotice('Folder deleted.');
362
+ } catch (error) { showNotice(String(error), true); } finally { setIsSaving(false); }
363
+ };
364
+ const replaceTreeChildren = async (parentId: string | null, childIds: string[]) => {
365
+ try {
366
+ await api('/tree/children', { method: 'PUT', body: JSON.stringify({ parentId, childIds }) });
367
+ } catch (error) { showNotice(String(error), true); throw error; }
368
+ };
369
+ const treeChanged = async () => {
370
+ await Promise.all([refreshTree(), refreshDocuments(), refreshDeliveries()]);
371
+ showNotice('Navigation updated.');
372
+ };
373
+
374
+ const updateField = (field: keyof DocumentFields, value: string | number | null) => {
375
+ setFields((currentFields) => ({ ...currentFields, [field]: value }));
376
+ setIsDirty(true);
377
+ };
378
+ const updateTitle = (title: string) => {
379
+ const suggestedSlug = slugify(title);
380
+ setFields((currentFields) => ({
381
+ ...currentFields,
382
+ title,
383
+ // A title only suggests the initial URL segment. It never overwrites a
384
+ // value chosen by the editor, and non-Latin titles leave it blank.
385
+ slug: current?.id || currentFields.slug || !suggestedSlug ? currentFields.slug : suggestedSlug,
386
+ }));
387
+ setIsDirty(true);
388
+ };
389
+ const updateSlug = (slug: string) => {
390
+ updateField('slug', normalizeSlugInput(slug));
391
+ };
392
+
393
+ const save = async (): Promise<DocumentRecord | undefined> => {
394
+ if (!editor || !current) return undefined;
395
+ if (!validSlug.test(fields.slug)) {
396
+ showNotice('URL segment must use lowercase letters, numbers, and single hyphens.', true);
397
+ return undefined;
398
+ }
399
+ setIsSaving(true);
400
+ try {
401
+ const body = { ...fields, contentJson: editor.getJSON(), version: current.version };
402
+ const saved = current.id
403
+ ? await api<DocumentRecord>(`/documents/${encodeURIComponent(current.id)}`, { method: 'PUT', body: JSON.stringify(body) }, current.locale)
404
+ : await api<DocumentRecord>('/documents', { method: 'POST', body: JSON.stringify(body) });
405
+ setCurrent(saved);
406
+ setFields(documentFields(saved));
407
+ setIsDirty(false);
408
+ await Promise.all([refreshDocuments(), refreshTree(), refreshDeliveries()]);
409
+ showNotice('Draft saved.');
410
+ return saved;
411
+ } catch (error) {
412
+ showNotice(String(error), true);
413
+ } finally {
414
+ setIsSaving(false);
415
+ }
416
+ };
417
+
418
+ const preview = () => {
419
+ if (!current?.id) return;
420
+ if (isDirty) {
421
+ showNotice('Save the draft before opening preview.', true);
422
+ return;
423
+ }
424
+ window.open(`/admin/preview/${encodeURIComponent(current.id)}?locale=${encodeURIComponent(current.locale)}`, '_blank', 'noopener');
425
+ };
426
+
427
+ const describeDelivery = (delivery: PublishDelivery) => {
428
+ if (delivery.status === 'accepted') return delivery.alreadyExists ? 'Build already requested.' : 'Build requested.';
429
+ if (delivery.status === 'skipped') return delivery.lastError === 'Local development rebuild is active' ? 'Published. Local docs rebuild automatically.' : 'Published, but Deploy Hook is not configured.';
430
+ if (delivery.status === 'failed') return 'Published, but the build request failed. Retry it from the header.';
431
+ return 'Build request is pending.';
432
+ };
433
+
434
+ const publish = async () => {
435
+ if (!current?.id) return showNotice('Save the document before publishing.', true);
436
+ let documentToPublish = current;
437
+ if (isDirty) {
438
+ const saved = await save();
439
+ if (!saved) return;
440
+ documentToPublish = saved;
441
+ }
442
+ setIsSaving(true);
443
+ try {
444
+ const result = await api<PublishDocumentResponse>(`/documents/${encodeURIComponent(documentToPublish.id)}/publish`, {
445
+ method: 'POST', body: JSON.stringify({ version: documentToPublish.version }),
446
+ }, documentToPublish.locale);
447
+ setCurrent(result.document);
448
+ setFields(documentFields(result.document));
449
+ setLatestDelivery(result.delivery);
450
+ await Promise.all([refreshDocuments(), refreshTree(), refreshDeliveries()]);
451
+ showNotice(describeDelivery(result.delivery), result.delivery.status === 'failed');
452
+ } catch (error) {
453
+ showNotice(String(error), true);
454
+ } finally {
455
+ setIsSaving(false);
456
+ }
457
+ };
458
+
459
+ const rebuildPublishedSite = async () => {
460
+ setIsSaving(true);
461
+ try {
462
+ const result = await api<PublishSiteResponse>('/publish/site', { method: 'POST' });
463
+ setLatestDelivery(result.delivery);
464
+ showNotice(describeDelivery(result.delivery), result.delivery.status === 'failed');
465
+ } catch (error) {
466
+ showNotice(String(error), true);
467
+ } finally {
468
+ setIsSaving(false);
469
+ }
470
+ };
471
+
472
+ const retryBuild = async () => {
473
+ if (!latestDelivery || latestDelivery.status !== 'failed') return;
474
+ setIsSaving(true);
475
+ try {
476
+ const result = await api<PublishSiteResponse>(`/publish/deliveries/${encodeURIComponent(latestDelivery.id)}/retry`, { method: 'POST' });
477
+ setLatestDelivery(result.delivery);
478
+ showNotice(describeDelivery(result.delivery), result.delivery.status === 'failed');
479
+ } catch (error) {
480
+ showNotice(String(error), true);
481
+ } finally {
482
+ setIsSaving(false);
483
+ }
484
+ };
485
+
486
+ const loadRevisions = async () => {
487
+ if (!current?.id) return;
488
+ try {
489
+ setRevisions(await api<Revision[]>(`/documents/${encodeURIComponent(current.id)}/revisions`, {}, current.locale));
490
+ setIsRevisionOpen(true);
491
+ } catch (error) {
492
+ showNotice(String(error), true);
493
+ }
494
+ };
495
+
496
+ const toggleRevisionHistory = async () => {
497
+ if (isRevisionOpen) {
498
+ setIsRevisionOpen(false);
499
+ return;
500
+ }
501
+ await loadRevisions();
502
+ };
503
+
504
+ const restore = async (revision: Revision) => {
505
+ if (!current?.id) return;
506
+ try {
507
+ const restored = await api<DocumentRecord>(`/documents/${encodeURIComponent(current.id)}/revisions/${encodeURIComponent(revision.id)}/restore`, {
508
+ method: 'POST', body: JSON.stringify({ version: current.version }),
509
+ }, current.locale);
510
+ setCurrent(restored);
511
+ setFields(documentFields(restored));
512
+ setEditorDocument(editor, restored.contentJson);
513
+ setIsDirty(false);
514
+ await Promise.all([refreshDocuments(), refreshTree(), refreshDeliveries()]);
515
+ showNotice(`Revision ${revision.revision} restored as a draft.`);
516
+ } catch (error) {
517
+ showNotice(String(error), true);
518
+ }
519
+ };
520
+
521
+ const remove = async () => {
522
+ if (!current?.id || !window.confirm(`Delete “${current.title}”?`)) return;
523
+ try {
524
+ await api<void>(`/documents/${encodeURIComponent(current.id)}`, {
525
+ method: 'DELETE', body: JSON.stringify({ version: current.version }),
526
+ }, current.locale);
527
+ setCurrent(null);
528
+ setFields(documentFields(emptyDocument));
529
+ setRevisions([]);
530
+ setEditorDocument(editor, emptyContent);
531
+ setIsDirty(false);
532
+ await Promise.all([refreshDocuments(), refreshTree(), refreshDeliveries()]);
533
+ showNotice('Document deleted.');
534
+ } catch (error) {
535
+ showNotice(String(error), true);
536
+ }
537
+ };
538
+
539
+ const upload = async (file: File | undefined) => {
540
+ if (!file) return undefined;
541
+ try {
542
+ const form = new FormData();
543
+ form.set('file', file);
544
+ const uploaded = await api<Media>('/media', { method: 'POST', body: form });
545
+ await refreshMedia();
546
+ showNotice('Media uploaded. Select it to insert it.');
547
+ return uploaded;
548
+ } catch (error) {
549
+ showNotice(String(error), true);
550
+ return undefined;
551
+ }
552
+ };
553
+
554
+ const removeMedia = async (item: Media) => {
555
+ if (item.isUsed || !window.confirm(`Delete ${item.fileName}? This cannot be undone.`)) return;
556
+ try {
557
+ setIsSaving(true);
558
+ await api<void>(`/media/${item.id}`, { method: 'DELETE' });
559
+ await refreshMedia();
560
+ showNotice('Media deleted.');
561
+ } catch (error) {
562
+ showNotice(error instanceof Error ? error.message : String(error), true);
563
+ } finally {
564
+ setIsSaving(false);
565
+ }
566
+ };
567
+
568
+ const insertMedia = (item: Media) => {
569
+ if (!editor) return;
570
+ if (item.contentType.startsWith('image/')) {
571
+ editor.chain().focus().setImage({ src: item.url, alt: item.fileName }).run();
572
+ } else {
573
+ editor.chain().focus().insertContent({ type: 'video', attrs: { src: item.url, mediaId: item.id } }).run();
574
+ }
575
+ setIsDirty(true);
576
+ setIsMediaPickerOpen(false);
577
+ showNotice(`${item.fileName} inserted.`);
578
+ };
579
+
580
+ const breadcrumbItems = (folderId: string | null, leaf?: string) => {
581
+ const folders: { id: string; name: string }[] = [];
582
+ const seen = new Set<string>();
583
+ let cursor = folderId;
584
+ while (cursor && !seen.has(cursor)) {
585
+ seen.add(cursor);
586
+ const folder = treeItems.find((item) => item.id === `folder:${cursor}`);
587
+ if (!folder) break;
588
+ folders.unshift({ id: cursor, name: folder.name });
589
+ cursor = folder.parentId?.slice('folder:'.length) ?? null;
590
+ }
591
+ return leaf ? [...folders, { id: '', name: leaf }] : folders;
592
+ };
593
+ const renderBreadcrumb = (folderId: string | null, leaf?: string) => {
594
+ const items = breadcrumbItems(folderId, leaf);
595
+ return <nav className="cms-breadcrumb" aria-label="Document hierarchy">
596
+ <button className="cms-breadcrumb-root" type="button" onClick={selectNavigationRoot}>Documents</button>
597
+ {items.map((item) => <span className="cms-breadcrumb-item" key={`${item.id}:${item.name}`}><ChevronRightIcon />{item.id ? <button type="button" onClick={() => selectFolder(item.id)}>{item.name}</button> : <strong>{item.name}</strong>}</span>)}
598
+ </nav>;
599
+ };
600
+
601
+ return <div className="cms-shell">
602
+ <header className="cms-topbar">
603
+ <a className="cms-brand" href="/admin/" aria-label={`${siteConfig.title} home`}><img className="cms-brand-logo" src={logoUrl} alt="" /></a>
604
+ <div className="cms-topbar-controls">
605
+ <div className="cms-actions">
606
+ <span className={`cms-notice ${noticeIsError ? 'is-error' : ''}`} role="status">{notice}</span>
607
+ {(latestDelivery?.status === 'failed' || (latestDelivery?.status === 'pending' && (!latestDelivery.nextRetryAt || latestDelivery.nextRetryAt <= Date.now()))) && <button className="cms-button" type="button" disabled={isSaving} onClick={() => void retryBuild()}>Retry build</button>}
608
+ {current?.id ? <button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void publish()}>Publish & rebuild</button> : <button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void rebuildPublishedSite()}>Rebuild public site</button>}
609
+ </div>
610
+ <button className="cms-theme-trigger" type="button" onClick={() => setTheme(displayedTheme === 'dark' ? 'light' : 'dark')} aria-label={`Switch to ${displayedTheme === 'dark' ? 'light' : 'dark'} mode`} title={`Switch to ${displayedTheme === 'dark' ? 'light' : 'dark'} mode`}>
611
+ {displayedTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
612
+ </button>
613
+ </div>
614
+ </header>
615
+
616
+ <div className="cms-workspace">
617
+ <aside className="cms-sidebar">
618
+ <div className="cms-sidebar-heading"><span>Documents</span><span className="cms-count">{documents.length}</span></div>
619
+ <label className="cms-search"><span className="sr-only">Search documents</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search documents" /></label>
620
+ <div className="cms-navigation-actions"><button className="cms-new-document" type="button" onClick={() => startNewDocument()}>New page</button><button className="cms-new-folder" type="button" onClick={() => openNewFolder()}>New folder</button></div>
621
+ <NavigationTree key={treeItems.map((item) => item.id).join(':')} items={treeItems.filter((item) => item.name.toLowerCase().includes(search.trim().toLowerCase()))} selectedDocumentId={current?.id} selectedFolderId={selectedFolderId} onSelectDocument={selectTreeDocument} onSelectFolder={selectFolder} onChangeChildren={replaceTreeChildren} onTreeChanged={treeChanged} canReorder={!search.trim()} />
622
+ </aside>
623
+
624
+ <main className="cms-main">
625
+ {selectedFolder ? <section className="cms-folder-panel" aria-label="Folder settings">{renderBreadcrumb(selectedFolderId)}<header><div><p>Folder</p><h1>{selectedFolder.name}</h1><span>Pages and nested folders inherit this location.</span></div><div className="cms-folder-panel-actions"><button className="cms-button cms-button-primary" type="button" onClick={() => startNewDocument(selectedFolderId)}>New page</button><button className="cms-button" type="button" onClick={() => openNewFolder(selectedFolderId)}>New folder</button></div></header><div className="cms-folder-fields"><label className="cms-meta-field"><span>Name</span><input value={folderName} onChange={(event) => { setFolderName(event.target.value); setFolderSlug((value) => value || slugify(event.target.value)); }} /></label><label className="cms-meta-field"><span>URL segment</span><input value={folderSlug} onChange={(event) => setFolderSlug(slugify(event.target.value))} /></label></div><footer><button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void saveFolder()}>{isSaving ? 'Saving…' : 'Save folder'}</button><button className="cms-button cms-button-danger" type="button" disabled={isSaving} onClick={() => void deleteFolder()}>Delete folder</button></footer></section> : missingDocumentId ? <section className="cms-empty-state"><span className="cms-empty-icon">文</span><h1>Translation not created</h1><label className="cms-content-locale"><span>Language</span><select value={locale} onChange={(event) => selectMissingDocumentLocale(event.target.value as SupportedLocale)}>{supportedLocales.map((item) => <option value={item} key={item}>{localeLabel(item)}</option>)}</select></label><p>This page has no {locale} translation.{missingTranslationSource ? ` Create a draft by copying the ${missingTranslationSource} version.` : ''}</p>{missingTranslationSource && <button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void createDocumentTranslation()}>Create {locale} translation</button>}</section> : !current ? <section className="cms-empty-state"><span className="cms-empty-icon">✦</span><h1>Start a document</h1><p>Create a page or folder, then organize it in the navigation tree.</p><div className="cms-empty-actions"><button className="cms-button cms-button-primary" type="button" onClick={() => startNewDocument()}>New page</button><button className="cms-button" type="button" onClick={() => openNewFolder()}>New folder</button></div></section> : <>
626
+ {renderBreadcrumb(fields.folderId, fields.title || fields.slug || 'Untitled document')}
627
+ <section className="cms-document-actions" aria-label="Document actions">
628
+ <div className="cms-document-state">
629
+ <span className={`cms-status cms-status-${current.status}`}>{isDirty ? 'Unsaved changes' : current.status}</span>
630
+ <span>{isDirty ? 'Draft changes are not saved.' : 'Saved draft.'}</span>
631
+ </div>
632
+ <div className="cms-document-action-buttons">
633
+ {current.id && <button className="cms-button" type="button" disabled={isSaving} onClick={preview}>Preview draft</button>}
634
+ <button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void save()}>{isSaving ? 'Saving…' : 'Save draft'}</button>
635
+ {current.id && <button className={`cms-history-button ${isRevisionOpen ? 'is-active' : ''}`} type="button" onClick={() => void toggleRevisionHistory()} aria-expanded={isRevisionOpen}><HistoryIcon />History</button>}
636
+ </div>
637
+ </section>
638
+ <section className="cms-editor-surface" aria-label="Document editor">
639
+ <div className="cms-document-fields">
640
+ <label className="cms-meta-field">
641
+ <span>URL segment</span>
642
+ <input className="cms-slug-input" value={fields.slug} onChange={(event) => updateSlug(event.target.value)} onBlur={() => updateField('slug', slugify(fields.slug))} inputMode="url" autoCapitalize="none" autoCorrect="off" spellCheck={false} maxLength={120} aria-invalid={Boolean(fields.slug) && !validSlug.test(fields.slug)} placeholder="getting-started" />
643
+ <small className="cms-field-help">Required. Lowercase letters, numbers, and hyphens. English titles suggest a value while this field is empty.</small>
644
+ </label>
645
+ {current.id && <label className="cms-content-locale"><span>Language</span><select value={current.locale} onChange={(event) => selectDocumentLocale(event.target.value as SupportedLocale)}>{supportedLocales.map((item) => <option value={item} key={item}>{localeLabel(item)}</option>)}</select></label>}
646
+ <label className="cms-meta-field">
647
+ <span>Title</span>
648
+ <input className="cms-title-input" value={fields.title} onChange={(event) => updateTitle(event.target.value)} placeholder="Untitled document" />
649
+ </label>
650
+ <label className="cms-meta-field">
651
+ <span>Description</span>
652
+ <textarea className="cms-description-input" value={fields.description} onChange={(event) => updateField('description', event.target.value)} placeholder="Add a concise summary for your readers" rows={2} />
653
+ </label>
654
+ </div>
655
+ <section className="cms-editor-field" aria-label="Content">
656
+ <header className="cms-editor-field-header"><span>Content</span></header>
657
+ <div className="cms-simple-editor"><SimpleEditor content={emptyContent} extensions={documentExtensions} onEditorReady={setEditor} onUpdate={() => setIsDirty(true)} uploadImage={async (file) => {
658
+ const uploaded = await upload(file);
659
+ if (!uploaded) throw new Error('Image upload failed');
660
+ return uploaded.url;
661
+ }} /></div>
662
+ </section>
663
+ {current.id && <section className="cms-danger-zone" aria-label="Danger zone"><div><h2>Delete page</h2><p>Permanently remove this page and its translations.</p></div><button className="cms-button cms-button-danger cms-button-danger-quiet" type="button" disabled={isSaving} onClick={() => void remove()}>Delete page</button></section>}
664
+ </section>
665
+ </>}
666
+ </main>
667
+ </div>
668
+
669
+ {isRevisionOpen && <div className="cms-revision-backdrop" role="presentation" onMouseDown={() => setIsRevisionOpen(false)}><aside className="cms-revision-drawer" role="dialog" aria-modal="true" aria-labelledby="revision-history-title" onMouseDown={(event) => event.stopPropagation()}><header><div><h2 id="revision-history-title">Revision history</h2><p>Restore adds the selected revision as a new draft.</p></div><button type="button" className="cms-close-settings" onClick={() => setIsRevisionOpen(false)} aria-label="Close revision history">×</button></header>{revisions.length === 0 ? <p className="cms-revisions-empty">No saved revisions yet.</p> : <div className="cms-revisions">{revisions.map((revision) => <div className="cms-revision" key={revision.id}><span><strong>Revision {revision.revision}</strong><time>{new Date(revision.createdAt).toLocaleString()}</time></span><button type="button" onClick={() => void restore(revision)}>Restore</button></div>)}</div>}</aside></div>}
670
+
671
+ {isMediaPickerOpen && <div className="cms-media-backdrop" role="presentation" onMouseDown={() => setIsMediaPickerOpen(false)}>
672
+ <section className="cms-media-dialog" role="dialog" aria-modal="true" aria-labelledby="media-library-title" onMouseDown={(event) => event.stopPropagation()}>
673
+ <header><div><h1 id="media-library-title">Media library</h1><p>Select an image or video to add it at the cursor.</p></div><button type="button" className="cms-close-settings" onClick={() => setIsMediaPickerOpen(false)} aria-label="Close media library">×</button></header>
674
+ <div className="cms-media-dialog-actions"><label className="cms-upload">Upload media<input type="file" accept={mediaTypes} onChange={(event) => void upload(event.currentTarget.files?.[0])} /></label></div>
675
+ <div className="cms-media-grid">{media.length === 0 ? <p className="cms-media-empty">No media uploaded yet.</p> : media.map((item) => <article key={item.id} className="cms-media-card"><button type="button" className="cms-media-insert" onClick={() => insertMedia(item)}><span className="cms-media-preview">{item.contentType.startsWith('image/') ? <img src={item.url} alt="" /> : <video src={item.url} muted preload="metadata" />}</span><strong>{item.fileName}</strong><span>{item.contentType.startsWith('image/') ? 'Image' : 'Video'}</span></button><footer><span className={item.isUsed ? 'cms-media-usage cms-media-usage-used' : 'cms-media-usage'}>{item.isUsed ? 'Used' : 'Unused'}</span>{!item.isUsed && <button type="button" className="cms-media-delete" disabled={isSaving} onClick={() => void removeMedia(item)}>Delete</button>}</footer></article>)}</div>
676
+ </section>
677
+ </div>}
678
+ {isFolderDialogOpen && <div className="cms-media-backdrop" role="presentation" onMouseDown={() => setIsFolderDialogOpen(false)}><section className="cms-folder-dialog" role="dialog" aria-modal="true" aria-labelledby="new-folder-title" onMouseDown={(event) => event.stopPropagation()}><header><div><h1 id="new-folder-title">New folder</h1><p>{selectedFolder ? `Create inside ${selectedFolder.name}.` : 'Create at the top level.'}</p></div><button type="button" className="cms-close-settings" onClick={() => setIsFolderDialogOpen(false)} aria-label="Close">×</button></header><label className="cms-meta-field"><span>Name</span><input autoFocus value={folderDraft.name} onChange={(event) => setFolderDraft((draft) => ({ ...draft, name: event.target.value, slug: draft.slug || slugify(event.target.value) }))} placeholder="Getting started" /></label><label className="cms-meta-field"><span>URL segment</span><input value={folderDraft.slug} onChange={(event) => setFolderDraft((draft) => ({ ...draft, slug: slugify(event.target.value) }))} placeholder="getting-started" /></label><footer><button className="cms-button" type="button" onClick={() => setIsFolderDialogOpen(false)}>Cancel</button><button className="cms-button cms-button-primary" type="button" disabled={isSaving} onClick={() => void createFolder()}>Create folder</button></footer></section></div>}
679
+ </div>;
680
+ }
681
+
682
+ const root = document.querySelector('#admin-root');
683
+ if (!root) throw new Error('Admin root is missing.');
684
+ createRoot(root).render(<App />);