@rigel-cms/rigel 0.2.0-beta.1 → 1.0.0-rc.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 (39) hide show
  1. package/README.md +4 -2
  2. package/package.json +3 -4
  3. package/src/article-body.ts +9 -2
  4. package/src/article-render.ts +27 -1
  5. package/src/config.ts +8 -0
  6. package/src/editor/App.svelte +6 -7
  7. package/src/editor/blocks/embed.ts +21 -0
  8. package/src/editor/blocks/index.ts +3 -1
  9. package/src/editor/blocks/video.ts +35 -0
  10. package/src/editor/lib/ActionDialog.svelte +2 -1
  11. package/src/editor/lib/BlockEditor.svelte +84 -15
  12. package/src/editor/lib/CollectionPanel.svelte +4 -3
  13. package/src/editor/lib/ImagePicker.svelte +141 -47
  14. package/src/editor/lib/MediaPicker.svelte +73 -0
  15. package/src/editor/lib/Select.svelte +5 -2
  16. package/src/editor/lib/VisualEditor.svelte +11 -6
  17. package/src/editor/lib/embed-preview.ts +57 -0
  18. package/src/editor/lib/embeds.ts +47 -0
  19. package/src/editor/lib/github-backend.ts +2 -1
  20. package/src/editor/lib/icons.ts +13 -0
  21. package/src/editor/lib/images.ts +20 -8
  22. package/src/editor/lib/media-library.ts +11 -1
  23. package/src/editor/lib/settings.ts +2 -2
  24. package/src/editor/lib/tooltip.ts +23 -4
  25. package/src/editor/styles.css +29 -12
  26. package/src/env.d.ts +1 -1
  27. package/src/index.ts +4 -6
  28. package/src/json-output.ts +5 -0
  29. package/src/manifest/types.ts +1 -0
  30. package/src/media-types.ts +14 -1
  31. package/src/preview.ts +7 -3
  32. package/src/routes/local-documents.ts +1 -1
  33. package/src/routes/local-media.ts +33 -28
  34. package/src/routes/local-menu.ts +2 -1
  35. package/src/routes/local-post.ts +2 -1
  36. package/src/routes/media-files.ts +36 -1
  37. package/src/routes/media-validation.ts +2 -2
  38. package/src/schema/store.ts +3 -1
  39. package/src/vite/manifest-plugin.ts +3 -0
@@ -1,15 +1,16 @@
1
1
  import type { APIRoute } from "astro";
2
- import { createHash } from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
3
3
  import { lstat, readFile, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
- import { mediaDirectory, listMedia, loadMediaSidecar, writeMediaSidecar, validateMetaUpdate } from "./media-files";
6
- import { mediaMetadata, safeMediaName } from "../media-types";
5
+ import { fileURLToPath } from "node:url";
6
+ import { mediaDirectory, listMedia, updateMediaSidecar, deleteMediaFile, validateMetaUpdate } from "./media-files";
7
+ import { DEFAULT_MAX_FILE_SIZE, mediaHeaders, mediaMetadata, safeMediaName } from "../media-types";
7
8
  import { validMediaBytes } from "./media-validation";
8
9
  import config from "virtual:rigel-config";
9
10
 
10
11
  export const prerender = false;
11
12
 
12
- export const GET: APIRoute = async ({ params }) => {
13
+ export const GET: APIRoute = async ({ params, request }) => {
13
14
  const filename = params.filename ?? "";
14
15
  if (import.meta.env.PROD || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
15
16
  return new Response("Not found", { status: 404 });
@@ -18,12 +19,7 @@ export const GET: APIRoute = async ({ params }) => {
18
19
  const file = path.join(await mediaDirectory(config.publicDir!, config.mediaDir), filename);
19
20
  if (!(await lstat(file)).isFile() || (await lstat(file)).isSymbolicLink()) throw new Error();
20
21
  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
- } });
22
+ return new Response(new Uint8Array(bytes), { headers: mediaHeaders(filename, new URL(request.url).searchParams.get("preview") === "1") });
27
23
  } catch {
28
24
  return new Response("Not found", { status: 404 });
29
25
  }
@@ -41,15 +37,15 @@ export const POST: APIRoute = async ({ request }) => {
41
37
  const fallback = ({ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" } as Record<string, string>)[mime ?? ""];
42
38
  const metadata = supplied ? (safeMediaName(supplied) ? mediaMetadata(supplied) : undefined) : fallback ? mediaMetadata(`image.${fallback}`) : undefined;
43
39
  if (!metadata || metadata.type !== mime) return new Response("Unsupported or mismatched media type/filename", { status: 415 });
44
- const limit = 10 * 1024 * 1024;
40
+ const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
45
41
  const length = request.headers.get("Content-Length");
46
42
  if (length !== null && !/^\d+$/.test(length)) {
47
43
  return new Response("Invalid Content-Length", { status: 400 });
48
44
  }
49
45
  if (length !== null && Number(length) > limit) {
50
- return new Response("Image exceeds 10 MiB", { status: 413 });
46
+ return new Response(`Media exceeds ${limit / 1024 / 1024} MB`, { status: 413 });
51
47
  }
52
- if (!request.body) return new Response("Empty image body", { status: 400 });
48
+ if (!request.body) return new Response("Empty media body", { status: 400 });
53
49
  const chunks: Uint8Array[] = [];
54
50
  let size = 0;
55
51
  const reader = request.body.getReader();
@@ -60,7 +56,7 @@ export const POST: APIRoute = async ({ request }) => {
60
56
  size += value.byteLength;
61
57
  if (size > limit) {
62
58
  void reader.cancel().catch(() => {});
63
- return new Response("Image exceeds 10 MiB", { status: 413 });
59
+ return new Response(`Media exceeds ${limit / 1024 / 1024} MB`, { status: 413 });
64
60
  }
65
61
  chunks.push(value);
66
62
  }
@@ -73,24 +69,16 @@ export const POST: APIRoute = async ({ request }) => {
73
69
  if (!bytes.length) return new Response("Empty image body", { status: 400 });
74
70
  const extension = metadata.extension === "jpeg" ? "jpg" : metadata.extension;
75
71
  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}`;
72
+ const filename = `${randomUUID()}.${extension}`;
77
73
  let url = "";
78
74
  try {
79
75
  const directory = await mediaDirectory(config.publicDir!, config.mediaDir, true);
80
76
  const existing = await lstat(path.join(directory, filename)).catch(error => { if (error.code !== "ENOENT") throw error; });
81
77
  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
- }
78
+ await writeFile(path.join(directory, filename), bytes, { flag: "wx" });
87
79
  url = `/${(config.mediaDir ?? "rigel-media").split("/").map(encodeURIComponent).join("/")}/${filename}`;
88
80
  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
- }
81
+ await updateMediaSidecar(directory, url, author ? { author } : {}, { name: (supplied ?? filename).slice(0, 120) });
94
82
  } catch {
95
83
  return new Response("Could not save image", { status: 500 });
96
84
  }
@@ -112,13 +100,30 @@ export const POST: APIRoute = async ({ request }) => {
112
100
  const assets = await listMedia(config.publicDir!, config.mediaDir);
113
101
  const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)));
114
102
  if (!valid.ok) return new Response(valid.error, { status: 400 });
115
- const record = await loadMediaSidecar(directory);
116
103
  const patch: { name?: string; title?: string; description?: string; alt?: string } = {};
117
104
  if (valid.value.name !== undefined) patch.name = valid.value.name;
118
105
  if (valid.value.title !== undefined) patch.title = valid.value.title;
119
106
  if (valid.value.description !== undefined) patch.description = valid.value.description;
120
107
  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);
108
+ try {
109
+ const file = path.join(fileURLToPath(config.publicDir!), ...valid.value.url.slice(1).split("/").map(decodeURIComponent));
110
+ await updateMediaSidecar(directory, valid.value.url, patch, {}, file);
111
+ } catch (error) {
112
+ return new Response("Could not save metadata", { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500 });
113
+ }
123
114
  return Response.json((await listMedia(config.publicDir!, config.mediaDir)).find(asset => asset.url === valid.value.url) ?? { url: valid.value.url });
124
115
  };
116
+
117
+ export const DELETE: APIRoute = async ({ request }) => {
118
+ if (import.meta.env.PROD) return new Response("Local backend is dev-only", { status: 404 });
119
+ if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
120
+ let body: unknown;
121
+ try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
122
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("url" in body) || typeof body.url !== "string") return new Response("A media URL is required", { status: 400 });
123
+ try {
124
+ if (!await deleteMediaFile(config.publicDir!, config.mediaDir, body.url)) return new Response("File not found", { status: 404 });
125
+ return new Response(null, { status: 204 });
126
+ } catch {
127
+ return new Response("Could not delete file. Try again.", { status: 500 });
128
+ }
129
+ };
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { serializeContentJson } from "../json-output";
2
3
  import { constants } from "node:fs";
3
4
  import { lstat, open, realpath, rename, unlink } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -60,7 +61,7 @@ const handle: APIRoute = async ({ request, params }) => {
60
61
  temporary = path.join(path.dirname(file), `.${slug}-${randomUUID()}.tmp`);
61
62
  const output = await open(temporary, "wx", 0o600);
62
63
  try {
63
- await output.writeFile(JSON.stringify(menu, null, 2) + "\n", "utf8");
64
+ await output.writeFile(serializeContentJson(menu, config.jsonOutput), "utf8");
64
65
  await output.sync();
65
66
  } finally { await output.close(); }
66
67
  await menuFile(slug);
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { serializeContentJson } from "../json-output";
2
3
  import { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
3
4
  import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
4
5
  import { randomUUID } from "node:crypto";
@@ -66,7 +67,7 @@ const savePost: APIRoute = async ({ request, params }) => {
66
67
  const fm = { ...body.frontmatter };
67
68
  const temporary = `${file}.${randomUUID()}.tmp`;
68
69
  try {
69
- await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
70
+ await writeFile(temporary, serializeContentJson({ frontmatter: fm, body: body.body }, config.jsonOutput), { flag: "wx" });
70
71
  if (request.headers.get("If-None-Match") === "*") {
71
72
  try { await link(temporary, file); }
72
73
  catch (error) {
@@ -1,4 +1,4 @@
1
- import { lstat, mkdir, readFile, readdir, realpath, rename, writeFile } from "node:fs/promises";
1
+ import { lstat, mkdir, readFile, readdir, realpath, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { mediaMetadata, safeMediaName } from "../media-types";
4
4
  import { fileURLToPath } from "node:url";
@@ -68,6 +68,41 @@ export async function writeMediaSidecar(directory: string, record: Record<string
68
68
  await rename(tmp, target);
69
69
  }
70
70
 
71
+ let pendingSidecarWrite: Promise<unknown> = Promise.resolve();
72
+ function queueMediaWrite<T>(operation: () => Promise<T>): Promise<T> {
73
+ const update = pendingSidecarWrite.catch(() => {}).then(operation);
74
+ pendingSidecarWrite = update;
75
+ return update;
76
+ }
77
+ export function updateMediaSidecar(directory: string, url: string, patch: MediaMeta, defaults: MediaMeta = {}, file?: string): Promise<void> {
78
+ return queueMediaWrite(async () => {
79
+ if (file) {
80
+ const info = await lstat(file);
81
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe media file");
82
+ }
83
+ const record = await loadMediaSidecar(directory);
84
+ record[url] = { ...defaults, ...(record[url] ?? {}), ...patch };
85
+ await writeMediaSidecar(directory, record);
86
+ });
87
+ }
88
+ export function deleteMediaFile(publicDir: string, folder: string | undefined, url: string): Promise<boolean> {
89
+ return queueMediaWrite(async () => {
90
+ if (!(await listMedia(publicDir, folder)).some(asset => asset.url === url)) return false;
91
+ const relative = decodeURIComponent(url.slice(1));
92
+ const parent = await mediaDirectory(publicDir, path.posix.dirname(relative));
93
+ const file = path.join(parent, path.posix.basename(relative));
94
+ const info = await lstat(file);
95
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error("Unsafe media file");
96
+ const directory = await mediaDirectory(publicDir, folder);
97
+ const record = await loadMediaSidecar(directory), next = { ...record };
98
+ delete next[url];
99
+ await writeMediaSidecar(directory, next);
100
+ try { await unlink(file); }
101
+ catch (error) { await writeMediaSidecar(directory, record); throw error; }
102
+ return true;
103
+ });
104
+ }
105
+
71
106
  export function validateMetaUpdate(
72
107
  body: unknown,
73
108
  existing: Set<string>,
@@ -30,8 +30,8 @@ export function validMediaBytes(bytes: Buffer, extension: string): boolean {
30
30
  if (extension === '7z') return starts('377abcaf271c');
31
31
  if (extension === 'rar') return starts('526172211a07');
32
32
  if (extension === 'tar') return bytes.length >= 512 && text(257, 262) === 'ustar';
33
- if (extension === 'csv') {
34
- try { const value = new TextDecoder('utf-8', {fatal: true}).decode(bytes); return !/[\u0000-\u0008\u000b\u000c\u000e-\u001f<>]/.test(value) && /[,;\t]/.test(value); } catch { return false; }
33
+ if (extension === 'csv' || extension === 'txt') {
34
+ try { const value = new TextDecoder('utf-8', {fatal: true}).decode(bytes); return !/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(value) && (extension === 'txt' || (!/[<>]/.test(value) && /[,;\t]/.test(value))); } catch { return false; }
35
35
  }
36
36
  return false;
37
37
  }
@@ -6,6 +6,7 @@ import { isPlainObject } from "./validate";
6
6
  import { SLUG_PATTERN, type ContentDocument, type Schema } from "./types";
7
7
  import { normalizeUrlSlug, validateDocumentSlugs } from "../slugs";
8
8
  import type { PreviewConfig } from "../preview";
9
+ import { serializeContentJson, type JsonOutput } from "../json-output";
9
10
 
10
11
  const MAX_BYTES = 1024 * 1024;
11
12
  const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
@@ -115,6 +116,7 @@ async function writeDocumentFile(
115
116
  data: Record<string, unknown>,
116
117
  mode: "create" | "update",
117
118
  preview?: PreviewConfig,
119
+ jsonOutput?: JsonOutput,
118
120
  ): Promise<void> {
119
121
  const type = schema.documents.find((entry) => entry.name === typeName);
120
122
  if (!type) throw new DocumentStoreError(`Unknown document type ${typeName}`, 404);
@@ -137,7 +139,7 @@ async function writeDocumentFile(
137
139
  const documents = await readDocuments(schema, root, preview);
138
140
  validateDocumentSlugs(schema, [...documents.filter(doc => doc.id !== `${typeName}:${slug}`), { id: `${typeName}:${slug}`, type: typeName, slug, data }], preview);
139
141
  } catch (error) { throw new DocumentStoreError((error as Error).message); }
140
- const serialized = JSON.stringify(data, null, 2) + "\n";
142
+ const serialized = serializeContentJson(data, jsonOutput);
141
143
  if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
142
144
  const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
143
145
  try {
@@ -19,6 +19,7 @@ export function rigelManifestPlugin(cfg: RigelConfig, publicDir?: URL, root?: UR
19
19
  const github: GitHubBackendConfig | undefined = cfg.backend === "github" ? {
20
20
  repo: cfg.repo!, branch: cfg.branch!, publishBranch: cfg.publishBranch!, repoRoot: cfg.repoRoot!, authOrigin: cfg.authOrigin!, appSlug: cfg.appSlug!, copyDir: cfg.copyDir,
21
21
  collections: collections.map(({ name, folder }) => ({ name, folder })),
22
+ jsonOutput: cfg.jsonOutput,
22
23
  ...(cfg.menus ? { menus: { folder: cfg.menus.folder, maxDepth: cfg.menus.maxDepth! } } : {}),
23
24
  } : undefined;
24
25
 
@@ -132,9 +133,11 @@ export const menus = {
132
133
  return `export default ${JSON.stringify({
133
134
  copyDir: cfg.copyDir,
134
135
  backend: cfg.backend,
136
+ jsonOutput: cfg.jsonOutput,
135
137
  demo: cfg.demo,
136
138
  publicDir: publicDir?.href,
137
139
  mediaDir: cfg.mediaDir,
140
+ maxFileSize: cfg.maxFileSize,
138
141
  root: root?.href,
139
142
  menus: cfg.menus,
140
143
  collections: cfg.collections ?? [],