@rigel-cms/rigel 1.0.0-rc.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/docs/extensions.md +194 -0
- package/package.json +16 -9
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +18 -3
- package/src/editor/App.svelte +99 -98
- package/src/editor/blocks/extension.ts +29 -0
- package/src/editor/blocks/index.ts +17 -15
- package/src/editor/blocks/video.ts +2 -1
- package/src/editor/i18n/README.md +23 -0
- package/src/editor/i18n/de/connection.json +136 -0
- package/src/editor/i18n/de/defaults.json +6 -0
- package/src/editor/i18n/de/fields.json +130 -0
- package/src/editor/i18n/de/local.json +49 -0
- package/src/editor/i18n/de/media.json +166 -0
- package/src/editor/i18n/de/richtext.json +116 -0
- package/src/editor/i18n/de/shell.json +181 -0
- package/src/editor/i18n/de/system.json +14 -0
- package/src/editor/i18n/de/validation.json +82 -0
- package/src/editor/i18n/en/connection.json +136 -0
- package/src/editor/i18n/en/defaults.json +6 -0
- package/src/editor/i18n/en/fields.json +130 -0
- package/src/editor/i18n/en/local.json +49 -0
- package/src/editor/i18n/en/media.json +166 -0
- package/src/editor/i18n/en/richtext.json +116 -0
- package/src/editor/i18n/en/shell.json +181 -0
- package/src/editor/i18n/en/system.json +14 -0
- package/src/editor/i18n/en/validation.json +82 -0
- package/src/editor/i18n/es/connection.json +136 -0
- package/src/editor/i18n/es/defaults.json +6 -0
- package/src/editor/i18n/es/fields.json +130 -0
- package/src/editor/i18n/es/local.json +49 -0
- package/src/editor/i18n/es/media.json +166 -0
- package/src/editor/i18n/es/richtext.json +116 -0
- package/src/editor/i18n/es/shell.json +181 -0
- package/src/editor/i18n/es/system.json +14 -0
- package/src/editor/i18n/es/validation.json +82 -0
- package/src/editor/i18n/format.ts +26 -0
- package/src/editor/i18n/fr/connection.json +136 -0
- package/src/editor/i18n/fr/defaults.json +6 -0
- package/src/editor/i18n/fr/fields.json +130 -0
- package/src/editor/i18n/fr/local.json +49 -0
- package/src/editor/i18n/fr/media.json +166 -0
- package/src/editor/i18n/fr/richtext.json +116 -0
- package/src/editor/i18n/fr/shell.json +181 -0
- package/src/editor/i18n/fr/system.json +14 -0
- package/src/editor/i18n/fr/validation.json +82 -0
- package/src/editor/i18n/index.ts +18 -0
- package/src/editor/i18n/it/connection.json +136 -0
- package/src/editor/i18n/it/defaults.json +6 -0
- package/src/editor/i18n/it/fields.json +130 -0
- package/src/editor/i18n/it/local.json +49 -0
- package/src/editor/i18n/it/media.json +166 -0
- package/src/editor/i18n/it/richtext.json +116 -0
- package/src/editor/i18n/it/shell.json +181 -0
- package/src/editor/i18n/it/system.json +14 -0
- package/src/editor/i18n/it/validation.json +82 -0
- package/src/editor/i18n/ja/connection.json +136 -0
- package/src/editor/i18n/ja/defaults.json +6 -0
- package/src/editor/i18n/ja/fields.json +130 -0
- package/src/editor/i18n/ja/local.json +49 -0
- package/src/editor/i18n/ja/media.json +166 -0
- package/src/editor/i18n/ja/richtext.json +116 -0
- package/src/editor/i18n/ja/shell.json +181 -0
- package/src/editor/i18n/ja/system.json +14 -0
- package/src/editor/i18n/ja/validation.json +82 -0
- package/src/editor/i18n/locales.ts +13 -0
- package/src/editor/i18n/nl/connection.json +136 -0
- package/src/editor/i18n/nl/defaults.json +6 -0
- package/src/editor/i18n/nl/fields.json +130 -0
- package/src/editor/i18n/nl/local.json +49 -0
- package/src/editor/i18n/nl/media.json +166 -0
- package/src/editor/i18n/nl/richtext.json +116 -0
- package/src/editor/i18n/nl/shell.json +181 -0
- package/src/editor/i18n/nl/system.json +14 -0
- package/src/editor/i18n/nl/validation.json +82 -0
- package/src/editor/i18n/ru/connection.json +138 -0
- package/src/editor/i18n/ru/defaults.json +6 -0
- package/src/editor/i18n/ru/fields.json +132 -0
- package/src/editor/i18n/ru/local.json +49 -0
- package/src/editor/i18n/ru/media.json +166 -0
- package/src/editor/i18n/ru/richtext.json +116 -0
- package/src/editor/i18n/ru/shell.json +181 -0
- package/src/editor/i18n/ru/system.json +14 -0
- package/src/editor/i18n/ru/validation.json +82 -0
- package/src/editor/i18n/server.ts +13 -0
- package/src/editor/i18n/zh/connection.json +136 -0
- package/src/editor/i18n/zh/defaults.json +6 -0
- package/src/editor/i18n/zh/fields.json +130 -0
- package/src/editor/i18n/zh/local.json +49 -0
- package/src/editor/i18n/zh/media.json +166 -0
- package/src/editor/i18n/zh/richtext.json +116 -0
- package/src/editor/i18n/zh/shell.json +181 -0
- package/src/editor/i18n/zh/system.json +14 -0
- package/src/editor/i18n/zh/validation.json +82 -0
- package/src/editor/lib/ActionDialog.svelte +5 -4
- package/src/editor/lib/BlockEditor.svelte +305 -88
- package/src/editor/lib/CollectionPanel.svelte +17 -15
- package/src/editor/lib/DatePicker.svelte +15 -12
- package/src/editor/lib/ExtensionMount.svelte +12 -0
- package/src/editor/lib/Field.svelte +3 -2
- package/src/editor/lib/ImagePicker.svelte +82 -81
- package/src/editor/lib/MarkdownEditor.svelte +25 -24
- package/src/editor/lib/MediaPicker.svelte +6 -5
- package/src/editor/lib/MenuEditor.svelte +31 -30
- package/src/editor/lib/PreviewFrame.svelte +11 -6
- package/src/editor/lib/PropertiesTabs.svelte +3 -2
- package/src/editor/lib/PublishDialog.svelte +15 -14
- package/src/editor/lib/ReferenceSelect.svelte +3 -2
- package/src/editor/lib/SchemaField.svelte +15 -13
- package/src/editor/lib/SchemaPanel.svelte +20 -19
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +6 -5
- package/src/editor/lib/Settings.svelte +54 -54
- package/src/editor/lib/Shortcuts.svelte +7 -6
- package/src/editor/lib/SitePreview.svelte +10 -9
- package/src/editor/lib/TopBar.svelte +18 -17
- package/src/editor/lib/UnsavedChanges.svelte +7 -6
- package/src/editor/lib/VisualEditor.svelte +14 -13
- package/src/editor/lib/calendar.ts +10 -4
- package/src/editor/lib/embed-preview.ts +23 -5
- package/src/editor/lib/extension-preview.ts +121 -0
- package/src/editor/lib/github-auth.ts +23 -22
- package/src/editor/lib/github-backend.ts +96 -91
- package/src/editor/lib/icons.ts +4 -0
- package/src/editor/lib/images.ts +33 -31
- package/src/editor/lib/menu-tree.ts +5 -4
- package/src/editor/lib/preview-frame.ts +4 -3
- package/src/editor/lib/schema-edit.ts +3 -2
- package/src/editor/lib/settings.ts +5 -3
- package/src/editor/lib/slash-command.ts +1 -0
- package/src/editor/lib/tooltip.ts +1 -1
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +6 -3
- package/src/env.d.ts +11 -1
- package/src/extensions.ts +160 -0
- package/src/manifest/collections.ts +6 -5
- package/src/manifest/menu.ts +12 -10
- package/src/manifest/navigation.ts +9 -8
- package/src/manifest/references.ts +8 -7
- package/src/manifest/seo.ts +4 -3
- package/src/manifest/types.ts +1 -0
- package/src/preview-urls.ts +6 -0
- package/src/preview.ts +38 -7
- package/src/routes/local-api.ts +5 -3
- package/src/routes/local-documents.ts +20 -16
- package/src/routes/local-media.ts +21 -16
- package/src/routes/local-menu.ts +9 -7
- package/src/routes/local-post.ts +18 -12
- package/src/routes/media-files.ts +10 -8
- package/src/routes/rigel-page.astro +2 -1
- package/src/schema/store.ts +22 -20
- package/src/schema/types.ts +1 -0
- package/src/schema/validate.ts +73 -69
- package/src/slugs.ts +12 -11
- package/src/vite/manifest-plugin.ts +33 -1
package/src/routes/local-post.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
+
import { requestTranslator } from "../editor/i18n/server";
|
|
2
3
|
import { serializeContentJson } from "../json-output";
|
|
3
4
|
import { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
|
|
4
5
|
import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
|
|
@@ -7,6 +8,8 @@ import path from "node:path";
|
|
|
7
8
|
|
|
8
9
|
import { validateBody, type ArticleBody } from "../article-body";
|
|
9
10
|
import config from "virtual:rigel-config";
|
|
11
|
+
import extensions from "virtual:rigel-extensions";
|
|
12
|
+
import { resolveExtensions, validateExtensionBlocks } from "../extensions";
|
|
10
13
|
import { validateCollectionValues } from "../manifest/collections";
|
|
11
14
|
import { readDocuments } from "../schema/store";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -33,35 +36,37 @@ export const POST: APIRoute = (context) => {
|
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const savePost: APIRoute = async ({ request, params }) => {
|
|
39
|
+
const t = requestTranslator(request);
|
|
36
40
|
if (import.meta.env.PROD) {
|
|
37
41
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
38
42
|
}
|
|
39
43
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
40
44
|
const slug = safeSlug(String(params.slug ?? ""));
|
|
41
45
|
if (!folder || !slug) {
|
|
42
|
-
return new Response("
|
|
46
|
+
return new Response(t("local.unknownCollectionSlug"), { status: 404 });
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
let body: { frontmatter: Record<string, unknown>; body: ArticleBody };
|
|
46
50
|
try {
|
|
47
51
|
body = await request.json();
|
|
48
|
-
if (!body || typeof body !== "object" || !body.frontmatter || typeof body.frontmatter !== "object" || Array.isArray(body.frontmatter)) throw new Error("
|
|
52
|
+
if (!body || typeof body !== "object" || !body.frontmatter || typeof body.frontmatter !== "object" || Array.isArray(body.frontmatter)) throw new Error(t("local.invalidArticle"));
|
|
49
53
|
} catch {
|
|
50
54
|
return new Response("Invalid JSON", { status: 400 });
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const file = path.resolve(folder, `${slug}.json`);
|
|
54
58
|
try {
|
|
55
|
-
validateBody(body.body);
|
|
59
|
+
validateBody(body.body, t);
|
|
60
|
+
validateExtensionBlocks(body.body, resolveExtensions(extensions).blocks);
|
|
56
61
|
const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
|
|
57
|
-
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview) : [];
|
|
58
|
-
validateCollectionValues(fields, body.frontmatter, documents);
|
|
59
|
-
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug);
|
|
62
|
+
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview, t) : [];
|
|
63
|
+
validateCollectionValues(fields, body.frontmatter, documents, t);
|
|
64
|
+
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug, t);
|
|
60
65
|
const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; });
|
|
61
66
|
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 }]);
|
|
67
|
+
assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }], t);
|
|
63
68
|
} catch (error) {
|
|
64
|
-
return new Response(error instanceof Error ? error.message : "
|
|
69
|
+
return new Response(error instanceof Error ? error.message : t("local.invalidArticle"), { status: 400 });
|
|
65
70
|
}
|
|
66
71
|
await mkdir(path.dirname(file), { recursive: true });
|
|
67
72
|
const fm = { ...body.frontmatter };
|
|
@@ -71,7 +76,7 @@ const savePost: APIRoute = async ({ request, params }) => {
|
|
|
71
76
|
if (request.headers.get("If-None-Match") === "*") {
|
|
72
77
|
try { await link(temporary, file); }
|
|
73
78
|
catch (error) {
|
|
74
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response("
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response(t("local.postExists"), { status: 409 });
|
|
75
80
|
throw error;
|
|
76
81
|
}
|
|
77
82
|
} else await rename(temporary, file);
|
|
@@ -79,20 +84,21 @@ const savePost: APIRoute = async ({ request, params }) => {
|
|
|
79
84
|
return new Response(null, { status: 204 });
|
|
80
85
|
};
|
|
81
86
|
|
|
82
|
-
export const DELETE: APIRoute = async ({ params }) => {
|
|
87
|
+
export const DELETE: APIRoute = async ({ request, params }) => {
|
|
88
|
+
const t = requestTranslator(request);
|
|
83
89
|
if (import.meta.env.PROD) {
|
|
84
90
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
85
91
|
}
|
|
86
92
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
87
93
|
const slug = safeSlug(String(params.slug ?? ""));
|
|
88
94
|
if (!folder || !slug) {
|
|
89
|
-
return new Response("
|
|
95
|
+
return new Response(t("local.unknownCollectionSlug"), { status: 404 });
|
|
90
96
|
}
|
|
91
97
|
const file = path.resolve(folder, `${slug}.json`);
|
|
92
98
|
try {
|
|
93
99
|
await readFile(file); // only delete existing files
|
|
94
100
|
} catch {
|
|
95
|
-
return new Response("
|
|
101
|
+
return new Response(t("local.postNotFound"), { status: 404 });
|
|
96
102
|
}
|
|
97
103
|
await rm(file);
|
|
98
104
|
return new Response(null, { status: 204 });
|
|
@@ -2,6 +2,7 @@ import { lstat, mkdir, readFile, readdir, realpath, rename, unlink, writeFile }
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { mediaMetadata, safeMediaName } from "../media-types";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
5
6
|
|
|
6
7
|
export const imageFile = /\\.(jpe?g|png|webp|gif|avif)$/i;
|
|
7
8
|
const SIDECAR = ".rigel-media.json";
|
|
@@ -106,29 +107,30 @@ export function deleteMediaFile(publicDir: string, folder: string | undefined, u
|
|
|
106
107
|
export function validateMetaUpdate(
|
|
107
108
|
body: unknown,
|
|
108
109
|
existing: Set<string>,
|
|
110
|
+
message: Translator = systemMessage,
|
|
109
111
|
): { ok: true; value: { url: string; name?: string; title?: string; description?: string; alt?: string } } | { ok: false; error: string } {
|
|
110
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "
|
|
112
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: message("local.invalidMetadataBody") };
|
|
111
113
|
const b = body as Record<string, unknown>;
|
|
112
114
|
const allowed = ["url", "name", "title", "description", "alt"];
|
|
113
|
-
if (Object.keys(b).some(key => !allowed.includes(key))) return { ok: false, error: "
|
|
114
|
-
if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: "
|
|
115
|
+
if (Object.keys(b).some(key => !allowed.includes(key))) return { ok: false, error: message("local.unknownMetadataField") };
|
|
116
|
+
if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: message("local.unknownAsset") };
|
|
115
117
|
const value: { url: string; name?: string; title?: string; description?: string; alt?: string } = { url: b.url };
|
|
116
118
|
if ("name" in b) {
|
|
117
119
|
const candidate = typeof b.name === "string" ? b.name.trim() : b.name;
|
|
118
|
-
if (!safeDisplayName(candidate)) return { ok: false, error: "
|
|
120
|
+
if (!safeDisplayName(candidate)) return { ok: false, error: message("local.invalidDisplayName") };
|
|
119
121
|
value.name = typeof candidate === "string" ? candidate : undefined;
|
|
120
122
|
}
|
|
121
123
|
if ("title" in b) {
|
|
122
|
-
if (typeof b.title !== "string") return { ok: false, error: "
|
|
124
|
+
if (typeof b.title !== "string") return { ok: false, error: message("local.metadataTitleString") };
|
|
123
125
|
value.title = sanitize(b.title, 200);
|
|
124
126
|
}
|
|
125
127
|
if ("description" in b) {
|
|
126
|
-
if (typeof b.description !== "string") return { ok: false, error: "
|
|
128
|
+
if (typeof b.description !== "string") return { ok: false, error: message("local.metadataDescriptionString") };
|
|
127
129
|
value.description = sanitize(b.description, 1000);
|
|
128
130
|
}
|
|
129
131
|
if ("alt" in b) {
|
|
130
|
-
if (typeof b.alt !== "string") return { ok: false, error: "
|
|
131
|
-
if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: "
|
|
132
|
+
if (typeof b.alt !== "string") return { ok: false, error: message("local.metadataAltString") };
|
|
133
|
+
if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: message("local.metadataAltImagesOnly") };
|
|
132
134
|
value.alt = sanitize(b.alt.replace(/\s+/g, " "), 1000) ?? "";
|
|
133
135
|
}
|
|
134
136
|
return { ok: true, value };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
+
import messages from "../editor/i18n/en/system.json";
|
|
2
3
|
---
|
|
3
4
|
<!doctype html>
|
|
4
5
|
<html lang="en">
|
|
@@ -6,7 +7,7 @@
|
|
|
6
7
|
<meta charset="utf-8" />
|
|
7
8
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
9
|
<meta name="robots" content="noindex, nofollow" />
|
|
9
|
-
<title>
|
|
10
|
+
<title>{messages["system.editorTitle"]}</title>
|
|
10
11
|
<style>
|
|
11
12
|
*, *::before, *::after { box-sizing: border-box; }
|
|
12
13
|
html { background: #F8FAFC; color-scheme: light; }
|
package/src/schema/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
2
3
|
import { link, lstat, mkdir, open, readdir, realpath, rename, unlink } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { constants } from "node:fs";
|
|
@@ -41,10 +42,10 @@ async function resolveFolder(root: string, folder: string): Promise<string> {
|
|
|
41
42
|
return directory;
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
async function readJsonFile(file: string): Promise<unknown> {
|
|
45
|
+
async function readJsonFile(file: string, t: Translator): Promise<unknown> {
|
|
45
46
|
const info = await lstat(file);
|
|
46
47
|
if (info.isSymbolicLink() || !info.isFile()) throw new DocumentStoreError("Document must be a regular file", 500);
|
|
47
|
-
if (info.size > MAX_BYTES) throw new DocumentStoreError("
|
|
48
|
+
if (info.size > MAX_BYTES) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
|
|
48
49
|
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
49
50
|
try {
|
|
50
51
|
return JSON.parse(await handle.readFile("utf8"));
|
|
@@ -71,7 +72,7 @@ async function ensureFolder(root: string, folder: string): Promise<string> {
|
|
|
71
72
|
return directory;
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
export async function readDocuments(schema: Schema, root: string, preview?: PreviewConfig): Promise<ContentDocument[]> {
|
|
75
|
+
export async function readDocuments(schema: Schema, root: string, preview?: PreviewConfig, t: Translator = systemMessage): Promise<ContentDocument[]> {
|
|
75
76
|
const documents: ContentDocument[] = [];
|
|
76
77
|
for (const type of schema.documents) {
|
|
77
78
|
let directory: string;
|
|
@@ -85,19 +86,19 @@ export async function readDocuments(schema: Schema, root: string, preview?: Prev
|
|
|
85
86
|
for (const name of names) {
|
|
86
87
|
const slug = name.slice(0, -5);
|
|
87
88
|
if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError(`Invalid document filename ${name}`, 500);
|
|
88
|
-
const data = await readJsonFile(path.join(directory, name));
|
|
89
|
+
const data = await readJsonFile(path.join(directory, name), t);
|
|
89
90
|
if (!isPlainObject(data)) throw new DocumentStoreError(`Document ${name} must be a JSON object`, 500);
|
|
90
91
|
if (data._type !== undefined && data._type !== type.name) throw new DocumentStoreError(`${name}: _type must be ${type.name}`, 500);
|
|
91
92
|
documents.push({ id: `${type.name}:${slug}`, type: type.name, slug, data: data as Record<string, unknown> });
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
|
-
try { validateDocumentSlugs(schema, documents, preview); }
|
|
95
|
+
try { validateDocumentSlugs(schema, documents, preview, t); }
|
|
95
96
|
catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
96
97
|
return documents;
|
|
97
98
|
}
|
|
98
99
|
|
|
99
|
-
export async function readDocument(schema: Schema, root: string, typeName: string, slug: string): Promise<ContentDocument | null> {
|
|
100
|
-
const documents = await readDocuments(schema, root);
|
|
100
|
+
export async function readDocument(schema: Schema, root: string, typeName: string, slug: string, t: Translator = systemMessage): Promise<ContentDocument | null> {
|
|
101
|
+
const documents = await readDocuments(schema, root, undefined, t);
|
|
101
102
|
return documents.find((doc) => doc.type === typeName && doc.slug === slug) ?? null;
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -117,12 +118,13 @@ async function writeDocumentFile(
|
|
|
117
118
|
mode: "create" | "update",
|
|
118
119
|
preview?: PreviewConfig,
|
|
119
120
|
jsonOutput?: JsonOutput,
|
|
121
|
+
t: Translator = systemMessage,
|
|
120
122
|
): Promise<void> {
|
|
121
123
|
const type = schema.documents.find((entry) => entry.name === typeName);
|
|
122
|
-
if (!type) throw new DocumentStoreError(
|
|
123
|
-
if (mode === "create" && (type.creatable === false || type.singleton)) throw new DocumentStoreError("
|
|
124
|
-
if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError("
|
|
125
|
-
if (!isPlainObject(data)) throw new DocumentStoreError("
|
|
124
|
+
if (!type) throw new DocumentStoreError(t("local.unknownDocumentType", { type: typeName }), 404);
|
|
125
|
+
if (mode === "create" && (type.creatable === false || type.singleton)) throw new DocumentStoreError(t("local.creatingDisabled"), 403);
|
|
126
|
+
if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError(t("local.invalidDocumentSlug"), 400);
|
|
127
|
+
if (!isPlainObject(data)) throw new DocumentStoreError(t("local.documentObjectRequired"), 400);
|
|
126
128
|
const directory = await ensureFolder(root, type.folder);
|
|
127
129
|
const file = path.join(directory, `${slug}.json`);
|
|
128
130
|
const existing = await lstat(file).catch((error: NodeJS.ErrnoException) => {
|
|
@@ -130,17 +132,17 @@ async function writeDocumentFile(
|
|
|
130
132
|
throw error;
|
|
131
133
|
});
|
|
132
134
|
if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new DocumentStoreError("Document path is unsafe", 409);
|
|
133
|
-
if (mode === "create" && existing) throw new DocumentStoreError("
|
|
134
|
-
if (mode === "update" && !existing) throw new DocumentStoreError("
|
|
135
|
-
if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError("
|
|
135
|
+
if (mode === "create" && existing) throw new DocumentStoreError(t("local.documentExists"), 409);
|
|
136
|
+
if (mode === "update" && !existing) throw new DocumentStoreError(t("local.documentNotFound"), 404);
|
|
137
|
+
if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
|
|
136
138
|
data = { ...data };
|
|
137
139
|
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);
|
|
140
|
+
if (!type.shared && Object.hasOwn(data, "slug")) data.slug = normalizeUrlSlug(data.slug, t);
|
|
141
|
+
const documents = await readDocuments(schema, root, preview, t);
|
|
142
|
+
validateDocumentSlugs(schema, [...documents.filter(doc => doc.id !== `${typeName}:${slug}`), { id: `${typeName}:${slug}`, type: typeName, slug, data }], preview, t);
|
|
141
143
|
} catch (error) { throw new DocumentStoreError((error as Error).message); }
|
|
142
144
|
const serialized = serializeContentJson(data, jsonOutput);
|
|
143
|
-
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("
|
|
145
|
+
if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
|
|
144
146
|
const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
|
|
145
147
|
try {
|
|
146
148
|
const handle = await open(temporary, "wx", 0o600);
|
|
@@ -154,8 +156,8 @@ async function writeDocumentFile(
|
|
|
154
156
|
else await rename(temporary, file);
|
|
155
157
|
} catch (error) {
|
|
156
158
|
if (error instanceof DocumentStoreError) throw error;
|
|
157
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new DocumentStoreError("
|
|
158
|
-
throw new DocumentStoreError("
|
|
159
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new DocumentStoreError(t("local.documentExists"), 409);
|
|
160
|
+
throw new DocumentStoreError(t("local.writeDocumentFailed"), 500);
|
|
159
161
|
} finally {
|
|
160
162
|
await unlink(temporary).catch(() => {});
|
|
161
163
|
}
|
package/src/schema/types.ts
CHANGED
package/src/schema/validate.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ScalarFieldType,
|
|
12
12
|
type Schema,
|
|
13
13
|
} from "./types";
|
|
14
|
+
import { systemMessage, type Parameters, type Translator } from "../editor/i18n/format";
|
|
14
15
|
|
|
15
16
|
const SCALARS: ScalarFieldType[] = ["string", "text", "markdown", "boolean", "number", "date", "image", "url", "color", "code", "password"];
|
|
16
17
|
const UNSAFE = new Set(["__proto__", "constructor", "prototype"]);
|
|
@@ -24,19 +25,19 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
|
|
|
24
25
|
return proto === Object.prototype || proto === null;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function fail(path: string, message:
|
|
28
|
-
throw new SchemaError(
|
|
28
|
+
function fail(path: string, key: string, message: Translator = systemMessage, parameters: Parameters = {}): never {
|
|
29
|
+
throw new SchemaError(message(key, { path, ...parameters }));
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export function objectDef(schema: Schema, name: string): ObjectDef {
|
|
32
|
+
export function objectDef(schema: Schema, name: string, message: Translator = systemMessage): ObjectDef {
|
|
32
33
|
const def = schema.objects.find((entry) => entry.name === name);
|
|
33
|
-
if (!def) fail(name, "
|
|
34
|
+
if (!def) fail(name, "validation.schema.unknownObject", message);
|
|
34
35
|
return def;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
export function documentDef(schema: Schema, name: string): DocumentDef {
|
|
38
|
+
export function documentDef(schema: Schema, name: string, message: Translator = systemMessage): DocumentDef {
|
|
38
39
|
const def = schema.documents.find((entry) => entry.name === name);
|
|
39
|
-
if (!def) fail(name, "
|
|
40
|
+
if (!def) fail(name, "validation.schema.unknownDocument", message);
|
|
40
41
|
return def;
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -135,21 +136,21 @@ function scalarOk(type: ScalarFieldType, value: unknown): boolean {
|
|
|
135
136
|
return true;
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
function requireValue(field: FieldDef, value: unknown, path: string) {
|
|
139
|
-
if (value === undefined || value === null) fail(path, "required");
|
|
140
|
-
if (typeof value === "string" && ["string", "text", "markdown", "image", "url"].includes(field.type) && !value.trim()) fail(path, "required");
|
|
141
|
-
if (field.required && Array.isArray(value) && !value.length) fail(path, "required");
|
|
139
|
+
function requireValue(field: FieldDef, value: unknown, path: string, message: Translator) {
|
|
140
|
+
if (value === undefined || value === null) fail(path, "validation.schema.required", message);
|
|
141
|
+
if (typeof value === "string" && ["string", "text", "markdown", "image", "url"].includes(field.type) && !value.trim()) fail(path, "validation.schema.required", message);
|
|
142
|
+
if (field.required && Array.isArray(value) && !value.length) fail(path, "validation.schema.required", message);
|
|
142
143
|
}
|
|
143
144
|
|
|
144
|
-
function checkKeys(value: unknown, path: string) {
|
|
145
|
+
function checkKeys(value: unknown, path: string, message: Translator) {
|
|
145
146
|
if (Array.isArray(value)) {
|
|
146
|
-
value.forEach((item, index) => checkKeys(item, `${path}[${index}]
|
|
147
|
+
value.forEach((item, index) => checkKeys(item, `${path}[${index}]`, message));
|
|
147
148
|
return;
|
|
148
149
|
}
|
|
149
150
|
if (!isPlainObject(value)) return;
|
|
150
151
|
for (const [key, child] of Object.entries(value)) {
|
|
151
|
-
if (UNSAFE.has(key)) fail(path,
|
|
152
|
-
checkKeys(child, `${path}.${key}
|
|
152
|
+
if (UNSAFE.has(key)) fail(path, "validation.schema.unsafeKey", message, { key });
|
|
153
|
+
checkKeys(child, `${path}.${key}`, message);
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
@@ -159,74 +160,75 @@ function validateObjectFields(
|
|
|
159
160
|
value: Record<string, unknown>,
|
|
160
161
|
path: string,
|
|
161
162
|
documents: ContentDocument[],
|
|
163
|
+
message: Translator,
|
|
162
164
|
) {
|
|
163
165
|
for (const field of fields) {
|
|
164
|
-
if (field.required) requireValue(field, value[field.key], `${path}.${field.key}
|
|
166
|
+
if (field.required) requireValue(field, value[field.key], `${path}.${field.key}`, message);
|
|
165
167
|
if (value[field.key] === undefined) continue;
|
|
166
|
-
validateFieldValue(schema, field, value[field.key], `${path}.${field.key}`, documents);
|
|
168
|
+
validateFieldValue(schema, field, value[field.key], `${path}.${field.key}`, documents, message);
|
|
167
169
|
}
|
|
168
170
|
for (const key of Object.keys(value)) {
|
|
169
|
-
if (UNSAFE.has(key)) fail(path,
|
|
171
|
+
if (UNSAFE.has(key)) fail(path, "validation.schema.unsafeKey", message, { key });
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|
|
173
|
-
function validateFieldValue(schema: Schema, field: FieldDef, value: unknown, path: string, documents: ContentDocument[]) {
|
|
175
|
+
function validateFieldValue(schema: Schema, field: FieldDef, value: unknown, path: string, documents: ContentDocument[], message: Translator) {
|
|
174
176
|
if (field.type === "object") {
|
|
175
|
-
if (!isPlainObject(value)) fail(path, "
|
|
176
|
-
checkKeys(value, path);
|
|
177
|
-
const def = field.of ? objectDef(schema, field.of) : undefined;
|
|
178
|
-
validateObjectFields(schema, def?.fields ?? field.fields ?? [], value, path, documents);
|
|
177
|
+
if (!isPlainObject(value)) fail(path, "validation.schema.object", message);
|
|
178
|
+
checkKeys(value, path, message);
|
|
179
|
+
const def = field.of ? objectDef(schema, field.of, message) : undefined;
|
|
180
|
+
validateObjectFields(schema, def?.fields ?? field.fields ?? [], value, path, documents, message);
|
|
179
181
|
return;
|
|
180
182
|
}
|
|
181
183
|
if (field.type === "array") {
|
|
182
|
-
if (!Array.isArray(value)) fail(path, "
|
|
183
|
-
if (field.min !== undefined && value.length < field.min) fail(path,
|
|
184
|
-
if (field.max !== undefined && value.length > field.max) fail(path,
|
|
185
|
-
checkKeys(value, path);
|
|
184
|
+
if (!Array.isArray(value)) fail(path, "validation.schema.array", message);
|
|
185
|
+
if (field.min !== undefined && value.length < field.min) fail(path, "validation.schema.minItems", message, { min: field.min });
|
|
186
|
+
if (field.max !== undefined && value.length > field.max) fail(path, "validation.schema.maxItems", message, { max: field.max });
|
|
187
|
+
checkKeys(value, path, message);
|
|
186
188
|
const keys = new Set<string>();
|
|
187
189
|
value.forEach((item, index) => {
|
|
188
190
|
const itemPath = `${path}[${index}]`;
|
|
189
|
-
const def = matchItem(schema, field.items!, item, itemPath);
|
|
191
|
+
const def = matchItem(schema, field.items!, item, itemPath, message);
|
|
190
192
|
if (def.type === "reference") {
|
|
191
|
-
if (!isPlainObject(item) || typeof item._ref !== "string") fail(itemPath, "
|
|
192
|
-
validateReference(schema, item, def.to ?? [], itemPath, documents);
|
|
193
|
+
if (!isPlainObject(item) || typeof item._ref !== "string") fail(itemPath, "validation.schema.reference", message);
|
|
194
|
+
validateReference(schema, item, def.to ?? [], itemPath, documents, message);
|
|
193
195
|
} else if (def.type === "object") {
|
|
194
|
-
if (!isPlainObject(item)) fail(itemPath, "
|
|
195
|
-
if (def.name && item._type !== def.name) fail(itemPath,
|
|
196
|
-
checkKeys(item, itemPath);
|
|
197
|
-
validateObjectFields(schema, def.fields ?? objectDef(schema, def.name
|
|
196
|
+
if (!isPlainObject(item)) fail(itemPath, "validation.schema.object", message);
|
|
197
|
+
if (def.name && item._type !== def.name) fail(itemPath, "validation.schema.section", message, { name: def.name });
|
|
198
|
+
checkKeys(item, itemPath, message);
|
|
199
|
+
validateObjectFields(schema, def.fields ?? objectDef(schema, def.name!, message).fields, item, itemPath, documents, message);
|
|
198
200
|
} else if (!scalarOk(def.type as ScalarFieldType, item)) {
|
|
199
|
-
fail(itemPath,
|
|
201
|
+
fail(itemPath, "validation.schema.scalar", message, { type: def.type });
|
|
200
202
|
}
|
|
201
203
|
if (def.type === "object" || def.type === "reference") {
|
|
202
204
|
const key = item && typeof item === "object" ? (item as Record<string, unknown>)._key : undefined;
|
|
203
|
-
if (typeof key !== "string" || !KEY_PATTERN.test(key)) fail(itemPath, "
|
|
204
|
-
if (keys.has(key as string)) fail(itemPath,
|
|
205
|
+
if (typeof key !== "string" || !KEY_PATTERN.test(key)) fail(itemPath, "validation.schema.stableKey", message);
|
|
206
|
+
if (keys.has(key as string)) fail(itemPath, "validation.schema.duplicateKey", message, { key });
|
|
205
207
|
keys.add(key as string);
|
|
206
208
|
}
|
|
207
209
|
});
|
|
208
210
|
return;
|
|
209
211
|
}
|
|
210
212
|
if (field.type === "reference") {
|
|
211
|
-
if (!isPlainObject(value)) fail(path, "
|
|
212
|
-
validateReference(schema, value, field.to ?? [], path, documents);
|
|
213
|
+
if (!isPlainObject(value)) fail(path, "validation.schema.reference", message);
|
|
214
|
+
validateReference(schema, value, field.to ?? [], path, documents, message);
|
|
213
215
|
return;
|
|
214
216
|
}
|
|
215
217
|
if ((field.type === "url" || field.type === "image") && value === "" && !field.required) return;
|
|
216
|
-
if (!scalarOk(field.type as ScalarFieldType, value)) fail(path,
|
|
218
|
+
if (!scalarOk(field.type as ScalarFieldType, value)) fail(path, "validation.schema.scalar", message, { type: field.type });
|
|
217
219
|
}
|
|
218
220
|
|
|
219
221
|
type ItemDef = { type: ScalarFieldType | "object" | "reference"; name?: string; fields?: FieldDef[]; to?: string[] };
|
|
220
222
|
|
|
221
|
-
function matchItem(schema: Schema, items: ArrayItem[], item: unknown, path: string): ItemDef {
|
|
223
|
+
function matchItem(schema: Schema, items: ArrayItem[], item: unknown, path: string, message: Translator): ItemDef {
|
|
222
224
|
if (isPlainObject(item) && typeof item._type === "string") {
|
|
223
225
|
const named = items.find((entry) => entry.type === "object" && entry.name === item._type);
|
|
224
|
-
if (named && named.type === "object") return { type: "object", name: named.name, fields: named.name ? objectDef(schema, named.name).fields : named.fields };
|
|
225
|
-
fail(path,
|
|
226
|
+
if (named && named.type === "object") return { type: "object", name: named.name, fields: named.name ? objectDef(schema, named.name, message).fields : named.fields };
|
|
227
|
+
fail(path, "validation.schema.unknownSection", message, { type: item._type });
|
|
226
228
|
}
|
|
227
229
|
if (items.length === 1) {
|
|
228
230
|
const only = items[0];
|
|
229
|
-
if (only.type === "object") return { type: "object", name: only.name, fields: only.name ? objectDef(schema, only.name).fields : only.fields };
|
|
231
|
+
if (only.type === "object") return { type: "object", name: only.name, fields: only.name ? objectDef(schema, only.name, message).fields : only.fields };
|
|
230
232
|
if (only.type === "reference") return { type: "reference", to: only.to };
|
|
231
233
|
return only;
|
|
232
234
|
}
|
|
@@ -236,22 +238,22 @@ function matchItem(schema: Schema, items: ArrayItem[], item: unknown, path: stri
|
|
|
236
238
|
if (isPlainObject(item) && inline && inline.type === "object") return { type: "object", fields: inline.fields };
|
|
237
239
|
const scalar = items.find((entry) => entry.type !== "object" && entry.type !== "reference" && scalarOk(entry.type, item));
|
|
238
240
|
if (scalar) return scalar;
|
|
239
|
-
fail(path, "
|
|
241
|
+
fail(path, "validation.schema.arrayItem", message);
|
|
240
242
|
}
|
|
241
243
|
|
|
242
|
-
function validateReference(schema: Schema, value: Record<string, unknown>, to: string[], path: string, documents: ContentDocument[]) {
|
|
244
|
+
function validateReference(schema: Schema, value: Record<string, unknown>, to: string[], path: string, documents: ContentDocument[], message: Translator) {
|
|
243
245
|
const ref = value._ref;
|
|
244
|
-
if (typeof ref !== "string" || !/^[a-z][a-zA-Z0-9-]*:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(ref)) fail(path, "
|
|
245
|
-
if (!to.some((name) => ref.startsWith(`${name}:`))) fail(path,
|
|
246
|
-
for (const key of Object.keys(value)) if (key !== "_ref" && !key.startsWith("_")) fail(path,
|
|
247
|
-
if (!documents.some((doc) => doc.id === ref)) fail(path,
|
|
248
|
-
if (!schema.documents.some((doc) => ref.startsWith(`${doc.name}:`))) fail(path,
|
|
246
|
+
if (typeof ref !== "string" || !/^[a-z][a-zA-Z0-9-]*:[a-z0-9]+(?:-[a-z0-9]+)*$/.test(ref)) fail(path, "validation.schema.referenceId", message);
|
|
247
|
+
if (!to.some((name) => ref.startsWith(`${name}:`))) fail(path, "validation.schema.referenceTarget", message, { targets: to.reduceRight((alternatives, target, index) => index === to.length - 1 ? target : message("validation.schema.referenceAlternatives", { target, alternatives }), "") });
|
|
248
|
+
for (const key of Object.keys(value)) if (key !== "_ref" && !key.startsWith("_")) fail(path, "validation.schema.referenceProperty", message, { key });
|
|
249
|
+
if (!documents.some((doc) => doc.id === ref)) fail(path, "validation.schema.unresolvedTarget", message, { ref });
|
|
250
|
+
if (!schema.documents.some((doc) => ref.startsWith(`${doc.name}:`))) fail(path, "validation.schema.undeclaredTarget", message);
|
|
249
251
|
}
|
|
250
252
|
|
|
251
|
-
export function documentIndex(documents: ContentDocument[]): { byId: Map<string, ContentDocument>; list: ContentDocument[] } {
|
|
253
|
+
export function documentIndex(documents: ContentDocument[], message: Translator = systemMessage): { byId: Map<string, ContentDocument>; list: ContentDocument[] } {
|
|
252
254
|
const byId = new Map<string, ContentDocument>();
|
|
253
255
|
for (const doc of documents) {
|
|
254
|
-
if (byId.has(doc.id)) throw new SchemaError(
|
|
256
|
+
if (byId.has(doc.id)) throw new SchemaError(message("validation.schema.duplicateDocument", { id: doc.id }));
|
|
255
257
|
byId.set(doc.id, doc);
|
|
256
258
|
}
|
|
257
259
|
return { byId, list: documents };
|
|
@@ -263,15 +265,16 @@ export function validateDocument(
|
|
|
263
265
|
slug: string,
|
|
264
266
|
data: Record<string, unknown>,
|
|
265
267
|
documents: ContentDocument[],
|
|
268
|
+
message: Translator = systemMessage,
|
|
266
269
|
): Record<string, unknown> {
|
|
267
|
-
const def = documentDef(schema, typeName);
|
|
268
|
-
if (!SLUG_PATTERN.test(slug)) fail(slug, "slug
|
|
269
|
-
if (!isPlainObject(data)) fail(slug, "
|
|
270
|
-
if (data._type !== undefined && data._type !== typeName) fail(slug,
|
|
271
|
-
if (data._id !== undefined && data._id !== `${typeName}:${slug}`) fail(slug,
|
|
272
|
-
checkKeys(data, slug);
|
|
273
|
-
validateObjectFields(schema, def.fields, data, slug, documents);
|
|
274
|
-
validateReferencesDeep(schema, def.fields, data, `${typeName}:${slug}`, `${typeName}:${slug}`, documents);
|
|
270
|
+
const def = documentDef(schema, typeName, message);
|
|
271
|
+
if (!SLUG_PATTERN.test(slug)) fail(slug, "validation.schema.slug", message);
|
|
272
|
+
if (!isPlainObject(data)) fail(slug, "validation.schema.documentObject", message);
|
|
273
|
+
if (data._type !== undefined && data._type !== typeName) fail(slug, "validation.schema.documentType", message, { type: typeName });
|
|
274
|
+
if (data._id !== undefined && data._id !== `${typeName}:${slug}`) fail(slug, "validation.schema.documentId", message, { id: `${typeName}:${slug}` });
|
|
275
|
+
checkKeys(data, slug, message);
|
|
276
|
+
validateObjectFields(schema, def.fields, data, slug, documents, message);
|
|
277
|
+
validateReferencesDeep(schema, def.fields, data, `${typeName}:${slug}`, `${typeName}:${slug}`, documents, message);
|
|
275
278
|
return data;
|
|
276
279
|
}
|
|
277
280
|
|
|
@@ -282,32 +285,33 @@ function validateReferencesDeep(
|
|
|
282
285
|
path: string,
|
|
283
286
|
self: string,
|
|
284
287
|
documents: ContentDocument[],
|
|
288
|
+
message: Translator,
|
|
285
289
|
) {
|
|
286
290
|
for (const field of fields) {
|
|
287
291
|
const current = value[field.key];
|
|
288
292
|
if (current === undefined) continue;
|
|
289
293
|
if (field.type === "reference") {
|
|
290
294
|
const ref = (current as Record<string, unknown>)._ref;
|
|
291
|
-
if (ref === self) fail(path, "
|
|
295
|
+
if (ref === self) fail(path, "validation.schema.selfReference", message);
|
|
292
296
|
const target = documents.find((doc) => doc.id === ref);
|
|
293
|
-
if (!target) fail(`${path}.${field.key}`,
|
|
297
|
+
if (!target) fail(`${path}.${field.key}`, "validation.schema.unresolvedReference", message, { ref: String(ref) });
|
|
294
298
|
const def = schema.documents.find((doc) => doc.name === target.type);
|
|
295
|
-
if (!field.to?.includes(target.type) || !def) fail(`${path}.${field.key}`,
|
|
299
|
+
if (!field.to?.includes(target.type) || !def) fail(`${path}.${field.key}`, "validation.schema.disallowedReference", message, { ref: String(ref) });
|
|
296
300
|
} else if (field.type === "object" && isPlainObject(current)) {
|
|
297
|
-
const objectFields = field.of ? objectDef(schema, field.of).fields : field.fields ?? [];
|
|
298
|
-
validateReferencesDeep(schema, objectFields, current, `${path}.${field.key}`, self, documents);
|
|
301
|
+
const objectFields = field.of ? objectDef(schema, field.of, message).fields : field.fields ?? [];
|
|
302
|
+
validateReferencesDeep(schema, objectFields, current, `${path}.${field.key}`, self, documents, message);
|
|
299
303
|
} else if (field.type === "array" && Array.isArray(current)) {
|
|
300
304
|
current.forEach((item, index) => {
|
|
301
305
|
if (!isPlainObject(item)) return;
|
|
302
306
|
for (const entry of field.items ?? []) {
|
|
303
307
|
if (entry.type === "reference" && typeof item._ref === "string") {
|
|
304
308
|
const target = documents.find((doc) => doc.id === item._ref);
|
|
305
|
-
if (!target) fail(`${path}.${field.key}[${index}]`,
|
|
306
|
-
if (!entry.to.includes(target.type)) fail(`${path}.${field.key}[${index}]`,
|
|
309
|
+
if (!target) fail(`${path}.${field.key}[${index}]`, "validation.schema.unresolvedReference", message, { ref: String(item._ref) });
|
|
310
|
+
if (!entry.to.includes(target.type)) fail(`${path}.${field.key}[${index}]`, "validation.schema.disallowedReference", message, { ref: String(item._ref) });
|
|
307
311
|
}
|
|
308
312
|
if (entry.type === "object") {
|
|
309
|
-
const objectFields = entry.name ? objectDef(schema, entry.name).fields : entry.fields ?? [];
|
|
310
|
-
validateReferencesDeep(schema, objectFields, item, `${path}.${field.key}[${index}]`, self, documents);
|
|
313
|
+
const objectFields = entry.name ? objectDef(schema, entry.name, message).fields : entry.fields ?? [];
|
|
314
|
+
validateReferencesDeep(schema, objectFields, item, `${path}.${field.key}[${index}]`, self, documents, message);
|
|
311
315
|
}
|
|
312
316
|
}
|
|
313
317
|
});
|
package/src/slugs.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentDocument, Schema } from "./schema/types";
|
|
2
2
|
import type { PreviewConfig } from "./preview";
|
|
3
|
+
import { systemMessage, type Translator } from "./editor/i18n/format";
|
|
3
4
|
|
|
4
5
|
export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
|
|
5
6
|
const base = input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
@@ -10,37 +11,37 @@ export function slugify(input: string, existingSlugs: Iterable<string> = []): st
|
|
|
10
11
|
return slug;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
export function normalizeUrlSlug(value: unknown): string {
|
|
14
|
-
if (typeof value !== "string" || !slugify(value)) throw new Error("
|
|
14
|
+
export function normalizeUrlSlug(value: unknown, message: Translator = systemMessage): string {
|
|
15
|
+
if (typeof value !== "string" || !slugify(value)) throw new Error(message("validation.slug.required"));
|
|
15
16
|
return slugify(value);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export function effectiveSlug(values: Record<string, unknown>, storageSlug: string): string {
|
|
19
|
+
export function effectiveSlug(values: Record<string, unknown>, storageSlug: string, message: Translator = systemMessage): string {
|
|
19
20
|
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("
|
|
21
|
+
if (typeof values.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(values.slug)) throw new Error(message("validation.slug.invalid"));
|
|
21
22
|
return values.slug;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function assertUniqueUrlSlugs(entries: { slug: string; values: Record<string, unknown> }[]): void {
|
|
25
|
+
export function assertUniqueUrlSlugs(entries: { slug: string; values: Record<string, unknown> }[], message: Translator = systemMessage): void {
|
|
25
26
|
const seen = new Set<string>();
|
|
26
27
|
for (const entry of entries) {
|
|
27
|
-
const slug = effectiveSlug(entry.values, entry.slug);
|
|
28
|
-
if (seen.has(slug)) throw new Error(
|
|
28
|
+
const slug = effectiveSlug(entry.values, entry.slug, message);
|
|
29
|
+
if (seen.has(slug)) throw new Error(message("validation.slug.duplicate", { slug }));
|
|
29
30
|
seen.add(slug);
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
export function validateDocumentSlugs(schema: Schema, documents: ContentDocument[], preview?: PreviewConfig): void {
|
|
34
|
+
export function validateDocumentSlugs(schema: Schema, documents: ContentDocument[], preview?: PreviewConfig, message: Translator = systemMessage): void {
|
|
34
35
|
const seen = new Set<string>();
|
|
35
36
|
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
|
+
const fixedRoutes = Object.entries(preview?.pages ?? {}).filter(([key]) => !pages.some(doc => doc.slug === key)).map(([, route]) => typeof route === "string" ? route : route.url);
|
|
37
38
|
for (const doc of documents) {
|
|
38
39
|
const type = schema.documents.find(type => type.name === doc.type);
|
|
39
40
|
if (!type || type.shared) continue;
|
|
40
|
-
const slug = effectiveSlug(doc.data, doc.slug);
|
|
41
|
+
const slug = effectiveSlug(doc.data, doc.slug, message);
|
|
41
42
|
const mapping = preview?.documents?.[doc.type];
|
|
42
43
|
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(
|
|
44
|
+
if (seen.has(route) || fixedRoutes.includes(route)) throw new Error(message("validation.slug.duplicate", { slug }));
|
|
44
45
|
seen.add(route);
|
|
45
46
|
}
|
|
46
47
|
}
|