@rigel-cms/rigel 0.1.3-beta.1 → 0.2.0-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigel-cms/rigel",
3
- "version": "0.1.3-beta.1",
3
+ "version": "0.2.0-beta.1",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"
package/src/content.ts CHANGED
@@ -2,4 +2,6 @@ export type { MenuItem, MenuDocument } from "./manifest/menu";
2
2
  export { defineCopy } from "./manifest/types";
3
3
  export type { CopyField, CopyGroup, CopyPage, Manifest, ManifestCollection } from "./manifest/types";
4
4
  export { defineSchema, defineDocument, defineObject, defineField } from "./schema/types";
5
- export type { Schema, DocumentDef, ObjectDef, FieldDef, ContentDocument } from "./schema/types";
5
+ export type { Schema, DocumentDef, ObjectDef, FieldDef, ContentDocument } from "./schema/types";
6
+ export { effectiveSlug } from "./slugs";
7
+ export { pageRoute } from "./schema/paths";
@@ -1,4 +1,7 @@
1
1
  <script lang="ts">
2
+ import { onMount, tick } from "svelte";
3
+ import { tooltipRoot } from "./lib/tooltip";
4
+ onMount(() => tooltipRoot(document.body).destroy);
2
5
  import type {
3
6
  Manifest,
4
7
  ManifestCollection,
@@ -17,22 +20,24 @@
17
20
  import { isSeoField } from "../manifest/seo";
18
21
  import { initialCollectionValues } from "../manifest/collections";
19
22
  import { slugify } from "./lib/slugify";
23
+ import { effectiveSlug, normalizeUrlSlug } from "../slugs";
20
24
  import Icon from "./lib/Icon.svelte";
21
25
  import SidebarIcon from "./lib/SidebarIcon.svelte";
22
26
  import logo from "./assets/rigelcms-icon.png?url";
23
27
  import { getSettings, setSettings } from "./lib/settings";
24
- import { eraseData } from "./lib/storage";
28
+ import { eraseData, readSession, writeSession, clearSession } from "./lib/storage";
25
29
  import MenuEditor from "./lib/MenuEditor.svelte";
26
30
  import type { MenuItem } from "../manifest/menu";
27
31
  import UnsavedChanges from "./lib/UnsavedChanges.svelte";
28
32
  import ActionDialog from "./lib/ActionDialog.svelte";
33
+ import PublishDialog from "./lib/PublishDialog.svelte";
29
34
  import VisualEditor from "./lib/VisualEditor.svelte";
30
35
  import { newDocumentFromTemplate } from "../schema/paths";
31
36
  import { previewUrl } from "../preview";
32
37
  import type { ContentDocument, Schema } from "../schema/types";
33
38
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
34
39
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
35
- import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, type GitContext } from "./lib/github-backend";
40
+ import { loadManifestRemote, saveRemoteSelection, deleteRemotePost, reviewRemotePublish, confirmRemotePublish, type GitContext, type PublishReview } from "./lib/github-backend";
36
41
 
37
42
  type Sel = Selection;
38
43
 
@@ -40,6 +45,7 @@
40
45
  let ready = $state(false);
41
46
  let blueprint: Manifest | null = null;
42
47
  let gitContext: GitContext | null = null;
48
+ let authRevision = 0;
43
49
  let connected = $state(false);
44
50
  let connectionBusy = $state(false);
45
51
  let connectionError = $state("");
@@ -57,6 +63,75 @@
57
63
  let saveState = $state<"idle" | "saving" | "saved" | "error">("idle");
58
64
  let saveTask: Promise<boolean> | null = null;
59
65
  let saveError = $state("");
66
+ let publishOpen = $state(false);
67
+ let publishLoading = $state(false);
68
+ let publishBusy = $state(false);
69
+ let publishReview = $state<PublishReview | null>(null);
70
+ let publishError = $state("");
71
+ let publishMessage = $state("");
72
+ let publishRevision = 0;
73
+
74
+ function closePublish() {
75
+ publishRevision += 1;
76
+ publishOpen = false;
77
+ publishReview = null;
78
+ void tick().then(() => document.querySelector<HTMLButtonElement>("#publish-button")?.focus());
79
+ }
80
+
81
+ function publishGuard(context: GitContext, revision: number, auth: number) {
82
+ if (!publishOpen || revision !== publishRevision || auth !== authRevision || gitContext !== context) throw new Error("Publish review is no longer active. Reconnect and review again.");
83
+ requireGitContext();
84
+ if (dirty || saveTask || saveState === "saving") throw new Error("Save your unsaved edits first, then review publishing. Your edits are still here.");
85
+ }
86
+
87
+ async function reviewPublish() {
88
+ if (publishBusy || publishLoading) return;
89
+ publishOpen = true;
90
+ publishReview = null;
91
+ publishError = "";
92
+ publishMessage = "";
93
+ publishLoading = true;
94
+ const revision = ++publishRevision, auth = authRevision;
95
+ try {
96
+ const context = requireGitContext();
97
+ const guard = () => publishGuard(context, revision, auth);
98
+ guard();
99
+ const review = await reviewRemotePublish(context, manifest, guard);
100
+ guard();
101
+ publishReview = review;
102
+ } catch (error) {
103
+ handleAuthError(error);
104
+ if (revision === publishRevision) publishError = error instanceof Error ? error.message : "Could not review saved changes.";
105
+ } finally { publishLoading = false; }
106
+ }
107
+
108
+ async function publish() {
109
+ if (publishBusy || publishLoading || !publishReview) return;
110
+ publishBusy = true;
111
+ publishError = "";
112
+ const review = publishReview, revision = publishRevision, auth = authRevision;
113
+ try {
114
+ const context = requireGitContext();
115
+ const guard = () => publishGuard(context, revision, auth);
116
+ guard();
117
+ const result = await confirmRemotePublish(context, manifest, review, guard);
118
+ guard();
119
+ publishReview = null;
120
+ if (result === "synced") {
121
+ const remote = await loadManifestRemote(context, blueprint!, false);
122
+ guard();
123
+ manifest = remote;
124
+ openSelection(sel);
125
+ publishMessage = "Drafts synced. Nothing was published. Close this dialog and review again before publishing.";
126
+ } else publishMessage = `Published to ${review.publishBranch}. Your configured deployment can now start; the site is not instantly live. The drafts branch is preserved.`;
127
+ } catch (error) {
128
+ handleAuthError(error);
129
+ if (revision === publishRevision) {
130
+ publishError = error instanceof Error ? error.message : "Could not verify publishing. Check GitHub before retrying.";
131
+ if (/changed|no longer matches/.test(publishError)) publishReview = null;
132
+ }
133
+ } finally { publishBusy = false; }
134
+ }
60
135
  let pendingNavigation = $state<(() => void) | null>(null);
61
136
  let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
62
137
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
@@ -76,6 +151,14 @@
76
151
  blueprint = data;
77
152
  manifest = data;
78
153
  ready = true;
154
+ if (data.backend === "github" && data.github) {
155
+ const session = readSession(data.github);
156
+ if (session) {
157
+ gitContext = { token: session.accessToken, repo: data.github.repo, branch: data.github.branch };
158
+ sessionExpiresAt = session.expiresAt;
159
+ await recheckGitHub();
160
+ }
161
+ }
79
162
  } catch (error) {
80
163
  loadError = error instanceof Error ? error.message : "Could not load the editor configuration.";
81
164
  }
@@ -85,7 +168,7 @@
85
168
  if (connectionBusy) throw new Error("Wait for GitHub access verification before saving.");
86
169
  if (!gitContext || !connected || githubAccess?.status !== "ready") throw new Error("Connect GitHub and verify repository access before saving.");
87
170
  if (Date.now() >= sessionExpiresAt - 30_000) {
88
- tokenExpired = true;
171
+ invalidateSession();
89
172
  throw new Error("Your GitHub session expired. Reconnect GitHub, then retry. Your unsaved edits are still here.");
90
173
  }
91
174
  return gitContext;
@@ -93,27 +176,56 @@
93
176
 
94
177
  async function verifyGitHub() {
95
178
  const config = blueprint?.github;
96
- if (!config || !gitContext) return;
97
- if (Date.now() >= sessionExpiresAt - 30_000) throw new Error("Your GitHub session expired. Sign in again to check repository access.");
98
- githubAccess = await checkGitHubAccess(gitContext.token, config.repo, config.appSlug);
99
- if (githubAccess.status !== "ready") return;
100
- if (!connected && blueprint) {
101
- const remote = await loadManifestRemote(gitContext, blueprint);
102
- manifest = remote;
103
- connected = true;
179
+ const context = gitContext;
180
+ if (!config || !context) return;
181
+ try {
182
+ if (Date.now() >= sessionExpiresAt - 30_000) {
183
+ invalidateSession();
184
+ throw new Error("Your GitHub session expired. Sign in again to check repository access.");
185
+ }
186
+ const access = await checkGitHubAccess(context.token, config.repo, config.appSlug);
187
+ if (gitContext !== context) return;
188
+ githubAccess = access;
189
+ if (access.status !== "ready") return;
190
+ if (!connected && blueprint) {
191
+ const remote = await loadManifestRemote(context, blueprint);
192
+ if (gitContext !== context) return;
193
+ manifest = remote;
194
+ connected = true;
195
+ }
196
+ } catch (error) {
197
+ if (gitContext === context) handleAuthError(error);
198
+ throw error;
104
199
  }
105
200
  }
106
201
 
202
+ function invalidateSession() {
203
+ closePublish();
204
+ authRevision += 1;
205
+ clearSession();
206
+ gitContext = null;
207
+ sessionExpiresAt = 0;
208
+ githubAccess = null;
209
+ tokenExpired = true;
210
+ }
211
+
212
+ function handleAuthError(error: unknown) {
213
+ if (error instanceof Error && "status" in error && error.status === 401) invalidateSession();
214
+ }
215
+
107
216
  async function connectGitHub() {
108
- if (connectionBusy || saveState === "saving" || !manifest.github) return;
217
+ if (connectionBusy || saveState === "saving" || publishBusy || !manifest.github) return;
218
+ closePublish();
109
219
  connectionBusy = true;
110
220
  connectionError = "";
221
+ const revision = ++authRevision;
111
222
  try {
112
223
  const session = await startGitHubLogin(manifest.github.authOrigin);
113
- if (gitContext) gitContext.token = session.accessToken;
114
- else gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
224
+ if (revision !== authRevision) return;
225
+ gitContext = { token: session.accessToken, repo: manifest.github.repo, branch: manifest.github.branch };
115
226
  githubAccess = null;
116
227
  sessionExpiresAt = session.expiresAt;
228
+ writeSession(manifest.github, session);
117
229
  await verifyGitHub();
118
230
  } catch (error) {
119
231
  connectionError = error instanceof Error ? error.message : "Could not connect GitHub. Try signing in again.";
@@ -134,10 +246,10 @@
134
246
  function signOut() {
135
247
  if (saveState === "saving" || connectionBusy) return;
136
248
  userOpen = false;
249
+ if (publishBusy) { invalidateSession(); connected = false; return; }
250
+ closePublish();
137
251
  requestNavigation(() => {
138
- gitContext = null;
139
- sessionExpiresAt = 0;
140
- githubAccess = null;
252
+ invalidateSession();
141
253
  connected = false;
142
254
  connectionError = "";
143
255
  settingsOpen = false;
@@ -158,13 +270,14 @@
158
270
 
159
271
  $effect(() => {
160
272
  const expiry = sessionExpiresAt;
161
- tokenExpired = !!expiry && Date.now() >= expiry - 30_000;
273
+ tokenExpired = !expiry || Date.now() >= expiry - 30_000;
162
274
  if (!expiry) return;
163
- const timer = setTimeout(() => { tokenExpired = true; }, Math.max(0, expiry - Date.now() - 30_000));
275
+ const timer = setTimeout(invalidateSession, Math.max(0, expiry - Date.now() - 30_000));
164
276
  return () => clearTimeout(timer);
165
277
  });
166
278
 
167
279
  function save(): Promise<boolean> {
280
+ if (publishOpen || publishBusy) return Promise.resolve(false);
168
281
  if (saveTask) return saveTask;
169
282
  saveTask = (async () => {
170
283
  if (dirty) {
@@ -178,6 +291,10 @@
178
291
  const docSel = sel?.kind === "document" ? sel : null;
179
292
  const creatingDoc = docSel !== null && newDocument?.id === docSel.id;
180
293
  try {
294
+ if ((selection?.kind === "post" || (docSel && !activeDocType?.shared)) && Object.hasOwn(values, "slug")) {
295
+ values.slug = normalizeUrlSlug(values.slug);
296
+ draft.slug = values.slug;
297
+ }
181
298
  if (githubMode) {
182
299
  if (!selection) throw new Error("Select content before saving.");
183
300
  await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
@@ -186,7 +303,7 @@
186
303
  ? `/rigel/api/local/documents/${encodeURIComponent(docSel.id)}`
187
304
  : m ? `/rigel/api/local/menus/${m.slug}` : po && c ? `/rigel/api/local/${c.name}/${encodeURIComponent(po.slug)}` : "/rigel/api/local/save";
188
305
  const payload = docSel ? { data: values } : m ? { slug: m.slug, title: values.title, items: values.items } : po ? { frontmatter: values, body } : { slug: p?.slug, values };
189
- const res = await fetch(url, { method: docSel ? (creatingDoc ? "POST" : "PUT") : m ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
306
+ 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) });
190
307
  if (!res.ok) throw new Error((await res.text()) || `Save failed (${res.status})`);
191
308
  }
192
309
  if (docSel) {
@@ -197,7 +314,7 @@
197
314
  newDocument = null;
198
315
  docBase = JSON.parse(JSON.stringify(values));
199
316
  } else if (m) { m.title = values.title; m.items = values.items; }
200
- else if (po) { po.frontmatter = { slug: po.slug, ...values }; po.body = body; bodyBase = body; }
317
+ else if (po) { po.frontmatter = values; po.body = body; bodyBase = body; }
201
318
  else if (p) p.values = values;
202
319
  if (creating && po && c) c.posts.push(po);
203
320
  newEntry = null;
@@ -205,6 +322,7 @@
205
322
  saveState = dirty ? "idle" : "saved";
206
323
  saveError = "";
207
324
  } catch (error) {
325
+ handleAuthError(error);
208
326
  saveState = "error";
209
327
  saveError = error instanceof Error ? error.message : "Could not save changes";
210
328
  return false;
@@ -220,10 +338,10 @@
220
338
 
221
339
  let settings = $state(getSettings());
222
340
  let settingsError = $state("");
223
- const userName = $derived(githubMode ? githubAccess?.user.login ?? "GitHub user" : "Local user");
341
+ const userName = $derived(githubMode ? githubAccess?.user.name ?? githubAccess?.user.login ?? "GitHub user" : "Local user");
224
342
  const userAvatar = $derived(githubMode && /^https:\/\/avatars\.githubusercontent\.com\//.test(githubAccess?.user.avatar_url ?? "") ? githubAccess!.user.avatar_url : "");
225
343
  let failedAvatar = $state("");
226
- const userMode = $derived(githubMode ? `${manifest.github?.repo} · ${manifest.github?.branch}` : "Local mode");
344
+ const userLogin = $derived(githubMode && githubAccess ? `@${githubAccess.user.login}` : "Local mode");
227
345
  let content: HTMLDivElement;
228
346
  let contentWidth = $state(0);
229
347
  let resizing = $state(false);
@@ -273,7 +391,7 @@
273
391
  function eraseBrowserData() {
274
392
  actionDialog = {
275
393
  title: "Erase browser data?", confirmLabel: "Erase data", danger: true,
276
- description: "This removes Rigel settings, API keys and unsaved drafts from this browser. Saved repository files will not change. The editor will reload.",
394
+ 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.",
277
395
  onconfirm: () => {
278
396
  if (saveState === "saving") throw new Error("Wait for the current save to finish.");
279
397
  eraseData();
@@ -372,9 +490,9 @@
372
490
  if (current?.kind === "document") map[current.id] = draft;
373
491
  return map;
374
492
  });
375
- function documentPreview(mode: "live" | "draft") {
376
- if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
377
- return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
493
+ function documentPreview(mode: "draft" | "website") {
494
+ if (sel?.kind !== "document" || (mode === "draft" && newDocument?.id === sel.id)) return null;
495
+ return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1), urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, mode);
378
496
  }
379
497
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
380
498
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
@@ -384,13 +502,13 @@
384
502
  ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
385
503
  .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
386
504
  ...(pageCategory === undefined ? pageDocuments
387
- .map((doc) => ({ slug: doc.slug, title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
505
+ .map((doc) => ({ slug: effectiveSlug(doc.data, doc.slug), title: String(doc.data.title ?? doc.slug), category: undefined, draft: doc.data.draft === true, target: { kind: "document", type: doc.type, id: doc.id } as Sel })) : []),
388
506
  ];
389
507
  const key = sortBy === "slug" || sortBy === "category" ? sortBy : "title";
390
508
  return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
391
509
  });
392
510
  function documentCell(doc: ContentDocument, key: string): string {
393
- return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? doc.slug : doc.data[key] ?? "");
511
+ return String(key === "title" ? doc.data.title ?? doc.data.name ?? doc.slug : key === "slug" ? activeDocType?.shared ? doc.slug : effectiveSlug(doc.data, doc.slug) : doc.data[key] ?? "");
394
512
  }
395
513
  const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
396
514
 
@@ -469,6 +587,7 @@
469
587
  }
470
588
 
471
589
  function requestNavigation(action: () => void) {
590
+ if (publishOpen || publishBusy) return;
472
591
  if (dirty || saveState === "saving") pendingNavigation = action;
473
592
  else action();
474
593
  }
@@ -526,7 +645,6 @@
526
645
  const po = post;
527
646
  if (!po) return;
528
647
  const fm: Record<string, unknown> = { ...po.frontmatter };
529
- delete fm.slug;
530
648
  draft = JSON.parse(JSON.stringify(fm));
531
649
  bodyDraft = po.body;
532
650
  bodyBase = po.body;
@@ -552,9 +670,7 @@
552
670
  }
553
671
  const ref =
554
672
  sel?.kind === "menu" && menu ? { title: menu.title, items: menu.items } : sel?.kind === "post" && post
555
- ? Object.fromEntries(
556
- Object.entries(post.frontmatter).filter(([k]) => k !== "slug"),
557
- )
673
+ ? post.frontmatter
558
674
  : page?.values;
559
675
  dirty =
560
676
  newEntry !== null ||
@@ -564,7 +680,7 @@
564
680
  }
565
681
 
566
682
  function cellValue(p: { slug: string; frontmatter: Record<string, unknown> }, key: string): string {
567
- const v = key === "slug" ? p.slug : p.frontmatter[key];
683
+ const v = key === "slug" ? effectiveSlug(p.frontmatter, p.slug) : p.frontmatter[key];
568
684
  const field = coll?.fields.find(field => field.key === key);
569
685
  if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents)) || "—";
570
686
  if (v === true) return "✓";
@@ -580,7 +696,7 @@
580
696
  onconfirm: (title) => {
581
697
  const slug = slugify(title);
582
698
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
583
- const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).map((doc) => doc.slug), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
699
+ const taken = [...(manifest.documents ?? []).filter(doc => !type.shared || doc.type === type.name).flatMap((doc) => [doc.slug, ...(type.shared || docTypes.find(type => type.name === doc.type)?.shared ? [] : [effectiveSlug(doc.data, doc.slug)])]), ...(!type.shared ? manifest.pages.map((page) => page.slug) : [])];
584
700
  if (taken.includes(slug)) throw new Error(`The slug "${slug}" is already used by another document or page.`);
585
701
  const id = `${type.name}:${slug}`;
586
702
  const data = newDocumentFromTemplate(schema, type.name, title, slug);
@@ -596,16 +712,16 @@
596
712
  actionDialog = {
597
713
  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",
598
714
  onconfirm: (title) => {
599
- const slug = slugify(title);
715
+ const slug = slugify(title, c.posts.flatMap((post) => [post.slug, effectiveSlug(post.frontmatter, post.slug)]));
600
716
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
601
- if (c.posts.some((p) => p.slug === slug)) throw new Error(`A post with slug "${slug}" already exists.`);
602
- newEntry = { collection: c.name, post: { slug, frontmatter: initialCollectionValues(c.fields, title), body: emptyBody() } };
717
+ newEntry = { collection: c.name, post: { slug, frontmatter: { ...initialCollectionValues(c.fields, title), slug }, body: emptyBody() } };
603
718
  openSelection({ kind: "post", collection: c.name, slug });
604
719
  },
605
720
  };
606
721
  }
607
722
 
608
723
  function deletePost() {
724
+ if (publishOpen || publishBusy) return;
609
725
  const current = post, collection = coll, unsaved = newEntry !== null;
610
726
  if (!current || !collection) return;
611
727
  actionDialog = {
@@ -613,7 +729,10 @@
613
729
  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.`,
614
730
  onconfirm: async () => {
615
731
  if (!unsaved) {
616
- if (githubMode) await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug);
732
+ if (githubMode) {
733
+ try { await deleteRemotePost(requireGitContext(), manifest, collection.name, current.slug); }
734
+ catch (error) { handleAuthError(error); throw error; }
735
+ }
617
736
  else {
618
737
  const res = await fetch(`/rigel/api/local/${collection.name}/${encodeURIComponent(current.slug)}`, { method: "DELETE" });
619
738
  if (!res.ok) throw new Error((await res.text()) || `Delete failed (${res.status})`);
@@ -640,10 +759,16 @@
640
759
  {/snippet}
641
760
 
642
761
  <svelte:window
762
+ onstorage={(event) => {
763
+ if ((event.key === "rigelcms.session" || event.key === null) && blueprint?.github && !readSession(blueprint.github)) {
764
+ invalidateSession();
765
+ connectionError = "Signed out in another tab. Reconnect GitHub to save. Your unsaved edits are still here.";
766
+ }
767
+ }}
643
768
  onresize={endPreviewResize}
644
769
  onblur={endPreviewResize}
645
770
  oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
646
- onbeforeunload={(event) => { if (dirty || saveState === "saving") { event.preventDefault(); event.returnValue = ""; } }}
771
+ onbeforeunload={(event) => { if (dirty || saveState === "saving" || publishBusy) { event.preventDefault(); event.returnValue = ""; } }}
647
772
  onclick={(e) => {
648
773
  if (!(e.target instanceof Element) || !e.target.closest('.user-menu-wrap')) userOpen = false;
649
774
  }}
@@ -658,8 +783,8 @@
658
783
  {#if !ready || (githubMode && !connected)}
659
784
  <div class="rigel connection-shell">
660
785
  <main id="github-connection" class="connection-card" aria-busy={connectionBusy}>
661
- <img src={logo} alt="" width="64" height="64" />
662
- <h1>{!ready ? "RigelCMS" : "Connect GitHub"}</h1>
786
+ <div class="connection-brand"><img src={logo} alt="" width="64" height="64" /><span>Rigel <span class="brand-suffix">CMS</span></span></div>
787
+ <h1>{!ready ? "Loading editor" : "Connect GitHub"}</h1>
663
788
  {#if !ready}
664
789
  {#if loadError}<p class="connection-error" role="alert">{loadError}</p><button class="primary" onclick={loadBlueprint}>Retry loading</button>
665
790
  {:else}<p role="status">Loading the editor configuration…</p>{/if}
@@ -693,7 +818,7 @@
693
818
  <aside id="rigel-sidebar">
694
819
  <a class="brand" href="/rigel/" role="button" aria-label="Welcome" aria-describedby="rigel-beta" onclick={(event) => { event.preventDefault(); goWelcome(); }}>
695
820
  <img src={logo} alt="" width="32" height="32" />
696
- <span>RigelCMS</span>
821
+ <span>Rigel <span class="brand-suffix">CMS</span></span>
697
822
  <span id="rigel-beta" class="badge">Beta</span>
698
823
  </a>
699
824
  <TopBar {manifest} {sel} onselect={select} />
@@ -714,7 +839,7 @@
714
839
  data-document={node.target.kind === "document" ? node.target.id : undefined}
715
840
  data-page={node.target.kind === "page" ? node.target.slug : undefined}
716
841
  aria-label={node.label}
717
- title={node.label}
842
+ data-tooltip={node.label}
718
843
  class:active={navActive(node.target)}
719
844
  aria-current={navActive(node.target) ? "page" : undefined}
720
845
  onclick={() => select(node.target)}
@@ -725,21 +850,26 @@
725
850
  {/each}
726
851
  </nav>
727
852
 
728
- <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; }}>
853
+ {#if githubMode && manifest.github?.publishBranch}
854
+ <button id="publish-button" class="sidebar-publish" aria-label="Publish" data-tooltip={dirty ? "Save your edits before publishing" : "Publish saved branch changes"} disabled={publishBusy || publishLoading || saveState === "saving" || connectionBusy || tokenExpired || githubAccess?.status !== "ready"} onclick={reviewPublish}>
855
+ <Icon name="external" size={18} /><span class="sidebar-label">Publish</span>
856
+ </button>
857
+ {/if}
858
+ <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; }}>
729
859
  <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
730
860
  </button>
731
861
  <div class="aside-foot">
732
862
  <div class="user-menu-wrap">
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)}>
863
+ <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)}>
734
864
  {@render avatar()}
735
- <span class="user-details"><span class="uname">{userName}</span><small>{userMode}</small></span>
865
+ <span class="user-details"><span class="uname">{userName}</span><small>{userLogin}</small></span>
736
866
  <span class="user-menu-icon"><Icon name="dotsThreeOutlineVertical" size={18} /></span>
737
867
  </button>
738
868
  {#if userOpen}
739
869
  <div class="user-menu" role="menu">
740
870
  <div class="user-menu-head">
741
871
  {@render avatar()}
742
- <div class="user-details"><strong>{userName}</strong><small>{userMode}</small></div>
872
+ <div class="user-details"><strong>{userName}</strong><small>{userLogin}</small></div>
743
873
  </div>
744
874
  <div class="divider"></div>
745
875
  <a role="menuitem" href="/" target="_blank" rel="noopener">
@@ -760,7 +890,7 @@
760
890
  <a role="menuitem" href={githubAccess?.installUrl} target="_blank" rel="noopener noreferrer">Configure GitHub access</a>
761
891
  <button role="menuitem" class="github-reconnect" disabled={connectionBusy || saveState === "saving"} onclick={() => { userOpen = false; void connectGitHub(); }}>Reconnect GitHub</button>
762
892
  {/if}
763
- <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}>
893
+ <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}>
764
894
  <Icon name="signOut" size={13} />
765
895
  Sign Out
766
896
  {#if !githubMode}<span class="soon">backend: github</span>{/if}
@@ -807,7 +937,7 @@
807
937
  <button class="primary" onclick={() => (sel?.kind === "collection" ? newPost(coll!) : newPage(sel?.kind === "documents" ? activeDocType : pageDocType))}>Add new</button>
808
938
  {/if}
809
939
  {#if sel?.kind === "post" && post}
810
- <button class="icon-btn" title="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
940
+ <button class="icon-btn" aria-label="Delete post" data-tooltip="Delete post" disabled={saveState === "saving"} onclick={deletePost}>
811
941
  <Icon name="trash" size={15} />
812
942
  </button>
813
943
  {/if}
@@ -885,7 +1015,7 @@
885
1015
  values={draft}
886
1016
  documents={draftDocuments}
887
1017
  previewPath={documentPreview("draft")}
888
- livePath={documentPreview("live")}
1018
+ websitePath={documentPreview("website")}
889
1019
  creating={newDocument?.id === sel.id}
890
1020
  devMode={settings.devMode}
891
1021
  reloadToken={documentReload}
@@ -905,6 +1035,9 @@
905
1035
  <MenuEditor items={draft.items as MenuItem[]} maxDepth={manifest.menus.maxDepth} oninput={(items) => { draft.items = items; touch(); }} />
906
1036
 
907
1037
  {:else if sel?.kind === "page" && page}
1038
+ {#if !page.shared}
1039
+ <section><label><span>Website route</span><input type="text" value={manifest.preview?.pages?.[page.slug] ?? "Not configured"} readonly /></label><p class="hint">This copy page uses a fixed Astro route. Its URL can only be changed in the website code; slug metadata cannot move it.</p></section>
1040
+ {/if}
908
1041
  {#each page.groups as group (group.id)}
909
1042
  <section>
910
1043
  <h3>{group.label}</h3>
@@ -933,13 +1066,13 @@
933
1066
  bodyInline={settings.userPref.editorExperience === "inline"}
934
1067
  onbody={(value) => { bodyDraft = value; touch(); }}
935
1068
  previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
936
- livePath={manifest.preview ? previewUrl(manifest.preview, sel, "live") : null}
1069
+ websitePath={manifest.preview ? previewUrl(manifest.preview, { ...sel, urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, "website") : null}
937
1070
  creating={!!newEntry}
938
1071
  devMode={settings.devMode}
939
1072
  onchange={(next) => { draft = next; touch(); }}
940
1073
  >
941
1074
  {#snippet properties(selected, focusToken, bodyInline, oneditbody)}
942
- <CollectionPanel fields={coll.fields} values={draft} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
1075
+ <CollectionPanel fields={coll.fields} values={draft} storageSlug={post.slug} documents={manifest.documents ?? []} body={bodyDraft as ArticleBody} {bodyInline} {oneditbody} {selected} {focusToken} devMode={settings.devMode} onchange={(key, value) => { draft[key] = value; touch(); }} onbody={(value) => { bodyDraft = value; touch(); }} />
943
1076
  {/snippet}
944
1077
  </VisualEditor>
945
1078
  {/key}
@@ -947,7 +1080,7 @@
947
1080
  {:else}
948
1081
  <div class="welcome">
949
1082
  <img src={logo} alt="" width="64" height="64" />
950
- <h2>Welcome to RigelCMS</h2>
1083
+ <h2>Welcome to Rigel CMS</h2>
951
1084
  <p>The best CMS in the whole space.</p>
952
1085
  <p class="welcome-hint">Select a page or collection from the sidebar to start editing.</p>
953
1086
  </div>
@@ -968,7 +1101,7 @@
968
1101
  {/if}
969
1102
 
970
1103
  {#if settingsOpen}
971
- <Settings bind:s={settings} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
1104
+ <Settings bind:s={settings} github={githubMode ? manifest.github : undefined} user={githubAccess?.user} error={settingsError} saving={saveState === "saving"} onerase={eraseBrowserData} onclose={() => (settingsOpen = false)} />
972
1105
  {/if}
973
1106
  {#if shortcutsOpen}
974
1107
  <Shortcuts onclose={() => (shortcutsOpen = false)} />
@@ -979,3 +1112,6 @@
979
1112
  {#if actionDialog}
980
1113
  <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
981
1114
  {/if}
1115
+ {#if publishOpen}
1116
+ <PublishDialog review={publishReview} loading={publishLoading} busy={publishBusy} error={publishError} message={publishMessage} onrefresh={reviewPublish} onconfirm={publish} onclose={closePublish} />
1117
+ {/if}