The table style is determined by the header row. After normalizing the header (filling empty cells between consecutive delimiters), extract two properties: the column count and whether the header starts with a delimiter. For each data row, normalization proceeds in three steps. First, fill in empty cells between consecutive delimiters (this handles empty cells in the middle of a row). Second, if the header has no leading delimiter but the row does, insert an empty cell after the row's leading delimiter (this corrects the one-position shift caused by the row having a leading pipe that the header lacks. Third, if the row has fewer cells than the header column count, pad with empty cells at the end (this handles rows that are shorter than the header because trailing pipes or cells were omitted). The same normalization is applied both in the renderer and the indexer. Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
571 lines
14 KiB
TypeScript
571 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 { inlineContentFromURL } 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 result = inlineContentFromURL(client.space, transclusion);
|
|
if (result instanceof Promise) {
|
|
// console.warn(
|
|
// `Unsupported inline in markdown render context: ${transclusion.url}`,
|
|
// );
|
|
// Can't support promises in this context, returning original text
|
|
return text;
|
|
}
|
|
// Running in non-browser context
|
|
if (!globalThis.HTMLElement || !(result instanceof HTMLElement)) {
|
|
return text;
|
|
}
|
|
|
|
return {
|
|
name: result.tagName,
|
|
attrs: Array.from(result.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);
|
|
}
|