@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.
Files changed (46) 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/content.ts +3 -1
  7. package/src/editor/App.svelte +114 -25
  8. package/src/editor/blocks/embed.ts +21 -0
  9. package/src/editor/blocks/index.ts +3 -1
  10. package/src/editor/blocks/video.ts +35 -0
  11. package/src/editor/lib/ActionDialog.svelte +2 -1
  12. package/src/editor/lib/BlockEditor.svelte +84 -15
  13. package/src/editor/lib/CollectionPanel.svelte +9 -4
  14. package/src/editor/lib/ImagePicker.svelte +141 -47
  15. package/src/editor/lib/MediaPicker.svelte +73 -0
  16. package/src/editor/lib/PreviewFrame.svelte +64 -22
  17. package/src/editor/lib/PublishDialog.svelte +65 -0
  18. package/src/editor/lib/SchemaPanel.svelte +6 -1
  19. package/src/editor/lib/Select.svelte +5 -2
  20. package/src/editor/lib/VisualEditor.svelte +11 -6
  21. package/src/editor/lib/embed-preview.ts +57 -0
  22. package/src/editor/lib/embeds.ts +47 -0
  23. package/src/editor/lib/github-backend.ts +220 -20
  24. package/src/editor/lib/icons.ts +13 -0
  25. package/src/editor/lib/images.ts +20 -8
  26. package/src/editor/lib/media-library.ts +11 -1
  27. package/src/editor/lib/settings.ts +2 -2
  28. package/src/editor/lib/slugify.ts +1 -15
  29. package/src/editor/lib/tooltip.ts +23 -4
  30. package/src/editor/styles.css +44 -13
  31. package/src/env.d.ts +1 -1
  32. package/src/index.ts +4 -6
  33. package/src/json-output.ts +5 -0
  34. package/src/manifest/types.ts +1 -0
  35. package/src/media-types.ts +14 -1
  36. package/src/preview.ts +14 -3
  37. package/src/routes/local-documents.ts +4 -4
  38. package/src/routes/local-media.ts +33 -28
  39. package/src/routes/local-menu.ts +2 -1
  40. package/src/routes/local-post.ts +18 -5
  41. package/src/routes/media-files.ts +36 -1
  42. package/src/routes/media-validation.ts +2 -2
  43. package/src/schema/paths.ts +3 -1
  44. package/src/schema/store.ts +24 -3
  45. package/src/slugs.ts +46 -0
  46. package/src/vite/manifest-plugin.ts +9 -2
@@ -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,5 +1,7 @@
1
1
  import type { APIRoute } from "astro";
2
- import { writeFile, mkdir, rm, readFile, rename, link } from "node:fs/promises";
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
- export const POST: APIRoute = async ({ request, params }) => {
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, slug };
67
+ const fm = { ...body.frontmatter };
55
68
  const temporary = `${file}.${randomUUID()}.tmp`;
56
69
  try {
57
- 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" });
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
  }
@@ -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(slug: string, mapping: { url: string; index?: string } | undefined): string | null {
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;
@@ -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
- export async function writeDocument(
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
- const serialized = JSON.stringify(data, null, 2) + "\n";
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 documentsSrc = documentTypes.length
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
  `;