@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.
- package/README.md +4 -0
- package/docs/extensions.md +194 -0
- package/package.json +15 -3
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +12 -2
- package/src/editor/App.svelte +98 -97
- package/src/editor/blocks/extension.ts +29 -0
- package/src/editor/blocks/index.ts +17 -15
- package/src/editor/blocks/video.ts +2 -1
- package/src/editor/i18n/README.md +23 -0
- package/src/editor/i18n/de/connection.json +136 -0
- package/src/editor/i18n/de/defaults.json +6 -0
- package/src/editor/i18n/de/fields.json +130 -0
- package/src/editor/i18n/de/local.json +49 -0
- package/src/editor/i18n/de/media.json +166 -0
- package/src/editor/i18n/de/richtext.json +116 -0
- package/src/editor/i18n/de/shell.json +181 -0
- package/src/editor/i18n/de/system.json +14 -0
- package/src/editor/i18n/de/validation.json +82 -0
- package/src/editor/i18n/en/connection.json +136 -0
- package/src/editor/i18n/en/defaults.json +6 -0
- package/src/editor/i18n/en/fields.json +130 -0
- package/src/editor/i18n/en/local.json +49 -0
- package/src/editor/i18n/en/media.json +166 -0
- package/src/editor/i18n/en/richtext.json +116 -0
- package/src/editor/i18n/en/shell.json +181 -0
- package/src/editor/i18n/en/system.json +14 -0
- package/src/editor/i18n/en/validation.json +82 -0
- package/src/editor/i18n/es/connection.json +136 -0
- package/src/editor/i18n/es/defaults.json +6 -0
- package/src/editor/i18n/es/fields.json +130 -0
- package/src/editor/i18n/es/local.json +49 -0
- package/src/editor/i18n/es/media.json +166 -0
- package/src/editor/i18n/es/richtext.json +116 -0
- package/src/editor/i18n/es/shell.json +181 -0
- package/src/editor/i18n/es/system.json +14 -0
- package/src/editor/i18n/es/validation.json +82 -0
- package/src/editor/i18n/format.ts +26 -0
- package/src/editor/i18n/fr/connection.json +136 -0
- package/src/editor/i18n/fr/defaults.json +6 -0
- package/src/editor/i18n/fr/fields.json +130 -0
- package/src/editor/i18n/fr/local.json +49 -0
- package/src/editor/i18n/fr/media.json +166 -0
- package/src/editor/i18n/fr/richtext.json +116 -0
- package/src/editor/i18n/fr/shell.json +181 -0
- package/src/editor/i18n/fr/system.json +14 -0
- package/src/editor/i18n/fr/validation.json +82 -0
- package/src/editor/i18n/index.ts +18 -0
- package/src/editor/i18n/it/connection.json +136 -0
- package/src/editor/i18n/it/defaults.json +6 -0
- package/src/editor/i18n/it/fields.json +130 -0
- package/src/editor/i18n/it/local.json +49 -0
- package/src/editor/i18n/it/media.json +166 -0
- package/src/editor/i18n/it/richtext.json +116 -0
- package/src/editor/i18n/it/shell.json +181 -0
- package/src/editor/i18n/it/system.json +14 -0
- package/src/editor/i18n/it/validation.json +82 -0
- package/src/editor/i18n/ja/connection.json +136 -0
- package/src/editor/i18n/ja/defaults.json +6 -0
- package/src/editor/i18n/ja/fields.json +130 -0
- package/src/editor/i18n/ja/local.json +49 -0
- package/src/editor/i18n/ja/media.json +166 -0
- package/src/editor/i18n/ja/richtext.json +116 -0
- package/src/editor/i18n/ja/shell.json +181 -0
- package/src/editor/i18n/ja/system.json +14 -0
- package/src/editor/i18n/ja/validation.json +82 -0
- package/src/editor/i18n/locales.ts +13 -0
- package/src/editor/i18n/nl/connection.json +136 -0
- package/src/editor/i18n/nl/defaults.json +6 -0
- package/src/editor/i18n/nl/fields.json +130 -0
- package/src/editor/i18n/nl/local.json +49 -0
- package/src/editor/i18n/nl/media.json +166 -0
- package/src/editor/i18n/nl/richtext.json +116 -0
- package/src/editor/i18n/nl/shell.json +181 -0
- package/src/editor/i18n/nl/system.json +14 -0
- package/src/editor/i18n/nl/validation.json +82 -0
- package/src/editor/i18n/ru/connection.json +138 -0
- package/src/editor/i18n/ru/defaults.json +6 -0
- package/src/editor/i18n/ru/fields.json +132 -0
- package/src/editor/i18n/ru/local.json +49 -0
- package/src/editor/i18n/ru/media.json +166 -0
- package/src/editor/i18n/ru/richtext.json +116 -0
- package/src/editor/i18n/ru/shell.json +181 -0
- package/src/editor/i18n/ru/system.json +14 -0
- package/src/editor/i18n/ru/validation.json +82 -0
- package/src/editor/i18n/server.ts +13 -0
- package/src/editor/i18n/zh/connection.json +136 -0
- package/src/editor/i18n/zh/defaults.json +6 -0
- package/src/editor/i18n/zh/fields.json +130 -0
- package/src/editor/i18n/zh/local.json +49 -0
- package/src/editor/i18n/zh/media.json +166 -0
- package/src/editor/i18n/zh/richtext.json +116 -0
- package/src/editor/i18n/zh/shell.json +181 -0
- package/src/editor/i18n/zh/system.json +14 -0
- package/src/editor/i18n/zh/validation.json +82 -0
- package/src/editor/lib/ActionDialog.svelte +5 -4
- package/src/editor/lib/BlockEditor.svelte +305 -88
- package/src/editor/lib/CollectionPanel.svelte +17 -15
- package/src/editor/lib/DatePicker.svelte +15 -12
- package/src/editor/lib/ExtensionMount.svelte +12 -0
- package/src/editor/lib/Field.svelte +3 -2
- package/src/editor/lib/ImagePicker.svelte +82 -81
- package/src/editor/lib/MarkdownEditor.svelte +25 -24
- package/src/editor/lib/MediaPicker.svelte +6 -5
- package/src/editor/lib/MenuEditor.svelte +31 -30
- package/src/editor/lib/PreviewFrame.svelte +11 -6
- package/src/editor/lib/PropertiesTabs.svelte +3 -2
- package/src/editor/lib/PublishDialog.svelte +15 -14
- package/src/editor/lib/ReferenceSelect.svelte +3 -2
- package/src/editor/lib/SchemaField.svelte +15 -13
- package/src/editor/lib/SchemaPanel.svelte +20 -19
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +6 -5
- package/src/editor/lib/Settings.svelte +54 -54
- package/src/editor/lib/Shortcuts.svelte +7 -6
- package/src/editor/lib/SitePreview.svelte +10 -9
- package/src/editor/lib/TopBar.svelte +18 -17
- package/src/editor/lib/UnsavedChanges.svelte +7 -6
- package/src/editor/lib/VisualEditor.svelte +14 -13
- package/src/editor/lib/calendar.ts +10 -4
- package/src/editor/lib/embed-preview.ts +23 -5
- package/src/editor/lib/extension-preview.ts +121 -0
- package/src/editor/lib/github-auth.ts +23 -22
- package/src/editor/lib/github-backend.ts +96 -91
- package/src/editor/lib/images.ts +33 -31
- package/src/editor/lib/menu-tree.ts +5 -4
- package/src/editor/lib/preview-frame.ts +4 -3
- package/src/editor/lib/schema-edit.ts +3 -2
- package/src/editor/lib/settings.ts +5 -3
- package/src/editor/lib/slash-command.ts +1 -0
- package/src/editor/lib/tooltip.ts +1 -1
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +6 -3
- package/src/env.d.ts +11 -1
- package/src/extensions.ts +160 -0
- package/src/manifest/collections.ts +6 -5
- package/src/manifest/menu.ts +12 -10
- package/src/manifest/navigation.ts +9 -8
- package/src/manifest/references.ts +8 -7
- package/src/manifest/seo.ts +4 -3
- package/src/manifest/types.ts +1 -0
- package/src/preview.ts +22 -2
- package/src/routes/local-api.ts +5 -3
- package/src/routes/local-documents.ts +20 -16
- package/src/routes/local-media.ts +21 -16
- package/src/routes/local-menu.ts +9 -7
- package/src/routes/local-post.ts +18 -12
- package/src/routes/media-files.ts +10 -8
- package/src/routes/rigel-page.astro +2 -1
- package/src/schema/store.ts +22 -20
- package/src/schema/types.ts +1 -0
- package/src/schema/validate.ts +73 -69
- package/src/slugs.ts +11 -10
- package/src/vite/manifest-plugin.ts +33 -1
package/src/editor/lib/images.ts
CHANGED
|
@@ -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
|
|
9
|
-
pixabay: { label: "Pixabay", keyURL: "https://pixabay.com/api/docs/#api_key", pattern: "[0-9]+-[a-f0-9]{25}", placeholder
|
|
10
|
-
unsplash: { label: "Unsplash", keyURL: "https://unsplash.com/oauth/applications", pattern: "[a-zA-Z0-9_-]{40,}", placeholder
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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("
|
|
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("
|
|
114
|
+
if (!keys.pexels) throw new Error(translate("media.pexelsKeyRequired"));
|
|
113
115
|
return pexels(query, keys.pexels);
|
|
114
116
|
}
|
|
115
|
-
throw new Error("
|
|
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("
|
|
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("
|
|
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(
|
|
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("
|
|
141
|
-
if (!value.startsWith("/") && new URL(value).protocol !== "https:") throw new Error("
|
|
142
|
-
if (!value.startsWith("/") && new URL(value).hostname === "images.unsplash.com") throw new Error("
|
|
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("
|
|
146
|
-
if (!response.ok) throw new Error(
|
|
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(
|
|
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("
|
|
151
|
-
if (!response.body) throw new Error("
|
|
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(
|
|
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(
|
|
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(
|
|
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("
|
|
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(
|
|
199
|
-
const saved = await fetch("/rigel/api/local/media", { method: "POST", body: blob });
|
|
200
|
-
if (saved.status === 404) throw new Error("
|
|
201
|
-
if (!saved.ok) throw new Error(
|
|
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("
|
|
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("
|
|
20
|
-
if (targetId && menuRows([source]).some((row) => row.item.id === targetId)) throw new Error("
|
|
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("
|
|
35
|
-
if (menuRows(result).some((row) => row.depth > maxDepth)) throw new Error(
|
|
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("
|
|
9
|
+
throw new Error(translate("richtext.previewFetchError"));
|
|
9
10
|
}
|
|
10
|
-
if (!response.ok) throw new Error(
|
|
11
|
-
if (!response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("
|
|
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 ??
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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) => {
|
package/src/editor/main.ts
CHANGED
|
@@ -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
|
}
|
package/src/editor/styles.css
CHANGED
|
@@ -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("
|
|
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(
|
|
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(
|
|
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
|
}
|
package/src/manifest/menu.ts
CHANGED
|
@@ -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("
|
|
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("
|
|
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(
|
|
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("
|
|
31
|
-
if (ids.has(item.id)) throw new Error(
|
|
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("
|
|
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("
|
|
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("
|
|
41
|
+
catch { throw new Error(message("validation.menu.invalidUrl")); }
|
|
40
42
|
if (!["http:", "https:", "mailto:", "tel:"].includes(parsed.protocol))
|
|
41
|
-
throw new Error("
|
|
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
|
}
|