@rigel-cms/rigel 0.1.3-beta.1 → 0.2.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/package.json +1 -1
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +191 -55
- package/src/editor/lib/BlockEditor.svelte +18 -18
- package/src/editor/lib/CollectionPanel.svelte +6 -2
- package/src/editor/lib/ImagePicker.svelte +7 -7
- package/src/editor/lib/MarkdownEditor.svelte +7 -7
- package/src/editor/lib/PreviewFrame.svelte +64 -22
- package/src/editor/lib/PublishDialog.svelte +65 -0
- package/src/editor/lib/SchemaField.svelte +4 -4
- package/src/editor/lib/SchemaPanel.svelte +10 -5
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +1 -1
- package/src/editor/lib/Settings.svelte +27 -2
- package/src/editor/lib/SitePreview.svelte +2 -2
- package/src/editor/lib/TopBar.svelte +1 -1
- package/src/editor/lib/VisualEditor.svelte +8 -4
- package/src/editor/lib/github-auth.ts +4 -3
- package/src/editor/lib/github-backend.ts +218 -19
- package/src/editor/lib/slugify.ts +1 -10
- package/src/editor/lib/storage.ts +30 -2
- package/src/editor/lib/tooltip.ts +120 -0
- package/src/editor/styles.css +25 -1
- package/src/env.d.ts +1 -1
- package/src/preview.ts +9 -2
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-post.ts +23 -5
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +21 -2
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +6 -2
package/src/editor/styles.css
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
.rigel, .rigel *, .rigel *::before, .rigel *::after { box-sizing: border-box; }
|
|
2
|
+
.rigel .rigel-tooltip { position: fixed; pointer-events: auto; inset: auto; margin: 0; padding: 0.375rem 0.625rem; max-width: min(20rem, calc(100vw - 16px)); max-height: calc(100dvh - 16px); overflow: auto; border: 1px solid var(--border-control); border-radius: var(--radius); background: var(--text); color: var(--panel); box-shadow: var(--shadow-md); font: 500 0.75rem/1.5 -apple-system, BlinkMacSystemFont, system-ui, sans-serif; overflow-wrap: anywhere; text-align: start; white-space: normal; }
|
|
3
|
+
.rigel .rigel-tooltip::backdrop { background: transparent; pointer-events: none; }
|
|
4
|
+
@media (forced-colors: active) { .rigel .rigel-tooltip { background: Canvas; color: CanvasText; border-color: CanvasText; } }
|
|
2
5
|
.rigel {
|
|
3
6
|
--rigel-50: #EFFBFF;
|
|
4
7
|
--rigel-100: #D9F4FF;
|
|
@@ -26,6 +29,9 @@
|
|
|
26
29
|
|
|
27
30
|
.rigel.connection-shell { align-items: center; justify-content: center; padding: 1.5rem; min-height: 100dvh; }
|
|
28
31
|
.rigel .connection-card { flex: none; width: 100%; max-width: 560px; margin: 0; padding: 2rem; background: var(--panel); border: 1px solid var(--border); overflow-wrap: anywhere; }
|
|
32
|
+
.rigel .connection-brand { display: flex; align-items: center; gap: 0.75rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.04em; }
|
|
33
|
+
.rigel .brand-suffix { font-weight: 450; }
|
|
34
|
+
.rigel .connection-brand img { flex-shrink: 0; }
|
|
29
35
|
.rigel .connection-card h1 { margin: 1rem 0; font-size: 1.75rem; line-height: 1.2; }
|
|
30
36
|
.rigel .connection-card p { margin: 1rem 0; }
|
|
31
37
|
.rigel .connection-card :is(button, a.primary) { display: inline-flex; align-items: center; justify-content: center; min-height: 44px; padding: 0.625rem 1rem; font: inherit; text-decoration: none; text-align: center; }
|
|
@@ -169,6 +175,8 @@
|
|
|
169
175
|
.rigel .rail-scroll > button:hover { background: var(--surface-raised); color: var(--text); }
|
|
170
176
|
.rigel .sidebar-asset-icon { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; background-color: currentColor; mask-image: var(--sidebar-icon); mask-position: center; mask-size: contain; mask-repeat: no-repeat; }
|
|
171
177
|
.rigel .rail-scroll > button { gap: 0.625rem; }
|
|
178
|
+
.rigel aside > button.sidebar-publish { gap: 0.625rem; width: calc(100% - 1.5rem); min-height: 44px; margin: 0 0.75rem 0.5rem; padding: 0.625rem; border: 1px solid var(--border-control); color: var(--text); }
|
|
179
|
+
.rigel aside > button.sidebar-publish:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
172
180
|
.rigel aside > button.sidebar-toggle { gap: 0.625rem; width: calc(100% - 1.5rem); margin: 0 0.75rem 0.5rem; padding: 0.625rem; font-size: 0.75rem; }
|
|
173
181
|
@media (min-width: 768px) {
|
|
174
182
|
.rigel.sidebar-collapsed { --sidebar-w: 64px; }
|
|
@@ -180,6 +188,7 @@
|
|
|
180
188
|
.rigel.sidebar-collapsed .user-btn > .user-menu-icon { display: none; }
|
|
181
189
|
.rigel.sidebar-collapsed .rail-scroll > button,
|
|
182
190
|
.rigel.sidebar-collapsed .search-trigger,
|
|
191
|
+
.rigel.sidebar-collapsed .sidebar-publish,
|
|
183
192
|
.rigel.sidebar-collapsed .sidebar-toggle { justify-content: center; }
|
|
184
193
|
.rigel.sidebar-collapsed .sidebar-heading { height: 1px; font-size: 0; padding: 0; margin: 0.75rem 0.625rem; border-top: 1px solid var(--border-subtle); }
|
|
185
194
|
.rigel.sidebar-collapsed .aside-foot { padding: 0.75rem 0.5rem; }
|
|
@@ -359,7 +368,18 @@
|
|
|
359
368
|
.rigel .preview-heading span, .rigel .site-preview small { color: var(--text-muted); font-size: 0.75rem; }
|
|
360
369
|
.rigel .site-preview p { overflow-wrap: anywhere; font-size: 0.8125rem; }
|
|
361
370
|
.rigel .site-preview .preview-error { color: var(--danger); }
|
|
362
|
-
.rigel .
|
|
371
|
+
.rigel .preview-frame { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; background: var(--panel); }
|
|
372
|
+
.rigel .preview-frame iframe { opacity: 0; visibility: hidden; transition: none; }
|
|
373
|
+
.rigel .preview-frame iframe.revealed { opacity: 1; visibility: visible; transition: opacity 200ms ease-out; }
|
|
374
|
+
.rigel .preview-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.625rem; color: var(--text); background: var(--panel); font-size: 0.8125rem; }
|
|
375
|
+
.rigel .preview-spinner { width: 1rem; height: 1rem; border: 2px solid var(--border); border-top-color: currentColor; border-radius: 50%; animation: preview-spin 800ms linear infinite; }
|
|
376
|
+
@keyframes preview-spin { to { transform: rotate(360deg); } }
|
|
377
|
+
@media (prefers-reduced-motion: reduce) {
|
|
378
|
+
.rigel .preview-spinner { animation: none; }
|
|
379
|
+
.rigel .preview-frame iframe.revealed { transition: none; }
|
|
380
|
+
}
|
|
381
|
+
.rigel .site-preview .preview-frame { min-height: 300px; border: 1px solid var(--border); }
|
|
382
|
+
.rigel .site-preview iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
|
|
363
383
|
.rigel .site-preview small { padding-top: 0.75rem; }
|
|
364
384
|
.rigel .preview-retry { align-self: start; }
|
|
365
385
|
@media (max-width: 1050px) {
|
|
@@ -959,6 +979,10 @@
|
|
|
959
979
|
font-size: 0.875rem; font-weight: 600; text-transform: none;
|
|
960
980
|
letter-spacing: 0; color: var(--text); margin: 0 0 0.75rem;
|
|
961
981
|
}
|
|
982
|
+
.rigel .connection-details { margin: 0; }
|
|
983
|
+
.rigel .connection-details dt { margin-top: 1rem; font-size: 0.75rem; font-weight: 600; color: var(--text-2); }
|
|
984
|
+
.rigel .connection-details dd { margin: 0.375rem 0 0; font-size: 0.875rem; overflow-wrap: anywhere; }
|
|
985
|
+
.rigel .connection-details small { display: block; margin-top: 0.125rem; color: var(--text-muted); }
|
|
962
986
|
.rigel .settings-pane fieldset { border: none; padding: 0; margin: 0 0 1rem; }
|
|
963
987
|
.rigel .settings-pane fieldset.accessibility-options { margin-top: 1.5rem; }
|
|
964
988
|
.rigel .accessibility-options label.inline { margin-top: 0; }
|
package/src/env.d.ts
CHANGED
|
@@ -17,6 +17,6 @@ declare module "virtual:rigel-content" {
|
|
|
17
17
|
|
|
18
18
|
declare module "virtual:rigel-config" {
|
|
19
19
|
import type { RigelConfig } from "@rigel-cms/rigel";
|
|
20
|
-
const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema"> & { publicDir?: string; root?: string };
|
|
20
|
+
const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema" | "preview"> & { publicDir?: string; root?: string };
|
|
21
21
|
export default config;
|
|
22
22
|
}
|
package/src/preview.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import MarkdownIt from "markdown-it";
|
|
2
2
|
import DOMPurify from "dompurify";
|
|
3
3
|
import { pageRoute } from "./schema/paths";
|
|
4
|
+
import { slugify } from "./slugs";
|
|
4
5
|
import { renderArticle } from "./article-render";
|
|
5
6
|
import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
|
|
6
7
|
export { safePreviewUrl } from "./preview-urls";
|
|
@@ -20,6 +21,7 @@ export interface PreviewConfig {
|
|
|
20
21
|
export interface PreviewTarget {
|
|
21
22
|
kind: "page" | "post" | "menu" | "document";
|
|
22
23
|
slug: string;
|
|
24
|
+
urlSlug?: string;
|
|
23
25
|
collection?: string;
|
|
24
26
|
type?: string;
|
|
25
27
|
}
|
|
@@ -30,8 +32,13 @@ export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew
|
|
|
30
32
|
return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !/%(?:2e|2f|5c)/i.test(path) && !path.split("/").some(part => part === "." || part === "..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" = "draft", isNew = false): string | null {
|
|
34
|
-
|
|
35
|
+
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
|
|
36
|
+
if (mode !== "draft" && target.kind !== "page" && target.urlSlug !== undefined) {
|
|
37
|
+
const slug = slugify(target.urlSlug);
|
|
38
|
+
if (!slug) return null;
|
|
39
|
+
target = { ...target, slug };
|
|
40
|
+
}
|
|
41
|
+
const path = previewPath(config, target, mode === "draft" && (target.kind === "post" || isNew));
|
|
35
42
|
const base = mode === "live" ? config.liveUrl : config.draftUrl;
|
|
36
43
|
if (!path || base === undefined) return path;
|
|
37
44
|
try { return new URL(path.slice(1), previewBaseUrl(base)).href; } catch { return null; }
|
|
@@ -46,7 +46,7 @@ export const GET: APIRoute = async ({ params }) => {
|
|
|
46
46
|
const target = validateDocumentId(guarded.schema, id);
|
|
47
47
|
if (!target) return new Response("Unknown document", { status: 404 });
|
|
48
48
|
try {
|
|
49
|
-
const documents = await readDocuments(guarded.schema, guarded.root);
|
|
49
|
+
const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
|
|
50
50
|
const document = documents.find((entry) => entry.id === id);
|
|
51
51
|
if (!document) return new Response("Unknown document", { status: 404 });
|
|
52
52
|
return Response.json(document);
|
|
@@ -66,10 +66,10 @@ const handle: APIRoute = async ({ request, params }) => {
|
|
|
66
66
|
if (!target) return new Response("Unknown document type or slug", { status: 400 });
|
|
67
67
|
try {
|
|
68
68
|
const data = await readBody(request);
|
|
69
|
-
const documents = await readDocuments(guarded.schema, guarded.root);
|
|
70
|
-
const next = { ...data, _id: id, _type: target.type.name
|
|
69
|
+
const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
|
|
70
|
+
const next = { ...data, _id: id, _type: target.type.name };
|
|
71
71
|
validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
|
|
72
|
-
await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update");
|
|
72
|
+
await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview);
|
|
73
73
|
return new Response(null, { status: 204 });
|
|
74
74
|
} catch (error) {
|
|
75
75
|
if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
|
package/src/routes/local-post.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
-
import { writeFile, mkdir, rm, readFile, rename } from "node:fs/promises";
|
|
2
|
+
import { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
|
|
3
|
+
import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
|
|
@@ -23,7 +24,14 @@ function safeSlug(slug: string): string | null {
|
|
|
23
24
|
return /^[a-z0-9][a-z0-9_-]*$/.test(slug) ? slug : null;
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
let writes: Promise<unknown> = Promise.resolve();
|
|
28
|
+
export const POST: APIRoute = (context) => {
|
|
29
|
+
const result = writes.then(() => savePost(context));
|
|
30
|
+
writes = result.catch(() => {});
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const savePost: APIRoute = async ({ request, params }) => {
|
|
27
35
|
if (import.meta.env.PROD) {
|
|
28
36
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
29
37
|
}
|
|
@@ -45,17 +53,27 @@ export const POST: APIRoute = async ({ request, params }) => {
|
|
|
45
53
|
try {
|
|
46
54
|
validateBody(body.body);
|
|
47
55
|
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)) : [];
|
|
56
|
+
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview) : [];
|
|
49
57
|
validateCollectionValues(fields, body.frontmatter, documents);
|
|
58
|
+
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug);
|
|
59
|
+
const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; });
|
|
60
|
+
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 })));
|
|
61
|
+
assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }]);
|
|
50
62
|
} catch (error) {
|
|
51
63
|
return new Response(error instanceof Error ? error.message : "Invalid article", { status: 400 });
|
|
52
64
|
}
|
|
53
65
|
await mkdir(path.dirname(file), { recursive: true });
|
|
54
|
-
const fm = { ...body.frontmatter
|
|
66
|
+
const fm = { ...body.frontmatter };
|
|
55
67
|
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
56
68
|
try {
|
|
57
69
|
await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
|
|
58
|
-
|
|
70
|
+
if (request.headers.get("If-None-Match") === "*") {
|
|
71
|
+
try { await link(temporary, file); }
|
|
72
|
+
catch (error) {
|
|
73
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response("A post with that slug already exists. Reload the collection and create the draft again.", { status: 409 });
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
} else await rename(temporary, file);
|
|
59
77
|
} finally { await rm(temporary, { force: true }); }
|
|
60
78
|
return new Response(null, { status: 204 });
|
|
61
79
|
};
|
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,8 @@ 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";
|
|
7
9
|
|
|
8
10
|
const MAX_BYTES = 1024 * 1024;
|
|
9
11
|
const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
|
|
@@ -68,7 +70,7 @@ async function ensureFolder(root: string, folder: string): Promise<string> {
|
|
|
68
70
|
return directory;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
export async function readDocuments(schema: Schema, root: string): Promise<ContentDocument[]> {
|
|
73
|
+
export async function readDocuments(schema: Schema, root: string, preview?: PreviewConfig): Promise<ContentDocument[]> {
|
|
72
74
|
const documents: ContentDocument[] = [];
|
|
73
75
|
for (const type of schema.documents) {
|
|
74
76
|
let directory: string;
|
|
@@ -88,6 +90,8 @@ export async function readDocuments(schema: Schema, root: string): Promise<Conte
|
|
|
88
90
|
documents.push({ id: `${type.name}:${slug}`, type: type.name, slug, data: data as Record<string, unknown> });
|
|
89
91
|
}
|
|
90
92
|
}
|
|
93
|
+
try { validateDocumentSlugs(schema, documents, preview); }
|
|
94
|
+
catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
91
95
|
return documents;
|
|
92
96
|
}
|
|
93
97
|
|
|
@@ -96,13 +100,21 @@ export async function readDocument(schema: Schema, root: string, typeName: strin
|
|
|
96
100
|
return documents.find((doc) => doc.type === typeName && doc.slug === slug) ?? null;
|
|
97
101
|
}
|
|
98
102
|
|
|
99
|
-
|
|
103
|
+
let writes: Promise<void> = Promise.resolve();
|
|
104
|
+
export function writeDocument(...args: Parameters<typeof writeDocumentFile>): Promise<void> {
|
|
105
|
+
const result = writes.then(() => writeDocumentFile(...args));
|
|
106
|
+
writes = result.catch(() => {});
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function writeDocumentFile(
|
|
100
111
|
schema: Schema,
|
|
101
112
|
root: string,
|
|
102
113
|
typeName: string,
|
|
103
114
|
slug: string,
|
|
104
115
|
data: Record<string, unknown>,
|
|
105
116
|
mode: "create" | "update",
|
|
117
|
+
preview?: PreviewConfig,
|
|
106
118
|
): Promise<void> {
|
|
107
119
|
const type = schema.documents.find((entry) => entry.name === typeName);
|
|
108
120
|
if (!type) throw new DocumentStoreError(`Unknown document type ${typeName}`, 404);
|
|
@@ -118,6 +130,13 @@ export async function writeDocument(
|
|
|
118
130
|
if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new DocumentStoreError("Document path is unsafe", 409);
|
|
119
131
|
if (mode === "create" && existing) throw new DocumentStoreError("A document with that slug already exists", 409);
|
|
120
132
|
if (mode === "update" && !existing) throw new DocumentStoreError("No such document", 404);
|
|
133
|
+
if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
134
|
+
data = { ...data };
|
|
135
|
+
try {
|
|
136
|
+
if (!type.shared && Object.hasOwn(data, "slug")) data.slug = normalizeUrlSlug(data.slug);
|
|
137
|
+
const documents = await readDocuments(schema, root, preview);
|
|
138
|
+
validateDocumentSlugs(schema, [...documents.filter(doc => doc.id !== `${typeName}:${slug}`), { id: `${typeName}:${slug}`, type: typeName, slug, data }], preview);
|
|
139
|
+
} catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
121
140
|
const serialized = JSON.stringify(data, null, 2) + "\n";
|
|
122
141
|
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
|
|
123
142
|
const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
|
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
|
+
}
|
|
@@ -22,7 +22,8 @@ export function rigelManifestPlugin(cfg: RigelConfig, publicDir?: URL, root?: UR
|
|
|
22
22
|
...(cfg.menus ? { menus: { folder: cfg.menus.folder, maxDepth: cfg.menus.maxDepth! } } : {}),
|
|
23
23
|
} : undefined;
|
|
24
24
|
|
|
25
|
-
const
|
|
25
|
+
const slugHelpers = `import { assertUniqueUrlSlugs as __rigelAssertUniqueUrlSlugs, validateDocumentSlugs as __rigelValidateDocumentSlugs } from ${JSON.stringify(fileURLToPath(new URL("../slugs.ts", import.meta.url)))};`;
|
|
26
|
+
const documentsSrc = slugHelpers + (documentTypes.length
|
|
26
27
|
? `
|
|
27
28
|
${documentTypes.map((type, index) => `const __rigelDoc${index} = import.meta.glob(${JSON.stringify(`/${type.folder}/*.json`)}, { eager: true, import: "default" });`).join("\n")}
|
|
28
29
|
export const documentTypes = ${JSON.stringify(documentTypes.map((type) => ({ name: type.name, shared: Boolean(type.shared) })))};
|
|
@@ -33,9 +34,10 @@ export const documents = Object.entries(__rigelRawDocuments)
|
|
|
33
34
|
return { id: type + ":" + slug, type, slug, data: { ...data, _id: type + ":" + slug, _type: type } };
|
|
34
35
|
}))
|
|
35
36
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
37
|
+
__rigelValidateDocumentSlugs(${JSON.stringify(cfg.schema)}, documents, ${JSON.stringify(cfg.preview)});
|
|
36
38
|
const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.find((type) => type.name === doc.type).shared).map((doc) => doc.slug));
|
|
37
39
|
`
|
|
38
|
-
: `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n
|
|
40
|
+
: `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n`);
|
|
39
41
|
|
|
40
42
|
const collectionCode = collections
|
|
41
43
|
.map(
|
|
@@ -137,6 +139,7 @@ export const menus = {
|
|
|
137
139
|
menus: cfg.menus,
|
|
138
140
|
collections: cfg.collections ?? [],
|
|
139
141
|
schema: cfg.schema,
|
|
142
|
+
preview: cfg.preview,
|
|
140
143
|
})};`;
|
|
141
144
|
}
|
|
142
145
|
if (id === "\0" + CONTENT_ID) {
|
|
@@ -174,6 +177,7 @@ export const pages = loaded.filter((page) => !__rigelReservedSlugs.has(page.slug
|
|
|
174
177
|
export const collections = [
|
|
175
178
|
${collectionCode}
|
|
176
179
|
];
|
|
180
|
+
for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
|
|
177
181
|
${menuCode}
|
|
178
182
|
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
183
|
`;
|