@rigel-cms/rigel 0.0.0-stage → 0.1.0-beta.1

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 (117) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +8 -2
  3. package/package.json +78 -4
  4. package/src/article-body.ts +60 -0
  5. package/src/article-render.ts +87 -0
  6. package/src/config.ts +97 -0
  7. package/src/content.ts +5 -0
  8. package/src/editor/App.svelte +972 -0
  9. package/src/editor/assets/rigelcms-icon.png +0 -0
  10. package/src/editor/assets/sidebar/LICENSE +21 -0
  11. package/src/editor/assets/sidebar/book-open.svg +1 -0
  12. package/src/editor/assets/sidebar/calendar.svg +1 -0
  13. package/src/editor/assets/sidebar/chart-bar.svg +1 -0
  14. package/src/editor/assets/sidebar/file-text.svg +1 -0
  15. package/src/editor/assets/sidebar/files.svg +1 -0
  16. package/src/editor/assets/sidebar/folder.svg +1 -0
  17. package/src/editor/assets/sidebar/folders.svg +1 -0
  18. package/src/editor/assets/sidebar/gear.svg +1 -0
  19. package/src/editor/assets/sidebar/globe.svg +1 -0
  20. package/src/editor/assets/sidebar/heart.svg +1 -0
  21. package/src/editor/assets/sidebar/house.svg +1 -0
  22. package/src/editor/assets/sidebar/images.svg +1 -0
  23. package/src/editor/assets/sidebar/layout.svg +1 -0
  24. package/src/editor/assets/sidebar/list.svg +1 -0
  25. package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
  26. package/src/editor/assets/sidebar/megaphone.svg +1 -0
  27. package/src/editor/assets/sidebar/newspaper.svg +1 -0
  28. package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
  29. package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
  30. package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
  31. package/src/editor/assets/sidebar/square-half.svg +1 -0
  32. package/src/editor/assets/sidebar/squares-four.svg +1 -0
  33. package/src/editor/assets/sidebar/tag.svg +1 -0
  34. package/src/editor/assets/sidebar/user.svg +1 -0
  35. package/src/editor/assets/sidebar/users.svg +1 -0
  36. package/src/editor/blocks/bulleted-list.ts +2 -0
  37. package/src/editor/blocks/code.ts +1 -0
  38. package/src/editor/blocks/divider.ts +14 -0
  39. package/src/editor/blocks/h1.ts +2 -0
  40. package/src/editor/blocks/h2.ts +2 -0
  41. package/src/editor/blocks/h3.ts +2 -0
  42. package/src/editor/blocks/h4.ts +2 -0
  43. package/src/editor/blocks/h5.ts +2 -0
  44. package/src/editor/blocks/h6.ts +2 -0
  45. package/src/editor/blocks/image.ts +34 -0
  46. package/src/editor/blocks/index.ts +40 -0
  47. package/src/editor/blocks/numbered-list.ts +2 -0
  48. package/src/editor/blocks/paragraph.ts +1 -0
  49. package/src/editor/blocks/quote.ts +1 -0
  50. package/src/editor/blocks/table.ts +5 -0
  51. package/src/editor/lib/ActionDialog.svelte +51 -0
  52. package/src/editor/lib/BlockEditor.svelte +614 -0
  53. package/src/editor/lib/CollectionPanel.svelte +103 -0
  54. package/src/editor/lib/DatePicker.svelte +106 -0
  55. package/src/editor/lib/Field.svelte +80 -0
  56. package/src/editor/lib/Icon.svelte +34 -0
  57. package/src/editor/lib/ImagePicker.svelte +264 -0
  58. package/src/editor/lib/MarkdownEditor.svelte +206 -0
  59. package/src/editor/lib/MenuEditor.svelte +209 -0
  60. package/src/editor/lib/PropertiesTabs.svelte +15 -0
  61. package/src/editor/lib/ReferenceSelect.svelte +17 -0
  62. package/src/editor/lib/SchemaField.svelte +156 -0
  63. package/src/editor/lib/SchemaPanel.svelte +189 -0
  64. package/src/editor/lib/Select.svelte +138 -0
  65. package/src/editor/lib/SeoGuidance.svelte +21 -0
  66. package/src/editor/lib/Settings.svelte +183 -0
  67. package/src/editor/lib/Shortcuts.svelte +29 -0
  68. package/src/editor/lib/SidebarIcon.svelte +8 -0
  69. package/src/editor/lib/SitePreview.svelte +50 -0
  70. package/src/editor/lib/TopBar.svelte +165 -0
  71. package/src/editor/lib/UnsavedChanges.svelte +38 -0
  72. package/src/editor/lib/VisualEditor.svelte +494 -0
  73. package/src/editor/lib/calendar.ts +55 -0
  74. package/src/editor/lib/github-auth.ts +117 -0
  75. package/src/editor/lib/github-backend.ts +263 -0
  76. package/src/editor/lib/icons.ts +111 -0
  77. package/src/editor/lib/images.ts +195 -0
  78. package/src/editor/lib/markdown.ts +71 -0
  79. package/src/editor/lib/media-library.ts +39 -0
  80. package/src/editor/lib/menu-tree.ts +37 -0
  81. package/src/editor/lib/rich-markdown.ts +30 -0
  82. package/src/editor/lib/schema-edit.ts +110 -0
  83. package/src/editor/lib/settings.ts +103 -0
  84. package/src/editor/lib/slugify.ts +10 -0
  85. package/src/editor/lib/storage.ts +17 -0
  86. package/src/editor/lib/store.svelte.ts +33 -0
  87. package/src/editor/main.ts +9 -0
  88. package/src/editor/styles.css +1280 -0
  89. package/src/env.d.ts +22 -0
  90. package/src/index.ts +128 -0
  91. package/src/manifest/frontmatter.ts +56 -0
  92. package/src/manifest/loader.ts +14 -0
  93. package/src/manifest/menu.ts +46 -0
  94. package/src/manifest/navigation.ts +94 -0
  95. package/src/manifest/references.ts +39 -0
  96. package/src/manifest/seo.ts +15 -0
  97. package/src/manifest/types.ts +75 -0
  98. package/src/media-types.ts +21 -0
  99. package/src/preview-urls.ts +3 -0
  100. package/src/preview.ts +167 -0
  101. package/src/routes/local-api.ts +42 -0
  102. package/src/routes/local-documents.ts +83 -0
  103. package/src/routes/local-media.ts +124 -0
  104. package/src/routes/local-menu.ts +81 -0
  105. package/src/routes/local-post.ts +80 -0
  106. package/src/routes/manifest.json.ts +15 -0
  107. package/src/routes/media-files.ts +126 -0
  108. package/src/routes/media-validation.ts +37 -0
  109. package/src/routes/media.json.ts +12 -0
  110. package/src/routes/rigel-page.astro +25 -0
  111. package/src/routes/serialize.ts +139 -0
  112. package/src/schema/docs.ts +33 -0
  113. package/src/schema/paths.ts +136 -0
  114. package/src/schema/store.ts +141 -0
  115. package/src/schema/types.ts +81 -0
  116. package/src/schema/validate.ts +327 -0
  117. package/src/vite/manifest-plugin.ts +180 -0
package/src/preview.ts ADDED
@@ -0,0 +1,167 @@
1
+ import MarkdownIt from "markdown-it";
2
+ import DOMPurify from "dompurify";
3
+ import { pageRoute } from "./schema/paths";
4
+ import { renderArticle } from "./article-render";
5
+ import { safePreviewUrl } from "./preview-urls";
6
+ export { safePreviewUrl } from "./preview-urls";
7
+
8
+ export interface PreviewDocumentMapping {
9
+ url: string;
10
+ index?: string;
11
+ }
12
+
13
+ export interface PreviewConfig {
14
+ pages?: Record<string, string>;
15
+ collections?: Record<string, { url: string; new: string }>;
16
+ documents?: Record<string, PreviewDocumentMapping>;
17
+ }
18
+ export interface PreviewTarget {
19
+ kind: "page" | "post" | "menu" | "document";
20
+ slug: string;
21
+ collection?: string;
22
+ type?: string;
23
+ }
24
+
25
+ export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew = false): string | null {
26
+ if (target.kind === "document") return pageRoute(target.slug, config.documents?.[target.type ?? ""]);
27
+ const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : undefined;
28
+ const path = target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
29
+ return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
30
+ }
31
+
32
+ export function anchorValue(values: unknown, path: string): { found: boolean; value: unknown } {
33
+ let value = values;
34
+ for (const key of path.split(".")) {
35
+ if (!value || typeof value !== "object") return { found: false, value: undefined };
36
+ if (Array.isArray(value)) {
37
+ const item = (value as Record<string, unknown>[]).find((entry) => entry && typeof entry === "object" && entry._key === key);
38
+ if (item === undefined) return { found: false, value: undefined };
39
+ value = item;
40
+ continue;
41
+ }
42
+ if (["__proto__", "constructor", "prototype"].includes(key) || !Object.hasOwn(value, key)) return { found: false, value: undefined };
43
+ value = (value as Record<string, unknown>)[key];
44
+ }
45
+ return { found: true, value };
46
+ }
47
+
48
+
49
+ const markdown = new MarkdownIt({ html: false, linkify: false });
50
+ const rendered = new WeakMap<Element, string>();
51
+
52
+ export interface PatchDocuments {
53
+ editingBody?: Element | null;
54
+ documents?: Record<string, Record<string, unknown>>;
55
+ primary?: string;
56
+ }
57
+
58
+ function rebindTree(root: Element | DocumentFragment, update: (element: Element) => void) {
59
+ if (root.nodeType === 1) update(root as Element);
60
+ for (const child of root.children) {
61
+ rebindTree(child, update);
62
+ if (child.tagName === "TEMPLATE") rebindTree((child as HTMLTemplateElement).content, update);
63
+ }
64
+ }
65
+
66
+ export function patchPreview(doc: Document, values: Record<string, unknown>, body: "" | import("./article-body").ArticleBody, options: PatchDocuments = {}) {
67
+ const sourceFor = (element: Element): Record<string, unknown> | undefined => {
68
+ const id = element.getAttribute("data-rigel-doc");
69
+ if (id === "") return {};
70
+ if (id === null || id === options.primary) return values;
71
+ if (options.documents && Object.hasOwn(options.documents, id)) return options.documents[id];
72
+ return !options.primary ? values : undefined;
73
+ };
74
+ const get = (element: Element, path: string) => {
75
+ const source = sourceFor(element);
76
+ if (source === undefined) return { found: false, value: undefined };
77
+ if (path === "$body") return { found: true, value: body };
78
+ return { found: true, value: anchorValue(source, path).value };
79
+ };
80
+ const syncStructure = (root: Element) => {
81
+ if (root.hasAttribute("data-rigel-reference")) {
82
+ const reference = get(root, root.getAttribute("data-rigel-reference")!).value;
83
+ const id = reference && typeof reference === "object" && "_ref" in reference ? String(reference._ref) : "";
84
+ const target = root.querySelector("[data-rigel-reference-target]");
85
+ for (const child of target ? [target] : [...root.children]) rebindTree(child, element => {
86
+ if (element.hasAttribute("data-rigel-doc")) element.setAttribute("data-rigel-doc", id);
87
+ });
88
+ }
89
+ const path = root.getAttribute("data-rigel-each");
90
+ if (path) {
91
+ const result = get(root, path);
92
+ if (result.found) {
93
+ const items = Array.isArray(result.value) ? result.value as Record<string, unknown>[] : [];
94
+ const templates = [...root.children].filter(element => element.hasAttribute("data-rigel-item-template")) as HTMLTemplateElement[];
95
+ const keyOf = (element: Element) => element.getAttribute("data-rigel-item") ?? element.getAttribute("data-rigel-key");
96
+ const existing = new Map([...root.children].filter(element => keyOf(element) !== null).map(element => [keyOf(element)!, element]));
97
+ const keep = new Set<Element>();
98
+ let previous: Element | null = null;
99
+ for (const item of items) {
100
+ if (!item || typeof item._key !== "string") continue;
101
+ const key = item._key;
102
+ let element = existing.get(key);
103
+ if (element?.hasAttribute("data-rigel-section") && element.getAttribute("data-rigel-section") !== item._type) { element.remove(); element = undefined; }
104
+ if (!element) {
105
+ const template = templates.find(template => template.getAttribute("data-rigel-item-template") === (item._type ?? ""));
106
+ element = template?.content.firstElementChild?.cloneNode(true) as Element | undefined;
107
+ if (!element) continue;
108
+ rebindTree(element, node => {
109
+ for (const attr of [...node.attributes]) {
110
+ if (!attr.name.startsWith("data-rigel-")) continue;
111
+ const prefix = `${path}.__item__`;
112
+ if (attr.value === prefix || attr.value.startsWith(`${prefix}.`)) node.setAttribute(attr.name, `${path}.${key}${attr.value.slice(prefix.length)}`);
113
+ }
114
+ });
115
+ element.setAttribute(element.hasAttribute("data-rigel-section") ? "data-rigel-key" : "data-rigel-item", key);
116
+ }
117
+ const next: Element | null = previous ? previous.nextElementSibling : root.firstElementChild;
118
+ if (next !== element) root.insertBefore(element, next);
119
+ keep.add(element);
120
+ previous = element;
121
+ }
122
+ for (const element of existing.values()) if (!keep.has(element)) element.remove();
123
+ }
124
+ }
125
+ for (const child of [...root.children]) if (child.tagName !== "TEMPLATE") syncStructure(child);
126
+ };
127
+ syncStructure(doc.documentElement);
128
+ for (const element of doc.querySelectorAll<HTMLElement>("[data-rigel-if]")) {
129
+ const result = get(element, element.getAttribute("data-rigel-if")!);
130
+ if (result.found) element.hidden = !result.value;
131
+ }
132
+ for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-text]")) {
133
+ const result = get(el, el.dataset.rigelText!);
134
+ if (!result.found) continue;
135
+ const text = result.value === null || result.value === undefined ? "" : String(result.value);
136
+ if (el.textContent !== text) el.textContent = text;
137
+ }
138
+ for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-richtext]")) {
139
+ if (el === options.editingBody) continue;
140
+ const result = get(el, el.dataset.rigelRichtext!);
141
+ if (!result.found) continue;
142
+ const blocks = el.dataset.rigelRichtext === "$body";
143
+ const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
144
+ if (rendered.get(el) === source) continue;
145
+ rendered.set(el, source);
146
+ const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody) : "") : markdown.render(source), {
147
+ RETURN_DOM_FRAGMENT: true,
148
+ ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td"],
149
+ ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope"],
150
+ });
151
+ for (const link of fragment.querySelectorAll("[href], [src]")) {
152
+ for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
153
+ }
154
+ el.replaceChildren(fragment);
155
+ }
156
+ for (const [anchor, attr, tag] of [["image", "src", "IMG"], ["alt", "alt", "IMG"], ["href", "href", "A"]]) {
157
+ for (const el of doc.querySelectorAll(`[data-rigel-${anchor}]`)) {
158
+ if (el.tagName !== tag) continue;
159
+ const result = get(el, el.getAttribute(`data-rigel-${anchor}`)!);
160
+ if (!result.found) continue;
161
+ const value = result.value === null || result.value === undefined ? "" : String(result.value);
162
+ if (attr === "alt" || safePreviewUrl(value, attr === "src")) {
163
+ if (el.getAttribute(attr) !== value) el.setAttribute(attr, value);
164
+ } else el.removeAttribute(attr);
165
+ }
166
+ }
167
+ }
@@ -0,0 +1,42 @@
1
+ import type { APIRoute } from "astro";
2
+ import { writeFile, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { applyValues } from "./serialize";
5
+
6
+ export const prerender = false;
7
+
8
+ export const POST: APIRoute = async ({ request }) => {
9
+ if (import.meta.env.PROD) {
10
+ return new Response("Local backend is dev-only", { status: 404 });
11
+ }
12
+
13
+ let body: { slug?: string; values?: Record<string, unknown> };
14
+ try {
15
+ body = await request.json();
16
+ } catch {
17
+ return new Response("Invalid JSON", { status: 400 });
18
+ }
19
+ const slug = String(body.slug ?? "");
20
+ const values = body.values ?? {};
21
+ if (!slug) return new Response("Missing slug", { status: 400 });
22
+
23
+ const file = path.resolve("src/content/copy", `${slug}.ts`);
24
+ let source: string;
25
+ try {
26
+ source = await readFile(file, "utf8");
27
+ } catch {
28
+ return new Response(`No copy module for ${slug}`, { status: 404 });
29
+ }
30
+
31
+ let next: string;
32
+ try {
33
+ next = applyValues(source, values);
34
+ } catch {
35
+ return new Response(`Could not locate values block in ${slug}.ts`, {
36
+ status: 422,
37
+ });
38
+ }
39
+
40
+ await writeFile(file, next, "utf8");
41
+ return new Response(null, { status: 204 });
42
+ };
@@ -0,0 +1,83 @@
1
+ import type { APIRoute } from "astro";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import config from "virtual:rigel-config";
5
+ import { DocumentStoreError, readDocuments, writeDocument } from "../schema/store";
6
+ import { SchemaError, isPlainObject, validateDocument, validateDocumentId } from "../schema/validate";
7
+ import type { Schema } from "../schema/types";
8
+
9
+ export const prerender = false;
10
+
11
+ const MAX_BODY = 1024 * 1024;
12
+
13
+ function guard(): { schema: Schema; root: string } | Response {
14
+ if (import.meta.env.PROD || config.backend !== "local" || !config.schema || !config.root)
15
+ return new Response("Local documents are not enabled", { status: 404 });
16
+ return { schema: config.schema as Schema, root: fileURLToPath(config.root) };
17
+ }
18
+
19
+ async function readBody(request: Request): Promise<Record<string, unknown>> {
20
+ const length = Number(request.headers.get("content-length") ?? "0");
21
+ if (length > MAX_BODY) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
22
+ const raw = await request.text();
23
+ if (Buffer.byteLength(raw) > MAX_BODY) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
24
+ let body: unknown;
25
+ try {
26
+ body = JSON.parse(raw);
27
+ } catch {
28
+ throw new DocumentStoreError("Invalid JSON", 400);
29
+ }
30
+ if (!isPlainObject(body) || !isPlainObject(body.data)) throw new DocumentStoreError("Body requires a data object", 400);
31
+ return body.data;
32
+ }
33
+
34
+ function decodeParam(value: string): string {
35
+ try {
36
+ return decodeURIComponent(value);
37
+ } catch {
38
+ return value;
39
+ }
40
+ }
41
+
42
+ export const GET: APIRoute = async ({ params }) => {
43
+ const guarded = guard();
44
+ if (guarded instanceof Response) return guarded;
45
+ const id = decodeParam(String(params.id ?? ""));
46
+ const target = validateDocumentId(guarded.schema, id);
47
+ if (!target) return new Response("Unknown document", { status: 404 });
48
+ try {
49
+ const documents = await readDocuments(guarded.schema, guarded.root);
50
+ const document = documents.find((entry) => entry.id === id);
51
+ if (!document) return new Response("Unknown document", { status: 404 });
52
+ return Response.json(document);
53
+ } catch (error) {
54
+ if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
55
+ return new Response("Could not read document", { status: 500 });
56
+ }
57
+ };
58
+
59
+ const handle: APIRoute = async ({ request, params }) => {
60
+ const guarded = guard();
61
+ if (guarded instanceof Response) return guarded;
62
+ if (request.method === "DELETE") return new Response("Documents are created and removed by the site developer", { status: 405, headers: { Allow: "GET, POST, PUT" } });
63
+ if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
64
+ const id = decodeParam(String(params.id ?? ""));
65
+ const target = validateDocumentId(guarded.schema, id);
66
+ if (!target) return new Response("Unknown document type or slug", { status: 400 });
67
+ try {
68
+ const data = await readBody(request);
69
+ const documents = await readDocuments(guarded.schema, guarded.root);
70
+ const next = { ...data, _id: id, _type: target.type.name, slug: target.slug };
71
+ validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
72
+ await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update");
73
+ return new Response(null, { status: 204 });
74
+ } catch (error) {
75
+ if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
76
+ if (error instanceof SchemaError) return new Response(error.message, { status: 400 });
77
+ return new Response("Could not save document", { status: 500 });
78
+ }
79
+ };
80
+
81
+ export const POST = handle;
82
+ export const PUT = handle;
83
+ export const DELETE = handle;
@@ -0,0 +1,124 @@
1
+ import type { APIRoute } from "astro";
2
+ import { createHash } from "node:crypto";
3
+ import { lstat, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { mediaDirectory, listMedia, loadMediaSidecar, writeMediaSidecar, validateMetaUpdate } from "./media-files";
6
+ import { mediaMetadata, safeMediaName } from "../media-types";
7
+ import { validMediaBytes } from "./media-validation";
8
+ import config from "virtual:rigel-config";
9
+
10
+ export const prerender = false;
11
+
12
+ export const GET: APIRoute = async ({ params }) => {
13
+ const filename = params.filename ?? "";
14
+ if (import.meta.env.PROD || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
15
+ return new Response("Not found", { status: 404 });
16
+ }
17
+ try {
18
+ const file = path.join(await mediaDirectory(config.publicDir!, config.mediaDir), filename);
19
+ if (!(await lstat(file)).isFile() || (await lstat(file)).isSymbolicLink()) throw new Error();
20
+ const bytes = await readFile(file);
21
+ const metadata = mediaMetadata(filename)!;
22
+ return new Response(new Uint8Array(bytes), { headers: {
23
+ "Content-Type": metadata.type,
24
+ "Content-Disposition": `${metadata.category === "Images" ? "inline" : "attachment"}; filename="${filename}"`,
25
+ "X-Content-Type-Options": "nosniff",
26
+ } });
27
+ } catch {
28
+ return new Response("Not found", { status: 404 });
29
+ }
30
+ };
31
+
32
+ export const POST: APIRoute = async ({ request }) => {
33
+ if (import.meta.env.PROD) {
34
+ return new Response("Local backend is dev-only", { status: 404 });
35
+ }
36
+ if (request.headers.get("Origin") !== new URL(request.url).origin) {
37
+ return new Response("Same-origin request required", { status: 403 });
38
+ }
39
+ const mime = request.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
40
+ const supplied = request.headers.get("X-Rigel-Filename");
41
+ const fallback = ({ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" } as Record<string, string>)[mime ?? ""];
42
+ const metadata = supplied ? (safeMediaName(supplied) ? mediaMetadata(supplied) : undefined) : fallback ? mediaMetadata(`image.${fallback}`) : undefined;
43
+ if (!metadata || metadata.type !== mime) return new Response("Unsupported or mismatched media type/filename", { status: 415 });
44
+ const limit = 10 * 1024 * 1024;
45
+ const length = request.headers.get("Content-Length");
46
+ if (length !== null && !/^\d+$/.test(length)) {
47
+ return new Response("Invalid Content-Length", { status: 400 });
48
+ }
49
+ if (length !== null && Number(length) > limit) {
50
+ return new Response("Image exceeds 10 MiB", { status: 413 });
51
+ }
52
+ if (!request.body) return new Response("Empty image body", { status: 400 });
53
+ const chunks: Uint8Array[] = [];
54
+ let size = 0;
55
+ const reader = request.body.getReader();
56
+ try {
57
+ while (true) {
58
+ const { done, value } = await reader.read();
59
+ if (done) break;
60
+ size += value.byteLength;
61
+ if (size > limit) {
62
+ void reader.cancel().catch(() => {});
63
+ return new Response("Image exceeds 10 MiB", { status: 413 });
64
+ }
65
+ chunks.push(value);
66
+ }
67
+ } catch {
68
+ return new Response("Could not read image body", { status: 400 });
69
+ } finally {
70
+ reader.releaseLock();
71
+ }
72
+ const bytes = Buffer.concat(chunks, size);
73
+ if (!bytes.length) return new Response("Empty image body", { status: 400 });
74
+ const extension = metadata.extension === "jpeg" ? "jpg" : metadata.extension;
75
+ if (!validMediaBytes(bytes, extension)) return new Response("Media type does not match its bytes", { status: 415 });
76
+ const filename = `${createHash("sha256").update(bytes).digest("hex")}.${extension}`;
77
+ let url = "";
78
+ try {
79
+ const directory = await mediaDirectory(config.publicDir!, config.mediaDir, true);
80
+ const existing = await lstat(path.join(directory, filename)).catch(error => { if (error.code !== "ENOENT") throw error; });
81
+ if (existing && (!existing.isFile() || existing.isSymbolicLink())) throw new Error();
82
+ try {
83
+ await writeFile(path.join(directory, filename), bytes, { flag: "wx" });
84
+ } catch (error) {
85
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
86
+ }
87
+ url = `/${(config.mediaDir ?? "rigel-media").split("/").map(encodeURIComponent).join("/")}/${filename}`;
88
+ const author = (request.headers.get("X-Rigel-Author") ?? "").trim().replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80);
89
+ if (author) {
90
+ const record = await loadMediaSidecar(directory);
91
+ record[url] = { ...(record[url] ?? {}), author };
92
+ await writeMediaSidecar(directory, record);
93
+ }
94
+ } catch {
95
+ return new Response("Could not save image", { status: 500 });
96
+ }
97
+ return Response.json({ url });
98
+ };
99
+
100
+ export const PUT: APIRoute = async ({ request }) => {
101
+ if (import.meta.env.PROD) {
102
+ return new Response("Local backend is dev-only", { status: 404 });
103
+ }
104
+ if (request.headers.get("Origin") !== new URL(request.url).origin) {
105
+ return new Response("Same-origin request required", { status: 403 });
106
+ }
107
+ let body: unknown;
108
+ try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
109
+ let directory: string;
110
+ try { directory = await mediaDirectory(config.publicDir!, config.mediaDir, true); }
111
+ catch { return new Response("Media directory unavailable", { status: 500 }); }
112
+ const assets = await listMedia(config.publicDir!, config.mediaDir);
113
+ const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)));
114
+ if (!valid.ok) return new Response(valid.error, { status: 400 });
115
+ const record = await loadMediaSidecar(directory);
116
+ const patch: { name?: string; title?: string; description?: string; alt?: string } = {};
117
+ if (valid.value.name !== undefined) patch.name = valid.value.name;
118
+ if (valid.value.title !== undefined) patch.title = valid.value.title;
119
+ if (valid.value.description !== undefined) patch.description = valid.value.description;
120
+ if (valid.value.alt !== undefined) patch.alt = valid.value.alt;
121
+ record[valid.value.url] = { ...(record[valid.value.url] ?? {}), ...patch };
122
+ await writeMediaSidecar(directory, record);
123
+ return Response.json((await listMedia(config.publicDir!, config.mediaDir)).find(asset => asset.url === valid.value.url) ?? { url: valid.value.url });
124
+ };
@@ -0,0 +1,81 @@
1
+ import type { APIRoute } from "astro";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath, rename, unlink } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { randomUUID } from "node:crypto";
7
+ import { parseConfig } from "../config";
8
+ import { MENU_SLUG, validateMenu, type MenuDocument } from "../manifest/menu";
9
+ import config from "virtual:rigel-config";
10
+
11
+ export const prerender = false;
12
+
13
+ async function menuFile(slug: string): Promise<string> {
14
+ const menus = parseConfig({ menus: config.menus }).menus!;
15
+ if (!config.root) throw new Error("Missing site root");
16
+ let directory = await realpath(fileURLToPath(config.root));
17
+ for (const part of menus.folder.split("/")) {
18
+ directory = path.join(directory, part);
19
+ const info = await lstat(directory);
20
+ if (info.isSymbolicLink() || !info.isDirectory())
21
+ throw new Error("Menu folder must contain only real directories");
22
+ }
23
+ const file = path.join(directory, `${slug}.json`);
24
+ const info = await lstat(file);
25
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error("Menu must be a regular file");
26
+ return file;
27
+ }
28
+
29
+ const handle: APIRoute = async ({ request, params }) => {
30
+ if (import.meta.env.PROD || config.backend !== "local" || !config.menus)
31
+ return new Response("Local menus are not enabled", { status: 404 });
32
+ const slug = params.slug ?? "";
33
+ if (!MENU_SLUG.test(slug)) return new Response("Invalid menu slug", { status: 400 });
34
+ const method = request.method;
35
+ if (method !== "GET" && method !== "PUT")
36
+ return new Response("Menus are declared by the site developer. Only existing menus can be edited.", { status: 405, headers: { Allow: "GET, PUT" } });
37
+ if (method !== "GET" && request.headers.get("Origin") !== new URL(request.url).origin)
38
+ return new Response("Same-origin request required", { status: 403 });
39
+ let menu: MenuDocument | undefined;
40
+ if (method === "PUT") {
41
+ try {
42
+ menu = validateMenu(await request.json(), config.menus.maxDepth ?? 3);
43
+ if (menu.slug !== slug) throw new Error("Menu slug must match the request slug");
44
+ } catch (error) {
45
+ return new Response(error instanceof SyntaxError ? "Invalid JSON" : (error as Error).message, { status: 400 });
46
+ }
47
+ }
48
+ let temporary: string | undefined;
49
+ try {
50
+ const file = await menuFile(slug);
51
+ if (method === "GET") {
52
+ const input = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
53
+ try {
54
+ const result = validateMenu(JSON.parse(await input.readFile("utf8")), config.menus.maxDepth ?? 3);
55
+ if (result.slug !== slug) throw new Error("Stored menu slug does not match filename");
56
+ return Response.json(result);
57
+ } finally { await input.close(); }
58
+ }
59
+
60
+ temporary = path.join(path.dirname(file), `.${slug}-${randomUUID()}.tmp`);
61
+ const output = await open(temporary, "wx", 0o600);
62
+ try {
63
+ await output.writeFile(JSON.stringify(menu, null, 2) + "\n", "utf8");
64
+ await output.sync();
65
+ } finally { await output.close(); }
66
+ await menuFile(slug);
67
+ await rename(temporary, file);
68
+ return new Response(null, { status: 204 });
69
+ } catch (error) {
70
+ const code = (error as NodeJS.ErrnoException).code;
71
+ if (code === "ENOENT") return new Response("No such menu", { status: 404 });
72
+ return new Response("Could not access menu: invalid stored data or unsafe/unavailable storage", { status: 500 });
73
+ } finally {
74
+ if (temporary) await unlink(temporary).catch(() => {});
75
+ }
76
+ };
77
+
78
+ export const GET = handle;
79
+ export const POST = handle;
80
+ export const PUT = handle;
81
+ export const DELETE = handle;
@@ -0,0 +1,80 @@
1
+ import type { APIRoute } from "astro";
2
+ import { writeFile, mkdir, rm, readFile, rename } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import path from "node:path";
5
+
6
+ import { validateBody, type ArticleBody } from "../article-body";
7
+ import config from "virtual:rigel-config";
8
+ import { validateCollectionReferences } from "../manifest/references";
9
+ import { readDocuments } from "../schema/store";
10
+ import { fileURLToPath } from "node:url";
11
+ import type { CollectionConfig } from "../config";
12
+
13
+ export const prerender = false;
14
+
15
+ function collectionFolder(name: string): string | null {
16
+ const col = (config.collections ?? []).find(
17
+ (c: { name: string }) => c.name === name,
18
+ );
19
+ return col ? col.folder : null;
20
+ }
21
+
22
+ function safeSlug(slug: string): string | null {
23
+ return /^[a-z0-9][a-z0-9_-]*$/.test(slug) ? slug : null;
24
+ }
25
+
26
+ export const POST: APIRoute = async ({ request, params }) => {
27
+ if (import.meta.env.PROD) {
28
+ return new Response("Local backend is dev-only", { status: 404 });
29
+ }
30
+ const folder = collectionFolder(String(params.collection ?? ""));
31
+ const slug = safeSlug(String(params.slug ?? ""));
32
+ if (!folder || !slug) {
33
+ return new Response("Unknown collection/slug", { status: 404 });
34
+ }
35
+
36
+ let body: { frontmatter: Record<string, unknown>; body: ArticleBody };
37
+ try {
38
+ body = await request.json();
39
+ if (!body || typeof body !== "object" || !body.frontmatter || typeof body.frontmatter !== "object" || Array.isArray(body.frontmatter)) throw new Error("Invalid article");
40
+ } catch {
41
+ return new Response("Invalid JSON", { status: 400 });
42
+ }
43
+
44
+ const file = path.resolve(folder, `${slug}.json`);
45
+ try {
46
+ validateBody(body.body);
47
+ const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
48
+ const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root)) : [];
49
+ validateCollectionReferences(fields, body.frontmatter, documents);
50
+ } catch (error) {
51
+ return new Response(error instanceof Error ? error.message : "Invalid references", { status: 400 });
52
+ }
53
+ await mkdir(path.dirname(file), { recursive: true });
54
+ const fm = { ...body.frontmatter, slug };
55
+ const temporary = `${file}.${randomUUID()}.tmp`;
56
+ try {
57
+ await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
58
+ await rename(temporary, file);
59
+ } finally { await rm(temporary, { force: true }); }
60
+ return new Response(null, { status: 204 });
61
+ };
62
+
63
+ export const DELETE: APIRoute = async ({ params }) => {
64
+ if (import.meta.env.PROD) {
65
+ return new Response("Local backend is dev-only", { status: 404 });
66
+ }
67
+ const folder = collectionFolder(String(params.collection ?? ""));
68
+ const slug = safeSlug(String(params.slug ?? ""));
69
+ if (!folder || !slug) {
70
+ return new Response("Unknown collection/slug", { status: 404 });
71
+ }
72
+ const file = path.resolve(folder, `${slug}.json`);
73
+ try {
74
+ await readFile(file); // only delete existing files
75
+ } catch {
76
+ return new Response("No such post", { status: 404 });
77
+ }
78
+ await rm(file);
79
+ return new Response(null, { status: 204 });
80
+ };
@@ -0,0 +1,15 @@
1
+ import type { APIRoute } from "astro";
2
+ import manifest from "virtual:rigel-manifest";
3
+
4
+ export const GET: APIRoute = async () => {
5
+ const editorManifest = manifest.backend === "github" ? {
6
+ ...manifest,
7
+ pages: manifest.pages.map(page => ({ ...page, values: {} })),
8
+ collections: manifest.collections?.map(collection => ({ ...collection, posts: [] })),
9
+ documents: [],
10
+ ...(manifest.menus ? { menus: { ...manifest.menus, entries: [] } } : {}),
11
+ } : manifest;
12
+ return new Response(JSON.stringify(editorManifest), {
13
+ headers: { "Content-Type": "application/json" },
14
+ });
15
+ };