@rigel-cms/rigel 0.1.3-beta.1 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
3
3
  import BlockEditor from "./BlockEditor.svelte";
4
+ import { tooltipRoot } from "./tooltip";
4
5
  import editorStyles from "../styles.css?inline";
5
6
  import type { ArticleBody } from "../../article-body";
6
7
  import { patchPreview } from "../../preview";
@@ -13,7 +14,7 @@
13
14
  import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
14
15
 
15
16
  let {
16
- docId, schema, docType, values, previewValues, documents = {}, previewPath, livePath = previewPath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
17
+ docId, schema, docType, values, previewValues, documents = {}, previewPath, websitePath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
17
18
  }: {
18
19
  docId: string;
19
20
  schema?: Schema;
@@ -22,7 +23,7 @@
22
23
  previewValues?: Record<string, unknown>;
23
24
  documents?: Record<string, Record<string, unknown>>;
24
25
  previewPath: string | null;
25
- livePath?: string | null;
26
+ websitePath?: string | null;
26
27
  body?: "" | import("../../article-body").ArticleBody;
27
28
  properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
28
29
  bodyInline?: boolean;
@@ -45,6 +46,7 @@
45
46
  let bodyContents: ChildNode[] = [];
46
47
  let bodyAttributes: [string, string | null][] = [];
47
48
  let bodyScope: HTMLElement | null = null;
49
+ let removeTooltips: (() => void) | undefined;
48
50
  let error = $state("");
49
51
  let mode = $state<"browse" | "edit">("edit");
50
52
  let panelOpen = $state(true);
@@ -186,6 +188,7 @@
186
188
 
187
189
  function stopBodyEdit() {
188
190
  if (!bodyEditor) return;
191
+ removeTooltips?.(); removeTooltips = undefined;
189
192
  void unmount(bodyEditor); bodyEditor = undefined;
190
193
  bodyThemeObserver?.disconnect(); bodyThemeObserver = undefined;
191
194
  editingBody?.replaceChildren(...bodyContents);
@@ -222,6 +225,7 @@
222
225
  syncTheme();
223
226
  const host = doc.createElement("div"); host.className = "rigel rigel-inline";
224
227
  scope.append(host); doc.body.append(scope); bodyScope = scope;
228
+ removeTooltips = tooltipRoot(host).destroy;
225
229
  bodyEditor = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
226
230
  bodyThemeObserver = new MutationObserver(syncTheme);
227
231
  bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
@@ -435,8 +439,8 @@
435
439
  </div>
436
440
  <span class="visual-hint">{mode === "edit" ? "Click a region to edit it. Scripts, forms and links stay disabled." : "Scripts, forms and links are disabled in this preview."}</span>
437
441
  <div class="visual-actions">
438
- {#if livePath}<a class="icon-btn visual-live" href={livePath} target="_blank" rel="noopener noreferrer" aria-label="View live page" title="View live page"><Icon name="external" size={18} /></a>{/if}
439
- <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" title="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
442
+ {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label="Preview on website" data-tooltip="Preview on website"><Icon name="external" size={18} /></a>{/if}
443
+ <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" data-tooltip="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
440
444
  <SidebarIcon name="square-half" />
441
445
  </button>
442
446
  </div>
@@ -1,5 +1,5 @@
1
1
  export interface GitHubAccess {
2
- user: { login: string; avatar_url: string; html_url: string };
2
+ user: { login: string; name?: string; avatar_url: string; html_url: string };
3
3
  status: "ready" | "installation-required" | "read-only";
4
4
  installUrl: string;
5
5
  installation: { id: number; account: string; html_url: string } | null;
@@ -63,7 +63,7 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
63
63
  });
64
64
  } catch { throw new Error("Unable to reach GitHub. Check your connection and try again."); }
65
65
  if (!response.ok) {
66
- if (response.status === 401) throw new Error("GitHub session expired or was revoked. Please sign in again.");
66
+ if (response.status === 401) throw Object.assign(new Error("GitHub session expired or was revoked. Please sign in again."), { status: 401 });
67
67
  if (response.status === 429 || (response.status === 403 && (response.headers.get("x-ratelimit-remaining") === "0" || response.headers.has("retry-after")))) throw new Error("GitHub rate limit reached. Wait a few minutes and try again.");
68
68
  if (response.status === 403) throw new Error("GitHub access denied. Check app permissions and organization approval, then sign in again.");
69
69
  if (response.status === 404) throw new Error("GitHub repository or installation is unavailable. Check the repository name and app installation, then try again.");
@@ -85,7 +85,8 @@ export async function checkGitHubAccess(token: string, repo: string, appSlug: st
85
85
  };
86
86
  const rawUser = await get("/user");
87
87
  if (!rawUser || ![rawUser.login, rawUser.avatar_url, rawUser.html_url].every(value => typeof value === "string" && value)) throw new Error("GitHub returned an invalid user. Please sign in again.");
88
- const user = { login: rawUser.login, avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
88
+ const name = typeof rawUser.name === "string" ? rawUser.name.trim() : "";
89
+ const user = { login: rawUser.login, ...(name ? { name } : {}), avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
89
90
  const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
90
91
  const result: GitHubAccess = { user, status: "installation-required", installUrl, installation: null, repository: null };
91
92
  type Installation = { id: number; app_slug: string; account?: { login?: string }; html_url?: string; permissions?: { contents?: string }; suspended_at?: string | null };
@@ -5,12 +5,13 @@ import { validateMenu } from "../../manifest/menu";
5
5
  import { validateCollectionValues } from "../../manifest/collections";
6
6
  import { documentIndex, isPlainObject, validateDocument, validateDocumentId, validateSchema } from "../../schema/validate";
7
7
  import { applyValues, readValues } from "../../routes/serialize";
8
+ import { assertUniqueUrlSlugs, normalizeUrlSlug, validateDocumentSlugs } from "../../slugs";
8
9
 
9
10
  export interface GitContext { token: string; repo: string; branch: string }
10
11
 
11
- type FileSnapshot = { sha: string; text: string };
12
- type PendingWrite = { sha: string; text: string | null; creating: boolean };
13
- type Snapshot = { target: string; files: Map<string, FileSnapshot>; paths: Set<string>; pending: Map<string, PendingWrite> };
12
+ type FileSnapshot = { sha: string; text: string; mode: string };
13
+ type PendingWrite = { sha: string; text: string | null; creating: boolean; head: string; mode: string };
14
+ type Snapshot = { target: string; head: string; writes: Promise<void>; files: Map<string, FileSnapshot>; paths: Set<string>; pending: Map<string, PendingWrite> };
14
15
  const REMOTE_SNAPSHOT = Symbol("rigel-remote-snapshot");
15
16
  const SHA = /^[a-f0-9]{40}$/;
16
17
  const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -71,11 +72,11 @@ function sha(value: unknown): string {
71
72
  return value;
72
73
  }
73
74
 
74
- async function draftHead(ctx: GitContext, manifest: Manifest): Promise<string> {
75
+ async function draftHead(ctx: GitContext, manifest: Manifest, create = true): Promise<string> {
75
76
  const cfg = config(ctx, manifest);
76
77
  const ref = `/git/ref/heads/${encodeURIComponent(ctx.branch)}`;
77
78
  try { return sha((await gh(ctx, ref))?.object?.sha); }
78
- catch (error) { if (!(error instanceof GitError) || error.status !== 404) throw error; }
79
+ catch (error) { if (!create || !(error instanceof GitError) || error.status !== 404) throw error; }
79
80
  const base = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(cfg.publishBranch)}`))?.object?.sha);
80
81
  try { await gh(ctx, "/git/refs", {method:"POST", body:JSON.stringify({ref:`refs/heads/${ctx.branch}`,sha:base})}); }
81
82
  catch (error) { if (!(error instanceof GitError) || ![409,422].includes(error.status)) throw error; }
@@ -105,13 +106,13 @@ function parseJson(text: string): unknown {
105
106
  try { return JSON.parse(text); } catch { throw new Error("Invalid remote content JSON"); }
106
107
  }
107
108
 
108
- export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest): Promise<Manifest> {
109
+ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest, createDraft = true): Promise<Manifest> {
109
110
  const cfg = config(ctx, blueprint);
110
- const head = await draftHead(ctx, blueprint);
111
+ const head = await draftHead(ctx, blueprint, createDraft);
111
112
  const tree = await gh(ctx, `/git/trees/${head}?recursive=1`);
112
113
  if (!tree || tree.truncated !== false || !Array.isArray(tree.tree)) throw new Error("Incomplete GitHub tree; content cannot be loaded safely");
113
114
  const manifest = structuredClone(blueprint);
114
- const snapshot: Snapshot = {target:target(ctx,manifest),files:new Map(),paths:new Set(),pending:new Map()};
115
+ const snapshot: Snapshot = {target:target(ctx,manifest),head,writes:Promise.resolve(),files:new Map(),paths:new Set(),pending:new Map()};
115
116
  const entries = new Map<string, any>();
116
117
  for (const entry of tree.tree) {
117
118
  if (!entry || typeof entry.path !== "string" || entry.path.split("/").some((part: string) => !part || part === "." || part === "..") || /[\\\u0000-\u001f]/.test(entry.path) || !["blob","tree","commit"].includes(entry.type) || !SHA.test(entry.sha) || !["100644","100755","120000","040000","160000"].includes(entry.mode)) throw new Error("Malformed GitHub tree entry");
@@ -132,7 +133,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
132
133
  const expected = sha(entry.sha);
133
134
  const blob = await gh(ctx, `/git/blobs/${expected}`);
134
135
  if (sha(blob?.sha) !== expected) throw new Error("GitHub blob SHA mismatch");
135
- const text = decode(blob); snapshot.files.set(path,{sha:expected,text}); return text;
136
+ const text = decode(blob); snapshot.files.set(path,{sha:expected,text,mode:entry.mode}); return text;
136
137
  };
137
138
  const list = (folder: string, pattern = SLUG) => {
138
139
  const prefix = [cfg.repoRoot,folder].filter(Boolean).join("/") + "/";
@@ -156,6 +157,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
156
157
  }
157
158
  documentIndex(manifest.documents);
158
159
  for (const doc of manifest.documents) validateDocument(manifest.schema!,doc.type,doc.slug,doc.data,manifest.documents);
160
+ if (manifest.schema) validateDocumentSlugs(manifest.schema, manifest.documents, manifest.preview);
159
161
  for (const collection of manifest.collections ?? []) {
160
162
  const folder = cfg.collections.find(c => c.name === collection.name)?.folder;
161
163
  if (!folder) throw new Error("Missing configured GitHub collection");
@@ -166,6 +168,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
166
168
  validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
167
169
  collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
168
170
  }
171
+ assertUniqueUrlSlugs(collection.posts.map(post => ({ slug: post.slug, values: post.frontmatter })));
169
172
  }
170
173
  if (cfg.menus) {
171
174
  manifest.menus = {maxDepth:cfg.menus.maxDepth,entries:[]};
@@ -188,8 +191,17 @@ function loaded(ctx: GitContext, manifest: Manifest): Snapshot {
188
191
 
189
192
  async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: string | null, creating: boolean) {
190
193
  const snapshot = loaded(ctx,manifest);
194
+ const head = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
195
+ const uncertain = snapshot.pending.get(path);
196
+ if (head !== snapshot.head && head !== uncertain?.head) throw new Error("GitHub draft branch conflict; reload before saving. Your unsaved edits are still here.");
191
197
  const endpoint = `/contents/${path.split("/").map(encodeURIComponent).join("/")}`;
192
198
  const verify = async (pending: PendingWrite) => {
199
+ const current = sha((await gh(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
200
+ if (current !== pending.head) {
201
+ if (current !== snapshot.head) throw new Error("GitHub draft branch conflict; reload before saving.");
202
+ const result = await gh(ctx, `/git/refs/heads/${encodeURIComponent(ctx.branch)}`, { method: "PATCH", body: JSON.stringify({ sha: pending.head, force: false }) });
203
+ if (sha(result?.object?.sha) !== pending.head) throw new Error("GitHub branch update could not be verified; retry or reload.");
204
+ }
193
205
  let verified;
194
206
  try { verified = await gh(ctx,`${endpoint}?ref=${encodeURIComponent(ctx.branch)}`); }
195
207
  catch (error) {
@@ -200,11 +212,11 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
200
212
  snapshot.files.delete(path); snapshot.paths.delete(path);
201
213
  } else {
202
214
  if (verified?.type !== "file" || sha(verified.sha) !== pending.sha || decode(verified) !== pending.text) throw new Error("GitHub save changed before verification; reload");
203
- snapshot.files.set(path,{sha:pending.sha,text:pending.text}); snapshot.paths.add(path);
215
+ snapshot.files.set(path,{sha:pending.sha,text:pending.text,mode:pending.mode}); snapshot.paths.add(path);
204
216
  }
217
+ snapshot.head = pending.head;
205
218
  snapshot.pending.delete(path);
206
219
  };
207
- const uncertain = snapshot.pending.get(path);
208
220
  if (uncertain) {
209
221
  await verify(uncertain);
210
222
  if (uncertain.text === text && (!creating || uncertain.creating)) return;
@@ -212,14 +224,27 @@ async function mutate(ctx: GitContext, manifest: Manifest, path: string, text: s
212
224
  }
213
225
  const previous = snapshot.files.get(path);
214
226
  if (creating ? snapshot.paths.has(path) : !previous) throw new Error(creating ? "Content already exists; choose another slug" : "Reload this content before changing it");
215
- const payload = {message:`rigel: ${text === null ? "delete" : "save"} ${path}`,branch:ctx.branch,...(!creating ? {sha:previous!.sha} : {}),...(text === null ? {} : {content:encode(text)})};
216
- const result = await gh(ctx,endpoint,{method:text === null ? "DELETE" : "PUT",body:JSON.stringify(payload)});
217
- const pending = {sha:text === null ? previous!.sha : sha(result?.content?.sha),text,creating};
227
+ const blob = text === null ? null : sha((await gh(ctx, "/git/blobs", { method: "POST", body: JSON.stringify({ content: encode(text), encoding: "base64" }) }))?.sha);
228
+ const mode = previous?.mode ?? "100644";
229
+ const base = sha((await gh(ctx, `/git/commits/${snapshot.head}`))?.tree?.sha);
230
+ const tree = sha((await gh(ctx, "/git/trees", { method: "POST", body: JSON.stringify({ base_tree: base, tree: [{ path, mode, type: "blob", sha: blob }] }) }))?.sha);
231
+ const commit = sha((await gh(ctx, "/git/commits", { method: "POST", body: JSON.stringify({ message: `rigel: ${text === null ? "delete" : "save"} ${path}`, tree, parents: [snapshot.head] }) }))?.sha);
232
+ const pending = {sha:blob ?? previous!.sha,text,creating,head:commit,mode};
218
233
  snapshot.pending.set(path,pending);
219
234
  await verify(pending);
220
235
  }
221
236
 
222
- export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
237
+ export async function saveRemoteSelection(...args: Parameters<typeof saveSelection>): Promise<void> {
238
+ const key = writeKey(args[0]);
239
+ if (publishing.has(key)) throw new Error("Wait for publishing to finish before saving.");
240
+ const snapshot = loaded(args[0], args[1]);
241
+ saving.set(key, (saving.get(key) ?? 0) + 1);
242
+ const result = snapshot.writes.then(() => saveSelection(...args));
243
+ snapshot.writes = result.catch(() => {});
244
+ try { await result; } finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
245
+ }
246
+
247
+ async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
223
248
  const cfg = config(ctx,manifest);
224
249
  const snapshot = loaded(ctx,manifest);
225
250
  if (!isPlainObject(values)) throw new Error("Content values must be an object");
@@ -238,15 +263,27 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
238
263
  if (!folder || !collection) throw new Error("Unknown collection");
239
264
  validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
240
265
  path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
241
- data = {frontmatter:{...values,slug:selection.slug},body};
266
+ const frontmatter = { ...values };
267
+ if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug);
268
+ const prefix = filePath(ctx,manifest,folder,"placeholder","json",POST_SLUG).replace(/placeholder\.json$/, "");
269
+ const others = [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ slug: file.slice(prefix.length, -5), values: (parseJson(value.text) as { frontmatter: Record<string, unknown> }).frontmatter }));
270
+ assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }]);
271
+ data = {frontmatter,body};
242
272
  } else if (selection.kind === "document") {
243
273
  if (!manifest.schema) throw new Error("No document schema configured");
244
274
  const document = validateDocumentId(manifest.schema,selection.id);
245
275
  if (!document || document.type.name !== selection.type) throw new Error("Unknown document type or slug");
246
276
  if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error("Creating entries is disabled for this document type");
247
- data = {...values,_id:selection.id,_type:selection.type,slug:document.slug};
277
+ const next = {...values,_id:selection.id,_type:selection.type} as Record<string, unknown>;
278
+ if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug);
279
+ data = next;
248
280
  validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? []);
249
281
  path = filePath(ctx,manifest,document.type.folder,document.slug);
282
+ const documents = manifest.schema.documents.flatMap(type => {
283
+ const prefix = filePath(ctx,manifest,type.folder,"placeholder").replace(/placeholder\.json$/, "");
284
+ return [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ id: `${type.name}:${file.slice(prefix.length,-5)}`, type: type.name, slug: file.slice(prefix.length,-5), data: parseJson(value.text) as Record<string, unknown> }));
285
+ });
286
+ validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview);
250
287
  } else if (selection.kind === "menu") {
251
288
  if (!cfg.menus || creating) throw new Error("Menus must be configured by the site developer");
252
289
  data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
@@ -255,9 +292,171 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
255
292
  await mutate(ctx,manifest,path,JSON.stringify(data,null,2) + "\n",creating);
256
293
  }
257
294
 
295
+ export interface PublishFile { filename: string; status: "added" | "modified" | "removed" | "renamed"; previous_filename?: string }
296
+ export interface PublishReview {
297
+ repo: string; branch: string; publishBranch: string; head: string; base: string;
298
+ files: PublishFile[]; needsSync: boolean; prNumber?: number; creationUncertain?: boolean;
299
+ }
300
+
301
+ async function publishHeads(ctx: GitContext, publishBranch: string, request: typeof gh) {
302
+ const head = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
303
+ const base = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(publishBranch)}`))?.object?.sha);
304
+ return { head, base };
305
+ }
306
+
307
+ async function publishTree(ctx: GitContext, head: string, request: typeof gh) {
308
+ const data = await request(ctx, `/git/trees/${head}?recursive=1`);
309
+ if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error("Incomplete GitHub tree. Publishing is unavailable; review this change on GitHub.");
310
+ const files = new Map<string, string>();
311
+ const paths = new Set<string>();
312
+ for (const entry of data.tree) {
313
+ if (!entry || typeof entry.path !== "string" || !entry.path || /[\u0000-\u001f\u007f\\\\]/.test(entry.path) || entry.path.split("/").some((p: string) => !p || p === "." || p === "..") || paths.has(entry.path) || !["blob", "tree", "commit"].includes(entry.type) || !["100644", "100755", "120000", "040000", "160000"].includes(entry.mode)) throw new Error("Malformed GitHub publish tree");
314
+ if (!({ blob: ["100644", "100755", "120000"], tree: ["040000"], commit: ["160000"] } as Record<string, string[]>)[entry.type]?.includes(entry.mode)) throw new Error("Malformed GitHub publish tree mode");
315
+ paths.add(entry.path);
316
+ const id = sha(entry.sha);
317
+ if (entry.type !== "tree") files.set(entry.path, `${entry.mode}:${entry.type}:${id}`);
318
+ }
319
+ return files;
320
+ }
321
+
322
+ export async function reviewRemotePublish(ctx: GitContext, manifest: Manifest, assertActive: () => void = () => {}): Promise<PublishReview> {
323
+ const cfg = config(ctx, manifest);
324
+ const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
325
+ const repository = await request(ctx, "");
326
+ if (repository?.permissions?.push !== true) throw new Error("Publishing requires repository write access.");
327
+ if (repository.allow_squash_merge !== true) throw new Error("Squash merging is disabled. Ask the repository owner to enable it before publishing.");
328
+ if (repository.delete_branch_on_merge !== false) throw new Error("Ask the repository owner to disable automatic head-branch deletion before publishing, so the drafts branch is preserved.");
329
+ const { head, base } = await publishHeads(ctx, cfg.publishBranch, request);
330
+ const [source, destination] = await Promise.all([publishTree(ctx, head, request), publishTree(ctx, base, request)]);
331
+ const changed = new Set([...source.keys(), ...destination.keys()].filter(path => source.get(path) !== destination.get(path)));
332
+ const review: PublishReview = { repo: ctx.repo, branch: ctx.branch, publishBranch: cfg.publishBranch, head, base, files: [], needsSync: false };
333
+ if (changed.size) {
334
+ const comparison = await request(ctx, `/compare/${base}...${head}?per_page=1&page=1`);
335
+ if (sha(comparison?.base_commit?.sha) !== base) throw new Error("GitHub comparison does not match the reviewed target.");
336
+ review.needsSync = sha(comparison?.merge_base_commit?.sha) !== base;
337
+ if (review.needsSync) {
338
+ review.files = [...changed].sort().map(filename => ({ filename, status: !source.has(filename) ? "removed" : !destination.has(filename) ? "added" : "modified" }));
339
+ } else {
340
+ if (!Array.isArray(comparison.files) || comparison.files.length >= 300) throw new Error("GitHub comparisons of 300 or more files are not supported. Publish this change on GitHub; no partial list is shown.");
341
+ const covered = new Set<string>();
342
+ for (const file of comparison.files) {
343
+ if (!file || typeof file.filename !== "string" || covered.has(file.filename) || !changed.has(file.filename) || !["added", "removed", "modified", "renamed"].includes(file.status)) throw new Error("Incomplete or unsupported GitHub file comparison. Publish this change on GitHub.");
344
+ covered.add(file.filename);
345
+ if (file.status === "renamed") {
346
+ if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error("Incomplete GitHub rename comparison");
347
+ covered.add(file.previous_filename);
348
+ }
349
+ review.files.push({ filename: file.filename, status: file.status, ...(file.status === "renamed" ? { previous_filename: file.previous_filename } : {}) });
350
+ }
351
+ if (covered.size !== changed.size) throw new Error("Incomplete GitHub file comparison. Publishing is unavailable; no partial list is shown.");
352
+ }
353
+ if (review.files.length >= 300) throw new Error("Publishing 300 or more files is not supported. Review and publish this change on GitHub.");
354
+ }
355
+ const current = await publishHeads(ctx, cfg.publishBranch, request);
356
+ if (current.head !== head || current.base !== base) throw new Error("Branches changed during review. Review changes again before publishing.");
357
+ assertActive();
358
+ return review;
359
+ }
360
+
361
+ const publishing = new Set<string>();
362
+ const saving = new Map<string, number>();
363
+ const writeKey = (ctx: GitContext) => `${ctx.repo}:${ctx.branch}`;
364
+
365
+ export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest, review: PublishReview, assertActive: () => void = () => {}): Promise<"published" | "synced"> {
366
+ const cfg = config(ctx, manifest), key = writeKey(ctx);
367
+ if (publishing.has(key) || saving.get(key)) throw new Error("Wait for the current save or publish to finish.");
368
+ if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error("Review saved changes before publishing.");
369
+ sha(review.head); sha(review.base);
370
+ publishing.add(key);
371
+ const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
372
+ const unchanged = async () => {
373
+ const current = await publishHeads(ctx, cfg.publishBranch, request);
374
+ if (current.head !== review.head || current.base !== review.base) throw new Error("Branches changed. Review changes again before publishing.");
375
+ };
376
+ const matches = (pr: any) => pr?.head?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr?.base?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr.head.ref === ctx.branch && pr.base.ref === cfg.publishBranch;
377
+ const validate = (pr: any) => {
378
+ if (!matches(pr) || !Number.isSafeInteger(pr.number) || pr.number <= 0 || pr.head.sha !== review.head) throw new Error("Pull request no longer matches this review. Review changes again.");
379
+ return pr;
380
+ };
381
+ const merged = (pr: any) => {
382
+ validate(pr);
383
+ if (pr.merged === true && pr.state === "closed") { sha(pr.merge_commit_sha); return true; }
384
+ return false;
385
+ };
386
+ const find = async () => {
387
+ const found: any[] = [];
388
+ for (let page = 1; page <= 100; page++) {
389
+ const query = new URLSearchParams({ state: "open", head: `${ctx.repo.split("/")[0]}:${ctx.branch}`, base: cfg.publishBranch, per_page: "100", page: String(page) });
390
+ const list = await request(ctx, `/pulls?${query}`);
391
+ if (!Array.isArray(list)) throw new Error("Incomplete GitHub pull request listing");
392
+ found.push(...list.filter(matches));
393
+ if (found.length > 1) throw new Error("Multiple matching pull requests. Resolve them on GitHub before publishing.");
394
+ if (list.length < 100) return found[0];
395
+ }
396
+ throw new Error("Incomplete GitHub pull request listing; publishing stopped.");
397
+ };
398
+ try {
399
+ if (review.prNumber) {
400
+ const previous = await request(ctx, `/pulls/${review.prNumber}`);
401
+ if (merged(previous)) return "published";
402
+ }
403
+ await unchanged();
404
+ const repository = await request(ctx, "");
405
+ if (repository?.permissions?.push !== true || repository.allow_squash_merge !== true || repository.delete_branch_on_merge !== false) throw new Error("Publishing settings or access changed. Enable squash merges, disable automatic branch deletion and verify write access, then review again.");
406
+ let pr = review.prNumber ? await request(ctx, `/pulls/${review.prNumber}`) : await find();
407
+ if (!pr) {
408
+ if (review.creationUncertain) throw new Error("The previous pull request creation is unconfirmed. Check GitHub before retrying; no duplicate was created.");
409
+ await unchanged();
410
+ review.creationUncertain = true;
411
+ try {
412
+ pr = await request(ctx, "/pulls", { method: "POST", body: JSON.stringify({ title: `Publish ${ctx.branch} to ${cfg.publishBranch}`, head: ctx.branch, base: cfg.publishBranch, draft: false, maintainer_can_modify: false }) });
413
+ } catch (error) {
414
+ pr = await find();
415
+ if (!pr) throw error;
416
+ }
417
+ }
418
+ validate(pr);
419
+ review.prNumber = pr.number;
420
+ pr = validate(await request(ctx, `/pulls/${pr.number}`));
421
+ if (merged(pr)) return "published";
422
+ if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new Error("Pull request or target changed. Review changes again before publishing.");
423
+ if (review.needsSync) {
424
+ await unchanged();
425
+ await request(ctx, `/pulls/${pr.number}/update-branch`, { method: "PUT", body: JSON.stringify({ expected_head_sha: review.head }) });
426
+ const updated = await request(ctx, `/pulls/${pr.number}`);
427
+ if (!matches(updated) || updated.state !== "open" || sha(updated.head.sha) === review.head) throw new Error("GitHub is still syncing drafts, or the sync could not be verified. Wait, reload draft content and review again. Nothing was published.");
428
+ return "synced";
429
+ }
430
+ if (pr.mergeable !== true || pr.mergeable_state !== "clean") throw new Error("GitHub has not allowed this merge. Resolve conflicts, required checks, reviews or branch protections on GitHub, then retry. No protection is bypassed.");
431
+ await unchanged();
432
+ let failure: unknown;
433
+ try {
434
+ const result = await request(ctx, `/pulls/${pr.number}/merge`, { method: "PUT", body: JSON.stringify({ merge_method: "squash", sha: review.head }) });
435
+ if (result?.merged !== true) failure = new Error("GitHub did not merge the pull request. Check required checks, reviews and branch protections, then retry.");
436
+ } catch (error) { failure = error; }
437
+ let verified;
438
+ try { verified = await request(ctx, `/pulls/${pr.number}`); }
439
+ catch (error) {
440
+ if (error instanceof GitError && error.status === 401) throw error;
441
+ throw new Error(`Publish outcome is unconfirmed. Check pull request #${pr.number} on GitHub, or retry to verify it before starting another publish.`);
442
+ }
443
+ if (merged(verified)) return "published";
444
+ if (failure) throw failure;
445
+ throw new Error("The publish result could not be verified. Check this pull request on GitHub before retrying.");
446
+ } catch (error) {
447
+ if (error instanceof GitError && [403, 404].includes(error.status)) throw new GitError("Publishing requires GitHub App Contents and Pull requests read/write permissions, installation-owner approval and repository write access. Reconnect after approval. Branch protections still apply.", error.status);
448
+ if (error instanceof GitError && [405, 409, 422].includes(error.status)) throw new GitError("GitHub rejected publishing. Check conflicts, required checks/reviews, branch protections and squash settings. Review changes again if either branch moved.", error.status);
449
+ throw error;
450
+ } finally { publishing.delete(key); }
451
+ }
452
+
258
453
  export async function deleteRemotePost(ctx: GitContext, manifest: Manifest, collection: string, slug: string): Promise<void> {
454
+ const key = writeKey(ctx);
455
+ if (publishing.has(key)) throw new Error("Wait for publishing to finish before deleting.");
259
456
  const cfg = config(ctx,manifest);
260
457
  const folder = cfg.collections.find(c => c.name === collection)?.folder;
261
458
  if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error("Unknown collection");
262
- await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false);
263
- }
459
+ saving.set(key, (saving.get(key) ?? 0) + 1);
460
+ try { await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false); }
461
+ finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
462
+ }
@@ -1,10 +1 @@
1
- export function slugify(input: string): string {
2
- return input
3
- .normalize("NFKD")
4
- .replace(/[̀-ͯ]/g, "") // combining marks → strip (é→e)
5
- .toLowerCase()
6
- .trim()
7
- .replace(/[^a-z0-9]+/g, "-")
8
- .replace(/^-+|-+$/g, "")
9
- .replace(/-{2,}/g, "-");
10
- }
1
+ export { slugify } from "../../slugs";
@@ -1,8 +1,36 @@
1
- export function readStorage(name: "settings" | "keys" | "mediaView"): string | null {
1
+ type SessionConfig = { authOrigin: string; repo: string; branch: string; appSlug: string };
2
+ type Session = { accessToken: string; expiresAt: number };
3
+
4
+ function sessionScope(config: SessionConfig) {
5
+ return JSON.stringify([config.authOrigin, config.repo, config.branch, config.appSlug]);
6
+ }
7
+
8
+ export function clearSession() {
9
+ try { localStorage.removeItem("rigelcms.session"); } catch {}
10
+ }
11
+
12
+ export function readSession(config: SessionConfig): Session | null {
13
+ try {
14
+ const saved = JSON.parse(localStorage.getItem("rigelcms.session") ?? "null");
15
+ if (saved?.version === 1 && saved.scope === sessionScope(config)
16
+ && typeof saved.accessToken === "string" && /^ghu_[a-zA-Z0-9_]{1,4092}$/.test(saved.accessToken)
17
+ && Number.isSafeInteger(saved.expiresAt) && saved.expiresAt > Date.now() + 30_000 && saved.expiresAt <= Date.now() + 86_400_000) {
18
+ return { accessToken: saved.accessToken, expiresAt: saved.expiresAt };
19
+ }
20
+ } catch {}
21
+ clearSession();
22
+ return null;
23
+ }
24
+
25
+ export function writeSession(config: SessionConfig, session: Session) {
26
+ try { writeStorage("session", { version: 1, scope: sessionScope(config), accessToken: session.accessToken, expiresAt: session.expiresAt }); } catch {}
27
+ }
28
+
29
+ export function readStorage(name: "settings" | "keys" | "mediaView" | "session"): string | null {
2
30
  return localStorage.getItem(`rigelcms.${name}`) ?? localStorage.getItem(`rigel.${name}`);
3
31
  }
4
32
 
5
- export function writeStorage(name: "settings" | "keys" | "mediaView", value: unknown) {
33
+ export function writeStorage(name: "settings" | "keys" | "mediaView" | "session", value: unknown) {
6
34
  localStorage.setItem(`rigelcms.${name}`, JSON.stringify(value));
7
35
  localStorage.removeItem(`rigel.${name}`);
8
36
  }
@@ -0,0 +1,120 @@
1
+ export function tooltipPosition(rect: Pick<DOMRect, "left" | "right" | "top" | "bottom">, width: number, height: number, viewportWidth: number, viewportHeight: number) {
2
+ return {
3
+ left: Math.max(8, Math.min((rect.left + rect.right - width) / 2, viewportWidth - width - 8)),
4
+ top: Math.max(8, Math.min(rect.top >= height + 14 ? rect.top - height - 6 : rect.bottom + 6, viewportHeight - height - 8)),
5
+ };
6
+ }
7
+
8
+ let provider: ReturnType<typeof createTooltips> | undefined;
9
+
10
+ export function tooltipRoot(root: HTMLElement) {
11
+ provider ??= createTooltips();
12
+ const remove = provider.register(root);
13
+ return { destroy: remove };
14
+ }
15
+
16
+ function createTooltips() {
17
+ let tip: HTMLDivElement | undefined;
18
+ let anchor: HTMLElement | null = null;
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ let opening: ReturnType<typeof setTimeout> | undefined;
21
+ let frame = 0;
22
+ let view: Window | null = null;
23
+ let pointerTime = 0;
24
+ let users = 0;
25
+ const id = "rigel-tooltip";
26
+
27
+ function hide() {
28
+ clearTimeout(timer);
29
+ clearTimeout(opening);
30
+ if (view) view.cancelAnimationFrame(frame);
31
+ if (anchor) {
32
+ const ids = (anchor.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(value => value && value !== id);
33
+ if (ids.length) anchor.setAttribute("aria-describedby", ids.join(" ")); else anchor.removeAttribute("aria-describedby");
34
+ }
35
+ anchor = null;
36
+ if (tip?.matches(":popover-open")) tip.hidePopover();
37
+ tip?.remove();
38
+ }
39
+
40
+ function update() {
41
+ if (!anchor || !tip || !view) return;
42
+ if (!anchor.isConnected || !anchor.checkVisibility() || !anchor.dataset.tooltip) { hide(); return; }
43
+ if (tip.textContent !== anchor.dataset.tooltip) tip.textContent = anchor.dataset.tooltip;
44
+ const position = tooltipPosition(anchor.getBoundingClientRect(), tip.offsetWidth, tip.offsetHeight, view.innerWidth, view.innerHeight);
45
+ tip.style.left = `${position.left}px`;
46
+ tip.style.top = `${position.top}px`;
47
+ frame = view.requestAnimationFrame(update);
48
+ }
49
+
50
+ function show(target: HTMLElement) {
51
+ hide();
52
+ if (!target.isConnected || !target.checkVisibility() || !target.dataset.tooltip) return;
53
+ const doc = target.ownerDocument;
54
+ tip ??= doc.createElement("div");
55
+ tip.id = id;
56
+ tip.className = "rigel-tooltip";
57
+ tip.setAttribute("role", "tooltip");
58
+ tip.popover = "manual";
59
+ anchor = target;
60
+ view = doc.defaultView;
61
+ const scope = target.closest("dialog, .rigel") ?? target.parentElement!;
62
+ scope.append(tip);
63
+ tip.textContent = target.dataset.tooltip;
64
+ target.setAttribute("aria-describedby", [...new Set([...(target.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean), id])].join(" "));
65
+ try { tip.showPopover(); update(); }
66
+ catch (error) {
67
+ if ((error as DOMException)?.name !== "InvalidStateError") throw error;
68
+ opening = setTimeout(() => { if (anchor !== target) return; if (!target.isConnected || !target.checkVisibility()) { hide(); return; } tip!.showPopover(); update(); }, 0);
69
+ }
70
+ }
71
+
72
+ function register(root: HTMLElement) {
73
+ users++;
74
+ const doc = root.ownerDocument;
75
+ const win = doc.defaultView!;
76
+ const target = (event: Event) => {
77
+ const element = (event.target as Element)?.closest?.<HTMLElement>("[data-tooltip]");
78
+ return element && root.contains(element) && element.closest(".rigel") ? element : null;
79
+ };
80
+ const over = (event: PointerEvent) => {
81
+ if (event.pointerType === "touch") return;
82
+ if (event.target === tip) { clearTimeout(timer); return; }
83
+ const element = target(event);
84
+ if (!element || element.contains(event.relatedTarget as Node | null) || element === anchor || element === doc.activeElement) return;
85
+ hide(); timer = setTimeout(() => show(element), 450);
86
+ };
87
+ const out = (event: PointerEvent) => {
88
+ const element = target(event);
89
+ if (element?.contains(event.relatedTarget as Node | null)) return;
90
+ if (event.relatedTarget === tip || anchor?.contains(event.relatedTarget as Node | null)) return;
91
+ if (anchor === doc.activeElement) return;
92
+ clearTimeout(timer); timer = setTimeout(hide, 150);
93
+ };
94
+ const focus = (event: FocusEvent) => {
95
+ const element = target(event);
96
+ if (element && Date.now() - pointerTime > 700) show(element);
97
+ };
98
+ const down = () => { pointerTime = Date.now(); hide(); };
99
+ const key = (event: KeyboardEvent) => {
100
+ pointerTime = 0;
101
+ if (event.key !== "Escape") return;
102
+ const visible = !!anchor;
103
+ hide();
104
+ if (visible) { event.preventDefault(); event.stopImmediatePropagation(); }
105
+ };
106
+ const listeners = [["pointerover", over], ["pointerout", out], ["focusin", focus], ["focusout", hide], ["pointerdown", down], ["click", hide], ["keydown", key]] as const;
107
+ for (const [name, listener] of listeners) root.addEventListener(name, listener as EventListener, true);
108
+ win.addEventListener("blur", hide);
109
+ win.addEventListener("resize", hide);
110
+ win.addEventListener("scroll", hide, true);
111
+ return () => {
112
+ hide();
113
+ for (const [name, listener] of listeners) root.removeEventListener(name, listener as EventListener, true);
114
+ win.removeEventListener("blur", hide); win.removeEventListener("resize", hide);
115
+ win.removeEventListener("scroll", hide, true);
116
+ if (!--users) { tip = undefined; provider = undefined; }
117
+ };
118
+ }
119
+ return { register };
120
+ }