@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,507 @@
1
+ import { Extension } from '@tiptap/core';
2
+ import StarterKit from '@tiptap/starter-kit';
3
+ import CodeBlock from '@tiptap/extension-code-block';
4
+ import Highlight from '@tiptap/extension-highlight';
5
+ import Subscript from '@tiptap/extension-subscript';
6
+ import Superscript from '@tiptap/extension-superscript';
7
+ import Underline from '@tiptap/extension-underline';
8
+ import Link from '@tiptap/extension-link';
9
+ import Image from '@tiptap/extension-image';
10
+ import Table from '@tiptap/extension-table';
11
+ import TableRow from '@tiptap/extension-table-row';
12
+ import TableCell from '@tiptap/extension-table-cell';
13
+ import TableHeader from '@tiptap/extension-table-header';
14
+ import TaskList from '@tiptap/extension-task-list';
15
+ import TaskItem from '@tiptap/extension-task-item';
16
+ import BulletList from '@tiptap/extension-bullet-list';
17
+ import ListItem from '@tiptap/extension-list-item';
18
+ import HardBreak from '@tiptap/extension-hard-break';
19
+
20
+ import { CarveInsert } from './extensions/carve-insert.js';
21
+ import { CarveDelete } from './extensions/carve-delete.js';
22
+ import { CarveDiv } from './extensions/carve-div.js';
23
+ import { CarveTabSet, CarveTab } from './extensions/carve-tabs.js';
24
+ import { CarveSpan } from './extensions/carve-span.js';
25
+ import { CarveFootnote } from './extensions/carve-footnote.js';
26
+ import { CarveMath } from './extensions/carve-math.js';
27
+ import { CarveFootnoteDefinition } from './extensions/carve-footnote-definition.js';
28
+ import { CarveEmbed } from './extensions/carve-embed.js';
29
+ import { CarveAbbreviation } from './extensions/carve-abbreviation.js';
30
+ import { CarveDefinitionList, CarveDefinitionTerm, CarveDefinitionDescription } from './extensions/carve-definition-list.js';
31
+ import { CarveKeymap } from './extensions/carve-keymap.js';
32
+ import { CarveMention, CarveTag } from './extensions/carve-mention.js';
33
+
34
+ // Languages offered by the code-block picker. The current language is always
35
+ // shown even if it is not in this list.
36
+ const CODE_LANGS = [
37
+ { value: '', label: 'Plain text' },
38
+ { value: 'php', label: 'PHP' },
39
+ { value: 'javascript', label: 'JavaScript' },
40
+ { value: 'typescript', label: 'TypeScript' },
41
+ { value: 'html', label: 'HTML' },
42
+ { value: 'css', label: 'CSS' },
43
+ { value: 'json', label: 'JSON' },
44
+ { value: 'bash', label: 'Bash' },
45
+ { value: 'python', label: 'Python' },
46
+ { value: 'sql', label: 'SQL' },
47
+ { value: 'yaml', label: 'YAML' },
48
+ { value: 'markdown', label: 'Markdown' },
49
+ { value: 'rust', label: 'Rust' },
50
+ { value: 'go', label: 'Go' },
51
+ ];
52
+
53
+ /**
54
+ * CarveKit - A Tiptap extension bundle for Carve markup
55
+ *
56
+ * Includes all standard Tiptap extensions plus Carve-specific marks:
57
+ * - CarveInsert: {+text+}
58
+ * - CarveDelete: {-text-}
59
+ * - CarveDiv: ::: containers
60
+ * - CarveSpan: [text]{.class}
61
+ * - CarveFootnote: [^label]
62
+ * - CarveEmbed: video/iframe embeds
63
+ * - CarveAbbreviation: [ABBR]{abbr="expansion"}
64
+ * - CarveDefinitionList: : term with definition
65
+ *
66
+ * @example
67
+ * ```js
68
+ * import { Editor } from '@tiptap/core'
69
+ * import { CarveKit, serializeToCarve } from 'carve-grammars/tiptap'
70
+ *
71
+ * const editor = new Editor({
72
+ * element: document.getElementById('editor'),
73
+ * extensions: [CarveKit],
74
+ * onUpdate: ({ editor }) => {
75
+ * const carve = serializeToCarve(editor.getJSON())
76
+ * console.log(carve)
77
+ * },
78
+ * })
79
+ * ```
80
+ *
81
+ * @example Configuration
82
+ * ```js
83
+ * import { CarveKit } from 'carve-grammars/tiptap'
84
+ *
85
+ * // Disable specific features
86
+ * CarveKit.configure({
87
+ * table: false,
88
+ * taskList: false,
89
+ * })
90
+ *
91
+ * // Configure specific extensions
92
+ * CarveKit.configure({
93
+ * link: {
94
+ * openOnClick: false,
95
+ * },
96
+ * codeBlock: {
97
+ * HTMLAttributes: {
98
+ * spellcheck: 'false',
99
+ * },
100
+ * },
101
+ * })
102
+ * ```
103
+ */
104
+ export const CarveKit = Extension.create({
105
+ name: 'carveKit',
106
+
107
+ addExtensions() {
108
+ const extensions = [];
109
+
110
+ // StarterKit provides: Document, Paragraph, Text, Bold, Italic, Code,
111
+ // CodeBlock, Blockquote, BulletList, OrderedList, ListItem, Heading,
112
+ // HardBreak, HorizontalRule, Dropcursor, Gapcursor, History
113
+ if (this.options.starterKit !== false) {
114
+ extensions.push(StarterKit.configure({
115
+ // Disable CodeBlock from StarterKit, we add a custom one below
116
+ codeBlock: false,
117
+ // Disable default lists - we add custom ones that handle task-list
118
+ bulletList: false,
119
+ listItem: false,
120
+ // Disable HardBreak, we add a custom one with visible indicator
121
+ hardBreak: false,
122
+ ...this.options.starterKit,
123
+ }));
124
+ }
125
+
126
+ // Custom HardBreak with visible indicator (shows ↵ symbol)
127
+ if (this.options.hardBreak !== false) {
128
+ const CustomHardBreak = HardBreak.extend({
129
+ addNodeView() {
130
+ return () => {
131
+ const dom = document.createElement('span');
132
+ dom.innerHTML = '<span class="hard-break">↵</span><br>';
133
+ return { dom };
134
+ };
135
+ },
136
+ });
137
+ extensions.push(CustomHardBreak.configure(this.options.hardBreak ?? {}));
138
+ }
139
+
140
+ // Custom CodeBlock that preserves data-language-raw for syntax highlighter options
141
+ if (this.options.codeBlock !== false) {
142
+ const CustomCodeBlock = CodeBlock.extend({
143
+ addAttributes() {
144
+ return {
145
+ ...this.parent?.(),
146
+ languageRaw: {
147
+ default: null,
148
+ parseHTML: element => {
149
+ // Check parent <pre> for data-language-raw
150
+ const pre = element.closest('pre');
151
+ return pre?.getAttribute('data-language-raw') || null;
152
+ },
153
+ renderHTML: attributes => {
154
+ if (!attributes.languageRaw) return {};
155
+ return { 'data-language-raw': attributes.languageRaw };
156
+ },
157
+ },
158
+ };
159
+ },
160
+
161
+ // Floating language picker: a <select> in the corner of every
162
+ // code block that shows the current language and edits it in
163
+ // place (the toolbar can't show a per-block value). Disable with
164
+ // CarveKit.configure({ codeBlock: { languagePicker: false } }).
165
+ addNodeView() {
166
+ if (this.options.languagePicker === false) {
167
+ return null;
168
+ }
169
+ return ({ node, editor, getPos }) => {
170
+ let current = node;
171
+ const pre = document.createElement('pre');
172
+ if (node.attrs.languageRaw) {
173
+ pre.setAttribute('data-language-raw', node.attrs.languageRaw);
174
+ }
175
+
176
+ const select = document.createElement('select');
177
+ select.className = 'carve-code-lang';
178
+ select.contentEditable = 'false';
179
+ select.setAttribute('aria-label', 'Code language');
180
+ const fill = (lang) => {
181
+ select.innerHTML = '';
182
+ const opts = CODE_LANGS.slice();
183
+ if (lang && !opts.some(o => o.value === lang)) {
184
+ opts.push({ value: lang, label: lang });
185
+ }
186
+ for (const o of opts) {
187
+ const el = document.createElement('option');
188
+ el.value = o.value;
189
+ el.textContent = o.label;
190
+ if ((lang || '') === o.value) {
191
+ el.selected = true;
192
+ }
193
+ select.appendChild(el);
194
+ }
195
+ };
196
+ fill(node.attrs.language || '');
197
+ // Keep clicks/keys inside the select from reaching PM.
198
+ select.addEventListener('mousedown', e => e.stopPropagation());
199
+ select.addEventListener('change', () => {
200
+ if (typeof getPos !== 'function') {
201
+ return;
202
+ }
203
+ editor.chain().focus().command(({ tr }) => {
204
+ tr.setNodeMarkup(getPos(), undefined, {
205
+ ...current.attrs,
206
+ language: select.value || null,
207
+ });
208
+ return true;
209
+ }).run();
210
+ });
211
+
212
+ const code = document.createElement('code');
213
+ const applyLangClass = (lang) => {
214
+ code.className = lang ? `language-${lang}` : '';
215
+ };
216
+ applyLangClass(node.attrs.language || '');
217
+ pre.appendChild(select);
218
+ pre.appendChild(code);
219
+
220
+ return {
221
+ dom: pre,
222
+ contentDOM: code,
223
+ update: (updated) => {
224
+ if (updated.type !== current.type) {
225
+ return false;
226
+ }
227
+ current = updated;
228
+ if (select.value !== (updated.attrs.language || '')) {
229
+ fill(updated.attrs.language || '');
230
+ }
231
+ applyLangClass(updated.attrs.language || '');
232
+ return true;
233
+ },
234
+ // The <select> is chrome, not editable content.
235
+ ignoreMutation: (m) => select.contains(m.target),
236
+ stopEvent: (e) => select.contains(e.target),
237
+ };
238
+ };
239
+ },
240
+ });
241
+ extensions.push(CustomCodeBlock.configure({
242
+ HTMLAttributes: {
243
+ spellcheck: 'false',
244
+ },
245
+ ...this.options.codeBlock,
246
+ }));
247
+ }
248
+
249
+ // Custom BulletList that excludes task-list class
250
+ if (this.options.bulletList !== false) {
251
+ const CustomBulletList = BulletList.extend({
252
+ parseHTML() {
253
+ return [
254
+ {
255
+ tag: 'ul',
256
+ getAttrs: element => {
257
+ // Don't match task lists - let TaskList handle them.
258
+ // carve-php renders them as a plain <ul> whose items
259
+ // carry a checkbox (no .task-list class), so detect
260
+ // that shape too.
261
+ if (element.classList.contains('task-list')) {
262
+ return false;
263
+ }
264
+ const hasCheckbox = Array.from(element.children).some(
265
+ (li) => li.tagName === 'LI' && li.querySelector('input[type="checkbox"]'),
266
+ );
267
+ if (hasCheckbox) {
268
+ return false;
269
+ }
270
+ return {};
271
+ },
272
+ },
273
+ ];
274
+ },
275
+ });
276
+ extensions.push(CustomBulletList.configure(this.options.bulletList ?? {}));
277
+ }
278
+
279
+ // Custom ListItem that excludes task items (those with checkboxes)
280
+ if (this.options.listItem !== false) {
281
+ const CustomListItem = ListItem.extend({
282
+ parseHTML() {
283
+ return [
284
+ {
285
+ tag: 'li',
286
+ getAttrs: element => {
287
+ // Don't match list items with checkboxes - let TaskItem handle those
288
+ const checkbox = element.querySelector('input[type="checkbox"]');
289
+ if (checkbox) {
290
+ return false;
291
+ }
292
+ return {};
293
+ },
294
+ },
295
+ ];
296
+ },
297
+ });
298
+ extensions.push(CustomListItem.configure(this.options.listItem ?? {}));
299
+ }
300
+
301
+ // Highlight mark (built-in, maps to ==text==)
302
+ if (this.options.highlight !== false) {
303
+ extensions.push(Highlight.configure(this.options.highlight ?? {}));
304
+ }
305
+
306
+ // Subscript mark (maps to the braced {,text,})
307
+ if (this.options.subscript !== false) {
308
+ extensions.push(Subscript.configure(this.options.subscript ?? {}));
309
+ }
310
+
311
+ // Superscript mark (maps to the braced {^text^})
312
+ if (this.options.superscript !== false) {
313
+ extensions.push(Superscript.configure(this.options.superscript ?? {}));
314
+ }
315
+
316
+ // Underline mark (maps to _text_)
317
+ if (this.options.underline !== false) {
318
+ extensions.push(Underline.configure(this.options.underline ?? {}));
319
+ }
320
+
321
+ // Link extension with keyboard shortcut
322
+ if (this.options.link !== false) {
323
+ extensions.push(
324
+ Link.configure({
325
+ openOnClick: false,
326
+ ...this.options.link,
327
+ }).extend({
328
+ addKeyboardShortcuts() {
329
+ return {
330
+ 'Mod-Shift-k': () => {
331
+ if (this.editor.isActive('link')) {
332
+ return this.editor.chain().focus().unsetLink().run();
333
+ }
334
+ const url = prompt('Enter URL:');
335
+ if (url) {
336
+ return this.editor.chain().focus().setLink({ href: url }).run();
337
+ }
338
+ return false;
339
+ },
340
+ };
341
+ },
342
+ })
343
+ );
344
+ }
345
+
346
+ // Image extension. Inline by default so `text ![alt](x) more` stays one
347
+ // paragraph (Carve images are inline); a block-level image just becomes a
348
+ // paragraph containing the inline image.
349
+ if (this.options.image !== false) {
350
+ extensions.push(Image.configure({ inline: true, ...(this.options.image ?? {}) }));
351
+ }
352
+
353
+ // Table extensions
354
+ if (this.options.table !== false) {
355
+ extensions.push(Table.configure({
356
+ resizable: true,
357
+ ...this.options.table,
358
+ }));
359
+ extensions.push(TableRow.configure(this.options.tableRow ?? {}));
360
+ extensions.push(TableCell.configure(this.options.tableCell ?? {}));
361
+ extensions.push(TableHeader.configure(this.options.tableHeader ?? {}));
362
+ }
363
+
364
+ // Task list extensions - extend to match PHP output format
365
+ if (this.options.taskList !== false) {
366
+ // Extend TaskList to also match ul.task-list with high priority
367
+ const CustomTaskList = TaskList.extend({
368
+ parseHTML() {
369
+ return [
370
+ { tag: 'ul[data-type="taskList"]', priority: 60 },
371
+ { tag: 'ul.task-list', priority: 60 },
372
+ // carve-php: a plain <ul> whose items carry a checkbox.
373
+ {
374
+ tag: 'ul',
375
+ priority: 55,
376
+ getAttrs: (element) => Array.from(element.children).some(
377
+ (li) => li.tagName === 'LI' && li.querySelector('input[type="checkbox"]'),
378
+ ) ? {} : false,
379
+ },
380
+ ];
381
+ },
382
+ });
383
+ extensions.push(CustomTaskList.configure(this.options.taskList ?? {}));
384
+
385
+ // Extend TaskItem to also match li with checkbox input with high priority
386
+ const CustomTaskItem = TaskItem.extend({
387
+ addAttributes() {
388
+ return {
389
+ ...this.parent?.(),
390
+ checked: {
391
+ default: false,
392
+ keepOnSplit: false,
393
+ parseHTML: element => {
394
+ // First check data-checked attribute
395
+ const dataChecked = element.getAttribute('data-checked');
396
+ if (dataChecked !== null) {
397
+ return dataChecked === 'true';
398
+ }
399
+ // Then check for checkbox input
400
+ const checkbox = element.querySelector('input[type="checkbox"]');
401
+ return checkbox?.hasAttribute('checked') || false;
402
+ },
403
+ renderHTML: attributes => ({
404
+ 'data-checked': attributes.checked,
405
+ }),
406
+ },
407
+ };
408
+ },
409
+ parseHTML() {
410
+ return [
411
+ { tag: 'li[data-type="taskItem"]', priority: 60 },
412
+ // Match list items that contain a checkbox input
413
+ {
414
+ tag: 'li',
415
+ priority: 60,
416
+ getAttrs: element => {
417
+ const checkbox = element.querySelector('input[type="checkbox"]');
418
+ if (checkbox) return {};
419
+ return false;
420
+ },
421
+ },
422
+ ];
423
+ },
424
+ });
425
+ extensions.push(CustomTaskItem.configure({
426
+ nested: true,
427
+ ...this.options.taskItem,
428
+ }));
429
+ }
430
+
431
+ // Carve-specific extensions
432
+ if (this.options.carveInsert !== false) {
433
+ extensions.push(CarveInsert.configure(this.options.carveInsert ?? {}));
434
+ }
435
+
436
+ if (this.options.carveDelete !== false) {
437
+ extensions.push(CarveDelete.configure(this.options.carveDelete ?? {}));
438
+ }
439
+
440
+ if (this.options.carveDiv !== false) {
441
+ extensions.push(CarveDiv.configure(this.options.carveDiv ?? {}));
442
+ }
443
+
444
+ // Tab sets (:::: tabs / ::: tab). Registered after CarveDiv but their
445
+ // div.tabs / div.tab parse rules use a higher priority, so a tab set is
446
+ // claimed here instead of by CarveDiv's generic div[class] rule.
447
+ if (this.options.carveTabs !== false) {
448
+ extensions.push(CarveTabSet.configure(this.options.carveTabs ?? {}));
449
+ extensions.push(CarveTab.configure(this.options.carveTabs ?? {}));
450
+ }
451
+
452
+ // Span with class mark (maps to [text]{.class})
453
+ if (this.options.carveSpan !== false) {
454
+ extensions.push(CarveSpan.configure(this.options.carveSpan ?? {}));
455
+ }
456
+
457
+ // Footnote reference node (maps to [^label])
458
+ if (this.options.carveFootnote !== false) {
459
+ extensions.push(CarveFootnote.configure(this.options.carveFootnote ?? {}));
460
+ }
461
+
462
+ // Math node (maps to $`x`$ inline, $$`x`$$ display)
463
+ if (this.options.carveMath !== false) {
464
+ extensions.push(CarveMath.configure(this.options.carveMath ?? {}));
465
+ }
466
+
467
+ // Footnote definition block (maps to [^label]: body)
468
+ if (this.options.carveFootnoteDefinition !== false) {
469
+ extensions.push(CarveFootnoteDefinition.configure(this.options.carveFootnoteDefinition ?? {}));
470
+ }
471
+
472
+ // Embed node (preserves videos, oEmbed content)
473
+ if (this.options.carveEmbed !== false) {
474
+ extensions.push(CarveEmbed.configure(this.options.carveEmbed ?? {}));
475
+ }
476
+
477
+ // Abbreviation mark (maps to [ABBR]{abbr="expansion"})
478
+ if (this.options.carveAbbreviation !== false) {
479
+ extensions.push(CarveAbbreviation.configure(this.options.carveAbbreviation ?? {}));
480
+ }
481
+
482
+ // Definition list nodes (maps to : term with definition)
483
+ if (this.options.definitionList !== false) {
484
+ extensions.push(CarveDefinitionList.configure(this.options.definitionList ?? {}));
485
+ extensions.push(CarveDefinitionTerm.configure(this.options.definitionTerm ?? {}));
486
+ extensions.push(CarveDefinitionDescription.configure(this.options.definitionDescription ?? {}));
487
+ }
488
+
489
+ // Mentions (@name) and tags (#tag); citations [@key] use a mention.
490
+ if (this.options.mention !== false) {
491
+ extensions.push(CarveMention.configure(this.options.mention ?? {}));
492
+ }
493
+ if (this.options.tag !== false) {
494
+ extensions.push(CarveTag.configure(this.options.tag ?? {}));
495
+ }
496
+
497
+ // Keyboard shortcuts (Ctrl/Cmd+1..6 headings, clear formatting, Enter
498
+ // reset, ...). Opt out with CarveKit.configure({ keymap: false }).
499
+ if (this.options.keymap !== false) {
500
+ extensions.push(CarveKeymap.configure(this.options.keymap ?? {}));
501
+ }
502
+
503
+ return extensions;
504
+ },
505
+ });
506
+
507
+ export default CarveKit;
@@ -0,0 +1,79 @@
1
+ import { Mark, mergeAttributes } from '@tiptap/core';
2
+
3
+ /**
4
+ * Carve Abbreviation extension for Tiptap
5
+ *
6
+ * Renders inline abbreviations with the `<abbr>` tag in the editor.
7
+ * Serializes to Carve as: [ABBR]{abbr="Full Text"}
8
+ *
9
+ * This is carve/djot-php's SemanticSpanExtension syntax: with that extension
10
+ * enabled, `[ABBR]{abbr="…"}` renders a real `<abbr title="…">` (which this
11
+ * mark's parseHTML reads back); without it, carve renders a `<span abbr="…">`.
12
+ *
13
+ * @example
14
+ * ```js
15
+ * // In editor
16
+ * <abbr title="HyperText Markup Language">HTML</abbr>
17
+ *
18
+ * // Carve output
19
+ * [HTML]{abbr="HyperText Markup Language"}
20
+ * ```
21
+ */
22
+ export const CarveAbbreviation = Mark.create({
23
+ name: 'carveAbbreviation',
24
+
25
+ addAttributes() {
26
+ return {
27
+ title: {
28
+ default: null,
29
+ parseHTML: element => element.getAttribute('title'),
30
+ renderHTML: attributes => {
31
+ if (!attributes.title) return {};
32
+ return { title: attributes.title };
33
+ },
34
+ },
35
+ };
36
+ },
37
+
38
+ parseHTML() {
39
+ return [
40
+ {
41
+ tag: 'abbr[title]',
42
+ priority: 51,
43
+ },
44
+ ];
45
+ },
46
+
47
+ renderHTML({ HTMLAttributes }) {
48
+ return ['abbr', mergeAttributes(HTMLAttributes), 0];
49
+ },
50
+
51
+ addCommands() {
52
+ return {
53
+ setAbbreviation: attributes => ({ commands }) => {
54
+ return commands.setMark(this.name, attributes);
55
+ },
56
+ toggleAbbreviation: attributes => ({ commands }) => {
57
+ return commands.toggleMark(this.name, attributes);
58
+ },
59
+ unsetAbbreviation: () => ({ commands }) => {
60
+ return commands.unsetMark(this.name);
61
+ },
62
+ };
63
+ },
64
+
65
+ addKeyboardShortcuts() {
66
+ return {
67
+ // Auto-exit abbreviation mark when pressing space
68
+ 'Space': () => {
69
+ if (this.editor.isActive(this.name)) {
70
+ this.editor.commands.unsetMark(this.name);
71
+ return false; // Let space be typed normally
72
+ }
73
+ return false;
74
+ },
75
+ };
76
+ },
77
+ });
78
+
79
+ export default CarveAbbreviation;