@o-a/cms-agent 0.1.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 (201) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +132 -0
  3. package/dist/boot.d.ts +15 -0
  4. package/dist/boot.js +33 -0
  5. package/dist/config.d.ts +19 -0
  6. package/dist/config.js +45 -0
  7. package/dist/create-site/cli.d.ts +2 -0
  8. package/dist/create-site/cli.js +31 -0
  9. package/dist/create-site/generate-site.d.ts +9 -0
  10. package/dist/create-site/generate-site.js +109 -0
  11. package/dist/create-site/mint-token-cli.d.ts +2 -0
  12. package/dist/create-site/mint-token-cli.js +26 -0
  13. package/dist/create-site/mint-token.d.ts +7 -0
  14. package/dist/create-site/mint-token.js +49 -0
  15. package/dist/create-site/template/content/menus/footerCompany.json +9 -0
  16. package/dist/create-site/template/content/menus/footerProduct.json +9 -0
  17. package/dist/create-site/template/content/menus/footerResources.json +9 -0
  18. package/dist/create-site/template/content/menus/main.json +9 -0
  19. package/dist/create-site/template/content/pages/404.json +22 -0
  20. package/dist/create-site/template/content/pages/about/careers.json +21 -0
  21. package/dist/create-site/template/content/pages/about/team.json +21 -0
  22. package/dist/create-site/template/content/pages/about.json +21 -0
  23. package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
  24. package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
  25. package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
  26. package/dist/create-site/template/content/pages/docs.json +21 -0
  27. package/dist/create-site/template/content/pages/index.json +181 -0
  28. package/dist/create-site/template/content/redirects.json +4 -0
  29. package/dist/create-site/template/dockerignore +6 -0
  30. package/dist/create-site/template/gitignore +11 -0
  31. package/dist/create-site/template/theme/assets/site.js +49 -0
  32. package/dist/create-site/template/theme/assets/style.css +1153 -0
  33. package/dist/create-site/template/theme/blocks/button.liquid +21 -0
  34. package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
  35. package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
  36. package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
  37. package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
  38. package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
  39. package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
  40. package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
  41. package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
  42. package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
  43. package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
  44. package/dist/create-site/template/theme/sections/faq.liquid +27 -0
  45. package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
  46. package/dist/create-site/template/theme/sections/hero.liquid +36 -0
  47. package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
  48. package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
  49. package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
  50. package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
  51. package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
  52. package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
  53. package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
  54. package/dist/create-site/template/vhost/Dockerfile +33 -0
  55. package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.js +4 -0
  58. package/dist/media/drivers/driver.d.ts +11 -0
  59. package/dist/media/drivers/driver.js +1 -0
  60. package/dist/media/drivers/local-fs-driver.d.ts +3 -0
  61. package/dist/media/drivers/local-fs-driver.js +81 -0
  62. package/dist/media/filename.d.ts +1 -0
  63. package/dist/media/filename.js +31 -0
  64. package/dist/media/manage-media.d.ts +19 -0
  65. package/dist/media/manage-media.js +45 -0
  66. package/dist/migrations/index.d.ts +3 -0
  67. package/dist/migrations/index.js +49 -0
  68. package/dist/renderer/engine.d.ts +2 -0
  69. package/dist/renderer/engine.js +48 -0
  70. package/dist/renderer/render-page.d.ts +28 -0
  71. package/dist/renderer/render-page.js +143 -0
  72. package/dist/renderer/theme-templates.d.ts +7 -0
  73. package/dist/renderer/theme-templates.js +82 -0
  74. package/dist/routes/assets.d.ts +6 -0
  75. package/dist/routes/assets.js +30 -0
  76. package/dist/routes/batch.d.ts +10 -0
  77. package/dist/routes/batch.js +148 -0
  78. package/dist/routes/capabilities.d.ts +5 -0
  79. package/dist/routes/capabilities.js +27 -0
  80. package/dist/routes/content.d.ts +8 -0
  81. package/dist/routes/content.js +166 -0
  82. package/dist/routes/drafts.d.ts +10 -0
  83. package/dist/routes/drafts.js +73 -0
  84. package/dist/routes/git.d.ts +8 -0
  85. package/dist/routes/git.js +158 -0
  86. package/dist/routes/index.d.ts +19 -0
  87. package/dist/routes/index.js +84 -0
  88. package/dist/routes/media-public.d.ts +5 -0
  89. package/dist/routes/media-public.js +48 -0
  90. package/dist/routes/media.d.ts +9 -0
  91. package/dist/routes/media.js +84 -0
  92. package/dist/routes/menus.d.ts +8 -0
  93. package/dist/routes/menus.js +77 -0
  94. package/dist/routes/preview-revision.d.ts +13 -0
  95. package/dist/routes/preview-revision.js +109 -0
  96. package/dist/routes/preview.d.ts +13 -0
  97. package/dist/routes/preview.js +52 -0
  98. package/dist/routes/public.d.ts +11 -0
  99. package/dist/routes/public.js +117 -0
  100. package/dist/routes/publish.d.ts +10 -0
  101. package/dist/routes/publish.js +105 -0
  102. package/dist/routes/redirects.d.ts +8 -0
  103. package/dist/routes/redirects.js +132 -0
  104. package/dist/routes/search.d.ts +8 -0
  105. package/dist/routes/search.js +11 -0
  106. package/dist/routes/sitemap.d.ts +6 -0
  107. package/dist/routes/sitemap.js +79 -0
  108. package/dist/routes/theme.d.ts +10 -0
  109. package/dist/routes/theme.js +17 -0
  110. package/dist/schemas/instance.schema.json +17 -0
  111. package/dist/schemas/menu.schema.json +23 -0
  112. package/dist/schemas/page.schema.json +20 -0
  113. package/dist/schemas/post.schema.json +25 -0
  114. package/dist/schemas/redirects.schema.json +24 -0
  115. package/dist/search/drivers/driver.d.ts +9 -0
  116. package/dist/search/drivers/driver.js +1 -0
  117. package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
  118. package/dist/search/drivers/node-sqlite-driver.js +19 -0
  119. package/dist/search/query-index.d.ts +5 -0
  120. package/dist/search/query-index.js +21 -0
  121. package/dist/search/rebuild-index.d.ts +2 -0
  122. package/dist/search/rebuild-index.js +111 -0
  123. package/dist/server-config.d.ts +22 -0
  124. package/dist/server-config.js +196 -0
  125. package/dist/server.d.ts +13 -0
  126. package/dist/server.js +163 -0
  127. package/dist/services/batch.d.ts +35 -0
  128. package/dist/services/batch.js +136 -0
  129. package/dist/services/checkpoint.d.ts +4 -0
  130. package/dist/services/checkpoint.js +45 -0
  131. package/dist/services/content-read.d.ts +27 -0
  132. package/dist/services/content-read.js +156 -0
  133. package/dist/services/delete-content.d.ts +12 -0
  134. package/dist/services/delete-content.js +174 -0
  135. package/dist/services/dev-tunnel.d.ts +5 -0
  136. package/dist/services/dev-tunnel.js +5 -0
  137. package/dist/services/drafts.d.ts +15 -0
  138. package/dist/services/drafts.js +155 -0
  139. package/dist/services/etag.d.ts +2 -0
  140. package/dist/services/etag.js +30 -0
  141. package/dist/services/fs-walk.d.ts +1 -0
  142. package/dist/services/fs-walk.js +26 -0
  143. package/dist/services/git-commit.d.ts +3 -0
  144. package/dist/services/git-commit.js +12 -0
  145. package/dist/services/git-history.d.ts +28 -0
  146. package/dist/services/git-history.js +131 -0
  147. package/dist/services/git-revert.d.ts +12 -0
  148. package/dist/services/git-revert.js +140 -0
  149. package/dist/services/git.d.ts +16 -0
  150. package/dist/services/git.js +127 -0
  151. package/dist/services/interval-job.d.ts +4 -0
  152. package/dist/services/interval-job.js +9 -0
  153. package/dist/services/ip-allowlist.d.ts +7 -0
  154. package/dist/services/ip-allowlist.js +31 -0
  155. package/dist/services/manage-menus.d.ts +10 -0
  156. package/dist/services/manage-menus.js +84 -0
  157. package/dist/services/manage-redirects.d.ts +25 -0
  158. package/dist/services/manage-redirects.js +166 -0
  159. package/dist/services/menus.d.ts +8 -0
  160. package/dist/services/menus.js +42 -0
  161. package/dist/services/migration-runner.d.ts +13 -0
  162. package/dist/services/migration-runner.js +123 -0
  163. package/dist/services/mime-types.d.ts +3 -0
  164. package/dist/services/mime-types.js +36 -0
  165. package/dist/services/move.d.ts +16 -0
  166. package/dist/services/move.js +175 -0
  167. package/dist/services/path-safety.d.ts +6 -0
  168. package/dist/services/path-safety.js +112 -0
  169. package/dist/services/post-urls.d.ts +3 -0
  170. package/dist/services/post-urls.js +27 -0
  171. package/dist/services/prepared-operation.d.ts +4 -0
  172. package/dist/services/prepared-operation.js +1 -0
  173. package/dist/services/publish.d.ts +14 -0
  174. package/dist/services/publish.js +254 -0
  175. package/dist/services/rate-limit-config.d.ts +9 -0
  176. package/dist/services/rate-limit-config.js +20 -0
  177. package/dist/services/redirects.d.ts +27 -0
  178. package/dist/services/redirects.js +175 -0
  179. package/dist/services/resolve-blog-url.d.ts +11 -0
  180. package/dist/services/resolve-blog-url.js +31 -0
  181. package/dist/services/resolve-url.d.ts +11 -0
  182. package/dist/services/resolve-url.js +20 -0
  183. package/dist/services/startup-checks.d.ts +10 -0
  184. package/dist/services/startup-checks.js +81 -0
  185. package/dist/services/static-file.d.ts +7 -0
  186. package/dist/services/static-file.js +37 -0
  187. package/dist/services/theme-component-file.d.ts +5 -0
  188. package/dist/services/theme-component-file.js +32 -0
  189. package/dist/services/theme-page-templates.d.ts +7 -0
  190. package/dist/services/theme-page-templates.js +53 -0
  191. package/dist/services/theme-schemas.d.ts +2 -0
  192. package/dist/services/theme-schemas.js +63 -0
  193. package/dist/services/token-auth.d.ts +10 -0
  194. package/dist/services/token-auth.js +64 -0
  195. package/dist/services/urls.d.ts +2 -0
  196. package/dist/services/urls.js +20 -0
  197. package/dist/services/validation.d.ts +24 -0
  198. package/dist/services/validation.js +158 -0
  199. package/dist/services/write-queue.d.ts +1 -0
  200. package/dist/services/write-queue.js +14 -0
  201. package/package.json +53 -0
@@ -0,0 +1,136 @@
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
+ export class BatchError extends Error {
8
+ reason;
9
+ // Which part of the batch failed. operationIndex indexes into the
10
+ // operations array; the trailing publish step is a distinct stage,
11
+ // not folded into that same index space (index 3 could otherwise
12
+ // ambiguously mean "the 4th operation" or "there were only 3
13
+ // operations, so this must be publish").
14
+ stage;
15
+ operationIndex;
16
+ constructor(reason, message, options) {
17
+ super(message, { cause: options?.cause });
18
+ this.name = 'BatchError';
19
+ this.reason = reason;
20
+ this.stage = options?.stage;
21
+ this.operationIndex = options?.operationIndex;
22
+ }
23
+ }
24
+ // No whole-batch duplicate-path pre-check: rejected in design review.
25
+ // publishDrafts's own internal duplicate check exists because its
26
+ // rollback restores multiple snapshots in *forward* order within a
27
+ // single call - a real hazard specific to that shape. Undo across
28
+ // batch *operations* runs in *reverse* chronological order instead
29
+ // (see batchJob below), which composes correctly even when multiple
30
+ // operations touch the same path (e.g. move A->B then content-delete
31
+ // B in the same batch unwinds correctly: undo delete restores B to
32
+ // what move wrote, then undo move renames B back to A). Genuine
33
+ // conflicts (two moves to the same destination, etc.) are already
34
+ // caught fresh by each sub-service's own real-time validation when it
35
+ // runs, precisely because processing is sequential.
36
+ async function runOperation(config, themeSchemas, operation) {
37
+ switch (operation.type) {
38
+ case 'draft-write':
39
+ return prepareSaveDraft(config, themeSchemas, operation.path, operation.content, operation.expectedEtag);
40
+ case 'draft-discard':
41
+ return prepareDiscardDraft(config, operation.path);
42
+ case 'content-delete':
43
+ return prepareDeleteContent(config, operation.path, operation.redirectTo);
44
+ case 'move':
45
+ return prepareMovePage(config, operation.from, operation.to);
46
+ }
47
+ }
48
+ function reasonFromError(error) {
49
+ if (error instanceof DraftError ||
50
+ error instanceof DeleteContentError ||
51
+ error instanceof MoveError ||
52
+ error instanceof PublishError) {
53
+ return error.reason;
54
+ }
55
+ return undefined;
56
+ }
57
+ async function batchJob(config, themeSchemas, operations, publish, message, author) {
58
+ const allPaths = [];
59
+ const undoStack = [];
60
+ // Sequential, not two-phase validate-then-write: operations can be
61
+ // order-dependent within a single batch (e.g. write a draft, then
62
+ // publish that same draft, in one call) - a later step's validation
63
+ // may depend on an earlier step's write having already happened.
64
+ for (let index = 0; index < operations.length; index++) {
65
+ const operation = operations[index];
66
+ try {
67
+ const prepared = await runOperation(config, themeSchemas, operation);
68
+ allPaths.push(...prepared.paths);
69
+ undoStack.push(prepared.undo);
70
+ }
71
+ catch (error) {
72
+ await rollbackAndThrow(undoStack, error, { stage: 'operation', operationIndex: index });
73
+ }
74
+ }
75
+ if (publish) {
76
+ try {
77
+ const prepared = preparePublishDrafts(config, themeSchemas, publish.relativePaths);
78
+ allPaths.push(...prepared.paths);
79
+ undoStack.push(prepared.undo);
80
+ }
81
+ catch (error) {
82
+ await rollbackAndThrow(undoStack, error, { stage: 'publish' });
83
+ }
84
+ }
85
+ // A batch of pure draft-write/draft-discard operations legitimately
86
+ // produces zero git-tracked paths (drafts are never git-tracked).
87
+ // git commit with nothing staged exits non-zero ("nothing to
88
+ // commit") - verified empirically - which would otherwise produce a
89
+ // spurious commit-failed (or a spurious full rollback) for a batch
90
+ // that did nothing wrong. Skip the commit entirely when there is
91
+ // nothing to stage.
92
+ if (allPaths.length === 0) {
93
+ return;
94
+ }
95
+ try {
96
+ commitPaths(config.siteRoot, allPaths, message, author);
97
+ }
98
+ catch (error) {
99
+ await rollbackAndThrow(undoStack, error, {});
100
+ }
101
+ }
102
+ // Runs every already-succeeded operation's undo, in reverse order,
103
+ // then throws. Never returns normally - always throws either
104
+ // BatchError('rollback-failed') (if any undo itself failed) or a
105
+ // BatchError carrying the original failure's own reason.
106
+ async function rollbackAndThrow(undoStack, cause, context) {
107
+ const failures = [];
108
+ for (const undo of [...undoStack].reverse()) {
109
+ failures.push(...undo());
110
+ }
111
+ if (failures.length > 0) {
112
+ 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 });
113
+ }
114
+ const reason = reasonFromError(cause);
115
+ if (reason) {
116
+ const detail = cause instanceof Error ? cause.message : String(cause);
117
+ throw new BatchError(reason, `Batch failed: ${detail}`, { cause, ...context });
118
+ }
119
+ // context.stage is only ever set when this was called from the
120
+ // operations/publish loop, never from the post-loop commitPaths call
121
+ // below - so a non-domain error reaching here (e.g. PathSafetyError
122
+ // thrown by sanitisePath deep inside a prepareX function) came from
123
+ // there too. Rethrow it as-is rather than mislabelling it
124
+ // 'commit-failed': callers up the stack (the route's own
125
+ // PathSafetyError check) need to see the original error type.
126
+ if (context.stage) {
127
+ throw cause;
128
+ }
129
+ // No stage means this can only have come from the commitPaths call
130
+ // just above, which only ever throws GitOperationError.
131
+ const detail = cause instanceof Error ? cause.message : String(cause);
132
+ throw new BatchError('commit-failed', `Batch failed: ${detail}`, { cause, ...context });
133
+ }
134
+ export function runBatch(config, themeSchemas, operations, publish, message, author) {
135
+ return enqueue(() => batchJob(config, themeSchemas, operations, publish, message, author));
136
+ }
@@ -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: 'CMS Agent', email: 'agent@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,156 @@
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
+ import { postPathToUrl } from "./post-urls.js";
8
+ export class ContentReadError extends Error {
9
+ reason;
10
+ constructor(reason, message) {
11
+ super(message);
12
+ this.name = 'ContentReadError';
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ // Shared by both content.ts (root = config.contentRoot) and drafts.ts
17
+ // (root = config.draftsRoot) - identical read+hash logic, just a
18
+ // different root. sanitisePath's PathSafetyError is left to propagate
19
+ // uncaught: the route handlers catch it explicitly and map it to 404,
20
+ // the same precedent preview.ts already established (PathSafetyError
21
+ // has no .statusCode, so left uncaught it would fall through to the
22
+ // global handler's generic sanitised 500).
23
+ export function readContentFile(root, relativePath) {
24
+ const fullPath = sanitisePath(root, relativePath);
25
+ if (!existsSync(fullPath)) {
26
+ throw new ContentReadError('not-found', `No file at "${relativePath}"`);
27
+ }
28
+ const bytes = readFileSync(fullPath);
29
+ return { bytes, etag: computeEtag(bytes) };
30
+ }
31
+ // Both contentRoot-relative live paths and draftsRoot-relative draft
32
+ // paths share the identical pages/posts/menus subroot prefix shape
33
+ // (content/drafts/ mirrors content/'s own layout), so one function
34
+ // handles both with no branching on which root an entry came from.
35
+ // Menus have no public URL - there is no preview route for them.
36
+ function computeContentUrl(relativePath) {
37
+ if (relativePath.startsWith('pages/')) {
38
+ return pagePathToUrl(relativePath.slice('pages/'.length));
39
+ }
40
+ if (relativePath.startsWith('posts/')) {
41
+ return postPathToUrl(relativePath.slice('posts/'.length));
42
+ }
43
+ return null;
44
+ }
45
+ // The later of the live file's and the draft's own mtime, whichever
46
+ // exist - a page with a pending draft edit should show that edit's
47
+ // recency, not just whichever file readSummary happened to prefer.
48
+ // File mtime, not a git commit date: a per-path git-log call for
49
+ // every entry in a listing that can run to hundreds of pages isn't
50
+ // viable, and mtime is a reasonable proxy for "last touched" - a
51
+ // known, accepted imprecision (e.g. a fresh git clone/checkout can
52
+ // reset it), not a claim of exact publish history (that's what the
53
+ // page history view's own real git log is for).
54
+ function latestMtime(candidatePaths) {
55
+ let latest = null;
56
+ for (const fullPath of candidatePaths) {
57
+ try {
58
+ const { mtime } = statSync(fullPath);
59
+ if (latest === null || mtime > latest) {
60
+ latest = mtime;
61
+ }
62
+ }
63
+ catch {
64
+ // A path that vanished between the directory walk and this stat
65
+ // (e.g. a concurrent delete) just doesn't contribute - not fatal
66
+ // to the rest of the listing.
67
+ }
68
+ }
69
+ return latest ? latest.toISOString() : null;
70
+ }
71
+ // Defensive, not the strict PageContent shape from render-page.ts:
72
+ // boot.ts deliberately never auto-migrates content at startup, so a
73
+ // file sitting at an old schemaVersion (and therefore missing a field
74
+ // as new as "type") is a real, persistent possibility, not a
75
+ // hypothetical - matching rebuild-index.ts's PageForIndex precedent.
76
+ function readSummary(fullPath) {
77
+ try {
78
+ return JSON.parse(readFileSync(fullPath, 'utf-8'));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
84
+ // GET /v1/content lists the union of contentRoot and draftsRoot paths,
85
+ // not live content alone: Group D's own checklist heading is "content
86
+ // AND draft reads", and there is no separate "list drafts" endpoint
87
+ // anywhere in the build plan - this is the only place a draft-only
88
+ // page (never yet published) is discoverable at all.
89
+ //
90
+ // Deliberately three named walks (pages/posts/menus), not one broad
91
+ // walk of contentRoot: since Group N nested draftsRoot and redirectsPath
92
+ // inside contentRoot (content/drafts/, content/redirects.json), a
93
+ // single broad contentRoot walk would descend into content/drafts/
94
+ // too, double-listing draft files under two different relative-path
95
+ // keys, and would pick up redirects.json as if it were a page. An
96
+ // inclusion-based walk sidesteps both problems structurally, with no
97
+ // exclusion list to maintain. base stays config.contentRoot for all
98
+ // three (not each subroot) so the resulting relative paths ("pages/x.json")
99
+ // line up with draftPaths below for the hasDraft/hasLive union logic.
100
+ export function listContent(config, filters) {
101
+ const contentPaths = new Set([
102
+ ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json'),
103
+ ...listFilesRecursively(config.postsRoot, config.contentRoot, '.json'),
104
+ ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json'),
105
+ ]);
106
+ const draftPaths = new Set(listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json'));
107
+ const allPaths = new Set([...contentPaths, ...draftPaths]);
108
+ const entries = [];
109
+ for (const relativePath of allPaths) {
110
+ const hasDraft = draftPaths.has(relativePath);
111
+ const hasLive = contentPaths.has(relativePath);
112
+ // Prefer the live file's fields on conflict - the draft is a
113
+ // pending edit, not yet the source of truth for a content
114
+ // inventory view - but hasDraft still reports the draft's
115
+ // presence either way.
116
+ const summary = hasLive
117
+ ? readSummary(join(config.contentRoot, relativePath))
118
+ : readSummary(join(config.draftsRoot, relativePath));
119
+ if (!summary) {
120
+ continue;
121
+ }
122
+ const title = typeof summary.title === 'string' ? summary.title : '';
123
+ const entry = {
124
+ path: relativePath,
125
+ // Falls back to title, not '', for content written before "name"
126
+ // existed and not yet migrated (migrations aren't wired to run
127
+ // automatically) - the same default migrateV4ToV5 itself writes,
128
+ // just applied defensively at read time too.
129
+ name: typeof summary.name === 'string' ? summary.name : title,
130
+ title,
131
+ type: typeof summary.type === 'string' ? summary.type : '',
132
+ published: summary.published === true,
133
+ hasDraft,
134
+ url: computeContentUrl(relativePath),
135
+ changedAt: latestMtime([
136
+ ...(hasLive ? [join(config.contentRoot, relativePath)] : []),
137
+ ...(hasDraft ? [join(config.draftsRoot, relativePath)] : []),
138
+ ]),
139
+ };
140
+ if (filters.type !== undefined && entry.type !== filters.type) {
141
+ continue;
142
+ }
143
+ if (filters.prefix !== undefined && !entry.path.startsWith(filters.prefix)) {
144
+ continue;
145
+ }
146
+ if (filters.draftStatus === 'has-draft' && !entry.hasDraft) {
147
+ continue;
148
+ }
149
+ if (filters.draftStatus === 'no-draft' && entry.hasDraft) {
150
+ continue;
151
+ }
152
+ entries.push(entry);
153
+ }
154
+ entries.sort((a, b) => a.path.localeCompare(b.path));
155
+ return entries;
156
+ }
@@ -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>;
@@ -0,0 +1,174 @@
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 { postPathToUrl } from "./post-urls.js";
6
+ import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirects, serialiseRedirects, } from "./redirects.js";
7
+ import { pagePathToUrl } from "./urls.js";
8
+ import { enqueue } from "./write-queue.js";
9
+ const PAGES_PREFIX = 'pages/';
10
+ const POSTS_PREFIX = 'posts/';
11
+ // Widens the redirect-on-delete gate to posts alongside pages, without
12
+ // touching the has-children subtree check below (PAGES_PREFIX-only,
13
+ // unchanged - posts never have nested children by construction). A
14
+ // tiny duplicated helper, not a shared "collection" abstraction,
15
+ // matching this codebase's existing precedent (e.g. prepareSaveDraft/
16
+ // prepareDiscardDraft mirror rather than share a base with publish.ts/
17
+ // move.ts). Returns null for anything else (menus have no public URL
18
+ // at all, so redirects are meaningless there).
19
+ function urlForDeletedEntry(relativePath) {
20
+ if (relativePath.startsWith(PAGES_PREFIX)) {
21
+ return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
22
+ }
23
+ if (relativePath.startsWith(POSTS_PREFIX)) {
24
+ return postPathToUrl(relativePath.slice(POSTS_PREFIX.length));
25
+ }
26
+ return null;
27
+ }
28
+ export class DeleteContentError extends Error {
29
+ reason;
30
+ constructor(reason, message, options) {
31
+ super(message, options);
32
+ this.name = 'DeleteContentError';
33
+ this.reason = reason;
34
+ }
35
+ }
36
+ // Validates and writes everything a delete needs, stopping short of
37
+ // the commit itself - used both by the standalone deleteContentJob
38
+ // (which commits immediately after) and by batch.ts (which accumulates
39
+ // this alongside other operations and commits once for the whole
40
+ // batch). A write-phase failure (including a client-triggered
41
+ // redirect-cycle) is caught and rolled back here, inside prepare
42
+ // itself, so prepare either fully succeeds or fully undoes itself
43
+ // before throwing.
44
+ export function prepareDeleteContent(config, relativePath, redirectTo) {
45
+ const livePath = sanitisePath(config.contentRoot, relativePath);
46
+ if (!existsSync(livePath)) {
47
+ throw new DeleteContentError('page-not-found', `No live page found at "${relativePath}"`);
48
+ }
49
+ // Subtree rejection: a deliberate scope decision (reject outright,
50
+ // not cascade) - an accidental single DELETE can't silently take a
51
+ // whole subtree with it. "Has children" means a real child page
52
+ // exists (checked via listFilesRecursively), never bare directory
53
+ // existence: a leftover *empty* sibling directory would otherwise
54
+ // make this page permanently undeletable, since no rmdir/cleanup
55
+ // endpoint exists anywhere in this API to ever clear it.
56
+ if (relativePath.startsWith(PAGES_PREFIX)) {
57
+ const dirRelative = relativePath.slice(PAGES_PREFIX.length).replace(/\.json$/, '');
58
+ const dirPath = sanitisePath(config.pagesRoot, dirRelative);
59
+ if (existsSync(dirPath) &&
60
+ statSync(dirPath).isDirectory() &&
61
+ listFilesRecursively(dirPath, dirPath, '.json').length > 0) {
62
+ throw new DeleteContentError('has-children', `"${relativePath}" has child pages; delete them first`);
63
+ }
64
+ }
65
+ // Never touches a draft at the same path: /v1/content and /v1/drafts
66
+ // are separate resources with their own dedicated DELETEs, and a
67
+ // draft-only page is already a legitimate, independently-listable
68
+ // state (Group D's listContent unions contentRoot/draftsRoot).
69
+ const original = readFileSync(livePath);
70
+ let redirectsBefore = null;
71
+ let redirectsChanged = false;
72
+ // Reverses the unlink and restores/removes redirects.json. Matches
73
+ // publish.ts's/move.ts's rollback() philosophy: attempt everything,
74
+ // report every failure.
75
+ function undoDeleteWrites() {
76
+ const restoreFailures = [];
77
+ try {
78
+ writeFileSync(livePath, original);
79
+ }
80
+ catch (restoreError) {
81
+ restoreFailures.push(restoreError);
82
+ }
83
+ if (redirectsChanged) {
84
+ try {
85
+ if (redirectsBefore === null) {
86
+ rmSync(config.redirectsPath, { force: true });
87
+ }
88
+ else {
89
+ writeFileSync(config.redirectsPath, redirectsBefore);
90
+ }
91
+ }
92
+ catch (restoreError) {
93
+ restoreFailures.push(restoreError);
94
+ }
95
+ }
96
+ return restoreFailures;
97
+ }
98
+ try {
99
+ unlinkSync(livePath);
100
+ // redirectTo is client-supplied, unlike publish.ts/move.ts's own
101
+ // automatic redirect bookkeeping - a client can trivially trigger a
102
+ // real cycle (e.g. redirectTo pointing back into an existing
103
+ // chain), so that's surfaced as its own reason, not left to fall
104
+ // into the generic write-failed/500 bucket an unlikely automatic-
105
+ // bookkeeping cycle would. Format is validated the same way as the
106
+ // dedicated manage-redirects.ts endpoints, internal-paths-only,
107
+ // so the two write paths never disagree on what's allowed into
108
+ // redirects.json.
109
+ const fromUrl = urlForDeletedEntry(relativePath);
110
+ if (redirectTo !== undefined && fromUrl !== null) {
111
+ if (!isValidRedirectTarget(redirectTo)) {
112
+ throw new DeleteContentError('invalid-redirect-target', `redirectTo "${redirectTo}" must be an internal path starting with "/"`);
113
+ }
114
+ redirectsBefore = existsSync(config.redirectsPath)
115
+ ? readFileSync(config.redirectsPath, 'utf-8')
116
+ : null;
117
+ let entries;
118
+ try {
119
+ entries = addRedirect(loadRedirects(config).entries, fromUrl, redirectTo).entries;
120
+ }
121
+ catch (error) {
122
+ if (error instanceof RedirectError) {
123
+ throw new DeleteContentError('redirect-cycle', error.message, { cause: error });
124
+ }
125
+ throw error;
126
+ }
127
+ const serialised = serialiseRedirects(entries);
128
+ if (serialised !== (redirectsBefore ?? '')) {
129
+ writeFileSync(config.redirectsPath, serialised);
130
+ redirectsChanged = true;
131
+ }
132
+ }
133
+ }
134
+ catch (error) {
135
+ const failures = undoDeleteWrites();
136
+ if (failures.length > 0) {
137
+ 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 });
138
+ }
139
+ // A redirect-cycle is already a DeleteContentError with its own
140
+ // reason - preserve it rather than reclassifying it as write-failed.
141
+ if (error instanceof DeleteContentError) {
142
+ throw error;
143
+ }
144
+ // Always write-failed here, structurally: commitPaths hasn't been
145
+ // called yet, so this can never be a GitOperationError.
146
+ const detail = error instanceof Error ? error.message : String(error);
147
+ throw new DeleteContentError('write-failed', `Delete failed: ${detail}`, { cause: error });
148
+ }
149
+ const paths = [livePath];
150
+ if (redirectsChanged) {
151
+ paths.push(config.redirectsPath);
152
+ }
153
+ return { paths, undo: undoDeleteWrites };
154
+ }
155
+ async function deleteContentJob(config, relativePath, redirectTo, message, author) {
156
+ const { paths, undo } = prepareDeleteContent(config, relativePath, redirectTo);
157
+ try {
158
+ commitPaths(config.siteRoot, paths, message, author);
159
+ }
160
+ catch (error) {
161
+ const failures = undo();
162
+ if (failures.length > 0) {
163
+ 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 });
164
+ }
165
+ // Always commit-failed here, structurally: prepare already
166
+ // succeeded, so anything caught in this try can only have come
167
+ // from commitPaths itself.
168
+ const detail = error instanceof Error ? error.message : String(error);
169
+ throw new DeleteContentError('commit-failed', `Delete failed: ${detail}`, { cause: error });
170
+ }
171
+ }
172
+ export function deleteContent(config, relativePath, redirectTo, message, author) {
173
+ return enqueue(() => deleteContentJob(config, relativePath, redirectTo, message, author));
174
+ }
@@ -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,15 @@
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 type DraftReason = 'validation-failed' | 'conflict';
5
+ export declare class DraftError extends Error {
6
+ readonly reason: DraftReason;
7
+ readonly errors: ValidationError[];
8
+ constructor(reason: DraftReason, message: string, errors?: ValidationError[]);
9
+ }
10
+ export declare function saveDraft(config: SiteConfig, themeSchemas: ThemeSchemas, relativePath: string, content: unknown, expectedEtag: string): Promise<string>;
11
+ export declare function discardDraft(config: SiteConfig, relativePath: string): Promise<void>;
12
+ export declare function prepareSaveDraft(config: SiteConfig, themeSchemas: ThemeSchemas, relativePath: string, content: unknown, expectedEtag: string): Promise<PreparedOperation & {
13
+ etag: string;
14
+ }>;
15
+ export declare function prepareDiscardDraft(config: SiteConfig, relativePath: string): Promise<PreparedOperation>;