1// @vitest-environment jsdom
2
3import "./dom";
4// The sanitizer (DOM-bound: DOMParser) and the HTML live preview through a real document-preset
5// view. The security assertions here are the contract: nothing outside the allowlist may ever
6// reach the rendered DOM.
7import { EditorSelection } from "@codemirror/state";
8import { afterEach, describe, expect, it } from "vite-plus/test";
9
10import { createMarkdownEditor, type MarkdownEditor } from "../src/editor.ts";
11import { sanitizeHtml } from "../src/html.ts";
12
13const IMG = "data:image/svg+xml;base64,PHN2Zy8+";
14
15describe("sanitizeHtml", () => {
16 it("drops script/style/iframe with their content", () => {
17 expect(sanitizeHtml("<script>alert(1)</script>")).toBeNull();
18 expect(sanitizeHtml("<style>*{}</style><iframe src='x'></iframe>")).toBeNull();
19 });
20
21 it("strips event handlers and unknown attributes", () => {
22 const clean = sanitizeHtml(`<img src="${IMG}" onerror="alert(1)" data-x="1">`)!;
23 const img = clean.querySelector("img")!;
24 expect(img.getAttribute("onerror")).toBeNull();
25 expect(img.getAttribute("data-x")).toBeNull();
26 expect(img.getAttribute("src")).toBe(IMG);
27 });
28
29 it("rejects javascript: links and keeps https ones hardened", () => {
30 const clean = sanitizeHtml('<a href="javascript:alert(1)">x</a><a href="https://ok">y</a>')!;
31 const links = clean.querySelectorAll("a");
32 expect(links[0]?.getAttribute("href")).toBeNull();
33 expect(links[1]?.getAttribute("href")).toBe("https://ok");
34 expect(links[1]?.getAttribute("target")).toBe("_blank");
35 expect(links[1]?.getAttribute("rel")).toBe("noopener noreferrer");
36 });
37
38 it("rejects non-image data: urls", () => {
39 const clean = sanitizeHtml('<img src="data:text/html;base64,PHNjcmlwdC8+">')!;
40 expect(clean.querySelector("img")?.getAttribute("src")).toBeNull();
41 });
42
43 it("unwraps harmless unknown tags, keeping their children", () => {
44 const clean = sanitizeHtml("<blink>keep <b>me</b></blink>")!;
45 expect(clean.querySelector("blink")).toBeNull();
46 expect(clean.textContent).toBe("keep me");
47 expect(clean.querySelector("b")?.textContent).toBe("me");
48 });
49
50 it("routes relative srcs through resolveUrl and filters srcset entries", () => {
51 const clean = sanitizeHtml(
52 `<img src="./logo.png" srcset="./logo.png 1x, javascript:x 2x, ${IMG} 3x">`,
53 { resolveUrl: (url) => `resolved:${url}` },
54 )!;
55 const img = clean.querySelector("img")!;
56 expect(img.getAttribute("src")).toBe("resolved:./logo.png");
57 expect(img.getAttribute("srcset")).toBe(`resolved:./logo.png 1x, ${IMG} 3x`);
58 });
59
60 it("keeps picture/source media queries", () => {
61 const clean = sanitizeHtml(
62 `<picture><source media="(prefers-color-scheme: dark)" srcset="${IMG}"><img src="${IMG}"></picture>`,
63 )!;
64 expect(clean.querySelector("source")?.getAttribute("media")).toBe(
65 "(prefers-color-scheme: dark)",
66 );
67 });
68});
69
70let destroy: (() => void) | undefined;
71afterEach(() => {
72 destroy?.();
73 destroy = undefined;
74});
75
76function mount(doc: string, head = 0): MarkdownEditor {
77 const editor = createMarkdownEditor({ preset: "document", doc });
78 document.body.append(editor.dom);
79 editor.view.contentDOM.dispatchEvent(new FocusEvent("focus"));
80 editor.view.dispatch({ selection: EditorSelection.single(head) });
81 destroy = () => {
82 editor.destroy();
83 editor.dom.remove();
84 };
85 return editor;
86}
87
88describe("html blocks in the document preset", () => {
89 it("renders a balanced block sanitized, away from the caret", () => {
90 const doc = `<p align="center"><img src="${IMG}" alt="logo" width="120"></p>\n\nafter`;
91 const editor = mount(doc, doc.length);
92 const html = editor.view.dom.querySelector(".cm-lp-html");
93 expect(html?.querySelector("img")?.getAttribute("width")).toBe("120");
94 expect(editor.view.dom.textContent).not.toContain("<p");
95 });
96
97 it("shows raw html source when the caret enters the block", () => {
98 const doc = `<p align="center"><img src="${IMG}"></p>\n\nafter`;
99 const editor = mount(doc, 3);
100 expect(editor.view.dom.querySelector(".cm-lp-html")).toBeNull();
101 expect(editor.view.dom.querySelectorAll(".cm-lp-html-src").length).toBe(1);
102 expect(editor.view.dom.textContent).toContain('<p align="center">');
103 });
104
105 it("never renders script content, even though the block previews", () => {
106 const doc = "<script>alert(1)</script>\n\nafter";
107 const editor = mount(doc, doc.length);
108 expect(editor.view.dom.textContent).not.toContain("alert");
109 expect(editor.view.dom.querySelector("script")).toBeNull();
110 });
111
112 it("absorbs a split centering wrapper: tags hidden, content centered", () => {
113 const doc = `<div align="center">\n\n# Title\n\nsome badges\n\n</div>\n\nafter`;
114 const editor = mount(doc, doc.length);
115 expect(editor.view.dom.textContent).not.toContain("<div");
116 expect(editor.view.dom.textContent).not.toContain("</div>");
117 const centered = editor.view.dom.querySelectorAll(".cm-lp-center");
118 expect(centered.length).toBeGreaterThanOrEqual(2);
119 expect(editor.view.dom.querySelector(".cm-lp-h1.cm-lp-center")).not.toBeNull();
120 });
121
122 it("reveals a wrapper tag as source when the caret reaches its line", () => {
123 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
124 const editor = mount(doc, 4);
125 expect(editor.view.dom.textContent).toContain('<div align="center">');
126 expect(editor.view.dom.querySelector(".cm-lp-html-src")).not.toBeNull();
127 });
128
129 it("leaves an unmatched wrapper open visible as source", () => {
130 const doc = `<div align="center">\n\ncontent with no close\n\nafter`;
131 const editor = mount(doc, doc.length);
132 expect(editor.view.dom.textContent).toContain('<div align="center">');
133 });
134
135 it("collapses comment blocks to a placeholder that expands on caret entry", () => {
136 const doc = "<!-- secret plan -->\n\nafter";
137 const away = mount(doc, doc.length);
138 expect(away.view.dom.textContent).not.toContain("secret plan");
139 expect(away.view.dom.querySelector(".cm-lp-comment")?.textContent).toBe("<!-- ⋯ -->");
140 destroy?.();
141 destroy = undefined;
142 const inside = mount(doc, 4);
143 expect(inside.view.dom.textContent).toContain("secret plan");
144 expect(inside.view.dom.querySelector(".cm-lp-comment")).toBeNull();
145 });
146
147 it("reveals both halves of an absorbed wrapper together, with token highlighting", () => {
148 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
149 const editor = mount(doc, 4); // caret inside the opening <div>
150 expect(editor.view.dom.textContent).toContain('<div align="center">');
151 expect(editor.view.dom.textContent).toContain("</div>");
152 const tags = [...editor.view.dom.querySelectorAll(".cm-html-tok-tag")];
153 expect(tags.map((tag) => tag.textContent)).toEqual(["div", "div"]);
154 expect(editor.view.dom.querySelector(".cm-html-tok-attr")?.textContent).toBe("align");
155 expect(editor.view.dom.querySelector(".cm-html-tok-value")?.textContent).toBe('"center"');
156 });
157
158 it("reveals the opener from the closer too", () => {
159 const doc = `<div align="center">\n\ncontent\n\n</div>\n\nafter`;
160 const editor = mount(doc, doc.indexOf("</div>") + 2);
161 expect(editor.view.dom.textContent).toContain('<div align="center">');
162 expect(editor.view.dom.textContent).toContain("</div>");
163 });
164});
165
166describe("inline html in the document preset", () => {
167 it("styles paired tags and hides them away from the caret", () => {
168 const doc = "press <kbd>Cmd</kbd> and <sub>low</sub>\n\nafter";
169 const editor = mount(doc, doc.length);
170 expect(editor.view.dom.querySelector(".cm-html-kbd")?.textContent).toBe("Cmd");
171 expect(editor.view.dom.querySelector(".cm-html-sub")?.textContent).toBe("low");
172 expect(editor.view.dom.textContent).not.toContain("<kbd>");
173 });
174
175 it("reveals paired tags as muted source when the caret touches the pair", () => {
176 const editor = mount("press <kbd>Cmd</kbd> now\n\nafter", 8);
177 expect(editor.view.dom.textContent).toContain("<kbd>");
178 expect(editor.view.dom.querySelectorAll(".cm-lp-html-inline").length).toBe(2);
179 });
180
181 it("renders <br> as a line break widget", () => {
182 const doc = "a<br>b\n\nafter";
183 const editor = mount(doc, doc.length);
184 expect(editor.view.dom.querySelector(".cm-lp-br br")).not.toBeNull();
185 expect(editor.view.dom.textContent).not.toContain("<br>");
186 });
187
188 it("renders an inline <img> and a badge-style <a><img></a> as sanitized widgets", () => {
189 const doc = `logo <img src="${IMG}"> and <a href="https://x"><img src="${IMG}"></a>\n\nafter`;
190 const editor = mount(doc, doc.length);
191 const widgets = editor.view.dom.querySelectorAll(".cm-lp-html");
192 expect(widgets.length).toBe(2);
193 expect(widgets[1]?.querySelector("a[target='_blank'] img")).not.toBeNull();
194 });
195
196 it("leaves unknown or unpaired tags visible as muted source", () => {
197 const doc = "a <blink>x</blink> and <b>unclosed\n\nafter";
198 const editor = mount(doc, doc.length);
199 expect(editor.view.dom.textContent).toContain("<blink>");
200 expect(editor.view.dom.textContent).toContain("<b>");
201 });
202
203 it("keeps the composer preset html-free (raw text, no widgets)", () => {
204 const composer = createMarkdownEditor({ doc: `x <img src="${IMG}"> <b>hi</b>` });
205 document.body.append(composer.dom);
206 expect(composer.dom.querySelector(".cm-lp-html")).toBeNull();
207 expect(composer.dom.textContent).toContain("<b>hi</b>");
208 composer.destroy();
209 composer.dom.remove();
210 });
211});