import { addParentPointers, collectNodesOfType, findNodeOfType, normalizeTableRow, type ParseTree, removeParentPointers, renderToText, traverseTree, } from "@silverbulletmd/silverbullet/lib/tree"; import { encodePageURI, encodeRef, parseToRef, } from "@silverbulletmd/silverbullet/lib/ref"; import { Fragment, renderHtml, type Tag } from "./html_render.ts"; import * as TagConstants from "../../plugs/index/constants.ts"; import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags"; import { justifiedTableRender } from "./justified_tables.ts"; import type { PageMeta } from "@silverbulletmd/silverbullet/type/index"; import { createMediaElement } from "./inline.ts"; import { parseTransclusion } from "@silverbulletmd/silverbullet/lib/transclusion"; export type MarkdownRenderOptions = { failOnUnknown?: true; smartHardBreak?: true; annotationPositions?: true; shortWikiLinks?: boolean; // When defined, use to inline images as data: urls translateUrls?: (url: string, type: "link" | "image") => string; expand?: true; }; function cleanTags(values: (Tag | null)[], cleanWhitespace = false): Tag[] { const result: Tag[] = []; for (const value of values) { if (cleanWhitespace && typeof value === "string" && value.match(/^\s+$/)) { continue; } if (value) { result.push(value); } } return result; } /** * Cleans up a markdown tree. Side effect: adds parent pointers */ function preprocess(t: ParseTree) { addParentPointers(t); traverseTree(t, (node) => { if (!node.type) { // Remove redundant newlines in table if (node.text?.startsWith("\n")) { const prevNodeIdx = node.parent!.children!.indexOf(node) - 1; const prevNodeType = node.parent!.children![prevNodeIdx]?.type; if ( prevNodeType?.includes("Heading") || prevNodeType?.includes("Table") ) { node.text = node.text.slice(1); } } } return false; }); } function posPreservingRender( t: ParseTree, options: MarkdownRenderOptions = {}, ): Tag | null { const tag = render(t, options); if (!options.annotationPositions) { return tag; } if (!tag) { return null; } if (typeof tag === "string") { return tag; } if (t.from) { if (!tag.attrs) { tag.attrs = {}; } tag.attrs["data-pos"] = `${t.from}`; } return tag; } function render(t: ParseTree, options: MarkdownRenderOptions = {}): Tag | null { if (t.type?.endsWith("Mark") || t.type?.endsWith("Delimiter")) { return null; } switch (t.type) { case "Document": return { name: Fragment, body: cleanTags(mapRender(t.children!)), }; case "FrontMatter": return null; case "CommentBlock": // Remove, for now return null; case "ATXHeading1": return { name: "h1", body: cleanTags(mapRender(t.children!)), }; case "ATXHeading2": return { name: "h2", body: cleanTags(mapRender(t.children!)), }; case "ATXHeading3": return { name: "h3", body: cleanTags(mapRender(t.children!)), }; case "ATXHeading4": return { name: "h4", body: cleanTags(mapRender(t.children!)), }; case "ATXHeading5": return { name: "h5", body: cleanTags(mapRender(t.children!)), }; case "ATXHeading6": return { name: "h6", body: cleanTags(mapRender(t.children!)), }; case "Paragraph": return { name: "span", attrs: { class: "p", }, body: cleanTags(mapRender(t.children!)), }; // Code blocks case "FencedCode": case "CodeBlock": { // Clear out top-level indent blocks const lang = findNodeOfType(t, "CodeInfo"); t.children = t.children!.filter((c) => c.type); return { name: "pre", attrs: { "data-lang": lang ? lang.children![0].text : undefined, }, body: cleanTags(mapRender(t.children!)), }; } case "CodeInfo": return null; case "CodeText": return t.children![0].text!; case "Blockquote": return { name: "blockquote", body: cleanTags(mapRender(t.children!)), }; case "HardBreak": return { name: "br", body: "", }; // Basic styling case "Emphasis": return { name: "em", body: cleanTags(mapRender(t.children!)), }; case "Highlight": return { name: "span", attrs: { class: "highlight", }, body: cleanTags(mapRender(t.children!)), }; case "Strikethrough": return { name: "del", body: cleanTags(mapRender(t.children!)), }; case "InlineCode": return { name: "code", attrs: { class: "sb-code", }, body: cleanTags(mapRender(t.children!)), }; case "BulletList": return { name: "ul", body: cleanTags(mapRender(t.children!), true), }; case "OrderedList": return { name: "ol", body: cleanTags(mapRender(t.children!), true), }; case "ListItem": return { name: "li", body: cleanTags(mapRender(t.children!), true), }; case "StrongEmphasis": return { name: "strong", body: cleanTags(mapRender(t.children!)), }; case "HorizontalRule": return { name: "hr", body: "", }; case "Link": { const linkTextChildren = t.children!.slice(1, -4); const urlNode = findNodeOfType(t, "URL"); if (!urlNode) { return renderToText(t); } return { name: "a", attrs: { href: urlNode.children![0].text!, target: "_blank", }, body: cleanTags(mapRender(linkTextChildren)), }; } case "Autolink": { const urlNode = findNodeOfType(t, "URL"); if (!urlNode) { return renderToText(t); } const url = urlNode.children![0].text!; return { name: "a", attrs: { href: url, target: "_blank", }, body: url, }; } case "Image": { const text = renderToText(t); const transclusion = parseTransclusion(text); if (!transclusion) { return text; } try { const element = createMediaElement(transclusion); if (!element) { return text; } return { name: element.tagName, attrs: Array.from(element.attributes).reduce( (obj, attr) => { obj[attr.name] = attr.value; return obj; }, {} as Record, ), body: "", }; } catch (e: any) { console.error("Error handling image or transclusion", e.message); return { name: "span", body: "Error loading image", }; } } // Custom stuff case "WikiLink": { const link = findNodeOfType(t, "WikiLinkPage")!.children![0].text!; let linkText = options.shortWikiLinks === true ? link.split("/").pop()! : link; const aliasNode = findNodeOfType(t, "WikiLinkAlias"); if (aliasNode) { linkText = aliasNode.children![0].text!; } // For invalid refs the link just won't link let href: string = `#`; const ref = parseToRef(link); if (ref) { href = `/${encodePageURI(encodeRef(ref))}`; } return { name: "a", attrs: { href, class: "wiki-link", "data-ref": link, }, body: linkText, }; } case "NakedURL": { const url = t.children![0].text!; return { name: "a", attrs: { href: url, target: "_blank", }, body: url, }; } case "Hashtag": { const tagText: string = t.children![0].text!; const link = TagConstants.tagPrefix + extractHashtag(tagText); return { name: "a", attrs: { class: "hashtag sb-hashtag", "data-tag-name": extractHashtag(tagText), href: `/${encodePageURI(link)}`, "data-ref": link, }, body: tagText, }; } case "Task": { let externalTaskRef = ""; collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => { const ref = parseToRef(wikilink.children![0].text!); if ( !externalTaskRef && ref && (ref.details?.type === "position" || ref.details?.type === "linecolumn") ) { externalTaskRef = wikilink.children![0].text!; } }); return { name: "span", attrs: externalTaskRef ? { "data-external-task-ref": externalTaskRef, } : {}, body: cleanTags(mapRender(t.children!)), }; } case "TaskState": { // child[0] = marker, child[1] = state, child[2] = marker const stateText = t.children![1].text!; if ([" ", "x", "X"].includes(stateText)) { return { name: "input", attrs: { type: "checkbox", checked: stateText !== " " ? "checked" : undefined, "data-state": stateText, }, body: "", }; } return { name: Fragment, body: [ { name: "span", attrs: { class: "sb-task-mark" }, body: "[" }, { name: "span", attrs: { class: "sb-task-state", "data-state": stateText }, body: stateText, }, { name: "span", attrs: { class: "sb-task-mark" }, body: "]" }, ], }; } // Tables case "Table": return { name: "table", body: justifiedTableRender(cleanTags(mapRender(t.children!)), t), }; case "TableHeader": return { name: "thead", body: [ { name: "tr", body: cleanTags(mapRender(t.children!), true), }, ], }; case "TableCell": return { name: "td", body: cleanTags(mapRender(t.children!)), }; case "TableRow": { const table = t.parent; const header = table?.children?.find((c) => c.type === "TableHeader"); let columnCount = 0; let headerHasLeadingDelim: boolean | undefined; if (header) { normalizeTableRow(header); headerHasLeadingDelim = header.children?.[0]?.type === "TableDelimiter"; for (const c of header.children ?? []) { if (c.type === "TableCell") columnCount++; } } normalizeTableRow(t, columnCount, headerHasLeadingDelim); return { name: "tr", body: cleanTags(mapRender(t.children!), true), }; } case "Attribute": { const nameNode = findNodeOfType(t, "AttributeName"); const valueNode = findNodeOfType(t, "AttributeValue"); const colonNode = findNodeOfType(t, "AttributeColon"); const attrName = nameNode?.children?.[0].text ?? ""; const attrValue = valueNode?.children?.[0].text ?? ""; const attrColon = colonNode?.children?.[0].text ?? ": "; return { name: "span", attrs: { class: "sb-attribute", ...(attrName ? { [`data-${attrName}`]: attrValue } : {}), }, body: [ { name: "span", attrs: { class: "sb-frontmatter sb-meta" }, body: "[", }, { name: "span", attrs: { class: "sb-frontmatter sb-attribute-name" }, body: attrName, }, { name: "span", attrs: { class: "sb-frontmatter sb-meta" }, body: attrColon, }, { name: "span", attrs: { class: "sb-frontmatter sb-attribute-value" }, body: attrValue, }, { name: "span", attrs: { class: "sb-frontmatter sb-meta" }, body: "]", }, ], }; } case "Escape": { return { name: "span", attrs: { class: "escape", }, body: t.children![0].text!.slice(1), }; } case "Entity": return t.children![0].text!; case "Superscript": return { name: "sup", body: cleanTags(mapRender(t.children!)), }; case "Subscript": return { name: "sub", body: cleanTags(mapRender(t.children!)), }; case "FootnoteRef": { const label = findNodeOfType(t, "FootnoteRefLabel")!.children![0].text!; return { name: "sup", attrs: { class: "sb-rendered-footnote-ref", }, body: label, }; } case "FootnoteDefinition": { const label = findNodeOfType(t, "FootnoteDefLabel")!.children![0].text!; const body = findNodeOfType(t, "FootnoteDefBody"); return { name: "div", attrs: { class: "sb-rendered-footnote-def", id: `fn-${label}`, }, body: [ { name: "span", attrs: { class: "sb-rendered-footnote-def-label" }, body: `${label}. `, }, ...(body ? cleanTags(mapRender(body.children!)) : []), ], }; } case "FootnoteDefBody": return { name: Fragment, body: cleanTags(mapRender(t.children!)), }; case "InlineFootnote": { const content = findNodeOfType(t, "InlineFootnoteContent")!.children![0] .text!; return { name: "sup", attrs: { class: "sb-rendered-footnote-ref", }, body: [ { name: "span", attrs: { class: "sb-rendered-inline-footnote-content", title: content, }, body: "*", }, ], }; } case "LuaDirective": return { name: "span", attrs: { class: "sb-lua-directive", }, body: renderToText(t), }; // Text case undefined: return t.text!; default: if (options.failOnUnknown) { removeParentPointers(t); console.error("Not handling", JSON.stringify(t, null, 2)); throw new Error(`Unknown markdown node type ${t.type}`); } else { // Falling back to rendering verbatim removeParentPointers(t); console.warn("Not handling", JSON.stringify(t, null, 2)); return renderToText(t); } } function mapRender(children: ParseTree[]) { return children.map((t) => posPreservingRender(t, options)); } } function traverseTag(t: Tag, fn: (t: Tag) => void) { fn(t); if (typeof t === "string") { return; } if (t.body) { for (const child of t.body) { traverseTag(child, fn); } } } export function renderMarkdownToHtml( t: ParseTree, options: MarkdownRenderOptions = {}, allPages: PageMeta[] = [], ) { preprocess(t); const htmlTree = posPreservingRender(t, options); if (htmlTree) { traverseTag(htmlTree, (t) => { if (typeof t === "string") { return; } if (t.name === "img" && options.translateUrls) { t.attrs!.src = options.translateUrls!(t.attrs!.src!, "image"); } if (t.name === "a" && t.attrs!.href) { if (options.translateUrls) { t.attrs!.href = options.translateUrls!(t.attrs!.href, "link"); } if (t.attrs!["data-ref"]?.length) { const ref = parseToRef(t.attrs!["data-ref"]!); if (!ref) { return; } const pageMeta = allPages.find( (p) => ref.path === parseToRef(p.ref)?.path, ); if ( pageMeta && !( ref.details?.type === "position" || ref.details?.type === "linecolumn" ) ) { t.body = [(pageMeta.pageDecoration?.prefix ?? "") + t.body]; if (pageMeta.pageDecoration?.cssClasses) { t.attrs!.class += " sb-decorated-object " + pageMeta.pageDecoration.cssClasses .join(" ") .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); } } } if (t.body.length === 0) { t.body = [t.attrs!.href]; } } }); } return renderHtml(htmlTree); }