diff --git a/packages/app/src/components/MarkdownExportDocument.test.tsx b/packages/app/src/components/MarkdownExportDocument.test.tsx index b408b702..d986141a 100644 --- a/packages/app/src/components/MarkdownExportDocument.test.tsx +++ b/packages/app/src/components/MarkdownExportDocument.test.tsx @@ -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( + Synthetic group", + 'AB
C', + "D" + ].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('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(""); + }); + it("renders ordinary Markdown line breaks without explicit br tags", async () => { const onRendered = vi.fn(); diff --git a/packages/app/src/components/MarkdownPreviewDocument.test.tsx b/packages/app/src/components/MarkdownPreviewDocument.test.tsx index 2ae8204c..4684ce34 100644 --- a/packages/app/src/components/MarkdownPreviewDocument.test.tsx +++ b/packages/app/src/components/MarkdownPreviewDocument.test.tsx @@ -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( +
AB
C
Unsafe'} + resolveImageSrc={resolveImageSrc} + onRendered={onRendered} + /> + ); + await waitFor(() => expect(onRendered).toHaveBeenCalled()); + const cell = container.querySelector('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(); + 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( +
Quoted
", + "", + "-
Listed
", + "", + "```html", + "
Code
", + "```" + ].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("
Code
"); + expect(container.querySelector("pre table")).toBeNull(); + }); + it("shows Mermaid diagnostics safely without blocking other diagrams", async () => { const diagnostic = "Parse error on line 2:\n\nExpecting 'TXT', got 'NEWLINE'"; renderMermaid.mockRejectedValueOnce(new Error(diagnostic)); diff --git a/packages/app/src/components/MarkdownPreviewDocument.tsx b/packages/app/src/components/MarkdownPreviewDocument.tsx index 013f83f5..0a9de030 100644 --- a/packages/app/src/components/MarkdownPreviewDocument.tsx +++ b/packages/app/src/components/MarkdownPreviewDocument.tsx @@ -20,6 +20,7 @@ import { remarkHugoMath, renderMarkraMathToString, renderMermaidToSvg, + sanitizeRawHtml, type MarkraMathMacros } from "@markra/editor"; import { parseMarkdownCalloutMarker, type ParsedMarkdownCalloutMarker } from "@markra/shared"; @@ -45,7 +46,7 @@ type MarkdownAstNode = { value?: string; }; -function replaceHtmlBreakNodes(node: MarkdownAstNode) { +function replaceHtmlNodes(node: MarkdownAstNode) { if (!Array.isArray(node.children)) return; node.children = node.children.map((child) => { @@ -53,17 +54,33 @@ function replaceHtmlBreakNodes(node: MarkdownAstNode) { 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
; +} + function hasMathClass(className: unknown, kind: "display" | "inline") { return typeof className === "string" && className.split(/\s+/u).includes(`math-${kind}`); } @@ -246,7 +263,7 @@ export function MarkdownPreviewDocument({ return (
, blockquote: ({ node: _node, ...props }) => @@ -262,6 +279,12 @@ export function MarkdownPreviewDocument({ return {children}; }, + div: ({ node, ...props }) => { + const source = node?.properties["data-markra-raw-html"]; + return typeof source === "string" + ? renderRawHtmlBlock(source, resolveImageSrc) + :
; + }, img: ({ node: _node, src, alt, ...props }) => ( { 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 = '
AB
C
'; + 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('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(); diff --git a/packages/editor/src/raw-html-sanitize.test.ts b/packages/editor/src/raw-html-sanitize.test.ts index 1a2917c5..cd27a3c3 100644 --- a/packages/editor/src/raw-html-sanitize.test.ts +++ b/packages/editor/src/raw-html-sanitize.test.ts @@ -8,6 +8,16 @@ function renderHtml(source: string) { } describe("HTML table sanitization", () => { + it("preserves safe vertical alignment on merged cells", () => { + const root = renderHtml('
AB
C
'); + const cell = root.querySelector("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([ "", diff --git a/packages/editor/src/raw-html-sanitize.ts b/packages/editor/src/raw-html-sanitize.ts index 378127fd..c15967c4 100644 --- a/packages/editor/src/raw-html-sanitize.ts +++ b/packages/editor/src/raw-html-sanitize.ts @@ -109,6 +109,7 @@ const allowedStyleProperties = new Set([ "min-width", "text-align", "table-layout", + "vertical-align", "width", ]);
Synthetic measurements