From a929508b536ea5d34dcb4a18b1564239543d1f8e Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Wed, 16 Feb 2022 11:06:32 +0100 Subject: [PATCH] Code --- .gitignore | 1 + src/app.ts | 6 +- src/markdown/commands.ts | 221 +++++++++++++++++++++++++++++++++++++++ src/markdown/index.ts | 56 ++++++++++ src/markdown/markdown.ts | 84 +++++++++++++++ 5 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 src/markdown/commands.ts create mode 100644 src/markdown/index.ts create mode 100644 src/markdown/markdown.ts diff --git a/.gitignore b/.gitignore index 41889b37..c91f704c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /node_modules/ /dist/ /.parcel-cache/ +/.idea diff --git a/src/app.ts b/src/app.ts index bb2191bc..f8f6bec0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ -import {markdown} from "./lang-markdown/index"; +import {markdown} from "./markdown"; +import {commonmark, mkLang} from "./markdown/markdown"; import { Decoration, @@ -25,7 +26,7 @@ import {lintKeymap} from '@codemirror/lint'; import {EditorSelection, EditorState, StateCommand, Transaction} from "@codemirror/state"; import {Text} from "@codemirror/text"; import {MarkdownConfig} from "@lezer/markdown"; -import {commonmark, mkLang} from "./lang-markdown/markdown"; +import {commonmarkLanguage} from "@codemirror/lang-markdown"; const defaultMd = `# Custom Box Design Some #time ago I (that's @zef.hemel) wrote [No More Boxes](https://zef.me/musing/no-more-boxes/). Let me finally follow up on that and share an approach that I’ve been using opportunistically here and there, primarily for roles that hadn’t been well defined yet. @@ -235,7 +236,6 @@ const TagLink: MarkdownConfig = { after: "Emphasis" }] } - const WikiMarkdown = commonmark.configure([WikiLink, AtMention, TagLink, { props: [ styleTags({ diff --git a/src/markdown/commands.ts b/src/markdown/commands.ts new file mode 100644 index 00000000..65fa1977 --- /dev/null +++ b/src/markdown/commands.ts @@ -0,0 +1,221 @@ +import {StateCommand, Text, EditorSelection, ChangeSpec} from "@codemirror/state" +import {syntaxTree} from "@codemirror/language" +import {SyntaxNode, Tree} from "@lezer/common" +import {markdownLanguage} from "./markdown" + +function nodeStart(node: SyntaxNode, doc: Text) { + return doc.sliceString(node.from, node.from + 50) +} + +class Context { + constructor( + readonly node: SyntaxNode, + readonly from: number, + readonly to: number, + readonly spaceBefore: string, + readonly spaceAfter: string, + readonly type: string, + readonly item: SyntaxNode | null + ) {} + + blank(trailing: boolean = true) { + let result = this.spaceBefore + if (this.node.name == "Blockquote") result += ">" + else for (let i = this.to - this.from - result.length - this.spaceAfter.length; i > 0; i--) result += " " + return result + (trailing ? this.spaceAfter : "") + } + + marker(doc: Text, add: number) { + let number = this.node.name == "OrderedList" ? String((+itemNumber(this.item!, doc)[2] + add)) : "" + return this.spaceBefore + number + this.type + this.spaceAfter + } +} + +function getContext(node: SyntaxNode, line: string, doc: Text) { + let nodes = [] + for (let cur: SyntaxNode | null = node; cur && cur.name != "Document"; cur = cur.parent) { + if (cur.name == "ListItem" || cur.name == "Blockquote") + nodes.push(cur) + } + let context = [], pos = 0 + for (let i = nodes.length - 1; i >= 0; i--) { + let node = nodes[i], match, start = pos + if (node.name == "Blockquote" && (match = /^[ \t]*>( ?)/.exec(line.slice(pos)))) { + pos += match[0].length + context.push(new Context(node, start, pos, "", match[1], ">", null)) + } else if (node.name == "ListItem" && node.parent!.name == "OrderedList" && + (match = /^([ \t]*)\d+([.)])([ \t]*)/.exec(nodeStart(node, doc)))) { + let after = match[3], len = match[0].length + if (after.length >= 4) { after = after.slice(0, after.length - 4); len -= 4 } + pos += len + context.push(new Context(node.parent!, start, pos, match[1], after, match[2], node)) + } else if (node.name == "ListItem" && node.parent!.name == "BulletList" && + (match = /^([ \t]*)([-+*])([ \t]+)/.exec(nodeStart(node, doc)))) { + let after = match[3], len = match[0].length + if (after.length > 4) { after = after.slice(0, after.length - 4); len -= 4 } + pos += len + context.push(new Context(node.parent!, start, pos, match[1], after, match[2], node)) + } + } + return context +} + +function itemNumber(item: SyntaxNode, doc: Text) { + return /^(\s*)(\d+)(?=[.)])/.exec(doc.sliceString(item.from, item.from + 10))! +} + +function renumberList(after: SyntaxNode, doc: Text, changes: ChangeSpec[], offset = 0) { + for (let prev = -1, node = after;;) { + if (node.name == "ListItem") { + let m = itemNumber(node, doc) + let number = +m[2] + if (prev >= 0) { + if (number != prev + 1) return + changes.push({from: node.from + m[1].length, to: node.from + m[0].length, insert: String(prev + 2 + offset)}) + } + prev = number + } + let next = node.nextSibling + if (!next) break + node = next + } +} + +/// This command, when invoked in Markdown context with cursor +/// selection(s), will create a new line with the markup for +/// blockquotes and lists that were active on the old line. If the +/// cursor was directly after the end of the markup for the old line, +/// trailing whitespace and list markers are removed from that line. +/// +/// The command does nothing in non-Markdown context, so it should +/// not be used as the only binding for Enter (even in a Markdown +/// document, HTML and code regions might use a different language). +export const insertNewlineContinueMarkup: StateCommand = ({state, dispatch}) => { + let tree = syntaxTree(state), {doc} = state + let dont = null, changes = state.changeByRange(range => { + if (!range.empty || !markdownLanguage.isActiveAt(state, range.from)) return dont = {range} + let pos = range.from, line = doc.lineAt(pos) + let context = getContext(tree.resolveInner(pos, -1), line.text, doc) + while (context.length && context[context.length - 1].from > pos - line.from) context.pop() + if (!context.length) return dont = {range} + let inner = context[context.length - 1] + if (inner.to - inner.spaceAfter.length > pos - line.from) return dont = {range} + + let emptyLine = pos >= (inner.to - inner.spaceAfter.length) && !/\S/.test(line.text.slice(inner.to)) + // Empty line in list + if (inner.item && emptyLine) { + // First list item or blank line before: delete a level of markup + if (inner.node.firstChild!.to >= pos || + line.from > 0 && !/[^\s>]/.test(doc.lineAt(line.from - 1).text)) { + let next = context.length > 1 ? context[context.length - 2] : null + let delTo, insert = "" + if (next && next.item) { // Re-add marker for the list at the next level + delTo = line.from + next.from + insert = next.marker(doc, 1) + } else { + delTo = line.from + (next ? next.to : 0) + } + let changes: ChangeSpec[] = [{from: delTo, to: pos, insert}] + if (inner.node.name == "OrderedList") renumberList(inner.item!, doc, changes, -2) + if (next && next.node.name == "OrderedList") renumberList(next.item!, doc, changes) + return {range: EditorSelection.cursor(delTo + insert.length), changes} + } else { // Move this line down + let insert = "" + for (let i = 0, e = context.length - 2; i <= e; i++) insert += context[i].blank(i < e) + insert += state.lineBreak + return {range: EditorSelection.cursor(pos + insert.length), changes: {from: line.from, insert}} + } + } + + if (inner.node.name == "Blockquote" && emptyLine && line.from) { + let prevLine = doc.lineAt(line.from - 1), quoted = />\s*$/.exec(prevLine.text) + // Two aligned empty quoted lines in a row + if (quoted && quoted.index == inner.from) { + let changes = state.changes([{from: prevLine.from + quoted.index, to: prevLine.to}, + {from: line.from + inner.from, to: line.to}]) + return {range: range.map(changes), changes} + } + } + + let changes: ChangeSpec[] = [] + if (inner.node.name == "OrderedList") renumberList(inner.item!, doc, changes) + let insert = state.lineBreak + let continued = inner.item && inner.item.from < line.from + // If not dedented + if (!continued || /^[\s\d.)\-+*>]*/.exec(line.text)![0].length >= inner.to) { + for (let i = 0, e = context.length - 1; i <= e; i++) + insert += i == e && !continued ? context[i].marker(doc, 1) : context[i].blank() + } + let from = pos + while (from > line.from && /\s/.test(line.text.charAt(from - line.from - 1))) from-- + changes.push({from, to: pos, insert}) + return {range: EditorSelection.cursor(from + insert.length), changes} + }) + if (dont) return false + dispatch(state.update(changes, {scrollIntoView: true, userEvent: "input"})) + return true +} + +function isMark(node: SyntaxNode) { + return node.name == "QuoteMark" || node.name == "ListMark" +} + +function contextNodeForDelete(tree: Tree, pos: number) { + let node = tree.resolveInner(pos, -1), scan = pos + if (isMark(node)) { + scan = node.from + node = node.parent! + } + for (let prev; prev = node.childBefore(scan);) { + if (isMark(prev)) { + scan = prev.from + } else if (prev.name == "OrderedList" || prev.name == "BulletList") { + node = prev.lastChild! + scan = node.to + } else { + break + } + } + return node +} + +/// This command will, when invoked in a Markdown context with the +/// cursor directly after list or blockquote markup, delete one level +/// of markup. When the markup is for a list, it will be replaced by +/// spaces on the first invocation (a further invocation will delete +/// the spaces), to make it easy to continue a list. +/// +/// When not after Markdown block markup, this command will return +/// false, so it is intended to be bound alongside other deletion +/// commands, with a higher precedence than the more generic commands. +export const deleteMarkupBackward: StateCommand = ({state, dispatch}) => { + let tree = syntaxTree(state) + let dont = null, changes = state.changeByRange(range => { + let pos = range.from, {doc} = state + if (range.empty && markdownLanguage.isActiveAt(state, range.from)) { + let line = doc.lineAt(pos) + let context = getContext(contextNodeForDelete(tree, pos), line.text, doc) + if (context.length) { + let inner = context[context.length - 1] + let spaceEnd = inner.to - inner.spaceAfter.length + (inner.spaceAfter ? 1 : 0) + // Delete extra trailing space after markup + if (pos - line.from > spaceEnd && !/\S/.test(line.text.slice(spaceEnd, pos - line.from))) + return {range: EditorSelection.cursor(line.from + spaceEnd), + changes: {from: line.from + spaceEnd, to: pos}} + if (pos - line.from == spaceEnd) { + let start = line.from + inner.from + // Replace a list item marker with blank space + if (inner.item && inner.node.from < inner.item.from && /\S/.test(line.text.slice(inner.from, inner.to))) + return {range, changes: {from: start, to: line.from + inner.to, insert: inner.blank()}} + // Delete one level of indentation + if (start < pos) + return {range: EditorSelection.cursor(start), changes: {from: start, to: pos}} + } + } + } + return dont = {range} + }) + if (dont) return false + dispatch(state.update(changes, {scrollIntoView: true, userEvent: "delete"})) + return true +} diff --git a/src/markdown/index.ts b/src/markdown/index.ts new file mode 100644 index 00000000..b14b9099 --- /dev/null +++ b/src/markdown/index.ts @@ -0,0 +1,56 @@ +import {Prec} from "@codemirror/state" +import {KeyBinding, keymap} from "@codemirror/view" +import {Language, LanguageSupport, LanguageDescription} from "@codemirror/language" +import {MarkdownExtension, MarkdownParser, parseCode} from "@lezer/markdown" +import {html} from "@codemirror/lang-html" +import {commonmarkLanguage, markdownLanguage, mkLang, getCodeParser} from "./markdown" +import {insertNewlineContinueMarkup, deleteMarkupBackward} from "./commands" +export {commonmarkLanguage, markdownLanguage, insertNewlineContinueMarkup, deleteMarkupBackward} + +/// A small keymap with Markdown-specific bindings. Binds Enter to +/// [`insertNewlineContinueMarkup`](#lang-markdown.insertNewlineContinueMarkup) +/// and Backspace to +/// [`deleteMarkupBackward`](#lang-markdown.deleteMarkupBackward). +export const markdownKeymap: readonly KeyBinding[] = [ + {key: "Enter", run: insertNewlineContinueMarkup}, + {key: "Backspace", run: deleteMarkupBackward} +] + +const htmlNoMatch = html({matchClosingTags: false}) + +/// Markdown language support. +export function markdown(config: { + /// When given, this language will be used by default to parse code + /// blocks. + defaultCodeLanguage?: Language | LanguageSupport, + /// A collection of language descriptions to search through for a + /// matching language (with + /// [`LanguageDescription.matchLanguageName`](#language.LanguageDescription^matchLanguageName)) + /// when a fenced code block has an info string. + codeLanguages?: readonly LanguageDescription[], + /// Set this to false to disable installation of the Markdown + /// [keymap](#lang-markdown.markdownKeymap). + addKeymap?: boolean, + /// Markdown parser + /// [extensions](https://github.com/lezer-parser/markdown#user-content-markdownextension) + /// to add to the parser. + extensions?: MarkdownExtension, + /// The base language to use. Defaults to + /// [`commonmarkLanguage`](#lang-markdown.commonmarkLanguage). + base?: Language +} = {}) { + let {codeLanguages, defaultCodeLanguage, addKeymap = true, base: {parser} = commonmarkLanguage} = config + if (!(parser instanceof MarkdownParser)) throw new RangeError("Base parser provided to `markdown` should be a Markdown parser") + let extensions = config.extensions ? [config.extensions] : [] + let support = [htmlNoMatch.support], defaultCode + if (defaultCodeLanguage instanceof LanguageSupport) { + support.push(defaultCodeLanguage.support) + defaultCode = defaultCodeLanguage.language + } else if (defaultCodeLanguage) { + defaultCode = defaultCodeLanguage + } + let codeParser = codeLanguages || defaultCode ? getCodeParser(codeLanguages || [], defaultCode) : undefined + extensions.push(parseCode({codeParser, htmlParser: htmlNoMatch.language.parser})) + if (addKeymap) support.push(Prec.high(keymap.of(markdownKeymap))) + return new LanguageSupport(mkLang(parser.configure(extensions)), support) +} diff --git a/src/markdown/markdown.ts b/src/markdown/markdown.ts new file mode 100644 index 00000000..230f4855 --- /dev/null +++ b/src/markdown/markdown.ts @@ -0,0 +1,84 @@ +import { + Language, defineLanguageFacet, languageDataProp, foldNodeProp, indentNodeProp, + LanguageDescription, ParseContext +} from "@codemirror/language" +import {styleTags, tags as t} from "@codemirror/highlight" +import {parser as baseParser, MarkdownParser, GFM, Subscript, Superscript, Emoji, MarkdownConfig} from "@lezer/markdown" + +const data = defineLanguageFacet({block: {open: ""}}) + +export const commonmark = baseParser.configure({ + props: [ + styleTags({ + "Blockquote/...": t.quote, + HorizontalRule: t.contentSeparator, + "ATXHeading1/... SetextHeading1/...": t.heading1, + "ATXHeading2/... SetextHeading2/...": t.heading2, + "ATXHeading3/...": t.heading3, + "ATXHeading4/...": t.heading4, + "ATXHeading5/...": t.heading5, + "ATXHeading6/...": t.heading6, + "Comment CommentBlock": t.comment, + Escape: t.escape, + Entity: t.character, + "Emphasis/...": t.emphasis, + "StrongEmphasis/...": t.strong, + "Link/... Image/...": t.link, + "OrderedList/... BulletList/...": t.list, + + // "CodeBlock/... FencedCode/...": t.blockComment, + "InlineCode CodeText": t.monospace, + URL: t.url, + "HeaderMark HardBreak QuoteMark ListMark LinkMark EmphasisMark CodeMark": t.processingInstruction, + "CodeInfo LinkLabel": t.labelName, + LinkTitle: t.string, + Paragraph: t.content + }), + foldNodeProp.add(type => { + if (!type.is("Block") || type.is("Document")) return undefined + return (tree, state) => ({from: state.doc.lineAt(tree.from).to, to: tree.to}) + }), + indentNodeProp.add({ + Document: () => null + }), + languageDataProp.add({ + Document: data + }) + ] +}) + +export function mkLang(parser: MarkdownParser) { + return new Language(data, parser, parser.nodeSet.types.find(t => t.name == "Document")!) +} + +/// Language support for strict CommonMark. +export const commonmarkLanguage = mkLang(commonmark) + +const extended = commonmark.configure([GFM, Subscript, Superscript, Emoji, { + props: [ + styleTags({ + "TableDelimiter SubscriptMark SuperscriptMark StrikethroughMark": t.processingInstruction, + "TableHeader/...": t.heading, + "Strikethrough/...": t.strikethrough, + TaskMarker: t.atom, + Task: t.list, + Emoji: t.character, + "Subscript Superscript": t.special(t.content), + TableCell: t.content + }) + ] +}]) + +/// Language support for [GFM](https://github.github.com/gfm/) plus +/// subscript, superscript, and emoji syntax. +export const markdownLanguage = mkLang(extended) + +export function getCodeParser(languages: readonly LanguageDescription[], + defaultLanguage?: Language) { + return (info: string) => { + let found = info && LanguageDescription.matchLanguageName(languages, info, true) + if (!found) return defaultLanguage ? defaultLanguage.parser : null + if (found.support) return found.support.language.parser + return ParseContext.getSkippingParser(found.load()) + } +}