@minamocms/framework 0.0.0-stage → 0.10.2

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 +240 -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 +94 -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
package/dist/server.js ADDED
@@ -0,0 +1,235 @@
1
+ import rateLimitPlugin from '@fastify/rate-limit';
2
+ import Fastify, {} from 'fastify';
3
+ import { bootSite } from "./boot.js";
4
+ import { loadServerConfig } from "./server-config.js";
5
+ import { adminRedirectRoutes } from "./routes/admin-redirect.js";
6
+ import { assetsRoutes } from "./routes/assets.js";
7
+ import { v1Routes } from "./routes/index.js";
8
+ import { mediaPublicRoutes } from "./routes/media-public.js";
9
+ import { publicRoutes } from "./routes/public.js";
10
+ import { searchPublicRoutes } from "./routes/search-public.js";
11
+ import { sitemapRoutes } from "./routes/sitemap.js";
12
+ import { CHECKPOINT_AUTHOR, runCheckpoint } from "./services/checkpoint.js";
13
+ import { startDevTunnel } from "./services/dev-tunnel.js";
14
+ import { startIntervalJob } from "./services/interval-job.js";
15
+ import { reindexOnBootIfMissing } from "./services/reindex-on-write.js";
16
+ import { isProcessAlive, readPidFile, removeOwnPidFile, writePidFile } from "./services/pid-file.js";
17
+ // Never wrap v1Routes (or any route-group plugin it registers) with
18
+ // fastify-plugin (fp()): plain app.register() gives each file its own
19
+ // encapsulation scope by default, which Group B's auth preHandler
20
+ // hooks depend on. Wrapping would silently hoist decorators/hooks to
21
+ // the parent scope and break that later.
22
+ export function buildServer(booted, serverConfig, options = {}) {
23
+ const app = Fastify({ logger: options.logger ?? false, trustProxy: serverConfig.trustProxy });
24
+ // global: false - only routes that explicitly opt in via a
25
+ // config: { rateLimit: {...} } marker on their own route options
26
+ // (see src/services/rate-limit-config.ts) are ever limited. The
27
+ // max/timeWindow set here are the configured (or defaulted)
28
+ // site.config.json values - an opting-in route's own empty
29
+ // { rateLimit: {} } marker inherits these, verified empirically.
30
+ // Registered once here, on the root app (an ancestor of v1Routes and
31
+ // everything it nests), so no individual route file needs to import
32
+ // this plugin itself. Its own 429 response body shape
33
+ // ({statusCode, error, message}) already matches this project's own
34
+ // error-JSON convention exactly - verified empirically before relying
35
+ // on it, no errorResponseBuilder override needed.
36
+ app.register(rateLimitPlugin, {
37
+ global: false,
38
+ max: serverConfig.rateLimit.max,
39
+ timeWindow: serverConfig.rateLimit.windowMs,
40
+ });
41
+ // Fastify's own default 500 body leaks the raw thrown Error.message
42
+ // to the client verbatim - confirmed empirically before this was
43
+ // written. Anything below 500 (e.g. a future route's schema-
44
+ // validation failure) already carries a safe, specific message and
45
+ // is passed through unchanged; only 500-and-above gets sanitised.
46
+ app.setErrorHandler((error, request, reply) => {
47
+ const statusCode = error.statusCode ?? 500;
48
+ if (statusCode >= 500) {
49
+ request.log.error(error);
50
+ reply.code(statusCode).send({
51
+ statusCode,
52
+ error: 'Internal Server Error',
53
+ message: 'Internal Server Error',
54
+ });
55
+ return;
56
+ }
57
+ reply.code(statusCode).send({ statusCode, error: error.name || 'Bad Request', message: error.message });
58
+ });
59
+ app.register(v1Routes, {
60
+ prefix: '/v1',
61
+ config: booted.config,
62
+ theme: booted.theme,
63
+ tokens: serverConfig.tokens,
64
+ ipAllowlist: serverConfig.ipAllowlist,
65
+ maxUploadBytes: serverConfig.media.maxUploadBytes,
66
+ });
67
+ // Registered without a /v1 prefix, alongside v1Routes: this is the
68
+ // site's own public website, not part of the site agent API. Order
69
+ // relative to v1Routes is not load-bearing - Fastify's router
70
+ // prefers exact/prefixed matches over this route's wildcard
71
+ // regardless of registration order (verified empirically). What
72
+ // actually protects unmatched /v1/* paths from being swallowed as
73
+ // page lookups is the explicit guard inside publicRoutes itself.
74
+ app.register(publicRoutes, {
75
+ config: booted.config,
76
+ theme: booted.theme,
77
+ renderCache: booted.renderCache,
78
+ });
79
+ // A more specific static-prefixed route than the public catch-all's
80
+ // own /* wildcard - Fastify prefers it regardless of registration
81
+ // order (verified empirically), so no guard is needed here the way
82
+ // publicRoutes needs one against /v1/*.
83
+ app.register(assetsRoutes, { config: booted.config });
84
+ // Same "more specific than the public catch-all" reasoning as
85
+ // assetsRoutes above. Deliberately not registered inside v1Routes
86
+ // (media.ts's own authenticated /v1/media CRUD routes are what live
87
+ // there) - this is the public, unauthenticated read side, matching
88
+ // assetsRoutes/publicRoutes' own placement.
89
+ //
90
+ // @fastify/multipart itself is NOT registered here, only inside
91
+ // media.ts's own plugin scope - see that file's comment for why
92
+ // (confining it to media routes only, leaving every other route
93
+ // file's default JSON body parsing untouched).
94
+ app.register(mediaPublicRoutes, { config: booted.config });
95
+ // Same "more specific than the public catch-all" reasoning again -
96
+ // this exact path always wins over publicRoutes' /* wildcard, and
97
+ // also always wins over a same-named static file under
98
+ // theme/root/sitemap.xml (see routes/public.ts's root-mirror check).
99
+ app.register(sitemapRoutes, { config: booted.config });
100
+ // Same "more specific than the public catch-all" reasoning again - a
101
+ // stable, public, front-end-facing contract (a theme's own JS calls
102
+ // it directly), not part of the versioned /v1 admin/integration
103
+ // surface, so it lives alongside these other unprefixed routes
104
+ // rather than inside v1Routes (search.ts's /v1/search/rebuild is the
105
+ // authenticated write side that does). GET /search.json, not
106
+ // GET /search: a single reserved path, not a whole prefix, so a
107
+ // site's own content page can still live at the bare /search URL
108
+ // (granite-starter's search-demo section does exactly that).
109
+ app.register(searchPublicRoutes, { config: booted.config });
110
+ // Conditional, unlike every other registration above - this is what
111
+ // makes GET /admin genuinely opt-in rather than a reserved
112
+ // namespace: with no adminBaseUrl configured, the route is never
113
+ // registered at all, so an unconfigured site can still use "admin"
114
+ // as an ordinary page path. See routes/admin-redirect.ts's own
115
+ // comment for the full reasoning.
116
+ if (serverConfig.adminBaseUrl) {
117
+ app.register(adminRedirectRoutes, { adminBaseUrl: serverConfig.adminBaseUrl });
118
+ }
119
+ return app;
120
+ }
121
+ // Neither a recurring timer nor process signal handling exists
122
+ // anywhere else in this codebase - this is genuinely new machinery for
123
+ // the low-frequency draft-checkpoint background job (checklist H4).
124
+ // Wired only here, never in buildServer: every existing route test
125
+ // uses buildServer + .inject() directly and must stay completely
126
+ // unaffected by any of this.
127
+ export async function startServer(siteRoot, options = {}) {
128
+ const booted = bootSite(siteRoot);
129
+ const serverConfig = loadServerConfig(siteRoot);
130
+ const app = buildServer(booted, serverConfig, options);
131
+ // Previously these three failure cases (missing/invalid {% schema %}
132
+ // block, or a required property with no valid default) were
133
+ // completely silent - a broken component just stopped being
134
+ // selectable, with nothing distinguishing "deliberately excluded"
135
+ // from "you have a typo". Never a boot failure (loadThemeSchemas's
136
+ // own contract), just no longer silent about it either.
137
+ for (const warning of booted.themeSchemas.warnings ?? []) {
138
+ console.warn(warning);
139
+ }
140
+ const doCheckpoint = () => runCheckpoint(booted.config, CHECKPOINT_AUTHOR);
141
+ const scheduler = startIntervalJob(doCheckpoint, serverConfig.checkpointIntervalMs, (error) => {
142
+ app.log.error(error, 'background draft checkpoint failed');
143
+ });
144
+ let tunnel;
145
+ // Node's default action for an unhandled SIGTERM/SIGINT is immediate
146
+ // process termination, bypassing all JS-level cleanup - without
147
+ // this, a real kill/Ctrl-C would never run the final checkpoint
148
+ // (build plan: "and on graceful shutdown"). process.once (not .on)
149
+ // self-removes after firing, and is explicitly removed again inside
150
+ // onClose below, so a test calling app.close() leaves zero global
151
+ // process-level listeners behind across the many startServer/
152
+ // buildServer calls the suite makes.
153
+ const shutdown = () => {
154
+ app.close().catch((error) => app.log.error(error, 'error during shutdown'));
155
+ };
156
+ process.once('SIGTERM', shutdown);
157
+ process.once('SIGINT', shutdown);
158
+ app.addHook('onClose', async () => {
159
+ tunnel?.close();
160
+ scheduler.stop();
161
+ process.removeListener('SIGTERM', shutdown);
162
+ process.removeListener('SIGINT', shutdown);
163
+ removeOwnPidFile(booted.config);
164
+ try {
165
+ await doCheckpoint();
166
+ }
167
+ catch (error) {
168
+ app.log.error(error, 'final draft checkpoint on shutdown failed');
169
+ }
170
+ });
171
+ // host: '0.0.0.0' - Fastify's own default binds 127.0.0.1 only,
172
+ // which is unreachable from outside any container or remote host.
173
+ try {
174
+ await app.listen({ port: serverConfig.port, host: '0.0.0.0' });
175
+ }
176
+ catch (error) {
177
+ // A raw EADDRINUSE crash is a wall of Node internals with no
178
+ // indication of what to actually do about it. This is the single
179
+ // most likely startup failure a developer hits on their own
180
+ // machine (something else already using the default port) - tell
181
+ // them the two real ways to pick a different one instead of
182
+ // rethrowing Node's own stack trace.
183
+ if (error instanceof Error && 'code' in error && error.code === 'EADDRINUSE') {
184
+ // Most often it's this same site, started earlier in another
185
+ // terminal - say so, and how to stop it from this one.
186
+ const running = readPidFile(booted.config);
187
+ if (running && running.port === serverConfig.port && running.pid !== process.pid && isProcessAlive(running.pid)) {
188
+ console.error(`This site is already running on port ${serverConfig.port} (process ${running.pid}).`);
189
+ console.error('Stop it with "npm run stop" from vhost/, then start it again.');
190
+ process.exit(1);
191
+ }
192
+ console.error(`Port ${serverConfig.port} is already in use.`);
193
+ console.error(`Set a different port with the PORT environment variable (e.g. PORT=3001 node server.js), or "port" in vhost/site.config.json.`);
194
+ process.exit(1);
195
+ }
196
+ throw error;
197
+ }
198
+ // The real bound port, not just serverConfig.port verbatim - the
199
+ // same reasoning tests already rely on (app.server.address()):
200
+ // port 0 means "OS picks a free one", so echoing the configured
201
+ // value back would print a meaningless 0 in that case. 127.0.0.1,
202
+ // not the 0.0.0.0 bind host above - that's what's actually
203
+ // reachable and clickable from the machine running this.
204
+ const address = app.server.address();
205
+ if (address !== null && typeof address !== 'string') {
206
+ console.log(`Site running at http://127.0.0.1:${address.port}`);
207
+ // Recorded only once the port is really bound, so a start that
208
+ // fails never leaves a record of a site that isn't running.
209
+ writePidFile(booted.config, address.port);
210
+ }
211
+ // A search index is never git-tracked (constraint 3), so a fresh
212
+ // clone or first-ever boot has no index file - without this,
213
+ // GET /search.json would stay empty until the first content write.
214
+ // Fire-and-forget, after the port is already bound: never blocks
215
+ // startup, and a slow/failed rebuild is never a reason the server
216
+ // itself fails to come up.
217
+ reindexOnBootIfMissing(booted.config);
218
+ if (options.tunnel) {
219
+ try {
220
+ tunnel = await (options.startTunnel ?? startDevTunnel)(serverConfig.port);
221
+ console.log(`Tunnel open: ${tunnel.url}`);
222
+ console.log('This URL is publicly reachable for as long as this process runs - anyone with it (and a valid access token) can reach this site. Do not leave it running unattended.');
223
+ }
224
+ catch (error) {
225
+ // The site itself is already up and correctly serving locally -
226
+ // a failed tunnel is not a reason to fail the whole boot.
227
+ app.log.error(error, 'failed to start dev tunnel');
228
+ console.error('Failed to start the dev tunnel - the site is still running locally.');
229
+ }
230
+ }
231
+ else {
232
+ console.log('Tip: run with --tunnel to expose this site publicly for testing against a hosted admin.');
233
+ }
234
+ return app;
235
+ }
@@ -0,0 +1,35 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { ThemeSchemas } from './validation.ts';
4
+ export type BatchOperation = {
5
+ type: 'draft-write';
6
+ path: string;
7
+ content: unknown;
8
+ expectedEtag: string;
9
+ } | {
10
+ type: 'draft-discard';
11
+ path: string;
12
+ } | {
13
+ type: 'content-delete';
14
+ path: string;
15
+ redirectTo?: string;
16
+ } | {
17
+ type: 'move';
18
+ from: string;
19
+ to: string;
20
+ };
21
+ export interface BatchPublish {
22
+ relativePaths: string[];
23
+ }
24
+ export type BatchReason = 'validation-failed' | 'conflict' | 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'source-not-found' | 'destination-exists' | 'draft-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
25
+ export declare class BatchError extends Error {
26
+ readonly reason: BatchReason;
27
+ readonly stage?: 'operation' | 'publish';
28
+ readonly operationIndex?: number;
29
+ constructor(reason: BatchReason, message: string, options?: {
30
+ cause?: unknown;
31
+ stage?: 'operation' | 'publish';
32
+ operationIndex?: number;
33
+ });
34
+ }
35
+ export declare function runBatch(config: SiteConfig, themeSchemas: ThemeSchemas, operations: BatchOperation[], publish: BatchPublish | undefined, message: string, author: CommitAuthor): Promise<void>;
@@ -0,0 +1,145 @@
1
+ import { DraftError, prepareDiscardDraft, prepareSaveDraft } from "./drafts.js";
2
+ import { DeleteContentError, prepareDeleteContent } from "./delete-content.js";
3
+ import { commitPaths } from "./git.js";
4
+ import { MoveError, prepareMovePage } from "./move.js";
5
+ import { PublishError, preparePublishDrafts } from "./publish.js";
6
+ import { enqueue } from "./write-queue.js";
7
+ import { reindexInBackground } from "./reindex-on-write.js";
8
+ export class BatchError extends Error {
9
+ reason;
10
+ // Which part of the batch failed. operationIndex indexes into the
11
+ // operations array; the trailing publish step is a distinct stage,
12
+ // not folded into that same index space (index 3 could otherwise
13
+ // ambiguously mean "the 4th operation" or "there were only 3
14
+ // operations, so this must be publish").
15
+ stage;
16
+ operationIndex;
17
+ constructor(reason, message, options) {
18
+ super(message, { cause: options?.cause });
19
+ this.name = 'BatchError';
20
+ this.reason = reason;
21
+ this.stage = options?.stage;
22
+ this.operationIndex = options?.operationIndex;
23
+ }
24
+ }
25
+ // No whole-batch duplicate-path pre-check: rejected in design review.
26
+ // publishDrafts's own internal duplicate check exists because its
27
+ // rollback restores multiple snapshots in *forward* order within a
28
+ // single call - a real hazard specific to that shape. Undo across
29
+ // batch *operations* runs in *reverse* chronological order instead
30
+ // (see batchJob below), which composes correctly even when multiple
31
+ // operations touch the same path (e.g. move A->B then content-delete
32
+ // B in the same batch unwinds correctly: undo delete restores B to
33
+ // what move wrote, then undo move renames B back to A). Genuine
34
+ // conflicts (two moves to the same destination, etc.) are already
35
+ // caught fresh by each sub-service's own real-time validation when it
36
+ // runs, precisely because processing is sequential.
37
+ async function runOperation(config, themeSchemas, operation) {
38
+ switch (operation.type) {
39
+ case 'draft-write':
40
+ return prepareSaveDraft(config, themeSchemas, operation.path, operation.content, operation.expectedEtag);
41
+ case 'draft-discard':
42
+ return prepareDiscardDraft(config, operation.path);
43
+ case 'content-delete':
44
+ return prepareDeleteContent(config, operation.path, operation.redirectTo);
45
+ case 'move':
46
+ return prepareMovePage(config, operation.from, operation.to);
47
+ }
48
+ }
49
+ function reasonFromError(error) {
50
+ if (error instanceof DraftError ||
51
+ error instanceof DeleteContentError ||
52
+ error instanceof MoveError ||
53
+ error instanceof PublishError) {
54
+ return error.reason;
55
+ }
56
+ return undefined;
57
+ }
58
+ async function batchJob(config, themeSchemas, operations, publish, message, author) {
59
+ const allPaths = [];
60
+ const undoStack = [];
61
+ // Sequential, not two-phase validate-then-write: operations can be
62
+ // order-dependent within a single batch (e.g. write a draft, then
63
+ // publish that same draft, in one call) - a later step's validation
64
+ // may depend on an earlier step's write having already happened.
65
+ for (let index = 0; index < operations.length; index++) {
66
+ const operation = operations[index];
67
+ try {
68
+ const prepared = await runOperation(config, themeSchemas, operation);
69
+ allPaths.push(...prepared.paths);
70
+ undoStack.push(prepared.undo);
71
+ }
72
+ catch (error) {
73
+ await rollbackAndThrow(undoStack, error, { stage: 'operation', operationIndex: index });
74
+ }
75
+ }
76
+ if (publish) {
77
+ try {
78
+ const prepared = preparePublishDrafts(config, themeSchemas, publish.relativePaths);
79
+ allPaths.push(...prepared.paths);
80
+ undoStack.push(prepared.undo);
81
+ }
82
+ catch (error) {
83
+ await rollbackAndThrow(undoStack, error, { stage: 'publish' });
84
+ }
85
+ }
86
+ // A batch of pure draft-write/draft-discard operations legitimately
87
+ // produces zero git-tracked paths (drafts are never git-tracked).
88
+ // git commit with nothing staged exits non-zero ("nothing to
89
+ // commit") - verified empirically - which would otherwise produce a
90
+ // spurious commit-failed (or a spurious full rollback) for a batch
91
+ // that did nothing wrong. Skip the commit entirely when there is
92
+ // nothing to stage.
93
+ if (allPaths.length === 0) {
94
+ return;
95
+ }
96
+ try {
97
+ commitPaths(config.siteRoot, allPaths, message, author);
98
+ }
99
+ catch (error) {
100
+ await rollbackAndThrow(undoStack, error, {});
101
+ }
102
+ }
103
+ // Runs every already-succeeded operation's undo, in reverse order,
104
+ // then throws. Never returns normally - always throws either
105
+ // BatchError('rollback-failed') (if any undo itself failed) or a
106
+ // BatchError carrying the original failure's own reason.
107
+ async function rollbackAndThrow(undoStack, cause, context) {
108
+ const failures = [];
109
+ for (const undo of [...undoStack].reverse()) {
110
+ failures.push(...undo());
111
+ }
112
+ if (failures.length > 0) {
113
+ throw new BatchError('rollback-failed', 'Batch failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause, ...context });
114
+ }
115
+ const reason = reasonFromError(cause);
116
+ if (reason) {
117
+ const detail = cause instanceof Error ? cause.message : String(cause);
118
+ throw new BatchError(reason, `Batch failed: ${detail}`, { cause, ...context });
119
+ }
120
+ // context.stage is only ever set when this was called from the
121
+ // operations/publish loop, never from the post-loop commitPaths call
122
+ // below - so a non-domain error reaching here (e.g. PathSafetyError
123
+ // thrown by sanitisePath deep inside a prepareX function) came from
124
+ // there too. Rethrow it as-is rather than mislabelling it
125
+ // 'commit-failed': callers up the stack (the route's own
126
+ // PathSafetyError check) need to see the original error type.
127
+ if (context.stage) {
128
+ throw cause;
129
+ }
130
+ // No stage means this can only have come from the commitPaths call
131
+ // just above, which only ever throws GitOperationError.
132
+ const detail = cause instanceof Error ? cause.message : String(cause);
133
+ throw new BatchError('commit-failed', `Batch failed: ${detail}`, { cause, ...context });
134
+ }
135
+ export function runBatch(config, themeSchemas, operations, publish, message, author) {
136
+ const result = enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
137
+ // One trigger for the whole batch, regardless of which operation
138
+ // types it contained (content-delete/move/publish affect the index,
139
+ // draft-write/draft-discard don't) - simpler and no real cost than
140
+ // inspecting `operations` to decide, since this never blocks the
141
+ // caller either way. See reindex-on-write.ts for why this is
142
+ // fire-and-forget, chained onto `result` rather than awaited here.
143
+ result.then(() => reindexInBackground(config), () => undefined);
144
+ return result;
145
+ }
@@ -0,0 +1,4 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ export declare const CHECKPOINT_AUTHOR: CommitAuthor;
4
+ export declare function runCheckpoint(config: SiteConfig, author: CommitAuthor): Promise<'committed' | 'clean'>;
@@ -0,0 +1,45 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { relative } from 'node:path';
3
+ import { CHECKPOINT_MESSAGE } from "./git-history.js";
4
+ import { commitPaths } from "./git.js";
5
+ import { enqueue } from "./write-queue.js";
6
+ // Resolves the checklist's own carried-forward open question 3: the
7
+ // same shape of question boot.ts already declined to answer for
8
+ // boot-time migrations. A fixed, hardcoded in-code identity - not
9
+ // configurable per-site - so this never depends on host config
10
+ // (matching the Phase 1 sign-off that commits never fall back to
11
+ // ~/.gitconfig), and there's zero possibility of a site operator
12
+ // misconfiguring or omitting it.
13
+ export const CHECKPOINT_AUTHOR = { name: 'Minamo', email: 'minamo@localhost' };
14
+ async function checkpointJob(config, author) {
15
+ // Derived from config.draftsRoot, never a hardcoded literal - Group N
16
+ // nested draftsRoot inside contentRoot (content/drafts), so a fixed
17
+ // 'drafts' pathspec would silently match nothing there, making this
18
+ // checkpoint a permanent no-op with zero error output. Always
19
+ // relative to siteRoot, matching commitPaths'/git status's own
20
+ // pathspec convention below.
21
+ const draftsPathspec = relative(config.siteRoot, config.draftsRoot);
22
+ // "When drafts have changed" (checklist H4) - checked before ever
23
+ // staging/committing, matching the empty-commit lesson batch.ts and
24
+ // git-revert.ts already learned: git commit with nothing staged
25
+ // exits non-zero, which must not surface as a spurious failure for
26
+ // a checkpoint tick that legitimately had nothing to do.
27
+ const status = execFileSync('git', ['status', '--porcelain', '--', draftsPathspec], {
28
+ cwd: config.siteRoot,
29
+ }).toString('utf-8');
30
+ if (status.trim().length === 0) {
31
+ return 'clean';
32
+ }
33
+ // Reuses commitPaths (not commitWorkingTree, which stages the
34
+ // *entire* working tree) - scoped to a single drafts-directory
35
+ // pathspec. Verified empirically that a bare pathspec (no -A flag)
36
+ // correctly stages both modifications and deletions under that
37
+ // directory, essential since a discarded draft (a pure fs removal
38
+ // today, no git call) must be captured by the next checkpoint as a
39
+ // real change, not silently missed.
40
+ commitPaths(config.siteRoot, [draftsPathspec], CHECKPOINT_MESSAGE, author);
41
+ return 'committed';
42
+ }
43
+ export function runCheckpoint(config, author) {
44
+ return enqueue(() => checkpointJob(config, author));
45
+ }
@@ -0,0 +1,27 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export type ContentReadReason = 'not-found';
3
+ export declare class ContentReadError extends Error {
4
+ readonly reason: ContentReadReason;
5
+ constructor(reason: ContentReadReason, message: string);
6
+ }
7
+ export interface ReadResult {
8
+ bytes: Buffer;
9
+ etag: string;
10
+ }
11
+ export declare function readContentFile(root: string, relativePath: string): ReadResult;
12
+ export interface ContentListFilters {
13
+ type?: string;
14
+ prefix?: string;
15
+ draftStatus?: 'has-draft' | 'no-draft';
16
+ }
17
+ export interface ContentListEntry {
18
+ path: string;
19
+ name: string;
20
+ title: string;
21
+ type: string;
22
+ published: boolean;
23
+ hasDraft: boolean;
24
+ url: string | null;
25
+ changedAt: string | null;
26
+ }
27
+ export declare function listContent(config: SiteConfig, filters: ContentListFilters): ContentListEntry[];
@@ -0,0 +1,151 @@
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { computeEtag } from "./etag.js";
4
+ import { listFilesRecursively } from "./fs-walk.js";
5
+ import { sanitisePath } from "./path-safety.js";
6
+ import { pagePathToUrl } from "./urls.js";
7
+ export class ContentReadError extends Error {
8
+ reason;
9
+ constructor(reason, message) {
10
+ super(message);
11
+ this.name = 'ContentReadError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ // Shared by both content.ts (root = config.contentRoot) and drafts.ts
16
+ // (root = config.draftsRoot) - identical read+hash logic, just a
17
+ // different root. sanitisePath's PathSafetyError is left to propagate
18
+ // uncaught: the route handlers catch it explicitly and map it to 404,
19
+ // the same precedent preview.ts already established (PathSafetyError
20
+ // has no .statusCode, so left uncaught it would fall through to the
21
+ // global handler's generic sanitised 500).
22
+ export function readContentFile(root, relativePath) {
23
+ const fullPath = sanitisePath(root, relativePath);
24
+ if (!existsSync(fullPath)) {
25
+ throw new ContentReadError('not-found', `No file at "${relativePath}"`);
26
+ }
27
+ const bytes = readFileSync(fullPath);
28
+ return { bytes, etag: computeEtag(bytes) };
29
+ }
30
+ // Both contentRoot-relative live paths and draftsRoot-relative draft
31
+ // paths share the identical pages/menus subroot prefix shape
32
+ // (content/drafts/ mirrors content/'s own layout), so one function
33
+ // handles both with no branching on which root an entry came from.
34
+ // Menus have no public URL - there is no preview route for them.
35
+ function computeContentUrl(relativePath) {
36
+ if (relativePath.startsWith('pages/')) {
37
+ return pagePathToUrl(relativePath.slice('pages/'.length));
38
+ }
39
+ return null;
40
+ }
41
+ // The later of the live file's and the draft's own mtime, whichever
42
+ // exist - a page with a pending draft edit should show that edit's
43
+ // recency, not just whichever file readSummary happened to prefer.
44
+ // File mtime, not a git commit date: a per-path git-log call for
45
+ // every entry in a listing that can run to hundreds of pages isn't
46
+ // viable, and mtime is a reasonable proxy for "last touched" - a
47
+ // known, accepted imprecision (e.g. a fresh git clone/checkout can
48
+ // reset it), not a claim of exact publish history (that's what the
49
+ // page history view's own real git log is for).
50
+ function latestMtime(candidatePaths) {
51
+ let latest = null;
52
+ for (const fullPath of candidatePaths) {
53
+ try {
54
+ const { mtime } = statSync(fullPath);
55
+ if (latest === null || mtime > latest) {
56
+ latest = mtime;
57
+ }
58
+ }
59
+ catch {
60
+ // A path that vanished between the directory walk and this stat
61
+ // (e.g. a concurrent delete) just doesn't contribute - not fatal
62
+ // to the rest of the listing.
63
+ }
64
+ }
65
+ return latest ? latest.toISOString() : null;
66
+ }
67
+ // Defensive, not the strict PageContent shape from render-page.ts:
68
+ // boot.ts deliberately never auto-migrates content at startup, so a
69
+ // file sitting at an old schemaVersion (and therefore missing a field
70
+ // as new as "type") is a real, persistent possibility, not a
71
+ // hypothetical - matching rebuild-index.ts's PageForIndex precedent.
72
+ function readSummary(fullPath) {
73
+ try {
74
+ return JSON.parse(readFileSync(fullPath, 'utf-8'));
75
+ }
76
+ catch {
77
+ return null;
78
+ }
79
+ }
80
+ // GET /v1/content lists the union of contentRoot and draftsRoot paths,
81
+ // not live content alone: Group D's own checklist heading is "content
82
+ // AND draft reads", and there is no separate "list drafts" endpoint
83
+ // anywhere in the build plan - this is the only place a draft-only
84
+ // page (never yet published) is discoverable at all.
85
+ //
86
+ // Deliberately two named walks (pages/menus), not one broad walk of
87
+ // contentRoot: since Group N nested draftsRoot and redirectsPath
88
+ // inside contentRoot (content/drafts/, content/redirects.json), a
89
+ // single broad contentRoot walk would descend into content/drafts/
90
+ // too, double-listing draft files under two different relative-path
91
+ // keys, and would pick up redirects.json as if it were a page. An
92
+ // inclusion-based walk sidesteps both problems structurally, with no
93
+ // exclusion list to maintain. base stays config.contentRoot for both
94
+ // (not each subroot) so the resulting relative paths ("pages/x.json")
95
+ // line up with draftPaths below for the hasDraft/hasLive union logic.
96
+ export function listContent(config, filters) {
97
+ const contentPaths = new Set([
98
+ ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
99
+ ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
100
+ ]);
101
+ const draftPaths = new Set(listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json'));
102
+ const allPaths = new Set([...contentPaths, ...draftPaths]);
103
+ const entries = [];
104
+ for (const relativePath of allPaths) {
105
+ const hasDraft = draftPaths.has(relativePath);
106
+ const hasLive = contentPaths.has(relativePath);
107
+ // Prefer the live file's fields on conflict - the draft is a
108
+ // pending edit, not yet the source of truth for a content
109
+ // inventory view - but hasDraft still reports the draft's
110
+ // presence either way.
111
+ const summary = hasLive
112
+ ? readSummary(join(config.contentRoot, relativePath))
113
+ : readSummary(join(config.draftsRoot, relativePath));
114
+ if (!summary) {
115
+ continue;
116
+ }
117
+ const title = typeof summary.title === 'string' ? summary.title : '';
118
+ const entry = {
119
+ path: relativePath,
120
+ // Falls back to title, not '', for content written before "name"
121
+ // existed and not yet migrated (migrations aren't wired to run
122
+ // automatically) - the same default migrateV4ToV5 itself writes,
123
+ // just applied defensively at read time too.
124
+ name: typeof summary.name === 'string' ? summary.name : title,
125
+ title,
126
+ type: typeof summary.type === 'string' ? summary.type : '',
127
+ published: summary.published === true,
128
+ hasDraft,
129
+ url: computeContentUrl(relativePath),
130
+ changedAt: latestMtime([
131
+ ...(hasLive ? [join(config.contentRoot, relativePath)] : []),
132
+ ...(hasDraft ? [join(config.draftsRoot, relativePath)] : []),
133
+ ]),
134
+ };
135
+ if (filters.type !== undefined && entry.type !== filters.type) {
136
+ continue;
137
+ }
138
+ if (filters.prefix !== undefined && !entry.path.startsWith(filters.prefix)) {
139
+ continue;
140
+ }
141
+ if (filters.draftStatus === 'has-draft' && !entry.hasDraft) {
142
+ continue;
143
+ }
144
+ if (filters.draftStatus === 'no-draft' && entry.hasDraft) {
145
+ continue;
146
+ }
147
+ entries.push(entry);
148
+ }
149
+ entries.sort((a, b) => a.path.localeCompare(b.path));
150
+ return entries;
151
+ }
@@ -0,0 +1,12 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { PreparedOperation } from './prepared-operation.ts';
4
+ export type DeleteContentReason = 'page-not-found' | 'has-children' | 'redirect-cycle' | 'invalid-redirect-target' | 'write-failed' | 'commit-failed' | 'rollback-failed';
5
+ export declare class DeleteContentError extends Error {
6
+ readonly reason: DeleteContentReason;
7
+ constructor(reason: DeleteContentReason, message: string, options?: {
8
+ cause?: unknown;
9
+ });
10
+ }
11
+ export declare function prepareDeleteContent(config: SiteConfig, relativePath: string, redirectTo: string | undefined): PreparedOperation;
12
+ export declare function deleteContent(config: SiteConfig, relativePath: string, redirectTo: string | undefined, message: string, author: CommitAuthor): Promise<void>;