@glw907/cairn-cms 0.38.0 → 0.41.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 +94 -0
- package/README.md +7 -6
- 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 +22 -3
- package/dist/components/DeleteDialog.svelte +18 -7
- package/dist/components/DeleteDialog.svelte.d.ts +11 -1
- package/dist/components/EditPage.svelte +604 -75
- package/dist/components/EditPage.svelte.d.ts +8 -1
- package/dist/components/EditorToolbar.svelte +206 -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/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/link-completion.js +10 -3
- 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.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/github/branches.d.ts +11 -0
- package/dist/github/branches.js +75 -0
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +22 -1
- package/dist/sveltekit/content-routes.js +320 -72
- package/package.json +8 -5
- package/src/lib/components/AdminLayout.svelte +53 -0
- package/src/lib/components/ComponentInsertDialog.svelte +27 -13
- package/src/lib/components/ConceptList.svelte +22 -3
- package/src/lib/components/DeleteDialog.svelte +18 -7
- package/src/lib/components/EditPage.svelte +604 -75
- package/src/lib/components/EditorToolbar.svelte +206 -29
- package/src/lib/components/LinkPicker.svelte +14 -6
- 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/link-completion.ts +10 -3
- 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 +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/github/branches.ts +83 -0
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/log/events.ts +4 -0
- package/src/lib/sveltekit/content-routes.ts +400 -73
|
@@ -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,174 @@ 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
|
+
// Write the clamp back so the stored stop never drifts from the displayed one across a
|
|
117
|
+
// Preview round trip. The effect reads roving, so the guarded write re-runs it once and
|
|
118
|
+
// converges (the second pass computes the same stop and writes nothing).
|
|
119
|
+
if (stop !== roving) roving = stop;
|
|
120
|
+
for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
function onToolbarKeydown(e: KeyboardEvent) {
|
|
124
|
+
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
|
|
125
|
+
// Leave the keys alone inside the open More menu; its items are not part of the roving order.
|
|
126
|
+
if ((e.target as HTMLElement | null)?.closest('[popover]')) return;
|
|
127
|
+
const items = rovingControls();
|
|
128
|
+
if (items.length === 0) return;
|
|
129
|
+
const current = items.indexOf(document.activeElement as HTMLElement);
|
|
130
|
+
const base = current >= 0 ? current : Math.min(roving, items.length - 1);
|
|
131
|
+
roving = (base + (e.key === 'ArrowRight' ? 1 : -1) + items.length) % items.length;
|
|
132
|
+
items[roving].focus();
|
|
133
|
+
e.preventDefault();
|
|
134
|
+
}
|
|
43
135
|
</script>
|
|
44
136
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
137
|
+
{#snippet strokeIcon(paths: string[])}
|
|
138
|
+
<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">
|
|
139
|
+
{#each paths as d (d)}
|
|
140
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
|
|
141
|
+
{/each}
|
|
142
|
+
</svg>
|
|
143
|
+
{/snippet}
|
|
144
|
+
|
|
145
|
+
{#snippet glyphButton(button: ToolButton)}
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
149
|
+
aria-label={button.label}
|
|
150
|
+
title={button.label}
|
|
151
|
+
disabled={mode === 'preview'}
|
|
152
|
+
onclick={() => format(button.kind)}
|
|
153
|
+
>
|
|
154
|
+
{@render strokeIcon(button.paths)}
|
|
155
|
+
</button>
|
|
156
|
+
{/snippet}
|
|
157
|
+
|
|
158
|
+
{#snippet tab(m: 'write' | 'preview', label: string)}
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
role="tab"
|
|
162
|
+
id={`cairn-tab-${m}`}
|
|
163
|
+
aria-selected={mode === m}
|
|
164
|
+
aria-controls={`cairn-pane-${m}`}
|
|
165
|
+
class="join-item btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
|
|
166
|
+
onclick={() => onMode(m)}
|
|
167
|
+
>
|
|
168
|
+
{label}
|
|
169
|
+
</button>
|
|
170
|
+
{/snippet}
|
|
171
|
+
|
|
172
|
+
<!-- tabindex -1: the container is never a tab stop itself; the roving tabindex on its controls
|
|
173
|
+
carries keyboard entry, per the ARIA toolbar pattern. -->
|
|
174
|
+
<div
|
|
175
|
+
bind:this={toolbarEl}
|
|
176
|
+
class="bg-base-100 flex flex-wrap items-center gap-1 border-b border-[var(--cairn-card-border)] p-1"
|
|
177
|
+
role="toolbar"
|
|
178
|
+
aria-label="Formatting"
|
|
179
|
+
tabindex="-1"
|
|
180
|
+
onkeydown={onToolbarKeydown}
|
|
181
|
+
>
|
|
182
|
+
{#each textButtons as button (button.kind)}
|
|
183
|
+
{@render glyphButton(button)}
|
|
184
|
+
{/each}
|
|
185
|
+
|
|
186
|
+
<div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
|
|
187
|
+
|
|
188
|
+
{#each structureButtons as button (button.kind)}
|
|
189
|
+
{@render glyphButton(button)}
|
|
60
190
|
{/each}
|
|
191
|
+
<!-- The More menu is a DaisyUI v5 popover dropdown: click to open (never focus-in-transit),
|
|
192
|
+
Escape and light dismiss from the Popover API, and the anchor-name/position-anchor pair
|
|
193
|
+
places the panel under its trigger. -->
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
197
|
+
aria-label="More formatting"
|
|
198
|
+
title="More formatting"
|
|
199
|
+
aria-expanded={moreOpen}
|
|
200
|
+
popovertarget="cairn-more-formatting-menu"
|
|
201
|
+
style="anchor-name:--cairn-more-formatting"
|
|
202
|
+
disabled={mode === 'preview'}
|
|
203
|
+
>
|
|
204
|
+
{@render strokeIcon(ellipsisPaths)}
|
|
205
|
+
</button>
|
|
206
|
+
<ul
|
|
207
|
+
bind:this={moreMenu}
|
|
208
|
+
popover="auto"
|
|
209
|
+
id="cairn-more-formatting-menu"
|
|
210
|
+
style="position-anchor:--cairn-more-formatting"
|
|
211
|
+
ontoggle={(e) => (moreOpen = e.newState === 'open')}
|
|
212
|
+
class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
|
|
213
|
+
>
|
|
214
|
+
{#each moreItems as item (item.kind)}
|
|
215
|
+
<li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
|
|
216
|
+
{/each}
|
|
217
|
+
</ul>
|
|
218
|
+
|
|
219
|
+
{#if insertControls}
|
|
220
|
+
<div class="w-px self-stretch bg-[var(--cairn-card-border)]" aria-hidden="true"></div>
|
|
221
|
+
<!-- The host's controls carry their own disabled state in Preview; this wrapper just keeps
|
|
222
|
+
any stray pointer target in the snippet inert while the pane is read-only. -->
|
|
223
|
+
<div
|
|
224
|
+
class="flex items-center gap-1"
|
|
225
|
+
class:pointer-events-none={mode === 'preview'}
|
|
226
|
+
class:opacity-50={mode === 'preview'}
|
|
227
|
+
>
|
|
228
|
+
{@render insertControls()}
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
|
|
232
|
+
<!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
|
|
233
|
+
the strip inside the same editor card. -->
|
|
234
|
+
<div class="join ml-auto" role="tablist" aria-label="Editor view">
|
|
235
|
+
{@render tab('write', 'Write')}
|
|
236
|
+
{@render tab('preview', 'Preview')}
|
|
237
|
+
</div>
|
|
61
238
|
</div>
|
|
@@ -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">
|
|
@@ -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}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The Markdown cheat sheet, opened from the edit page's editor footer. A one-screen table pairs
|
|
4
|
+
each piece of syntax with what it makes, and a closing note explains the ::: layout blocks.
|
|
5
|
+
Built on a native <dialog>, the DeleteDialog recipe; the host drives it through the exported
|
|
6
|
+
open(), so the component renders no trigger of its own.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
10
|
+
|
|
11
|
+
/** Open the cheat sheet. The trigger lives in the host (the edit page's editor footer). */
|
|
12
|
+
export function open() {
|
|
13
|
+
dialog?.showModal();
|
|
14
|
+
}
|
|
15
|
+
function close() {
|
|
16
|
+
dialog?.close();
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<dialog class="modal" aria-labelledby="cairn-markdown-help-title" bind:this={dialog}>
|
|
21
|
+
<div class="modal-box">
|
|
22
|
+
<div class="mb-3 flex items-center justify-between">
|
|
23
|
+
<h2 id="cairn-markdown-help-title" class="text-base font-semibold">Markdown help</h2>
|
|
24
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
25
|
+
</div>
|
|
26
|
+
<table class="table table-sm">
|
|
27
|
+
<thead>
|
|
28
|
+
<tr>
|
|
29
|
+
<th class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">Type this</th>
|
|
30
|
+
<th class="text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">What it makes</th>
|
|
31
|
+
</tr>
|
|
32
|
+
</thead>
|
|
33
|
+
<tbody>
|
|
34
|
+
<tr><td><code>## Heading</code></td><td>A heading</td></tr>
|
|
35
|
+
<tr><td><code>### Heading</code></td><td>A smaller heading</td></tr>
|
|
36
|
+
<tr><td><code>**bold**</code></td><td>Bold text</td></tr>
|
|
37
|
+
<tr><td><code>*italic*</code></td><td>Italic text</td></tr>
|
|
38
|
+
<tr><td><code>~~text~~</code></td><td>Crossed-out text</td></tr>
|
|
39
|
+
<tr><td><code>[text](url)</code></td><td>A link</td></tr>
|
|
40
|
+
<tr><td><code>[[page-name]]</code></td><td>A link to one of your pages</td></tr>
|
|
41
|
+
<tr><td><code>- item</code></td><td>A bulleted list</td></tr>
|
|
42
|
+
<tr><td><code>1. item</code></td><td>A numbered list</td></tr>
|
|
43
|
+
<tr><td><code>- [ ] item</code></td><td>A checklist</td></tr>
|
|
44
|
+
<tr><td><code>> quote</code></td><td>A quote</td></tr>
|
|
45
|
+
<tr><td><code>`code`</code></td><td>Code</td></tr>
|
|
46
|
+
<tr><td>Table</td><td>The Table button in the toolbar inserts one</td></tr>
|
|
47
|
+
<tr><td><code>---</code></td><td>A horizontal rule</td></tr>
|
|
48
|
+
</tbody>
|
|
49
|
+
</table>
|
|
50
|
+
<p class="mt-3 text-sm">
|
|
51
|
+
Lines starting with <code>:::</code> are layout blocks. Edit the text inside them and leave
|
|
52
|
+
the <code>:::</code> lines alone.
|
|
53
|
+
</p>
|
|
54
|
+
</div>
|
|
55
|
+
<form method="dialog" class="modal-backdrop">
|
|
56
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
57
|
+
</form>
|
|
58
|
+
</dialog>
|
|
@@ -17,9 +17,15 @@ DeleteDialog a11y conventions.
|
|
|
17
17
|
label: string;
|
|
18
18
|
/** The current slug, prefilled into the input. */
|
|
19
19
|
slug: string;
|
|
20
|
+
/** Render the built-in Change URL 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;
|
|
23
|
+
/** Called when the rename form submits, before the document navigates. The edit page uses it
|
|
24
|
+
* to stand down its leave guard while the POST is in flight. */
|
|
25
|
+
onsubmitting?: () => void;
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
let { conceptId, id, label, slug }: Props = $props();
|
|
28
|
+
let { conceptId, id, label, slug, trigger = true, onsubmitting }: Props = $props();
|
|
23
29
|
|
|
24
30
|
let dialog = $state<HTMLDialogElement | null>(null);
|
|
25
31
|
let slugInput = $state<HTMLInputElement | null>(null);
|
|
@@ -27,7 +33,8 @@ DeleteDialog a11y conventions.
|
|
|
27
33
|
// current slug each time the dialog opens without capturing only the initial prop value.
|
|
28
34
|
let nextSlug = $state('');
|
|
29
35
|
|
|
30
|
-
|
|
36
|
+
/** Open the dialog with a fresh prefill. Exported so a trigger={false} host can drive it. */
|
|
37
|
+
export function open() {
|
|
31
38
|
nextSlug = slug;
|
|
32
39
|
dialog?.showModal();
|
|
33
40
|
// showModal() lands focus on the first focusable element (the header Close button), so move
|
|
@@ -43,7 +50,9 @@ DeleteDialog a11y conventions.
|
|
|
43
50
|
}
|
|
44
51
|
</script>
|
|
45
52
|
|
|
46
|
-
|
|
53
|
+
{#if trigger}
|
|
54
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" onclick={open}>Change URL</button>
|
|
55
|
+
{/if}
|
|
47
56
|
|
|
48
57
|
<dialog class="modal" aria-labelledby="cairn-rename-dialog-title" bind:this={dialog}>
|
|
49
58
|
<div class="modal-box">
|
|
@@ -51,7 +60,7 @@ DeleteDialog a11y conventions.
|
|
|
51
60
|
<h2 id="cairn-rename-dialog-title" class="text-base font-semibold">Change this {label.toLowerCase()} URL</h2>
|
|
52
61
|
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
53
62
|
</div>
|
|
54
|
-
<form method="POST" action="?/rename" class="flex flex-col gap-3">
|
|
63
|
+
<form method="POST" action="?/rename" class="flex flex-col gap-3" onsubmit={() => onsubmitting?.()}>
|
|
55
64
|
<CsrfField />
|
|
56
65
|
<input type="hidden" name="concept" value={conceptId} />
|
|
57
66
|
<input type="hidden" name="id" value={id} />
|