@markup-carve/carve-grammars 0.1.2

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.
@@ -0,0 +1,248 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+ import { carveMediaDirective } from '../serializer.js';
3
+
4
+ /**
5
+ * A preview iframe URL for the node view. Prefer an explicit embed src captured
6
+ * from the render; otherwise derive one from the Carve source directive so the
7
+ * player still shows after an inline edit.
8
+ *
9
+ * @param {object} attrs - carveEmbed node attrs.
10
+ * @returns {string} An https URL to embed, or '' if none can be derived.
11
+ */
12
+ function embedPreviewSrc(attrs) {
13
+ if (attrs.src) {
14
+ return attrs.src.startsWith('//') ? `https:${attrs.src}` : attrs.src;
15
+ }
16
+ const source = attrs.carveSource || '';
17
+ let m = source.match(/^:youtube\[([\w-]+)\]/i);
18
+ if (m) return `https://www.youtube.com/embed/${m[1]}`;
19
+ m = source.match(/^:vimeo\[(\d+)\]/i);
20
+ if (m) return `https://player.vimeo.com/video/${m[1]}`;
21
+ m = source.match(/^:media\[([^\]]+)\]/i);
22
+ if (m) return m[1].startsWith('//') ? `https:${m[1]}` : m[1];
23
+ return '';
24
+ }
25
+
26
+ /**
27
+ * Carve Embed node extension for Tiptap
28
+ *
29
+ * Preserves video embeds, iframes, and oEmbed content during round-trips.
30
+ * Stores the original Carve source (e.g., YouTube URL) in data-carve-src.
31
+ *
32
+ * @example
33
+ * ```js
34
+ * import { CarveEmbed } from 'carve-grammars/tiptap'
35
+ *
36
+ * const editor = new Editor({
37
+ * extensions: [CarveEmbed],
38
+ * })
39
+ *
40
+ * // Insert an embed
41
+ * editor.chain().focus().setCarveEmbed({
42
+ * src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
43
+ * html: '<iframe...></iframe>',
44
+ * }).run()
45
+ * ```
46
+ */
47
+ export const CarveEmbed = Node.create({
48
+ name: 'carveEmbed',
49
+
50
+ group: 'block',
51
+
52
+ atom: true,
53
+
54
+ addAttributes() {
55
+ return {
56
+ // The exact Carve source stamped by the renderer (data-carve-source),
57
+ // e.g. ":youtube[id]" or ":media[url]". When present it is emitted
58
+ // verbatim on serialize - lossless for every provider, no guessing.
59
+ carveSource: {
60
+ default: null,
61
+ parseHTML: element => element.getAttribute('data-carve-source')
62
+ || element.querySelector('[data-carve-source]')?.getAttribute('data-carve-source')
63
+ || null,
64
+ renderHTML: attributes => {
65
+ if (!attributes.carveSource) return {};
66
+ return { 'data-carve-source': attributes.carveSource };
67
+ },
68
+ },
69
+ src: {
70
+ default: null,
71
+ parseHTML: element => {
72
+ // Check for data-carve-src first
73
+ const carveSrc = element.getAttribute('data-carve-src');
74
+ if (carveSrc) return carveSrc;
75
+ // The element may BE the iframe (matched by the tag rule) or
76
+ // wrap one.
77
+ if (element.tagName === 'IFRAME') return element.getAttribute('src');
78
+ const iframe = element.querySelector('iframe');
79
+ if (iframe) return iframe.getAttribute('src');
80
+ // Check for video source
81
+ const video = element.querySelector('video source');
82
+ if (video) return video.getAttribute('src');
83
+ return null;
84
+ },
85
+ renderHTML: attributes => {
86
+ if (!attributes.src) return {};
87
+ return { 'data-carve-src': attributes.src };
88
+ },
89
+ },
90
+ html: {
91
+ default: null,
92
+ // Keep the full original embed markup so the editor previews the
93
+ // real player (all iframe attributes: allow, referrerpolicy, ...),
94
+ // not a stripped-down reconstruction.
95
+ parseHTML: element => {
96
+ if (element.tagName === 'IFRAME') return element.outerHTML;
97
+ const iframe = element.querySelector('iframe');
98
+ if (iframe) return iframe.outerHTML;
99
+ return element.innerHTML || null;
100
+ },
101
+ renderHTML: () => ({}),
102
+ },
103
+ };
104
+ },
105
+
106
+ parseHTML() {
107
+ return [
108
+ // Match WordPress embed wrappers
109
+ { tag: 'figure.wp-block-embed' },
110
+ { tag: 'div.wp-block-embed' },
111
+ // Match wpcarve-embed class
112
+ { tag: 'figure.wpcarve-embed' },
113
+ { tag: 'div.wpcarve-embed' },
114
+ // A source-stamped element round-trips exactly - match it first.
115
+ { tag: '[data-carve-source]', priority: 60 },
116
+ // Match elements with data-carve-src
117
+ { tag: '[data-carve-src]' },
118
+ // Match iframes that look like video embeds
119
+ {
120
+ tag: 'iframe',
121
+ getAttrs: element => {
122
+ const src = element.getAttribute('src') || '';
123
+ // Only match video embed iframes
124
+ if (src.includes('youtube') || src.includes('vimeo') ||
125
+ src.includes('dailymotion') || src.includes('wistia')) {
126
+ return {};
127
+ }
128
+ return false;
129
+ },
130
+ },
131
+ ];
132
+ },
133
+
134
+ renderHTML({ HTMLAttributes, node }) {
135
+ const wrapper = document.createElement('div');
136
+ wrapper.innerHTML = node.attrs.html || '';
137
+
138
+ return ['figure', mergeAttributes(HTMLAttributes, {
139
+ class: 'wpcarve-embed',
140
+ 'data-carve-src': node.attrs.src,
141
+ }), node.attrs.html ? ['div', { innerHTML: node.attrs.html }] : ['p', 'Embedded content']];
142
+ },
143
+
144
+ addNodeView() {
145
+ return ({ node, editor, getPos }) => {
146
+ let current = node;
147
+ const dom = document.createElement('figure');
148
+ dom.classList.add('wpcarve-embed');
149
+ dom.contentEditable = 'false';
150
+
151
+ const media = document.createElement('div');
152
+ media.className = 'wpcarve-embed-media';
153
+
154
+ // Inline edit affordance: change the media URL / video id in place.
155
+ const editBtn = document.createElement('button');
156
+ editBtn.type = 'button';
157
+ editBtn.className = 'carve-embed-edit';
158
+ editBtn.textContent = 'Edit';
159
+ editBtn.contentEditable = 'false';
160
+ editBtn.setAttribute('aria-label', 'Edit media URL');
161
+ editBtn.addEventListener('mousedown', e => e.stopPropagation());
162
+ editBtn.addEventListener('click', () => {
163
+ if (typeof getPos !== 'function') return;
164
+ const shown = current.attrs.carveSource || current.attrs.src || '';
165
+ const val = window.prompt('Media URL or video id', shown);
166
+ if (val === null) return;
167
+ const input = val.trim();
168
+ if (!input) return;
169
+ // A `:name[...]` directive is kept as-is; a URL/id becomes one
170
+ // (a bare id is assumed to be YouTube).
171
+ const directive = input.startsWith(':')
172
+ ? input
173
+ : carveMediaDirective(/^https?:|^\/\//.test(input) ? input : `https://www.youtube.com/watch?v=${input}`);
174
+ editor.chain().focus().command(({ tr }) => {
175
+ tr.setNodeMarkup(getPos(), undefined, {
176
+ ...current.attrs,
177
+ carveSource: directive,
178
+ src: null,
179
+ html: null,
180
+ });
181
+ return true;
182
+ }).run();
183
+ });
184
+
185
+ const paint = (n) => {
186
+ media.innerHTML = '';
187
+ const src = embedPreviewSrc(n.attrs);
188
+ if (n.attrs.html) {
189
+ media.innerHTML = n.attrs.html;
190
+ } else if (src) {
191
+ const iframe = document.createElement('iframe');
192
+ iframe.src = src;
193
+ iframe.setAttribute('loading', 'lazy');
194
+ iframe.setAttribute('allowfullscreen', '');
195
+ iframe.setAttribute('frameborder', '0');
196
+ iframe.width = '480';
197
+ iframe.height = '270';
198
+ media.appendChild(iframe);
199
+ } else {
200
+ const p = document.createElement('p');
201
+ p.textContent = `Embedded: ${n.attrs.carveSource || 'unknown'}`;
202
+ media.appendChild(p);
203
+ }
204
+ if (n.attrs.src) {
205
+ dom.setAttribute('data-carve-src', n.attrs.src);
206
+ } else {
207
+ dom.removeAttribute('data-carve-src');
208
+ }
209
+ if (n.attrs.carveSource) {
210
+ dom.setAttribute('data-carve-source', n.attrs.carveSource);
211
+ } else {
212
+ dom.removeAttribute('data-carve-source');
213
+ }
214
+ };
215
+
216
+ dom.appendChild(media);
217
+ dom.appendChild(editBtn);
218
+ paint(node);
219
+
220
+ return {
221
+ dom,
222
+ update: (updated) => {
223
+ if (updated.type !== current.type) {
224
+ return false;
225
+ }
226
+ current = updated;
227
+ paint(updated);
228
+ return true;
229
+ },
230
+ // Atom chrome: never let ProseMirror re-read our internal DOM.
231
+ ignoreMutation: () => true,
232
+ };
233
+ };
234
+ },
235
+
236
+ addCommands() {
237
+ return {
238
+ setCarveEmbed: (attributes) => ({ commands }) => {
239
+ return commands.insertContent({
240
+ type: this.name,
241
+ attrs: attributes,
242
+ });
243
+ },
244
+ };
245
+ },
246
+ });
247
+
248
+ export default CarveEmbed;
@@ -0,0 +1,81 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Footnote Definition node for Tiptap.
5
+ *
6
+ * A block that holds a footnote's body, paired with an inline `[^label]`
7
+ * reference (see CarveFootnote). Serializes to Carve as:
8
+ *
9
+ * ```
10
+ * [^label]: the footnote body
11
+ * ```
12
+ *
13
+ * Hosts typically collect these at the end of the document. The body is regular
14
+ * block content (`paragraph+`).
15
+ *
16
+ * @example
17
+ * ```js
18
+ * import { CarveFootnoteDefinition } from 'carve-grammars/tiptap'
19
+ *
20
+ * editor.chain().focus().insertCarveFootnoteDefinition({ label: '1' }).run()
21
+ * ```
22
+ */
23
+ export const CarveFootnoteDefinition = Node.create({
24
+ name: 'carveFootnoteDefinition',
25
+
26
+ group: 'block',
27
+
28
+ content: 'paragraph+',
29
+
30
+ defining: true,
31
+
32
+ addAttributes() {
33
+ return {
34
+ label: {
35
+ default: 'note',
36
+ parseHTML: element => element.getAttribute('data-footnote-label')
37
+ // carve-php / carve-js reference: id="fnN".
38
+ || element.id.replace(/^fn/, '')
39
+ || 'note',
40
+ renderHTML: attributes => ({ 'data-footnote-label': attributes.label }),
41
+ },
42
+ };
43
+ },
44
+
45
+ parseHTML() {
46
+ return [
47
+ { tag: 'li[data-footnote-label]' },
48
+ { tag: 'section.carve-footnotes > ol > li' },
49
+ // carve-php / carve-js render the footnote section as
50
+ // <section role="doc-endnotes"><hr><ol><li id="fnN"><p>body
51
+ // <a role="doc-backlink">↩</a></p></li></ol></section>. Take each li
52
+ // as a definition; unwrap the <ol>, drop the <hr> and the back-link
53
+ // so only the body survives.
54
+ { tag: 'section[role="doc-endnotes"] li', priority: 60 },
55
+ { tag: 'section[role="doc-endnotes"] ol', skip: true, priority: 60 },
56
+ { tag: 'section[role="doc-endnotes"] hr', ignore: true, priority: 60 },
57
+ { tag: 'a[role="doc-backlink"]', ignore: true, priority: 60 },
58
+ ];
59
+ },
60
+
61
+ renderHTML({ HTMLAttributes, node }) {
62
+ return ['li', mergeAttributes(HTMLAttributes, {
63
+ class: 'carve-footnote-definition',
64
+ 'data-footnote-label': node.attrs.label,
65
+ }), 0];
66
+ },
67
+
68
+ addCommands() {
69
+ return {
70
+ insertCarveFootnoteDefinition: attributes => ({ commands }) => {
71
+ return commands.insertContent({
72
+ type: this.name,
73
+ attrs: attributes,
74
+ content: [{ type: 'paragraph' }],
75
+ });
76
+ },
77
+ };
78
+ },
79
+ });
80
+
81
+ export default CarveFootnoteDefinition;
@@ -0,0 +1,76 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Footnote node extension for Tiptap
5
+ *
6
+ * Renders as [^label] in Carve markup
7
+ *
8
+ * @example
9
+ * ```js
10
+ * import { CarveFootnote } from 'carve-grammars/tiptap'
11
+ *
12
+ * const editor = new Editor({
13
+ * extensions: [CarveFootnote],
14
+ * })
15
+ *
16
+ * // Insert a footnote reference
17
+ * editor.chain().focus().insertCarveFootnote({ label: 'note1' }).run()
18
+ * ```
19
+ */
20
+ export const CarveFootnote = Node.create({
21
+ name: 'carveFootnote',
22
+
23
+ group: 'inline',
24
+
25
+ inline: true,
26
+
27
+ atom: true,
28
+
29
+ addAttributes() {
30
+ return {
31
+ label: {
32
+ default: 'note',
33
+ parseHTML: element => element.getAttribute('data-footnote-label')
34
+ || element.textContent?.replace(/[[\]^]/g, '').trim()
35
+ // carve-php / carve-js reference: id="fnrefN".
36
+ || element.id.replace(/^fnref/, '')
37
+ || 'note',
38
+ renderHTML: attributes => {
39
+ return { 'data-footnote-label': attributes.label };
40
+ },
41
+ },
42
+ };
43
+ },
44
+
45
+ parseHTML() {
46
+ return [
47
+ { tag: 'sup.carve-footnote' },
48
+ { tag: 'span.carve-footnote-ref' },
49
+ // carve-php / carve-js render the reference as
50
+ // <a id="fnrefN" role="doc-noteref"><sup>N</sup></a>. Beat Link to it.
51
+ { tag: 'a[role="doc-noteref"]', priority: 60 },
52
+ ];
53
+ },
54
+
55
+ renderHTML({ HTMLAttributes }) {
56
+ const label = HTMLAttributes['data-footnote-label'] || 'note';
57
+ return ['sup', mergeAttributes(HTMLAttributes, {
58
+ class: 'carve-footnote',
59
+ 'data-footnote-label': label,
60
+ contenteditable: 'false',
61
+ }), `[^${label}]`];
62
+ },
63
+
64
+ addCommands() {
65
+ return {
66
+ insertCarveFootnote: (attributes) => ({ commands }) => {
67
+ return commands.insertContent({
68
+ type: this.name,
69
+ attrs: attributes,
70
+ });
71
+ },
72
+ };
73
+ },
74
+ });
75
+
76
+ export default CarveFootnote;
@@ -0,0 +1,54 @@
1
+ import { Mark, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Insert mark extension for Tiptap
5
+ *
6
+ * Renders as {+text+} in Carve markup
7
+ *
8
+ * @example
9
+ * ```js
10
+ * import { CarveInsert } from 'carve-grammars/tiptap'
11
+ *
12
+ * const editor = new Editor({
13
+ * extensions: [CarveInsert],
14
+ * })
15
+ *
16
+ * // Toggle insert mark
17
+ * editor.chain().focus().toggleCarveInsert().run()
18
+ * ```
19
+ */
20
+ export const CarveInsert = Mark.create({
21
+ name: 'carveInsert',
22
+
23
+ // Outrank StarterKit's Strike, whose parseHTML also claims <del> - at
24
+ // default priority the strike mark wins and {-...-} degrades to ~...~
25
+ // after an HTML round-trip.
26
+ priority: 101,
27
+
28
+ parseHTML() {
29
+ return [
30
+ { tag: 'ins' },
31
+ { tag: 'span.carve-insert' },
32
+ ];
33
+ },
34
+
35
+ renderHTML({ HTMLAttributes }) {
36
+ return ['span', mergeAttributes(HTMLAttributes, { class: 'carve-insert' }), 0];
37
+ },
38
+
39
+ addCommands() {
40
+ return {
41
+ toggleCarveInsert: () => ({ commands }) => commands.toggleMark(this.name),
42
+ setCarveInsert: () => ({ commands }) => commands.setMark(this.name),
43
+ unsetCarveInsert: () => ({ commands }) => commands.unsetMark(this.name),
44
+ };
45
+ },
46
+
47
+ addKeyboardShortcuts() {
48
+ return {
49
+ 'Mod-Shift-i': () => this.editor.commands.toggleCarveInsert(),
50
+ };
51
+ },
52
+ });
53
+
54
+ export default CarveInsert;
@@ -0,0 +1,71 @@
1
+ import { Extension } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve keyboard shortcuts.
5
+ *
6
+ * Tiptap only binds Mod-Alt-1..6 for headings, so plain Ctrl/Cmd+1..6 fall
7
+ * through to the browser (switching tabs). These bindings cover the common
8
+ * Carve constructs, all return true so the browser default is prevented, and
9
+ * use toggle* commands so pressing a heading level on an existing heading
10
+ * REPLACES it (or toggles back to a paragraph) rather than stacking.
11
+ *
12
+ * Included in CarveKit by default; import standalone to add to a custom set.
13
+ */
14
+ export const CarveKeymap = Extension.create({
15
+ name: 'carveKeymap',
16
+
17
+ addKeyboardShortcuts() {
18
+ const editor = this.editor;
19
+ const heading = (level) => () => editor.chain().focus().toggleHeading({ level }).run();
20
+
21
+ return {
22
+ 'Mod-1': heading(1),
23
+ 'Mod-2': heading(2),
24
+ 'Mod-3': heading(3),
25
+ 'Mod-4': heading(4),
26
+ 'Mod-5': heading(5),
27
+ 'Mod-6': heading(6),
28
+ 'Mod-e': () => editor.chain().focus().toggleCode().run(),
29
+ 'Mod-.': () => editor.chain().focus().toggleSuperscript().run(),
30
+ 'Mod-,': () => editor.chain().focus().toggleSubscript().run(),
31
+ 'Mod-Shift-x': () => editor.chain().focus().toggleStrike().run(),
32
+ 'Mod-Shift-h': () => editor.chain().focus().toggleHighlight().run(),
33
+ 'Mod-Shift-e': () => editor.chain().focus().toggleCodeBlock().run(),
34
+ 'Mod-Shift-.': () => editor.chain().focus().toggleBlockquote().run(),
35
+ 'Mod-Shift-8': () => editor.chain().focus().toggleBulletList().run(),
36
+ 'Mod-Shift-7': () => editor.chain().focus().toggleOrderedList().run(),
37
+ // Clear formatting: node type back to paragraph + drop all marks.
38
+ 'Mod-\\': () => editor.chain().focus().clearNodes().unsetAllMarks().run(),
39
+ // Enter at the END of a top-level textblock starts a fresh, mark-free
40
+ // paragraph - so heading/bold/italic reset on a new line instead of
41
+ // carrying over. Mid-block splits, lists, blockquotes and containers
42
+ // keep the default behavior.
43
+ Enter: () => {
44
+ const { $from, empty } = editor.state.selection;
45
+ if (!empty) {
46
+ return false;
47
+ }
48
+ const parent = $from.parent;
49
+ const atEnd = $from.parentOffset === parent.content.size;
50
+ const isPlainTextblock = parent.isTextblock && parent.type.name !== 'codeBlock';
51
+ const topLevel = $from.depth === 1;
52
+ if (!atEnd || !isPlainTextblock || !topLevel) {
53
+ return false;
54
+ }
55
+ return editor
56
+ .chain()
57
+ .insertContentAt($from.after(), { type: 'paragraph' })
58
+ .setTextSelection($from.after() + 1)
59
+ .command(({ tr, dispatch }) => {
60
+ if (dispatch) {
61
+ dispatch(tr.setStoredMarks([]));
62
+ }
63
+ return true;
64
+ })
65
+ .run();
66
+ },
67
+ };
68
+ },
69
+ });
70
+
71
+ export default CarveKeymap;
@@ -0,0 +1,82 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Math node extension for Tiptap.
5
+ *
6
+ * Inline atom holding raw math source. Serializes to Carve as:
7
+ * - inline: `` $`x`$ ``
8
+ * - display: `` $$`x`$$ `` (when `display` is true)
9
+ *
10
+ * The source is stored verbatim in `data-carve-math`; rendering of the math
11
+ * itself (KaTeX/MathML/etc.) is left to the host application's node view.
12
+ *
13
+ * @example
14
+ * ```js
15
+ * import { CarveMath } from 'carve-grammars/tiptap'
16
+ *
17
+ * editor.chain().focus().insertCarveMath({ src: 'E = mc^2' }).run()
18
+ * ```
19
+ */
20
+ export const CarveMath = Node.create({
21
+ name: 'carveMath',
22
+
23
+ group: 'inline',
24
+
25
+ inline: true,
26
+
27
+ atom: true,
28
+
29
+ addAttributes() {
30
+ return {
31
+ src: {
32
+ default: '',
33
+ parseHTML: element => {
34
+ const explicit = element.getAttribute('data-carve-math');
35
+ if (explicit) return explicit;
36
+ // carve-php renders <span class="math ...">\(TEX\)</span> or
37
+ // \[TEX\] for display; recover the raw TeX by stripping the
38
+ // \( \) / \[ \] delimiters.
39
+ return (element.textContent || '')
40
+ .trim()
41
+ .replace(/^\\[([]/, '')
42
+ .replace(/\\[)\]]$/, '')
43
+ .trim();
44
+ },
45
+ renderHTML: attributes => ({ 'data-carve-math': attributes.src }),
46
+ },
47
+ display: {
48
+ default: false,
49
+ parseHTML: element => element.getAttribute('data-display') === 'true' || element.classList.contains('display'),
50
+ renderHTML: attributes => (attributes.display ? { 'data-display': 'true' } : {}),
51
+ },
52
+ };
53
+ },
54
+
55
+ parseHTML() {
56
+ return [
57
+ { tag: 'span[data-carve-math]' },
58
+ // carve-php rendered output.
59
+ { tag: 'span.math' },
60
+ ];
61
+ },
62
+
63
+ renderHTML({ HTMLAttributes, node }) {
64
+ return ['span', mergeAttributes(HTMLAttributes, {
65
+ class: 'carve-math',
66
+ 'data-carve-math': node.attrs.src,
67
+ }), node.attrs.src];
68
+ },
69
+
70
+ addCommands() {
71
+ return {
72
+ insertCarveMath: attributes => ({ commands }) => {
73
+ return commands.insertContent({
74
+ type: this.name,
75
+ attrs: attributes,
76
+ });
77
+ },
78
+ };
79
+ },
80
+ });
81
+
82
+ export default CarveMath;
@@ -0,0 +1,44 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve mention / tag inline nodes.
5
+ *
6
+ * carve-php / carve-js render `@name` as
7
+ * `<span class="mention"><strong>@name</strong></span>` and `#tag` as
8
+ * `<span class="tag"><strong>#tag</strong></span>`. Citations use a mention:
9
+ * `[@key]` is literal brackets around a mention. These are inline atoms so they
10
+ * round-trip verbatim (the bare `@`/`#` is escaped in ordinary prose, so only a
11
+ * real mention/tag node emits an unescaped one).
12
+ */
13
+ function mentionNode(name, cssClass, sigil) {
14
+ return Node.create({
15
+ name,
16
+ group: 'inline',
17
+ inline: true,
18
+ atom: true,
19
+
20
+ addAttributes() {
21
+ return {
22
+ id: {
23
+ default: '',
24
+ parseHTML: element => (element.textContent || '').replace(/^[@#]/, '').trim(),
25
+ renderHTML: attributes => ({ 'data-id': attributes.id }),
26
+ },
27
+ };
28
+ },
29
+
30
+ parseHTML() {
31
+ // Beat CarveSpan (which also matches span[class]).
32
+ return [{ tag: `span.${cssClass}`, priority: 60 }];
33
+ },
34
+
35
+ renderHTML({ HTMLAttributes, node }) {
36
+ return ['span', mergeAttributes(HTMLAttributes, { class: cssClass }), sigil + node.attrs.id];
37
+ },
38
+ });
39
+ }
40
+
41
+ export const CarveMention = mentionNode('carveMention', 'mention', '@');
42
+ export const CarveTag = mentionNode('carveTag', 'tag', '#');
43
+
44
+ export default CarveMention;