@sourcepress/server 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 (184) hide show
  1. package/.omc/state/agent-replay-31d84b63-606a-4368-b9e6-93fe4f5ae0f7.jsonl +2 -0
  2. package/.omc/state/last-tool-error.json +7 -0
  3. package/.omc/state/mission-state.json +53 -0
  4. package/.omc/state/subagent-tracking.json +17 -0
  5. package/.turbo/turbo-build.log +4 -0
  6. package/.turbo/turbo-test.log +26 -0
  7. package/dist/__tests__/app-integration.test.d.ts +2 -0
  8. package/dist/__tests__/app-integration.test.d.ts.map +1 -0
  9. package/dist/__tests__/app-integration.test.js +71 -0
  10. package/dist/__tests__/app-integration.test.js.map +1 -0
  11. package/dist/__tests__/approval.test.d.ts +2 -0
  12. package/dist/__tests__/approval.test.d.ts.map +1 -0
  13. package/dist/__tests__/approval.test.js +170 -0
  14. package/dist/__tests__/approval.test.js.map +1 -0
  15. package/dist/__tests__/content.test.d.ts +2 -0
  16. package/dist/__tests__/content.test.d.ts.map +1 -0
  17. package/dist/__tests__/content.test.js +187 -0
  18. package/dist/__tests__/content.test.js.map +1 -0
  19. package/dist/__tests__/engine.test.d.ts +2 -0
  20. package/dist/__tests__/engine.test.d.ts.map +1 -0
  21. package/dist/__tests__/engine.test.js +77 -0
  22. package/dist/__tests__/engine.test.js.map +1 -0
  23. package/dist/__tests__/eval.test.d.ts +2 -0
  24. package/dist/__tests__/eval.test.d.ts.map +1 -0
  25. package/dist/__tests__/eval.test.js +320 -0
  26. package/dist/__tests__/eval.test.js.map +1 -0
  27. package/dist/__tests__/graph.test.d.ts +2 -0
  28. package/dist/__tests__/graph.test.d.ts.map +1 -0
  29. package/dist/__tests__/graph.test.js +169 -0
  30. package/dist/__tests__/graph.test.js.map +1 -0
  31. package/dist/__tests__/health.test.d.ts +2 -0
  32. package/dist/__tests__/health.test.d.ts.map +1 -0
  33. package/dist/__tests__/health.test.js +56 -0
  34. package/dist/__tests__/health.test.js.map +1 -0
  35. package/dist/__tests__/import.test.d.ts +2 -0
  36. package/dist/__tests__/import.test.d.ts.map +1 -0
  37. package/dist/__tests__/import.test.js +138 -0
  38. package/dist/__tests__/import.test.js.map +1 -0
  39. package/dist/__tests__/intent.test.d.ts +2 -0
  40. package/dist/__tests__/intent.test.d.ts.map +1 -0
  41. package/dist/__tests__/intent.test.js +122 -0
  42. package/dist/__tests__/intent.test.js.map +1 -0
  43. package/dist/__tests__/jobs.test.d.ts +2 -0
  44. package/dist/__tests__/jobs.test.d.ts.map +1 -0
  45. package/dist/__tests__/jobs.test.js +96 -0
  46. package/dist/__tests__/jobs.test.js.map +1 -0
  47. package/dist/__tests__/knowledge.test.d.ts +2 -0
  48. package/dist/__tests__/knowledge.test.d.ts.map +1 -0
  49. package/dist/__tests__/knowledge.test.js +110 -0
  50. package/dist/__tests__/knowledge.test.js.map +1 -0
  51. package/dist/__tests__/media-routes.test.d.ts +2 -0
  52. package/dist/__tests__/media-routes.test.d.ts.map +1 -0
  53. package/dist/__tests__/media-routes.test.js +88 -0
  54. package/dist/__tests__/media-routes.test.js.map +1 -0
  55. package/dist/__tests__/schema.test.d.ts +2 -0
  56. package/dist/__tests__/schema.test.d.ts.map +1 -0
  57. package/dist/__tests__/schema.test.js +92 -0
  58. package/dist/__tests__/schema.test.js.map +1 -0
  59. package/dist/app.d.ts +7 -0
  60. package/dist/app.d.ts.map +1 -0
  61. package/dist/app.js +85 -0
  62. package/dist/app.js.map +1 -0
  63. package/dist/engine.d.ts +38 -0
  64. package/dist/engine.d.ts.map +1 -0
  65. package/dist/engine.js +106 -0
  66. package/dist/engine.js.map +1 -0
  67. package/dist/index.d.ts +8 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +9 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/middleware/auth.d.ts +17 -0
  72. package/dist/middleware/auth.d.ts.map +1 -0
  73. package/dist/middleware/auth.js +54 -0
  74. package/dist/middleware/auth.js.map +1 -0
  75. package/dist/middleware/cors.d.ts +2 -0
  76. package/dist/middleware/cors.d.ts.map +1 -0
  77. package/dist/middleware/cors.js +13 -0
  78. package/dist/middleware/cors.js.map +1 -0
  79. package/dist/middleware/error-handler.d.ts +24 -0
  80. package/dist/middleware/error-handler.d.ts.map +1 -0
  81. package/dist/middleware/error-handler.js +30 -0
  82. package/dist/middleware/error-handler.js.map +1 -0
  83. package/dist/middleware/index.d.ts +7 -0
  84. package/dist/middleware/index.d.ts.map +1 -0
  85. package/dist/middleware/index.js +5 -0
  86. package/dist/middleware/index.js.map +1 -0
  87. package/dist/middleware/rate-limit.d.ts +11 -0
  88. package/dist/middleware/rate-limit.d.ts.map +1 -0
  89. package/dist/middleware/rate-limit.js +26 -0
  90. package/dist/middleware/rate-limit.js.map +1 -0
  91. package/dist/middleware/route-error-handler.d.ts +12 -0
  92. package/dist/middleware/route-error-handler.d.ts.map +1 -0
  93. package/dist/middleware/route-error-handler.js +9 -0
  94. package/dist/middleware/route-error-handler.js.map +1 -0
  95. package/dist/routes/approval.d.ts +4 -0
  96. package/dist/routes/approval.d.ts.map +1 -0
  97. package/dist/routes/approval.js +70 -0
  98. package/dist/routes/approval.js.map +1 -0
  99. package/dist/routes/content.d.ts +4 -0
  100. package/dist/routes/content.d.ts.map +1 -0
  101. package/dist/routes/content.js +145 -0
  102. package/dist/routes/content.js.map +1 -0
  103. package/dist/routes/eval.d.ts +4 -0
  104. package/dist/routes/eval.d.ts.map +1 -0
  105. package/dist/routes/eval.js +178 -0
  106. package/dist/routes/eval.js.map +1 -0
  107. package/dist/routes/graph.d.ts +4 -0
  108. package/dist/routes/graph.d.ts.map +1 -0
  109. package/dist/routes/graph.js +90 -0
  110. package/dist/routes/graph.js.map +1 -0
  111. package/dist/routes/health.d.ts +13 -0
  112. package/dist/routes/health.d.ts.map +1 -0
  113. package/dist/routes/health.js +19 -0
  114. package/dist/routes/health.js.map +1 -0
  115. package/dist/routes/import.d.ts +4 -0
  116. package/dist/routes/import.d.ts.map +1 -0
  117. package/dist/routes/import.js +85 -0
  118. package/dist/routes/import.js.map +1 -0
  119. package/dist/routes/index.d.ts +12 -0
  120. package/dist/routes/index.d.ts.map +1 -0
  121. package/dist/routes/index.js +12 -0
  122. package/dist/routes/index.js.map +1 -0
  123. package/dist/routes/intent.d.ts +4 -0
  124. package/dist/routes/intent.d.ts.map +1 -0
  125. package/dist/routes/intent.js +80 -0
  126. package/dist/routes/intent.js.map +1 -0
  127. package/dist/routes/jobs.d.ts +4 -0
  128. package/dist/routes/jobs.d.ts.map +1 -0
  129. package/dist/routes/jobs.js +67 -0
  130. package/dist/routes/jobs.js.map +1 -0
  131. package/dist/routes/knowledge.d.ts +4 -0
  132. package/dist/routes/knowledge.d.ts.map +1 -0
  133. package/dist/routes/knowledge.js +48 -0
  134. package/dist/routes/knowledge.js.map +1 -0
  135. package/dist/routes/media.d.ts +4 -0
  136. package/dist/routes/media.d.ts.map +1 -0
  137. package/dist/routes/media.js +87 -0
  138. package/dist/routes/media.js.map +1 -0
  139. package/dist/routes/schema.d.ts +4 -0
  140. package/dist/routes/schema.d.ts.map +1 -0
  141. package/dist/routes/schema.js +54 -0
  142. package/dist/routes/schema.js.map +1 -0
  143. package/dist/standalone.d.ts +2 -0
  144. package/dist/standalone.d.ts.map +1 -0
  145. package/dist/standalone.js +115 -0
  146. package/dist/standalone.js.map +1 -0
  147. package/package.json +36 -0
  148. package/src/__tests__/app-integration.test.ts +80 -0
  149. package/src/__tests__/approval.test.ts +195 -0
  150. package/src/__tests__/content.test.ts +202 -0
  151. package/src/__tests__/engine.test.ts +86 -0
  152. package/src/__tests__/eval.test.ts +343 -0
  153. package/src/__tests__/graph.test.ts +182 -0
  154. package/src/__tests__/health.test.ts +68 -0
  155. package/src/__tests__/import.test.ts +148 -0
  156. package/src/__tests__/intent.test.ts +133 -0
  157. package/src/__tests__/jobs.test.ts +107 -0
  158. package/src/__tests__/knowledge.test.ts +121 -0
  159. package/src/__tests__/media-routes.test.ts +109 -0
  160. package/src/__tests__/schema.test.ts +100 -0
  161. package/src/app.ts +92 -0
  162. package/src/engine.ts +168 -0
  163. package/src/index.ts +31 -0
  164. package/src/middleware/auth.ts +66 -0
  165. package/src/middleware/cors.ts +15 -0
  166. package/src/middleware/error-handler.ts +42 -0
  167. package/src/middleware/index.ts +6 -0
  168. package/src/middleware/rate-limit.ts +27 -0
  169. package/src/middleware/route-error-handler.ts +13 -0
  170. package/src/routes/approval.ts +90 -0
  171. package/src/routes/content.ts +256 -0
  172. package/src/routes/eval.ts +262 -0
  173. package/src/routes/graph.ts +111 -0
  174. package/src/routes/health.ts +33 -0
  175. package/src/routes/import.ts +122 -0
  176. package/src/routes/index.ts +11 -0
  177. package/src/routes/intent.ts +105 -0
  178. package/src/routes/jobs.ts +84 -0
  179. package/src/routes/knowledge.ts +73 -0
  180. package/src/routes/media.ts +117 -0
  181. package/src/routes/schema.ts +75 -0
  182. package/src/standalone.ts +130 -0
  183. package/tsconfig.json +8 -0
  184. package/vitest.config.ts +7 -0
@@ -0,0 +1,15 @@
1
+ import { cors } from "hono/cors";
2
+
3
+ // Default to localhost only when no origins are configured (deny-by-default in production)
4
+ const DEFAULT_ORIGINS = ["http://localhost:3000", "http://localhost:4321", "http://localhost:5173"];
5
+
6
+ export function corsMiddleware(origins?: string | string[]) {
7
+ const origin = origins ?? DEFAULT_ORIGINS;
8
+
9
+ return cors({
10
+ origin,
11
+ allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
12
+ allowHeaders: ["Content-Type", "Authorization"],
13
+ maxAge: 86400,
14
+ });
15
+ }
@@ -0,0 +1,42 @@
1
+ import type { Context, Next } from "hono";
2
+ import { handleRouteError } from "./route-error-handler.js";
3
+
4
+ export interface ApiError {
5
+ status: number;
6
+ message: string;
7
+ code: string;
8
+ details?: unknown;
9
+ }
10
+
11
+ export class SourcePressError extends Error {
12
+ status: number;
13
+ code: string;
14
+ details?: unknown;
15
+
16
+ constructor(status: number, code: string, message: string, details?: unknown) {
17
+ super(message);
18
+ this.name = "SourcePressError";
19
+ this.status = status;
20
+ this.code = code;
21
+ this.details = details;
22
+ }
23
+ }
24
+
25
+ export function errorHandler() {
26
+ return async (c: Context, next: Next) => {
27
+ try {
28
+ await next();
29
+ } catch (err) {
30
+ if (err instanceof Error) {
31
+ if (!(err instanceof SourcePressError)) {
32
+ console.error("Unhandled error:", err);
33
+ }
34
+ return handleRouteError(err, c);
35
+ }
36
+ return c.json<ApiError>(
37
+ { status: 500, message: "Internal server error", code: "INTERNAL_ERROR" },
38
+ 500,
39
+ );
40
+ }
41
+ };
42
+ }
@@ -0,0 +1,6 @@
1
+ export { corsMiddleware } from "./cors.js";
2
+ export { errorHandler, SourcePressError } from "./error-handler.js";
3
+ export type { ApiError } from "./error-handler.js";
4
+ export { authMiddleware, requireAuth } from "./auth.js";
5
+ export type { AuthContext } from "./auth.js";
6
+ export { handleRouteError } from "./route-error-handler.js";
@@ -0,0 +1,27 @@
1
+ import { rateLimiter } from "hono-rate-limiter";
2
+
3
+ /**
4
+ * Rate limiter for AI-consuming endpoints (expensive operations).
5
+ * 10 requests per minute per IP.
6
+ */
7
+ export function aiRateLimit() {
8
+ return rateLimiter({
9
+ windowMs: 60 * 1000,
10
+ limit: 10,
11
+ standardHeaders: "draft-6",
12
+ keyGenerator: (c) => c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown",
13
+ });
14
+ }
15
+
16
+ /**
17
+ * Rate limiter for general endpoints.
18
+ * 100 requests per minute per IP.
19
+ */
20
+ export function generalRateLimit() {
21
+ return rateLimiter({
22
+ windowMs: 60 * 1000,
23
+ limit: 100,
24
+ standardHeaders: "draft-6",
25
+ keyGenerator: (c) => c.req.header("x-forwarded-for") ?? c.req.header("x-real-ip") ?? "unknown",
26
+ });
27
+ }
@@ -0,0 +1,13 @@
1
+ import type { Context } from "hono";
2
+ import { SourcePressError } from "./error-handler.js";
3
+
4
+ export function handleRouteError(err: Error, c: Context) {
5
+ if (err instanceof SourcePressError) {
6
+ return c.json(
7
+ { status: err.status, message: err.message, code: err.code, details: err.details },
8
+ err.status as 400 | 404 | 500 | 501,
9
+ );
10
+ }
11
+ console.error("Unhandled route error:", err);
12
+ return c.json({ status: 500, message: "Internal server error", code: "INTERNAL_ERROR" }, 500);
13
+ }
@@ -0,0 +1,90 @@
1
+ import type { ContentChange } from "@sourcepress/core";
2
+ import { Hono } from "hono";
3
+ import type { EngineContext } from "../engine.js";
4
+ import { resolveApprovalRule } from "../engine.js";
5
+ import { SourcePressError } from "../middleware/error-handler.js";
6
+ import { handleRouteError } from "../middleware/route-error-handler.js";
7
+
8
+ export function approvalRoutes(engine: EngineContext) {
9
+ const app = new Hono();
10
+
11
+ // GET /approval/pending — list pending approval requests
12
+ app.get("/approval/pending", async (c) => {
13
+ if (!engine.approval) {
14
+ throw new SourcePressError(501, "APPROVAL_NOT_CONFIGURED", "Approval not configured");
15
+ }
16
+ const items = await engine.approval.pending();
17
+ return c.json({ items, total: items.length });
18
+ });
19
+
20
+ // GET /approval/:id — get single approval request status
21
+ app.get("/approval/:id", async (c) => {
22
+ if (!engine.approval) {
23
+ throw new SourcePressError(501, "APPROVAL_NOT_CONFIGURED", "Approval not configured");
24
+ }
25
+ const id = c.req.param("id");
26
+ const status = await engine.approval.status(id);
27
+ return c.json({ id, status });
28
+ });
29
+
30
+ // POST /approval — submit content for approval (creates PR or commits directly)
31
+ app.post("/approval", async (c) => {
32
+ if (!engine.approval) {
33
+ throw new SourcePressError(501, "APPROVAL_NOT_CONFIGURED", "Approval not configured");
34
+ }
35
+ const body = await c.req.json<ContentChange>();
36
+ if (!body.collection || !body.slug || !body.path || !body.action || !body.content) {
37
+ throw new SourcePressError(
38
+ 400,
39
+ "INVALID_INPUT",
40
+ "collection, slug, path, action, and content are required",
41
+ );
42
+ }
43
+
44
+ const rule = resolveApprovalRule(engine.config, body.collection, body.path);
45
+
46
+ if (rule === "direct") {
47
+ await engine.github.createOrUpdateFile(
48
+ body.path,
49
+ body.content,
50
+ `chore: direct publish ${body.collection}/${body.slug}`,
51
+ );
52
+ return c.json({ status: "direct", path: body.path }, 201);
53
+ }
54
+
55
+ const request = await engine.approval.submit(body);
56
+ return c.json(request, 201);
57
+ });
58
+
59
+ // POST /approval/:id/approve — approve (merge PR)
60
+ app.post("/approval/:id/approve", async (c) => {
61
+ if (!engine.approval) {
62
+ throw new SourcePressError(501, "APPROVAL_NOT_CONFIGURED", "Approval not configured");
63
+ }
64
+ const id = c.req.param("id");
65
+ const body = await c.req.json<{ by: string; comment?: string }>();
66
+ if (!body.by) {
67
+ throw new SourcePressError(400, "INVALID_INPUT", "by is required");
68
+ }
69
+ await engine.approval.approve(id, body.by, body.comment);
70
+ return c.json({ status: "approved" });
71
+ });
72
+
73
+ // POST /approval/:id/reject — reject (close PR)
74
+ app.post("/approval/:id/reject", async (c) => {
75
+ if (!engine.approval) {
76
+ throw new SourcePressError(501, "APPROVAL_NOT_CONFIGURED", "Approval not configured");
77
+ }
78
+ const id = c.req.param("id");
79
+ const body = await c.req.json<{ by: string; reason: string }>();
80
+ if (!body.by || !body.reason) {
81
+ throw new SourcePressError(400, "INVALID_INPUT", "by and reason are required");
82
+ }
83
+ await engine.approval.reject(id, body.by, body.reason);
84
+ return c.json({ status: "rejected" });
85
+ });
86
+
87
+ app.onError(handleRouteError);
88
+
89
+ return app;
90
+ }
@@ -0,0 +1,256 @@
1
+ import { Hono } from "hono";
2
+ import type { EngineContext } from "../engine.js";
3
+ import { SourcePressError } from "../middleware/error-handler.js";
4
+ import { handleRouteError } from "../middleware/route-error-handler.js";
5
+
6
+ function validateSlug(slug: string): void {
7
+ if (!/^[a-z0-9-]+$/.test(slug)) {
8
+ throw new SourcePressError(
9
+ 400,
10
+ "INVALID_SLUG",
11
+ `Slug "${slug}" is invalid. Only lowercase letters, digits, and hyphens are allowed.`,
12
+ );
13
+ }
14
+ }
15
+
16
+ export function contentRoutes(engine: EngineContext) {
17
+ const app = new Hono();
18
+
19
+ app.onError(handleRouteError);
20
+
21
+ // GET /content — list all content across all collections
22
+ app.get("/content", async (c) => {
23
+ const collections = engine.listCollections();
24
+ const allContent: Array<{
25
+ collection: string;
26
+ slug: string;
27
+ path: string;
28
+ frontmatter: Record<string, unknown>;
29
+ }> = [];
30
+
31
+ for (const collection of collections) {
32
+ const files = await engine.listContent(collection);
33
+ for (const file of files) {
34
+ allContent.push({
35
+ collection: file.collection,
36
+ slug: file.slug,
37
+ path: file.path,
38
+ frontmatter: file.frontmatter,
39
+ });
40
+ }
41
+ }
42
+
43
+ return c.json({ items: allContent, total: allContent.length });
44
+ });
45
+
46
+ // GET /content/:collection — list content in a collection
47
+ app.get("/content/:collection", async (c) => {
48
+ const collection = c.req.param("collection");
49
+ const def = engine.getCollectionDef(collection);
50
+
51
+ if (!def) {
52
+ throw new SourcePressError(
53
+ 404,
54
+ "COLLECTION_NOT_FOUND",
55
+ `Collection "${collection}" not found`,
56
+ );
57
+ }
58
+
59
+ const files = await engine.listContent(collection);
60
+ const items = files.map((f) => ({
61
+ collection: f.collection,
62
+ slug: f.slug,
63
+ path: f.path,
64
+ frontmatter: f.frontmatter,
65
+ body: f.body,
66
+ }));
67
+
68
+ return c.json({ items, total: items.length, collection: def });
69
+ });
70
+
71
+ // GET /content/:collection/:slug — get a single content file
72
+ app.get("/content/:collection/:slug", async (c) => {
73
+ const collection = c.req.param("collection");
74
+ const slug = c.req.param("slug");
75
+ validateSlug(slug);
76
+ const def = engine.getCollectionDef(collection);
77
+
78
+ if (!def) {
79
+ throw new SourcePressError(
80
+ 404,
81
+ "COLLECTION_NOT_FOUND",
82
+ `Collection "${collection}" not found`,
83
+ );
84
+ }
85
+
86
+ const file = await engine.getContent(collection, slug);
87
+ if (!file) {
88
+ throw new SourcePressError(
89
+ 404,
90
+ "CONTENT_NOT_FOUND",
91
+ `Content "${slug}" not found in "${collection}"`,
92
+ );
93
+ }
94
+
95
+ return c.json(file);
96
+ });
97
+
98
+ // POST /content/:collection — create new content (returns PR)
99
+ app.post("/content/:collection", async (c) => {
100
+ const collection = c.req.param("collection");
101
+ const def = engine.getCollectionDef(collection);
102
+
103
+ if (!def) {
104
+ throw new SourcePressError(
105
+ 404,
106
+ "COLLECTION_NOT_FOUND",
107
+ `Collection "${collection}" not found`,
108
+ );
109
+ }
110
+
111
+ const body = await c.req.json<{
112
+ slug: string;
113
+ frontmatter: Record<string, unknown>;
114
+ body: string;
115
+ }>();
116
+
117
+ if (!body.slug || !body.body) {
118
+ throw new SourcePressError(400, "INVALID_INPUT", "slug and body are required");
119
+ }
120
+ validateSlug(body.slug);
121
+
122
+ // Build file content with frontmatter
123
+ const ext = def.format === "yaml" ? "yaml" : def.format === "json" ? "json" : def.format;
124
+ const basePath = def.path.replace(/\/+$/, "");
125
+ const filePath = `${basePath}/${body.slug}.${ext}`;
126
+ const frontmatterYaml = Object.entries(body.frontmatter ?? {})
127
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
128
+ .join("\n");
129
+ const fileContent = `---\n${frontmatterYaml}\n---\n\n${body.body}`;
130
+
131
+ // Create branch + commit + PR
132
+ const branchName = `sourcepress/create-${collection}-${body.slug}-${Date.now()}`;
133
+ await engine.github.createBranch(branchName);
134
+ await engine.github.createOrUpdateFile(
135
+ filePath,
136
+ fileContent,
137
+ `feat(${collection}): create ${body.slug}`,
138
+ branchName,
139
+ );
140
+ const pr = await engine.github.createPR(
141
+ `Create ${collection}: ${body.slug}`,
142
+ `Created by SourcePress API.\n\nCollection: ${collection}\nSlug: ${body.slug}`,
143
+ branchName,
144
+ );
145
+
146
+ return c.json(
147
+ { created: true, path: filePath, pr: { number: pr.number, url: pr.html_url } },
148
+ 201,
149
+ );
150
+ });
151
+
152
+ // PUT /content/:collection/:slug — update content (returns PR)
153
+ app.put("/content/:collection/:slug", async (c) => {
154
+ const collection = c.req.param("collection");
155
+ const slug = c.req.param("slug");
156
+ validateSlug(slug);
157
+ const def = engine.getCollectionDef(collection);
158
+
159
+ if (!def) {
160
+ throw new SourcePressError(
161
+ 404,
162
+ "COLLECTION_NOT_FOUND",
163
+ `Collection "${collection}" not found`,
164
+ );
165
+ }
166
+
167
+ const existing = await engine.getContent(collection, slug);
168
+ if (!existing) {
169
+ throw new SourcePressError(
170
+ 404,
171
+ "CONTENT_NOT_FOUND",
172
+ `Content "${slug}" not found in "${collection}"`,
173
+ );
174
+ }
175
+
176
+ const body = await c.req.json<{
177
+ frontmatter?: Record<string, unknown>;
178
+ body?: string;
179
+ }>();
180
+
181
+ const mergedFrontmatter = { ...existing.frontmatter, ...(body.frontmatter ?? {}) };
182
+ const mergedBody = body.body ?? existing.body;
183
+
184
+ const frontmatterYaml = Object.entries(mergedFrontmatter)
185
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
186
+ .join("\n");
187
+ const fileContent = `---\n${frontmatterYaml}\n---\n\n${mergedBody}`;
188
+
189
+ // Get existing file SHA for update
190
+ const ext = def.format === "yaml" ? "yaml" : def.format === "json" ? "json" : def.format;
191
+ const filePath = `${def.path}/${slug}.${ext}`;
192
+ const existingFile = await engine.github.getFile(filePath);
193
+ const existingSha = existingFile?.sha;
194
+
195
+ const branchName = `sourcepress/update-${collection}-${slug}-${Date.now()}`;
196
+ await engine.github.createBranch(branchName);
197
+ await engine.github.createOrUpdateFile(
198
+ filePath,
199
+ fileContent,
200
+ `update(${collection}): update ${slug}`,
201
+ branchName,
202
+ existingSha,
203
+ );
204
+ const pr = await engine.github.createPR(
205
+ `Update ${collection}: ${slug}`,
206
+ `Updated by SourcePress API.\n\nCollection: ${collection}\nSlug: ${slug}`,
207
+ branchName,
208
+ );
209
+
210
+ return c.json({ updated: true, path: filePath, pr: { number: pr.number, url: pr.html_url } });
211
+ });
212
+
213
+ // DELETE /content/:collection/:slug — delete content (returns PR)
214
+ app.delete("/content/:collection/:slug", async (c) => {
215
+ const collection = c.req.param("collection");
216
+ const slug = c.req.param("slug");
217
+ validateSlug(slug);
218
+ const def = engine.getCollectionDef(collection);
219
+
220
+ if (!def) {
221
+ throw new SourcePressError(
222
+ 404,
223
+ "COLLECTION_NOT_FOUND",
224
+ `Collection "${collection}" not found`,
225
+ );
226
+ }
227
+
228
+ const existing = await engine.getContent(collection, slug);
229
+ if (!existing) {
230
+ throw new SourcePressError(
231
+ 404,
232
+ "CONTENT_NOT_FOUND",
233
+ `Content "${slug}" not found in "${collection}"`,
234
+ );
235
+ }
236
+
237
+ // Delete is implemented as creating a PR that removes the file
238
+ // For now, we create a branch and commit an empty update with a delete marker
239
+ const branchName = `sourcepress/delete-${collection}-${slug}-${Date.now()}`;
240
+ await engine.github.createBranch(branchName);
241
+
242
+ const pr = await engine.github.createPR(
243
+ `Delete ${collection}: ${slug}`,
244
+ `Deletion requested by SourcePress API.\n\nCollection: ${collection}\nSlug: ${slug}\nPath: ${existing.path}`,
245
+ branchName,
246
+ );
247
+
248
+ return c.json({
249
+ deleted: true,
250
+ path: existing.path,
251
+ pr: { number: pr.number, url: pr.html_url },
252
+ });
253
+ });
254
+
255
+ return app;
256
+ }