@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,270 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Definition List extension for Tiptap
5
+ *
6
+ * Provides full definition list support with keyboard navigation.
7
+ *
8
+ * Carve syntax:
9
+ * ```
10
+ * : term
11
+ *
12
+ * Definition paragraph (indented)
13
+ * ```
14
+ *
15
+ * Multiple terms sharing definition:
16
+ * ```
17
+ * : color
18
+ * : colour
19
+ *
20
+ * The visual property...
21
+ * ```
22
+ *
23
+ * Multiple definitions (: + continuation):
24
+ * ```
25
+ * : term
26
+ *
27
+ * First definition.
28
+ *
29
+ * : +
30
+ *
31
+ * Second definition.
32
+ * ```
33
+ *
34
+ * Keyboard shortcuts:
35
+ * - Enter in term: go to definition
36
+ * - Shift+Enter in term: add another term (for synonyms)
37
+ * - Enter in empty definition: create new term+definition pair
38
+ * - Shift+Enter in definition: add another definition (: + syntax)
39
+ */
40
+
41
+ /**
42
+ * Definition List container node (<dl>)
43
+ */
44
+ export const CarveDefinitionList = Node.create({
45
+ name: 'definitionList',
46
+
47
+ group: 'block',
48
+
49
+ // Allow multiple terms followed by multiple descriptions, repeating
50
+ content: '(definitionTerm+ definitionDescription+)+',
51
+
52
+ parseHTML() {
53
+ return [{ tag: 'dl', priority: 51 }];
54
+ },
55
+
56
+ renderHTML({ HTMLAttributes }) {
57
+ return ['dl', mergeAttributes(HTMLAttributes, { class: 'carve-definition-list' }), 0];
58
+ },
59
+
60
+ addCommands() {
61
+ return {
62
+ insertDefinitionList: () => ({ chain, state }) => {
63
+ const { $from } = state.selection;
64
+ const insertPos = $from.end();
65
+
66
+ return chain()
67
+ .insertContentAt(insertPos, {
68
+ type: 'definitionList',
69
+ content: [
70
+ { type: 'definitionTerm' },
71
+ { type: 'definitionDescription', content: [{ type: 'paragraph' }] },
72
+ ],
73
+ })
74
+ // Focus at the start of the term
75
+ .focus(insertPos + 2)
76
+ .run();
77
+ },
78
+ };
79
+ },
80
+ });
81
+
82
+ /**
83
+ * Definition Term node (<dt>)
84
+ * Note: No group - can only appear inside definitionList
85
+ */
86
+ export const CarveDefinitionTerm = Node.create({
87
+ name: 'definitionTerm',
88
+
89
+ content: 'inline*',
90
+
91
+ defining: true,
92
+
93
+ parseHTML() {
94
+ return [{ tag: 'dt', priority: 51 }];
95
+ },
96
+
97
+ renderHTML({ HTMLAttributes }) {
98
+ return ['dt', mergeAttributes(HTMLAttributes), 0];
99
+ },
100
+
101
+ addKeyboardShortcuts() {
102
+ return {
103
+ // Enter in term: go to description or create one
104
+ 'Enter': () => {
105
+ const { state } = this.editor;
106
+ const { $from } = state.selection;
107
+
108
+ if ($from.parent.type.name !== 'definitionTerm') {
109
+ return false;
110
+ }
111
+
112
+ const termEnd = $from.end();
113
+
114
+ // Empty term - let default behavior handle it
115
+ if ($from.parent.content.size === 0) {
116
+ return false;
117
+ }
118
+
119
+ // Check what comes next
120
+ const $afterTerm = state.doc.resolve(termEnd + 1);
121
+ const nextNode = $afterTerm.nodeAfter;
122
+
123
+ if (nextNode && nextNode.type.name === 'definitionDescription') {
124
+ // Move cursor to the description
125
+ return this.editor.chain()
126
+ .focus(termEnd + 2)
127
+ .run();
128
+ } else {
129
+ // Insert a description after this term
130
+ return this.editor.chain()
131
+ .insertContentAt(termEnd + 1, {
132
+ type: 'definitionDescription',
133
+ content: [{ type: 'paragraph' }],
134
+ })
135
+ .focus(termEnd + 3)
136
+ .run();
137
+ }
138
+ },
139
+ // Shift+Enter: add another term (for multiple terms sharing a definition)
140
+ 'Shift-Enter': () => {
141
+ const { state } = this.editor;
142
+ const { $from } = state.selection;
143
+
144
+ if ($from.parent.type.name !== 'definitionTerm') {
145
+ return false;
146
+ }
147
+
148
+ const termEnd = $from.end();
149
+
150
+ return this.editor.chain()
151
+ .insertContentAt(termEnd + 1, {
152
+ type: 'definitionTerm',
153
+ })
154
+ .focus(termEnd + 2)
155
+ .run();
156
+ },
157
+ };
158
+ },
159
+ });
160
+
161
+ /**
162
+ * Definition Description node (<dd>)
163
+ * Note: No group - can only appear inside definitionList
164
+ */
165
+ export const CarveDefinitionDescription = Node.create({
166
+ name: 'definitionDescription',
167
+
168
+ content: 'block+',
169
+
170
+ defining: true,
171
+
172
+ parseHTML() {
173
+ return [{ tag: 'dd', priority: 51 }];
174
+ },
175
+
176
+ renderHTML({ HTMLAttributes }) {
177
+ return ['dd', mergeAttributes(HTMLAttributes), 0];
178
+ },
179
+
180
+ addKeyboardShortcuts() {
181
+ return {
182
+ // Enter in empty paragraph inside description: create new term+description pair
183
+ 'Enter': () => {
184
+ const { state } = this.editor;
185
+ const { $from } = state.selection;
186
+
187
+ // Check if we're in a paragraph inside a description
188
+ let inDescription = false;
189
+ let descriptionDepth = 0;
190
+ for (let d = $from.depth; d > 0; d--) {
191
+ if ($from.node(d).type.name === 'definitionDescription') {
192
+ inDescription = true;
193
+ descriptionDepth = d;
194
+ break;
195
+ }
196
+ }
197
+
198
+ if (!inDescription) {
199
+ return false;
200
+ }
201
+
202
+ // Check if current paragraph is empty
203
+ const paragraph = $from.parent;
204
+ if (paragraph.type.name !== 'paragraph' || paragraph.content.size !== 0) {
205
+ return false;
206
+ }
207
+
208
+ // Find the definition list
209
+ let listDepth = 0;
210
+ for (let d = $from.depth; d > 0; d--) {
211
+ if ($from.node(d).type.name === 'definitionList') {
212
+ listDepth = d;
213
+ break;
214
+ }
215
+ }
216
+
217
+ if (listDepth === 0) {
218
+ return false;
219
+ }
220
+
221
+ const descNode = $from.node(descriptionDepth);
222
+
223
+ // If description only has one empty paragraph, add new term + description
224
+ if (descNode.childCount === 1) {
225
+ const descEnd = $from.end(descriptionDepth);
226
+
227
+ return this.editor.chain()
228
+ .insertContentAt(descEnd + 1, [
229
+ { type: 'definitionTerm' },
230
+ { type: 'definitionDescription', content: [{ type: 'paragraph' }] },
231
+ ])
232
+ .focus(descEnd + 2)
233
+ .run();
234
+ }
235
+
236
+ return false;
237
+ },
238
+ // Shift+Enter: add another description (for : + continuation)
239
+ 'Shift-Enter': () => {
240
+ const { state } = this.editor;
241
+ const { $from } = state.selection;
242
+
243
+ // Check if we're in a description
244
+ let descriptionDepth = 0;
245
+ for (let d = $from.depth; d > 0; d--) {
246
+ if ($from.node(d).type.name === 'definitionDescription') {
247
+ descriptionDepth = d;
248
+ break;
249
+ }
250
+ }
251
+
252
+ if (descriptionDepth === 0) {
253
+ return false;
254
+ }
255
+
256
+ const descEnd = $from.end(descriptionDepth);
257
+
258
+ return this.editor.chain()
259
+ .insertContentAt(descEnd + 1, {
260
+ type: 'definitionDescription',
261
+ content: [{ type: 'paragraph' }],
262
+ })
263
+ .focus(descEnd + 3)
264
+ .run();
265
+ },
266
+ };
267
+ },
268
+ });
269
+
270
+ export default { CarveDefinitionList, CarveDefinitionTerm, CarveDefinitionDescription };
@@ -0,0 +1,54 @@
1
+ import { Mark, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Delete mark extension for Tiptap
5
+ *
6
+ * Renders as {-text-} in Carve markup
7
+ *
8
+ * @example
9
+ * ```js
10
+ * import { CarveDelete } from 'carve-grammars/tiptap'
11
+ *
12
+ * const editor = new Editor({
13
+ * extensions: [CarveDelete],
14
+ * })
15
+ *
16
+ * // Toggle delete mark
17
+ * editor.chain().focus().toggleCarveDelete().run()
18
+ * ```
19
+ */
20
+ export const CarveDelete = Mark.create({
21
+ name: 'carveDelete',
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: 'del' },
31
+ { tag: 'span.carve-delete' },
32
+ ];
33
+ },
34
+
35
+ renderHTML({ HTMLAttributes }) {
36
+ return ['span', mergeAttributes(HTMLAttributes, { class: 'carve-delete' }), 0];
37
+ },
38
+
39
+ addCommands() {
40
+ return {
41
+ toggleCarveDelete: () => ({ commands }) => commands.toggleMark(this.name),
42
+ setCarveDelete: () => ({ commands }) => commands.setMark(this.name),
43
+ unsetCarveDelete: () => ({ commands }) => commands.unsetMark(this.name),
44
+ };
45
+ },
46
+
47
+ addKeyboardShortcuts() {
48
+ return {
49
+ 'Mod-Shift-d': () => this.editor.commands.toggleCarveDelete(),
50
+ };
51
+ },
52
+ });
53
+
54
+ export default CarveDelete;
@@ -0,0 +1,188 @@
1
+ import { Node, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * First direct child that carries the admonition-title class (carve-php and
5
+ * carve-js render a quoted container title as such a paragraph).
6
+ */
7
+ function findTitleChild(element) {
8
+ for (const child of element.children || []) {
9
+ if (child.classList && child.classList.contains('admonition-title')) {
10
+ return child;
11
+ }
12
+ }
13
+ return null;
14
+ }
15
+
16
+ /** First direct child carrying the carve-div-body class (own editor DOM). */
17
+ function findBodyChild(element) {
18
+ for (const child of element.children || []) {
19
+ if (child.classList && child.classList.contains('carve-div-body')) {
20
+ return child;
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Content for the node: everything except the title paragraph, which is
28
+ * captured as the `title` attribute instead (else it would duplicate into the
29
+ * body and the quoted summary would be lost on serialization). The editor's
30
+ * own DOM wraps content in .carve-div-body next to the rendered title.
31
+ */
32
+ function contentWithoutTitle(element) {
33
+ const body = findBodyChild(element);
34
+ if (body) {
35
+ return body;
36
+ }
37
+ const clone = element.cloneNode(true);
38
+ const title = findTitleChild(clone);
39
+ if (!title) {
40
+ return element;
41
+ }
42
+ title.remove();
43
+ return clone;
44
+ }
45
+
46
+ /**
47
+ * Carve Div container node extension for Tiptap
48
+ *
49
+ * Renders as ::: class in Carve markup, with an optional quoted title
50
+ * (::: note "Custom title") kept in the `title` attribute
51
+ *
52
+ * @example
53
+ * ```js
54
+ * import { CarveDiv } from 'carve-grammars/tiptap'
55
+ *
56
+ * const editor = new Editor({
57
+ * extensions: [CarveDiv],
58
+ * })
59
+ *
60
+ * // Wrap selection in a div container
61
+ * editor.chain().focus().setCarveDiv({ class: 'warning' }).run()
62
+ * ```
63
+ */
64
+ export const CarveDiv = Node.create({
65
+ name: 'carveDiv',
66
+
67
+ group: 'block',
68
+
69
+ content: 'block+',
70
+
71
+ defining: true,
72
+
73
+ addAttributes() {
74
+ return {
75
+ class: {
76
+ default: null,
77
+ parseHTML: element => element.getAttribute('data-carve-class')
78
+ // Drop the framing classes so an <aside class="admonition note">
79
+ // (carve-php / carve-js output) yields just "note".
80
+ || element.className.replace(/\b(carve-div|admonition)\b/g, '').replace(/\s+/g, ' ').trim()
81
+ || null,
82
+ renderHTML: attributes => {
83
+ if (!attributes.class) return {};
84
+ return { 'data-carve-class': attributes.class };
85
+ },
86
+ },
87
+ title: {
88
+ default: null,
89
+ // An empty string is meaningful (::: note "" suppresses the
90
+ // default title), so only a missing title maps to null.
91
+ parseHTML: element => {
92
+ const attr = element.getAttribute('data-carve-title');
93
+ if (attr !== null) return attr;
94
+ const child = findTitleChild(element);
95
+ if (child) return child.textContent.trim();
96
+ // carve-js renders an authored {title="..."} block attribute
97
+ // as a literal title attribute on the container (carve-php
98
+ // promotes it to an admonition-title paragraph instead);
99
+ // capture it so the title survives that engine's seed too.
100
+ return element.getAttribute('title');
101
+ },
102
+ renderHTML: attributes => {
103
+ if (attributes.title == null) return {};
104
+ return { 'data-carve-title': attributes.title };
105
+ },
106
+ },
107
+ };
108
+ },
109
+
110
+ parseHTML() {
111
+ return [
112
+ { tag: 'div.carve-div', contentElement: contentWithoutTitle },
113
+ // Admonitions render as <aside class="admonition TYPE"> (carve-php,
114
+ // carve-js). Match highest so it wins over the generic rules.
115
+ { tag: 'aside.admonition', priority: 60, contentElement: contentWithoutTitle },
116
+ // Also match common container classes rendered by carve-php
117
+ { tag: 'div.note', contentElement: contentWithoutTitle },
118
+ { tag: 'div.tip', contentElement: contentWithoutTitle },
119
+ { tag: 'div.warning', contentElement: contentWithoutTitle },
120
+ { tag: 'div.danger', contentElement: contentWithoutTitle },
121
+ { tag: 'div.info', contentElement: contentWithoutTitle },
122
+ // Match any div with a single class (likely a ::: container)
123
+ {
124
+ tag: 'div[class]',
125
+ contentElement: contentWithoutTitle,
126
+ getAttrs: element => {
127
+ // Only match divs with a simple class (not complex component divs)
128
+ const className = element.className;
129
+ // Skip if it looks like a WordPress/editor component
130
+ if (className.includes('wp-') || className.includes('block-') ||
131
+ className.includes('editor-') || className.includes('is-')) {
132
+ return false;
133
+ }
134
+ // Skip Torchlight code block line divs
135
+ if (className === 'line' || className.includes('line-')) {
136
+ return false;
137
+ }
138
+ // Skip if inside a pre or code element (syntax highlighting)
139
+ if (element.closest('pre') || element.closest('code')) {
140
+ return false;
141
+ }
142
+ // Accept single-word classes or carve-div
143
+ if (/^[a-z-]+$/i.test(className) || className.includes('carve-div')) {
144
+ return {};
145
+ }
146
+ return false;
147
+ },
148
+ },
149
+ ];
150
+ },
151
+
152
+ renderHTML({ HTMLAttributes }) {
153
+ const classes = ['carve-div'];
154
+ if (HTMLAttributes['data-carve-class']) {
155
+ classes.push(HTMLAttributes['data-carve-class']);
156
+ }
157
+ const attrs = mergeAttributes(HTMLAttributes, { class: classes.join(' ') });
158
+ const title = HTMLAttributes['data-carve-title'];
159
+ if (title === undefined) {
160
+ return ['div', attrs, 0];
161
+ }
162
+ // Keep the captured title VISIBLE in the editor: a non-editable title
163
+ // element plus a body wrapper carrying the content hole (ProseMirror
164
+ // requires the hole to be its parent's only child). The title itself
165
+ // is edited in source mode; contentWithoutTitle() reads content from
166
+ // .carve-div-body so this shape re-parses without duplication.
167
+ return ['div', attrs,
168
+ ['p', { class: 'admonition-title', contenteditable: 'false' }, title],
169
+ ['div', { class: 'carve-div-body' }, 0],
170
+ ];
171
+ },
172
+
173
+ addCommands() {
174
+ return {
175
+ setCarveDiv: (attributes) => ({ commands }) => {
176
+ return commands.wrapIn(this.name, attributes);
177
+ },
178
+ toggleCarveDiv: (attributes) => ({ commands }) => {
179
+ return commands.toggleWrap(this.name, attributes);
180
+ },
181
+ unsetCarveDiv: () => ({ commands }) => {
182
+ return commands.lift(this.name);
183
+ },
184
+ };
185
+ },
186
+ });
187
+
188
+ export default CarveDiv;