caracms 0.0.0-stage → 0.2.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 (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The editor in React: one context holding the editor (editor.ts), and `useStore`
3
+ * to read any of its stores. A selector must return a stable value (a primitive, or
4
+ * a part of the state as stored), never a new object, or React re-renders forever.
5
+ *
6
+ * const editor = useEditor();
7
+ * const level = useStore(editor.view, (v) => v.level);
8
+ * const session = useStore(editor.doc.state);
9
+ */
10
+ import { createContext, useContext, useSyncExternalStore } from 'react';
11
+ import type { Editor } from './editor.ts';
12
+ import type { Store } from './state/store.ts';
13
+
14
+ export const EditorContext = createContext<Editor | null>(null);
15
+
16
+ export function useEditor(): Editor {
17
+ const editor = useContext(EditorContext);
18
+ if (!editor) throw new Error('useEditor: no editor (render inside <EditorApp>)');
19
+ return editor;
20
+ }
21
+
22
+ export function useStore<T>(store: Store<T>): T;
23
+ export function useStore<T, S>(store: Store<T>, select: (value: T) => S): S;
24
+ export function useStore<T, S>(store: Store<T>, select?: (value: T) => S): T | S {
25
+ const read = () => (select ? select(store.get()) : store.get());
26
+ return useSyncExternalStore((onChange) => store.subscribe(onChange), read, read);
27
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The open document's menu in its level header (the mockup's docMenuHTML; #home-menu,
3
+ * #delete-locked): **View on the live site** (off for a document that isn't live yet)
4
+ * and **Delete page…** (or Delete <a case study>…, Delete shared block…), which opens
5
+ * the confirmation (dialogs.tsx). Delete is off, with the reason under it, for Home
6
+ * ("The home page can't be deleted.") and while someone else is editing it ("Aoife is
7
+ * editing Contact. It can be deleted once she's done."). A shared block still in use
8
+ * opens the "still on N pages" dialog instead. Site Settings has no menu.
9
+ *
10
+ * Pre-M7 item 8: **Change address…** for a document with an address (a page, a case
11
+ * study, a journal entry), off for Home or a collection's top page and while someone
12
+ * else is editing it.
13
+ *
14
+ * M6c (G1): a document with no page of its own (a shared block, a fields collection
15
+ * with no `urlBase`) has neither View on the live site nor Change address…, only Delete.
16
+ */
17
+ import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
18
+ import { useEditor, useStore } from '../context.tsx';
19
+ import { displayName } from '../live/format.ts';
20
+ import { Icon } from '../shell/Icons.tsx';
21
+ import { docId, sameDoc } from '../../types.ts';
22
+ import { singular } from './actions.ts';
23
+ import { openDialog } from './dialogs.tsx';
24
+
25
+ export function DocMenu({ label }: { label: string }) {
26
+ const editor = useEditor();
27
+ const ref = useStore(editor.doc.state, (s) => s.ref);
28
+ const entry = useStore(editor.doc.state, (s) => s.entry);
29
+ const title = useStore(editor.doc.state, (s) => s.document.title || s.entry.label);
30
+ const documents = useStore(editor.documents);
31
+ const access = useStore(editor.access);
32
+ const waiting = useStore(editor.liveState, (s) => s.status?.waiting);
33
+ const signedOut = useStore(editor.liveState, (s) => !!s.signedOut);
34
+ const [open, setOpen] = useState(false);
35
+ const menu = useRef<HTMLDivElement>(null);
36
+ const button = useRef<HTMLButtonElement>(null);
37
+ const collection = editor.site.collections.find((c) => c.name === ref.collection);
38
+
39
+ useEffect(() => {
40
+ if (!open) return;
41
+ menu.current?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
42
+ const onDown = (event: MouseEvent) => {
43
+ const target = event.target as Element | null;
44
+ if (menu.current?.contains(target) || button.current?.contains(target)) return;
45
+ setOpen(false);
46
+ };
47
+ document.addEventListener('mousedown', onDown);
48
+ return () => document.removeEventListener('mousedown', onDown);
49
+ }, [open]);
50
+ // Another document, another menu.
51
+ useEffect(() => setOpen(false), [ref]);
52
+
53
+ if (!collection || collection.kind === 'settings') return null;
54
+ const shared = collection.kind === 'shared';
55
+ const noPage = collection.urlBase === null;
56
+ const pending = (waiting ?? []).find((w) => w.collection === ref.collection && w.path === ref.path && w.change === 'new');
57
+ const isNew = !!pending;
58
+ const listed = documents.find((d) => docId(d) === docId(ref));
59
+ const home = sameDoc(ref, editor.site.home);
60
+ const holder = access.lock === 'theirs' && access.holder ? displayName(access.holder.person) : null;
61
+ const why = home ? "The home page can't be deleted." : holder ? `${holder} is editing ${title}. It can be deleted once they're done.` : access.deleted ? 'It has been deleted already.' : null;
62
+ const top = collection.nested && ref.path === 'index';
63
+ const moveWhy = top
64
+ ? home
65
+ ? 'Home is always at /.'
66
+ : `It's the top of ${collection.label}, so its address is ${collection.label}'s.`
67
+ : holder
68
+ ? `${holder} is editing it.`
69
+ : access.held
70
+ ? 'Use mine or Discard mine first.'
71
+ : signedOut
72
+ ? 'Sign in again first: its unsent changes would be lost.'
73
+ : !entry.url
74
+ ? "It can't be opened, so its address can't change."
75
+ : null;
76
+ const close = () => {
77
+ setOpen(false);
78
+ button.current?.focus();
79
+ };
80
+ const onKey = (event: KeyboardEvent<HTMLDivElement>) => {
81
+ if (event.key === 'Escape') {
82
+ // Closes the menu only; the editor's Esc would go up a level.
83
+ event.preventDefault();
84
+ event.stopPropagation();
85
+ close();
86
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
87
+ event.preventDefault();
88
+ const items = [...(menu.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
89
+ const at = items.indexOf(document.activeElement as HTMLButtonElement);
90
+ items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
91
+ }
92
+ };
93
+ const remove = () => {
94
+ setOpen(false);
95
+ if (shared && (listed?.usedOn?.length ?? 0) > 0) openDialog(editor, { kind: 'in-use', ref });
96
+ else openDialog(editor, { kind: 'delete', ref });
97
+ };
98
+ return (
99
+ <div style={{ position: 'relative' }} data-slot="page-menu">
100
+ <button
101
+ ref={button}
102
+ type="button"
103
+ className="ib"
104
+ data-act="doc-menu"
105
+ aria-label={label}
106
+ aria-haspopup="menu"
107
+ aria-expanded={open}
108
+ onClick={() => setOpen((o) => !o)}
109
+ >
110
+ <Icon name="more" />
111
+ </button>
112
+ {open ? (
113
+ <div ref={menu} className="menu" role="menu" style={{ right: 0, top: 'calc(100% + 4px)' }} onKeyDown={onKey}>
114
+ {noPage ? null : (
115
+ <>
116
+ <button
117
+ type="button"
118
+ role="menuitem"
119
+ disabled={isNew || !entry.url}
120
+ onClick={() => {
121
+ setOpen(false);
122
+ if (entry.url) window.open(entry.url, '_blank', 'noopener');
123
+ }}
124
+ >
125
+ <Icon name="ext" />
126
+ View on the live site
127
+ </button>
128
+ {isNew ? <div className="why">Not live yet.</div> : null}
129
+ <button type="button" role="menuitem" data-act="move-document" disabled={!!moveWhy} onClick={() => (setOpen(false), openDialog(editor, { kind: 'move', ref }))}>
130
+ <Icon name="arrow-r" />
131
+ Change address…
132
+ </button>
133
+ {moveWhy ? (
134
+ <div className="why" data-why="move">
135
+ {moveWhy}
136
+ </div>
137
+ ) : null}
138
+ <hr />
139
+ </>
140
+ )}
141
+ <button type="button" role="menuitem" className="danger" data-act="delete-document" disabled={!!why} onClick={remove}>
142
+ <Icon name="trash" />
143
+ Delete {shared ? 'shared block' : singular(collection)}…
144
+ </button>
145
+ {why ? (
146
+ <div className="why" data-why="delete">
147
+ {why}
148
+ </div>
149
+ ) : null}
150
+ </div>
151
+ ) : null}
152
+ </div>
153
+ );
154
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Creating and deleting documents from the editor (M5a Decisions 11 and 12), on Phase
3
+ * 3's routes (`POST /_caracms/create`, `POST /_caracms/delete`). Nothing here decides a
4
+ * rule: the dialogs pre-check what they can (slug.ts) and show the server's own
5
+ * sentence when it refuses.
6
+ *
7
+ * createDocument(editor, request) → the new document opens at once (its level)
8
+ * deleteDocument(editor, ref) → the tab goes to the level it was listed in
9
+ *
10
+ * plus the small pure helpers the dialogs and menus share: what a new page's "Inside"
11
+ * offers, a collection's singular word, a document's public address.
12
+ */
13
+ import { titleCase } from '../../../fields.ts';
14
+ import { slugify } from '../../../slug.ts';
15
+ import type { Editor } from '../editor.ts';
16
+ import { docId, ENDPOINTS, MEDIA_ENDPOINTS, TAB_HEADER, sameDoc, type CreateDeleteRefusal, type CreateRequest, type CreateResponse, type DeleteResponse, type DocRef, type MoveResponse, type EditorAnyCollection, type ListedDocument, type ReplaceFileResponse } from '../../types.ts';
17
+
18
+ export type Refused = { ok: false; status: number; error: string; code: string | null; usedOn: CreateDeleteRefusal['usedOn'] | null };
19
+
20
+ async function post<T>(editor: Editor, url: string, body: unknown): Promise<{ ok: true; body: T } | Refused> {
21
+ const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json' };
22
+ if (editor.liveSession) headers[TAB_HEADER] = editor.liveSession.tab;
23
+ let response: Response;
24
+ try {
25
+ response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
26
+ } catch {
27
+ return { ok: false, status: 0, error: "The server can't be reached. Nothing was changed; try again in a moment.", code: 'offline', usedOn: null };
28
+ }
29
+ const said = (await response.json().catch(() => ({}))) as CreateDeleteRefusal & T;
30
+ if (response.status === 401 && editor.liveSession) editor.liveState.update({ signedOut: said as never });
31
+ if (!response.ok) {
32
+ return { ok: false, status: response.status, error: said.error ?? `The server said no (${response.status}).`, code: said.code ?? null, usedOn: said.usedOn ?? null };
33
+ }
34
+ return { ok: true, body: said as T };
35
+ }
36
+
37
+ /** Create a document; on success the list is read again and the new document opens (Decision 11). */
38
+ export async function createDocument(editor: Editor, request: CreateRequest): Promise<{ ok: true; body: CreateResponse } | Refused> {
39
+ const answer = await post<CreateResponse>(editor, ENDPOINTS.create, request);
40
+ if (!answer.ok) return answer;
41
+ await editor.refreshDocuments().catch(() => undefined);
42
+ await editor.openDocument({ collection: answer.body.collection, path: answer.body.path });
43
+ editor.announce(`Created ${request.title}.`);
44
+ return answer;
45
+ }
46
+
47
+ /**
48
+ * Delete a document (Decision 12). Its unsent edits are dropped first (they'd only be
49
+ * refused), then, if it was open, Home opens behind and the pane goes to the level it
50
+ * was listed in: Pages' documents level, or its collection's own level.
51
+ */
52
+ export async function deleteDocument(editor: Editor, ref: DocRef, title: string): Promise<{ ok: true; body: DeleteResponse } | Refused> {
53
+ const id = docId(ref);
54
+ const open = sameDoc(editor.doc.get().ref, ref);
55
+ // Its last edits go first (in live mode the lock is this tab's), so nothing is in flight after.
56
+ await editor.keeper?.flush(id).catch(() => undefined);
57
+ const answer = await post<DeleteResponse>(editor, ENDPOINTS.delete, { collection: ref.collection, path: ref.path });
58
+ if (!answer.ok) return answer;
59
+ editor.keeper?.discard(id);
60
+ const collection = editor.site.collections.find((c) => c.name === ref.collection);
61
+ await editor.refreshDocuments().catch(() => undefined);
62
+ if (open) await editor.openDocument(editor.site.home, { history: 'push' });
63
+ if (collection && !collection.nested && collection.kind !== 'settings') editor.openCollection(collection.name);
64
+ else editor.goTo('documents');
65
+ editor.toast(`Deleted ${title}.`);
66
+ editor.announce(`Deleted ${title}.`);
67
+ return answer;
68
+ }
69
+
70
+ /**
71
+ * Change a document's address (pre-M7 item 8). Its unsent edits go first (the move
72
+ * carries them), then the document opens at its new address in place of the old one,
73
+ * and the list is read again (other documents' links changed too).
74
+ */
75
+ export async function moveDocument(editor: Editor, ref: DocRef, to: string, title: string): Promise<{ ok: true; body: MoveResponse } | Refused> {
76
+ const id = docId(ref);
77
+ const open = sameDoc(editor.doc.get().ref, ref);
78
+ // Nothing moves while the keeper still holds edits the server hasn't got (a held conflict, a
79
+ // signed-out session, a refused write): the move would discard them.
80
+ let flushed = true;
81
+ await editor.keeper?.flush(id).catch(() => {
82
+ flushed = false;
83
+ });
84
+ if (!flushed || editor.keeper?.dirty(id)) {
85
+ return { ok: false, status: 0, error: `${title} has changes that haven't been saved yet, so its address can't change. Sort those out first (Use mine or Discard mine, or sign in again), then try again.`, code: 'unsent', usedOn: null };
86
+ }
87
+ const answer = await post<MoveResponse>(editor, ENDPOINTS.move, { collection: ref.collection, path: ref.path, to });
88
+ if (!answer.ok) return answer;
89
+ editor.keeper?.discard(id);
90
+ await editor.refreshDocuments().catch(() => undefined);
91
+ if (open) await editor.openDocument({ collection: answer.body.collection, path: answer.body.path }, { history: 'replace' });
92
+ const { url, from } = answer.body;
93
+ editor.toast(`Moved ${title} to ${url}.`, 'check');
94
+ editor.announce(`Moved ${title} from ${from.url} to ${url}.`);
95
+ return answer;
96
+ }
97
+
98
+ /**
99
+ * M6c G4 (Decision 9): Replace… on a file. Every unsent edit in this tab goes first, and
100
+ * nothing is replaced while any is still held (a conflict, a refusal, a signed-out
101
+ * session): Replace changes other documents, so this tab has to be up to date (`unsent`).
102
+ * Then every link to `from` moves to `to`; the list is read again, and the open document
103
+ * too when it was one of them (its text changed under it).
104
+ */
105
+ export async function replaceFile(editor: Editor, from: string, to: string): Promise<{ ok: true; body: ReplaceFileResponse } | Refused> {
106
+ await editor.keeper?.flush().catch(() => undefined);
107
+ const keep = editor.keeper?.status.get();
108
+ if (keep && keep.state !== 'kept') {
109
+ const n = keep.unsent ?? 0;
110
+ return { ok: false, status: 0, error: n > 1 ? `Your last ${n} changes haven't reached the server yet` : n === 1 ? "Your last change hasn't reached the server yet" : "This tab has changes that haven't reached the server yet", code: 'unsent', usedOn: null };
111
+ }
112
+ const answer = await post<ReplaceFileResponse>(editor, MEDIA_ENDPOINTS.replace, { from, to });
113
+ if (!answer.ok) return answer;
114
+ await editor.refreshDocuments().catch(() => undefined);
115
+ const open = editor.doc.get().ref;
116
+ if (answer.body.relinked.some((r) => sameDoc(r, open))) await editor.reloadOpen().catch(() => undefined);
117
+ return answer;
118
+ }
119
+
120
+ // ── Pure helpers ─────────────────────────────────────────────────────────────
121
+
122
+ /** A collection's word for one of its documents: "page", "shared block", "case study", "journal entry". */
123
+ export function singular(collection: Pick<EditorAnyCollection, 'kind' | 'nested' | 'label'>): string {
124
+ if (collection.kind === 'shared') return 'shared block';
125
+ if (collection.nested) return 'page';
126
+ const label = collection.label.toLowerCase();
127
+ if (/ies$/.test(label)) return label.replace(/ies$/, 'y');
128
+ if (/[^s]s$/.test(label)) return label.slice(0, -1);
129
+ return `${label} entry`;
130
+ }
131
+
132
+ /** M6c (G1): a collection with no pages of its own, under its name (as shared blocks' "Edited in one place…"). */
133
+ export const PAGELESS_SUB = 'Shown on other pages; no pages of their own';
134
+
135
+ /** M6c (G2): the name of a section builder's page fields group: "Page details", "Game details". */
136
+ export function pageFieldsLabel(collection: Pick<EditorAnyCollection, 'kind' | 'nested' | 'label'>): string {
137
+ const one = singular(collection);
138
+ return `${one.charAt(0).toUpperCase()}${one.slice(1)} details`;
139
+ }
140
+
141
+ /** A document's public address in a collection (`/studio/visit`, `/work/kiln-and-co`, Home `/`), or null. */
142
+ export function addressFor(collection: Pick<EditorAnyCollection, 'urlBase'>, path: string): string | null {
143
+ if (collection.urlBase === null) return null;
144
+ if (path === 'index') return collection.urlBase;
145
+ if (path.endsWith('/index')) return `${collection.urlBase}${path.slice(0, -'/index'.length)}`;
146
+ return `${collection.urlBase}${path}`;
147
+ }
148
+
149
+ export interface InsideOption {
150
+ /** The folder's path in the collection ('' for the top level). */
151
+ path: string;
152
+ label: string;
153
+ /** The address a page inside it starts with (`/services/`). */
154
+ base: string;
155
+ }
156
+
157
+ /**
158
+ * New page's "Inside" (Decision 11): the top level, then every folder that holds pages
159
+ * (a group, or a page with pages under it), at any depth, in tree order.
160
+ */
161
+ export function insideOptions(collection: Pick<EditorAnyCollection, 'name' | 'urlBase'>, documents: readonly ListedDocument[]): InsideOption[] {
162
+ const base = collection.urlBase ?? '/';
163
+ const docs = documents.filter((d) => d.collection === collection.name);
164
+ const folders = new Set<string>();
165
+ for (const d of docs) {
166
+ const parts = d.path.split('/');
167
+ for (let i = 1; i < parts.length; i++) folders.add(parts.slice(0, i).join('/'));
168
+ }
169
+ const labelOf = (path: string) => docs.find((d) => d.path === path)?.label ?? titleCase(path.split('/').pop()!);
170
+ const options = [...folders]
171
+ .sort((a, b) => a.localeCompare(b))
172
+ .map((path) => {
173
+ const parts = path.split('/');
174
+ const label = parts.map((_, i) => labelOf(parts.slice(0, i + 1).join('/'))).join(' / ');
175
+ return { path, label, base: `${base}${path}/` };
176
+ });
177
+ return [{ path: '', label: 'Top level', base }, ...options];
178
+ }
179
+
180
+ /** The pages under a folder (any depth), for "its pages stay" lines. */
181
+ export function pagesUnder(collection: string, path: string, documents: readonly ListedDocument[]): ListedDocument[] {
182
+ return documents.filter((d) => d.collection === collection && d.path.startsWith(`${path}/`));
183
+ }
184
+
185
+ /** "A, B and C". */
186
+ export function andList(names: readonly string[]): string {
187
+ if (names.length <= 1) return names.join('');
188
+ return `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
189
+ }
190
+
191
+ /** An address part from what's typed, by the name rule (slug.ts). */
192
+ export const partFrom = (typed: string) => slugify(typed);