@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.
- package/LICENSE +203 -0
- package/README.md +8 -2
- package/package.json +78 -4
- package/src/article-body.ts +60 -0
- package/src/article-render.ts +87 -0
- package/src/config.ts +97 -0
- package/src/content.ts +5 -0
- package/src/editor/App.svelte +972 -0
- package/src/editor/assets/rigelcms-icon.png +0 -0
- package/src/editor/assets/sidebar/LICENSE +21 -0
- package/src/editor/assets/sidebar/book-open.svg +1 -0
- package/src/editor/assets/sidebar/calendar.svg +1 -0
- package/src/editor/assets/sidebar/chart-bar.svg +1 -0
- package/src/editor/assets/sidebar/file-text.svg +1 -0
- package/src/editor/assets/sidebar/files.svg +1 -0
- package/src/editor/assets/sidebar/folder.svg +1 -0
- package/src/editor/assets/sidebar/folders.svg +1 -0
- package/src/editor/assets/sidebar/gear.svg +1 -0
- package/src/editor/assets/sidebar/globe.svg +1 -0
- package/src/editor/assets/sidebar/heart.svg +1 -0
- package/src/editor/assets/sidebar/house.svg +1 -0
- package/src/editor/assets/sidebar/images.svg +1 -0
- package/src/editor/assets/sidebar/layout.svg +1 -0
- package/src/editor/assets/sidebar/list.svg +1 -0
- package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
- package/src/editor/assets/sidebar/megaphone.svg +1 -0
- package/src/editor/assets/sidebar/newspaper.svg +1 -0
- package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
- package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
- package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
- package/src/editor/assets/sidebar/square-half.svg +1 -0
- package/src/editor/assets/sidebar/squares-four.svg +1 -0
- package/src/editor/assets/sidebar/tag.svg +1 -0
- package/src/editor/assets/sidebar/user.svg +1 -0
- package/src/editor/assets/sidebar/users.svg +1 -0
- package/src/editor/blocks/bulleted-list.ts +2 -0
- package/src/editor/blocks/code.ts +1 -0
- package/src/editor/blocks/divider.ts +14 -0
- package/src/editor/blocks/h1.ts +2 -0
- package/src/editor/blocks/h2.ts +2 -0
- package/src/editor/blocks/h3.ts +2 -0
- package/src/editor/blocks/h4.ts +2 -0
- package/src/editor/blocks/h5.ts +2 -0
- package/src/editor/blocks/h6.ts +2 -0
- package/src/editor/blocks/image.ts +34 -0
- package/src/editor/blocks/index.ts +40 -0
- package/src/editor/blocks/numbered-list.ts +2 -0
- package/src/editor/blocks/paragraph.ts +1 -0
- package/src/editor/blocks/quote.ts +1 -0
- package/src/editor/blocks/table.ts +5 -0
- package/src/editor/lib/ActionDialog.svelte +51 -0
- package/src/editor/lib/BlockEditor.svelte +614 -0
- package/src/editor/lib/CollectionPanel.svelte +103 -0
- package/src/editor/lib/DatePicker.svelte +106 -0
- package/src/editor/lib/Field.svelte +80 -0
- package/src/editor/lib/Icon.svelte +34 -0
- package/src/editor/lib/ImagePicker.svelte +264 -0
- package/src/editor/lib/MarkdownEditor.svelte +206 -0
- package/src/editor/lib/MenuEditor.svelte +209 -0
- package/src/editor/lib/PropertiesTabs.svelte +15 -0
- package/src/editor/lib/ReferenceSelect.svelte +17 -0
- package/src/editor/lib/SchemaField.svelte +156 -0
- package/src/editor/lib/SchemaPanel.svelte +189 -0
- package/src/editor/lib/Select.svelte +138 -0
- package/src/editor/lib/SeoGuidance.svelte +21 -0
- package/src/editor/lib/Settings.svelte +183 -0
- package/src/editor/lib/Shortcuts.svelte +29 -0
- package/src/editor/lib/SidebarIcon.svelte +8 -0
- package/src/editor/lib/SitePreview.svelte +50 -0
- package/src/editor/lib/TopBar.svelte +165 -0
- package/src/editor/lib/UnsavedChanges.svelte +38 -0
- package/src/editor/lib/VisualEditor.svelte +494 -0
- package/src/editor/lib/calendar.ts +55 -0
- package/src/editor/lib/github-auth.ts +117 -0
- package/src/editor/lib/github-backend.ts +263 -0
- package/src/editor/lib/icons.ts +111 -0
- package/src/editor/lib/images.ts +195 -0
- package/src/editor/lib/markdown.ts +71 -0
- package/src/editor/lib/media-library.ts +39 -0
- package/src/editor/lib/menu-tree.ts +37 -0
- package/src/editor/lib/rich-markdown.ts +30 -0
- package/src/editor/lib/schema-edit.ts +110 -0
- package/src/editor/lib/settings.ts +103 -0
- package/src/editor/lib/slugify.ts +10 -0
- package/src/editor/lib/storage.ts +17 -0
- package/src/editor/lib/store.svelte.ts +33 -0
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +1280 -0
- package/src/env.d.ts +22 -0
- package/src/index.ts +128 -0
- package/src/manifest/frontmatter.ts +56 -0
- package/src/manifest/loader.ts +14 -0
- package/src/manifest/menu.ts +46 -0
- package/src/manifest/navigation.ts +94 -0
- package/src/manifest/references.ts +39 -0
- package/src/manifest/seo.ts +15 -0
- package/src/manifest/types.ts +75 -0
- package/src/media-types.ts +21 -0
- package/src/preview-urls.ts +3 -0
- package/src/preview.ts +167 -0
- package/src/routes/local-api.ts +42 -0
- package/src/routes/local-documents.ts +83 -0
- package/src/routes/local-media.ts +124 -0
- package/src/routes/local-menu.ts +81 -0
- package/src/routes/local-post.ts +80 -0
- package/src/routes/manifest.json.ts +15 -0
- package/src/routes/media-files.ts +126 -0
- package/src/routes/media-validation.ts +37 -0
- package/src/routes/media.json.ts +12 -0
- package/src/routes/rigel-page.astro +25 -0
- package/src/routes/serialize.ts +139 -0
- package/src/schema/docs.ts +33 -0
- package/src/schema/paths.ts +136 -0
- package/src/schema/store.ts +141 -0
- package/src/schema/types.ts +81 -0
- package/src/schema/validate.ts +327 -0
- package/src/vite/manifest-plugin.ts +180 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { lstat, mkdir, readFile, readdir, realpath, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { mediaMetadata, safeMediaName } from "../media-types";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
export const imageFile = /\\.(jpe?g|png|webp|gif|avif)$/i;
|
|
7
|
+
const SIDECAR = ".rigel-media.json";
|
|
8
|
+
const META_KEYS: Record<string, number> = { name: 120, title: 200, description: 1000, alt: 1000, author: 80 };
|
|
9
|
+
export interface MediaMeta { name?: string; title?: string; description?: string; alt?: string; author?: string; }
|
|
10
|
+
export interface ListedAsset extends MediaMeta { url: string; name: string; type: string; size: number; modified: string; }
|
|
11
|
+
|
|
12
|
+
export function mediaFolder(folder = "rigel-media") {
|
|
13
|
+
if (!folder.split("/").every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(part))) throw new Error("Invalid media folder");
|
|
14
|
+
return folder;
|
|
15
|
+
}
|
|
16
|
+
export async function mediaDirectory(publicDir: string, folder = "rigel-media", create = false) {
|
|
17
|
+
mediaFolder(folder);
|
|
18
|
+
if (create) await mkdir(fileURLToPath(publicDir), { recursive: true });
|
|
19
|
+
let directory = await realpath(fileURLToPath(publicDir));
|
|
20
|
+
for (const part of folder.split("/")) {
|
|
21
|
+
directory = path.join(directory, part);
|
|
22
|
+
if (create) await mkdir(directory).catch(error => { if (error.code !== "EEXIST") throw error; });
|
|
23
|
+
const info = await lstat(directory);
|
|
24
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new Error("Unsafe media directory");
|
|
25
|
+
}
|
|
26
|
+
return directory;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sanitize(value: unknown, max: number): string | undefined {
|
|
30
|
+
if (typeof value !== "string") return undefined;
|
|
31
|
+
const clean = value.trim().replace(/[\u0000-\u001f\u007f]/g, "").slice(0, max);
|
|
32
|
+
return clean || undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** User-facing renameable label: safe chars + length, no extension required (text display name). */
|
|
36
|
+
export function safeDisplayName(value: unknown): boolean {
|
|
37
|
+
return typeof value === "string" && value.length > 0 && value.length <= 120
|
|
38
|
+
&& !value.startsWith(".") && /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function sidecarPath(directory: string) { return path.join(directory, SIDECAR); }
|
|
42
|
+
|
|
43
|
+
export async function loadMediaSidecar(directory: string): Promise<Record<string, MediaMeta>> {
|
|
44
|
+
let text: string;
|
|
45
|
+
try { text = await readFile(await sidecarPath(directory), "utf8"); }
|
|
46
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; return {}; }
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(text);
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
50
|
+
const out: Record<string, MediaMeta> = {};
|
|
51
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
52
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
|
|
53
|
+
const meta: MediaMeta = {};
|
|
54
|
+
for (const [k, max] of Object.entries(META_KEYS)) {
|
|
55
|
+
const clean = sanitize((value as Record<string, unknown>)[k], max);
|
|
56
|
+
if (clean !== undefined) meta[k as keyof MediaMeta] = clean;
|
|
57
|
+
}
|
|
58
|
+
out[key] = meta;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
} catch { return {}; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeMediaSidecar(directory: string, record: Record<string, MediaMeta>): Promise<void> {
|
|
65
|
+
const target = await sidecarPath(directory);
|
|
66
|
+
const tmp = path.join(directory, `.${SIDECAR}.${process.pid}.${Date.now()}.tmp`);
|
|
67
|
+
await writeFile(tmp, JSON.stringify(record, null, 2), { flag: "wx" });
|
|
68
|
+
await rename(tmp, target);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function validateMetaUpdate(
|
|
72
|
+
body: unknown,
|
|
73
|
+
existing: Set<string>,
|
|
74
|
+
): { ok: true; value: { url: string; name?: string; title?: string; description?: string; alt?: string } } | { ok: false; error: string } {
|
|
75
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "Invalid request body" };
|
|
76
|
+
const b = body as Record<string, unknown>;
|
|
77
|
+
const allowed = ["url", "name", "title", "description", "alt"];
|
|
78
|
+
if (Object.keys(b).some(key => !allowed.includes(key))) return { ok: false, error: "Unknown metadata field" };
|
|
79
|
+
if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: "Unknown asset" };
|
|
80
|
+
const value: { url: string; name?: string; title?: string; description?: string; alt?: string } = { url: b.url };
|
|
81
|
+
if ("name" in b) {
|
|
82
|
+
const candidate = typeof b.name === "string" ? b.name.trim() : b.name;
|
|
83
|
+
if (!safeDisplayName(candidate)) return { ok: false, error: "Invalid display name" };
|
|
84
|
+
value.name = typeof candidate === "string" ? candidate : undefined;
|
|
85
|
+
}
|
|
86
|
+
if ("title" in b) {
|
|
87
|
+
if (typeof b.title !== "string") return { ok: false, error: "Title must be a string" };
|
|
88
|
+
value.title = sanitize(b.title, 200);
|
|
89
|
+
}
|
|
90
|
+
if ("description" in b) {
|
|
91
|
+
if (typeof b.description !== "string") return { ok: false, error: "Description must be a string" };
|
|
92
|
+
value.description = sanitize(b.description, 1000);
|
|
93
|
+
}
|
|
94
|
+
if ("alt" in b) {
|
|
95
|
+
if (typeof b.alt !== "string") return { ok: false, error: "Alt text must be a string" };
|
|
96
|
+
if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: "Alt text is only available for images" };
|
|
97
|
+
value.alt = sanitize(b.alt.replace(/\s+/g, " "), 1000) ?? "";
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function listMedia(publicDir: string, folder = "rigel-media") {
|
|
103
|
+
const assets: ListedAsset[] = [];
|
|
104
|
+
let directory = "";
|
|
105
|
+
try { directory = await mediaDirectory(publicDir, folder); }
|
|
106
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
107
|
+
if (!directory) return assets;
|
|
108
|
+
const sidecar = await loadMediaSidecar(directory);
|
|
109
|
+
async function walk(current: string, relative = "") {
|
|
110
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
111
|
+
if (entry.name.startsWith(".") || entry.isSymbolicLink()) continue;
|
|
112
|
+
const name = relative + entry.name;
|
|
113
|
+
if (entry.isDirectory()) await walk(path.join(current, entry.name), name + "/");
|
|
114
|
+
else if (entry.isFile() && safeMediaName(entry.name)) {
|
|
115
|
+
const info = await lstat(path.join(current, entry.name));
|
|
116
|
+
if (!info.isFile()) continue;
|
|
117
|
+
const metadata = mediaMetadata(name)!;
|
|
118
|
+
const url = "/" + `${folder}/${name}`.split("/").map(encodeURIComponent).join("/");
|
|
119
|
+
const meta = sidecar[url] ?? {};
|
|
120
|
+
assets.push({ name: meta.name ?? name, url, type: metadata.type, size: info.size, modified: info.mtime.toISOString(), title: meta.title, description: meta.description, alt: meta.alt, author: meta.author });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
await walk(directory);
|
|
125
|
+
return assets.sort((a, b) => a.name.localeCompare(b.name));
|
|
126
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MEDIA_TYPES } from '../media-types';
|
|
2
|
+
|
|
3
|
+
export function validMediaBytes(bytes: Buffer, extension: string): boolean {
|
|
4
|
+
const starts = (hex: string) => bytes.subarray(0, hex.length / 2).equals(Buffer.from(hex, 'hex'));
|
|
5
|
+
const text = (start: number, end: number) => bytes.subarray(start, end).toString('latin1');
|
|
6
|
+
if (['jpg', 'jpeg'].includes(extension)) return starts('ffd8ff');
|
|
7
|
+
if (extension === 'png') return starts('89504e470d0a1a0a');
|
|
8
|
+
if (extension === 'webp') return text(0, 4) === 'RIFF' && text(8, 12) === 'WEBP';
|
|
9
|
+
if (extension === 'gif') return ['GIF87a', 'GIF89a'].includes(text(0, 6));
|
|
10
|
+
if (['avif', 'mp4', 'm4a', 'mov'].includes(extension)) {
|
|
11
|
+
if (bytes.length < 16 || text(4, 8) !== 'ftyp') return false;
|
|
12
|
+
const brands = text(8, Math.min(bytes.readUInt32BE(0), 128));
|
|
13
|
+
return extension === 'avif' ? /avif|avis/.test(brands) : extension === 'm4a' ? /M4A |M4B /.test(brands) : extension === 'mov' ? brands.includes('qt ') : /isom|iso2|mp41|mp42|avc1/.test(brands);
|
|
14
|
+
}
|
|
15
|
+
if (extension === 'wav') return text(0, 4) === 'RIFF' && text(8, 12) === 'WAVE';
|
|
16
|
+
if (extension === 'mp3') return text(0, 3) === 'ID3' || (bytes[0] === 255 && (bytes[1] & 0xe0) === 0xe0);
|
|
17
|
+
if (extension === 'ogg') return text(0, 4) === 'OggS' && /vorbis|OpusHead|Speex/.test(text(0, 256));
|
|
18
|
+
if (extension === 'webm') return starts('1a45dfa3') && text(0, 128).includes('webm');
|
|
19
|
+
if (extension === 'pdf') return text(0, 5) === '%PDF-' && text(Math.max(0, bytes.length - 1024), bytes.length).includes('%%EOF');
|
|
20
|
+
if (['doc', 'xls', 'ppt'].includes(extension)) return starts('d0cf11e0a1b11ae1') && bytes.includes(Buffer.from({doc: 'WordDocument', xls: 'Workbook', ppt: 'PowerPoint Document'}[extension]!, 'utf16le'));
|
|
21
|
+
if (extension === 'rtf') return text(0, 5) === '{\\rtf';
|
|
22
|
+
if (['zip', 'docx', 'xlsx', 'pptx', 'odt', 'ods'].includes(extension)) {
|
|
23
|
+
if (!starts('504b0304') && !starts('504b0506')) return false;
|
|
24
|
+
if (!bytes.includes(Buffer.from('504b0506', 'hex'))) return false;
|
|
25
|
+
if (extension === 'zip') return true;
|
|
26
|
+
if (['odt', 'ods'].includes(extension)) return bytes.includes(Buffer.from(MEDIA_TYPES[extension].type));
|
|
27
|
+
return bytes.includes(Buffer.from('[Content_Types].xml')) && bytes.includes(Buffer.from({docx: 'word/document.xml', xlsx: 'xl/workbook.xml', pptx: 'ppt/presentation.xml'}[extension]!));
|
|
28
|
+
}
|
|
29
|
+
if (extension === 'gz') return starts('1f8b08');
|
|
30
|
+
if (extension === '7z') return starts('377abcaf271c');
|
|
31
|
+
if (extension === 'rar') return starts('526172211a07');
|
|
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; }
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { APIRoute } from "astro";
|
|
2
|
+
import config from "virtual:rigel-config";
|
|
3
|
+
import { listMedia } from "./media-files";
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = async () => {
|
|
6
|
+
try {
|
|
7
|
+
const assets = await listMedia(config.publicDir!, config.mediaDir);
|
|
8
|
+
return Response.json({ assets, images: assets.filter(asset => asset.type.startsWith("image/")), folder: config.mediaDir ?? "rigel-media", writable: import.meta.env.DEV && config.backend === "local" });
|
|
9
|
+
} catch {
|
|
10
|
+
return Response.json({ error: "Could not read the configured media directory" }, { status: 500 });
|
|
11
|
+
}
|
|
12
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
---
|
|
2
|
+
---
|
|
3
|
+
<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
|
+
<meta name="robots" content="noindex, nofollow" />
|
|
9
|
+
<title>Rigel — Content Editor</title>
|
|
10
|
+
<style>
|
|
11
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
12
|
+
html { background: #F8FAFC; color-scheme: light; }
|
|
13
|
+
html[data-theme="dark"] { background: #080F1C; color-scheme: dark; }
|
|
14
|
+
html[data-contrast="high"] { background: #FFFFFF; }
|
|
15
|
+
html[data-theme="dark"][data-contrast="high"] { background: #000000; }
|
|
16
|
+
body { margin: 0; background: inherit; }
|
|
17
|
+
</style>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div id="rigel"></div>
|
|
21
|
+
<script type="module">
|
|
22
|
+
import "../editor/main.ts";
|
|
23
|
+
</script>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export const VALUES_RE = /values:\s*\{[\s\S]*?\n\s*\},\n\}\);/;
|
|
2
|
+
|
|
3
|
+
function valuesLiteral(source: string) {
|
|
4
|
+
let i = 0;
|
|
5
|
+
let range: [number, number] | undefined;
|
|
6
|
+
const fail = (): never => { throw new Error("Unsupported copy literal; use data-only defineCopy values"); };
|
|
7
|
+
const skip = () => {
|
|
8
|
+
while (true) {
|
|
9
|
+
const match = /^(?:\s+|\/\/[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)/.exec(source.slice(i));
|
|
10
|
+
if (!match) break;
|
|
11
|
+
i += match[0].length;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const take = (text: string) => { skip(); if (!source.startsWith(text, i)) fail(); i += text.length; };
|
|
15
|
+
const string = (): string => {
|
|
16
|
+
const quote = source[i++];
|
|
17
|
+
let value = "";
|
|
18
|
+
while (i < source.length) {
|
|
19
|
+
const char = source[i++];
|
|
20
|
+
if (char === quote) return value;
|
|
21
|
+
if (char === "\n" || char === "\r") fail();
|
|
22
|
+
if (char !== "\\") { value += char; continue; }
|
|
23
|
+
const escape = source[i++];
|
|
24
|
+
const simple: Record<string, string> = { n: "\n", r: "\r", t: "\t", b: "\b", f: "\f", v: "\v", "\\": "\\", "'": "'", '"': '"', "/": "/" };
|
|
25
|
+
if (Object.hasOwn(simple, escape)) value += simple[escape];
|
|
26
|
+
else if (escape === "u" || escape === "x") {
|
|
27
|
+
const length = escape === "u" ? 4 : 2;
|
|
28
|
+
const hex = source.slice(i, i + length);
|
|
29
|
+
if (!new RegExp(`^[0-9a-fA-F]{${length}}$`).test(hex)) fail();
|
|
30
|
+
value += String.fromCharCode(parseInt(hex, 16)); i += length;
|
|
31
|
+
} else fail();
|
|
32
|
+
}
|
|
33
|
+
return fail();
|
|
34
|
+
};
|
|
35
|
+
const value = (depth = 0): unknown => {
|
|
36
|
+
if (depth > 100) fail();
|
|
37
|
+
skip();
|
|
38
|
+
if (source[i] === '"' || source[i] === "'") return string();
|
|
39
|
+
const opener = source[i];
|
|
40
|
+
if (opener === "{" || opener === "[") {
|
|
41
|
+
i++;
|
|
42
|
+
const object: Record<string, unknown> = {};
|
|
43
|
+
const array: unknown[] = [];
|
|
44
|
+
const end = opener === "{" ? "}" : "]";
|
|
45
|
+
skip();
|
|
46
|
+
while (source[i] !== end) {
|
|
47
|
+
let key = "";
|
|
48
|
+
if (opener === "{") {
|
|
49
|
+
skip();
|
|
50
|
+
if (source[i] === '"' || source[i] === "'") key = string();
|
|
51
|
+
else {
|
|
52
|
+
const match = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(source.slice(i));
|
|
53
|
+
if (!match) fail();
|
|
54
|
+
key = match![0]; i += key.length;
|
|
55
|
+
}
|
|
56
|
+
if (["__proto__", "prototype", "constructor"].includes(key) || Object.hasOwn(object, key)) fail();
|
|
57
|
+
take(":");
|
|
58
|
+
}
|
|
59
|
+
skip(); const start = i;
|
|
60
|
+
const child = value(depth + 1);
|
|
61
|
+
if (opener === "{") object[key] = child; else array.push(child);
|
|
62
|
+
if (depth === 0 && key === "values") range = [start, i];
|
|
63
|
+
skip();
|
|
64
|
+
if (source[i] !== ",") break;
|
|
65
|
+
i++; skip();
|
|
66
|
+
}
|
|
67
|
+
take(end);
|
|
68
|
+
return opener === "{" ? object : array;
|
|
69
|
+
}
|
|
70
|
+
const match = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/.exec(source.slice(i));
|
|
71
|
+
if (!match) return fail();
|
|
72
|
+
i += match[0].length;
|
|
73
|
+
const result = JSON.parse(match[0]);
|
|
74
|
+
if (typeof result === "number" && !Number.isFinite(result)) fail();
|
|
75
|
+
return result;
|
|
76
|
+
};
|
|
77
|
+
skip();
|
|
78
|
+
const imported = /^import\s*\{\s*defineCopy\s*\}\s*from\s*(["'])@rigel-cms\/rigel\/content\1\s*;/.exec(source.slice(i));
|
|
79
|
+
if (imported) i += imported[0].length;
|
|
80
|
+
take("export"); take("default"); take("defineCopy"); take("(");
|
|
81
|
+
const page = value();
|
|
82
|
+
take(")"); skip(); if (source[i] === ";") i++; skip();
|
|
83
|
+
if (i !== source.length || !range || !page || typeof page !== "object" || Array.isArray(page)) fail();
|
|
84
|
+
const values = (page as Record<string, unknown>).values;
|
|
85
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) fail();
|
|
86
|
+
return { values: values as Record<string, unknown>, range: range! };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readValues(source: string): Record<string, unknown> {
|
|
90
|
+
return valuesLiteral(source).values;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function serializeValues(values: Record<string, unknown>): string {
|
|
94
|
+
const groups = Object.entries(values)
|
|
95
|
+
.map(([gid, fields]) => ` ${safeKey(gid)}: ${fmtValue(fields, 3)},`)
|
|
96
|
+
.join("\n");
|
|
97
|
+
return `{\n${groups}\n }`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function applyValues(
|
|
101
|
+
source: string,
|
|
102
|
+
values: Record<string, unknown>,
|
|
103
|
+
): string {
|
|
104
|
+
const { range: [start, end] } = valuesLiteral(source);
|
|
105
|
+
const literal = serializeValues(values);
|
|
106
|
+
readValues(`export default defineCopy({values:${literal}});`);
|
|
107
|
+
return source.slice(0, start) + literal + source.slice(end);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fmtValue(v: unknown, indent: number): string {
|
|
111
|
+
const pad = " ".repeat(indent);
|
|
112
|
+
if (Array.isArray(v)) {
|
|
113
|
+
if (v.length === 0) return "[]";
|
|
114
|
+
const items = v.map((x) => `${pad}${inlineObj(x)}`).join(",\n");
|
|
115
|
+
return `[\n${items},\n${" ".repeat(indent - 1)}]`;
|
|
116
|
+
}
|
|
117
|
+
if (v !== null && typeof v === "object") {
|
|
118
|
+
if (Object.keys(v).length === 0) return "{}";
|
|
119
|
+
const entries = Object.entries(v as Record<string, unknown>)
|
|
120
|
+
.map(([k, x]) => `${pad}${safeKey(k)}: ${fmtValue(x, indent + 1)}`)
|
|
121
|
+
.join(",\n");
|
|
122
|
+
return `{\n${entries},\n${" ".repeat(indent - 1)}}`;
|
|
123
|
+
}
|
|
124
|
+
return JSON.stringify(v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function inlineObj(v: unknown): string {
|
|
128
|
+
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
|
|
129
|
+
const entries = Object.entries(v as Record<string, unknown>)
|
|
130
|
+
.map(([k, x]) => `${safeKey(k)}: ${JSON.stringify(x)}`)
|
|
131
|
+
.join(", ");
|
|
132
|
+
return `{ ${entries} }`;
|
|
133
|
+
}
|
|
134
|
+
return JSON.stringify(v);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function safeKey(k: string): string {
|
|
138
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : JSON.stringify(k);
|
|
139
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { isPlainObject } from "./validate";
|
|
2
|
+
import type { ContentDocument } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface DocumentIndex {
|
|
5
|
+
byId: Map<string, ContentDocument>;
|
|
6
|
+
list: ContentDocument[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildDocumentIndex(documents: ContentDocument[]): DocumentIndex {
|
|
10
|
+
const byId = new Map<string, ContentDocument>();
|
|
11
|
+
for (const doc of documents) byId.set(doc.id, doc);
|
|
12
|
+
return { byId, list: documents };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function deref(value: unknown, index: DocumentIndex): ContentDocument | undefined {
|
|
16
|
+
if (!isPlainObject(value) || typeof value._ref !== "string") return undefined;
|
|
17
|
+
return index.byId.get(value._ref);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function referencedIds(value: unknown): string[] {
|
|
21
|
+
const ids: string[] = [];
|
|
22
|
+
const visit = (current: unknown) => {
|
|
23
|
+
if (Array.isArray(current)) {
|
|
24
|
+
current.forEach(visit);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (!isPlainObject(current)) return;
|
|
28
|
+
if (typeof current._ref === "string") ids.push(current._ref);
|
|
29
|
+
for (const child of Object.values(current)) visit(child);
|
|
30
|
+
};
|
|
31
|
+
visit(value);
|
|
32
|
+
return [...new Set(ids)];
|
|
33
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { documentDef, isPlainObject, objectDef } from "./validate";
|
|
2
|
+
import {
|
|
3
|
+
SLUG_PATTERN,
|
|
4
|
+
type ContentDocument,
|
|
5
|
+
type DocumentDef,
|
|
6
|
+
type FieldDef,
|
|
7
|
+
type Schema,
|
|
8
|
+
} from "./types";
|
|
9
|
+
|
|
10
|
+
export function newKey(): string {
|
|
11
|
+
return `k${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cloneJson<T>(value: T): T {
|
|
15
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ResolvedPath {
|
|
19
|
+
found: boolean;
|
|
20
|
+
value?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const BLOCKED = new Set(["__proto__", "constructor", "prototype"]);
|
|
24
|
+
|
|
25
|
+
export function resolvePath(values: unknown, path: string): ResolvedPath {
|
|
26
|
+
let current: unknown = values;
|
|
27
|
+
for (const segment of path.split(".")) {
|
|
28
|
+
if (BLOCKED.has(segment)) return { found: false };
|
|
29
|
+
if (Array.isArray(current)) {
|
|
30
|
+
const item = current.find((entry) => isPlainObject(entry) && entry._key === segment);
|
|
31
|
+
if (item !== undefined) {
|
|
32
|
+
current = item;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (!/^\d+$/.test(segment)) return { found: false };
|
|
36
|
+
const index = Number(segment);
|
|
37
|
+
if (index >= current.length) return { found: false };
|
|
38
|
+
current = current[index];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!isPlainObject(current) || !Object.hasOwn(current, segment)) return { found: false };
|
|
42
|
+
current = current[segment];
|
|
43
|
+
}
|
|
44
|
+
return { found: true, value: current };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function setPath(values: Record<string, unknown>, path: string, next: unknown): boolean {
|
|
48
|
+
const segments = path.split(".");
|
|
49
|
+
if (!segments.length || segments.some((segment) => BLOCKED.has(segment))) return false;
|
|
50
|
+
let current: unknown = values;
|
|
51
|
+
for (let index = 0; index < segments.length - 1; index++) {
|
|
52
|
+
const segment = segments[index];
|
|
53
|
+
if (Array.isArray(current)) {
|
|
54
|
+
current = /^\d+$/.test(segment)
|
|
55
|
+
? current[Number(segment)]
|
|
56
|
+
: current.find((entry) => isPlainObject(entry) && entry._key === segment);
|
|
57
|
+
} else if (isPlainObject(current)) {
|
|
58
|
+
current = current[segment];
|
|
59
|
+
} else return false;
|
|
60
|
+
}
|
|
61
|
+
const last = segments[segments.length - 1];
|
|
62
|
+
if (Array.isArray(current)) {
|
|
63
|
+
const index = /^\d+$/.test(last)
|
|
64
|
+
? Number(last)
|
|
65
|
+
: current.findIndex((entry) => isPlainObject(entry) && entry._key === last);
|
|
66
|
+
if (index < 0 || index >= current.length) return false;
|
|
67
|
+
current[index] = next;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
if (!isPlainObject(current)) return false;
|
|
71
|
+
current[last] = next;
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sectionDef(schema: Schema, type: string) {
|
|
76
|
+
return schema.objects.find((entry) => entry.name === type && entry.type === "section");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function arrayObjectFields(schema: Schema, field: FieldDef): FieldDef[] {
|
|
80
|
+
const item = field.items?.find((entry) => entry.type === "object" && entry.name);
|
|
81
|
+
if (!item || item.type !== "object") return [];
|
|
82
|
+
return objectDef(schema, item.name!).fields;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fieldByKey(fields: FieldDef[], key: string): FieldDef | undefined {
|
|
86
|
+
return fields.find((field) => field.key === key);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function regenerateKeys<T>(value: T): T {
|
|
90
|
+
if (Array.isArray(value)) return value.map((item) => regenerateKeys(item)) as T;
|
|
91
|
+
if (!isPlainObject(value)) return value;
|
|
92
|
+
const next: Record<string, unknown> = {};
|
|
93
|
+
for (const [key, child] of Object.entries(value)) next[key] = regenerateKeys(child);
|
|
94
|
+
if (typeof value._key === "string") next._key = newKey();
|
|
95
|
+
return next as T;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function newDocumentFromTemplate(schema: Schema, typeName: string, title: string, slug: string): Record<string, unknown> {
|
|
99
|
+
if (!SLUG_PATTERN.test(slug)) throw new Error("Slug must be lowercase letters, numbers and single dashes.");
|
|
100
|
+
const def = documentDef(schema, typeName);
|
|
101
|
+
const data: Record<string, unknown> = { ...def.initialValue, _id: `${typeName}:${slug}`, _type: typeName, slug };
|
|
102
|
+
for (const field of def.fields) {
|
|
103
|
+
if (field.key === "title") data.title = title;
|
|
104
|
+
else if (field.key === "name" && !def.fields.some(field => field.key === "title")) data.name = title;
|
|
105
|
+
else if (data[field.key] !== undefined) continue;
|
|
106
|
+
else if (field.initialValue !== undefined) data[field.key] = field.initialValue;
|
|
107
|
+
else if (field.type === "array") data[field.key] = [];
|
|
108
|
+
}
|
|
109
|
+
if (def.template?.length) {
|
|
110
|
+
data.sections = def.template.map((section) => {
|
|
111
|
+
const sectionType = sectionDef(schema, section.type);
|
|
112
|
+
if (!sectionType) throw new Error(`Template references unknown section ${section.type}`);
|
|
113
|
+
const values: Record<string, unknown> = { _key: newKey(), _type: section.type };
|
|
114
|
+
for (const field of sectionType.fields) {
|
|
115
|
+
if (section.values && Object.hasOwn(section.values, field.key)) values[field.key] = section.values[field.key];
|
|
116
|
+
else if (field.initialValue !== undefined) values[field.key] = field.initialValue;
|
|
117
|
+
else if (field.type === "array") values[field.key] = [];
|
|
118
|
+
}
|
|
119
|
+
return values;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return regenerateKeys(data);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function assertUniqueSlug(documents: ContentDocument[], typeName: string, slug: string, legacySlugs: string[] = []) {
|
|
126
|
+
if (!SLUG_PATTERN.test(slug)) throw new Error("Slug must be lowercase letters, numbers and single dashes.");
|
|
127
|
+
if (documents.some((doc) => doc.type === typeName && doc.slug === slug)) throw new Error(`A ${typeName} document with slug "${slug}" already exists.`);
|
|
128
|
+
if (legacySlugs.includes(slug)) throw new Error(`The slug "${slug}" is already used by a copy page.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function pageRoute(slug: string, mapping: { url: string; index?: string } | undefined): string | null {
|
|
132
|
+
if (!mapping) return null;
|
|
133
|
+
if (slug === "home" && mapping.index) return mapping.index;
|
|
134
|
+
const path = mapping.url.replaceAll("{slug}", slug);
|
|
135
|
+
return /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { link, lstat, mkdir, open, readdir, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { isPlainObject } from "./validate";
|
|
6
|
+
import { SLUG_PATTERN, type ContentDocument, type Schema } from "./types";
|
|
7
|
+
|
|
8
|
+
const MAX_BYTES = 1024 * 1024;
|
|
9
|
+
const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
|
|
10
|
+
|
|
11
|
+
export class DocumentStoreError extends Error {
|
|
12
|
+
status: number;
|
|
13
|
+
constructor(message: string, status = 400) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function resolveRoot(root: string): Promise<string> {
|
|
20
|
+
const real = await realpath(root).catch(() => {
|
|
21
|
+
throw new DocumentStoreError("Site root is unavailable", 500);
|
|
22
|
+
});
|
|
23
|
+
return real;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolveFolder(root: string, folder: string): Promise<string> {
|
|
27
|
+
if (!folder.split("/").every((part) => FOLDER_PART.test(part))) throw new DocumentStoreError("Unsafe document folder", 500);
|
|
28
|
+
let directory = await resolveRoot(root);
|
|
29
|
+
for (const part of folder.split("/")) {
|
|
30
|
+
directory = path.join(directory, part);
|
|
31
|
+
const info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
|
32
|
+
if (error.code === "ENOENT") return null;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
if (!info) throw new DocumentStoreError(`Document folder ${folder} does not exist`, 500);
|
|
36
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new DocumentStoreError(`Document folder ${folder} must contain only real directories`, 500);
|
|
37
|
+
}
|
|
38
|
+
return directory;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readJsonFile(file: string): Promise<unknown> {
|
|
42
|
+
const info = await lstat(file);
|
|
43
|
+
if (info.isSymbolicLink() || !info.isFile()) throw new DocumentStoreError("Document must be a regular file", 500);
|
|
44
|
+
if (info.size > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
45
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(await handle.readFile("utf8"));
|
|
48
|
+
} finally {
|
|
49
|
+
await handle.close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function ensureFolder(root: string, folder: string): Promise<string> {
|
|
54
|
+
if (!folder.split("/").every((part) => FOLDER_PART.test(part))) throw new DocumentStoreError("Unsafe document folder", 500);
|
|
55
|
+
let directory = await resolveRoot(root);
|
|
56
|
+
for (const part of folder.split("/")) {
|
|
57
|
+
directory = path.join(directory, part);
|
|
58
|
+
let info = await lstat(directory).catch((error: NodeJS.ErrnoException) => {
|
|
59
|
+
if (error.code === "ENOENT") return null;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
if (!info) {
|
|
63
|
+
await mkdir(directory);
|
|
64
|
+
info = await lstat(directory);
|
|
65
|
+
}
|
|
66
|
+
if (info.isSymbolicLink() || !info.isDirectory()) throw new DocumentStoreError(`Document folder ${folder} must contain only real directories`, 500);
|
|
67
|
+
}
|
|
68
|
+
return directory;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function readDocuments(schema: Schema, root: string): Promise<ContentDocument[]> {
|
|
72
|
+
const documents: ContentDocument[] = [];
|
|
73
|
+
for (const type of schema.documents) {
|
|
74
|
+
let directory: string;
|
|
75
|
+
try {
|
|
76
|
+
directory = await resolveFolder(root, type.folder);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
if (error instanceof DocumentStoreError && error.status === 500 && /does not exist/.test(error.message)) continue;
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
const names = (await readdir(directory)).filter((name) => name.endsWith(".json")).sort();
|
|
82
|
+
for (const name of names) {
|
|
83
|
+
const slug = name.slice(0, -5);
|
|
84
|
+
if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError(`Invalid document filename ${name}`, 500);
|
|
85
|
+
const data = await readJsonFile(path.join(directory, name));
|
|
86
|
+
if (!isPlainObject(data)) throw new DocumentStoreError(`Document ${name} must be a JSON object`, 500);
|
|
87
|
+
if (data._type !== undefined && data._type !== type.name) throw new DocumentStoreError(`${name}: _type must be ${type.name}`, 500);
|
|
88
|
+
documents.push({ id: `${type.name}:${slug}`, type: type.name, slug, data: data as Record<string, unknown> });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return documents;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function readDocument(schema: Schema, root: string, typeName: string, slug: string): Promise<ContentDocument | null> {
|
|
95
|
+
const documents = await readDocuments(schema, root);
|
|
96
|
+
return documents.find((doc) => doc.type === typeName && doc.slug === slug) ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function writeDocument(
|
|
100
|
+
schema: Schema,
|
|
101
|
+
root: string,
|
|
102
|
+
typeName: string,
|
|
103
|
+
slug: string,
|
|
104
|
+
data: Record<string, unknown>,
|
|
105
|
+
mode: "create" | "update",
|
|
106
|
+
): Promise<void> {
|
|
107
|
+
const type = schema.documents.find((entry) => entry.name === typeName);
|
|
108
|
+
if (!type) throw new DocumentStoreError(`Unknown document type ${typeName}`, 404);
|
|
109
|
+
if (mode === "create" && (type.creatable === false || type.singleton)) throw new DocumentStoreError("Creating entries is disabled for this document type", 403);
|
|
110
|
+
if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError("Invalid document slug", 400);
|
|
111
|
+
if (!isPlainObject(data)) throw new DocumentStoreError("Document must be an object", 400);
|
|
112
|
+
const directory = await ensureFolder(root, type.folder);
|
|
113
|
+
const file = path.join(directory, `${slug}.json`);
|
|
114
|
+
const existing = await lstat(file).catch((error: NodeJS.ErrnoException) => {
|
|
115
|
+
if (error.code === "ENOENT") return null;
|
|
116
|
+
throw error;
|
|
117
|
+
});
|
|
118
|
+
if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new DocumentStoreError("Document path is unsafe", 409);
|
|
119
|
+
if (mode === "create" && existing) throw new DocumentStoreError("A document with that slug already exists", 409);
|
|
120
|
+
if (mode === "update" && !existing) throw new DocumentStoreError("No such document", 404);
|
|
121
|
+
const serialized = JSON.stringify(data, null, 2) + "\n";
|
|
122
|
+
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
123
|
+
const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
|
|
124
|
+
try {
|
|
125
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
126
|
+
try {
|
|
127
|
+
await handle.writeFile(serialized, "utf8");
|
|
128
|
+
await handle.sync();
|
|
129
|
+
} finally {
|
|
130
|
+
await handle.close();
|
|
131
|
+
}
|
|
132
|
+
if (mode === "create") await link(temporary, file);
|
|
133
|
+
else await rename(temporary, file);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof DocumentStoreError) throw error;
|
|
136
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new DocumentStoreError("A document with that slug already exists", 409);
|
|
137
|
+
throw new DocumentStoreError("Could not write document", 500);
|
|
138
|
+
} finally {
|
|
139
|
+
await unlink(temporary).catch(() => {});
|
|
140
|
+
}
|
|
141
|
+
}
|