Files
plainleaf/client/markdown_renderer/markdown_render.ts
T
Zef Hemel 64898923e1 Restore separate copy path for Lua widgets with cleaner markdown
Specifically for tables using (less rich) markdown syntax when possible
2026-04-09 14:38:10 +02:00

863 lines
22 KiB
TypeScript

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, RawHtml, renderHtml, type Tag } from "./html_render.ts";
import { CustomSyntaxRenderedHtmlType } from "./inline.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";
import { parseHtmlTag } from "../codemirror/html_element.ts";
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;
resolveTagHref?: (tagName: string) => 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": {
const hasHtml = t.children!.some((c) => c.type === "HTMLTag");
return {
name: "span",
attrs: {
class: "p",
},
body: cleanTags(
hasHtml
? groupInlineHtml(t.children!, options, posPreservingRender)
: 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<string, string>,
),
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 tagName = extractHashtag(tagText);
const link = options.resolveTagHref?.(tagName) ??
TagConstants.tagPrefix + tagName;
return {
name: "a",
attrs: {
class: "hashtag sb-hashtag",
"data-tag-name": tagName,
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-task-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": {
const cellHasHtml = t.children!.some((c) => c.type === "HTMLTag");
return {
name: "td",
body: cleanTags(
cellHasHtml
? groupInlineHtml(t.children!, options, posPreservingRender)
: 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 CustomSyntaxRenderedHtmlType:
return {
name: RawHtml,
body: renderToText(t),
};
case "LuaDirective":
return {
name: "span",
attrs: {
class: "sb-lua-directive",
},
body: renderToText(t),
};
// Structured HTMLBlock from the custom parser: flat sequence of
// HTMLOpenTag / HTMLCloseTag / HTMLSelfClosingTag + inline content.
// We rebuild nesting via a stack, similar to groupInlineHtml.
case "HTMLBlock":
return renderHtmlBlock(t.children ?? [], options);
// Tag markers inside HTMLBlock — should not appear outside of it,
// but handle gracefully.
case "HTMLOpenTag":
case "HTMLCloseTag":
case "HTMLSelfClosingTag":
return renderToText(t);
// Unmatched HTMLTag nodes render as literal text (matched ones are
// handled at the Paragraph level by groupInlineHtml)
case "HTMLTag":
return 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));
}
}
/**
* Render a structured HTMLBlock's children into nested Tag elements.
*
* Walks the flat sequence of HTMLOpenTag / HTMLCloseTag /
* HTMLSelfClosingTag and inline content nodes, using a stack to
* rebuild proper nesting.
*/
function renderHtmlBlock(
children: ParseTree[],
options: MarkdownRenderOptions,
): Tag {
// Stack entry: tag info + accumulated child tags
const stack: {
name: string;
attrs: Record<string, string> | undefined;
body: Tag[];
}[] = [];
// Root container collects top-level elements
const root: Tag[] = [];
function currentBody(): Tag[] {
return stack.length > 0 ? stack[stack.length - 1].body : root;
}
for (const child of children) {
switch (child.type) {
case "HTMLOpenTag": {
const text = renderToText(child);
const parsed = parseHtmlTag(text);
if (parsed) {
stack.push({
name: parsed.tagName,
attrs: Object.keys(parsed.parsedAttrs).length > 0
? parsed.parsedAttrs
: undefined,
body: [],
});
} else {
// Unparseable open tag — render as text
currentBody().push(text);
}
break;
}
case "HTMLCloseTag": {
if (stack.length > 0) {
const top = stack.pop()!;
currentBody().push({
name: top.name,
attrs: top.attrs,
body: top.body,
});
}
// If stack is empty, silently drop unmatched close tag
break;
}
case "HTMLSelfClosingTag": {
const text = renderToText(child);
// Emit as raw HTML to preserve the self-closing form (e.g. <br />)
currentBody().push({
name: RawHtml,
body: text,
});
break;
}
default: {
// Inline content — render via the normal markdown renderer
const rendered = posPreservingRender(child, options);
if (rendered !== null) {
currentBody().push(rendered);
}
break;
}
}
}
// Flush any unclosed tags (shouldn't happen with well-formed HTML)
while (stack.length > 0) {
const top = stack.pop()!;
currentBody().push({
name: top.name,
attrs: top.attrs,
body: top.body,
});
}
if (root.length === 1) {
return root[0];
}
return {
name: Fragment,
body: root,
};
}
type RenderFn = (
t: ParseTree,
options: MarkdownRenderOptions,
) => Tag | null;
function groupInlineHtml(
children: ParseTree[],
options: MarkdownRenderOptions,
renderFn: RenderFn,
): (Tag | null)[] {
const result: (Tag | null)[] = [];
let i = 0;
while (i < children.length) {
const child = children[i];
if (child.type === "HTMLTag") {
const text = renderToText(child);
if (!text.startsWith("</")) {
const parsed = parseHtmlTag(text);
if (parsed?.isSelfClosing) {
// Void elements (<br/>, <hr/>, <img/>...) have no closer — emit
// as raw HTML to preserve the self-closing form. Must run before
// the pair-search below, which would otherwise fail to find a
// closer and fall through to text-escaping.
result.push({ name: RawHtml, body: text });
i++;
continue;
}
if (parsed && !parsed.isClosing) {
// Find matching closing tag
let depth = 1;
let j = i + 1;
for (; j < children.length; j++) {
if (children[j].type === "HTMLTag") {
const jText = renderToText(children[j]);
const jParsed = parseHtmlTag(jText);
if (!jParsed) continue;
if (
!jParsed.isClosing &&
jParsed.tagName.toLowerCase() === parsed.tagName.toLowerCase()
) {
depth++;
} else if (
jParsed.isClosing &&
jParsed.tagName.toLowerCase() === parsed.tagName.toLowerCase()
) {
depth--;
if (depth === 0) break;
}
}
}
if (j < children.length) {
const innerChildren = children.slice(i + 1, j);
const attrs = Object.keys(parsed.parsedAttrs).length > 0
? parsed.parsedAttrs
: undefined;
result.push({
name: parsed.tagName,
attrs,
body: cleanTags(
groupInlineHtml(innerChildren, options, renderFn),
),
});
i = j + 1;
continue;
}
}
}
// Unmatched tag — render as literal text
result.push(renderFn(child, options));
i++;
continue;
}
result.push(renderFn(child, options));
i++;
}
return result;
}
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);
}