0
# Markdown
1
2
The markdown system provides bidirectional conversion between ProseMirror documents and Markdown text. It supports custom parsing and serialization rules while maintaining document fidelity.
3
4
## Capabilities
5
6
### Markdown Parser
7
8
Convert Markdown text to ProseMirror documents.
9
10
```typescript { .api }
11
/**
12
* Markdown parser for converting Markdown to ProseMirror documents
13
*/
14
class MarkdownParser {
15
/**
16
* Create a markdown parser
17
*/
18
constructor(schema: Schema, tokenizer: any, tokens: { [name: string]: ParseSpec });
19
20
/**
21
* Parse markdown text into a ProseMirror document
22
*/
23
parse(text: string): Node;
24
25
/**
26
* Parse markdown text into a document fragment
27
*/
28
parseSlice(text: string): Slice;
29
}
30
```
31
32
### Markdown Serializer
33
34
Convert ProseMirror documents to Markdown text.
35
36
```typescript { .api }
37
/**
38
* Markdown serializer for converting ProseMirror documents to Markdown
39
*/
40
class MarkdownSerializer {
41
/**
42
* Create a markdown serializer
43
*/
44
constructor(
45
nodes: { [name: string]: (state: MarkdownSerializerState, node: Node) => void },
46
marks: { [name: string]: MarkSerializerSpec }
47
);
48
49
/**
50
* Serialize a ProseMirror node to markdown text
51
*/
52
serialize(content: Node, options?: { tightLists?: boolean }): string;
53
}
54
55
/**
56
* Serialization state management
57
*/
58
class MarkdownSerializerState {
59
/**
60
* Current output text
61
*/
62
out: string;
63
64
/**
65
* Write text to output
66
*/
67
write(content?: string): void;
68
69
/**
70
* Close the current block
71
*/
72
closeBlock(node: Node): void;
73
74
/**
75
* Write text with proper escaping
76
*/
77
text(text: string, escape?: boolean): void;
78
79
/**
80
* Render node content
81
*/
82
render(node: Node, parent: Node, index: number): void;
83
84
/**
85
* Render inline content with marks
86
*/
87
renderInline(parent: Node): void;
88
89
/**
90
* Render a list
91
*/
92
renderList(node: Node, delim: string, firstDelim?: (index: number) => string): void;
93
94
/**
95
* Wrap text with delimiters
96
*/
97
wrapBlock(delim: string, firstDelim: string | null, node: Node, f: () => void): void;
98
99
/**
100
* Ensure block separation
101
*/
102
ensureNewLine(): void;
103
104
/**
105
* Prepare block for content
106
*/
107
prepare(str: string): string;
108
109
/**
110
* Quote text for safe output
111
*/
112
quote(str: string): string;
113
114
/**
115
* Repeat string n times
116
*/
117
repeat(str: string, n: number): string;
118
119
/**
120
* Get mark delimiter
121
*/
122
markString(mark: Mark, open: boolean, parent: Node, index: number): string;
123
124
/**
125
* Get current mark attributes
126
*/
127
getMarkAttrs(mark: Mark): Attrs;
128
}
129
```
130
131
### Parse Specifications
132
133
Define how Markdown tokens map to ProseMirror nodes.
134
135
```typescript { .api }
136
/**
137
* Specification for parsing a markdown token
138
*/
139
interface ParseSpec {
140
/**
141
* Node type to create
142
*/
143
node?: string;
144
145
/**
146
* Mark type to create
147
*/
148
mark?: string;
149
150
/**
151
* Attributes for the node/mark
152
*/
153
attrs?: Attrs | ((token: any) => Attrs);
154
155
/**
156
* Content handling
157
*/
158
content?: string;
159
160
/**
161
* Custom parsing function
162
*/
163
parse?: (state: any, token: any) => void;
164
165
/**
166
* Ignore this token
167
*/
168
ignore?: boolean;
169
}
170
171
/**
172
* Mark serialization specification
173
*/
174
type MarkSerializerSpec = {
175
/**
176
* Opening delimiter
177
*/
178
open: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string);
179
180
/**
181
* Closing delimiter
182
*/
183
close: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string);
184
185
/**
186
* Mixed content handling
187
*/
188
mixable?: boolean;
189
190
/**
191
* Expel marks when serializing
192
*/
193
expelEnclosingWhitespace?: boolean;
194
195
/**
196
* Escape function
197
*/
198
escape?: boolean;
199
};
200
```
201
202
### Default Implementations
203
204
Pre-configured parser and serializer for standard Markdown.
205
206
```typescript { .api }
207
/**
208
* Default markdown parser instance
209
*/
210
const defaultMarkdownParser: MarkdownParser;
211
212
/**
213
* Default markdown serializer instance
214
*/
215
const defaultMarkdownSerializer: MarkdownSerializer;
216
217
/**
218
* Markdown-compatible schema
219
*/
220
const schema: Schema;
221
```
222
223
**Usage Examples:**
224
225
```typescript
226
import {
227
MarkdownParser,
228
MarkdownSerializer,
229
defaultMarkdownParser,
230
defaultMarkdownSerializer,
231
schema as markdownSchema
232
} from "@tiptap/pm/markdown";
233
234
// Basic usage with defaults
235
const markdownText = `
236
# Hello World
237
238
This is **bold** and *italic* text.
239
240
- List item 1
241
- List item 2
242
243
\`\`\`javascript
244
console.log("Hello, world!");
245
\`\`\`
246
`;
247
248
// Parse markdown to ProseMirror
249
const doc = defaultMarkdownParser.parse(markdownText);
250
251
// Serialize ProseMirror to markdown
252
const serialized = defaultMarkdownSerializer.serialize(doc);
253
254
// Create editor with markdown schema
255
const state = EditorState.create({
256
schema: markdownSchema,
257
doc
258
});
259
260
// Custom markdown parser
261
const customParser = new MarkdownParser(mySchema, markdownit(), {
262
// Built-in tokens
263
blockquote: { block: "blockquote" },
264
paragraph: { block: "paragraph" },
265
list_item: { block: "list_item" },
266
bullet_list: { block: "bullet_list", attrs: { tight: true } },
267
ordered_list: {
268
block: "ordered_list",
269
attrs: (tok) => ({ order: +tok.attrGet("start") || 1, tight: true })
270
},
271
heading: {
272
block: "heading",
273
attrs: (tok) => ({ level: +tok.tag.slice(1) })
274
},
275
code_block: {
276
block: "code_block",
277
attrs: (tok) => ({ params: tok.info || "" })
278
},
279
fence: {
280
block: "code_block",
281
attrs: (tok) => ({ params: tok.info || "" })
282
},
283
hr: { node: "horizontal_rule" },
284
image: {
285
node: "image",
286
attrs: (tok) => ({
287
src: tok.attrGet("src"),
288
title: tok.attrGet("title") || null,
289
alt: tok.children?.[0]?.content || null
290
})
291
},
292
hardbreak: { node: "hard_break" },
293
294
// Inline tokens
295
em: { mark: "em" },
296
strong: { mark: "strong" },
297
link: {
298
mark: "link",
299
attrs: (tok) => ({
300
href: tok.attrGet("href"),
301
title: tok.attrGet("title") || null
302
})
303
},
304
code_inline: { mark: "code", noCloseToken: true },
305
306
// Custom tokens
307
custom_callout: {
308
block: "callout",
309
attrs: (tok) => ({ type: tok.info })
310
}
311
});
312
313
// Custom markdown serializer
314
const customSerializer = new MarkdownSerializer({
315
// Node serializers
316
blockquote(state, node) {
317
state.wrapBlock("> ", null, node, () => state.renderContent(node));
318
},
319
320
code_block(state, node) {
321
state.write("```" + (node.attrs.params || "") + "\n");
322
state.text(node.textContent, false);
323
state.ensureNewLine();
324
state.write("```");
325
state.closeBlock(node);
326
},
327
328
heading(state, node) {
329
state.write(state.repeat("#", node.attrs.level) + " ");
330
state.renderInline(node);
331
state.closeBlock(node);
332
},
333
334
horizontal_rule(state, node) {
335
state.write(node.attrs.markup || "---");
336
state.closeBlock(node);
337
},
338
339
bullet_list(state, node) {
340
state.renderList(node, " ", () => "* ");
341
},
342
343
ordered_list(state, node) {
344
const start = node.attrs.order || 1;
345
const maxW = String(start + node.childCount - 1).length;
346
const space = state.repeat(" ", maxW + 2);
347
state.renderList(node, space, (i) => {
348
const nStr = String(start + i);
349
return state.repeat(" ", maxW - nStr.length) + nStr + ". ";
350
});
351
},
352
353
list_item(state, node) {
354
state.renderContent(node);
355
},
356
357
paragraph(state, node) {
358
state.renderInline(node);
359
state.closeBlock(node);
360
},
361
362
image(state, node) {
363
state.write(" +
365
(node.attrs.title ? " " + state.quote(node.attrs.title) : "") + ")");
366
},
367
368
hard_break(state, node, parent, index) {
369
for (let i = index + 1; i < parent.childCount; i++) {
370
if (parent.child(i).type != node.type) {
371
state.write("\\\n");
372
return;
373
}
374
}
375
},
376
377
text(state, node) {
378
state.text(node.text);
379
}
380
}, {
381
// Mark serializers
382
em: { open: "*", close: "*", mixable: true, expelEnclosingWhitespace: true },
383
strong: { open: "**", close: "**", mixable: true, expelEnclosingWhitespace: true },
384
link: {
385
open: (_state, mark, parent, index) => {
386
return isPlainURL(mark, parent, index) ? "<" : "[";
387
},
388
close: (state, mark, parent, index) => {
389
return isPlainURL(mark, parent, index) ? ">" :
390
"](" + state.esc(mark.attrs.href) +
391
(mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")";
392
}
393
},
394
code: { open: "`", close: "`", escape: false }
395
});
396
397
function isPlainURL(link: Mark, parent: Node, index: number) {
398
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
399
const content = parent.child(index);
400
if (!content.isText || content.text != link.attrs.href ||
401
content.marks[content.marks.length - 1] != link) return false;
402
if (index == parent.childCount - 1) return true;
403
const next = parent.child(index + 1);
404
return !link.isInSet(next.marks);
405
}
406
```
407
408
## Advanced Markdown Features
409
410
### Custom Extensions
411
412
Add support for extended Markdown syntax.
413
414
```typescript
415
// GitHub Flavored Markdown extensions
416
class GFMExtension {
417
static addToParser(parser: MarkdownParser): MarkdownParser {
418
const tokens = {
419
...parser.tokens,
420
421
// Strikethrough
422
s: { mark: "strikethrough" },
423
424
// Task lists
425
task_list_item: {
426
block: "task_list_item",
427
attrs: (tok) => ({ checked: tok.attrGet("checked") === "true" })
428
},
429
430
// Tables
431
table: { block: "table" },
432
thead: { ignore: true },
433
tbody: { ignore: true },
434
tr: { block: "table_row" },
435
th: { block: "table_header" },
436
td: { block: "table_cell" },
437
438
// Footnotes
439
footnote_ref: {
440
node: "footnote_ref",
441
attrs: (tok) => ({ id: tok.meta.id, label: tok.meta.label })
442
}
443
};
444
445
return new MarkdownParser(parser.schema, parser.tokenizer, tokens);
446
}
447
448
static addToSerializer(serializer: MarkdownSerializer): MarkdownSerializer {
449
const nodes = {
450
...serializer.nodes,
451
452
task_list_item(state, node) {
453
const checked = node.attrs.checked ? "[x]" : "[ ]";
454
state.write(checked + " ");
455
state.renderContent(node);
456
},
457
458
table(state, node) {
459
state.renderTable(node);
460
},
461
462
footnote_ref(state, node) {
463
state.write(`[^${node.attrs.label}]`);
464
}
465
};
466
467
const marks = {
468
...serializer.marks,
469
470
strikethrough: {
471
open: "~~",
472
close: "~~",
473
mixable: true,
474
expelEnclosingWhitespace: true
475
}
476
};
477
478
return new MarkdownSerializer(nodes, marks);
479
}
480
}
481
482
// Math extension
483
class MathExtension {
484
static addToParser(parser: MarkdownParser): MarkdownParser {
485
const tokens = {
486
...parser.tokens,
487
488
math_inline: {
489
mark: "math",
490
attrs: (tok) => ({ content: tok.content })
491
},
492
493
math_block: {
494
block: "math_block",
495
attrs: (tok) => ({ content: tok.content })
496
}
497
};
498
499
return new MarkdownParser(parser.schema, parser.tokenizer, tokens);
500
}
501
502
static addToSerializer(serializer: MarkdownSerializer): MarkdownSerializer {
503
const nodes = {
504
...serializer.nodes,
505
506
math_block(state, node) {
507
state.write("$$\n");
508
state.text(node.attrs.content, false);
509
state.ensureNewLine();
510
state.write("$$");
511
state.closeBlock(node);
512
}
513
};
514
515
const marks = {
516
...serializer.marks,
517
518
math: {
519
open: "$",
520
close: "$",
521
escape: false
522
}
523
};
524
525
return new MarkdownSerializer(nodes, marks);
526
}
527
}
528
```
529
530
### Markdown Import/Export
531
532
Handle markdown file operations with metadata preservation.
533
534
```typescript
535
class MarkdownConverter {
536
constructor(
537
private parser: MarkdownParser,
538
private serializer: MarkdownSerializer
539
) {}
540
541
// Import markdown with frontmatter
542
importMarkdown(text: string): { doc: Node; metadata?: any } {
543
const { content, data } = this.extractFrontmatter(text);
544
const doc = this.parser.parse(content);
545
546
return { doc, metadata: data };
547
}
548
549
// Export with frontmatter preservation
550
exportMarkdown(doc: Node, metadata?: any): string {
551
const content = this.serializer.serialize(doc);
552
553
if (metadata) {
554
const frontmatter = this.serializeFrontmatter(metadata);
555
return `${frontmatter}\n${content}`;
556
}
557
558
return content;
559
}
560
561
private extractFrontmatter(text: string): { content: string; data?: any } {
562
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
563
const match = text.match(frontmatterRegex);
564
565
if (match) {
566
try {
567
const data = yaml.parse(match[1]);
568
return { content: match[2], data };
569
} catch (error) {
570
console.warn("Failed to parse frontmatter:", error);
571
}
572
}
573
574
return { content: text };
575
}
576
577
private serializeFrontmatter(data: any): string {
578
try {
579
const yamlContent = yaml.stringify(data).trim();
580
return `---\n${yamlContent}\n---`;
581
} catch (error) {
582
console.warn("Failed to serialize frontmatter:", error);
583
return "";
584
}
585
}
586
587
// Convert between different markdown flavors
588
convertFlavor(
589
text: string,
590
fromFlavor: "commonmark" | "gfm" | "custom",
591
toFlavor: "commonmark" | "gfm" | "custom"
592
): string {
593
// Parse with source flavor
594
const sourceParser = this.getParserForFlavor(fromFlavor);
595
const doc = sourceParser.parse(text);
596
597
// Serialize with target flavor
598
const targetSerializer = this.getSerializerForFlavor(toFlavor);
599
return targetSerializer.serialize(doc);
600
}
601
602
private getParserForFlavor(flavor: string): MarkdownParser {
603
switch (flavor) {
604
case "gfm":
605
return GFMExtension.addToParser(this.parser);
606
case "custom":
607
return MathExtension.addToParser(this.parser);
608
default:
609
return this.parser;
610
}
611
}
612
613
private getSerializerForFlavor(flavor: string): MarkdownSerializer {
614
switch (flavor) {
615
case "gfm":
616
return GFMExtension.addToSerializer(this.serializer);
617
case "custom":
618
return MathExtension.addToSerializer(this.serializer);
619
default:
620
return this.serializer;
621
}
622
}
623
}
624
```
625
626
### Live Markdown Editing
627
628
Implement live markdown preview and editing modes.
629
630
```typescript
631
class MarkdownEditor {
632
private view: EditorView;
633
private isMarkdownMode = false;
634
635
constructor(
636
container: HTMLElement,
637
private parser: MarkdownParser,
638
private serializer: MarkdownSerializer
639
) {
640
this.createEditor(container);
641
this.setupModeToggle(container);
642
}
643
644
private createEditor(container: HTMLElement) {
645
this.view = new EditorView(container, {
646
state: EditorState.create({
647
schema: this.parser.schema,
648
plugins: [
649
// Add markdown-specific plugins
650
this.createMarkdownPlugin(),
651
keymap({
652
"Mod-m": () => this.toggleMode(),
653
"Mod-Shift-p": () => this.showPreview()
654
})
655
]
656
})
657
});
658
}
659
660
private createMarkdownPlugin(): Plugin {
661
return new Plugin({
662
state: {
663
init: () => ({ isMarkdownMode: false }),
664
apply: (tr, value) => {
665
const meta = tr.getMeta("markdown-mode");
666
if (meta !== undefined) {
667
return { isMarkdownMode: meta };
668
}
669
return value;
670
}
671
},
672
673
props: {
674
decorations: (state) => {
675
const pluginState = this.getMarkdownPluginState(state);
676
if (pluginState.isMarkdownMode) {
677
return this.createMarkdownDecorations(state);
678
}
679
return null;
680
}
681
}
682
});
683
}
684
685
private createMarkdownDecorations(state: EditorState): DecorationSet {
686
const decorations: Decoration[] = [];
687
688
// Add syntax highlighting decorations
689
state.doc.descendants((node, pos) => {
690
if (node.isText && node.text) {
691
const text = node.text;
692
693
// Highlight markdown syntax
694
const patterns = [
695
{ regex: /(\*\*|__)(.*?)\1/g, class: "markdown-bold" },
696
{ regex: /(\*|_)(.*?)\1/g, class: "markdown-italic" },
697
{ regex: /(`)(.*?)\1/g, class: "markdown-code" },
698
{ regex: /^(#{1,6})\s/gm, class: "markdown-heading" },
699
{ regex: /^\s*[-*+]\s/gm, class: "markdown-list" }
700
];
701
702
for (const pattern of patterns) {
703
let match;
704
while ((match = pattern.regex.exec(text)) !== null) {
705
decorations.push(
706
Decoration.inline(
707
pos + match.index,
708
pos + match.index + match[0].length,
709
{ class: pattern.class }
710
)
711
);
712
}
713
}
714
}
715
});
716
717
return DecorationSet.create(state.doc, decorations);
718
}
719
720
toggleMode(): boolean {
721
this.isMarkdownMode = !this.isMarkdownMode;
722
723
if (this.isMarkdownMode) {
724
// Switch to markdown text mode
725
const markdown = this.serializer.serialize(this.view.state.doc);
726
const textDoc = this.view.state.schema.node("doc", null, [
727
this.view.state.schema.node("code_block", { params: "markdown" },
728
this.view.state.schema.text(markdown)
729
)
730
]);
731
732
this.view.dispatch(
733
this.view.state.tr
734
.replaceWith(0, this.view.state.doc.content.size, textDoc)
735
.setMeta("markdown-mode", true)
736
);
737
} else {
738
// Switch back to rich text mode
739
const codeBlock = this.view.state.doc.firstChild;
740
if (codeBlock && codeBlock.type.name === "code_block") {
741
const markdown = codeBlock.textContent;
742
try {
743
const richDoc = this.parser.parse(markdown);
744
this.view.dispatch(
745
this.view.state.tr
746
.replaceWith(0, this.view.state.doc.content.size, richDoc)
747
.setMeta("markdown-mode", false)
748
);
749
} catch (error) {
750
console.error("Failed to parse markdown:", error);
751
return false;
752
}
753
}
754
}
755
756
return true;
757
}
758
759
private getMarkdownPluginState(state: EditorState) {
760
// Get markdown plugin state helper
761
return state.plugins.find(p => p.spec.key === "markdown")?.getState(state) || {};
762
}
763
764
showPreview() {
765
const markdown = this.serializer.serialize(this.view.state.doc);
766
const previewWindow = window.open("", "_blank");
767
768
if (previewWindow) {
769
previewWindow.document.write(`
770
<html>
771
<head>
772
<title>Markdown Preview</title>
773
<style>
774
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
775
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; }
776
code { background: #f5f5f5; padding: 2px 4px; border-radius: 2px; }
777
</style>
778
</head>
779
<body>
780
<pre><code>${markdown.replace(/</g, "<").replace(/>/g, ">")}</code></pre>
781
</body>
782
</html>
783
`);
784
}
785
}
786
}
787
```
788
789
## Types
790
791
```typescript { .api }
792
/**
793
* Token parsing specification
794
*/
795
interface ParseSpec {
796
node?: string;
797
mark?: string;
798
attrs?: Attrs | ((token: any) => Attrs);
799
content?: string;
800
parse?: (state: any, token: any) => void;
801
ignore?: boolean;
802
noCloseToken?: boolean;
803
}
804
805
/**
806
* Mark serialization specification
807
*/
808
type MarkSerializerSpec = {
809
open: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string);
810
close: string | ((state: MarkdownSerializerState, mark: Mark, parent: Node, index: number) => string);
811
mixable?: boolean;
812
expelEnclosingWhitespace?: boolean;
813
escape?: boolean;
814
};
815
816
/**
817
* Serialization options
818
*/
819
interface SerializationOptions {
820
tightLists?: boolean;
821
}
822
```