@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,3 @@
1
+ export declare const MIME_TYPES: Record<string, string>;
2
+ export declare const DEFAULT_MIME_TYPE = "application/octet-stream";
3
+ export declare function mimeTypeFor(path: string): string;
@@ -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,3 @@
1
+ export declare function isBlogUrl(url: string): boolean;
2
+ export declare function urlToPostPath(url: string): string | null;
3
+ export declare function postPathToUrl(relativePostPath: string): string;
@@ -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,4 @@
1
+ export interface PreparedOperation {
2
+ paths: string[];
3
+ undo: () => unknown[];
4
+ }
@@ -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
+ }