Files
plainleaf/client/markdown_renderer/markdown_render.test.ts
T

587 lines
18 KiB
TypeScript

import { expect, test } from "vitest";
import { parse } from "../markdown_parser/parse_tree.ts";
import { renderMarkdownToHtml } from "./markdown_render.ts";
import {
buildExtendedMarkdownLanguage,
extendedMarkdownLanguage,
} from "../markdown_parser/parser.ts";
import { CustomSyntaxRenderedHtmlType, expandMarkdown } from "./inline.ts";
import type { Space } from "../space.ts";
import type { SpaceLuaEnvironment } from "../space_lua.ts";
import { LuaEnv } from "../space_lua/runtime.ts";
const sampleMarkdown = `---
name: Sup
---
# Hello world
This is **bold** and _italic_, or _italic_. And a **_mix_**. And ==highlight==!
Lists:
- This
- Is a
- list
- And here we go nested
1. This is a numbered
2. Two
- And different
- Bla
- More bla
And:
1. Numbered
2. Two
## Second heading
And some
And like this:
More code
Bla
And a blockquote:
> Sup yo Empty line\
> Second part
<!-- this is a comment -->
And more custom stuff [[Page link]]
{[Command button]}
- [ ] #next Task
- [x] #next Task 2
## Tables
| type | actor_login | created_at | payload_ref |
| --------- | ----------- | -------------------- | ---------------------- |
| PushEvent | avb | 2022-10-27T08:27:48Z | refs/heads/master |
| PushEvent | avb | 2022-10-27T04:31:27Z | refs/heads/jitterSched |
Here is something
---
A new thing.
`;
test("Markdown render", () => {
const tree = parse(extendedMarkdownLanguage, sampleMarkdown);
renderMarkdownToHtml(tree, {
failOnUnknown: true,
});
});
test("Wiki link with embedded image path", () => {
// This particular one caused an infinite regex loop previously, adding it here as a regression to avoid in the future
const example = `![[Inbox/2026-01-08/CleanShot 2026-01-01 at 12.36.23.png]]`;
const tree = parse(extendedMarkdownLanguage, example);
renderMarkdownToHtml(tree, {
failOnUnknown: true,
});
});
test("Smart hard break test", () => {
const example = `**Hello**
*world!*`;
const tree = parse(extendedMarkdownLanguage, example);
const html = renderMarkdownToHtml(tree, {
failOnUnknown: true,
smartHardBreak: true,
});
expect(html).toEqual(
`<span class="p"><strong>Hello</strong><br/><em>world!</em></span>`,
);
const example2 = `This is going to be a text. With a new line.
And another
* and a list
* with a second item
### [[Bla]]
Url: something
Server: something else
📅 last_updated - [Release notes](release_notes_url)`;
const tree2 = parse(extendedMarkdownLanguage, example2);
const html2 = renderMarkdownToHtml(tree2, {
failOnUnknown: true,
smartHardBreak: true,
});
console.log(html2);
});
test("Inline HTML tags render as proper elements", () => {
const tree = parse(extendedMarkdownLanguage, "<marquee>Hello</marquee>");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual('<span class="p"><marquee>Hello</marquee></span>');
});
test("Inline HTML tags with attributes", () => {
const tree = parse(
extendedMarkdownLanguage,
'<span style="color:red">red text</span>',
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual(
'<span class="p"><span style="color:red">red text</span></span>',
);
});
test("Inline HTML tags with markdown content", () => {
const tree = parse(
extendedMarkdownLanguage,
'<marquee class="x">Hello **there**</marquee>',
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual(
'<span class="p"><marquee class="x">Hello <strong>there</strong></marquee></span>',
);
});
test("Inline HTML mixed with text", () => {
const tree = parse(extendedMarkdownLanguage, "Before <b>bold</b> after");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual('<span class="p">Before <b>bold</b> after</span>');
});
test("Nested same-name HTML tags", () => {
const tree = parse(extendedMarkdownLanguage, "<b><b>nested</b></b>");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual('<span class="p"><b><b>nested</b></b></span>');
});
test("Inline HTML with wiki link", () => {
const tree = parse(extendedMarkdownLanguage, "<span>hello [[there]]</span>");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual(
'<span class="p"><span>hello <a href="/there" class="wiki-link" data-ref="there">there</a></span></span>',
);
});
test("Unmatched HTML tags render as literal text", () => {
const tree = parse(extendedMarkdownLanguage, "text <b>unclosed");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual('<span class="p">text &lt;b&gt;unclosed</span>');
});
test("Inline HTML renders inside task items", () => {
const tree = parse(
extendedMarkdownLanguage,
"* [ ] <mark>highlighted</mark> task",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual(
'<ul><li><span class="sb-task"><input type="checkbox" data-state=" "> <mark>highlighted</mark> task</span></li></ul>',
);
});
test("CustomSyntaxRenderedHtml renders raw HTML", () => {
// Directly test the renderer with a synthetic parse tree
const tree = {
type: "Document",
children: [
{
type: "Paragraph",
children: [
{ text: "Before " },
{
type: CustomSyntaxRenderedHtmlType,
children: [{ text: "<em>rendered</em>" }],
},
{ text: " after" },
],
},
],
};
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toEqual('<span class="p">Before <em>rendered</em> after</span>');
});
// Minimal stubs for expandMarkdown tests
const stubSpace = {} as Space;
const stubSle = { env: new LuaEnv() } as SpaceLuaEnvironment;
const defaultExpandOpts = {
expandTransclusions: false,
expandLuaDirectives: false,
rewriteTasks: false,
};
const latexInlineSpec = {
name: "LatexInline",
startMarker: "\\$",
endMarker: "\\$",
mode: "inline" as const,
};
const latexBlockSpec = {
name: "LatexBlock",
startMarker: "^\\$\\$$",
endMarker: "^\\$\\$$",
mode: "block" as const,
};
const customSpec = {
name: "Custom",
startMarker: "<<",
endMarker: ">>",
mode: "inline" as const,
};
test("expandMarkdown resolves inline custom syntax renderHtml", async () => {
const lang = buildExtendedMarkdownLanguage({
LatexInline: latexInlineSpec,
});
const tree = parse(lang, "Hello $E=mc^2$ world");
const expanded = await expandMarkdown(stubSpace, "test", tree, stubSle, {
...defaultExpandOpts,
syntaxExtensions: {
LatexInline: {
...latexInlineSpec,
renderHtml: (body, _pageName) => `<span class="latex">${body}</span>`,
},
},
});
const html = renderMarkdownToHtml(expanded, { failOnUnknown: true });
expect(html).toEqual(
'<span class="p">Hello <span class="latex">E=mc^2</span> world</span>',
);
});
test("expandMarkdown resolves block custom syntax renderHtml", async () => {
const lang = buildExtendedMarkdownLanguage({
LatexBlock: latexBlockSpec,
});
const tree = parse(lang, "$$\nE=mc^2\n$$");
const expanded = await expandMarkdown(stubSpace, "test", tree, stubSle, {
...defaultExpandOpts,
syntaxExtensions: {
LatexBlock: {
...latexBlockSpec,
renderHtml: (body, _pageName) =>
`<div class="math-block">${body.trim()}</div>`,
},
},
});
const html = renderMarkdownToHtml(expanded, { failOnUnknown: true });
expect(html).toEqual('<div class="math-block">E=mc^2</div>');
});
test("expandMarkdown passes pageName to renderHtml", async () => {
const lang = buildExtendedMarkdownLanguage({ Custom: customSpec });
const tree = parse(lang, "Hello <<content>> world");
let receivedPageName: string | undefined;
await expandMarkdown(stubSpace, "MyPage", tree, stubSle, {
...defaultExpandOpts,
syntaxExtensions: {
Custom: {
...customSpec,
renderHtml: (_body, pageName) => {
receivedPageName = pageName;
return "<span>ok</span>";
},
},
},
});
expect(receivedPageName).toEqual("MyPage");
});
test("expandMarkdown handles renderHtml errors gracefully", async () => {
const lang = buildExtendedMarkdownLanguage({ Custom: customSpec });
const tree = parse(lang, "Hello <<content>> world");
const expanded = await expandMarkdown(stubSpace, "test", tree, stubSle, {
...defaultExpandOpts,
syntaxExtensions: {
Custom: {
...customSpec,
renderHtml: () => {
throw new Error("something failed");
},
},
},
});
const html = renderMarkdownToHtml(expanded);
expect(html).toContain("Error in Custom renderHtml: something failed");
expect(html).toContain('<span class="error">');
});
test("expandMarkdown skips custom syntax without renderHtml", async () => {
const lang = buildExtendedMarkdownLanguage({ Custom: customSpec });
const tree = parse(lang, "Hello <<content>> world");
const expanded = await expandMarkdown(stubSpace, "test", tree, stubSle, {
...defaultExpandOpts,
syntaxExtensions: {
Custom: {
...customSpec,
// No renderHtml callback
},
},
});
const html = renderMarkdownToHtml(expanded);
// Should fall through to default rendering (raw text, HTML-escaped)
expect(html).toContain("&lt;&lt;content&gt;&gt;");
});
// ── Block-level HTML rendering ─────────────────────────────────────
test("Block HTML table renders correctly", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table><tr><td>hello</td></tr></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe("<table><tr><td>hello</td></tr></table>");
});
test("Block HTML preserves data attributes", () => {
const tree = parse(
extendedMarkdownLanguage,
'<td data-table-cell-type="number">42</td>',
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe('<td data-table-cell-type="number">42</td>');
});
test("Markdown inside block HTML td is rendered", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table><tr><td>hello **world**</td></tr></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
"<table><tr><td>hello <strong>world</strong></td></tr></table>",
);
});
test("Self-closing <br/> inside block HTML td renders as HTML", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table><tr><td>Hello<br/>there</td></tr></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe("<table><tr><td>Hello<br/>there</td></tr></table>");
});
test("Wiki link inside block HTML td is rendered", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table><tr><td>see [[MyPage]]</td></tr></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toContain('<a href="/MyPage" class="wiki-link"');
expect(html).toContain('data-ref="MyPage"');
});
test("Block HTML with self-closing tags", () => {
const tree = parse(extendedMarkdownLanguage, "<div><br /><hr /></div>");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe("<div><br /><hr /></div>");
});
test("Multi-line block HTML table", () => {
const md = `<table>
<thead><tr><th>name</th><th>age</th></tr></thead>
<tbody>
<tr><td>Alice</td><td>30</td></tr>
</tbody>
</table>`;
const tree = parse(extendedMarkdownLanguage, md);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toContain("<thead><tr><th>name</th><th>age</th></tr></thead>");
expect(html).toContain("<td>Alice</td><td>30</td>");
});
test("Block HTML with data attributes and wiki links", () => {
const md = `<table>
<thead><tr><th>name</th></tr></thead>
<tbody>
<tr><td data-table-cell-type="string">[[Alice]]</td></tr>
</tbody>
</table>`;
const tree = parse(extendedMarkdownLanguage, md);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toContain('data-table-cell-type="string"');
expect(html).toContain('<a href="/Alice" class="wiki-link"');
});
test("Block HTML ul/li with data attributes", () => {
const tree = parse(
extendedMarkdownLanguage,
'<ul><li data-list-item-type="string">hello</li></ul>',
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe('<ul><li data-list-item-type="string">hello</li></ul>');
});
test("Nested block HTML tables", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table><tr><td><table><tr><td>inner</td></tr></table></td></tr></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toContain("<table><tr><td>inner</td></tr></table>");
// Should have two table open/close pairs
expect(html.match(/<table>/g)).toHaveLength(2);
expect(html.match(/<\/table>/g)).toHaveLength(2);
});
test("Empty block HTML table", () => {
const tree = parse(
extendedMarkdownLanguage,
"<table data-table-empty></table>",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toContain("data-table-empty");
});
test("HTML comment is still removed", () => {
const tree = parse(extendedMarkdownLanguage, "<!-- comment -->");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe("");
});
test("Conforming inline comment renders to nothing", () => {
const tree = parse(
extendedMarkdownLanguage,
"A claim.\n\n<!-- @pete: verify — john, 2026-08-04 -->\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).not.toContain("pete");
expect(html).not.toContain("verify");
expect(html).not.toContain("<!--");
});
test("Whitespace between block siblings is dropped (no spurious <br>)", () => {
const tree = parse(
extendedMarkdownLanguage,
"# Heading\n\n- item one\n- item two\n\n# Next heading\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
"<h1>Heading</h1>" +
'<ul><li><span class="p">item one</span></li>' +
'<li><span class="p">item two</span></li></ul>' +
"<h1>Next heading</h1>",
);
});
test("Whitespace between paragraphs is preserved as <br/>", () => {
const tree = parse(extendedMarkdownLanguage, "Hello there\n\nThis is cool\n");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
'<span class="p">Hello there</span>' +
"<br/><br/>" +
'<span class="p">This is cool</span>',
);
});
test("Whitespace between paragraph and heading is preserved", () => {
// Paragraph renders as an inline `<span class="p">`, so a blank line
// before a following block (heading) must still produce a visible gap.
const tree = parse(
extendedMarkdownLanguage,
"Intro paragraph\n\n# Heading\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
'<span class="p">Intro paragraph</span><br/><h1>Heading</h1>',
);
});
test("Whitespace between heading and paragraph is preserved", () => {
// Symmetric to the previous case: block-then-paragraph keeps its break.
// Note: the heading consumes the newline that terminates its own line, so
// the whitespace text node between heading and paragraph is a single `\n`,
// not the `\n\n` of the markdown source.
const tree = parse(
extendedMarkdownLanguage,
"# Heading\n\nIntro paragraph\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
'<h1>Heading</h1><br/><span class="p">Intro paragraph</span>',
);
});
test("Whitespace between bullet list and paragraph is preserved", () => {
const tree = parse(
extendedMarkdownLanguage,
"- item one\n- item two\n\nOther text\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
'<ul><li><span class="p">item one</span></li>' +
'<li><span class="p">item two</span></li></ul>' +
"<br/>" +
'<span class="p">Other text</span>',
);
});
test("Multiple blank lines between blocks collapse to nothing", () => {
const tree = parse(extendedMarkdownLanguage, "# A\n\n\n\n# B\n");
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe("<h1>A</h1><h1>B</h1>");
});
test("Heading then list then heading (transclusion shape)", () => {
// Mirrors the transcluded API page: heading, query result (bullet list),
// heading, query result (bullet list). The bug being guarded against here
// is the original symptom: bare <br>...<br><br><br> between sections.
const tree = parse(
extendedMarkdownLanguage,
"# Lua Standard Library\n" +
"- one\n- two\n\n" +
"# Space Lua APIs\n" +
"- three\n- four\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
"<h1>Lua Standard Library</h1>" +
'<ul><li><span class="p">one</span></li>' +
'<li><span class="p">two</span></li></ul>' +
"<h1>Space Lua APIs</h1>" +
'<ul><li><span class="p">three</span></li>' +
'<li><span class="p">four</span></li></ul>',
);
});
test("Paragraph between two blocks keeps its surrounding breaks where needed", () => {
// # heading\n\npara1\n\npara2\n\n# heading2
// Whitespace adjacent to a paragraph stays (paragraphs are inline);
// whitespace between two block-level siblings is dropped.
const tree = parse(
extendedMarkdownLanguage,
"# H1\n\npara1\n\npara2\n\n# H2\n",
);
const html = renderMarkdownToHtml(tree, { failOnUnknown: true });
expect(html).toBe(
"<h1>H1</h1>" +
"<br/>" +
'<span class="p">para1</span>' +
"<br/><br/>" +
'<span class="p">para2</span>' +
"<br/>" +
"<h1>H2</h1>",
);
});