@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,36 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
// A small hand-rolled lookup, not a dependency (@fastify/static would
|
|
3
|
+
// be the obvious ecosystem package, but this is a handful of lines and
|
|
4
|
+
// matches this codebase's existing minimal-dependency posture). Only
|
|
5
|
+
// the common web asset types - nothing here needs to be exhaustive.
|
|
6
|
+
//
|
|
7
|
+
// Shared by both routes/assets.ts (theme CSS/JS/images/fonts) and
|
|
8
|
+
// routes/public.ts's theme/root/ mirror (robots.txt, .well-known/*,
|
|
9
|
+
// site-verification HTML files) via services/static-file.ts - the
|
|
10
|
+
// text/xml/html entries below exist specifically for that second,
|
|
11
|
+
// broader use case. Found live: robots.txt fell back to
|
|
12
|
+
// DEFAULT_MIME_TYPE (application/octet-stream), which every browser
|
|
13
|
+
// treats as "download this" rather than displaying it inline, even
|
|
14
|
+
// though the actual content is plain text.
|
|
15
|
+
export const MIME_TYPES = {
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
18
|
+
'.json': 'application/json; charset=utf-8',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.jpg': 'image/jpeg',
|
|
21
|
+
'.jpeg': 'image/jpeg',
|
|
22
|
+
'.gif': 'image/gif',
|
|
23
|
+
'.svg': 'image/svg+xml',
|
|
24
|
+
'.webp': 'image/webp',
|
|
25
|
+
'.ico': 'image/x-icon',
|
|
26
|
+
'.woff': 'font/woff',
|
|
27
|
+
'.woff2': 'font/woff2',
|
|
28
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
29
|
+
'.xml': 'application/xml; charset=utf-8',
|
|
30
|
+
'.html': 'text/html; charset=utf-8',
|
|
31
|
+
'.htm': 'text/html; charset=utf-8',
|
|
32
|
+
};
|
|
33
|
+
export const DEFAULT_MIME_TYPE = 'application/octet-stream';
|
|
34
|
+
export function mimeTypeFor(path) {
|
|
35
|
+
return MIME_TYPES[extname(path).toLowerCase()] ?? DEFAULT_MIME_TYPE;
|
|
36
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 MoveReason = 'source-not-found' | 'destination-exists' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
5
|
+
export declare class MoveError extends Error {
|
|
6
|
+
readonly reason: MoveReason;
|
|
7
|
+
constructor(reason: MoveReason, message: string, options?: {
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export declare function prepareMovePage(config: SiteConfig, fromUrl: string, toUrl: string, options?: {
|
|
12
|
+
createRedirect?: boolean;
|
|
13
|
+
}): PreparedOperation;
|
|
14
|
+
export declare function movePage(config: SiteConfig, fromUrl: string, toUrl: string, message: string, author: CommitAuthor, options?: {
|
|
15
|
+
createRedirect?: boolean;
|
|
16
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { listFilesRecursively } from "./fs-walk.js";
|
|
4
|
+
import { commitPaths } from "./git.js";
|
|
5
|
+
import { sanitisePath } from "./path-safety.js";
|
|
6
|
+
import { addRedirect, loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
7
|
+
import { pagePathToUrl, urlToPagePath } from "./urls.js";
|
|
8
|
+
import { enqueue } from "./write-queue.js";
|
|
9
|
+
export class MoveError extends Error {
|
|
10
|
+
reason;
|
|
11
|
+
constructor(reason, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.name = 'MoveError';
|
|
14
|
+
this.reason = reason;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// Validates and writes everything a move needs, stopping short of the
|
|
18
|
+
// commit itself - used both by the standalone movePageJob (which
|
|
19
|
+
// commits immediately after) and by batch.ts (which accumulates this
|
|
20
|
+
// alongside other operations and commits once for the whole batch). A
|
|
21
|
+
// write-phase failure is caught and rolled back here, inside prepare
|
|
22
|
+
// itself, so prepare either fully succeeds or fully undoes itself
|
|
23
|
+
// before throwing.
|
|
24
|
+
//
|
|
25
|
+
// options.createRedirect defaults true - existing callers (and
|
|
26
|
+
// batch.ts) keep today's behaviour unchanged. false skips only
|
|
27
|
+
// addRedirect (the "old URL now redirects to the new one" step);
|
|
28
|
+
// removeRedirectForPath still always runs regardless, since that's a
|
|
29
|
+
// correctness cleanup (the destination URL can't stay claimed by a
|
|
30
|
+
// stale redirect once a real page exists there), not a new redirect
|
|
31
|
+
// being created.
|
|
32
|
+
export function prepareMovePage(config, fromUrl, toUrl, options = {}) {
|
|
33
|
+
const createRedirect = options.createRedirect ?? true;
|
|
34
|
+
const fromRelative = urlToPagePath(fromUrl);
|
|
35
|
+
const toRelative = urlToPagePath(toUrl);
|
|
36
|
+
const fromPageFile = sanitisePath(config.pagesRoot, fromRelative);
|
|
37
|
+
const toPageFile = sanitisePath(config.pagesRoot, toRelative);
|
|
38
|
+
if (!existsSync(fromPageFile)) {
|
|
39
|
+
throw new MoveError('source-not-found', `No page found at "${fromUrl}"`);
|
|
40
|
+
}
|
|
41
|
+
if (existsSync(toPageFile)) {
|
|
42
|
+
throw new MoveError('destination-exists', `A page already exists at "${toUrl}"`);
|
|
43
|
+
}
|
|
44
|
+
const fromDirRelative = fromRelative.replace(/\.json$/, '');
|
|
45
|
+
const toDirRelative = toRelative.replace(/\.json$/, '');
|
|
46
|
+
const fromDir = sanitisePath(config.pagesRoot, fromDirRelative);
|
|
47
|
+
const toDir = sanitisePath(config.pagesRoot, toDirRelative);
|
|
48
|
+
const fromDirExists = existsSync(fromDir) && statSync(fromDir).isDirectory();
|
|
49
|
+
if (fromDirExists && existsSync(toDir)) {
|
|
50
|
+
throw new MoveError('destination-exists', `A directory already exists at the destination for "${toUrl}"`);
|
|
51
|
+
}
|
|
52
|
+
// Enumerate every affected page (the page itself, plus every
|
|
53
|
+
// descendant if it has any) before touching disk. This list drives
|
|
54
|
+
// both the redirect entries (E3: one per affected page) and the git
|
|
55
|
+
// staging list.
|
|
56
|
+
const affected = [
|
|
57
|
+
{ oldRelativePath: fromRelative, newRelativePath: toRelative, oldUrl: fromUrl, newUrl: toUrl },
|
|
58
|
+
];
|
|
59
|
+
if (fromDirExists) {
|
|
60
|
+
for (const rel of listFilesRecursively(fromDir, fromDir, '.json')) {
|
|
61
|
+
const oldRelativePath = join(fromDirRelative, rel);
|
|
62
|
+
const newRelativePath = join(toDirRelative, rel);
|
|
63
|
+
affected.push({
|
|
64
|
+
oldRelativePath,
|
|
65
|
+
newRelativePath,
|
|
66
|
+
oldUrl: pagePathToUrl(oldRelativePath),
|
|
67
|
+
newUrl: pagePathToUrl(newRelativePath),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const performedRenames = [];
|
|
72
|
+
let redirectsBefore = null;
|
|
73
|
+
let redirectsChanged = false;
|
|
74
|
+
// Reverses every rename (in reverse order) and restores/removes
|
|
75
|
+
// redirects.json. Attempts everything even if one step fails,
|
|
76
|
+
// reporting every failure - matches publish.ts's rollback()
|
|
77
|
+
// philosophy exactly (a filesystem cannot give a true atomicity
|
|
78
|
+
// guarantee, so the honest contract is "restores everything it can,
|
|
79
|
+
// and is loud if it can't").
|
|
80
|
+
function undoMoveWrites() {
|
|
81
|
+
const restoreFailures = [];
|
|
82
|
+
for (const rename of [...performedRenames].reverse()) {
|
|
83
|
+
try {
|
|
84
|
+
renameSync(rename.to, rename.from);
|
|
85
|
+
}
|
|
86
|
+
catch (restoreError) {
|
|
87
|
+
restoreFailures.push(restoreError);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (redirectsChanged) {
|
|
91
|
+
try {
|
|
92
|
+
if (redirectsBefore === null) {
|
|
93
|
+
rmSync(config.redirectsPath, { force: true });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
writeFileSync(config.redirectsPath, redirectsBefore);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (restoreError) {
|
|
100
|
+
restoreFailures.push(restoreError);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return restoreFailures;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
mkdirSync(dirname(toPageFile), { recursive: true });
|
|
107
|
+
renameSync(fromPageFile, toPageFile);
|
|
108
|
+
performedRenames.push({ from: fromPageFile, to: toPageFile });
|
|
109
|
+
if (fromDirExists) {
|
|
110
|
+
mkdirSync(dirname(toDir), { recursive: true });
|
|
111
|
+
renameSync(fromDir, toDir);
|
|
112
|
+
performedRenames.push({ from: fromDir, to: toDir });
|
|
113
|
+
}
|
|
114
|
+
redirectsBefore = existsSync(config.redirectsPath)
|
|
115
|
+
? readFileSync(config.redirectsPath, 'utf-8')
|
|
116
|
+
: null;
|
|
117
|
+
let entries = loadRedirects(config).entries;
|
|
118
|
+
for (const page of affected) {
|
|
119
|
+
// E5 (move half) must run BEFORE addRedirect, not after: if the
|
|
120
|
+
// destination held a stale redirect (page.newUrl was itself a
|
|
121
|
+
// key), addRedirect's chain-walk would otherwise treat it as a
|
|
122
|
+
// legitimate hop and silently collapse straight through it,
|
|
123
|
+
// rather than the new page superseding it.
|
|
124
|
+
entries = removeRedirectForPath(entries, page.newUrl);
|
|
125
|
+
if (createRedirect) {
|
|
126
|
+
entries = addRedirect(entries, page.oldUrl, page.newUrl).entries;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const redirectsAfter = serialiseRedirects(entries);
|
|
130
|
+
if (redirectsAfter !== (redirectsBefore ?? '')) {
|
|
131
|
+
writeFileSync(config.redirectsPath, redirectsAfter);
|
|
132
|
+
redirectsChanged = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
// A write-phase failure: roll back immediately, inside prepare
|
|
137
|
+
// itself, and always classify as write-failed - commitPaths hasn't
|
|
138
|
+
// been called yet, so this can never be a GitOperationError.
|
|
139
|
+
const failures = undoMoveWrites();
|
|
140
|
+
if (failures.length > 0) {
|
|
141
|
+
throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
142
|
+
}
|
|
143
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
144
|
+
throw new MoveError('write-failed', `Move failed: ${detail}`, { cause: error });
|
|
145
|
+
}
|
|
146
|
+
const paths = [];
|
|
147
|
+
for (const page of affected) {
|
|
148
|
+
paths.push(sanitisePath(config.pagesRoot, page.oldRelativePath));
|
|
149
|
+
paths.push(sanitisePath(config.pagesRoot, page.newRelativePath));
|
|
150
|
+
}
|
|
151
|
+
if (redirectsChanged) {
|
|
152
|
+
paths.push(config.redirectsPath);
|
|
153
|
+
}
|
|
154
|
+
return { paths, undo: undoMoveWrites };
|
|
155
|
+
}
|
|
156
|
+
async function movePageJob(config, fromUrl, toUrl, message, author, options = {}) {
|
|
157
|
+
const { paths, undo } = prepareMovePage(config, fromUrl, toUrl, options);
|
|
158
|
+
try {
|
|
159
|
+
commitPaths(config.siteRoot, paths, message, author);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
const failures = undo();
|
|
163
|
+
if (failures.length > 0) {
|
|
164
|
+
throw new MoveError('rollback-failed', 'Move failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
165
|
+
}
|
|
166
|
+
// Always commit-failed here, structurally: prepare already
|
|
167
|
+
// succeeded, so anything caught in this try can only have come
|
|
168
|
+
// from commitPaths itself.
|
|
169
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
170
|
+
throw new MoveError('commit-failed', `Move failed: ${detail}`, { cause: error });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export function movePage(config, fromUrl, toUrl, message, author, options = {}) {
|
|
174
|
+
return enqueue(() => movePageJob(config, fromUrl, toUrl, message, author, options));
|
|
175
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type PathSafetyReason = 'absolute-path' | 'traversal-segment' | 'malformed-encoding' | 'outside-root' | 'resolves-to-root' | 'symlink-escape' | 'invalid-path';
|
|
2
|
+
export declare class PathSafetyError extends Error {
|
|
3
|
+
readonly reason: PathSafetyReason;
|
|
4
|
+
constructor(reason: PathSafetyReason, message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function sanitisePath(root: string, requestedPath: string): string;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
+
export class PathSafetyError extends Error {
|
|
4
|
+
reason;
|
|
5
|
+
constructor(reason, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'PathSafetyError';
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function isWithin(root, candidate) {
|
|
12
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
13
|
+
}
|
|
14
|
+
// Only existing path segments can be symlinks, so for a target that
|
|
15
|
+
// doesn't exist yet (e.g. a new draft being written for the first time)
|
|
16
|
+
// it is sufficient to realpath the nearest existing ancestor and confirm
|
|
17
|
+
// that stays within the root; segments that don't exist can't smuggle a
|
|
18
|
+
// symlink escape.
|
|
19
|
+
function realpathOfNearestExisting(candidate) {
|
|
20
|
+
let current = candidate;
|
|
21
|
+
for (;;) {
|
|
22
|
+
try {
|
|
23
|
+
return realpathSync(current);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const code = error.code;
|
|
27
|
+
// ENOENT/ENOTDIR mean only "this segment doesn't exist yet" -
|
|
28
|
+
// walk up to the nearest ancestor that does, per this function's
|
|
29
|
+
// own doc comment. Any other failure (ENAMETOOLONG for an
|
|
30
|
+
// over-length segment, ERR_INVALID_ARG_VALUE for an embedded null
|
|
31
|
+
// byte, etc. - verified empirically, not an exhaustive enum by
|
|
32
|
+
// design) means the path itself is malformed, not merely absent.
|
|
33
|
+
// Rejecting it here as a PathSafetyError is required, not
|
|
34
|
+
// optional: left as a raw rethrow, it would escape every route
|
|
35
|
+
// handler's `instanceof PathSafetyError` check and surface as an
|
|
36
|
+
// unsanitised 500, the exact failure mode this file exists to
|
|
37
|
+
// prevent (CLAUDE.md's #1 security concern).
|
|
38
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
39
|
+
throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
|
|
40
|
+
}
|
|
41
|
+
const parent = dirname(current);
|
|
42
|
+
if (parent === current) {
|
|
43
|
+
throw new PathSafetyError('invalid-path', `Path could not be resolved: "${candidate}"`);
|
|
44
|
+
}
|
|
45
|
+
current = parent;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The single shared path-sanitisation helper. Every fs-touching code
|
|
50
|
+
// path that handles a request-supplied :path parameter must resolve it
|
|
51
|
+
// through this function before touching disk (enforced by a grep test,
|
|
52
|
+
// see test/static/static-analysis.test.ts).
|
|
53
|
+
export function sanitisePath(root, requestedPath) {
|
|
54
|
+
const normalisedRoot = resolve(root);
|
|
55
|
+
// Step 1: reject a raw absolute path before any decoding.
|
|
56
|
+
if (isAbsolute(requestedPath)) {
|
|
57
|
+
throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
|
|
58
|
+
}
|
|
59
|
+
// Step 2: percent-decode. Decoding before splitting into segments (not
|
|
60
|
+
// decoding each segment independently) is what's actually required to
|
|
61
|
+
// catch an encoded slash disguising a segment boundary, e.g.
|
|
62
|
+
// "a%2f..%2fetc" decodes to "a/../etc" and is then caught by the
|
|
63
|
+
// segment scan below; decoding per-segment would miss it. Malformed
|
|
64
|
+
// encoding is rejected outright rather than passed through: this will
|
|
65
|
+
// also reject a benign filename containing a literal unencoded "%",
|
|
66
|
+
// which is an acceptable reject-on-ambiguity trade-off, not a bug.
|
|
67
|
+
let decoded;
|
|
68
|
+
try {
|
|
69
|
+
decoded = decodeURIComponent(requestedPath);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new PathSafetyError('malformed-encoding', `Path contains malformed percent-encoding: "${requestedPath}"`);
|
|
73
|
+
}
|
|
74
|
+
// Step 3: an encoded absolute path (e.g. "%2fetc%2fpasswd") doesn't
|
|
75
|
+
// look absolute in raw form, so check again after decoding.
|
|
76
|
+
if (isAbsolute(decoded)) {
|
|
77
|
+
throw new PathSafetyError('absolute-path', `Path must be relative, got "${requestedPath}"`);
|
|
78
|
+
}
|
|
79
|
+
// Step 4: reject any ".." segment, covering both plain and encoded
|
|
80
|
+
// traversal now that decoding has already happened.
|
|
81
|
+
if (decoded.split('/').includes('..')) {
|
|
82
|
+
throw new PathSafetyError('traversal-segment', `Path must not contain ".." segments: "${requestedPath}"`);
|
|
83
|
+
}
|
|
84
|
+
// Step 5: string-only containment check, still no fs call.
|
|
85
|
+
const resolved = resolve(normalisedRoot, decoded);
|
|
86
|
+
if (!isWithin(normalisedRoot, resolved)) {
|
|
87
|
+
throw new PathSafetyError('outside-root', `Path resolves outside the allowed root: "${requestedPath}"`);
|
|
88
|
+
}
|
|
89
|
+
// Step 6: deliberate hardening beyond the literal B3-B6 wording. A
|
|
90
|
+
// request that resolves to exactly the root (e.g. "", ".") must not be
|
|
91
|
+
// handed to a caller expecting a single file path.
|
|
92
|
+
if (resolved === normalisedRoot) {
|
|
93
|
+
throw new PathSafetyError('resolves-to-root', `Path must not resolve to the root itself: "${requestedPath}"`);
|
|
94
|
+
}
|
|
95
|
+
// Step 7: only now touch the filesystem, to catch a symlink inside the
|
|
96
|
+
// tree pointing outside the root.
|
|
97
|
+
const realRoot = realpathSync(normalisedRoot);
|
|
98
|
+
const realResolved = realpathOfNearestExisting(resolved);
|
|
99
|
+
if (!isWithin(realRoot, realResolved)) {
|
|
100
|
+
throw new PathSafetyError('symlink-escape', `Path escapes the allowed root via a symlink: "${requestedPath}"`);
|
|
101
|
+
}
|
|
102
|
+
// Accepted residual risk, not a structural gap: the symlink check
|
|
103
|
+
// above and the fs operation a caller performs with the returned
|
|
104
|
+
// path are not atomic, so a symlink swapped in between could in
|
|
105
|
+
// principle slip through (TOCTOU). Given the actual threat model -
|
|
106
|
+
// a single-operator site, all writes serialised through one queue,
|
|
107
|
+
// no concurrent untrusted filesystem access - this is low realistic
|
|
108
|
+
// risk. Flagged by plan-reviewer's Phase 1 audit; noted rather than
|
|
109
|
+
// solved, the same way other accepted trade-offs in this codebase
|
|
110
|
+
// are (see publish.ts's rollback() comment).
|
|
111
|
+
return resolved;
|
|
112
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Pure, filesystem-free mapping between a /blog/<slug> URL and a
|
|
2
|
+
// post's path relative to postsRoot. Unlike pages' arbitrary nested
|
|
3
|
+
// paths (about.json beside a sibling about/ directory), posts are
|
|
4
|
+
// flat only - a URL with more than one segment after /blog/ is never
|
|
5
|
+
// a valid post URL, enforced here rather than left to sanitisePath.
|
|
6
|
+
const BLOG_PREFIX = '/blog/';
|
|
7
|
+
// /blog is a permanently reserved namespace: both "/blog" itself (no
|
|
8
|
+
// slug) and every "/blog/..." URL are recognised here, so a caller can
|
|
9
|
+
// route the whole namespace to post resolution before ever checking
|
|
10
|
+
// for a page, matching the confirmed reserved-namespace decision.
|
|
11
|
+
export function isBlogUrl(url) {
|
|
12
|
+
return url === '/blog' || url.startsWith(BLOG_PREFIX);
|
|
13
|
+
}
|
|
14
|
+
export function urlToPostPath(url) {
|
|
15
|
+
if (!url.startsWith(BLOG_PREFIX)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const slug = url.slice(BLOG_PREFIX.length);
|
|
19
|
+
if (slug === '' || slug.includes('/')) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return `${slug}.json`;
|
|
23
|
+
}
|
|
24
|
+
export function postPathToUrl(relativePostPath) {
|
|
25
|
+
const withoutExtension = relativePostPath.replace(/\.json$/, '');
|
|
26
|
+
return `${BLOG_PREFIX}${withoutExtension}`;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SiteConfig } from '../config.ts';
|
|
2
|
+
import type { CommitAuthor } from './git.ts';
|
|
3
|
+
import type { PreparedOperation } from './prepared-operation.ts';
|
|
4
|
+
import type { ThemeSchemas } from './validation.ts';
|
|
5
|
+
export type PublishReason = 'validation-failed' | 'draft-not-found' | 'page-not-found' | 'duplicate-path' | 'write-failed' | 'commit-failed' | 'rollback-failed';
|
|
6
|
+
export declare class PublishError extends Error {
|
|
7
|
+
readonly reason: PublishReason;
|
|
8
|
+
constructor(reason: PublishReason, message: string, options?: {
|
|
9
|
+
cause?: unknown;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export declare function preparePublishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[]): PreparedOperation;
|
|
13
|
+
export declare function publishDrafts(config: SiteConfig, themeSchemas: ThemeSchemas, relativePaths: string[], message: string, author: CommitAuthor): Promise<void>;
|
|
14
|
+
export declare function unpublishPage(config: SiteConfig, relativePath: string, message: string, author: CommitAuthor): Promise<void>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { GitOperationError, commitPaths } from "./git.js";
|
|
4
|
+
import { sanitisePath } from "./path-safety.js";
|
|
5
|
+
import { postPathToUrl } from "./post-urls.js";
|
|
6
|
+
import { loadRedirects, removeRedirectForPath, serialiseRedirects } from "./redirects.js";
|
|
7
|
+
import { pagePathToUrl } from "./urls.js";
|
|
8
|
+
import { validateContent } from "./validation.js";
|
|
9
|
+
import { enqueue } from "./write-queue.js";
|
|
10
|
+
const PAGES_PREFIX = 'pages/';
|
|
11
|
+
const POSTS_PREFIX = 'posts/';
|
|
12
|
+
// Mirrors delete-content.ts's urlForDeletedEntry - a tiny duplicated
|
|
13
|
+
// helper, not a shared abstraction. Widens the stale-redirect-clearing
|
|
14
|
+
// block below to posts alongside pages; returns null for anything
|
|
15
|
+
// else (menus have no public URL, so redirect semantics don't apply).
|
|
16
|
+
function urlForPublishedEntry(relativePath) {
|
|
17
|
+
if (relativePath.startsWith(PAGES_PREFIX)) {
|
|
18
|
+
return pagePathToUrl(relativePath.slice(PAGES_PREFIX.length));
|
|
19
|
+
}
|
|
20
|
+
if (relativePath.startsWith(POSTS_PREFIX)) {
|
|
21
|
+
return postPathToUrl(relativePath.slice(POSTS_PREFIX.length));
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export class PublishError extends Error {
|
|
26
|
+
reason;
|
|
27
|
+
constructor(reason, message, options) {
|
|
28
|
+
super(message, options);
|
|
29
|
+
this.name = 'PublishError';
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Hand-rolled fs snapshot/restore, not a database transaction
|
|
34
|
+
// (deliberate, given the minimal-dependency policy). Attempts every
|
|
35
|
+
// restore even if one fails, rather than aborting on the first error,
|
|
36
|
+
// and reports every failure to the caller: a filesystem cannot give a
|
|
37
|
+
// true atomicity guarantee (disk full, permissions changing mid-flight
|
|
38
|
+
// are real residual risks), so the honest contract is "restores
|
|
39
|
+
// everything it can, and is loud if it can't" rather than a silent
|
|
40
|
+
// best-effort.
|
|
41
|
+
function rollback(snapshots, redirects) {
|
|
42
|
+
const failures = [];
|
|
43
|
+
for (const snapshot of snapshots) {
|
|
44
|
+
try {
|
|
45
|
+
if (snapshot.liveExisted) {
|
|
46
|
+
writeFileSync(snapshot.livePath, snapshot.liveOriginal);
|
|
47
|
+
}
|
|
48
|
+
else if (existsSync(snapshot.livePath)) {
|
|
49
|
+
unlinkSync(snapshot.livePath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
failures.push(error);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dirname(snapshot.draftPath), { recursive: true });
|
|
57
|
+
writeFileSync(snapshot.draftPath, snapshot.draftOriginal);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
failures.push(error);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (redirects) {
|
|
64
|
+
try {
|
|
65
|
+
if (redirects.existed) {
|
|
66
|
+
writeFileSync(redirects.path, redirects.original);
|
|
67
|
+
}
|
|
68
|
+
else if (existsSync(redirects.path)) {
|
|
69
|
+
unlinkSync(redirects.path);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
failures.push(error);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return failures;
|
|
77
|
+
}
|
|
78
|
+
function rollbackOrRethrow(snapshots, cause, redirects) {
|
|
79
|
+
const failures = rollback(snapshots, redirects);
|
|
80
|
+
if (failures.length > 0) {
|
|
81
|
+
throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause });
|
|
82
|
+
}
|
|
83
|
+
const reason = cause instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
84
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
85
|
+
throw new PublishError(reason, `Publish failed: ${message}`, { cause });
|
|
86
|
+
}
|
|
87
|
+
// Validates and writes everything a publish needs, stopping short of
|
|
88
|
+
// the commit itself - used both by the standalone publishDraftsJob
|
|
89
|
+
// (which commits immediately after) and by batch.ts (which accumulates
|
|
90
|
+
// this alongside other operations and commits once for the whole
|
|
91
|
+
// batch). A write-phase failure is caught and rolled back here,
|
|
92
|
+
// inside prepare itself - never left for the caller to discover -
|
|
93
|
+
// so prepare either fully succeeds or fully undoes itself before
|
|
94
|
+
// throwing.
|
|
95
|
+
export function preparePublishDrafts(config, themeSchemas, relativePaths) {
|
|
96
|
+
// Reject duplicates up front: without this, a repeated path's live-
|
|
97
|
+
// file snapshot would capture already-overwritten state instead of
|
|
98
|
+
// the true original, corrupting any later rollback.
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const relativePath of relativePaths) {
|
|
101
|
+
if (seen.has(relativePath)) {
|
|
102
|
+
throw new PublishError('duplicate-path', `Duplicate path in publish request: "${relativePath}"`);
|
|
103
|
+
}
|
|
104
|
+
seen.add(relativePath);
|
|
105
|
+
}
|
|
106
|
+
// Validate every path before writing anything. Nothing is written
|
|
107
|
+
// during this pass, so "no files change and no commit is created" on
|
|
108
|
+
// any failure (C6) falls out of ordering, not cleanup logic. Safe
|
|
109
|
+
// because the write queue serialises everything: no other job can
|
|
110
|
+
// interleave and change a draft between this pass and the write pass.
|
|
111
|
+
const entries = [];
|
|
112
|
+
for (const relativePath of relativePaths) {
|
|
113
|
+
const draftPath = sanitisePath(config.draftsRoot, relativePath);
|
|
114
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
115
|
+
let draftContent;
|
|
116
|
+
try {
|
|
117
|
+
draftContent = readFileSync(draftPath);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
throw new PublishError('draft-not-found', `No draft found at "${relativePath}"`);
|
|
121
|
+
}
|
|
122
|
+
const parsed = JSON.parse(draftContent.toString('utf-8'));
|
|
123
|
+
const result = validateContent(relativePath, parsed, themeSchemas);
|
|
124
|
+
if (!result.valid) {
|
|
125
|
+
throw new PublishError('validation-failed', `Draft at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`);
|
|
126
|
+
}
|
|
127
|
+
entries.push({ relativePath, draftPath, livePath, draftContent });
|
|
128
|
+
}
|
|
129
|
+
const snapshots = [];
|
|
130
|
+
let redirectsSnapshot;
|
|
131
|
+
try {
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
let liveExisted = false;
|
|
134
|
+
let liveOriginal;
|
|
135
|
+
try {
|
|
136
|
+
liveOriginal = readFileSync(entry.livePath);
|
|
137
|
+
liveExisted = true;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
liveExisted = false;
|
|
141
|
+
}
|
|
142
|
+
snapshots.push({
|
|
143
|
+
livePath: entry.livePath,
|
|
144
|
+
liveExisted,
|
|
145
|
+
liveOriginal,
|
|
146
|
+
draftPath: entry.draftPath,
|
|
147
|
+
draftOriginal: entry.draftContent,
|
|
148
|
+
});
|
|
149
|
+
mkdirSync(dirname(entry.livePath), { recursive: true });
|
|
150
|
+
writeFileSync(entry.livePath, entry.draftContent);
|
|
151
|
+
unlinkSync(entry.draftPath);
|
|
152
|
+
}
|
|
153
|
+
// E5 (publish half): a brand-new live page or post may supersede a
|
|
154
|
+
// stale redirect recorded at its own URL (checklist wording:
|
|
155
|
+
// "creating a page at a path that has a redirect entry"). Only
|
|
156
|
+
// applies to pages/posts content - redirect semantics aren't
|
|
157
|
+
// defined for menus (no public URL), so anything else is skipped
|
|
158
|
+
// silently via urlForPublishedEntry's null return.
|
|
159
|
+
const qualifyingUrls = entries
|
|
160
|
+
.map((entry) => urlForPublishedEntry(entry.relativePath))
|
|
161
|
+
.filter((url) => url !== null);
|
|
162
|
+
if (qualifyingUrls.length > 0) {
|
|
163
|
+
const existed = existsSync(config.redirectsPath);
|
|
164
|
+
const original = existed ? readFileSync(config.redirectsPath, 'utf-8') : '';
|
|
165
|
+
let entriesAfterRemoval = loadRedirects(config).entries;
|
|
166
|
+
for (const url of qualifyingUrls) {
|
|
167
|
+
entriesAfterRemoval = removeRedirectForPath(entriesAfterRemoval, url);
|
|
168
|
+
}
|
|
169
|
+
const updated = serialiseRedirects(entriesAfterRemoval);
|
|
170
|
+
if (updated !== (existed ? original : '')) {
|
|
171
|
+
writeFileSync(config.redirectsPath, updated);
|
|
172
|
+
redirectsSnapshot = { path: config.redirectsPath, existed, original };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// A write-phase failure: roll back immediately, inside prepare
|
|
178
|
+
// itself. cause here can never be a GitOperationError (commitPaths
|
|
179
|
+
// hasn't been called yet), so rollbackOrRethrow's own
|
|
180
|
+
// instanceof-based classification correctly resolves to
|
|
181
|
+
// 'write-failed', never 'commit-failed'.
|
|
182
|
+
rollbackOrRethrow(snapshots, error, redirectsSnapshot);
|
|
183
|
+
}
|
|
184
|
+
// Only the live paths (and redirects.json, if it changed) are ever
|
|
185
|
+
// staged: drafts are never git-tracked in the first place (saves
|
|
186
|
+
// don't commit, per C2), so a draft's deletion has no git-visible
|
|
187
|
+
// effect at all. Including it in the commit would fail outright,
|
|
188
|
+
// since `git add` on a path that is both absent from disk and was
|
|
189
|
+
// never tracked is a hard error ("pathspec did not match any
|
|
190
|
+
// files"), not a silent no-op.
|
|
191
|
+
const paths = entries.map((entry) => entry.livePath);
|
|
192
|
+
if (redirectsSnapshot) {
|
|
193
|
+
paths.push(redirectsSnapshot.path);
|
|
194
|
+
}
|
|
195
|
+
return { paths, undo: () => rollback(snapshots, redirectsSnapshot) };
|
|
196
|
+
}
|
|
197
|
+
async function publishDraftsJob(config, themeSchemas, relativePaths, message, author) {
|
|
198
|
+
const { paths, undo } = preparePublishDrafts(config, themeSchemas, relativePaths);
|
|
199
|
+
try {
|
|
200
|
+
// commitPaths gives its own all-or-nothing contract for git staging,
|
|
201
|
+
// so a failure here only ever leaves filesystem state for this
|
|
202
|
+
// catch block to worry about, never git index state.
|
|
203
|
+
commitPaths(config.siteRoot, paths, message, author);
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
const failures = undo();
|
|
207
|
+
if (failures.length > 0) {
|
|
208
|
+
throw new PublishError('rollback-failed', 'Publish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
209
|
+
}
|
|
210
|
+
// Always commit-failed here, structurally: prepare already
|
|
211
|
+
// succeeded (no write-phase error), so anything caught in this try
|
|
212
|
+
// can only have come from commitPaths itself.
|
|
213
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
214
|
+
throw new PublishError('commit-failed', `Publish failed: ${errorMessage}`, { cause: error });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async function unpublishPageJob(config, relativePath, message, author) {
|
|
218
|
+
const livePath = sanitisePath(config.contentRoot, relativePath);
|
|
219
|
+
let original;
|
|
220
|
+
try {
|
|
221
|
+
original = readFileSync(livePath);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
throw new PublishError('page-not-found', `No live page found at "${relativePath}"`);
|
|
225
|
+
}
|
|
226
|
+
const parsed = JSON.parse(original.toString('utf-8'));
|
|
227
|
+
parsed.published = false;
|
|
228
|
+
const updated = Buffer.from(JSON.stringify(parsed, null, 2));
|
|
229
|
+
// Unpublish only ever touches one file and never a draft, so it gets
|
|
230
|
+
// its own minimal inline restore rather than routing through the
|
|
231
|
+
// two-file publish rollback() above, which would need an unused
|
|
232
|
+
// draft slot to fit the same shape for no benefit.
|
|
233
|
+
try {
|
|
234
|
+
writeFileSync(livePath, updated);
|
|
235
|
+
commitPaths(config.siteRoot, [livePath], message, author);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
try {
|
|
239
|
+
writeFileSync(livePath, original);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new PublishError('rollback-failed', 'Unpublish failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
|
|
243
|
+
}
|
|
244
|
+
const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
|
|
245
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
246
|
+
throw new PublishError(reason, `Unpublish failed: ${errorMessage}`, { cause: error });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export function publishDrafts(config, themeSchemas, relativePaths, message, author) {
|
|
250
|
+
return enqueue(() => publishDraftsJob(config, themeSchemas, relativePaths, message, author));
|
|
251
|
+
}
|
|
252
|
+
export function unpublishPage(config, relativePath, message, author) {
|
|
253
|
+
return enqueue(() => unpublishPageJob(config, relativePath, message, author));
|
|
254
|
+
}
|