@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.
- package/LICENSE +105 -0
- package/README.md +132 -0
- package/dist/boot.d.ts +15 -0
- package/dist/boot.js +33 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +45 -0
- package/dist/create-site/cli.d.ts +2 -0
- package/dist/create-site/cli.js +31 -0
- package/dist/create-site/generate-site.d.ts +9 -0
- package/dist/create-site/generate-site.js +109 -0
- package/dist/create-site/mint-token-cli.d.ts +2 -0
- package/dist/create-site/mint-token-cli.js +26 -0
- package/dist/create-site/mint-token.d.ts +7 -0
- package/dist/create-site/mint-token.js +49 -0
- package/dist/create-site/template/content/menus/footerCompany.json +9 -0
- package/dist/create-site/template/content/menus/footerProduct.json +9 -0
- package/dist/create-site/template/content/menus/footerResources.json +9 -0
- package/dist/create-site/template/content/menus/main.json +9 -0
- package/dist/create-site/template/content/pages/404.json +22 -0
- package/dist/create-site/template/content/pages/about/careers.json +21 -0
- package/dist/create-site/template/content/pages/about/team.json +21 -0
- package/dist/create-site/template/content/pages/about.json +21 -0
- package/dist/create-site/template/content/pages/docs/deployment.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started/quickstart.json +21 -0
- package/dist/create-site/template/content/pages/docs/getting-started.json +23 -0
- package/dist/create-site/template/content/pages/docs.json +21 -0
- package/dist/create-site/template/content/pages/index.json +181 -0
- package/dist/create-site/template/content/redirects.json +4 -0
- package/dist/create-site/template/dockerignore +6 -0
- package/dist/create-site/template/gitignore +11 -0
- package/dist/create-site/template/theme/assets/site.js +49 -0
- package/dist/create-site/template/theme/assets/style.css +1153 -0
- package/dist/create-site/template/theme/blocks/button.liquid +21 -0
- package/dist/create-site/template/theme/blocks/comparison-row.liquid +19 -0
- package/dist/create-site/template/theme/blocks/faq-item.liquid +22 -0
- package/dist/create-site/template/theme/blocks/feature-card.liquid +23 -0
- package/dist/create-site/template/theme/blocks/logo-mark.liquid +13 -0
- package/dist/create-site/template/theme/blocks/pricing-tier.liquid +42 -0
- package/dist/create-site/template/theme/blocks/step-card.liquid +19 -0
- package/dist/create-site/template/theme/blocks/testimonial-card.liquid +25 -0
- package/dist/create-site/template/theme/layouts/theme.liquid +99 -0
- package/dist/create-site/template/theme/sections/comparison.liquid +41 -0
- package/dist/create-site/template/theme/sections/cta-banner.liquid +27 -0
- package/dist/create-site/template/theme/sections/faq.liquid +27 -0
- package/dist/create-site/template/theme/sections/feature-grid.liquid +27 -0
- package/dist/create-site/template/theme/sections/hero.liquid +36 -0
- package/dist/create-site/template/theme/sections/how-it-works.liquid +27 -0
- package/dist/create-site/template/theme/sections/pricing.liquid +27 -0
- package/dist/create-site/template/theme/sections/social-proof.liquid +21 -0
- package/dist/create-site/template/theme/sections/testimonials.liquid +27 -0
- package/dist/create-site/template/theme/snippets/icon.liquid +24 -0
- package/dist/create-site/template/theme/snippets/logo.liquid +7 -0
- package/dist/create-site/template/theme/snippets/site-name.liquid +1 -0
- package/dist/create-site/template/vhost/Dockerfile +33 -0
- package/dist/create-site/template/vhost/docker-entrypoint.sh +30 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/media/drivers/driver.d.ts +11 -0
- package/dist/media/drivers/driver.js +1 -0
- package/dist/media/drivers/local-fs-driver.d.ts +3 -0
- package/dist/media/drivers/local-fs-driver.js +81 -0
- package/dist/media/filename.d.ts +1 -0
- package/dist/media/filename.js +31 -0
- package/dist/media/manage-media.d.ts +19 -0
- package/dist/media/manage-media.js +45 -0
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.js +49 -0
- package/dist/renderer/engine.d.ts +2 -0
- package/dist/renderer/engine.js +48 -0
- package/dist/renderer/render-page.d.ts +28 -0
- package/dist/renderer/render-page.js +143 -0
- package/dist/renderer/theme-templates.d.ts +7 -0
- package/dist/renderer/theme-templates.js +82 -0
- package/dist/routes/assets.d.ts +6 -0
- package/dist/routes/assets.js +30 -0
- package/dist/routes/batch.d.ts +10 -0
- package/dist/routes/batch.js +148 -0
- package/dist/routes/capabilities.d.ts +5 -0
- package/dist/routes/capabilities.js +27 -0
- package/dist/routes/content.d.ts +8 -0
- package/dist/routes/content.js +166 -0
- package/dist/routes/drafts.d.ts +10 -0
- package/dist/routes/drafts.js +73 -0
- package/dist/routes/git.d.ts +8 -0
- package/dist/routes/git.js +158 -0
- package/dist/routes/index.d.ts +19 -0
- package/dist/routes/index.js +84 -0
- package/dist/routes/media-public.d.ts +5 -0
- package/dist/routes/media-public.js +48 -0
- package/dist/routes/media.d.ts +9 -0
- package/dist/routes/media.js +84 -0
- package/dist/routes/menus.d.ts +8 -0
- package/dist/routes/menus.js +77 -0
- package/dist/routes/preview-revision.d.ts +13 -0
- package/dist/routes/preview-revision.js +109 -0
- package/dist/routes/preview.d.ts +13 -0
- package/dist/routes/preview.js +52 -0
- package/dist/routes/public.d.ts +11 -0
- package/dist/routes/public.js +117 -0
- package/dist/routes/publish.d.ts +10 -0
- package/dist/routes/publish.js +105 -0
- package/dist/routes/redirects.d.ts +8 -0
- package/dist/routes/redirects.js +132 -0
- package/dist/routes/search.d.ts +8 -0
- package/dist/routes/search.js +11 -0
- package/dist/routes/sitemap.d.ts +6 -0
- package/dist/routes/sitemap.js +79 -0
- package/dist/routes/theme.d.ts +10 -0
- package/dist/routes/theme.js +17 -0
- package/dist/schemas/instance.schema.json +17 -0
- package/dist/schemas/menu.schema.json +23 -0
- package/dist/schemas/page.schema.json +20 -0
- package/dist/schemas/post.schema.json +25 -0
- package/dist/schemas/redirects.schema.json +24 -0
- package/dist/search/drivers/driver.d.ts +9 -0
- package/dist/search/drivers/driver.js +1 -0
- package/dist/search/drivers/node-sqlite-driver.d.ts +3 -0
- package/dist/search/drivers/node-sqlite-driver.js +19 -0
- package/dist/search/query-index.d.ts +5 -0
- package/dist/search/query-index.js +21 -0
- package/dist/search/rebuild-index.d.ts +2 -0
- package/dist/search/rebuild-index.js +111 -0
- package/dist/server-config.d.ts +22 -0
- package/dist/server-config.js +196 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +163 -0
- package/dist/services/batch.d.ts +35 -0
- package/dist/services/batch.js +136 -0
- package/dist/services/checkpoint.d.ts +4 -0
- package/dist/services/checkpoint.js +45 -0
- package/dist/services/content-read.d.ts +27 -0
- package/dist/services/content-read.js +156 -0
- package/dist/services/delete-content.d.ts +12 -0
- package/dist/services/delete-content.js +174 -0
- package/dist/services/dev-tunnel.d.ts +5 -0
- package/dist/services/dev-tunnel.js +5 -0
- package/dist/services/drafts.d.ts +15 -0
- package/dist/services/drafts.js +155 -0
- package/dist/services/etag.d.ts +2 -0
- package/dist/services/etag.js +30 -0
- package/dist/services/fs-walk.d.ts +1 -0
- package/dist/services/fs-walk.js +26 -0
- package/dist/services/git-commit.d.ts +3 -0
- package/dist/services/git-commit.js +12 -0
- package/dist/services/git-history.d.ts +28 -0
- package/dist/services/git-history.js +131 -0
- package/dist/services/git-revert.d.ts +12 -0
- package/dist/services/git-revert.js +140 -0
- package/dist/services/git.d.ts +16 -0
- package/dist/services/git.js +127 -0
- package/dist/services/interval-job.d.ts +4 -0
- package/dist/services/interval-job.js +9 -0
- package/dist/services/ip-allowlist.d.ts +7 -0
- package/dist/services/ip-allowlist.js +31 -0
- package/dist/services/manage-menus.d.ts +10 -0
- package/dist/services/manage-menus.js +84 -0
- package/dist/services/manage-redirects.d.ts +25 -0
- package/dist/services/manage-redirects.js +166 -0
- package/dist/services/menus.d.ts +8 -0
- package/dist/services/menus.js +42 -0
- package/dist/services/migration-runner.d.ts +13 -0
- package/dist/services/migration-runner.js +123 -0
- package/dist/services/mime-types.d.ts +3 -0
- package/dist/services/mime-types.js +36 -0
- package/dist/services/move.d.ts +16 -0
- package/dist/services/move.js +175 -0
- package/dist/services/path-safety.d.ts +6 -0
- package/dist/services/path-safety.js +112 -0
- package/dist/services/post-urls.d.ts +3 -0
- package/dist/services/post-urls.js +27 -0
- package/dist/services/prepared-operation.d.ts +4 -0
- package/dist/services/prepared-operation.js +1 -0
- package/dist/services/publish.d.ts +14 -0
- package/dist/services/publish.js +254 -0
- package/dist/services/rate-limit-config.d.ts +9 -0
- package/dist/services/rate-limit-config.js +20 -0
- package/dist/services/redirects.d.ts +27 -0
- package/dist/services/redirects.js +175 -0
- package/dist/services/resolve-blog-url.d.ts +11 -0
- package/dist/services/resolve-blog-url.js +31 -0
- package/dist/services/resolve-url.d.ts +11 -0
- package/dist/services/resolve-url.js +20 -0
- package/dist/services/startup-checks.d.ts +10 -0
- package/dist/services/startup-checks.js +81 -0
- package/dist/services/static-file.d.ts +7 -0
- package/dist/services/static-file.js +37 -0
- package/dist/services/theme-component-file.d.ts +5 -0
- package/dist/services/theme-component-file.js +32 -0
- package/dist/services/theme-page-templates.d.ts +7 -0
- package/dist/services/theme-page-templates.js +53 -0
- package/dist/services/theme-schemas.d.ts +2 -0
- package/dist/services/theme-schemas.js +63 -0
- package/dist/services/token-auth.d.ts +10 -0
- package/dist/services/token-auth.js +64 -0
- package/dist/services/urls.d.ts +2 -0
- package/dist/services/urls.js +20 -0
- package/dist/services/validation.d.ts +24 -0
- package/dist/services/validation.js +158 -0
- package/dist/services/write-queue.d.ts +1 -0
- package/dist/services/write-queue.js +14 -0
- 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,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,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>;
|