@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,127 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export class GitOperationError extends Error {
3
+ reason;
4
+ constructor(reason, message, options) {
5
+ super(message, options);
6
+ this.name = 'GitOperationError';
7
+ this.reason = reason;
8
+ }
9
+ }
10
+ const CONTROL_CHAR_PATTERN = /[\r\n]/;
11
+ // The route layer's shape check for a caller-supplied author before it
12
+ // ever reaches `git commit --author` below. execFileSync (not a shell)
13
+ // already rules out command injection, but a name/email containing a
14
+ // literal newline could still smuggle a malformed or multi-line author
15
+ // string into git history - this is the first place in the codebase a
16
+ // caller-supplied identity reaches a real commit (saveDraft/discardDraft
17
+ // never take an author at all), so this check is new, necessary
18
+ // surface, not a repeat of an existing one.
19
+ export function isValidCommitAuthor(value) {
20
+ if (typeof value !== 'object' || value === null) {
21
+ return false;
22
+ }
23
+ const { name, email } = value;
24
+ return (typeof name === 'string' &&
25
+ name.length > 0 &&
26
+ !CONTROL_CHAR_PATTERN.test(name) &&
27
+ typeof email === 'string' &&
28
+ email.length > 0 &&
29
+ !CONTROL_CHAR_PATTERN.test(email));
30
+ }
31
+ // Author identity always comes from the caller, never host config
32
+ // (constraint: commit authorship passthrough, checklist C5). Passing
33
+ // GIT_AUTHOR_*/GIT_COMMITTER_* explicitly means this never depends on
34
+ // ~/.gitconfig or any host identity being configured at all, which also
35
+ // retires the "usable identity config" startup prerequisite entirely
36
+ // rather than merely deferring it.
37
+ function authorEnv(author) {
38
+ return {
39
+ ...process.env,
40
+ GIT_AUTHOR_NAME: author.name,
41
+ GIT_AUTHOR_EMAIL: author.email,
42
+ GIT_COMMITTER_NAME: author.name,
43
+ GIT_COMMITTER_EMAIL: author.email,
44
+ };
45
+ }
46
+ // Guarantees its own all-or-nothing contract: either fully committed,
47
+ // or nothing left staged. Callers (publish.ts) therefore only ever need
48
+ // to reason about filesystem state on failure, never git staging state.
49
+ export function commitPaths(cwd, paths, message, author) {
50
+ try {
51
+ execFileSync('git', ['add', '--', ...paths], { cwd, stdio: 'ignore' });
52
+ }
53
+ catch (error) {
54
+ throw new GitOperationError('add-failed', `git add failed in ${cwd}`, { cause: error });
55
+ }
56
+ try {
57
+ execFileSync('git', ['commit', '-m', message, '--author', `${author.name} <${author.email}>`], {
58
+ cwd,
59
+ env: authorEnv(author),
60
+ stdio: 'ignore',
61
+ });
62
+ }
63
+ catch (error) {
64
+ try {
65
+ execFileSync('git', ['reset', '--', ...paths], { cwd, stdio: 'ignore' });
66
+ }
67
+ catch (resetError) {
68
+ throw new GitOperationError('reset-failed', `git commit failed in ${cwd} and unstaging afterwards also failed; index may be inconsistent`, { cause: resetError });
69
+ }
70
+ throw new GitOperationError('commit-failed', `git commit failed in ${cwd}`, { cause: error });
71
+ }
72
+ }
73
+ export function resetPaths(cwd, paths) {
74
+ try {
75
+ execFileSync('git', ['reset', '--', ...paths], { cwd, stdio: 'ignore' });
76
+ }
77
+ catch (error) {
78
+ throw new GitOperationError('reset-failed', `git reset failed in ${cwd}`, { cause: error });
79
+ }
80
+ }
81
+ // Allowlist, not a denylist of "dangerous" characters - the same
82
+ // reject-on-ambiguity posture sanitisePath itself uses for paths. A
83
+ // denylist (reject leading '-', reject whitespace) is exactly the
84
+ // shape of check that's easy to get subtly wrong one case at a time
85
+ // (reflog @{...} syntax, ^{...} suffixes, unicode whitespace); this
86
+ // closes all of that in one line instead of enumerating each dangerous
87
+ // case as it's discovered.
88
+ const REF_PATTERN = /^[A-Za-z0-9._/-]+$/;
89
+ export function isValidGitRef(value) {
90
+ return value.length > 0 && !value.startsWith('-') && !value.includes('..') && REF_PATTERN.test(value);
91
+ }
92
+ // The escape hatch (checklist G5): stages and commits literally
93
+ // everything currently changed in the working tree, deliberately
94
+ // unscoped - a path-scoped variant would defeat the entire point of
95
+ // "out-of-band changes the API doesn't otherwise know about" (build
96
+ // plan wording). Respects .gitignore like any git invocation, so
97
+ // data/ stays excluded per existing convention.
98
+ export function commitWorkingTree(cwd, message, author) {
99
+ const status = execFileSync('git', ['status', '--porcelain'], { cwd }).toString('utf-8');
100
+ if (status.trim().length === 0) {
101
+ // Nothing to stage - matches batch.ts's empty-commit lesson
102
+ // (git commit with nothing staged exits non-zero), simpler here
103
+ // since there's no path list to check, just the whole tree.
104
+ return 'clean';
105
+ }
106
+ try {
107
+ execFileSync('git', ['add', '-A'], { cwd, stdio: 'ignore' });
108
+ }
109
+ catch (error) {
110
+ throw new GitOperationError('add-failed', `git add -A failed in ${cwd}`, { cause: error });
111
+ }
112
+ try {
113
+ execFileSync('git', ['commit', '-m', message, '--author', `${author.name} <${author.email}>`], { cwd, env: authorEnv(author), stdio: 'ignore' });
114
+ }
115
+ catch (error) {
116
+ try {
117
+ // Bare reset (no path list): unstages everything just added,
118
+ // mirroring commitPaths's own rollback shape.
119
+ execFileSync('git', ['reset'], { cwd, stdio: 'ignore' });
120
+ }
121
+ catch (resetError) {
122
+ throw new GitOperationError('reset-failed', `git commit failed in ${cwd} and unstaging afterwards also failed; index may be inconsistent`, { cause: resetError });
123
+ }
124
+ throw new GitOperationError('commit-failed', `git commit failed in ${cwd}`, { cause: error });
125
+ }
126
+ return 'committed';
127
+ }
@@ -0,0 +1,4 @@
1
+ export interface IntervalJobHandle {
2
+ stop: () => void;
3
+ }
4
+ export declare function startIntervalJob(job: () => Promise<unknown>, intervalMs: number, onError: (error: unknown) => void): IntervalJobHandle;
@@ -0,0 +1,9 @@
1
+ export function startIntervalJob(job, intervalMs, onError) {
2
+ const timer = setInterval(() => {
3
+ job().catch(onError);
4
+ }, intervalMs);
5
+ // Must not itself keep a process (or a test) alive - the listening
6
+ // socket is what should be process-life-sustaining, not this timer.
7
+ timer.unref();
8
+ return { stop: () => clearInterval(timer) };
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+ export declare class IpAllowlistError extends Error {
3
+ readonly statusCode = 403;
4
+ constructor();
5
+ }
6
+ export declare function isIpAllowed(allowlist: string[], ip: string): boolean;
7
+ export declare function ipAllowlistGuard(allowlist: string[]): (request: FastifyRequest) => Promise<void>;
@@ -0,0 +1,31 @@
1
+ // Mirrors token-auth.ts's AuthError shape: statusCode set explicitly
2
+ // in the constructor so the existing global error handler passes this
3
+ // through with its real message intact, never sanitising it as a
4
+ // generic 500.
5
+ export class IpAllowlistError extends Error {
6
+ statusCode = 403;
7
+ constructor() {
8
+ super('This IP address is not permitted to access this API');
9
+ this.name = 'IpAllowlistError';
10
+ }
11
+ }
12
+ // An empty allowlist is a no-op (checklist H3), not "nothing is
13
+ // allowed" - exact-IP-only for this pass, no CIDR ranges: IP
14
+ // allowlisting is "optional, not the backbone" per the build plan, a
15
+ // deliberate scope reduction rather than an oversight.
16
+ export function isIpAllowed(allowlist, ip) {
17
+ return allowlist.length === 0 || allowlist.includes(ip);
18
+ }
19
+ // Applied as a whole-file addHook('onRequest', ...) inside v1Routes's
20
+ // own plugin body (routes/index.ts), not a per-route preHandler like
21
+ // requireScope - an IP allowlist is a coarse "is this client's network
22
+ // address allowed to talk to the API at all" gate, orthogonal to
23
+ // token-scope exemption, so it applies to the entire /v1 surface
24
+ // uniformly, including capabilities.ts and every GET route.
25
+ export function ipAllowlistGuard(allowlist) {
26
+ return async function guard(request) {
27
+ if (!isIpAllowed(allowlist, request.ip)) {
28
+ throw new IpAllowlistError();
29
+ }
30
+ };
31
+ }
@@ -0,0 +1,10 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ export type ManageMenuReason = 'validation-failed' | 'conflict' | 'write-failed' | 'commit-failed' | 'rollback-failed';
4
+ export declare class ManageMenuError extends Error {
5
+ readonly reason: ManageMenuReason;
6
+ constructor(reason: ManageMenuReason, message: string, options?: {
7
+ cause?: unknown;
8
+ });
9
+ }
10
+ export declare function saveMenu(config: SiteConfig, relativePath: string, content: unknown, expectedEtag: string, message: string, author: CommitAuthor): Promise<string>;
@@ -0,0 +1,84 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { ContentReadError, readContentFile } from "./content-read.js";
4
+ import { computeEtag, etagsMatch } from "./etag.js";
5
+ import { commitPaths } from "./git.js";
6
+ import { sanitisePath } from "./path-safety.js";
7
+ import { validateMenu } from "./validation.js";
8
+ import { enqueue } from "./write-queue.js";
9
+ export class ManageMenuError extends Error {
10
+ reason;
11
+ constructor(reason, message, options) {
12
+ super(message, options);
13
+ this.name = 'ManageMenuError';
14
+ this.reason = reason;
15
+ }
16
+ }
17
+ // Mirrors drafts.ts's readCurrentEtag, narrowed to a single root since
18
+ // menus have no draft state to prefer over live.
19
+ function readCurrentEtag(config, relativePath) {
20
+ try {
21
+ return readContentFile(config.menusRoot, relativePath).etag;
22
+ }
23
+ catch (error) {
24
+ if (!(error instanceof ContentReadError) || error.reason !== 'not-found') {
25
+ throw error;
26
+ }
27
+ return null;
28
+ }
29
+ }
30
+ async function saveMenuJob(config, relativePath, content, expectedEtag, message, author) {
31
+ // menusRoot is optional, unlike pagesRoot - a site that has never
32
+ // had a menu has no content/menus/ at all. sanitisePath unconditionally
33
+ // realpaths its root argument, so it must exist before the very
34
+ // first menu is ever created, or a legitimate first-write crashes
35
+ // with a raw ENOENT instead of just creating the directory.
36
+ mkdirSync(config.menusRoot, { recursive: true });
37
+ const menuPath = sanitisePath(config.menusRoot, relativePath);
38
+ // The If-Match check happens here, inside the queued job, never at
39
+ // the route layer before enqueuing - same TOCTOU reasoning as
40
+ // drafts.ts's saveDraftJob (a route-layer check-then-enqueue has a
41
+ // real, empirically-verified gap; the same check run inside the job
42
+ // does not).
43
+ const currentEtag = readCurrentEtag(config, relativePath);
44
+ if (currentEtag !== null && !etagsMatch(currentEtag, expectedEtag)) {
45
+ throw new ManageMenuError('conflict', `If-Match "${expectedEtag}" does not match the current ETag for "${relativePath}"`);
46
+ }
47
+ // currentEtag === null means no menu exists yet at this path (a
48
+ // brand-new menu) - matches saveDraftJob's own precedent of skipping
49
+ // the comparison for a wholly new resource.
50
+ const result = validateMenu(content);
51
+ if (!result.valid) {
52
+ throw new ManageMenuError('validation-failed', `Menu at "${relativePath}" failed validation: ${JSON.stringify(result.errors)}`);
53
+ }
54
+ const existedBefore = existsSync(menuPath);
55
+ const originalBytes = existedBefore ? readFileSync(menuPath) : null;
56
+ mkdirSync(dirname(menuPath), { recursive: true });
57
+ const bytes = Buffer.from(JSON.stringify(content, null, 2));
58
+ writeFileSync(menuPath, bytes);
59
+ // No draft state (Group N: menus behave like redirects.json) -
60
+ // commits immediately, mirroring manage-redirects.ts's shape, rather
61
+ // than drafts.ts's write-with-no-commit.
62
+ try {
63
+ commitPaths(config.siteRoot, [menuPath], message, author);
64
+ }
65
+ catch (error) {
66
+ try {
67
+ if (existedBefore) {
68
+ writeFileSync(menuPath, originalBytes);
69
+ }
70
+ else {
71
+ unlinkSync(menuPath);
72
+ }
73
+ }
74
+ catch (rollbackError) {
75
+ throw new ManageMenuError('rollback-failed', 'Menu save failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: rollbackError });
76
+ }
77
+ const detail = error instanceof Error ? error.message : String(error);
78
+ throw new ManageMenuError('commit-failed', `Menu save failed: ${detail}`, { cause: error });
79
+ }
80
+ return computeEtag(bytes);
81
+ }
82
+ export function saveMenu(config, relativePath, content, expectedEtag, message, author) {
83
+ return enqueue(() => saveMenuJob(config, relativePath, content, expectedEtag, message, author));
84
+ }
@@ -0,0 +1,25 @@
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 { RedirectEntry } from './redirects.ts';
5
+ export type ManageRedirectReason = 'invalid-from' | 'invalid-target' | 'already-exists' | 'not-found' | 'live-content-exists' | 'redirect-cycle' | 'malformed-file' | 'write-failed' | 'commit-failed' | 'rollback-failed';
6
+ export declare class ManageRedirectError extends Error {
7
+ readonly reason: ManageRedirectReason;
8
+ constructor(reason: ManageRedirectReason, message: string, options?: {
9
+ cause?: unknown;
10
+ });
11
+ }
12
+ export interface RedirectMutationResult {
13
+ entry: RedirectEntry;
14
+ retargeted: RedirectEntry[];
15
+ }
16
+ export declare function prepareCreateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined): PreparedOperation & {
17
+ result: RedirectMutationResult;
18
+ };
19
+ export declare function prepareUpdateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined): PreparedOperation & {
20
+ result: RedirectMutationResult;
21
+ };
22
+ export declare function prepareDeleteRedirect(config: SiteConfig, from: string): PreparedOperation;
23
+ export declare function createRedirect(config: SiteConfig, from: string, to: string, note: string | undefined, message: string, author: CommitAuthor): Promise<RedirectMutationResult | undefined>;
24
+ export declare function updateRedirect(config: SiteConfig, from: string, to: string, note: string | undefined, message: string, author: CommitAuthor): Promise<RedirectMutationResult | undefined>;
25
+ export declare function deleteRedirect(config: SiteConfig, from: string, message: string, author: CommitAuthor): Promise<undefined>;
@@ -0,0 +1,166 @@
1
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { commitPaths } from "./git.js";
3
+ import { sanitisePath } from "./path-safety.js";
4
+ import { isBlogUrl, urlToPostPath } from "./post-urls.js";
5
+ import { RedirectError, addRedirect, isValidRedirectTarget, loadRedirectsStrict, removeRedirectForPath, serialiseRedirects, } from "./redirects.js";
6
+ import { urlToPagePath } from "./urls.js";
7
+ import { enqueue } from "./write-queue.js";
8
+ export class ManageRedirectError extends Error {
9
+ reason;
10
+ constructor(reason, message, options) {
11
+ super(message, options);
12
+ this.name = 'ManageRedirectError';
13
+ this.reason = reason;
14
+ }
15
+ }
16
+ // Not required for correctness (resolve-url.ts/resolve-blog-url.ts
17
+ // already guarantee live content always wins over a redirect at the
18
+ // same URL), but rejecting here avoids a marketing manager creating a
19
+ // redirect that would silently never fire.
20
+ function liveContentExistsAt(config, url) {
21
+ const pageFile = sanitisePath(config.pagesRoot, urlToPagePath(url));
22
+ if (existsSync(pageFile)) {
23
+ return true;
24
+ }
25
+ if (isBlogUrl(url) && existsSync(config.postsRoot)) {
26
+ const postRelative = urlToPostPath(url);
27
+ if (postRelative !== null && existsSync(sanitisePath(config.postsRoot, postRelative))) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ }
33
+ function validateFromAndTo(from, to) {
34
+ if (!isValidRedirectTarget(from)) {
35
+ throw new ManageRedirectError('invalid-from', `"from" must be an internal path starting with "/", got "${from}"`);
36
+ }
37
+ if (!isValidRedirectTarget(to)) {
38
+ throw new ManageRedirectError('invalid-target', `"to" must be an internal path starting with "/", got "${to}"`);
39
+ }
40
+ }
41
+ function readRedirectsSnapshot(config) {
42
+ const existed = existsSync(config.redirectsPath);
43
+ return { existed, original: existed ? readFileSync(config.redirectsPath, 'utf-8') : '' };
44
+ }
45
+ function undoRedirectsWrite(config, snapshot) {
46
+ const failures = [];
47
+ try {
48
+ if (snapshot.existed) {
49
+ writeFileSync(config.redirectsPath, snapshot.original);
50
+ }
51
+ else {
52
+ rmSync(config.redirectsPath, { force: true });
53
+ }
54
+ }
55
+ catch (error) {
56
+ failures.push(error);
57
+ }
58
+ return failures;
59
+ }
60
+ function loadEntriesOrThrow(config) {
61
+ try {
62
+ return loadRedirectsStrict(config).entries;
63
+ }
64
+ catch (error) {
65
+ if (error instanceof RedirectError) {
66
+ throw new ManageRedirectError('malformed-file', error.message, { cause: error });
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+ export function prepareCreateRedirect(config, from, to, note) {
72
+ validateFromAndTo(from, to);
73
+ const entries = loadEntriesOrThrow(config);
74
+ if (entries.some((entry) => entry.from === from)) {
75
+ throw new ManageRedirectError('already-exists', `A redirect already exists for "${from}"`);
76
+ }
77
+ if (liveContentExistsAt(config, from)) {
78
+ throw new ManageRedirectError('live-content-exists', `"${from}" already has live content; a redirect there would never take effect`);
79
+ }
80
+ const snapshot = readRedirectsSnapshot(config);
81
+ let addResult;
82
+ try {
83
+ addResult = addRedirect(entries, from, to, note);
84
+ }
85
+ catch (error) {
86
+ if (error instanceof RedirectError) {
87
+ throw new ManageRedirectError('redirect-cycle', error.message, { cause: error });
88
+ }
89
+ throw error;
90
+ }
91
+ writeFileSync(config.redirectsPath, serialiseRedirects(addResult.entries));
92
+ const createdEntry = addResult.entries.find((entry) => entry.from === from);
93
+ if (!createdEntry) {
94
+ throw new Error('addRedirect did not produce the expected entry - this is a bug');
95
+ }
96
+ return {
97
+ paths: [config.redirectsPath],
98
+ undo: () => undoRedirectsWrite(config, snapshot),
99
+ result: { entry: createdEntry, retargeted: addResult.retargeted },
100
+ };
101
+ }
102
+ export function prepareUpdateRedirect(config, from, to, note) {
103
+ validateFromAndTo(from, to);
104
+ const entries = loadEntriesOrThrow(config);
105
+ if (!entries.some((entry) => entry.from === from)) {
106
+ throw new ManageRedirectError('not-found', `No redirect exists for "${from}"`);
107
+ }
108
+ const snapshot = readRedirectsSnapshot(config);
109
+ let addResult;
110
+ try {
111
+ addResult = addRedirect(entries, from, to, note);
112
+ }
113
+ catch (error) {
114
+ if (error instanceof RedirectError) {
115
+ throw new ManageRedirectError('redirect-cycle', error.message, { cause: error });
116
+ }
117
+ throw error;
118
+ }
119
+ writeFileSync(config.redirectsPath, serialiseRedirects(addResult.entries));
120
+ const updatedEntry = addResult.entries.find((entry) => entry.from === from);
121
+ if (!updatedEntry) {
122
+ throw new Error('addRedirect did not produce the expected entry - this is a bug');
123
+ }
124
+ return {
125
+ paths: [config.redirectsPath],
126
+ undo: () => undoRedirectsWrite(config, snapshot),
127
+ result: { entry: updatedEntry, retargeted: addResult.retargeted },
128
+ };
129
+ }
130
+ export function prepareDeleteRedirect(config, from) {
131
+ const entries = loadEntriesOrThrow(config);
132
+ if (!entries.some((entry) => entry.from === from)) {
133
+ throw new ManageRedirectError('not-found', `No redirect exists for "${from}"`);
134
+ }
135
+ const snapshot = readRedirectsSnapshot(config);
136
+ const updated = removeRedirectForPath(entries, from);
137
+ writeFileSync(config.redirectsPath, serialiseRedirects(updated));
138
+ return {
139
+ paths: [config.redirectsPath],
140
+ undo: () => undoRedirectsWrite(config, snapshot),
141
+ };
142
+ }
143
+ async function runRedirectJob(config, prepare, message, author) {
144
+ const prepared = prepare();
145
+ try {
146
+ commitPaths(config.siteRoot, prepared.paths, message, author);
147
+ }
148
+ catch (error) {
149
+ const failures = prepared.undo();
150
+ if (failures.length > 0) {
151
+ throw new ManageRedirectError('rollback-failed', 'Redirect write failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
152
+ }
153
+ const detail = error instanceof Error ? error.message : String(error);
154
+ throw new ManageRedirectError('commit-failed', `Redirect write failed: ${detail}`, { cause: error });
155
+ }
156
+ return prepared.result;
157
+ }
158
+ export function createRedirect(config, from, to, note, message, author) {
159
+ return enqueue(() => runRedirectJob(config, () => prepareCreateRedirect(config, from, to, note), message, author));
160
+ }
161
+ export function updateRedirect(config, from, to, note, message, author) {
162
+ return enqueue(() => runRedirectJob(config, () => prepareUpdateRedirect(config, from, to, note), message, author));
163
+ }
164
+ export function deleteRedirect(config, from, message, author) {
165
+ return enqueue(() => runRedirectJob(config, () => prepareDeleteRedirect(config, from), message, author));
166
+ }
@@ -0,0 +1,8 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ export interface MenuContent {
3
+ items: Array<{
4
+ label: string;
5
+ url: string;
6
+ }>;
7
+ }
8
+ export declare function loadMenus(config: SiteConfig): Record<string, MenuContent>;
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ function tryReadMenu(fullPath) {
5
+ try {
6
+ const parsed = JSON.parse(readFileSync(fullPath, 'utf-8'));
7
+ if (!Array.isArray(parsed.items)) {
8
+ return null;
9
+ }
10
+ return { items: parsed.items };
11
+ }
12
+ catch {
13
+ // One malformed menu file is skipped individually, not fatal to
14
+ // the whole render - a broken menu shouldn't take down every page
15
+ // on the site (matches rebuild-index.ts's best-effort-skip
16
+ // precedent for pages).
17
+ return null;
18
+ }
19
+ }
20
+ function loadMenusFrom(root) {
21
+ const menus = {};
22
+ // listFilesRecursively already returns [] for a missing directory -
23
+ // a site that has never used menus has no content/menus/ at all, and
24
+ // that resolves to {} gracefully, no existsSync guard needed here.
25
+ for (const relativePath of listFilesRecursively(root, root, '.json')) {
26
+ const name = relativePath.replace(/\.json$/, '');
27
+ const menu = tryReadMenu(join(root, relativePath));
28
+ if (menu) {
29
+ menus[name] = menu;
30
+ }
31
+ }
32
+ return menus;
33
+ }
34
+ // Menus are content (git-tracked), but unlike pages/posts they have no
35
+ // draft/publish state at all (Group N) - direct-write with an
36
+ // immediate commit, same as redirects.json. Always the live file,
37
+ // identically in public and preview mode - there is no draft to
38
+ // overlay. Loaded fresh on every render call, not once at boot like
39
+ // theme templates/layouts/snippets.
40
+ export function loadMenus(config) {
41
+ return loadMenusFrom(config.menusRoot);
42
+ }
@@ -0,0 +1,13 @@
1
+ import type { SiteConfig } from '../config.ts';
2
+ import type { CommitAuthor } from './git.ts';
3
+ import type { ThemeSchemas } from './validation.ts';
4
+ export type MigrationFunction = (content: Record<string, unknown>) => Record<string, unknown>;
5
+ export type MigrationMap = Record<number, MigrationFunction>;
6
+ export type MigrationReason = 'migration-failed' | 'validation-failed' | 'write-failed' | 'commit-failed' | 'rollback-failed';
7
+ export declare class MigrationError extends Error {
8
+ readonly reason: MigrationReason;
9
+ constructor(reason: MigrationReason, message: string, options?: {
10
+ cause?: unknown;
11
+ });
12
+ }
13
+ export declare function runMigrations(config: SiteConfig, themeSchemas: ThemeSchemas, migrations: MigrationMap, currentVersion: number, author: CommitAuthor): Promise<void>;
@@ -0,0 +1,123 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { listFilesRecursively } from "./fs-walk.js";
4
+ import { GitOperationError, commitPaths } from "./git.js";
5
+ import { validateContent } from "./validation.js";
6
+ import { enqueue } from "./write-queue.js";
7
+ export class MigrationError extends Error {
8
+ reason;
9
+ constructor(reason, message, options) {
10
+ super(message, options);
11
+ this.name = 'MigrationError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+ // Walks the migration chain one step at a time until currentVersion is
16
+ // reached. Throws if a step is missing, or if a migration doesn't
17
+ // actually advance the version (a defensive check against a broken
18
+ // migration function).
19
+ function applyMigrationChain(content, migrations, currentVersion, path) {
20
+ let current = content;
21
+ const initialVersion = current.schemaVersion;
22
+ if (typeof initialVersion !== 'number') {
23
+ throw new MigrationError('migration-failed', `"${path}" has no numeric schemaVersion`);
24
+ }
25
+ let version = initialVersion;
26
+ while (version < currentVersion) {
27
+ const migrate = migrations[version];
28
+ if (!migrate) {
29
+ throw new MigrationError('migration-failed', `No migration registered for schemaVersion ${version} ("${path}")`);
30
+ }
31
+ const next = migrate(current);
32
+ const nextVersion = next.schemaVersion;
33
+ if (typeof nextVersion !== 'number' || nextVersion <= version) {
34
+ throw new MigrationError('migration-failed', `Migration from schemaVersion ${version} did not advance schemaVersion ("${path}")`);
35
+ }
36
+ current = next;
37
+ version = nextVersion;
38
+ }
39
+ return current;
40
+ }
41
+ // Hand-rolled fs snapshot/restore, matching publish.ts's established
42
+ // shape - but simpler: every file the write phase touches already
43
+ // existed with real prior bytes (F3's skip logic guarantees this), so
44
+ // there is no "did this file exist before" branch to carry.
45
+ function rollback(files) {
46
+ const failures = [];
47
+ for (const file of files) {
48
+ try {
49
+ writeFileSync(file.path, file.originalBytes);
50
+ }
51
+ catch (error) {
52
+ failures.push(error);
53
+ }
54
+ }
55
+ return failures;
56
+ }
57
+ async function runMigrationsJob(config, themeSchemas, migrations, currentVersion, author) {
58
+ // Three named walks (pages/posts/menus), not one broad walk of
59
+ // contentRoot - see content-read.ts's listContent for the identical
60
+ // reasoning (Group N nested draftsRoot/redirectsPath inside
61
+ // contentRoot; a broad walk would double-migrate drafts and try to
62
+ // validate redirects.json as a page). base stays config.contentRoot
63
+ // for all three so relativePath matches what validateContent expects.
64
+ const files = [
65
+ ...listFilesRecursively(config.pagesRoot, config.contentRoot, '.json').map((rel) => ({
66
+ path: join(config.contentRoot, rel),
67
+ relativePath: rel,
68
+ })),
69
+ ...listFilesRecursively(config.postsRoot, config.contentRoot, '.json').map((rel) => ({
70
+ path: join(config.contentRoot, rel),
71
+ relativePath: rel,
72
+ })),
73
+ ...listFilesRecursively(config.menusRoot, config.contentRoot, '.json').map((rel) => ({
74
+ path: join(config.contentRoot, rel),
75
+ relativePath: rel,
76
+ })),
77
+ ...listFilesRecursively(config.draftsRoot, config.draftsRoot, '.json').map((rel) => ({
78
+ path: join(config.draftsRoot, rel),
79
+ relativePath: rel,
80
+ })),
81
+ ];
82
+ // Compute every migration before writing anything. If any file's
83
+ // migration or validation throws, everything computed for other
84
+ // files so far is simply discarded - a mid-list failure aborts with
85
+ // literally zero writes having happened (F4's primary scenario).
86
+ const toMigrate = [];
87
+ for (const { path, relativePath } of files) {
88
+ const originalBytes = readFileSync(path);
89
+ const parsed = JSON.parse(originalBytes.toString('utf-8'));
90
+ const schemaVersion = parsed.schemaVersion;
91
+ if (typeof schemaVersion === 'number' && schemaVersion >= currentVersion) {
92
+ continue;
93
+ }
94
+ const migrated = applyMigrationChain(parsed, migrations, currentVersion, path);
95
+ const result = validateContent(relativePath, migrated, themeSchemas);
96
+ if (!result.valid) {
97
+ throw new MigrationError('validation-failed', `Migrated content at "${path}" failed validation: ${JSON.stringify(result.errors)}`);
98
+ }
99
+ const migratedBytes = Buffer.from(JSON.stringify(migrated, null, 2));
100
+ toMigrate.push({ path, originalBytes, migratedBytes });
101
+ }
102
+ if (toMigrate.length === 0) {
103
+ return;
104
+ }
105
+ try {
106
+ for (const file of toMigrate) {
107
+ writeFileSync(file.path, file.migratedBytes);
108
+ }
109
+ commitPaths(config.siteRoot, toMigrate.map((file) => file.path), `chore: migrate content to schema version ${currentVersion}`, author);
110
+ }
111
+ catch (error) {
112
+ const failures = rollback(toMigrate);
113
+ if (failures.length > 0) {
114
+ throw new MigrationError('rollback-failed', 'Migration run failed and rolling back afterwards also failed; the working tree may be inconsistent and needs manual inspection', { cause: error });
115
+ }
116
+ const reason = error instanceof GitOperationError ? 'commit-failed' : 'write-failed';
117
+ const detail = error instanceof Error ? error.message : String(error);
118
+ throw new MigrationError(reason, `Migration run failed: ${detail}`, { cause: error });
119
+ }
120
+ }
121
+ export function runMigrations(config, themeSchemas, migrations, currentVersion, author) {
122
+ return enqueue(() => runMigrationsJob(config, themeSchemas, migrations, currentVersion, author));
123
+ }