Also an opportunity to refactor the transclusion code, which had become very messy
560 lines
14 KiB
TypeScript
560 lines
14 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, 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;
|
|
preserveAttributes?: 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<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 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: "span",
|
|
attrs: {
|
|
class: "task-state",
|
|
},
|
|
body: stateText,
|
|
};
|
|
}
|
|
|
|
// 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":
|
|
if (options.preserveAttributes) {
|
|
return {
|
|
name: "span",
|
|
attrs: {
|
|
class: "attribute",
|
|
},
|
|
body: renderToText(t),
|
|
};
|
|
}
|
|
return null;
|
|
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 "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);
|
|
}
|