@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.
Files changed (158) hide show
  1. package/README.md +19 -7
  2. package/docs/extensions.md +194 -0
  3. package/package.json +16 -9
  4. package/src/article-body.ts +49 -37
  5. package/src/article-render.ts +25 -2
  6. package/src/article.ts +44 -0
  7. package/src/config.ts +18 -3
  8. package/src/editor/App.svelte +99 -98
  9. package/src/editor/blocks/extension.ts +29 -0
  10. package/src/editor/blocks/index.ts +17 -15
  11. package/src/editor/blocks/video.ts +2 -1
  12. package/src/editor/i18n/README.md +23 -0
  13. package/src/editor/i18n/de/connection.json +136 -0
  14. package/src/editor/i18n/de/defaults.json +6 -0
  15. package/src/editor/i18n/de/fields.json +130 -0
  16. package/src/editor/i18n/de/local.json +49 -0
  17. package/src/editor/i18n/de/media.json +166 -0
  18. package/src/editor/i18n/de/richtext.json +116 -0
  19. package/src/editor/i18n/de/shell.json +181 -0
  20. package/src/editor/i18n/de/system.json +14 -0
  21. package/src/editor/i18n/de/validation.json +82 -0
  22. package/src/editor/i18n/en/connection.json +136 -0
  23. package/src/editor/i18n/en/defaults.json +6 -0
  24. package/src/editor/i18n/en/fields.json +130 -0
  25. package/src/editor/i18n/en/local.json +49 -0
  26. package/src/editor/i18n/en/media.json +166 -0
  27. package/src/editor/i18n/en/richtext.json +116 -0
  28. package/src/editor/i18n/en/shell.json +181 -0
  29. package/src/editor/i18n/en/system.json +14 -0
  30. package/src/editor/i18n/en/validation.json +82 -0
  31. package/src/editor/i18n/es/connection.json +136 -0
  32. package/src/editor/i18n/es/defaults.json +6 -0
  33. package/src/editor/i18n/es/fields.json +130 -0
  34. package/src/editor/i18n/es/local.json +49 -0
  35. package/src/editor/i18n/es/media.json +166 -0
  36. package/src/editor/i18n/es/richtext.json +116 -0
  37. package/src/editor/i18n/es/shell.json +181 -0
  38. package/src/editor/i18n/es/system.json +14 -0
  39. package/src/editor/i18n/es/validation.json +82 -0
  40. package/src/editor/i18n/format.ts +26 -0
  41. package/src/editor/i18n/fr/connection.json +136 -0
  42. package/src/editor/i18n/fr/defaults.json +6 -0
  43. package/src/editor/i18n/fr/fields.json +130 -0
  44. package/src/editor/i18n/fr/local.json +49 -0
  45. package/src/editor/i18n/fr/media.json +166 -0
  46. package/src/editor/i18n/fr/richtext.json +116 -0
  47. package/src/editor/i18n/fr/shell.json +181 -0
  48. package/src/editor/i18n/fr/system.json +14 -0
  49. package/src/editor/i18n/fr/validation.json +82 -0
  50. package/src/editor/i18n/index.ts +18 -0
  51. package/src/editor/i18n/it/connection.json +136 -0
  52. package/src/editor/i18n/it/defaults.json +6 -0
  53. package/src/editor/i18n/it/fields.json +130 -0
  54. package/src/editor/i18n/it/local.json +49 -0
  55. package/src/editor/i18n/it/media.json +166 -0
  56. package/src/editor/i18n/it/richtext.json +116 -0
  57. package/src/editor/i18n/it/shell.json +181 -0
  58. package/src/editor/i18n/it/system.json +14 -0
  59. package/src/editor/i18n/it/validation.json +82 -0
  60. package/src/editor/i18n/ja/connection.json +136 -0
  61. package/src/editor/i18n/ja/defaults.json +6 -0
  62. package/src/editor/i18n/ja/fields.json +130 -0
  63. package/src/editor/i18n/ja/local.json +49 -0
  64. package/src/editor/i18n/ja/media.json +166 -0
  65. package/src/editor/i18n/ja/richtext.json +116 -0
  66. package/src/editor/i18n/ja/shell.json +181 -0
  67. package/src/editor/i18n/ja/system.json +14 -0
  68. package/src/editor/i18n/ja/validation.json +82 -0
  69. package/src/editor/i18n/locales.ts +13 -0
  70. package/src/editor/i18n/nl/connection.json +136 -0
  71. package/src/editor/i18n/nl/defaults.json +6 -0
  72. package/src/editor/i18n/nl/fields.json +130 -0
  73. package/src/editor/i18n/nl/local.json +49 -0
  74. package/src/editor/i18n/nl/media.json +166 -0
  75. package/src/editor/i18n/nl/richtext.json +116 -0
  76. package/src/editor/i18n/nl/shell.json +181 -0
  77. package/src/editor/i18n/nl/system.json +14 -0
  78. package/src/editor/i18n/nl/validation.json +82 -0
  79. package/src/editor/i18n/ru/connection.json +138 -0
  80. package/src/editor/i18n/ru/defaults.json +6 -0
  81. package/src/editor/i18n/ru/fields.json +132 -0
  82. package/src/editor/i18n/ru/local.json +49 -0
  83. package/src/editor/i18n/ru/media.json +166 -0
  84. package/src/editor/i18n/ru/richtext.json +116 -0
  85. package/src/editor/i18n/ru/shell.json +181 -0
  86. package/src/editor/i18n/ru/system.json +14 -0
  87. package/src/editor/i18n/ru/validation.json +82 -0
  88. package/src/editor/i18n/server.ts +13 -0
  89. package/src/editor/i18n/zh/connection.json +136 -0
  90. package/src/editor/i18n/zh/defaults.json +6 -0
  91. package/src/editor/i18n/zh/fields.json +130 -0
  92. package/src/editor/i18n/zh/local.json +49 -0
  93. package/src/editor/i18n/zh/media.json +166 -0
  94. package/src/editor/i18n/zh/richtext.json +116 -0
  95. package/src/editor/i18n/zh/shell.json +181 -0
  96. package/src/editor/i18n/zh/system.json +14 -0
  97. package/src/editor/i18n/zh/validation.json +82 -0
  98. package/src/editor/lib/ActionDialog.svelte +5 -4
  99. package/src/editor/lib/BlockEditor.svelte +305 -88
  100. package/src/editor/lib/CollectionPanel.svelte +17 -15
  101. package/src/editor/lib/DatePicker.svelte +15 -12
  102. package/src/editor/lib/ExtensionMount.svelte +12 -0
  103. package/src/editor/lib/Field.svelte +3 -2
  104. package/src/editor/lib/ImagePicker.svelte +82 -81
  105. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  106. package/src/editor/lib/MediaPicker.svelte +6 -5
  107. package/src/editor/lib/MenuEditor.svelte +31 -30
  108. package/src/editor/lib/PreviewFrame.svelte +11 -6
  109. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  110. package/src/editor/lib/PublishDialog.svelte +15 -14
  111. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  112. package/src/editor/lib/SchemaField.svelte +15 -13
  113. package/src/editor/lib/SchemaPanel.svelte +20 -19
  114. package/src/editor/lib/Select.svelte +3 -2
  115. package/src/editor/lib/SeoGuidance.svelte +6 -5
  116. package/src/editor/lib/Settings.svelte +54 -54
  117. package/src/editor/lib/Shortcuts.svelte +7 -6
  118. package/src/editor/lib/SitePreview.svelte +10 -9
  119. package/src/editor/lib/TopBar.svelte +18 -17
  120. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  121. package/src/editor/lib/VisualEditor.svelte +14 -13
  122. package/src/editor/lib/calendar.ts +10 -4
  123. package/src/editor/lib/embed-preview.ts +23 -5
  124. package/src/editor/lib/extension-preview.ts +121 -0
  125. package/src/editor/lib/github-auth.ts +23 -22
  126. package/src/editor/lib/github-backend.ts +96 -91
  127. package/src/editor/lib/icons.ts +4 -0
  128. package/src/editor/lib/images.ts +33 -31
  129. package/src/editor/lib/menu-tree.ts +5 -4
  130. package/src/editor/lib/preview-frame.ts +4 -3
  131. package/src/editor/lib/schema-edit.ts +3 -2
  132. package/src/editor/lib/settings.ts +5 -3
  133. package/src/editor/lib/slash-command.ts +1 -0
  134. package/src/editor/lib/tooltip.ts +1 -1
  135. package/src/editor/main.ts +9 -0
  136. package/src/editor/styles.css +6 -3
  137. package/src/env.d.ts +11 -1
  138. package/src/extensions.ts +160 -0
  139. package/src/manifest/collections.ts +6 -5
  140. package/src/manifest/menu.ts +12 -10
  141. package/src/manifest/navigation.ts +9 -8
  142. package/src/manifest/references.ts +8 -7
  143. package/src/manifest/seo.ts +4 -3
  144. package/src/manifest/types.ts +1 -0
  145. package/src/preview-urls.ts +6 -0
  146. package/src/preview.ts +38 -7
  147. package/src/routes/local-api.ts +5 -3
  148. package/src/routes/local-documents.ts +20 -16
  149. package/src/routes/local-media.ts +21 -16
  150. package/src/routes/local-menu.ts +9 -7
  151. package/src/routes/local-post.ts +18 -12
  152. package/src/routes/media-files.ts +10 -8
  153. package/src/routes/rigel-page.astro +2 -1
  154. package/src/schema/store.ts +22 -20
  155. package/src/schema/types.ts +1 -0
  156. package/src/schema/validate.ts +73 -69
  157. package/src/slugs.ts +12 -11
  158. package/src/vite/manifest-plugin.ts +33 -1
@@ -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("Unknown collection/slug", { status: 404 });
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("Invalid article");
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 : "Invalid article", { status: 400 });
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("A post with that slug already exists. Reload the collection and create the draft again.", { status: 409 });
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("Unknown collection/slug", { status: 404 });
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("No such post", { status: 404 });
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: "Invalid request body" };
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: "Unknown metadata field" };
114
- if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: "Unknown asset" };
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: "Invalid display name" };
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: "Title must be a string" };
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: "Description must be a string" };
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: "Alt text must be a string" };
131
- if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: "Alt text is only available for images" };
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>Rigel — Content Editor</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; }
@@ -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("Document exceeds the 1 MiB limit", 413);
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(`Unknown document type ${typeName}`, 404);
123
- if (mode === "create" && (type.creatable === false || type.singleton)) throw new DocumentStoreError("Creating entries is disabled for this document type", 403);
124
- if (!SLUG_PATTERN.test(slug)) throw new DocumentStoreError("Invalid document slug", 400);
125
- if (!isPlainObject(data)) throw new DocumentStoreError("Document must be an object", 400);
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("A document with that slug already exists", 409);
134
- if (mode === "update" && !existing) throw new DocumentStoreError("No such document", 404);
135
- if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
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("Document exceeds the 1 MiB limit", 413);
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("A document with that slug already exists", 409);
158
- throw new DocumentStoreError("Could not write document", 500);
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
  }
@@ -8,6 +8,7 @@ export type ArrayItem =
8
8
  export interface FieldDef {
9
9
  key: string;
10
10
  label: string;
11
+ labelKey?: string;
11
12
  type: ScalarFieldType | "object" | "array" | "reference";
12
13
  placeholder?: string;
13
14
  required?: boolean;
@@ -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: string): never {
28
- throw new SchemaError(`${path}: ${message}`);
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, "unknown object type");
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, "unknown document type");
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, `unsafe key ${key}`);
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, `unsafe key ${key}`);
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, "must be an object");
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, "must be an array");
183
- if (field.min !== undefined && value.length < field.min) fail(path, `needs at least ${field.min} items`);
184
- if (field.max !== undefined && value.length > field.max) fail(path, `allows at most ${field.max} items`);
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, "must be a reference");
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, "must be an object");
195
- if (def.name && item._type !== def.name) fail(itemPath, `must be a ${def.name} section`);
196
- checkKeys(item, itemPath);
197
- validateObjectFields(schema, def.fields ?? objectDef(schema, def.name!).fields, item, itemPath, documents);
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, `must be a ${def.type}`);
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, "requires a stable _key");
204
- if (keys.has(key as string)) fail(itemPath, `duplicate _key ${key}`);
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, "must be a reference");
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, `must be a ${field.type}`);
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, `unknown section type ${item._type}`);
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, "does not match an allowed array item type");
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, "invalid reference id");
245
- if (!to.some((name) => ref.startsWith(`${name}:`))) fail(path, `reference must target ${to.join(" or ")}`);
246
- for (const key of Object.keys(value)) if (key !== "_ref" && !key.startsWith("_")) fail(path, `unknown reference property ${key}`);
247
- if (!documents.some((doc) => doc.id === ref)) fail(path, `reference target ${ref} does not resolve`);
248
- if (!schema.documents.some((doc) => ref.startsWith(`${doc.name}:`))) fail(path, `reference target type is not declared`);
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(`Duplicate document id: ${doc.id}`);
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 must be lowercase letters, numbers and single dashes");
269
- if (!isPlainObject(data)) fail(slug, "document must be an object");
270
- if (data._type !== undefined && data._type !== typeName) fail(slug, `_type must be ${typeName}`);
271
- if (data._id !== undefined && data._id !== `${typeName}:${slug}`) fail(slug, `_id must be ${typeName}:${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, "a document cannot reference itself");
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}`, `reference ${ref} does not resolve`);
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}`, `reference ${ref} is not an allowed target`);
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}]`, `reference ${item._ref} does not resolve`);
306
- if (!entry.to.includes(target.type)) fail(`${path}.${field.key}[${index}]`, `reference ${item._ref} is not an allowed target`);
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("URL slug must contain letters or numbers.");
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("Invalid URL slug; use lowercase letters, numbers and single dashes.");
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(`URL slug "${slug}" is already used.`);
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(`URL slug "${slug}" is already used.`);
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
  }