@minamocms/framework 0.0.0-stage → 0.10.1

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 (286) hide show
  1. package/CHANGELOG.md +234 -0
  2. package/LICENSE +105 -0
  3. package/README.md +133 -2
  4. package/dist/boot.d.ts +19 -0
  5. package/dist/boot.js +22 -0
  6. package/dist/config.d.ts +18 -0
  7. package/dist/config.js +44 -0
  8. package/dist/create-site/cli.d.ts +2 -0
  9. package/dist/create-site/cli.js +31 -0
  10. package/dist/create-site/generate-site.d.ts +12 -0
  11. package/dist/create-site/generate-site.js +168 -0
  12. package/dist/create-site/mint-token-cli.d.ts +2 -0
  13. package/dist/create-site/mint-token-cli.js +26 -0
  14. package/dist/create-site/mint-token.d.ts +7 -0
  15. package/dist/create-site/mint-token.js +49 -0
  16. package/dist/create-site/template/AGENTS.md +473 -0
  17. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  18. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  19. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  20. package/dist/create-site/template/content/menus/main.json +9 -0
  21. package/dist/create-site/template/content/pages/404.json +23 -0
  22. package/dist/create-site/template/content/pages/about/careers.json +22 -0
  23. package/dist/create-site/template/content/pages/about/team.json +22 -0
  24. package/dist/create-site/template/content/pages/about.json +22 -0
  25. package/dist/create-site/template/content/pages/docs/deployment.json +22 -0
  26. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +22 -0
  27. package/dist/create-site/template/content/pages/docs/getting-started.json +24 -0
  28. package/dist/create-site/template/content/pages/docs.json +22 -0
  29. package/dist/create-site/template/content/pages/index.json +182 -0
  30. package/dist/create-site/template/content/redirects.json +4 -0
  31. package/dist/create-site/template/dockerignore +6 -0
  32. package/dist/create-site/template/gitignore +11 -0
  33. package/dist/create-site/template/theme/assets/site.js +49 -0
  34. package/dist/create-site/template/theme/assets/style.css +1223 -0
  35. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  36. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  37. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  38. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  39. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  40. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  41. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  42. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  43. package/dist/create-site/template/theme/config/site_settings.json +11 -0
  44. package/dist/create-site/template/theme/layouts/theme.liquid +102 -0
  45. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  46. package/dist/create-site/template/theme/sections/cta-banner.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/hero.liquid +48 -0
  50. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  51. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  52. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  53. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  54. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  55. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  56. package/dist/create-site/template/theme/snippets/responsive-media.liquid +107 -0
  57. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  58. package/dist/create-site/template/vhost/Dockerfile +33 -0
  59. package/dist/create-site/template/vhost/docker-entrypoint.sh +58 -0
  60. package/dist/index.d.ts +2 -0
  61. package/dist/index.js +4 -0
  62. package/dist/media/drivers/driver.d.ts +11 -0
  63. package/dist/media/drivers/driver.js +1 -0
  64. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  65. package/dist/media/drivers/local-fs-driver.js +81 -0
  66. package/dist/media/filename.d.ts +2 -0
  67. package/dist/media/filename.js +54 -0
  68. package/dist/media/manage-media.d.ts +19 -0
  69. package/dist/media/manage-media.js +45 -0
  70. package/dist/media/seed-media-cli.d.ts +2 -0
  71. package/dist/media/seed-media-cli.js +22 -0
  72. package/dist/media/seed-media.d.ts +11 -0
  73. package/dist/media/seed-media.js +76 -0
  74. package/dist/migrations/index.d.ts +3 -0
  75. package/dist/migrations/index.js +83 -0
  76. package/dist/pull-site/cli.d.ts +2 -0
  77. package/dist/pull-site/cli.js +78 -0
  78. package/dist/pull-site/prompt-token.d.ts +16 -0
  79. package/dist/pull-site/prompt-token.js +69 -0
  80. package/dist/pull-site/prompts.d.ts +22 -0
  81. package/dist/pull-site/prompts.js +127 -0
  82. package/dist/pull-site/pull-site.d.ts +23 -0
  83. package/dist/pull-site/pull-site.js +211 -0
  84. package/dist/renderer/engine.d.ts +2 -0
  85. package/dist/renderer/engine.js +48 -0
  86. package/dist/renderer/render-cache.d.ts +11 -0
  87. package/dist/renderer/render-cache.js +11 -0
  88. package/dist/renderer/render-page.d.ts +34 -0
  89. package/dist/renderer/render-page.js +209 -0
  90. package/dist/renderer/theme-templates.d.ts +7 -0
  91. package/dist/renderer/theme-templates.js +82 -0
  92. package/dist/routes/admin-redirect.d.ts +5 -0
  93. package/dist/routes/admin-redirect.js +26 -0
  94. package/dist/routes/assets.d.ts +6 -0
  95. package/dist/routes/assets.js +30 -0
  96. package/dist/routes/batch.d.ts +10 -0
  97. package/dist/routes/batch.js +148 -0
  98. package/dist/routes/capabilities.d.ts +6 -0
  99. package/dist/routes/capabilities.js +27 -0
  100. package/dist/routes/content.d.ts +8 -0
  101. package/dist/routes/content.js +169 -0
  102. package/dist/routes/deleted-pages.d.ts +8 -0
  103. package/dist/routes/deleted-pages.js +9 -0
  104. package/dist/routes/drafts.d.ts +10 -0
  105. package/dist/routes/drafts.js +73 -0
  106. package/dist/routes/git.d.ts +8 -0
  107. package/dist/routes/git.js +158 -0
  108. package/dist/routes/index.d.ts +12 -0
  109. package/dist/routes/index.js +86 -0
  110. package/dist/routes/links.d.ts +8 -0
  111. package/dist/routes/links.js +18 -0
  112. package/dist/routes/media-public.d.ts +11 -0
  113. package/dist/routes/media-public.js +125 -0
  114. package/dist/routes/media.d.ts +9 -0
  115. package/dist/routes/media.js +80 -0
  116. package/dist/routes/menus.d.ts +8 -0
  117. package/dist/routes/menus.js +148 -0
  118. package/dist/routes/preview-revision.d.ts +10 -0
  119. package/dist/routes/preview-revision.js +96 -0
  120. package/dist/routes/preview.d.ts +10 -0
  121. package/dist/routes/preview.js +56 -0
  122. package/dist/routes/public.d.ts +10 -0
  123. package/dist/routes/public.js +123 -0
  124. package/dist/routes/publish.d.ts +10 -0
  125. package/dist/routes/publish.js +139 -0
  126. package/dist/routes/redirects.d.ts +8 -0
  127. package/dist/routes/redirects.js +132 -0
  128. package/dist/routes/search-public.d.ts +6 -0
  129. package/dist/routes/search-public.js +104 -0
  130. package/dist/routes/search.d.ts +8 -0
  131. package/dist/routes/search.js +15 -0
  132. package/dist/routes/settings.d.ts +10 -0
  133. package/dist/routes/settings.js +68 -0
  134. package/dist/routes/sitemap.d.ts +7 -0
  135. package/dist/routes/sitemap.js +79 -0
  136. package/dist/routes/theme.d.ts +10 -0
  137. package/dist/routes/theme.js +116 -0
  138. package/dist/schemas/instance.schema.json +17 -0
  139. package/dist/schemas/menu.schema.json +24 -0
  140. package/dist/schemas/page.schema.json +26 -0
  141. package/dist/schemas/redirects.schema.json +24 -0
  142. package/dist/search/drivers/driver.d.ts +9 -0
  143. package/dist/search/drivers/driver.js +1 -0
  144. package/dist/search/drivers/node-sqlite-driver.d.ts +7 -0
  145. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  146. package/dist/search/query-content.d.ts +32 -0
  147. package/dist/search/query-content.js +207 -0
  148. package/dist/search/query-index.d.ts +5 -0
  149. package/dist/search/query-index.js +21 -0
  150. package/dist/search/rebuild-index.d.ts +3 -0
  151. package/dist/search/rebuild-index.js +320 -0
  152. package/dist/server-config.d.ts +23 -0
  153. package/dist/server-config.js +237 -0
  154. package/dist/server.d.ts +13 -0
  155. package/dist/server.js +235 -0
  156. package/dist/services/batch.d.ts +35 -0
  157. package/dist/services/batch.js +145 -0
  158. package/dist/services/checkpoint.d.ts +4 -0
  159. package/dist/services/checkpoint.js +45 -0
  160. package/dist/services/content-read.d.ts +27 -0
  161. package/dist/services/content-read.js +151 -0
  162. package/dist/services/delete-content.d.ts +12 -0
  163. package/dist/services/delete-content.js +166 -0
  164. package/dist/services/deleted-pages.d.ts +11 -0
  165. package/dist/services/deleted-pages.js +83 -0
  166. package/dist/services/dev-tunnel.d.ts +5 -0
  167. package/dist/services/dev-tunnel.js +5 -0
  168. package/dist/services/drafts.d.ts +16 -0
  169. package/dist/services/drafts.js +164 -0
  170. package/dist/services/etag.d.ts +2 -0
  171. package/dist/services/etag.js +30 -0
  172. package/dist/services/fs-walk.d.ts +1 -0
  173. package/dist/services/fs-walk.js +26 -0
  174. package/dist/services/git-commit.d.ts +3 -0
  175. package/dist/services/git-commit.js +12 -0
  176. package/dist/services/git-history.d.ts +28 -0
  177. package/dist/services/git-history.js +131 -0
  178. package/dist/services/git-revert.d.ts +12 -0
  179. package/dist/services/git-revert.js +140 -0
  180. package/dist/services/git.d.ts +16 -0
  181. package/dist/services/git.js +127 -0
  182. package/dist/services/interval-job.d.ts +4 -0
  183. package/dist/services/interval-job.js +9 -0
  184. package/dist/services/ip-allowlist.d.ts +7 -0
  185. package/dist/services/ip-allowlist.js +31 -0
  186. package/dist/services/links.d.ts +22 -0
  187. package/dist/services/links.js +169 -0
  188. package/dist/services/manage-menus.d.ts +15 -0
  189. package/dist/services/manage-menus.js +138 -0
  190. package/dist/services/manage-redirects.d.ts +25 -0
  191. package/dist/services/manage-redirects.js +156 -0
  192. package/dist/services/menu-references.d.ts +3 -0
  193. package/dist/services/menu-references.js +38 -0
  194. package/dist/services/menus.d.ts +9 -0
  195. package/dist/services/menus.js +43 -0
  196. package/dist/services/migration-runner.d.ts +16 -0
  197. package/dist/services/migration-runner.js +169 -0
  198. package/dist/services/mime-types.d.ts +3 -0
  199. package/dist/services/mime-types.js +43 -0
  200. package/dist/services/move.d.ts +16 -0
  201. package/dist/services/move.js +236 -0
  202. package/dist/services/path-safety.d.ts +6 -0
  203. package/dist/services/path-safety.js +112 -0
  204. package/dist/services/pid-file.d.ts +13 -0
  205. package/dist/services/pid-file.js +54 -0
  206. package/dist/services/post-urls.d.ts +3 -0
  207. package/dist/services/post-urls.js +27 -0
  208. package/dist/services/prepared-operation.d.ts +4 -0
  209. package/dist/services/prepared-operation.js +1 -0
  210. package/dist/services/publish.d.ts +15 -0
  211. package/dist/services/publish.js +313 -0
  212. package/dist/services/rate-limit-config.d.ts +9 -0
  213. package/dist/services/rate-limit-config.js +22 -0
  214. package/dist/services/redirects.d.ts +27 -0
  215. package/dist/services/redirects.js +175 -0
  216. package/dist/services/reindex-on-write.d.ts +3 -0
  217. package/dist/services/reindex-on-write.js +30 -0
  218. package/dist/services/resolve-blog-url.d.ts +11 -0
  219. package/dist/services/resolve-blog-url.js +31 -0
  220. package/dist/services/resolve-url.d.ts +11 -0
  221. package/dist/services/resolve-url.js +20 -0
  222. package/dist/services/site-settings.d.ts +28 -0
  223. package/dist/services/site-settings.js +116 -0
  224. package/dist/services/startup-checks.d.ts +10 -0
  225. package/dist/services/startup-checks.js +81 -0
  226. package/dist/services/static-file.d.ts +7 -0
  227. package/dist/services/static-file.js +37 -0
  228. package/dist/services/theme-component-file.d.ts +6 -0
  229. package/dist/services/theme-component-file.js +39 -0
  230. package/dist/services/theme-files.d.ts +33 -0
  231. package/dist/services/theme-files.js +148 -0
  232. package/dist/services/theme-page-templates.d.ts +7 -0
  233. package/dist/services/theme-page-templates.js +53 -0
  234. package/dist/services/theme-schemas.d.ts +2 -0
  235. package/dist/services/theme-schemas.js +137 -0
  236. package/dist/services/token-auth.d.ts +10 -0
  237. package/dist/services/token-auth.js +64 -0
  238. package/dist/services/urls.d.ts +2 -0
  239. package/dist/services/urls.js +20 -0
  240. package/dist/services/validation.d.ts +26 -0
  241. package/dist/services/validation.js +195 -0
  242. package/dist/services/write-queue.d.ts +1 -0
  243. package/dist/services/write-queue.js +14 -0
  244. package/dist/site-check/cli.d.ts +2 -0
  245. package/dist/site-check/cli.js +48 -0
  246. package/dist/site-check/run-check.d.ts +12 -0
  247. package/dist/site-check/run-check.js +188 -0
  248. package/dist/site-sync/cli-helpers.d.ts +23 -0
  249. package/dist/site-sync/cli-helpers.js +75 -0
  250. package/dist/site-sync/deploy.d.ts +36 -0
  251. package/dist/site-sync/deploy.js +124 -0
  252. package/dist/site-sync/live-content.d.ts +30 -0
  253. package/dist/site-sync/live-content.js +68 -0
  254. package/dist/site-sync/prompts.d.ts +30 -0
  255. package/dist/site-sync/prompts.js +237 -0
  256. package/dist/site-sync/pull-cli.d.ts +2 -0
  257. package/dist/site-sync/pull-cli.js +75 -0
  258. package/dist/site-sync/pull-site.d.ts +28 -0
  259. package/dist/site-sync/pull-site.js +171 -0
  260. package/dist/site-sync/push-cli.d.ts +2 -0
  261. package/dist/site-sync/push-cli.js +268 -0
  262. package/dist/site-sync/push-site.d.ts +96 -0
  263. package/dist/site-sync/push-site.js +376 -0
  264. package/dist/site-sync/remote-site.d.ts +38 -0
  265. package/dist/site-sync/remote-site.js +122 -0
  266. package/dist/site-sync/sync-record.d.ts +26 -0
  267. package/dist/site-sync/sync-record.js +76 -0
  268. package/dist/site-sync/theme-sync.d.ts +34 -0
  269. package/dist/site-sync/theme-sync.js +128 -0
  270. package/dist/stop-site/cli.d.ts +2 -0
  271. package/dist/stop-site/cli.js +28 -0
  272. package/dist/stop-site/stop-site.d.ts +26 -0
  273. package/dist/stop-site/stop-site.js +56 -0
  274. package/dist/theme-state.d.ts +20 -0
  275. package/dist/theme-state.js +33 -0
  276. package/dist/upgrade/changelog.d.ts +8 -0
  277. package/dist/upgrade/changelog.js +46 -0
  278. package/dist/upgrade/finish.d.ts +9 -0
  279. package/dist/upgrade/finish.js +30 -0
  280. package/dist/upgrade/owned-files.d.ts +11 -0
  281. package/dist/upgrade/owned-files.js +42 -0
  282. package/dist/upgrade/package-names.d.ts +4 -0
  283. package/dist/upgrade/package-names.js +26 -0
  284. package/dist/upgrade/upgrade-cli.d.ts +2 -0
  285. package/dist/upgrade/upgrade-cli.js +170 -0
  286. package/package.json +64 -4
@@ -0,0 +1,166 @@
1
+ import { existsSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { listFilesRecursively } from "./fs-walk.js";
3
+ import { commitPaths } from "./git.js";
4
+ import { sanitisePath } from "./path-safety.js";
5
+ import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirects, serialiseRedirects, } from "./redirects.js";
6
+ import { pagePathToUrl } from "./urls.js";
7
+ import { enqueue } from "./write-queue.js";
8
+ import { reindexInBackground } from "./reindex-on-write.js";
9
+ const PAGES_PREFIX = 'pages/';
10
+ // Returns null for anything else (menus have no public URL at all, so
11
+ // redirects are meaningless there).
12
+ function urlForDeletedEntry(relativePath) {
13
+ if (relativePath.startsWith(PAGES_PREFIX)) {
14
+ return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
15
+ }
16
+ return null;
17
+ }
18
+ export class DeleteContentError extends Error {
19
+ reason;
20
+ constructor(reason, message, options) {
21
+ super(message, options);
22
+ this.name = 'DeleteContentError';
23
+ this.reason = reason;
24
+ }
25
+ }
26
+ // Validates and writes everything a delete needs, stopping short of
27
+ // the commit itself - used both by the standalone deleteContentJob
28
+ // (which commits immediately after) and by batch.ts (which accumulates
29
+ // this alongside other operations and commits once for the whole
30
+ // batch). A write-phase failure (including a client-triggered
31
+ // redirect-cycle) is caught and rolled back here, inside prepare
32
+ // itself, so prepare either fully succeeds or fully undoes itself
33
+ // before throwing.
34
+ export function prepareDeleteContent(config, relativePath, redirectTo) {
35
+ const livePath = sanitisePath(config.contentRoot, relativePath);
36
+ if (!existsSync(livePath)) {
37
+ throw new DeleteContentError('page-not-found', `No live page found at "${relativePath}"`);
38
+ }
39
+ // Subtree rejection: a deliberate scope decision (reject outright,
40
+ // not cascade) - an accidental single DELETE can't silently take a
41
+ // whole subtree with it. "Has children" means a real child page
42
+ // exists (checked via listFilesRecursively), never bare directory
43
+ // existence: a leftover *empty* sibling directory would otherwise
44
+ // make this page permanently undeletable, since no rmdir/cleanup
45
+ // endpoint exists anywhere in this API to ever clear it.
46
+ if (relativePath.startsWith(PAGES_PREFIX)) {
47
+ const dirRelative = relativePath.slice(PAGES_PREFIX.length).replace(/\.json$/, '');
48
+ const dirPath = sanitisePath(config.pagesRoot, dirRelative);
49
+ if (existsSync(dirPath) &&
50
+ statSync(dirPath).isDirectory() &&
51
+ listFilesRecursively(dirPath, dirPath, '.json').length > 0) {
52
+ throw new DeleteContentError('has-children', `"${relativePath}" has child pages; delete them first`);
53
+ }
54
+ }
55
+ // Never touches a draft at the same path: /v1/content and /v1/drafts
56
+ // are separate resources with their own dedicated DELETEs, and a
57
+ // draft-only page is already a legitimate, independently-listable
58
+ // state (Group D's listContent unions contentRoot/draftsRoot).
59
+ const original = readFileSync(livePath);
60
+ let redirectsBefore = null;
61
+ let redirectsChanged = false;
62
+ // Reverses the unlink and restores/removes redirects.json. Matches
63
+ // publish.ts's/move.ts's rollback() philosophy: attempt everything,
64
+ // report every failure.
65
+ function undoDeleteWrites() {
66
+ const restoreFailures = [];
67
+ try {
68
+ writeFileSync(livePath, original);
69
+ }
70
+ catch (restoreError) {
71
+ restoreFailures.push(restoreError);
72
+ }
73
+ if (redirectsChanged) {
74
+ try {
75
+ if (redirectsBefore === null) {
76
+ rmSync(config.redirectsPath, { force: true });
77
+ }
78
+ else {
79
+ writeFileSync(config.redirectsPath, redirectsBefore);
80
+ }
81
+ }
82
+ catch (restoreError) {
83
+ restoreFailures.push(restoreError);
84
+ }
85
+ }
86
+ return restoreFailures;
87
+ }
88
+ try {
89
+ unlinkSync(livePath);
90
+ // redirectTo is client-supplied, unlike publish.ts/move.ts's own
91
+ // automatic redirect bookkeeping - a client can trivially trigger a
92
+ // real cycle (e.g. redirectTo pointing back into an existing
93
+ // chain), so that's surfaced as its own reason, not left to fall
94
+ // into the generic write-failed/500 bucket an unlikely automatic-
95
+ // bookkeeping cycle would. Format is validated the same way as the
96
+ // dedicated manage-redirects.ts endpoints, internal-paths-only,
97
+ // so the two write paths never disagree on what's allowed into
98
+ // redirects.json.
99
+ const fromUrl = urlForDeletedEntry(relativePath);
100
+ if (redirectTo !== undefined && fromUrl !== null) {
101
+ if (!isValidRedirectTarget(redirectTo)) {
102
+ throw new DeleteContentError('invalid-redirect-target', `redirectTo "${redirectTo}" must be an internal path starting with "/"`);
103
+ }
104
+ redirectsBefore = existsSync(config.redirectsPath)
105
+ ? readFileSync(config.redirectsPath, 'utf-8')
106
+ : null;
107
+ let entries;
108
+ try {
109
+ entries = addRedirect(loadRedirects(config).entries, fromUrl, redirectTo).entries;
110
+ }
111
+ catch (error) {
112
+ if (error instanceof RedirectError) {
113
+ throw new DeleteContentError('redirect-cycle', error.message, { cause: error });
114
+ }
115
+ throw error;
116
+ }
117
+ const serialised = serialiseRedirects(entries);
118
+ if (serialised !== (redirectsBefore ?? '')) {
119
+ writeFileSync(config.redirectsPath, serialised);
120
+ redirectsChanged = true;
121
+ }
122
+ }
123
+ }
124
+ catch (error) {
125
+ const failures = undoDeleteWrites();
126
+ if (failures.length > 0) {
127
+ throw new DeleteContentError('rollback-failed', 'Delete failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
128
+ }
129
+ // A redirect-cycle is already a DeleteContentError with its own
130
+ // reason - preserve it rather than reclassifying it as write-failed.
131
+ if (error instanceof DeleteContentError) {
132
+ throw error;
133
+ }
134
+ // Always write-failed here, structurally: commitPaths hasn't been
135
+ // called yet, so this can never be a GitOperationError.
136
+ const detail = error instanceof Error ? error.message : String(error);
137
+ throw new DeleteContentError('write-failed', `Delete failed: ${detail}`, { cause: error });
138
+ }
139
+ const paths = [livePath];
140
+ if (redirectsChanged) {
141
+ paths.push(config.redirectsPath);
142
+ }
143
+ return { paths, undo: undoDeleteWrites };
144
+ }
145
+ async function deleteContentJob(config, relativePath, redirectTo, message, author) {
146
+ const { paths, undo } = prepareDeleteContent(config, relativePath, redirectTo);
147
+ try {
148
+ commitPaths(config.siteRoot, paths, message, author);
149
+ }
150
+ catch (error) {
151
+ const failures = undo();
152
+ if (failures.length > 0) {
153
+ throw new DeleteContentError('rollback-failed', 'Delete failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
154
+ }
155
+ // Always commit-failed here, structurally: prepare already
156
+ // succeeded, so anything caught in this try can only have come
157
+ // from commitPaths itself.
158
+ const detail = error instanceof Error ? error.message : String(error);
159
+ throw new DeleteContentError('commit-failed', `Delete failed: ${detail}`, { cause: error });
160
+ }
161
+ }
162
+ export function deleteContent(config, relativePath, redirectTo, message, author) {
163
+ const result = enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
164
+ result.then(() => reindexInBackground(config), () => undefined);
165
+ return result;
166
+ }
@@ -0,0 +1,11 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface DeletedPage {
3
+ path: string;
4
+ url: string;
5
+ name: string;
6
+ title: string;
7
+ deletedAt: string;
8
+ deletedBy: string;
9
+ ref: string;
10
+ }
11
+ export declare function listDeletedPages(config: SiteConfig, limit?: number): DeletedPage[];
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { readFileAtRevision } from "./git-history.js";
4
+ import { sanitisePath } from "./path-safety.js";
5
+ import { pagePathToUrl } from "./urls.js";
6
+ const FIELD_SEP = '\x1f';
7
+ const RECORD_SEP = '\x1e';
8
+ const DEFAULT_LIMIT = 50;
9
+ // How far back "recently" reaches (agreed with the owner). Older deletions
10
+ // can still be restored from git; they just aren't listed.
11
+ const RECENT_DAYS = 90;
12
+ function hasCommits(config) {
13
+ try {
14
+ execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd: config.siteRoot, stdio: 'ignore' });
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ function existsNow(config, path) {
22
+ try {
23
+ return existsSync(sanitisePath(config.contentRoot, path)) || (existsSync(config.draftsRoot) && existsSync(sanitisePath(config.draftsRoot, path)));
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ function nonEmpty(value) {
30
+ return typeof value === 'string' && value.trim() !== '' ? value : null;
31
+ }
32
+ // The page's name and title as they were at ref, each falling back to
33
+ // the other (and to its address) when missing.
34
+ function labelsAt(config, ref, path, url) {
35
+ let name = null;
36
+ let title = null;
37
+ try {
38
+ const page = JSON.parse(readFileAtRevision(config, ref, `content/${path}`).toString('utf-8'));
39
+ name = nonEmpty(page.name);
40
+ title = nonEmpty(page.title);
41
+ }
42
+ catch {
43
+ // Unreadable at ref: fall back to the address.
44
+ }
45
+ return { name: name ?? title ?? url, title: title ?? name ?? url };
46
+ }
47
+ export function listDeletedPages(config, limit = DEFAULT_LIMIT) {
48
+ if (!hasCommits(config)) {
49
+ return [];
50
+ }
51
+ const raw = execFileSync('git', ['log', `--since=${RECENT_DAYS} days ago`, '--diff-filter=D', '--name-only', `--format=${RECORD_SEP}%P${FIELD_SEP}%an${FIELD_SEP}%aI`, '--', 'content/pages'], { cwd: config.siteRoot, maxBuffer: 32 * 1024 * 1024 }).toString('utf-8');
52
+ const seen = new Set();
53
+ const deleted = [];
54
+ for (const record of raw.split(RECORD_SEP)) {
55
+ const [header, ...files] = record.split('\n');
56
+ // %P: the deleting commit's parent - the last version with the page.
57
+ // A full commit id, since refs like "abc^" aren't accepted.
58
+ const [parents, author, date] = (header ?? '').split(FIELD_SEP);
59
+ const parent = parents?.split(' ')[0];
60
+ if (!parent || !author || !date) {
61
+ continue;
62
+ }
63
+ for (const file of files) {
64
+ const path = file.trim().replace(/^content\//, '');
65
+ if (!/^pages\/.+\.json$/.test(path) || seen.has(path)) {
66
+ continue;
67
+ }
68
+ // Most recent deletion first: an older one of the same path is
69
+ // history, not something to restore.
70
+ seen.add(path);
71
+ if (existsNow(config, path)) {
72
+ continue;
73
+ }
74
+ const ref = parent;
75
+ const url = pagePathToUrl(path.slice('pages/'.length));
76
+ deleted.push({ path, url, ...labelsAt(config, ref, path, url), deletedAt: date, deletedBy: author, ref });
77
+ if (deleted.length >= limit) {
78
+ return deleted;
79
+ }
80
+ }
81
+ }
82
+ return deleted;
83
+ }
@@ -0,0 +1,5 @@
1
+ export interface DevTunnel {
2
+ url: string;
3
+ close: () => void;
4
+ }
5
+ export declare function startDevTunnel(port: number): Promise<DevTunnel>;
@@ -0,0 +1,5 @@
1
+ import localtunnel from 'localtunnel';
2
+ export async function startDevTunnel(port) {
3
+ const tunnel = await localtunnel({ port });
4
+ return { url: tunnel.url, close: () => tunnel.close() };
5
+ }
@@ -0,0 +1,16 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { PreparedOperation } from './prepared-operation.ts';
3
+ import type { ThemeSchemas, ValidationError } from './validation.ts';
4
+ export declare function ensureDraftsRoot(config: SiteConfig): void;
5
+ export type DraftReason = 'validation-failed' | 'conflict';
6
+ export declare class DraftError extends Error {
7
+ readonly reason: DraftReason;
8
+ readonly errors: ValidationError[];
9
+ constructor(reason: DraftReason, message: string, errors?: ValidationError[]);
10
+ }
11
+ export declare function saveDraft(config: SiteConfig, themeSchemas: ThemeSchemas, relativePath: string, content: unknown, expectedEtag: string): Promise<string>;
12
+ export declare function discardDraft(config: SiteConfig, relativePath: string): Promise<void>;
13
+ export declare function prepareSaveDraft(config: SiteConfig, themeSchemas: ThemeSchemas, relativePath: string, content: unknown, expectedEtag: string): Promise<PreparedOperation & {
14
+ etag: string;
15
+ }>;
16
+ export declare function prepareDiscardDraft(config: SiteConfig, relativePath: string): Promise<PreparedOperation>;
@@ -0,0 +1,164 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { ContentReadError, readContentFile } from "./content-read.js";
4
+ import { computeEtag, etagsMatch } from "./etag.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { validateContent } from "./validation.js";
7
+ import { enqueue } from "./write-queue.js";
8
+ // content/drafts/ may not exist: git keeps no empty folders, so a site
9
+ // cloned or deployed with no drafts has none. Every draft read and write
10
+ // resolves its path under this folder (sanitisePath needs the root to
11
+ // exist), so without it the first draft save failed with a 500 - found
12
+ // running a fresh clone of a real site. Made once at start-up, before any
13
+ // request, rather than on every write.
14
+ export function ensureDraftsRoot(config) {
15
+ mkdirSync(config.draftsRoot, { recursive: true });
16
+ }
17
+ export class DraftError extends Error {
18
+ reason;
19
+ // Group I: the real ajv errors (already field-pointing, e.g.
20
+ // /sections/0/settings/columns) - previously only available
21
+ // stringified inside .message. Always [] for the 'conflict' reason.
22
+ errors;
23
+ constructor(reason, message, errors = []) {
24
+ super(message);
25
+ this.name = 'DraftError';
26
+ this.reason = reason;
27
+ this.errors = errors;
28
+ }
29
+ }
30
+ // Draft takes precedence over live: the ETag a client's If-Match must
31
+ // match is whatever they would have read from a prior GET, and D1/D2
32
+ // already prefer the draft over the live file at the same path. Reuses
33
+ // readContentFile/computeEtag (etag.ts's own doc comment anticipates
34
+ // this exact reuse) rather than reimplementing hashing here. Returns
35
+ // null when neither a draft nor a live file exists yet (a brand-new
36
+ // page) - see saveDraftJob's own comment for what that means.
37
+ function readCurrentEtag(config, relativePath) {
38
+ try {
39
+ return readContentFile(config.draftsRoot, relativePath).etag;
40
+ }
41
+ catch (error) {
42
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
43
+ throw error;
44
+ }
45
+ }
46
+ try {
47
+ return readContentFile(config.contentRoot, relativePath).etag;
48
+ }
49
+ catch (error) {
50
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
51
+ throw error;
52
+ }
53
+ return null;
54
+ }
55
+ }
56
+ async function saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag) {
57
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
58
+ // The If-Match check happens here, inside the queued job, never at
59
+ // the route layer before enqueuing: the queue only serialises the
60
+ // writes themselves, not a check that happened earlier, so a
61
+ // route-layer check-then-enqueue has a real TOCTOU gap (verified
62
+ // empirically: run 2000 times, a check-before-enqueue design lets
63
+ // both of two racing requests succeed every single time; the same
64
+ // check run inside the job, like this, produced exactly one winner
65
+ // every time, no exceptions).
66
+ const currentEtag = readCurrentEtag(config, relativePath);
67
+ if (currentEtag !== null && !etagsMatch(currentEtag, expectedEtag)) {
68
+ throw new DraftError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${relativePath}"`);
69
+ }
70
+ // currentEtag === null means neither a draft nor a live file exists
71
+ // yet (a brand-new page) - deliberately not checked further here.
72
+ // This isn't a race loophole: the *next* write to this same path
73
+ // lands after this job's write has created a real draft, so that
74
+ // job's own currentEtag is no longer null and gets checked for
75
+ // real. Only the very first write to a wholly new path skips the
76
+ // comparison, and nothing (E1-E6) tests or specifies what that first
77
+ // write's If-Match value should mean.
78
+ // Validate before touching disk at all, so a validation failure
79
+ // writes nothing (checklist C3) structurally, not via cleanup.
80
+ const result = validateContent(relativePath, content, themeSchemas);
81
+ if (!result.valid) {
82
+ throw new DraftError('validation-failed', `Draft at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`, result.errors);
83
+ }
84
+ mkdirSync(dirname(draftPath), { recursive: true });
85
+ const bytes = Buffer.from(JSON.stringify(content, null, 2));
86
+ writeFileSync(draftPath, bytes);
87
+ // No git call: saves are cheap and frequent, and never create history
88
+ // (checklist C2, constraint 5).
89
+ // Computed from the exact bytes just written, never a second disk
90
+ // read (inside or outside the queue) - a second read could itself
91
+ // race a subsequent queued job and hand the client a stale value.
92
+ return computeEtag(bytes);
93
+ }
94
+ async function discardDraftJob(config, relativePath) {
95
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
96
+ // Idempotent, matching DELETE semantics: silently succeed if the
97
+ // draft is already absent rather than inventing a not-found error
98
+ // the checklist doesn't ask for.
99
+ if (existsSync(draftPath)) {
100
+ unlinkSync(draftPath);
101
+ }
102
+ // No git call: discard never touches /content/ or history.
103
+ }
104
+ export function saveDraft(config, themeSchemas, relativePath, content, expectedEtag) {
105
+ return enqueue(() => saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag));
106
+ }
107
+ export function discardDraft(config, relativePath) {
108
+ return enqueue(() => discardDraftJob(config, relativePath));
109
+ }
110
+ // The batch.ts-composable shape, mirroring publish.ts/move.ts/
111
+ // delete-content.ts's own prepare* exports for a single uniform
112
+ // vocabulary - even though saveDraftJob/discardDraftJob never call
113
+ // commitPaths at all (drafts are never git-tracked, so paths is
114
+ // always []), batch.ts still needs an undo for them: F6 requires "no
115
+ // partial writes" for a whole failed batch, which includes rolling
116
+ // back an earlier draft write if a later operation in the same batch
117
+ // fails. Snapshots the draft's prior bytes/existence *before*
118
+ // delegating to the existing job function, rather than reimplementing
119
+ // its logic here.
120
+ export async function prepareSaveDraft(config, themeSchemas, relativePath, content, expectedEtag) {
121
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
122
+ const existedBefore = existsSync(draftPath);
123
+ const originalBytes = existedBefore ? readFileSync(draftPath) : null;
124
+ const etag = await saveDraftJob(config, themeSchemas, relativePath, content, expectedEtag);
125
+ return {
126
+ paths: [],
127
+ etag,
128
+ undo: () => {
129
+ try {
130
+ if (existedBefore) {
131
+ writeFileSync(draftPath, originalBytes);
132
+ }
133
+ else {
134
+ unlinkSync(draftPath);
135
+ }
136
+ return [];
137
+ }
138
+ catch (error) {
139
+ return [error];
140
+ }
141
+ },
142
+ };
143
+ }
144
+ export async function prepareDiscardDraft(config, relativePath) {
145
+ const draftPath = sanitisePath(config.draftsRoot, relativePath);
146
+ const existedBefore = existsSync(draftPath);
147
+ const originalBytes = existedBefore ? readFileSync(draftPath) : null;
148
+ await discardDraftJob(config, relativePath);
149
+ return {
150
+ paths: [],
151
+ undo: () => {
152
+ try {
153
+ if (existedBefore) {
154
+ mkdirSync(dirname(draftPath), { recursive: true });
155
+ writeFileSync(draftPath, originalBytes);
156
+ }
157
+ return [];
158
+ }
159
+ catch (error) {
160
+ return [error];
161
+ }
162
+ },
163
+ };
164
+ }
@@ -0,0 +1,2 @@
1
+ export declare function computeEtag(bytes: Buffer): string;
2
+ export declare function etagsMatch(a: string, b: string): boolean;
@@ -0,0 +1,30 @@
1
+ import { createHash } from 'node:crypto';
2
+ // A plain content hash, not a git blob hash: git.ts only shells out to
3
+ // git for actual commits, never for a per-request read, and nothing
4
+ // downstream needs the ETag to coincide with a real git blob hash
5
+ // (the git log/show endpoints address content by ref+path, not by
6
+ // hash lookup). A quoted strong ETag, matching HTTP's own syntax -
7
+ // this is a byte-for-byte digest, so strong semantics are correct.
8
+ //
9
+ // The one hash function every ETag-producing read (D1/D2) and every
10
+ // future If-Match comparison (Group E) must share - never inlined
11
+ // separately, or the two could silently drift.
12
+ export function computeEtag(bytes) {
13
+ return `"${createHash('sha256').update(bytes).digest('hex')}"`;
14
+ }
15
+ // RFC 7232 weak comparison: a compressing proxy sitting in front of a
16
+ // real deployment (confirmed live against a Railway-hosted admin - its
17
+ // edge downgrades this server's own strong ETag to weak in transit,
18
+ // since the compressed bytes on the wire differ from what computeEtag
19
+ // hashed) is standard, spec-compliant proxy behaviour, not a bug on
20
+ // the proxy's part. A client that captured and echoes back that
21
+ // now-weak value represents the exact same content a strict `===`
22
+ // would wrongly reject as changed - every If-Match check must compare
23
+ // this way, never with a bare `!==`, or any real host behind a
24
+ // compressing proxy/CDN produces spurious conflicts on every save.
25
+ function stripWeakPrefix(etag) {
26
+ return etag.startsWith('W/') ? etag.slice(2) : etag;
27
+ }
28
+ export function etagsMatch(a, b) {
29
+ return stripWeakPrefix(a) === stripWeakPrefix(b);
30
+ }
@@ -0,0 +1 @@
1
+ export declare function listFilesRecursively(dir: string, base: string, extension: string): string[];
@@ -0,0 +1,26 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ // Recursively lists files matching extension under dir, returned as
4
+ // paths relative to base. Agent-configured roots only (content/drafts
5
+ // roots, theme directories), never a request-supplied :path - callers
6
+ // that walk request-shaped paths must go through sanitisePath instead.
7
+ export function listFilesRecursively(dir, base, extension) {
8
+ let out = [];
9
+ let entries;
10
+ try {
11
+ entries = readdirSync(dir, { withFileTypes: true });
12
+ }
13
+ catch {
14
+ return out;
15
+ }
16
+ for (const entry of entries) {
17
+ const full = join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ out = out.concat(listFilesRecursively(full, base, extension));
20
+ }
21
+ else if (entry.isFile() && entry.name.endsWith(extension)) {
22
+ out.push(relative(base, full));
23
+ }
24
+ }
25
+ return out;
26
+ }
@@ -0,0 +1,3 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ export declare function commitWorkingTreeChanges(config: SiteConfig, message: string, author: CommitAuthor): Promise<'committed' | 'clean'>;
@@ -0,0 +1,12 @@
1
+ import { commitWorkingTree } from "./git.js";
2
+ import { enqueue } from "./write-queue.js";
3
+ // The escape hatch (checklist G5). No PreparedOperation/undo-stack
4
+ // here, unlike publish/move/delete/revert: those services write new
5
+ // content themselves on request, so a write can fail independently of
6
+ // the git step, needing an undo. Here the "write" already exists in
7
+ // the working tree before this is ever called - the only failure
8
+ // modes are git add/git commit themselves, which commitWorkingTree's
9
+ // own bare `git reset` already unwinds (git.ts).
10
+ export function commitWorkingTreeChanges(config, message, author) {
11
+ return enqueue(() => Promise.resolve(commitWorkingTree(config.siteRoot, message, author)));
12
+ }
@@ -0,0 +1,28 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface LogEntry {
3
+ hash: string;
4
+ author: {
5
+ name: string;
6
+ email: string;
7
+ };
8
+ date: string;
9
+ message: string;
10
+ isCheckpoint: boolean;
11
+ }
12
+ export interface LogResult {
13
+ commits: LogEntry[];
14
+ hasMore: boolean;
15
+ }
16
+ export declare const CHECKPOINT_MESSAGE = "chore: draft checkpoint";
17
+ export declare function getCommitLog(config: SiteConfig, options: {
18
+ path?: string;
19
+ limit?: number;
20
+ }): LogResult;
21
+ export type GitShowReason = 'invalid-ref' | 'not-found-at-ref';
22
+ export declare class GitShowError extends Error {
23
+ readonly reason: GitShowReason;
24
+ constructor(reason: GitShowReason, message: string, options?: {
25
+ cause?: unknown;
26
+ });
27
+ }
28
+ export declare function readFileAtRevision(config: SiteConfig, ref: string, relativePath: string): Buffer;