@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 +1 -1
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +109 -19
- package/src/editor/lib/CollectionPanel.svelte +6 -2
- package/src/editor/lib/PreviewFrame.svelte +64 -22
- package/src/editor/lib/PublishDialog.svelte +65 -0
- package/src/editor/lib/SchemaPanel.svelte +6 -1
- package/src/editor/lib/github-backend.ts +218 -19
- package/src/editor/lib/slugify.ts +1 -15
- package/src/editor/styles.css +15 -1
- package/src/env.d.ts +1 -1
- package/src/preview.ts +7 -0
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-post.ts +16 -4
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +21 -2
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +6 -2
package/package.json
CHANGED
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";
|
package/src/editor/App.svelte
CHANGED
|
@@ -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 =
|
|
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
|
-
?
|
|
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).
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
42
|
-
{#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{/
|
|
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
|
|
216
|
-
const
|
|
217
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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";
|
package/src/editor/styles.css
CHANGED
|
@@ -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 .
|
|
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
|
|
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 });
|
package/src/routes/local-post.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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" });
|
package/src/schema/paths.ts
CHANGED
|
@@ -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(
|
|
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;
|
package/src/schema/store.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
`;
|