@rigel-cms/rigel 1.0.0 → 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 (156) hide show
  1. package/README.md +4 -0
  2. package/docs/extensions.md +194 -0
  3. package/package.json +15 -3
  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 +12 -2
  8. package/src/editor/App.svelte +98 -97
  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/images.ts +33 -31
  128. package/src/editor/lib/menu-tree.ts +5 -4
  129. package/src/editor/lib/preview-frame.ts +4 -3
  130. package/src/editor/lib/schema-edit.ts +3 -2
  131. package/src/editor/lib/settings.ts +5 -3
  132. package/src/editor/lib/slash-command.ts +1 -0
  133. package/src/editor/lib/tooltip.ts +1 -1
  134. package/src/editor/main.ts +9 -0
  135. package/src/editor/styles.css +6 -3
  136. package/src/env.d.ts +11 -1
  137. package/src/extensions.ts +160 -0
  138. package/src/manifest/collections.ts +6 -5
  139. package/src/manifest/menu.ts +12 -10
  140. package/src/manifest/navigation.ts +9 -8
  141. package/src/manifest/references.ts +8 -7
  142. package/src/manifest/seo.ts +4 -3
  143. package/src/manifest/types.ts +1 -0
  144. package/src/preview.ts +22 -2
  145. package/src/routes/local-api.ts +5 -3
  146. package/src/routes/local-documents.ts +20 -16
  147. package/src/routes/local-media.ts +21 -16
  148. package/src/routes/local-menu.ts +9 -7
  149. package/src/routes/local-post.ts +18 -12
  150. package/src/routes/media-files.ts +10 -8
  151. package/src/routes/rigel-page.astro +2 -1
  152. package/src/schema/store.ts +22 -20
  153. package/src/schema/types.ts +1 -0
  154. package/src/schema/validate.ts +73 -69
  155. package/src/slugs.ts +11 -10
  156. package/src/vite/manifest-plugin.ts +33 -1
@@ -1,4 +1,5 @@
1
1
  import type { Manifest } from "./types";
2
+ import { systemMessage, type Translator } from "../editor/i18n/format";
2
3
 
3
4
  export type Selection =
4
5
  | { kind: "media" }
@@ -27,24 +28,24 @@ export function navigationKey(target: Selection): string {
27
28
  return JSON.stringify([target.kind, "category" in target ? target.category : null, "name" in target ? target.name : null, "slug" in target ? target.slug : null, "type" in target ? target.type : null, "id" in target ? target.id : null, "collection" in target ? target.collection : null]);
28
29
  }
29
30
 
30
- export function buildSidebar(manifest: Manifest): SidebarNode[] {
31
+ export function buildSidebar(manifest: Manifest, message: Translator = systemMessage): SidebarNode[] {
31
32
  const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
32
33
  const types = manifest.schema?.documents ?? [];
33
34
  const pageTypes = types.filter(type => !type.shared);
34
35
  const pages = manifest.pages.filter(page => !page.shared);
35
36
  const categories = [...new Set(pages.map(page => page.category).filter((category): category is string => !!category))].sort();
36
37
  const defaults: SidebarNode[] = [
37
- { kind: "heading", label: "Website" },
38
- link({ kind: "pages" }, "Pages"),
38
+ { kind: "heading", label: message("system.sidebar.website") },
39
+ link({ kind: "pages" }, message("system.sidebar.pages")),
39
40
  ...(manifest.collections ?? []).map(collection => link({ kind: "collection", name: collection.name }, collection.label)),
40
41
  ...categories.map(category => link({ kind: "pages", category }, category)),
41
42
  ...pageTypes.slice(1).map(type => link({ kind: "documents", type: type.name }, type.title)),
42
- { kind: "heading", label: "Shared content" },
43
+ { kind: "heading", label: message("system.sidebar.shared") },
43
44
  ...types.filter(type => type.shared && !type.singleton).map(type => link({ kind: "documents", type: type.name }, type.title)),
44
45
  ...manifest.pages.filter(page => page.shared).map(page => link({ kind: "page", slug: page.slug }, page.label)),
45
- ...(manifest.menus ? [link({ kind: "menus" }, "Menus")] : []),
46
- { kind: "heading", label: "Assets" },
47
- link({ kind: "media" }, "Media"),
46
+ ...(manifest.menus ? [link({ kind: "menus" }, message("system.sidebar.menus"))] : []),
47
+ { kind: "heading", label: message("system.sidebar.assets") },
48
+ link({ kind: "media" }, message("system.sidebar.media")),
48
49
  ...(types.some(type => type.shared && type.singleton) ? [{ kind: "separator" } as SidebarNode] : []),
49
50
  ...types.filter(type => type.shared && type.singleton).map(type => {
50
51
  const doc = manifest.documents?.find(doc => doc.type === type.name);
@@ -70,7 +71,7 @@ export function buildSidebar(manifest: Manifest): SidebarNode[] {
70
71
  nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
71
72
  }
72
73
  const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)));
73
- if (remaining.length) nodes.push({ kind: "heading", label: "More content" }, ...remaining);
74
+ if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
74
75
  return nodes;
75
76
  }
76
77
 
@@ -1,5 +1,6 @@
1
1
  import type { CollectionField } from "./types";
2
2
  import type { ContentDocument } from "../schema/types";
3
+ import { systemMessage, type Translator } from "../editor/i18n/format";
3
4
 
4
5
  export interface ReferenceOption {
5
6
  value: string;
@@ -19,21 +20,21 @@ export function referenceOptions(field: CollectionField, documents: ContentDocum
19
20
  }));
20
21
  }
21
22
 
22
- export function referenceLabel(value: unknown, options: ReferenceOption[]): string {
23
- return (Array.isArray(value) ? value : [value]).filter(Boolean).map(id => options.find(option => option.value === id)?.label ?? `Unavailable (${String(id)})`).join(", ");
23
+ export function referenceLabel(value: unknown, options: ReferenceOption[], message: Translator = systemMessage): string {
24
+ return (Array.isArray(value) ? value : [value]).filter(Boolean).map(id => options.find(option => option.value === id)?.label ?? message("system.reference.unavailable", { id: String(id) })).join(", ");
24
25
  }
25
26
 
26
- export function collectionPreviewValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): Record<string, unknown> {
27
- return { ...values, ...Object.fromEntries(fields.filter(field => field.type === "reference").map(field => [field.key, referenceLabel(values[field.key], referenceOptions(field, documents))])) };
27
+ export function collectionPreviewValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[], message: Translator = systemMessage): Record<string, unknown> {
28
+ return { ...values, ...Object.fromEntries(fields.filter(field => field.type === "reference").map(field => [field.key, referenceLabel(values[field.key], referenceOptions(field, documents), message)])) };
28
29
  }
29
30
 
30
- export function validateCollectionReferences(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): void {
31
+ export function validateCollectionReferences(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[], message: Translator = systemMessage): void {
31
32
  for (const field of fields.filter(field => field.type === "reference")) {
32
33
  const value = values[field.key];
33
34
  if (value === undefined) continue;
34
- if (field.multiple ? !Array.isArray(value) : typeof value !== "string") throw new Error(`${field.label}: expected ${field.multiple ? "an array of references" : "one reference"}`);
35
+ if (field.multiple ? !Array.isArray(value) : typeof value !== "string") throw new Error(message(field.multiple ? "system.reference.expectedMany" : "system.reference.expectedOne", { label: field.label }));
35
36
  const selected = field.multiple ? value as unknown[] : value === "" ? [] : [value];
36
37
  const options = referenceOptions(field, documents);
37
- if (new Set(selected).size !== selected.length || selected.some(id => typeof id !== "string" || !options.some(option => option.value === id))) throw new Error(`${field.label}: select existing entries only`);
38
+ if (new Set(selected).size !== selected.length || selected.some(id => typeof id !== "string" || !options.some(option => option.value === id))) throw new Error(message("system.reference.existingOnly", { label: field.label }));
38
39
  }
39
40
  }
@@ -1,9 +1,10 @@
1
1
  import type { CollectionField } from "./types";
2
+ import { systemMessage } from "../editor/i18n/format";
2
3
 
3
4
  export const SEO_FIELDS = [
4
- { key: "metaTitle", label: "SEO title", type: "string" },
5
- { key: "description", label: "SEO description", type: "text" },
6
- { key: "ogImage", label: "Social image", type: "image" },
5
+ { key: "metaTitle", label: systemMessage("defaults.seoTitle"), labelKey: "defaults.seoTitle", type: "string" },
6
+ { key: "description", label: systemMessage("defaults.seoDescription"), labelKey: "defaults.seoDescription", type: "text" },
7
+ { key: "ogImage", label: systemMessage("defaults.socialImage"), labelKey: "defaults.socialImage", type: "image" },
7
8
  ] satisfies CollectionField[];
8
9
 
9
10
  export function isSeoField(key: string): boolean {
@@ -50,6 +50,7 @@ export interface Manifest {
50
50
  export interface CollectionField {
51
51
  key: string;
52
52
  label: string;
53
+ labelKey?: string;
53
54
  type: "string" | "text" | "boolean" | "date" | "tags" | "markdown" | "image" | "reference";
54
55
  to?: string;
55
56
  multiple?: boolean;
package/src/preview.ts CHANGED
@@ -4,6 +4,7 @@ import { pageRoute } from "./schema/paths";
4
4
  import { slugify } from "./slugs";
5
5
  import { renderArticle } from "./article-render";
6
6
  import { createEmbedPreview } from "./editor/lib/embed-preview";
7
+ import { mountExtensionPreviews } from "./editor/lib/extension-preview";
7
8
  import { safePreviewUrl, previewBaseUrl, previewRoute } from "./preview-urls";
8
9
  export { safePreviewUrl } from "./preview-urls";
9
10
 
@@ -75,6 +76,14 @@ export function anchorValue(values: unknown, path: string): { found: boolean; va
75
76
 
76
77
  const markdown = new MarkdownIt({ html: false, linkify: false });
77
78
  const rendered = new WeakMap<Element, string>();
79
+ const extensionPreviews = new WeakMap<Document, Map<Element, () => void>>();
80
+
81
+ export function cleanupPreview(doc: Document) {
82
+ const previews = extensionPreviews.get(doc);
83
+ if (!previews) return;
84
+ for (const [element, cleanup] of previews) { cleanup(); rendered.delete(element); }
85
+ previews.clear();
86
+ }
78
87
 
79
88
  export interface PatchDocuments {
80
89
  editingBody?: Element | null;
@@ -91,6 +100,13 @@ function rebindTree(root: Element | DocumentFragment, update: (element: Element)
91
100
  }
92
101
 
93
102
  export function patchPreview(doc: Document, values: Record<string, unknown>, body: "" | import("./article-body").ArticleBody, options: PatchDocuments = {}) {
103
+ let previews = extensionPreviews.get(doc);
104
+ if (!previews) {
105
+ previews = new Map();
106
+ extensionPreviews.set(doc, previews);
107
+ doc.defaultView?.addEventListener("pagehide", () => cleanupPreview(doc), { once: true });
108
+ }
109
+ for (const [element, cleanup] of previews) if (!element.isConnected) { cleanup(); previews.delete(element); rendered.delete(element); }
94
110
  const sourceFor = (element: Element): Record<string, unknown> | undefined => {
95
111
  const id = element.getAttribute("data-rigel-doc");
96
112
  if (id === "") return {};
@@ -169,11 +185,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
169
185
  const blocks = el.dataset.rigelRichtext === "$body";
170
186
  const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
171
187
  if (rendered.get(el) === source) continue;
172
- rendered.set(el, source);
173
188
  const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody, { preview: true }) : "") : markdown.render(source), {
174
189
  RETURN_DOM_FRAGMENT: true,
175
190
  ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td", ...(blocks ? ["video"] : [])],
176
- ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope", ...(blocks ? ["controls", "muted", "preload", "playsinline", "poster", "class"] : [])],
191
+ ALLOW_DATA_ATTR: false,
192
+ ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope", ...(blocks ? ["controls", "muted", "preload", "playsinline", "poster", "class", "data-rigel-embed", "data-rigel-video-id", "data-rigel-video-title", "data-rigel-extension", "data-rigel-extension-data"] : [])],
177
193
  });
178
194
  if (blocks) for (const marker of fragment.querySelectorAll<HTMLElement>("[data-rigel-embed]")) {
179
195
  marker.replaceWith(createEmbedPreview({ provider: marker.dataset.rigelEmbed, id: marker.dataset.rigelVideoId, title: marker.dataset.rigelVideoTitle }, doc));
@@ -181,7 +197,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
181
197
  for (const link of fragment.querySelectorAll("[href], [src]")) {
182
198
  for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
183
199
  }
200
+ previews.get(el)?.();
201
+ previews.delete(el);
184
202
  el.replaceChildren(fragment);
203
+ if (blocks) previews.set(el, mountExtensionPreviews(el));
204
+ rendered.set(el, source);
185
205
  }
186
206
  for (const [anchor, attr, tag] of [["image", "src", "IMG"], ["alt", "alt", "IMG"], ["href", "href", "A"]]) {
187
207
  for (const el of doc.querySelectorAll(`[data-rigel-${anchor}]`)) {
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { requestTranslator } from "../editor/i18n/server";
2
3
  import { writeFile, readFile } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { applyValues } from "./serialize";
@@ -6,6 +7,7 @@ import { applyValues } from "./serialize";
6
7
  export const prerender = false;
7
8
 
8
9
  export const POST: APIRoute = async ({ request }) => {
10
+ const t = requestTranslator(request);
9
11
  if (import.meta.env.PROD) {
10
12
  return new Response("Local backend is dev-only", { status: 404 });
11
13
  }
@@ -18,21 +20,21 @@ export const POST: APIRoute = async ({ request }) => {
18
20
  }
19
21
  const slug = String(body.slug ?? "");
20
22
  const values = body.values ?? {};
21
- if (!slug) return new Response("Missing slug", { status: 400 });
23
+ if (!slug) return new Response(t("local.missingSlug"), { status: 400 });
22
24
 
23
25
  const file = path.resolve("src/content/copy", `${slug}.ts`);
24
26
  let source: string;
25
27
  try {
26
28
  source = await readFile(file, "utf8");
27
29
  } catch {
28
- return new Response(`No copy module for ${slug}`, { status: 404 });
30
+ return new Response(t("local.copyNotFound", { slug }), { status: 404 });
29
31
  }
30
32
 
31
33
  let next: string;
32
34
  try {
33
35
  next = applyValues(source, values);
34
36
  } catch {
35
- return new Response(`Could not locate values block in ${slug}.ts`, {
37
+ return new Response(t("local.copyValuesNotFound", { slug }), {
36
38
  status: 422,
37
39
  });
38
40
  }
@@ -1,4 +1,6 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { requestTranslator } from "../editor/i18n/server";
3
+ import type { Translator } from "../editor/i18n/format";
2
4
  import path from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import config from "virtual:rigel-config";
@@ -16,18 +18,18 @@ function guard(): { schema: Schema; root: string } | Response {
16
18
  return { schema: config.schema as Schema, root: fileURLToPath(config.root) };
17
19
  }
18
20
 
19
- async function readBody(request: Request): Promise<Record<string, unknown>> {
21
+ async function readBody(request: Request, t: Translator): Promise<Record<string, unknown>> {
20
22
  const length = Number(request.headers.get("content-length") ?? "0");
21
- if (length > MAX_BODY) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
23
+ if (length > MAX_BODY) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
22
24
  const raw = await request.text();
23
- if (Buffer.byteLength(raw) > MAX_BODY) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
25
+ if (Buffer.byteLength(raw) > MAX_BODY) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
24
26
  let body: unknown;
25
27
  try {
26
28
  body = JSON.parse(raw);
27
29
  } catch {
28
30
  throw new DocumentStoreError("Invalid JSON", 400);
29
31
  }
30
- if (!isPlainObject(body) || !isPlainObject(body.data)) throw new DocumentStoreError("Body requires a data object", 400);
32
+ if (!isPlainObject(body) || !isPlainObject(body.data)) throw new DocumentStoreError(t("local.dataRequired"), 400);
31
33
  return body.data;
32
34
  }
33
35
 
@@ -39,42 +41,44 @@ function decodeParam(value: string): string {
39
41
  }
40
42
  }
41
43
 
42
- export const GET: APIRoute = async ({ params }) => {
44
+ export const GET: APIRoute = async ({ request, params }) => {
45
+ const t = requestTranslator(request);
43
46
  const guarded = guard();
44
47
  if (guarded instanceof Response) return guarded;
45
48
  const id = decodeParam(String(params.id ?? ""));
46
49
  const target = validateDocumentId(guarded.schema, id);
47
- if (!target) return new Response("Unknown document", { status: 404 });
50
+ if (!target) return new Response(t("local.unknownDocument"), { status: 404 });
48
51
  try {
49
- const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
52
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview, t);
50
53
  const document = documents.find((entry) => entry.id === id);
51
- if (!document) return new Response("Unknown document", { status: 404 });
54
+ if (!document) return new Response(t("local.unknownDocument"), { status: 404 });
52
55
  return Response.json(document);
53
56
  } catch (error) {
54
57
  if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
55
- return new Response("Could not read document", { status: 500 });
58
+ return new Response(t("local.readDocumentFailed"), { status: 500 });
56
59
  }
57
60
  };
58
61
 
59
62
  const handle: APIRoute = async ({ request, params }) => {
63
+ const t = requestTranslator(request);
60
64
  const guarded = guard();
61
65
  if (guarded instanceof Response) return guarded;
62
- if (request.method === "DELETE") return new Response("Documents are created and removed by the site developer", { status: 405, headers: { Allow: "GET, POST, PUT" } });
66
+ if (request.method === "DELETE") return new Response(t("local.documentDeveloperOnly"), { status: 405, headers: { Allow: "GET, POST, PUT" } });
63
67
  if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
64
68
  const id = decodeParam(String(params.id ?? ""));
65
69
  const target = validateDocumentId(guarded.schema, id);
66
- if (!target) return new Response("Unknown document type or slug", { status: 400 });
70
+ if (!target) return new Response(t("local.unknownDocumentTarget"), { status: 400 });
67
71
  try {
68
- const data = await readBody(request);
69
- const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
72
+ const data = await readBody(request, t);
73
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview, t);
70
74
  const next = { ...data, _id: id, _type: target.type.name };
71
- validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
72
- await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview, config.jsonOutput);
75
+ validateDocument(guarded.schema, target.type.name, target.slug, next, documents, t);
76
+ await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview, config.jsonOutput, t);
73
77
  return new Response(null, { status: 204 });
74
78
  } catch (error) {
75
79
  if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
76
80
  if (error instanceof SchemaError) return new Response(error.message, { status: 400 });
77
- return new Response("Could not save document", { status: 500 });
81
+ return new Response(t("local.saveDocumentFailed"), { status: 500 });
78
82
  }
79
83
  };
80
84
 
@@ -1,4 +1,5 @@
1
1
  import type { APIRoute } from "astro";
2
+ import { requestTranslator } from "../editor/i18n/server";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { lstat, readFile, writeFile } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -11,9 +12,10 @@ import config from "virtual:rigel-config";
11
12
  export const prerender = false;
12
13
 
13
14
  export const GET: APIRoute = async ({ params, request }) => {
15
+ const t = requestTranslator(request);
14
16
  const filename = params.filename ?? "";
15
17
  if (import.meta.env.PROD || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
16
- return new Response("Not found", { status: 404 });
18
+ return new Response(t("local.notFound"), { status: 404 });
17
19
  }
18
20
  try {
19
21
  const file = path.join(await mediaDirectory(config.publicDir!, config.mediaDir), filename);
@@ -21,11 +23,12 @@ export const GET: APIRoute = async ({ params, request }) => {
21
23
  const bytes = await readFile(file);
22
24
  return new Response(new Uint8Array(bytes), { headers: mediaHeaders(filename, new URL(request.url).searchParams.get("preview") === "1") });
23
25
  } catch {
24
- return new Response("Not found", { status: 404 });
26
+ return new Response(t("local.notFound"), { status: 404 });
25
27
  }
26
28
  };
27
29
 
28
30
  export const POST: APIRoute = async ({ request }) => {
31
+ const t = requestTranslator(request);
29
32
  if (import.meta.env.PROD) {
30
33
  return new Response("Local backend is dev-only", { status: 404 });
31
34
  }
@@ -36,16 +39,16 @@ export const POST: APIRoute = async ({ request }) => {
36
39
  const supplied = request.headers.get("X-Rigel-Filename");
37
40
  const fallback = ({ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" } as Record<string, string>)[mime ?? ""];
38
41
  const metadata = supplied ? (safeMediaName(supplied) ? mediaMetadata(supplied) : undefined) : fallback ? mediaMetadata(`image.${fallback}`) : undefined;
39
- if (!metadata || metadata.type !== mime) return new Response("Unsupported or mismatched media type/filename", { status: 415 });
42
+ if (!metadata || metadata.type !== mime) return new Response(t("local.unsupportedMedia"), { status: 415 });
40
43
  const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
41
44
  const length = request.headers.get("Content-Length");
42
45
  if (length !== null && !/^\d+$/.test(length)) {
43
46
  return new Response("Invalid Content-Length", { status: 400 });
44
47
  }
45
48
  if (length !== null && Number(length) > limit) {
46
- return new Response(`Media exceeds ${limit / 1024 / 1024} MB`, { status: 413 });
49
+ return new Response(t("local.mediaTooLarge", { limit: limit / 1024 / 1024 }), { status: 413 });
47
50
  }
48
- if (!request.body) return new Response("Empty media body", { status: 400 });
51
+ if (!request.body) return new Response(t("local.emptyMedia"), { status: 400 });
49
52
  const chunks: Uint8Array[] = [];
50
53
  let size = 0;
51
54
  const reader = request.body.getReader();
@@ -56,19 +59,19 @@ export const POST: APIRoute = async ({ request }) => {
56
59
  size += value.byteLength;
57
60
  if (size > limit) {
58
61
  void reader.cancel().catch(() => {});
59
- return new Response(`Media exceeds ${limit / 1024 / 1024} MB`, { status: 413 });
62
+ return new Response(t("local.mediaTooLarge", { limit: limit / 1024 / 1024 }), { status: 413 });
60
63
  }
61
64
  chunks.push(value);
62
65
  }
63
66
  } catch {
64
- return new Response("Could not read image body", { status: 400 });
67
+ return new Response(t("local.readImageFailed"), { status: 400 });
65
68
  } finally {
66
69
  reader.releaseLock();
67
70
  }
68
71
  const bytes = Buffer.concat(chunks, size);
69
- if (!bytes.length) return new Response("Empty image body", { status: 400 });
72
+ if (!bytes.length) return new Response(t("local.emptyImage"), { status: 400 });
70
73
  const extension = metadata.extension === "jpeg" ? "jpg" : metadata.extension;
71
- if (!validMediaBytes(bytes, extension)) return new Response("Media type does not match its bytes", { status: 415 });
74
+ if (!validMediaBytes(bytes, extension)) return new Response(t("local.mediaBytesMismatch"), { status: 415 });
72
75
  const filename = `${randomUUID()}.${extension}`;
73
76
  let url = "";
74
77
  try {
@@ -80,12 +83,13 @@ export const POST: APIRoute = async ({ request }) => {
80
83
  const author = (request.headers.get("X-Rigel-Author") ?? "").trim().replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80);
81
84
  await updateMediaSidecar(directory, url, author ? { author } : {}, { name: (supplied ?? filename).slice(0, 120) });
82
85
  } catch {
83
- return new Response("Could not save image", { status: 500 });
86
+ return new Response(t("local.saveImageFailed"), { status: 500 });
84
87
  }
85
88
  return Response.json({ url });
86
89
  };
87
90
 
88
91
  export const PUT: APIRoute = async ({ request }) => {
92
+ const t = requestTranslator(request);
89
93
  if (import.meta.env.PROD) {
90
94
  return new Response("Local backend is dev-only", { status: 404 });
91
95
  }
@@ -96,9 +100,9 @@ export const POST: APIRoute = async ({ request }) => {
96
100
  try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
97
101
  let directory: string;
98
102
  try { directory = await mediaDirectory(config.publicDir!, config.mediaDir, true); }
99
- catch { return new Response("Media directory unavailable", { status: 500 }); }
103
+ catch { return new Response(t("local.mediaDirectoryUnavailable"), { status: 500 }); }
100
104
  const assets = await listMedia(config.publicDir!, config.mediaDir);
101
- const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)));
105
+ const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)), t);
102
106
  if (!valid.ok) return new Response(valid.error, { status: 400 });
103
107
  const patch: { name?: string; title?: string; description?: string; alt?: string } = {};
104
108
  if (valid.value.name !== undefined) patch.name = valid.value.name;
@@ -109,21 +113,22 @@ export const POST: APIRoute = async ({ request }) => {
109
113
  const file = path.join(fileURLToPath(config.publicDir!), ...valid.value.url.slice(1).split("/").map(decodeURIComponent));
110
114
  await updateMediaSidecar(directory, valid.value.url, patch, {}, file);
111
115
  } catch (error) {
112
- return new Response("Could not save metadata", { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500 });
116
+ return new Response(t("local.saveMetadataFailed"), { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500 });
113
117
  }
114
118
  return Response.json((await listMedia(config.publicDir!, config.mediaDir)).find(asset => asset.url === valid.value.url) ?? { url: valid.value.url });
115
119
  };
116
120
 
117
121
  export const DELETE: APIRoute = async ({ request }) => {
122
+ const t = requestTranslator(request);
118
123
  if (import.meta.env.PROD) return new Response("Local backend is dev-only", { status: 404 });
119
124
  if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
120
125
  let body: unknown;
121
126
  try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
122
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("url" in body) || typeof body.url !== "string") return new Response("A media URL is required", { status: 400 });
127
+ if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("url" in body) || typeof body.url !== "string") return new Response(t("local.mediaUrlRequired"), { status: 400 });
123
128
  try {
124
- if (!await deleteMediaFile(config.publicDir!, config.mediaDir, body.url)) return new Response("File not found", { status: 404 });
129
+ if (!await deleteMediaFile(config.publicDir!, config.mediaDir, body.url)) return new Response(t("local.fileNotFound"), { status: 404 });
125
130
  return new Response(null, { status: 204 });
126
131
  } catch {
127
- return new Response("Could not delete file. Try again.", { status: 500 });
132
+ return new Response(t("local.deleteFileFailed"), { status: 500 });
128
133
  }
129
134
  };
@@ -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 { constants } from "node:fs";
4
5
  import { lstat, open, realpath, rename, unlink } from "node:fs/promises";
@@ -28,20 +29,21 @@ async function menuFile(slug: string): Promise<string> {
28
29
  }
29
30
 
30
31
  const handle: APIRoute = async ({ request, params }) => {
32
+ const t = requestTranslator(request);
31
33
  if (import.meta.env.PROD || config.backend !== "local" || !config.menus)
32
34
  return new Response("Local menus are not enabled", { status: 404 });
33
35
  const slug = params.slug ?? "";
34
- if (!MENU_SLUG.test(slug)) return new Response("Invalid menu slug", { status: 400 });
36
+ if (!MENU_SLUG.test(slug)) return new Response(t("local.invalidMenuSlug"), { status: 400 });
35
37
  const method = request.method;
36
38
  if (method !== "GET" && method !== "PUT")
37
- return new Response("Menus are declared by the site developer. Only existing menus can be edited.", { status: 405, headers: { Allow: "GET, PUT" } });
39
+ return new Response(t("local.menuDeveloperOnly"), { status: 405, headers: { Allow: "GET, PUT" } });
38
40
  if (method !== "GET" && request.headers.get("Origin") !== new URL(request.url).origin)
39
41
  return new Response("Same-origin request required", { status: 403 });
40
42
  let menu: MenuDocument | undefined;
41
43
  if (method === "PUT") {
42
44
  try {
43
- menu = validateMenu(await request.json(), config.menus.maxDepth ?? 3);
44
- if (menu.slug !== slug) throw new Error("Menu slug must match the request slug");
45
+ menu = validateMenu(await request.json(), config.menus.maxDepth ?? 3, t);
46
+ if (menu.slug !== slug) throw new Error(t("local.menuSlugMismatch"));
45
47
  } catch (error) {
46
48
  return new Response(error instanceof SyntaxError ? "Invalid JSON" : (error as Error).message, { status: 400 });
47
49
  }
@@ -52,7 +54,7 @@ const handle: APIRoute = async ({ request, params }) => {
52
54
  if (method === "GET") {
53
55
  const input = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
54
56
  try {
55
- const result = validateMenu(JSON.parse(await input.readFile("utf8")), config.menus.maxDepth ?? 3);
57
+ const result = validateMenu(JSON.parse(await input.readFile("utf8")), config.menus.maxDepth ?? 3, t);
56
58
  if (result.slug !== slug) throw new Error("Stored menu slug does not match filename");
57
59
  return Response.json(result);
58
60
  } finally { await input.close(); }
@@ -69,8 +71,8 @@ const handle: APIRoute = async ({ request, params }) => {
69
71
  return new Response(null, { status: 204 });
70
72
  } catch (error) {
71
73
  const code = (error as NodeJS.ErrnoException).code;
72
- if (code === "ENOENT") return new Response("No such menu", { status: 404 });
73
- return new Response("Could not access menu: invalid stored data or unsafe/unavailable storage", { status: 500 });
74
+ if (code === "ENOENT") return new Response(t("local.menuNotFound"), { status: 404 });
75
+ return new Response(t("local.menuStorageUnavailable"), { status: 500 });
74
76
  } finally {
75
77
  if (temporary) await unlink(temporary).catch(() => {});
76
78
  }
@@ -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 };