@signal9/era-ui 3.9.0 → 3.11.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/apps/index.d.ts +19 -0
- package/dist/apps/index.js +19 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.d.ts +11 -0
- package/dist/apps/notes/editor/bubble-menu.svelte.js +106 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte +42 -0
- package/dist/apps/notes/editor/bubble-toolbar.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/extensions.d.ts +18 -0
- package/dist/apps/notes/editor/extensions.js +88 -0
- package/dist/apps/notes/editor/floating.d.ts +21 -0
- package/dist/apps/notes/editor/floating.js +44 -0
- package/dist/apps/notes/editor/link.d.ts +9 -0
- package/dist/apps/notes/editor/link.js +22 -0
- package/dist/apps/notes/editor/list-cleanup-rule.d.ts +14 -0
- package/dist/apps/notes/editor/list-cleanup-rule.js +47 -0
- package/dist/apps/notes/editor/slash-command.svelte.d.ts +22 -0
- package/dist/apps/notes/editor/slash-command.svelte.js +215 -0
- package/dist/apps/notes/editor/slash-menu.svelte +63 -0
- package/dist/apps/notes/editor/slash-menu.svelte.d.ts +23 -0
- package/dist/apps/notes/index.d.ts +11 -0
- package/dist/apps/notes/index.js +13 -0
- package/dist/apps/notes/markdown.d.ts +12 -0
- package/dist/apps/notes/markdown.js +165 -0
- package/dist/apps/notes/note-editor.svelte +456 -0
- package/dist/apps/notes/note-editor.svelte.d.ts +35 -0
- package/dist/apps/notes/notes-store.svelte.d.ts +64 -0
- package/dist/apps/notes/notes-store.svelte.js +234 -0
- package/dist/apps/notes/notes.svelte +377 -0
- package/dist/apps/notes/notes.svelte.d.ts +22 -0
- package/dist/apps/notes/tree.d.ts +70 -0
- package/dist/apps/notes/tree.js +185 -0
- package/dist/apps/notes/types.d.ts +49 -0
- package/dist/apps/notes/types.js +11 -0
- package/dist/dev/audit/audits/index.d.ts +2 -1
- package/dist/dev/audit/audits/index.js +3 -1
- package/dist/dev/audit/audits/resting-gap.d.ts +2 -0
- package/dist/dev/audit/audits/resting-gap.js +78 -0
- package/dist/docs/notes.md +82 -0
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/llms-full.txt +92 -1
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/manifest.json +16 -1
- package/dist/generated-docs/notes.md +86 -0
- package/dist/generated-docs/utilities.json +1 -1
- package/dist/generated-docs/utilities.md +1 -1
- package/dist/styles/index.css +2 -1
- package/dist/styles/themes.css +70 -52
- package/dist/ui/badge/badge.svelte.d.ts +17 -17
- package/dist/ui/bar/bar.svelte.d.ts +11 -11
- package/dist/ui/button/variants.d.ts +44 -44
- package/dist/ui/card/card.svelte.d.ts +20 -20
- package/dist/ui/chip/chip.svelte.d.ts +14 -14
- package/dist/ui/pane/pane-root.svelte.d.ts +1 -1
- package/dist/ui/pane/pane.svelte.d.ts +1 -1
- package/dist/ui/sheet/sheet-content.svelte.d.ts +14 -14
- package/dist/ui/skeleton/skeleton.svelte.d.ts +11 -11
- package/dist/utils/index.js +11 -0
- package/package.json +31 -5
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/` slash-command menu.
|
|
3
|
+
*
|
|
4
|
+
* TipTap's Suggestion plugin is imperative — it hands you a `render()` object
|
|
5
|
+
* with `onStart`/`onUpdate`/`onKeyDown`/`onExit` and expects you to produce DOM.
|
|
6
|
+
* Rather than hand-build that DOM (and hand-maintain a second set of colours and
|
|
7
|
+
* spacings that would drift from the library the moment a token moved), this
|
|
8
|
+
* mounts a real Svelte component with `mount()` and feeds it a `$state` props
|
|
9
|
+
* object. The panel is therefore styled with era utilities like everything else
|
|
10
|
+
* and follows every axis automatically.
|
|
11
|
+
*/
|
|
12
|
+
import { Extension } from '@tiptap/core';
|
|
13
|
+
import Suggestion from '@tiptap/suggestion';
|
|
14
|
+
import { flushSync, mount, unmount } from 'svelte';
|
|
15
|
+
import Heading1 from '@lucide/svelte/icons/heading-1';
|
|
16
|
+
import Heading2 from '@lucide/svelte/icons/heading-2';
|
|
17
|
+
import Heading3 from '@lucide/svelte/icons/heading-3';
|
|
18
|
+
import List from '@lucide/svelte/icons/list';
|
|
19
|
+
import ListOrdered from '@lucide/svelte/icons/list-ordered';
|
|
20
|
+
import ListChecks from '@lucide/svelte/icons/list-checks';
|
|
21
|
+
import SquareCode from '@lucide/svelte/icons/square-code';
|
|
22
|
+
import Quote from '@lucide/svelte/icons/quote';
|
|
23
|
+
import TableIcon from '@lucide/svelte/icons/table';
|
|
24
|
+
import Minus from '@lucide/svelte/icons/minus';
|
|
25
|
+
import ListCollapse from '@lucide/svelte/icons/list-collapse';
|
|
26
|
+
import ImageIcon from '@lucide/svelte/icons/image';
|
|
27
|
+
import SlashMenu from './slash-menu.svelte';
|
|
28
|
+
import { createOverlayHost, placeBelow } from './floating.js';
|
|
29
|
+
export const slashItems = [
|
|
30
|
+
{
|
|
31
|
+
title: 'Heading 1',
|
|
32
|
+
description: 'Large heading',
|
|
33
|
+
icon: Heading1,
|
|
34
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run()
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
title: 'Heading 2',
|
|
38
|
+
description: 'Medium heading',
|
|
39
|
+
icon: Heading2,
|
|
40
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run()
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
title: 'Heading 3',
|
|
44
|
+
description: 'Small heading',
|
|
45
|
+
icon: Heading3,
|
|
46
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run()
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
title: 'Bullet List',
|
|
50
|
+
description: 'Unordered list',
|
|
51
|
+
icon: List,
|
|
52
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).toggleBulletList().run()
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
title: 'Numbered List',
|
|
56
|
+
description: 'Ordered list',
|
|
57
|
+
icon: ListOrdered,
|
|
58
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).toggleOrderedList().run()
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: 'Task List',
|
|
62
|
+
description: 'Checklist',
|
|
63
|
+
icon: ListChecks,
|
|
64
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).toggleTaskList().run()
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
title: 'Code Block',
|
|
68
|
+
description: 'Highlighted code',
|
|
69
|
+
icon: SquareCode,
|
|
70
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
title: 'Blockquote',
|
|
74
|
+
description: 'Quoted text',
|
|
75
|
+
icon: Quote,
|
|
76
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).toggleBlockquote().run()
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: 'Table',
|
|
80
|
+
description: '3×3 with header',
|
|
81
|
+
icon: TableIcon,
|
|
82
|
+
command: (editor, range) => editor
|
|
83
|
+
.chain()
|
|
84
|
+
.focus()
|
|
85
|
+
.deleteRange(range)
|
|
86
|
+
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
|
87
|
+
.run()
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
title: 'Divider',
|
|
91
|
+
description: 'Horizontal rule',
|
|
92
|
+
icon: Minus,
|
|
93
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run()
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
title: 'Details',
|
|
97
|
+
description: 'Collapsible section',
|
|
98
|
+
icon: ListCollapse,
|
|
99
|
+
command: (editor, range) => editor.chain().focus().deleteRange(range).setDetails().run()
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
title: 'Image',
|
|
103
|
+
description: 'Insert by URL',
|
|
104
|
+
icon: ImageIcon,
|
|
105
|
+
command: (editor, range) => {
|
|
106
|
+
const url = globalThis.prompt?.('Image URL:');
|
|
107
|
+
if (url)
|
|
108
|
+
editor.chain().focus().deleteRange(range).setImage({ src: url }).run();
|
|
109
|
+
// Still consume the "/image" text even when the prompt is dismissed.
|
|
110
|
+
else
|
|
111
|
+
editor.chain().focus().deleteRange(range).run();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
export const SlashCommand = Extension.create({
|
|
116
|
+
name: 'slashCommand',
|
|
117
|
+
addProseMirrorPlugins() {
|
|
118
|
+
return [
|
|
119
|
+
Suggestion({
|
|
120
|
+
editor: this.editor,
|
|
121
|
+
char: '/',
|
|
122
|
+
allowSpaces: false,
|
|
123
|
+
startOfLine: false,
|
|
124
|
+
items: ({ query }) => {
|
|
125
|
+
const q = query.toLowerCase();
|
|
126
|
+
return slashItems.filter((item) => item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q));
|
|
127
|
+
},
|
|
128
|
+
command: ({ editor, range, props }) => {
|
|
129
|
+
props.command(editor, range);
|
|
130
|
+
},
|
|
131
|
+
render: () => {
|
|
132
|
+
let host = null;
|
|
133
|
+
let menu = null;
|
|
134
|
+
let run = null;
|
|
135
|
+
// The one object the mounted component reads. Mutating it is what
|
|
136
|
+
// re-renders the panel — there is no second render path.
|
|
137
|
+
const view = $state({
|
|
138
|
+
items: [],
|
|
139
|
+
selected: 0,
|
|
140
|
+
onSelect: (index) => {
|
|
141
|
+
const item = view.items[index];
|
|
142
|
+
if (item)
|
|
143
|
+
run?.(item);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
/** Apply pending prop changes, THEN measure — the panel's height
|
|
147
|
+
* changes with the filtered set, and placement depends on it. */
|
|
148
|
+
function reposition(clientRect) {
|
|
149
|
+
if (!host || !clientRect)
|
|
150
|
+
return;
|
|
151
|
+
flushSync();
|
|
152
|
+
const rect = clientRect();
|
|
153
|
+
if (rect)
|
|
154
|
+
placeBelow(host, rect);
|
|
155
|
+
}
|
|
156
|
+
function move(delta) {
|
|
157
|
+
const n = view.items.length;
|
|
158
|
+
if (n === 0)
|
|
159
|
+
return;
|
|
160
|
+
view.selected = (view.selected + delta + n) % n;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
onStart(props) {
|
|
164
|
+
host = createOverlayHost();
|
|
165
|
+
document.body.append(host);
|
|
166
|
+
view.items = props.items;
|
|
167
|
+
view.selected = 0;
|
|
168
|
+
run = props.command;
|
|
169
|
+
menu = mount(SlashMenu, { target: host, props: view });
|
|
170
|
+
reposition(props.clientRect);
|
|
171
|
+
},
|
|
172
|
+
onUpdate(props) {
|
|
173
|
+
view.items = props.items;
|
|
174
|
+
// A changed query means a different result set — a stale index
|
|
175
|
+
// would point at an unrelated command.
|
|
176
|
+
view.selected = 0;
|
|
177
|
+
run = props.command;
|
|
178
|
+
reposition(props.clientRect);
|
|
179
|
+
},
|
|
180
|
+
onKeyDown(props) {
|
|
181
|
+
switch (props.event.key) {
|
|
182
|
+
case 'ArrowDown':
|
|
183
|
+
move(1);
|
|
184
|
+
return true;
|
|
185
|
+
case 'ArrowUp':
|
|
186
|
+
move(-1);
|
|
187
|
+
return true;
|
|
188
|
+
case 'Enter': {
|
|
189
|
+
const item = view.items[view.selected];
|
|
190
|
+
if (item)
|
|
191
|
+
run?.(item);
|
|
192
|
+
// Swallow Enter even with no match, so it can't split the
|
|
193
|
+
// paragraph out from under a menu that is still open.
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
case 'Escape':
|
|
197
|
+
return true;
|
|
198
|
+
default:
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
onExit() {
|
|
203
|
+
if (menu)
|
|
204
|
+
unmount(menu);
|
|
205
|
+
menu = null;
|
|
206
|
+
host?.remove();
|
|
207
|
+
host = null;
|
|
208
|
+
run = null;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The `/` command palette, rendered from era's menu vocabulary — the same
|
|
4
|
+
* floating-panel and row recipes DropdownMenu.Content / .Item use, so it picks
|
|
5
|
+
* up every axis (density tiers, surface chrome, corners, font) for free.
|
|
6
|
+
*
|
|
7
|
+
* Presentational on purpose: TipTap's Suggestion plugin owns the query,
|
|
8
|
+
* the filtered set, and the keyboard, and pushes the result in as props.
|
|
9
|
+
*/
|
|
10
|
+
import type { Component } from 'svelte';
|
|
11
|
+
import type { IconProps } from '@lucide/svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
items: Array<{ title: string; description: string; icon: Component<IconProps> }>;
|
|
15
|
+
/** Index of the highlighted row, driven by the plugin's key handler. */
|
|
16
|
+
selected: number;
|
|
17
|
+
onSelect: (index: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { items, selected, onSelect }: Props = $props();
|
|
21
|
+
|
|
22
|
+
let list = $state<HTMLDivElement | null>(null);
|
|
23
|
+
|
|
24
|
+
// Arrowing past the fold has to bring the row with it — the plugin moves the
|
|
25
|
+
// index, so nothing else would scroll the panel.
|
|
26
|
+
$effect(() => {
|
|
27
|
+
const row = list?.children[selected] as HTMLElement | undefined;
|
|
28
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
29
|
+
});
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div
|
|
33
|
+
bind:this={list}
|
|
34
|
+
class="scrollbar-none flex max-h-[calc(var(--era-h-md)*8)] w-max min-w-48 flex-col overflow-y-auto rounded-md bg-elevated p-menu shadow-lg glass-blur"
|
|
35
|
+
>
|
|
36
|
+
{#if items.length === 0}
|
|
37
|
+
<div class="flex h-md items-center px-inset-md text-body era-text-trim text-muted">
|
|
38
|
+
No commands
|
|
39
|
+
</div>
|
|
40
|
+
{:else}
|
|
41
|
+
{#each items as item, i (item.title)}
|
|
42
|
+
{@const Icon = item.icon}
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
class="flex h-md w-full cursor-pointer items-center gap-gutter rounded-item px-inset-md text-left text-body era-text-trim text-fg {i ===
|
|
46
|
+
selected
|
|
47
|
+
? 'bg-highlight shadow-highlight'
|
|
48
|
+
: ''}"
|
|
49
|
+
title={item.description}
|
|
50
|
+
onmousedown={(e) => {
|
|
51
|
+
// Never let the panel take focus — the editor selection the command
|
|
52
|
+
// is about to act on would collapse before the handler runs.
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
onSelect(i);
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<Icon class="size-xs shrink-0" />
|
|
58
|
+
<span class="flex-1">{item.title}</span>
|
|
59
|
+
<span class="shrink-0 text-muted">{item.description}</span>
|
|
60
|
+
</button>
|
|
61
|
+
{/each}
|
|
62
|
+
{/if}
|
|
63
|
+
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `/` command palette, rendered from era's menu vocabulary — the same
|
|
3
|
+
* floating-panel and row recipes DropdownMenu.Content / .Item use, so it picks
|
|
4
|
+
* up every axis (density tiers, surface chrome, corners, font) for free.
|
|
5
|
+
*
|
|
6
|
+
* Presentational on purpose: TipTap's Suggestion plugin owns the query,
|
|
7
|
+
* the filtered set, and the keyboard, and pushes the result in as props.
|
|
8
|
+
*/
|
|
9
|
+
import type { Component } from 'svelte';
|
|
10
|
+
import type { IconProps } from '@lucide/svelte';
|
|
11
|
+
interface Props {
|
|
12
|
+
items: Array<{
|
|
13
|
+
title: string;
|
|
14
|
+
description: string;
|
|
15
|
+
icon: Component<IconProps>;
|
|
16
|
+
}>;
|
|
17
|
+
/** Index of the highlighted row, driven by the plugin's key handler. */
|
|
18
|
+
selected: number;
|
|
19
|
+
onSelect: (index: number) => void;
|
|
20
|
+
}
|
|
21
|
+
declare const SlashMenu: Component<Props, {}, "">;
|
|
22
|
+
type SlashMenu = ReturnType<typeof SlashMenu>;
|
|
23
|
+
export default SlashMenu;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { default as Notes } from './notes.svelte';
|
|
2
|
+
export { default as NoteEditor } from './note-editor.svelte';
|
|
3
|
+
export { NotesStore, localStorageAdapter, memoryAdapter, DEFAULT_STORAGE_KEY, type NotesAdapter, type NotesStoreOptions, type SaveStatus } from './notes-store.svelte.js';
|
|
4
|
+
export { noteExtensions } from './editor/extensions.js';
|
|
5
|
+
export { SlashCommand, slashItems, type SlashCommandItem } from './editor/slash-command.svelte.js';
|
|
6
|
+
export { BubbleMenu } from './editor/bubble-menu.svelte.js';
|
|
7
|
+
export { ListCleanupRule } from './editor/list-cleanup-rule.js';
|
|
8
|
+
export { toggleLink } from './editor/link.js';
|
|
9
|
+
export { tiptapToMarkdown, textContent } from './markdown.js';
|
|
10
|
+
export { buildNoteTree, buildGlobalTree, countNodes, totalWords, treeToText, extractSection, extractSectionNodes, type NoteTreeNode, type SectionResult, type SectionNodes } from './tree.js';
|
|
11
|
+
export { EMPTY_DOC, type Note, type NoteInit, type NoteListItem, type NotePatch, type TiptapDocument, type TiptapMark, type TiptapNode } from './types.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { default as Notes } from './notes.svelte';
|
|
2
|
+
export { default as NoteEditor } from './note-editor.svelte';
|
|
3
|
+
export { NotesStore, localStorageAdapter, memoryAdapter, DEFAULT_STORAGE_KEY } from './notes-store.svelte.js';
|
|
4
|
+
// The editor internals, exported so a host can extend the extension set,
|
|
5
|
+
// reuse the slash items, or mount the editor without the surrounding app.
|
|
6
|
+
export { noteExtensions } from './editor/extensions.js';
|
|
7
|
+
export { SlashCommand, slashItems } from './editor/slash-command.svelte.js';
|
|
8
|
+
export { BubbleMenu } from './editor/bubble-menu.svelte.js';
|
|
9
|
+
export { ListCleanupRule } from './editor/list-cleanup-rule.js';
|
|
10
|
+
export { toggleLink } from './editor/link.js';
|
|
11
|
+
export { tiptapToMarkdown, textContent } from './markdown.js';
|
|
12
|
+
export { buildNoteTree, buildGlobalTree, countNodes, totalWords, treeToText, extractSection, extractSectionNodes } from './tree.js';
|
|
13
|
+
export { EMPTY_DOC } from './types.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipTap JSON → Markdown.
|
|
3
|
+
*
|
|
4
|
+
* A pure AST walker with no editor instance and no DOM: it runs on a stored
|
|
5
|
+
* document, not on a mounted editor. That is what lets `tree.ts` serialise one
|
|
6
|
+
* SECTION of a note (a heading and the blocks under it) — there is no editor
|
|
7
|
+
* for a slice of a document, so `editor.storage.markdown` cannot help there.
|
|
8
|
+
*/
|
|
9
|
+
import type { TiptapDocument, TiptapNode } from './types.js';
|
|
10
|
+
export declare function tiptapToMarkdown(doc: TiptapDocument): string;
|
|
11
|
+
/** Flatten a node's text, ignoring all structure. Used for `plainText` and search. */
|
|
12
|
+
export declare function textContent(node: TiptapNode): string;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TipTap JSON → Markdown.
|
|
3
|
+
*
|
|
4
|
+
* A pure AST walker with no editor instance and no DOM: it runs on a stored
|
|
5
|
+
* document, not on a mounted editor. That is what lets `tree.ts` serialise one
|
|
6
|
+
* SECTION of a note (a heading and the blocks under it) — there is no editor
|
|
7
|
+
* for a slice of a document, so `editor.storage.markdown` cannot help there.
|
|
8
|
+
*/
|
|
9
|
+
export function tiptapToMarkdown(doc) {
|
|
10
|
+
if (!doc.content)
|
|
11
|
+
return '';
|
|
12
|
+
return doc.content.map((n) => blockToMd(n, 0)).join('\n\n');
|
|
13
|
+
}
|
|
14
|
+
/** Flatten a node's text, ignoring all structure. Used for `plainText` and search. */
|
|
15
|
+
export function textContent(node) {
|
|
16
|
+
if (node.text)
|
|
17
|
+
return node.text;
|
|
18
|
+
if (!node.content)
|
|
19
|
+
return '';
|
|
20
|
+
return node.content.map(textContent).join('');
|
|
21
|
+
}
|
|
22
|
+
// -- Block nodes ----------------------------------------------------------
|
|
23
|
+
function blockToMd(node, indent) {
|
|
24
|
+
switch (node.type) {
|
|
25
|
+
case 'heading':
|
|
26
|
+
return '#'.repeat(node.attrs?.level ?? 1) + ' ' + inline(node);
|
|
27
|
+
case 'paragraph':
|
|
28
|
+
return inline(node);
|
|
29
|
+
case 'bulletList':
|
|
30
|
+
case 'orderedList':
|
|
31
|
+
case 'taskList':
|
|
32
|
+
return listToMd(node, indent);
|
|
33
|
+
case 'codeBlock': {
|
|
34
|
+
const lang = node.attrs?.language ?? '';
|
|
35
|
+
return '```' + lang + '\n' + textContent(node) + '\n```';
|
|
36
|
+
}
|
|
37
|
+
case 'blockquote': {
|
|
38
|
+
const inner = (node.content ?? []).map((n) => blockToMd(n, 0)).join('\n\n');
|
|
39
|
+
return inner.replace(/^/gm, '> ');
|
|
40
|
+
}
|
|
41
|
+
case 'table':
|
|
42
|
+
return tableToMd(node);
|
|
43
|
+
case 'details':
|
|
44
|
+
return detailsToMd(node);
|
|
45
|
+
case 'image':
|
|
46
|
+
return ``;
|
|
47
|
+
case 'horizontalRule':
|
|
48
|
+
return '---';
|
|
49
|
+
default:
|
|
50
|
+
return textContent(node);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// -- Lists ----------------------------------------------------------------
|
|
54
|
+
function listToMd(node, indent) {
|
|
55
|
+
const items = node.content ?? [];
|
|
56
|
+
const isOrdered = node.type === 'orderedList';
|
|
57
|
+
const isTask = node.type === 'taskList';
|
|
58
|
+
return items
|
|
59
|
+
.map((item, idx) => {
|
|
60
|
+
let marker;
|
|
61
|
+
if (isTask) {
|
|
62
|
+
marker = item.attrs?.checked ? '- [x] ' : '- [ ] ';
|
|
63
|
+
}
|
|
64
|
+
else if (isOrdered) {
|
|
65
|
+
marker = `${idx + 1}. `;
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
marker = '- ';
|
|
69
|
+
}
|
|
70
|
+
const blocks = item.content ?? [];
|
|
71
|
+
const pad = ' '.repeat(indent);
|
|
72
|
+
const continuation = pad + ' '.repeat(marker.length);
|
|
73
|
+
const lines = [];
|
|
74
|
+
for (const [i, block] of blocks.entries()) {
|
|
75
|
+
if (i === 0 && block.type === 'paragraph') {
|
|
76
|
+
lines.push(pad + marker + inline(block));
|
|
77
|
+
}
|
|
78
|
+
else if (block.type === 'bulletList' ||
|
|
79
|
+
block.type === 'orderedList' ||
|
|
80
|
+
block.type === 'taskList') {
|
|
81
|
+
lines.push(listToMd(block, indent + 1));
|
|
82
|
+
}
|
|
83
|
+
else if (block.type === 'paragraph') {
|
|
84
|
+
lines.push(continuation + inline(block));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
lines.push(blockToMd(block, indent + 1));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return lines.join('\n');
|
|
91
|
+
})
|
|
92
|
+
.join('\n');
|
|
93
|
+
}
|
|
94
|
+
// -- Tables ---------------------------------------------------------------
|
|
95
|
+
function tableToMd(node) {
|
|
96
|
+
const rows = (node.content ?? []).filter((r) => r.type === 'tableRow');
|
|
97
|
+
if (rows.length === 0)
|
|
98
|
+
return '';
|
|
99
|
+
const mdRows = rows.map((row) => {
|
|
100
|
+
const cells = (row.content ?? []).map((cell) => (cell.content ?? []).map((block) => inline(block)).join(' '));
|
|
101
|
+
return '| ' + cells.join(' | ') + ' |';
|
|
102
|
+
});
|
|
103
|
+
// Markdown needs the alignment row directly under the header row.
|
|
104
|
+
const colCount = (rows[0].content ?? []).length;
|
|
105
|
+
const sep = '| ' + Array.from({ length: colCount }, () => '---').join(' | ') + ' |';
|
|
106
|
+
mdRows.splice(1, 0, sep);
|
|
107
|
+
return mdRows.join('\n');
|
|
108
|
+
}
|
|
109
|
+
// -- Details/Summary ------------------------------------------------------
|
|
110
|
+
function detailsToMd(node) {
|
|
111
|
+
let summary = '';
|
|
112
|
+
const parts = [];
|
|
113
|
+
for (const child of node.content ?? []) {
|
|
114
|
+
if (child.type === 'detailsSummary') {
|
|
115
|
+
summary = inline(child);
|
|
116
|
+
}
|
|
117
|
+
else if (child.type === 'detailsContent') {
|
|
118
|
+
parts.push((child.content ?? []).map((n) => blockToMd(n, 0)).join('\n\n'));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return `<details>\n<summary>${summary}</summary>\n\n${parts.join('\n\n')}\n</details>`;
|
|
122
|
+
}
|
|
123
|
+
// -- Inline content -------------------------------------------------------
|
|
124
|
+
function inline(node) {
|
|
125
|
+
if (!node.content)
|
|
126
|
+
return '';
|
|
127
|
+
return node.content.map(inlineNode).join('');
|
|
128
|
+
}
|
|
129
|
+
function inlineNode(node) {
|
|
130
|
+
if (node.type === 'text')
|
|
131
|
+
return applyMarks(node.text ?? '', node.marks);
|
|
132
|
+
if (node.type === 'hardBreak')
|
|
133
|
+
return '\n';
|
|
134
|
+
if (node.type === 'image')
|
|
135
|
+
return ``;
|
|
136
|
+
return textContent(node);
|
|
137
|
+
}
|
|
138
|
+
function applyMarks(text, marks) {
|
|
139
|
+
if (!marks || marks.length === 0)
|
|
140
|
+
return text;
|
|
141
|
+
// Code wins outright — emphasis inside a code span is literal backtick noise.
|
|
142
|
+
if (marks.some((m) => m.type === 'code'))
|
|
143
|
+
return `\`${text}\``;
|
|
144
|
+
let result = text;
|
|
145
|
+
for (const mark of marks) {
|
|
146
|
+
switch (mark.type) {
|
|
147
|
+
case 'bold':
|
|
148
|
+
result = `**${result}**`;
|
|
149
|
+
break;
|
|
150
|
+
case 'italic':
|
|
151
|
+
result = `*${result}*`;
|
|
152
|
+
break;
|
|
153
|
+
case 'strike':
|
|
154
|
+
result = `~~${result}~~`;
|
|
155
|
+
break;
|
|
156
|
+
case 'highlight':
|
|
157
|
+
result = `==${result}==`;
|
|
158
|
+
break;
|
|
159
|
+
case 'link':
|
|
160
|
+
result = `[${result}](${mark.attrs?.href ?? ''})`;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|