lezer-markdown-obsidian/src/extensions.ts
Eric Rykwalder d1dc4afca6 add parsing for YAML frontmatter
This doesn't parse the YAML, but recognizes the YAML markers in
markdown.
2022-03-05 13:28:07 -05:00

332 lines
8.2 KiB
TypeScript

import { Input, PartialParse, Tree } from "@lezer/common";
import {
BlockContext,
Element,
InlineContext,
LeafBlock,
LeafBlockParser,
Line,
MarkdownConfig,
parser as defParser,
Strikethrough,
Table,
} from "@lezer/markdown";
declare module "@lezer/markdown" {
class BlockContext {
readonly input: Input;
checkedYaml: boolean | null;
}
}
/*
Copyright (C) 2020 by Marijn Haverbeke <marijnh@gmail.com> and others
https://github.com/lezer-parser/markdown/blob/f49eb8c8c82cfe45aa213ca1fe2cebc95305b88b/LICENSE
*/
class TaskParser implements LeafBlockParser {
nextLine() {
return false;
}
finish(cx: BlockContext, leaf: LeafBlock) {
cx.addLeafElement(
leaf,
cx.elt("Task", leaf.start, leaf.start + leaf.content.length, [
cx.elt("TaskMarker", leaf.start, leaf.start + 3),
...cx.parser.parseInline(leaf.content.slice(3), leaf.start + 3),
])
);
return true;
}
}
/// Extension providing
/// [GFM-style](https://github.github.com/gfm/#task-list-items-extension-)
/// task list items, where list items can be prefixed with `[ ]` or
/// `[x]` to add a checkbox.
/// `x` can be any character
export const TaskList: MarkdownConfig = {
defineNodes: [{ name: "Task", block: true }, "TaskMarker"],
parseBlock: [
{
name: "TaskList",
leaf(cx, leaf) {
return /^\[.\]/.test(leaf.content) && cx.parentType().name == "ListItem"
? new TaskParser()
: null;
},
after: "SetextHeading",
},
],
};
/* End Copyright */
function parseInternalLink(cx: InlineContext, pos: number): Element | null {
if (
cx.char(pos) != 91 /* [ */ ||
cx.char(pos + 1) != 91 ||
!isClosedLink(cx, pos)
) {
return null;
}
const contents: Element[] = [];
contents.push(cx.elt("InternalMark", pos, pos + 2));
pos = cx.skipSpace(pos + 2);
const path = parsePath(cx, pos - cx.offset, cx.offset);
if (path) {
contents.push(path);
pos = cx.skipSpace(path.to);
}
const subpath = parseSubpath(cx, pos);
if (subpath) {
contents.push(subpath);
pos = cx.skipSpace(subpath.to);
}
if (path == null && subpath == null) {
return null;
}
if (cx.char(pos) == 124 /* | */) {
contents.push(cx.elt("InternalMark", pos, pos + 1));
pos += 1;
const display = parseDisplay(cx, pos);
if (display) {
contents.push(display);
pos = cx.skipSpace(display.to);
}
}
contents.push(cx.elt("InternalMark", pos, pos + 2));
return cx.elt(
"InternalLink",
contents[0].from,
contents[contents.length - 1].to,
contents
);
}
export const InternalLink: MarkdownConfig = {
defineNodes: [
"Embed",
"EmbedMark",
"InternalLink",
"InternalMark",
"InternalPath",
"InternalSubpath",
"InternalDisplay",
],
parseInline: [
{
name: "InternalLink",
parse(cx: InlineContext, _: number, pos: number) {
const el = parseInternalLink(cx, pos);
if (el) {
return cx.addElement(el);
}
return -1;
},
before: "Link",
},
{
name: "Embed",
parse(cx: InlineContext, next: number, pos: number): number {
if (next != 33) {
return -1;
}
const link = parseInternalLink(cx, pos + 1);
if (link) {
const embedMark = cx.elt("EmbedMark", pos, pos + 1);
return cx.addElement(
cx.elt("Embed", pos, link.to, [embedMark, link])
);
}
return -1;
},
before: "Image",
},
],
};
function isClosedLink(cx: InlineContext, start: number): boolean {
for (let pos = start + 2; pos < cx.end; pos++) {
if (cx.char(pos) == 91 /* [ */ && cx.char(pos + 1) == 91) {
return false;
} else if (cx.char(pos) == 93 /* ] */ && cx.char(pos + 1) == 93) {
// return false for empty
// true otherwise
return pos > start + 2;
}
}
return false;
}
function parsePath(
cx: InlineContext,
start: number,
offset: number
): Element | null {
// anything but: |[]#^\/
const match = /^[^[\]|#^\\/]+/.exec(cx.text.slice(start));
if (match) {
return cx.elt(
"InternalPath",
offset + start,
offset + start + match[0].length
);
}
return null;
}
function parseSubpath(cx: InlineContext, start: number): Element | null {
if (cx.char(start) != 35 /* # */) {
return null;
}
for (let pos = start + 1; pos < cx.end; pos++) {
if (
cx.char(pos) == 124 /* | */ ||
(cx.char(pos) == 93 /* ] */ && cx.char(pos + 1) == 93)
) {
return cx.elt("InternalSubpath", start, pos);
}
}
return null;
}
function parseDisplay(cx: InlineContext, start: number): Element | null {
for (let pos = start; pos < cx.end; pos++) {
if (cx.char(pos) == 93 /* ] */ && cx.char(pos + 1) == 93) {
if (pos == start) {
return null;
}
return cx.elt("InternalDisplay", start, pos);
}
}
return null;
}
function isFootnoteRef(content: string): number {
const match = /^\[\^[^\s[\]]+\]:/.exec(content);
return match ? match[0].length : -1;
}
class FootnoteReferenceParser implements LeafBlockParser {
constructor(private labelEnd: number) {}
nextLine(cx: BlockContext, line: Line, leaf: LeafBlock) {
if (isFootnoteRef(line.text) != -1) {
return this.complete(cx, leaf);
}
return false;
}
finish(cx: BlockContext, leaf: LeafBlock) {
return this.complete(cx, leaf);
}
complete(cx: BlockContext, leaf: LeafBlock) {
cx.addLeafElement(
leaf,
cx.elt(
"FootnoteReference",
leaf.start,
leaf.start + leaf.content.length,
[
cx.elt("FootnoteMark", leaf.start, leaf.start + 2),
cx.elt("FootnoteLabel", leaf.start + 2, this.labelEnd - 2),
cx.elt("FootnoteMark", this.labelEnd - 2, this.labelEnd),
...cx.parser.parseInline(
leaf.content.slice(this.labelEnd - leaf.start),
this.labelEnd
),
]
)
);
return true;
}
}
export const Footnote: MarkdownConfig = {
defineNodes: [
"Footnote",
"FootnoteLabel",
"FootnoteMark",
"FootnoteReference",
],
parseInline: [
{
name: "Footnote",
parse(cx: InlineContext, _: number, pos: number) {
// typically [^1], but inside can match any characters but
// square brackets and spaces.
const match = /^\[\^[^\s[\]]+\]/.exec(cx.text.slice(pos - cx.offset));
if (match) {
const end = pos + match[0].length;
return cx.addElement(
cx.elt("Footnote", pos, end, [
cx.elt("FootnoteMark", pos, pos + 2),
cx.elt("FootnoteLabel", pos + 2, end - 1),
cx.elt("FootnoteMark", end - 1, end),
])
);
}
return -1;
},
before: "Link",
},
],
parseBlock: [
{
name: "FootnoteReference",
leaf(cx: BlockContext, leaf: LeafBlock): LeafBlockParser | null {
const ref = isFootnoteRef(leaf.content);
if (ref != -1) {
return new FootnoteReferenceParser(leaf.start + ref);
}
return null;
},
before: "LinkReference",
},
],
};
export const YAMLFrontMatter: MarkdownConfig = {
defineNodes: ["YAMLFrontMatter", "YAMLMarker", "YAMLContent"],
parseBlock: [
{
name: "YAMLFrontMatter",
parse(cx, line) {
if (cx.checkedYaml) {
return false;
}
cx.checkedYaml = true;
const fmRegex = /(^|^\s*\n)(---\n.+?\n---)/s;
const match = fmRegex.exec(cx.input.chunk(0));
if (match) {
const start = match[1].length;
const end = start + match[2].length;
cx.addElement(
cx.elt("YAMLFrontMatter", start, end, [
cx.elt("YAMLMarker", start, start + 3),
cx.elt("YAMLContent", start + 4, end - 4),
cx.elt("YAMLMarker", end - 3, end),
])
);
while (cx.lineStart + line.text.length < end && cx.nextLine()) {}
line.pos = 3;
return true;
}
return false;
},
before: "LinkReference",
},
],
};
export const ObsidianMDExtensions = [
Footnote,
YAMLFrontMatter,
InternalLink,
Strikethrough,
Table,
TaskList,
];
export const parser = defParser.configure(ObsidianMDExtensions);