@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
|
@@ -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.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { pageRoute } from "./schema/paths";
|
|
|
4
4
|
import { slugify } from "./slugs";
|
|
5
5
|
import { renderArticle } from "./article-render";
|
|
6
6
|
import { createEmbedPreview } from "./editor/lib/embed-preview";
|
|
7
|
+
import { mountExtensionPreviews } from "./editor/lib/extension-preview";
|
|
7
8
|
import { safePreviewUrl, previewBaseUrl, previewRoute } from "./preview-urls";
|
|
8
9
|
export { safePreviewUrl } from "./preview-urls";
|
|
9
10
|
|
|
@@ -75,6 +76,14 @@ export function anchorValue(values: unknown, path: string): { found: boolean; va
|
|
|
75
76
|
|
|
76
77
|
const markdown = new MarkdownIt({ html: false, linkify: false });
|
|
77
78
|
const rendered = new WeakMap<Element, string>();
|
|
79
|
+
const extensionPreviews = new WeakMap<Document, Map<Element, () => void>>();
|
|
80
|
+
|
|
81
|
+
export function cleanupPreview(doc: Document) {
|
|
82
|
+
const previews = extensionPreviews.get(doc);
|
|
83
|
+
if (!previews) return;
|
|
84
|
+
for (const [element, cleanup] of previews) { cleanup(); rendered.delete(element); }
|
|
85
|
+
previews.clear();
|
|
86
|
+
}
|
|
78
87
|
|
|
79
88
|
export interface PatchDocuments {
|
|
80
89
|
editingBody?: Element | null;
|
|
@@ -91,6 +100,13 @@ function rebindTree(root: Element | DocumentFragment, update: (element: Element)
|
|
|
91
100
|
}
|
|
92
101
|
|
|
93
102
|
export function patchPreview(doc: Document, values: Record<string, unknown>, body: "" | import("./article-body").ArticleBody, options: PatchDocuments = {}) {
|
|
103
|
+
let previews = extensionPreviews.get(doc);
|
|
104
|
+
if (!previews) {
|
|
105
|
+
previews = new Map();
|
|
106
|
+
extensionPreviews.set(doc, previews);
|
|
107
|
+
doc.defaultView?.addEventListener("pagehide", () => cleanupPreview(doc), { once: true });
|
|
108
|
+
}
|
|
109
|
+
for (const [element, cleanup] of previews) if (!element.isConnected) { cleanup(); previews.delete(element); rendered.delete(element); }
|
|
94
110
|
const sourceFor = (element: Element): Record<string, unknown> | undefined => {
|
|
95
111
|
const id = element.getAttribute("data-rigel-doc");
|
|
96
112
|
if (id === "") return {};
|
|
@@ -169,11 +185,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
|
|
|
169
185
|
const blocks = el.dataset.rigelRichtext === "$body";
|
|
170
186
|
const source = blocks ? JSON.stringify(result.value) : typeof result.value === "string" ? result.value : "";
|
|
171
187
|
if (rendered.get(el) === source) continue;
|
|
172
|
-
rendered.set(el, source);
|
|
173
188
|
const fragment = DOMPurify.sanitize(blocks ? (result.value ? renderArticle(result.value as import("./article-body").ArticleBody, { preview: true }) : "") : markdown.render(source), {
|
|
174
189
|
RETURN_DOM_FRAGMENT: true,
|
|
175
190
|
ALLOWED_TAGS: ["p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "aside", "figure", "figcaption", "pre", "code", "strong", "em", "s", "u", "mark", "sub", "sup", "a", "img", "br", "hr", "table", "thead", "tbody", "tr", "th", "td", ...(blocks ? ["video"] : [])],
|
|
176
|
-
|
|
191
|
+
ALLOW_DATA_ATTR: false,
|
|
192
|
+
ALLOWED_ATTR: ["href", "src", "alt", "title", "start", "scope", ...(blocks ? ["controls", "muted", "preload", "playsinline", "poster", "class", "data-rigel-embed", "data-rigel-video-id", "data-rigel-video-title", "data-rigel-extension", "data-rigel-extension-data"] : [])],
|
|
177
193
|
});
|
|
178
194
|
if (blocks) for (const marker of fragment.querySelectorAll<HTMLElement>("[data-rigel-embed]")) {
|
|
179
195
|
marker.replaceWith(createEmbedPreview({ provider: marker.dataset.rigelEmbed, id: marker.dataset.rigelVideoId, title: marker.dataset.rigelVideoTitle }, doc));
|
|
@@ -181,7 +197,11 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
|
|
|
181
197
|
for (const link of fragment.querySelectorAll("[href], [src]")) {
|
|
182
198
|
for (const attr of ["href", "src"]) if (link.hasAttribute(attr) && !safePreviewUrl(link.getAttribute(attr)!, attr === "src")) link.removeAttribute(attr);
|
|
183
199
|
}
|
|
200
|
+
previews.get(el)?.();
|
|
201
|
+
previews.delete(el);
|
|
184
202
|
el.replaceChildren(fragment);
|
|
203
|
+
if (blocks) previews.set(el, mountExtensionPreviews(el));
|
|
204
|
+
rendered.set(el, source);
|
|
185
205
|
}
|
|
186
206
|
for (const [anchor, attr, tag] of [["image", "src", "IMG"], ["alt", "alt", "IMG"], ["href", "href", "A"]]) {
|
|
187
207
|
for (const el of doc.querySelectorAll(`[data-rigel-${anchor}]`)) {
|
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
|
}
|
package/src/routes/local-post.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 { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
|
|
4
5
|
import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
|
|
@@ -7,6 +8,8 @@ import path from "node:path";
|
|
|
7
8
|
|
|
8
9
|
import { validateBody, type ArticleBody } from "../article-body";
|
|
9
10
|
import config from "virtual:rigel-config";
|
|
11
|
+
import extensions from "virtual:rigel-extensions";
|
|
12
|
+
import { resolveExtensions, validateExtensionBlocks } from "../extensions";
|
|
10
13
|
import { validateCollectionValues } from "../manifest/collections";
|
|
11
14
|
import { readDocuments } from "../schema/store";
|
|
12
15
|
import { fileURLToPath } from "node:url";
|
|
@@ -33,35 +36,37 @@ export const POST: APIRoute = (context) => {
|
|
|
33
36
|
};
|
|
34
37
|
|
|
35
38
|
const savePost: APIRoute = async ({ request, params }) => {
|
|
39
|
+
const t = requestTranslator(request);
|
|
36
40
|
if (import.meta.env.PROD) {
|
|
37
41
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
38
42
|
}
|
|
39
43
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
40
44
|
const slug = safeSlug(String(params.slug ?? ""));
|
|
41
45
|
if (!folder || !slug) {
|
|
42
|
-
return new Response("
|
|
46
|
+
return new Response(t("local.unknownCollectionSlug"), { status: 404 });
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
let body: { frontmatter: Record<string, unknown>; body: ArticleBody };
|
|
46
50
|
try {
|
|
47
51
|
body = await request.json();
|
|
48
|
-
if (!body || typeof body !== "object" || !body.frontmatter || typeof body.frontmatter !== "object" || Array.isArray(body.frontmatter)) throw new Error("
|
|
52
|
+
if (!body || typeof body !== "object" || !body.frontmatter || typeof body.frontmatter !== "object" || Array.isArray(body.frontmatter)) throw new Error(t("local.invalidArticle"));
|
|
49
53
|
} catch {
|
|
50
54
|
return new Response("Invalid JSON", { status: 400 });
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
const file = path.resolve(folder, `${slug}.json`);
|
|
54
58
|
try {
|
|
55
|
-
validateBody(body.body);
|
|
59
|
+
validateBody(body.body, t);
|
|
60
|
+
validateExtensionBlocks(body.body, resolveExtensions(extensions).blocks);
|
|
56
61
|
const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
|
|
57
|
-
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview) : [];
|
|
58
|
-
validateCollectionValues(fields, body.frontmatter, documents);
|
|
59
|
-
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug);
|
|
62
|
+
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview, t) : [];
|
|
63
|
+
validateCollectionValues(fields, body.frontmatter, documents, t);
|
|
64
|
+
if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug, t);
|
|
60
65
|
const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; });
|
|
61
66
|
const entries = await Promise.all(names.filter(name => name.endsWith(".json") && name !== `${slug}.json`).map(async name => ({ slug: name.slice(0, -5), values: JSON.parse(await readFile(path.join(folder, name), "utf8")).frontmatter })));
|
|
62
|
-
assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }]);
|
|
67
|
+
assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }], t);
|
|
63
68
|
} catch (error) {
|
|
64
|
-
return new Response(error instanceof Error ? error.message : "
|
|
69
|
+
return new Response(error instanceof Error ? error.message : t("local.invalidArticle"), { status: 400 });
|
|
65
70
|
}
|
|
66
71
|
await mkdir(path.dirname(file), { recursive: true });
|
|
67
72
|
const fm = { ...body.frontmatter };
|
|
@@ -71,7 +76,7 @@ const savePost: APIRoute = async ({ request, params }) => {
|
|
|
71
76
|
if (request.headers.get("If-None-Match") === "*") {
|
|
72
77
|
try { await link(temporary, file); }
|
|
73
78
|
catch (error) {
|
|
74
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response("
|
|
79
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return new Response(t("local.postExists"), { status: 409 });
|
|
75
80
|
throw error;
|
|
76
81
|
}
|
|
77
82
|
} else await rename(temporary, file);
|
|
@@ -79,20 +84,21 @@ const savePost: APIRoute = async ({ request, params }) => {
|
|
|
79
84
|
return new Response(null, { status: 204 });
|
|
80
85
|
};
|
|
81
86
|
|
|
82
|
-
export const DELETE: APIRoute = async ({ params }) => {
|
|
87
|
+
export const DELETE: APIRoute = async ({ request, params }) => {
|
|
88
|
+
const t = requestTranslator(request);
|
|
83
89
|
if (import.meta.env.PROD) {
|
|
84
90
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
85
91
|
}
|
|
86
92
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
87
93
|
const slug = safeSlug(String(params.slug ?? ""));
|
|
88
94
|
if (!folder || !slug) {
|
|
89
|
-
return new Response("
|
|
95
|
+
return new Response(t("local.unknownCollectionSlug"), { status: 404 });
|
|
90
96
|
}
|
|
91
97
|
const file = path.resolve(folder, `${slug}.json`);
|
|
92
98
|
try {
|
|
93
99
|
await readFile(file); // only delete existing files
|
|
94
100
|
} catch {
|
|
95
|
-
return new Response("
|
|
101
|
+
return new Response(t("local.postNotFound"), { status: 404 });
|
|
96
102
|
}
|
|
97
103
|
await rm(file);
|
|
98
104
|
return new Response(null, { status: 204 });
|
|
@@ -2,6 +2,7 @@ import { lstat, mkdir, readFile, readdir, realpath, rename, unlink, writeFile }
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { mediaMetadata, safeMediaName } from "../media-types";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { systemMessage, type Translator } from "../editor/i18n/format";
|
|
5
6
|
|
|
6
7
|
export const imageFile = /\\.(jpe?g|png|webp|gif|avif)$/i;
|
|
7
8
|
const SIDECAR = ".rigel-media.json";
|
|
@@ -106,29 +107,30 @@ export function deleteMediaFile(publicDir: string, folder: string | undefined, u
|
|
|
106
107
|
export function validateMetaUpdate(
|
|
107
108
|
body: unknown,
|
|
108
109
|
existing: Set<string>,
|
|
110
|
+
message: Translator = systemMessage,
|
|
109
111
|
): { ok: true; value: { url: string; name?: string; title?: string; description?: string; alt?: string } } | { ok: false; error: string } {
|
|
110
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: "
|
|
112
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return { ok: false, error: message("local.invalidMetadataBody") };
|
|
111
113
|
const b = body as Record<string, unknown>;
|
|
112
114
|
const allowed = ["url", "name", "title", "description", "alt"];
|
|
113
|
-
if (Object.keys(b).some(key => !allowed.includes(key))) return { ok: false, error: "
|
|
114
|
-
if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: "
|
|
115
|
+
if (Object.keys(b).some(key => !allowed.includes(key))) return { ok: false, error: message("local.unknownMetadataField") };
|
|
116
|
+
if (typeof b.url !== "string" || !existing.has(b.url)) return { ok: false, error: message("local.unknownAsset") };
|
|
115
117
|
const value: { url: string; name?: string; title?: string; description?: string; alt?: string } = { url: b.url };
|
|
116
118
|
if ("name" in b) {
|
|
117
119
|
const candidate = typeof b.name === "string" ? b.name.trim() : b.name;
|
|
118
|
-
if (!safeDisplayName(candidate)) return { ok: false, error: "
|
|
120
|
+
if (!safeDisplayName(candidate)) return { ok: false, error: message("local.invalidDisplayName") };
|
|
119
121
|
value.name = typeof candidate === "string" ? candidate : undefined;
|
|
120
122
|
}
|
|
121
123
|
if ("title" in b) {
|
|
122
|
-
if (typeof b.title !== "string") return { ok: false, error: "
|
|
124
|
+
if (typeof b.title !== "string") return { ok: false, error: message("local.metadataTitleString") };
|
|
123
125
|
value.title = sanitize(b.title, 200);
|
|
124
126
|
}
|
|
125
127
|
if ("description" in b) {
|
|
126
|
-
if (typeof b.description !== "string") return { ok: false, error: "
|
|
128
|
+
if (typeof b.description !== "string") return { ok: false, error: message("local.metadataDescriptionString") };
|
|
127
129
|
value.description = sanitize(b.description, 1000);
|
|
128
130
|
}
|
|
129
131
|
if ("alt" in b) {
|
|
130
|
-
if (typeof b.alt !== "string") return { ok: false, error: "
|
|
131
|
-
if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: "
|
|
132
|
+
if (typeof b.alt !== "string") return { ok: false, error: message("local.metadataAltString") };
|
|
133
|
+
if (!mediaMetadata(b.url)?.type.startsWith("image/")) return { ok: false, error: message("local.metadataAltImagesOnly") };
|
|
132
134
|
value.alt = sanitize(b.alt.replace(/\s+/g, " "), 1000) ?? "";
|
|
133
135
|
}
|
|
134
136
|
return { ok: true, value };
|