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;