@rigel-cms/rigel 0.0.0-stage → 0.1.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/LICENSE +203 -0
- package/README.md +8 -2
- package/package.json +78 -4
- package/src/article-body.ts +60 -0
- package/src/article-render.ts +87 -0
- package/src/config.ts +97 -0
- package/src/content.ts +5 -0
- package/src/editor/App.svelte +972 -0
- package/src/editor/assets/rigelcms-icon.png +0 -0
- package/src/editor/assets/sidebar/LICENSE +21 -0
- package/src/editor/assets/sidebar/book-open.svg +1 -0
- package/src/editor/assets/sidebar/calendar.svg +1 -0
- package/src/editor/assets/sidebar/chart-bar.svg +1 -0
- package/src/editor/assets/sidebar/file-text.svg +1 -0
- package/src/editor/assets/sidebar/files.svg +1 -0
- package/src/editor/assets/sidebar/folder.svg +1 -0
- package/src/editor/assets/sidebar/folders.svg +1 -0
- package/src/editor/assets/sidebar/gear.svg +1 -0
- package/src/editor/assets/sidebar/globe.svg +1 -0
- package/src/editor/assets/sidebar/heart.svg +1 -0
- package/src/editor/assets/sidebar/house.svg +1 -0
- package/src/editor/assets/sidebar/images.svg +1 -0
- package/src/editor/assets/sidebar/layout.svg +1 -0
- package/src/editor/assets/sidebar/list.svg +1 -0
- package/src/editor/assets/sidebar/magnifying-glass.svg +1 -0
- package/src/editor/assets/sidebar/megaphone.svg +1 -0
- package/src/editor/assets/sidebar/newspaper.svg +1 -0
- package/src/editor/assets/sidebar/pencil-simple.svg +1 -0
- package/src/editor/assets/sidebar/shopping-bag.svg +1 -0
- package/src/editor/assets/sidebar/sidebar-simple.svg +1 -0
- package/src/editor/assets/sidebar/square-half.svg +1 -0
- package/src/editor/assets/sidebar/squares-four.svg +1 -0
- package/src/editor/assets/sidebar/tag.svg +1 -0
- package/src/editor/assets/sidebar/user.svg +1 -0
- package/src/editor/assets/sidebar/users.svg +1 -0
- package/src/editor/blocks/bulleted-list.ts +2 -0
- package/src/editor/blocks/code.ts +1 -0
- package/src/editor/blocks/divider.ts +14 -0
- package/src/editor/blocks/h1.ts +2 -0
- package/src/editor/blocks/h2.ts +2 -0
- package/src/editor/blocks/h3.ts +2 -0
- package/src/editor/blocks/h4.ts +2 -0
- package/src/editor/blocks/h5.ts +2 -0
- package/src/editor/blocks/h6.ts +2 -0
- package/src/editor/blocks/image.ts +34 -0
- package/src/editor/blocks/index.ts +40 -0
- package/src/editor/blocks/numbered-list.ts +2 -0
- package/src/editor/blocks/paragraph.ts +1 -0
- package/src/editor/blocks/quote.ts +1 -0
- package/src/editor/blocks/table.ts +5 -0
- package/src/editor/lib/ActionDialog.svelte +51 -0
- package/src/editor/lib/BlockEditor.svelte +614 -0
- package/src/editor/lib/CollectionPanel.svelte +103 -0
- package/src/editor/lib/DatePicker.svelte +106 -0
- package/src/editor/lib/Field.svelte +80 -0
- package/src/editor/lib/Icon.svelte +34 -0
- package/src/editor/lib/ImagePicker.svelte +264 -0
- package/src/editor/lib/MarkdownEditor.svelte +206 -0
- package/src/editor/lib/MenuEditor.svelte +209 -0
- package/src/editor/lib/PropertiesTabs.svelte +15 -0
- package/src/editor/lib/ReferenceSelect.svelte +17 -0
- package/src/editor/lib/SchemaField.svelte +156 -0
- package/src/editor/lib/SchemaPanel.svelte +189 -0
- package/src/editor/lib/Select.svelte +138 -0
- package/src/editor/lib/SeoGuidance.svelte +21 -0
- package/src/editor/lib/Settings.svelte +183 -0
- package/src/editor/lib/Shortcuts.svelte +29 -0
- package/src/editor/lib/SidebarIcon.svelte +8 -0
- package/src/editor/lib/SitePreview.svelte +50 -0
- package/src/editor/lib/TopBar.svelte +165 -0
- package/src/editor/lib/UnsavedChanges.svelte +38 -0
- package/src/editor/lib/VisualEditor.svelte +494 -0
- package/src/editor/lib/calendar.ts +55 -0
- package/src/editor/lib/github-auth.ts +117 -0
- package/src/editor/lib/github-backend.ts +263 -0
- package/src/editor/lib/icons.ts +111 -0
- package/src/editor/lib/images.ts +195 -0
- package/src/editor/lib/markdown.ts +71 -0
- package/src/editor/lib/media-library.ts +39 -0
- package/src/editor/lib/menu-tree.ts +37 -0
- package/src/editor/lib/rich-markdown.ts +30 -0
- package/src/editor/lib/schema-edit.ts +110 -0
- package/src/editor/lib/settings.ts +103 -0
- package/src/editor/lib/slugify.ts +10 -0
- package/src/editor/lib/storage.ts +17 -0
- package/src/editor/lib/store.svelte.ts +33 -0
- package/src/editor/main.ts +9 -0
- package/src/editor/styles.css +1280 -0
- package/src/env.d.ts +22 -0
- package/src/index.ts +128 -0
- package/src/manifest/frontmatter.ts +56 -0
- package/src/manifest/loader.ts +14 -0
- package/src/manifest/menu.ts +46 -0
- package/src/manifest/navigation.ts +94 -0
- package/src/manifest/references.ts +39 -0
- package/src/manifest/seo.ts +15 -0
- package/src/manifest/types.ts +75 -0
- package/src/media-types.ts +21 -0
- package/src/preview-urls.ts +3 -0
- package/src/preview.ts +167 -0
- package/src/routes/local-api.ts +42 -0
- package/src/routes/local-documents.ts +83 -0
- package/src/routes/local-media.ts +124 -0
- package/src/routes/local-menu.ts +81 -0
- package/src/routes/local-post.ts +80 -0
- package/src/routes/manifest.json.ts +15 -0
- package/src/routes/media-files.ts +126 -0
- package/src/routes/media-validation.ts +37 -0
- package/src/routes/media.json.ts +12 -0
- package/src/routes/rigel-page.astro +25 -0
- package/src/routes/serialize.ts +139 -0
- package/src/schema/docs.ts +33 -0
- package/src/schema/paths.ts +136 -0
- package/src/schema/store.ts +141 -0
- package/src/schema/types.ts +81 -0
- package/src/schema/validate.ts +327 -0
- package/src/vite/manifest-plugin.ts +180 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { mount, unmount, onDestroy, tick, type Snippet } from "svelte";
|
|
3
|
+
import BlockEditor from "./BlockEditor.svelte";
|
|
4
|
+
import editorStyles from "../styles.css?inline";
|
|
5
|
+
import type { ArticleBody } from "../../article-body";
|
|
6
|
+
import { patchPreview } from "../../preview";
|
|
7
|
+
import { anchorValue } from "../../preview";
|
|
8
|
+
import SchemaPanel from "./SchemaPanel.svelte";
|
|
9
|
+
import SidebarIcon from "./SidebarIcon.svelte";
|
|
10
|
+
import Icon from "./Icon.svelte";
|
|
11
|
+
import { fieldForPath, replaceAt, type SelectedField } from "./schema-edit";
|
|
12
|
+
import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
docId, schema, docType, values, previewValues, documents = {}, previewPath, livePath = previewPath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
|
|
16
|
+
}: {
|
|
17
|
+
docId: string;
|
|
18
|
+
schema?: Schema;
|
|
19
|
+
docType?: DocumentDef;
|
|
20
|
+
values: Record<string, unknown>;
|
|
21
|
+
previewValues?: Record<string, unknown>;
|
|
22
|
+
documents?: Record<string, Record<string, unknown>>;
|
|
23
|
+
previewPath: string | null;
|
|
24
|
+
livePath?: string | null;
|
|
25
|
+
body?: "" | import("../../article-body").ArticleBody;
|
|
26
|
+
properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
|
|
27
|
+
bodyInline?: boolean;
|
|
28
|
+
onbody?: (body: ArticleBody) => void;
|
|
29
|
+
creating: boolean;
|
|
30
|
+
devMode: boolean;
|
|
31
|
+
reloadToken?: number;
|
|
32
|
+
onchange: (next: Record<string, unknown>) => void;
|
|
33
|
+
oneditdocument?: (id: string) => void;
|
|
34
|
+
} = $props();
|
|
35
|
+
|
|
36
|
+
let frame: HTMLIFrameElement | undefined = $state();
|
|
37
|
+
let stage: HTMLDivElement;
|
|
38
|
+
let loaded = $state(false);
|
|
39
|
+
let bodyAvailable = $state(false);
|
|
40
|
+
const inlineBodyEnabled = $derived(bodyInline && loaded && bodyAvailable);
|
|
41
|
+
let bodyEditor: ReturnType<typeof mount> | undefined;
|
|
42
|
+
let bodyThemeObserver: MutationObserver | undefined;
|
|
43
|
+
let editingBody: HTMLElement | null = null;
|
|
44
|
+
let bodyContents: ChildNode[] = [];
|
|
45
|
+
let bodyAttributes: [string, string | null][] = [];
|
|
46
|
+
let bodyScope: HTMLElement | null = null;
|
|
47
|
+
let error = $state("");
|
|
48
|
+
let mode = $state<"browse" | "edit">("edit");
|
|
49
|
+
let panelOpen = $state(true);
|
|
50
|
+
let editorWidth = $state(0);
|
|
51
|
+
let requestedWidth = $state(340);
|
|
52
|
+
let resizing = $state(false);
|
|
53
|
+
let resizePointer: { element: HTMLElement; id: number } | null = null;
|
|
54
|
+
const panelMax = $derived(Math.max(280, Math.min(640, editorWidth - 328)));
|
|
55
|
+
const panelWidth = $derived(Math.max(280, Math.min(panelMax, requestedWidth)));
|
|
56
|
+
let selected = $state<SelectedField | null>(null);
|
|
57
|
+
let pending = $state<string[]>([]);
|
|
58
|
+
let focusToken = $state(0);
|
|
59
|
+
let highlighted: HTMLElement | null = null;
|
|
60
|
+
let hovered: HTMLElement | null = null;
|
|
61
|
+
let structureSignature = "";
|
|
62
|
+
let inline = $state<{ path: string; value: string; original: string; left: number; top: number; width: number; height: number; fontSize: string } | null>(null);
|
|
63
|
+
let inlineInput: HTMLInputElement | undefined = $state();
|
|
64
|
+
const documentList = $derived(Object.entries(documents).map(([id, data]) => ({
|
|
65
|
+
id,
|
|
66
|
+
type: id.slice(0, id.indexOf(":")),
|
|
67
|
+
slug: id.slice(id.indexOf(":") + 1),
|
|
68
|
+
data,
|
|
69
|
+
})) as ContentDocument[]);
|
|
70
|
+
|
|
71
|
+
function docElement(): Document | null {
|
|
72
|
+
try {
|
|
73
|
+
return frame?.contentDocument ?? null;
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function clearHighlight() {
|
|
80
|
+
highlighted?.style.removeProperty("outline");
|
|
81
|
+
highlighted?.style.removeProperty("outline-offset");
|
|
82
|
+
highlighted = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function clearHover() {
|
|
86
|
+
if (hovered && hovered !== highlighted) hovered.style.removeProperty("outline");
|
|
87
|
+
hovered = null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function anchorSelector(path: string) {
|
|
91
|
+
const escaped = CSS.escape(path);
|
|
92
|
+
return `[data-rigel-text="${escaped}"],[data-rigel-richtext="${escaped}"],[data-rigel-image="${escaped}"],[data-rigel-alt="${escaped}"],[data-rigel-href="${escaped}"]`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function applySelection(scroll = false) {
|
|
96
|
+
const doc = docElement();
|
|
97
|
+
if (!doc || !selected) return;
|
|
98
|
+
clearHighlight();
|
|
99
|
+
let element: HTMLElement | null = null;
|
|
100
|
+
if (selected.path) {
|
|
101
|
+
element = [...doc.querySelectorAll<HTMLElement>(anchorSelector(selected.path))].find(element => (element.getAttribute("data-rigel-doc") ?? docId) === selected?.docId) ?? null;
|
|
102
|
+
}
|
|
103
|
+
if (!element && selected.sectionKey) {
|
|
104
|
+
element = doc.querySelector<HTMLElement>(`[data-rigel-section][data-rigel-key="${selected.sectionKey}"][data-rigel-doc="${selected.docId}"]`);
|
|
105
|
+
}
|
|
106
|
+
if (element && element !== editingBody) {
|
|
107
|
+
element.style.setProperty("outline", "2px solid var(--accent, #005aa3)", "important");
|
|
108
|
+
element.style.setProperty("outline-offset", "3px", "important");
|
|
109
|
+
highlighted = element;
|
|
110
|
+
if (scroll) element.scrollIntoView({ block: "center" });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function applyStructure() {
|
|
115
|
+
const doc = docElement();
|
|
116
|
+
if (!doc) return;
|
|
117
|
+
const sections = Array.isArray(values.sections) ? (values.sections as Record<string, unknown>[]) : [];
|
|
118
|
+
const prepared = [...doc.querySelectorAll('[data-rigel-each="sections"]')].some(element => element.getAttribute("data-rigel-doc") === docId);
|
|
119
|
+
if (prepared) { pending = []; return; }
|
|
120
|
+
const elements = new Map<string, HTMLElement>();
|
|
121
|
+
for (const element of doc.querySelectorAll<HTMLElement>(`[data-rigel-section][data-rigel-doc="${docId}"]`)) {
|
|
122
|
+
const key = element.getAttribute("data-rigel-key");
|
|
123
|
+
if (key) elements.set(key, element);
|
|
124
|
+
}
|
|
125
|
+
for (const [key, element] of elements) {
|
|
126
|
+
if (!sections.some((section) => section._key === key)) {
|
|
127
|
+
element.remove();
|
|
128
|
+
elements.delete(key);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const parent = elements.values().next().value?.parentElement;
|
|
132
|
+
if (!parent) {
|
|
133
|
+
pending = sections.map((section) => String(section._key)).filter((key) => !elements.has(key));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const onlySections = [...parent.children].every((child) => child.hasAttribute("data-rigel-section"));
|
|
137
|
+
if (!onlySections) {
|
|
138
|
+
pending = [];
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const section of sections) {
|
|
142
|
+
const element = elements.get(String(section._key));
|
|
143
|
+
if (element) parent.appendChild(element);
|
|
144
|
+
}
|
|
145
|
+
pending = sections.map((section) => String(section._key)).filter((key) => !elements.has(key));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function patch() {
|
|
149
|
+
const doc = docElement();
|
|
150
|
+
if (!doc) return;
|
|
151
|
+
patchPreview(doc, previewValues ?? values, body, { documents, primary: docId, editingBody });
|
|
152
|
+
const signature = (Array.isArray(values.sections) ? (values.sections as Record<string, unknown>[]) : [])
|
|
153
|
+
.map((section) => `${section._key}:${section._type}`)
|
|
154
|
+
.join(",");
|
|
155
|
+
if (signature !== structureSignature) {
|
|
156
|
+
structureSignature = signature;
|
|
157
|
+
applyStructure();
|
|
158
|
+
}
|
|
159
|
+
applySelection();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fieldPathFrom(element: HTMLElement): { path: string; doc: string } | null {
|
|
163
|
+
for (const attr of ["data-rigel-text", "data-rigel-richtext", "data-rigel-image", "data-rigel-alt", "data-rigel-href"]) {
|
|
164
|
+
const value = element.getAttribute(attr);
|
|
165
|
+
if (value) return { path: value, doc: element.getAttribute("data-rigel-doc") ?? docId };
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function selectFromElement(element: HTMLElement) {
|
|
171
|
+
const field = fieldPathFrom(element);
|
|
172
|
+
const section = element.closest<HTMLElement>("[data-rigel-section]");
|
|
173
|
+
const sectionKey = section?.getAttribute("data-rigel-key") ?? undefined;
|
|
174
|
+
if (field && field.doc !== docId && section?.getAttribute("data-rigel-doc") === docId) selected = { docId, path: "", sectionKey };
|
|
175
|
+
else if (field) selected = { docId: field.doc, path: field.path, sectionKey };
|
|
176
|
+
else if (sectionKey) selected = { docId: docId, path: "", sectionKey };
|
|
177
|
+
panelOpen = true;
|
|
178
|
+
focusToken++;
|
|
179
|
+
applySelection();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function eventElement(target: EventTarget | null): Element | null {
|
|
183
|
+
return target && (target as Element).nodeType === 1 ? (target as Element) : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function stopBodyEdit() {
|
|
187
|
+
if (!bodyEditor) return;
|
|
188
|
+
void unmount(bodyEditor); bodyEditor = undefined;
|
|
189
|
+
bodyThemeObserver?.disconnect(); bodyThemeObserver = undefined;
|
|
190
|
+
editingBody?.replaceChildren(...bodyContents);
|
|
191
|
+
for (const [name, value] of bodyAttributes) { if (value === null) editingBody?.removeAttribute(name); else editingBody?.setAttribute(name, value); }
|
|
192
|
+
bodyAttributes = []; bodyScope?.remove(); bodyScope = null;
|
|
193
|
+
editingBody = null; bodyContents = [];
|
|
194
|
+
patch();
|
|
195
|
+
}
|
|
196
|
+
async function startBodyEdit(element: HTMLElement, event?: MouseEvent) {
|
|
197
|
+
if (!body) return;
|
|
198
|
+
if (bodyEditor) { element.focus(); return; }
|
|
199
|
+
const doc = element.ownerDocument;
|
|
200
|
+
const blockIndex = event ? [...element.children].findIndex(child => child.contains(event.target as Node)) : 0;
|
|
201
|
+
const caret = event && doc.caretPositionFromPoint?.(event.clientX, event.clientY);
|
|
202
|
+
let offset = 0;
|
|
203
|
+
if (caret && element.children[blockIndex]?.contains(caret.offsetNode)) {
|
|
204
|
+
const range = doc.createRange(); range.selectNodeContents(element.children[blockIndex]); range.setEnd(caret.offsetNode, caret.offset); offset = range.toString().length;
|
|
205
|
+
}
|
|
206
|
+
let editorStyle = doc.querySelector<HTMLStyleElement>('style[data-rigel-editor-styles]');
|
|
207
|
+
if (!editorStyle) { editorStyle = doc.createElement("style"); editorStyle.dataset.rigelEditorStyles = ""; doc.head.append(editorStyle); }
|
|
208
|
+
if (editorStyle.textContent !== editorStyles) editorStyle.textContent = editorStyles;
|
|
209
|
+
clearHover(); clearHighlight();
|
|
210
|
+
editingBody = element; bodyContents = [...element.childNodes];
|
|
211
|
+
bodyAttributes = ["class", "style", "role", "aria-label", "aria-multiline", "aria-keyshortcuts", "contenteditable", "data-lexical-editor", "spellcheck"].map(name => [name, element.getAttribute(name)]);
|
|
212
|
+
const pre = doc.createElement("pre"), code = doc.createElement("code");
|
|
213
|
+
pre.append(code); pre.style.cssText = "position:absolute;visibility:hidden"; element.append(pre);
|
|
214
|
+
for (const [sample, properties] of [[pre, ["background-color", "padding", "margin", "border", "border-radius"]], [code, ["color", "font-family", "font-size", "font-weight", "line-height", "letter-spacing"]]] as const) {
|
|
215
|
+
const style = doc.defaultView!.getComputedStyle(sample);
|
|
216
|
+
for (const property of properties) element.style.setProperty(`--rigel-code-${property}`, style.getPropertyValue(property));
|
|
217
|
+
}
|
|
218
|
+
pre.remove();
|
|
219
|
+
const scope = doc.createElement("div");
|
|
220
|
+
const syncTheme = () => { scope.dataset.theme = document.documentElement.dataset.theme; scope.dataset.contrast = document.documentElement.dataset.contrast; };
|
|
221
|
+
syncTheme();
|
|
222
|
+
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
223
|
+
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
224
|
+
bodyEditor = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
|
|
225
|
+
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
226
|
+
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
227
|
+
await tick();
|
|
228
|
+
const editable = element; editable.focus();
|
|
229
|
+
const block = editable?.children[Math.max(0, blockIndex)];
|
|
230
|
+
if (block) {
|
|
231
|
+
const range = doc.createRange(); range.selectNodeContents(block); range.collapse(true);
|
|
232
|
+
const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
|
233
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
234
|
+
if (offset <= (node.textContent?.length ?? 0)) { range.setStart(node, offset); range.collapse(true); break; }
|
|
235
|
+
offset -= node.textContent?.length ?? 0;
|
|
236
|
+
}
|
|
237
|
+
const selection = doc.defaultView?.getSelection(); selection?.removeAllRanges(); selection?.addRange(range);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function focusBody() {
|
|
241
|
+
const target = docElement()?.querySelector<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
242
|
+
if (inlineBodyEnabled && target) { target.scrollIntoView({ block: "center" }); void startBodyEdit(target); }
|
|
243
|
+
}
|
|
244
|
+
$effect(() => { if (!inlineBodyEnabled || mode !== "edit") stopBodyEdit(); });
|
|
245
|
+
onDestroy(stopBodyEdit);
|
|
246
|
+
|
|
247
|
+
function onClick(event: MouseEvent) {
|
|
248
|
+
const target = eventElement(event.target);
|
|
249
|
+
if (target && (editingBody?.contains(target) || bodyScope?.contains(target))) return;
|
|
250
|
+
event.preventDefault();
|
|
251
|
+
event.stopPropagation();
|
|
252
|
+
if (mode !== "edit") return;
|
|
253
|
+
if (!target) return;
|
|
254
|
+
const articleBody = target.closest<HTMLElement>('[data-rigel-richtext="$body"]');
|
|
255
|
+
if (inlineBodyEnabled && articleBody) { void startBodyEdit(articleBody, event); return; }
|
|
256
|
+
const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
|
|
257
|
+
if (element) selectFromElement(element);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function onDoubleClick(event: MouseEvent) {
|
|
261
|
+
if (mode !== "edit" || !schema || !docType) return;
|
|
262
|
+
const target = eventElement(event.target);
|
|
263
|
+
if (!target) return;
|
|
264
|
+
const element = target.closest<HTMLElement>("[data-rigel-text]");
|
|
265
|
+
if (!element) return;
|
|
266
|
+
const field = fieldPathFrom(element);
|
|
267
|
+
if (!field || field.doc !== docId) return;
|
|
268
|
+
const def = fieldForPath(schema, docType, values, field.path);
|
|
269
|
+
if (!def || def.type !== "string") return;
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
const doc = docElement();
|
|
272
|
+
const style = doc?.defaultView?.getComputedStyle(element);
|
|
273
|
+
const rect = element.getBoundingClientRect();
|
|
274
|
+
const frameRect = frame?.getBoundingClientRect();
|
|
275
|
+
const stageRect = stage.getBoundingClientRect();
|
|
276
|
+
if (!frameRect) return;
|
|
277
|
+
const value = String(anchorValue(values, field.path).value ?? "");
|
|
278
|
+
inline = {
|
|
279
|
+
path: field.path,
|
|
280
|
+
value,
|
|
281
|
+
original: value,
|
|
282
|
+
left: frameRect.left - stageRect.left + rect.left,
|
|
283
|
+
top: frameRect.top - stageRect.top + rect.top,
|
|
284
|
+
width: Math.max(140, rect.width),
|
|
285
|
+
height: Math.max(28, rect.height),
|
|
286
|
+
fontSize: style?.fontSize ?? "16px",
|
|
287
|
+
};
|
|
288
|
+
queueMicrotask(() => inlineInput?.focus());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function closeInline(revert = false) {
|
|
292
|
+
if (!inline) return;
|
|
293
|
+
if (revert && inline.value !== inline.original) onchange(replaceAt(values, inline.path, inline.original));
|
|
294
|
+
const path = inline.path;
|
|
295
|
+
inline = null;
|
|
296
|
+
const doc = docElement();
|
|
297
|
+
const element = doc?.querySelector<HTMLElement>(`[data-rigel-text="${path}"][data-rigel-doc="${docId}"]`);
|
|
298
|
+
element?.setAttribute("tabindex", "-1");
|
|
299
|
+
queueMicrotask(() => element?.focus());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function onOver(event: MouseEvent) {
|
|
303
|
+
if (mode !== "edit") return;
|
|
304
|
+
const target = eventElement(event.target);
|
|
305
|
+
if (target && (editingBody?.contains(target) || bodyScope?.contains(target))) return;
|
|
306
|
+
if (!target) return;
|
|
307
|
+
const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
|
|
308
|
+
if (!element || element === highlighted) return;
|
|
309
|
+
if (hovered && hovered !== element) clearHover();
|
|
310
|
+
hovered = element;
|
|
311
|
+
element.style.setProperty("outline", "1px dashed var(--accent, #005aa3)", "important");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function onOut(event: MouseEvent) {
|
|
315
|
+
const target = eventElement(event.target);
|
|
316
|
+
if (!target) return;
|
|
317
|
+
const element = target.closest<HTMLElement>("[data-rigel-text],[data-rigel-richtext],[data-rigel-image],[data-rigel-alt],[data-rigel-href],[data-rigel-section]");
|
|
318
|
+
if (element && element === hovered) clearHover();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function onLoad() {
|
|
322
|
+
stopBodyEdit(); bodyAvailable = false;
|
|
323
|
+
loaded = false;
|
|
324
|
+
error = "";
|
|
325
|
+
clearHighlight();
|
|
326
|
+
hovered = null;
|
|
327
|
+
structureSignature = "";
|
|
328
|
+
const doc = docElement();
|
|
329
|
+
if (!doc) {
|
|
330
|
+
error = "The page could not be loaded in the preview.";
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
doc.addEventListener("click", onClick, true);
|
|
335
|
+
doc.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
|
|
336
|
+
doc.addEventListener("dblclick", onDoubleClick, true);
|
|
337
|
+
doc.addEventListener("mouseover", onOver, true);
|
|
338
|
+
doc.addEventListener("mouseout", onOut, true);
|
|
339
|
+
doc.addEventListener("submit", (event) => event.preventDefault(), true);
|
|
340
|
+
doc.defaultView?.addEventListener("scroll", () => closeInline(), { passive: true });
|
|
341
|
+
if (previewPath && new URL(doc.URL).pathname.replace(/\/$/, "") !== previewPath.replace(/\/$/, "")) throw new Error("The prepared page could not be loaded.");
|
|
342
|
+
if (!doc.querySelector(schema ? `[data-rigel-doc="${docId}"]` : "[data-rigel-text],[data-rigel-richtext],[data-rigel-image]")) throw new Error("This page isn’t set up for live editing. Changes won’t appear in this preview.");
|
|
343
|
+
loaded = true;
|
|
344
|
+
bodyAvailable = !schema && !!body && !!doc.querySelector('[data-rigel-richtext="$body"]');
|
|
345
|
+
patch();
|
|
346
|
+
} catch (cause) {
|
|
347
|
+
error = cause instanceof Error ? cause.message : "Preview unavailable";
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function setMode(next: "browse" | "edit") {
|
|
352
|
+
mode = next;
|
|
353
|
+
panelOpen = next === "edit";
|
|
354
|
+
if (next === "browse") {
|
|
355
|
+
clearHighlight();
|
|
356
|
+
clearHover();
|
|
357
|
+
selected = null;
|
|
358
|
+
closeInline();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function endResize() {
|
|
363
|
+
const pointer = resizePointer;
|
|
364
|
+
resizePointer = null;
|
|
365
|
+
resizing = false;
|
|
366
|
+
if (pointer?.element.hasPointerCapture(pointer.id)) pointer.element.releasePointerCapture(pointer.id);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function startResize(event: PointerEvent) {
|
|
370
|
+
if (event.button !== 0 || resizePointer) return;
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
closeInline();
|
|
373
|
+
const element = event.currentTarget as HTMLElement;
|
|
374
|
+
element.focus();
|
|
375
|
+
element.setPointerCapture(event.pointerId);
|
|
376
|
+
resizePointer = { element, id: event.pointerId };
|
|
377
|
+
resizing = true;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function moveResize(event: PointerEvent) {
|
|
381
|
+
if (resizePointer?.id !== event.pointerId) return;
|
|
382
|
+
const editor = resizePointer.element.parentElement!;
|
|
383
|
+
requestedWidth = Math.max(280, Math.min(panelMax, editor.getBoundingClientRect().right - event.clientX - 5));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function keyResize(event: KeyboardEvent) {
|
|
387
|
+
const width = { ArrowLeft: panelWidth + 20, ArrowRight: panelWidth - 20, Home: 280, End: panelMax }[event.key];
|
|
388
|
+
if (width === undefined) return;
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
closeInline();
|
|
391
|
+
requestedWidth = Math.max(280, Math.min(panelMax, width));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
$effect(() => {
|
|
395
|
+
if (!panelOpen) endResize();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
$effect(() => {
|
|
399
|
+
void JSON.stringify(values);
|
|
400
|
+
void JSON.stringify(previewValues);
|
|
401
|
+
void body;
|
|
402
|
+
void loaded;
|
|
403
|
+
patch();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
let lastDocId = "";
|
|
407
|
+
$effect(() => {
|
|
408
|
+
if (docId === lastDocId) return;
|
|
409
|
+
lastDocId = docId;
|
|
410
|
+
requestedWidth = 340;
|
|
411
|
+
endResize();
|
|
412
|
+
inline = null;
|
|
413
|
+
selected = null;
|
|
414
|
+
structureSignature = "";
|
|
415
|
+
pending = [];
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
function updateInline(value: string) {
|
|
419
|
+
if (!inline) return;
|
|
420
|
+
inline = { ...inline, value };
|
|
421
|
+
onchange(replaceAt(values, inline.path, value));
|
|
422
|
+
}
|
|
423
|
+
</script>
|
|
424
|
+
|
|
425
|
+
<svelte:window onkeydown={(event) => {
|
|
426
|
+
if (event.key === "Escape" && inline) { event.stopPropagation(); closeInline(true); }
|
|
427
|
+
}} />
|
|
428
|
+
|
|
429
|
+
<div class="visual-editor" bind:clientWidth={editorWidth} class:with-panel={panelOpen} class:resizing-properties={resizing} style={`--properties-width: ${panelWidth}px`}>
|
|
430
|
+
<div class="visual-canvas">
|
|
431
|
+
<div class="visual-toolbar">
|
|
432
|
+
<div class="mode-toggle" role="group" aria-label="Preview mode">
|
|
433
|
+
<button type="button" aria-pressed={mode === "browse"} onclick={() => setMode("browse")}>Browse</button>
|
|
434
|
+
<button type="button" aria-pressed={mode === "edit"} onclick={() => setMode("edit")}>Edit</button>
|
|
435
|
+
</div>
|
|
436
|
+
<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
|
+
<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)}>
|
|
440
|
+
<SidebarIcon name="square-half" />
|
|
441
|
+
</button>
|
|
442
|
+
</div>
|
|
443
|
+
</div>
|
|
444
|
+
<div class="canvas-stage" bind:this={stage} class:grabbing={mode === "edit"} data-ready={loaded}>
|
|
445
|
+
{#if error}
|
|
446
|
+
<p class="visual-error" role="status">{error}</p>
|
|
447
|
+
{/if}
|
|
448
|
+
{#if !previewPath}
|
|
449
|
+
<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>
|
|
450
|
+
{:else}
|
|
451
|
+
{#key `${previewPath}:${reloadToken}`}
|
|
452
|
+
<iframe bind:this={frame} title="Website page editor" sandbox="allow-same-origin" referrerpolicy="no-referrer" src={previewPath} onload={onLoad}></iframe>
|
|
453
|
+
{/key}
|
|
454
|
+
{/if}
|
|
455
|
+
{#if inline}
|
|
456
|
+
<input
|
|
457
|
+
class="inline-editor"
|
|
458
|
+
bind:this={inlineInput}
|
|
459
|
+
type="text"
|
|
460
|
+
aria-label="Edit text"
|
|
461
|
+
style={`left:${inline.left}px;top:${inline.top}px;width:${inline.width}px;height:${inline.height}px;font-size:${inline.fontSize}`}
|
|
462
|
+
value={inline.value}
|
|
463
|
+
oninput={(event) => updateInline(event.currentTarget.value)}
|
|
464
|
+
onkeydown={(event) => {
|
|
465
|
+
if (event.key === "Enter") { event.preventDefault(); closeInline(); }
|
|
466
|
+
}}
|
|
467
|
+
onblur={() => closeInline()}
|
|
468
|
+
/>
|
|
469
|
+
{/if}
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
{#if panelOpen}
|
|
473
|
+
<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>
|
|
474
|
+
{/if}
|
|
475
|
+
{#if panelOpen && properties}
|
|
476
|
+
{@render properties(selected, focusToken, inlineBodyEnabled, focusBody)}
|
|
477
|
+
{:else if panelOpen && schema && docType}
|
|
478
|
+
<SchemaPanel
|
|
479
|
+
{schema}
|
|
480
|
+
{docType}
|
|
481
|
+
{values}
|
|
482
|
+
{docId}
|
|
483
|
+
{selected}
|
|
484
|
+
{creating}
|
|
485
|
+
{pending}
|
|
486
|
+
{focusToken}
|
|
487
|
+
{devMode}
|
|
488
|
+
documents={documentList}
|
|
489
|
+
onchange={onchange}
|
|
490
|
+
{oneditdocument}
|
|
491
|
+
onselect={async (next) => { selected = next; focusToken++; await tick(); if (selected?.docId === next?.docId && selected?.sectionKey === next?.sectionKey) applySelection(true); }}
|
|
492
|
+
/>
|
|
493
|
+
{/if}
|
|
494
|
+
</div>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const display = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
|
|
2
|
+
export const months = Array.from({ length: 12 }, (_, month) => new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" }).format(new Date(Date.UTC(2000, month, 1))));
|
|
3
|
+
|
|
4
|
+
export function isoDate(date: Date): string {
|
|
5
|
+
return `${String(date.getUTCFullYear()).padStart(4, "0")}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseDate(value: string): Date | null {
|
|
9
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number(value.slice(0, 4)) < 1) return null;
|
|
10
|
+
const date = new Date(0);
|
|
11
|
+
date.setUTCHours(12, 0, 0, 0);
|
|
12
|
+
date.setUTCFullYear(Number(value.slice(0, 4)), Number(value.slice(5, 7)) - 1, Number(value.slice(8, 10)));
|
|
13
|
+
return isoDate(date) === value ? date : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatDate(value: string): string {
|
|
17
|
+
const date = parseDate(value);
|
|
18
|
+
return date ? display.format(date) : value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function todayDate(date = new Date()): string {
|
|
22
|
+
return `${String(date.getFullYear()).padStart(4, "0")}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function moveMonth(value: string, delta: number): string {
|
|
26
|
+
const date = parseDate(value);
|
|
27
|
+
if (!date) return value;
|
|
28
|
+
const day = date.getUTCDate();
|
|
29
|
+
date.setUTCDate(1);
|
|
30
|
+
date.setUTCMonth(date.getUTCMonth() + delta);
|
|
31
|
+
if (date.getUTCFullYear() < 1 || date.getUTCFullYear() > 9999) return value;
|
|
32
|
+
const last = new Date(date);
|
|
33
|
+
last.setUTCMonth(last.getUTCMonth() + 1, 0);
|
|
34
|
+
date.setUTCDate(Math.min(day, last.getUTCDate()));
|
|
35
|
+
return isoDate(date);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function moveDay(value: string, delta: number): string {
|
|
39
|
+
const date = parseDate(value);
|
|
40
|
+
if (!date) return value;
|
|
41
|
+
date.setUTCDate(date.getUTCDate() + delta);
|
|
42
|
+
return date.getUTCFullYear() < 1 || date.getUTCFullYear() > 9999 ? value : isoDate(date);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function monthDays(value: string): ({ value: string; day: number } | null)[] {
|
|
46
|
+
const date = parseDate(value);
|
|
47
|
+
if (!date) return [];
|
|
48
|
+
date.setUTCDate(1);
|
|
49
|
+
const offset = date.getUTCDay(), month = date.getUTCMonth();
|
|
50
|
+
return Array.from({ length: 42 }, (_, index) => {
|
|
51
|
+
const cell = new Date(date);
|
|
52
|
+
cell.setUTCDate(index - offset + 1);
|
|
53
|
+
return cell.getUTCMonth() === month ? { value: isoDate(cell), day: cell.getUTCDate() } : null;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface GitHubAccess {
|
|
2
|
+
user: { login: string; avatar_url: string; html_url: string };
|
|
3
|
+
status: "ready" | "installation-required" | "read-only";
|
|
4
|
+
installUrl: string;
|
|
5
|
+
installation: { id: number; account: string; html_url: string } | null;
|
|
6
|
+
repository: { id: number; full_name: string; default_branch: string; permissions: { push: boolean } } | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function startGitHubLogin(authOrigin: string): Promise<{ accessToken: string; expiresAt: number }> {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
let origin: URL;
|
|
12
|
+
try {
|
|
13
|
+
origin = new URL(authOrigin);
|
|
14
|
+
if (origin.username || origin.password || origin.search || origin.hash || origin.pathname !== "/" || (origin.protocol !== "https:" && !(origin.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(origin.hostname)))) throw new Error();
|
|
15
|
+
} catch { reject(new Error("Invalid authentication gateway origin.")); return; }
|
|
16
|
+
const nonce = Array.from(crypto.getRandomValues(new Uint8Array(32)), byte => byte.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
const url = new URL("/authorize", origin);
|
|
18
|
+
url.searchParams.set("origin", window.location.origin);
|
|
19
|
+
url.searchParams.set("nonce", nonce);
|
|
20
|
+
let popup: Window | null = null;
|
|
21
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
22
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
const finish = (error?: string, result?: { accessToken: string; expiresAt: number }) => {
|
|
24
|
+
window.removeEventListener("message", receive);
|
|
25
|
+
clearInterval(timer);
|
|
26
|
+
clearTimeout(timeout);
|
|
27
|
+
try { popup?.close(); } catch {}
|
|
28
|
+
if (result) resolve(result);
|
|
29
|
+
else reject(new Error(error));
|
|
30
|
+
};
|
|
31
|
+
const receive = (event: MessageEvent) => {
|
|
32
|
+
const data = event.data;
|
|
33
|
+
if (!popup || event.source !== popup || event.origin !== origin.origin || !data || data.type !== "rigel:auth" || data.provider !== "github" || data.nonce !== nonce) return;
|
|
34
|
+
if (data.error) {
|
|
35
|
+
finish(data.error === "Authorization denied"
|
|
36
|
+
? "GitHub authorization was denied. Sign in again and approve access to continue."
|
|
37
|
+
: data.error === "Token exchange failed"
|
|
38
|
+
? "The auth gateway could not exchange GitHub's authorization code for a token. Repository access has not been checked."
|
|
39
|
+
: "GitHub login failed. Please try signing in again.");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (typeof data.access_token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(data.access_token) || !Number.isInteger(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400) { finish("GitHub returned invalid or expired credentials. Please sign in again."); return; }
|
|
43
|
+
finish(undefined, { accessToken: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 });
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener("message", receive);
|
|
46
|
+
try { popup = window.open(url.href, "rigel-github-" + nonce, "popup,width=600,height=720"); }
|
|
47
|
+
catch { finish("Unable to open GitHub login. Allow popups and try again."); return; }
|
|
48
|
+
if (!popup) { finish("GitHub login popup was blocked. Allow popups and try again."); return; }
|
|
49
|
+
timer = setInterval(() => { if (popup?.closed) finish("GitHub login was cancelled. Please try again."); }, 250);
|
|
50
|
+
timeout = setTimeout(() => finish("GitHub login timed out. Please try again."), 120000);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function checkGitHubAccess(token: string, repo: string, appSlug: string): Promise<GitHubAccess> {
|
|
55
|
+
if (typeof token !== "string" || !/^ghu_[a-zA-Z0-9_]{1,4092}$/.test(token)) throw new Error("GitHub session is invalid. Please sign in again.");
|
|
56
|
+
if (typeof repo !== "string" || !/^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\/[a-zA-Z0-9_.-]+$/.test(repo) || [".", ".."].includes(repo.split("/")[1]) || typeof appSlug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(appSlug)) throw new Error("Invalid GitHub repository or app configuration.");
|
|
57
|
+
const get = async (endpoint: string) => {
|
|
58
|
+
let response: Response;
|
|
59
|
+
try {
|
|
60
|
+
response = await fetch("https://api.github.com" + endpoint, {
|
|
61
|
+
method: "GET", redirect: "error", credentials: "omit", cache: "no-store", signal: AbortSignal.timeout(15000),
|
|
62
|
+
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28" },
|
|
63
|
+
});
|
|
64
|
+
} catch { throw new Error("Unable to reach GitHub. Check your connection and try again."); }
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
if (response.status === 401) throw new Error("GitHub session expired or was revoked. Please sign in again.");
|
|
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
|
+
if (response.status === 403) throw new Error("GitHub access denied. Check app permissions and organization approval, then sign in again.");
|
|
69
|
+
if (response.status === 404) throw new Error("GitHub repository or installation is unavailable. Check the repository name and app installation, then try again.");
|
|
70
|
+
throw new Error("GitHub could not verify access. Please try again later.");
|
|
71
|
+
}
|
|
72
|
+
try { return await response.json(); }
|
|
73
|
+
catch { throw new Error("GitHub returned an invalid access response. Please try again."); }
|
|
74
|
+
};
|
|
75
|
+
const list = async <T>(endpoint: string, key: string): Promise<T[]> => {
|
|
76
|
+
const entries: T[] = [];
|
|
77
|
+
for (let page = 1; page <= 100; page++) {
|
|
78
|
+
const data = await get(`${endpoint}?per_page=100&page=${page}`);
|
|
79
|
+
if (!data || !Array.isArray(data[key])) throw new Error("GitHub returned an invalid access response. Please try again.");
|
|
80
|
+
entries.push(...data[key]);
|
|
81
|
+
if (data[key].length < 100 && !(typeof data.total_count === "number" && entries.length < data.total_count)) return entries;
|
|
82
|
+
if (!data[key].length) break;
|
|
83
|
+
}
|
|
84
|
+
throw new Error("GitHub access listing is incomplete. Please try again or narrow the app's repository selection.");
|
|
85
|
+
};
|
|
86
|
+
const rawUser = await get("/user");
|
|
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 };
|
|
89
|
+
const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
|
|
90
|
+
const result: GitHubAccess = { user, status: "installation-required", installUrl, installation: null, repository: null };
|
|
91
|
+
type Installation = { id: number; app_slug: string; account?: { login?: string }; html_url?: string; permissions?: { contents?: string }; suspended_at?: string | null };
|
|
92
|
+
const installations = await list<Installation>("/user/installations", "installations");
|
|
93
|
+
for (const installation of installations) {
|
|
94
|
+
if (!installation || installation.app_slug !== appSlug || !Number.isSafeInteger(installation.id) || installation.id <= 0 || typeof installation.account?.login !== "string") continue;
|
|
95
|
+
let configureUrl = installUrl;
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(installation.html_url ?? "");
|
|
98
|
+
if (url.origin === "https://github.com" && !url.username && !url.password && !url.search && !url.hash && new RegExp(`^/(?:settings|organizations/[a-zA-Z0-9-]+/settings)/installations/${installation.id}$`).test(url.pathname)) configureUrl = url.href;
|
|
99
|
+
} catch {}
|
|
100
|
+
const verifiedInstallation = { id: installation.id, account: installation.account.login, html_url: configureUrl };
|
|
101
|
+
if (installation.account.login.toLowerCase() === repo.split("/")[0].toLowerCase()) {
|
|
102
|
+
result.installUrl = configureUrl;
|
|
103
|
+
result.installation = verifiedInstallation;
|
|
104
|
+
}
|
|
105
|
+
const repositories = await list<NonNullable<GitHubAccess["repository"]>>(`/user/installations/${installation.id}/repositories`, "repositories");
|
|
106
|
+
const repository = repositories.find(repository => repository && typeof repository.full_name === "string" && repository.full_name.toLowerCase() === repo.toLowerCase());
|
|
107
|
+
if (!repository) continue;
|
|
108
|
+
if (!Number.isSafeInteger(repository.id) || repository.id <= 0 || typeof repository.default_branch !== "string" || !repository.default_branch) throw new Error("GitHub returned an invalid repository. Please try again.");
|
|
109
|
+
const push = repository.permissions?.push === true;
|
|
110
|
+
return {
|
|
111
|
+
user, status: push && installation.permissions?.contents === "write" && !installation.suspended_at ? "ready" : "read-only", installUrl: configureUrl,
|
|
112
|
+
installation: verifiedInstallation,
|
|
113
|
+
repository: { id: repository.id, full_name: repository.full_name, default_branch: repository.default_branch, permissions: { push } },
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|