863 lines
22 KiB
TypeScript
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);
|
|
}
|