@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,13 +1,15 @@
1
1
  import { getSettings, setSettings } from "./settings";
2
+ import { translate, locale } from "../i18n";
3
+ import { get } from "svelte/store";
2
4
  import { DEFAULT_MAX_FILE_SIZE, mediaMetadata, safeMediaName, storedMediaFile } from "../../media-types";
3
5
  import config from "virtual:rigel-config";
4
6
  export function getKeys(): StockKeys { return getSettings().apiKeys; }
5
7
  export function setKeys(keys: StockKeys) { setSettings({ apiKeys: keys }); }
6
8
 
7
9
  export const STOCK_PROVIDERS = {
8
- pexels: { label: "Pexels", keyURL: "https://www.pexels.com/api/new/", pattern: "[a-zA-Z0-9]{56}", placeholder: "e.g. Ab3dE6fG9hJ2…" },
9
- pixabay: { label: "Pixabay", keyURL: "https://pixabay.com/api/docs/#api_key", pattern: "[0-9]+-[a-f0-9]{25}", placeholder: "e.g. 12345678-abcdef012345…" },
10
- unsplash: { label: "Unsplash", keyURL: "https://unsplash.com/oauth/applications", pattern: "[a-zA-Z0-9_-]{40,}", placeholder: "e.g. AbCdEf12_GhIjKl34…" },
10
+ pexels: { label: "Pexels", keyURL: "https://www.pexels.com/api/new/", pattern: "[a-zA-Z0-9]{56}", get placeholder() { return translate("media.pexelsKeyPlaceholder"); } },
11
+ pixabay: { label: "Pixabay", keyURL: "https://pixabay.com/api/docs/#api_key", pattern: "[0-9]+-[a-f0-9]{25}", get placeholder() { return translate("media.pixabayKeyPlaceholder"); } },
12
+ unsplash: { label: "Unsplash", keyURL: "https://unsplash.com/oauth/applications", pattern: "[a-zA-Z0-9_-]{40,}", get placeholder() { return translate("media.unsplashKeyPlaceholder"); } },
11
13
  } as const;
12
14
 
13
15
  export type StockSource = "picsum" | keyof typeof STOCK_PROVIDERS;
@@ -43,7 +45,7 @@ export async function unsplash(query: string, key: string): Promise<StockResult[
43
45
  query ? `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=24` : "https://api.unsplash.com/photos?per_page=24",
44
46
  { headers: { Authorization: `Client-ID ${key}` } },
45
47
  );
46
- if (!r.ok) throw new Error(`Unsplash ${r.status}`);
48
+ if (!r.ok) throw new Error(translate("media.providerStatus", { provider: "Unsplash", status: r.status }));
47
49
  const data = await r.json();
48
50
  return (query ? data.results ?? [] : data).map((p: Record<string, any>) => ({
49
51
  source: "unsplash",
@@ -60,7 +62,7 @@ export async function pexels(query: string, key: string): Promise<StockResult[]>
60
62
  query ? `https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=24` : "https://api.pexels.com/v1/curated?per_page=24",
61
63
  { headers: { Authorization: key } },
62
64
  );
63
- if (!r.ok) throw new Error(`Pexels ${r.status}`);
65
+ if (!r.ok) throw new Error(translate("media.providerStatus", { provider: "Pexels", status: r.status }));
64
66
  const data = await r.json();
65
67
  return (data.photos ?? []).map((p: Record<string, any>) => ({
66
68
  source: "pexels",
@@ -79,7 +81,7 @@ export async function pixabay(query: string, key: string): Promise<StockResult[]
79
81
  if (query) params.set("q", query);
80
82
  else params.set("editors_choice", "true");
81
83
  const r = await fetch(`https://pixabay.com/api/?${params}`);
82
- if (!r.ok) throw new Error(`Pixabay ${r.status}`);
84
+ if (!r.ok) throw new Error(translate("media.providerStatus", { provider: "Pixabay", status: r.status }));
83
85
  const data = await r.json();
84
86
  return (data.hits ?? []).map((p: Record<string, any>) => ({
85
87
  source: "pixabay",
@@ -101,18 +103,18 @@ export async function searchImages(
101
103
  if (source === "picsum") return picsum(query);
102
104
  const keys = getKeys();
103
105
  if (source === "pixabay") {
104
- if (!keys.pixabay) throw new Error("Add a Pixabay API key in Settings → Media");
106
+ if (!keys.pixabay) throw new Error(translate("media.pixabayKeyRequired"));
105
107
  return pixabay(query, keys.pixabay);
106
108
  }
107
109
  if (source === "unsplash") {
108
- if (!keys.unsplash) throw new Error("Add an Unsplash API key in Settings");
110
+ if (!keys.unsplash) throw new Error(translate("media.unsplashKeyRequired"));
109
111
  return unsplash(query, keys.unsplash);
110
112
  }
111
113
  if (source === "pexels") {
112
- if (!keys.pexels) throw new Error("Add a Pexels API key in Settings → Media");
114
+ if (!keys.pexels) throw new Error(translate("media.pexelsKeyRequired"));
113
115
  return pexels(query, keys.pexels);
114
116
  }
115
- throw new Error("Unknown stock photo provider");
117
+ throw new Error(translate("media.unknownStockProvider"));
116
118
  }
117
119
 
118
120
  export async function importStockImage(image: StockResult): Promise<string> {
@@ -122,33 +124,33 @@ export async function importStockImage(image: StockResult): Promise<string> {
122
124
  pexels: ["images.pexels.com"], pixabay: ["pixabay.com", "cdn.pixabay.com"],
123
125
  };
124
126
  if (url.protocol !== "https:" || url.username || url.password || !hosts[image.source]?.includes(url.hostname)) {
125
- throw new Error("Unexpected stock photo URL");
127
+ throw new Error(translate("media.unexpectedStockUrl"));
126
128
  }
127
129
  if (image.source === "unsplash") {
128
130
  const key = getKeys().unsplash;
129
- if (!key || !image.id) throw new Error("Unsplash photo or API key is missing");
131
+ if (!key || !image.id) throw new Error(translate("media.unsplashPhotoMissing"));
130
132
  const tracked = await fetch(`https://api.unsplash.com/photos/${encodeURIComponent(image.id)}/download`, {
131
133
  headers: { Authorization: "Client-" + "ID " + key },
132
134
  });
133
- if (!tracked.ok) throw new Error(`Unsplash download tracking failed (${tracked.status})`);
135
+ if (!tracked.ok) throw new Error(translate("media.unsplashTrackingFailed", { status: tracked.status }));
134
136
  return image.url;
135
137
  }
136
138
  return importImageUrl(image.url);
137
139
  }
138
140
 
139
141
  export async function importImageUrl(value: string): Promise<string> {
140
- if (!safeImageUrl(value)) throw new Error("Unsafe image URL");
141
- if (!value.startsWith("/") && new URL(value).protocol !== "https:") throw new Error("Remote imports require HTTPS");
142
- if (!value.startsWith("/") && new URL(value).hostname === "images.unsplash.com") throw new Error("Unsplash API images must stay hotlinked. Use Unsplash in the content image picker.");
142
+ if (!safeImageUrl(value)) throw new Error(translate("media.unsafeImageUrl"));
143
+ if (!value.startsWith("/") && new URL(value).protocol !== "https:") throw new Error(translate("media.remoteRequiresHttps"));
144
+ if (!value.startsWith("/") && new URL(value).hostname === "images.unsplash.com") throw new Error(translate("media.unsplashHotlinkRequired"));
143
145
  let response: Response;
144
146
  try { response = await fetch(value, { credentials: "omit", referrerPolicy: "no-referrer" }); }
145
- catch { throw new Error("Image download failed. The source must allow browser downloads (CORS); alternatively upload the image file."); }
146
- if (!response.ok) throw new Error(`Photo download failed (${response.status})`);
147
+ catch { throw new Error(translate("media.imageDownloadFailed")); }
148
+ if (!response.ok) throw new Error(translate("media.photoDownloadFailed", { status: response.status }));
147
149
  const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
148
- if (Number(response.headers.get("Content-Length")) > limit) throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`);
150
+ if (Number(response.headers.get("Content-Length")) > limit) throw new Error(translate("media.photoLimitExceeded", { limit: limit / 1024 / 1024 }));
149
151
  const type = response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase();
150
- if (!["image/jpeg", "image/png", "image/webp"].includes(type ?? "")) throw new Error("Choose a JPEG, PNG or WebP image.");
151
- if (!response.body) throw new Error("Empty image download");
152
+ if (!["image/jpeg", "image/png", "image/webp"].includes(type ?? "")) throw new Error(translate("media.chooseImageFormat"));
153
+ if (!response.body) throw new Error(translate("media.emptyDownload"));
152
154
  const reader = response.body.getReader();
153
155
  const chunks: Uint8Array<ArrayBuffer>[] = [];
154
156
  let size = 0;
@@ -157,7 +159,7 @@ export async function importImageUrl(value: string): Promise<string> {
157
159
  const { done, value: chunk } = await reader.read();
158
160
  if (done) break;
159
161
  size += chunk.byteLength;
160
- if (size > limit) { await reader.cancel(); throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`); }
162
+ if (size > limit) { await reader.cancel(); throw new Error(translate("media.photoLimitExceeded", { limit: limit / 1024 / 1024 })); }
161
163
  chunks.push(new Uint8Array(chunk));
162
164
  }
163
165
  } finally { reader.releaseLock(); }
@@ -185,23 +187,23 @@ export function currentAuthor(): string {
185
187
  export async function uploadMedia(file: File): Promise<string> {
186
188
  const metadata = mediaMetadata(file.name);
187
189
  const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
188
- if (!safeMediaName(file.name) || !metadata || file.size > limit) throw new Error(`Choose a supported file up to ${limit / 1024 / 1024} MB with a safe filename.`);
189
- const response = await fetch("/rigel/api/local/media", { method: "POST", headers: { "Content-Type": metadata.type, "X-Rigel-Filename": file.name, "X-Rigel-Author": currentAuthor() }, body: file });
190
- if (!response.ok) throw new Error(`Upload failed (${response.status}): ${await response.text()}`);
190
+ if (!safeMediaName(file.name) || !metadata || file.size > limit) throw new Error(translate("media.chooseSupportedFile", { limit: limit / 1024 / 1024 }));
191
+ const response = await fetch("/rigel/api/local/media", { method: "POST", headers: { "Content-Type": metadata.type, "Accept-Language": get(locale), "X-Rigel-Filename": file.name, "X-Rigel-Author": currentAuthor() }, body: file });
192
+ if (!response.ok) throw new Error(translate("media.uploadFailed", { status: response.status, body: await response.text() }));
191
193
  const result = await response.json();
192
- if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !storedMediaFile.test(result.url.split("/").pop() ?? "") || !mediaMetadata(result.url)) throw new Error("Invalid uploaded asset URL");
194
+ if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !storedMediaFile.test(result.url.split("/").pop() ?? "") || !mediaMetadata(result.url)) throw new Error(translate("media.invalidUploadedUrl"));
193
195
  return result.url;
194
196
  }
195
197
 
196
198
  export async function uploadImage(blob: Blob): Promise<string> {
197
199
  const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
198
- if (blob.size > limit) throw new Error(`Photo exceeds the ${limit / 1024 / 1024} MB import limit`);
199
- const saved = await fetch("/rigel/api/local/media", { method: "POST", body: blob });
200
- if (saved.status === 404) throw new Error("Photo import requires the local dev server");
201
- if (!saved.ok) throw new Error(`Photo import failed (${saved.status})`);
200
+ if (blob.size > limit) throw new Error(translate("media.photoLimitExceeded", { limit: limit / 1024 / 1024 }));
201
+ const saved = await fetch("/rigel/api/local/media", { method: "POST", headers: { "Accept-Language": get(locale) }, body: blob });
202
+ if (saved.status === 404) throw new Error(translate("media.localImportRequired"));
203
+ if (!saved.ok) throw new Error(translate("media.photoImportFailedStatus", { status: saved.status }));
202
204
  const result = await saved.json();
203
205
  if (typeof result.url !== "string" || !safeImageUrl(result.url) || !result.url.startsWith("/") || !storedMediaFile.test(result.url.split("/").pop() ?? "") || !/\.(jpg|png|webp)$/.test(result.url)) {
204
- throw new Error("Invalid imported photo URL");
206
+ throw new Error(translate("media.invalidImportedUrl"));
205
207
  }
206
208
  return result.url;
207
209
  }
@@ -1,4 +1,5 @@
1
1
  import type { MenuItem } from "../../manifest/menu";
2
+ import { translate } from "../i18n";
2
3
 
3
4
  export function menuRows(items: MenuItem[], parentId: string | null = null, depth = 1): { item: MenuItem; parentId: string | null; depth: number; previous?: string; next?: string }[] {
4
5
  return items.flatMap((item, index) => [
@@ -16,8 +17,8 @@ export function editMenuItem(items: MenuItem[], id: string, update: (item: MenuI
16
17
 
17
18
  export function moveMenuItem(items: MenuItem[], id: string, targetId: string | null, position: "before" | "after" | "inside", maxDepth: number): MenuItem[] {
18
19
  const source = menuRows(items).find((row) => row.item.id === id)?.item;
19
- if (!source) throw new Error("Menu item not found");
20
- if (targetId && menuRows([source]).some((row) => row.item.id === targetId)) throw new Error("An item cannot be moved into itself or its children");
20
+ if (!source) throw new Error(translate("fields.menuItemNotFound"));
21
+ if (targetId && menuRows([source]).some((row) => row.item.id === targetId)) throw new Error(translate("fields.menuMoveIntoSelf"));
21
22
  const next = editMenuItem(items, id, () => null);
22
23
  let found = targetId === null;
23
24
  function insert(list: MenuItem[]): MenuItem[] {
@@ -31,7 +32,7 @@ export function moveMenuItem(items: MenuItem[], id: string, targetId: string | n
31
32
  });
32
33
  }
33
34
  const result = targetId === null ? [...next, source] : insert(next);
34
- if (!found) throw new Error("Drop target no longer exists");
35
- if (menuRows(result).some((row) => row.depth > maxDepth)) throw new Error(`This move exceeds the ${maxDepth}-level limit`);
35
+ if (!found) throw new Error(translate("fields.menuDropTargetMissing"));
36
+ if (menuRows(result).some((row) => row.depth > maxDepth)) throw new Error(translate("fields.menuMoveDepthLimit", { maxDepth }));
36
37
  return result;
37
38
  }
@@ -1,3 +1,4 @@
1
+ import { translate } from "../i18n";
1
2
  import DOMPurify from "dompurify";
2
3
 
3
4
  export async function fetchPreview(url: URL, signal: AbortSignal): Promise<string> {
@@ -5,10 +6,10 @@ export async function fetchPreview(url: URL, signal: AbortSignal): Promise<strin
5
6
  try {
6
7
  response = await fetch(url, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer", redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
7
8
  } catch {
8
- throw new Error("Draft preview could not be fetched. Check the URL, HTTPS, CORS (Access-Control-Allow-Origin for this editor), redirects and the editor’s connect-src policy. Authentication cookies are not sent.");
9
+ throw new Error(translate("richtext.previewFetchError"));
9
10
  }
10
- if (!response.ok) throw new Error(`Draft preview returned HTTP ${response.status}. Check the draft deployment and mapped route.`);
11
- if (!response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Draft preview did not return HTML. Check the mapped route and Content-Type header.");
11
+ if (!response.ok) throw new Error(translate("richtext.previewHttpError", { status: response.status }));
12
+ if (!response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error(translate("richtext.previewHtmlError"));
12
13
  const root = DOMPurify.sanitize(await response.text(), {
13
14
  WHOLE_DOCUMENT: true,
14
15
  RETURN_DOM: true,
@@ -1,4 +1,5 @@
1
1
  import { cloneJson, newKey, regenerateKeys, setPath } from "../../schema/paths";
2
+ import { translate } from "../i18n";
2
3
  import { isPlainObject, objectDef } from "../../schema/validate";
3
4
  import type { ContentDocument, DocumentDef, FieldDef, Schema } from "../../schema/types";
4
5
 
@@ -16,8 +17,8 @@ export function sectionTypeDef(schema: Schema, type: unknown) {
16
17
  return schema.objects.find((object) => object.name === type && object.type === "section");
17
18
  }
18
19
 
19
- export function sectionLabel(schema: Schema, type: unknown): string {
20
- return sectionTypeDef(schema, type)?.title ?? String(type ?? "Section");
20
+ export function sectionLabel(schema: Schema, type: unknown, fallback = translate("fields.section")): string {
21
+ return sectionTypeDef(schema, type)?.title ?? String(type ?? fallback);
21
22
  }
22
23
 
23
24
  export function fieldForPath(schema: Schema, docType: DocumentDef, values: Record<string, unknown>, path: string): FieldDef | null {
@@ -1,5 +1,6 @@
1
1
  import { readStorage, writeStorage } from "./storage";
2
2
  import type { StockKeys } from "./images";
3
+ import { availableLocales, setLocale, type Locale } from "../i18n";
3
4
 
4
5
  export interface RigelUserPref {
5
6
  sidebarCollapsed?: boolean;
@@ -10,7 +11,7 @@ export interface RigelUserPref {
10
11
 
11
12
  export interface RigelSettings {
12
13
  theme: "system" | "light" | "dark";
13
- language: "en" | "es" | "ru";
14
+ language: Locale;
14
15
 
15
16
  devMode: boolean;
16
17
  highContrast: boolean;
@@ -19,7 +20,7 @@ export interface RigelSettings {
19
20
  userPref: RigelUserPref;
20
21
  }
21
22
 
22
- const prefs = (theme: "system" | "light" | "dark" = "system", language: "en" | "es" | "ru" = "en"): Pick<RigelSettings, "theme" | "language" | "devMode" | "highContrast"> => ({
23
+ const prefs = (theme: "system" | "light" | "dark" = "system", language: Locale = "en"): Pick<RigelSettings, "theme" | "language" | "devMode" | "highContrast"> => ({
23
24
  theme, language, devMode: false, highContrast: false,
24
25
  });
25
26
 
@@ -40,7 +41,7 @@ export function getSettings(): RigelSettings {
40
41
  const legacyPref = readLegacyPref();
41
42
  return {
42
43
  theme: ["system", "light", "dark"].includes(saved?.theme) ? saved.theme : DEFAULTS.theme,
43
- language: ["en", "es", "ru"].includes(saved?.language) ? saved.language : DEFAULTS.language,
44
+ language: availableLocales.includes(saved?.language) ? saved.language : DEFAULTS.language,
44
45
  devMode: typeof saved?.devMode === "boolean" ? saved.devMode : DEFAULTS.devMode,
45
46
  highContrast: typeof saved?.highContrast === "boolean" ? saved.highContrast : DEFAULTS.highContrast,
46
47
  apiKeys: mergedKeys,
@@ -83,6 +84,7 @@ function readLegacyPref(): Partial<RigelUserPref> {
83
84
  export function setSettings(s: Partial<RigelSettings>) {
84
85
  const next = { ...getSettings(), ...s, userPref: { ...getSettings().userPref, ...s.userPref } };
85
86
  writeStorage("settings", next);
87
+ setLocale(next.language);
86
88
  clearLegacy();
87
89
  localStorage.removeItem("rigel.user");
88
90
  }
@@ -0,0 +1 @@
1
+ export const slashCommand = /^\/([\p{L}\p{M}\p{N}_ ]*)$/u;
@@ -100,7 +100,7 @@ function createTooltips() {
100
100
  if (event.pointerType === "touch") return;
101
101
  if (event.target === tip) { clearTimeout(timer); return; }
102
102
  const element = target(event);
103
- if (!element || element.contains(event.relatedTarget as Node | null) || element === anchor || element === doc.activeElement) return;
103
+ if (!element || element.contains(event.relatedTarget as Node | null) || element === anchor) return;
104
104
  hide(); timer = setTimeout(() => show(element), 450);
105
105
  };
106
106
  const out = (event: PointerEvent) => {
@@ -1,9 +1,18 @@
1
1
  import { mount } from "svelte";
2
+ import { get } from "svelte/store";
3
+ import { locale, setLocale, t } from "./i18n";
4
+ import { getSettings } from "./lib/settings";
2
5
  import App from "./App.svelte";
3
6
  import "./styles.css";
4
7
 
5
8
  const target = document.getElementById("rigel");
6
9
  if (target) {
10
+ setLocale(getSettings().language);
11
+ const unsubscribe = t.subscribe(message => {
12
+ document.documentElement.lang = get(locale);
13
+ document.title = message("system.editorTitle");
14
+ });
15
+ import.meta.hot?.dispose(unsubscribe);
7
16
  import.meta.hot?.send("rigel:editor-ready");
8
17
  mount(App, { target });
9
18
  }
@@ -986,7 +986,7 @@
986
986
  .rigel .settings-nav button:hover { background: var(--surface-raised); color: var(--text); }
987
987
  .rigel .settings-nav button.active { background: var(--accent-soft); color: var(--on-accent-soft); font-weight: 600; }
988
988
  .rigel .settings-nav .pico { width: 24px; flex-shrink: 0; }
989
- .rigel .settings-pane { padding: 1.25rem 1.5rem 1.25rem; }
989
+ .rigel .settings-pane { min-width: 0; overflow-wrap: anywhere; padding: 1.25rem 1.5rem 1.25rem; }
990
990
  .rigel .settings-pane h3 {
991
991
  font-size: 0.875rem; font-weight: 600; text-transform: none;
992
992
  letter-spacing: 0; color: var(--text); margin: 0 0 0.75rem;
@@ -1231,8 +1231,11 @@
1231
1231
  .rigel .block-menu { position: fixed; inset: auto; margin: 0; }
1232
1232
  .rigel .block-slash { position: fixed; inset: auto; margin: 0; }
1233
1233
  .rigel .block-markdown { margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; font: 0.8125rem/1.7 ui-monospace, monospace; }
1234
- .rigel :is(.block-menu, .block-slash) > button { display: block; width: 100%; text-align: left; padding: 0.5rem; background: var(--panel); border: 0; color: var(--text); font: inherit; font-size: 0.8125rem; cursor: pointer; }
1235
- .rigel :is(.block-menu, .block-slash) > button:is(:hover,[aria-selected=true]) { background: var(--accent-soft); color: var(--on-accent-soft); }
1234
+ .rigel :is(.block-menu, .block-slash) > button, .rigel .extension-group-menu [data-rigel-command] button { display: block; width: 100%; text-align: left; padding: 0.5rem; background: var(--panel); border: 0; color: var(--text); font: inherit; font-size: 0.8125rem; cursor: pointer; }
1235
+ .rigel :is(.block-menu, .block-slash) > button:is(:hover,[aria-selected=true]), .rigel .extension-group-menu button:hover:not(:disabled) { background: var(--accent-soft); color: var(--on-accent-soft); }
1236
+ .rigel .extension-group-trigger, .rigel .extension-group-menu > span { display: contents; }
1237
+ .rigel .body-toolbar :where([data-rigel-command], [data-rigel-group]) button { display: inline-flex; align-items: center; justify-content: center; min-width: 32px; height: 32px; padding: 0 0.5rem; gap: 0.25rem; background: transparent; color: var(--text); border: 0; font: inherit; cursor: pointer; }
1238
+ .rigel .body-toolbar :where([data-rigel-command], [data-rigel-group]) button:hover:not(:disabled) { background: var(--surface-raised); }
1236
1239
  .rigel .block-format-menu > button { display: flex; align-items: center; gap: 0.75rem; }
1237
1240
  .rigel .block-format-menu > button > span { flex: 1; }
1238
1241
  .rigel .block-format-menu > button[aria-checked=true] { background: var(--accent-soft); color: var(--on-accent-soft); }
package/src/env.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ declare module "virtual:rigel-extensions" {
2
+ const extensions: import("./extensions").RigelExtension[];
3
+ export default extensions;
4
+ }
5
+
6
+ declare module "virtual:rigel-i18n" {
7
+ const dictionaries: import("./editor/i18n/format").Dictionaries;
8
+ export default dictionaries;
9
+ }
10
+
1
11
  declare module "virtual:rigel-manifest" {
2
12
  import type { CopyPage, ManifestCollection } from "@rigel-cms/rigel/content";
3
13
  export const pages: CopyPage[];
@@ -17,6 +27,6 @@ declare module "virtual:rigel-content" {
17
27
 
18
28
  declare module "virtual:rigel-config" {
19
29
  import type { RigelConfig } from "@rigel-cms/rigel";
20
- const config: Pick<RigelConfig, "copyDir" | "backend" | "jsonOutput" | "demo" | "collections" | "menus" | "mediaDir" | "maxFileSize" | "schema" | "preview"> & { publicDir?: string; root?: string };
30
+ const config: Pick<RigelConfig, "copyDir" | "backend" | "jsonOutput" | "demo" | "languages" | "collections" | "menus" | "mediaDir" | "maxFileSize" | "schema" | "preview"> & { publicDir?: string; root?: string };
21
31
  export default config;
22
32
  }
@@ -0,0 +1,160 @@
1
+ import type { Component } from "svelte";
2
+
3
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
4
+ export type BlockData = { [key: string]: JsonValue };
5
+
6
+ export interface ExtensionSelection {
7
+ text: string;
8
+ blockType: string;
9
+ block?: { name: string; data: BlockData };
10
+ }
11
+
12
+ export interface ExtensionContext {
13
+ readonly selection: ExtensionSelection;
14
+ insertText(text: string): void;
15
+ insertBlock(name: string, data: BlockData): void;
16
+ updateBlock(data: BlockData): void;
17
+ }
18
+
19
+ export interface ExtensionButtonProps {
20
+ label: string;
21
+ disabled: boolean;
22
+ onclick: () => void;
23
+ }
24
+
25
+ export interface ExtensionBlockProps {
26
+ data: BlockData;
27
+ onchange: (data: BlockData) => void;
28
+ }
29
+
30
+ export interface ExtensionCommand {
31
+ name: string;
32
+ label: string;
33
+ slash?: string;
34
+ toolbar?: boolean;
35
+ group?: string;
36
+ button?: Component<ExtensionButtonProps>;
37
+ run: (context: ExtensionContext) => void | Promise<void>;
38
+ }
39
+
40
+ export interface ExtensionBlock {
41
+ name: string;
42
+ label: string;
43
+ editor: Component<ExtensionBlockProps>;
44
+ render: Component<{ data: BlockData }>;
45
+ validate: (data: BlockData) => void;
46
+ toMarkdown?: (data: BlockData) => string;
47
+ }
48
+
49
+ export interface RigelExtension {
50
+ name: string;
51
+ commands?: ExtensionCommand[];
52
+ blocks?: ExtensionBlock[];
53
+ groups?: ExtensionGroup[];
54
+ }
55
+
56
+ export function defineExtension<T extends RigelExtension>(extension: T): T {
57
+ return extension;
58
+ }
59
+
60
+ const forbiddenKeys = new Set(["__proto__", "constructor", "prototype"]);
61
+ const builtins = new Set(["paragraph", "h1", "h2", "h3", "h4", "h5", "h6", "quote", "bullet", "number", "code", "image", "divider", "table", "video", "youtube", "vimeo", "bold", "italic", "underline", "strikethrough", "subscript", "superscript", "highlight", "link", "undo", "redo", "clear", "clear-formatting"]);
62
+
63
+ export function validateExtensionName(value: unknown): asserts value is string {
64
+ if (typeof value !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(value) || forbiddenKeys.has(value)) throw new Error("rigel: invalid extension identifier");
65
+ }
66
+
67
+ function plainObject(value: unknown): value is Record<string, unknown> {
68
+ return value !== null && typeof value === "object" && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
69
+ }
70
+
71
+ function objectShape(value: unknown, required: string[], optional: string[] = []): asserts value is Record<string, unknown> {
72
+ if (!plainObject(value) || required.some(key => !Object.hasOwn(value, key))) throw new Error("rigel: invalid extension object");
73
+ for (const key of Reflect.ownKeys(value)) {
74
+ const descriptor = Object.getOwnPropertyDescriptor(value, key)!;
75
+ if (typeof key !== "string" || ![...required, ...optional].includes(key) || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) throw new Error("rigel: invalid extension property");
76
+ }
77
+ }
78
+
79
+ export function validateBlockData(value: unknown): asserts value is BlockData {
80
+ if (!plainObject(value)) throw new Error("rigel: extension data must be a plain JSON object");
81
+ let nodes = 0;
82
+ const ancestors = new Set<object>();
83
+ function check(item: unknown, depth: number) {
84
+ if (++nodes > 10000 || depth > 16) throw new Error("rigel: extension data exceeds depth or node limit");
85
+ if (item === null || typeof item === "string" || typeof item === "boolean" || typeof item === "number" && Number.isFinite(item)) return;
86
+ if (typeof item !== "object" || !item || (!Array.isArray(item) && !plainObject(item)) || ancestors.has(item)) throw new Error("rigel: extension data must be finite plain JSON without cycles");
87
+ ancestors.add(item);
88
+ const array = Array.isArray(item);
89
+ if (array && Object.getPrototypeOf(item) !== Array.prototype) throw new Error("rigel: extension data must contain plain JSON arrays");
90
+ if (array && item.length > 10000) throw new Error("rigel: extension data exceeds node limit");
91
+ const keys = Reflect.ownKeys(item);
92
+ if (array && keys.length !== item.length + 1) throw new Error("rigel: extension data contains a sparse or decorated array");
93
+ for (const key of keys) {
94
+ if (array && key === "length") continue;
95
+ const descriptor = Object.getOwnPropertyDescriptor(item, key)!;
96
+ if (typeof key !== "string" || forbiddenKeys.has(key) || !descriptor.enumerable || !Object.hasOwn(descriptor, "value") || array && (!/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= item.length)) throw new Error("rigel: extension data contains a non-JSON property");
97
+ check(descriptor.value, depth + 1);
98
+ }
99
+ ancestors.delete(item);
100
+ }
101
+ check(value, 0);
102
+ if (new TextEncoder().encode(JSON.stringify(value)).length > 100000) throw new Error("rigel: extension data exceeds 100000 UTF-8 bytes");
103
+ }
104
+
105
+ export interface ExtensionGroup {
106
+ name: string;
107
+ label: string;
108
+ button?: Component<ExtensionButtonProps>;
109
+ }
110
+
111
+ export function resolveExtensions(modules: RigelExtension[]): { commands: ExtensionCommand[]; blocks: ExtensionBlock[]; groups: ExtensionGroup[] } {
112
+ if (!Array.isArray(modules)) throw new Error("rigel: extensions must be an array");
113
+ const commands: ExtensionCommand[] = [], blocks: ExtensionBlock[] = [], groups: ExtensionGroup[] = [];
114
+ const modulesSeen = new Set<string>(), names = new Set<string>(), slashes = new Set<string>(), groupNames = new Set<string>();
115
+ function unique(name: unknown, seen: Set<string>, builtin = false) {
116
+ validateExtensionName(name);
117
+ if (seen.has(name) || builtin && builtins.has(name)) throw new Error(`rigel: duplicate or reserved extension name: ${name}`);
118
+ seen.add(name);
119
+ }
120
+ for (const module of modules) {
121
+ objectShape(module, ["name"], ["commands", "blocks", "groups"]);
122
+ unique(module.name, modulesSeen);
123
+ for (const kind of ["commands", "blocks", "groups"] as const) {
124
+ if (!Object.hasOwn(module, kind)) continue;
125
+ const entries = module[kind];
126
+ if (!Array.isArray(entries)) throw new Error(`rigel: extension ${kind} must be an array`);
127
+ for (const entry of entries) {
128
+ objectShape(entry, kind === "commands" ? ["name", "label", "run"] : kind === "blocks" ? ["name", "label", "editor", "render", "validate"] : ["name", "label"], kind === "commands" ? ["slash", "toolbar", "button", "group"] : kind === "blocks" ? ["toMarkdown"] : ["button"]);
129
+ unique(entry.name, kind === "groups" ? groupNames : names, kind !== "groups");
130
+ if (typeof entry.label !== "string" || !entry.label.trim()) throw new Error("rigel: extension label is required");
131
+ for (const key of kind === "commands" ? ["run", "button"] : kind === "blocks" ? ["editor", "render", "validate", "toMarkdown"] : ["button"]) {
132
+ if (Object.hasOwn(entry, key) && typeof entry[key] !== "function") throw new Error(`rigel: extension ${key} must be a function/component`);
133
+ }
134
+ if (kind === "commands") {
135
+ if (Object.hasOwn(entry, "toolbar") && typeof entry.toolbar !== "boolean") throw new Error("rigel: extension toolbar must be boolean");
136
+ if (Object.hasOwn(entry, "slash")) unique(entry.slash, slashes, true);
137
+ if (Object.hasOwn(entry, "group")) validateExtensionName(entry.group);
138
+ commands.push(entry as unknown as ExtensionCommand);
139
+ } else if (kind === "blocks") blocks.push(entry as unknown as ExtensionBlock);
140
+ else groups.push(entry as unknown as ExtensionGroup);
141
+ }
142
+ }
143
+ }
144
+ for (const command of commands) {
145
+ if (command.group !== undefined && !groupNames.has(command.group)) throw new Error(`rigel: unknown extension group: ${command.group}`);
146
+ }
147
+ return { commands, blocks, groups };
148
+ }
149
+
150
+ export function validateExtensionBlocks(body: unknown, blocks: ExtensionBlock[]): void {
151
+ const children = (body as { root?: { children?: unknown[] } } | null)?.root?.children;
152
+ if (!Array.isArray(children)) return;
153
+ for (const raw of children) {
154
+ if (!raw || typeof raw !== "object" || (raw as { type?: unknown }).type !== "extension") continue;
155
+ const node = raw as { name: string; data: unknown };
156
+ validateExtensionName(node.name);
157
+ validateBlockData(node.data);
158
+ blocks.find(block => block.name === node.name)?.validate(node.data);
159
+ }
160
+ }
@@ -1,6 +1,7 @@
1
1
  import type { CollectionField } from "./types";
2
2
  import type { ContentDocument } from "../schema/types";
3
3
  import { validateCollectionReferences } from "./references";
4
+ import { systemMessage, type Translator } from "../editor/i18n/format";
4
5
 
5
6
  export function initialCollectionValues(fields: CollectionField[], title: string): Record<string, unknown> {
6
7
  return Object.fromEntries(fields.map(field => [field.key,
@@ -11,17 +12,17 @@ export function initialCollectionValues(fields: CollectionField[], title: string
11
12
  ]));
12
13
  }
13
14
 
14
- export function validateCollectionValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[] = []): void {
15
- if (!values || typeof values !== "object" || Array.isArray(values)) throw new Error("Invalid article frontmatter");
15
+ export function validateCollectionValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[] = [], message: Translator = systemMessage): void {
16
+ if (!values || typeof values !== "object" || Array.isArray(values)) throw new Error(message("validation.collection.invalid"));
16
17
  for (const field of fields) {
17
18
  const value = values[field.key];
18
- if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()) || (Array.isArray(value) && !value.length))) throw new Error(`${field.label}: required`);
19
+ if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()) || (Array.isArray(value) && !value.length))) throw new Error(message("validation.collection.required", { label: field.label }));
19
20
  if (value === undefined || field.type === "reference") continue;
20
21
  const valid = field.type === "boolean" ? typeof value === "boolean" :
21
22
  field.type === "tags" ? Array.isArray(value) && value.every(tag => typeof tag === "string") :
22
23
  field.type === "date" ? typeof value === "string" && (value === "" || (/^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value)) :
23
24
  typeof value === "string";
24
- if (!valid) throw new Error(`${field.label}: expected ${field.type}`);
25
+ if (!valid) throw new Error(message("validation.collection.expected", { label: field.label, type: field.type }));
25
26
  }
26
- validateCollectionReferences(fields, values, documents);
27
+ validateCollectionReferences(fields, values, documents, message);
27
28
  }
@@ -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
  }