@rigel-cms/rigel 0.1.0-beta.1 → 0.1.3-beta.1
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 +8 -0
- package/package.json +7 -2
- package/src/config.ts +6 -0
- package/src/editor/App.svelte +31 -22
- package/src/editor/lib/PreviewFrame.svelte +45 -0
- package/src/editor/lib/SitePreview.svelte +8 -7
- package/src/editor/lib/VisualEditor.svelte +2 -4
- package/src/editor/lib/github-backend.ts +3 -3
- package/src/editor/lib/preview-frame.ts +29 -0
- package/src/editor/styles.css +1 -0
- package/src/manifest/collections.ts +27 -0
- package/src/manifest/types.ts +1 -0
- package/src/preview-urls.ts +9 -0
- package/src/preview.ts +12 -4
- package/src/routes/local-post.ts +3 -3
- package/src/vite/manifest-plugin.ts +2 -0
package/README.md
CHANGED
|
@@ -4,6 +4,14 @@ A CMS for Astro sites. You edit the text, git keeps the history.
|
|
|
4
4
|
|
|
5
5
|
`@rigel-cms/rigel` is a beta package and a work in progress. Expect bugs and breaking changes.
|
|
6
6
|
|
|
7
|
+
## Collection validation
|
|
8
|
+
|
|
9
|
+
New collection drafts initialize every configured field, including Rigel's automatic `description`, `metaTitle`, and `ogImage` strings. Existing content is never filled in automatically; unknown properties are preserved and missing optional fields remain allowed.
|
|
10
|
+
|
|
11
|
+
Rigel validates configured collection field types and `required: true` on load and before local or GitHub saves. Required strings must contain non-whitespace text, required arrays must contain an item, and `false` is a valid required boolean. Dates use real `YYYY-MM-DD` calendar dates; optional dates may be empty. Tags are arrays of strings and references must select configured entries.
|
|
12
|
+
|
|
13
|
+
Astro content schemas remain the host site's contract: Rigel does not discover or execute them. Align Rigel field types and requiredness with the host schema. For example, Astro's `z.string()` accepts an initialized empty description; `z.string().min(1)` also needs Rigel's `required: true`.
|
|
14
|
+
|
|
7
15
|
## License
|
|
8
16
|
|
|
9
17
|
[Apache-2.0](LICENSE). Copyright 2026 [Alex Zappa](https://alex.zappa.dev/) ([FreshJuice](https://freshjuice.dev/)).
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigel-cms/rigel",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3-beta.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public",
|
|
6
|
+
"tag": "beta"
|
|
7
|
+
},
|
|
4
8
|
"description": "Rigel CMS — the copy editor for Astro sites. Clients edit text, git keeps the history.",
|
|
5
9
|
"author": {
|
|
6
10
|
"name": "FreshJuice",
|
|
@@ -28,7 +32,8 @@
|
|
|
28
32
|
"scripts": {
|
|
29
33
|
"build": "pnpm typecheck",
|
|
30
34
|
"test": "vitest run",
|
|
31
|
-
"typecheck": "tsc --noEmit"
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"publish:beta": "npm publish --access public --tag beta"
|
|
32
37
|
},
|
|
33
38
|
"exports": {
|
|
34
39
|
".": "./src/index.ts",
|
package/src/config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { validateSchema } from "./schema/validate";
|
|
|
3
3
|
import type { Schema } from "./schema/types";
|
|
4
4
|
import { validateSidebar, type SidebarEntry } from "./manifest/navigation";
|
|
5
5
|
import { withSeoFields } from "./manifest/seo";
|
|
6
|
+
import { previewBaseUrl } from "./preview-urls";
|
|
6
7
|
|
|
7
8
|
export interface RigelConfig {
|
|
8
9
|
copyDir: string;
|
|
@@ -41,6 +42,10 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
|
|
|
41
42
|
const cfg = { ...DEFAULTS, ...options };
|
|
42
43
|
if (cfg.demo === undefined) cfg.demo = true;
|
|
43
44
|
if (typeof cfg.demo !== "boolean") throw new Error("rigel: demo must be boolean");
|
|
45
|
+
if (cfg.preview) {
|
|
46
|
+
cfg.preview = { ...cfg.preview };
|
|
47
|
+
for (const key of ["liveUrl", "draftUrl"] as const) if (cfg.preview[key] !== undefined) cfg.preview[key] = previewBaseUrl(cfg.preview[key]);
|
|
48
|
+
}
|
|
44
49
|
if (cfg.mediaDir !== undefined && (typeof cfg.mediaDir !== "string" || !cfg.mediaDir.split("/").every(part => /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/.test(part)))) throw new Error("rigel: mediaDir must be a relative folder inside publicDir");
|
|
45
50
|
if (cfg.backend === "github") {
|
|
46
51
|
if (typeof cfg.repo !== "string" || !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9_.-]+$/.test(cfg.repo) || [".", ".."].includes(cfg.repo.split("/")[1])) throw new Error("rigel: github backend requires `repo: owner/name`");
|
|
@@ -82,6 +87,7 @@ export function parseConfig(options: Partial<RigelConfig> = {}): RigelConfig {
|
|
|
82
87
|
if (cfg.collections) cfg.collections = cfg.collections.map(collection => ({ ...collection, fields: withSeoFields(collection.fields) }));
|
|
83
88
|
for (const collection of cfg.collections ?? []) {
|
|
84
89
|
for (const field of collection.fields) {
|
|
90
|
+
if (field.required !== undefined && typeof field.required !== "boolean") throw new Error(`rigel: ${collection.name}.${field.key}: required must be boolean`);
|
|
85
91
|
if (field.type !== "reference") continue;
|
|
86
92
|
if (field.multiple !== undefined && typeof field.multiple !== "boolean") throw new Error(`rigel: ${collection.name}.${field.key}: multiple must be boolean`);
|
|
87
93
|
if ((field.to !== undefined ? 1 : 0) + (field.options !== undefined ? 1 : 0) !== 1) throw new Error(`rigel: ${collection.name}.${field.key}: reference requires either to or options`);
|
package/src/editor/App.svelte
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import SchemaPanel from "./lib/SchemaPanel.svelte";
|
|
16
16
|
import { collectionPreviewValues, referenceLabel, referenceOptions } from "../manifest/references";
|
|
17
17
|
import { isSeoField } from "../manifest/seo";
|
|
18
|
+
import { initialCollectionValues } from "../manifest/collections";
|
|
18
19
|
import { slugify } from "./lib/slugify";
|
|
19
20
|
import Icon from "./lib/Icon.svelte";
|
|
20
21
|
import SidebarIcon from "./lib/SidebarIcon.svelte";
|
|
@@ -27,7 +28,7 @@
|
|
|
27
28
|
import ActionDialog from "./lib/ActionDialog.svelte";
|
|
28
29
|
import VisualEditor from "./lib/VisualEditor.svelte";
|
|
29
30
|
import { newDocumentFromTemplate } from "../schema/paths";
|
|
30
|
-
import {
|
|
31
|
+
import { previewUrl } from "../preview";
|
|
31
32
|
import type { ContentDocument, Schema } from "../schema/types";
|
|
32
33
|
import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
|
|
33
34
|
import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
|
|
@@ -220,6 +221,8 @@
|
|
|
220
221
|
let settings = $state(getSettings());
|
|
221
222
|
let settingsError = $state("");
|
|
222
223
|
const userName = $derived(githubMode ? githubAccess?.user.login ?? "GitHub user" : "Local user");
|
|
224
|
+
const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
|
|
225
|
+
let failedAvatar = $state("");
|
|
223
226
|
const userMode = $derived(githubMode ? `${manifest.github?.repo} · ${manifest.github?.branch}` : "Local mode");
|
|
224
227
|
let content: HTMLDivElement;
|
|
225
228
|
let contentWidth = $state(0);
|
|
@@ -369,10 +372,10 @@
|
|
|
369
372
|
if (current?.kind === "document") map[current.id] = draft;
|
|
370
373
|
return map;
|
|
371
374
|
});
|
|
372
|
-
|
|
375
|
+
function documentPreview(mode: "live" | "draft") {
|
|
373
376
|
if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
|
|
374
|
-
return
|
|
375
|
-
}
|
|
377
|
+
return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
|
|
378
|
+
}
|
|
376
379
|
const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
|
|
377
380
|
const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
|
|
378
381
|
|
|
@@ -596,7 +599,7 @@
|
|
|
596
599
|
const slug = slugify(title);
|
|
597
600
|
if (!slug) throw new Error("Enter a title containing letters or numbers.");
|
|
598
601
|
if (c.posts.some((p) => p.slug === slug)) throw new Error(`A post with slug "${slug}" already exists.`);
|
|
599
|
-
newEntry = { collection: c.name, post: { slug, frontmatter:
|
|
602
|
+
newEntry = { collection: c.name, post: { slug, frontmatter: initialCollectionValues(c.fields, title), body: emptyBody() } };
|
|
600
603
|
openSelection({ kind: "post", collection: c.name, slug });
|
|
601
604
|
},
|
|
602
605
|
};
|
|
@@ -626,6 +629,16 @@
|
|
|
626
629
|
|
|
627
630
|
</script>
|
|
628
631
|
|
|
632
|
+
{#snippet avatar()}
|
|
633
|
+
<span class="avatar">
|
|
634
|
+
{#if userAvatar && failedAvatar !== userAvatar}
|
|
635
|
+
<img src={userAvatar} alt="" width="36" height="36" referrerpolicy="no-referrer" onerror={() => { failedAvatar = userAvatar; }} />
|
|
636
|
+
{:else}
|
|
637
|
+
<Icon name="user" size={20} />
|
|
638
|
+
{/if}
|
|
639
|
+
</span>
|
|
640
|
+
{/snippet}
|
|
641
|
+
|
|
629
642
|
<svelte:window
|
|
630
643
|
onresize={endPreviewResize}
|
|
631
644
|
onblur={endPreviewResize}
|
|
@@ -651,31 +664,26 @@
|
|
|
651
664
|
{#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
|
|
652
665
|
{:else}<p role="status">Loading the editor configuration…</p>{/if}
|
|
653
666
|
{:else if manifest.github}
|
|
654
|
-
<p>Content repository <strong>{manifest.github.repo}</strong></p>
|
|
655
667
|
{#if githubAccess}
|
|
656
668
|
<p>Signed in as <strong>{githubAccess.user.login}</strong>.</p>
|
|
657
|
-
{#if githubAccess.status === "read-only"}
|
|
658
|
-
<p>
|
|
659
|
-
{:else if githubAccess.status === "installation-required"}
|
|
660
|
-
<p>Install or configure the Rigel GitHub App for this repository. On GitHub, choose your account or organization, select <strong>Only select repositories</strong>, and include <strong>{manifest.github.repo}</strong>.</p>
|
|
661
|
-
<p>If your organization requires approval, its administrator must approve the installation first.</p>
|
|
669
|
+
{#if githubAccess.status === "read-only" || githubAccess.status === "installation-required"}
|
|
670
|
+
<p>You don’t have editing access. Contact the website administrator.</p>
|
|
662
671
|
{:else}
|
|
663
|
-
<p role="status">{connectionBusy ? "
|
|
672
|
+
<p role="status">{connectionBusy ? "Opening the editor…" : "Access confirmed. Try opening the editor again."}</p>
|
|
664
673
|
{/if}
|
|
665
674
|
{#if githubAccess.status !== "ready"}
|
|
666
|
-
<a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">
|
|
667
|
-
<p>Keep this tab open. Return here after configuring access on GitHub.</p>
|
|
675
|
+
<a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">Manage GitHub access</a>
|
|
668
676
|
{/if}
|
|
669
677
|
{:else}
|
|
670
|
-
<p>Sign in
|
|
678
|
+
<p>Sign in to edit this website.</p>
|
|
671
679
|
{/if}
|
|
672
680
|
<div class="connection-actions">
|
|
673
681
|
{#if sessionExpiresAt && !tokenExpired}
|
|
674
|
-
<button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? "Checking access…" : githubAccess?.status === "ready" ? "
|
|
682
|
+
<button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? "Checking access…" : githubAccess?.status === "ready" ? "Open editor" : "Check access"}</button>
|
|
675
683
|
{/if}
|
|
676
684
|
<button id="github-sign-in" class:primary={!sessionExpiresAt || tokenExpired} class:outlined={sessionExpiresAt > 0 && !tokenExpired} disabled={connectionBusy} onclick={connectGitHub}>{connectionBusy && !sessionExpiresAt ? "Signing in…" : sessionExpiresAt ? "Sign in again" : "Sign in with GitHub"}</button>
|
|
677
685
|
</div>
|
|
678
|
-
<p class="connection-note">
|
|
686
|
+
<p class="connection-note">By continuing, you agree to the <a href="https://rigelcms.dev/terms" target="_blank" rel="noopener noreferrer">Terms of Use</a> and acknowledge the <a href="https://rigelcms.dev/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.</p>
|
|
679
687
|
{/if}
|
|
680
688
|
{#if connectionError}<p class="connection-error" role="alert">{connectionError}</p>{/if}
|
|
681
689
|
</main>
|
|
@@ -723,14 +731,14 @@
|
|
|
723
731
|
<div class="aside-foot">
|
|
724
732
|
<div class="user-menu-wrap">
|
|
725
733
|
<button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"} title={githubMode ? "GitHub user menu" : "Local user menu"} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
|
|
726
|
-
|
|
734
|
+
{@render avatar()}
|
|
727
735
|
<span class="user-details"><span class="uname">{userName}</span><small>{userMode}</small></span>
|
|
728
736
|
<span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
|
|
729
737
|
</button>
|
|
730
738
|
{#if userOpen}
|
|
731
739
|
<div class="user-menu" role="menu">
|
|
732
740
|
<div class="user-menu-head">
|
|
733
|
-
|
|
741
|
+
{@render avatar()}
|
|
734
742
|
<div class="user-details"><strong>{userName}</strong><small>{userMode}</small></div>
|
|
735
743
|
</div>
|
|
736
744
|
<div class="divider"></div>
|
|
@@ -876,7 +884,8 @@
|
|
|
876
884
|
docType={activeDocType}
|
|
877
885
|
values={draft}
|
|
878
886
|
documents={draftDocuments}
|
|
879
|
-
previewPath={
|
|
887
|
+
previewPath={documentPreview("draft")}
|
|
888
|
+
livePath={documentPreview("live")}
|
|
880
889
|
creating={newDocument?.id === sel.id}
|
|
881
890
|
devMode={settings.devMode}
|
|
882
891
|
reloadToken={documentReload}
|
|
@@ -923,8 +932,8 @@
|
|
|
923
932
|
body={bodyDraft}
|
|
924
933
|
bodyInline={settings.userPref.editorExperience === "inline"}
|
|
925
934
|
onbody={(value) => { bodyDraft = value; touch(); }}
|
|
926
|
-
previewPath={manifest.preview ?
|
|
927
|
-
livePath={manifest.preview ?
|
|
935
|
+
previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
|
|
936
|
+
livePath={manifest.preview ? previewUrl(manifest.preview, sel, "live") : null}
|
|
928
937
|
creating={!!newEntry}
|
|
929
938
|
devMode={settings.devMode}
|
|
930
939
|
onchange={(next) => { draft = next; touch(); }}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { fetchPreview } from "./preview-frame";
|
|
4
|
+
|
|
5
|
+
let { url, title, frame = $bindable(), reloadToken = 0, onload, onloading, onerror }: {
|
|
6
|
+
url: string;
|
|
7
|
+
title: string;
|
|
8
|
+
frame?: HTMLIFrameElement;
|
|
9
|
+
reloadToken?: number;
|
|
10
|
+
onload: () => void;
|
|
11
|
+
onloading: () => void;
|
|
12
|
+
onerror: (message: string) => void;
|
|
13
|
+
} = $props();
|
|
14
|
+
let source = $state<{ src?: string; srcdoc?: string } | null>(null);
|
|
15
|
+
|
|
16
|
+
$effect(() => {
|
|
17
|
+
const target = new URL(url, location.href);
|
|
18
|
+
void reloadToken;
|
|
19
|
+
source = null;
|
|
20
|
+
untrack(onloading);
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
if (target.origin === location.origin) source = { src: target.href };
|
|
23
|
+
else fetchPreview(target, controller.signal).then(srcdoc => {
|
|
24
|
+
if (!controller.signal.aborted) source = { srcdoc };
|
|
25
|
+
}).catch(cause => {
|
|
26
|
+
if (!controller.signal.aborted) onerror(cause instanceof Error ? cause.message : "Preview unavailable");
|
|
27
|
+
});
|
|
28
|
+
return () => controller.abort();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function ready() {
|
|
32
|
+
const doc = frame?.contentDocument;
|
|
33
|
+
if (!doc || (source?.src && new URL(doc.URL).pathname.replace(/\/$/, "") !== new URL(source.src).pathname.replace(/\/$/, ""))) {
|
|
34
|
+
onerror("The prepared page could not be loaded. Check its URL and framing headers.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
onload();
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
{#if source}
|
|
42
|
+
{#key source}
|
|
43
|
+
<iframe bind:this={frame} {title} sandbox="allow-same-origin" referrerpolicy="no-referrer" src={source.src} srcdoc={source.srcdoc} onload={ready}></iframe>
|
|
44
|
+
{/key}
|
|
45
|
+
{/if}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from "svelte";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
|
-
import { patchPreview,
|
|
4
|
+
import { patchPreview, previewUrl, type PreviewConfig, type PreviewTarget } from "../../preview";
|
|
5
|
+
import PreviewFrame from "./PreviewFrame.svelte";
|
|
5
6
|
let { target, config, values, body, isNew = false, devMode = false }: { target: PreviewTarget; config: PreviewConfig; values: Record<string, unknown>; body: "" | import("../../article-body").ArticleBody; isNew?: boolean; devMode?: boolean } = $props();
|
|
6
7
|
|
|
7
|
-
const livePath = $derived(
|
|
8
|
+
const livePath = $derived(previewUrl(config, target, "live"));
|
|
8
9
|
const key = $derived(`${target.kind}:${target.collection ?? ""}:${target.slug}`);
|
|
9
|
-
let frame: HTMLIFrameElement;
|
|
10
|
+
let frame: HTMLIFrameElement | undefined = $state();
|
|
10
11
|
let loaded = $state(false);
|
|
11
12
|
let error = $state("");
|
|
12
13
|
let path = $state<string | null>(null);
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
void key;
|
|
15
16
|
loaded = false;
|
|
16
17
|
error = "";
|
|
17
|
-
path = untrack(() =>
|
|
18
|
+
path = untrack(() => previewUrl(config, target, "draft", isNew));
|
|
18
19
|
});
|
|
19
20
|
function patch() {
|
|
20
21
|
if (!loaded || !frame?.contentDocument) return;
|
|
@@ -22,9 +23,9 @@
|
|
|
22
23
|
}
|
|
23
24
|
function onload() {
|
|
24
25
|
try {
|
|
25
|
-
const doc = frame
|
|
26
|
+
const doc = frame?.contentDocument;
|
|
26
27
|
doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
|
|
27
|
-
if (!doc
|
|
28
|
+
if (!doc) throw new Error("The prepared page could not be loaded.");
|
|
28
29
|
if (!doc.querySelector('[data-rigel-text], [data-rigel-richtext], [data-rigel-image]')) throw new Error("This page has no prepared Rigel anchors.");
|
|
29
30
|
doc.addEventListener("click", event => event.preventDefault(), true);
|
|
30
31
|
doc.addEventListener("submit", event => event.preventDefault(), true);
|
|
@@ -42,7 +43,7 @@
|
|
|
42
43
|
</div>
|
|
43
44
|
{#if error}<p role="status" class="preview-error">{error}</p>{/if}
|
|
44
45
|
{#if path}
|
|
45
|
-
{#key key}<
|
|
46
|
+
{#key key}<PreviewFrame bind:frame url={path} title="Live website draft preview" {onload} onloading={() => { loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />{/key}
|
|
46
47
|
<small>Prepared template · Scripts, navigation and forms disabled. Unanchored fields and lists are not previewed.</small>
|
|
47
48
|
{:else}
|
|
48
49
|
<p role="status">{target.kind === "menu" ? "Menu preview is not supported." : "No prepared preview URL is mapped to this content."}</p>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import SchemaPanel from "./SchemaPanel.svelte";
|
|
9
9
|
import SidebarIcon from "./SidebarIcon.svelte";
|
|
10
10
|
import Icon from "./Icon.svelte";
|
|
11
|
+
import PreviewFrame from "./PreviewFrame.svelte";
|
|
11
12
|
import { fieldForPath, replaceAt, type SelectedField } from "./schema-edit";
|
|
12
13
|
import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
|
|
13
14
|
|
|
@@ -338,7 +339,6 @@
|
|
|
338
339
|
doc.addEventListener("mouseout", onOut, true);
|
|
339
340
|
doc.addEventListener("submit", (event) => event.preventDefault(), true);
|
|
340
341
|
doc.defaultView?.addEventListener("scroll", () => closeInline(), { passive: true });
|
|
341
|
-
if (previewPath && new URL(doc.URL).pathname.replace(/\/$/, "") !== previewPath.replace(/\/$/, "")) throw new Error("The prepared page could not be loaded.");
|
|
342
342
|
if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error("This page isn’t set up for live editing. Changes won’t appear in this preview.");
|
|
343
343
|
loaded = true;
|
|
344
344
|
bodyAvailable = !schema && !!body && !!doc.querySelector('[data-rigel-richtext="$body"]');
|
|
@@ -448,9 +448,7 @@
|
|
|
448
448
|
{#if !previewPath}
|
|
449
449
|
<p class="visual-error" role="status">{creating ? "New page — Save writes the file. The route renders once the site has it." : "No preview URL is mapped to this document. Use the properties panel to edit."}</p>
|
|
450
450
|
{:else}
|
|
451
|
-
|
|
452
|
-
<iframe bind:this={frame} title="Website page editor" sandbox="allow-same-origin" referrerpolicy="no-referrer" src={previewPath} onload={onLoad}></iframe>
|
|
453
|
-
{/key}
|
|
451
|
+
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
454
452
|
{/if}
|
|
455
453
|
{#if inline}
|
|
456
454
|
<input
|
|
@@ -2,7 +2,7 @@ import type { Manifest } from "../../manifest/types";
|
|
|
2
2
|
import type { Selection } from "../../manifest/navigation";
|
|
3
3
|
import { validateBody, type ArticleBody } from "../../article-body";
|
|
4
4
|
import { validateMenu } from "../../manifest/menu";
|
|
5
|
-
import {
|
|
5
|
+
import { validateCollectionValues } from "../../manifest/collections";
|
|
6
6
|
import { documentIndex, isPlainObject, validateDocument, validateDocumentId, validateSchema } from "../../schema/validate";
|
|
7
7
|
import { applyValues, readValues } from "../../routes/serialize";
|
|
8
8
|
|
|
@@ -163,7 +163,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
|
|
|
163
163
|
for (const file of list(folder,POST_SLUG)) {
|
|
164
164
|
const post = parseJson(await read(file.path));
|
|
165
165
|
if (!isPlainObject(post) || !isPlainObject(post.frontmatter)) throw new Error("Invalid remote article");
|
|
166
|
-
validateBody(post.body);
|
|
166
|
+
validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
|
|
167
167
|
collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -236,7 +236,7 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
|
|
|
236
236
|
const folder = cfg.collections.find(c => c.name === selection.collection)?.folder;
|
|
237
237
|
const collection = manifest.collections?.find(c => c.name === selection.collection);
|
|
238
238
|
if (!folder || !collection) throw new Error("Unknown collection");
|
|
239
|
-
validateBody(body);
|
|
239
|
+
validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
|
|
240
240
|
path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
|
|
241
241
|
data = {frontmatter:{...values,slug:selection.slug},body};
|
|
242
242
|
} else if (selection.kind === "document") {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
|
|
3
|
+
export async function fetchPreview(url: URL, signal: AbortSignal): Promise<string> {
|
|
4
|
+
let response: Response;
|
|
5
|
+
try {
|
|
6
|
+
response = await fetch(url, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer", redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
|
7
|
+
} catch {
|
|
8
|
+
throw new Error("Draft preview could not be fetched. Check the URL, HTTPS, CORS (Access-Control-Allow-Origin for this editor), redirects and the editor’s connect-src policy. Authentication cookies are not sent.");
|
|
9
|
+
}
|
|
10
|
+
if (!response.ok) throw new Error(`Draft preview returned HTTP ${response.status}. Check the draft deployment and mapped route.`);
|
|
11
|
+
if (!response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Draft preview did not return HTML. Check the mapped route and Content-Type header.");
|
|
12
|
+
const root = DOMPurify.sanitize(await response.text(), {
|
|
13
|
+
WHOLE_DOCUMENT: true,
|
|
14
|
+
RETURN_DOM: true,
|
|
15
|
+
ADD_TAGS: ["link"],
|
|
16
|
+
FORBID_TAGS: ["script", "base", "meta", "iframe", "object", "embed", "form"],
|
|
17
|
+
FORBID_ATTR: ["srcdoc", "action", "formaction", "autofocus"],
|
|
18
|
+
}) as HTMLElement;
|
|
19
|
+
for (const link of root.querySelectorAll("link")) {
|
|
20
|
+
if (link.getAttribute("rel")?.toLowerCase() !== "stylesheet") link.remove();
|
|
21
|
+
}
|
|
22
|
+
const head = root.querySelector("head")!;
|
|
23
|
+
const base = document.createElement("base"); base.href = url.href;
|
|
24
|
+
const policy = document.createElement("meta"); policy.httpEquiv = "Content-Security-Policy";
|
|
25
|
+
policy.content = "default-src 'none'; script-src 'none'; style-src https: http: 'unsafe-inline'; img-src https: http: data: blob:; font-src https: http: data:; media-src https: http:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri " + url.origin;
|
|
26
|
+
const referrer = document.createElement("meta"); referrer.name = "referrer"; referrer.content = "no-referrer";
|
|
27
|
+
head.prepend(policy, referrer, base);
|
|
28
|
+
return "<!doctype html>" + root.outerHTML;
|
|
29
|
+
}
|
package/src/editor/styles.css
CHANGED
|
@@ -263,6 +263,7 @@
|
|
|
263
263
|
justify-content: center;
|
|
264
264
|
flex-shrink: 0;
|
|
265
265
|
}
|
|
266
|
+
.rigel .avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
266
267
|
.rigel .uname { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
267
268
|
.rigel .user-details { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 0.125rem; }
|
|
268
269
|
.rigel .user-details small { color: var(--text-muted); font-size: 0.75rem; font-weight: 400; }
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CollectionField } from "./types";
|
|
2
|
+
import type { ContentDocument } from "../schema/types";
|
|
3
|
+
import { validateCollectionReferences } from "./references";
|
|
4
|
+
|
|
5
|
+
export function initialCollectionValues(fields: CollectionField[], title: string): Record<string, unknown> {
|
|
6
|
+
return Object.fromEntries(fields.map(field => [field.key,
|
|
7
|
+
field.type === "boolean" ? field.key === "draft" :
|
|
8
|
+
field.type === "tags" || (field.type === "reference" && field.multiple) ? [] :
|
|
9
|
+
field.type === "date" ? new Date().toISOString().slice(0, 10) :
|
|
10
|
+
field.key === "title" && field.type === "string" ? title : "",
|
|
11
|
+
]));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function validateCollectionValues(fields: CollectionField[], values: Record<string, unknown>, documents: ContentDocument[] = []): void {
|
|
15
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) throw new Error("Invalid article frontmatter");
|
|
16
|
+
for (const field of fields) {
|
|
17
|
+
const value = values[field.key];
|
|
18
|
+
if (field.required && (value === undefined || value === null || (typeof value === "string" && !value.trim()) || (Array.isArray(value) && !value.length))) throw new Error(`${field.label}: required`);
|
|
19
|
+
if (value === undefined || field.type === "reference") continue;
|
|
20
|
+
const valid = field.type === "boolean" ? typeof value === "boolean" :
|
|
21
|
+
field.type === "tags" ? Array.isArray(value) && value.every(tag => typeof tag === "string") :
|
|
22
|
+
field.type === "date" ? typeof value === "string" && (value === "" || (/^\d{4}-\d{2}-\d{2}$/.test(value) && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value)) :
|
|
23
|
+
typeof value === "string";
|
|
24
|
+
if (!valid) throw new Error(`${field.label}: expected ${field.type}`);
|
|
25
|
+
}
|
|
26
|
+
validateCollectionReferences(fields, values, documents);
|
|
27
|
+
}
|
package/src/manifest/types.ts
CHANGED
package/src/preview-urls.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
1
|
export function safePreviewUrl(value: string, image = false): boolean {
|
|
2
2
|
return !!value && !/[ \t\r\n\f\v\u0000-\u001f\u007f\\]/.test(value) && !value.startsWith("//") && (!/^[a-z][a-z\d+.-]*:/i.test(value) || (image ? /^https?:/i : /^(https?:|mailto:)/i).test(value));
|
|
3
3
|
}
|
|
4
|
+
|
|
5
|
+
export function previewBaseUrl(value: unknown): string {
|
|
6
|
+
try {
|
|
7
|
+
if (typeof value !== "string" || !/^https?:\/\/[^/@]+(?:\/|$)/.test(value) || /[\s\u0000-\u001f\u007f\\%?#]/.test(value) || value.split("/").some(part => part === "." || part === "..")) throw new Error();
|
|
8
|
+
const url = new URL(value);
|
|
9
|
+
if (url.username || url.password || (url.protocol !== "https:" && !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error();
|
|
10
|
+
return `${url.origin}${url.pathname.replace(/\/$/, "")}/`;
|
|
11
|
+
} catch { throw new Error("rigel: preview.liveUrl and preview.draftUrl must be absolute HTTPS base URLs without credentials, query or fragment (HTTP allowed on loopback)"); }
|
|
12
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -2,7 +2,7 @@ import MarkdownIt from "markdown-it";
|
|
|
2
2
|
import DOMPurify from "dompurify";
|
|
3
3
|
import { pageRoute } from "./schema/paths";
|
|
4
4
|
import { renderArticle } from "./article-render";
|
|
5
|
-
import { safePreviewUrl } from "./preview-urls";
|
|
5
|
+
import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
|
|
6
6
|
export { safePreviewUrl } from "./preview-urls";
|
|
7
7
|
|
|
8
8
|
export interface PreviewDocumentMapping {
|
|
@@ -11,6 +11,8 @@ export interface PreviewDocumentMapping {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export interface PreviewConfig {
|
|
14
|
+
liveUrl?: string;
|
|
15
|
+
draftUrl?: string;
|
|
14
16
|
pages?: Record<string, string>;
|
|
15
17
|
collections?: Record<string, { url: string; new: string }>;
|
|
16
18
|
documents?: Record<string, PreviewDocumentMapping>;
|
|
@@ -23,10 +25,16 @@ export interface PreviewTarget {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew = false): string | null {
|
|
26
|
-
if (target.kind === "document") return pageRoute(target.slug, config.documents?.[target.type ?? ""]);
|
|
27
28
|
const mapping = target.kind === "post" ? config.collections?.[target.collection ?? ""] : undefined;
|
|
28
|
-
const path = target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
|
|
29
|
-
return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").
|
|
29
|
+
const path = target.kind === "document" ? pageRoute(target.slug, config.documents?.[target.type ?? ""]) : target.kind === "page" ? config.pages?.[target.slug] : mapping ? (isNew ? mapping.new : mapping.url.replaceAll("{slug}", encodeURIComponent(target.slug))) : undefined;
|
|
30
|
+
return typeof path === "string" && /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !/%(?:2e|2f|5c)/i.test(path) && !path.split("/").some(part => part === "." || part === "..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" = "draft", isNew = false): string | null {
|
|
34
|
+
const path = previewPath(config, target, mode === "draft" && isNew);
|
|
35
|
+
const base = mode === "live" ? config.liveUrl : config.draftUrl;
|
|
36
|
+
if (!path || base === undefined) return path;
|
|
37
|
+
try { return new URL(path.slice(1), previewBaseUrl(base)).href; } catch { return null; }
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export function anchorValue(values: unknown, path: string): { found: boolean; value: unknown } {
|
package/src/routes/local-post.ts
CHANGED
|
@@ -5,7 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { validateBody, type ArticleBody } from "../article-body";
|
|
7
7
|
import config from "virtual:rigel-config";
|
|
8
|
-
import {
|
|
8
|
+
import { validateCollectionValues } from "../manifest/collections";
|
|
9
9
|
import { readDocuments } from "../schema/store";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
11
|
import type { CollectionConfig } from "../config";
|
|
@@ -46,9 +46,9 @@ export const POST: APIRoute = async ({ request, params }) => {
|
|
|
46
46
|
validateBody(body.body);
|
|
47
47
|
const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
|
|
48
48
|
const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root)) : [];
|
|
49
|
-
|
|
49
|
+
validateCollectionValues(fields, body.frontmatter, documents);
|
|
50
50
|
} catch (error) {
|
|
51
|
-
return new Response(error instanceof Error ? error.message : "Invalid
|
|
51
|
+
return new Response(error instanceof Error ? error.message : "Invalid article", { status: 400 });
|
|
52
52
|
}
|
|
53
53
|
await mkdir(path.dirname(file), { recursive: true });
|
|
54
54
|
const fm = { ...body.frontmatter, slug };
|
|
@@ -50,6 +50,7 @@ const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.fi
|
|
|
50
50
|
).map(([path, raw]) => {
|
|
51
51
|
const post = JSON.parse(raw);
|
|
52
52
|
__rigelValidateBody(post.body);
|
|
53
|
+
__rigelValidateCollectionValues(${JSON.stringify(c.fields)}, post.frontmatter, documents);
|
|
53
54
|
post.slug = path.split("/").pop().replace(/\\.json$/, "");
|
|
54
55
|
return post;
|
|
55
56
|
}),
|
|
@@ -165,6 +166,7 @@ export function derefDocument(value) { return deref(value, __rigelIndex); }
|
|
|
165
166
|
}
|
|
166
167
|
return `
|
|
167
168
|
import { validateBody as __rigelValidateBody } from ${JSON.stringify(fileURLToPath(new URL("../article-body.ts", import.meta.url)))};
|
|
169
|
+
import { validateCollectionValues as __rigelValidateCollectionValues } from ${JSON.stringify(fileURLToPath(new URL("../manifest/collections.ts", import.meta.url)))};
|
|
168
170
|
const modules = import.meta.glob(${JSON.stringify(pattern)}, { eager: true });
|
|
169
171
|
const loaded = Object.values(modules).map((m) => m.default);
|
|
170
172
|
${documentsSrc}
|