@rigel-cms/rigel 0.1.0-beta.1 → 0.1.4-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.4-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`);
@@ -1,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import { onMount } from "svelte";
3
+ import { tooltipRoot } from "./lib/tooltip";
4
+ onMount(() => tooltipRoot(document.body).destroy);
2
5
  import type {
3
6
  Manifest,
4
7
  ManifestCollection,
@@ -15,19 +18,20 @@
15
18
  import SchemaPanel from "./lib/SchemaPanel.svelte";
16
19
  import { collectionPreviewValues, referenceLabel, referenceOptions } from "../manifest/references";
17
20
  import { isSeoField } from "../manifest/seo";
21
+ import { initialCollectionValues } from "../manifest/collections";
18
22
  import { slugify } from "./lib/slugify";
19
23
  import Icon from "./lib/Icon.svelte";
20
24
  import SidebarIcon from "./lib/SidebarIcon.svelte";
21
25
  import logo from "./assets/rigelcms-icon.png?url";
22
26
  import { getSettings, setSettings } from "./lib/settings";
23
- import { eraseData } from "./lib/storage";
27
+ import { eraseData, readSession, writeSession, clearSession } from "./lib/storage";
24
28
  import MenuEditor from "./lib/MenuEditor.svelte";
25
29
  import type { MenuItem } from "../manifest/menu";
26
30
  import UnsavedChanges from "./lib/UnsavedChanges.svelte";
27
31
  import ActionDialog from "./lib/ActionDialog.svelte";
28
32
  import VisualEditor from "./lib/VisualEditor.svelte";
29
33
  import { newDocumentFromTemplate } from "../schema/paths";
30
- import { previewPath } from "../preview";
34
+ import { previewUrl } from "../preview";
31
35
  import type { ContentDocument, Schema } from "../schema/types";
32
36
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
33
37
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
@@ -39,6 +43,7 @@
39
43
  let ready = $state(false);
40
44
  let blueprint: Manifest | null = null;
41
45
  let gitContext: GitContext | null = null;
46
+ let authRevision = 0;
42
47
  let connected = $state(false);
43
48
  let connectionBusy = $state(false);
44
49
  let connectionError = $state("");
@@ -75,6 +80,14 @@
75
80
  blueprint = data;
76
81
  manifest = data;
77
82
  ready = true;
83
+ if (data.backend === "github" && data.github) {
84
+ const session = readSession(data.github);
85
+ if (session) {
86
+ gitContext = { token: session.accessToken, repo: data.github.repo, branch: data.github.branch };
87
+ sessionExpiresAt = session.expiresAt;
88
+ await recheckGitHub();
89
+ }
90
+ }
78
91
  } catch (error) {
79
92
  loadError = error instanceof Error ? error.message : "Could not load the editor configuration.";
80
93
  }
@@ -84,7 +97,7 @@
84
97
  if (connectionBusy) throw new Error("Wait for GitHub access verification before saving.");
85
98
  if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error("Connect GitHub and verify repository access before saving.");
86
99
  if (Date.now() >= sessionExpiresAt - 30_000) {
87
- tokenExpired = true;
100
+ invalidateSession();
88
101
  throw new Error("Your GitHub session expired. Reconnect GitHub, then retry. Your unsaved edits are still here.");
89
102
  }
90
103
  return gitContext;
@@ -92,27 +105,54 @@
92
105
 
93
106
  async function verifyGitHub() {
94
107
  const config = blueprint?.github;
95
- if (!config || !gitContext) return;
96
- if (Date.now() >= sessionExpiresAt - 30_000) throw new Error("Your GitHub session expired. Sign in again to check repository access.");
97
- githubAccess = await checkGitHubAccess(gitContext.token, config.repo, config.appSlug);
98
- if (githubAccess.status !== "ready") return;
99
- if (!connected && blueprint) {
100
- const remote = await loadManifestRemote(gitContext, blueprint);
101
- manifest = remote;
102
- connected = true;
108
+ const context = gitContext;
109
+ if (!config || !context) return;
110
+ try {
111
+ if (Date.now() >= sessionExpiresAt - 30_000) {
112
+ invalidateSession();
113
+ throw new Error("Your GitHub session expired. Sign in again to check repository access.");
114
+ }
115
+ const access = await checkGitHubAccess(context.token, config.repo, config.appSlug);
116
+ if (gitContext !== context) return;
117
+ githubAccess = access;
118
+ if (access.status !== "ready") return;
119
+ if (!connected && blueprint) {
120
+ const remote = await loadManifestRemote(context, blueprint);
121
+ if (gitContext !== context) return;
122
+ manifest = remote;
123
+ connected = true;
124
+ }
125
+ } catch (error) {
126
+ if (gitContext === context) handleAuthError(error);
127
+ throw error;
103
128
  }
104
129
  }
105
130
 
131
+ function invalidateSession() {
132
+ authRevision += 1;
133
+ clearSession();
134
+ gitContext = null;
135
+ sessionExpiresAt = 0;
136
+ githubAccess = null;
137
+ tokenExpired = true;
138
+ }
139
+
140
+ function handleAuthError(error: unknown) {
141
+ if (error instanceof Error && "status" in error && error.status === 401) invalidateSession();
142
+ }
143
+
106
144
  async function connectGitHub() {
107
145
  if (connectionBusy || saveState === "saving" || !manifest.github) return;
108
146
  connectionBusy = true;
109
147
  connectionError = "";
148
+ const revision = ++authRevision;
110
149
  try {
111
150
  const session = await startGitHubLogin(manifest.github.authOrigin);
112
- if (gitContext) gitContext.token = session.accessToken;
113
- else gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
151
+ if (revision !== authRevision) return;
152
+ gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
114
153
  githubAccess = null;
115
154
  sessionExpiresAt = session.expiresAt;
155
+ writeSession(manifest.github, session);
116
156
  await verifyGitHub();
117
157
  } catch (error) {
118
158
  connectionError = error instanceof Error ? error.message : "Could not connect GitHub. Try signing in again.";
@@ -134,9 +174,7 @@
134
174
  if (saveState === "saving" || connectionBusy) return;
135
175
  userOpen = false;
136
176
  requestNavigation(() => {
137
- gitContext = null;
138
- sessionExpiresAt = 0;
139
- githubAccess = null;
177
+ invalidateSession();
140
178
  connected = false;
141
179
  connectionError = "";
142
180
  settingsOpen = false;
@@ -157,9 +195,9 @@
157
195
 
158
196
  $effect(() => {
159
197
  const expiry = sessionExpiresAt;
160
- tokenExpired = !!expiry && Date.now() >= expiry - 30_000;
198
+ tokenExpired = !expiry || Date.now() >= expiry - 30_000;
161
199
  if (!expiry) return;
162
- const timer = setTimeout(() => { tokenExpired = true; }, Math.max(0, expiry - Date.now() - 30_000));
200
+ const timer = setTimeout(invalidateSession, Math.max(0, expiry - Date.now() - 30_000));
163
201
  return () => clearTimeout(timer);
164
202
  });
165
203
 
@@ -185,7 +223,7 @@
185
223
  ? `/rigel/api/local/documents/${encodeURIComponent(docSel.id)}`
186
224
  : m ? `/rigel/api/local/menus/${m.slug}` : po && c ? `/rigel/api/local/${c.name}/${encodeURIComponent(po.slug)}` : "/rigel/api/local/save";
187
225
  const payload = docSel ? { data: values } : m ? { slug: m.slug, title: values.title, items: values.items } : po ? { frontmatter: values, body } : { slug: p?.slug, values };
188
- const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
226
+ const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json", ...(po && creating ? { "If-None-Match": "*" } : {}) }, body: JSON.stringify(payload) });
189
227
  if (!res.ok) throw new Error((await res.text()) || `Save failed (${res.status})`);
190
228
  }
191
229
  if (docSel) {
@@ -204,6 +242,7 @@
204
242
  saveState = dirty ? "idle" : "saved";
205
243
  saveError = "";
206
244
  } catch (error) {
245
+ handleAuthError(error);
207
246
  saveState = "error";
208
247
  saveError = error instanceof Error ? error.message : "Could not save changes";
209
248
  return false;
@@ -219,8 +258,10 @@
219
258
 
220
259
  let settings = $state(getSettings());
221
260
  let settingsError = $state("");
222
- const userName = $derived(githubMode ? githubAccess?.user.login ?? "GitHub user" : "Local user");
223
- const userMode = $derived(githubMode ? `${manifest.github?.repo} · ${manifest.github?.branch}` : "Local mode");
261
+ const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? "GitHub user" : "Local user");
262
+ const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
263
+ let failedAvatar = $state("");
264
+ const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : "Local mode");
224
265
  let content: HTMLDivElement;
225
266
  let contentWidth = $state(0);
226
267
  let resizing = $state(false);
@@ -270,7 +311,7 @@
270
311
  function eraseBrowserData() {
271
312
  actionDialog = {
272
313
  title: "Erase browser data?", confirmLabel: "Erase data", danger: true,
273
- description: "This removes Rigel settings, API keys and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
314
+ description: "This removes Rigel settings, API keys, GitHub login and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
274
315
  onconfirm: () => {
275
316
  if (saveState === "saving") throw new Error("Wait for the current save to finish.");
276
317
  eraseData();
@@ -369,10 +410,10 @@
369
410
  if (current?.kind === "document") map[current.id] = draft;
370
411
  return map;
371
412
  });
372
- const previewDocPath = $derived.by(() => {
413
+ function documentPreview(mode: "draft" | "website") {
373
414
  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
- });
415
+ return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
416
+ }
376
417
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
377
418
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
378
419
 
@@ -593,10 +634,9 @@
593
634
  actionDialog = {
594
635
  title: `Add to ${c.label}`, description: "Enter a title to start a draft. Nothing is written until you press Save.", fieldLabel: "Title", confirmLabel: "Create draft",
595
636
  onconfirm: (title) => {
596
- const slug = slugify(title);
637
+ const slug = slugify(title, c.posts.map((post) => post.slug));
597
638
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
598
- 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() } };
639
+ newEntry = { collection: c.name, post: { slug, frontmatter: initialCollectionValues(c.fields, title), body: emptyBody() } };
600
640
  openSelection({ kind: "post", collection: c.name, slug });
601
641
  },
602
642
  };
@@ -610,7 +650,10 @@
610
650
  description: unsaved ? "This discards the new post and its unsaved changes." : `This deletes ${current.slug}.json${githubMode ? ` from ${manifest.github?.branch}` : ""} and discards any unsaved changes. This cannot be undone in the editor.`,
611
651
  onconfirm: async () => {
612
652
  if (!unsaved) {
613
- if (githubMode) await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug);
653
+ if (githubMode) {
654
+ try { await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug); }
655
+ catch (error) { handleAuthError(error); throw error; }
656
+ }
614
657
  else {
615
658
  const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE" });
616
659
  if (!res.ok) throw new Error((await res.text()) || `Delete failed (${res.status})`);
@@ -626,7 +669,23 @@
626
669
 
627
670
  </script>
628
671
 
672
+ {#snippet avatar()}
673
+ <span class="avatar">
674
+ {#if userAvatar && failedAvatar !== userAvatar}
675
+ <img src={userAvatar} alt="" width="36" height="36" referrerpolicy="no-referrer" onerror={() => { failedAvatar = userAvatar; }} />
676
+ {:else}
677
+ <Icon name="user" size={20} />
678
+ {/if}
679
+ </span>
680
+ {/snippet}
681
+
629
682
  <svelte:window
683
+ onstorage={(event) => {
684
+ if ((event.key === "rigelcms.session" || event.key === null) && blueprint?.github && !readSession(blueprint.github)) {
685
+ invalidateSession();
686
+ connectionError = "Signed out in another tab. Reconnect GitHub to save. Your unsaved edits are still here.";
687
+ }
688
+ }}
630
689
  onresize={endPreviewResize}
631
690
  onblur={endPreviewResize}
632
691
  oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
@@ -645,37 +704,32 @@
645
704
  {#if !ready || (githubMode && !connected)}
646
705
  <div class="rigel connection-shell">
647
706
  <main id="github-connection" class="connection-card" aria-busy={connectionBusy}>
648
- <img src={logo} alt="" width="64" height="64" />
649
- <h1>{!ready ? "RigelCMS" : "Connect GitHub"}</h1>
707
+ <div class="connection-brand"><img src={logo} alt="" width="64" height="64" /><span>Rigel <span class="brand-suffix">CMS</span></span></div>
708
+ <h1>{!ready ? "Loading editor" : "Connect GitHub"}</h1>
650
709
  {#if !ready}
651
710
  {#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
652
711
  {:else}<p role="status">Loading the editor configuration…</p>{/if}
653
712
  {:else if manifest.github}
654
- <p>Content repository <strong>{manifest.github.repo}</strong></p>
655
713
  {#if githubAccess}
656
714
  <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>
715
+ {#if githubAccess.status === "read-only" || githubAccess.status === "installation-required"}
716
+ <p>You don’t have editing access. Contact the website administrator.</p>
662
717
  {:else}
663
- <p role="status">{connectionBusy ? "Loading the shared drafts workspace…" : "Repository access verified. Retry loading the drafts workspace."}</p>
718
+ <p role="status">{connectionBusy ? "Opening the editor…" : "Access confirmed. Try opening the editor again."}</p>
664
719
  {/if}
665
720
  {#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>
721
+ <a id="github-install" class="primary" href={githubAccess.installUrl} target="_blank" rel="noopener noreferrer">Manage GitHub access</a>
668
722
  {/if}
669
723
  {: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>
724
+ <p>Sign in to edit this website.</p>
671
725
  {/if}
672
726
  <div class="connection-actions">
673
727
  {#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>
728
+ <button id="github-check-access" class="primary" disabled={connectionBusy} onclick={recheckGitHub}>{connectionBusy ? "Checking access…" : githubAccess?.status === "ready" ? "Open editor" : "Check access"}</button>
675
729
  {/if}
676
730
  <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
731
  </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>
732
+ <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
733
  {/if}
680
734
  {#if connectionError}<p class="connection-error" role="alert">{connectionError}</p>{/if}
681
735
  </main>
@@ -685,7 +739,7 @@
685
739
  <aside id="rigel-sidebar">
686
740
  <a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
687
741
  <img src={logo} alt="" width="32" height="32" />
688
- <span>RigelCMS</span>
742
+ <span>Rigel <span class="brand-suffix">CMS</span></span>
689
743
  <span id="rigel-beta" class="badge">Beta</span>
690
744
  </a>
691
745
  <TopBar {manifest} {sel} onselect={select} />
@@ -706,7 +760,7 @@
706
760
  data-document={node.target.kind === "document" ? node.target.id : undefined}
707
761
  data-page={node.target.kind === "page" ? node.target.slug : undefined}
708
762
  aria-label={node.label}
709
- title={node.label}
763
+ data-tooltip={node.label}
710
764
  class:active={navActive(node.target)}
711
765
  aria-current={navActive(node.target) ? "page" : undefined}
712
766
  onclick={() => select(node.target)}
@@ -717,21 +771,21 @@
717
771
  {/each}
718
772
  </nav>
719
773
 
720
- <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} title={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
774
+ <button class="sidebar-toggle" aria-expanded={!settings.userPref.sidebarCollapsed} aria-controls="rigel-sidebar-navigation" aria-label={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} data-tooltip={settings.userPref.sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"} onclick={() => { settings.userPref.sidebarCollapsed = !settings.userPref.sidebarCollapsed; }}>
721
775
  <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
722
776
  </button>
723
777
  <div class="aside-foot">
724
778
  <div class="user-menu-wrap">
725
- <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>
727
- <span class="user-details"><span class="uname">{userName}</span><small>{userMode}</small></span>
779
+ <button class="user-btn" aria-label={githubMode ? "GitHub user menu" : "Local user menu"} data-tooltip={githubMode ? "GitHub user menu" : "Local user menu"} aria-haspopup="menu" aria-expanded={userOpen} onclick={() => (userOpen = !userOpen)}>
780
+ {@render avatar()}
781
+ <span class="user-details"><span class="uname">{userName}</span><small>{userLogin}</small></span>
728
782
  <span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
729
783
  </button>
730
784
  {#if userOpen}
731
785
  <div class="user-menu" role="menu">
732
786
  <div class="user-menu-head">
733
- <span class="avatar"><Icon name="user" size={20} /></span>
734
- <div class="user-details"><strong>{userName}</strong><small>{userMode}</small></div>
787
+ {@render avatar()}
788
+ <div class="user-details"><strong>{userName}</strong><small>{userLogin}</small></div>
735
789
  </div>
736
790
  <div class="divider"></div>
737
791
  <a role="menuitem" href="/" target="_blank" rel="noopener">
@@ -752,7 +806,7 @@
752
806
  <a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">Configure GitHub access</a>
753
807
  <button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>Reconnect GitHub</button>
754
808
  {/if}
755
- <button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} title={githubMode ? "Sign out of this editor" : "Local mode"} onclick={signOut}>
809
+ <button id={githubMode ? "github-sign-out" : undefined} role="menuitem" class:muted={!githubMode} disabled={!githubMode || saveState === "saving" || connectionBusy} data-tooltip={githubMode ? "Sign out of this editor" : "Local mode"} onclick={signOut}>
756
810
  <Icon name="signOut" size={13} />
757
811
  Sign Out
758
812
  {#if !githubMode}<span class="soon">backend: github</span>{/if}
@@ -799,7 +853,7 @@
799
853
  <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>Add new</button>
800
854
  {/if}
801
855
  {#if sel?.kind === "post" && post}
802
- <button class="icon-btn" title="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
856
+ <button class="icon-btn" aria-label="Delete post" data-tooltip="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
803
857
  <Icon name="trash" size={15} />
804
858
  </button>
805
859
  {/if}
@@ -876,7 +930,8 @@
876
930
  docType={activeDocType}
877
931
  values={draft}
878
932
  documents={draftDocuments}
879
- previewPath={previewDocPath}
933
+ previewPath={documentPreview("draft")}
934
+ websitePath={documentPreview("website")}
880
935
  creating={newDocument?.id === sel.id}
881
936
  devMode={settings.devMode}
882
937
  reloadToken={documentReload}
@@ -923,8 +978,8 @@
923
978
  body={bodyDraft}
924
979
  bodyInline={settings.userPref.editorExperience === "inline"}
925
980
  onbody={(value) => { bodyDraft = value; touch(); }}
926
- previewPath={manifest.preview ? previewPath(manifest.preview, sel, !!newEntry) : null}
927
- livePath={manifest.preview ? previewPath(manifest.preview, sel) : null}
981
+ previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
982
+ websitePath={manifest.preview ? previewUrl(manifest.preview, sel, "website") : null}
928
983
  creating={!!newEntry}
929
984
  devMode={settings.devMode}
930
985
  onchange={(next) => { draft = next; touch(); }}
@@ -938,7 +993,7 @@
938
993
  {:else}
939
994
  <div class="welcome">
940
995
  <img src={logo} alt="" width="64" height="64" />
941
- <h2>Welcome to RigelCMS</h2>
996
+ <h2>Welcome to Rigel CMS</h2>
942
997
  <p>The best CMS in the whole space.</p>
943
998
  <p class="welcome-hint">Select a page or collection from the sidebar to start editing.</p>
944
999
  </div>
@@ -959,7 +1014,7 @@
959
1014
  {/if}
960
1015
 
961
1016
  {#if settingsOpen}
962
- <Settings bind:s={settings} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
1017
+ <Settings bind:s={settings} github={githubMode ? manifest.github : undefined} user={githubAccess?.user} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
963
1018
  {/if}
964
1019
  {#if shortcutsOpen}
965
1020
  <Shortcuts onclose={() => (shortcutsOpen = false)} />
@@ -509,10 +509,10 @@
509
509
 
510
510
  {#snippet formatButtons()}
511
511
  {#each marks.slice(0, 2) as mark}
512
- <button type="button" aria-label={mark.label} title={mark.label} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
512
+ <button type="button" aria-label={mark.label} data-tooltip={mark.label} aria-pressed={activeMarks.includes(mark.type)} onpointerdown={event => event.preventDefault()} onclick={() => format(mark.type)}><Icon name={mark.icon} size={18} /></button>
513
513
  {/each}
514
- <button type="button" aria-label="Link" title="Link" aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
515
- <button type="button" aria-label="More formatting" title="More formatting" aria-haspopup="menu" aria-expanded={formatsOpen} aria-controls={`${id}-formats`} popovertarget={`${id}-formats`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); formatTrigger = event.currentTarget; formatMenu.matches(':popover-open') ? closeMenu() : openMenu(event, formatMenu); }}><Icon name="caretDown" size={18} /></button>
514
+ <button type="button" aria-label="Link" data-tooltip="Link" aria-expanded={linkOpen} aria-controls={`${id}-link`} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
515
+ <button type="button" aria-label="More formatting" data-tooltip="More formatting" aria-haspopup="menu" aria-expanded={formatsOpen} aria-controls={`${id}-formats`} popovertarget={`${id}-formats`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); formatTrigger = event.currentTarget; formatMenu.matches(':popover-open') ? closeMenu() : openMenu(event, formatMenu); }}><Icon name="caretDown" size={18} /></button>
516
516
  {/snippet}
517
517
  {#snippet tableControls()}
518
518
  <div class="block-table-controls" role="group" aria-label="Table controls">
@@ -526,24 +526,24 @@
526
526
  {#snippet toolbar(image: boolean)}
527
527
  <div class="body-toolbar formatting-toolbar" class:image-toolbar={image} role="group" aria-label={image ? "Image editing" : "Body formatting"} style={inline || image ? floating : undefined} hidden={markdownView || ((inline || image) && !floating) || (image && !imageData)}>
528
528
  {#if image}
529
- <button type="button" aria-label="Edit alt text" title="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
530
- <button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} title={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
529
+ <button type="button" aria-label="Edit alt text" data-tooltip="Edit alt text" onpointerdown={event => event.preventDefault()} onclick={openAlt}><Icon name="textAa" size={18} /></button>
530
+ <button type="button" aria-label={imageData?.url ? "Edit image link" : "Add image link"} data-tooltip={imageData?.url ? "Edit image link" : "Add image link"} onpointerdown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
531
531
  {:else}
532
- <Select ariaLabel="Block type" title={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
532
+ <Select ariaLabel="Block type" data-tooltip={activeBlock?.label ?? "Block type"} icon={activeBlock?.icon ?? "paragraph"} value={imageData ? "" : blockType} options={textBlocks} disabled={!!imageData} onchange={next => { hoverKey = ""; changeBlock(String(next)); }} />
533
533
  {#if !inline || !["image", "divider"].includes(blockType)}<span class="toolbar-separator" aria-hidden="true"></span>{@render formatButtons()}<span class="toolbar-separator" aria-hidden="true"></span>{/if}
534
534
  {/if}
535
535
  <ImagePicker toolbar value={image ? imageData?.src ?? "" : ""} altText={image ? imageData?.alt ?? "" : ""} triggerLabel={image ? "Replace image" : "Insert image"} onopen={() => { if (!imageJob) imageJob = { key: activeKey, replace: image }; linkOpen = false; altOpen = false; }} onpick={pickImage} onclose={() => { imageJob = null; refocus(); }} />
536
536
  {#if !image}
537
- <button type="button" aria-label="Table" title="Table" aria-pressed={blockType === "table"} popovertarget={`${id}-table`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); tableMenu.matches(':popover-open') ? closeMenu() : openMenu(event, tableMenu); }}><Icon name="table" size={18} /></button>
538
- <button type="button" aria-label="Bulleted list" title="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
539
- <button type="button" aria-label="Numbered list" title="Numbered list" aria-pressed={listType === "number"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
537
+ <button type="button" aria-label="Table" data-tooltip="Table" aria-pressed={blockType === "table"} popovertarget={`${id}-table`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); tableMenu.matches(':popover-open') ? closeMenu() : openMenu(event, tableMenu); }}><Icon name="table" size={18} /></button>
538
+ <button type="button" aria-label="Bulleted list" data-tooltip="Bulleted list" aria-pressed={listType === "bullet"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("bullet")}><Icon name="listBullets" size={18} /></button>
539
+ <button type="button" aria-label="Numbered list" data-tooltip="Numbered list" aria-pressed={listType === "number"} disabled={["image", "divider"].includes(blockType)} onpointerdown={event => event.preventDefault()} onclick={() => toggleList("number")}><Icon name="listNumbers" size={18} /></button>
540
540
  {#if !inline}
541
541
  <button type="button" class="block-history" disabled={!canUndo} onclick={() => editor.dispatchCommand(L.UNDO_COMMAND, undefined)}>Undo</button>
542
542
  <button type="button" class="block-history" disabled={!canRedo} onclick={() => editor.dispatchCommand(L.REDO_COMMAND, undefined)}>Redo</button>
543
- <button type="button" class="block-add" aria-label="Add block" title="Add block" onclick={addBlock}>Add block</button>
543
+ <button type="button" class="block-add" aria-label="Add block" data-tooltip="Add block" onclick={addBlock}>Add block</button>
544
544
  {/if}
545
545
  {/if}
546
- {#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions" title="More actions" aria-haspopup="true" popovertarget={`${id}-actions`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); menu.matches(':popover-open') ? closeMenu() : openMenu(event); }}><Icon name="dotsThreeOutlineVertical" size={18} /></button>{/if}
546
+ {#if inline || image}<span class="toolbar-separator" aria-hidden="true"></span><button type="button" aria-label="More actions" data-tooltip="More actions" aria-haspopup="true" popovertarget={`${id}-actions`} onpointerdown={event => event.preventDefault()} onclick={event => { event.preventDefault(); menu.matches(':popover-open') ? closeMenu() : openMenu(event); }}><Icon name="dotsThreeOutlineVertical" size={18} /></button>{/if}
547
547
  </div>
548
548
  {/snippet}
549
549
  <div class="block-editor" bind:this={editorHost}>
@@ -556,9 +556,9 @@
556
556
  <label for={`${id}-url`}>Link URL</label>
557
557
  <div class="markdown-link-controls">
558
558
  <input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} />
559
- <button type="button" aria-label="Apply link" title="Apply link" onclick={() => applyLink()}><Icon name="check" size={16} /></button>
560
- <button type="button" aria-label="Remove link" title="Remove link" onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
561
- <button type="button" aria-label="Close link" title="Close link" onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
559
+ <button type="button" aria-label="Apply link" data-tooltip="Apply link" onclick={() => applyLink()}><Icon name="check" size={16} /></button>
560
+ <button type="button" aria-label="Remove link" data-tooltip="Remove link" onclick={() => applyLink(true)}><Icon name="linkBreak" size={16} /></button>
561
+ <button type="button" aria-label="Close link" data-tooltip="Close link" onclick={() => { linkOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
562
562
  </div>
563
563
  {#if linkError}<p role="alert">{linkError}</p>{/if}
564
564
  </div>
@@ -568,8 +568,8 @@
568
568
  <label for={`${id}-alt`}>Alt text (leave empty if decorative)</label>
569
569
  <div class="markdown-link-controls">
570
570
  <input id={`${id}-alt`} type="text" aria-label="Image alt text" bind:this={altInput} bind:value={altText} />
571
- <button type="button" aria-label="Apply alt text" title="Apply alt text" onclick={applyAlt}><Icon name="check" size={16} /></button>
572
- <button type="button" aria-label="Close alt text" title="Close alt text" onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
571
+ <button type="button" aria-label="Apply alt text" data-tooltip="Apply alt text" onclick={applyAlt}><Icon name="check" size={16} /></button>
572
+ <button type="button" aria-label="Close alt text" data-tooltip="Close alt text" onclick={() => { altOpen = false; refocus(); }}><Icon name="x" size={16} /></button>
573
573
  </div>
574
574
  </div>
575
575
  {/if}
@@ -578,9 +578,9 @@
578
578
  <div class="block-markdown-view">{#if inline}<button type="button" class="outlined" onclick={async () => { markdownView = false; await tick(); editor.focus(); }}>Close Markdown view</button>{/if}<pre class="block-markdown" aria-label="Body as Markdown" tabindex="0">{articleMarkdown(value)}</pre></div>
579
579
  {/if}
580
580
  <div class="block-surface" bind:this={surface} hidden={markdownView} onpointermove={hover} role="group" aria-label="Article blocks">
581
- {#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here" title="Insert paragraph" onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
581
+ {#if insertion && !markdownView}<button type="button" class="block-insert" style={`top:${insertion.top}px;left:${insertion.left}px`} aria-label="Insert paragraph here" data-tooltip="Insert paragraph" onpointerdown={event => event.preventDefault()} onpointerleave={leaveInsertion} onblur={() => { insertion = null; }} onclick={insertParagraph}><Icon name="plus" size={16} /></button>{/if}
582
582
  {#if !inline}<div class="block-content" contenteditable="true" role="textbox" aria-label="Body" aria-multiline="true" bind:this={root}></div>{/if}
583
- {#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="Block options and drag handle" title="Drag to reorder; click for block options" onpointerdown={startDrag} onpointermove={moveDrag} onpointerup={() => endDrag()} onpointercancel={() => endDrag(true)} onlostpointercapture={() => endDrag(true)} onclick={openMenu}><Icon name="dotsThreeOutlineVertical" size={16} /></button>{/if}
583
+ {#if hoverKey || activeKey}<button bind:this={handle} type="button" class="block-handle" style={`top:${handleTop}px;${inline ? `left:${handleLeft}px` : ''}`} aria-label="Block options and drag handle" data-tooltip="Drag to reorder; click for block options" onpointerdown={startDrag} onpointermove={moveDrag} onpointerup={() => endDrag()} onpointercancel={() => endDrag(true)} onlostpointercapture={() => endDrag(true)} onclick={openMenu}><Icon name="dotsThreeOutlineVertical" size={16} /></button>{/if}
584
584
  {#if drop}<div class="block-drop-indicator" style={`top:${drop.top}px;${inline ? `position:fixed;left:${root.getBoundingClientRect().left}px;width:${root.getBoundingClientRect().width}px;right:auto` : ''}`}></div>{/if}
585
585
  <div bind:this={slashMenu} class="block-slash" id={`${id}-slash`} popover="manual" style={slashPosition} role="listbox" aria-label="Insert block" onkeydown={slashKeydown}>
586
586
  {#if slash !== null}