273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
// @ts-ignore
|
|
import MarkdownIt from "markdown-it"
|
|
import Token from "markdown-it/lib/token.mjs"
|
|
import {schema} from "./schema"
|
|
import {Mark, MarkType, Node, Attrs, Schema, NodeType} from "prosemirror-model"
|
|
|
|
function maybeMerge(a: Node, b: Node): Node | undefined {
|
|
if (a.isText && b.isText && Mark.sameSet(a.marks, b.marks))
|
|
return (a as any).withText(a.text! + b.text!)
|
|
}
|
|
|
|
// Object used to track the context of a running parse.
|
|
class MarkdownParseState {
|
|
stack: {type: NodeType, attrs: Attrs | null, content: Node[], marks: readonly Mark[]}[]
|
|
|
|
constructor(
|
|
readonly schema: Schema,
|
|
readonly tokenHandlers: {[token: string]: (stat: MarkdownParseState, token: Token, tokens: Token[], i: number) => void}
|
|
) {
|
|
this.stack = [{type: schema.topNodeType, attrs: null, content: [], marks: Mark.none}]
|
|
}
|
|
|
|
top() {
|
|
return this.stack[this.stack.length - 1]
|
|
}
|
|
|
|
push(elt: Node) {
|
|
if (this.stack.length) this.top().content.push(elt)
|
|
}
|
|
|
|
// Adds the given text to the current position in the document,
|
|
// using the current marks as styling.
|
|
addText(text: string) {
|
|
if (!text) return
|
|
let top = this.top(), nodes = top.content, last = nodes[nodes.length - 1]
|
|
let node = this.schema.text(text, top.marks), merged
|
|
if (last && (merged = maybeMerge(last, node))) nodes[nodes.length - 1] = merged
|
|
else nodes.push(node)
|
|
}
|
|
|
|
// Adds the given mark to the set of active marks.
|
|
openMark(mark: Mark) {
|
|
let top = this.top()
|
|
top.marks = mark.addToSet(top.marks)
|
|
}
|
|
|
|
// Removes the given mark from the set of active marks.
|
|
closeMark(mark: MarkType) {
|
|
let top = this.top()
|
|
top.marks = mark.removeFromSet(top.marks)
|
|
}
|
|
|
|
parseTokens(toks: Token[]) {
|
|
for (let i = 0; i < toks.length; i++) {
|
|
let tok = toks[i]
|
|
let handler = this.tokenHandlers[tok.type]
|
|
if (!handler)
|
|
throw new Error("Token type `" + tok.type + "` not supported by Markdown parser")
|
|
handler(this, tok, toks, i)
|
|
}
|
|
}
|
|
|
|
// Add a node at the current position.
|
|
addNode(type: NodeType, attrs: Attrs | null, content?: readonly Node[]) {
|
|
let top = this.top()
|
|
let node = type.createAndFill(attrs, content, top ? top.marks : [])
|
|
if (!node) return null
|
|
this.push(node)
|
|
return node
|
|
}
|
|
|
|
// Wrap subsequent content in a node of the given type.
|
|
openNode(type: NodeType, attrs: Attrs | null) {
|
|
this.stack.push({type: type, attrs: attrs, content: [], marks: Mark.none})
|
|
}
|
|
|
|
// Close and return the node that is currently on top of the stack.
|
|
closeNode() {
|
|
let info = this.stack.pop()!
|
|
return this.addNode(info.type, info.attrs, info.content)
|
|
}
|
|
}
|
|
|
|
function attrs(spec: ParseSpec, token: Token, tokens: Token[], i: number) {
|
|
if (spec.getAttrs) return spec.getAttrs(token, tokens, i)
|
|
// For backwards compatibility when `attrs` is a Function
|
|
else if (spec.attrs instanceof Function) return spec.attrs(token)
|
|
else return spec.attrs
|
|
}
|
|
|
|
// Code content is represented as a single token with a `content`
|
|
// property in Markdown-it.
|
|
function noCloseToken(spec: ParseSpec, type: string) {
|
|
return spec.noCloseToken || type == "code_inline" || type == "code_block" || type == "fence"
|
|
}
|
|
|
|
function withoutTrailingNewline(str: string) {
|
|
return str[str.length - 1] == "\n" ? str.slice(0, str.length - 1) : str
|
|
}
|
|
|
|
function noOp() {}
|
|
|
|
function tokenHandlers(schema: Schema, tokens: {[token: string]: ParseSpec}) {
|
|
let handlers: {[token: string]: (stat: MarkdownParseState, token: Token, tokens: Token[], i: number) => void} =
|
|
Object.create(null)
|
|
for (let type in tokens) {
|
|
let spec = tokens[type]
|
|
if (spec.block) {
|
|
let nodeType = schema.nodeType(spec.block)
|
|
if (noCloseToken(spec, type)) {
|
|
handlers[type] = (state, tok, tokens, i) => {
|
|
state.openNode(nodeType, attrs(spec, tok, tokens, i))
|
|
state.addText(withoutTrailingNewline(tok.content))
|
|
state.closeNode()
|
|
}
|
|
} else {
|
|
handlers[type + "_open"] = (state, tok, tokens, i) => state.openNode(nodeType, attrs(spec, tok, tokens, i))
|
|
handlers[type + "_close"] = state => state.closeNode()
|
|
}
|
|
} else if (spec.node) {
|
|
let nodeType = schema.nodeType(spec.node)
|
|
handlers[type] = (state, tok, tokens, i) => state.addNode(nodeType, attrs(spec, tok, tokens, i))
|
|
} else if (spec.mark) {
|
|
let markType = schema.marks[spec.mark]
|
|
if (noCloseToken(spec, type)) {
|
|
handlers[type] = (state, tok, tokens, i) => {
|
|
state.openMark(markType.create(attrs(spec, tok, tokens, i)))
|
|
state.addText(withoutTrailingNewline(tok.content))
|
|
state.closeMark(markType)
|
|
}
|
|
} else {
|
|
handlers[type + "_open"] = (state, tok, tokens, i) => state.openMark(markType.create(attrs(spec, tok, tokens, i)))
|
|
handlers[type + "_close"] = state => state.closeMark(markType)
|
|
}
|
|
} else if (spec.ignore) {
|
|
if (noCloseToken(spec, type)) {
|
|
handlers[type] = noOp
|
|
} else {
|
|
handlers[type + "_open"] = noOp
|
|
handlers[type + "_close"] = noOp
|
|
}
|
|
} else {
|
|
throw new RangeError("Unrecognized parsing spec " + JSON.stringify(spec))
|
|
}
|
|
}
|
|
|
|
handlers.text = (state, tok) => state.addText(tok.content)
|
|
handlers.inline = (state, tok) => state.parseTokens(tok.children!)
|
|
handlers.softbreak = handlers.softbreak || (state => state.addText(" "))
|
|
|
|
return handlers
|
|
}
|
|
|
|
/// Object type used to specify how Markdown tokens should be parsed.
|
|
export interface ParseSpec {
|
|
/// This token maps to a single node, whose type can be looked up
|
|
/// in the schema under the given name. Exactly one of `node`,
|
|
/// `block`, or `mark` must be set.
|
|
node?: string
|
|
|
|
/// This token (unless `noCloseToken` is true) comes in `_open`
|
|
/// and `_close` variants (which are appended to the base token
|
|
/// name provides a the object property), and wraps a block of
|
|
/// content. The block should be wrapped in a node of the type
|
|
/// named to by the property's value. If the token does not have
|
|
/// `_open` or `_close`, use the `noCloseToken` option.
|
|
block?: string
|
|
|
|
/// This token (again, unless `noCloseToken` is true) also comes
|
|
/// in `_open` and `_close` variants, but should add a mark
|
|
/// (named by the value) to its content, rather than wrapping it
|
|
/// in a node.
|
|
mark?: string
|
|
|
|
/// Attributes for the node or mark. When `getAttrs` is provided,
|
|
/// it takes precedence.
|
|
attrs?: Attrs | null
|
|
|
|
/// A function used to compute the attributes for the node or mark
|
|
/// that takes a [markdown-it
|
|
/// token](https://markdown-it.github.io/markdown-it/#Token) and
|
|
/// returns an attribute object.
|
|
getAttrs?: (token: Token, tokenStream: Token[], index: number) => Attrs | null
|
|
|
|
/// Indicates that the [markdown-it
|
|
/// token](https://markdown-it.github.io/markdown-it/#Token) has
|
|
/// no `_open` or `_close` for the nodes. This defaults to `true`
|
|
/// for `code_inline`, `code_block` and `fence`.
|
|
noCloseToken?: boolean
|
|
|
|
/// When true, ignore content for the matched token.
|
|
ignore?: boolean
|
|
}
|
|
|
|
/// A configuration of a Markdown parser. Such a parser uses
|
|
/// [markdown-it](https://github.com/markdown-it/markdown-it) to
|
|
/// tokenize a file, and then runs the custom rules it is given over
|
|
/// the tokens to create a ProseMirror document tree.
|
|
export class MarkdownParser {
|
|
/// @internal
|
|
tokenHandlers: {[token: string]: (stat: MarkdownParseState, token: Token, tokens: Token[], i: number) => void}
|
|
|
|
/// Create a parser with the given configuration. You can configure
|
|
/// the markdown-it parser to parse the dialect you want, and provide
|
|
/// a description of the ProseMirror entities those tokens map to in
|
|
/// the `tokens` object, which maps token names to descriptions of
|
|
/// what to do with them. Such a description is an object, and may
|
|
/// have the following properties:
|
|
constructor(
|
|
/// The parser's document schema.
|
|
readonly schema: Schema,
|
|
/// This parser's markdown-it tokenizer.
|
|
readonly tokenizer: MarkdownIt,
|
|
/// The value of the `tokens` object used to construct this
|
|
/// parser. Can be useful to copy and modify to base other parsers
|
|
/// on.
|
|
readonly tokens: {[name: string]: ParseSpec}
|
|
) {
|
|
this.tokenHandlers = tokenHandlers(schema, tokens)
|
|
}
|
|
|
|
/// Parse a string as [CommonMark](http://commonmark.org/) markup,
|
|
/// and create a ProseMirror document as prescribed by this parser's
|
|
/// rules.
|
|
///
|
|
/// The second argument, when given, is passed through to the
|
|
/// [Markdown
|
|
/// parser](https://markdown-it.github.io/markdown-it/#MarkdownIt.parse).
|
|
parse(text: string, markdownEnv: Object = {}) {
|
|
let state = new MarkdownParseState(this.schema, this.tokenHandlers), doc
|
|
state.parseTokens(this.tokenizer.parse(text, markdownEnv))
|
|
do { doc = state.closeNode() } while (state.stack.length)
|
|
return doc || this.schema.topNodeType.createAndFill()!
|
|
}
|
|
}
|
|
|
|
function listIsTight(tokens: readonly Token[], i: number) {
|
|
while (++i < tokens.length)
|
|
if (tokens[i].type != "list_item_open") return tokens[i].hidden
|
|
return false
|
|
}
|
|
|
|
/// A parser parsing unextended [CommonMark](http://commonmark.org/),
|
|
/// without inline HTML, and producing a document in the basic schema.
|
|
export const defaultMarkdownParser = new MarkdownParser(schema, MarkdownIt("commonmark", {html: false}), {
|
|
blockquote: {block: "blockquote"},
|
|
paragraph: {block: "paragraph"},
|
|
list_item: {block: "list_item"},
|
|
bullet_list: {block: "bullet_list", getAttrs: (_, tokens, i) => ({tight: listIsTight(tokens, i)})},
|
|
ordered_list: {block: "ordered_list", getAttrs: (tok, tokens, i) => ({
|
|
order: +tok.attrGet("start")! || 1,
|
|
tight: listIsTight(tokens, i)
|
|
})},
|
|
heading: {block: "heading", getAttrs: tok => ({level: +tok.tag.slice(1)})},
|
|
code_block: {block: "code_block", noCloseToken: true},
|
|
fence: {block: "code_block", getAttrs: tok => ({params: tok.info || ""}), noCloseToken: true},
|
|
hr: {node: "horizontal_rule"},
|
|
image: {node: "image", getAttrs: tok => ({
|
|
src: tok.attrGet("src"),
|
|
title: tok.attrGet("title") || null,
|
|
alt: tok.children![0] && tok.children![0].content || null
|
|
})},
|
|
hardbreak: {node: "hard_break"},
|
|
|
|
em: {mark: "em"},
|
|
strong: {mark: "strong"},
|
|
link: {mark: "link", getAttrs: tok => ({
|
|
href: tok.attrGet("href"),
|
|
title: tok.attrGet("title") || null
|
|
})},
|
|
code_inline: {mark: "code", noCloseToken: true}
|
|
})
|