@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.
@@ -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} title={triggerLabel} onmousedown={event => event.preventDefault()} onclick={launch}><Icon name="image" size={18} /></button>
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" title="Remove image" onclick={() => onpick?.("", "")}><Icon name="x" size={14} /></button>{/if}
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" title="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" title="List view" aria-label="List view" aria-pressed={view === "list"} onclick={() => setView("list")}><Icon name="listBullets" size={18} /></button>
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" title="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" }) }))]} />
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" title={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>
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}} title={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>
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" title="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)))} />
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} title={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>
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} title={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>
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" title="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>
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" title="Apply link" onclick={() => link()}><Icon name="check" size={16} /></button>
195
- <button type="button" aria-label="Remove link" title="Remove link" disabled={!activeMark("link")} onclick={() => link(true)}><Icon name="linkBreak" size={16} /></button>
196
- <button type="button" aria-label="Close link" title="Close link" onclick={() => { linkOpen = false; linkButton.focus(); }}><Icon name="x" size={16} /></button>
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`} title="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`} title="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}`} title="Duplicate" onclick={() => onvalue(duplicateItem(itemList, index))}>⧉</button>
133
- <button type="button" aria-label={`Remove ${field.label} item ${index + 1}`} title="Remove" onclick={() => onvalue(itemList.filter((_, position) => position !== index))}>×</button>
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`} title="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`} title="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)}`} title="Duplicate" onclick={() => setSections(duplicateItem(sections, index) as Record<string, unknown>[])}>⧉</button>
125
- <button type="button" aria-label={`Remove ${sectionLabel(schema, section._type)}`} title="Remove" onclick={() => removeSection(index)}>×</button>
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`} {title} {disabled} onclick={event => { event.preventDefault(); menu.matches(":popover-open") ? close() : void reveal(); }} onkeydown={onkey}>
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}.`} title={status}>
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, previewPath, type PreviewConfig, type PreviewTarget } from "../../preview";
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 livePath = $derived(previewPath(config, target));
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(() => previewPath(config, target, isNew));
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.contentDocument;
26
+ const doc = frame?.contentDocument;
26
27
  doc?.addEventListener("contextmenu", event => { if (!devMode) event.preventDefault(); }, true);
27
- if (!doc || new URL(doc.URL).pathname !== path) throw new Error("The prepared same-origin page could not be loaded.");
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 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}
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}<iframe bind:this={frame} title="Live website draft preview" sandbox="allow-same-origin" referrerpolicy="no-referrer" src={path} {onload}></iframe>{/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" title="Search content (⌘K)" onclick={() => { active = -1; open = true; }}>
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, livePath = previewPath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
17
+ docId, schema, docType, values, previewValues, documents = {}, previewPath, websitePath, body = "", bodyInline = false, onbody = () => {}, properties, creating, devMode, reloadToken = 0, onchange, oneditdocument = () => {},
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
- livePath?: string | null;
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 livePath}<a class="icon-btn visual-live" href={livePath} target="_blank" rel="noopener noreferrer" aria-label="View live page" title="View live page"><Icon name="external" size={18} /></a>{/if}
439
- <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" title="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
442
+ {#if websitePath}<a class="icon-btn visual-live" href={websitePath} target="_blank" rel="noopener noreferrer" aria-label="Preview on website" data-tooltip="Preview on website"><Icon name="external" size={18} /></a>{/if}
443
+ <button type="button" class="icon-btn" aria-pressed={panelOpen} aria-label="Toggle properties panel" data-tooltip="Toggle properties panel" onclick={() => (panelOpen = !panelOpen)}>
440
444
  <SidebarIcon name="square-half" />
441
445
  </button>
442
446
  </div>
@@ -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
- {#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}
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 user = { login: rawUser.login, avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
88
+ const name = typeof rawUser.name === "string" ? rawUser.name.trim() : "";
89
+ const user = { login: rawUser.login, ...(name ? { name } : {}), avatar_url: rawUser.avatar_url, html_url: rawUser.html_url };
89
90
  const installUrl = `https://github.com/apps/${appSlug}/installations/new`;
90
91
  const result: GitHubAccess = { user, status: "installation-required", installUrl, installation: null, repository: null };
91
92
  type Installation = { id: number; app_slug: string; account?: { login?: string }; html_url?: string; permissions?: { contents?: string }; suspended_at?: string | null };
@@ -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 { validateCollectionReferences } from "../../manifest/references";
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); validateCollectionReferences(collection.fields,post.frontmatter,manifest.documents);
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); validateCollectionReferences(collection.fields,values,manifest.documents ?? []);
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
- return input
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
- export function readStorage(name: "settings" | "keys" | "mediaView"): string | null {
1
+ type SessionConfig = { authOrigin: string; repo: string; branch: string; appSlug: string };
2
+ type Session = { accessToken: string; expiresAt: number };
3
+
4
+ function sessionScope(config: SessionConfig) {
5
+ return JSON.stringify([config.authOrigin, config.repo, config.branch, config.appSlug]);
6
+ }
7
+
8
+ export function clearSession() {
9
+ try { localStorage.removeItem("rigelcms.session"); } catch {}
10
+ }
11
+
12
+ export function readSession(config: SessionConfig): Session | null {
13
+ try {
14
+ const saved = JSON.parse(localStorage.getItem("rigelcms.session") ?? "null");
15
+ if (saved?.version === 1 && saved.scope === sessionScope(config)
16
+ && typeof saved.accessToken === "string" && /^ghu_[a-zA-Z0-9_]{1,4092}$/.test(saved.accessToken)
17
+ && Number.isSafeInteger(saved.expiresAt) && saved.expiresAt > Date.now() + 30_000 && saved.expiresAt <= Date.now() + 86_400_000) {
18
+ return { accessToken: saved.accessToken, expiresAt: saved.expiresAt };
19
+ }
20
+ } catch {}
21
+ clearSession();
22
+ return null;
23
+ }
24
+
25
+ export function writeSession(config: SessionConfig, session: Session) {
26
+ try { writeStorage("session", { version: 1, scope: sessionScope(config), accessToken: session.accessToken, expiresAt: session.expiresAt }); } catch {}
27
+ }
28
+
29
+ export function readStorage(name: "settings" | "keys" | "mediaView" | "session"): string | null {
2
30
  return localStorage.getItem(`rigelcms.${name}`) ?? localStorage.getItem(`rigel.${name}`);
3
31
  }
4
32
 
5
- export function writeStorage(name: "settings" | "keys" | "mediaView", value: unknown) {
33
+ export function writeStorage(name: "settings" | "keys" | "mediaView" | "session", value: unknown) {
6
34
  localStorage.setItem(`rigelcms.${name}`, JSON.stringify(value));
7
35
  localStorage.removeItem(`rigel.${name}`);
8
36
  }