@rigel-cms/rigel 0.1.4-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.4-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,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount } from "svelte";
2
+ import { onMount, tick } from "svelte";
3
3
  import { tooltipRoot } from "./lib/tooltip";
4
4
  onMount(() => tooltipRoot(document.body).destroy);
5
5
  import type {
@@ -20,6 +20,7 @@
20
20
  import { isSeoField } from "../manifest/seo";
21
21
  import { initialCollectionValues } from "../manifest/collections";
22
22
  import { slugify } from "./lib/slugify";
23
+ import { effectiveSlug, normalizeUrlSlug } from "../slugs";
23
24
  import Icon from "./lib/Icon.svelte";
24
25
  import SidebarIcon from "./lib/SidebarIcon.svelte";
25
26
  import logo from "./assets/rigelcms-icon.png?url";
@@ -29,13 +30,14 @@
29
30
  import type { MenuItem } from "../manifest/menu";
30
31
  import UnsavedChanges from "./lib/UnsavedChanges.svelte";
31
32
  import ActionDialog from "./lib/ActionDialog.svelte";
33
+ import PublishDialog from "./lib/PublishDialog.svelte";
32
34
  import VisualEditor from "./lib/VisualEditor.svelte";
33
35
  import { newDocumentFromTemplate } from "../schema/paths";
34
36
  import { previewUrl } from "../preview";
35
37
  import type { ContentDocument, Schema } from "../schema/types";
36
38
  import { buildSidebar, navigationKey, type Selection } from "../manifest/navigation";
37
39
  import { startGitHubLogin, checkGitHubAccess, type GitHubAccess } from "./lib/github-auth";
38
- 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";
39
41
 
40
42
  type Sel = Selection;
41
43
 
@@ -61,6 +63,75 @@
61
63
  let saveState = $state<"idle" | "saving" | "saved" | "error">("idle");
62
64
  let saveTask: Promise<boolean> | null = null;
63
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
+ }
64
135
  let pendingNavigation = $state<(() => void) | null>(null);
65
136
  let actionDialog = $state<{ title: string; description: string; confirmLabel: string; fieldLabel?: string; danger?: boolean; onconfirm: (value: string) => void | Promise<void> } | null>(null);
66
137
  let newEntry = $state<{ collection: string; post: CollectionPost } | null>(null);
@@ -129,6 +200,7 @@
129
200
  }
130
201
 
131
202
  function invalidateSession() {
203
+ closePublish();
132
204
  authRevision += 1;
133
205
  clearSession();
134
206
  gitContext = null;
@@ -142,7 +214,8 @@
142
214
  }
143
215
 
144
216
  async function connectGitHub() {
145
- if (connectionBusy || saveState === "saving" || !manifest.github) return;
217
+ if (connectionBusy || saveState === "saving" || publishBusy || !manifest.github) return;
218
+ closePublish();
146
219
  connectionBusy = true;
147
220
  connectionError = "";
148
221
  const revision = ++authRevision;
@@ -173,6 +246,8 @@
173
246
  function signOut() {
174
247
  if (saveState === "saving" || connectionBusy) return;
175
248
  userOpen = false;
249
+ if (publishBusy) { invalidateSession(); connected = false; return; }
250
+ closePublish();
176
251
  requestNavigation(() => {
177
252
  invalidateSession();
178
253
  connected = false;
@@ -202,6 +277,7 @@
202
277
  });
203
278
 
204
279
  function save(): Promise<boolean> {
280
+ if (publishOpen || publishBusy) return Promise.resolve(false);
205
281
  if (saveTask) return saveTask;
206
282
  saveTask = (async () => {
207
283
  if (dirty) {
@@ -215,6 +291,10 @@
215
291
  const docSel = sel?.kind === "document" ? sel : null;
216
292
  const creatingDoc = docSel !== null && newDocument?.id === docSel.id;
217
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
+ }
218
298
  if (githubMode) {
219
299
  if (!selection) throw new Error("Select content before saving.");
220
300
  await saveRemoteSelection(requireGitContext(), manifest, selection, values, body, !!creating || creatingDoc);
@@ -234,7 +314,7 @@
234
314
  newDocument = null;
235
315
  docBase = JSON.parse(JSON.stringify(values));
236
316
  } else if (m) { m.title = values.title; m.items = values.items; }
237
- 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; }
238
318
  else if (p) p.values = values;
239
319
  if (creating && po && c) c.posts.push(po);
240
320
  newEntry = null;
@@ -411,8 +491,8 @@
411
491
  return map;
412
492
  });
413
493
  function documentPreview(mode: "draft" | "website") {
414
- if (sel?.kind !== "document" || newDocument?.id === sel.id) return null;
415
- return previewUrl(manifest.preview ?? {}, { kind: "document", type: sel.type, slug: sel.id.slice(sel.type.length + 1) }, mode);
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);
416
496
  }
417
497
  const standaloneDocument = $derived(sel?.kind === "document" && activeDocType?.shared === true);
418
498
  const visualSelection = $derived(sel?.kind === "post" || (sel?.kind === "document" && !standaloneDocument));
@@ -422,13 +502,13 @@
422
502
  ...manifest.pages.filter((p) => !p.shared && (pageCategory === undefined || p.category === pageCategory))
423
503
  .map((p) => ({ slug: p.slug, title: p.label, category: p.category, draft: false, target: { kind: "page", slug: p.slug } as Sel })),
424
504
  ...(pageCategory === undefined ? pageDocuments
425
- .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 })) : []),
426
506
  ];
427
507
  const key = sortBy === "slug" || sortBy === "category" ? sortBy : "title";
428
508
  return rows.sort((a, b) => String(a[key] ?? "").localeCompare(String(b[key] ?? "")) * sortDir);
429
509
  });
430
510
  function documentCell(doc: ContentDocument, key: string): string {
431
- 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] ?? "");
432
512
  }
433
513
  const typeDocuments = $derived((manifest.documents ?? []).filter((doc) => doc.type === docTypeName(sel)).sort((a, b) => documentCell(a, sortBy).localeCompare(documentCell(b, sortBy)) * sortDir));
434
514
 
@@ -507,6 +587,7 @@
507
587
  }
508
588
 
509
589
  function requestNavigation(action: () => void) {
590
+ if (publishOpen || publishBusy) return;
510
591
  if (dirty || saveState === "saving") pendingNavigation = action;
511
592
  else action();
512
593
  }
@@ -564,7 +645,6 @@
564
645
  const po = post;
565
646
  if (!po) return;
566
647
  const fm: Record<string, unknown> = { ...po.frontmatter };
567
- delete fm.slug;
568
648
  draft = JSON.parse(JSON.stringify(fm));
569
649
  bodyDraft = po.body;
570
650
  bodyBase = po.body;
@@ -590,9 +670,7 @@
590
670
  }
591
671
  const ref =
592
672
  sel?.kind === "menu" && menu ? { title: menu.title, items: menu.items } : sel?.kind === "post" && post
593
- ? Object.fromEntries(
594
- Object.entries(post.frontmatter).filter(([k]) => k !== "slug"),
595
- )
673
+ ? post.frontmatter
596
674
  : page?.values;
597
675
  dirty =
598
676
  newEntry !== null ||
@@ -602,7 +680,7 @@
602
680
  }
603
681
 
604
682
  function cellValue(p: { slug: string; frontmatter: Record<string, unknown> }, key: string): string {
605
- const v = key === "slug" ? p.slug : p.frontmatter[key];
683
+ const v = key === "slug" ? effectiveSlug(p.frontmatter, p.slug) : p.frontmatter[key];
606
684
  const field = coll?.fields.find(field => field.key === key);
607
685
  if (field?.type === "reference") return referenceLabel(v, referenceOptions(field, manifest.documents)) || "—";
608
686
  if (v === true) return "✓";
@@ -618,7 +696,7 @@
618
696
  onconfirm: (title) => {
619
697
  const slug = slugify(title);
620
698
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
621
- 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) : [])];
622
700
  if (taken.includes(slug)) throw new Error(`The slug "${slug}" is already used by another document or page.`);
623
701
  const id = `${type.name}:${slug}`;
624
702
  const data = newDocumentFromTemplate(schema, type.name, title, slug);
@@ -634,15 +712,16 @@
634
712
  actionDialog = {
635
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",
636
714
  onconfirm: (title) => {
637
- const slug = slugify(title, c.posts.map((post) => post.slug));
715
+ const slug = slugify(title, c.posts.flatMap((post) => [post.slug, effectiveSlug(post.frontmatter, post.slug)]));
638
716
  if (!slug) throw new Error("Enter a title containing letters or numbers.");
639
- 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() } };
640
718
  openSelection({ kind: "post", collection: c.name, slug });
641
719
  },
642
720
  };
643
721
  }
644
722
 
645
723
  function deletePost() {
724
+ if (publishOpen || publishBusy) return;
646
725
  const current = post, collection = coll, unsaved = newEntry !== null;
647
726
  if (!current || !collection) return;
648
727
  actionDialog = {
@@ -689,7 +768,7 @@
689
768
  onresize={endPreviewResize}
690
769
  onblur={endPreviewResize}
691
770
  oncontextmenu={(event) => { if (!settings.devMode) event.preventDefault(); }}
692
- onbeforeunload={(event) => { if (dirty || saveState === "saving") { event.preventDefault(); event.returnValue = ""; } }}
771
+ onbeforeunload={(event) => { if (dirty || saveState === "saving" || publishBusy) { event.preventDefault(); event.returnValue = ""; } }}
693
772
  onclick={(e) => {
694
773
  if (!(e.target instanceof Element) || !e.target.closest('.user-menu-wrap')) userOpen = false;
695
774
  }}
@@ -771,6 +850,11 @@
771
850
  {/each}
772
851
  </nav>
773
852
 
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}
774
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; }}>
775
859
  <SidebarIcon name="sidebar-simple" /><span class="sidebar-label">Collapse sidebar</span>
776
860
  </button>
@@ -951,6 +1035,9 @@
951
1035
  <MenuEditor items={draft.items as MenuItem[]} maxDepth={manifest.menus.maxDepth} oninput={(items) => { draft.items = items; touch(); }} />
952
1036
 
953
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}
954
1041
  {#each page.groups as group (group.id)}
955
1042
  <section>
956
1043
  <h3>{group.label}</h3>
@@ -979,13 +1066,13 @@
979
1066
  bodyInline={settings.userPref.editorExperience === "inline"}
980
1067
  onbody={(value) => { bodyDraft = value; touch(); }}
981
1068
  previewPath={manifest.preview ? previewUrl(manifest.preview, sel, "draft", !!newEntry) : null}
982
- websitePath={manifest.preview ? previewUrl(manifest.preview, sel, "website") : null}
1069
+ websitePath={manifest.preview ? previewUrl(manifest.preview, { ...sel, urlSlug: Object.hasOwn(draft, "slug") ? String(draft.slug) : undefined }, "website") : null}
983
1070
  creating={!!newEntry}
984
1071
  devMode={settings.devMode}
985
1072
  onchange={(next) => { draft = next; touch(); }}
986
1073
  >
987
1074
  {#snippet properties(selected, focusToken, bodyInline, oneditbody)}
988
- <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(); }} />
989
1076
  {/snippet}
990
1077
  </VisualEditor>
991
1078
  {/key}
@@ -1025,3 +1112,6 @@
1025
1112
  {#if actionDialog}
1026
1113
  <ActionDialog {...actionDialog} onclose={() => { actionDialog = null; }} />
1027
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}
@@ -13,10 +13,12 @@
13
13
  import { referenceOptions } from "../../manifest/references";
14
14
  import { isSeoField } from "../../manifest/seo";
15
15
  import type { ContentDocument } from "../../schema/types";
16
+ import { slugify } from "../../slugs";
16
17
 
17
- let { fields, values, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
18
+ let { fields, values, storageSlug, body, selected, focusToken, devMode, documents = [], bodyInline = false, oneditbody = () => {}, onchange, onbody }: {
18
19
  fields: CollectionField[];
19
20
  values: Record<string, unknown>;
21
+ storageSlug: string;
20
22
  body: ArticleBody;
21
23
  bodyInline?: boolean;
22
24
  oneditbody?: () => void;
@@ -49,7 +51,9 @@
49
51
  <div id="properties-content" role="tabpanel" aria-labelledby="properties-tab-content" hidden={tab !== "content"}>
50
52
  <section>
51
53
  <h3>Article details</h3>
52
- {#each fields.filter(field => !isSeoField(field.key)) as field (field.key)}
54
+ <label><span>URL slug</span><input type="text" value={String(values.slug ?? storageSlug)} oninput={(event) => onchange("slug", event.currentTarget.value)} onblur={() => { if (Object.hasOwn(values, "slug")) onchange("slug", slugify(String(values.slug))); }} /></label>
55
+ <p class="hint">Changes the URL, not the content filename. The website route must use the slug metadata.</p>
56
+ {#each fields.filter(field => field.key !== "slug" && !isSeoField(field.key)) as field (field.key)}
53
57
  <div data-collection-field={field.key}>
54
58
  {#if field.type === "reference"}
55
59
  <ReferenceSelect label={field.label} value={values[field.key]} options={referenceOptions(field, documents)} multiple={field.multiple} onchange={(value) => onchange(field.key, value)} />
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { untrack } from "svelte";
2
+ import { tick, untrack } from "svelte";
3
3
  import { fetchPreview } from "./preview-frame";
4
4
 
5
5
  let { url, title, frame = $bindable(), reloadToken = 0, onload, onloading, onerror }: {
@@ -11,35 +11,77 @@
11
11
  onloading: () => void;
12
12
  onerror: (message: string) => void;
13
13
  } = $props();
14
- let source = $state<{ src?: string; srcdoc?: string } | null>(null);
14
+ let source = $state<{ src?: string; srcdoc?: string; ready: (event: Event) => void; fail: () => void } | null>(null);
15
+ let loading = $state(true);
16
+ let revealed = $state(false);
15
17
 
16
18
  $effect(() => {
17
19
  const target = new URL(url, location.href);
18
20
  void reloadToken;
21
+ const started = performance.now();
22
+ const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
23
+ const controller = new AbortController();
24
+ const { signal } = controller;
19
25
  source = null;
26
+ loading = true;
27
+ revealed = false;
20
28
  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 };
29
+ function fail(message = "The prepared page could not be loaded. Check its URL and framing headers.") {
30
+ if (signal.aborted) return;
31
+ controller.abort();
32
+ clearTimeout(timeout);
33
+ loading = false;
34
+ onerror(message);
35
+ }
36
+ const timeout = setTimeout(() => fail("The preview took too long to load. Check its URL and try again."), 20000);
37
+ function wait(ms: number) {
38
+ return new Promise<void>(resolve => {
39
+ const finish = () => { clearTimeout(timer); signal.removeEventListener("abort", finish); resolve(); };
40
+ const timer = setTimeout(finish, ms);
41
+ signal.addEventListener("abort", finish, { once: true });
42
+ });
43
+ }
44
+ async function ready(event: Event) {
45
+ const iframe = event.currentTarget as HTMLIFrameElement;
46
+ const doc = iframe.contentDocument;
47
+ if (signal.aborted) return;
48
+ if (!doc || (target.origin === location.origin && new URL(doc.URL).pathname.replace(/\/$/, "") !== target.pathname.replace(/\/$/, ""))) {
49
+ fail();
50
+ return;
51
+ }
52
+ try {
53
+ if (doc.fonts) await Promise.race([doc.fonts.ready.catch(() => {}), wait(200)]);
54
+ if (signal.aborted) return;
55
+ if (!reducedMotion) await wait(Math.max(0, 500 - (performance.now() - started)));
56
+ if (signal.aborted || iframe !== frame) return;
57
+ onload();
58
+ await tick();
59
+ if (signal.aborted || iframe !== frame) return;
60
+ clearTimeout(timeout);
61
+ loading = false;
62
+ revealed = true;
63
+ } catch (cause) {
64
+ fail(cause instanceof Error ? cause.message : "Preview unavailable");
65
+ }
66
+ }
67
+ const handlers = { ready, fail: () => fail() };
68
+ if (target.origin === location.origin) source = { src: target.href, ...handlers };
69
+ else fetchPreview(target, signal).then(srcdoc => {
70
+ if (!signal.aborted) source = { srcdoc, ...handlers };
25
71
  }).catch(cause => {
26
- if (!controller.signal.aborted) onerror(cause instanceof Error ? cause.message : "Preview unavailable");
72
+ fail(cause instanceof Error ? cause.message : "Preview unavailable");
27
73
  });
28
- return () => controller.abort();
74
+ return () => { controller.abort(); clearTimeout(timeout); };
29
75
  });
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
76
  </script>
40
77
 
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}
78
+ <div class="preview-frame" aria-busy={loading}>
79
+ {#if source}
80
+ {#key source}
81
+ <iframe bind:this={frame} {title} sandbox="allow-same-origin" referrerpolicy="no-referrer" src={source.src} srcdoc={source.srcdoc} onload={source.ready} onerror={source.fail} class:revealed inert={!revealed} aria-hidden={!revealed}></iframe>
82
+ {/key}
83
+ {/if}
84
+ {#if loading}
85
+ <div class="preview-loading" role="status"><span class="preview-spinner" aria-hidden="true"></span>Loading preview…</div>
86
+ {/if}
87
+ </div>
@@ -0,0 +1,65 @@
1
+ <script lang="ts">
2
+ import Icon from "./Icon.svelte";
3
+ import type { PublishReview } from "./github-backend";
4
+ let { review, loading, busy, error, message, onrefresh, onconfirm, onclose }: {
5
+ review: PublishReview | null; loading: boolean; busy: boolean; error: string; message: string;
6
+ onrefresh: () => void; onconfirm: () => void; onclose: () => void;
7
+ } = $props();
8
+ let dialog: HTMLDialogElement;
9
+ let cancel: HTMLButtonElement;
10
+ $effect(() => {
11
+ dialog.showModal();
12
+ cancel.focus();
13
+ return () => dialog.close();
14
+ });
15
+ </script>
16
+
17
+ <dialog bind:this={dialog} class="rigel rigel-modal-backdrop publish-dialog" aria-labelledby="publish-title" aria-describedby="publish-description" oncancel={(event) => { event.preventDefault(); if (!busy) onclose(); }}>
18
+ <form class="rigel-modal" onsubmit={(event) => { event.preventDefault(); onconfirm(); }} aria-busy={loading || busy}>
19
+ <div class="publish-head"><h2 id="publish-title">Publish saved changes</h2><button type="button" class="icon-btn" aria-label="Close publish review" disabled={busy} onclick={onclose}><Icon name="x" size={16} /></button></div>
20
+ <p id="publish-description">Publish includes <strong>all saved changes on the drafts branch</strong>, including files outside CMS content folders. Unsaved edits are never included or saved automatically.</p>
21
+ {#if loading}<p role="status">Loading the complete file review…</p>{/if}
22
+ {#if review}
23
+ <p><strong>{review.repo}</strong><br /><span class="publish-branch">{review.branch}</span> to <span class="publish-branch">{review.publishBranch}</span>, as one squash commit.</p>
24
+ {#if review.needsSync}
25
+ <p>The publishing branch has changes not in drafts, including earlier squash publishes. First, explicitly sync it into drafts using GitHub’s normal merge. This does not publish. Conflicts stop the sync. Then review the updated files again before publishing.</p>
26
+ <p>These are the current tree differences, not the final publish list:</p>
27
+ {/if}
28
+ <p id="publish-file-count"><strong>{review.files.length} {review.files.length === 1 ? "file" : "files"}</strong></p>
29
+ {#if review.files.length}
30
+ <ul class="publish-files" tabindex="0" aria-label="All changed files" aria-describedby="publish-file-count">
31
+ {#each review.files as file (file.filename)}
32
+ <li><span class="publish-file-status">{file.status}</span><span>{file.filename}{#if file.previous_filename}<small>Previously: {file.previous_filename}</small>{/if}</span></li>
33
+ {/each}
34
+ </ul>
35
+ {:else}<p>No saved changes to publish. The branch trees are identical.</p>{/if}
36
+ <p class="hint">Both branch revisions are checked again before publishing. GitHub pins the draft revision, but cannot atomically lock the target revision; a target update in the final instant can still race. Required checks, reviews and branch protections apply.</p>
37
+ {/if}
38
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
39
+ {#if message}<p role="status">{message}</p>{/if}
40
+ <div class="publish-actions">
41
+ <button type="button" bind:this={cancel} class="outlined" disabled={busy} onclick={onclose}>{message ? "Close" : "Cancel"}</button>
42
+ {#if !message}<button type="button" class="outlined" disabled={loading || busy} onclick={onrefresh}>Review again</button>{/if}
43
+ {#if review && !message}<button id="confirm-publish" type="submit" class="primary" disabled={loading || busy || !review.files.length}>{busy ? "Working…" : review.needsSync ? "Sync drafts first" : "Confirm publish"}</button>{/if}
44
+ </div>
45
+ </form>
46
+ </dialog>
47
+
48
+ <style>
49
+ .publish-dialog { box-sizing: border-box; width: 100%; height: 100%; max-width: none; max-height: none; margin: 0; padding: 1rem; border: none; color: var(--text); }
50
+ .publish-dialog:not([open]) { display: none; }
51
+ .publish-dialog::backdrop { background: transparent; }
52
+ .publish-dialog .rigel-modal { box-sizing: border-box; width: min(42rem, 100%); max-height: calc(100dvh - 2rem); overflow-y: auto; overflow-wrap: anywhere; }
53
+ .publish-head, .publish-actions { display: flex; align-items: center; gap: 0.75rem; }
54
+ .publish-head { justify-content: space-between; }
55
+ .publish-actions { justify-content: flex-end; flex-wrap: wrap; margin-top: 1.5rem; }
56
+ .publish-dialog button { min-height: 44px; }
57
+ .publish-head .icon-btn { min-width: 44px; }
58
+ .publish-branch { font-weight: 600; }
59
+ .publish-files { list-style: none; margin: 0; padding: 0; max-height: min(35dvh, 20rem); overflow-y: auto; border: 1px solid var(--border); }
60
+ .publish-files li { display: grid; grid-template-columns: 5rem minmax(0, 1fr); gap: 0.75rem; padding: 0.625rem; border-bottom: 1px solid var(--border); font-size: 0.8125rem; }
61
+ .publish-files li:last-child { border-bottom: none; }
62
+ .publish-files small { display: block; margin-top: 0.25rem; }
63
+ .publish-file-status { text-transform: capitalize; }
64
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
65
+ </style>
@@ -19,6 +19,7 @@
19
19
  import { resolvePath } from "../../schema/paths";
20
20
  import { isSeoField } from "../../manifest/seo";
21
21
  import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
22
+ import { slugify } from "../../slugs";
22
23
 
23
24
  let {
24
25
  schema, docType, values, docId, selected, creating, pending, documents, focusToken, devMode, onchange, onselect, oneditdocument,
@@ -46,7 +47,7 @@
46
47
  const selectedField = $derived(selected?.docId === docId ? fieldForPath(schema, docType, values, selected.path) : null);
47
48
  const selectedSection = $derived(selected?.sectionKey ? sectionTypeDef(schema, sections.find((section) => section._key === selected?.sectionKey)?._type) : undefined);
48
49
  const selectedValue = $derived(selected && selectedField ? resolvePath(values, selected.path).value : undefined);
49
- const docFields = $derived(docType.fields.filter((field) => field.key !== "sections"));
50
+ const docFields = $derived(docType.fields.filter((field) => field.key !== "sections" && (docType.shared || field.key !== "slug")));
50
51
  const seoFields = $derived(docType.shared ? [] : docFields.filter(field => isSeoField(field.key)));
51
52
 
52
53
  $effect(() => {
@@ -97,6 +98,10 @@
97
98
  {#if seoFields.length}<PropertiesTabs bind:tab />{/if}
98
99
  <section class="document-properties" hidden={tab !== "content"}>
99
100
  <h3>{docType.shared ? "Content" : "Page properties"}</h3>
101
+ {#if !docType.shared}
102
+ <label><span>URL slug</span><input type="text" value={String(values.slug ?? docId.slice(docType.name.length + 1))} oninput={(event) => onchange({ ...values, slug: event.currentTarget.value })} onblur={() => { if (Object.hasOwn(values, "slug")) onchange({ ...values, slug: slugify(String(values.slug)) }); }} /></label>
103
+ <p class="hint">Changes the URL, not the document identifier or filename. The website route must use the slug metadata.</p>
104
+ {/if}
100
105
  {#each docFields.filter(field => docType.shared || !isSeoField(field.key)) as field (field.key)}
101
106
  <div data-schema-path={field.key}>
102
107
  <SchemaField {schema} {documents} {devMode} {field} base={field.key} value={values[field.key]} onvalue={(next) => onchange(replaceAt(values, field.key, next))} />
@@ -5,12 +5,13 @@ import { validateMenu } from "../../manifest/menu";
5
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
+ import { assertUniqueUrlSlugs, normalizeUrlSlug, validateDocumentSlugs } from "../../slugs";
8
9
 
9
10
  export interface GitContext { token: string; repo: string; branch: string }
10
11
 
11
- type FileSnapshot = { sha: string; text: string };
12
- type PendingWrite = { sha: string; text: string | null; creating: boolean };
13
- type Snapshot = { target: string; files: Map<string, FileSnapshot>; paths: Set<string>; pending: Map<string, PendingWrite> };
12
+ type FileSnapshot = { sha: string; text: string; mode: string };
13
+ type PendingWrite = { sha: string; text: string | null; creating: boolean; head: string; mode: string };
14
+ type Snapshot = { target: string; head: string; writes: Promise<void>; files: Map<string, FileSnapshot>; paths: Set<string>; pending: Map<string, PendingWrite> };
14
15
  const REMOTE_SNAPSHOT = Symbol("rigel-remote-snapshot");
15
16
  const SHA = /^[a-f0-9]{40}$/;
16
17
  const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -71,11 +72,11 @@ function sha(value: unknown): string {
71
72
  return value;
72
73
  }
73
74
 
74
- async function draftHead(ctx: GitContext, manifest: Manifest): Promise<string> {
75
+ async function draftHead(ctx: GitContext, manifest: Manifest, create = true): Promise<string> {
75
76
  const cfg = config(ctx, manifest);
76
77
  const ref = `/git/ref/heads/${encodeURIComponent(ctx.branch)}`;
77
78
  try { return sha((await gh(ctx, ref))?.object?.sha); }
78
- catch (error) { if (!(error instanceof GitError) || error.status !== 404) throw error; }
79
+ catch (error) { if (!create || !(error instanceof GitError) || error.status !== 404) throw error; }
79
80
  const base = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(cfg.publishBranch)}`))?.object?.sha);
80
81
  try { await gh(ctx, "/git/refs", {method:"POST", body:JSON.stringify({ref:`refs/heads/${ctx.branch}`,sha:base})}); }
81
82
  catch (error) { if (!(error instanceof GitError) || ![409,422].includes(error.status)) throw error; }
@@ -105,13 +106,13 @@ function parseJson(text: string): unknown {
105
106
  try { return JSON.parse(text); } catch { throw new Error("Invalid remote content JSON"); }
106
107
  }
107
108
 
108
- export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest): Promise<Manifest> {
109
+ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, createDraft = true): Promise<Manifest> {
109
110
  const cfg = config(ctx, blueprint);
110
- const head = await draftHead(ctx, blueprint);
111
+ const head = await draftHead(ctx, blueprint, createDraft);
111
112
  const tree = await gh(ctx, `/git/trees/${head}?recursive=1`);
112
113
  if (!tree || tree.truncated !== false || !Array.isArray(tree.tree)) throw new Error("Incomplete GitHub tree; content cannot be loaded safely");
113
114
  const manifest = structuredClone(blueprint);
114
- const snapshot: Snapshot = {target:target(ctx,manifest),files:new Map(),paths:new Set(),pending:new Map()};
115
+ const snapshot: Snapshot = {target:target(ctx,manifest),head,writes:Promise.resolve(),files:new Map(),paths:new Set(),pending:new Map()};
115
116
  const entries = new Map<string, any>();
116
117
  for (const entry of tree.tree) {
117
118
  if (!entry || typeof entry.path !== "string" || entry.path.split("/").some((part: string) => !part || part === "." || part === "..") || /[\\\u0000-\u001f]/.test(entry.path) || !["blob","tree","commit"].includes(entry.type) || !SHA.test(entry.sha) || !["100644","100755","120000","040000","160000"].includes(entry.mode)) throw new Error("Malformed GitHub tree entry");
@@ -132,7 +133,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
132
133
  const expected = sha(entry.sha);
133
134
  const blob = await gh(ctx, `/git/blobs/${expected}`);
134
135
  if (sha(blob?.sha) !== expected) throw new Error("GitHub blob SHA mismatch");
135
- const text = decode(blob); snapshot.files.set(path,{sha:expected,text}); return text;
136
+ const text = decode(blob); snapshot.files.set(path,{sha:expected,text,mode:entry.mode}); return text;
136
137
  };
137
138
  const list = (folder: string, pattern = SLUG) => {
138
139
  const prefix = [cfg.repoRoot,folder].filter(Boolean).join("/") + "/";
@@ -156,6 +157,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
156
157
  }
157
158
  documentIndex(manifest.documents);
158
159
  for (const doc of manifest.documents) validateDocument(manifest.schema!,doc.type,doc.slug,doc.data,manifest.documents);
160
+ if (manifest.schema) validateDocumentSlugs(manifest.schema, manifest.documents, manifest.preview);
159
161
  for (const collection of manifest.collections ?? []) {
160
162
  const folder = cfg.collections.find(c => c.name === collection.name)?.folder;
161
163
  if (!folder) throw new Error("Missing configured GitHub collection");
@@ -166,6 +168,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
166
168
  validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
167
169
  collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
168
170
  }
171
+ assertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
169
172
  }
170
173
  if (cfg.menus) {
171
174
  manifest.menus = {maxDepth:cfg.menus.maxDepth,entries:[]};
@@ -188,8 +191,17 @@ function loaded(ctx: GitContext, manifest: Manifest): Snapshot {
188
191
 
189
192
  async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: string | null, creating: boolean) {
190
193
  const snapshot = loaded(ctx,manifest);
194
+ const head = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
195
+ const uncertain = snapshot.pending.get(path);
196
+ if (head !== snapshot.head && head !== uncertain?.head) throw new Error("GitHub draft branch conflict; reload before saving. Your unsaved edits are still here.");
191
197
  const endpoint = `/contents/${path.split("/").map(encodeURIComponent).join("/")}`;
192
198
  const verify = async (pending: PendingWrite) => {
199
+ const current = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
200
+ if (current !== pending.head) {
201
+ if (current !== snapshot.head) throw new Error("GitHub draft branch conflict; reload before saving.");
202
+ const result = await gh(ctx, `/git/refs/heads/${encodeURIComponent(ctx.branch)}`, { method: "PATCH", body: JSON.stringify({ sha: pending.head, force: false }) });
203
+ if (sha(result?.object?.sha) !== pending.head) throw new Error("GitHub branch update could not be verified; retry or reload.");
204
+ }
193
205
  let verified;
194
206
  try { verified = await gh(ctx,`${endpoint}?ref=${encodeURIComponent(ctx.branch)}`); }
195
207
  catch (error) {
@@ -200,11 +212,11 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
200
212
  snapshot.files.delete(path); snapshot.paths.delete(path);
201
213
  } else {
202
214
  if (verified?.type !== "file" || sha(verified.sha) !== pending.sha || decode(verified) !== pending.text) throw new Error("GitHub save changed before verification; reload");
203
- snapshot.files.set(path,{sha:pending.sha,text:pending.text}); snapshot.paths.add(path);
215
+ snapshot.files.set(path,{sha:pending.sha,text:pending.text,mode:pending.mode}); snapshot.paths.add(path);
204
216
  }
217
+ snapshot.head = pending.head;
205
218
  snapshot.pending.delete(path);
206
219
  };
207
- const uncertain = snapshot.pending.get(path);
208
220
  if (uncertain) {
209
221
  await verify(uncertain);
210
222
  if (uncertain.text === text && (!creating || uncertain.creating)) return;
@@ -212,14 +224,27 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
212
224
  }
213
225
  const previous = snapshot.files.get(path);
214
226
  if (creating ? snapshot.paths.has(path) : !previous) throw new Error(creating ? "Content already exists; choose another slug" : "Reload this content before changing it");
215
- const payload = {message:`rigel: ${text === null ? "delete" : "save"} ${path}`,branch:ctx.branch,...(!creating ? {sha:previous!.sha} : {}),...(text === null ? {} : {content:encode(text)})};
216
- const result = await gh(ctx,endpoint,{method:text === null ? "DELETE" : "PUT",body:JSON.stringify(payload)});
217
- const pending = {sha:text === null ? previous!.sha : sha(result?.content?.sha),text,creating};
227
+ const blob = text === null ? null : sha((await gh(ctx, "/git/blobs", { method: "POST", body: JSON.stringify({ content: encode(text), encoding: "base64" }) }))?.sha);
228
+ const mode = previous?.mode ?? "100644";
229
+ const base = sha((await gh(ctx, `/git/commits/${snapshot.head}`))?.tree?.sha);
230
+ const tree = sha((await gh(ctx, "/git/trees", { method: "POST", body: JSON.stringify({ base_tree: base, tree: [{ path, mode, type: "blob", sha: blob }] }) }))?.sha);
231
+ const commit = sha((await gh(ctx, "/git/commits", { method: "POST", body: JSON.stringify({ message: `rigel: ${text === null ? "delete" : "save"} ${path}`, tree, parents: [snapshot.head] }) }))?.sha);
232
+ const pending = {sha:blob ?? previous!.sha,text,creating,head:commit,mode};
218
233
  snapshot.pending.set(path,pending);
219
234
  await verify(pending);
220
235
  }
221
236
 
222
- export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
237
+ export async function saveRemoteSelection(...args: Parameters<typeof saveSelection>): Promise<void> {
238
+ const key = writeKey(args[0]);
239
+ if (publishing.has(key)) throw new Error("Wait for publishing to finish before saving.");
240
+ const snapshot = loaded(args[0], args[1]);
241
+ saving.set(key, (saving.get(key) ?? 0) + 1);
242
+ const result = snapshot.writes.then(() => saveSelection(...args));
243
+ snapshot.writes = result.catch(() => {});
244
+ try { await result; } finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
245
+ }
246
+
247
+ async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
223
248
  const cfg = config(ctx,manifest);
224
249
  const snapshot = loaded(ctx,manifest);
225
250
  if (!isPlainObject(values)) throw new Error("Content values must be an object");
@@ -238,15 +263,27 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
238
263
  if (!folder || !collection) throw new Error("Unknown collection");
239
264
  validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
240
265
  path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
241
- data = {frontmatter:{...values,slug:selection.slug},body};
266
+ const frontmatter = { ...values };
267
+ if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug);
268
+ const prefix = filePath(ctx,manifest,folder,"placeholder","json",POST_SLUG).replace(/placeholder\.json$/, "");
269
+ const others = [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ slug: file.slice(prefix.length, -5), values: (parseJson(value.text) as { frontmatter: Record<string, unknown> }).frontmatter }));
270
+ assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }]);
271
+ data = {frontmatter,body};
242
272
  } else if (selection.kind === "document") {
243
273
  if (!manifest.schema) throw new Error("No document schema configured");
244
274
  const document = validateDocumentId(manifest.schema,selection.id);
245
275
  if (!document || document.type.name !== selection.type) throw new Error("Unknown document type or slug");
246
276
  if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error("Creating entries is disabled for this document type");
247
- data = {...values,_id:selection.id,_type:selection.type,slug:document.slug};
277
+ const next = {...values,_id:selection.id,_type:selection.type} as Record<string, unknown>;
278
+ if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug);
279
+ data = next;
248
280
  validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? []);
249
281
  path = filePath(ctx,manifest,document.type.folder,document.slug);
282
+ const documents = manifest.schema.documents.flatMap(type => {
283
+ const prefix = filePath(ctx,manifest,type.folder,"placeholder").replace(/placeholder\.json$/, "");
284
+ return [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ id: `${type.name}:${file.slice(prefix.length,-5)}`, type: type.name, slug: file.slice(prefix.length,-5), data: parseJson(value.text) as Record<string, unknown> }));
285
+ });
286
+ validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview);
250
287
  } else if (selection.kind === "menu") {
251
288
  if (!cfg.menus || creating) throw new Error("Menus must be configured by the site developer");
252
289
  data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
@@ -255,9 +292,171 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
255
292
  await mutate(ctx,manifest,path,JSON.stringify(data,null,2) + "\n",creating);
256
293
  }
257
294
 
295
+ export interface PublishFile { filename: string; status: "added" | "modified" | "removed" | "renamed"; previous_filename?: string }
296
+ export interface PublishReview {
297
+ repo: string; branch: string; publishBranch: string; head: string; base: string;
298
+ files: PublishFile[]; needsSync: boolean; prNumber?: number; creationUncertain?: boolean;
299
+ }
300
+
301
+ async function publishHeads(ctx: GitContext, publishBranch: string, request: typeof gh) {
302
+ const head = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
303
+ const base = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(publishBranch)}`))?.object?.sha);
304
+ return { head, base };
305
+ }
306
+
307
+ async function publishTree(ctx: GitContext, head: string, request: typeof gh) {
308
+ const data = await request(ctx, `/git/trees/${head}?recursive=1`);
309
+ if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error("Incomplete GitHub tree. Publishing is unavailable; review this change on GitHub.");
310
+ const files = new Map<string, string>();
311
+ const paths = new Set<string>();
312
+ for (const entry of data.tree) {
313
+ if (!entry || typeof entry.path !== "string" || !entry.path || /[\u0000-\u001f\u007f\\\\]/.test(entry.path) || entry.path.split("/").some((p: string) => !p || p === "." || p === "..") || paths.has(entry.path) || !["blob", "tree", "commit"].includes(entry.type) || !["100644", "100755", "120000", "040000", "160000"].includes(entry.mode)) throw new Error("Malformed GitHub publish tree");
314
+ if (!({ blob: ["100644", "100755", "120000"], tree: ["040000"], commit: ["160000"] } as Record<string, string[]>)[entry.type]?.includes(entry.mode)) throw new Error("Malformed GitHub publish tree mode");
315
+ paths.add(entry.path);
316
+ const id = sha(entry.sha);
317
+ if (entry.type !== "tree") files.set(entry.path, `${entry.mode}:${entry.type}:${id}`);
318
+ }
319
+ return files;
320
+ }
321
+
322
+ export async function reviewRemotePublish(ctx: GitContext, manifest: Manifest, assertActive: () => void = () => {}): Promise<PublishReview> {
323
+ const cfg = config(ctx, manifest);
324
+ const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
325
+ const repository = await request(ctx, "");
326
+ if (repository?.permissions?.push !== true) throw new Error("Publishing requires repository write access.");
327
+ if (repository.allow_squash_merge !== true) throw new Error("Squash merging is disabled. Ask the repository owner to enable it before publishing.");
328
+ if (repository.delete_branch_on_merge !== false) throw new Error("Ask the repository owner to disable automatic head-branch deletion before publishing, so the drafts branch is preserved.");
329
+ const { head, base } = await publishHeads(ctx, cfg.publishBranch, request);
330
+ const [source, destination] = await Promise.all([publishTree(ctx, head, request), publishTree(ctx, base, request)]);
331
+ const changed = new Set([...source.keys(), ...destination.keys()].filter(path => source.get(path) !== destination.get(path)));
332
+ const review: PublishReview = { repo: ctx.repo, branch: ctx.branch, publishBranch: cfg.publishBranch, head, base, files: [], needsSync: false };
333
+ if (changed.size) {
334
+ const comparison = await request(ctx, `/compare/${base}...${head}?per_page=1&page=1`);
335
+ if (sha(comparison?.base_commit?.sha) !== base) throw new Error("GitHub comparison does not match the reviewed target.");
336
+ review.needsSync = sha(comparison?.merge_base_commit?.sha) !== base;
337
+ if (review.needsSync) {
338
+ review.files = [...changed].sort().map(filename => ({ filename, status: !source.has(filename) ? "removed" : !destination.has(filename) ? "added" : "modified" }));
339
+ } else {
340
+ if (!Array.isArray(comparison.files) || comparison.files.length >= 300) throw new Error("GitHub comparisons of 300 or more files are not supported. Publish this change on GitHub; no partial list is shown.");
341
+ const covered = new Set<string>();
342
+ for (const file of comparison.files) {
343
+ if (!file || typeof file.filename !== "string" || covered.has(file.filename) || !changed.has(file.filename) || !["added", "removed", "modified", "renamed"].includes(file.status)) throw new Error("Incomplete or unsupported GitHub file comparison. Publish this change on GitHub.");
344
+ covered.add(file.filename);
345
+ if (file.status === "renamed") {
346
+ if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error("Incomplete GitHub rename comparison");
347
+ covered.add(file.previous_filename);
348
+ }
349
+ review.files.push({ filename: file.filename, status: file.status, ...(file.status === "renamed" ? { previous_filename: file.previous_filename } : {}) });
350
+ }
351
+ if (covered.size !== changed.size) throw new Error("Incomplete GitHub file comparison. Publishing is unavailable; no partial list is shown.");
352
+ }
353
+ if (review.files.length >= 300) throw new Error("Publishing 300 or more files is not supported. Review and publish this change on GitHub.");
354
+ }
355
+ const current = await publishHeads(ctx, cfg.publishBranch, request);
356
+ if (current.head !== head || current.base !== base) throw new Error("Branches changed during review. Review changes again before publishing.");
357
+ assertActive();
358
+ return review;
359
+ }
360
+
361
+ const publishing = new Set<string>();
362
+ const saving = new Map<string, number>();
363
+ const writeKey = (ctx: GitContext) => `${ctx.repo}:${ctx.branch}`;
364
+
365
+ export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest, review: PublishReview, assertActive: () => void = () => {}): Promise<"published" | "synced"> {
366
+ const cfg = config(ctx, manifest), key = writeKey(ctx);
367
+ if (publishing.has(key) || saving.get(key)) throw new Error("Wait for the current save or publish to finish.");
368
+ if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error("Review saved changes before publishing.");
369
+ sha(review.head); sha(review.base);
370
+ publishing.add(key);
371
+ const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
372
+ const unchanged = async () => {
373
+ const current = await publishHeads(ctx, cfg.publishBranch, request);
374
+ if (current.head !== review.head || current.base !== review.base) throw new Error("Branches changed. Review changes again before publishing.");
375
+ };
376
+ const matches = (pr: any) => pr?.head?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr?.base?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr.head.ref === ctx.branch && pr.base.ref === cfg.publishBranch;
377
+ const validate = (pr: any) => {
378
+ if (!matches(pr) || !Number.isSafeInteger(pr.number) || pr.number <= 0 || pr.head.sha !== review.head) throw new Error("Pull request no longer matches this review. Review changes again.");
379
+ return pr;
380
+ };
381
+ const merged = (pr: any) => {
382
+ validate(pr);
383
+ if (pr.merged === true && pr.state === "closed") { sha(pr.merge_commit_sha); return true; }
384
+ return false;
385
+ };
386
+ const find = async () => {
387
+ const found: any[] = [];
388
+ for (let page = 1; page <= 100; page++) {
389
+ const query = new URLSearchParams({ state: "open", head: `${ctx.repo.split("/")[0]}:${ctx.branch}`, base: cfg.publishBranch, per_page: "100", page: String(page) });
390
+ const list = await request(ctx, `/pulls?${query}`);
391
+ if (!Array.isArray(list)) throw new Error("Incomplete GitHub pull request listing");
392
+ found.push(...list.filter(matches));
393
+ if (found.length > 1) throw new Error("Multiple matching pull requests. Resolve them on GitHub before publishing.");
394
+ if (list.length < 100) return found[0];
395
+ }
396
+ throw new Error("Incomplete GitHub pull request listing; publishing stopped.");
397
+ };
398
+ try {
399
+ if (review.prNumber) {
400
+ const previous = await request(ctx, `/pulls/${review.prNumber}`);
401
+ if (merged(previous)) return "published";
402
+ }
403
+ await unchanged();
404
+ const repository = await request(ctx, "");
405
+ if (repository?.permissions?.push !== true || repository.allow_squash_merge !== true || repository.delete_branch_on_merge !== false) throw new Error("Publishing settings or access changed. Enable squash merges, disable automatic branch deletion and verify write access, then review again.");
406
+ let pr = review.prNumber ? await request(ctx, `/pulls/${review.prNumber}`) : await find();
407
+ if (!pr) {
408
+ if (review.creationUncertain) throw new Error("The previous pull request creation is unconfirmed. Check GitHub before retrying; no duplicate was created.");
409
+ await unchanged();
410
+ review.creationUncertain = true;
411
+ try {
412
+ pr = await request(ctx, "/pulls", { method: "POST", body: JSON.stringify({ title: `Publish ${ctx.branch} to ${cfg.publishBranch}`, head: ctx.branch, base: cfg.publishBranch, draft: false, maintainer_can_modify: false }) });
413
+ } catch (error) {
414
+ pr = await find();
415
+ if (!pr) throw error;
416
+ }
417
+ }
418
+ validate(pr);
419
+ review.prNumber = pr.number;
420
+ pr = validate(await request(ctx, `/pulls/${pr.number}`));
421
+ if (merged(pr)) return "published";
422
+ if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new Error("Pull request or target changed. Review changes again before publishing.");
423
+ if (review.needsSync) {
424
+ await unchanged();
425
+ await request(ctx, `/pulls/${pr.number}/update-branch`, { method: "PUT", body: JSON.stringify({ expected_head_sha: review.head }) });
426
+ const updated = await request(ctx, `/pulls/${pr.number}`);
427
+ if (!matches(updated) || updated.state !== "open" || sha(updated.head.sha) === review.head) throw new Error("GitHub is still syncing drafts, or the sync could not be verified. Wait, reload draft content and review again. Nothing was published.");
428
+ return "synced";
429
+ }
430
+ if (pr.mergeable !== true || pr.mergeable_state !== "clean") throw new Error("GitHub has not allowed this merge. Resolve conflicts, required checks, reviews or branch protections on GitHub, then retry. No protection is bypassed.");
431
+ await unchanged();
432
+ let failure: unknown;
433
+ try {
434
+ const result = await request(ctx, `/pulls/${pr.number}/merge`, { method: "PUT", body: JSON.stringify({ merge_method: "squash", sha: review.head }) });
435
+ if (result?.merged !== true) failure = new Error("GitHub did not merge the pull request. Check required checks, reviews and branch protections, then retry.");
436
+ } catch (error) { failure = error; }
437
+ let verified;
438
+ try { verified = await request(ctx, `/pulls/${pr.number}`); }
439
+ catch (error) {
440
+ if (error instanceof GitError && error.status === 401) throw error;
441
+ throw new Error(`Publish outcome is unconfirmed. Check pull request #${pr.number} on GitHub, or retry to verify it before starting another publish.`);
442
+ }
443
+ if (merged(verified)) return "published";
444
+ if (failure) throw failure;
445
+ throw new Error("The publish result could not be verified. Check this pull request on GitHub before retrying.");
446
+ } catch (error) {
447
+ if (error instanceof GitError && [403, 404].includes(error.status)) throw new GitError("Publishing requires GitHub App Contents and Pull requests read/write permissions, installation-owner approval and repository write access. Reconnect after approval. Branch protections still apply.", error.status);
448
+ if (error instanceof GitError && [405, 409, 422].includes(error.status)) throw new GitError("GitHub rejected publishing. Check conflicts, required checks/reviews, branch protections and squash settings. Review changes again if either branch moved.", error.status);
449
+ throw error;
450
+ } finally { publishing.delete(key); }
451
+ }
452
+
258
453
  export async function deleteRemotePost(ctx: GitContext, manifest: Manifest, collection: string, slug: string): Promise<void> {
454
+ const key = writeKey(ctx);
455
+ if (publishing.has(key)) throw new Error("Wait for publishing to finish before deleting.");
259
456
  const cfg = config(ctx,manifest);
260
457
  const folder = cfg.collections.find(c => c.name === collection)?.folder;
261
458
  if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error("Unknown collection");
262
- await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false);
263
- }
459
+ saving.set(key, (saving.get(key) ?? 0) + 1);
460
+ try { await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false); }
461
+ finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
462
+ }
@@ -1,15 +1 @@
1
- export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
2
- const base = input
3
- .normalize("NFKD")
4
- .replace(/[̀-ͯ]/g, "") // combining marks → strip (é→e)
5
- .toLowerCase()
6
- .trim()
7
- .replace(/[^a-z0-9]+/g, "-")
8
- .replace(/^-+|-+$/g, "")
9
- .replace(/-{2,}/g, "-");
10
- if (!base) return "";
11
- const existing = new Set(existingSlugs);
12
- let slug = base;
13
- for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
14
- return slug;
15
- }
1
+ export { slugify } from "../../slugs";
@@ -175,6 +175,8 @@
175
175
  .rigel .rail-scroll > button:hover { background: var(--surface-raised); color: var(--text); }
176
176
  .rigel .sidebar-asset-icon { display: inline-block; width: 18px; height: 18px; flex-shrink: 0; background-color: currentColor; mask-image: var(--sidebar-icon); mask-position: center; mask-size: contain; mask-repeat: no-repeat; }
177
177
  .rigel .rail-scroll > button { gap: 0.625rem; }
178
+ .rigel aside > button.sidebar-publish { gap: 0.625rem; width: calc(100% - 1.5rem); min-height: 44px; margin: 0 0.75rem 0.5rem; padding: 0.625rem; border: 1px solid var(--border-control); color: var(--text); }
179
+ .rigel aside > button.sidebar-publish:disabled { opacity: 0.5; cursor: not-allowed; }
178
180
  .rigel aside > button.sidebar-toggle { gap: 0.625rem; width: calc(100% - 1.5rem); margin: 0 0.75rem 0.5rem; padding: 0.625rem; font-size: 0.75rem; }
179
181
  @media (min-width: 768px) {
180
182
  .rigel.sidebar-collapsed { --sidebar-w: 64px; }
@@ -186,6 +188,7 @@
186
188
  .rigel.sidebar-collapsed .user-btn > .user-menu-icon { display: none; }
187
189
  .rigel.sidebar-collapsed .rail-scroll > button,
188
190
  .rigel.sidebar-collapsed .search-trigger,
191
+ .rigel.sidebar-collapsed .sidebar-publish,
189
192
  .rigel.sidebar-collapsed .sidebar-toggle { justify-content: center; }
190
193
  .rigel.sidebar-collapsed .sidebar-heading { height: 1px; font-size: 0; padding: 0; margin: 0.75rem 0.625rem; border-top: 1px solid var(--border-subtle); }
191
194
  .rigel.sidebar-collapsed .aside-foot { padding: 0.75rem 0.5rem; }
@@ -365,7 +368,18 @@
365
368
  .rigel .preview-heading span, .rigel .site-preview small { color: var(--text-muted); font-size: 0.75rem; }
366
369
  .rigel .site-preview p { overflow-wrap: anywhere; font-size: 0.8125rem; }
367
370
  .rigel .site-preview .preview-error { color: var(--danger); }
368
- .rigel .site-preview iframe { flex: 1; width: 100%; min-height: 300px; border: 1px solid var(--border); background: white; }
371
+ .rigel .preview-frame { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; background: var(--panel); }
372
+ .rigel .preview-frame iframe { opacity: 0; visibility: hidden; transition: none; }
373
+ .rigel .preview-frame iframe.revealed { opacity: 1; visibility: visible; transition: opacity 200ms ease-out; }
374
+ .rigel .preview-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 0.625rem; color: var(--text); background: var(--panel); font-size: 0.8125rem; }
375
+ .rigel .preview-spinner { width: 1rem; height: 1rem; border: 2px solid var(--border); border-top-color: currentColor; border-radius: 50%; animation: preview-spin 800ms linear infinite; }
376
+ @keyframes preview-spin { to { transform: rotate(360deg); } }
377
+ @media (prefers-reduced-motion: reduce) {
378
+ .rigel .preview-spinner { animation: none; }
379
+ .rigel .preview-frame iframe.revealed { transition: none; }
380
+ }
381
+ .rigel .site-preview .preview-frame { min-height: 300px; border: 1px solid var(--border); }
382
+ .rigel .site-preview iframe { flex: 1; width: 100%; min-height: 0; border: 0; background: white; }
369
383
  .rigel .site-preview small { padding-top: 0.75rem; }
370
384
  .rigel .preview-retry { align-self: start; }
371
385
  @media (max-width: 1050px) {
package/src/env.d.ts CHANGED
@@ -17,6 +17,6 @@ declare module "virtual:rigel-content" {
17
17
 
18
18
  declare module "virtual:rigel-config" {
19
19
  import type { RigelConfig } from "@rigel-cms/rigel";
20
- const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema"> & { publicDir?: string; root?: string };
20
+ const config: Pick<RigelConfig, "copyDir" | "backend" | "demo" | "collections" | "menus" | "mediaDir" | "schema" | "preview"> & { publicDir?: string; root?: string };
21
21
  export default config;
22
22
  }
package/src/preview.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import MarkdownIt from "markdown-it";
2
2
  import DOMPurify from "dompurify";
3
3
  import { pageRoute } from "./schema/paths";
4
+ import { slugify } from "./slugs";
4
5
  import { renderArticle } from "./article-render";
5
6
  import { safePreviewUrl, previewBaseUrl } from "./preview-urls";
6
7
  export { safePreviewUrl } from "./preview-urls";
@@ -20,6 +21,7 @@ export interface PreviewConfig {
20
21
  export interface PreviewTarget {
21
22
  kind: "page" | "post" | "menu" | "document";
22
23
  slug: string;
24
+ urlSlug?: string;
23
25
  collection?: string;
24
26
  type?: string;
25
27
  }
@@ -31,6 +33,11 @@ export function previewPath(config: PreviewConfig, target: PreviewTarget, isNew
31
33
  }
32
34
 
33
35
  export function previewUrl(config: PreviewConfig, target: PreviewTarget, mode: "live" | "draft" | "website" = "draft", isNew = false): string | null {
36
+ if (mode !== "draft" && target.kind !== "page" && target.urlSlug !== undefined) {
37
+ const slug = slugify(target.urlSlug);
38
+ if (!slug) return null;
39
+ target = { ...target, slug };
40
+ }
34
41
  const path = previewPath(config, target, mode === "draft" && (target.kind === "post" || isNew));
35
42
  const base = mode === "live" ? config.liveUrl : config.draftUrl;
36
43
  if (!path || base === undefined) return path;
@@ -46,7 +46,7 @@ export const GET: APIRoute = async ({ params }) => {
46
46
  const target = validateDocumentId(guarded.schema, id);
47
47
  if (!target) return new Response("Unknown document", { status: 404 });
48
48
  try {
49
- const documents = await readDocuments(guarded.schema, guarded.root);
49
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
50
50
  const document = documents.find((entry) => entry.id === id);
51
51
  if (!document) return new Response("Unknown document", { status: 404 });
52
52
  return Response.json(document);
@@ -66,10 +66,10 @@ const handle: APIRoute = async ({ request, params }) => {
66
66
  if (!target) return new Response("Unknown document type or slug", { status: 400 });
67
67
  try {
68
68
  const data = await readBody(request);
69
- const documents = await readDocuments(guarded.schema, guarded.root);
70
- const next = { ...data, _id: id, _type: target.type.name, slug: target.slug };
69
+ const documents = await readDocuments(guarded.schema, guarded.root, config.preview);
70
+ const next = { ...data, _id: id, _type: target.type.name };
71
71
  validateDocument(guarded.schema, target.type.name, target.slug, next, documents);
72
- await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update");
72
+ await writeDocument(guarded.schema, guarded.root, target.type.name, target.slug, next, request.method === "POST" ? "create" : "update", config.preview);
73
73
  return new Response(null, { status: 204 });
74
74
  } catch (error) {
75
75
  if (error instanceof DocumentStoreError) return new Response(error.message, { status: error.status });
@@ -1,5 +1,6 @@
1
1
  import type { APIRoute } from "astro";
2
- import { writeFile, mkdir, rm, readFile, rename, link } from "node:fs/promises";
2
+ import { writeFile, mkdir, rm, readFile, readdir, rename, link } from "node:fs/promises";
3
+ import { assertUniqueUrlSlugs, normalizeUrlSlug } from "../slugs";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import path from "node:path";
5
6
 
@@ -23,7 +24,14 @@ function safeSlug(slug: string): string | null {
23
24
  return /^[a-z0-9][a-z0-9_-]*$/.test(slug) ? slug : null;
24
25
  }
25
26
 
26
- export const POST: APIRoute = async ({ request, params }) => {
27
+ let writes: Promise<unknown> = Promise.resolve();
28
+ export const POST: APIRoute = (context) => {
29
+ const result = writes.then(() => savePost(context));
30
+ writes = result.catch(() => {});
31
+ return result;
32
+ };
33
+
34
+ const savePost: APIRoute = async ({ request, params }) => {
27
35
  if (import.meta.env.PROD) {
28
36
  return new Response("Local backend is dev-only", { status: 404 });
29
37
  }
@@ -45,13 +53,17 @@ export const POST: APIRoute = async ({ request, params }) => {
45
53
  try {
46
54
  validateBody(body.body);
47
55
  const fields = config.collections?.find((collection: CollectionConfig) => collection.name === params.collection)?.fields ?? [];
48
- const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root)) : [];
56
+ const documents = config.schema && config.root ? await readDocuments(config.schema, fileURLToPath(config.root), config.preview) : [];
49
57
  validateCollectionValues(fields, body.frontmatter, documents);
58
+ if (Object.hasOwn(body.frontmatter, "slug")) body.frontmatter.slug = normalizeUrlSlug(body.frontmatter.slug);
59
+ const names = await readdir(folder).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return []; throw error; });
60
+ const entries = await Promise.all(names.filter(name => name.endsWith(".json") && name !== `${slug}.json`).map(async name => ({ slug: name.slice(0, -5), values: JSON.parse(await readFile(path.join(folder, name), "utf8")).frontmatter })));
61
+ assertUniqueUrlSlugs([...entries, { slug, values: body.frontmatter }]);
50
62
  } catch (error) {
51
63
  return new Response(error instanceof Error ? error.message : "Invalid article", { status: 400 });
52
64
  }
53
65
  await mkdir(path.dirname(file), { recursive: true });
54
- const fm = { ...body.frontmatter, slug };
66
+ const fm = { ...body.frontmatter };
55
67
  const temporary = `${file}.${randomUUID()}.tmp`;
56
68
  try {
57
69
  await writeFile(temporary, JSON.stringify({ frontmatter: fm, body: body.body }, null, 2) + "\n", { flag: "wx" });
@@ -1,4 +1,5 @@
1
1
  import { documentDef, isPlainObject, objectDef } from "./validate";
2
+ import { effectiveSlug } from "../slugs";
2
3
  import {
3
4
  SLUG_PATTERN,
4
5
  type ContentDocument,
@@ -128,8 +129,9 @@ export function assertUniqueSlug(documents: ContentDocument[], typeName: string,
128
129
  if (legacySlugs.includes(slug)) throw new Error(`The slug "${slug}" is already used by a copy page.`);
129
130
  }
130
131
 
131
- export function pageRoute(slug: string, mapping: { url: string; index?: string } | undefined): string | null {
132
+ export function pageRoute(target: string | Pick<ContentDocument, "slug" | "data">, mapping: { url: string; index?: string } | undefined): string | null {
132
133
  if (!mapping) return null;
134
+ const slug = typeof target === "string" ? target : effectiveSlug(target.data, target.slug);
133
135
  if (slug === "home" && mapping.index) return mapping.index;
134
136
  const path = mapping.url.replaceAll("{slug}", slug);
135
137
  return /^\/(?!\/)/.test(path) && !/[\\\s?#]/.test(path) && !path.split("/").includes("..") && !/^\/rigel(?:\/|$)/.test(path) ? path : null;
@@ -4,6 +4,8 @@ import path from "node:path";
4
4
  import { constants } from "node:fs";
5
5
  import { isPlainObject } from "./validate";
6
6
  import { SLUG_PATTERN, type ContentDocument, type Schema } from "./types";
7
+ import { normalizeUrlSlug, validateDocumentSlugs } from "../slugs";
8
+ import type { PreviewConfig } from "../preview";
7
9
 
8
10
  const MAX_BYTES = 1024 * 1024;
9
11
  const FOLDER_PART = /^[a-zA-Z0-9_-][a-zA-Z0-9_. -]*$/;
@@ -68,7 +70,7 @@ async function ensureFolder(root: string, folder: string): Promise<string> {
68
70
  return directory;
69
71
  }
70
72
 
71
- export async function readDocuments(schema: Schema, root: string): Promise<ContentDocument[]> {
73
+ export async function readDocuments(schema: Schema, root: string, preview?: PreviewConfig): Promise<ContentDocument[]> {
72
74
  const documents: ContentDocument[] = [];
73
75
  for (const type of schema.documents) {
74
76
  let directory: string;
@@ -88,6 +90,8 @@ export async function readDocuments(schema: Schema, root: string): Promise<Conte
88
90
  documents.push({ id: `${type.name}:${slug}`, type: type.name, slug, data: data as Record<string, unknown> });
89
91
  }
90
92
  }
93
+ try { validateDocumentSlugs(schema, documents, preview); }
94
+ catch (error) { throw new DocumentStoreError((error as Error).message); }
91
95
  return documents;
92
96
  }
93
97
 
@@ -96,13 +100,21 @@ export async function readDocument(schema: Schema, root: string, typeName: strin
96
100
  return documents.find((doc) => doc.type === typeName && doc.slug === slug) ?? null;
97
101
  }
98
102
 
99
- export async function writeDocument(
103
+ let writes: Promise<void> = Promise.resolve();
104
+ export function writeDocument(...args: Parameters<typeof writeDocumentFile>): Promise<void> {
105
+ const result = writes.then(() => writeDocumentFile(...args));
106
+ writes = result.catch(() => {});
107
+ return result;
108
+ }
109
+
110
+ async function writeDocumentFile(
100
111
  schema: Schema,
101
112
  root: string,
102
113
  typeName: string,
103
114
  slug: string,
104
115
  data: Record<string, unknown>,
105
116
  mode: "create" | "update",
117
+ preview?: PreviewConfig,
106
118
  ): Promise<void> {
107
119
  const type = schema.documents.find((entry) => entry.name === typeName);
108
120
  if (!type) throw new DocumentStoreError(`Unknown document type ${typeName}`, 404);
@@ -118,6 +130,13 @@ export async function writeDocument(
118
130
  if (existing && (existing.isSymbolicLink() || !existing.isFile())) throw new DocumentStoreError("Document path is unsafe", 409);
119
131
  if (mode === "create" && existing) throw new DocumentStoreError("A document with that slug already exists", 409);
120
132
  if (mode === "update" && !existing) throw new DocumentStoreError("No such document", 404);
133
+ if (Buffer.byteLength(JSON.stringify(data)) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
134
+ data = { ...data };
135
+ try {
136
+ if (!type.shared && Object.hasOwn(data, "slug")) data.slug = normalizeUrlSlug(data.slug);
137
+ const documents = await readDocuments(schema, root, preview);
138
+ validateDocumentSlugs(schema, [...documents.filter(doc => doc.id !== `${typeName}:${slug}`), { id: `${typeName}:${slug}`, type: typeName, slug, data }], preview);
139
+ } catch (error) { throw new DocumentStoreError((error as Error).message); }
121
140
  const serialized = JSON.stringify(data, null, 2) + "\n";
122
141
  if (Buffer.byteLength(serialized) > MAX_BYTES) throw new DocumentStoreError("Document exceeds the 1 MiB limit", 413);
123
142
  const temporary = path.join(directory, `.${slug}-${randomUUID()}.tmp`);
package/src/slugs.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { ContentDocument, Schema } from "./schema/types";
2
+ import type { PreviewConfig } from "./preview";
3
+
4
+ export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
5
+ const base = input.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
6
+ if (!base) return "";
7
+ const existing = new Set(existingSlugs);
8
+ let slug = base;
9
+ for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
10
+ return slug;
11
+ }
12
+
13
+ export function normalizeUrlSlug(value: unknown): string {
14
+ if (typeof value !== "string" || !slugify(value)) throw new Error("URL slug must contain letters or numbers.");
15
+ return slugify(value);
16
+ }
17
+
18
+ export function effectiveSlug(values: Record<string, unknown>, storageSlug: string): string {
19
+ if (!Object.hasOwn(values, "slug")) return storageSlug;
20
+ if (typeof values.slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(values.slug)) throw new Error("Invalid URL slug; use lowercase letters, numbers and single dashes.");
21
+ return values.slug;
22
+ }
23
+
24
+ export function assertUniqueUrlSlugs(entries: { slug: string; values: Record<string, unknown> }[]): void {
25
+ const seen = new Set<string>();
26
+ for (const entry of entries) {
27
+ const slug = effectiveSlug(entry.values, entry.slug);
28
+ if (seen.has(slug)) throw new Error(`URL slug "${slug}" is already used.`);
29
+ seen.add(slug);
30
+ }
31
+ }
32
+
33
+ export function validateDocumentSlugs(schema: Schema, documents: ContentDocument[], preview?: PreviewConfig): void {
34
+ const seen = new Set<string>();
35
+ const pages = documents.filter(doc => schema.documents.some(type => type.name === doc.type && !type.shared));
36
+ const fixedRoutes = Object.entries(preview?.pages ?? {}).filter(([key]) => !pages.some(doc => doc.slug === key)).map(([, route]) => route);
37
+ for (const doc of documents) {
38
+ const type = schema.documents.find(type => type.name === doc.type);
39
+ if (!type || type.shared) continue;
40
+ const slug = effectiveSlug(doc.data, doc.slug);
41
+ const mapping = preview?.documents?.[doc.type];
42
+ const route = mapping ? slug === "home" && mapping.index ? mapping.index : mapping.url.replaceAll("{slug}", slug) : `/${slug}/`;
43
+ if (seen.has(route) || fixedRoutes.includes(route)) throw new Error(`URL slug "${slug}" is already used.`);
44
+ seen.add(route);
45
+ }
46
+ }
@@ -22,7 +22,8 @@ export function rigelManifestPlugin(cfg: RigelConfig, publicDir?: URL, root?: UR
22
22
  ...(cfg.menus ? { menus: { folder: cfg.menus.folder, maxDepth: cfg.menus.maxDepth! } } : {}),
23
23
  } : undefined;
24
24
 
25
- const documentsSrc = documentTypes.length
25
+ const slugHelpers = `import { assertUniqueUrlSlugs as __rigelAssertUniqueUrlSlugs, validateDocumentSlugs as __rigelValidateDocumentSlugs } from ${JSON.stringify(fileURLToPath(new URL("../slugs.ts", import.meta.url)))};`;
26
+ const documentsSrc = slugHelpers + (documentTypes.length
26
27
  ? `
27
28
  ${documentTypes.map((type, index) => `const __rigelDoc${index} = import.meta.glob(${JSON.stringify(`/${type.folder}/*.json`)}, { eager: true, import: "default" });`).join("\n")}
28
29
  export const documentTypes = ${JSON.stringify(documentTypes.map((type) => ({ name: type.name, shared: Boolean(type.shared) })))};
@@ -33,9 +34,10 @@ export const documents = Object.entries(__rigelRawDocuments)
33
34
  return { id: type + ":" + slug, type, slug, data: { ...data, _id: type + ":" + slug, _type: type } };
34
35
  }))
35
36
  .sort((a, b) => a.id.localeCompare(b.id));
37
+ __rigelValidateDocumentSlugs(${JSON.stringify(cfg.schema)}, documents, ${JSON.stringify(cfg.preview)});
36
38
  const __rigelReservedSlugs = new Set(documents.filter((doc) => !documentTypes.find((type) => type.name === doc.type).shared).map((doc) => doc.slug));
37
39
  `
38
- : `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n`;
40
+ : `export const documentTypes = [];\nexport const documents = [];\nconst __rigelReservedSlugs = new Set();\n`);
39
41
 
40
42
  const collectionCode = collections
41
43
  .map(
@@ -137,6 +139,7 @@ export const menus = {
137
139
  menus: cfg.menus,
138
140
  collections: cfg.collections ?? [],
139
141
  schema: cfg.schema,
142
+ preview: cfg.preview,
140
143
  })};`;
141
144
  }
142
145
  if (id === "\0" + CONTENT_ID) {
@@ -174,6 +177,7 @@ export const pages = loaded.filter((page) => !__rigelReservedSlugs.has(page.slug
174
177
  export const collections = [
175
178
  ${collectionCode}
176
179
  ];
180
+ for (const collection of collections) __rigelAssertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
177
181
  ${menuCode}
178
182
  export default { pages, collections, documents${github ? ', backend: "github", github: ' + JSON.stringify(github) : ""}${cfg.menus ? ", menus" : ""}${cfg.schema ? ", schema: " + JSON.stringify(cfg.schema) : ""}${cfg.preview ? ", preview: " + JSON.stringify(cfg.preview) : ""}${cfg.sidebar ? ", sidebar: " + JSON.stringify(cfg.sidebar) : ""} };
179
183
  `;