@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.
Files changed (46) hide show
  1. package/README.md +4 -2
  2. package/package.json +3 -4
  3. package/src/article-body.ts +9 -2
  4. package/src/article-render.ts +27 -1
  5. package/src/config.ts +8 -0
  6. package/src/content.ts +3 -1
  7. package/src/editor/App.svelte +114 -25
  8. package/src/editor/blocks/embed.ts +21 -0
  9. package/src/editor/blocks/index.ts +3 -1
  10. package/src/editor/blocks/video.ts +35 -0
  11. package/src/editor/lib/ActionDialog.svelte +2 -1
  12. package/src/editor/lib/BlockEditor.svelte +84 -15
  13. package/src/editor/lib/CollectionPanel.svelte +9 -4
  14. package/src/editor/lib/ImagePicker.svelte +141 -47
  15. package/src/editor/lib/MediaPicker.svelte +73 -0
  16. package/src/editor/lib/PreviewFrame.svelte +64 -22
  17. package/src/editor/lib/PublishDialog.svelte +65 -0
  18. package/src/editor/lib/SchemaPanel.svelte +6 -1
  19. package/src/editor/lib/Select.svelte +5 -2
  20. package/src/editor/lib/VisualEditor.svelte +11 -6
  21. package/src/editor/lib/embed-preview.ts +57 -0
  22. package/src/editor/lib/embeds.ts +47 -0
  23. package/src/editor/lib/github-backend.ts +220 -20
  24. package/src/editor/lib/icons.ts +13 -0
  25. package/src/editor/lib/images.ts +20 -8
  26. package/src/editor/lib/media-library.ts +11 -1
  27. package/src/editor/lib/settings.ts +2 -2
  28. package/src/editor/lib/slugify.ts +1 -15
  29. package/src/editor/lib/tooltip.ts +23 -4
  30. package/src/editor/styles.css +44 -13
  31. package/src/env.d.ts +1 -1
  32. package/src/index.ts +4 -6
  33. package/src/json-output.ts +5 -0
  34. package/src/manifest/types.ts +1 -0
  35. package/src/media-types.ts +14 -1
  36. package/src/preview.ts +14 -3
  37. package/src/routes/local-documents.ts +4 -4
  38. package/src/routes/local-media.ts +33 -28
  39. package/src/routes/local-menu.ts +2 -1
  40. package/src/routes/local-post.ts +18 -5
  41. package/src/routes/media-files.ts +36 -1
  42. package/src/routes/media-validation.ts +2 -2
  43. package/src/schema/paths.ts +3 -1
  44. package/src/schema/store.ts +24 -3
  45. package/src/slugs.ts +46 -0
  46. 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
- const inlineBodyEnabled = $derived(bodyInline && loaded && bodyAvailable);
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
- bodyEditor = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
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 payload = {message:`rigel: ${text === null ? "delete" : "save"} ${path}`,branch:ctx.branch,...(!creating ? {sha:previous!.sha} : {}),...(text === null ? {} : {content:encode(text)})};
216
- const result = await gh(ctx,endpoint,{method:text === null ? "DELETE" : "PUT",body:JSON.stringify(payload)});
217
- const pending = {sha:text === null ? previous!.sha : sha(result?.content?.sha),text,creating};
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(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
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
- data = {frontmatter:{...values,slug:selection.slug},body};
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
- data = {...values,_id:selection.id,_type:selection.type,slug:document.slug};
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,JSON.stringify(data,null,2) + "\n",creating);
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
- await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false);
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
+ }