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,89 @@
1
+ {
2
+ "name": "cloudflare-starlight-cms",
3
+ "description": "A self-hosted documentation CMS for Astro Starlight, built on Cloudflare Workers, D1, R2, and Access.",
4
+ "private": true,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=22.19.0"
9
+ },
10
+ "scripts": {
11
+ "build": "astro build && node scripts/build-admin.mjs",
12
+ "build:empty": "CMS_INITIAL_EMPTY=1 npm run build",
13
+ "dev": "node scripts/dev.mjs",
14
+ "check": "CMS_INITIAL_EMPTY=1 astro check && tsc --noEmit && npm run check:admin && npm run check:linux-lock",
15
+ "check:admin": "tsc -p tsconfig.admin.json --noEmit",
16
+ "check:linux-lock": "node scripts/check-linux-bindings.mjs",
17
+ "test": "vitest run",
18
+ "migrate:local": "wrangler d1 migrations apply DB --local",
19
+ "migrate:remote": "wrangler d1 migrations apply DB --remote",
20
+ "deploy": "wrangler deploy",
21
+ "typegen": "wrangler types worker-configuration.d.ts --env-interface CloudflareBindings",
22
+ "dry-run": "wrangler deploy --dry-run"
23
+ },
24
+ "dependencies": {
25
+ "@astrojs/check": "0.9.10",
26
+ "@astrojs/starlight": "0.42.0",
27
+ "@floating-ui/react": "0.27.20",
28
+ "@headless-tree/core": "1.7.0",
29
+ "@headless-tree/react": "1.7.0",
30
+ "@radix-ui/react-dropdown-menu": "2.1.24",
31
+ "@radix-ui/react-popover": "1.1.23",
32
+ "@tiptap/core": "3.31.3",
33
+ "@tiptap/extension-find-and-replace": "3.31.3",
34
+ "@tiptap/extension-highlight": "3.31.3",
35
+ "@tiptap/extension-horizontal-rule": "3.31.3",
36
+ "@tiptap/extension-image": "3.31.3",
37
+ "@tiptap/extension-link": "3.31.3",
38
+ "@tiptap/extension-list": "3.31.3",
39
+ "@tiptap/extension-subscript": "3.31.3",
40
+ "@tiptap/extension-superscript": "3.31.3",
41
+ "@tiptap/extension-table": "3.31.3",
42
+ "@tiptap/extension-table-cell": "3.31.3",
43
+ "@tiptap/extension-table-header": "3.31.3",
44
+ "@tiptap/extension-table-row": "3.31.3",
45
+ "@tiptap/extension-text-align": "3.31.3",
46
+ "@tiptap/extension-typography": "3.31.3",
47
+ "@tiptap/extensions": "3.31.3",
48
+ "@tiptap/pm": "3.31.3",
49
+ "@tiptap/react": "3.31.3",
50
+ "@tiptap/starter-kit": "3.31.3",
51
+ "astro": "7.3.2",
52
+ "clsx": "2.1.1",
53
+ "drizzle-orm": "0.45.2",
54
+ "github-slugger": "2.0.0",
55
+ "hono": "4.13.7",
56
+ "lodash.throttle": "4.1.1",
57
+ "react": "18.3.1",
58
+ "react-dom": "18.3.1",
59
+ "react-hotkeys-hook": "5.3.3",
60
+ "zod": "4.6.4"
61
+ },
62
+ "devDependencies": {
63
+ "@base-ui/react": "1.8.0",
64
+ "@types/lodash.throttle": "4.1.9",
65
+ "@types/node": "22.20.1",
66
+ "@types/react": "18.3.12",
67
+ "@types/react-dom": "18.3.1",
68
+ "class-variance-authority": "0.7.1",
69
+ "esbuild": "0.28.2",
70
+ "miniflare": "5.20260908.0-alpha",
71
+ "sass-embedded": "1.104.0",
72
+ "typescript": "5.9.3",
73
+ "vitest": "5.0.0",
74
+ "wrangler": "4.130.0"
75
+ },
76
+ "optionalDependencies": {
77
+ "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0",
78
+ "@bruits/satteri-linux-x64-gnu": "0.10.5",
79
+ "@esbuild/linux-x64": "0.28.1",
80
+ "@img/sharp-libvips-linux-x64": "1.3.1",
81
+ "@img/sharp-linux-x64": "0.35.2",
82
+ "@pagefind/linux-x64": "1.5.2",
83
+ "@rolldown/binding-linux-x64-gnu": "1.2.7",
84
+ "lightningcss-linux-x64-gnu": "1.33.0"
85
+ },
86
+ "cloudflareStarlightCms": {
87
+ "templateVersion": "0.9.0"
88
+ }
89
+ }
@@ -0,0 +1,38 @@
1
+ import { build, context } from 'esbuild';
2
+ import { compile } from 'sass-embedded';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { copyFile, mkdir } from 'node:fs/promises';
5
+
6
+ const assetDirectory = process.env.ADMIN_ASSETS_DIR ?? 'dist';
7
+ await mkdir(`${assetDirectory}/admin`, { recursive: true });
8
+ await copyFile('src/assets/favicon.svg', `${assetDirectory}/favicon.svg`);
9
+
10
+ const sassPlugin = {
11
+ name: 'sass',
12
+ setup(buildContext) {
13
+ buildContext.onLoad({ filter: /\.scss$/ }, (args) => {
14
+ const result = compile(args.path, { style: 'compressed', loadPaths: ['src/admin'] });
15
+ return { contents: result.css, loader: 'css', watchFiles: result.loadedUrls.filter((url) => url.protocol === 'file:').map(fileURLToPath) };
16
+ });
17
+ },
18
+ };
19
+
20
+ const options = {
21
+ entryPoints: ['src/admin/client.tsx'],
22
+ bundle: true,
23
+ format: 'esm',
24
+ jsx: 'automatic',
25
+ loader: { '.svg': 'dataurl' },
26
+ outfile: `${assetDirectory}/admin/app.js`,
27
+ alias: { '@': './src/admin' },
28
+ plugins: [sassPlugin],
29
+ };
30
+ if (process.argv.includes('--watch')) {
31
+ const buildContext = await context(options);
32
+ await buildContext.watch();
33
+ const stop = async () => { await buildContext.dispose(); };
34
+ process.once('SIGINT', stop);
35
+ process.once('SIGTERM', stop);
36
+ } else {
37
+ await build(options);
38
+ }
@@ -0,0 +1,18 @@
1
+ import { readFile } from 'node:fs/promises';
2
+
3
+ const lock = JSON.parse(await readFile(new URL('../package-lock.json', import.meta.url), 'utf8'));
4
+ const packages = lock.packages ?? {};
5
+ const linuxX64 = /linux-x64(?:-(?:gnu|glibc))?$/;
6
+ const missing = new Set();
7
+
8
+ for (const pkg of Object.values(packages)) {
9
+ for (const name of Object.keys(pkg.optionalDependencies ?? {})) {
10
+ if (linuxX64.test(name) && !packages[`node_modules/${name}`]) missing.add(name);
11
+ }
12
+ }
13
+
14
+ if (missing.size > 0) {
15
+ throw new Error(`package-lock.json is missing Linux x64 native bindings: ${[...missing].sort().join(', ')}. Add the matching package as an exact optionalDependency and regenerate the lockfile.`);
16
+ }
17
+
18
+ console.log('Linux x64 native bindings are present in package-lock.json.');
@@ -0,0 +1,94 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const cmsPort = Number(process.env.CMS_PORT ?? 8787);
4
+ const docsPort = Number(process.env.DOCS_PORT ?? 4321);
5
+ const cmsUrl = `http://127.0.0.1:${cmsPort}/admin/export/snapshot`;
6
+ const children = new Set();
7
+ let stopping = false;
8
+ let timer;
9
+
10
+ function stop() {
11
+ stopping = true;
12
+ clearTimeout(timer);
13
+ for (const child of children) child.kill('SIGTERM');
14
+ }
15
+ function start(command, args, env = process.env) {
16
+ const child = spawn(command, args, { stdio: 'inherit', env });
17
+ children.add(child);
18
+ child.once('exit', () => children.delete(child));
19
+ return child;
20
+ }
21
+ function run(command, args, env) {
22
+ return new Promise((resolve, reject) => {
23
+ const child = start(command, args, env);
24
+ child.once('error', reject);
25
+ child.once('exit', (code) => code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)));
26
+ });
27
+ }
28
+ function server(command, args, env) {
29
+ const child = start(command, args, env);
30
+ const failed = (message) => {
31
+ if (stopping) return;
32
+ console.error(message);
33
+ process.exitCode = 1;
34
+ stop();
35
+ };
36
+ child.once('error', (error) => failed(error.message));
37
+ child.once('exit', () => failed(`${command} stopped unexpectedly`));
38
+ }
39
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
40
+ async function snapshot() {
41
+ const response = await fetch(cmsUrl, { signal: AbortSignal.timeout(5000) });
42
+ if (!response.ok) throw new Error(`CMS export returned ${response.status}`);
43
+ return response.text();
44
+ }
45
+ async function ready() {
46
+ for (let attempt = 0; attempt < 60 && !stopping; attempt++) {
47
+ try { return await snapshot(); } catch { await wait(500); }
48
+ }
49
+ throw new Error('Local CMS did not become ready');
50
+ }
51
+ const cli = (name) => `node_modules/${name}/bin/${name === 'wrangler' ? 'wrangler.js' : 'astro.mjs'}`;
52
+ async function build() {
53
+ await run(process.execPath, [cli('astro'), 'build'], { ...process.env, CMS_EXPORT_URL: cmsUrl });
54
+ // Match production: both public and Admin assets are served from the configured
55
+ // Static Assets directory. Astro clears dist, so recreate the Admin bundle after it.
56
+ await run(process.execPath, ['scripts/build-admin.mjs']);
57
+ }
58
+ process.once('SIGINT', stop);
59
+ process.once('SIGTERM', stop);
60
+
61
+ try {
62
+ if (![cmsPort, docsPort].every((port) => Number.isInteger(port) && port > 0 && port < 65536) || cmsPort === docsPort) throw new Error('CMS_PORT and DOCS_PORT must be distinct valid ports');
63
+ // Local development always uses Miniflare state. Applying migrations is idempotent
64
+ // and avoids a separate first-run command without touching a remote D1 database.
65
+ await run(process.execPath, [cli('wrangler'), 'd1', 'migrations', 'apply', 'DB', '--local'], { ...process.env, CI: '1' });
66
+ await run(process.execPath, ['scripts/build-admin.mjs']);
67
+ server(process.execPath, ['scripts/build-admin.mjs', '--watch']);
68
+ server(process.execPath, [cli('wrangler'), 'dev', '--local', '--log-level', 'warn', '--port', String(cmsPort), '--var', 'LOCAL_DEV_BUILD:true']);
69
+ let completed = await ready();
70
+ if (stopping) throw new Error('Development server stopped');
71
+ await build();
72
+ if (stopping) throw new Error('Development server stopped');
73
+ server(process.execPath, [cli('astro'), 'preview', '--ignore-lock', '--host', '127.0.0.1', '--port', String(docsPort)], { ...process.env, ASTRO_PREVIEW_BACKGROUND: 'false' });
74
+ console.log(`Admin: http://127.0.0.1:${cmsPort}/admin/\nPublished docs: http://127.0.0.1:${docsPort}/`);
75
+ let failed;
76
+ async function poll() {
77
+ try {
78
+ const next = await snapshot();
79
+ if (next !== completed && next !== failed && !stopping) {
80
+ console.log('Published content changed. Building docs…');
81
+ try { await build(); completed = next; failed = undefined; console.log('Docs rebuilt. Refresh the public page.'); }
82
+ catch (error) { failed = next; throw error; }
83
+ }
84
+ } catch (error) {
85
+ if (!stopping) console.error('Local build/export failed. Fix the error and restart dev, or publish a new change.', error.message);
86
+ } finally {
87
+ if (!stopping) timer = setTimeout(poll, 1000);
88
+ }
89
+ }
90
+ if (!stopping) timer = setTimeout(poll, 1000);
91
+ } catch (error) {
92
+ if (!stopping) { console.error(error.message); process.exitCode = 1; }
93
+ stop();
94
+ }
@@ -0,0 +1,182 @@
1
+ :root { color: var(--cms-text); background: var(--cms-bg); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
2
+ * { box-sizing: border-box; }
3
+ html, body, #admin-root { min-width: 320px; min-height: 100%; margin: 0; background: var(--cms-bg); }
4
+ button, input, textarea { font: inherit; }
5
+ button { cursor: pointer; }
6
+ button:disabled { cursor: not-allowed; opacity: .55; }
7
+ .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
8
+
9
+ .cms-shell { min-height: 100vh; color: var(--cms-text); background: var(--cms-bg); }
10
+ .cms-topbar { position: sticky; top: 0; z-index: 30; display: flex; min-height: 64px; align-items: center; justify-content: space-between; gap: 1rem; padding: 0 1.5rem; background: color-mix(in srgb, var(--cms-surface) 94%, transparent); border-bottom: 1px solid var(--cms-border); backdrop-filter: blur(16px); }
11
+ .cms-brand { display: inline-flex; align-items: center; gap: .65rem; color: var(--cms-text); font-size: .98rem; font-weight: 750; letter-spacing: -.02em; text-decoration: none; }
12
+ .cms-brand-logo { display: block; width: auto; height: 2.25rem; max-width: min(15rem, 52vw); }
13
+ .cms-brand-mark, .cms-empty-icon { display: grid; width: 30px; height: 30px; place-items: center; color: var(--cms-accent-foreground); background: var(--cms-accent); border-radius: 9px; box-shadow: 0 4px 10px var(--cms-focus-ring); }
14
+ .cms-topbar-controls, .cms-actions { display: flex; align-items: center; justify-content: flex-end; }
15
+ .cms-topbar-controls { gap: .7rem; }
16
+ .cms-actions { min-width: 0; gap: .5rem; }
17
+ .cms-theme-trigger { display: grid; width: 32px; height: 32px; place-items: center; padding: 0; color: var(--cms-text-muted); background: transparent; border: 1px solid var(--cms-border); border-radius: 7px; }
18
+ .cms-theme-trigger:hover { color: var(--cms-text); background: var(--cms-hover); }
19
+ .cms-theme-trigger svg { width: 1rem; height: 1rem; }
20
+ .cms-status { padding: .25rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 700; line-height: 1.2; text-transform: capitalize; }
21
+ .cms-status-draft, .cms-status-unsaved, .cms-sidebar .tree .treeitem-status.draft, .cms-sidebar .tree .treeitem-status.changes { color: var(--cms-warning-text); background: var(--cms-warning-subtle); }
22
+ .cms-status-published { color: var(--cms-success-text); background: var(--cms-success-subtle); }
23
+ .cms-notice { max-width: 19rem; overflow: hidden; color: var(--cms-text-muted); font-size: .78rem; text-overflow: ellipsis; white-space: nowrap; }
24
+ .cms-notice.is-error { color: var(--cms-danger-text); }
25
+ .cms-button, .cms-history-button { min-height: 34px; padding: .45rem .72rem; color: var(--cms-text-muted); background: var(--cms-elevated); border: 1px solid var(--cms-border); border-radius: 7px; box-shadow: 0 1px 2px var(--cms-shadow); font-size: .8rem; font-weight: 650; }
26
+ .cms-button:hover:not(:disabled), .cms-history-button:hover { color: var(--cms-text); background: var(--cms-hover); }
27
+ .cms-button-primary { color: var(--cms-accent-foreground); background: var(--cms-accent); border-color: var(--cms-accent); }
28
+ .cms-button-primary:hover:not(:disabled) { color: var(--cms-accent-foreground); background: var(--cms-accent-hover); border-color: var(--cms-accent-hover); }
29
+ .cms-button-quiet { background: transparent; box-shadow: none; }
30
+ .cms-button-danger, .cms-button-danger-quiet { color: var(--cms-danger-text); background: var(--cms-danger-subtle); border-color: var(--cms-danger); }
31
+ .cms-button-danger-quiet { background: transparent; border-color: transparent; box-shadow: none; }
32
+ .cms-button-danger:hover:not(:disabled), .cms-button-danger-quiet:hover:not(:disabled) { color: var(--cms-danger-text); background: var(--cms-danger-subtle); border-color: var(--cms-danger); }
33
+
34
+ .cms-workspace { display: grid; grid-template-columns: 250px minmax(0, 1fr); min-height: calc(100vh - 64px); }
35
+ .cms-sidebar { position: sticky; top: 64px; display: flex; height: calc(100vh - 64px); flex-direction: column; gap: .85rem; padding: 1.1rem .8rem; overflow: hidden; background: var(--cms-surface); border-right: 1px solid var(--cms-border); }
36
+ .cms-sidebar-heading { display: flex; align-items: center; justify-content: space-between; padding: 0 .35rem; color: var(--cms-text-muted); font-size: .82rem; font-weight: 750; }
37
+ .cms-count { display: grid; min-width: 22px; height: 22px; place-items: center; color: var(--cms-accent); background: var(--cms-accent-subtle); border-radius: 999px; font-size: .72rem; }
38
+ .cms-new-document, .cms-upload { color: var(--cms-accent-foreground); background: var(--cms-accent); border: 1px solid var(--cms-accent); border-radius: 7px; font-weight: 700; }
39
+ .cms-new-document { width: 100%; padding: .62rem .72rem; box-shadow: 0 1px 2px var(--cms-shadow); font-size: .82rem; text-align: left; }
40
+ .cms-new-document:hover { background: var(--cms-accent-hover); border-color: var(--cms-accent-hover); }
41
+ .cms-new-document::before { margin-right: .45rem; content: "+"; font-size: 1rem; }
42
+ .cms-navigation-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .45rem; }
43
+ .cms-new-folder { padding: .62rem .72rem; color: var(--cms-text-muted); background: var(--cms-elevated); border: 1px solid var(--cms-border); border-radius: 7px; font-size: .78rem; font-weight: 700; }
44
+ .cms-new-folder:hover { color: var(--cms-text); background: var(--cms-hover); }
45
+ .cms-search input, .cms-title-input, .cms-description-input, .cms-slug-input, .cms-folder-fields input, .cms-folder-dialog input { width: 100%; color: var(--cms-text); background: var(--cms-input-bg); border: 1px solid var(--cms-border); outline: 0; }
46
+ .cms-search input { padding: .56rem .65rem; border-radius: 7px; font-size: .78rem; }
47
+ .cms-search input:focus, .cms-title-input:focus, .cms-description-input:focus, .cms-slug-input:focus, .cms-folder-fields input:focus, .cms-folder-dialog input:focus, .cms-content-locale select:focus { border-color: var(--cms-accent); box-shadow: 0 0 0 3px var(--cms-focus-ring); }
48
+ .cms-document-list { display: grid; gap: .95rem; overflow: auto; padding: .15rem .15rem 1.5rem; }
49
+ .cms-document-group { display: grid; gap: .15rem; }
50
+ .cms-document-group h2 { margin: 0 0 .22rem; padding: 0 .35rem; color: var(--cms-text-muted); font-size: .68rem; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; }
51
+ .cms-document-item { display: grid; gap: .15rem; width: 100%; padding: .55rem .6rem; color: var(--cms-text-muted); background: transparent; border: 1px solid transparent; border-radius: 7px; text-align: left; }
52
+ .cms-document-item:hover { background: var(--cms-hover); }
53
+ .cms-document-item.is-active { color: var(--cms-accent); background: var(--cms-accent-subtle); border-color: var(--cms-accent); }
54
+ .cms-document-item strong { overflow: hidden; color: inherit; font-size: .8rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
55
+ .cms-document-item span, .cms-empty-search { color: var(--cms-text-subtle); font-size: .68rem; }
56
+ .cms-document-item.is-active span { color: var(--cms-accent); }
57
+ .cms-empty-search { margin: 0; padding: .35rem; font-size: .78rem; }
58
+
59
+ /* Adapted from Headless Tree's Comprehensive DnD story (MIT). */
60
+ .navigation-tree-scroll { flex: 1 1 0; min-height: 12rem; overflow: auto; padding: 8px; }
61
+ .cms-sidebar .tree { position: relative; display: block; min-height: 100%; max-width: none; }
62
+ .cms-sidebar .tree button[role="treeitem"] { display: flex; width: 100%; padding: 0 0 2px; background: transparent; border: none; }
63
+ .cms-sidebar .tree .treeitem { position: relative; display: flex; width: 100%; align-items: center; gap: .4rem; padding: 6px 10px; color: var(--cms-text-muted); background-color: var(--cms-surface); border-radius: 8px; font-size: .8rem; text-align: left; transition: background-color .2s ease; cursor: pointer; }
64
+ .cms-sidebar .tree .treeitem-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
65
+ .cms-sidebar .tree .treeitem-status { margin-left: auto; padding: .12rem .34rem; border-radius: 999px; font-size: .6rem; font-weight: 750; line-height: 1.2; }
66
+ .cms-sidebar .tree .treeitem:hover { background-color: var(--cms-hover); }
67
+ .cms-sidebar .tree button[role="treeitem"]:focus { outline: none; }
68
+ .cms-sidebar .tree .treeitem.selected, .cms-sidebar .tree .treeitem.drop { color: var(--cms-accent); background-color: var(--cms-accent-subtle); }
69
+ .cms-sidebar .tree button:focus-visible .treeitem.focused { outline: 2px solid var(--cms-accent); outline-offset: 1px; }
70
+ .cms-sidebar .tree .treeitem.folder::before { display: inline-block; z-index: 1; width: 10px; margin-right: 4px; content: "›"; color: var(--cms-text-subtle); font-size: .95rem; transition: transform .1s ease-in-out; }
71
+ .cms-sidebar .tree .treeitem.folder.expanded::before { transform: rotate(90deg); }
72
+ .cms-sidebar .tree .treeitem:not(.folder) { padding-left: 24px; }
73
+ .cms-sidebar .tree .treeitem.selected::after { position: absolute; top: 5px; left: -2px; width: 4px; height: 16px; content: " "; background-color: var(--cms-accent); border-radius: 99px; }
74
+ .cms-sidebar .tree .dragline { height: 2px; margin-top: -1px; background-color: var(--cms-accent); }
75
+ .cms-sidebar .tree .dragline::before { box-sizing: content-box; position: absolute; top: -3px; left: 0; width: 4px; height: 4px; content: ""; background: var(--cms-surface); border: 2px solid var(--cms-accent); border-radius: 99px; }
76
+
77
+ .cms-main { min-width: 0; padding: 1.35rem clamp(1rem, 3vw, 3.5rem) 3rem; }
78
+ .cms-breadcrumb { display: flex; align-items: center; flex-wrap: wrap; gap: .35rem; margin: 0 0 1rem; color: var(--cms-text-muted); font-size: .76rem; }
79
+ .cms-breadcrumb-item { display: inline-flex; align-items: center; min-width: 0; gap: .35rem; }
80
+ .cms-breadcrumb-item svg { flex: 0 0 auto; width: .85rem; height: .85rem; color: var(--cms-text-subtle); }
81
+ .cms-breadcrumb button { padding: 0; color: var(--cms-text-muted); background: transparent; border: 0; font: inherit; }
82
+ .cms-breadcrumb button:hover { color: var(--cms-accent); text-decoration: underline; }
83
+ .cms-breadcrumb strong { overflow: hidden; color: var(--cms-text); font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
84
+ .cms-document-actions { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 0 0 1.1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--cms-border); }
85
+ .cms-document-state, .cms-document-action-buttons { display: flex; align-items: center; gap: .65rem; min-width: 0; }
86
+ .cms-document-state > span:not(.cms-status) { overflow: hidden; color: var(--cms-text-muted); font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; }
87
+ .cms-history-button { display: inline-flex; align-items: center; gap: .35rem; }
88
+ .cms-history-button svg { width: .9rem; height: .9rem; }
89
+ .cms-history-button.is-active { color: var(--cms-accent); background: var(--cms-accent-subtle); border-color: var(--cms-accent); box-shadow: 0 0 0 3px var(--cms-focus-ring); }
90
+ .cms-danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: 1.5rem; padding-top: 1.25rem; border-top: 1px solid var(--cms-danger); }
91
+ .cms-danger-zone h2 { margin: 0; color: var(--cms-danger-text); font-size: .8rem; }
92
+ .cms-danger-zone p { margin: .25rem 0 0; color: var(--cms-text-muted); font-size: .75rem; }
93
+ .cms-editor-surface { width: 100%; min-width: 0; overflow: visible; }
94
+ .cms-document-fields, .cms-folder-fields { display: grid; gap: .85rem; }
95
+ .cms-content-locale { display: flex; width: fit-content; align-items: center; gap: .5rem; color: var(--cms-text-muted); font-size: .72rem; font-weight: 700; }
96
+ .cms-content-locale select { height: 30px; padding: 0 .45rem; color: var(--cms-text); background: var(--cms-input-bg); border: 1px solid var(--cms-border); border-radius: 6px; font-size: .74rem; font-weight: 650; }
97
+ .cms-meta-field { display: grid; gap: .55rem; }
98
+ .cms-meta-field > span, .cms-editor-field-header > span { color: var(--cms-text-muted); font-size: .72rem; font-weight: 700; letter-spacing: .02em; }
99
+ .cms-title-input, .cms-description-input, .cms-slug-input { border-radius: 8px; box-shadow: 0 1px 2px var(--cms-shadow); }
100
+ .cms-title-input { padding: .85rem .95rem; font-size: clamp(1.45rem, 3vw, 2rem); font-weight: 750; line-height: 1.2; }
101
+ .cms-title-input::placeholder, .cms-description-input::placeholder { color: var(--cms-text-subtle); }
102
+ .cms-description-input { display: block; min-height: 5rem; padding: .75rem .95rem; resize: vertical; font: inherit; font-size: .95rem; line-height: 1.5; }
103
+ .cms-slug-input { padding: .65rem .8rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88rem; }
104
+ .cms-field-help { color: var(--cms-text-subtle); font-size: .72rem; line-height: 1.4; }
105
+ .cms-editor-field { display: grid; gap: .55rem; margin-top: 1.35rem; }
106
+ .cms-editor-field-header { display: flex; align-items: center; justify-content: space-between; }
107
+
108
+ /* Tiptap's official template supplies its editor UI. This wrapper only fits it into the CMS. */
109
+ .cms-simple-editor { overflow: hidden; background: var(--cms-surface); border: 1px solid var(--cms-border); border-radius: 10px; }
110
+ .cms-simple-editor .simple-editor-wrapper { position: relative; width: 100%; height: min(720px, calc(100vh - 220px)); min-height: 560px; overflow: auto; }
111
+ .cms-simple-editor .simple-editor-content { max-width: 54rem; }
112
+ .cms-simple-editor .simple-editor-search-and-replace { position: absolute; top: .5rem; right: .5rem; }
113
+ .cms-simple-editor .tiptap-toolbar { top: 0; }
114
+ .cms-simple-editor .tiptap.ProseMirror.simple-editor img, .cms-simple-editor .tiptap.ProseMirror.simple-editor video { display: block; max-width: 100%; height: auto; margin: 1.25rem 0; border-radius: 8px; }
115
+ .cms-simple-editor .tiptap.ProseMirror.simple-editor aside[data-callout] { margin: 1.25rem 0; padding: 1rem 1.1rem; background: var(--cms-accent-subtle); border-left: 3px solid var(--cms-accent); border-radius: 0 8px 8px 0; }
116
+ .cms-simple-editor .tiptap.ProseMirror.simple-editor ol[data-steps] { padding: 1rem 1rem 1rem 2.25rem; background: var(--cms-hover); border-radius: 8px; }
117
+ .cms-simple-editor .tiptap.ProseMirror.simple-editor section[data-tabs] { padding: 1rem; border: 1px solid var(--cms-border); border-radius: 8px; }
118
+ .cms-simple-editor .tiptap.ProseMirror.simple-editor section[data-tab] + section[data-tab] { margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--cms-border); }
119
+
120
+ .cms-close-settings { display: grid; width: 28px; height: 28px; place-items: center; padding: 0; color: var(--cms-text-muted); background: transparent; border: 0; border-radius: 5px; font-size: 1.2rem; }
121
+ .cms-close-settings:hover { background: var(--cms-hover); }
122
+ .cms-revision-backdrop { position: fixed; z-index: 100; inset: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 5rem 1.5rem 1.5rem; background: var(--cms-backdrop); }
123
+ .cms-revision-drawer { display: flex; flex-direction: column; gap: 1rem; width: min(25rem, 100%); max-height: min(34rem, calc(100vh - 6.5rem)); padding: 1.25rem; overflow: hidden; color: var(--cms-text); background: var(--cms-elevated); border: 1px solid var(--cms-border); border-radius: .75rem; box-shadow: -16px 0 36px var(--cms-shadow); }
124
+ .cms-revision-drawer > header, .cms-folder-panel > header, .cms-folder-dialog header, .cms-media-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
125
+ .cms-revision-drawer h2 { margin: 0; color: var(--cms-text); font-size: 1rem; }
126
+ .cms-revision-drawer header p, .cms-revisions-empty { margin: .3rem 0 0; color: var(--cms-text-muted); font-size: .78rem; line-height: 1.45; }
127
+ .cms-revisions { display: grid; gap: .45rem; }
128
+ .cms-revision-drawer .cms-revisions { overflow: auto; padding-right: .2rem; }
129
+ .cms-revisions > p { margin: 0; color: var(--cms-text-subtle); font-size: .75rem; }
130
+ .cms-revision { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .5rem; background: var(--cms-bg); border: 1px solid var(--cms-border); border-radius: 6px; }
131
+ .cms-revision span { display: grid; gap: .12rem; min-width: 0; color: var(--cms-text-muted); font-size: .7rem; }
132
+ .cms-revision time { overflow: hidden; color: var(--cms-text-subtle); font-size: .65rem; text-overflow: ellipsis; white-space: nowrap; }
133
+ .cms-revision button { padding: .25rem .35rem; color: var(--cms-accent); background: var(--cms-surface); border: 1px solid var(--cms-accent); border-radius: 5px; font-size: .68rem; font-weight: 650; }
134
+
135
+ .cms-empty-state { display: grid; justify-items: center; gap: .75rem; max-width: 440px; margin: 18vh auto; color: var(--cms-text-muted); text-align: center; }
136
+ .cms-empty-state .cms-empty-icon { width: 42px; height: 42px; font-size: 1.1rem; }
137
+ .cms-empty-state h1 { margin: .35rem 0 0; color: var(--cms-text); font-size: 1.35rem; }
138
+ .cms-empty-state p { margin: 0; font-size: .9rem; }
139
+ .cms-empty-actions, .cms-folder-panel-actions { display: flex; flex-wrap: wrap; gap: .55rem; }
140
+ .cms-folder-panel { display: grid; gap: 1.35rem; width: min(100%, 54rem); margin: 1.25rem auto; }
141
+ .cms-folder-panel header p { margin: 0 0 .35rem; color: var(--cms-text-muted); font-size: .72rem; font-weight: 700; text-transform: uppercase; }
142
+ .cms-folder-panel h1 { margin: 0; color: var(--cms-text); font-size: 1.75rem; letter-spacing: -.03em; }
143
+ .cms-folder-panel header span { display: block; margin-top: .45rem; color: var(--cms-text-muted); font-size: .86rem; }
144
+ .cms-folder-fields input, .cms-folder-dialog input { padding: .72rem .82rem; border-radius: 8px; }
145
+ .cms-folder-panel > footer, .cms-folder-dialog footer { display: flex; align-items: center; justify-content: space-between; gap: .6rem; padding-top: 1rem; border-top: 1px solid var(--cms-border); }
146
+ .cms-folder-dialog { display: grid; gap: 1rem; width: min(440px, 100%); padding: 1.25rem; color: var(--cms-text); background: var(--cms-elevated); border: 1px solid var(--cms-border); border-radius: 12px; box-shadow: 0 24px 48px var(--cms-shadow); }
147
+ .cms-folder-dialog h1, .cms-media-dialog h1 { margin: 0; color: var(--cms-text); font-size: 1rem; }
148
+ .cms-folder-dialog p, .cms-media-dialog p { margin: .3rem 0 0; color: var(--cms-text-muted); font-size: .78rem; }
149
+ .cms-media-backdrop { position: fixed; z-index: 100; inset: 0; display: grid; place-items: center; padding: 1rem; background: var(--cms-backdrop); }
150
+ .cms-media-dialog { width: min(760px, 100%); max-height: min(700px, 90vh); overflow: auto; padding: 1.25rem; background: var(--cms-elevated); border: 1px solid var(--cms-border); border-radius: 12px; box-shadow: 0 24px 48px var(--cms-shadow); }
151
+ .cms-media-dialog-actions { display: flex; justify-content: flex-end; margin: 1rem 0; }
152
+ .cms-upload { position: relative; display: inline-flex; overflow: hidden; padding: .48rem .64rem; }
153
+ .cms-upload input { position: absolute; inset: 0; cursor: pointer; opacity: 0; }
154
+ .cms-media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(145px, 1fr)); gap: .75rem; }
155
+ .cms-media-empty { grid-column: 1 / -1; padding: 2rem 0; text-align: center; }
156
+ .cms-media-card { display: grid; gap: .4rem; min-width: 0; padding: .45rem; color: var(--cms-text-muted); background: var(--cms-surface); border: 1px solid var(--cms-border); border-radius: 8px; }
157
+ .cms-media-insert { display: grid; gap: .35rem; min-width: 0; padding: 0; color: inherit; background: transparent; border: 0; text-align: left; }
158
+ .cms-media-insert:hover .cms-media-preview { outline: 1px solid var(--cms-accent); box-shadow: 0 2px 7px var(--cms-focus-ring); }
159
+ .cms-media-preview { display: grid; height: 96px; overflow: hidden; place-items: center; background: var(--cms-hover); border-radius: 5px; }
160
+ .cms-media-preview img, .cms-media-preview video { width: 100%; height: 100%; object-fit: cover; }
161
+ .cms-media-card strong { overflow: hidden; color: var(--cms-text); font-size: .72rem; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
162
+ .cms-media-insert > span:last-child { color: var(--cms-text-subtle); font-size: .65rem; }
163
+ .cms-media-card footer { display: flex; align-items: center; justify-content: space-between; gap: .35rem; }
164
+ .cms-media-usage { color: var(--cms-text-subtle); font-size: .65rem; }
165
+ .cms-media-usage-used { color: var(--cms-success); }
166
+ .cms-media-delete { padding: .18rem .35rem; color: var(--cms-danger); background: transparent; border: 1px solid var(--cms-border); border-radius: 4px; font-size: .65rem; }
167
+ .cms-media-delete:hover:not(:disabled) { border-color: var(--cms-danger); background: var(--cms-danger-subtle); }
168
+
169
+ @media (max-width: 720px) {
170
+ .cms-topbar { min-height: 58px; padding: 0 .85rem; }
171
+ .cms-actions { gap: .35rem; }
172
+ .cms-notice, .cms-button-quiet, .cms-status { display: none; }
173
+ .cms-workspace { grid-template-columns: 1fr; }
174
+ .cms-sidebar { position: static; height: auto; max-height: 255px; padding: .85rem; border-right: 0; border-bottom: 1px solid var(--cms-border); }
175
+ .cms-document-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; }
176
+ .cms-main { padding: 1rem .85rem 2rem; }
177
+ .cms-document-actions, .cms-danger-zone { align-items: flex-start; flex-direction: column; }
178
+ .cms-document-action-buttons { width: 100%; justify-content: flex-end; }
179
+ .cms-revision-backdrop { padding: 4rem .75rem .75rem; }
180
+ .cms-revision-drawer { max-height: calc(100vh - 4.75rem); }
181
+ .cms-simple-editor .simple-editor-wrapper { min-height: 450px; }
182
+ }
@@ -0,0 +1,130 @@
1
+ import { Hono, type Context, type MiddlewareHandler } from 'hono';
2
+ import { ensureSchema } from '../db/bootstrap.ts';
3
+ import { z } from 'zod';
4
+ import { DocumentConflictError, DocumentNotFoundError, createDocument, createDocumentTranslation, deleteDocument, getDocument, listDocuments, listRevisions, restoreRevision, updateDocument } from '../documents/service.ts';
5
+ import { InvalidMediaError, MediaInUseError, MediaNotFoundError, deleteUnusedMedia, listMedia, uploadMedia } from '../media/service.ts';
6
+ import type { RuntimeEnv } from '../env.ts';
7
+ import { defaultLocale, isSupportedLocale, type SupportedLocale } from '../locales.ts';
8
+ import { publishedSnapshot } from '../starlight/snapshot.ts';
9
+ import { adminHtml } from './html.ts';
10
+ import { previewDocument } from './preview.ts';
11
+ import { createFolder, createFolderTranslation, deleteFolder, FolderNotFoundError, listTree, NavigationConflictError, replaceTreeChildren, updateFolder } from '../navigation/service.ts';
12
+ import { PublishDeliveryConflictError, PublishDeliveryNotFoundError, deliverPendingChanges, listPublishDeliveries, publishDocumentAndRequest, requestPublish, retryPublish } from '../publish/service.ts';
13
+
14
+ type AdminEnv = { Bindings: RuntimeEnv };
15
+ const app = new Hono<AdminEnv>();
16
+ const jsonHeaders = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
17
+ const noStore = { 'Cache-Control': 'no-store' };
18
+
19
+ function locale(c: Context<AdminEnv>): SupportedLocale {
20
+ const value = c.req.query('locale') ?? defaultLocale;
21
+ if (!isSupportedLocale(value)) throw new NavigationConflictError('Unsupported language');
22
+ return value;
23
+ }
24
+
25
+ function csrfAllowed(request: Request): boolean {
26
+ if (!['POST', 'PUT', 'DELETE'].includes(request.method)) return true;
27
+ const origin = request.headers.get('origin');
28
+ return request.headers.get('x-requested-with') === 'cloudflare-starlight-cms' &&
29
+ (!origin || origin === new URL(request.url).origin);
30
+ }
31
+
32
+ const csrf: MiddlewareHandler<AdminEnv> = async (c, next) => {
33
+ if (!csrfAllowed(c.req.raw)) return c.json({ error: 'Cross-site request rejected' }, 403, jsonHeaders);
34
+ await next();
35
+ };
36
+
37
+
38
+ app.onError((error, c) => {
39
+ if (error instanceof DocumentNotFoundError) return c.json({ error: 'Not found' }, 404, jsonHeaders);
40
+ if (error instanceof DocumentConflictError) return c.json({ error: error.message || 'The document changed. Reload it and try again.' }, 409, jsonHeaders);
41
+ if (error instanceof InvalidMediaError) return c.json({ error: error.message }, 400, jsonHeaders);
42
+ if (error instanceof MediaNotFoundError) return c.json({ error: 'Media not found' }, 404, jsonHeaders);
43
+ if (error instanceof MediaInUseError) return c.json({ error: error.message }, 409, jsonHeaders);
44
+ if (error instanceof FolderNotFoundError) return c.json({ error: 'Folder not found' }, 404, jsonHeaders);
45
+ if (error instanceof NavigationConflictError) return c.json({ error: error.message }, 409, jsonHeaders);
46
+ if (error instanceof PublishDeliveryNotFoundError) return c.json({ error: 'Build request not found' }, 404, jsonHeaders);
47
+ if (error instanceof PublishDeliveryConflictError) return c.json({ error: error.message }, 409, jsonHeaders);
48
+ if (error instanceof z.ZodError) return c.json({ error: 'Invalid request', details: error.issues }, 400, jsonHeaders);
49
+ console.error('Admin API error', error);
50
+ return c.json({ error: 'Request failed' }, 500, jsonHeaders);
51
+ });
52
+
53
+ app.use('/admin/*', async (c, next) => {
54
+ if (c.req.path.startsWith('/admin/api/') || c.req.path.startsWith('/admin/preview/') || c.req.path === '/admin/export/snapshot') {
55
+ try {
56
+ await ensureSchema(c.env.DB);
57
+ } catch {
58
+ return c.json({ error: 'Database initialization failed. Retry the request.' }, 503, { ...noStore, 'Retry-After': '5' });
59
+ }
60
+ }
61
+ await next();
62
+ });
63
+ app.get('/admin/export/snapshot', async (c) => c.json(await publishedSnapshot(c.env), 200, jsonHeaders));
64
+ const adminHome = (c: Context<AdminEnv>) => {
65
+ return c.html(adminHtml(), 200, { 'Cache-Control': 'no-store' });
66
+ };
67
+ app.get('/admin', (c) => c.redirect('/admin/', 302));
68
+ app.get('/admin/', adminHome);
69
+ app.get('/admin/preview/:id', async (c) => previewDocument(c.env, c.req.raw, c.req.param('id'), locale(c)));
70
+ app.get('/admin/app.js', (c) => c.env.ASSETS.fetch(c.req.raw));
71
+
72
+ app.use('/admin/api/*', csrf);
73
+ app.use('/admin/api/*', async (c, next) => {
74
+ await next();
75
+ if (!c.req.path.startsWith('/admin/api/media') && ['POST', 'PUT', 'DELETE'].includes(c.req.method) && c.res.status < 400) {
76
+ c.executionCtx.waitUntil(deliverPendingChanges(c.env));
77
+ }
78
+ });
79
+ app.get('/admin/api/tree', async (c) => c.json(await listTree(c.env, locale(c)), 200, jsonHeaders));
80
+ app.post('/admin/api/folders', async (c) => c.json(await createFolder(c.env, await c.req.json(), locale(c)), 201, jsonHeaders));
81
+ app.put('/admin/api/folders/:id', async (c) => c.json(await updateFolder(c.env, c.req.param('id'), await c.req.json(), locale(c)), 200, jsonHeaders));
82
+ app.post('/admin/api/folders/:id/translations', async (c) => c.json(await createFolderTranslation(c.env, c.req.param('id'), locale(c), (await c.req.json<{ sourceLocale?: SupportedLocale }>()).sourceLocale ?? defaultLocale), 201, jsonHeaders));
83
+ app.delete('/admin/api/folders/:id', async (c) => { await deleteFolder(c.env, c.req.param('id')); return new Response(null, { status: 204, headers: noStore }); });
84
+ app.put('/admin/api/tree/children', async (c) => c.json(await replaceTreeChildren(c.env, await c.req.json()), 200, jsonHeaders));
85
+ app.get('/admin/api/documents', async (c) => c.json(await listDocuments(c.env, locale(c)), 200, jsonHeaders));
86
+ app.post('/admin/api/documents', async (c) => c.json(await createDocument(c.env, await c.req.json(), locale(c)), 201, jsonHeaders));
87
+ app.post('/admin/api/documents/:id/translations', async (c) => c.json(await createDocumentTranslation(c.env, c.req.param('id'), locale(c), (await c.req.json<{ sourceLocale?: SupportedLocale }>()).sourceLocale ?? defaultLocale), 201, jsonHeaders));
88
+ app.get('/admin/api/documents/:id', async (c) => c.json(await getDocument(c.env, c.req.param('id'), locale(c)), 200, jsonHeaders));
89
+ app.put('/admin/api/documents/:id', async (c) => c.json(await updateDocument(c.env, c.req.param('id'), await c.req.json(), locale(c)), 200, jsonHeaders));
90
+ app.delete('/admin/api/documents/:id', async (c) => {
91
+ const { version } = await c.req.json<{ version?: number }>();
92
+ if (!Number.isInteger(version)) return c.json({ error: 'version is required' }, 400, jsonHeaders);
93
+ await deleteDocument(c.env, c.req.param('id'), version!, locale(c));
94
+ return new Response(null, { status: 204, headers: noStore });
95
+ });
96
+ app.post('/admin/api/documents/:id/publish', async (c) => {
97
+ const { version } = await c.req.json<{ version?: number }>();
98
+ if (!Number.isInteger(version)) return c.json({ error: 'version is required' }, 400, jsonHeaders);
99
+ const result = await publishDocumentAndRequest(c.env, c.req.param('id'), version!, locale(c));
100
+ return c.json(result, 200, jsonHeaders);
101
+ });
102
+ app.post('/admin/api/publish/site', async (c) => c.json({ delivery: await requestPublish(c.env, 'site') }, 200, jsonHeaders));
103
+ app.get('/admin/api/publish/deliveries', async (c) => c.json(await listPublishDeliveries(c.env), 200, jsonHeaders));
104
+ app.post('/admin/api/publish/deliveries/:id/retry', async (c) => c.json({ delivery: await retryPublish(c.env, c.req.param('id')) }, 200, jsonHeaders));
105
+ app.get('/admin/api/documents/:id/revisions', async (c) => c.json(await listRevisions(c.env, c.req.param('id'), locale(c)), 200, jsonHeaders));
106
+ app.post('/admin/api/documents/:id/revisions/:revisionId/restore', async (c) => {
107
+ const { version } = await c.req.json<{ version?: number }>();
108
+ if (!Number.isInteger(version)) return c.json({ error: 'version is required' }, 400, jsonHeaders);
109
+ return c.json(await restoreRevision(c.env, c.req.param('id'), c.req.param('revisionId'), version!, locale(c)), 200, jsonHeaders);
110
+ });
111
+ app.get('/admin/api/media', async (c) => c.json(await listMedia(c.env), 200, jsonHeaders));
112
+ app.post('/admin/api/media', async (c) => c.json(await uploadMedia(c.env, c.req.raw), 201, jsonHeaders));
113
+ app.delete('/admin/api/media/:id', async (c) => {
114
+ await deleteUnusedMedia(c.env, c.req.param('id'));
115
+ return new Response(null, { status: 204, headers: noStore });
116
+ });
117
+ app.get('/admin/api/media/object/:key{.+}', async (c) => {
118
+ const key = c.req.param('key');
119
+ if (!/^media\/[a-z0-9-]+\.(png|jpg|webp|avif|mp4|webm)$/.test(key)) return c.json({ error: 'Not found' }, 404, jsonHeaders);
120
+ const object = await c.env.MEDIA.get(key);
121
+ if (!object) return c.json({ error: 'Not found' }, 404, jsonHeaders);
122
+ const headers = new Headers(noStore);
123
+ object.writeHttpMetadata(headers);
124
+ headers.set('ETag', object.httpEtag);
125
+ return new Response(object.body, { headers });
126
+ });
127
+
128
+ app.all('/admin/*', (c) => c.env.ASSETS.fetch(c.req.raw));
129
+
130
+ export { app as adminApp };