@glw907/cairn-cms 0.37.1 → 0.40.0
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/CHANGELOG.md +71 -0
- package/README.md +6 -5
- package/dist/components/AdminLayout.svelte +53 -0
- package/dist/components/ComponentInsertDialog.svelte +27 -13
- package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
- package/dist/components/ConceptList.svelte +13 -3
- package/dist/components/DeleteDialog.svelte +18 -7
- package/dist/components/DeleteDialog.svelte.d.ts +11 -1
- package/dist/components/EditPage.svelte +575 -70
- package/dist/components/EditPage.svelte.d.ts +8 -1
- package/dist/components/EditorToolbar.svelte +202 -29
- package/dist/components/EditorToolbar.svelte.d.ts +12 -4
- package/dist/components/LinkPicker.svelte +14 -6
- package/dist/components/LinkPicker.svelte.d.ts +9 -2
- package/dist/components/LoginPage.svelte +16 -4
- package/dist/components/LoginPage.svelte.d.ts +3 -1
- package/dist/components/MarkdownEditor.svelte +80 -34
- package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
- package/dist/components/MarkdownHelpDialog.svelte +58 -0
- package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
- package/dist/components/RenameDialog.svelte +13 -4
- package/dist/components/RenameDialog.svelte.d.ts +9 -1
- package/dist/components/WebLinkDialog.svelte +89 -0
- package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +353 -4
- package/dist/components/editor-highlight.d.ts +9 -0
- package/dist/components/editor-highlight.js +62 -0
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +22 -0
- package/dist/components/markdown-format.d.ts +1 -1
- package/dist/components/markdown-format.js +91 -12
- package/dist/content/pending.d.ts +9 -0
- package/dist/content/pending.js +24 -0
- package/dist/diagnostics/conditions.js +16 -0
- package/dist/email.d.ts +20 -1
- package/dist/email.js +25 -0
- package/dist/github/branches.d.ts +11 -0
- package/dist/github/branches.js +75 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/auth-routes.d.ts +16 -3
- package/dist/sveltekit/auth-routes.js +47 -28
- package/dist/sveltekit/content-routes.d.ts +22 -1
- package/dist/sveltekit/content-routes.js +312 -72
- package/dist/sveltekit/index.d.ts +1 -1
- package/package.json +3 -2
- package/src/lib/components/AdminLayout.svelte +53 -0
- package/src/lib/components/ComponentInsertDialog.svelte +27 -13
- package/src/lib/components/ConceptList.svelte +13 -3
- package/src/lib/components/DeleteDialog.svelte +18 -7
- package/src/lib/components/EditPage.svelte +575 -70
- package/src/lib/components/EditorToolbar.svelte +202 -29
- package/src/lib/components/LinkPicker.svelte +14 -6
- package/src/lib/components/LoginPage.svelte +16 -4
- package/src/lib/components/MarkdownEditor.svelte +80 -34
- package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
- package/src/lib/components/RenameDialog.svelte +13 -4
- package/src/lib/components/WebLinkDialog.svelte +89 -0
- package/src/lib/components/cairn-admin.css +26 -4
- package/src/lib/components/editor-highlight.ts +67 -0
- package/src/lib/components/markdown-directives.ts +23 -0
- package/src/lib/components/markdown-format.ts +118 -13
- package/src/lib/content/pending.ts +24 -0
- package/src/lib/diagnostics/conditions.ts +16 -0
- package/src/lib/email.ts +31 -1
- package/src/lib/github/branches.ts +83 -0
- package/src/lib/log/events.ts +3 -0
- package/src/lib/sveltekit/auth-routes.ts +59 -29
- package/src/lib/sveltekit/content-routes.ts +391 -73
- package/src/lib/sveltekit/index.ts +1 -1
|
@@ -28,7 +28,14 @@ interface Props {
|
|
|
28
28
|
/**
|
|
29
29
|
* The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
30
30
|
* markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
31
|
-
* `?/save` action
|
|
31
|
+
* `?/save` action. The title field is hoisted above the editor card as the document title; the
|
|
32
|
+
* remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
|
|
33
|
+
* toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
|
|
34
|
+
* swap the editing surface for the rendered preview inside the same card; every visit lands on
|
|
35
|
+
* Write. A sticky glass header carries the breadcrumb, the status badges, the save-state indicator,
|
|
36
|
+
* and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
|
|
37
|
+
* pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
|
|
38
|
+
* transient flashes, and the editor card's footer holds the word count and the Markdown help.
|
|
32
39
|
*/
|
|
33
40
|
declare const EditPage: import("svelte").Component<Props, {}, "">;
|
|
34
41
|
type EditPage = ReturnType<typeof EditPage>;
|
|
@@ -1,35 +1,62 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The editor's
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
|
|
4
|
+
More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
|
|
5
|
+
right. Format buttons ask the host to transform the editor's current selection; the host supplies the
|
|
6
|
+
Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
|
|
7
|
+
are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
10
|
+
import type { Snippet } from 'svelte';
|
|
9
11
|
import type { FormatKind } from './markdown-format.js';
|
|
10
12
|
|
|
11
13
|
interface Props {
|
|
12
14
|
/** Apply a markdown transform to the editor's current selection. */
|
|
13
15
|
format: (kind: FormatKind) => void;
|
|
16
|
+
/** Which pane the editor card shows; the segmented control reflects it. */
|
|
17
|
+
mode: 'write' | 'preview';
|
|
18
|
+
/** Ask the host to switch panes. */
|
|
19
|
+
onMode: (m: 'write' | 'preview') => void;
|
|
20
|
+
/** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
|
|
21
|
+
insertControls?: Snippet;
|
|
14
22
|
}
|
|
15
23
|
|
|
16
|
-
let { format }: Props = $props();
|
|
24
|
+
let { format, mode, onMode, insertControls }: Props = $props();
|
|
17
25
|
|
|
18
26
|
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
19
27
|
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
28
|
+
type ToolButton = { kind: FormatKind; label: string; paths: string[] };
|
|
29
|
+
|
|
30
|
+
// Labels carry the shortcut where one exists. "Ctrl" is written literally for macOS readers
|
|
31
|
+
// too; detecting the platform buys little for what it costs.
|
|
32
|
+
const textButtons: ToolButton[] = [
|
|
33
|
+
{ kind: 'bold', label: 'Bold (Ctrl+B)', paths: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'] },
|
|
34
|
+
{ kind: 'italic', label: 'Italic (Ctrl+I)', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const structureButtons: ToolButton[] = [
|
|
38
|
+
{
|
|
39
|
+
kind: 'h2',
|
|
40
|
+
label: 'Heading',
|
|
41
|
+
paths: ['M4 12h8', 'M4 18V6', 'M12 18V6', 'M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1'],
|
|
42
|
+
},
|
|
24
43
|
{
|
|
25
|
-
kind: '
|
|
26
|
-
label: '
|
|
44
|
+
kind: 'h3',
|
|
45
|
+
label: 'Smaller heading',
|
|
27
46
|
paths: [
|
|
28
|
-
'
|
|
29
|
-
'
|
|
47
|
+
'M4 12h8',
|
|
48
|
+
'M4 18V6',
|
|
49
|
+
'M12 18V6',
|
|
50
|
+
'M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2',
|
|
51
|
+
'M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2',
|
|
30
52
|
],
|
|
31
53
|
},
|
|
32
54
|
{ kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
|
|
55
|
+
{
|
|
56
|
+
kind: 'ol',
|
|
57
|
+
label: 'Numbered list',
|
|
58
|
+
paths: ['M10 12h11', 'M10 18h11', 'M10 6h11', 'M4 10h2', 'M4 6h1v4', 'M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'],
|
|
59
|
+
},
|
|
33
60
|
{
|
|
34
61
|
kind: 'quote',
|
|
35
62
|
label: 'Quote',
|
|
@@ -38,24 +65,170 @@ house style (24x24 viewBox, `currentColor`, round caps), so the row matches the
|
|
|
38
65
|
'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
|
|
39
66
|
],
|
|
40
67
|
},
|
|
41
|
-
{ kind: 'code', label: 'Code', paths: ['M16 18l6-6-6-6', 'M8 6l-6 6 6 6'] },
|
|
42
68
|
];
|
|
69
|
+
|
|
70
|
+
const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
|
|
71
|
+
|
|
72
|
+
const moreItems: { kind: FormatKind; label: string }[] = [
|
|
73
|
+
{ kind: 'strike', label: 'Strikethrough' },
|
|
74
|
+
{ kind: 'code', label: 'Inline code' },
|
|
75
|
+
{ kind: 'codeblock', label: 'Code block' },
|
|
76
|
+
{ kind: 'table', label: 'Table' },
|
|
77
|
+
{ kind: 'hr', label: 'Horizontal rule' },
|
|
78
|
+
{ kind: 'task', label: 'Task list' },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
// The More menu's popover element and its open state, mirrored from the toggle event into
|
|
82
|
+
// aria-expanded on the trigger.
|
|
83
|
+
let moreMenu = $state<HTMLUListElement | null>(null);
|
|
84
|
+
let moreOpen = $state(false);
|
|
85
|
+
|
|
86
|
+
function pickMore(kind: FormatKind) {
|
|
87
|
+
format(kind);
|
|
88
|
+
// Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
|
|
89
|
+
if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let toolbarEl = $state<HTMLDivElement | null>(null);
|
|
93
|
+
// The roving tab stop's position among the strip's enabled top-level controls. The Write/Preview
|
|
94
|
+
// tabs join the toolbar's roving order instead of managing their own arrow keys: the ARIA toolbar
|
|
95
|
+
// pattern allows either, and one arrow model over the whole strip is the simpler of the two.
|
|
96
|
+
let roving = $state(0);
|
|
97
|
+
|
|
98
|
+
/** The strip's top-level controls in DOM order: every enabled button outside the More menu's
|
|
99
|
+
* popover and outside the insert controls' dialogs. The host's insertControls render their own
|
|
100
|
+
* buttons, so the set is queried, not declared. */
|
|
101
|
+
function rovingControls(): HTMLElement[] {
|
|
102
|
+
if (!toolbarEl) return [];
|
|
103
|
+
return Array.from(toolbarEl.querySelectorAll<HTMLElement>('button')).filter(
|
|
104
|
+
(el) => !el.hasAttribute('disabled') && !el.closest('[popover]') && !el.closest('dialog'),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Keep exactly one tab stop. Runs on mount (the snippet's buttons render synchronously, so the
|
|
109
|
+
// first pass sees them) and again whenever the stop moves or a mode switch changes which
|
|
110
|
+
// controls are enabled.
|
|
111
|
+
$effect(() => {
|
|
112
|
+
void mode;
|
|
113
|
+
const items = rovingControls();
|
|
114
|
+
if (items.length === 0) return;
|
|
115
|
+
const stop = Math.min(roving, items.length - 1);
|
|
116
|
+
for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function onToolbarKeydown(e: KeyboardEvent) {
|
|
120
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
121
|
+
// Leave the keys alone inside the open More menu; its items are not part of the roving order.
|
|
122
|
+
if ((e.target as HTMLElement | null)?.closest('[popover]')) return;
|
|
123
|
+
const items = rovingControls();
|
|
124
|
+
if (items.length === 0) return;
|
|
125
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
126
|
+
const base = current >= 0 ? current : Math.min(roving, items.length - 1);
|
|
127
|
+
roving = (base + (e.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
|
|
128
|
+
items[roving].focus();
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
}
|
|
43
131
|
</script>
|
|
44
132
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
133
|
+
{#snippet strokeIcon(paths: string[])}
|
|
134
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
135
|
+
{#each paths as d (d)}
|
|
136
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
|
|
137
|
+
{/each}
|
|
138
|
+
</svg>
|
|
139
|
+
{/snippet}
|
|
140
|
+
|
|
141
|
+
{#snippet glyphButton(button: ToolButton)}
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
145
|
+
aria-label={button.label}
|
|
146
|
+
title={button.label}
|
|
147
|
+
disabled={mode === 'preview'}
|
|
148
|
+
onclick={() => format(button.kind)}
|
|
149
|
+
>
|
|
150
|
+
{@render strokeIcon(button.paths)}
|
|
151
|
+
</button>
|
|
152
|
+
{/snippet}
|
|
153
|
+
|
|
154
|
+
{#snippet tab(m: 'write' | 'preview', label: string)}
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
role="tab"
|
|
158
|
+
id={`cairn-tab-${m}`}
|
|
159
|
+
aria-selected={mode === m}
|
|
160
|
+
aria-controls={`cairn-pane-${m}`}
|
|
161
|
+
class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
|
|
162
|
+
onclick={() => onMode(m)}
|
|
163
|
+
>
|
|
164
|
+
{label}
|
|
165
|
+
</button>
|
|
166
|
+
{/snippet}
|
|
167
|
+
|
|
168
|
+
<!-- tabindex -1: the container is never a tab stop itself; the roving tabindex on its controls
|
|
169
|
+
carries keyboard entry, per the ARIA toolbar pattern. -->
|
|
170
|
+
<div
|
|
171
|
+
bind:this={toolbarEl}
|
|
172
|
+
class="bg-base-100 flex flex-wrap items-center gap-1 border-b border-[var(--cairn-card-border)] p-1"
|
|
173
|
+
role="toolbar"
|
|
174
|
+
aria-label="Formatting"
|
|
175
|
+
tabindex="-1"
|
|
176
|
+
onkeydown={onToolbarKeydown}
|
|
177
|
+
>
|
|
178
|
+
{#each textButtons as button (button.kind)}
|
|
179
|
+
{@render glyphButton(button)}
|
|
180
|
+
{/each}
|
|
181
|
+
|
|
182
|
+
<div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
|
|
183
|
+
|
|
184
|
+
{#each structureButtons as button (button.kind)}
|
|
185
|
+
{@render glyphButton(button)}
|
|
60
186
|
{/each}
|
|
187
|
+
<!-- The More menu is a DaisyUI v5 popover dropdown: click to open (never focus-in-transit),
|
|
188
|
+
Escape and light dismiss from the Popover API, and the anchor-name/position-anchor pair
|
|
189
|
+
places the panel under its trigger. -->
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
193
|
+
aria-label="More formatting"
|
|
194
|
+
title="More formatting"
|
|
195
|
+
aria-expanded={moreOpen}
|
|
196
|
+
popovertarget="cairn-more-formatting-menu"
|
|
197
|
+
style="anchor-name:--cairn-more-formatting"
|
|
198
|
+
disabled={mode === 'preview'}
|
|
199
|
+
>
|
|
200
|
+
{@render strokeIcon(ellipsisPaths)}
|
|
201
|
+
</button>
|
|
202
|
+
<ul
|
|
203
|
+
bind:this={moreMenu}
|
|
204
|
+
popover="auto"
|
|
205
|
+
id="cairn-more-formatting-menu"
|
|
206
|
+
style="position-anchor:--cairn-more-formatting"
|
|
207
|
+
ontoggle={(e) => (moreOpen = e.newState === 'open')}
|
|
208
|
+
class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
|
|
209
|
+
>
|
|
210
|
+
{#each moreItems as item (item.kind)}
|
|
211
|
+
<li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
|
|
212
|
+
{/each}
|
|
213
|
+
</ul>
|
|
214
|
+
|
|
215
|
+
{#if insertControls}
|
|
216
|
+
<div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
|
|
217
|
+
<!-- The host's controls carry their own disabled state in Preview; this wrapper just keeps
|
|
218
|
+
any stray pointer target in the snippet inert while the pane is read-only. -->
|
|
219
|
+
<div
|
|
220
|
+
class="flex items-center gap-1"
|
|
221
|
+
class:pointer-events-none={mode === 'preview'}
|
|
222
|
+
class:opacity-50={mode === 'preview'}
|
|
223
|
+
>
|
|
224
|
+
{@render insertControls()}
|
|
225
|
+
</div>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
<!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
|
|
229
|
+
the strip inside the same editor card. -->
|
|
230
|
+
<div class="join ml-auto" role="tablist" aria-label="Editor view">
|
|
231
|
+
{@render tab('write', 'Write')}
|
|
232
|
+
{@render tab('preview', 'Preview')}
|
|
233
|
+
</div>
|
|
61
234
|
</div>
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
import type { FormatKind } from './markdown-format.js';
|
|
2
3
|
interface Props {
|
|
3
4
|
/** Apply a markdown transform to the editor's current selection. */
|
|
4
5
|
format: (kind: FormatKind) => void;
|
|
6
|
+
/** Which pane the editor card shows; the segmented control reflects it. */
|
|
7
|
+
mode: 'write' | 'preview';
|
|
8
|
+
/** Ask the host to switch panes. */
|
|
9
|
+
onMode: (m: 'write' | 'preview') => void;
|
|
10
|
+
/** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
|
|
11
|
+
insertControls?: Snippet;
|
|
5
12
|
}
|
|
6
13
|
/**
|
|
7
|
-
* The editor's
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
14
|
+
* The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
|
|
15
|
+
* More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
|
|
16
|
+
* right. Format buttons ask the host to transform the editor's current selection; the host supplies the
|
|
17
|
+
* Insert group through the `insertControls` snippet so the strip stays free of picker wiring. The glyphs
|
|
18
|
+
* are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`, round caps).
|
|
11
19
|
*/
|
|
12
20
|
declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
|
|
13
21
|
type EditorToolbar = ReturnType<typeof EditorToolbar>;
|
|
@@ -4,7 +4,7 @@ The "Link to page" control and its modal. It lists the site's posts and pages fr
|
|
|
4
4
|
manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
|
|
5
5
|
showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
|
|
6
6
|
editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
|
|
7
|
-
conventions. The plain-URL link
|
|
7
|
+
conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
|
|
8
8
|
-->
|
|
9
9
|
<script lang="ts">
|
|
10
10
|
import type { LinkTarget } from '../content/manifest.js';
|
|
@@ -15,9 +15,14 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
|
|
|
15
15
|
linkTargets: LinkTarget[];
|
|
16
16
|
/** Insert an inline cairn link at the editor cursor. */
|
|
17
17
|
insert: (href: string, title: string) => void;
|
|
18
|
+
/** Disable the trigger; the host sets it while Preview shows. */
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
/** Render the built-in Link to page trigger. False mounts only the dialog, for a host that
|
|
21
|
+
* supplies its own trigger and opens the dialog through the exported open(). */
|
|
22
|
+
trigger?: boolean;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
|
-
let { linkTargets, insert }: Props = $props();
|
|
25
|
+
let { linkTargets, insert, disabled = false, trigger = true }: Props = $props();
|
|
21
26
|
|
|
22
27
|
let dialog = $state<HTMLDialogElement | null>(null);
|
|
23
28
|
let query = $state('');
|
|
@@ -48,7 +53,8 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
|
|
|
48
53
|
.sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
|
|
49
54
|
});
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
/** Open the picker programmatically, for a host that drives it without the trigger. */
|
|
57
|
+
export function open() {
|
|
52
58
|
query = '';
|
|
53
59
|
dialog?.showModal();
|
|
54
60
|
}
|
|
@@ -61,9 +67,11 @@ conventions. The plain-URL link stays the toolbar's link button; this is for an
|
|
|
61
67
|
}
|
|
62
68
|
</script>
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
Link to page
|
|
66
|
-
|
|
70
|
+
{#if trigger}
|
|
71
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" {disabled} onclick={open}>
|
|
72
|
+
Link to page
|
|
73
|
+
</button>
|
|
74
|
+
{/if}
|
|
67
75
|
|
|
68
76
|
<dialog class="modal" aria-labelledby="cairn-link-dialog-title" bind:this={dialog}>
|
|
69
77
|
<div class="modal-box">
|
|
@@ -4,14 +4,21 @@ interface Props {
|
|
|
4
4
|
linkTargets: LinkTarget[];
|
|
5
5
|
/** Insert an inline cairn link at the editor cursor. */
|
|
6
6
|
insert: (href: string, title: string) => void;
|
|
7
|
+
/** Disable the trigger; the host sets it while Preview shows. */
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
/** Render the built-in Link to page trigger. False mounts only the dialog, for a host that
|
|
10
|
+
* supplies its own trigger and opens the dialog through the exported open(). */
|
|
11
|
+
trigger?: boolean;
|
|
7
12
|
}
|
|
8
13
|
/**
|
|
9
14
|
* The "Link to page" control and its modal. It lists the site's posts and pages from the committed
|
|
10
15
|
* manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
|
|
11
16
|
* showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
|
|
12
17
|
* editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
|
|
13
|
-
* conventions. The plain-URL link
|
|
18
|
+
* conventions. The plain-URL link is the toolbar's Web link dialog; this is for an internal target.
|
|
14
19
|
*/
|
|
15
|
-
declare const LinkPicker: import("svelte").Component<Props, {
|
|
20
|
+
declare const LinkPicker: import("svelte").Component<Props, {
|
|
21
|
+
open: () => void;
|
|
22
|
+
}, "">;
|
|
16
23
|
type LinkPicker = ReturnType<typeof LinkPicker>;
|
|
17
24
|
export default LinkPicker;
|
|
@@ -17,8 +17,9 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
17
17
|
interface Props {
|
|
18
18
|
/** The login load's data: the site name, an optional error, and the CSRF token. */
|
|
19
19
|
data: { siteName: string; error: string | null; csrf: string };
|
|
20
|
-
/** The action result
|
|
21
|
-
|
|
20
|
+
/** The action result. `sent` is true once a request was accepted; `status` discriminates the
|
|
21
|
+
* neutral, send-error, and throttled outcomes. */
|
|
22
|
+
form: { sent?: boolean; status?: 'sent' | 'send_error' | 'throttled' } | null;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
let { data, form }: Props = $props();
|
|
@@ -52,7 +53,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
52
53
|
<div data-theme="cairn-admin" bind:this={rootEl}>
|
|
53
54
|
<div class="flex min-h-screen flex-col items-center justify-center gap-6 bg-base-200 p-4 text-base-content">
|
|
54
55
|
<div class="w-full max-w-sm rounded-box border border-[var(--cairn-card-border)] bg-base-100 p-7 shadow-[var(--cairn-shadow)]">
|
|
55
|
-
{#if form?.sent && !dismissed}
|
|
56
|
+
{#if (form?.status === 'sent' || form?.sent) && !dismissed}
|
|
56
57
|
<!-- The confirmation is a centered moment: brand, then the mail mark, heading, and one line of
|
|
57
58
|
instruction. The fallback help sits in a gentle inset note below. -->
|
|
58
59
|
<div role="status" class="flex flex-col items-center text-center">
|
|
@@ -86,7 +87,18 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
86
87
|
<div class="mb-6 flex justify-center">{@render brand()}</div>
|
|
87
88
|
<h1 class="text-center text-lg font-semibold">Sign in to {data.siteName}</h1>
|
|
88
89
|
<p class="mt-1 mb-5 text-center text-sm text-[var(--color-muted)]">Enter your email. We'll send a one-time sign-in link.</p>
|
|
89
|
-
{#if
|
|
90
|
+
{#if form?.status === 'send_error'}
|
|
91
|
+
<div role="alert" class="alert alert-warning mb-3 text-sm">
|
|
92
|
+
We're having trouble sending sign-in links right now. Please contact the site owner.
|
|
93
|
+
</div>
|
|
94
|
+
{:else if form?.status === 'throttled'}
|
|
95
|
+
<div role="status" class="alert mb-3 text-sm">
|
|
96
|
+
You requested a link recently. Check your inbox, or wait a minute and try again.
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
<!-- A fresh action result supersedes the GET-time error, so a resubmit into a throttle or a
|
|
100
|
+
send failure never shows the stale expired-link alert alongside the new state. -->
|
|
101
|
+
{#if data.error && !form?.status}
|
|
90
102
|
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
|
|
91
103
|
{/if}
|
|
92
104
|
<form method="POST" class="flex flex-col gap-3">
|
|
@@ -6,9 +6,11 @@ interface Props {
|
|
|
6
6
|
error: string | null;
|
|
7
7
|
csrf: string;
|
|
8
8
|
};
|
|
9
|
-
/** The action result
|
|
9
|
+
/** The action result. `sent` is true once a request was accepted; `status` discriminates the
|
|
10
|
+
* neutral, send-error, and throttled outcomes. */
|
|
10
11
|
form: {
|
|
11
12
|
sent?: boolean;
|
|
13
|
+
status?: 'sent' | 'send_error' | 'throttled';
|
|
12
14
|
} | null;
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
3
|
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
|
|
4
|
-
value and
|
|
4
|
+
value and cursor-edit callbacks. CodeMirror is client-only, so it mounts after the component does
|
|
5
5
|
through a dynamic import; until then a plain textarea carries the value so the form still submits, and
|
|
6
|
-
the hidden field mirrors the value throughout. The
|
|
7
|
-
|
|
6
|
+
the hidden field mirrors the value throughout. The host owns the toolbar and the card chrome, driving
|
|
7
|
+
selection transforms through the registerFormat seam; the design-accurate preview lives in EditPage
|
|
8
|
+
through the adapter's render. Swapping the editor stays a one-file change.
|
|
8
9
|
-->
|
|
9
10
|
<script lang="ts">
|
|
10
11
|
import { onMount, onDestroy } from 'svelte';
|
|
11
|
-
import
|
|
12
|
-
import { applyMarkdownFormat, insertInlineLink, type FormatKind } from './markdown-format.js';
|
|
12
|
+
import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
@@ -20,12 +20,24 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
20
20
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
21
21
|
/** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
|
|
22
22
|
registerInsertLink?: (insert: (href: string, title: string) => void) => void;
|
|
23
|
+
/** Receives a `() => string` returning the selected text; the web link dialog reads it. */
|
|
24
|
+
registerGetSelection?: (get: () => string) => void;
|
|
25
|
+
/** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
|
|
26
|
+
registerFormat?: (format: (kind: FormatKind) => void) => void;
|
|
23
27
|
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
24
28
|
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
25
29
|
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
let {
|
|
32
|
+
let {
|
|
33
|
+
value = $bindable(),
|
|
34
|
+
name,
|
|
35
|
+
registerInsert,
|
|
36
|
+
registerInsertLink,
|
|
37
|
+
registerGetSelection,
|
|
38
|
+
registerFormat,
|
|
39
|
+
completionSources = [],
|
|
40
|
+
}: Props = $props();
|
|
29
41
|
|
|
30
42
|
let host = $state<HTMLDivElement | null>(null);
|
|
31
43
|
let mounted = $state(false);
|
|
@@ -41,19 +53,47 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
41
53
|
const commandsMod = await import('@codemirror/commands');
|
|
42
54
|
const languageMod = await import('@codemirror/language');
|
|
43
55
|
const autocompleteMod = await import('@codemirror/autocomplete');
|
|
56
|
+
const highlightMod = await import('./editor-highlight.js');
|
|
44
57
|
|
|
45
58
|
if (!host) return;
|
|
46
59
|
|
|
47
60
|
const { EditorView, keymap } = viewMod;
|
|
61
|
+
// Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
|
|
62
|
+
// tooltip above all) renders dark-on-dark instead of light-on-dark.
|
|
63
|
+
const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
|
|
64
|
+
// The directive machinery ink, one rule for the fence, leaf, and inline decorations.
|
|
65
|
+
const directiveInk = {
|
|
66
|
+
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
67
|
+
color: 'var(--color-accent)',
|
|
68
|
+
};
|
|
48
69
|
const theme = EditorView.theme(
|
|
49
70
|
{
|
|
50
|
-
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.
|
|
51
|
-
|
|
71
|
+
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.9375rem' },
|
|
72
|
+
// The 50vh floor keeps a short entry reading as a writing surface, and because the
|
|
73
|
+
// contenteditable content area carries the height, a click in the empty space below the
|
|
74
|
+
// text still lands in the editor and focuses it.
|
|
75
|
+
'.cm-content': {
|
|
76
|
+
fontFamily: 'ui-monospace, monospace',
|
|
77
|
+
padding: '0.875rem 1.25rem',
|
|
78
|
+
lineHeight: '1.8',
|
|
79
|
+
minHeight: '50vh',
|
|
80
|
+
},
|
|
52
81
|
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
53
|
-
|
|
82
|
+
// A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
|
|
83
|
+
// focused text-entry surface as keyboard-modal, so a 2px ring would shout through every
|
|
84
|
+
// typing session. One subtle line keeps focus visible (WCAG 2.4.7) without competing
|
|
85
|
+
// with the manuscript. The 70% primary mix clears the 3:1 non-text contrast floor
|
|
86
|
+
// (WCAG 1.4.11) on both themes (3.23:1 light, 3.32:1 dark), where 45% measured near 2:1.
|
|
87
|
+
'&.cm-focused': {
|
|
88
|
+
outline: '1px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
|
|
89
|
+
outlineOffset: '-1px',
|
|
90
|
+
},
|
|
54
91
|
'.cm-line': { padding: '0' },
|
|
92
|
+
'.cm-cairn-directive-fence': directiveInk,
|
|
93
|
+
'.cm-cairn-directive-leaf': directiveInk,
|
|
94
|
+
'.cm-cairn-directive-inline': directiveInk,
|
|
55
95
|
},
|
|
56
|
-
{ dark:
|
|
96
|
+
{ dark: isDark },
|
|
57
97
|
);
|
|
58
98
|
|
|
59
99
|
view = new EditorView({
|
|
@@ -70,7 +110,9 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
70
110
|
[autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
|
|
71
111
|
: []),
|
|
72
112
|
EditorView.lineWrapping,
|
|
73
|
-
languageMod.syntaxHighlighting(
|
|
113
|
+
languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
|
|
114
|
+
highlightMod.cairnDirectivePlugin(),
|
|
115
|
+
EditorView.contentAttributes.of({ spellcheck: 'true', autocorrect: 'on', autocapitalize: 'sentences' }),
|
|
74
116
|
theme,
|
|
75
117
|
EditorView.updateListener.of((update) => {
|
|
76
118
|
if (update.docChanged) value = update.state.doc.toString();
|
|
@@ -81,6 +123,8 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
81
123
|
|
|
82
124
|
registerInsert?.(insertAtCursor);
|
|
83
125
|
registerInsertLink?.(insertLink);
|
|
126
|
+
registerGetSelection?.(selectedText);
|
|
127
|
+
registerFormat?.(applyFormat);
|
|
84
128
|
mounted = true;
|
|
85
129
|
});
|
|
86
130
|
|
|
@@ -108,6 +152,20 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
108
152
|
view.focus();
|
|
109
153
|
}
|
|
110
154
|
|
|
155
|
+
// Run a pure selection transform over the mounted editor: hand it the document and selection,
|
|
156
|
+
// dispatch the document and selection it returns, and put focus back on the surface.
|
|
157
|
+
function transformSelection(transform: (doc: string, from: number, to: number) => FormatResult) {
|
|
158
|
+
if (!view) return;
|
|
159
|
+
const { from, to } = view.state.selection.main;
|
|
160
|
+
const doc = view.state.doc.toString();
|
|
161
|
+
const next = transform(doc, from, to);
|
|
162
|
+
view.dispatch({
|
|
163
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
164
|
+
selection: { anchor: next.from, head: next.to },
|
|
165
|
+
});
|
|
166
|
+
view.focus();
|
|
167
|
+
}
|
|
168
|
+
|
|
111
169
|
function insertLink(href: string, title: string) {
|
|
112
170
|
if (!view) {
|
|
113
171
|
// The editor has not mounted yet; append the link to the raw value so a pick is never lost,
|
|
@@ -116,35 +174,23 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
116
174
|
value = value ? `${value} ${link}` : link;
|
|
117
175
|
return;
|
|
118
176
|
}
|
|
177
|
+
transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function selectedText(): string {
|
|
181
|
+
if (!view) return '';
|
|
119
182
|
const { from, to } = view.state.selection.main;
|
|
120
|
-
|
|
121
|
-
const next = insertInlineLink(doc, from, to, href, title);
|
|
122
|
-
view.dispatch({
|
|
123
|
-
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
124
|
-
selection: { anchor: next.from, head: next.to },
|
|
125
|
-
});
|
|
126
|
-
view.focus();
|
|
183
|
+
return view.state.sliceDoc(from, to);
|
|
127
184
|
}
|
|
128
185
|
|
|
129
186
|
function applyFormat(kind: FormatKind) {
|
|
130
|
-
|
|
131
|
-
const { from, to } = view.state.selection.main;
|
|
132
|
-
const doc = view.state.doc.toString();
|
|
133
|
-
const next = applyMarkdownFormat(doc, from, to, kind);
|
|
134
|
-
view.dispatch({
|
|
135
|
-
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
136
|
-
selection: { anchor: next.from, head: next.to },
|
|
137
|
-
});
|
|
138
|
-
view.focus();
|
|
187
|
+
transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
|
|
139
188
|
}
|
|
140
189
|
</script>
|
|
141
190
|
|
|
142
191
|
<input type="hidden" {name} {value} />
|
|
143
192
|
|
|
144
|
-
<div
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
<textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
|
|
149
|
-
{/if}
|
|
150
|
-
</div>
|
|
193
|
+
<div bind:this={host}></div>
|
|
194
|
+
{#if !mounted}
|
|
195
|
+
<textarea class="textarea min-h-[50vh] w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
|
|
196
|
+
{/if}
|