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,61 @@
1
+ /**
2
+ * M6c (G5, Decision 19): the documents listing's card pictures, signed on the way to
3
+ * the browser, as the library's listing carries its thumbnails (media/library.ts).
4
+ *
5
+ * Each listed document whose collection has the List / Grid switch and whose first
6
+ * image was found (content.ts → `cover`) gets `card`: imgproxy `rs:fill` at the
7
+ * collection's ratio with the focal point as gravity (media.ts → imageUrl), at
8
+ * CARD_WIDTH and its 2× rung of the ladder, and the alt text; a clip gets its Mux poster
9
+ * cropped the same. `cover` itself doesn't leave the server. So the grid draws at once,
10
+ * without opening each document and without the thumbnail route's uncached redirects.
11
+ *
12
+ * Without the image variables (no signing), a picture has no URL: the card is a text card.
13
+ */
14
+ import { IMAGE_WIDTHS, imageUrl, type MediaSettings } from '../../media.ts';
15
+ import { MUX_IMAGE_URL } from '../../media-value.ts';
16
+ import { ratioValue, type CardCover, type CardRatio } from '../../cards.ts';
17
+ import type { ResolvedSite } from '../../config.ts';
18
+ import type { CardPicture, ListedDocument } from '../types.ts';
19
+ import { readImagesConfig } from './media/config.ts';
20
+
21
+ /** A card's picture width, a rung of the ladder; its 2× is the next rung up that doubles it. A card is 208px at its narrowest, rarely past 320. */
22
+ export const CARD_WIDTH = 320;
23
+ export const CARD_WIDTH_2X = 640;
24
+ if (!IMAGE_WIDTHS.includes(CARD_WIDTH) || !IMAGE_WIDTHS.includes(CARD_WIDTH_2X)) throw new Error('The card widths must be rungs of IMAGE_WIDTHS.');
25
+
26
+ /** The picture's height at a width and ratio, rounded. */
27
+ export const cardHeight = (width: number, ratio: CardRatio) => Math.round(width / ratioValue(ratio));
28
+
29
+ /** A clip's Mux poster, cropped to the card (Mux's own `fit_mode=crop`), at `time` seconds (0 when unset). WebP, never the default PNG. */
30
+ export function cardPosterUrl(playbackId: string, time: number | null, width: number, ratio: CardRatio): string {
31
+ const t = typeof time === 'number' && Number.isFinite(time) && time >= 0 ? time : 0;
32
+ return `${MUX_IMAGE_URL}/${encodeURIComponent(playbackId)}/thumbnail.webp?width=${width}&height=${cardHeight(width, ratio)}&fit_mode=crop&time=${t}`;
33
+ }
34
+
35
+ /** One cover, signed. */
36
+ export function cardPicture(signing: MediaSettings | null, cover: CardCover, ratio: CardRatio): CardPicture | null {
37
+ if (cover.kind === 'clip') {
38
+ return { kind: 'clip', src: cardPosterUrl(cover.playbackId, cover.time, CARD_WIDTH, ratio), src2x: cardPosterUrl(cover.playbackId, cover.time, CARD_WIDTH_2X, ratio), alt: cover.name, duration: cover.duration };
39
+ }
40
+ if (!signing) return null;
41
+ const at = (width: number) => imageUrl(signing, cover.key, { width, height: cardHeight(width, ratio), resize: 'fill', focal: cover.focal });
42
+ const position = cover.focal ? `${Math.round(cover.focal[0] * 100)}% ${Math.round(cover.focal[1] * 100)}%` : null;
43
+ return { kind: 'image', src: at(CARD_WIDTH), src2x: at(CARD_WIDTH_2X), alt: cover.alt, position };
44
+ }
45
+
46
+ /** The image side's signing settings, or null when the image variables aren't set. */
47
+ export function cardSigning(): MediaSettings | null {
48
+ const config = readImagesConfig();
49
+ return config.kind === 'unset' ? null : config.signing;
50
+ }
51
+
52
+ /** The listing as the browser gets it: each `cover` swapped for its signed `card`. */
53
+ export function withCards(site: Pick<ResolvedSite, 'collections'>, documents: readonly ListedDocument[], signing: MediaSettings | null = cardSigning()): ListedDocument[] {
54
+ return documents.map((doc) => {
55
+ if (!doc.cover) return doc;
56
+ const { cover, ...rest } = doc;
57
+ const view = site.collections[doc.collection]?.view;
58
+ const card = view ? cardPicture(signing, cover, view.ratio) : null;
59
+ return card ? { ...rest, card } : rest;
60
+ });
61
+ }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Creating and deleting documents (M5a Phase 3; Decisions 11 and 12): the rules both
3
+ * stores apply, the disk half (local mode), and the two routes' handling.
4
+ *
5
+ * **A security boundary, as the write route is** (M3 Decision 5): these take a path
6
+ * from the browser and make or remove a file. So:
7
+ *
8
+ * - **POST, JSON, same origin**, a small body (`handleCreateDelete`, beside write.ts's
9
+ * `checkOrigin`); in live mode the guard also wants a session (M4 Decision 14: its
10
+ * path-prefix rule covers `/_caracms/create` and `/_caracms/delete` with no list), and
11
+ * the live store refuses a request with no person (401) or no tab (400).
12
+ * - **Confined exactly as the write route is** (`target`): the path goes through
13
+ * content.ts → `resolveDocumentFile` (no `..`, `.`, empty parts, backslash, NUL,
14
+ * absolute path or dot-file; one part in a flat collection), and the file it gives
15
+ * must come back through write.ts → `confinePath` as the same collection and path
16
+ * (a folder no collection declares, or one inside another collection's folder, is
17
+ * refused). On disk, **every folder on the way is checked with symlinks resolved**:
18
+ * an existing one must resolve inside the collection's folder; a missing one is made
19
+ * inside the resolved parent (`createOnDisk`); a delete goes through write.ts →
20
+ * `confineDocumentFile` (the parent resolved inside, the file itself not a link).
21
+ * - **Disk: an exclusive create.** The text is serialised first (nothing is touched if
22
+ * that fails), written to a temp file beside it, then hard-linked into place, which
23
+ * fails if a file is already there (409) and never leaves half a file under the
24
+ * document's name. Folders made for it are removed again if it fails.
25
+ * - **Delete** removes the file, then each folder it leaves empty, walking up **to but
26
+ * never including** the collection's folder, even if that ends up empty.
27
+ *
28
+ * The rules (`planCreate`, `planDelete`), the same in both stores, over the store's own
29
+ * listing (live mode: the copy with autosaves on top):
30
+ *
31
+ * - **Addresses** (slug.ts, browser-safe, the dialog's too): every new part of the path
32
+ * is one `slugify` leaves as it is (a folder that already has documents in it may be
33
+ * named otherwise); no part is `index`; nothing left (only symbols) is refused with
34
+ * the field's message. The public address must be free in every collection, not
35
+ * under `/admin` or `/_caracms`, and not strictly below another collection's
36
+ * non-root `urlBase` (exactly at it is allowed).
37
+ * - **A document that exists is 409.** Site Settings is never created or deleted; a
38
+ * nested collection's `index` (Home) is never deleted; a shared block anything uses
39
+ * can't be deleted (409 `in-use`, naming where, from the listing's `usedOn`). A page
40
+ * with pages under it can be: they stay, as a group.
41
+ * - **The new document's content:** a page `{ "title", "blocks": [] }`; a fields
42
+ * document `{ "title" }`; a shared block `{ "title", "block": { "type" } }`.
43
+ */
44
+ import { randomBytes } from 'node:crypto';
45
+ import { link, lstat, mkdir, realpath, rm, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
46
+ import { basename, dirname, join, relative, sep } from 'node:path';
47
+ import type { ResolvedCollection, ResolvedSite } from '../../config.ts';
48
+ import { DocumentError, documentUrl, resolveDocumentFile, type DocumentEntry } from '../../content.ts';
49
+ import { own, parseAnyDocument, type AnyDocument } from '../../document.ts';
50
+ import type { Person } from '../../live/messages.ts';
51
+ import { applyDocumentEdits } from '../../serialize.ts';
52
+ import { addressProblem, INDEX_MESSAGE, isSlug, NO_ADDRESS_MESSAGE, RESERVED_SLUG, slugify } from '../../slug.ts';
53
+ import { RELEASE_HEADER, type CreateDeleteCode, type CreateRequest, type CreateResponse, type DeleteRequest, type DeleteResponse } from '../types.ts';
54
+ import { checkOrigin, confineDocumentFile, confinePath, seqOf, tabOf, WriteRefused, type WriteContext } from './write.ts';
55
+ import { schemaRef, stampSchema } from '../../schema-key.ts';
56
+
57
+ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks' | 'codeRoutes'>;
58
+
59
+ /** Largest create or delete body: a collection, a path and a title. */
60
+ export const MAX_CREATE_BODY_BYTES = 16 * 1024;
61
+ /** The longest title a new document may have. */
62
+ export const MAX_TITLE = 200;
63
+
64
+ const posix = (p: string) => p.split(sep).join('/');
65
+
66
+ /** A refusal with its code (types.ts → CreateDeleteRefusal). */
67
+ export function refused(status: number, code: CreateDeleteCode, message: string, more: Record<string, unknown> = {}): WriteRefused {
68
+ return new WriteRefused(status, message, { code, ...more });
69
+ }
70
+
71
+ /** A path for a message: control characters shown escaped, long ones cut. */
72
+ function printable(value: string): string {
73
+ const shown = JSON.stringify(value).slice(1, -1);
74
+ return shown.length > 120 ? `${shown.slice(0, 117)}...` : shown;
75
+ }
76
+
77
+ /** "Home", "Home and About", "Home, About and Work". */
78
+ export function andList(items: string[]): string {
79
+ if (items.length <= 1) return items.join('');
80
+ return `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
81
+ }
82
+
83
+ // ── Bodies ──────────────────────────────────────────────────────────────────
84
+
85
+ const object = (body: unknown, shape: string): Record<string, unknown> => {
86
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw refused(400, 'bad-request', `Malformed body: it must be a JSON object. Send ${shape}.`);
87
+ return body as Record<string, unknown>;
88
+ };
89
+
90
+ const onlyKeys = (body: Record<string, unknown>, keys: string[], shape: string) => {
91
+ const extra = Object.keys(body).filter((k) => !keys.includes(k));
92
+ if (extra.length) throw refused(400, 'bad-request', `Malformed body: unexpected ${extra.map((k) => `"${printable(k)}"`).join(', ')}. Send ${shape}.`);
93
+ };
94
+
95
+ const CREATE_SHAPE = '{ collection, path, title } (and blockType for a shared block)';
96
+ const DELETE_SHAPE = '{ collection, path }';
97
+
98
+ /** Validate a create body's shape. Throws WriteRefused 400. */
99
+ export function parseCreateBody(body: unknown): CreateRequest {
100
+ const b = object(body, CREATE_SHAPE);
101
+ onlyKeys(b, ['collection', 'path', 'title', 'blockType'], CREATE_SHAPE);
102
+ const bad = (message: string) => refused(400, 'bad-request', `Malformed body: ${message} Send ${CREATE_SHAPE}.`);
103
+ if (typeof b.collection !== 'string' || b.collection === '') throw bad('"collection" must be a collection\'s name.');
104
+ if (typeof b.path !== 'string') throw bad('"path" must be the new document\'s path in its collection.');
105
+ if (b.path.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
106
+ if (typeof b.title !== 'string') throw bad('"title" must be a string.');
107
+ const title = b.title.trim();
108
+ if (title === '') throw refused(400, 'bad-name', 'Give it a name.');
109
+ if (title.length > MAX_TITLE) throw refused(400, 'bad-name', `That name is too long (at most ${MAX_TITLE} characters).`);
110
+ // One line (a title is one-line text): no breaks or control characters, which would also reach the commit headlines.
111
+ if (/\p{Cc}/u.test(title)) throw refused(400, 'bad-name', 'A name is one line, with no breaks or control characters.');
112
+ if (b.blockType !== undefined && typeof b.blockType !== 'string') throw bad('"blockType" must be a block type\'s name.');
113
+ return { collection: b.collection, path: b.path, title, ...(b.blockType !== undefined ? { blockType: b.blockType as string } : {}) };
114
+ }
115
+
116
+ /** Validate a delete body's shape. Throws WriteRefused 400. */
117
+ export function parseDeleteBody(body: unknown): DeleteRequest {
118
+ const b = object(body, DELETE_SHAPE);
119
+ onlyKeys(b, ['collection', 'path'], DELETE_SHAPE);
120
+ if (typeof b.collection !== 'string' || b.collection === '') throw refused(400, 'bad-request', `Malformed body: "collection" must be a collection's name. Send ${DELETE_SHAPE}.`);
121
+ if (typeof b.path !== 'string' || b.path === '') throw refused(400, 'bad-request', `Malformed body: "path" must be the document's path in its collection. Send ${DELETE_SHAPE}.`);
122
+ if (b.path.length > 512) throw refused(400, 'bad-path', 'That path is too long.');
123
+ return { collection: b.collection, path: b.path };
124
+ }
125
+
126
+ // ── Confinement ────────────────────────────────────────────────────────────
127
+
128
+ export interface Target {
129
+ collection: string;
130
+ path: string;
131
+ /** Relative to the site, `/`-separated. */
132
+ file: string;
133
+ /** Absolute, as configured (not symlink-resolved). */
134
+ absolute: string;
135
+ }
136
+
137
+ /**
138
+ * A collection and path as a file, confined as the write route confines a file: the
139
+ * reader's path rules, then write.ts → `confinePath` must give back the same
140
+ * collection and path. Throws WriteRefused 400 (`bad-path`).
141
+ */
142
+ export function target(site: Site, collection: string, path: string): Target {
143
+ if (!own(site.collections, collection)) throw refused(400, 'bad-path', `No collection "${printable(collection)}" is declared.`);
144
+ let absolute: string;
145
+ try {
146
+ absolute = resolveDocumentFile(site, collection, path);
147
+ } catch (error) {
148
+ if (error instanceof DocumentError) throw refused(400, 'bad-path', error.message);
149
+ throw error;
150
+ }
151
+ const file = posix(relative(site.root, absolute));
152
+ let confined;
153
+ try {
154
+ confined = confinePath(site, file);
155
+ } catch (error) {
156
+ if (error instanceof WriteRefused) throw refused(400, 'bad-path', error.message);
157
+ throw error;
158
+ }
159
+ if (confined.collection !== collection || confined.path !== path) {
160
+ throw refused(400, 'bad-path', `"${printable(path)}" isn't a document path in ${site.collections[collection]!.label}.`);
161
+ }
162
+ return { collection, path, file, absolute };
163
+ }
164
+
165
+ // ── The rules ──────────────────────────────────────────────────────────────
166
+
167
+ export interface CreatePlan extends Target {
168
+ title: string;
169
+ /** Its public address; null for a shared block. */
170
+ url: string | null;
171
+ /** The new file's text. */
172
+ text: string;
173
+ /** The new document, as the reader reads `text`. */
174
+ document: AnyDocument;
175
+ }
176
+
177
+ /** The new document's first text (Decision 11), serialised as the write route would. Throws if it can't be. */
178
+ export function initialText(site: Site, collection: ResolvedCollection, title: string, blockType: string | undefined, file: string): string {
179
+ // M8 Decision 14: a new document names its schema, as its first key.
180
+ return stampSchema(firstText(site, collection, title, blockType), schemaRef(collection, file));
181
+ }
182
+
183
+ function firstText(site: Site, collection: ResolvedCollection, title: string, blockType: string | undefined): string {
184
+ switch (collection.kind) {
185
+ case 'sections':
186
+ return applyDocumentEdits(null, { title, blocks: [] }, site, collection);
187
+ case 'fields':
188
+ return applyDocumentEdits(null, { kind: 'fields', title, data: {} }, site, collection);
189
+ case 'shared':
190
+ return `${JSON.stringify({ title, block: { type: blockType } }, null, 2)}\n`;
191
+ default:
192
+ throw new Error(`caracms: a ${collection.kind} document is never created`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Check a create against the rules and the store's listing (`documents`: every
198
+ * document in every collection now), and work out the new file. Throws WriteRefused.
199
+ */
200
+ export function planCreate(site: Site, request: CreateRequest, documents: readonly DocumentEntry[]): CreatePlan {
201
+ const collection = own(site.collections, request.collection);
202
+ if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
203
+ if (collection.kind === 'settings') throw refused(400, 'not-allowed', 'Site Settings is always there, so it\'s never created.');
204
+ if (request.path === '') throw refused(400, 'bad-name', NO_ADDRESS_MESSAGE);
205
+ if (request.path.split('/').includes(RESERVED_SLUG)) throw refused(400, 'index', INDEX_MESSAGE);
206
+
207
+ const where = target(site, collection.name, request.path);
208
+ const parts = where.path.split('/');
209
+ parts.forEach((part, i) => {
210
+ // A folder that already has documents in it keeps its name, whatever it is.
211
+ const prefix = parts.slice(0, i + 1).join('/');
212
+ const existing = i < parts.length - 1 && documents.some((d) => d.collection === collection.name && d.path.startsWith(`${prefix}/`));
213
+ if (existing || isSlug(part)) return;
214
+ const better = slugify(part);
215
+ throw refused(400, 'bad-name', better ? `"${printable(part)}" can't be part of an address; "${better}" can.` : NO_ADDRESS_MESSAGE);
216
+ });
217
+
218
+ if (collection.kind === 'shared') {
219
+ if (request.blockType === undefined || request.blockType === '') throw refused(400, 'not-allowed', 'Choose a block type for the shared block.');
220
+ if (!own(site.blocks, request.blockType)) throw refused(400, 'not-allowed', `There's no block type "${printable(request.blockType)}".`);
221
+ } else if (request.blockType !== undefined) {
222
+ throw refused(400, 'bad-request', `Malformed body: "blockType" is only for a shared block. Send ${CREATE_SHAPE}.`);
223
+ }
224
+
225
+ const url = documentUrl(collection, where.path);
226
+ if (documents.some((d) => d.file === where.file)) {
227
+ throw refused(409, 'exists', url ? `There's already a page at ${url}.` : `There's already a ${collection.kind === 'shared' ? 'shared block' : 'document'} called "${where.path}".`);
228
+ }
229
+ if (url !== null) {
230
+ const collections = Object.values(site.collections).map(({ name, label, urlBase }) => ({ name, label, urlBase }));
231
+ const problem = addressProblem(url, collection.name, collections, documents, site.codeRoutes);
232
+ if (problem) throw refused(problem.code === 'address-taken' ? 409 : 400, problem.code, problem.message);
233
+ }
234
+
235
+ const text = initialText(site, collection, request.title, request.blockType, where.file);
236
+ const document = parseAnyDocument(JSON.parse(text), site, collection);
237
+ return { ...where, title: request.title, url, text, document };
238
+ }
239
+
240
+ export interface DeletePlan extends Target {
241
+ entry: DocumentEntry;
242
+ }
243
+
244
+ /** Check a delete against the rules and the store's listing. Throws WriteRefused. */
245
+ export function planDelete(site: Site, request: DeleteRequest, documents: readonly DocumentEntry[]): DeletePlan {
246
+ const collection = own(site.collections, request.collection);
247
+ if (!collection) throw refused(400, 'bad-path', `No collection "${printable(request.collection)}" is declared.`);
248
+ if (collection.kind === 'settings') throw refused(400, 'not-allowed', 'Site Settings can\'t be deleted.');
249
+ const where = target(site, collection.name, request.path);
250
+ const entry = documents.find((d) => d.file === where.file);
251
+ if (collection.nested && where.path === 'index') {
252
+ throw refused(400, 'not-allowed', collection.urlBase === '/' ? 'Home can\'t be deleted.' : `${entry?.label ?? 'This page'} is the top of ${collection.label}, so it can't be deleted.`);
253
+ }
254
+ if (!entry) throw refused(404, 'not-found', `${collection.label}: no document "${printable(where.path)}".`);
255
+ if (collection.kind === 'shared' && entry.usedOn?.length) {
256
+ const usedOn = entry.usedOn.map((u) => ({ ...u, title: documents.find((d) => d.collection === u.collection && d.path === u.path)?.label ?? u.path }));
257
+ throw refused(
258
+ 409,
259
+ 'in-use',
260
+ `${entry.label} is used on ${andList(usedOn.map((u) => u.title))}, so it can't be deleted. Take it off ${usedOn.length === 1 ? 'that page' : 'those pages'} first.`,
261
+ { usedOn },
262
+ );
263
+ }
264
+ return { ...where, entry };
265
+ }
266
+
267
+ // ── The disk (local mode) ──────────────────────────────────────────────────
268
+
269
+ const code = (error: unknown) => (error as NodeJS.ErrnoException)?.code;
270
+
271
+ /**
272
+ * Make the new file, exclusively: every folder on the way checked with symlinks
273
+ * resolved (inside the collection's folder), missing ones made, the text written to a
274
+ * temp file and hard-linked into place (EEXIST: 409). Folders it made are removed if it fails.
275
+ */
276
+ export async function createOnDisk(site: Site, plan: CreatePlan): Promise<void> {
277
+ const collection = site.collections[plan.collection]!;
278
+ const outside = () => refused(400, 'bad-path', `"${printable(plan.file)}" leads outside ${collection.label}'s folder through a link.`);
279
+ await mkdir(collection.dir, { recursive: true }); // the config's own folder (an empty collection may not have it yet)
280
+ const realDir = await realpath(collection.dir);
281
+ const inside = (p: string) => p === realDir || p.startsWith(realDir + sep);
282
+ const made: string[] = [];
283
+ try {
284
+ let at = realDir;
285
+ const folders = plan.path.split('/').slice(0, -1);
286
+ for (const [i, name] of folders.entries()) {
287
+ const next = join(at, name);
288
+ try {
289
+ await mkdir(next);
290
+ made.push(next);
291
+ at = next;
292
+ continue;
293
+ } catch (error) {
294
+ if (code(error) !== 'EEXIST') throw error;
295
+ }
296
+ const info = await lstat(next);
297
+ const real = info.isSymbolicLink() ? await realpath(next) : next;
298
+ if (!inside(real)) throw outside();
299
+ if (!(await stat(real)).isDirectory()) throw refused(400, 'bad-path', `"${printable(folders.slice(0, i + 1).join('/'))}" is a file in ${collection.label}, not a folder.`);
300
+ at = real;
301
+ }
302
+ // Once more, now the folders are there: the file's folder, resolved, is still inside.
303
+ const parent = await realpath(at);
304
+ if (!inside(parent)) throw outside();
305
+ const file = join(parent, basename(plan.absolute));
306
+ const temp = join(parent, `.${basename(plan.absolute)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
307
+ await writeFile(temp, plan.text, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
308
+ try {
309
+ await link(temp, file);
310
+ } catch (error) {
311
+ if (code(error) === 'EEXIST') throw refused(409, 'exists', plan.url ? `There's already a page at ${plan.url}.` : `There's already a document called "${plan.path}".`);
312
+ if (code(error) !== 'EPERM' && code(error) !== 'ENOTSUP' && code(error) !== 'EXDEV') throw error;
313
+ // A filesystem with no hard links: an exclusive write of the whole text at once.
314
+ try {
315
+ await writeFile(file, plan.text, { encoding: 'utf8', flag: 'wx', mode: 0o644 });
316
+ } catch (fallback) {
317
+ if (code(fallback) === 'EEXIST') throw refused(409, 'exists', `There's already a document called "${plan.path}".`);
318
+ throw fallback;
319
+ }
320
+ } finally {
321
+ await rm(temp, { force: true });
322
+ }
323
+ } catch (error) {
324
+ for (const dir of made.reverse()) await rmdir(dir).catch(() => {});
325
+ throw error;
326
+ }
327
+ }
328
+
329
+ /**
330
+ * An existing document's file on disk, through write.ts → `confineDocumentFile` (its
331
+ * folder resolved inside its collection's, the file itself not a link), with this
332
+ * route's codes: 400 `bad-path`, 404 `not-found`.
333
+ */
334
+ export async function confineOnDisk(site: Site, file: string) {
335
+ try {
336
+ return await confineDocumentFile(site, file);
337
+ } catch (error) {
338
+ if (error instanceof WriteRefused && !error.body.code) throw refused(error.status, error.status === 404 ? 'not-found' : 'bad-path', error.message);
339
+ throw error;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Remove the file (write.ts → `confineDocumentFile`: inside its collection's folder with
345
+ * links resolved, and not itself a link), then every folder it leaves empty, up to but
346
+ * never including the collection's folder.
347
+ */
348
+ export async function deleteOnDisk(site: Site, plan: DeletePlan): Promise<void> {
349
+ const confined = await confineOnDisk(site, plan.file);
350
+ const collection = site.collections[confined.collection]!;
351
+ const realDir = await realpath(collection.dir);
352
+ try {
353
+ await unlink(confined.absolute);
354
+ } catch (error) {
355
+ if (code(error) === 'ENOENT') throw refused(404, 'not-found', `${collection.label}: no document "${plan.path}".`);
356
+ throw error;
357
+ }
358
+ for (let dir = dirname(confined.absolute); dir !== realDir && dir.startsWith(realDir + sep); dir = dirname(dir)) {
359
+ try {
360
+ await rmdir(dir);
361
+ } catch {
362
+ break; // not empty (or gone): the folders above it stay too
363
+ }
364
+ }
365
+ }
366
+
367
+ // ── The routes ─────────────────────────────────────────────────────────────
368
+
369
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
370
+ new Response(JSON.stringify(body), {
371
+ status,
372
+ headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers },
373
+ });
374
+
375
+ /** What the routes need of the drafts store. */
376
+ export interface CreatesAndDeletes {
377
+ create(request: CreateRequest, context?: WriteContext): Promise<CreateResponse>;
378
+ delete(request: DeleteRequest, context?: WriteContext): Promise<DeleteResponse>;
379
+ }
380
+
381
+ async function handle(request: Request, who: { person?: Person | null }, run: (body: unknown, context: WriteContext) => Promise<unknown>, name: string): Promise<Response> {
382
+ try {
383
+ if (request.method !== 'POST') throw new WriteRefused(405, `The ${name} route only takes POST.`);
384
+ checkOrigin(request);
385
+ const type = request.headers.get('content-type') ?? '';
386
+ if (!/^application\/json\s*(;|$)/i.test(type)) throw new WriteRefused(415, 'Send JSON (Content-Type: application/json).');
387
+ const length = Number(request.headers.get('content-length') ?? 0);
388
+ if (length > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
389
+ const raw = await request.text();
390
+ if (Buffer.byteLength(raw) > MAX_CREATE_BODY_BYTES) throw new WriteRefused(413, 'That body is too large.');
391
+ let body: unknown;
392
+ try {
393
+ body = JSON.parse(raw);
394
+ } catch {
395
+ throw refused(400, 'bad-request', 'Malformed body: it isn\'t valid JSON.');
396
+ }
397
+ const context: WriteContext = { person: who.person ?? null, tab: tabOf(request), release: request.headers.get(RELEASE_HEADER) === '1', seq: seqOf(request) };
398
+ return json(200, await run(body, context));
399
+ } catch (error) {
400
+ if (error instanceof WriteRefused) return json(error.status, { error: error.message, ...error.body }, error.status === 405 ? { Allow: 'POST' } : {});
401
+ console.error(`[caracms] ${name} failed:`, error);
402
+ return json(500, { error: `That didn't work (${error instanceof Error ? error.message : String(error)}).` });
403
+ }
404
+ }
405
+
406
+ /** POST /_caracms/create: a Request in, a Response out, through the mode's store. */
407
+ export function handleCreate(request: Request, store: Pick<CreatesAndDeletes, 'create'>, who: { person?: Person | null } = {}): Promise<Response> {
408
+ return handle(request, who, (body, context) => store.create(parseCreateBody(body), context), 'create');
409
+ }
410
+
411
+ /** POST /_caracms/delete: a Request in, a Response out, through the mode's store. */
412
+ export function handleDelete(request: Request, store: Pick<CreatesAndDeletes, 'delete'>, who: { person?: Person | null } = {}): Promise<Response> {
413
+ return handle(request, who, (body, context) => store.delete(parseDeleteBody(body), context), 'delete');
414
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The drafts store (Decision 5): read a document, list documents, write a document,
3
+ * behind one seam (M5a adds creating and deleting one: create.ts has the rules). M3's implementation, here, is the site's content files on disk
4
+ * (local mode). M4's live mode swaps in Redis autosaves over the `drafts` copy
5
+ * behind the same three calls (../../live/drafts-store.ts); server/mode.ts picks one.
6
+ *
7
+ * Everything reads at request time (never imported, Decision 5), and keeps no state.
8
+ */
9
+ import { stat } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { SITE_SETTINGS_FILE, type ResolvedSite } from '../../config.ts';
12
+ import { diskSource, listDocuments, readDocument, type ContentSource } from '../../content.ts';
13
+ import type { AnyOpenedDocument, CreateRequest, CreateResponse, DeleteRequest, DeleteResponse, ListedDocument, MoveRequest, MoveResponse, OpenedDocument, ReplaceFileRequest, ReplaceFileResponse } from '../types.ts';
14
+ import { confineOnDisk, createOnDisk, deleteOnDisk, planCreate, planDelete, target } from './create.ts';
15
+ import { moveChanges, moveOnDisk, moveResponse, planMove, settingsWithoutRedirectFrom } from './move.ts';
16
+ import { replaceChanges, replaceResponse } from './replace.ts';
17
+ import { applyWrite, fileVersion, writeAtomic, type WriteContext, type WriteRequest, type WriteResponse } from './write.ts';
18
+
19
+ export type { AnyOpenedDocument, ListedDocument, OpenedDocument };
20
+
21
+ export interface DraftsStore {
22
+ /**
23
+ * Every document, in the editor's order (content.ts → listDocuments), with warnings:
24
+ * every collection's, the reserved `shared` and `settings` included (M5a Decision 8),
25
+ * each with its public URL and its kind; a shared block with its `usedOn`.
26
+ */
27
+ list(): Promise<ListedDocument[]>;
28
+ /** One document, of any kind (M5a). Throws content.ts's DocumentError ('not-found', 'bad-path', a warning code…). */
29
+ read(collection: string, path: string): Promise<AnyOpenedDocument>;
30
+ /**
31
+ * The store's content as a content.ts source (M5a Decision 9): what a preview's
32
+ * render reads other documents from. Local: the disk. Live: the `drafts` copy
33
+ * with every autosave on top.
34
+ */
35
+ source(): Promise<ContentSource>;
36
+ /**
37
+ * Write one document (write.ts: confined, atomic). Throws WriteRefused. `context`
38
+ * says who and which tab (M4): the live store needs it, the local one ignores it.
39
+ */
40
+ write(request: WriteRequest, context?: WriteContext): Promise<WriteResponse>;
41
+ /**
42
+ * Create a document (M5a Decision 11; create.ts has the rules, the same in both
43
+ * stores). Local: an exclusive create on disk. Live: the creator's tab takes the lock
44
+ * and the new document is its autosave, with no base in `drafts`. Throws WriteRefused.
45
+ */
46
+ create(request: CreateRequest, context?: WriteContext): Promise<CreateResponse>;
47
+ /**
48
+ * Delete a document (M5a Decision 12). Local: the file goes, then the folders it
49
+ * leaves empty, up to the collection's. Live: the lock, then a "gone" autosave the
50
+ * sweep commits as a removed file. Throws WriteRefused.
51
+ */
52
+ delete(request: DeleteRequest, context?: WriteContext): Promise<DeleteResponse>;
53
+ /**
54
+ * Change a document's address (pre-M7 item 8; move.ts has the rules, the same in both
55
+ * stores): the document moves, every link and reference to it is rewritten, and Site
56
+ * Settings gets a redirect from the old address. Local: on disk, the new file first and
57
+ * the old one last. Live: every change is an autosave under this tab's locks, all going
58
+ * out at the next Publish. Throws WriteRefused.
59
+ */
60
+ move(request: MoveRequest, context?: WriteContext): Promise<MoveResponse>;
61
+ /**
62
+ * M6c G4 (Decision 9): Replace… on a file. Every link to `from`, in every document, points
63
+ * at `to` (replace.ts has the rules, the same in both stores). Local: each file written
64
+ * as a local move writes them (no rollback). Live: autosaves under this tab's locks,
65
+ * landing whole or rolled back. Throws WriteRefused.
66
+ */
67
+ replaceFile(request: ReplaceFileRequest, context?: WriteContext): Promise<ReplaceFileResponse>;
68
+ }
69
+
70
+ type Site = Pick<ResolvedSite, 'root' | 'collections' | 'blocks'>;
71
+
72
+ /** The local store: the content files on disk. */
73
+ export function localDraftsStore(site: Site): DraftsStore {
74
+ return {
75
+ async list() {
76
+ const entries = await listDocuments(site);
77
+ return Promise.all(
78
+ entries.map(async (entry) => ({
79
+ ...entry,
80
+ mtimeMs: await stat(join(site.root, entry.file)).then((s) => s.mtimeMs, () => null),
81
+ })),
82
+ );
83
+ },
84
+ async read(collection, path) {
85
+ const read = await readDocument(site, collection, path);
86
+ const { mtimeMs } = await stat(join(site.root, read.entry.file));
87
+ return { ...read, mtimeMs, version: fileVersion(read.text) };
88
+ },
89
+ write(request) {
90
+ return applyWrite(site, request);
91
+ },
92
+ async create(request) {
93
+ const plan = planCreate(site, request, await listDocuments(site));
94
+ await createOnDisk(site, plan);
95
+ // Pre-M7 item 8: a redirect from this address goes, now a page is here.
96
+ const settings = join(site.root, SITE_SETTINGS_FILE);
97
+ const without = settingsWithoutRedirectFrom(site, await diskSource.readText(settings), plan.url);
98
+ if (without !== null) await writeAtomic(settings, without);
99
+ return { ok: true, collection: plan.collection, path: plan.path, file: plan.file, url: plan.url, version: fileVersion(plan.text) };
100
+ },
101
+ async delete(request) {
102
+ // The file first (a link out of the collection is refused before anything else), then the rules.
103
+ await confineOnDisk(site, target(site, request.collection, request.path).file);
104
+ const plan = planDelete(site, request, await listDocuments(site));
105
+ await deleteOnDisk(site, plan);
106
+ return { ok: true, collection: plan.collection, path: plan.path, file: plan.file };
107
+ },
108
+ async move(request) {
109
+ // The file first (a link out of the collection is refused before anything else), then the rules.
110
+ await confineOnDisk(site, target(site, request.collection, request.path).file);
111
+ const documents = await listDocuments(site);
112
+ const plan = planMove(site, request, documents);
113
+ const changes = await moveChanges(site, plan, documents, (file) => diskSource.readText(join(site.root, file)), true);
114
+ await moveOnDisk(site, plan, changes);
115
+ return moveResponse(site, plan, changes);
116
+ },
117
+ async replaceFile(request) {
118
+ const changes = await replaceChanges(site, request, await listDocuments(site), (file) => diskSource.readText(join(site.root, file)));
119
+ // As a local move writes its relinked files: each confined and atomic, in listing order.
120
+ for (const c of changes) await writeAtomic((await confineOnDisk(site, c.file)).absolute, c.text);
121
+ return replaceResponse(request, changes);
122
+ },
123
+ async source() {
124
+ return diskSource;
125
+ },
126
+ };
127
+ }