@rigel-cms/rigel 0.1.4-beta.1 → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/package.json +3 -4
- package/src/article-body.ts +9 -2
- package/src/article-render.ts +27 -1
- package/src/config.ts +8 -0
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +114 -25
- package/src/editor/blocks/embed.ts +21 -0
- package/src/editor/blocks/index.ts +3 -1
- package/src/editor/blocks/video.ts +35 -0
- package/src/editor/lib/ActionDialog.svelte +2 -1
- package/src/editor/lib/BlockEditor.svelte +84 -15
- package/src/editor/lib/CollectionPanel.svelte +9 -4
- package/src/editor/lib/ImagePicker.svelte +141 -47
- package/src/editor/lib/MediaPicker.svelte +73 -0
- 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/Select.svelte +5 -2
- package/src/editor/lib/VisualEditor.svelte +11 -6
- package/src/editor/lib/embed-preview.ts +57 -0
- package/src/editor/lib/embeds.ts +47 -0
- package/src/editor/lib/github-backend.ts +220 -20
- package/src/editor/lib/icons.ts +13 -0
- package/src/editor/lib/images.ts +20 -8
- package/src/editor/lib/media-library.ts +11 -1
- package/src/editor/lib/settings.ts +2 -2
- package/src/editor/lib/slugify.ts +1 -15
- package/src/editor/lib/tooltip.ts +23 -4
- package/src/editor/styles.css +44 -13
- package/src/env.d.ts +1 -1
- package/src/index.ts +4 -6
- package/src/json-output.ts +5 -0
- package/src/manifest/types.ts +1 -0
- package/src/media-types.ts +14 -1
- package/src/preview.ts +14 -3
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-media.ts +33 -28
- package/src/routes/local-menu.ts +2 -1
- package/src/routes/local-post.ts +18 -5
- package/src/routes/media-files.ts +36 -1
- package/src/routes/media-validation.ts +2 -2
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +24 -3
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +9 -2
|
@@ -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))} />
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { tick } from "svelte";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
4
|
import type { ICONS } from "./icons";
|
|
5
|
+
import type { TooltipSide, TooltipAlign } from "./tooltip";
|
|
5
6
|
|
|
6
|
-
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
|
+
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, "data-tooltip-position": tooltipPosition, "data-tooltip-align": tooltipAlign, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
8
|
label?: string;
|
|
8
9
|
ariaLabel?: string;
|
|
9
10
|
title?: string;
|
|
10
11
|
"data-tooltip"?: string;
|
|
12
|
+
"data-tooltip-position"?: TooltipSide;
|
|
13
|
+
"data-tooltip-align"?: TooltipAlign;
|
|
11
14
|
icon?: keyof typeof ICONS;
|
|
12
15
|
value?: string | string[];
|
|
13
16
|
options: { value: string; label: string; icon?: keyof typeof ICONS; disabled?: boolean }[];
|
|
@@ -121,7 +124,7 @@
|
|
|
121
124
|
|
|
122
125
|
<div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
|
|
123
126
|
{#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
|
|
124
|
-
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
127
|
+
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} data-tooltip-position={tooltipPosition} data-tooltip-align={tooltipAlign} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
125
128
|
{#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
|
|
126
129
|
</button>
|
|
127
130
|
<select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
previewPath: string | null;
|
|
26
26
|
websitePath?: string | null;
|
|
27
27
|
body?: "" | import("../../article-body").ArticleBody;
|
|
28
|
-
properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
|
|
28
|
+
properties?: Snippet<[SelectedField | null, number, boolean, () => void, boolean]>;
|
|
29
29
|
bodyInline?: boolean;
|
|
30
30
|
onbody?: (body: ArticleBody) => void;
|
|
31
31
|
creating: boolean;
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
let stage: HTMLDivElement;
|
|
40
40
|
let loaded = $state(false);
|
|
41
41
|
let bodyAvailable = $state(false);
|
|
42
|
-
|
|
42
|
+
let error = $state("");
|
|
43
|
+
const inlineBodyEnabled = $derived(bodyInline && !!previewPath && loaded && bodyAvailable && !error);
|
|
44
|
+
const inlineBodyLoading = $derived(bodyInline && !!previewPath && !loaded && !error);
|
|
43
45
|
let bodyEditor: ReturnType<typeof mount> | undefined;
|
|
44
46
|
let bodyThemeObserver: MutationObserver | undefined;
|
|
45
47
|
let editingBody: HTMLElement | null = null;
|
|
@@ -47,7 +49,6 @@
|
|
|
47
49
|
let bodyAttributes: [string, string | null][] = [];
|
|
48
50
|
let bodyScope: HTMLElement | null = null;
|
|
49
51
|
let removeTooltips: (() => void) | undefined;
|
|
50
|
-
let error = $state("");
|
|
51
52
|
let mode = $state<"browse" | "edit">("edit");
|
|
52
53
|
let panelOpen = $state(true);
|
|
53
54
|
let editorWidth = $state(0);
|
|
@@ -152,6 +153,7 @@
|
|
|
152
153
|
const doc = docElement();
|
|
153
154
|
if (!doc) return;
|
|
154
155
|
patchPreview(doc, previewValues ?? values, body, { documents, primary: docId, editingBody });
|
|
156
|
+
for (const button of doc.querySelectorAll<HTMLButtonElement>('[data-rigel-edit-video]')) button.hidden = !inlineBodyEnabled || mode !== "edit";
|
|
155
157
|
const signature = (Array.isArray(values.sections) ? (values.sections as Record<string, unknown>[]) : [])
|
|
156
158
|
.map((section) => `${section._key}:${section._type}`)
|
|
157
159
|
.join(",");
|
|
@@ -202,6 +204,7 @@
|
|
|
202
204
|
if (bodyEditor) { element.focus(); return; }
|
|
203
205
|
const doc = element.ownerDocument;
|
|
204
206
|
const blockIndex = event ? [...element.children].findIndex(child => child.contains(event.target as Node)) : 0;
|
|
207
|
+
const editVideo = !!(event?.target as Element | undefined)?.closest('[data-rigel-edit-video]');
|
|
205
208
|
const caret = event && doc.caretPositionFromPoint?.(event.clientX, event.clientY);
|
|
206
209
|
let offset = 0;
|
|
207
210
|
if (caret && element.children[blockIndex]?.contains(caret.offsetNode)) {
|
|
@@ -226,7 +229,8 @@
|
|
|
226
229
|
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
227
230
|
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
228
231
|
removeTooltips = tooltipRoot(host).destroy;
|
|
229
|
-
|
|
232
|
+
const mounted = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
|
|
233
|
+
bodyEditor = mounted;
|
|
230
234
|
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
231
235
|
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
232
236
|
await tick();
|
|
@@ -241,6 +245,7 @@
|
|
|
241
245
|
}
|
|
242
246
|
const selection = doc.defaultView?.getSelection(); selection?.removeAllRanges(); selection?.addRange(range);
|
|
243
247
|
}
|
|
248
|
+
if (editVideo) mounted.editVideoAt(blockIndex);
|
|
244
249
|
}
|
|
245
250
|
function focusBody() {
|
|
246
251
|
const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
@@ -452,7 +457,7 @@
|
|
|
452
457
|
{#if !previewPath}
|
|
453
458
|
<p class="visual-error" role="status">{creating ? "New page — Save writes the file. The route renders once the site has it." : "No preview URL is mapped to this document. Use the properties panel to edit."}</p>
|
|
454
459
|
{:else}
|
|
455
|
-
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
460
|
+
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; bodyAvailable = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
456
461
|
{/if}
|
|
457
462
|
{#if inline}
|
|
458
463
|
<input
|
|
@@ -475,7 +480,7 @@
|
|
|
475
480
|
<div class="properties-divider" role="separator" tabindex="0" aria-label="Properties panel width" aria-orientation="vertical" aria-valuemin="280" aria-valuemax={panelMax} aria-valuenow={panelWidth} aria-valuetext={`${Math.round(panelWidth)} pixels`} onpointerdown={startResize} onpointermove={moveResize} onpointerup={endResize} onpointercancel={endResize} onlostpointercapture={endResize} onkeydown={keyResize}></div>
|
|
476
481
|
{/if}
|
|
477
482
|
{#if panelOpen && properties}
|
|
478
|
-
{@render properties(selected, focusToken, inlineBodyEnabled, focusBody)}
|
|
483
|
+
{@render properties(selected, focusToken, inlineBodyEnabled || inlineBodyLoading, focusBody, inlineBodyLoading)}
|
|
479
484
|
{:else if panelOpen && schema && docType}
|
|
480
485
|
<SchemaPanel
|
|
481
486
|
{schema}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { embedProviderLabel, embedWatchUrl, safeEmbed } from "./embeds";
|
|
2
|
+
|
|
3
|
+
export function createVideoOverlay(doc: Document, label: string, url: string, editable = false): HTMLElement {
|
|
4
|
+
const overlay = doc.createElement("div");
|
|
5
|
+
overlay.contentEditable = "false";
|
|
6
|
+
overlay.style.cssText = "position:absolute;inset:0;display:grid;place-items:center;padding:1rem;background:#0006";
|
|
7
|
+
const card = doc.createElement("div");
|
|
8
|
+
card.style.cssText = "box-sizing:border-box;width:100%;max-width:360px;padding:1rem;background:#18181b;color:white;border:1px solid #52525b;font:14px/1.5 system-ui;text-align:left";
|
|
9
|
+
const heading = doc.createElement("strong");
|
|
10
|
+
heading.textContent = label;
|
|
11
|
+
const link = doc.createElement("div");
|
|
12
|
+
link.textContent = url;
|
|
13
|
+
link.style.cssText = "margin:0.5rem 0 1rem;overflow-wrap:anywhere;color:#e4e4e7";
|
|
14
|
+
const edit = doc.createElement("button");
|
|
15
|
+
edit.type = "button";
|
|
16
|
+
edit.dataset.rigelEditVideo = "";
|
|
17
|
+
edit.addEventListener("pointerdown", event => event.stopPropagation());
|
|
18
|
+
edit.addEventListener("keydown", event => {
|
|
19
|
+
event.stopPropagation();
|
|
20
|
+
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); edit.click(); }
|
|
21
|
+
});
|
|
22
|
+
edit.textContent = "Edit video";
|
|
23
|
+
edit.hidden = !editable;
|
|
24
|
+
edit.style.cssText = "min-height:44px;padding:0.5rem 0.75rem;border:1px solid #d4d4d8;background:white;color:#18181b;font:inherit;cursor:pointer";
|
|
25
|
+
card.append(heading, link, edit);
|
|
26
|
+
overlay.append(card);
|
|
27
|
+
return overlay;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createEmbedPreview(value: unknown, doc: Document, editable = false): HTMLElement {
|
|
31
|
+
const figure = doc.createElement("figure");
|
|
32
|
+
figure.className = "media-embed";
|
|
33
|
+
figure.style.cssText = "max-width:100%;min-width:0;margin-inline:0";
|
|
34
|
+
const ref = safeEmbed(value);
|
|
35
|
+
if (!ref) return figure;
|
|
36
|
+
const title = typeof (value as { title?: unknown }).title === "string" ? (value as { title: string }).title : "";
|
|
37
|
+
figure.dataset.rigelEmbed = ref.provider;
|
|
38
|
+
figure.dataset.rigelVideoId = ref.id;
|
|
39
|
+
const poster = doc.createElement("div");
|
|
40
|
+
poster.style.cssText = "position:relative;width:100%;min-width:0;aspect-ratio:16/9;min-height:220px;overflow:hidden;background:#18181b";
|
|
41
|
+
if (ref.provider === "youtube") {
|
|
42
|
+
const image = doc.createElement("img");
|
|
43
|
+
image.src = `https://i.ytimg.com/vi/${ref.id}/hqdefault.jpg`;
|
|
44
|
+
image.alt = "";
|
|
45
|
+
image.loading = "lazy";
|
|
46
|
+
image.referrerPolicy = "no-referrer";
|
|
47
|
+
image.style.cssText = "position:absolute;inset:0;width:100%;height:100%;object-fit:cover;margin:0";
|
|
48
|
+
image.onerror = () => { image.hidden = true; };
|
|
49
|
+
poster.append(image);
|
|
50
|
+
}
|
|
51
|
+
const caption = doc.createElement("figcaption");
|
|
52
|
+
caption.textContent = `${embedProviderLabel(ref)} · ${title || "Video"}`;
|
|
53
|
+
caption.style.cssText = "margin-top:0.5rem;font-size:0.875rem;line-height:1.5";
|
|
54
|
+
poster.append(createVideoOverlay(doc, `${embedProviderLabel(ref)} Video`, embedWatchUrl(ref), editable));
|
|
55
|
+
figure.append(poster, caption);
|
|
56
|
+
return figure;
|
|
57
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type EmbedProvider = "youtube" | "vimeo";
|
|
2
|
+
export type EmbedRef = { provider: EmbedProvider; id: string };
|
|
3
|
+
|
|
4
|
+
const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
5
|
+
const VIMEO_ID = /^\d{6,12}$/;
|
|
6
|
+
const YOUTUBE_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "music.youtube.com", "youtube-nocookie.com", "www.youtube-nocookie.com"]);
|
|
7
|
+
const VIMEO_HOSTS = new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
|
|
8
|
+
|
|
9
|
+
export function parseEmbedUrl(value: string): EmbedRef | undefined {
|
|
10
|
+
const raw = value.trim();
|
|
11
|
+
if (!/^https?:\/\//i.test(raw) || /[\s\\\u0000-\u001f\u007f]/.test(raw)) return undefined;
|
|
12
|
+
let url: URL;
|
|
13
|
+
try { url = new URL(raw); } catch { return undefined; }
|
|
14
|
+
const host = url.hostname.toLowerCase();
|
|
15
|
+
if (YOUTUBE_HOSTS.has(host)) {
|
|
16
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
17
|
+
const candidate = host === "youtu.be" ? parts[0] : url.searchParams.get("v") ?? (["embed", "shorts", "live", "v"].includes(parts[0] ?? "") ? parts[1] : undefined);
|
|
18
|
+
return candidate && YOUTUBE_ID.test(candidate) ? { provider: "youtube", id: candidate } : undefined;
|
|
19
|
+
}
|
|
20
|
+
if (VIMEO_HOSTS.has(host)) {
|
|
21
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
22
|
+
const candidate = parts[0] === "video" ? parts[1] : /^\d{6,12}$/.test(parts.at(-1) ?? "") ? parts.at(-1) : undefined;
|
|
23
|
+
return candidate && VIMEO_ID.test(candidate) ? { provider: "vimeo", id: candidate } : undefined;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function embedSrc(ref: EmbedRef, privacy = true): string {
|
|
29
|
+
if (ref.provider === "youtube") return `https://www.${privacy ? "youtube-nocookie.com" : "youtube.com"}/embed/${ref.id}?rel=0`;
|
|
30
|
+
return `https://player.vimeo.com/video/${ref.id}${privacy ? "?dnt=1" : ""}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function embedWatchUrl(ref: EmbedRef): string {
|
|
34
|
+
return ref.provider === "youtube" ? `https://www.youtube.com/watch?v=${ref.id}` : `https://vimeo.com/${ref.id}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function embedProviderLabel(ref: EmbedRef): string {
|
|
38
|
+
return ref.provider === "youtube" ? "YouTube" : "Vimeo";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function safeEmbed(ref: unknown): EmbedRef | undefined {
|
|
42
|
+
if (!ref || typeof ref !== "object") return undefined;
|
|
43
|
+
const { provider, id } = ref as EmbedRef;
|
|
44
|
+
if (provider === "youtube" && typeof id === "string" && YOUTUBE_ID.test(id)) return { provider, id };
|
|
45
|
+
if (provider === "vimeo" && typeof id === "string" && VIMEO_ID.test(id)) return { provider, id };
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import type { Manifest } from "../../manifest/types";
|
|
2
|
+
import { serializeContentJson } from "../../json-output";
|
|
2
3
|
import type { Selection } from "../../manifest/navigation";
|
|
3
4
|
import { validateBody, type ArticleBody } from "../../article-body";
|
|
4
5
|
import { validateMenu } from "../../manifest/menu";
|
|
5
6
|
import { validateCollectionValues } from "../../manifest/collections";
|
|
6
7
|
import { documentIndex, isPlainObject, validateDocument, validateDocumentId, validateSchema } from "../../schema/validate";
|
|
7
8
|
import { applyValues, readValues } from "../../routes/serialize";
|
|
9
|
+
import { assertUniqueUrlSlugs, normalizeUrlSlug, validateDocumentSlugs } from "../../slugs";
|
|
8
10
|
|
|
9
11
|
export interface GitContext { token: string; repo: string; branch: string }
|
|
10
12
|
|
|
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> };
|
|
13
|
+
type FileSnapshot = { sha: string; text: string; mode: string };
|
|
14
|
+
type PendingWrite = { sha: string; text: string | null; creating: boolean; head: string; mode: string };
|
|
15
|
+
type Snapshot = { target: string; head: string; writes: Promise<void>; files: Map<string, FileSnapshot>; paths: Set<string>; pending: Map<string, PendingWrite> };
|
|
14
16
|
const REMOTE_SNAPSHOT = Symbol("rigel-remote-snapshot");
|
|
15
17
|
const SHA = /^[a-f0-9]{40}$/;
|
|
16
18
|
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -71,11 +73,11 @@ function sha(value: unknown): string {
|
|
|
71
73
|
return value;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
|
-
async function draftHead(ctx: GitContext, manifest: Manifest): Promise<string> {
|
|
76
|
+
async function draftHead(ctx: GitContext, manifest: Manifest, create = true): Promise<string> {
|
|
75
77
|
const cfg = config(ctx, manifest);
|
|
76
78
|
const ref = `/git/ref/heads/${encodeURIComponent(ctx.branch)}`;
|
|
77
79
|
try { return sha((await gh(ctx, ref))?.object?.sha); }
|
|
78
|
-
catch (error) { if (!(error instanceof GitError) || error.status !== 404) throw error; }
|
|
80
|
+
catch (error) { if (!create || !(error instanceof GitError) || error.status !== 404) throw error; }
|
|
79
81
|
const base = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(cfg.publishBranch)}`))?.object?.sha);
|
|
80
82
|
try { await gh(ctx, "/git/refs", {method:"POST", body:JSON.stringify({ref:`refs/heads/${ctx.branch}`,sha:base})}); }
|
|
81
83
|
catch (error) { if (!(error instanceof GitError) || ![409,422].includes(error.status)) throw error; }
|
|
@@ -105,13 +107,13 @@ function parseJson(text: string): unknown {
|
|
|
105
107
|
try { return JSON.parse(text); } catch { throw new Error("Invalid remote content JSON"); }
|
|
106
108
|
}
|
|
107
109
|
|
|
108
|
-
export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest): Promise<Manifest> {
|
|
110
|
+
export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, createDraft = true): Promise<Manifest> {
|
|
109
111
|
const cfg = config(ctx, blueprint);
|
|
110
|
-
const head = await draftHead(ctx, blueprint);
|
|
112
|
+
const head = await draftHead(ctx, blueprint, createDraft);
|
|
111
113
|
const tree = await gh(ctx, `/git/trees/${head}?recursive=1`);
|
|
112
114
|
if (!tree || tree.truncated !== false || !Array.isArray(tree.tree)) throw new Error("Incomplete GitHub tree; content cannot be loaded safely");
|
|
113
115
|
const manifest = structuredClone(blueprint);
|
|
114
|
-
const snapshot: Snapshot = {target:target(ctx,manifest),files:new Map(),paths:new Set(),pending:new Map()};
|
|
116
|
+
const snapshot: Snapshot = {target:target(ctx,manifest),head,writes:Promise.resolve(),files:new Map(),paths:new Set(),pending:new Map()};
|
|
115
117
|
const entries = new Map<string, any>();
|
|
116
118
|
for (const entry of tree.tree) {
|
|
117
119
|
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 +134,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
|
|
|
132
134
|
const expected = sha(entry.sha);
|
|
133
135
|
const blob = await gh(ctx, `/git/blobs/${expected}`);
|
|
134
136
|
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;
|
|
137
|
+
const text = decode(blob); snapshot.files.set(path,{sha:expected,text,mode:entry.mode}); return text;
|
|
136
138
|
};
|
|
137
139
|
const list = (folder: string, pattern = SLUG) => {
|
|
138
140
|
const prefix = [cfg.repoRoot,folder].filter(Boolean).join("/") + "/";
|
|
@@ -156,6 +158,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
|
|
|
156
158
|
}
|
|
157
159
|
documentIndex(manifest.documents);
|
|
158
160
|
for (const doc of manifest.documents) validateDocument(manifest.schema!,doc.type,doc.slug,doc.data,manifest.documents);
|
|
161
|
+
if (manifest.schema) validateDocumentSlugs(manifest.schema, manifest.documents, manifest.preview);
|
|
159
162
|
for (const collection of manifest.collections ?? []) {
|
|
160
163
|
const folder = cfg.collections.find(c => c.name === collection.name)?.folder;
|
|
161
164
|
if (!folder) throw new Error("Missing configured GitHub collection");
|
|
@@ -166,6 +169,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
|
|
|
166
169
|
validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
|
|
167
170
|
collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
|
|
168
171
|
}
|
|
172
|
+
assertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
|
|
169
173
|
}
|
|
170
174
|
if (cfg.menus) {
|
|
171
175
|
manifest.menus = {maxDepth:cfg.menus.maxDepth,entries:[]};
|
|
@@ -188,8 +192,17 @@ function loaded(ctx: GitContext, manifest: Manifest): Snapshot {
|
|
|
188
192
|
|
|
189
193
|
async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: string | null, creating: boolean) {
|
|
190
194
|
const snapshot = loaded(ctx,manifest);
|
|
195
|
+
const head = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
|
|
196
|
+
const uncertain = snapshot.pending.get(path);
|
|
197
|
+
if (head !== snapshot.head && head !== uncertain?.head) throw new Error("GitHub draft branch conflict; reload before saving. Your unsaved edits are still here.");
|
|
191
198
|
const endpoint = `/contents/${path.split("/").map(encodeURIComponent).join("/")}`;
|
|
192
199
|
const verify = async (pending: PendingWrite) => {
|
|
200
|
+
const current = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
|
|
201
|
+
if (current !== pending.head) {
|
|
202
|
+
if (current !== snapshot.head) throw new Error("GitHub draft branch conflict; reload before saving.");
|
|
203
|
+
const result = await gh(ctx, `/git/refs/heads/${encodeURIComponent(ctx.branch)}`, { method: "PATCH", body: JSON.stringify({ sha: pending.head, force: false }) });
|
|
204
|
+
if (sha(result?.object?.sha) !== pending.head) throw new Error("GitHub branch update could not be verified; retry or reload.");
|
|
205
|
+
}
|
|
193
206
|
let verified;
|
|
194
207
|
try { verified = await gh(ctx,`${endpoint}?ref=${encodeURIComponent(ctx.branch)}`); }
|
|
195
208
|
catch (error) {
|
|
@@ -200,11 +213,11 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
|
|
|
200
213
|
snapshot.files.delete(path); snapshot.paths.delete(path);
|
|
201
214
|
} else {
|
|
202
215
|
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);
|
|
216
|
+
snapshot.files.set(path,{sha:pending.sha,text:pending.text,mode:pending.mode}); snapshot.paths.add(path);
|
|
204
217
|
}
|
|
218
|
+
snapshot.head = pending.head;
|
|
205
219
|
snapshot.pending.delete(path);
|
|
206
220
|
};
|
|
207
|
-
const uncertain = snapshot.pending.get(path);
|
|
208
221
|
if (uncertain) {
|
|
209
222
|
await verify(uncertain);
|
|
210
223
|
if (uncertain.text === text && (!creating || uncertain.creating)) return;
|
|
@@ -212,14 +225,27 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
|
|
|
212
225
|
}
|
|
213
226
|
const previous = snapshot.files.get(path);
|
|
214
227
|
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
|
|
228
|
+
const blob = text === null ? null : sha((await gh(ctx, "/git/blobs", { method: "POST", body: JSON.stringify({ content: encode(text), encoding: "base64" }) }))?.sha);
|
|
229
|
+
const mode = previous?.mode ?? "100644";
|
|
230
|
+
const base = sha((await gh(ctx, `/git/commits/${snapshot.head}`))?.tree?.sha);
|
|
231
|
+
const tree = sha((await gh(ctx, "/git/trees", { method: "POST", body: JSON.stringify({ base_tree: base, tree: [{ path, mode, type: "blob", sha: blob }] }) }))?.sha);
|
|
232
|
+
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);
|
|
233
|
+
const pending = {sha:blob ?? previous!.sha,text,creating,head:commit,mode};
|
|
218
234
|
snapshot.pending.set(path,pending);
|
|
219
235
|
await verify(pending);
|
|
220
236
|
}
|
|
221
237
|
|
|
222
|
-
export async function saveRemoteSelection(
|
|
238
|
+
export async function saveRemoteSelection(...args: Parameters<typeof saveSelection>): Promise<void> {
|
|
239
|
+
const key = writeKey(args[0]);
|
|
240
|
+
if (publishing.has(key)) throw new Error("Wait for publishing to finish before saving.");
|
|
241
|
+
const snapshot = loaded(args[0], args[1]);
|
|
242
|
+
saving.set(key, (saving.get(key) ?? 0) + 1);
|
|
243
|
+
const result = snapshot.writes.then(() => saveSelection(...args));
|
|
244
|
+
snapshot.writes = result.catch(() => {});
|
|
245
|
+
try { await result; } finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
|
|
223
249
|
const cfg = config(ctx,manifest);
|
|
224
250
|
const snapshot = loaded(ctx,manifest);
|
|
225
251
|
if (!isPlainObject(values)) throw new Error("Content values must be an object");
|
|
@@ -238,26 +264,200 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
|
|
|
238
264
|
if (!folder || !collection) throw new Error("Unknown collection");
|
|
239
265
|
validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
|
|
240
266
|
path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
|
|
241
|
-
|
|
267
|
+
const frontmatter = { ...values };
|
|
268
|
+
if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug);
|
|
269
|
+
const prefix = filePath(ctx,manifest,folder,"placeholder","json",POST_SLUG).replace(/placeholder\.json$/, "");
|
|
270
|
+
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 }));
|
|
271
|
+
assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }]);
|
|
272
|
+
data = {frontmatter,body};
|
|
242
273
|
} else if (selection.kind === "document") {
|
|
243
274
|
if (!manifest.schema) throw new Error("No document schema configured");
|
|
244
275
|
const document = validateDocumentId(manifest.schema,selection.id);
|
|
245
276
|
if (!document || document.type.name !== selection.type) throw new Error("Unknown document type or slug");
|
|
246
277
|
if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error("Creating entries is disabled for this document type");
|
|
247
|
-
|
|
278
|
+
const next = {...values,_id:selection.id,_type:selection.type} as Record<string, unknown>;
|
|
279
|
+
if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug);
|
|
280
|
+
data = next;
|
|
248
281
|
validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? []);
|
|
249
282
|
path = filePath(ctx,manifest,document.type.folder,document.slug);
|
|
283
|
+
const documents = manifest.schema.documents.flatMap(type => {
|
|
284
|
+
const prefix = filePath(ctx,manifest,type.folder,"placeholder").replace(/placeholder\.json$/, "");
|
|
285
|
+
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> }));
|
|
286
|
+
});
|
|
287
|
+
validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview);
|
|
250
288
|
} else if (selection.kind === "menu") {
|
|
251
289
|
if (!cfg.menus || creating) throw new Error("Menus must be configured by the site developer");
|
|
252
290
|
data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
|
|
253
291
|
path = filePath(ctx,manifest,cfg.menus.folder,selection.slug);
|
|
254
292
|
} else throw new Error("Select editable content before saving");
|
|
255
|
-
await mutate(ctx,manifest,path,
|
|
293
|
+
await mutate(ctx,manifest,path,serializeContentJson(data,cfg.jsonOutput),creating);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface PublishFile { filename: string; status: "added" | "modified" | "removed" | "renamed"; previous_filename?: string }
|
|
297
|
+
export interface PublishReview {
|
|
298
|
+
repo: string; branch: string; publishBranch: string; head: string; base: string;
|
|
299
|
+
files: PublishFile[]; needsSync: boolean; prNumber?: number; creationUncertain?: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function publishHeads(ctx: GitContext, publishBranch: string, request: typeof gh) {
|
|
303
|
+
const head = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
|
|
304
|
+
const base = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(publishBranch)}`))?.object?.sha);
|
|
305
|
+
return { head, base };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function publishTree(ctx: GitContext, head: string, request: typeof gh) {
|
|
309
|
+
const data = await request(ctx, `/git/trees/${head}?recursive=1`);
|
|
310
|
+
if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error("Incomplete GitHub tree. Publishing is unavailable; review this change on GitHub.");
|
|
311
|
+
const files = new Map<string, string>();
|
|
312
|
+
const paths = new Set<string>();
|
|
313
|
+
for (const entry of data.tree) {
|
|
314
|
+
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");
|
|
315
|
+
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");
|
|
316
|
+
paths.add(entry.path);
|
|
317
|
+
const id = sha(entry.sha);
|
|
318
|
+
if (entry.type !== "tree") files.set(entry.path, `${entry.mode}:${entry.type}:${id}`);
|
|
319
|
+
}
|
|
320
|
+
return files;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function reviewRemotePublish(ctx: GitContext, manifest: Manifest, assertActive: () => void = () => {}): Promise<PublishReview> {
|
|
324
|
+
const cfg = config(ctx, manifest);
|
|
325
|
+
const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
|
|
326
|
+
const repository = await request(ctx, "");
|
|
327
|
+
if (repository?.permissions?.push !== true) throw new Error("Publishing requires repository write access.");
|
|
328
|
+
if (repository.allow_squash_merge !== true) throw new Error("Squash merging is disabled. Ask the repository owner to enable it before publishing.");
|
|
329
|
+
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.");
|
|
330
|
+
const { head, base } = await publishHeads(ctx, cfg.publishBranch, request);
|
|
331
|
+
const [source, destination] = await Promise.all([publishTree(ctx, head, request), publishTree(ctx, base, request)]);
|
|
332
|
+
const changed = new Set([...source.keys(), ...destination.keys()].filter(path => source.get(path) !== destination.get(path)));
|
|
333
|
+
const review: PublishReview = { repo: ctx.repo, branch: ctx.branch, publishBranch: cfg.publishBranch, head, base, files: [], needsSync: false };
|
|
334
|
+
if (changed.size) {
|
|
335
|
+
const comparison = await request(ctx, `/compare/${base}...${head}?per_page=1&page=1`);
|
|
336
|
+
if (sha(comparison?.base_commit?.sha) !== base) throw new Error("GitHub comparison does not match the reviewed target.");
|
|
337
|
+
review.needsSync = sha(comparison?.merge_base_commit?.sha) !== base;
|
|
338
|
+
if (review.needsSync) {
|
|
339
|
+
review.files = [...changed].sort().map(filename => ({ filename, status: !source.has(filename) ? "removed" : !destination.has(filename) ? "added" : "modified" }));
|
|
340
|
+
} else {
|
|
341
|
+
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.");
|
|
342
|
+
const covered = new Set<string>();
|
|
343
|
+
for (const file of comparison.files) {
|
|
344
|
+
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.");
|
|
345
|
+
covered.add(file.filename);
|
|
346
|
+
if (file.status === "renamed") {
|
|
347
|
+
if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error("Incomplete GitHub rename comparison");
|
|
348
|
+
covered.add(file.previous_filename);
|
|
349
|
+
}
|
|
350
|
+
review.files.push({ filename: file.filename, status: file.status, ...(file.status === "renamed" ? { previous_filename: file.previous_filename } : {}) });
|
|
351
|
+
}
|
|
352
|
+
if (covered.size !== changed.size) throw new Error("Incomplete GitHub file comparison. Publishing is unavailable; no partial list is shown.");
|
|
353
|
+
}
|
|
354
|
+
if (review.files.length >= 300) throw new Error("Publishing 300 or more files is not supported. Review and publish this change on GitHub.");
|
|
355
|
+
}
|
|
356
|
+
const current = await publishHeads(ctx, cfg.publishBranch, request);
|
|
357
|
+
if (current.head !== head || current.base !== base) throw new Error("Branches changed during review. Review changes again before publishing.");
|
|
358
|
+
assertActive();
|
|
359
|
+
return review;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const publishing = new Set<string>();
|
|
363
|
+
const saving = new Map<string, number>();
|
|
364
|
+
const writeKey = (ctx: GitContext) => `${ctx.repo}:${ctx.branch}`;
|
|
365
|
+
|
|
366
|
+
export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest, review: PublishReview, assertActive: () => void = () => {}): Promise<"published" | "synced"> {
|
|
367
|
+
const cfg = config(ctx, manifest), key = writeKey(ctx);
|
|
368
|
+
if (publishing.has(key) || saving.get(key)) throw new Error("Wait for the current save or publish to finish.");
|
|
369
|
+
if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error("Review saved changes before publishing.");
|
|
370
|
+
sha(review.head); sha(review.base);
|
|
371
|
+
publishing.add(key);
|
|
372
|
+
const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
|
|
373
|
+
const unchanged = async () => {
|
|
374
|
+
const current = await publishHeads(ctx, cfg.publishBranch, request);
|
|
375
|
+
if (current.head !== review.head || current.base !== review.base) throw new Error("Branches changed. Review changes again before publishing.");
|
|
376
|
+
};
|
|
377
|
+
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;
|
|
378
|
+
const validate = (pr: any) => {
|
|
379
|
+
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.");
|
|
380
|
+
return pr;
|
|
381
|
+
};
|
|
382
|
+
const merged = (pr: any) => {
|
|
383
|
+
validate(pr);
|
|
384
|
+
if (pr.merged === true && pr.state === "closed") { sha(pr.merge_commit_sha); return true; }
|
|
385
|
+
return false;
|
|
386
|
+
};
|
|
387
|
+
const find = async () => {
|
|
388
|
+
const found: any[] = [];
|
|
389
|
+
for (let page = 1; page <= 100; page++) {
|
|
390
|
+
const query = new URLSearchParams({ state: "open", head: `${ctx.repo.split("/")[0]}:${ctx.branch}`, base: cfg.publishBranch, per_page: "100", page: String(page) });
|
|
391
|
+
const list = await request(ctx, `/pulls?${query}`);
|
|
392
|
+
if (!Array.isArray(list)) throw new Error("Incomplete GitHub pull request listing");
|
|
393
|
+
found.push(...list.filter(matches));
|
|
394
|
+
if (found.length > 1) throw new Error("Multiple matching pull requests. Resolve them on GitHub before publishing.");
|
|
395
|
+
if (list.length < 100) return found[0];
|
|
396
|
+
}
|
|
397
|
+
throw new Error("Incomplete GitHub pull request listing; publishing stopped.");
|
|
398
|
+
};
|
|
399
|
+
try {
|
|
400
|
+
if (review.prNumber) {
|
|
401
|
+
const previous = await request(ctx, `/pulls/${review.prNumber}`);
|
|
402
|
+
if (merged(previous)) return "published";
|
|
403
|
+
}
|
|
404
|
+
await unchanged();
|
|
405
|
+
const repository = await request(ctx, "");
|
|
406
|
+
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.");
|
|
407
|
+
let pr = review.prNumber ? await request(ctx, `/pulls/${review.prNumber}`) : await find();
|
|
408
|
+
if (!pr) {
|
|
409
|
+
if (review.creationUncertain) throw new Error("The previous pull request creation is unconfirmed. Check GitHub before retrying; no duplicate was created.");
|
|
410
|
+
await unchanged();
|
|
411
|
+
review.creationUncertain = true;
|
|
412
|
+
try {
|
|
413
|
+
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 }) });
|
|
414
|
+
} catch (error) {
|
|
415
|
+
pr = await find();
|
|
416
|
+
if (!pr) throw error;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
validate(pr);
|
|
420
|
+
review.prNumber = pr.number;
|
|
421
|
+
pr = validate(await request(ctx, `/pulls/${pr.number}`));
|
|
422
|
+
if (merged(pr)) return "published";
|
|
423
|
+
if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new Error("Pull request or target changed. Review changes again before publishing.");
|
|
424
|
+
if (review.needsSync) {
|
|
425
|
+
await unchanged();
|
|
426
|
+
await request(ctx, `/pulls/${pr.number}/update-branch`, { method: "PUT", body: JSON.stringify({ expected_head_sha: review.head }) });
|
|
427
|
+
const updated = await request(ctx, `/pulls/${pr.number}`);
|
|
428
|
+
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.");
|
|
429
|
+
return "synced";
|
|
430
|
+
}
|
|
431
|
+
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.");
|
|
432
|
+
await unchanged();
|
|
433
|
+
let failure: unknown;
|
|
434
|
+
try {
|
|
435
|
+
const result = await request(ctx, `/pulls/${pr.number}/merge`, { method: "PUT", body: JSON.stringify({ merge_method: "squash", sha: review.head }) });
|
|
436
|
+
if (result?.merged !== true) failure = new Error("GitHub did not merge the pull request. Check required checks, reviews and branch protections, then retry.");
|
|
437
|
+
} catch (error) { failure = error; }
|
|
438
|
+
let verified;
|
|
439
|
+
try { verified = await request(ctx, `/pulls/${pr.number}`); }
|
|
440
|
+
catch (error) {
|
|
441
|
+
if (error instanceof GitError && error.status === 401) throw error;
|
|
442
|
+
throw new Error(`Publish outcome is unconfirmed. Check pull request #${pr.number} on GitHub, or retry to verify it before starting another publish.`);
|
|
443
|
+
}
|
|
444
|
+
if (merged(verified)) return "published";
|
|
445
|
+
if (failure) throw failure;
|
|
446
|
+
throw new Error("The publish result could not be verified. Check this pull request on GitHub before retrying.");
|
|
447
|
+
} catch (error) {
|
|
448
|
+
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);
|
|
449
|
+
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);
|
|
450
|
+
throw error;
|
|
451
|
+
} finally { publishing.delete(key); }
|
|
256
452
|
}
|
|
257
453
|
|
|
258
454
|
export async function deleteRemotePost(ctx: GitContext, manifest: Manifest, collection: string, slug: string): Promise<void> {
|
|
455
|
+
const key = writeKey(ctx);
|
|
456
|
+
if (publishing.has(key)) throw new Error("Wait for publishing to finish before deleting.");
|
|
259
457
|
const cfg = config(ctx,manifest);
|
|
260
458
|
const folder = cfg.collections.find(c => c.name === collection)?.folder;
|
|
261
459
|
if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error("Unknown collection");
|
|
262
|
-
|
|
263
|
-
}
|
|
460
|
+
saving.set(key, (saving.get(key) ?? 0) + 1);
|
|
461
|
+
try { await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false); }
|
|
462
|
+
finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
|
|
463
|
+
}
|