Diffdown is a real-time collaborative Markdown editor/previewer built on the AT Protocol
diffdown.com
1import { EditorSelection, countColumn, Prec, EditorState } from '@codemirror/state';
2import { EditorView, keymap } from '@codemirror/view';
3import { defineLanguageFacet, foldNodeProp, indentNodeProp, languageDataProp, foldService, syntaxTree, Language, LanguageDescription, ParseContext, indentUnit, LanguageSupport } from '@codemirror/language';
4import { CompletionContext } from '@codemirror/autocomplete';
5import { parser, GFM, Subscript, Superscript, Emoji, MarkdownParser, parseCode } from '@lezer/markdown';
6import { html, htmlCompletionSource } from '@codemirror/lang-html';
7import { NodeProp } from '@lezer/common';
8
9const data = /*@__PURE__*/defineLanguageFacet({ commentTokens: { block: { open: "<!--", close: "-->" } } });
10const headingProp = /*@__PURE__*/new NodeProp();
11const commonmark = /*@__PURE__*/parser.configure({
12 props: [
13 /*@__PURE__*/foldNodeProp.add(type => {
14 return !type.is("Block") || type.is("Document") || isHeading(type) != null || isList(type) ? undefined
15 : (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to });
16 }),
17 /*@__PURE__*/headingProp.add(isHeading),
18 /*@__PURE__*/indentNodeProp.add({
19 Document: () => null
20 }),
21 /*@__PURE__*/languageDataProp.add({
22 Document: data
23 })
24 ]
25});
26function isHeading(type) {
27 let match = /^(?:ATX|Setext)Heading(\d)$/.exec(type.name);
28 return match ? +match[1] : undefined;
29}
30function isList(type) {
31 return type.name == "OrderedList" || type.name == "BulletList";
32}
33function findSectionEnd(headerNode, level) {
34 let last = headerNode;
35 for (;;) {
36 let next = last.nextSibling, heading;
37 if (!next || (heading = isHeading(next.type)) != null && heading <= level)
38 break;
39 last = next;
40 }
41 return last.to;
42}
43const headerIndent = /*@__PURE__*/foldService.of((state, start, end) => {
44 for (let node = syntaxTree(state).resolveInner(end, -1); node; node = node.parent) {
45 if (node.from < start)
46 break;
47 let heading = node.type.prop(headingProp);
48 if (heading == null)
49 continue;
50 let upto = findSectionEnd(node, heading);
51 if (upto > end)
52 return { from: end, to: upto };
53 }
54 return null;
55});
56function mkLang(parser) {
57 return new Language(data, parser, [], "markdown");
58}
59/**
60Language support for strict CommonMark.
61*/
62const commonmarkLanguage = /*@__PURE__*/mkLang(commonmark);
63const extended = /*@__PURE__*/commonmark.configure([GFM, Subscript, Superscript, Emoji, {
64 props: [
65 /*@__PURE__*/foldNodeProp.add({
66 Table: (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to })
67 })
68 ]
69 }]);
70/**
71Language support for [GFM](https://github.github.com/gfm/) plus
72subscript, superscript, and emoji syntax.
73*/
74const markdownLanguage = /*@__PURE__*/mkLang(extended);
75function getCodeParser(languages, defaultLanguage) {
76 return (info) => {
77 if (info && languages) {
78 let found = null;
79 // Strip anything after whitespace
80 info = /\S*/.exec(info)[0];
81 if (typeof languages == "function")
82 found = languages(info);
83 else
84 found = LanguageDescription.matchLanguageName(languages, info, true);
85 if (found instanceof LanguageDescription)
86 return found.support ? found.support.language.parser : ParseContext.getSkippingParser(found.load());
87 else if (found)
88 return found.parser;
89 }
90 return defaultLanguage ? defaultLanguage.parser : null;
91 };
92}
93
94class Context {
95 constructor(node, from, to, spaceBefore, spaceAfter, type, item) {
96 this.node = node;
97 this.from = from;
98 this.to = to;
99 this.spaceBefore = spaceBefore;
100 this.spaceAfter = spaceAfter;
101 this.type = type;
102 this.item = item;
103 }
104 blank(maxWidth, trailing = true) {
105 let result = this.spaceBefore + (this.node.name == "Blockquote" ? ">" : "");
106 if (maxWidth != null) {
107 while (result.length < maxWidth)
108 result += " ";
109 return result;
110 }
111 else {
112 for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--)
113 result += " ";
114 return result + (trailing ? this.spaceAfter : "");
115 }
116 }
117 marker(doc, add) {
118 let number = this.node.name == "OrderedList" ? String((+itemNumber(this.item, doc)[2] + add)) : "";
119 return this.spaceBefore + number + this.type + this.spaceAfter;
120 }
121}
122function getContext(node, doc) {
123 let nodes = [], context = [];
124 for (let cur = node; cur; cur = cur.parent) {
125 if (cur.name == "FencedCode")
126 return context;
127 if (cur.name == "ListItem" || cur.name == "Blockquote")
128 nodes.push(cur);
129 }
130 for (let i = nodes.length - 1; i >= 0; i--) {
131 let node = nodes[i], match;
132 let line = doc.lineAt(node.from), startPos = node.from - line.from;
133 if (node.name == "Blockquote" && (match = /^ *>( ?)/.exec(line.text.slice(startPos)))) {
134 context.push(new Context(node, startPos, startPos + match[0].length, "", match[1], ">", null));
135 }
136 else if (node.name == "ListItem" && node.parent.name == "OrderedList" &&
137 (match = /^( *)\d+([.)])( *)/.exec(line.text.slice(startPos)))) {
138 let after = match[3], len = match[0].length;
139 if (after.length >= 4) {
140 after = after.slice(0, after.length - 4);
141 len -= 4;
142 }
143 context.push(new Context(node.parent, startPos, startPos + len, match[1], after, match[2], node));
144 }
145 else if (node.name == "ListItem" && node.parent.name == "BulletList" &&
146 (match = /^( *)([-+*])( {1,4}\[[ xX]\])?( +)/.exec(line.text.slice(startPos)))) {
147 let after = match[4], len = match[0].length;
148 if (after.length > 4) {
149 after = after.slice(0, after.length - 4);
150 len -= 4;
151 }
152 let type = match[2];
153 if (match[3])
154 type += match[3].replace(/[xX]/, ' ');
155 context.push(new Context(node.parent, startPos, startPos + len, match[1], after, type, node));
156 }
157 }
158 return context;
159}
160function itemNumber(item, doc) {
161 return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10));
162}
163function renumberList(after, doc, changes, offset = 0) {
164 for (let prev = -1, node = after;;) {
165 if (node.name == "ListItem") {
166 let m = itemNumber(node, doc);
167 let number = +m[2];
168 if (prev >= 0) {
169 if (number != prev + 1)
170 return;
171 changes.push({ from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset) });
172 }
173 prev = number;
174 }
175 let next = node.nextSibling;
176 if (!next)
177 break;
178 node = next;
179 }
180}
181function normalizeIndent(content, state) {
182 let blank = /^[ \t]*/.exec(content)[0].length;
183 if (!blank || state.facet(indentUnit) != "\t")
184 return content;
185 let col = countColumn(content, 4, blank);
186 let space = "";
187 for (let i = col; i > 0;) {
188 if (i >= 4) {
189 space += "\t";
190 i -= 4;
191 }
192 else {
193 space += " ";
194 i--;
195 }
196 }
197 return space + content.slice(blank);
198}
199/**
200Returns a command like
201[`insertNewlineContinueMarkup`](https://codemirror.net/6/docs/ref/#lang-markdown.insertNewlineContinueMarkup),
202allowing further configuration.
203*/
204const insertNewlineContinueMarkupCommand = (config = {}) => ({ state, dispatch }) => {
205 let tree = syntaxTree(state), { doc } = state;
206 let dont = null, changes = state.changeByRange(range => {
207 if (!range.empty || !markdownLanguage.isActiveAt(state, range.from, -1) && !markdownLanguage.isActiveAt(state, range.from, 1))
208 return dont = { range };
209 let pos = range.from, line = doc.lineAt(pos);
210 let context = getContext(tree.resolveInner(pos, -1), doc);
211 while (context.length && context[context.length - 1].from > pos - line.from)
212 context.pop();
213 if (!context.length)
214 return dont = { range };
215 let inner = context[context.length - 1];
216 if (inner.to - inner.spaceAfter.length > pos - line.from)
217 return dont = { range };
218 let emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to));
219 // Empty line in list
220 if (inner.item && emptyLine) {
221 let first = inner.node.firstChild, second = inner.node.getChild("ListItem", "ListItem");
222 // Not second item or blank line before: delete a level of markup
223 if (first.to >= pos || second && second.to < pos ||
224 line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text) ||
225 config.nonTightLists === false) {
226 let next = context.length > 1 ? context[context.length - 2] : null;
227 let delTo, insert = "";
228 if (next && next.item) { // Re-add marker for the list at the next level
229 delTo = line.from + next.from;
230 insert = next.marker(doc, 1);
231 }
232 else {
233 delTo = line.from + (next ? next.to : 0);
234 }
235 let changes = [{ from: delTo, to: pos, insert }];
236 if (inner.node.name == "OrderedList")
237 renumberList(inner.item, doc, changes, -2);
238 if (next && next.node.name == "OrderedList")
239 renumberList(next.item, doc, changes);
240 return { range: EditorSelection.cursor(delTo + insert.length), changes };
241 }
242 else { // Move second item down, making tight two-item list non-tight
243 let insert = blankLine(context, state, line);
244 return { range: EditorSelection.cursor(pos + insert.length + 1),
245 changes: { from: line.from, insert: insert + state.lineBreak } };
246 }
247 }
248 if (inner.node.name == "Blockquote" && emptyLine && line.from) {
249 let prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text);
250 // Two aligned empty quoted lines in a row
251 if (quoted && quoted.index == inner.from) {
252 let changes = state.changes([{ from: prevLine.from + quoted.index, to: prevLine.to },
253 { from: line.from + inner.from, to: line.to }]);
254 return { range: range.map(changes), changes };
255 }
256 }
257 let changes = [];
258 if (inner.node.name == "OrderedList")
259 renumberList(inner.item, doc, changes);
260 let continued = inner.item && inner.item.from < line.from;
261 let insert = "";
262 // If not dedented
263 if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)[0].length >= inner.to) {
264 for (let i = 0, e = context.length - 1; i <= e; i++) {
265 insert += i == e && !continued ? context[i].marker(doc, 1)
266 : context[i].blank(i < e ? countColumn(line.text, 4, context[i + 1].from) - insert.length : null);
267 }
268 }
269 let from = pos;
270 while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1)))
271 from--;
272 insert = normalizeIndent(insert, state);
273 if (nonTightList(inner.node, state.doc))
274 insert = blankLine(context, state, line) + state.lineBreak + insert;
275 changes.push({ from, to: pos, insert: state.lineBreak + insert });
276 return { range: EditorSelection.cursor(from + insert.length + 1), changes };
277 });
278 if (dont)
279 return false;
280 dispatch(state.update(changes, { scrollIntoView: true, userEvent: "input" }));
281 return true;
282};
283/**
284This command, when invoked in Markdown context with cursor
285selection(s), will create a new line with the markup for
286blockquotes and lists that were active on the old line. If the
287cursor was directly after the end of the markup for the old line,
288trailing whitespace and list markers are removed from that line.
289
290The command does nothing in non-Markdown context, so it should
291not be used as the only binding for Enter (even in a Markdown
292document, HTML and code regions might use a different language).
293*/
294const insertNewlineContinueMarkup = /*@__PURE__*/insertNewlineContinueMarkupCommand();
295function isMark(node) {
296 return node.name == "QuoteMark" || node.name == "ListMark";
297}
298function nonTightList(node, doc) {
299 if (node.name != "OrderedList" && node.name != "BulletList")
300 return false;
301 let first = node.firstChild, second = node.getChild("ListItem", "ListItem");
302 if (!second)
303 return false;
304 let line1 = doc.lineAt(first.to), line2 = doc.lineAt(second.from);
305 let empty = /^[\s>]*$/.test(line1.text);
306 return line1.number + (empty ? 0 : 1) < line2.number;
307}
308function blankLine(context, state, line) {
309 let insert = "";
310 for (let i = 0, e = context.length - 2; i <= e; i++) {
311 insert += context[i].blank(i < e
312 ? countColumn(line.text, 4, context[i + 1].from) - insert.length
313 : null, i < e);
314 }
315 return normalizeIndent(insert, state);
316}
317function contextNodeForDelete(tree, pos) {
318 let node = tree.resolveInner(pos, -1), scan = pos;
319 if (isMark(node)) {
320 scan = node.from;
321 node = node.parent;
322 }
323 for (let prev; prev = node.childBefore(scan);) {
324 if (isMark(prev)) {
325 scan = prev.from;
326 }
327 else if (prev.name == "OrderedList" || prev.name == "BulletList") {
328 node = prev.lastChild;
329 scan = node.to;
330 }
331 else {
332 break;
333 }
334 }
335 return node;
336}
337/**
338This command will, when invoked in a Markdown context with the
339cursor directly after list or blockquote markup, delete one level
340of markup. When the markup is for a list, it will be replaced by
341spaces on the first invocation (a further invocation will delete
342the spaces), to make it easy to continue a list.
343
344When not after Markdown block markup, this command will return
345false, so it is intended to be bound alongside other deletion
346commands, with a higher precedence than the more generic commands.
347*/
348const deleteMarkupBackward = ({ state, dispatch }) => {
349 let tree = syntaxTree(state);
350 let dont = null, changes = state.changeByRange(range => {
351 let pos = range.from, { doc } = state;
352 if (range.empty && markdownLanguage.isActiveAt(state, range.from)) {
353 let line = doc.lineAt(pos);
354 let context = getContext(contextNodeForDelete(tree, pos), doc);
355 if (context.length) {
356 let inner = context[context.length - 1];
357 let spaceEnd = inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0);
358 // Delete extra trailing space after markup
359 if (pos - line.from > spaceEnd && !/\S/.test(line.text.slice(spaceEnd, pos - line.from)))
360 return { range: EditorSelection.cursor(line.from + spaceEnd),
361 changes: { from: line.from + spaceEnd, to: pos } };
362 if (pos - line.from == spaceEnd &&
363 // Only apply this if we're on the line that has the
364 // construct's syntax, or there's only indentation in the
365 // target range
366 (!inner.item || line.from <= inner.item.from || !/\S/.test(line.text.slice(0, inner.to)))) {
367 let start = line.from + inner.from;
368 // Replace a list item marker with blank space
369 if (inner.item && inner.node.from < inner.item.from && /\S/.test(line.text.slice(inner.from, inner.to))) {
370 let insert = inner.blank(countColumn(line.text, 4, inner.to) - countColumn(line.text, 4, inner.from));
371 if (start == line.from)
372 insert = normalizeIndent(insert, state);
373 return { range: EditorSelection.cursor(start + insert.length),
374 changes: { from: start, to: line.from + inner.to, insert } };
375 }
376 // Delete one level of indentation
377 if (start < pos)
378 return { range: EditorSelection.cursor(start), changes: { from: start, to: pos } };
379 }
380 }
381 }
382 return dont = { range };
383 });
384 if (dont)
385 return false;
386 dispatch(state.update(changes, { scrollIntoView: true, userEvent: "delete" }));
387 return true;
388};
389
390/**
391A small keymap with Markdown-specific bindings. Binds Enter to
392[`insertNewlineContinueMarkup`](https://codemirror.net/6/docs/ref/#lang-markdown.insertNewlineContinueMarkup)
393and Backspace to
394[`deleteMarkupBackward`](https://codemirror.net/6/docs/ref/#lang-markdown.deleteMarkupBackward).
395*/
396const markdownKeymap = [
397 { key: "Enter", run: insertNewlineContinueMarkup },
398 { key: "Backspace", run: deleteMarkupBackward }
399];
400const htmlNoMatch = /*@__PURE__*/html({ matchClosingTags: false });
401/**
402Markdown language support.
403*/
404function markdown(config = {}) {
405 let { codeLanguages, defaultCodeLanguage, addKeymap = true, base: { parser } = commonmarkLanguage, completeHTMLTags = true, pasteURLAsLink: pasteURL = true, htmlTagLanguage = htmlNoMatch } = config;
406 if (!(parser instanceof MarkdownParser))
407 throw new RangeError("Base parser provided to `markdown` should be a Markdown parser");
408 let extensions = config.extensions ? [config.extensions] : [];
409 let support = [htmlTagLanguage.support, headerIndent], defaultCode;
410 if (pasteURL)
411 support.push(pasteURLAsLink);
412 if (defaultCodeLanguage instanceof LanguageSupport) {
413 support.push(defaultCodeLanguage.support);
414 defaultCode = defaultCodeLanguage.language;
415 }
416 else if (defaultCodeLanguage) {
417 defaultCode = defaultCodeLanguage;
418 }
419 let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages, defaultCode) : undefined;
420 extensions.push(parseCode({ codeParser, htmlParser: htmlTagLanguage.language.parser }));
421 if (addKeymap)
422 support.push(Prec.high(keymap.of(markdownKeymap)));
423 let lang = mkLang(parser.configure(extensions));
424 if (completeHTMLTags)
425 support.push(lang.data.of({ autocomplete: htmlTagCompletion }));
426 return new LanguageSupport(lang, support);
427}
428function htmlTagCompletion(context) {
429 let { state, pos } = context, m = /<[:\-\.\w\u00b7-\uffff]*$/.exec(state.sliceDoc(pos - 25, pos));
430 if (!m)
431 return null;
432 let tree = syntaxTree(state).resolveInner(pos, -1);
433 while (tree && !tree.type.isTop) {
434 if (tree.name == "CodeBlock" || tree.name == "FencedCode" || tree.name == "ProcessingInstructionBlock" ||
435 tree.name == "CommentBlock" || tree.name == "Link" || tree.name == "Image")
436 return null;
437 tree = tree.parent;
438 }
439 return {
440 from: pos - m[0].length, to: pos,
441 options: htmlTagCompletions(),
442 validFor: /^<[:\-\.\w\u00b7-\uffff]*$/
443 };
444}
445let _tagCompletions = null;
446function htmlTagCompletions() {
447 if (_tagCompletions)
448 return _tagCompletions;
449 let result = htmlCompletionSource(new CompletionContext(EditorState.create({ extensions: htmlNoMatch }), 0, true));
450 return _tagCompletions = result ? result.options : [];
451}
452const nonPlainText = /code|horizontalrule|html|link|comment|processing|escape|entity|image|mark|url/i;
453/**
454An extension that intercepts pastes when the pasted content looks
455like a URL and the selection is non-empty and selects regular
456text, making the selection a link with the pasted URL as target.
457*/
458const pasteURLAsLink = /*@__PURE__*/EditorView.domEventHandlers({
459 paste: (event, view) => {
460 var _a;
461 let { main } = view.state.selection;
462 if (main.empty)
463 return false;
464 let link = (_a = event.clipboardData) === null || _a === void 0 ? void 0 : _a.getData("text/plain");
465 if (!link || !/^(https?:\/\/|mailto:|xmpp:|www\.)/.test(link))
466 return false;
467 if (/^www\./.test(link))
468 link = "https://" + link;
469 if (!markdownLanguage.isActiveAt(view.state, main.from, 1))
470 return false;
471 let tree = syntaxTree(view.state), crossesNode = false;
472 // Verify that no nodes are started/ended between the selection
473 // points, and we're not inside any non-plain-text construct.
474 tree.iterate({
475 from: main.from, to: main.to,
476 enter: node => { if (node.from > main.from || nonPlainText.test(node.name))
477 crossesNode = true; },
478 leave: node => { if (node.to < main.to)
479 crossesNode = true; }
480 });
481 if (crossesNode)
482 return false;
483 view.dispatch({
484 changes: [{ from: main.from, insert: "[" }, { from: main.to, insert: `](${link})` }],
485 userEvent: "input.paste",
486 scrollIntoView: true
487 });
488 return true;
489 }
490});
491
492export { commonmarkLanguage, deleteMarkupBackward, insertNewlineContinueMarkup, insertNewlineContinueMarkupCommand, markdown, markdownKeymap, markdownLanguage, pasteURLAsLink };