@rigel-cms/rigel 0.1.0-beta.1 → 0.1.4-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/README.md +8 -0
- package/package.json +7 -2
- package/src/config.ts +6 -0
- package/src/editor/App.svelte +113 -58
- package/src/editor/lib/BlockEditor.svelte +18 -18
- package/src/editor/lib/ImagePicker.svelte +7 -7
- package/src/editor/lib/MarkdownEditor.svelte +7 -7
- package/src/editor/lib/PreviewFrame.svelte +45 -0
- package/src/editor/lib/SchemaField.svelte +4 -4
- package/src/editor/lib/SchemaPanel.svelte +4 -4
- 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 +9 -8
- package/src/editor/lib/TopBar.svelte +1 -1
- package/src/editor/lib/VisualEditor.svelte +10 -8
- package/src/editor/lib/github-auth.ts +4 -3
- package/src/editor/lib/github-backend.ts +3 -3
- package/src/editor/lib/preview-frame.ts +29 -0
- package/src/editor/lib/slugify.ts +7 -2
- package/src/editor/lib/storage.ts +30 -2
- package/src/editor/lib/tooltip.ts +120 -0
- package/src/editor/styles.css +11 -0
- package/src/manifest/collections.ts +27 -0
- package/src/manifest/types.ts +1 -0
- package/src/preview-urls.ts +9 -0
- package/src/preview.ts +12 -4
- package/src/routes/local-post.ts +11 -5
- package/src/vite/manifest-plugin.ts +2 -0
|
@@ -153,12 +153,12 @@
|
|
|
153
153
|
{#if error && !open}<p class="error" role="alert">{error}</p>{/if}
|
|
154
154
|
</div>
|
|
155
155
|
{:else if toolbar}
|
|
156
|
-
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel}
|
|
156
|
+
<button bind:this={trigger} data-image-trigger type="button" aria-label={triggerLabel} data-tooltip={triggerLabel} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
|
|
157
157
|
{:else}
|
|
158
158
|
<div class="image-field">
|
|
159
159
|
{#if value}<img src={value} alt="" class="preview" />{:else}<div class="preview empty">No image</div>{/if}
|
|
160
160
|
<button bind:this={trigger} type="button" class="outlined" onclick={launch}>Choose…</button>
|
|
161
|
-
{#if value && onpick}<button type="button" class="outlined" aria-label="Remove image"
|
|
161
|
+
{#if value && onpick}<button type="button" class="outlined" aria-label="Remove image" data-tooltip="Remove image" onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
|
|
162
162
|
</div>
|
|
163
163
|
{/if}
|
|
164
164
|
|
|
@@ -166,14 +166,14 @@
|
|
|
166
166
|
<p class="hint">Repository media: public/{folder || "…"}. {writable ? "Uploads write files immediately on confirmation; content still requires Save." : "Build-time snapshot. Uploads and stock imports require the local dev server; GitHub uploads are not connected."}</p>
|
|
167
167
|
<div class="media-tools">
|
|
168
168
|
<div class="media-views" role="group" aria-label="Media view">
|
|
169
|
-
<button type="button" class="outlined"
|
|
170
|
-
<button type="button" class="outlined"
|
|
169
|
+
<button type="button" class="outlined" data-tooltip="Grid view" aria-label="Grid view" aria-pressed={view === "grid"} onclick={() => setView("grid")}><Icon name="squaresFour" size={18} /></button>
|
|
170
|
+
<button type="button" class="outlined" data-tooltip="List view" aria-label="List view" aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
|
|
171
171
|
</div>
|
|
172
172
|
<div class="media-select">
|
|
173
173
|
<Select ariaLabel={inline ? "Media type" : "Image format"} value={format} onchange={next => { format = String(next); }} options={inline ? [{ value: "", label: "All media items" }, ...MEDIA_CATEGORIES.map(category => ({ value: category, label: category }))] : [{ value: "", label: "All images" }, ...["jpeg", "png", "webp", "gif", "avif"].map(format => ({ value: `image/${format}`, label: format === "jpeg" ? "JPEG" : format === "webp" ? "WebP" : format.toUpperCase() }))]} />
|
|
174
174
|
</div>
|
|
175
175
|
<div class="media-select">
|
|
176
|
-
<Select ariaLabel="Modified month"
|
|
176
|
+
<Select ariaLabel="Modified month" data-tooltip="Filesystem modified month (UTC), not upload date" value={month} onchange={next => { month = String(next); }} options={[{ value: "", label: "All modified dates" }, ...months.map(date => ({ value: date, label: new Date(date + "-01T00:00:00Z").toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" }) }))]} />
|
|
177
177
|
</div>
|
|
178
178
|
<button type="button" class="outlined" onclick={() => { tab = "library"; void run(); }} disabled={loading || picking}>Refresh</button>
|
|
179
179
|
{#if filter || format || month}<button type="button" class="outlined" onclick={resetFilters}>Reset filters</button>{/if}
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
</div>
|
|
182
182
|
{#if view === "grid"}<div class="grid">
|
|
183
183
|
{#each filtered as asset}
|
|
184
|
-
<figure><button type="button" class="thumb"
|
|
184
|
+
<figure><button type="button" class="thumb" aria-label={asset.name} data-tooltip={asset.name} aria-pressed={librarySelection === asset.url} onclick={() => selectAsset(asset)}>{#if asset.type?.startsWith("image/")}<img src={asset.url} alt={asset.name} loading="lazy" />{:else}<span class="media-file-icon"><Icon name="listBullets" size={40} /><span>{mediaMetadata(asset.name)?.category} · {mediaMetadata(asset.name)?.extension.toUpperCase()}</span></span>{/if}</button><figcaption>{asset.name}</figcaption></figure>
|
|
185
185
|
{/each}
|
|
186
186
|
</div>
|
|
187
187
|
{:else}<div class="table-card media-table"><table aria-label="Media library"><thead><tr><th scope="col">File</th><th scope="col">Type</th><th scope="col">Size</th><th scope="col">Modified (UTC)</th></tr></thead><tbody>
|
|
@@ -253,7 +253,7 @@
|
|
|
253
253
|
{#if searched && !results.length && !error}<p class="hint" role="status">No photos found. Try another search.</p>{/if}
|
|
254
254
|
<div class="grid">
|
|
255
255
|
{#each results as r (r.url)}
|
|
256
|
-
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selected = {url: r.url, stock: r}}
|
|
256
|
+
<figure><button type="button" class="thumb" disabled={picking} onclick={() => selected = {url: r.url, stock: r}} data-tooltip={r.credit}><img src={r.thumb} alt={r.credit} loading="lazy" /></button><figcaption>{#if r.creditURL?.startsWith("https://")}<a href={r.creditURL} target="_blank" rel="noopener noreferrer">{r.credit}</a>{:else}{r.credit}{/if}</figcaption></figure>
|
|
257
257
|
{/each}
|
|
258
258
|
</div>
|
|
259
259
|
{/if}
|
|
@@ -172,18 +172,18 @@
|
|
|
172
172
|
{#if warning}<p class="markdown-warning" role="status">{warning}</p>{/if}
|
|
173
173
|
<div class="markdown-visual" hidden={mode !== "visual"}>
|
|
174
174
|
<div class="body-toolbar formatting-toolbar" role="group" aria-label={`${label} formatting`}>
|
|
175
|
-
<Select ariaLabel="Text style"
|
|
175
|
+
<Select ariaLabel="Text style" data-tooltip="Text style" value={format} options={[{ value: "paragraph", label: "Paragraph" }, ...[1, 2, 3, 4, 5, 6].map(level => ({ value: `h${level}`, label: `Heading ${level}` })), { value: "code_block", label: "Code block" }].map(option => ({ ...option, disabled: !!state && format !== option.value && !formatCommand(option.value)(state) }))} onchange={next => command(formatCommand(String(next)))} />
|
|
176
176
|
<div class="format-group" role="group" aria-label="Text formatting">
|
|
177
177
|
{#each marks as item}
|
|
178
|
-
<button type="button" aria-label={item.name}
|
|
178
|
+
<button type="button" aria-label={item.name} data-tooltip={item.name} aria-pressed={activeMark(item.type)} disabled={!state || !toggleMark(schema.marks[item.type])(state)} onmousedown={event => event.preventDefault()} onclick={() => command(toggleMark(schema.marks[item.type]))}><Icon name={item.icon} size={18} /></button>
|
|
179
179
|
{/each}
|
|
180
180
|
</div>
|
|
181
181
|
<div class="format-group" role="group" aria-label="Lists and quote">
|
|
182
182
|
{#each blocks as item}
|
|
183
|
-
<button type="button" aria-label={item.name}
|
|
183
|
+
<button type="button" aria-label={item.name} data-tooltip={item.name} aria-pressed={activeBlock(item.type)} disabled={!state || !blockCommand(item.type)(state)} onmousedown={event => event.preventDefault()} onclick={() => command(blockCommand(item.type))}><Icon name={item.icon} size={18} /></button>
|
|
184
184
|
{/each}
|
|
185
185
|
</div>
|
|
186
|
-
<button bind:this={linkButton} type="button" aria-label="Link"
|
|
186
|
+
<button bind:this={linkButton} type="button" aria-label="Link" data-tooltip="Link" aria-pressed={activeMark("link")} aria-expanded={linkOpen} aria-controls={`${id}-link`} disabled={!state || !toggleMark(schema.marks.link)(state)} onmousedown={event => event.preventDefault()} onclick={openLink}><Icon name="link" size={18} /></button>
|
|
187
187
|
<ImagePicker toolbar onopen={() => { imageSelection = view?.state.selection.getBookmark(); linkOpen = false; }} onpick={insertImage} onclose={() => { imageSelection = undefined; }} />
|
|
188
188
|
</div>
|
|
189
189
|
{#if linkOpen}
|
|
@@ -191,9 +191,9 @@
|
|
|
191
191
|
<label for={`${id}-url`}>Link URL</label>
|
|
192
192
|
<div class="markdown-link-controls">
|
|
193
193
|
<input id={`${id}-url`} type="text" placeholder="https://…" bind:this={linkInput} bind:value={linkUrl} aria-invalid={!!linkError} aria-describedby={linkError ? `${id}-error` : undefined} />
|
|
194
|
-
<button type="button" aria-label="Apply link"
|
|
195
|
-
<button type="button" aria-label="Remove link"
|
|
196
|
-
<button type="button" aria-label="Close link"
|
|
194
|
+
<button type="button" aria-label="Apply link" data-tooltip="Apply link" onclick={() => link()}><Icon name="check" size={16} /></button>
|
|
195
|
+
<button type="button" aria-label="Remove link" data-tooltip="Remove link" disabled={!activeMark("link")} onclick={() => link(true)}><Icon name="linkBreak" size={16} /></button>
|
|
196
|
+
<button type="button" aria-label="Close link" data-tooltip="Close link" onclick={() => { linkOpen = false; linkButton.focus(); }}><Icon name="x" size={16} /></button>
|
|
197
197
|
</div>
|
|
198
198
|
{#if linkError}<p id={`${id}-error`} role="alert">{linkError}</p>{/if}
|
|
199
199
|
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { untrack } from "svelte";
|
|
3
|
+
import { fetchPreview } from "./preview-frame";
|
|
4
|
+
|
|
5
|
+
let { url, title, frame = $bindable(), reloadToken = 0, onload, onloading, onerror }: {
|
|
6
|
+
url: string;
|
|
7
|
+
title: string;
|
|
8
|
+
frame?: HTMLIFrameElement;
|
|
9
|
+
reloadToken?: number;
|
|
10
|
+
onload: () => void;
|
|
11
|
+
onloading: () => void;
|
|
12
|
+
onerror: (message: string) => void;
|
|
13
|
+
} = $props();
|
|
14
|
+
let source = $state<{ src?: string; srcdoc?: string } | null>(null);
|
|
15
|
+
|
|
16
|
+
$effect(() => {
|
|
17
|
+
const target = new URL(url, location.href);
|
|
18
|
+
void reloadToken;
|
|
19
|
+
source = null;
|
|
20
|
+
untrack(onloading);
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
if (target.origin === location.origin) source = { src: target.href };
|
|
23
|
+
else fetchPreview(target, controller.signal).then(srcdoc => {
|
|
24
|
+
if (!controller.signal.aborted) source = { srcdoc };
|
|
25
|
+
}).catch(cause => {
|
|
26
|
+
if (!controller.signal.aborted) onerror(cause instanceof Error ? cause.message : "Preview unavailable");
|
|
27
|
+
});
|
|
28
|
+
return () => controller.abort();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
function ready() {
|
|
32
|
+
const doc = frame?.contentDocument;
|
|
33
|
+
if (!doc || (source?.src && new URL(doc.URL).pathname.replace(/\/$/, "") !== new URL(source.src).pathname.replace(/\/$/, ""))) {
|
|
34
|
+
onerror("The prepared page could not be loaded. Check its URL and framing headers.");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
onload();
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
{#if source}
|
|
42
|
+
{#key source}
|
|
43
|
+
<iframe bind:this={frame} {title} sandbox="allow-same-origin" referrerpolicy="no-referrer" src={source.src} srcdoc={source.srcdoc} onload={ready}></iframe>
|
|
44
|
+
{/key}
|
|
45
|
+
{/if}
|
|
@@ -127,10 +127,10 @@
|
|
|
127
127
|
<span>Item {index + 1}</span>
|
|
128
128
|
<span class="schema-key">{(item && typeof item === "object" ? (item as Record<string, unknown>)._key : "") ?? ""}</span>
|
|
129
129
|
<div class="schema-item-actions">
|
|
130
|
-
<button type="button" aria-label={`Move ${field.label} item ${index + 1} up`}
|
|
131
|
-
<button type="button" aria-label={`Move ${field.label} item ${index + 1} down`}
|
|
132
|
-
<button type="button" aria-label={`Duplicate ${field.label} item ${index + 1}`}
|
|
133
|
-
<button type="button" aria-label={`Remove ${field.label} item ${index + 1}`}
|
|
130
|
+
<button type="button" aria-label={`Move ${field.label} item ${index + 1} up`} data-tooltip="Move up" disabled={index === 0} onclick={() => onvalue(moveItem(itemList, index, -1))}>↑</button>
|
|
131
|
+
<button type="button" aria-label={`Move ${field.label} item ${index + 1} down`} data-tooltip="Move down" disabled={index === itemList.length - 1} onclick={() => onvalue(moveItem(itemList, index, 1))}>↓</button>
|
|
132
|
+
<button type="button" aria-label={`Duplicate ${field.label} item ${index + 1}`} data-tooltip="Duplicate" onclick={() => onvalue(duplicateItem(itemList, index))}>⧉</button>
|
|
133
|
+
<button type="button" aria-label={`Remove ${field.label} item ${index + 1}`} data-tooltip="Remove" onclick={() => onvalue(itemList.filter((_, position) => position !== index))}>×</button>
|
|
134
134
|
</div>
|
|
135
135
|
</div>
|
|
136
136
|
{#if itemSchema.length}
|
|
@@ -119,10 +119,10 @@
|
|
|
119
119
|
{#if devMode}<span class="schema-key">{section._key}</span>{/if}
|
|
120
120
|
</button>
|
|
121
121
|
<div class="structure-actions">
|
|
122
|
-
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`}
|
|
123
|
-
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`}
|
|
124
|
-
<button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`}
|
|
125
|
-
<button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`}
|
|
122
|
+
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} up`} data-tooltip="Move up" disabled={index === 0} onclick={() => setSections(moveItem(sections, index, -1) as Record<string, unknown>[])}>↑</button>
|
|
123
|
+
<button type="button" aria-label={`Move ${sectionLabel(schema, section._type)} down`} data-tooltip="Move down" disabled={index === sections.length - 1} onclick={() => setSections(moveItem(sections, index, 1) as Record<string, unknown>[])}>↓</button>
|
|
124
|
+
<button type="button" aria-label={`Duplicate ${sectionLabel(schema, section._type)}`} data-tooltip="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
|
|
125
|
+
<button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} data-tooltip="Remove" onclick={() => removeSection(index)}>×</button>
|
|
126
126
|
</div>
|
|
127
127
|
</li>
|
|
128
128
|
{/each}
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
4
|
import type { ICONS } from "./icons";
|
|
5
5
|
|
|
6
|
-
let { label = "", ariaLabel = label, title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
6
|
+
let { label = "", ariaLabel = label, title, "data-tooltip": tooltip = title, icon, value = "", options, multiple = false, disabled = false, onchange = () => {} }: {
|
|
7
7
|
label?: string;
|
|
8
8
|
ariaLabel?: string;
|
|
9
9
|
title?: string;
|
|
10
|
+
"data-tooltip"?: string;
|
|
10
11
|
icon?: keyof typeof ICONS;
|
|
11
12
|
value?: string | string[];
|
|
12
13
|
options: { value: string; label: string; icon?: keyof typeof ICONS; disabled?: boolean }[];
|
|
@@ -120,7 +121,7 @@
|
|
|
120
121
|
|
|
121
122
|
<div class="custom-select" onfocusout={() => { if (open) requestAnimationFrame(() => { if (trigger.ownerDocument.activeElement?.tagName === "IFRAME") close(); }); }}>
|
|
122
123
|
{#if label}<span class="select-label" id={`${id}-label`}>{label}</span>{/if}
|
|
123
|
-
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} {
|
|
124
|
+
<button bind:this={trigger} type="button" class="select-trigger" class:icon-only={!!icon} role="combobox" aria-label={label ? undefined : ariaLabel} aria-labelledby={label ? `${id}-label` : undefined} aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && active >= 0 ? `${id}-option-${active}` : undefined} popovertarget={`${id}-list`} data-tooltip={tooltip} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
|
|
124
125
|
{#if icon}<Icon name={icon} size={18} />{:else}<span>{text || (multiple ? `Select ${label.toLowerCase() || "options"}…` : "Choose…")}</span><Icon name="caretDown" size={12} />{/if}
|
|
125
126
|
</button>
|
|
126
127
|
<select hidden aria-hidden="true" tabindex="-1" aria-label={ariaLabel} {multiple} {disabled} {value} onchange={event => { value = multiple ? Array.from(event.currentTarget.selectedOptions, option => option.value) : event.currentTarget.value; onchange(value); }}>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
{#if field === "metaTitle" || field === "description"}
|
|
10
10
|
<div class="seo-length" data-seo-length={field} data-tone={tone}>
|
|
11
|
-
<div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`}
|
|
11
|
+
<div class="seo-length-track" role="meter" aria-label={`${limits.label} length`} aria-valuemin={0} aria-valuemax={Math.max(limits.max, count)} aria-valuenow={count} aria-valuetext={`${count} characters. ${status}. Recommended ${limits.min} to ${limits.max}.`} data-tooltip={status}>
|
|
12
12
|
<span class="seo-length-fill" style:width={`${Math.min(count / limits.max * 100, 100)}%`}></span>
|
|
13
13
|
</div>
|
|
14
14
|
<div class="seo-length-summary hint">
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getKeys, setKeys, STOCK_PROVIDERS } from "./images";
|
|
3
3
|
import type { RigelSettings } from "./settings";
|
|
4
|
+
import type { Manifest } from "../../manifest/types";
|
|
5
|
+
import type { GitHubAccess } from "./github-auth";
|
|
4
6
|
import Icon from "./Icon.svelte";
|
|
5
7
|
|
|
6
|
-
let { s = $bindable(), error, saving, onerase, onclose }: {
|
|
8
|
+
let { s = $bindable(), github, user, error, saving, onerase, onclose }: {
|
|
7
9
|
s: RigelSettings;
|
|
10
|
+
github?: Manifest["github"];
|
|
11
|
+
user?: GitHubAccess["user"];
|
|
8
12
|
error: string;
|
|
9
13
|
saving: boolean;
|
|
10
14
|
onerase: () => void;
|
|
11
15
|
onclose: () => void;
|
|
12
16
|
} = $props();
|
|
13
17
|
|
|
14
|
-
type PaneId = "appearance" | "editor" | "media" | "language" | "advanced";
|
|
18
|
+
type PaneId = "appearance" | "editor" | "connection" | "media" | "language" | "advanced";
|
|
15
19
|
let active: PaneId = $state("appearance");
|
|
16
20
|
|
|
17
21
|
const providerIds = Object.keys(STOCK_PROVIDERS) as (keyof typeof STOCK_PROVIDERS)[];
|
|
@@ -23,6 +27,7 @@
|
|
|
23
27
|
const panes: { id: PaneId; label: string }[] = [
|
|
24
28
|
{ id: "appearance", label: "Appearance" },
|
|
25
29
|
{ id: "editor", label: "Editor" },
|
|
30
|
+
{ id: "connection", label: "Connection" },
|
|
26
31
|
{ id: "media", label: "Media" },
|
|
27
32
|
{ id: "language", label: "Language" },
|
|
28
33
|
{ id: "advanced", label: "Advanced" },
|
|
@@ -31,6 +36,7 @@
|
|
|
31
36
|
const PANE_ICONS = {
|
|
32
37
|
appearance: "palette",
|
|
33
38
|
editor: "textB",
|
|
39
|
+
connection: "user",
|
|
34
40
|
media: "image",
|
|
35
41
|
language: "translate",
|
|
36
42
|
advanced: "wrench",
|
|
@@ -103,6 +109,25 @@
|
|
|
103
109
|
<label class="radio"><input type="radio" bind:group={s.userPref.editorExperience} value="inline" /><span>Inline preview</span></label>
|
|
104
110
|
</fieldset>
|
|
105
111
|
<p class="hint">Inline preview lets you click an article body and write directly on the page. Other fields stay in the sidebar. This preference saves in this browser; content still requires Save.</p>
|
|
112
|
+
{:else if active === "connection"}
|
|
113
|
+
<h3>Connection</h3>
|
|
114
|
+
{#if github}
|
|
115
|
+
<dl class="connection-details">
|
|
116
|
+
<dt>GitHub account</dt>
|
|
117
|
+
<dd>{#if user}{user.name ?? user.login}<small>@{user.login}</small>{:else}No GitHub account connected.{/if}</dd>
|
|
118
|
+
<dt>Repository</dt>
|
|
119
|
+
<dd>{github.repo}</dd>
|
|
120
|
+
<dt>Editing branch</dt>
|
|
121
|
+
<dd>{github.branch}</dd>
|
|
122
|
+
{#if github.publishBranch}
|
|
123
|
+
<dt>Publishing branch</dt>
|
|
124
|
+
<dd>{github.publishBranch}</dd>
|
|
125
|
+
{/if}
|
|
126
|
+
</dl>
|
|
127
|
+
{:else}
|
|
128
|
+
<p>Local mode</p>
|
|
129
|
+
<p class="hint">No GitHub account connected. Content saves to local files when you press Save.</p>
|
|
130
|
+
{/if}
|
|
106
131
|
{:else if active === "media"}
|
|
107
132
|
|
|
108
133
|
<section class="media-settings-section">
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { untrack } from "svelte";
|
|
3
3
|
import Icon from "./Icon.svelte";
|
|
4
|
-
import { patchPreview,
|
|
4
|
+
import { patchPreview, previewUrl, type PreviewConfig, type PreviewTarget } from "../../preview";
|
|
5
|
+
import PreviewFrame from "./PreviewFrame.svelte";
|
|
5
6
|
let { target, config, values, body, isNew = false, devMode = false }: { target: PreviewTarget; config: PreviewConfig; values: Record<string, unknown>; body: "" | import("../../article-body").ArticleBody; isNew?: boolean; devMode?: boolean } = $props();
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const websitePath = $derived(previewUrl(config, target, "website"));
|
|
8
9
|
const key = $derived(`${target.kind}:${target.collection ?? ""}:${target.slug}`);
|
|
9
|
-
let frame: HTMLIFrameElement;
|
|
10
|
+
let frame: HTMLIFrameElement | undefined = $state();
|
|
10
11
|
let loaded = $state(false);
|
|
11
12
|
let error = $state("");
|
|
12
13
|
let path = $state<string | null>(null);
|
|
@@ -14,7 +15,7 @@
|
|
|
14
15
|
void key;
|
|
15
16
|
loaded = false;
|
|
16
17
|
error = "";
|
|
17
|
-
path = untrack(() =>
|
|
18
|
+
path = untrack(() => previewUrl(config, target, "draft", isNew));
|
|
18
19
|
});
|
|
19
20
|
function patch() {
|
|
20
21
|
if (!loaded || !frame?.contentDocument) return;
|
|
@@ -22,9 +23,9 @@
|
|
|
22
23
|
}
|
|
23
24
|
function onload() {
|
|
24
25
|
try {
|
|
25
|
-
const doc = frame
|
|
26
|
+
const doc = frame?.contentDocument;
|
|
26
27
|
doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
|
|
27
|
-
if (!doc
|
|
28
|
+
if (!doc) throw new Error("The prepared page could not be loaded.");
|
|
28
29
|
if (!doc.querySelector('[data-rigel-text], [data-rigel-richtext], [data-rigel-image]')) throw new Error("This page has no prepared Rigel anchors.");
|
|
29
30
|
doc.addEventListener("click", event => event.preventDefault(), true);
|
|
30
31
|
doc.addEventListener("submit", event => event.preventDefault(), true);
|
|
@@ -38,11 +39,11 @@
|
|
|
38
39
|
<div class="site-preview" aria-label="Website preview">
|
|
39
40
|
<div class="preview-heading">
|
|
40
41
|
<strong>Website preview</strong>
|
|
41
|
-
{#if
|
|
42
|
+
{#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}
|
|
42
43
|
</div>
|
|
43
44
|
{#if error}<p role="status" class="preview-error">{error}</p>{/if}
|
|
44
45
|
{#if path}
|
|
45
|
-
{#key key}<
|
|
46
|
+
{#key key}<PreviewFrame bind:frame url={path} title="Live website draft preview" {onload} onloading={() => { loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />{/key}
|
|
46
47
|
<small>Prepared template · Scripts, navigation and forms disabled. Unanchored fields and lists are not previewed.</small>
|
|
47
48
|
{:else}
|
|
48
49
|
<p role="status">{target.kind === "menu" ? "Menu preview is not supported." : "No prepared preview URL is mapped to this content."}</p>
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
|
|
113
113
|
<svelte:window onkeydown={onKeydown} />
|
|
114
114
|
|
|
115
|
-
<button class="search-trigger" aria-label="Search content"
|
|
115
|
+
<button class="search-trigger" aria-label="Search content" data-tooltip="Search content (⌘K)" onclick={() => { active = -1; open = true; }}>
|
|
116
116
|
<SidebarIcon name="magnifying-glass" />
|
|
117
117
|
<span class="sidebar-label">Search…</span>
|
|
118
118
|
<kbd>⌘K</kbd>
|
|
@@ -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";
|
|
@@ -8,11 +9,12 @@
|
|
|
8
9
|
import SchemaPanel from "./SchemaPanel.svelte";
|
|
9
10
|
import SidebarIcon from "./SidebarIcon.svelte";
|
|
10
11
|
import Icon from "./Icon.svelte";
|
|
12
|
+
import PreviewFrame from "./PreviewFrame.svelte";
|
|
11
13
|
import { fieldForPath, replaceAt, type SelectedField } from "./schema-edit";
|
|
12
14
|
import type { ContentDocument, DocumentDef, Schema } from "../../schema/types";
|
|
13
15
|
|
|
14
16
|
let {
|
|
15
|
-
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 = () => {},
|
|
16
18
|
}: {
|
|
17
19
|
docId: string;
|
|
18
20
|
schema?: Schema;
|
|
@@ -21,7 +23,7 @@
|
|
|
21
23
|
previewValues?: Record<string, unknown>;
|
|
22
24
|
documents?: Record<string, Record<string, unknown>>;
|
|
23
25
|
previewPath: string | null;
|
|
24
|
-
|
|
26
|
+
websitePath?: string | null;
|
|
25
27
|
body?: "" | import("../../article-body").ArticleBody;
|
|
26
28
|
properties?: Snippet<[SelectedField | null, number, boolean, () => void]>;
|
|
27
29
|
bodyInline?: boolean;
|
|
@@ -44,6 +46,7 @@
|
|
|
44
46
|
let bodyContents: ChildNode[] = [];
|
|
45
47
|
let bodyAttributes: [string, string | null][] = [];
|
|
46
48
|
let bodyScope: HTMLElement | null = null;
|
|
49
|
+
let removeTooltips: (() => void) | undefined;
|
|
47
50
|
let error = $state("");
|
|
48
51
|
let mode = $state<"browse" | "edit">("edit");
|
|
49
52
|
let panelOpen = $state(true);
|
|
@@ -185,6 +188,7 @@
|
|
|
185
188
|
|
|
186
189
|
function stopBodyEdit() {
|
|
187
190
|
if (!bodyEditor) return;
|
|
191
|
+
removeTooltips?.(); removeTooltips = undefined;
|
|
188
192
|
void unmount(bodyEditor); bodyEditor = undefined;
|
|
189
193
|
bodyThemeObserver?.disconnect(); bodyThemeObserver = undefined;
|
|
190
194
|
editingBody?.replaceChildren(...bodyContents);
|
|
@@ -221,6 +225,7 @@
|
|
|
221
225
|
syncTheme();
|
|
222
226
|
const host = doc.createElement("div"); host.className = "rigel rigel-inline";
|
|
223
227
|
scope.append(host); doc.body.append(scope); bodyScope = scope;
|
|
228
|
+
removeTooltips = tooltipRoot(host).destroy;
|
|
224
229
|
bodyEditor = mount(BlockEditor, { target: host, props: { editableRoot: element, get value() { return body as ArticleBody; }, oninput: (next: ArticleBody) => onbody(next) } });
|
|
225
230
|
bodyThemeObserver = new MutationObserver(syncTheme);
|
|
226
231
|
bodyThemeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme", "data-contrast"] });
|
|
@@ -338,7 +343,6 @@
|
|
|
338
343
|
doc.addEventListener("mouseout", onOut, true);
|
|
339
344
|
doc.addEventListener("submit", (event) => event.preventDefault(), true);
|
|
340
345
|
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
346
|
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
347
|
loaded = true;
|
|
344
348
|
bodyAvailable = !schema && !!body && !!doc.querySelector('[data-rigel-richtext="$body"]');
|
|
@@ -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>
|
|
@@ -448,9 +452,7 @@
|
|
|
448
452
|
{#if !previewPath}
|
|
449
453
|
<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
454
|
{:else}
|
|
451
|
-
|
|
452
|
-
<iframe bind:this={frame} title="Website page editor" sandbox="allow-same-origin" referrerpolicy="no-referrer" src={previewPath} onload={onLoad}></iframe>
|
|
453
|
-
{/key}
|
|
455
|
+
<PreviewFrame bind:frame url={previewPath} {reloadToken} title="Website page editor" onload={onLoad} onloading={() => { stopBodyEdit(); inline = null; loaded = false; error = ""; }} onerror={(message) => { loaded = false; error = message; }} />
|
|
454
456
|
{/if}
|
|
455
457
|
{#if inline}
|
|
456
458
|
<input
|
|
@@ -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 };
|
|
@@ -2,7 +2,7 @@ import type { Manifest } from "../../manifest/types";
|
|
|
2
2
|
import type { Selection } from "../../manifest/navigation";
|
|
3
3
|
import { validateBody, type ArticleBody } from "../../article-body";
|
|
4
4
|
import { validateMenu } from "../../manifest/menu";
|
|
5
|
-
import {
|
|
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
8
|
|
|
@@ -163,7 +163,7 @@ export async function loadManifestRemote(ctx: GitContext, blueprint: Manifest):
|
|
|
163
163
|
for (const file of list(folder,POST_SLUG)) {
|
|
164
164
|
const post = parseJson(await read(file.path));
|
|
165
165
|
if (!isPlainObject(post) || !isPlainObject(post.frontmatter)) throw new Error("Invalid remote article");
|
|
166
|
-
validateBody(post.body);
|
|
166
|
+
validateBody(post.body); validateCollectionValues(collection.fields,post.frontmatter,manifest.documents);
|
|
167
167
|
collection.posts.push({slug:file.slug,frontmatter:post.frontmatter,body:post.body});
|
|
168
168
|
}
|
|
169
169
|
}
|
|
@@ -236,7 +236,7 @@ export async function saveRemoteSelection(ctx: GitContext, manifest: Manifest, s
|
|
|
236
236
|
const folder = cfg.collections.find(c => c.name === selection.collection)?.folder;
|
|
237
237
|
const collection = manifest.collections?.find(c => c.name === selection.collection);
|
|
238
238
|
if (!folder || !collection) throw new Error("Unknown collection");
|
|
239
|
-
validateBody(body);
|
|
239
|
+
validateBody(body); validateCollectionValues(collection.fields,values,manifest.documents ?? []);
|
|
240
240
|
path = filePath(ctx,manifest,folder,selection.slug,"json",POST_SLUG);
|
|
241
241
|
data = {frontmatter:{...values,slug:selection.slug},body};
|
|
242
242
|
} else if (selection.kind === "document") {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import DOMPurify from "dompurify";
|
|
2
|
+
|
|
3
|
+
export async function fetchPreview(url: URL, signal: AbortSignal): Promise<string> {
|
|
4
|
+
let response: Response;
|
|
5
|
+
try {
|
|
6
|
+
response = await fetch(url, { mode: "cors", credentials: "omit", referrerPolicy: "no-referrer", redirect: "error", signal: AbortSignal.any([signal, AbortSignal.timeout(15000)]) });
|
|
7
|
+
} catch {
|
|
8
|
+
throw new Error("Draft preview could not be fetched. Check the URL, HTTPS, CORS (Access-Control-Allow-Origin for this editor), redirects and the editor’s connect-src policy. Authentication cookies are not sent.");
|
|
9
|
+
}
|
|
10
|
+
if (!response.ok) throw new Error(`Draft preview returned HTTP ${response.status}. Check the draft deployment and mapped route.`);
|
|
11
|
+
if (!response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Draft preview did not return HTML. Check the mapped route and Content-Type header.");
|
|
12
|
+
const root = DOMPurify.sanitize(await response.text(), {
|
|
13
|
+
WHOLE_DOCUMENT: true,
|
|
14
|
+
RETURN_DOM: true,
|
|
15
|
+
ADD_TAGS: ["link"],
|
|
16
|
+
FORBID_TAGS: ["script", "base", "meta", "iframe", "object", "embed", "form"],
|
|
17
|
+
FORBID_ATTR: ["srcdoc", "action", "formaction", "autofocus"],
|
|
18
|
+
}) as HTMLElement;
|
|
19
|
+
for (const link of root.querySelectorAll("link")) {
|
|
20
|
+
if (link.getAttribute("rel")?.toLowerCase() !== "stylesheet") link.remove();
|
|
21
|
+
}
|
|
22
|
+
const head = root.querySelector("head")!;
|
|
23
|
+
const base = document.createElement("base"); base.href = url.href;
|
|
24
|
+
const policy = document.createElement("meta"); policy.httpEquiv = "Content-Security-Policy";
|
|
25
|
+
policy.content = "default-src 'none'; script-src 'none'; style-src https: http: 'unsafe-inline'; img-src https: http: data: blob:; font-src https: http: data:; media-src https: http:; connect-src 'none'; frame-src 'none'; object-src 'none'; form-action 'none'; base-uri " + url.origin;
|
|
26
|
+
const referrer = document.createElement("meta"); referrer.name = "referrer"; referrer.content = "no-referrer";
|
|
27
|
+
head.prepend(policy, referrer, base);
|
|
28
|
+
return "<!doctype html>" + root.outerHTML;
|
|
29
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export function slugify(input: string): string {
|
|
2
|
-
|
|
1
|
+
export function slugify(input: string, existingSlugs: Iterable<string> = []): string {
|
|
2
|
+
const base = input
|
|
3
3
|
.normalize("NFKD")
|
|
4
4
|
.replace(/[̀-ͯ]/g, "") // combining marks → strip (é→e)
|
|
5
5
|
.toLowerCase()
|
|
@@ -7,4 +7,9 @@ export function slugify(input: string): string {
|
|
|
7
7
|
.replace(/[^a-z0-9]+/g, "-")
|
|
8
8
|
.replace(/^-+|-+$/g, "")
|
|
9
9
|
.replace(/-{2,}/g, "-");
|
|
10
|
+
if (!base) return "";
|
|
11
|
+
const existing = new Set(existingSlugs);
|
|
12
|
+
let slug = base;
|
|
13
|
+
for (let suffix = 2; existing.has(slug); suffix++) slug = `${base}-${suffix}`;
|
|
14
|
+
return slug;
|
|
10
15
|
}
|
|
@@ -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
|
}
|