@rigel-cms/rigel 1.4.0 → 1.5.0-rc.2
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 +2 -0
- package/docs/media.md +1 -1
- package/docs/preview.md +15 -0
- package/package.json +1 -1
- package/src/config.ts +2 -1
- package/src/editor/App.svelte +29 -12
- package/src/editor/i18n/de/shell.json +7 -0
- package/src/editor/i18n/en/shell.json +7 -0
- package/src/editor/i18n/es/shell.json +7 -0
- package/src/editor/i18n/fr/shell.json +7 -0
- package/src/editor/i18n/it/shell.json +7 -0
- package/src/editor/i18n/ja/shell.json +7 -0
- package/src/editor/i18n/nl/shell.json +7 -0
- package/src/editor/i18n/ru/shell.json +7 -0
- package/src/editor/i18n/zh/shell.json +7 -0
- package/src/editor/lib/ImagePicker.svelte +14 -9
- package/src/editor/lib/PublishDialog.svelte +4 -3
- package/src/editor/lib/demo-backend.ts +41 -0
- package/src/editor/lib/images.ts +4 -0
- package/src/manifest/types.ts +1 -1
- package/src/preview.ts +16 -1
- package/src/routes/local-api.ts +2 -1
- package/src/routes/local-media.ts +4 -4
- package/src/routes/local-post.ts +2 -2
- package/src/vite/manifest-plugin.ts +1 -1
package/README.md
CHANGED
|
@@ -16,6 +16,8 @@ Edit page copy, structured content and articles at `/rigel/`. Your content stays
|
|
|
16
16
|
|
|
17
17
|
Rigel edits content, not `.astro` components. Developers define the editable fields and add preview bindings to their templates. Saving content and publishing the website are separate steps.
|
|
18
18
|
|
|
19
|
+
Set `backend: "demo"` for a public editor with no sign-in. Save, create and delete changes stay in browser memory; publishing is simulated. Refreshing restores the original content. Demo mode never writes content to disk or GitHub, and media uploads, imports and metadata changes are disabled. Existing media can still be selected. The demo manifest exposes all configured content publicly, so use sample content only. The separate `demo` boolean controls sample-field visibility, not the storage backend.
|
|
20
|
+
|
|
19
21
|
## Get started
|
|
20
22
|
|
|
21
23
|
Use the [Astro starter](https://github.com/rigel-cms/rigel-starter), or follow the [installation guide](docs/getting-started.md) to add Rigel to an existing site. Requires Node.js 24 and Astro 7.3 or later within Astro 7.
|
package/docs/media.md
CHANGED
|
@@ -52,7 +52,7 @@ On the local backend, open an asset in Media, edit its display name, title, desc
|
|
|
52
52
|
|
|
53
53
|
Local file uploads send a browser-supplied author label through `X-Rigel-Author`. Rigel uses the browser identity's name or nickname, falling back to `Local user`. This is a descriptive label, not verified ownership or an audit identity. The metadata editor cannot change it. Existing files and URL or stock imports may have no captured author; the interface displays `Local user` as a fallback.
|
|
54
54
|
|
|
55
|
-
For rich-text image insertion, saved repository-image alt text supplies a default that you can override per use, or leave empty for a decorative image. Image fields store the URL; declare and render a separate alt field where your site needs one. Rigel does not rewrite alt text already stored in content. Rich-text insertions remain unsaved until the document's Save action.
|
|
55
|
+
For rich-text image insertion, saved repository-image alt text supplies a default that you can override per use, or leave empty for a decorative image. Image fields store the URL; declare and render a separate alt field where your site needs one. Store the original URL rather than a path your build generates. [Preview](preview.md) covers the `data-rigel-src` binding for optimized Astro images. Rigel does not rewrite alt text already stored in content. Rich-text insertions remain unsaved until the document's Save action.
|
|
56
56
|
|
|
57
57
|
## Image URLs and stock providers
|
|
58
58
|
|
package/docs/preview.md
CHANGED
|
@@ -44,6 +44,21 @@ These HTML excerpts illustrate the scalar anchors. Render the saved values throu
|
|
|
44
44
|
|
|
45
45
|
Paths use object keys and stable array `_key` values. Array positions are not preview bindings. For schema content, put `data-rigel-doc="page:about"` on each bound element and on section/list containers. It identifies the source document for that element; a parent attribute does not substitute for attributes on descendant anchors. Referenced records use their own IDs and local field paths.
|
|
46
46
|
|
|
47
|
+
### Keep optimized images editable
|
|
48
|
+
|
|
49
|
+
Astro's `Image` and `Picture` components replace `src` with hashed, responsive candidates, so changing `src` alone changes nothing while `srcset` still lists the old files. Mark an editable image with `data-rigel-src` to show the draft instead:
|
|
50
|
+
|
|
51
|
+
```astro
|
|
52
|
+
<Image src={cover} alt={hero.alt} widths={[480, 960]} sizes="100vw" data-rigel-image="hero.image" data-rigel-alt="hero.alt" data-rigel-src={hero.image} />
|
|
53
|
+
<Picture src={cover} formats={["avif", "webp"]} alt={hero.alt} data-rigel-image="hero.image" data-rigel-src={hero.image} />
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`data-rigel-image` names the field, as before. `data-rigel-src` holds the original browser-addressable URL that the field stores, and it marks the element as optimized. In preview, a marked image loses its `srcset` and `sizes`, and the surrounding `picture` sources lose theirs, so the field value is the file the browser loads. The attribute is not a second source of truth: preview renders the field value, and a value that fails the image URL check is dropped rather than loaded.
|
|
57
|
+
|
|
58
|
+
Public pages are untouched. Rigel patches only the editor iframe, so built markup keeps its hashed URLs, `srcset` and AVIF or WebP sources. Images without `data-rigel-src` keep their previous behavior: the `src` changes and any `srcset` stays.
|
|
59
|
+
|
|
60
|
+
Store the original reference in content, never a generated path. `/images/cover.jpg`, a media URL or an HTTPS URL work; `/_astro/cover.BnV1MXKY.webp` does not, because Astro derives it at build time. An imported file is not addressable either: `src/assets/cover.jpg` has no URL a browser can request. Serve the file from a public path or the media folder and bind that URL, even when the template imports the same file for optimization. Preview shows the original at its own size, not a resized or converted variant, and the built `width` and `height` stay as they were, so check a replacement with different proportions against the template's layout. An upload becomes reachable at its media URL once the host serves it or rebuilds.
|
|
61
|
+
|
|
47
62
|
## Prepare changing sections and lists
|
|
48
63
|
|
|
49
64
|
Scalar anchors update existing elements. Adding sections needs inert templates for each allowed section type. This HTML excerpt uses the `intro` section from [Content model](content-model.md):
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -23,7 +23,7 @@ export interface RigelConfig {
|
|
|
23
23
|
mediaDir?: string;
|
|
24
24
|
maxFileSize?: number;
|
|
25
25
|
preview?: import("./preview").PreviewConfig;
|
|
26
|
-
backend: "local" | "github";
|
|
26
|
+
backend: "local" | "github" | "demo";
|
|
27
27
|
jsonOutput?: JsonOutput;
|
|
28
28
|
demo?: boolean;
|
|
29
29
|
languages?: Locale[];
|
|
@@ -60,6 +60,7 @@ const DEFAULTS: Pick<RigelConfig, "copyDir" | "backend"> = {
|
|
|
60
60
|
|
|
61
61
|
export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
|
|
62
62
|
const cfg = { ...DEFAULTS, ...options };
|
|
63
|
+
if (!["local", "github", "demo"].includes(cfg.backend)) throw new Error("rigel: backend must be local, github or demo");
|
|
63
64
|
cfg.search = { scope: effectiveSearchScope(cfg.search, undefined) };
|
|
64
65
|
if (cfg.contentLocales !== undefined) {
|
|
65
66
|
validateContentLocales(cfg.contentLocales);
|
package/src/editor/App.svelte
CHANGED
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
import UnsavedChanges from "./lib/UnsavedChanges.svelte";
|
|
38
38
|
import ActionDialog from "./lib/ActionDialog.svelte";
|
|
39
39
|
import PublishDialog from "./lib/PublishDialog.svelte";
|
|
40
|
+
import { validateDemoSelection } from "./lib/demo-backend";
|
|
40
41
|
import VisualEditor from "./lib/VisualEditor.svelte";
|
|
41
42
|
import { newDocumentFromTemplate } from "../schema/paths";
|
|
42
43
|
import { previewUrl, previewPath } from "../preview";
|
|
@@ -64,6 +65,7 @@
|
|
|
64
65
|
let sessionExpiresAt = $state(0);
|
|
65
66
|
let tokenExpired = $state(false);
|
|
66
67
|
const githubMode = $derived(manifest.backend === "github");
|
|
68
|
+
const demoMode = $derived(manifest.backend === "demo");
|
|
67
69
|
let sel = $state<Sel | null>(null);
|
|
68
70
|
const previewTarget = $derived(sel?.kind === "page" ? sel : null);
|
|
69
71
|
let draft = $state<Record<string, unknown>>({});
|
|
@@ -96,6 +98,12 @@
|
|
|
96
98
|
|
|
97
99
|
async function reviewPublish() {
|
|
98
100
|
if (publishBusy || publishLoading) return;
|
|
101
|
+
if (demoMode) {
|
|
102
|
+
publishOpen = true;
|
|
103
|
+
publishMessage = "";
|
|
104
|
+
publishError = dirty ? $t("shell.saveBeforeReview") : "";
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
99
107
|
publishOpen = true;
|
|
100
108
|
publishReview = null;
|
|
101
109
|
publishError = "";
|
|
@@ -116,6 +124,11 @@
|
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
async function publish() {
|
|
127
|
+
if (demoMode) {
|
|
128
|
+
if (dirty || saveTask || saveState === "saving") { publishError = $t("shell.saveBeforeReview"); return; }
|
|
129
|
+
publishMessage = $t("shell.demoPublished");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
119
132
|
if (publishBusy || publishLoading || !publishReview) return;
|
|
120
133
|
publishBusy = true;
|
|
121
134
|
publishError = "";
|
|
@@ -149,7 +162,7 @@
|
|
|
149
162
|
let newDocument = $state<{ type: string; id: string; slug: string } | null>(null);
|
|
150
163
|
let docBase = $state<Record<string, unknown> | null>(null);
|
|
151
164
|
let documentReload = $state(0);
|
|
152
|
-
if (import.meta.hot) import.meta.hot.on("rigel:content-updated", () => { if (!githubMode) documentReload += 1; });
|
|
165
|
+
if (import.meta.hot) import.meta.hot.on("rigel:content-updated", () => { if (!githubMode && !demoMode) documentReload += 1; });
|
|
153
166
|
|
|
154
167
|
async function loadBlueprint() {
|
|
155
168
|
loadError = "";
|
|
@@ -307,7 +320,10 @@
|
|
|
307
320
|
values.slug = normalizeUrlSlug(values.slug, $t);
|
|
308
321
|
draft.slug = values.slug;
|
|
309
322
|
}
|
|
310
|
-
if (
|
|
323
|
+
if (demoMode) {
|
|
324
|
+
if (!selection) throw new Error($t("shell.selectBeforeSaving"));
|
|
325
|
+
validateDemoSelection(manifest, selection, values, body, !!creating || creatingDoc);
|
|
326
|
+
} else if (githubMode) {
|
|
311
327
|
if (!selection) throw new Error($t("shell.selectBeforeSaving"));
|
|
312
328
|
await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
|
|
313
329
|
} else {
|
|
@@ -350,10 +366,10 @@
|
|
|
350
366
|
|
|
351
367
|
let settings = $state(getSettings());
|
|
352
368
|
let settingsError = $state("");
|
|
353
|
-
const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? $t("shell.githubUser") : $t("shell.localUser"));
|
|
369
|
+
const userName = $derived(demoMode ? $t("shell.demoUser") : githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? $t("shell.githubUser") : $t("shell.localUser"));
|
|
354
370
|
const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
|
|
355
371
|
let failedAvatar = $state("");
|
|
356
|
-
const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : $t("shell.localMode"));
|
|
372
|
+
const userLogin = $derived(demoMode ? $t("shell.demoMode") : githubMode && githubAccess ? `@${githubAccess.user.login}` : $t("shell.localMode"));
|
|
357
373
|
let content: HTMLDivElement;
|
|
358
374
|
let contentWidth = $state(0);
|
|
359
375
|
let resizing = $state(false);
|
|
@@ -811,14 +827,14 @@
|
|
|
811
827
|
if (!current || !collection) return;
|
|
812
828
|
actionDialog = {
|
|
813
829
|
title: $t("shell.deletePostTitle", { name: String(current.frontmatter.title ?? current.slug) }), confirmLabel: $t("shell.delete"), danger: true,
|
|
814
|
-
description: unsaved ? $t("shell.discardNewPostDescription") : githubMode ? $t("shell.deleteRemotePostDescription", { slug: current.slug, branch: String(manifest.github?.branch) }) : $t("shell.deleteLocalPostDescription", { slug: current.slug }),
|
|
830
|
+
description: unsaved ? $t("shell.discardNewPostDescription") : demoMode ? $t("shell.demoDelete") : githubMode ? $t("shell.deleteRemotePostDescription", { slug: current.slug, branch: String(manifest.github?.branch) }) : $t("shell.deleteLocalPostDescription", { slug: current.slug }),
|
|
815
831
|
onconfirm: async () => {
|
|
816
832
|
if (!unsaved) {
|
|
817
833
|
if (githubMode) {
|
|
818
834
|
try { await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug); }
|
|
819
835
|
catch (error) { handleAuthError(error); throw error; }
|
|
820
836
|
}
|
|
821
|
-
else {
|
|
837
|
+
else if (!demoMode) {
|
|
822
838
|
const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE", headers: { "Accept-Language": $locale } });
|
|
823
839
|
if (!res.ok) throw new Error((await res.text()) || $t("shell.deleteFailedStatus", { status: res.status }));
|
|
824
840
|
}
|
|
@@ -941,8 +957,8 @@
|
|
|
941
957
|
{/each}
|
|
942
958
|
</nav>
|
|
943
959
|
|
|
944
|
-
{#if githubMode && manifest.github?.publishBranch}
|
|
945
|
-
<button id="publish-button" class="sidebar-publish" aria-label={$t("shell.publish")} data-tooltip={dirty ? $t("shell.saveBeforePublishing") : $t("shell.publishSavedChanges")} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
|
|
960
|
+
{#if demoMode || (githubMode && manifest.github?.publishBranch)}
|
|
961
|
+
<button id="publish-button" class="sidebar-publish" aria-label={$t("shell.publish")} data-tooltip={dirty ? $t("shell.saveBeforePublishing") : $t("shell.publishSavedChanges")} disabled={publishBusy || publishLoading || saveState === "saving" || (!demoMode && (connectionBusy || tokenExpired || githubAccess?.status !== "ready"))} onclick={reviewPublish}>
|
|
946
962
|
<Icon name="rocketLaunch" size={18} /><span class="sidebar-label">{$t("shell.publish")}</span>
|
|
947
963
|
</button>
|
|
948
964
|
{/if}
|
|
@@ -951,7 +967,7 @@
|
|
|
951
967
|
</button>
|
|
952
968
|
<div class="aside-foot">
|
|
953
969
|
<div class="user-menu-wrap">
|
|
954
|
-
<button class="user-btn" aria-label={githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} data-tooltip={githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
|
|
970
|
+
<button class="user-btn" aria-label={demoMode ? $t("shell.demoUser") : githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} data-tooltip={demoMode ? $t("shell.demoUser") : githubMode ? $t("shell.githubUserMenu") : $t("shell.localUserMenu")} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
|
|
955
971
|
{@render avatar()}
|
|
956
972
|
<span class="user-details"><span class="uname">{userName}</span><small>{userLogin}</small></span>
|
|
957
973
|
<span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
|
|
@@ -981,11 +997,11 @@
|
|
|
981
997
|
<a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">{$t("shell.configureGithubAccess")}</a>
|
|
982
998
|
<button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>{$t("shell.reconnectGithub")}</button>
|
|
983
999
|
{/if}
|
|
984
|
-
<button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? $t("shell.signOutTooltip") : $t("shell.localMode")} onclick={signOut}>
|
|
1000
|
+
{#if !demoMode}<button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? $t("shell.signOutTooltip") : $t("shell.localMode")} onclick={signOut}>
|
|
985
1001
|
<Icon name="signOut" size={13} />
|
|
986
1002
|
{$t("shell.signOut")}
|
|
987
1003
|
{#if !githubMode}<span class="soon">{$t("shell.githubBackendHint")}</span>{/if}
|
|
988
|
-
</button>
|
|
1004
|
+
</button>{/if}
|
|
989
1005
|
</div>
|
|
990
1006
|
{/if}
|
|
991
1007
|
</div>
|
|
@@ -1000,6 +1016,7 @@
|
|
|
1000
1016
|
<button class="primary" disabled={connectionBusy || saveState === "saving"} onclick={connectGitHub}>{connectionBusy ? $t("shell.connecting") : $t("shell.reconnectGithub")}</button>
|
|
1001
1017
|
</div>
|
|
1002
1018
|
{/if}
|
|
1019
|
+
{#if demoMode}<p class="github-session" role="status">{$t("shell.demoBanner")}</p>{/if}
|
|
1003
1020
|
{#if settingsError}<p role="alert">{settingsError}</p>{/if}
|
|
1004
1021
|
{#if ready && sel && sel.kind !== "media"}
|
|
1005
1022
|
{#if sel.kind === "page" || sel.kind === "post" || sel.kind === "menu" || sel.kind === "document"}
|
|
@@ -1224,5 +1241,5 @@
|
|
|
1224
1241
|
<ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
|
|
1225
1242
|
{/if}
|
|
1226
1243
|
{#if publishOpen}
|
|
1227
|
-
<PublishDialog review={publishReview} loading={publishLoading} busy={publishBusy} error={publishError} message={publishMessage} onrefresh={reviewPublish} onconfirm={publish} onclose={closePublish} />
|
|
1244
|
+
<PublishDialog demo={demoMode} review={publishReview} loading={publishLoading} busy={publishBusy} error={publishError} message={publishMessage} onrefresh={reviewPublish} onconfirm={publish} onclose={closePublish} />
|
|
1228
1245
|
{/if}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Demobesucher",
|
|
3
|
+
"shell.demoMode": "Demomodus",
|
|
4
|
+
"shell.demoBanner": "Öffentliche Demo — Änderungen bleiben im Speicher und werden beim Neuladen zurückgesetzt. Keine Dateien werden geschrieben.",
|
|
5
|
+
"shell.demoPublishDescription": "Simulierte Veröffentlichung: Es wird nichts geschrieben oder bereitgestellt.",
|
|
6
|
+
"shell.demoPublished": "Simulation abgeschlossen. Nichts wurde geschrieben oder bereitgestellt. Neuladen setzt Änderungen zurück.",
|
|
7
|
+
"shell.demoDelete": "Eintrag aus der Demositzung entfernen? Neuladen stellt den Inhalt wieder her.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Medien sind schreibgeschützt. Vorhandene Dateien auswählen; Uploads, Importe und Metadatenänderungen sind deaktiviert.",
|
|
2
9
|
"shell.searchScope": "Inhaltssprachen durchsuchen",
|
|
3
10
|
"shell.searchScopeDefault": "Website-Standard ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Alle Sprachen",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Demo visitor",
|
|
3
|
+
"shell.demoMode": "Demo mode",
|
|
4
|
+
"shell.demoBanner": "Public demo — changes stay in memory and reset when you refresh. No files are written.",
|
|
5
|
+
"shell.demoPublishDescription": "Try publishing your saved changes. This is a simulation: nothing is written or deployed.",
|
|
6
|
+
"shell.demoPublished": "Demo publish complete. Nothing was written or deployed. Refresh to reset your changes.",
|
|
7
|
+
"shell.demoDelete": "Remove this entry from your demo session? Refresh restores the original content.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Media is read-only in the demo. Choose an existing asset; uploads, imports and metadata changes are disabled.",
|
|
2
9
|
"shell.searchScope": "Search content languages",
|
|
3
10
|
"shell.searchScopeDefault": "Website default ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "All languages",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Visitante de la demo",
|
|
3
|
+
"shell.demoMode": "Modo demo",
|
|
4
|
+
"shell.demoBanner": "Demo pública: los cambios quedan en memoria y se restablecen al recargar. No se escriben archivos.",
|
|
5
|
+
"shell.demoPublishDescription": "Publicación simulada: no se escribe ni se despliega nada.",
|
|
6
|
+
"shell.demoPublished": "Simulación completada. No se ha escrito ni desplegado nada. Recarga para restablecer los cambios.",
|
|
7
|
+
"shell.demoDelete": "¿Eliminar esta entrada de la sesión de demo? Recargar restaura el contenido original.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Medios de solo lectura. Elige un archivo existente; las subidas, importaciones y cambios de metadatos están desactivados.",
|
|
2
9
|
"shell.searchScope": "Idiomas del contenido de búsqueda",
|
|
3
10
|
"shell.searchScopeDefault": "Predeterminado del sitio ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Todos los idiomas",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Visiteur de la démo",
|
|
3
|
+
"shell.demoMode": "Mode démo",
|
|
4
|
+
"shell.demoBanner": "Démo publique — les modifications restent en mémoire et disparaissent à l’actualisation. Aucun fichier n’est modifié.",
|
|
5
|
+
"shell.demoPublishDescription": "Publication simulée : rien n’est écrit ni déployé.",
|
|
6
|
+
"shell.demoPublished": "Publication simulée terminée. Rien n’a été écrit ni déployé. Actualisez pour réinitialiser vos modifications.",
|
|
7
|
+
"shell.demoDelete": "Supprimer cette entrée de la session de démo ? L’actualisation restaure le contenu d’origine.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Médias en lecture seule : choisissez un fichier existant. Envois, imports et modifications des métadonnées désactivés.",
|
|
2
9
|
"shell.searchScope": "Langues du contenu recherché",
|
|
3
10
|
"shell.searchScopeDefault": "Par défaut du site ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Toutes les langues",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Visitatore demo",
|
|
3
|
+
"shell.demoMode": "Modalità demo",
|
|
4
|
+
"shell.demoBanner": "Demo pubblica: le modifiche restano in memoria e si azzerano ricaricando. Nessun file viene scritto.",
|
|
5
|
+
"shell.demoPublishDescription": "Pubblicazione simulata: nulla viene scritto o distribuito.",
|
|
6
|
+
"shell.demoPublished": "Simulazione completata. Nulla è stato scritto o distribuito. Ricarica per azzerare le modifiche.",
|
|
7
|
+
"shell.demoDelete": "Rimuovere questa voce dalla sessione demo? Ricaricando si ripristina il contenuto originale.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Media in sola lettura. Scegli un file esistente; caricamenti, importazioni e modifiche ai metadati sono disattivati.",
|
|
2
9
|
"shell.searchScope": "Lingue dei contenuti da cercare",
|
|
3
10
|
"shell.searchScopeDefault": "Predefinito del sito ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Tutte le lingue",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "デモ訪問者",
|
|
3
|
+
"shell.demoMode": "デモモード",
|
|
4
|
+
"shell.demoBanner": "公開デモ:変更はメモリ内にのみ保存され、再読み込みでリセットされます。ファイルは書き込まれません。",
|
|
5
|
+
"shell.demoPublishDescription": "公開のシミュレーションです。書き込みやデプロイは行いません。",
|
|
6
|
+
"shell.demoPublished": "シミュレーション完了。書き込みやデプロイは行われていません。再読み込みで変更をリセットできます。",
|
|
7
|
+
"shell.demoDelete": "この項目をデモセッションから削除しますか?再読み込みで元に戻ります。",
|
|
8
|
+
"shell.demoMediaReadOnly": "メディアは読み取り専用です。既存のファイルを選択してください。アップロード、インポート、メタデータの変更は無効です。",
|
|
2
9
|
"shell.searchScope": "検索するコンテンツの言語",
|
|
3
10
|
"shell.searchScopeDefault": "サイトの既定値({scope})",
|
|
4
11
|
"shell.searchScopeAll": "すべての言語",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Demobezoeker",
|
|
3
|
+
"shell.demoMode": "Demomodus",
|
|
4
|
+
"shell.demoBanner": "Openbare demo — wijzigingen blijven in het geheugen en verdwijnen bij vernieuwen. Er worden geen bestanden geschreven.",
|
|
5
|
+
"shell.demoPublishDescription": "Gesimuleerde publicatie: er wordt niets geschreven of uitgerold.",
|
|
6
|
+
"shell.demoPublished": "Simulatie voltooid. Niets geschreven of uitgerold. Vernieuw om wijzigingen te herstellen.",
|
|
7
|
+
"shell.demoDelete": "Dit item uit de demosessie verwijderen? Vernieuwen herstelt de inhoud.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Media zijn alleen-lezen. Kies een bestaand bestand; uploads, imports en metadatawijzigingen zijn uitgeschakeld.",
|
|
2
9
|
"shell.searchScope": "Inhoudstalen doorzoeken",
|
|
3
10
|
"shell.searchScopeDefault": "Websitestandaard ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Alle talen",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "Посетитель демо",
|
|
3
|
+
"shell.demoMode": "Деморежим",
|
|
4
|
+
"shell.demoBanner": "Публичное демо: изменения хранятся в памяти и сбрасываются при обновлении страницы. Файлы не изменяются.",
|
|
5
|
+
"shell.demoPublishDescription": "Имитация публикации: ничего не записывается и не развёртывается.",
|
|
6
|
+
"shell.demoPublished": "Имитация завершена. Ничего не записано и не развёрнуто. Обновите страницу для сброса изменений.",
|
|
7
|
+
"shell.demoDelete": "Удалить запись из демосессии? Обновление страницы восстановит исходное содержимое.",
|
|
8
|
+
"shell.demoMediaReadOnly": "Медиа доступны только для чтения. Выберите существующий файл; загрузки, импорт и изменение метаданных отключены.",
|
|
2
9
|
"shell.searchScope": "Языки содержимого для поиска",
|
|
3
10
|
"shell.searchScopeDefault": "По умолчанию на сайте ({scope})",
|
|
4
11
|
"shell.searchScopeAll": "Все языки",
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"shell.demoUser": "演示访客",
|
|
3
|
+
"shell.demoMode": "演示模式",
|
|
4
|
+
"shell.demoBanner": "公开演示:更改仅保存在内存中,刷新后重置。不会写入任何文件。",
|
|
5
|
+
"shell.demoPublishDescription": "模拟发布:不会写入或部署任何内容。",
|
|
6
|
+
"shell.demoPublished": "模拟发布完成。未写入或部署任何内容。刷新以重置更改。",
|
|
7
|
+
"shell.demoDelete": "从演示会话中删除此条目?刷新后将恢复原始内容。",
|
|
8
|
+
"shell.demoMediaReadOnly": "演示中的媒体为只读。请选择现有文件;上传、导入和元数据修改已禁用。",
|
|
2
9
|
"shell.searchScope": "搜索内容语言",
|
|
3
10
|
"shell.searchScopeDefault": "网站默认({scope})",
|
|
4
11
|
"shell.searchScopeAll": "所有语言",
|
|
@@ -26,12 +26,13 @@
|
|
|
26
26
|
} = $props();
|
|
27
27
|
const id = $props.id();
|
|
28
28
|
const maxFileSize = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
29
|
+
const demoMode = config.backend === "demo";
|
|
29
30
|
let open = $state(false);
|
|
30
31
|
let tab = $state<"library" | "upload" | "url" | EmbedProvider | StockSource>("library");
|
|
31
32
|
const providerTab = $derived(tab === "youtube" || tab === "vimeo");
|
|
32
33
|
const editing = $derived(!!embed || !!value);
|
|
33
34
|
const stockKeys = $derived(open ? getSettings().apiKeys : null);
|
|
34
|
-
const tabs = $derived((kind === "file" ? ["library", "upload"] : kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
35
|
+
const tabs = $derived((demoMode ? ["library"] : kind === "file" ? ["library", "upload"] : kind === "video" ? ["library", "upload", "url", "youtube", "vimeo"] : ["library", "upload", "url", "picsum", ...Object.keys(STOCK_PROVIDERS).filter(source => stockKeys?.[source as keyof typeof STOCK_PROVIDERS]?.trim())]) as (typeof tab)[]);
|
|
35
36
|
let videoTitle = $state("");
|
|
36
37
|
let privacy = $state(true);
|
|
37
38
|
let muted = $state(false);
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
if (inline) { tab = "library"; open = true; }
|
|
83
84
|
}
|
|
84
85
|
async function saveMetadata() {
|
|
85
|
-
if (!selected?.asset || savingMeta) return;
|
|
86
|
+
if (demoMode || !selected?.asset || !writable || savingMeta) return;
|
|
86
87
|
const url = selected.asset.url;
|
|
87
88
|
savingMeta = true; metaError = ""; metaNotice = "";
|
|
88
89
|
try {
|
|
@@ -120,7 +121,7 @@
|
|
|
120
121
|
if (!response.ok) throw new Error($t("media.listingUnavailable"));
|
|
121
122
|
const data = await response.json();
|
|
122
123
|
assets = (data.assets ?? data.images).filter((asset: MediaAsset) => inline || (kind === "file" ? safeFileUrl(asset.url) && matchesMediaAccept(asset.url, accept) : asset.type?.startsWith(kind === "video" ? "video/" : "image/")));
|
|
123
|
-
writable = data.writable === true;
|
|
124
|
+
writable = !demoMode && data.writable === true;
|
|
124
125
|
folder = data.folder;
|
|
125
126
|
}
|
|
126
127
|
onMount(() => { if (inline) void run(); });
|
|
@@ -154,6 +155,7 @@
|
|
|
154
155
|
finally { if (request === requestId) loading = false; }
|
|
155
156
|
}
|
|
156
157
|
async function confirm() {
|
|
158
|
+
if (demoMode && (tab !== "library" || !selected?.asset)) { error = $t("shell.demoMediaReadOnly"); return; }
|
|
157
159
|
if (providerTab) {
|
|
158
160
|
const ref = parseEmbedUrl(urlInput);
|
|
159
161
|
if (!ref || ref.provider !== tab) { error = $t("media.pasteVideoUrl", { provider: tab === "youtube" ? "YouTube" : "Vimeo" }); return; }
|
|
@@ -187,6 +189,7 @@
|
|
|
187
189
|
finally { if (request === requestId) picking = false; }
|
|
188
190
|
}
|
|
189
191
|
function upload(file?: File) {
|
|
192
|
+
if (demoMode) return;
|
|
190
193
|
if (!file || picking) return;
|
|
191
194
|
error = "";
|
|
192
195
|
const limit = maxFileSize;
|
|
@@ -204,6 +207,7 @@
|
|
|
204
207
|
releaseFile(); onclose?.(); trigger?.focus();
|
|
205
208
|
}
|
|
206
209
|
function changeTab(next: typeof tab) {
|
|
210
|
+
if (demoMode) next = "library";
|
|
207
211
|
alt = altText; metaTitle = ""; metaDescription = "";
|
|
208
212
|
requestId++; releaseFile(); tab = next; results = []; selected = null; error = ""; searched = false; loading = false;
|
|
209
213
|
if (next === "youtube" || next === "vimeo") {
|
|
@@ -220,7 +224,7 @@
|
|
|
220
224
|
</script>
|
|
221
225
|
|
|
222
226
|
{#if inline}
|
|
223
|
-
<div class="titlebar"><h2>{$t("media.heading")}</h2><button bind:this={trigger} type="button" class="primary" onclick={launch}>{$t("media.upload")}</button></div>
|
|
227
|
+
<div class="titlebar"><h2>{$t("media.heading")}</h2><button bind:this={trigger} type="button" class="primary" disabled={demoMode} onclick={launch}>{$t("media.upload")}</button></div>
|
|
224
228
|
<div class="media-library">
|
|
225
229
|
{@render libraryBrowser()}
|
|
226
230
|
{#if notice}<p role="status">{notice}</p>{/if}
|
|
@@ -239,6 +243,7 @@
|
|
|
239
243
|
{/if}
|
|
240
244
|
|
|
241
245
|
{#snippet libraryBrowser()}
|
|
246
|
+
{#if demoMode}<p class="hint">{$t("shell.demoMediaReadOnly")}</p>{/if}
|
|
242
247
|
<p class="hint">{$t(writable ? "media.repositoryWritable" : "media.repositorySnapshot", { folder: folder || "…" })}</p>
|
|
243
248
|
<div class="media-tools">
|
|
244
249
|
<div class="media-views" role="group" aria-label={$t("media.view")}>
|
|
@@ -287,10 +292,10 @@
|
|
|
287
292
|
<div class="media-details">
|
|
288
293
|
{#if (inline && selected.asset) || metadataImport}
|
|
289
294
|
<div class="media-meta">
|
|
290
|
-
{#if inline && selected.asset}<label><span>{$t("media.displayName")}</span><input aria-label={$t("media.mediaDisplayName")} type="text" maxlength="120" bind:value={metaName} /></label>{/if}
|
|
291
|
-
{#if imageImport}<label><span>{$t("media.decorativeAlt")}</span><input aria-label={$t("media.imageAlt")} type="text" maxlength="1000" disabled={picking} bind:value={alt} /></label>{:else if selected.asset?.type?.startsWith("image/")}<label><span>{$t("media.alt")}</span><textarea aria-label={$t("media.mediaAlt")} rows="3" maxlength="1000" bind:value={metaAlt}></textarea></label>{/if}
|
|
292
|
-
<label><span>{$t("media.title")}</span><input aria-label={$t(imageImport ? "media.imageTitle" : "media.mediaTitle")} type="text" maxlength="200" disabled={picking} bind:value={metaTitle} /></label>
|
|
293
|
-
<label><span>{$t("media.description")}</span><textarea aria-label={$t(imageImport ? "media.imageDescription" : "media.mediaDescription")} rows="3" maxlength="1000" disabled={picking} bind:value={metaDescription}></textarea></label>
|
|
295
|
+
{#if inline && selected.asset}<label><span>{$t("media.displayName")}</span><input aria-label={$t("media.mediaDisplayName")} type="text" maxlength="120" readonly={demoMode} bind:value={metaName} /></label>{/if}
|
|
296
|
+
{#if imageImport}<label><span>{$t("media.decorativeAlt")}</span><input aria-label={$t("media.imageAlt")} type="text" maxlength="1000" disabled={picking} bind:value={alt} /></label>{:else if selected.asset?.type?.startsWith("image/")}<label><span>{$t("media.alt")}</span><textarea aria-label={$t("media.mediaAlt")} rows="3" maxlength="1000" readonly={demoMode} bind:value={metaAlt}></textarea></label>{/if}
|
|
297
|
+
<label><span>{$t("media.title")}</span><input aria-label={$t(imageImport ? "media.imageTitle" : "media.mediaTitle")} type="text" maxlength="200" readonly={demoMode} disabled={picking} bind:value={metaTitle} /></label>
|
|
298
|
+
<label><span>{$t("media.description")}</span><textarea aria-label={$t(imageImport ? "media.imageDescription" : "media.mediaDescription")} rows="3" maxlength="1000" readonly={demoMode} disabled={picking} bind:value={metaDescription}></textarea></label>
|
|
294
299
|
{#if selected.asset}<p class="hint">{$t("media.author", { author: selected.asset.author || $t("media.localUser") })}</p>{:else if selected.file}<p class="hint">{selected.file.name}</p>{/if}
|
|
295
300
|
</div>
|
|
296
301
|
{:else if selected.file}
|
|
@@ -317,7 +322,7 @@
|
|
|
317
322
|
<div class="media-button-actions">
|
|
318
323
|
{#if inline && selected.asset}
|
|
319
324
|
<button type="button" class="outlined" disabled={picking || savingMeta} onclick={close}>{$t("media.cancel")}</button>
|
|
320
|
-
<button type="button" class="primary" disabled={picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
|
|
325
|
+
<button type="button" class="primary" disabled={!writable || picking || savingMeta} onclick={saveMetadata}>{$t(savingMeta ? "media.saving" : "media.save")}</button>
|
|
321
326
|
{:else}
|
|
322
327
|
<button type="button" class="outlined" disabled={picking} onclick={() => { selected = null; releaseFile(); }}>{$t("media.back")}</button>
|
|
323
328
|
{#if inline && tab === "library"}<button type="button" class="primary" onclick={close}>{$t("media.done")}</button>{:else}<button type="button" class="primary" disabled={picking || (needsImport && !writable)} onclick={confirm}>{$t(picking ? kind === "file" ? "media.uploadingFile" : kind === "video" ? "media.uploadingVideo" : "media.importingPhoto" : kind === "file" ? selected.file ? "media.uploadFile" : "media.useFile" : kind === "video" ? "media.useVideo" : !inline ? "media.insertImage" : selected.file && mediaMetadata(selected.file.name)?.category !== "Images" ? "media.uploadFile" : "media.importImage")}</button>{/if}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import { t } from "../i18n";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
4
|
import type { PublishReview } from "./github-backend";
|
|
5
|
-
let { review, loading, busy, error, message, onrefresh, onconfirm, onclose }: {
|
|
5
|
+
let { demo = false, review, loading, busy, error, message, onrefresh, onconfirm, onclose }: {
|
|
6
|
+
demo?: boolean;
|
|
6
7
|
review: PublishReview | null; loading: boolean; busy: boolean; error: string; message: string;
|
|
7
8
|
onrefresh: () => void; onconfirm: () => void; onclose: () => void;
|
|
8
9
|
} = $props();
|
|
@@ -18,7 +19,7 @@
|
|
|
18
19
|
<dialog bind:this={dialog} class="rigel rigel-modal-backdrop publish-dialog" aria-labelledby="publish-title" aria-describedby="publish-description" oncancel={(event) => { event.preventDefault(); if (!busy) onclose(); }}>
|
|
19
20
|
<form class="rigel-modal" onsubmit={(event) => { event.preventDefault(); onconfirm(); }} aria-busy={loading || busy}>
|
|
20
21
|
<div class="publish-head"><h2 id="publish-title">{$t("connection.publishTitle")}</h2><button type="button" class="icon-btn" aria-label={$t("connection.closePublishReview")} disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
|
|
21
|
-
<p id="publish-description">{#each $t("connection.publishDescription", { scope: "{scope}" }).split("{scope}") as part, index}{#if index}<strong>{$t("connection.publishScope")}</strong>{/if}{part}{/each}</p>
|
|
22
|
+
<p id="publish-description">{#if demo}{$t("shell.demoPublishDescription")}{:else}{#each $t("connection.publishDescription", { scope: "{scope}" }).split("{scope}") as part, index}{#if index}<strong>{$t("connection.publishScope")}</strong>{/if}{part}{/each}{/if}</p>
|
|
22
23
|
{#if loading}<p role="status">{$t("connection.loadingReview")}</p>{/if}
|
|
23
24
|
{#if review}
|
|
24
25
|
<p><strong>{review.repo}</strong><br />{#each $t("connection.publishBranches", { branch: "{branch}", publishBranch: "{publishBranch}" }).split(/(\{branch\}|\{publishBranch\})/) as part}{#if part === "{branch}"}<span class="publish-branch">{review.branch}</span>{:else if part === "{publishBranch}"}<span class="publish-branch">{review.publishBranch}</span>{:else}{part}{/if}{/each}</p>
|
|
@@ -41,7 +42,7 @@
|
|
|
41
42
|
<div class="publish-actions">
|
|
42
43
|
<button type="button" bind:this={cancel} class="outlined" disabled={busy} onclick={onclose}>{message ? $t("connection.close") : $t("connection.cancel")}</button>
|
|
43
44
|
{#if !message}<button type="button" class="outlined" disabled={loading || busy} onclick={onrefresh}>{$t("connection.reviewAgain")}</button>{/if}
|
|
44
|
-
{#if review && !message}<button id="confirm-publish" type="submit" class="primary" disabled={loading || busy || !review
|
|
45
|
+
{#if (demo || review) && !message}<button id="confirm-publish" type="submit" class="primary" disabled={loading || busy || (demo ? !!error : !review?.files.length)}>{busy ? $t("connection.working") : !demo && review?.needsSync ? $t("connection.syncDraftsFirst") : $t("connection.confirmPublish")}</button>{/if}
|
|
45
46
|
</div>
|
|
46
47
|
</form>
|
|
47
48
|
</dialog>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Manifest } from "../../manifest/types";
|
|
2
|
+
import type { Selection } from "../../manifest/navigation";
|
|
3
|
+
import { validateBody, type ArticleBody } from "../../article-body";
|
|
4
|
+
import { validateMenu } from "../../manifest/menu";
|
|
5
|
+
import { validateCollectionValues } from "../../manifest/collections";
|
|
6
|
+
import { isPlainObject, validateDocument, validateDocumentId } from "../../schema/validate";
|
|
7
|
+
import { assertUniqueUrlSlugs, validateDocumentSlugs } from "../../slugs";
|
|
8
|
+
import { resolveExtensions, validateExtensionBlocks } from "../../extensions";
|
|
9
|
+
import extensions from "virtual:rigel-extensions";
|
|
10
|
+
import { translate } from "../i18n";
|
|
11
|
+
|
|
12
|
+
export function validateDemoSelection(manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean) {
|
|
13
|
+
if (manifest.backend !== "demo" || !isPlainObject(values)) throw new Error(translate("connection.invalidValues"));
|
|
14
|
+
if (selection.kind === "page") {
|
|
15
|
+
if (creating || !manifest.pages.some(page => page.slug === selection.slug)) throw new Error(translate("connection.unknownCopyPage"));
|
|
16
|
+
if (!Object.values(values).every(isPlainObject)) throw new Error(translate("connection.invalidCopyGroups"));
|
|
17
|
+
} else if (selection.kind === "post") {
|
|
18
|
+
const collection = manifest.collections?.find(collection => collection.name === selection.collection);
|
|
19
|
+
if (!collection) throw new Error(translate("connection.unknownCollection"));
|
|
20
|
+
const exists = collection.posts.some(post => post.slug === selection.slug);
|
|
21
|
+
if (exists === creating) throw new Error(translate("connection.invalidValues"));
|
|
22
|
+
validateBody(body, translate);
|
|
23
|
+
validateExtensionBlocks(body, resolveExtensions(extensions).blocks);
|
|
24
|
+
validateCollectionValues(collection.fields, values, manifest.documents ?? [], translate);
|
|
25
|
+
assertUniqueUrlSlugs([...collection.posts.filter(post => post.slug !== selection.slug).map(post => ({ slug: post.slug, values: post.frontmatter })), { slug: selection.slug, values }], translate);
|
|
26
|
+
} else if (selection.kind === "document") {
|
|
27
|
+
if (!manifest.schema) throw new Error(translate("connection.missingSchema"));
|
|
28
|
+
const document = validateDocumentId(manifest.schema, selection.id);
|
|
29
|
+
if (!document || document.type.name !== selection.type) throw new Error(translate("connection.unknownDocument"));
|
|
30
|
+
if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error(translate("connection.creationDisabled"));
|
|
31
|
+
const documents = manifest.documents ?? [];
|
|
32
|
+
if (documents.some(doc => doc.id === selection.id) === creating) throw new Error(translate("connection.invalidValues"));
|
|
33
|
+
values._id = selection.id;
|
|
34
|
+
values._type = selection.type;
|
|
35
|
+
validateDocument(manifest.schema, selection.type, document.slug, values, documents, translate);
|
|
36
|
+
validateDocumentSlugs(manifest.schema, [...documents.filter(doc => doc.id !== selection.id), { id: selection.id, type: selection.type, slug: document.slug, data: values }], manifest.preview, translate);
|
|
37
|
+
} else if (selection.kind === "menu") {
|
|
38
|
+
if (creating || !manifest.menus?.entries.some(menu => menu.slug === selection.slug)) throw new Error(translate("connection.menusNotConfigured"));
|
|
39
|
+
validateMenu({ ...values, slug: selection.slug }, manifest.menus.maxDepth, translate);
|
|
40
|
+
} else throw new Error(translate("connection.selectContent"));
|
|
41
|
+
}
|
package/src/editor/lib/images.ts
CHANGED
|
@@ -118,6 +118,7 @@ export async function searchImages(
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
export async function importStockImage(image: StockResult): Promise<string> {
|
|
121
|
+
if (config.backend === "demo") throw new Error(translate("shell.demoMediaReadOnly"));
|
|
121
122
|
const url = new URL(image.url);
|
|
122
123
|
const hosts = {
|
|
123
124
|
picsum: ["picsum.photos"], unsplash: ["images.unsplash.com"],
|
|
@@ -139,6 +140,7 @@ export async function importStockImage(image: StockResult): Promise<string> {
|
|
|
139
140
|
}
|
|
140
141
|
|
|
141
142
|
export async function importImageUrl(value: string): Promise<string> {
|
|
143
|
+
if (config.backend === "demo") throw new Error(translate("shell.demoMediaReadOnly"));
|
|
142
144
|
if (!safeImageUrl(value)) throw new Error(translate("media.unsafeImageUrl"));
|
|
143
145
|
if (!value.startsWith("/") && new URL(value).protocol !== "https:") throw new Error(translate("media.remoteRequiresHttps"));
|
|
144
146
|
if (!value.startsWith("/") && new URL(value).hostname === "images.unsplash.com") throw new Error(translate("media.unsplashHotlinkRequired"));
|
|
@@ -185,6 +187,7 @@ export function currentAuthor(): string {
|
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
export async function uploadMedia(file: File): Promise<string> {
|
|
190
|
+
if (config.backend === "demo") throw new Error(translate("shell.demoMediaReadOnly"));
|
|
188
191
|
const metadata = mediaMetadata(file.name);
|
|
189
192
|
const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
190
193
|
if (!safeMediaName(file.name) || !metadata || file.size > limit) throw new Error(translate("media.chooseSupportedFile", { limit: limit / 1024 / 1024 }));
|
|
@@ -196,6 +199,7 @@ export async function uploadMedia(file: File): Promise<string> {
|
|
|
196
199
|
}
|
|
197
200
|
|
|
198
201
|
export async function uploadImage(blob: Blob): Promise<string> {
|
|
202
|
+
if (config.backend === "demo") throw new Error(translate("shell.demoMediaReadOnly"));
|
|
199
203
|
const limit = config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
200
204
|
if (blob.size > limit) throw new Error(translate("media.photoLimitExceeded", { limit: limit / 1024 / 1024 }));
|
|
201
205
|
const saved = await fetch("/rigel/api/local/media", { method: "POST", headers: { "Accept-Language": get(locale) }, body: blob });
|
package/src/manifest/types.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface GitHubBackendConfig {
|
|
|
39
39
|
export interface Manifest {
|
|
40
40
|
contentLocales?: import("../config").ContentLocalesConfig;
|
|
41
41
|
search?: { scope: import("../config").SearchScope };
|
|
42
|
-
backend?: "github";
|
|
42
|
+
backend?: "github" | "demo";
|
|
43
43
|
github?: GitHubBackendConfig;
|
|
44
44
|
sidebar?: import("./navigation").SidebarEntry[];
|
|
45
45
|
preview?: import("../preview").PreviewConfig;
|
package/src/preview.ts
CHANGED
|
@@ -217,7 +217,22 @@ export function patchPreview(doc: Document, values: Record<string, unknown>, bod
|
|
|
217
217
|
if (blocks) previews.set(el, mountExtensionPreviews(el));
|
|
218
218
|
rendered.set(el, source);
|
|
219
219
|
}
|
|
220
|
-
for (const
|
|
220
|
+
for (const el of doc.querySelectorAll<HTMLImageElement>("[data-rigel-image]")) {
|
|
221
|
+
if (el.tagName !== "IMG") continue;
|
|
222
|
+
const result = get(el, el.getAttribute("data-rigel-image")!);
|
|
223
|
+
if (!result.found) continue;
|
|
224
|
+
const value = result.value === null || result.value === undefined ? "" : String(result.value);
|
|
225
|
+
const safe = safePreviewUrl(value, true);
|
|
226
|
+
if (el.hasAttribute("data-rigel-src")) {
|
|
227
|
+
el.removeAttribute("srcset");
|
|
228
|
+
el.removeAttribute("sizes");
|
|
229
|
+
for (const source of el.closest("picture")?.querySelectorAll("source") ?? []) { source.removeAttribute("srcset"); source.removeAttribute("sizes"); }
|
|
230
|
+
if (safe) el.setAttribute("data-rigel-src", value);
|
|
231
|
+
}
|
|
232
|
+
if (safe) { if (el.getAttribute("src") !== value) el.setAttribute("src", value); }
|
|
233
|
+
else el.removeAttribute("src");
|
|
234
|
+
}
|
|
235
|
+
for (const [anchor, attr, tag] of [["alt", "alt", "IMG"], ["href", "href", "A"]]) {
|
|
221
236
|
for (const el of doc.querySelectorAll(`[data-rigel-${anchor}]`)) {
|
|
222
237
|
if (el.tagName !== tag) continue;
|
|
223
238
|
const result = get(el, el.getAttribute(`data-rigel-${anchor}`)!);
|
package/src/routes/local-api.ts
CHANGED
|
@@ -3,12 +3,13 @@ import { requestTranslator } from "../editor/i18n/server";
|
|
|
3
3
|
import { writeFile, readFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { applyValues } from "./serialize";
|
|
6
|
+
import config from "virtual:rigel-config";
|
|
6
7
|
|
|
7
8
|
export const prerender = false;
|
|
8
9
|
|
|
9
10
|
export const POST: APIRoute = async ({ request }) => {
|
|
10
11
|
const t = requestTranslator(request);
|
|
11
|
-
if (import.meta.env.PROD) {
|
|
12
|
+
if (import.meta.env.PROD || config.backend !== "local") {
|
|
12
13
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -14,7 +14,7 @@ export const prerender = false;
|
|
|
14
14
|
export const GET: APIRoute = async ({ params, request }) => {
|
|
15
15
|
const t = requestTranslator(request);
|
|
16
16
|
const filename = params.filename ?? "";
|
|
17
|
-
if (import.meta.env.PROD || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
|
|
17
|
+
if (import.meta.env.PROD || config.backend !== "local" || !safeMediaName(filename) || filename.startsWith(".") || /[/\\\u0000]/.test(filename)) {
|
|
18
18
|
return new Response(t("local.notFound"), { status: 404 });
|
|
19
19
|
}
|
|
20
20
|
try {
|
|
@@ -29,7 +29,7 @@ export const GET: APIRoute = async ({ params, request }) => {
|
|
|
29
29
|
|
|
30
30
|
export const POST: APIRoute = async ({ request }) => {
|
|
31
31
|
const t = requestTranslator(request);
|
|
32
|
-
if (import.meta.env.PROD) {
|
|
32
|
+
if (import.meta.env.PROD || config.backend !== "local") {
|
|
33
33
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
34
34
|
}
|
|
35
35
|
if (request.headers.get("Origin") !== new URL(request.url).origin) {
|
|
@@ -90,7 +90,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
90
90
|
|
|
91
91
|
export const PUT: APIRoute = async ({ request }) => {
|
|
92
92
|
const t = requestTranslator(request);
|
|
93
|
-
if (import.meta.env.PROD) {
|
|
93
|
+
if (import.meta.env.PROD || config.backend !== "local") {
|
|
94
94
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
95
95
|
}
|
|
96
96
|
if (request.headers.get("Origin") !== new URL(request.url).origin) {
|
|
@@ -120,7 +120,7 @@ export const POST: APIRoute = async ({ request }) => {
|
|
|
120
120
|
|
|
121
121
|
export const DELETE: APIRoute = async ({ request }) => {
|
|
122
122
|
const t = requestTranslator(request);
|
|
123
|
-
if (import.meta.env.PROD) return new Response("Local backend is dev-only", { status: 404 });
|
|
123
|
+
if (import.meta.env.PROD || config.backend !== "local") return new Response("Local backend is dev-only", { status: 404 });
|
|
124
124
|
if (request.headers.get("Origin") !== new URL(request.url).origin) return new Response("Same-origin request required", { status: 403 });
|
|
125
125
|
let body: unknown;
|
|
126
126
|
try { body = await request.json(); } catch { return new Response("Invalid JSON body", { status: 400 }); }
|
package/src/routes/local-post.ts
CHANGED
|
@@ -37,7 +37,7 @@ export const POST: APIRoute = (context) => {
|
|
|
37
37
|
|
|
38
38
|
const savePost: APIRoute = async ({ request, params }) => {
|
|
39
39
|
const t = requestTranslator(request);
|
|
40
|
-
if (import.meta.env.PROD) {
|
|
40
|
+
if (import.meta.env.PROD || config.backend !== "local") {
|
|
41
41
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
42
42
|
}
|
|
43
43
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
@@ -86,7 +86,7 @@ const savePost: APIRoute = async ({ request, params }) => {
|
|
|
86
86
|
|
|
87
87
|
export const DELETE: APIRoute = async ({ request, params }) => {
|
|
88
88
|
const t = requestTranslator(request);
|
|
89
|
-
if (import.meta.env.PROD) {
|
|
89
|
+
if (import.meta.env.PROD || config.backend !== "local") {
|
|
90
90
|
return new Response("Local backend is dev-only", { status: 404 });
|
|
91
91
|
}
|
|
92
92
|
const folder = collectionFolder(String(params.collection ?? ""));
|
|
@@ -219,7 +219,7 @@ ${collectionCode}
|
|
|
219
219
|
];
|
|
220
220
|
for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
|
|
221
221
|
${menuCode}
|
|
222
|
-
export default { pages, collections, documents, search: ${JSON.stringify(cfg.search)}${cfg.contentLocales ? ", contentLocales: " + JSON.stringify(cfg.contentLocales) : ""}${github ? ', backend: "github", github: ' + JSON.stringify(github) : ""}${cfg.menus ? ", menus" : ""}${cfg.schema ? ", schema: " + JSON.stringify(cfg.schema) : ""}${cfg.preview ? ", preview: " + JSON.stringify(cfg.preview) : ""}${cfg.sidebar ? ", sidebar: " + JSON.stringify(cfg.sidebar) : ""} };
|
|
222
|
+
export default { pages, collections, documents, search: ${JSON.stringify(cfg.search)}${cfg.contentLocales ? ", contentLocales: " + JSON.stringify(cfg.contentLocales) : ""}${github ? ', backend: "github", github: ' + JSON.stringify(github) : cfg.backend === "demo" ? ', backend: "demo"' : ""}${cfg.menus ? ", menus" : ""}${cfg.schema ? ", schema: " + JSON.stringify(cfg.schema) : ""}${cfg.preview ? ", preview: " + JSON.stringify(cfg.preview) : ""}${cfg.sidebar ? ", sidebar: " + JSON.stringify(cfg.sidebar) : ""} };
|
|
223
223
|
`;
|
|
224
224
|
},
|
|
225
225
|
};
|