@rigel-cms/rigel 1.0.0 → 1.2.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 (159) hide show
  1. package/README.md +8 -0
  2. package/docs/content-locales.md +53 -0
  3. package/docs/extensions.md +194 -0
  4. package/package.json +16 -3
  5. package/src/article-body.ts +49 -37
  6. package/src/article-render.ts +25 -2
  7. package/src/article.ts +44 -0
  8. package/src/config.ts +26 -2
  9. package/src/editor/App.svelte +149 -107
  10. package/src/editor/blocks/extension.ts +29 -0
  11. package/src/editor/blocks/index.ts +17 -15
  12. package/src/editor/blocks/video.ts +2 -1
  13. package/src/editor/i18n/README.md +23 -0
  14. package/src/editor/i18n/de/connection.json +136 -0
  15. package/src/editor/i18n/de/defaults.json +6 -0
  16. package/src/editor/i18n/de/fields.json +130 -0
  17. package/src/editor/i18n/de/local.json +49 -0
  18. package/src/editor/i18n/de/media.json +166 -0
  19. package/src/editor/i18n/de/richtext.json +116 -0
  20. package/src/editor/i18n/de/shell.json +182 -0
  21. package/src/editor/i18n/de/system.json +14 -0
  22. package/src/editor/i18n/de/validation.json +82 -0
  23. package/src/editor/i18n/en/connection.json +136 -0
  24. package/src/editor/i18n/en/defaults.json +6 -0
  25. package/src/editor/i18n/en/fields.json +130 -0
  26. package/src/editor/i18n/en/local.json +49 -0
  27. package/src/editor/i18n/en/media.json +166 -0
  28. package/src/editor/i18n/en/richtext.json +116 -0
  29. package/src/editor/i18n/en/shell.json +182 -0
  30. package/src/editor/i18n/en/system.json +14 -0
  31. package/src/editor/i18n/en/validation.json +82 -0
  32. package/src/editor/i18n/es/connection.json +136 -0
  33. package/src/editor/i18n/es/defaults.json +6 -0
  34. package/src/editor/i18n/es/fields.json +130 -0
  35. package/src/editor/i18n/es/local.json +49 -0
  36. package/src/editor/i18n/es/media.json +166 -0
  37. package/src/editor/i18n/es/richtext.json +116 -0
  38. package/src/editor/i18n/es/shell.json +182 -0
  39. package/src/editor/i18n/es/system.json +14 -0
  40. package/src/editor/i18n/es/validation.json +82 -0
  41. package/src/editor/i18n/format.ts +26 -0
  42. package/src/editor/i18n/fr/connection.json +136 -0
  43. package/src/editor/i18n/fr/defaults.json +6 -0
  44. package/src/editor/i18n/fr/fields.json +130 -0
  45. package/src/editor/i18n/fr/local.json +49 -0
  46. package/src/editor/i18n/fr/media.json +166 -0
  47. package/src/editor/i18n/fr/richtext.json +116 -0
  48. package/src/editor/i18n/fr/shell.json +182 -0
  49. package/src/editor/i18n/fr/system.json +14 -0
  50. package/src/editor/i18n/fr/validation.json +82 -0
  51. package/src/editor/i18n/index.ts +18 -0
  52. package/src/editor/i18n/it/connection.json +136 -0
  53. package/src/editor/i18n/it/defaults.json +6 -0
  54. package/src/editor/i18n/it/fields.json +130 -0
  55. package/src/editor/i18n/it/local.json +49 -0
  56. package/src/editor/i18n/it/media.json +166 -0
  57. package/src/editor/i18n/it/richtext.json +116 -0
  58. package/src/editor/i18n/it/shell.json +182 -0
  59. package/src/editor/i18n/it/system.json +14 -0
  60. package/src/editor/i18n/it/validation.json +82 -0
  61. package/src/editor/i18n/ja/connection.json +136 -0
  62. package/src/editor/i18n/ja/defaults.json +6 -0
  63. package/src/editor/i18n/ja/fields.json +130 -0
  64. package/src/editor/i18n/ja/local.json +49 -0
  65. package/src/editor/i18n/ja/media.json +166 -0
  66. package/src/editor/i18n/ja/richtext.json +116 -0
  67. package/src/editor/i18n/ja/shell.json +182 -0
  68. package/src/editor/i18n/ja/system.json +14 -0
  69. package/src/editor/i18n/ja/validation.json +82 -0
  70. package/src/editor/i18n/locales.ts +13 -0
  71. package/src/editor/i18n/nl/connection.json +136 -0
  72. package/src/editor/i18n/nl/defaults.json +6 -0
  73. package/src/editor/i18n/nl/fields.json +130 -0
  74. package/src/editor/i18n/nl/local.json +49 -0
  75. package/src/editor/i18n/nl/media.json +166 -0
  76. package/src/editor/i18n/nl/richtext.json +116 -0
  77. package/src/editor/i18n/nl/shell.json +182 -0
  78. package/src/editor/i18n/nl/system.json +14 -0
  79. package/src/editor/i18n/nl/validation.json +82 -0
  80. package/src/editor/i18n/ru/connection.json +138 -0
  81. package/src/editor/i18n/ru/defaults.json +6 -0
  82. package/src/editor/i18n/ru/fields.json +132 -0
  83. package/src/editor/i18n/ru/local.json +49 -0
  84. package/src/editor/i18n/ru/media.json +166 -0
  85. package/src/editor/i18n/ru/richtext.json +116 -0
  86. package/src/editor/i18n/ru/shell.json +182 -0
  87. package/src/editor/i18n/ru/system.json +14 -0
  88. package/src/editor/i18n/ru/validation.json +82 -0
  89. package/src/editor/i18n/server.ts +13 -0
  90. package/src/editor/i18n/zh/connection.json +136 -0
  91. package/src/editor/i18n/zh/defaults.json +6 -0
  92. package/src/editor/i18n/zh/fields.json +130 -0
  93. package/src/editor/i18n/zh/local.json +49 -0
  94. package/src/editor/i18n/zh/media.json +166 -0
  95. package/src/editor/i18n/zh/richtext.json +116 -0
  96. package/src/editor/i18n/zh/shell.json +182 -0
  97. package/src/editor/i18n/zh/system.json +14 -0
  98. package/src/editor/i18n/zh/validation.json +82 -0
  99. package/src/editor/lib/ActionDialog.svelte +5 -4
  100. package/src/editor/lib/BlockEditor.svelte +305 -88
  101. package/src/editor/lib/CollectionPanel.svelte +17 -15
  102. package/src/editor/lib/DatePicker.svelte +15 -12
  103. package/src/editor/lib/ExtensionMount.svelte +12 -0
  104. package/src/editor/lib/Field.svelte +3 -2
  105. package/src/editor/lib/ImagePicker.svelte +82 -81
  106. package/src/editor/lib/MarkdownEditor.svelte +25 -24
  107. package/src/editor/lib/MediaPicker.svelte +6 -5
  108. package/src/editor/lib/MenuEditor.svelte +31 -30
  109. package/src/editor/lib/PreviewFrame.svelte +11 -6
  110. package/src/editor/lib/PropertiesTabs.svelte +3 -2
  111. package/src/editor/lib/PublishDialog.svelte +15 -14
  112. package/src/editor/lib/ReferenceSelect.svelte +3 -2
  113. package/src/editor/lib/SchemaField.svelte +15 -13
  114. package/src/editor/lib/SchemaPanel.svelte +20 -19
  115. package/src/editor/lib/Select.svelte +5 -4
  116. package/src/editor/lib/SeoGuidance.svelte +6 -5
  117. package/src/editor/lib/Settings.svelte +54 -54
  118. package/src/editor/lib/Shortcuts.svelte +7 -6
  119. package/src/editor/lib/SitePreview.svelte +10 -9
  120. package/src/editor/lib/TopBar.svelte +18 -17
  121. package/src/editor/lib/UnsavedChanges.svelte +7 -6
  122. package/src/editor/lib/VisualEditor.svelte +19 -18
  123. package/src/editor/lib/calendar.ts +10 -4
  124. package/src/editor/lib/embed-preview.ts +23 -5
  125. package/src/editor/lib/extension-preview.ts +121 -0
  126. package/src/editor/lib/github-auth.ts +23 -22
  127. package/src/editor/lib/github-backend.ts +96 -91
  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 +15 -3
  137. package/src/env.d.ts +11 -1
  138. package/src/extensions.ts +160 -0
  139. package/src/index.ts +1 -1
  140. package/src/manifest/collections.ts +6 -5
  141. package/src/manifest/content-locales.ts +45 -0
  142. package/src/manifest/menu.ts +12 -10
  143. package/src/manifest/navigation.ts +13 -8
  144. package/src/manifest/references.ts +8 -7
  145. package/src/manifest/seo.ts +4 -3
  146. package/src/manifest/types.ts +4 -0
  147. package/src/preview.ts +36 -2
  148. package/src/routes/local-api.ts +5 -3
  149. package/src/routes/local-documents.ts +20 -16
  150. package/src/routes/local-media.ts +21 -16
  151. package/src/routes/local-menu.ts +9 -7
  152. package/src/routes/local-post.ts +18 -12
  153. package/src/routes/media-files.ts +10 -8
  154. package/src/routes/rigel-page.astro +2 -1
  155. package/src/schema/store.ts +22 -20
  156. package/src/schema/types.ts +2 -0
  157. package/src/schema/validate.ts +73 -69
  158. package/src/slugs.ts +11 -10
  159. package/src/vite/manifest-plugin.ts +38 -2
@@ -1,3 +1,5 @@
1
+ import { systemMessage, type Translator } from "../editor/i18n/format";
2
+
1
3
  export interface MenuItem {
2
4
  id: string;
3
5
  label: string;
@@ -14,31 +16,31 @@ export interface MenuDocument {
14
16
 
15
17
  export const MENU_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
16
18
 
17
- export function validateMenu(value: unknown, maxDepth: number): MenuDocument {
19
+ export function validateMenu(value: unknown, maxDepth: number, message: Translator = systemMessage): MenuDocument {
18
20
  if (!Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 10)
19
- throw new Error("Menu maxDepth must be an integer from 1 to 10");
21
+ throw new Error(message("validation.menu.maxDepth"));
20
22
  const object = (v: unknown): v is Record<string, unknown> =>
21
23
  v !== null && typeof v === "object" && !Array.isArray(v);
22
24
  const text = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0;
23
25
  if (!object(value) || typeof value.slug !== "string" || !MENU_SLUG.test(value.slug) || !text(value.title) || !Array.isArray(value.items))
24
- throw new Error("Menu requires a lowercase alphanumeric-hyphen slug, title, and items array");
26
+ throw new Error(message("validation.menu.required"));
25
27
  const ids = new Set<string>();
26
28
  function items(values: unknown[], depth: number): MenuItem[] {
27
29
  return values.map((item) => {
28
- if (depth > maxDepth) throw new Error(`Menu exceeds maximum depth ${maxDepth}`);
30
+ if (depth > maxDepth) throw new Error(message("validation.menu.depth", { maxDepth }));
29
31
  if (!object(item) || !text(item.id) || !text(item.label) || !text(item.url) || !Array.isArray(item.children))
30
- throw new Error("Menu items require nonempty id, label, url, and children array");
31
- if (ids.has(item.id)) throw new Error(`Duplicate menu item id: ${item.id}`);
32
+ throw new Error(message("validation.menu.itemRequired"));
33
+ if (ids.has(item.id)) throw new Error(message("validation.menu.duplicateId", { id: item.id }));
32
34
  ids.add(item.id);
33
- if (item.newTab !== undefined && typeof item.newTab !== "boolean") throw new Error("Menu item newTab must be a boolean");
35
+ if (item.newTab !== undefined && typeof item.newTab !== "boolean") throw new Error(message("validation.menu.newTab"));
34
36
  const url = item.url;
35
37
  if (url !== url.trim() || /[\u0000-\u0020\u007f\\]/.test(url) || url.startsWith("//"))
36
- throw new Error("Unsafe menu URL");
38
+ throw new Error(message("validation.menu.unsafeUrl"));
37
39
  let parsed: URL;
38
40
  try { parsed = new URL(url, "https://rigel.invalid/"); }
39
- catch { throw new Error("Invalid menu URL"); }
41
+ catch { throw new Error(message("validation.menu.invalidUrl")); }
40
42
  if (!["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol))
41
- throw new Error("Unsafe menu URL protocol");
43
+ throw new Error(message("validation.menu.unsafeProtocol"));
42
44
  return { id: item.id, label: item.label, url, ...(item.newTab === undefined ? {} : { newTab: item.newTab }), children: items(item.children, depth + 1) };
43
45
  });
44
46
  }
@@ -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);
@@ -53,6 +54,10 @@ export function buildSidebar(manifest: Manifest): SidebarNode[] {
53
54
  ];
54
55
  if (!manifest.sidebar) return defaults;
55
56
  const available = new Map(defaults.filter((node): node is SidebarLink => node.kind === "link").map(node => [navigationKey(node.target), node]));
57
+ for (const type of types) {
58
+ const target: SidebarTarget = { kind: "documents", type: type.name };
59
+ available.set(navigationKey(target), link(target, type.title));
60
+ }
56
61
  for (const page of manifest.pages) available.set(navigationKey({ kind: "page", slug: page.slug }), link({ kind: "page", slug: page.slug }, page.label));
57
62
  for (const doc of manifest.documents ?? []) {
58
63
  const target: SidebarTarget = { kind: "document", type: doc.type, id: doc.id };
@@ -70,7 +75,7 @@ export function buildSidebar(manifest: Manifest): SidebarNode[] {
70
75
  nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
71
76
  }
72
77
  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);
78
+ if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
74
79
  return nodes;
75
80
  }
76
81
 
@@ -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 {
@@ -15,6 +15,7 @@ export interface CopyGroup {
15
15
 
16
16
  export interface CopyPage {
17
17
  slug: string;
18
+ locale?: string;
18
19
  label: string;
19
20
  shared?: boolean;
20
21
  category?: string;
@@ -36,6 +37,7 @@ export interface GitHubBackendConfig {
36
37
  }
37
38
 
38
39
  export interface Manifest {
40
+ contentLocales?: import("../config").ContentLocalesConfig;
39
41
  backend?: "github";
40
42
  github?: GitHubBackendConfig;
41
43
  sidebar?: import("./navigation").SidebarEntry[];
@@ -50,6 +52,7 @@ export interface Manifest {
50
52
  export interface CollectionField {
51
53
  key: string;
52
54
  label: string;
55
+ labelKey?: string;
53
56
  type: "string" | "text" | "boolean" | "date" | "tags" | "markdown" | "image" | "reference";
54
57
  to?: string;
55
58
  multiple?: boolean;
@@ -65,6 +68,7 @@ export interface CollectionPost {
65
68
 
66
69
  export interface ManifestCollection {
67
70
  name: string;
71
+ locale?: string;
68
72
  label: string;
69
73
  listTitle?: string;
70
74
  fields: CollectionField[];
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,14 @@ 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
+ if (!doc.documentElement) return;
104
+ let previews = extensionPreviews.get(doc);
105
+ if (!previews) {
106
+ previews = new Map();
107
+ extensionPreviews.set(doc, previews);
108
+ doc.defaultView?.addEventListener("pagehide", () => cleanupPreview(doc), { once: true });
109
+ }
110
+ for (const [element, cleanup] of previews) if (!element.isConnected) { cleanup(); previews.delete(element); rendered.delete(element); }
94
111
  const sourceFor = (element: Element): Record<string, unknown> | undefined => {
95
112
  const id = element.getAttribute("data-rigel-doc");
96
113
  if (id === "") return {};
@@ -156,6 +173,19 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
156
173
  const result = get(element, element.getAttribute("data-rigel-if")!);
157
174
  if (result.found) element.hidden = !result.value;
158
175
  }
176
+ for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-tags]")) {
177
+ if (el.tagName !== "UL" && el.tagName !== "OL") continue;
178
+ const result = get(el, el.dataset.rigelTags!);
179
+ if (!result.found) continue;
180
+ const source = typeof result.value === "string" ? result.value : "";
181
+ if (rendered.get(el) === source) continue;
182
+ el.replaceChildren(...source.split(",").map(tag => tag.trim()).filter(Boolean).map(tag => {
183
+ const item = doc.createElement("li");
184
+ item.textContent = tag;
185
+ return item;
186
+ }));
187
+ rendered.set(el, source);
188
+ }
159
189
  for (const el of doc.querySelectorAll<HTMLElement>("[data-rigel-text]")) {
160
190
  const result = get(el, el.dataset.rigelText!);
161
191
  if (!result.found) continue;
@@ -169,11 +199,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
169
199
  const blocks = el.dataset.rigelRichtext === "$body";
170
200
  const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
171
201
  if (rendered.get(el) === source) continue;
172
- rendered.set(el, source);
173
202
  const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody, { preview: true }) : "") : markdown.render(source), {
174
203
  RETURN_DOM_FRAGMENT: true,
175
204
  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"] : [])],
205
+ ALLOW_DATA_ATTR: false,
206
+ 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
207
  });
178
208
  if (blocks) for (const marker of fragment.querySelectorAll<HTMLElement>("[data-rigel-embed]")) {
179
209
  marker.replaceWith(createEmbedPreview({ provider: marker.dataset.rigelEmbed, id: marker.dataset.rigelVideoId, title: marker.dataset.rigelVideoTitle }, doc));
@@ -181,7 +211,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
181
211
  for (const link of fragment.querySelectorAll("[href], [src]")) {
182
212
  for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
183
213
  }
214
+ previews.get(el)?.();
215
+ previews.delete(el);
184
216
  el.replaceChildren(fragment);
217
+ if (blocks) previews.set(el, mountExtensionPreviews(el));
218
+ rendered.set(el, source);
185
219
  }
186
220
  for (const [anchor, attr, tag] of [["image", "src", "IMG"], ["alt", "alt", "IMG"], ["href", "href", "A"]]) {
187
221
  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
  }