import { commandLinkRegex } from "../command.ts"; import { yaml as yamlLanguage } from "@codemirror/legacy-modes/mode/yaml?external=@codemirror/language&target=es2022"; import { styleTags, type Tag, tags as t } from "@lezer/highlight"; import { type BlockContext, type LeafBlock, type LeafBlockParser, type Line, type MarkdownConfig, Strikethrough, Subscript, Superscript, } from "@lezer/markdown"; import { markdown } from "@codemirror/lang-markdown"; import { StreamLanguage } from "@codemirror/language"; import * as ct from "./customtags.ts"; import { NakedURLTag } from "./customtags.ts"; import { TaskList } from "./extended_task.ts"; const WikiLink: MarkdownConfig = { defineNodes: [ { name: "WikiLink", style: ct.WikiLinkTag }, { name: "WikiLinkPage", style: ct.WikiLinkPageTag }, { name: "WikiLinkAlias", style: ct.WikiLinkPageTag }, { name: "WikiLinkDimensions", style: ct.WikiLinkPageTag }, { name: "WikiLinkMark", style: t.processingInstruction }, ], parseInline: [ { name: "WikiLink", parse(cx, next, pos) { let match: RegExpMatchArray | null; if ( next != 91 /* '[' */ && next != 33 /* '!' */ || !(match = pWikiLinkRegex.exec(cx.slice(pos, cx.end))) ) { return -1; } const [fullMatch, firstMark, page, alias, _lastMark] = match; const endPos = pos + fullMatch.length; let aliasElts: any[] = []; if (alias) { const pipeStartPos = pos + firstMark.length + page.length; aliasElts = [ cx.elt("WikiLinkMark", pipeStartPos, pipeStartPos + 1), cx.elt( "WikiLinkAlias", pipeStartPos + 1, pipeStartPos + 1 + alias.length, ), ]; } let allElts = cx.elt("WikiLink", pos, endPos, [ cx.elt("WikiLinkMark", pos, pos + firstMark.length), cx.elt( "WikiLinkPage", pos + firstMark.length, pos + firstMark.length + page.length, ), ...aliasElts, cx.elt("WikiLinkMark", endPos - 2, endPos), ]); // If inline image if (next == 33) { allElts = cx.elt("Image", pos, endPos, [allElts]); } return cx.addElement(allElts); }, after: "Emphasis", }, ], }; const CommandLink: MarkdownConfig = { defineNodes: [ { name: "CommandLink", style: { "CommandLink/...": ct.CommandLinkTag } }, { name: "CommandLinkName", style: ct.CommandLinkNameTag }, { name: "CommandLinkAlias", style: ct.CommandLinkNameTag }, { name: "CommandLinkArgs", style: ct.CommandLinkArgsTag }, { name: "CommandLinkMark", style: t.processingInstruction }, ], parseInline: [ { name: "CommandLink", parse(cx, next, pos) { let match: RegExpMatchArray | null; if ( next != 123 /* '{' */ || !(match = commandLinkRegex.exec(cx.slice(pos, cx.end))) ) { return -1; } const [fullMatch, command, pipePart, label, argsPart, args] = match; const endPos = pos + fullMatch.length; let aliasElts: any[] = []; if (pipePart) { const pipeStartPos = pos + 2 + command.length; aliasElts = [ cx.elt("CommandLinkMark", pipeStartPos, pipeStartPos + 1), cx.elt( "CommandLinkAlias", pipeStartPos + 1, pipeStartPos + 1 + label.length, ), ]; } let argsElts: any[] = []; if (argsPart) { const argsStartPos = pos + 2 + command.length + (pipePart?.length ?? 0); argsElts = [ cx.elt("CommandLinkMark", argsStartPos, argsStartPos + 2), cx.elt( "CommandLinkArgs", argsStartPos + 2, argsStartPos + 2 + args.length, ), ]; } return cx.addElement( cx.elt("CommandLink", pos, endPos, [ cx.elt("CommandLinkMark", pos, pos + 2), cx.elt("CommandLinkName", pos + 2, pos + 2 + command.length), ...aliasElts, ...argsElts, cx.elt("CommandLinkMark", endPos - 2, endPos), ]), ); }, after: "Emphasis", }, ], }; const TemplateDirective: MarkdownConfig = { defineNodes: [ { name: "TemplateDirective" }, { name: "TemplateExpressionDirective" }, { name: "TemplateIfStartDirective", style: ct.DirectiveTag }, { name: "TemplateEachStartDirective", style: ct.DirectiveTag }, { name: "TemplateEachVarStartDirective", style: ct.DirectiveTag }, { name: "TemplateLetStartDirective", style: ct.DirectiveTag }, { name: "TemplateIfEndDirective", style: ct.DirectiveTag }, { name: "TemplateEachEndDirective", style: ct.DirectiveTag }, { name: "TemplateLetEndDirective", style: ct.DirectiveTag }, { name: "TemplateVar", style: t.variableName }, { name: "TemplateDirectiveMark", style: ct.DirectiveMarkTag }, ], parseInline: [ { name: "TemplateDirective", parse(cx, next, pos) { const textFromPos = cx.slice(pos, cx.end); if ( next != 123 /* '{' */ || cx.slice(pos, pos + 2) !== "{{" ) { return -1; } let bracketNestingDepth = 0; let valueLength = 0; // We need to ensure balanced { and } pairs loopLabel: for (; valueLength < textFromPos.length; valueLength++) { switch (textFromPos[valueLength]) { case "{": bracketNestingDepth++; break; case "}": bracketNestingDepth--; if (bracketNestingDepth === 0) { // Done! break loopLabel; } break; } } if (bracketNestingDepth !== 0) { return -1; } const bodyText = textFromPos.slice(2, valueLength - 1); // console.log("Body text", bodyText); const endPos = pos + valueLength + 1; let bodyEl: any; // Is this an let block directive? const openLetBlockMatch = /^(\s*#let\s*)(@\w+)(\s*=\s*)(.+)$/s.exec( bodyText, ); if (openLetBlockMatch) { const [_, directiveStart, varName, eq, expr] = openLetBlockMatch; const parsedExpression = highlightingExpressionParser.parse( expr, ); bodyEl = cx.elt( "TemplateLetStartDirective", pos + 2, endPos - 2, [ cx.elt( "TemplateVar", pos + 2 + directiveStart.length, pos + 2 + directiveStart.length + varName.length, ), cx.elt( parsedExpression, pos + 2 + directiveStart.length + varName.length + eq.length, ), ], ); } if (!bodyEl) { // Is this an #each @p = block directive? const openEachVariableBlockMatch = /^(\s*#each\s*)(@\w+)(\s+in\s+)(.+)$/s.exec( bodyText, ); if (openEachVariableBlockMatch) { const [_, directiveStart, varName, eq, expr] = openEachVariableBlockMatch; const parsedExpression = highlightingExpressionParser.parse( expr, ); bodyEl = cx.elt( "TemplateEachVarStartDirective", pos + 2, endPos - 2, [ cx.elt( "TemplateVar", pos + 2 + directiveStart.length, pos + 2 + directiveStart.length + varName.length, ), cx.elt( parsedExpression, pos + 2 + directiveStart.length + varName.length + eq.length, ), ], ); } } if (!bodyEl) { // Is this an open block directive? const openBlockMatch = /^(\s*#(if|each)\s*)(.+)$/s.exec(bodyText); if (openBlockMatch) { const [_, directiveStart, directiveType, directiveBody] = openBlockMatch; const parsedExpression = highlightingExpressionParser.parse( directiveBody, ); bodyEl = cx.elt( directiveType === "if" ? "TemplateIfStartDirective" : "TemplateEachStartDirective", pos + 2, endPos - 2, [cx.elt(parsedExpression, pos + 2 + directiveStart.length)], ); } } if (!bodyEl) { // Is this a directive close? const closeBlockMatch = /^\s*\/(if|each|let)/.exec(bodyText); if (closeBlockMatch) { const [_, directiveType] = closeBlockMatch; const upCaseDirectiveType = directiveType[0].toUpperCase() + directiveType.slice(1); bodyEl = cx.elt( `Template${upCaseDirectiveType}EndDirective`, pos + 2, endPos - 2, ); } } if (!bodyEl) { // Let's parse as an expression const parsedExpression = highlightingExpressionParser.parse(bodyText); bodyEl = cx.elt( "TemplateExpressionDirective", pos + 2, endPos - 2, [cx.elt(parsedExpression, pos + 2)], ); } return cx.addElement( cx.elt("TemplateDirective", pos, endPos, [ cx.elt("TemplateDirectiveMark", pos, pos + 2), bodyEl!, cx.elt("TemplateDirectiveMark", endPos - 2, endPos), ]), ); }, after: "Emphasis", }, ], }; const LuaDirectives: MarkdownConfig = { defineNodes: [ { name: "LuaDirective" }, { name: "LuaExpressionDirective" }, { name: "LuaDirectiveMark", style: ct.DirectiveMarkTag }, ], parseInline: [ { name: "LuaDirective", parse(cx, next, pos) { const textFromPos = cx.slice(pos, cx.end); if ( next !== 36 /* '$' */ || cx.slice(pos, pos + 2) !== "${" ) { return -1; } let bracketNestingDepth = 0; let valueLength = 0; // We need to ensure balanced { and } pairs loopLabel: for (; valueLength < textFromPos.length; valueLength++) { switch (textFromPos[valueLength]) { case "{": bracketNestingDepth++; break; case "}": bracketNestingDepth--; if (bracketNestingDepth === 0) { // Done! break loopLabel; } break; } } if (bracketNestingDepth !== 0) { return -1; } const bodyText = textFromPos.slice(2, valueLength); const endPos = pos + valueLength + 1; // Let's parse as an expression const parsedExpression = luaLanguage.parser.parse(`_(${bodyText})`); const node = parsedExpression.resolveInner(2, 0).firstChild?.nextSibling ?.nextSibling; if (!node) { return -1; } const bodyEl = cx.elt( "LuaExpressionDirective", pos + 2, endPos - 1, [cx.elt(node.toTree()!, pos + 2)], ); return cx.addElement( cx.elt("LuaDirective", pos, endPos, [ cx.elt("LuaDirectiveMark", pos, pos + 2), bodyEl, cx.elt("LuaDirectiveMark", endPos - 1, endPos), ]), ); }, after: "Emphasis", }, ], }; const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" }; export const Highlight: MarkdownConfig = { defineNodes: [ { name: "Highlight", style: { "Highlight/...": ct.Highlight }, }, { name: "HighlightMark", style: t.processingInstruction, }, ], parseInline: [ { name: "Highlight", parse(cx, next, pos) { if (next != 61 /* '=' */ || cx.char(pos + 1) != 61) return -1; return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true); }, after: "Emphasis", }, ], }; import { parser as queryParser } from "./parse-query.js"; const expressionStyleTags = styleTags({ Identifier: t.variableName, TagIdentifier: t.variableName, GlobalIdentifier: t.variableName, String: t.string, Number: t.number, PageRef: ct.WikiLinkTag, BinExpression: t.operator, TernaryExpression: t.operator, Regex: t.regexp, "where limit select render desc asc and or null as in true false not each all Order/...": t.keyword, }); export const highlightingQueryParser = queryParser.configure({ props: [ expressionStyleTags, ], }); import { parser as expressionParser } from "./parse-expression.js"; export const highlightingExpressionParser = expressionParser.configure({ props: [expressionStyleTags], }); export const attributeStartRegex = /^\[([\w\$]+)(::?\s*)/; export const Attribute: MarkdownConfig = { defineNodes: [ { name: "Attribute", style: { "Attribute/...": ct.AttributeTag } }, { name: "AttributeName", style: ct.AttributeNameTag }, { name: "AttributeValue", style: ct.AttributeValueTag }, { name: "AttributeMark", style: t.processingInstruction }, { name: "AttributeColon", style: t.processingInstruction }, ], parseInline: [ { name: "Attribute", parse(cx, next, pos) { let match: RegExpMatchArray | null; const textFromPos = cx.slice(pos, cx.end); if ( next != 91 /* '[' */ || // and match the whole thing !(match = attributeStartRegex.exec(textFromPos)) ) { return -1; } const [fullMatch, attributeName, attributeColon] = match; let bracketNestingDepth = 1; let valueLength = fullMatch.length; loopLabel: for (; valueLength < textFromPos.length; valueLength++) { switch (textFromPos[valueLength]) { case "[": bracketNestingDepth++; break; case "]": bracketNestingDepth--; if (bracketNestingDepth === 0) { // Done! break loopLabel; } break; } } if (bracketNestingDepth !== 0) { console.log("Failed to parse attribute", fullMatch, textFromPos); return -1; } if (textFromPos[valueLength + 1] === "(") { // This turns out to be a link, back out! return -1; } return cx.addElement( cx.elt("Attribute", pos, pos + valueLength + 1, [ cx.elt("AttributeMark", pos, pos + 1), // [ cx.elt("AttributeName", pos + 1, pos + 1 + attributeName.length), cx.elt( "AttributeColon", pos + 1 + attributeName.length, pos + 1 + attributeName.length + attributeColon.length, ), cx.elt( "AttributeValue", pos + 1 + attributeName.length + attributeColon.length, pos + valueLength, ), cx.elt("AttributeMark", pos + valueLength, pos + valueLength + 1), // [ ]), ); }, after: "Emphasis", }, ], }; class CommentParser implements LeafBlockParser { nextLine() { return false; } finish(cx: BlockContext, leaf: LeafBlock) { cx.addLeafElement( leaf, cx.elt("Comment", leaf.start, leaf.start + leaf.content.length, [ // cx.elt("CommentMarker", leaf.start, leaf.start + 3), ...cx.parser.parseInline(leaf.content.slice(3), leaf.start + 3), ]), ); return true; } } export const Comment: MarkdownConfig = { defineNodes: [{ name: "Comment", block: true }], parseBlock: [ { name: "Comment", leaf(_cx, leaf) { return /^%%\s/.test(leaf.content) ? new CommentParser() : null; }, after: "SetextHeading", }, ], }; type RegexParserExtension = { // unicode char code for efficiency .charCodeAt(0) firstCharCode: number; regex: RegExp; nodeType: string; tag: Tag; className?: string; }; function regexParser({ regex, firstCharCode, nodeType, }: RegexParserExtension): MarkdownConfig { return { defineNodes: [nodeType], parseInline: [ { name: nodeType, parse(cx, next, pos) { if (firstCharCode !== next) { return -1; } const match = regex.exec(cx.slice(pos, cx.end)); if (!match) { return -1; } return cx.addElement(cx.elt(nodeType, pos, pos + match[0].length)); }, }, ], }; } const NakedURL = regexParser( { firstCharCode: 104, // h regex: /(^https?:\/\/([-a-zA-Z0-9@:%_\+~#=]|(?:[.](?!(\s|$)))){1,256})(([-a-zA-Z0-9(@:%_\+~#?&=\/]|(?:[.,:;)](?!(\s|$))))*)/, nodeType: "NakedURL", className: "sb-naked-url", tag: NakedURLTag, }, ); const Hashtag = regexParser({ firstCharCode: 35, // # regex: new RegExp(`^${tagRegex.source}`), nodeType: "Hashtag", className: "sb-hashtag-text", tag: ct.HashtagTag, }); const TaskDeadline = regexParser({ firstCharCode: 55357, // 📅 regex: /^📅\s*\d{4}\-\d{2}\-\d{2}/, className: "sb-task-deadline", nodeType: "DeadlineDate", tag: ct.TaskDeadlineTag, }); const NamedAnchor = regexParser({ firstCharCode: 36, // $ regex: /^\$[a-zA-Z\.\-\/]+[\w\.\-\/]*/, className: "sb-named-anchor", nodeType: "NamedAnchor", tag: ct.NamedAnchorTag, }); import { Table } from "./table_parser.ts"; import { foldNodeProp } from "@codemirror/language"; import { pWikiLinkRegex, tagRegex } from "$common/markdown_parser/constants.ts"; import { parse } from "$common/markdown_parser/parse_tree.ts"; import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree"; import { luaLanguage } from "$common/space_lua/parse.ts"; // FrontMatter parser const yamlLang = StreamLanguage.define(yamlLanguage); export const FrontMatter: MarkdownConfig = { defineNodes: [ { name: "FrontMatter", block: true }, { name: "FrontMatterMarker" }, { name: "FrontMatterCode" }, ], parseBlock: [{ name: "FrontMatter", parse: (cx, line: Line) => { if (cx.parsedPos !== 0) { return false; } if (line.text !== "---") { return false; } const frontStart = cx.parsedPos; const elts = [ cx.elt( "FrontMatterMarker", cx.parsedPos, cx.parsedPos + line.text.length + 1, ), ]; cx.nextLine(); const startPos = cx.parsedPos; let endPos = startPos; let text = ""; let lastPos = cx.parsedPos; do { text += line.text + "\n"; endPos += line.text.length + 1; cx.nextLine(); if (cx.parsedPos === lastPos) { // End of file, no progress made, there may be a better way to do this but :shrug: return false; } lastPos = cx.parsedPos; } while (line.text !== "---"); const yamlTree = yamlLang.parser.parse(text); elts.push( cx.elt("FrontMatterCode", startPos, endPos, [ cx.elt(yamlTree, startPos), ]), ); endPos = cx.parsedPos + line.text.length; elts.push(cx.elt( "FrontMatterMarker", cx.parsedPos, cx.parsedPos + line.text.length, )); cx.nextLine(); cx.addElement(cx.elt("FrontMatter", frontStart, endPos, elts)); return true; }, before: "HorizontalRule", }], }; export const extendedMarkdownLanguage = markdown({ extensions: [ WikiLink, CommandLink, Attribute, FrontMatter, TaskList, Comment, Highlight, TemplateDirective, LuaDirectives, Strikethrough, Table, NakedURL, Hashtag, TaskDeadline, NamedAnchor, Superscript, Subscript, { props: [ foldNodeProp.add({ // Don't fold at the list level BulletList: () => null, OrderedList: () => null, // Fold list items ListItem: (tree, state) => ({ from: state.doc.lineAt(tree.from).to, to: tree.to, }), // Fold frontmatter FrontMatter: (tree) => ({ from: tree.from, to: tree.to, }), }), styleTags({ Task: ct.TaskTag, TaskMark: ct.TaskMarkTag, Comment: ct.CommentTag, "Subscript": ct.SubscriptTag, "Superscript": ct.SuperscriptTag, "TableDelimiter StrikethroughMark": t.processingInstruction, "TableHeader/...": t.heading, TableCell: t.content, CodeInfo: ct.CodeInfoTag, HorizontalRule: ct.HorizontalRuleTag, Hashtag: ct.HashtagTag, NakedURL: ct.NakedURLTag, DeadlineDate: ct.TaskDeadlineTag, NamedAnchor: ct.NamedAnchorTag, }), ], }, ], }).language; export function parseMarkdown(text: string): ParseTree { return parse(extendedMarkdownLanguage, text); }