2022-11-01 14:01:28 +00:00
|
|
|
import {
|
|
|
|
findNodeOfType,
|
|
|
|
ParseTree,
|
|
|
|
renderToText,
|
|
|
|
traverseTree,
|
|
|
|
} from "$sb/lib/tree.ts";
|
|
|
|
import * as YAML from "yaml";
|
|
|
|
import { Fragment, renderHtml, Tag } from "./html_render.ts";
|
|
|
|
|
|
|
|
type MarkdownRenderOptions = {
|
|
|
|
failOnUnknown?: true;
|
|
|
|
smartHardBreak?: true;
|
|
|
|
annotationPositions?: true;
|
|
|
|
renderFrontMatter?: true;
|
2022-11-01 16:03:42 +00:00
|
|
|
attachmentUrlPrefix?: string;
|
2022-11-01 14:01:28 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
function cleanTags(values: (Tag | null)[]): Tag[] {
|
|
|
|
const result: Tag[] = [];
|
|
|
|
for (const value of values) {
|
|
|
|
if (value) {
|
|
|
|
result.push(value);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result;
|
|
|
|
}
|
|
|
|
|
|
|
|
function preprocess(t: ParseTree, options: MarkdownRenderOptions = {}) {
|
|
|
|
traverseTree(t, (node) => {
|
|
|
|
if (node.type === "Paragraph" && options.smartHardBreak) {
|
|
|
|
for (const child of node.children!) {
|
|
|
|
// If at the paragraph level there's a newline, let's turn it into a hard break
|
|
|
|
if (!child.type && child.text === "\n") {
|
|
|
|
child.type = "HardBreak";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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":
|
|
|
|
if (options.renderFrontMatter) {
|
|
|
|
const yamlCode = renderToText(t.children![1]);
|
|
|
|
const parsedYaml = YAML.parse(yamlCode) as Record<string, any>;
|
|
|
|
const rows: Tag[] = [];
|
|
|
|
for (const [k, v] of Object.entries(parsedYaml)) {
|
|
|
|
rows.push({
|
|
|
|
name: "tr",
|
|
|
|
body: [
|
|
|
|
{ name: "td", attrs: { class: "key" }, body: k },
|
|
|
|
{
|
|
|
|
name: "td",
|
|
|
|
attrs: { class: "value" },
|
|
|
|
body: YAML.stringify(v),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
name: "table",
|
|
|
|
attrs: {
|
|
|
|
class: "front-matter",
|
|
|
|
},
|
|
|
|
body: rows,
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
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 "Paragraph":
|
|
|
|
return {
|
|
|
|
name: "p",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
// Code blocks
|
|
|
|
case "FencedCode":
|
|
|
|
case "CodeBlock": {
|
2022-11-01 16:03:42 +00:00
|
|
|
// Clear out top-level indent blocks
|
|
|
|
t.children = t.children!.filter((c) => c.type);
|
2022-11-01 14:01:28 +00:00
|
|
|
return {
|
|
|
|
name: "pre",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
}
|
2022-11-01 16:03:42 +00:00
|
|
|
case "CodeInfo":
|
|
|
|
return null;
|
2022-11-01 14:01:28 +00:00
|
|
|
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 "InlineCode":
|
|
|
|
return {
|
|
|
|
name: "tt",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "BulletList":
|
|
|
|
return {
|
|
|
|
name: "ul",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "OrderedList":
|
|
|
|
return {
|
|
|
|
name: "ol",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "ListItem":
|
|
|
|
return {
|
|
|
|
name: "li",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "StrongEmphasis":
|
|
|
|
return {
|
|
|
|
name: "strong",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "HorizontalRule":
|
|
|
|
return {
|
|
|
|
name: "hr",
|
|
|
|
body: "",
|
|
|
|
};
|
|
|
|
case "Link": {
|
|
|
|
const linkText = t.children![1].text!;
|
2022-11-01 16:03:42 +00:00
|
|
|
const urlNode = findNodeOfType(t, "URL");
|
|
|
|
if (!urlNode) {
|
|
|
|
return renderToText(t);
|
|
|
|
}
|
|
|
|
let url = urlNode.children![0].text!;
|
|
|
|
if (url.indexOf("://") === -1) {
|
|
|
|
url = `${options.attachmentUrlPrefix || ""}${url}`;
|
|
|
|
}
|
2022-11-01 14:01:28 +00:00
|
|
|
return {
|
|
|
|
name: "a",
|
|
|
|
attrs: {
|
|
|
|
href: url,
|
|
|
|
},
|
|
|
|
body: linkText,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
case "Image": {
|
|
|
|
const altText = t.children![1].text!;
|
2022-11-01 16:03:42 +00:00
|
|
|
const urlNode = findNodeOfType(t, "URL");
|
|
|
|
if (!urlNode) {
|
|
|
|
return renderToText(t);
|
|
|
|
}
|
|
|
|
let url = urlNode!.children![0].text!;
|
2022-11-01 14:01:28 +00:00
|
|
|
if (url.indexOf("://") === -1) {
|
2022-11-01 16:03:42 +00:00
|
|
|
url = `${options.attachmentUrlPrefix || ""}${url}`;
|
2022-11-01 14:01:28 +00:00
|
|
|
}
|
|
|
|
return {
|
|
|
|
name: "img",
|
|
|
|
attrs: {
|
|
|
|
src: url,
|
|
|
|
alt: altText,
|
|
|
|
},
|
|
|
|
body: "",
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Custom stuff
|
|
|
|
case "WikiLink": {
|
|
|
|
// console.log("WikiLink", JSON.stringify(t, null, 2));
|
|
|
|
const ref = findNodeOfType(t, "WikiLinkPage")!.children![0].text!;
|
2022-11-27 10:20:43 +00:00
|
|
|
let linkText = ref;
|
|
|
|
const aliasNode = findNodeOfType(t, "WikiLinkAlias");
|
|
|
|
if (aliasNode) {
|
|
|
|
linkText = aliasNode.children![0].text!;
|
|
|
|
}
|
2022-11-01 14:01:28 +00:00
|
|
|
return {
|
|
|
|
name: "a",
|
|
|
|
attrs: {
|
2022-11-01 16:03:42 +00:00
|
|
|
href: `/${ref.replaceAll(" ", "_").replace("@", "#")}`,
|
2022-11-01 14:01:28 +00:00
|
|
|
},
|
2022-11-27 10:20:43 +00:00
|
|
|
body: linkText,
|
2022-11-01 14:01:28 +00:00
|
|
|
};
|
|
|
|
}
|
|
|
|
case "NakedURL": {
|
|
|
|
const url = t.children![0].text!;
|
|
|
|
return {
|
|
|
|
name: "a",
|
|
|
|
attrs: {
|
|
|
|
href: url,
|
|
|
|
},
|
|
|
|
body: url,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
case "Hashtag":
|
|
|
|
return {
|
|
|
|
name: "strong",
|
|
|
|
body: t.children![0].text!,
|
|
|
|
};
|
|
|
|
|
|
|
|
case "Task":
|
|
|
|
return {
|
|
|
|
name: "span",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "TaskMarker":
|
|
|
|
return {
|
|
|
|
name: "input",
|
|
|
|
attrs: {
|
|
|
|
type: "checkbox",
|
|
|
|
checked: t.children![0].text !== "[ ]" ? "checked" : undefined,
|
|
|
|
"data-onclick": JSON.stringify(["task", t.to]),
|
|
|
|
},
|
|
|
|
body: "",
|
|
|
|
};
|
|
|
|
case "NamedAnchor":
|
|
|
|
return {
|
|
|
|
name: "a",
|
|
|
|
attrs: {
|
|
|
|
name: t.children![0].text?.substring(1),
|
|
|
|
},
|
|
|
|
body: "",
|
|
|
|
};
|
|
|
|
case "CommandLink": {
|
2022-11-18 15:04:37 +00:00
|
|
|
// Child 0 is CommandLinkMark, child 1 is CommandLinkPage
|
|
|
|
const commandText = t.children![1].children![0].text!;
|
2022-11-01 14:01:28 +00:00
|
|
|
|
|
|
|
return {
|
|
|
|
name: "button",
|
|
|
|
attrs: {
|
|
|
|
"data-onclick": JSON.stringify(["command", commandText]),
|
|
|
|
},
|
|
|
|
body: commandText,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
case "DeadlineDate":
|
|
|
|
return renderToText(t);
|
|
|
|
|
|
|
|
// Tables
|
|
|
|
case "Table":
|
|
|
|
return {
|
|
|
|
name: "table",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
|
|
|
case "TableHeader":
|
|
|
|
return {
|
|
|
|
name: "thead",
|
|
|
|
body: [
|
|
|
|
{
|
|
|
|
name: "tr",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
};
|
|
|
|
case "TableCell":
|
|
|
|
return {
|
|
|
|
name: "td",
|
|
|
|
body: cleanTags(mapRender(t.children!)),
|
|
|
|
};
|
2022-11-30 14:59:41 +00:00
|
|
|
case "TableRow": {
|
|
|
|
const children = t.children!;
|
|
|
|
const newChildren: ParseTree[] = [];
|
|
|
|
// Ensure there is TableCell in between every delimiter
|
|
|
|
let lookingForCell = false;
|
|
|
|
for (const child of children) {
|
|
|
|
if (child.type === "TableDelimiter" && lookingForCell) {
|
|
|
|
// We were looking for a cell, but didn't fine one: empty cell!
|
|
|
|
// Let's inject an empty one
|
|
|
|
newChildren.push({
|
|
|
|
type: "TableCell",
|
|
|
|
children: [],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
if (child.type === "TableDelimiter") {
|
|
|
|
lookingForCell = true;
|
|
|
|
}
|
|
|
|
if (child.type === "TableCell") {
|
|
|
|
lookingForCell = false;
|
|
|
|
}
|
|
|
|
newChildren.push(child);
|
|
|
|
}
|
2022-11-01 14:01:28 +00:00
|
|
|
return {
|
|
|
|
name: "tr",
|
2022-11-30 14:59:41 +00:00
|
|
|
body: cleanTags(mapRender(newChildren)),
|
2022-11-01 14:01:28 +00:00
|
|
|
};
|
2022-11-30 14:59:41 +00:00
|
|
|
}
|
2022-11-01 14:01:28 +00:00
|
|
|
// Text
|
|
|
|
case undefined:
|
|
|
|
return t.text!;
|
|
|
|
default:
|
|
|
|
if (options.failOnUnknown) {
|
|
|
|
console.error("Not handling", JSON.stringify(t, null, 2));
|
|
|
|
throw new Error(`Unknown markdown node type ${t.type}`);
|
|
|
|
} else {
|
|
|
|
// Falling back to rendering verbatim
|
|
|
|
console.warn("Not handling", JSON.stringify(t, null, 2));
|
|
|
|
return renderToText(t);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function mapRender(children: ParseTree[]) {
|
|
|
|
return children.map((t) => posPreservingRender(t, options));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export function renderMarkdownToHtml(
|
|
|
|
t: ParseTree,
|
|
|
|
options: MarkdownRenderOptions = {},
|
|
|
|
) {
|
|
|
|
preprocess(t, options);
|
|
|
|
const htmlTree = posPreservingRender(t, options);
|
|
|
|
return renderHtml(htmlTree);
|
|
|
|
}
|