@rigel-cms/rigel 1.0.0-rc.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -7
- package/docs/extensions.md +194 -0
- package/package.json +16 -9
- package/src/article-body.ts +49 -37
- package/src/article-render.ts +25 -2
- package/src/article.ts +44 -0
- package/src/config.ts +18 -3
- package/src/editor/App.svelte +99 -98
- 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/icons.ts +4 -0
- 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-urls.ts +6 -0
- package/src/preview.ts +38 -7
- 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 +12 -11
- package/src/vite/manifest-plugin.ts +33 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Manifest } from "./types";
|
|
2
|
+
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
2
3
|
|
|
3
4
|
export type Selection =
|
|
4
5
|
| { kind: "media" }
|
|
@@ -27,24 +28,24 @@ export function navigationKey(target: Selection): string {
|
|
|
27
28
|
return JSON.stringify([target.kind, "category" in target ? target.category : null, "name" in target ? target.name : null, "slug" in target ? target.slug : null, "type" in target ? target.type : null, "id" in target ? target.id : null, "collection" in target ? target.collection : null]);
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function buildSidebar(manifest: Manifest): SidebarNode[] {
|
|
31
|
+
export function buildSidebar(manifest: Manifest, message: Translator = systemMessage): SidebarNode[] {
|
|
31
32
|
const link = (target: SidebarTarget, label: string): SidebarLink => ({ kind: "link", target, label });
|
|
32
33
|
const types = manifest.schema?.documents ?? [];
|
|
33
34
|
const pageTypes = types.filter(type => !type.shared);
|
|
34
35
|
const pages = manifest.pages.filter(page => !page.shared);
|
|
35
36
|
const categories = [...new Set(pages.map(page => page.category).filter((category): category is string => !!category))].sort();
|
|
36
37
|
const defaults: SidebarNode[] = [
|
|
37
|
-
{ kind: "heading", label: "
|
|
38
|
-
link({ kind: "pages" }, "
|
|
38
|
+
{ kind: "heading", label: message("system.sidebar.website") },
|
|
39
|
+
link({ kind: "pages" }, message("system.sidebar.pages")),
|
|
39
40
|
...(manifest.collections ?? []).map(collection => link({ kind: "collection", name: collection.name }, collection.label)),
|
|
40
41
|
...categories.map(category => link({ kind: "pages", category }, category)),
|
|
41
42
|
...pageTypes.slice(1).map(type => link({ kind: "documents", type: type.name }, type.title)),
|
|
42
|
-
{ kind: "heading", label: "
|
|
43
|
+
{ kind: "heading", label: message("system.sidebar.shared") },
|
|
43
44
|
...types.filter(type => type.shared && !type.singleton).map(type => link({ kind: "documents", type: type.name }, type.title)),
|
|
44
45
|
...manifest.pages.filter(page => page.shared).map(page => link({ kind: "page", slug: page.slug }, page.label)),
|
|
45
|
-
...(manifest.menus ? [link({ kind: "menus" }, "
|
|
46
|
-
{ kind: "heading", label: "
|
|
47
|
-
link({ kind: "media" }, "
|
|
46
|
+
...(manifest.menus ? [link({ kind: "menus" }, message("system.sidebar.menus"))] : []),
|
|
47
|
+
{ kind: "heading", label: message("system.sidebar.assets") },
|
|
48
|
+
link({ kind: "media" }, message("system.sidebar.media")),
|
|
48
49
|
...(types.some(type => type.shared && type.singleton) ? [{ kind: "separator" } as SidebarNode] : []),
|
|
49
50
|
...types.filter(type => type.shared && type.singleton).map(type => {
|
|
50
51
|
const doc = manifest.documents?.find(doc => doc.type === type.name);
|
|
@@ -70,7 +71,7 @@ export function buildSidebar(manifest: Manifest): SidebarNode[] {
|
|
|
70
71
|
nodes.push({ ...item, label: entry.label ?? item.label, ...(entry.icon ? { icon: entry.icon } : {}) });
|
|
71
72
|
}
|
|
72
73
|
const remaining = defaults.filter((node): node is SidebarLink => node.kind === "link" && !used.has(navigationKey(node.target)));
|
|
73
|
-
if (remaining.length) nodes.push({ kind: "heading", label: "
|
|
74
|
+
if (remaining.length) nodes.push({ kind: "heading", label: message("system.sidebar.more") }, ...remaining);
|
|
74
75
|
return nodes;
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CollectionField } from "./types";
|
|
2
2
|
import type { ContentDocument } from "../schema/types";
|
|
3
|
+
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
3
4
|
|
|
4
5
|
export interface ReferenceOption {
|
|
5
6
|
value: string;
|
|
@@ -19,21 +20,21 @@ export function referenceOptions(field: CollectionField, documents: ContentDocum
|
|
|
19
20
|
}));
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function referenceLabel(value: unknown, options: ReferenceOption[]): string {
|
|
23
|
-
return (Array.isArray(value) ? value : [value]).filter(Boolean).map(id => options.find(option => option.value === id)?.label ??
|
|
23
|
+
export function referenceLabel(value: unknown, options: ReferenceOption[], message: Translator = systemMessage): string {
|
|
24
|
+
return (Array.isArray(value) ? value : [value]).filter(Boolean).map(id => options.find(option => option.value === id)?.label ?? message("system.reference.unavailable", { id: String(id) })).join(", ");
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export function collectionPreviewValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): Record<string, unknown> {
|
|
27
|
-
return { ...values, ...Object.fromEntries(fields.filter(field => field.type === "reference").map(field => [field.key, referenceLabel(values[field.key], referenceOptions(field, documents))])) };
|
|
27
|
+
export function collectionPreviewValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[], message: Translator = systemMessage): Record<string, unknown> {
|
|
28
|
+
return { ...values, ...Object.fromEntries(fields.filter(field => field.type === "reference").map(field => [field.key, referenceLabel(values[field.key], referenceOptions(field, documents), message)])) };
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
export function validateCollectionReferences(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[]): void {
|
|
31
|
+
export function validateCollectionReferences(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[], message: Translator = systemMessage): void {
|
|
31
32
|
for (const field of fields.filter(field => field.type === "reference")) {
|
|
32
33
|
const value = values[field.key];
|
|
33
34
|
if (value === undefined) continue;
|
|
34
|
-
if (field.multiple ? !Array.isArray(value) : typeof value !== "string") throw new Error(
|
|
35
|
+
if (field.multiple ? !Array.isArray(value) : typeof value !== "string") throw new Error(message(field.multiple ? "system.reference.expectedMany" : "system.reference.expectedOne", { label: field.label }));
|
|
35
36
|
const selected = field.multiple ? value as unknown[] : value === "" ? [] : [value];
|
|
36
37
|
const options = referenceOptions(field, documents);
|
|
37
|
-
if (new Set(selected).size !== selected.length || selected.some(id => typeof id !== "string" || !options.some(option => option.value === id))) throw new Error(
|
|
38
|
+
if (new Set(selected).size !== selected.length || selected.some(id => typeof id !== "string" || !options.some(option => option.value === id))) throw new Error(message("system.reference.existingOnly", { label: field.label }));
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/manifest/seo.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { CollectionField } from "./types";
|
|
2
|
+
import { systemMessage } from "../editor/i18n/format";
|
|
2
3
|
|
|
3
4
|
export const SEO_FIELDS = [
|
|
4
|
-
{ key: "metaTitle", label: "
|
|
5
|
-
{ key: "description", label: "
|
|
6
|
-
{ key: "ogImage", label: "
|
|
5
|
+
{ key: "metaTitle", label: systemMessage("defaults.seoTitle"), labelKey: "defaults.seoTitle", type: "string" },
|
|
6
|
+
{ key: "description", label: systemMessage("defaults.seoDescription"), labelKey: "defaults.seoDescription", type: "text" },
|
|
7
|
+
{ key: "ogImage", label: systemMessage("defaults.socialImage"), labelKey: "defaults.socialImage", type: "image" },
|
|
7
8
|
] satisfies CollectionField[];
|
|
8
9
|
|
|
9
10
|
export function isSeoField(key: string): boolean {
|
package/src/manifest/types.ts
CHANGED
package/src/preview-urls.ts
CHANGED
|
@@ -2,6 +2,12 @@ export function safePreviewUrl(value: string, image = false): boolean {
|
|
|
2
2
|
return !!value && !/[ \t\r\n\f\v\u0000-\u001f\u007f\\]/.test(value) && !value.startsWith("//") && (!/^[a-z][a-z\d+.-]*:/i.test(value) || (image ? /^https?:/i : /^(https?:|mailto:)/i).test(value));
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
+
export function previewRoute(value: unknown, template = false): string | null {
|
|
6
|
+
if (typeof value !== "string" || !/^\/(?!\/)/.test(value) || /[\\\s?#\u0000-\u001f\u007f]/.test(value) || /%(?:2e|2f|5c)/i.test(value) || value.split("/").some(part => part === "." || part === "..")) return null;
|
|
7
|
+
if (template && /[{}]/.test(value)) return null;
|
|
8
|
+
return !/^\/rigel(?:\/|$)/.test(value) || (template && /^\/rigel\/preview(?:\/|$)/.test(value)) ? value : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
export function previewBaseUrl(value: unknown): string {
|
|
6
12
|
try {
|
|
7
13
|
if (typeof value !== "string" || !/^https?:\/\/[^/@]+(?:\/|$)/.test(value) || /[\s\u0000-\u001f\u007f\\%?#]/.test(value) || value.split("/").some(part => part === "." || part === "..")) throw new Error();
|
package/src/preview.ts
CHANGED
|
@@ -4,19 +4,21 @@ import { pageRoute } from "./schema/paths";
|
|
|
4
4
|
import { slugify } from "./slugs";
|
|
5
5
|
import { renderArticle } from "./article-render";
|
|
6
6
|
import { createEmbedPreview } from "./editor/lib/embed-preview";
|
|
7
|
-
import {
|
|
7
|
+
import { mountExtensionPreviews } from "./editor/lib/extension-preview";
|
|
8
|
+
import { safePreviewUrl, previewBaseUrl, previewRoute } from "./preview-urls";
|
|
8
9
|
export { safePreviewUrl } from "./preview-urls";
|
|
9
10
|
|
|
10
11
|
export interface PreviewDocumentMapping {
|
|
11
12
|
url: string;
|
|
12
13
|
index?: string;
|
|
14
|
+
template?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export interface PreviewConfig {
|
|
16
18
|
liveUrl?: string;
|
|
17
19
|
draftUrl?: string;
|
|
18
|
-
pages?: Record<string, string>;
|
|
19
|
-
collections?: Record<string, { url: string; new
|
|
20
|
+
pages?: Record<string, string | PreviewDocumentMapping>;
|
|
21
|
+
collections?: Record<string, { url: string; new?: string; template?: string }>;
|
|
20
22
|
documents?: Record<string, PreviewDocumentMapping>;
|
|
21
23
|
}
|
|
22
24
|
export interface PreviewTarget {
|
|
@@ -27,13 +29,23 @@ export interface PreviewTarget {
|
|
|
27
29
|
type?: string;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function templatePath(config: PreviewConfig, target: PreviewTarget): string | null | undefined {
|
|
33
|
+
const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : target.kind === "document" ? config.documents?.[target.type ?? ""] : target.kind === "page" ? config.pages?.[target.slug] : undefined;
|
|
34
|
+
return mapping && typeof mapping === "object" && mapping.template !== undefined ? previewRoute(mapping.template, true) : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
30
37
|
export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew = false): string | null {
|
|
38
|
+
const template = isNew ? templatePath(config, target) : undefined;
|
|
39
|
+
if (template !== undefined) return template;
|
|
31
40
|
const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : undefined;
|
|
32
|
-
const
|
|
33
|
-
|
|
41
|
+
const page = config.pages?.[target.slug];
|
|
42
|
+
const path = target.kind === "document" ? pageRoute(target.slug, config.documents?.[target.type ?? ""]) : target.kind === "page" ? (typeof page === "string" ? page : page?.url) : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
|
|
43
|
+
return previewRoute(path);
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
|
|
47
|
+
const template = mode === "draft" ? templatePath(config, target) : undefined;
|
|
48
|
+
if (template !== undefined) return template;
|
|
37
49
|
if (mode !== "draft" && target.kind !== "page" && target.urlSlug !== undefined) {
|
|
38
50
|
const slug = slugify(target.urlSlug);
|
|
39
51
|
if (!slug) return null;
|
|
@@ -64,6 +76,14 @@ export function anchorValue(values: unknown, path: string): { found: boolean; va
|
|
|
64
76
|
|
|
65
77
|
const markdown = new MarkdownIt({ html: false, linkify: false });
|
|
66
78
|
const rendered = new WeakMap<Element, string>();
|
|
79
|
+
const extensionPreviews = new WeakMap<Document, Map<Element, () => void>>();
|
|
80
|
+
|
|
81
|
+
export function cleanupPreview(doc: Document) {
|
|
82
|
+
const previews = extensionPreviews.get(doc);
|
|
83
|
+
if (!previews) return;
|
|
84
|
+
for (const [element, cleanup] of previews) { cleanup(); rendered.delete(element); }
|
|
85
|
+
previews.clear();
|
|
86
|
+
}
|
|
67
87
|
|
|
68
88
|
export interface PatchDocuments {
|
|
69
89
|
editingBody?: Element | null;
|
|
@@ -80,6 +100,13 @@ function rebindTree(root: Element | DocumentFragment, update: (element: Element)
|
|
|
80
100
|
}
|
|
81
101
|
|
|
82
102
|
export function patchPreview(doc: Document, values: Record<string, unknown>, body: "" | import("./article-body").ArticleBody, options: PatchDocuments = {}) {
|
|
103
|
+
let previews = extensionPreviews.get(doc);
|
|
104
|
+
if (!previews) {
|
|
105
|
+
previews = new Map();
|
|
106
|
+
extensionPreviews.set(doc, previews);
|
|
107
|
+
doc.defaultView?.addEventListener("pagehide", () => cleanupPreview(doc), { once: true });
|
|
108
|
+
}
|
|
109
|
+
for (const [element, cleanup] of previews) if (!element.isConnected) { cleanup(); previews.delete(element); rendered.delete(element); }
|
|
83
110
|
const sourceFor = (element: Element): Record<string, unknown> | undefined => {
|
|
84
111
|
const id = element.getAttribute("data-rigel-doc");
|
|
85
112
|
if (id === "") return {};
|
|
@@ -158,11 +185,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
|
|
|
158
185
|
const blocks = el.dataset.rigelRichtext === "$body";
|
|
159
186
|
const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
|
|
160
187
|
if (rendered.get(el) === source) continue;
|
|
161
|
-
rendered.set(el, source);
|
|
162
188
|
const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody, { preview: true }) : "") : markdown.render(source), {
|
|
163
189
|
RETURN_DOM_FRAGMENT: true,
|
|
164
190
|
ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td", ...(blocks ? ["video"] : [])],
|
|
165
|
-
|
|
191
|
+
ALLOW_DATA_ATTR: false,
|
|
192
|
+
ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope", ...(blocks ? ["controls", "muted", "preload", "playsinline", "poster", "class", "data-rigel-embed", "data-rigel-video-id", "data-rigel-video-title", "data-rigel-extension", "data-rigel-extension-data"] : [])],
|
|
166
193
|
});
|
|
167
194
|
if (blocks) for (const marker of fragment.querySelectorAll<HTMLElement>("[data-rigel-embed]")) {
|
|
168
195
|
marker.replaceWith(createEmbedPreview({ provider: marker.dataset.rigelEmbed, id: marker.dataset.rigelVideoId, title: marker.dataset.rigelVideoTitle }, doc));
|
|
@@ -170,7 +197,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
|
|
|
170
197
|
for (const link of fragment.querySelectorAll("[href], [src]")) {
|
|
171
198
|
for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
|
|
172
199
|
}
|
|
200
|
+
previews.get(el)?.();
|
|
201
|
+
previews.delete(el);
|
|
173
202
|
el.replaceChildren(fragment);
|
|
203
|
+
if (blocks) previews.set(el, mountExtensionPreviews(el));
|
|
204
|
+
rendered.set(el, source);
|
|
174
205
|
}
|
|
175
206
|
for (const [anchor, attr, tag] of [["image", "src", "IMG"], ["alt", "alt", "IMG"], ["href", "href", "A"]]) {
|
|
176
207
|
for (const el of doc.querySelectorAll(`[data-rigel-${anchor}]`)) {
|
package/src/routes/local-api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
+
import { requestTranslator } from "../editor/i18n/server";
|
|
2
3
|
import { writeFile, readFile } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { applyValues } from "./serialize";
|
|
@@ -6,6 +7,7 @@ import { applyValues } from "./serialize";
|
|
|
6
7
|
export const prerender = false;
|
|
7
8
|
|
|
8
9
|
export const POST: APIRoute = async ({ request }) => {
|
|
10
|
+
const t = requestTranslator(request);
|
|
9
11
|
if (import.meta.env.PROD) {
|
|
10
12
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
11
13
|
}
|
|
@@ -18,21 +20,21 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
18
20
|
}
|
|
19
21
|
const slug = String(body.slug ?? "");
|
|
20
22
|
const values = body.values ?? {};
|
|
21
|
-
if (!slug) return new Response("
|
|
23
|
+
if (!slug) return new Response(t("local.missingSlug"), { status: 400 });
|
|
22
24
|
|
|
23
25
|
const file = path.resolve("src/content/copy", `${slug}.ts`);
|
|
24
26
|
let source: string;
|
|
25
27
|
try {
|
|
26
28
|
source = await readFile(file, "utf8");
|
|
27
29
|
} catch {
|
|
28
|
-
return new Response(
|
|
30
|
+
return new Response(t("local.copyNotFound", { slug }), { status: 404 });
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
let next: string;
|
|
32
34
|
try {
|
|
33
35
|
next = applyValues(source, values);
|
|
34
36
|
} catch {
|
|
35
|
-
return new Response(
|
|
37
|
+
return new Response(t("local.copyValuesNotFound", { slug }), {
|
|
36
38
|
status: 422,
|
|
37
39
|
});
|
|
38
40
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
+
import { requestTranslator } from "../editor/i18n/server";
|
|
3
|
+
import type { Translator } from "../editor/i18n/format";
|
|
2
4
|
import path from "node:path";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
4
6
|
import config from "virtual:rigel-config";
|
|
@@ -16,18 +18,18 @@ function guard(): { schema: Schema; root: string } | Response {
|
|
|
16
18
|
return { schema: config.schema as Schema, root: fileURLToPath(config.root) };
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
async function readBody(request: Request): Promise<Record<string, unknown>> {
|
|
21
|
+
async function readBody(request: Request, t: Translator): Promise<Record<string, unknown>> {
|
|
20
22
|
const length = Number(request.headers.get("content-length") ?? "0");
|
|
21
|
-
if (length > MAX_BODY) throw new DocumentStoreError("
|
|
23
|
+
if (length > MAX_BODY) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
|
|
22
24
|
const raw = await request.text();
|
|
23
|
-
if (Buffer.byteLength(raw) > MAX_BODY) throw new DocumentStoreError("
|
|
25
|
+
if (Buffer.byteLength(raw) > MAX_BODY) throw new DocumentStoreError(t("local.documentTooLarge"), 413);
|
|
24
26
|
let body: unknown;
|
|
25
27
|
try {
|
|
26
28
|
body = JSON.parse(raw);
|
|
27
29
|
} catch {
|
|
28
30
|
throw new DocumentStoreError("Invalid JSON", 400);
|
|
29
31
|
}
|
|
30
|
-
if (!isPlainObject(body) || !isPlainObject(body.data)) throw new DocumentStoreError("
|
|
32
|
+
if (!isPlainObject(body) || !isPlainObject(body.data)) throw new DocumentStoreError(t("local.dataRequired"), 400);
|
|
31
33
|
return body.data;
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -39,42 +41,44 @@ function decodeParam(value: string): string {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
export const GET: APIRoute = async ({ params }) => {
|
|
44
|
+
export const GET: APIRoute = async ({ request, params }) => {
|
|
45
|
+
const t = requestTranslator(request);
|
|
43
46
|
const guarded = guard();
|
|
44
47
|
if (guarded instanceof Response) return guarded;
|
|
45
48
|
const id = decodeParam(String(params.id ?? ""));
|
|
46
49
|
const target = validateDocumentId(guarded.schema, id);
|
|
47
|
-
if (!target) return new Response("
|
|
50
|
+
if (!target) return new Response(t("local.unknownDocument"), { status: 404 });
|
|
48
51
|
try {
|
|
49
|
-
const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
|
|
52
|
+
const documents = await readDocuments(guarded.schema, guarded.root, config.preview, t);
|
|
50
53
|
const document = documents.find((entry) => entry.id === id);
|
|
51
|
-
if (!document) return new Response("
|
|
54
|
+
if (!document) return new Response(t("local.unknownDocument"), { status: 404 });
|
|
52
55
|
return Response.json(document);
|
|
53
56
|
} catch (error) {
|
|
54
57
|
if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
|
|
55
|
-
return new Response("
|
|
58
|
+
return new Response(t("local.readDocumentFailed"), { status: 500 });
|
|
56
59
|
}
|
|
57
60
|
};
|
|
58
61
|
|
|
59
62
|
const handle: APIRoute = async ({ request, params }) => {
|
|
63
|
+
const t = requestTranslator(request);
|
|
60
64
|
const guarded = guard();
|
|
61
65
|
if (guarded instanceof Response) return guarded;
|
|
62
|
-
if (request.method === "DELETE") return new Response("
|
|
66
|
+
if (request.method === "DELETE") return new Response(t("local.documentDeveloperOnly"), { status: 405, headers: { Allow: "GET, POST, PUT" } });
|
|
63
67
|
if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
|
|
64
68
|
const id = decodeParam(String(params.id ?? ""));
|
|
65
69
|
const target = validateDocumentId(guarded.schema, id);
|
|
66
|
-
if (!target) return new Response("
|
|
70
|
+
if (!target) return new Response(t("local.unknownDocumentTarget"), { status: 400 });
|
|
67
71
|
try {
|
|
68
|
-
const data = await readBody(request);
|
|
69
|
-
const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
|
|
72
|
+
const data = await readBody(request, t);
|
|
73
|
+
const documents = await readDocuments(guarded.schema, guarded.root, config.preview, t);
|
|
70
74
|
const next = { ...data, _id: id, _type: target.type.name };
|
|
71
|
-
validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
|
|
72
|
-
await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview, config.jsonOutput);
|
|
75
|
+
validateDocument(guarded.schema, target.type.name, target.slug, next, documents, t);
|
|
76
|
+
await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview, config.jsonOutput, t);
|
|
73
77
|
return new Response(null, { status: 204 });
|
|
74
78
|
} catch (error) {
|
|
75
79
|
if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
|
|
76
80
|
if (error instanceof SchemaError) return new Response(error.message, { status: 400 });
|
|
77
|
-
return new Response("
|
|
81
|
+
return new Response(t("local.saveDocumentFailed"), { status: 500 });
|
|
78
82
|
}
|
|
79
83
|
};
|
|
80
84
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
+
import { requestTranslator } from "../editor/i18n/server";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { lstat, readFile, writeFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -11,9 +12,10 @@ import config from "virtual:rigel-config";
|
|
|
11
12
|
export const prerender = false;
|
|
12
13
|
|
|
13
14
|
export const GET: APIRoute = async ({ params, request }) => {
|
|
15
|
+
const t = requestTranslator(request);
|
|
14
16
|
const filename = params.filename ?? "";
|
|
15
17
|
if (import.meta.env.PROD || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
|
|
16
|
-
return new Response("
|
|
18
|
+
return new Response(t("local.notFound"), { status: 404 });
|
|
17
19
|
}
|
|
18
20
|
try {
|
|
19
21
|
const file = path.join(await mediaDirectory(config.publicDir!, config.mediaDir), filename);
|
|
@@ -21,11 +23,12 @@ export const GET: APIRoute = async ({ params, request }) => {
|
|
|
21
23
|
const bytes = await readFile(file);
|
|
22
24
|
return new Response(new Uint8Array(bytes), { headers: mediaHeaders(filename, new URL(request.url).searchParams.get("preview") === "1") });
|
|
23
25
|
} catch {
|
|
24
|
-
return new Response("
|
|
26
|
+
return new Response(t("local.notFound"), { status: 404 });
|
|
25
27
|
}
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
export const POST: APIRoute = async ({ request }) => {
|
|
31
|
+
const t = requestTranslator(request);
|
|
29
32
|
if (import.meta.env.PROD) {
|
|
30
33
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
31
34
|
}
|
|
@@ -36,16 +39,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
36
39
|
const supplied = request.headers.get("X-Rigel-Filename");
|
|
37
40
|
const fallback = ({ "image/jpeg": "jpg", "image/png": "png", "image/webp": "webp" } as Record<string, string>)[mime ?? ""];
|
|
38
41
|
const metadata = supplied ? (safeMediaName(supplied) ? mediaMetadata(supplied) : undefined) : fallback ? mediaMetadata(`image.${fallback}`) : undefined;
|
|
39
|
-
if (!metadata || metadata.type !== mime) return new Response("
|
|
42
|
+
if (!metadata || metadata.type !== mime) return new Response(t("local.unsupportedMedia"), { status: 415 });
|
|
40
43
|
const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
41
44
|
const length = request.headers.get("Content-Length");
|
|
42
45
|
if (length !== null && !/^\d+$/.test(length)) {
|
|
43
46
|
return new Response("Invalid Content-Length", { status: 400 });
|
|
44
47
|
}
|
|
45
48
|
if (length !== null && Number(length) > limit) {
|
|
46
|
-
return new Response(
|
|
49
|
+
return new Response(t("local.mediaTooLarge", { limit: limit / 1024 / 1024 }), { status: 413 });
|
|
47
50
|
}
|
|
48
|
-
if (!request.body) return new Response("
|
|
51
|
+
if (!request.body) return new Response(t("local.emptyMedia"), { status: 400 });
|
|
49
52
|
const chunks: Uint8Array[] = [];
|
|
50
53
|
let size = 0;
|
|
51
54
|
const reader = request.body.getReader();
|
|
@@ -56,19 +59,19 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
56
59
|
size += value.byteLength;
|
|
57
60
|
if (size > limit) {
|
|
58
61
|
void reader.cancel().catch(() => {});
|
|
59
|
-
return new Response(
|
|
62
|
+
return new Response(t("local.mediaTooLarge", { limit: limit / 1024 / 1024 }), { status: 413 });
|
|
60
63
|
}
|
|
61
64
|
chunks.push(value);
|
|
62
65
|
}
|
|
63
66
|
} catch {
|
|
64
|
-
return new Response("
|
|
67
|
+
return new Response(t("local.readImageFailed"), { status: 400 });
|
|
65
68
|
} finally {
|
|
66
69
|
reader.releaseLock();
|
|
67
70
|
}
|
|
68
71
|
const bytes = Buffer.concat(chunks, size);
|
|
69
|
-
if (!bytes.length) return new Response("
|
|
72
|
+
if (!bytes.length) return new Response(t("local.emptyImage"), { status: 400 });
|
|
70
73
|
const extension = metadata.extension === "jpeg" ? "jpg" : metadata.extension;
|
|
71
|
-
if (!validMediaBytes(bytes, extension)) return new Response("
|
|
74
|
+
if (!validMediaBytes(bytes, extension)) return new Response(t("local.mediaBytesMismatch"), { status: 415 });
|
|
72
75
|
const filename = `${randomUUID()}.${extension}`;
|
|
73
76
|
let url = "";
|
|
74
77
|
try {
|
|
@@ -80,12 +83,13 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
80
83
|
const author = (request.headers.get("X-Rigel-Author") ?? "").trim().replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80);
|
|
81
84
|
await updateMediaSidecar(directory, url, author ? { author } : {}, { name: (supplied ?? filename).slice(0, 120) });
|
|
82
85
|
} catch {
|
|
83
|
-
return new Response("
|
|
86
|
+
return new Response(t("local.saveImageFailed"), { status: 500 });
|
|
84
87
|
}
|
|
85
88
|
return Response.json({ url });
|
|
86
89
|
};
|
|
87
90
|
|
|
88
91
|
export const PUT: APIRoute = async ({ request }) => {
|
|
92
|
+
const t = requestTranslator(request);
|
|
89
93
|
if (import.meta.env.PROD) {
|
|
90
94
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
91
95
|
}
|
|
@@ -96,9 +100,9 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
96
100
|
try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
|
|
97
101
|
let directory: string;
|
|
98
102
|
try { directory = await mediaDirectory(config.publicDir!, config.mediaDir, true); }
|
|
99
|
-
catch { return new Response("
|
|
103
|
+
catch { return new Response(t("local.mediaDirectoryUnavailable"), { status: 500 }); }
|
|
100
104
|
const assets = await listMedia(config.publicDir!, config.mediaDir);
|
|
101
|
-
const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)));
|
|
105
|
+
const valid = validateMetaUpdate(body, new Set(assets.map(asset => asset.url)), t);
|
|
102
106
|
if (!valid.ok) return new Response(valid.error, { status: 400 });
|
|
103
107
|
const patch: { name?: string; title?: string; description?: string; alt?: string } = {};
|
|
104
108
|
if (valid.value.name !== undefined) patch.name = valid.value.name;
|
|
@@ -109,21 +113,22 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
109
113
|
const file = path.join(fileURLToPath(config.publicDir!), ...valid.value.url.slice(1).split("/").map(decodeURIComponent));
|
|
110
114
|
await updateMediaSidecar(directory, valid.value.url, patch, {}, file);
|
|
111
115
|
} catch (error) {
|
|
112
|
-
return new Response("
|
|
116
|
+
return new Response(t("local.saveMetadataFailed"), { status: (error as NodeJS.ErrnoException).code === "ENOENT" ? 404 : 500 });
|
|
113
117
|
}
|
|
114
118
|
return Response.json((await listMedia(config.publicDir!, config.mediaDir)).find(asset => asset.url === valid.value.url) ?? { url: valid.value.url });
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
export const DELETE: APIRoute = async ({ request }) => {
|
|
122
|
+
const t = requestTranslator(request);
|
|
118
123
|
if (import.meta.env.PROD) return new Response("Local backend is dev-only", { status: 404 });
|
|
119
124
|
if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
|
|
120
125
|
let body: unknown;
|
|
121
126
|
try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
|
|
122
|
-
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("url" in body) || typeof body.url !== "string") return new Response("
|
|
127
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || !("url" in body) || typeof body.url !== "string") return new Response(t("local.mediaUrlRequired"), { status: 400 });
|
|
123
128
|
try {
|
|
124
|
-
if (!await deleteMediaFile(config.publicDir!, config.mediaDir, body.url)) return new Response("
|
|
129
|
+
if (!await deleteMediaFile(config.publicDir!, config.mediaDir, body.url)) return new Response(t("local.fileNotFound"), { status: 404 });
|
|
125
130
|
return new Response(null, { status: 204 });
|
|
126
131
|
} catch {
|
|
127
|
-
return new Response("
|
|
132
|
+
return new Response(t("local.deleteFileFailed"), { status: 500 });
|
|
128
133
|
}
|
|
129
134
|
};
|
package/src/routes/local-menu.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { APIRoute } from "astro";
|
|
2
|
+
import { requestTranslator } from "../editor/i18n/server";
|
|
2
3
|
import { serializeContentJson } from "../json-output";
|
|
3
4
|
import { constants } from "node:fs";
|
|
4
5
|
import { lstat, open, realpath, rename, unlink } from "node:fs/promises";
|
|
@@ -28,20 +29,21 @@ async function menuFile(slug: string): Promise<string> {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const handle: APIRoute = async ({ request, params }) => {
|
|
32
|
+
const t = requestTranslator(request);
|
|
31
33
|
if (import.meta.env.PROD || config.backend !== "local" || !config.menus)
|
|
32
34
|
return new Response("Local menus are not enabled", { status: 404 });
|
|
33
35
|
const slug = params.slug ?? "";
|
|
34
|
-
if (!MENU_SLUG.test(slug)) return new Response("
|
|
36
|
+
if (!MENU_SLUG.test(slug)) return new Response(t("local.invalidMenuSlug"), { status: 400 });
|
|
35
37
|
const method = request.method;
|
|
36
38
|
if (method !== "GET" && method !== "PUT")
|
|
37
|
-
return new Response("
|
|
39
|
+
return new Response(t("local.menuDeveloperOnly"), { status: 405, headers: { Allow: "GET, PUT" } });
|
|
38
40
|
if (method !== "GET" && request.headers.get("Origin") !== new URL(request.url).origin)
|
|
39
41
|
return new Response("Same-origin request required", { status: 403 });
|
|
40
42
|
let menu: MenuDocument | undefined;
|
|
41
43
|
if (method === "PUT") {
|
|
42
44
|
try {
|
|
43
|
-
menu = validateMenu(await request.json(), config.menus.maxDepth ?? 3);
|
|
44
|
-
if (menu.slug !== slug) throw new Error("
|
|
45
|
+
menu = validateMenu(await request.json(), config.menus.maxDepth ?? 3, t);
|
|
46
|
+
if (menu.slug !== slug) throw new Error(t("local.menuSlugMismatch"));
|
|
45
47
|
} catch (error) {
|
|
46
48
|
return new Response(error instanceof SyntaxError ? "Invalid JSON" : (error as Error).message, { status: 400 });
|
|
47
49
|
}
|
|
@@ -52,7 +54,7 @@ const handle: APIRoute = async ({ request, params }) => {
|
|
|
52
54
|
if (method === "GET") {
|
|
53
55
|
const input = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
54
56
|
try {
|
|
55
|
-
const result = validateMenu(JSON.parse(await input.readFile("utf8")), config.menus.maxDepth ?? 3);
|
|
57
|
+
const result = validateMenu(JSON.parse(await input.readFile("utf8")), config.menus.maxDepth ?? 3, t);
|
|
56
58
|
if (result.slug !== slug) throw new Error("Stored menu slug does not match filename");
|
|
57
59
|
return Response.json(result);
|
|
58
60
|
} finally { await input.close(); }
|
|
@@ -69,8 +71,8 @@ const handle: APIRoute = async ({ request, params }) => {
|
|
|
69
71
|
return new Response(null, { status: 204 });
|
|
70
72
|
} catch (error) {
|
|
71
73
|
const code = (error as NodeJS.ErrnoException).code;
|
|
72
|
-
if (code === "ENOENT") return new Response("
|
|
73
|
-
return new Response("
|
|
74
|
+
if (code === "ENOENT") return new Response(t("local.menuNotFound"), { status: 404 });
|
|
75
|
+
return new Response(t("local.menuStorageUnavailable"), { status: 500 });
|
|
74
76
|
} finally {
|
|
75
77
|
if (temporary) await unlink(temporary).catch(() => {});
|
|
76
78
|
}
|