@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 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.0-beta.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`);
@@ -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 { previewPath } from "../preview";
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
- const previewDocPath = $derived.by(() => {
375
+ function documentPreview(mode: "live" | "draft") {
373
376
  if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
374
- return previewPath(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) });
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: { title, date: new Date().toISOString().slice(0, 10), author: "", draft: true, ...Object.fromEntries(c.fields.filter(field => field.type === "reference" && field.multiple).map(field => [field.key, []])) }, body: emptyBody() } };
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>Editing needs write access from both your account and the GitHub App. Ask an administrator to check repository permissions and the App installation, then check again.</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 ? "Loading the shared drafts workspace…" : "Repository access verified. Retry loading the drafts workspace."}</p>
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">Install / configure GitHub access</a>
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, then choose which repositories the Rigel GitHub App may access. The editor opens only after GitHub confirms write access to this repository.</p>
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" ? "Retry loading drafts" : "Check repository access"}</button>
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">Save writes to <strong>{manifest.github.branch}</strong>, not <strong>{manifest.github.publishBranch}</strong>. Publishing is a separate pull request.</p>
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
- <span class="avatar"><Icon name="user" size={20} /></span>
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
- <span class="avatar"><Icon name="user" size={20} /></span>
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={previewDocPath}
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 ? previewPath(manifest.preview, sel, !!newEntry) : null}
927
- livePath={manifest.preview ? previewPath(manifest.preview, sel) : null}
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, previewPath, type PreviewConfig, type PreviewTarget } from "../../preview";
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(previewPath(config, target));
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(() => previewPath(config, target, isNew));
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.contentDocument;
26
+ const doc = frame?.contentDocument;
26
27
  doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
27
- if (!doc || new URL(doc.URL).pathname !== path) throw new Error("The prepared same-origin page could not be loaded.");
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}<iframe bind:this={frame} title="Live website draft preview" sandbox="allow-same-origin" referrerpolicy="no-referrer" src={path} {onload}></iframe>{/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
- {#key `${previewPath}:${reloadToken}`}
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 { validateCollectionReferences } from "../../manifest/references";
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); validateCollectionReferences(collection.fields,post.frontmatter,manifest.documents);
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); validateCollectionReferences(collection.fields,values,manifest.documents ?? []);
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
+ }
@@ -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
+ }
@@ -52,6 +52,7 @@ export interface CollectionField {
52
52
  type: "string" | "text" | "boolean" | "date" | "tags" | "markdown" | "image" | "reference";
53
53
  to?: string;
54
54
  multiple?: boolean;
55
+ required?: boolean;
55
56
  options?: import("./references").ReferenceOption[];
56
57
  }
57
58
 
@@ -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("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
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 } {
@@ -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 { validateCollectionReferences } from "../manifest/references";
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
- validateCollectionReferences(fields, body.frontmatter, documents);
49
+ validateCollectionValues(fields, body.frontmatter, documents);
50
50
  } catch (error) {
51
- return new Response(error instanceof Error ? error.message : "Invalid references", { status: 400 });
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}