@rigel-cms/rigel 0.1.4-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.
- package/README.md +4 -2
- package/package.json +3 -4
- package/src/article-body.ts +9 -2
- package/src/article-render.ts +27 -1
- package/src/config.ts +8 -0
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +114 -25
- package/src/editor/blocks/embed.ts +21 -0
- package/src/editor/blocks/index.ts +3 -1
- package/src/editor/blocks/video.ts +35 -0
- package/src/editor/lib/ActionDialog.svelte +2 -1
- package/src/editor/lib/BlockEditor.svelte +84 -15
- package/src/editor/lib/CollectionPanel.svelte +9 -4
- package/src/editor/lib/ImagePicker.svelte +141 -47
- package/src/editor/lib/MediaPicker.svelte +73 -0
- package/src/editor/lib/PreviewFrame.svelte +64 -22
- package/src/editor/lib/PublishDialog.svelte +65 -0
- package/src/editor/lib/SchemaPanel.svelte +6 -1
- package/src/editor/lib/Select.svelte +5 -2
- package/src/editor/lib/VisualEditor.svelte +11 -6
- package/src/editor/lib/embed-preview.ts +57 -0
- package/src/editor/lib/embeds.ts +47 -0
- package/src/editor/lib/github-backend.ts +220 -20
- package/src/editor/lib/icons.ts +13 -0
- package/src/editor/lib/images.ts +20 -8
- package/src/editor/lib/media-library.ts +11 -1
- package/src/editor/lib/settings.ts +2 -2
- package/src/editor/lib/slugify.ts +1 -15
- package/src/editor/lib/tooltip.ts +23 -4
- package/src/editor/styles.css +44 -13
- package/src/env.d.ts +1 -1
- package/src/index.ts +4 -6
- package/src/json-output.ts +5 -0
- package/src/manifest/types.ts +1 -0
- package/src/media-types.ts +14 -1
- package/src/preview.ts +14 -3
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-media.ts +33 -28
- package/src/routes/local-menu.ts +2 -1
- package/src/routes/local-post.ts +18 -5
- package/src/routes/media-files.ts +36 -1
- package/src/routes/media-validation.ts +2 -2
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +24 -3
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +9 -2
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
46
|
+
return new Response(`Media exceeds ${limit / 1024 / 1024} MB`, { status: 413 });
|
|
51
47
|
}
|
|
52
|
-
if (!request.body) return new Response("Empty
|
|
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(
|
|
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 = `${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
+
};
|
package/src/routes/local-menu.ts
CHANGED
|
@@ -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(
|
|
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);
|
package/src/routes/local-post.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import {
|
|
2
|
+
import { serializeContentJson } from "../json-output";
|
|
3
|
+
import { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
|
|
4
|
+
import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
|
|
3
5
|
import { randomUUID } from "node:crypto";
|
|
4
6
|
import path from "node:path";
|
|
5
7
|
|
|
@@ -23,7 +25,14 @@ function safeSlug(slug: string): string | null {
|
|
|
23
25
|
return /^[a-z0-9][a-z0-9_-]*$/.test(slug) ? slug : null;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
let writes: Promise<unknown> = Promise.resolve();
|
|
29
|
+
export const POST: APIRoute = (context) => {
|
|
30
|
+
const result = writes.then(() => savePost(context));
|
|
31
|
+
writes = result.catch(() => {});
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const savePost: APIRoute = async ({ request, params }) => {
|
|
27
36
|
if (import.meta.env.PROD) {
|
|
28
37
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
29
38
|
}
|
|
@@ -45,16 +54,20 @@ export const POST: APIRoute = async ({ request, params }) => {
|
|
|
45
54
|
try {
|
|
46
55
|
validateBody(body.body);
|
|
47
56
|
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)) : [];
|
|
57
|
+
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview) : [];
|
|
49
58
|
validateCollectionValues(fields, body.frontmatter, documents);
|
|
59
|
+
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug);
|
|
60
|
+
const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; });
|
|
61
|
+
const entries = await Promise.all(names.filter(name => name.endsWith(".json") && name !== `${slug}.json`).map(async name => ({ slug: name.slice(0, -5), values: JSON.parse(await readFile(path.join(folder, name), "utf8")).frontmatter })));
|
|
62
|
+
assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }]);
|
|
50
63
|
} catch (error) {
|
|
51
64
|
return new Response(error instanceof Error ? error.message : "Invalid article", { status: 400 });
|
|
52
65
|
}
|
|
53
66
|
await mkdir(path.dirname(file), { recursive: true });
|
|
54
|
-
const fm = { ...body.frontmatter
|
|
67
|
+
const fm = { ...body.frontmatter };
|
|
55
68
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
56
69
|
try {
|
|
57
|
-
await writeFile(temporary,
|
|
70
|
+
await writeFile(temporary, serializeContentJson({ frontmatter: fm, body: body.body }, config.jsonOutput), { flag: "wx" });
|
|
58
71
|
if (request.headers.get("If-None-Match") === "*") {
|
|
59
72
|
try { await link(temporary, file); }
|
|
60
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
|
}
|
package/src/schema/paths.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { documentDef, isPlainObject, objectDef } from "./validate";
|
|
2
|
+
import { effectiveSlug } from "../slugs";
|
|
2
3
|
import {
|
|
3
4
|
SLUG_PATTERN,
|
|
4
5
|
type ContentDocument,
|
|
@@ -128,8 +129,9 @@ export function assertUniqueSlug(documents: ContentDocument[], typeName: string,
|
|
|
128
129
|
if (legacySlugs.includes(slug)) throw new Error(`The slug "${slug}" is already used by a copy page.`);
|
|
129
130
|
}
|
|
130
131
|
|
|
131
|
-
export function pageRoute(
|
|
132
|
+
export function pageRoute(target: string | Pick<ContentDocument, "slug" | "data">, mapping: { url: string; index?: string } | undefined): string | null {
|
|
132
133
|
if (!mapping) return null;
|
|
134
|
+
const slug = typeof target === "string" ? target : effectiveSlug(target.data, target.slug);
|
|
133
135
|
if (slug === "home" && mapping.index) return mapping.index;
|
|
134
136
|
const path = mapping.url.replaceAll("{slug}", slug);
|
|
135
137
|
return /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
|
package/src/schema/store.ts
CHANGED
|
@@ -4,6 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { constants } from "node:fs";
|
|
5
5
|
import { isPlainObject } from "./validate";
|
|
6
6
|
import { SLUG_PATTERN, type ContentDocument, type Schema } from "./types";
|
|
7
|
+
import { normalizeUrlSlug, validateDocumentSlugs } from "../slugs";
|
|
8
|
+
import type { PreviewConfig } from "../preview";
|
|
9
|
+
import { serializeContentJson, type JsonOutput } from "../json-output";
|
|
7
10
|
|
|
8
11
|
const MAX_BYTES = 1024 * 1024;
|
|
9
12
|
const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
|
|
@@ -68,7 +71,7 @@ async function ensureFolder(root: string, folder: string): Promise<string> {
|
|
|
68
71
|
return directory;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
export async function readDocuments(schema: Schema, root: string): Promise<ContentDocument[]> {
|
|
74
|
+
export async function readDocuments(schema: Schema, root: string, preview?: PreviewConfig): Promise<ContentDocument[]> {
|
|
72
75
|
const documents: ContentDocument[] = [];
|
|
73
76
|
for (const type of schema.documents) {
|
|
74
77
|
let directory: string;
|
|
@@ -88,6 +91,8 @@ export async function readDocuments(schema: Schema, root: string): Promise<Conte
|
|
|
88
91
|
documents.push({ id: `${type.name}:${slug}`, type: type.name, slug, data: data as Record<string, unknown> });
|
|
89
92
|
}
|
|
90
93
|
}
|
|
94
|
+
try { validateDocumentSlugs(schema, documents, preview); }
|
|
95
|
+
catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
91
96
|
return documents;
|
|
92
97
|
}
|
|
93
98
|
|
|
@@ -96,13 +101,22 @@ export async function readDocument(schema: Schema, root: string, typeName: strin
|
|
|
96
101
|
return documents.find((doc) => doc.type === typeName && doc.slug === slug) ?? null;
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
104
|
+
let writes: Promise<void> = Promise.resolve();
|
|
105
|
+
export function writeDocument(...args: Parameters<typeof writeDocumentFile>): Promise<void> {
|
|
106
|
+
const result = writes.then(() => writeDocumentFile(...args));
|
|
107
|
+
writes = result.catch(() => {});
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function writeDocumentFile(
|
|
100
112
|
schema: Schema,
|
|
101
113
|
root: string,
|
|
102
114
|
typeName: string,
|
|
103
115
|
slug: string,
|
|
104
116
|
data: Record<string, unknown>,
|
|
105
117
|
mode: "create" | "update",
|
|
118
|
+
preview?: PreviewConfig,
|
|
119
|
+
jsonOutput?: JsonOutput,
|
|
106
120
|
): Promise<void> {
|
|
107
121
|
const type = schema.documents.find((entry) => entry.name === typeName);
|
|
108
122
|
if (!type) throw new DocumentStoreError(`Unknown document type ${typeName}`, 404);
|
|
@@ -118,7 +132,14 @@ export async function writeDocument(
|
|
|
118
132
|
if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new DocumentStoreError("Document path is unsafe", 409);
|
|
119
133
|
if (mode === "create" && existing) throw new DocumentStoreError("A document with that slug already exists", 409);
|
|
120
134
|
if (mode === "update" && !existing) throw new DocumentStoreError("No such document", 404);
|
|
121
|
-
|
|
135
|
+
if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
136
|
+
data = { ...data };
|
|
137
|
+
try {
|
|
138
|
+
if (!type.shared && Object.hasOwn(data, "slug")) data.slug = normalizeUrlSlug(data.slug);
|
|
139
|
+
const documents = await readDocuments(schema, root, preview);
|
|
140
|
+
validateDocumentSlugs(schema, [...documents.filter(doc => doc.id !== `${typeName}:${slug}`), { id: `${typeName}:${slug}`, type: typeName, slug, data }], preview);
|
|
141
|
+
} catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
142
|
+
const serialized = serializeContentJson(data, jsonOutput);
|
|
122
143
|
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
123
144
|
const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
|
|
124
145
|
try {
|
package/src/slugs.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ContentDocument, Schema } from "./schema/types";
|
|
2
|
+
import type { PreviewConfig } from "./preview";
|
|
3
|
+
|
|
4
|
+
export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
|
|
5
|
+
const base = input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
6
|
+
if (!base) return "";
|
|
7
|
+
const existing = new Set(existingSlugs);
|
|
8
|
+
let slug = base;
|
|
9
|
+
for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
|
|
10
|
+
return slug;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function normalizeUrlSlug(value: unknown): string {
|
|
14
|
+
if (typeof value !== "string" || !slugify(value)) throw new Error("URL slug must contain letters or numbers.");
|
|
15
|
+
return slugify(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function effectiveSlug(values: Record<string, unknown>, storageSlug: string): string {
|
|
19
|
+
if (!Object.hasOwn(values, "slug")) return storageSlug;
|
|
20
|
+
if (typeof values.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(values.slug)) throw new Error("Invalid URL slug; use lowercase letters, numbers and single dashes.");
|
|
21
|
+
return values.slug;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assertUniqueUrlSlugs(entries: { slug: string; values: Record<string, unknown> }[]): void {
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
const slug = effectiveSlug(entry.values, entry.slug);
|
|
28
|
+
if (seen.has(slug)) throw new Error(`URL slug "${slug}" is already used.`);
|
|
29
|
+
seen.add(slug);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validateDocumentSlugs(schema: Schema, documents: ContentDocument[], preview?: PreviewConfig): void {
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
const pages = documents.filter(doc => schema.documents.some(type => type.name === doc.type && !type.shared));
|
|
36
|
+
const fixedRoutes = Object.entries(preview?.pages ?? {}).filter(([key]) => !pages.some(doc => doc.slug === key)).map(([, route]) => route);
|
|
37
|
+
for (const doc of documents) {
|
|
38
|
+
const type = schema.documents.find(type => type.name === doc.type);
|
|
39
|
+
if (!type || type.shared) continue;
|
|
40
|
+
const slug = effectiveSlug(doc.data, doc.slug);
|
|
41
|
+
const mapping = preview?.documents?.[doc.type];
|
|
42
|
+
const route = mapping ? slug === "home" && mapping.index ? mapping.index : mapping.url.replaceAll("{slug}", slug) : `/${slug}/`;
|
|
43
|
+
if (seen.has(route) || fixedRoutes.includes(route)) throw new Error(`URL slug "${slug}" is already used.`);
|
|
44
|
+
seen.add(route);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -19,10 +19,12 @@ 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
|
|
|
25
|
-
const
|
|
26
|
+
const slugHelpers = `import { assertUniqueUrlSlugs as __rigelAssertUniqueUrlSlugs, validateDocumentSlugs as __rigelValidateDocumentSlugs } from ${JSON.stringify(fileURLToPath(new URL("../slugs.ts", import.meta.url)))};`;
|
|
27
|
+
const documentsSrc = slugHelpers + (documentTypes.length
|
|
26
28
|
? `
|
|
27
29
|
${documentTypes.map((type, index) => `const __rigelDoc${index} = import.meta.glob(${JSON.stringify(`/${type.folder}/*.json`)}, { eager: true, import: "default" });`).join("\n")}
|
|
28
30
|
export const documentTypes = ${JSON.stringify(documentTypes.map((type) => ({ name: type.name, shared: Boolean(type.shared) })))};
|
|
@@ -33,9 +35,10 @@ export const documents = Object.entries(__rigelRawDocuments)
|
|
|
33
35
|
return { id: type + ":" + slug, type, slug, data: { ...data, _id: type + ":" + slug, _type: type } };
|
|
34
36
|
}))
|
|
35
37
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
38
|
+
__rigelValidateDocumentSlugs(${JSON.stringify(cfg.schema)}, documents, ${JSON.stringify(cfg.preview)});
|
|
36
39
|
const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.find((type) => type.name === doc.type).shared).map((doc) => doc.slug));
|
|
37
40
|
`
|
|
38
|
-
: `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n
|
|
41
|
+
: `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n`);
|
|
39
42
|
|
|
40
43
|
const collectionCode = collections
|
|
41
44
|
.map(
|
|
@@ -130,13 +133,16 @@ export const menus = {
|
|
|
130
133
|
return `export default ${JSON.stringify({
|
|
131
134
|
copyDir: cfg.copyDir,
|
|
132
135
|
backend: cfg.backend,
|
|
136
|
+
jsonOutput: cfg.jsonOutput,
|
|
133
137
|
demo: cfg.demo,
|
|
134
138
|
publicDir: publicDir?.href,
|
|
135
139
|
mediaDir: cfg.mediaDir,
|
|
140
|
+
maxFileSize: cfg.maxFileSize,
|
|
136
141
|
root: root?.href,
|
|
137
142
|
menus: cfg.menus,
|
|
138
143
|
collections: cfg.collections ?? [],
|
|
139
144
|
schema: cfg.schema,
|
|
145
|
+
preview: cfg.preview,
|
|
140
146
|
})};`;
|
|
141
147
|
}
|
|
142
148
|
if (id === "\0" + CONTENT_ID) {
|
|
@@ -174,6 +180,7 @@ export const pages = loaded.filter((page) => !__rigelReservedSlugs.has(page.slug
|
|
|
174
180
|
export const collections = [
|
|
175
181
|
${collectionCode}
|
|
176
182
|
];
|
|
183
|
+
for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
|
|
177
184
|
${menuCode}
|
|
178
185
|
export default { pages, collections, documents${github ? ', backend: "github", github: ' + JSON.stringify(github) : ""}${cfg.menus ? ", menus" : ""}${cfg.schema ? ", schema: " + JSON.stringify(cfg.schema) : ""}${cfg.preview ? ", preview: " + JSON.stringify(cfg.preview) : ""}${cfg.sidebar ? ", sidebar: " + JSON.stringify(cfg.sidebar) : ""} };
|
|
179
186
|
`;
|