sonorance 0.1.0-beta.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +103 -0
  3. package/build/icon.png +0 -0
  4. package/package.json +86 -0
  5. package/skill/SKILL.md +41 -0
  6. package/skill/scripts/sonorance.mjs +31 -0
  7. package/src/azure-monitor.mjs +221 -0
  8. package/src/cli.mjs +315 -0
  9. package/src/comment-client.mjs +75 -0
  10. package/src/engine-default.mjs +136 -0
  11. package/src/feedback.mjs +151 -0
  12. package/src/gitignore.mjs +27 -0
  13. package/src/grammar.mjs +55 -0
  14. package/src/identity.mjs +126 -0
  15. package/src/otlp.mjs +166 -0
  16. package/src/plugins/deliberate/contribute.mjs +28 -0
  17. package/src/plugins/deliberate/domain.mjs +40 -0
  18. package/src/plugins/deliberate/frontmatter.mjs +85 -0
  19. package/src/plugins/deliberate/gitignore.mjs +21 -0
  20. package/src/plugins/deliberate/kinds.mjs +44 -0
  21. package/src/plugins/deliberate/markdown.mjs +42 -0
  22. package/src/plugins/deliberate/paths.mjs +245 -0
  23. package/src/plugins/deliberate/stages.mjs +27 -0
  24. package/src/plugins/deliberate/vault.mjs +1043 -0
  25. package/src/plugins.mjs +91 -0
  26. package/src/release-config.mjs +2 -0
  27. package/src/scrubber.mjs +64 -0
  28. package/src/server/index.mjs +993 -0
  29. package/src/sources.mjs +80 -0
  30. package/src/telemetry-schema.mjs +187 -0
  31. package/src/telemetry.mjs +390 -0
  32. package/src/ui/active-line.mjs +42 -0
  33. package/src/ui/app.js +3553 -0
  34. package/src/ui/at-mention.mjs +67 -0
  35. package/src/ui/comments-plugin.mjs +107 -0
  36. package/src/ui/diff-plugin.mjs +73 -0
  37. package/src/ui/editor.mjs +210 -0
  38. package/src/ui/index.html +1723 -0
  39. package/src/ui/md.mjs +233 -0
  40. package/src/ui/paste-md.mjs +54 -0
  41. package/src/ui/shell.html +1374 -0
  42. package/src/ui/slash.mjs +122 -0
  43. package/src/vault-registry.mjs +150 -0
@@ -0,0 +1,67 @@
1
+ // "@" mention for the Tiptap surface — type "@" to summon the agent and leave a
2
+ // comment for it, from the keyboard, without reaching for the mouse.
3
+ //
4
+ // In Sonorance a comment IS a message to the agent (the composer says "leave a
5
+ // comment for the agent to address"), so "@agent" is the natural, self-marketing
6
+ // trigger: it reads as addressing the AI. Built on Tiptap's Suggestion utility,
7
+ // mirroring slash.mjs (same self-rendered popup, no tippy).
8
+ //
9
+ // The mention is a pure TRIGGER: picking "@agent" deletes the typed "@…" (the
10
+ // document is never mutated with mention text) and hands off to the app, which
11
+ // opens the comment composer anchored to the block the caret is in. The mention
12
+ // list is built to grow — add "@person" here when collaboration lands.
13
+ import { Extension } from '@tiptap/core';
14
+ import { Suggestion } from '@tiptap/suggestion';
15
+ import { PluginKey } from '@tiptap/pm/state';
16
+ import { makeRenderer } from './slash.mjs';
17
+
18
+ // The mention roster. `run()` clears the typed trigger, then the app callback
19
+ // opens the composer on the current block (see onAtComment in app.js).
20
+ function targets(onComment) {
21
+ return [
22
+ { title: '@agent — ask the agent', keys: 'agent ai ask comment', run: ({ editor, range }) => {
23
+ editor.chain().focus().deleteRange(range).run();
24
+ // Defer so ProseMirror's DOM selection settles on the (now shortened) block
25
+ // before the app reads it to anchor the comment.
26
+ setTimeout(() => onComment && onComment(), 0);
27
+ } },
28
+ ];
29
+ }
30
+
31
+ function filter(list, query) {
32
+ const q = (query || '').toLowerCase().trim();
33
+ if (!q) return list;
34
+ return list.filter(c => (c.title + ' ' + c.keys).toLowerCase().includes(q));
35
+ }
36
+
37
+ // Factory: the "@" extension needs the app's composer callback, so it's built
38
+ // per editor (like the comments plugin) rather than shared statically.
39
+ export function atMention({ onComment } = {}) {
40
+ const list = targets(onComment);
41
+ return Extension.create({
42
+ name: 'atMention',
43
+ addProseMirrorPlugins() {
44
+ return [
45
+ Suggestion({
46
+ editor: this.editor,
47
+ pluginKey: new PluginKey('atSuggestion'),
48
+ char: '@',
49
+ startOfLine: false,
50
+ allowSpaces: false,
51
+ // Fire only when "@" opens a word (start of block or after whitespace),
52
+ // never mid-token — so an email address (a@b) never pops the menu, and
53
+ // never inside a code block.
54
+ allow: ({ state, range }) => {
55
+ const $from = state.doc.resolve(range.from);
56
+ if ($from.parent.type.spec.code) return false;
57
+ const before = $from.parent.textBetween(0, $from.parentOffset, undefined, ' ');
58
+ return /(^|\s)@?$/.test(before);
59
+ },
60
+ items: ({ query }) => filter(list, query),
61
+ command: ({ editor, range, props }) => props.run({ editor, range }),
62
+ render: makeRenderer,
63
+ }),
64
+ ];
65
+ },
66
+ });
67
+ }
@@ -0,0 +1,107 @@
1
+ // Comment highlighting for the Tiptap surface, as a ProseMirror plugin.
2
+ //
3
+ // The hand-rolled live-preview highlighted comment anchors by injecting <span
4
+ // class="cmark"> into the DOM (surroundContents). That fights ProseMirror,
5
+ // which owns and re-renders its DOM. The correct mechanism is a decoration
6
+ // set: we locate each comment's anchor (its `quote`, scoped to the section
7
+ // under its `heading`) in the current doc and render an inline decoration.
8
+ // Decorations survive edits (they re-derive from doc state) and never mutate
9
+ // the document.
10
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
11
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
12
+
13
+ export const commentsKey = new PluginKey('sonorance-comments');
14
+
15
+ // Flatten the doc into a plain string with a parallel map from each character
16
+ // index to its ProseMirror position, plus the char index at which each heading
17
+ // (by trimmed text) starts — so a quote can be scoped to its section.
18
+ function flatten(doc) {
19
+ let text = '';
20
+ const posAt = []; // posAt[i] = PM position of character i
21
+ const headingStart = []; // { text, at } in order
22
+ let curHeadingText = null, curHeadingAt = -1;
23
+ doc.descendants((node, pos) => {
24
+ if (node.type.name === 'heading') { curHeadingText = ''; curHeadingAt = text.length; return; }
25
+ if (node.isText) {
26
+ const t = node.text || '';
27
+ for (let i = 0; i < t.length; i++) { posAt.push(pos + i); }
28
+ text += t;
29
+ if (curHeadingText !== null) curHeadingText += t;
30
+ } else if (node.isBlock && text.length && text[text.length - 1] !== '\n') {
31
+ // block boundary — keep quotes from spanning across blocks
32
+ text += '\n'; posAt.push(pos);
33
+ if (curHeadingText !== null && curHeadingText.trim()) {
34
+ headingStart.push({ text: curHeadingText.trim(), at: curHeadingAt });
35
+ curHeadingText = null;
36
+ }
37
+ }
38
+ });
39
+ if (curHeadingText !== null && curHeadingText.trim()) headingStart.push({ text: curHeadingText.trim(), at: curHeadingAt });
40
+ return { text, posAt, headingStart };
41
+ }
42
+
43
+ // Locate a comment anchor -> { from, to } PM positions, or null. Prefers a
44
+ // quote match scoped to its heading's section; falls back to a whole-doc quote
45
+ // search, then to the heading itself.
46
+ export function findRange(doc, anchor) {
47
+ if (!anchor) return null;
48
+ const { text, posAt, headingStart } = flatten(doc);
49
+ const quote = (anchor.quote || '').trim();
50
+ const heading = (anchor.heading || '').trim();
51
+
52
+ let scopeStart = 0;
53
+ if (heading) {
54
+ const h = headingStart.find(x => x.text === heading);
55
+ if (h) scopeStart = h.at;
56
+ }
57
+ if (quote && quote.length >= 2) {
58
+ let idx = text.indexOf(quote, scopeStart);
59
+ if (idx < 0) idx = text.indexOf(quote); // fall back to a global search
60
+ if (idx >= 0 && posAt[idx] != null) {
61
+ const from = posAt[idx];
62
+ const to = (posAt[idx + quote.length - 1] ?? from) + 1;
63
+ return { from, to };
64
+ }
65
+ }
66
+ // No usable quote: highlight the heading line if we found it.
67
+ if (heading) {
68
+ const h = headingStart.find(x => x.text === heading);
69
+ if (h && posAt[h.at] != null) return { from: posAt[h.at], to: (posAt[h.at + heading.length - 1] ?? posAt[h.at]) + 1 };
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function buildDecorations(doc, comments) {
75
+ const decos = [];
76
+ for (const c of comments || []) {
77
+ const r = findRange(doc, c.anchor);
78
+ if (!r || r.to <= r.from) continue;
79
+ decos.push(Decoration.inline(r.from, r.to, { class: 'cmark', 'data-comment': c.id }));
80
+ }
81
+ return DecorationSet.create(doc, decos);
82
+ }
83
+
84
+ // A Tiptap-compatible ProseMirror plugin. `onClick(id)` fires when a highlight
85
+ // is clicked (to focus the matching comment card).
86
+ export function commentsPlugin({ onClick } = {}) {
87
+ return new Plugin({
88
+ key: commentsKey,
89
+ state: {
90
+ init: () => ({ comments: [], decos: DecorationSet.empty }),
91
+ apply(tr, value) {
92
+ const meta = tr.getMeta(commentsKey);
93
+ if (meta && meta.comments) return { comments: meta.comments, decos: buildDecorations(tr.doc, meta.comments) };
94
+ if (tr.docChanged) return { comments: value.comments, decos: buildDecorations(tr.doc, value.comments) };
95
+ return value;
96
+ },
97
+ },
98
+ props: {
99
+ decorations(state) { return commentsKey.getState(state)?.decos; },
100
+ handleClick(view, pos, event) {
101
+ const el = event.target.closest?.('.cmark');
102
+ if (el && el.dataset.comment) { onClick?.(el.dataset.comment); return true; }
103
+ return false;
104
+ },
105
+ },
106
+ });
107
+ }
@@ -0,0 +1,73 @@
1
+ // Change decorations for the Tiptap surface, as a ProseMirror plugin.
2
+ //
3
+ // The hand-rolled live-preview decorated diffs by mutating the rendered DOM
4
+ // (inserting `.diffdel` islands, toggling `.diff-add`/`.diff-mod` on `.blk`
5
+ // elements). That fights ProseMirror, which owns its DOM. Here the diff is a
6
+ // decoration set instead: added/modified top-level blocks get a NODE
7
+ // decoration (a class), and removed blocks are shown as WIDGET decorations
8
+ // (read-only red islands rendered from pre-computed HTML) before the block
9
+ // that follows them.
10
+ //
11
+ // The diff itself is computed in the app (over serialized Markdown, reusing the
12
+ // same LCS the live-preview path uses) and handed in as instructions keyed by
13
+ // top-level block index — this module only turns those into decorations, and
14
+ // bails safely (renders nothing) if the block count no longer lines up with the
15
+ // document (e.g. mid-edit), exactly like the live-preview `diffWorkUnits` guard.
16
+ import { Plugin, PluginKey } from '@tiptap/pm/state';
17
+ import { Decoration, DecorationSet } from '@tiptap/pm/view';
18
+
19
+ export const diffKey = new PluginKey('sonorance-diff');
20
+
21
+ // Absolute positions of each top-level block node, in document order.
22
+ function topLevelPositions(doc) {
23
+ const out = [];
24
+ doc.forEach((node, offset) => { out.push({ pos: offset, size: node.nodeSize }); });
25
+ return out;
26
+ }
27
+
28
+ function buildDecorations(doc, diff) {
29
+ if (!diff) return DecorationSet.empty;
30
+ const { nodeClasses = [], deletions = [], count } = diff;
31
+ const blocks = topLevelPositions(doc);
32
+ // Bail (show nothing) if the document no longer matches the diff we were
33
+ // handed — a transient mid-edit state; the app re-derives on the next tick.
34
+ if (typeof count === 'number' && blocks.length !== count) return DecorationSet.empty;
35
+
36
+ const decos = [];
37
+ for (let i = 0; i < blocks.length; i++) {
38
+ const cls = nodeClasses[i];
39
+ if (cls) decos.push(Decoration.node(blocks[i].pos, blocks[i].pos + blocks[i].size, { class: cls }));
40
+ }
41
+ for (const d of deletions || []) {
42
+ const at = d.at;
43
+ const pos = at >= 0 && at < blocks.length ? blocks[at].pos
44
+ : (blocks.length ? blocks[blocks.length - 1].pos + blocks[blocks.length - 1].size : 0);
45
+ const side = at >= 0 && at < blocks.length ? -1 : 1;
46
+ decos.push(Decoration.widget(pos, () => {
47
+ const wrap = document.createElement('div');
48
+ wrap.className = 'diffdel-wrap';
49
+ wrap.setAttribute('contenteditable', 'false');
50
+ wrap.innerHTML = d.html || '';
51
+ return wrap;
52
+ }, { side, ignoreSelection: true, key: 'del-' + at + '-' + (d.key || '') }));
53
+ }
54
+ return DecorationSet.create(doc, decos);
55
+ }
56
+
57
+ export function diffPlugin() {
58
+ return new Plugin({
59
+ key: diffKey,
60
+ state: {
61
+ init: () => ({ diff: null, decos: DecorationSet.empty }),
62
+ apply(tr, value) {
63
+ const meta = tr.getMeta(diffKey);
64
+ if (meta !== undefined) return { diff: meta, decos: buildDecorations(tr.doc, meta) };
65
+ if (tr.docChanged) return { diff: value.diff, decos: buildDecorations(tr.doc, value.diff) };
66
+ return value;
67
+ },
68
+ },
69
+ props: {
70
+ decorations(state) { return diffKey.getState(state)?.decos; },
71
+ },
72
+ });
73
+ }
@@ -0,0 +1,210 @@
1
+ // The Sonorance editor core — a thin controller over a Tiptap/ProseMirror
2
+ // editor. The app talks to this module, never to Tiptap directly, so the rest
3
+ // of the UI (open/save, ToC, comments, diff) has one stable seam.
4
+ //
5
+ // Markdown is the on-disk format; the editor's live truth is a ProseMirror
6
+ // doc. Leading YAML frontmatter is split out on load (kept verbatim, hidden)
7
+ // and re-attached on serialize. See md.mjs for the bridge.
8
+ import { Editor } from '@tiptap/core';
9
+ import { StarterKit } from '@tiptap/starter-kit';
10
+ import { Link } from '@tiptap/extension-link';
11
+ import { TaskList } from '@tiptap/extension-task-list';
12
+ import { TaskItem } from '@tiptap/extension-task-item';
13
+ import { TableKit } from '@tiptap/extension-table';
14
+ import { Placeholder } from '@tiptap/extension-placeholder';
15
+ import { Extension } from '@tiptap/core';
16
+ import { mdToDoc, docToMd } from './md.mjs';
17
+ import { commentsPlugin, commentsKey, findRange } from './comments-plugin.mjs';
18
+ import { diffPlugin, diffKey } from './diff-plugin.mjs';
19
+ import { activeLinePlugin } from './active-line.mjs';
20
+ import { SlashCommands } from './slash.mjs';
21
+ import { atMention } from './at-mention.mjs';
22
+ import { markdownPastePlugin } from './paste-md.mjs';
23
+
24
+ // Slug a heading's text into a stable id (matches the old reindexHeadings so
25
+ // ToC links + comment heading-anchors keep working).
26
+ function slug(text) {
27
+ return String(text).toLowerCase().trim()
28
+ .replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'section';
29
+ }
30
+
31
+ function textOf(node) {
32
+ let out = '';
33
+ node.descendants?.((n) => { if (n.isText) out += n.text; });
34
+ return out;
35
+ }
36
+
37
+ // Split a Markdown string into its top-level block sources, normalized through
38
+ // the SAME PM-JSON -> Markdown path the live editor uses (so a block's text is
39
+ // byte-identical whether it comes from the committed base here or from the live
40
+ // document via blockSources()). Used by the app's WYSIWYG diff. Frontmatter is
41
+ // dropped (it isn't a document block).
42
+ export function mdBlocks(md) {
43
+ const { doc } = mdToDoc(md || '');
44
+ const kids = (doc && doc.content) || [];
45
+ return kids.map((node) => docToMd({ type: 'doc', content: [node] }, '').trimEnd());
46
+ }
47
+
48
+ export function baseExtensions({ placeholder } = {}) {
49
+ const ext = [
50
+ StarterKit.configure({ link: false }),
51
+ Link.configure({ openOnClick: false, autolink: true }),
52
+ TaskList,
53
+ TaskItem.configure({ nested: true }),
54
+ TableKit.configure({ table: { resizable: true } }),
55
+ SlashCommands,
56
+ ];
57
+ if (placeholder) ext.push(Placeholder.configure({
58
+ // Per-block hint (only the block with the caret, see showOnlyCurrent): the
59
+ // empty document gets the passed prompt; any other empty block advertises the
60
+ // two keyboard affordances so "/" and "@" are discoverable in place.
61
+ includeChildren: false,
62
+ showOnlyCurrent: true,
63
+ placeholder: ({ editor, node }) => {
64
+ if (editor.isEmpty) return placeholder;
65
+ if (node.type.name === 'paragraph') return 'Type “/” for blocks, “@” to ask the agent…';
66
+ return '';
67
+ },
68
+ }));
69
+ return ext;
70
+ }
71
+
72
+ export function createEditor({ element, editable = true, placeholder, onChange, onSelectionChange, onCommentClick, onAtComment, onLinkClick, extensions } = {}) {
73
+ let frontmatter = '';
74
+
75
+ const commentExt = Extension.create({
76
+ name: 'sonoranceComments',
77
+ addProseMirrorPlugins() { return [commentsPlugin({ onClick: onCommentClick }), diffPlugin(), activeLinePlugin(), markdownPastePlugin()]; },
78
+ });
79
+
80
+ const editor = new Editor({
81
+ element,
82
+ editable,
83
+ extensions: (extensions || baseExtensions({ placeholder })).concat(commentExt, atMention({ onComment: onAtComment })),
84
+ content: { type: 'doc', content: [{ type: 'paragraph' }] },
85
+ // Links stay `openOnClick: false` (so a plain click still places the caret for editing the
86
+ // surrounding text), but a click that lands ON the anchor jumps out. The app handler opens
87
+ // external targets in a new tab and scrolls in-page `#anchor` links.
88
+ editorProps: {
89
+ handleClick(view, pos, event) {
90
+ const a = event.target && event.target.closest && event.target.closest('a[href]');
91
+ if (a && onLinkClick) { onLinkClick(a, event); return true; }
92
+ return false;
93
+ },
94
+ },
95
+ onUpdate: () => { onChange?.(); },
96
+ onSelectionUpdate: () => { onSelectionChange?.(); },
97
+ });
98
+
99
+ return {
100
+ editor,
101
+
102
+ // Load a Markdown string (frontmatter split off + remembered).
103
+ setMarkdown(md, { emit = false } = {}) {
104
+ const { frontmatter: fm, doc } = mdToDoc(md || '');
105
+ frontmatter = fm;
106
+ editor.commands.setContent(doc, { emitUpdate: emit });
107
+ },
108
+
109
+ // Serialize the current doc back to Markdown (frontmatter re-attached).
110
+ getMarkdown() {
111
+ return docToMd(editor.getJSON(), frontmatter);
112
+ },
113
+
114
+ frontmatter() { return frontmatter; },
115
+ setFrontmatter(fm) { frontmatter = fm || ''; },
116
+
117
+ setEditable(on) { editor.setEditable(!!on); },
118
+ isEditable() { return editor.isEditable; },
119
+ focus() { editor.commands.focus(); },
120
+ hasFocus() { try { return editor.view.hasFocus(); } catch { return false; } },
121
+ isEmpty() { return editor.isEmpty; },
122
+
123
+ // Whether a mark/node is active at the current selection — for lighting the
124
+ // bubble-menu buttons (bold/italic/link/H2) and detecting a table context.
125
+ isActive(name, attrs) { try { return editor.isActive(name, attrs); } catch { return false; } },
126
+
127
+ // Inline formatting toggles from the selection bubble menu (thin wrappers so
128
+ // the app never reaches into Tiptap directly). Each keeps the selection.
129
+ toggleBold() { editor.chain().focus().toggleBold().run(); },
130
+ toggleItalic() { editor.chain().focus().toggleItalic().run(); },
131
+ toggleHeading(level = 2) { editor.chain().focus().toggleHeading({ level }).run(); },
132
+ // Set (or clear when href is empty) a link on the current selection.
133
+ setLink(href) {
134
+ const h = (href || '').trim();
135
+ if (!h) editor.chain().focus().unsetLink().run();
136
+ else editor.chain().focus().setLink({ href: h }).run();
137
+ },
138
+
139
+ // Table structure edits from the in-table toolbar.
140
+ addRow() { editor.chain().focus().addRowAfter().run(); },
141
+ addColumn() { editor.chain().focus().addColumnAfter().run(); },
142
+ deleteRow() { editor.chain().focus().deleteRow().run(); },
143
+ deleteColumn() { editor.chain().focus().deleteColumn().run(); },
144
+ deleteTable() { editor.chain().focus().deleteTable().run(); },
145
+
146
+ // Show a set of comments as inline highlight decorations (no DOM mutation).
147
+ setComments(comments) {
148
+ const tr = editor.state.tr.setMeta(commentsKey, { comments: comments || [] });
149
+ editor.view.dispatch(tr);
150
+ },
151
+
152
+ // Show change decorations (added/modified block classes + removed-block
153
+ // widget islands). Pass null to clear. See diff-plugin.mjs for the shape.
154
+ setDiff(diff) {
155
+ const tr = editor.state.tr.setMeta(diffKey, diff || null);
156
+ editor.view.dispatch(tr);
157
+ },
158
+
159
+ // The top-level block sources of the current doc, in order — the unit the
160
+ // app's diff compares against the committed base. Serialized per block so
161
+ // block index lines up 1:1 with the ProseMirror top-level nodes.
162
+ blockSources() {
163
+ const json = editor.getJSON();
164
+ const kids = (json && json.content) || [];
165
+ return kids.map((node) => docToMd({ type: 'doc', content: [node] }, '').trimEnd());
166
+ },
167
+
168
+ // The PM range { from, to } a comment anchors to in the current doc, or null.
169
+ commentRange(anchor) { return findRange(editor.state.doc, anchor); },
170
+
171
+ // The DOM rect of a comment's anchor (for placing/scrolling its card).
172
+ commentRect(anchor) {
173
+ const r = findRange(editor.state.doc, anchor);
174
+ if (!r) return null;
175
+ try { const s = editor.view.coordsAtPos(r.from); return { top: s.top, bottom: s.bottom, left: s.left }; } catch { return null; }
176
+ },
177
+
178
+ // The anchor { quote, heading } for the current (non-empty) selection, or
179
+ // null when the selection is collapsed. Used to create a new comment.
180
+ selectionAnchor() {
181
+ const { from, to, empty } = editor.state.selection;
182
+ if (empty || to <= from) return null;
183
+ const quote = editor.state.doc.textBetween(from, to, ' ').trim();
184
+ if (!quote) return null;
185
+ let heading = '';
186
+ editor.state.doc.nodesBetween(0, from, (node, pos) => {
187
+ if (node.type.name === 'heading' && pos <= from) heading = node.textContent;
188
+ });
189
+ return { quote: quote.slice(0, 2000), heading: heading.trim() };
190
+ },
191
+
192
+ // Heading list for the ToC / scroll-spy, with stable, de-duplicated ids.
193
+ headings() {
194
+ const out = [];
195
+ const seen = new Map();
196
+ editor.state.doc.descendants((node) => {
197
+ if (node.type.name !== 'heading') return;
198
+ const text = textOf(node);
199
+ let id = slug(text);
200
+ const n = (seen.get(id) || 0) + 1;
201
+ seen.set(id, n);
202
+ if (n > 1) id = `${id}-${n}`;
203
+ out.push({ level: node.attrs.level, text, id });
204
+ });
205
+ return out;
206
+ },
207
+
208
+ destroy() { editor.destroy(); },
209
+ };
210
+ }