Files
plainleaf/client/markdown_renderer/markdown_render.ts
T
Matouš Jan Fialka 81f5ec7f67 Fix table cell alignment for missing and misaligned cells
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>
2026-03-06 14:20:48 +01:00

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);
}