@glw907/cairn-cms 0.8.0 → 0.9.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/dist/components/EditPage.svelte +4 -6
- package/dist/components/EditPage.svelte.d.ts +1 -3
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/EditorToolbar.svelte +61 -0
- package/dist/components/EditorToolbar.svelte.d.ts +15 -0
- package/dist/components/EditorToolbar.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +96 -57
- package/dist/components/MarkdownEditor.svelte.d.ts +5 -6
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
- package/dist/components/markdown-format.d.ts +13 -0
- package/dist/components/markdown-format.d.ts.map +1 -0
- package/dist/components/markdown-format.js +23 -0
- package/dist/render/pipeline.d.ts +1 -1
- package/dist/render/pipeline.js +1 -1
- package/dist/render/sanitize.js +2 -2
- package/package.json +7 -3
- package/src/lib/components/EditPage.svelte +4 -6
- package/src/lib/components/EditorToolbar.svelte +61 -0
- package/src/lib/components/MarkdownEditor.svelte +96 -57
- package/src/lib/components/markdown-format.ts +39 -0
- package/src/lib/render/pipeline.ts +1 -1
- package/src/lib/render/sanitize.ts +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
3
|
+
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
4
4
|
markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
5
5
|
`?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
|
|
6
6
|
-->
|
|
@@ -18,13 +18,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
18
18
|
data: EditData & { siteName: string };
|
|
19
19
|
/** The site's component registry, for the insert palette. */
|
|
20
20
|
registry?: ComponentRegistry;
|
|
21
|
-
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
22
|
-
preview?: unknown[];
|
|
23
21
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
24
22
|
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
let { data, registry,
|
|
25
|
+
let { data, registry, render }: Props = $props();
|
|
28
26
|
|
|
29
27
|
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
30
28
|
// untrack() captures the initial value without subscribing to future prop changes.
|
|
@@ -46,7 +44,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
// Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
|
|
49
|
-
// The sanitize is the one barrier between editor-authored markdown and the page (
|
|
47
|
+
// The sanitize is the one barrier between editor-authored markdown and the page (the editor is unsanitized).
|
|
50
48
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
51
49
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
52
50
|
let previewRun = 0;
|
|
@@ -103,7 +101,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
103
101
|
|
|
104
102
|
<div class="lg:order-1">
|
|
105
103
|
<div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
|
|
106
|
-
<MarkdownEditor bind:value={body} name="body"
|
|
104
|
+
<MarkdownEditor bind:value={body} name="body" registerInsert={(fn) => (insert = fn)} />
|
|
107
105
|
</div>
|
|
108
106
|
{#if showPreview}
|
|
109
107
|
<section
|
|
@@ -7,15 +7,13 @@ interface Props {
|
|
|
7
7
|
};
|
|
8
8
|
/** The site's component registry, for the insert palette. */
|
|
9
9
|
registry?: ComponentRegistry;
|
|
10
|
-
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
11
|
-
preview?: unknown[];
|
|
12
10
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
13
11
|
render?: (md: string, opts?: {
|
|
14
12
|
stagger?: boolean;
|
|
15
13
|
}) => string | Promise<string>;
|
|
16
14
|
}
|
|
17
15
|
/**
|
|
18
|
-
* The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
16
|
+
* The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
19
17
|
* markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
20
18
|
* `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
|
|
21
19
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B
|
|
1
|
+
{"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACjF;AAqJH;;;;GAIG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
|
|
4
|
+
asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
|
|
5
|
+
cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
|
|
6
|
+
house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { FormatKind } from './markdown-format.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Apply a markdown transform to the editor's current selection. */
|
|
13
|
+
format: (kind: FormatKind) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { format }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
19
|
+
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
20
|
+
const buttons: { kind: FormatKind; label: string; paths: string[] }[] = [
|
|
21
|
+
{ kind: 'bold', label: 'Bold', 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'] },
|
|
22
|
+
{ kind: 'italic', label: 'Italic', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
|
|
23
|
+
{ kind: 'heading', label: 'Heading', paths: ['M6 4v16', 'M18 4v16', 'M6 12h12'] },
|
|
24
|
+
{
|
|
25
|
+
kind: 'link',
|
|
26
|
+
label: 'Link',
|
|
27
|
+
paths: [
|
|
28
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
29
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
|
|
33
|
+
{
|
|
34
|
+
kind: 'quote',
|
|
35
|
+
label: 'Quote',
|
|
36
|
+
paths: [
|
|
37
|
+
'M16 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',
|
|
38
|
+
'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
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{ kind: 'code', label: 'Code', paths: ['M16 18l6-6-6-6', 'M8 6l-6 6 6 6'] },
|
|
42
|
+
];
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="border-base-300 bg-base-200 flex gap-1 border-b p-1" role="toolbar" aria-label="Formatting">
|
|
46
|
+
{#each buttons as button (button.kind)}
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
50
|
+
aria-label={button.label}
|
|
51
|
+
title={button.label}
|
|
52
|
+
onclick={() => format(button.kind)}
|
|
53
|
+
>
|
|
54
|
+
<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">
|
|
55
|
+
{#each button.paths as d (d)}
|
|
56
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
|
|
57
|
+
{/each}
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
{/each}
|
|
61
|
+
</div>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FormatKind } from './markdown-format.js';
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Apply a markdown transform to the editor's current selection. */
|
|
4
|
+
format: (kind: FormatKind) => void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
|
|
8
|
+
* asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
|
|
9
|
+
* cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
|
|
10
|
+
* house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
|
|
11
|
+
*/
|
|
12
|
+
declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type EditorToolbar = ReturnType<typeof EditorToolbar>;
|
|
14
|
+
export default EditorToolbar;
|
|
15
|
+
//# sourceMappingURL=EditorToolbar.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"EditorToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditorToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGrD,UAAU,KAAK;IACb,oEAAoE;IACpE,MAAM,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;CACpC;AAiDH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -1,81 +1,120 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over
|
|
4
|
-
and a cursor-insert callback.
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
|
|
4
|
+
value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
|
|
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 edit surface owns its toolbar; the design-accurate
|
|
7
|
+
preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
9
|
-
import { onMount } from 'svelte';
|
|
10
|
+
import { onMount, onDestroy } from 'svelte';
|
|
11
|
+
import EditorToolbar from './EditorToolbar.svelte';
|
|
12
|
+
import { applyMarkdownFormat, type FormatKind } from './markdown-format.js';
|
|
10
13
|
|
|
11
14
|
interface Props {
|
|
12
15
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
13
16
|
value: string;
|
|
14
17
|
/** The hidden field name the value is mirrored to for form submit. */
|
|
15
18
|
name: string;
|
|
16
|
-
/** Carta extensions from the adapter, for the design-accurate preview. */
|
|
17
|
-
plugins?: unknown[];
|
|
18
19
|
/** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
|
|
19
20
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
let { value = $bindable(), name,
|
|
23
|
-
|
|
24
|
-
// Local structural type for the Carta editing surface this seam uses. carta-md re-exports its
|
|
25
|
-
// Svelte components from the package entry, so its `Carta` class is not reachable as a named
|
|
26
|
-
// export under NodeNext; a structural type stays compatible without naming it (the shape
|
|
27
|
-
// legacy/src/lib/editor.ts relied on, verified against carta-md@4.11).
|
|
28
|
-
interface CartaInput {
|
|
29
|
-
getSelection(): { start: number };
|
|
30
|
-
insertAt(position: number, text: string): void;
|
|
31
|
-
update(): boolean;
|
|
32
|
-
}
|
|
33
|
-
interface CartaLike {
|
|
34
|
-
input?: CartaInput;
|
|
35
|
-
}
|
|
23
|
+
let { value = $bindable(), name, registerInsert }: Props = $props();
|
|
36
24
|
|
|
25
|
+
let host = $state<HTMLDivElement | null>(null);
|
|
37
26
|
let mounted = $state(false);
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
let Editor = $state<(typeof import('carta-md'))['MarkdownEditor'] | null>(null);
|
|
43
|
-
let carta = $state<CartaLike | null>(null);
|
|
27
|
+
// The CodeMirror view, untyped at the runtime boundary because @codemirror/* loads only in the
|
|
28
|
+
// browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
|
|
29
|
+
// so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
|
|
30
|
+
let view: import('@codemirror/view').EditorView | null = null;
|
|
44
31
|
|
|
45
32
|
onMount(async () => {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
33
|
+
const viewMod = await import('@codemirror/view');
|
|
34
|
+
const stateMod = await import('@codemirror/state');
|
|
35
|
+
const markdownMod = await import('@codemirror/lang-markdown');
|
|
36
|
+
const commandsMod = await import('@codemirror/commands');
|
|
37
|
+
const languageMod = await import('@codemirror/language');
|
|
38
|
+
|
|
39
|
+
if (!host) return;
|
|
40
|
+
|
|
41
|
+
const { EditorView, keymap } = viewMod;
|
|
42
|
+
const theme = EditorView.theme(
|
|
43
|
+
{
|
|
44
|
+
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.875rem' },
|
|
45
|
+
'.cm-content': { fontFamily: 'ui-monospace, monospace', padding: '0.75rem', lineHeight: '1.7' },
|
|
46
|
+
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
47
|
+
'&.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '-2px' },
|
|
48
|
+
'.cm-line': { padding: '0' },
|
|
49
|
+
},
|
|
50
|
+
{ dark: false },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
view = new EditorView({
|
|
54
|
+
parent: host,
|
|
55
|
+
state: stateMod.EditorState.create({
|
|
56
|
+
doc: value,
|
|
57
|
+
extensions: [
|
|
58
|
+
commandsMod.history(),
|
|
59
|
+
keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
60
|
+
markdownMod.markdown(),
|
|
61
|
+
EditorView.lineWrapping,
|
|
62
|
+
languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
|
|
63
|
+
theme,
|
|
64
|
+
EditorView.updateListener.of((update) => {
|
|
65
|
+
if (update.docChanged) value = update.state.doc.toString();
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
registerInsert?.(insertAtCursor);
|
|
70
72
|
mounted = true;
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
onDestroy(() => view?.destroy());
|
|
76
|
+
|
|
77
|
+
// Reconcile an externally reassigned `value` into the mounted editor. A no-op until `view` exists,
|
|
78
|
+
// and the doc-equality guard ignores the updateListener's own writes so the two never feed back.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
const incoming = value;
|
|
81
|
+
if (!view) return;
|
|
82
|
+
const current = view.state.doc.toString();
|
|
83
|
+
if (incoming === current) return;
|
|
84
|
+
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function insertAtCursor(text: string) {
|
|
88
|
+
if (!view) {
|
|
89
|
+
value = value ? `${value}\n\n${text}` : text;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const pos = view.state.selection.main.head;
|
|
93
|
+
const prefix = pos > 0 ? '\n\n' : '';
|
|
94
|
+
const insert = `${prefix}${text}`;
|
|
95
|
+
view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
|
|
96
|
+
view.focus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyFormat(kind: FormatKind) {
|
|
100
|
+
if (!view) return;
|
|
101
|
+
const { from, to } = view.state.selection.main;
|
|
102
|
+
const doc = view.state.doc.toString();
|
|
103
|
+
const next = applyMarkdownFormat(doc, from, to, kind);
|
|
104
|
+
view.dispatch({
|
|
105
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
106
|
+
selection: { anchor: next.from, head: next.to },
|
|
107
|
+
});
|
|
108
|
+
view.focus();
|
|
109
|
+
}
|
|
72
110
|
</script>
|
|
73
111
|
|
|
74
|
-
<input type="hidden" {name}
|
|
112
|
+
<input type="hidden" {name} {value} />
|
|
75
113
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
{/if}
|
|
114
|
+
<div class="border-base-300 overflow-hidden rounded-box border">
|
|
115
|
+
<EditorToolbar format={applyFormat} />
|
|
116
|
+
<div bind:this={host}></div>
|
|
117
|
+
{#if !mounted}
|
|
118
|
+
<textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
@@ -3,16 +3,15 @@ interface Props {
|
|
|
3
3
|
value: string;
|
|
4
4
|
/** The hidden field name the value is mirrored to for form submit. */
|
|
5
5
|
name: string;
|
|
6
|
-
/** Carta extensions from the adapter, for the design-accurate preview. */
|
|
7
|
-
plugins?: unknown[];
|
|
8
6
|
/** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
|
|
9
7
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
10
8
|
}
|
|
11
9
|
/**
|
|
12
|
-
* The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over
|
|
13
|
-
* and a cursor-insert callback.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
10
|
+
* The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
|
|
11
|
+
* value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
|
|
12
|
+
* through a dynamic import; until then a plain textarea carries the value so the form still submits, and
|
|
13
|
+
* the hidden field mirrors the value throughout. The edit surface owns its toolbar; the design-accurate
|
|
14
|
+
* preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
|
|
16
15
|
*/
|
|
17
16
|
declare const MarkdownEditor: import("svelte").Component<Props, {}, "value">;
|
|
18
17
|
type MarkdownEditor = ReturnType<typeof MarkdownEditor>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MarkdownEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MarkdownEditor.svelte.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"MarkdownEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MarkdownEditor.svelte.ts"],"names":[],"mappings":"AAQE,UAAU,KAAK;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAC;CAC3D;AA6GH;;;;;;GAMG;AACH,QAAA,MAAM,cAAc,gDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure markdown selection transforms for the editor toolbar. Each call maps a document and a
|
|
3
|
+
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
|
+
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
|
+
*/
|
|
6
|
+
export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
|
|
7
|
+
export interface FormatResult {
|
|
8
|
+
doc: string;
|
|
9
|
+
from: number;
|
|
10
|
+
to: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult;
|
|
13
|
+
//# sourceMappingURL=markdown-format.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"markdown-format.d.ts","sourceRoot":"","sources":["../../src/lib/components/markdown-format.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1F,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAKD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,YAAY,CAsBzG"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const WRAP = { bold: '**', italic: '_', code: '`' };
|
|
2
|
+
const LINE_PREFIX = { heading: '# ', quote: '> ', ul: '- ' };
|
|
3
|
+
export function applyMarkdownFormat(doc, from, to, kind) {
|
|
4
|
+
if (kind === 'bold' || kind === 'italic' || kind === 'code') {
|
|
5
|
+
const marker = WRAP[kind];
|
|
6
|
+
const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
|
|
7
|
+
return { doc: next, from: from + marker.length, to: to + marker.length };
|
|
8
|
+
}
|
|
9
|
+
if (kind === 'link') {
|
|
10
|
+
const text = doc.slice(from, to);
|
|
11
|
+
const placeholder = 'url';
|
|
12
|
+
const lead = `[${text}](`; // everything before the url placeholder
|
|
13
|
+
const inserted = `${lead}${placeholder})`;
|
|
14
|
+
const urlStart = from + lead.length;
|
|
15
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
|
|
16
|
+
}
|
|
17
|
+
const prefix = LINE_PREFIX[kind];
|
|
18
|
+
const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
|
|
19
|
+
const region = doc.slice(lineStart, to);
|
|
20
|
+
const prefixed = region.replace(/^/gm, prefix);
|
|
21
|
+
const added = prefixed.length - region.length;
|
|
22
|
+
return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
|
|
23
|
+
}
|
|
@@ -8,7 +8,7 @@ export interface RendererOptions {
|
|
|
8
8
|
}
|
|
9
9
|
/** Compose a site's render pipeline from its component registry: directive syntax to
|
|
10
10
|
* stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
11
|
-
* rehype plugin arrays (so the
|
|
11
|
+
* rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
|
|
12
12
|
export declare function createRenderer(registry: ComponentRegistry, options?: RendererOptions): {
|
|
13
13
|
remarkPlugins: PluggableList;
|
|
14
14
|
rehypePlugins: PluggableList;
|
package/dist/render/pipeline.js
CHANGED
|
@@ -10,7 +10,7 @@ import { remarkDirectiveStamp } from './remark-directives.js';
|
|
|
10
10
|
import { rehypeDispatch } from './rehype-dispatch.js';
|
|
11
11
|
/** Compose a site's render pipeline from its component registry: directive syntax to
|
|
12
12
|
* stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
13
|
-
* rehype plugin arrays (so the
|
|
13
|
+
* rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
|
|
14
14
|
export function createRenderer(registry, options = {}) {
|
|
15
15
|
const remarkPlugins = [remarkDirective, [remarkDirectiveStamp, registry]];
|
|
16
16
|
const rehypePlugins = [rehypeRaw, [rehypeDispatch, registry, options.stagger], rehypeSlug];
|
package/dist/render/sanitize.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The live preview's sanitize floor.
|
|
2
|
-
//
|
|
1
|
+
// The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
|
|
2
|
+
// so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
|
|
3
3
|
// DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
|
|
4
4
|
// loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
|
|
5
5
|
// server import of this file pulls in nothing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -56,13 +56,18 @@
|
|
|
56
56
|
],
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"@sveltejs/kit": "^2",
|
|
59
|
-
"carta-md": "^4.11",
|
|
60
59
|
"svelte": "^5.0.0"
|
|
61
60
|
},
|
|
62
61
|
"dependencies": {
|
|
62
|
+
"@codemirror/commands": "^6.10.3",
|
|
63
|
+
"@codemirror/lang-markdown": "^6.5.0",
|
|
64
|
+
"@codemirror/language": "^6.12.3",
|
|
65
|
+
"@codemirror/state": "^6.6.0",
|
|
66
|
+
"@codemirror/view": "^6.43.0",
|
|
63
67
|
"@rodrigodagostino/svelte-sortable-list": "^2.1.17",
|
|
64
68
|
"@types/hast": "^3.0.4",
|
|
65
69
|
"@types/mdast": "^4.0.4",
|
|
70
|
+
"codemirror": "^6.0.2",
|
|
66
71
|
"dompurify": "^3.4.7",
|
|
67
72
|
"gray-matter": "^4",
|
|
68
73
|
"hastscript": "^9.0.1",
|
|
@@ -88,7 +93,6 @@
|
|
|
88
93
|
"@types/node": "^22.19.19",
|
|
89
94
|
"@vitest/browser": "^4.1.7",
|
|
90
95
|
"@vitest/browser-playwright": "^4.1.7",
|
|
91
|
-
"carta-md": "^4.11",
|
|
92
96
|
"playwright": "^1.60.0",
|
|
93
97
|
"publint": "^0.3.21",
|
|
94
98
|
"svelte": "^5.55",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
3
|
+
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
4
4
|
markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
5
5
|
`?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
|
|
6
6
|
-->
|
|
@@ -18,13 +18,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
18
18
|
data: EditData & { siteName: string };
|
|
19
19
|
/** The site's component registry, for the insert palette. */
|
|
20
20
|
registry?: ComponentRegistry;
|
|
21
|
-
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
22
|
-
preview?: unknown[];
|
|
23
21
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
24
22
|
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
let { data, registry,
|
|
25
|
+
let { data, registry, render }: Props = $props();
|
|
28
26
|
|
|
29
27
|
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
30
28
|
// untrack() captures the initial value without subscribing to future prop changes.
|
|
@@ -46,7 +44,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
// Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
|
|
49
|
-
// The sanitize is the one barrier between editor-authored markdown and the page (
|
|
47
|
+
// The sanitize is the one barrier between editor-authored markdown and the page (the editor is unsanitized).
|
|
50
48
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
51
49
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
52
50
|
let previewRun = 0;
|
|
@@ -103,7 +101,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
103
101
|
|
|
104
102
|
<div class="lg:order-1">
|
|
105
103
|
<div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
|
|
106
|
-
<MarkdownEditor bind:value={body} name="body"
|
|
104
|
+
<MarkdownEditor bind:value={body} name="body" registerInsert={(fn) => (insert = fn)} />
|
|
107
105
|
</div>
|
|
108
106
|
{#if showPreview}
|
|
109
107
|
<section
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
|
|
4
|
+
asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
|
|
5
|
+
cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
|
|
6
|
+
house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { FormatKind } from './markdown-format.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Apply a markdown transform to the editor's current selection. */
|
|
13
|
+
format: (kind: FormatKind) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { format }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
19
|
+
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
20
|
+
const buttons: { kind: FormatKind; label: string; paths: string[] }[] = [
|
|
21
|
+
{ kind: 'bold', label: 'Bold', 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'] },
|
|
22
|
+
{ kind: 'italic', label: 'Italic', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
|
|
23
|
+
{ kind: 'heading', label: 'Heading', paths: ['M6 4v16', 'M18 4v16', 'M6 12h12'] },
|
|
24
|
+
{
|
|
25
|
+
kind: 'link',
|
|
26
|
+
label: 'Link',
|
|
27
|
+
paths: [
|
|
28
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
29
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
|
|
33
|
+
{
|
|
34
|
+
kind: 'quote',
|
|
35
|
+
label: 'Quote',
|
|
36
|
+
paths: [
|
|
37
|
+
'M16 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',
|
|
38
|
+
'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
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{ kind: 'code', label: 'Code', paths: ['M16 18l6-6-6-6', 'M8 6l-6 6 6 6'] },
|
|
42
|
+
];
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="border-base-300 bg-base-200 flex gap-1 border-b p-1" role="toolbar" aria-label="Formatting">
|
|
46
|
+
{#each buttons as button (button.kind)}
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
50
|
+
aria-label={button.label}
|
|
51
|
+
title={button.label}
|
|
52
|
+
onclick={() => format(button.kind)}
|
|
53
|
+
>
|
|
54
|
+
<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">
|
|
55
|
+
{#each button.paths as d (d)}
|
|
56
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
|
|
57
|
+
{/each}
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
{/each}
|
|
61
|
+
</div>
|
|
@@ -1,81 +1,120 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over
|
|
4
|
-
and a cursor-insert callback.
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
|
|
4
|
+
value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
|
|
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 edit surface owns its toolbar; the design-accurate
|
|
7
|
+
preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
9
|
-
import { onMount } from 'svelte';
|
|
10
|
+
import { onMount, onDestroy } from 'svelte';
|
|
11
|
+
import EditorToolbar from './EditorToolbar.svelte';
|
|
12
|
+
import { applyMarkdownFormat, type FormatKind } from './markdown-format.js';
|
|
10
13
|
|
|
11
14
|
interface Props {
|
|
12
15
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
13
16
|
value: string;
|
|
14
17
|
/** The hidden field name the value is mirrored to for form submit. */
|
|
15
18
|
name: string;
|
|
16
|
-
/** Carta extensions from the adapter, for the design-accurate preview. */
|
|
17
|
-
plugins?: unknown[];
|
|
18
19
|
/** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
|
|
19
20
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
let { value = $bindable(), name,
|
|
23
|
-
|
|
24
|
-
// Local structural type for the Carta editing surface this seam uses. carta-md re-exports its
|
|
25
|
-
// Svelte components from the package entry, so its `Carta` class is not reachable as a named
|
|
26
|
-
// export under NodeNext; a structural type stays compatible without naming it (the shape
|
|
27
|
-
// legacy/src/lib/editor.ts relied on, verified against carta-md@4.11).
|
|
28
|
-
interface CartaInput {
|
|
29
|
-
getSelection(): { start: number };
|
|
30
|
-
insertAt(position: number, text: string): void;
|
|
31
|
-
update(): boolean;
|
|
32
|
-
}
|
|
33
|
-
interface CartaLike {
|
|
34
|
-
input?: CartaInput;
|
|
35
|
-
}
|
|
23
|
+
let { value = $bindable(), name, registerInsert }: Props = $props();
|
|
36
24
|
|
|
25
|
+
let host = $state<HTMLDivElement | null>(null);
|
|
37
26
|
let mounted = $state(false);
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
let Editor = $state<(typeof import('carta-md'))['MarkdownEditor'] | null>(null);
|
|
43
|
-
let carta = $state<CartaLike | null>(null);
|
|
27
|
+
// The CodeMirror view, untyped at the runtime boundary because @codemirror/* loads only in the
|
|
28
|
+
// browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
|
|
29
|
+
// so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
|
|
30
|
+
let view: import('@codemirror/view').EditorView | null = null;
|
|
44
31
|
|
|
45
32
|
onMount(async () => {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
33
|
+
const viewMod = await import('@codemirror/view');
|
|
34
|
+
const stateMod = await import('@codemirror/state');
|
|
35
|
+
const markdownMod = await import('@codemirror/lang-markdown');
|
|
36
|
+
const commandsMod = await import('@codemirror/commands');
|
|
37
|
+
const languageMod = await import('@codemirror/language');
|
|
38
|
+
|
|
39
|
+
if (!host) return;
|
|
40
|
+
|
|
41
|
+
const { EditorView, keymap } = viewMod;
|
|
42
|
+
const theme = EditorView.theme(
|
|
43
|
+
{
|
|
44
|
+
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.875rem' },
|
|
45
|
+
'.cm-content': { fontFamily: 'ui-monospace, monospace', padding: '0.75rem', lineHeight: '1.7' },
|
|
46
|
+
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
47
|
+
'&.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '-2px' },
|
|
48
|
+
'.cm-line': { padding: '0' },
|
|
49
|
+
},
|
|
50
|
+
{ dark: false },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
view = new EditorView({
|
|
54
|
+
parent: host,
|
|
55
|
+
state: stateMod.EditorState.create({
|
|
56
|
+
doc: value,
|
|
57
|
+
extensions: [
|
|
58
|
+
commandsMod.history(),
|
|
59
|
+
keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
60
|
+
markdownMod.markdown(),
|
|
61
|
+
EditorView.lineWrapping,
|
|
62
|
+
languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
|
|
63
|
+
theme,
|
|
64
|
+
EditorView.updateListener.of((update) => {
|
|
65
|
+
if (update.docChanged) value = update.state.doc.toString();
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
registerInsert?.(insertAtCursor);
|
|
70
72
|
mounted = true;
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
onDestroy(() => view?.destroy());
|
|
76
|
+
|
|
77
|
+
// Reconcile an externally reassigned `value` into the mounted editor. A no-op until `view` exists,
|
|
78
|
+
// and the doc-equality guard ignores the updateListener's own writes so the two never feed back.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
const incoming = value;
|
|
81
|
+
if (!view) return;
|
|
82
|
+
const current = view.state.doc.toString();
|
|
83
|
+
if (incoming === current) return;
|
|
84
|
+
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function insertAtCursor(text: string) {
|
|
88
|
+
if (!view) {
|
|
89
|
+
value = value ? `${value}\n\n${text}` : text;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const pos = view.state.selection.main.head;
|
|
93
|
+
const prefix = pos > 0 ? '\n\n' : '';
|
|
94
|
+
const insert = `${prefix}${text}`;
|
|
95
|
+
view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
|
|
96
|
+
view.focus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyFormat(kind: FormatKind) {
|
|
100
|
+
if (!view) return;
|
|
101
|
+
const { from, to } = view.state.selection.main;
|
|
102
|
+
const doc = view.state.doc.toString();
|
|
103
|
+
const next = applyMarkdownFormat(doc, from, to, kind);
|
|
104
|
+
view.dispatch({
|
|
105
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
106
|
+
selection: { anchor: next.from, head: next.to },
|
|
107
|
+
});
|
|
108
|
+
view.focus();
|
|
109
|
+
}
|
|
72
110
|
</script>
|
|
73
111
|
|
|
74
|
-
<input type="hidden" {name}
|
|
112
|
+
<input type="hidden" {name} {value} />
|
|
75
113
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
{/if}
|
|
114
|
+
<div class="border-base-300 overflow-hidden rounded-box border">
|
|
115
|
+
<EditorToolbar format={applyFormat} />
|
|
116
|
+
<div bind:this={host}></div>
|
|
117
|
+
{#if !mounted}
|
|
118
|
+
<textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure markdown selection transforms for the editor toolbar. Each call maps a document and a
|
|
3
|
+
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
|
+
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
|
+
*/
|
|
6
|
+
export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
|
|
7
|
+
|
|
8
|
+
export interface FormatResult {
|
|
9
|
+
doc: string;
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WRAP: Record<'bold' | 'italic' | 'code', string> = { bold: '**', italic: '_', code: '`' };
|
|
15
|
+
const LINE_PREFIX: Record<'heading' | 'quote' | 'ul', string> = { heading: '# ', quote: '> ', ul: '- ' };
|
|
16
|
+
|
|
17
|
+
export function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult {
|
|
18
|
+
if (kind === 'bold' || kind === 'italic' || kind === 'code') {
|
|
19
|
+
const marker = WRAP[kind];
|
|
20
|
+
const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
|
|
21
|
+
return { doc: next, from: from + marker.length, to: to + marker.length };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (kind === 'link') {
|
|
25
|
+
const text = doc.slice(from, to);
|
|
26
|
+
const placeholder = 'url';
|
|
27
|
+
const lead = `[${text}](`; // everything before the url placeholder
|
|
28
|
+
const inserted = `${lead}${placeholder})`;
|
|
29
|
+
const urlStart = from + lead.length;
|
|
30
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const prefix = LINE_PREFIX[kind];
|
|
34
|
+
const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
|
|
35
|
+
const region = doc.slice(lineStart, to);
|
|
36
|
+
const prefixed = region.replace(/^/gm, prefix);
|
|
37
|
+
const added = prefixed.length - region.length;
|
|
38
|
+
return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
|
|
39
|
+
}
|
|
@@ -19,7 +19,7 @@ export interface RendererOptions {
|
|
|
19
19
|
|
|
20
20
|
/** Compose a site's render pipeline from its component registry: directive syntax to
|
|
21
21
|
* stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
22
|
-
* rehype plugin arrays (so the
|
|
22
|
+
* rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
|
|
23
23
|
export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
|
|
24
24
|
const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
|
|
25
25
|
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.stagger], rehypeSlug];
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The live preview's sanitize floor.
|
|
2
|
-
//
|
|
1
|
+
// The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
|
|
2
|
+
// so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
|
|
3
3
|
// DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
|
|
4
4
|
// loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
|
|
5
5
|
// server import of this file pulls in nothing.
|