Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions packages/app/src/components/MarkdownExportDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,36 @@ vi.mock("mermaid", () => ({
import { MarkdownExportDocument } from "./MarkdownExportDocument";

describe("MarkdownExportDocument", () => {
it.each(["html", "pdf"] as const)("exports raw HTML merged tables as elements for %s", async (kind) => {
const onRendered = vi.fn();
render(
<MarkdownExportDocument
onRendered={onRendered}
snapshot={{
id: 1,
kind,
markdown: [
"<table><thead><tr><th colspan=\"2\">Synthetic group</th></tr></thead>",
'<tbody><tr><td rowspan="2" style="text-align: center; vertical-align: middle">A</td><td>B<br>C</td></tr>',
"<tr><td>D</td></tr></tbody></table>"
].join("\n"),
title: "synthetic-table.md"
}}
/>
);
await waitFor(() => expect(onRendered).toHaveBeenCalledTimes(1));
const root = document.createElement("div");
root.innerHTML = onRendered.mock.calls[0]![0].bodyHtml;
const cell = root.querySelector<HTMLTableCellElement>('td[rowspan="2"]');

expect(root.querySelector("table")).not.toBeNull();
expect(root.querySelector("th")?.colSpan).toBe(2);
expect(cell?.style.verticalAlign).toBe("middle");
expect(cell?.style.textAlign).toBe("center");
expect(root.querySelector("td br")).not.toBeNull();
expect(root.textContent).not.toContain("<table>");
});

it("renders ordinary Markdown line breaks without explicit br tags", async () => {
const onRendered = vi.fn();

Expand Down
44 changes: 44 additions & 0 deletions packages/app/src/components/MarkdownPreviewDocument.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,50 @@ vi.mock("@markra/editor", async (importOriginal) => ({
import { MarkdownPreviewDocument } from "./MarkdownPreviewDocument";

describe("MarkdownPreviewDocument", () => {
it("renders sanitized HTML blocks and resolves their image sources before reporting completion", async () => {
const onRendered = vi.fn();
const resolveImageSrc = vi.fn((src: string) => `asset://${src}`);
const { container, rerender } = render(
<MarkdownPreviewDocument
markdown={'<div><table onclick="bad()"><tr><td rowspan="2" style="vertical-align: middle; position: fixed"><strong>A</strong><img src="assets/mock.png" onerror="bad()"></td><td>B</td></tr><tr><td>C</td></tr></table><script>bad()</script><iframe src="https://example.test"></iframe><a href="javascript:bad()">Unsafe</a></div>'}
resolveImageSrc={resolveImageSrc}
onRendered={onRendered}
/>
);
await waitFor(() => expect(onRendered).toHaveBeenCalled());
const cell = container.querySelector<HTMLTableCellElement>('td[rowspan="2"]');

expect(cell?.style.verticalAlign).toBe("middle");
expect(cell?.style.position).toBe("");
expect(cell?.querySelector("strong")?.textContent).toBe("A");
expect(cell?.querySelector("img")).toHaveAttribute("src", "asset://assets/mock.png");
expect(container.querySelector("script, iframe, [onclick], [onerror], a[href]")).toBeNull();
expect(onRendered.mock.calls[0]![0].querySelector("table")).not.toBeNull();

rerender(<MarkdownPreviewDocument markdown="Updated **synthetic** content" />);
expect(container.querySelector("table")).toBeNull();
expect(container.querySelector("strong")?.textContent).toBe("synthetic");
});

it("renders HTML blocks inside quotes and list items while keeping fenced HTML as code", () => {
const { container } = render(
<MarkdownPreviewDocument markdown={[
"> <table><tr><td>Quoted</td></tr></table>",
"",
"- <table><tr><td>Listed</td></tr></table>",
"",
"```html",
"<table><tr><td>Code</td></tr></table>",
"```"
].join("\n")} />
);

expect(container.querySelector("blockquote table td")?.textContent).toBe("Quoted");
expect(container.querySelector("li table td")?.textContent).toBe("Listed");
expect(container.querySelector("pre code")?.textContent).toContain("<table><tr><td>Code</td></tr></table>");
expect(container.querySelector("pre table")).toBeNull();
});

it("shows Mermaid diagnostics safely without blocking other diagrams", async () => {
const diagnostic = "Parse error on line 2:\n<img src=x onerror=alert(1)>\nExpecting 'TXT', got 'NEWLINE'";
renderMermaid.mockRejectedValueOnce(new Error(diagnostic));
Expand Down
33 changes: 28 additions & 5 deletions packages/app/src/components/MarkdownPreviewDocument.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
remarkHugoMath,
renderMarkraMathToString,
renderMermaidToSvg,
sanitizeRawHtml,
type MarkraMathMacros
} from "@markra/editor";
import { parseMarkdownCalloutMarker, type ParsedMarkdownCalloutMarker } from "@markra/shared";
Expand All @@ -45,25 +46,41 @@ type MarkdownAstNode = {
value?: string;
};

function replaceHtmlBreakNodes(node: MarkdownAstNode) {
function replaceHtmlNodes(node: MarkdownAstNode) {
if (!Array.isArray(node.children)) return;

node.children = node.children.map((child) => {
if (child.type === "html" && typeof child.value === "string" && /^\s*<br\s*\/?>\s*$/iu.test(child.value)) {
return { type: "break" };
}

replaceHtmlBreakNodes(child);
if (child.type === "html" && typeof child.value === "string" &&
(node.type === "root" || node.type === "blockquote" || node.type === "listItem")) {
// Block HTML is a complete fragment; inline tags can be split across Markdown nodes.
return {
type: "markraHtml",
data: { hName: "div", hProperties: { "data-markra-raw-html": child.value } }
};
}

replaceHtmlNodes(child);
return child;
});
}

function remarkMarkraHtmlBreaks() {
function remarkMarkraHtml() {
return (tree: MarkdownAstNode) => {
replaceHtmlBreakNodes(tree);
replaceHtmlNodes(tree);
};
}

function renderRawHtmlBlock(source: string, resolveImageSrc: MarkdownPreviewDocumentProps["resolveImageSrc"]) {
const root = document.createElement("div");
// Preview and export must use the editor's allowlist before inserting authored HTML.
root.append(...sanitizeRawHtml(source, document, { resolveImageSrc }));
return <div className="markra-html-node" dangerouslySetInnerHTML={{ __html: root.innerHTML }} />;
}

function hasMathClass(className: unknown, kind: "display" | "inline") {
return typeof className === "string" && className.split(/\s+/u).includes(`math-${kind}`);
}
Expand Down Expand Up @@ -246,7 +263,7 @@ export function MarkdownPreviewDocument({
return (
<article className={previewClassName(className)} ref={articleRef}>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath, remarkHugoMath, remarkMarkraHtmlBreaks]}
remarkPlugins={[remarkGfm, remarkBreaks, remarkMath, remarkHugoMath, remarkMarkraHtml]}
components={{
a: ({ node: _node, ...props }) => <a {...props} rel="noreferrer" target="_blank" />,
blockquote: ({ node: _node, ...props }) =>
Expand All @@ -262,6 +279,12 @@ export function MarkdownPreviewDocument({

return <code {...props} className={codeClassName}>{children}</code>;
},
div: ({ node, ...props }) => {
const source = node?.properties["data-markra-raw-html"];
return typeof source === "string"
? renderRawHtmlBlock(source, resolveImageSrc)
: <div {...props} />;
},
img: ({ node: _node, src, alt, ...props }) => (
<img
{...props}
Expand Down
14 changes: 14 additions & 0 deletions packages/editor/src/codemirror/html-table-widget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ function input(cell: HTMLElement, html: string) {
afterEach(() => { for (const view of views.splice(0)) view.destroy(); document.body.replaceChildren(); });

describe("HTML table editing widget", () => {
it("keeps merged cell alignment and source unchanged after editor recreation", () => {
const html = '<table><tbody><tr><td rowspan="2" style="vertical-align: middle; text-align: center">A</td><td>B</td></tr><tr><td>C</td></tr></tbody></table>';
const original = `Before\n\n${html}\n\nAfter`;
for (let index = 0; index < 2; index += 1) {
const view = createView(html);
const cell = view.dom.querySelector<HTMLTableCellElement>('td[rowspan="2"]');

expect(cell?.style.verticalAlign).toBe("middle");
expect(cell?.style.textAlign).toBe("center");
expect(view.state.doc.toString()).toBe(original);
if (index === 0) views.pop()?.destroy();
}
});

it("uses the Markdown table controls for adding, aligning and resizing", () => {
const view = createView();
button(view, "Add column to the right").click();
Expand Down
10 changes: 10 additions & 0 deletions packages/editor/src/raw-html-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ function renderHtml(source: string) {
}

describe("HTML table sanitization", () => {
it("preserves safe vertical alignment on merged cells", () => {
const root = renderHtml('<table><tbody><tr><td rowspan="2" style="text-align: center; vertical-align: middle; position: fixed">A</td><td>B</td></tr><tr><td>C</td></tr></tbody></table>');
const cell = root.querySelector<HTMLTableCellElement>("td");

expect(cell?.rowSpan).toBe(2);
expect(cell?.style.textAlign).toBe("center");
expect(cell?.style.verticalAlign).toBe("middle");
expect(cell?.style.position).toBe("");
});

it("preserves table sections, column groups and merged cells", () => {
const root = renderHtml([
"<table><caption>Synthetic measurements</caption>",
Expand Down
1 change: 1 addition & 0 deletions packages/editor/src/raw-html-sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ const allowedStyleProperties = new Set([
"min-width",
"text-align",
"table-layout",
"vertical-align",
"width",
]);

Expand Down
Loading