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