@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.
- package/package.json +1 -1
- package/src/content.ts +3 -1
- package/src/editor/App.svelte +191 -55
- package/src/editor/lib/BlockEditor.svelte +18 -18
- package/src/editor/lib/CollectionPanel.svelte +6 -2
- package/src/editor/lib/ImagePicker.svelte +7 -7
- package/src/editor/lib/MarkdownEditor.svelte +7 -7
- package/src/editor/lib/PreviewFrame.svelte +64 -22
- package/src/editor/lib/PublishDialog.svelte +65 -0
- package/src/editor/lib/SchemaField.svelte +4 -4
- package/src/editor/lib/SchemaPanel.svelte +10 -5
- package/src/editor/lib/Select.svelte +3 -2
- package/src/editor/lib/SeoGuidance.svelte +1 -1
- package/src/editor/lib/Settings.svelte +27 -2
- package/src/editor/lib/SitePreview.svelte +2 -2
- package/src/editor/lib/TopBar.svelte +1 -1
- package/src/editor/lib/VisualEditor.svelte +8 -4
- package/src/editor/lib/github-auth.ts +4 -3
- package/src/editor/lib/github-backend.ts +218 -19
- package/src/editor/lib/slugify.ts +1 -10
- package/src/editor/lib/storage.ts +30 -2
- package/src/editor/lib/tooltip.ts +120 -0
- package/src/editor/styles.css +25 -1
- package/src/env.d.ts +1 -1
- package/src/preview.ts +9 -2
- package/src/routes/local-documents.ts +4 -4
- package/src/routes/local-post.ts +23 -5
- package/src/schema/paths.ts +3 -1
- package/src/schema/store.ts +21 -2
- package/src/slugs.ts +46 -0
- package/src/vite/manifest-plugin.ts +6 -2
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
439
|
-
<button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel"
|
|
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
|
|
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
|
|
216
|
-
const
|
|
217
|
-
const
|
|
227
|
+
const blob = text === null ? null : sha((await gh(ctx, "/git/blobs", { method: "POST", body: JSON.stringify({ content: encode(text), encoding: "base64" }) }))?.sha);
|
|
228
|
+
const mode = previous?.mode ?? "100644";
|
|
229
|
+
const base = sha((await gh(ctx, `/git/commits/${snapshot.head}`))?.tree?.sha);
|
|
230
|
+
const tree = sha((await gh(ctx, "/git/trees", { method: "POST", body: JSON.stringify({ base_tree: base, tree: [{ path, mode, type: "blob", sha: blob }] }) }))?.sha);
|
|
231
|
+
const commit = sha((await gh(ctx, "/git/commits", { method: "POST", body: JSON.stringify({ message: `rigel: ${text === null ? "delete" : "save"} ${path}`, tree, parents: [snapshot.head] }) }))?.sha);
|
|
232
|
+
const pending = {sha:blob ?? previous!.sha,text,creating,head:commit,mode};
|
|
218
233
|
snapshot.pending.set(path,pending);
|
|
219
234
|
await verify(pending);
|
|
220
235
|
}
|
|
221
236
|
|
|
222
|
-
export async function saveRemoteSelection(
|
|
237
|
+
export async function saveRemoteSelection(...args: Parameters<typeof saveSelection>): Promise<void> {
|
|
238
|
+
const key = writeKey(args[0]);
|
|
239
|
+
if (publishing.has(key)) throw new Error("Wait for publishing to finish before saving.");
|
|
240
|
+
const snapshot = loaded(args[0], args[1]);
|
|
241
|
+
saving.set(key, (saving.get(key) ?? 0) + 1);
|
|
242
|
+
const result = snapshot.writes.then(() => saveSelection(...args));
|
|
243
|
+
snapshot.writes = result.catch(() => {});
|
|
244
|
+
try { await result; } finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function saveSelection(ctx: GitContext, manifest: Manifest, selection: Selection, values: Record<string, unknown>, body: ArticleBody | "", creating: boolean): Promise<void> {
|
|
223
248
|
const cfg = config(ctx,manifest);
|
|
224
249
|
const snapshot = loaded(ctx,manifest);
|
|
225
250
|
if (!isPlainObject(values)) throw new Error("Content values must be an object");
|
|
@@ -238,15 +263,27 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
|
|
|
238
263
|
if (!folder || !collection) throw new Error("Unknown collection");
|
|
239
264
|
validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
|
|
240
265
|
path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
|
|
241
|
-
|
|
266
|
+
const frontmatter = { ...values };
|
|
267
|
+
if (Object.hasOwn(frontmatter, "slug")) frontmatter.slug = normalizeUrlSlug(frontmatter.slug);
|
|
268
|
+
const prefix = filePath(ctx,manifest,folder,"placeholder","json",POST_SLUG).replace(/placeholder\.json$/, "");
|
|
269
|
+
const others = [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ slug: file.slice(prefix.length, -5), values: (parseJson(value.text) as { frontmatter: Record<string, unknown> }).frontmatter }));
|
|
270
|
+
assertUniqueUrlSlugs([...others, { slug: selection.slug, values: frontmatter }]);
|
|
271
|
+
data = {frontmatter,body};
|
|
242
272
|
} else if (selection.kind === "document") {
|
|
243
273
|
if (!manifest.schema) throw new Error("No document schema configured");
|
|
244
274
|
const document = validateDocumentId(manifest.schema,selection.id);
|
|
245
275
|
if (!document || document.type.name !== selection.type) throw new Error("Unknown document type or slug");
|
|
246
276
|
if (creating && (document.type.creatable === false || document.type.singleton)) throw new Error("Creating entries is disabled for this document type");
|
|
247
|
-
|
|
277
|
+
const next = {...values,_id:selection.id,_type:selection.type} as Record<string, unknown>;
|
|
278
|
+
if (!document.type.shared && Object.hasOwn(next, "slug")) next.slug = normalizeUrlSlug(next.slug);
|
|
279
|
+
data = next;
|
|
248
280
|
validateDocument(manifest.schema,selection.type,document.slug,data as Record<string,unknown>,manifest.documents ?? []);
|
|
249
281
|
path = filePath(ctx,manifest,document.type.folder,document.slug);
|
|
282
|
+
const documents = manifest.schema.documents.flatMap(type => {
|
|
283
|
+
const prefix = filePath(ctx,manifest,type.folder,"placeholder").replace(/placeholder\.json$/, "");
|
|
284
|
+
return [...snapshot.files].filter(([file]) => file.startsWith(prefix) && file !== path && file.endsWith(".json")).map(([file, value]) => ({ id: `${type.name}:${file.slice(prefix.length,-5)}`, type: type.name, slug: file.slice(prefix.length,-5), data: parseJson(value.text) as Record<string, unknown> }));
|
|
285
|
+
});
|
|
286
|
+
validateDocumentSlugs(manifest.schema, [...documents, { id: selection.id, type: selection.type, slug: document.slug, data: next }], manifest.preview);
|
|
250
287
|
} else if (selection.kind === "menu") {
|
|
251
288
|
if (!cfg.menus || creating) throw new Error("Menus must be configured by the site developer");
|
|
252
289
|
data = validateMenu({...values,slug:selection.slug},cfg.menus.maxDepth);
|
|
@@ -255,9 +292,171 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
|
|
|
255
292
|
await mutate(ctx,manifest,path,JSON.stringify(data,null,2) + "\n",creating);
|
|
256
293
|
}
|
|
257
294
|
|
|
295
|
+
export interface PublishFile { filename: string; status: "added" | "modified" | "removed" | "renamed"; previous_filename?: string }
|
|
296
|
+
export interface PublishReview {
|
|
297
|
+
repo: string; branch: string; publishBranch: string; head: string; base: string;
|
|
298
|
+
files: PublishFile[]; needsSync: boolean; prNumber?: number; creationUncertain?: boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function publishHeads(ctx: GitContext, publishBranch: string, request: typeof gh) {
|
|
302
|
+
const head = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(ctx.branch)}`))?.object?.sha);
|
|
303
|
+
const base = sha((await request(ctx, `/git/ref/heads/${encodeURIComponent(publishBranch)}`))?.object?.sha);
|
|
304
|
+
return { head, base };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function publishTree(ctx: GitContext, head: string, request: typeof gh) {
|
|
308
|
+
const data = await request(ctx, `/git/trees/${head}?recursive=1`);
|
|
309
|
+
if (data?.truncated !== false || !Array.isArray(data.tree)) throw new Error("Incomplete GitHub tree. Publishing is unavailable; review this change on GitHub.");
|
|
310
|
+
const files = new Map<string, string>();
|
|
311
|
+
const paths = new Set<string>();
|
|
312
|
+
for (const entry of data.tree) {
|
|
313
|
+
if (!entry || typeof entry.path !== "string" || !entry.path || /[\u0000-\u001f\u007f\\\\]/.test(entry.path) || entry.path.split("/").some((p: string) => !p || p === "." || p === "..") || paths.has(entry.path) || !["blob", "tree", "commit"].includes(entry.type) || !["100644", "100755", "120000", "040000", "160000"].includes(entry.mode)) throw new Error("Malformed GitHub publish tree");
|
|
314
|
+
if (!({ blob: ["100644", "100755", "120000"], tree: ["040000"], commit: ["160000"] } as Record<string, string[]>)[entry.type]?.includes(entry.mode)) throw new Error("Malformed GitHub publish tree mode");
|
|
315
|
+
paths.add(entry.path);
|
|
316
|
+
const id = sha(entry.sha);
|
|
317
|
+
if (entry.type !== "tree") files.set(entry.path, `${entry.mode}:${entry.type}:${id}`);
|
|
318
|
+
}
|
|
319
|
+
return files;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function reviewRemotePublish(ctx: GitContext, manifest: Manifest, assertActive: () => void = () => {}): Promise<PublishReview> {
|
|
323
|
+
const cfg = config(ctx, manifest);
|
|
324
|
+
const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
|
|
325
|
+
const repository = await request(ctx, "");
|
|
326
|
+
if (repository?.permissions?.push !== true) throw new Error("Publishing requires repository write access.");
|
|
327
|
+
if (repository.allow_squash_merge !== true) throw new Error("Squash merging is disabled. Ask the repository owner to enable it before publishing.");
|
|
328
|
+
if (repository.delete_branch_on_merge !== false) throw new Error("Ask the repository owner to disable automatic head-branch deletion before publishing, so the drafts branch is preserved.");
|
|
329
|
+
const { head, base } = await publishHeads(ctx, cfg.publishBranch, request);
|
|
330
|
+
const [source, destination] = await Promise.all([publishTree(ctx, head, request), publishTree(ctx, base, request)]);
|
|
331
|
+
const changed = new Set([...source.keys(), ...destination.keys()].filter(path => source.get(path) !== destination.get(path)));
|
|
332
|
+
const review: PublishReview = { repo: ctx.repo, branch: ctx.branch, publishBranch: cfg.publishBranch, head, base, files: [], needsSync: false };
|
|
333
|
+
if (changed.size) {
|
|
334
|
+
const comparison = await request(ctx, `/compare/${base}...${head}?per_page=1&page=1`);
|
|
335
|
+
if (sha(comparison?.base_commit?.sha) !== base) throw new Error("GitHub comparison does not match the reviewed target.");
|
|
336
|
+
review.needsSync = sha(comparison?.merge_base_commit?.sha) !== base;
|
|
337
|
+
if (review.needsSync) {
|
|
338
|
+
review.files = [...changed].sort().map(filename => ({ filename, status: !source.has(filename) ? "removed" : !destination.has(filename) ? "added" : "modified" }));
|
|
339
|
+
} else {
|
|
340
|
+
if (!Array.isArray(comparison.files) || comparison.files.length >= 300) throw new Error("GitHub comparisons of 300 or more files are not supported. Publish this change on GitHub; no partial list is shown.");
|
|
341
|
+
const covered = new Set<string>();
|
|
342
|
+
for (const file of comparison.files) {
|
|
343
|
+
if (!file || typeof file.filename !== "string" || covered.has(file.filename) || !changed.has(file.filename) || !["added", "removed", "modified", "renamed"].includes(file.status)) throw new Error("Incomplete or unsupported GitHub file comparison. Publish this change on GitHub.");
|
|
344
|
+
covered.add(file.filename);
|
|
345
|
+
if (file.status === "renamed") {
|
|
346
|
+
if (typeof file.previous_filename !== "string" || !changed.has(file.previous_filename) || covered.has(file.previous_filename)) throw new Error("Incomplete GitHub rename comparison");
|
|
347
|
+
covered.add(file.previous_filename);
|
|
348
|
+
}
|
|
349
|
+
review.files.push({ filename: file.filename, status: file.status, ...(file.status === "renamed" ? { previous_filename: file.previous_filename } : {}) });
|
|
350
|
+
}
|
|
351
|
+
if (covered.size !== changed.size) throw new Error("Incomplete GitHub file comparison. Publishing is unavailable; no partial list is shown.");
|
|
352
|
+
}
|
|
353
|
+
if (review.files.length >= 300) throw new Error("Publishing 300 or more files is not supported. Review and publish this change on GitHub.");
|
|
354
|
+
}
|
|
355
|
+
const current = await publishHeads(ctx, cfg.publishBranch, request);
|
|
356
|
+
if (current.head !== head || current.base !== base) throw new Error("Branches changed during review. Review changes again before publishing.");
|
|
357
|
+
assertActive();
|
|
358
|
+
return review;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const publishing = new Set<string>();
|
|
362
|
+
const saving = new Map<string, number>();
|
|
363
|
+
const writeKey = (ctx: GitContext) => `${ctx.repo}:${ctx.branch}`;
|
|
364
|
+
|
|
365
|
+
export async function confirmRemotePublish(ctx: GitContext, manifest: Manifest, review: PublishReview, assertActive: () => void = () => {}): Promise<"published" | "synced"> {
|
|
366
|
+
const cfg = config(ctx, manifest), key = writeKey(ctx);
|
|
367
|
+
if (publishing.has(key) || saving.get(key)) throw new Error("Wait for the current save or publish to finish.");
|
|
368
|
+
if (review.repo !== ctx.repo || review.branch !== ctx.branch || review.publishBranch !== cfg.publishBranch || !review.files.length) throw new Error("Review saved changes before publishing.");
|
|
369
|
+
sha(review.head); sha(review.base);
|
|
370
|
+
publishing.add(key);
|
|
371
|
+
const request: typeof gh = (context, path, init) => { assertActive(); return gh(context, path, init); };
|
|
372
|
+
const unchanged = async () => {
|
|
373
|
+
const current = await publishHeads(ctx, cfg.publishBranch, request);
|
|
374
|
+
if (current.head !== review.head || current.base !== review.base) throw new Error("Branches changed. Review changes again before publishing.");
|
|
375
|
+
};
|
|
376
|
+
const matches = (pr: any) => pr?.head?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr?.base?.repo?.full_name?.toLowerCase() === ctx.repo.toLowerCase() && pr.head.ref === ctx.branch && pr.base.ref === cfg.publishBranch;
|
|
377
|
+
const validate = (pr: any) => {
|
|
378
|
+
if (!matches(pr) || !Number.isSafeInteger(pr.number) || pr.number <= 0 || pr.head.sha !== review.head) throw new Error("Pull request no longer matches this review. Review changes again.");
|
|
379
|
+
return pr;
|
|
380
|
+
};
|
|
381
|
+
const merged = (pr: any) => {
|
|
382
|
+
validate(pr);
|
|
383
|
+
if (pr.merged === true && pr.state === "closed") { sha(pr.merge_commit_sha); return true; }
|
|
384
|
+
return false;
|
|
385
|
+
};
|
|
386
|
+
const find = async () => {
|
|
387
|
+
const found: any[] = [];
|
|
388
|
+
for (let page = 1; page <= 100; page++) {
|
|
389
|
+
const query = new URLSearchParams({ state: "open", head: `${ctx.repo.split("/")[0]}:${ctx.branch}`, base: cfg.publishBranch, per_page: "100", page: String(page) });
|
|
390
|
+
const list = await request(ctx, `/pulls?${query}`);
|
|
391
|
+
if (!Array.isArray(list)) throw new Error("Incomplete GitHub pull request listing");
|
|
392
|
+
found.push(...list.filter(matches));
|
|
393
|
+
if (found.length > 1) throw new Error("Multiple matching pull requests. Resolve them on GitHub before publishing.");
|
|
394
|
+
if (list.length < 100) return found[0];
|
|
395
|
+
}
|
|
396
|
+
throw new Error("Incomplete GitHub pull request listing; publishing stopped.");
|
|
397
|
+
};
|
|
398
|
+
try {
|
|
399
|
+
if (review.prNumber) {
|
|
400
|
+
const previous = await request(ctx, `/pulls/${review.prNumber}`);
|
|
401
|
+
if (merged(previous)) return "published";
|
|
402
|
+
}
|
|
403
|
+
await unchanged();
|
|
404
|
+
const repository = await request(ctx, "");
|
|
405
|
+
if (repository?.permissions?.push !== true || repository.allow_squash_merge !== true || repository.delete_branch_on_merge !== false) throw new Error("Publishing settings or access changed. Enable squash merges, disable automatic branch deletion and verify write access, then review again.");
|
|
406
|
+
let pr = review.prNumber ? await request(ctx, `/pulls/${review.prNumber}`) : await find();
|
|
407
|
+
if (!pr) {
|
|
408
|
+
if (review.creationUncertain) throw new Error("The previous pull request creation is unconfirmed. Check GitHub before retrying; no duplicate was created.");
|
|
409
|
+
await unchanged();
|
|
410
|
+
review.creationUncertain = true;
|
|
411
|
+
try {
|
|
412
|
+
pr = await request(ctx, "/pulls", { method: "POST", body: JSON.stringify({ title: `Publish ${ctx.branch} to ${cfg.publishBranch}`, head: ctx.branch, base: cfg.publishBranch, draft: false, maintainer_can_modify: false }) });
|
|
413
|
+
} catch (error) {
|
|
414
|
+
pr = await find();
|
|
415
|
+
if (!pr) throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
validate(pr);
|
|
419
|
+
review.prNumber = pr.number;
|
|
420
|
+
pr = validate(await request(ctx, `/pulls/${pr.number}`));
|
|
421
|
+
if (merged(pr)) return "published";
|
|
422
|
+
if (pr.state !== "open" || pr.draft || pr.base.sha !== review.base) throw new Error("Pull request or target changed. Review changes again before publishing.");
|
|
423
|
+
if (review.needsSync) {
|
|
424
|
+
await unchanged();
|
|
425
|
+
await request(ctx, `/pulls/${pr.number}/update-branch`, { method: "PUT", body: JSON.stringify({ expected_head_sha: review.head }) });
|
|
426
|
+
const updated = await request(ctx, `/pulls/${pr.number}`);
|
|
427
|
+
if (!matches(updated) || updated.state !== "open" || sha(updated.head.sha) === review.head) throw new Error("GitHub is still syncing drafts, or the sync could not be verified. Wait, reload draft content and review again. Nothing was published.");
|
|
428
|
+
return "synced";
|
|
429
|
+
}
|
|
430
|
+
if (pr.mergeable !== true || pr.mergeable_state !== "clean") throw new Error("GitHub has not allowed this merge. Resolve conflicts, required checks, reviews or branch protections on GitHub, then retry. No protection is bypassed.");
|
|
431
|
+
await unchanged();
|
|
432
|
+
let failure: unknown;
|
|
433
|
+
try {
|
|
434
|
+
const result = await request(ctx, `/pulls/${pr.number}/merge`, { method: "PUT", body: JSON.stringify({ merge_method: "squash", sha: review.head }) });
|
|
435
|
+
if (result?.merged !== true) failure = new Error("GitHub did not merge the pull request. Check required checks, reviews and branch protections, then retry.");
|
|
436
|
+
} catch (error) { failure = error; }
|
|
437
|
+
let verified;
|
|
438
|
+
try { verified = await request(ctx, `/pulls/${pr.number}`); }
|
|
439
|
+
catch (error) {
|
|
440
|
+
if (error instanceof GitError && error.status === 401) throw error;
|
|
441
|
+
throw new Error(`Publish outcome is unconfirmed. Check pull request #${pr.number} on GitHub, or retry to verify it before starting another publish.`);
|
|
442
|
+
}
|
|
443
|
+
if (merged(verified)) return "published";
|
|
444
|
+
if (failure) throw failure;
|
|
445
|
+
throw new Error("The publish result could not be verified. Check this pull request on GitHub before retrying.");
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if (error instanceof GitError && [403, 404].includes(error.status)) throw new GitError("Publishing requires GitHub App Contents and Pull requests read/write permissions, installation-owner approval and repository write access. Reconnect after approval. Branch protections still apply.", error.status);
|
|
448
|
+
if (error instanceof GitError && [405, 409, 422].includes(error.status)) throw new GitError("GitHub rejected publishing. Check conflicts, required checks/reviews, branch protections and squash settings. Review changes again if either branch moved.", error.status);
|
|
449
|
+
throw error;
|
|
450
|
+
} finally { publishing.delete(key); }
|
|
451
|
+
}
|
|
452
|
+
|
|
258
453
|
export async function deleteRemotePost(ctx: GitContext, manifest: Manifest, collection: string, slug: string): Promise<void> {
|
|
454
|
+
const key = writeKey(ctx);
|
|
455
|
+
if (publishing.has(key)) throw new Error("Wait for publishing to finish before deleting.");
|
|
259
456
|
const cfg = config(ctx,manifest);
|
|
260
457
|
const folder = cfg.collections.find(c => c.name === collection)?.folder;
|
|
261
458
|
if (!folder || !manifest.collections?.some(c => c.name === collection)) throw new Error("Unknown collection");
|
|
262
|
-
|
|
263
|
-
}
|
|
459
|
+
saving.set(key, (saving.get(key) ?? 0) + 1);
|
|
460
|
+
try { await mutate(ctx,manifest,filePath(ctx,manifest,folder,slug,"json",POST_SLUG),null,false); }
|
|
461
|
+
finally { saving.set(key, (saving.get(key) ?? 1) - 1); }
|
|
462
|
+
}
|
|
@@ -1,10 +1 @@
|
|
|
1
|
-
export
|
|
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
|
-
|
|
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
|
+
}
|