Skip to content

Commit 122b780

Browse files
feat(export): add Notion, Bilibili, Mastodon, Bluesky, Markdown round-trip targets (#19)
* feat(export): add notion, bilibili, mastodon, bluesky, markdown-roundtrip targets - notion.ts: juice-inline body + drop section wrappers and class/data-* noise; keep `language-*` on <code> so Notion picks the right code mode. - bilibili.ts: walk DOM through a 专栏 tag whitelist; replace non-bili-CDN images with a "re-upload to bilibili" placeholder, stash the original src on data-original-src so the user can recover it. - mastodon.ts / bluesky.ts: build {blob, text, filename} share bundles — screenshot via the existing iframeToBlob, caption trimmed by code-point count to 500 / 300 chars (so emoji and surrogate pairs don't get sliced). Image and text go on the clipboard via separate calls — browsers refuse mixed-mime writes. - markdown-roundtrip.ts: focused HTML → GFM walker covering the subset the editor renders (headings, lists with nesting, fenced code with language, tables, links, images, blockquotes). Skipped turndown to avoid pulling a large dep for a narrow input. Wire-up into the export menu is left for a follow-up so this PR stays mechanical. * fix(export): tighten markdown emitter, bilibili code class, mastodon comment Addresses review feedback on the new export targets: - markdown-roundtrip: use longest-backtick-run fences for inline + fenced code so embedded backticks don't break the block; URL-escape link/image hrefs with whitespace or parens via the angle-bracket form, and escape quotes/backslashes in titles; backslash-escape brackets inside link text and img alt; new escapeBlockStarts pass keeps a paragraph that starts with "# ", "> ", "- ", "1. ", etc. from being parsed as a heading or list; renderList now partitions <li> children into inline / nested- list / other-block buckets so paragraphs and code blocks inside list items render with a proper continuation indent. - bilibili: filter <code class> to language-* only so arbitrary highlight classes don't leak through; drop dead "caller decides" comment. - mastodon: clarify that truncateForPost counts code points, not graphemes — ZWJ emoji sequences are conservatively counted as multiple units. * test(export): vitest coverage for notion/bilibili/mastodon/markdown emitters Adds vitest + happy-dom and 50 tests covering the new export targets. Each test runs in a real DOM environment; the markdown emitter tests additionally pipe the output through `marked` (already a runtime dep) to verify the produced Markdown round-trips into the same structural shape — closest "simulate real" check we can do without spinning up Hugo / 11ty / Obsidian. - markdown-roundtrip.test.ts (25): headings, inline marks (with literal *, _, ~ kept literal), inline code with backticks (longest-fence rule), fenced code with embedded triple backticks, links/images (angle-bracket URL form for spaces+parens, title quote escape, bracket escape in link text), line-start block-marker escaping (paragraph starting with `1. `, `# `, `- `, `> ` stays a paragraph), nested lists, list item with paragraph + code block, blockquote, table with pipe escape, hr, full-document smoke. - bilibili.test.ts (9): whitelist enforcement, attribute filtering, language-* class retention on <code>, CDN vs. non-CDN image rewrite. - notion.test.ts (8): section/article/header/footer/main unwrapping, data-* + class stripping, style attribute preserved, code language-* class retention, language-plaintext fallback, <pre> without <code> gets wrapped. - mastodon.test.ts (8): whitespace collapse, surrogate-pair safety, ZWJ-emoji conservative behavior, boundary cases. While writing these: - Hoisted the first <p>'s inline content onto the list-marker line when an <li> has no inline children; otherwise marked saw "1. \n\n text" and started a new list at the next marker. - Tightened notion.ts <code class> filter to keep only language-* tokens (matches bilibili.ts; arbitrary highlighter classes no longer leak through). `pnpm test` (now wired in package.json) runs the full suite; `tsc --noEmit` clean. * fix(export): CDN lookalike, nested-in-link mangle, table cell <br> Addresses the three non-blocking findings from PerishCode's review: - bilibili `isBilibiliCdn`: tighten the regex so `evilhdslb.com` / `notbilibili.com` no longer pass as valid CDNs. Require either an exact host match or a subdomain dot before the literal domain. Without this, the placeholder swap was skipped for lookalike URLs and the user would silently lose the image after bilibili's publish-time scrub. - markdown emitter, nested elements inside <a>: move bracket escape into `escapeMd` at the text-node level instead of wrapping the fully-composed `inner()` string. The old approach mangled <a><img src=y alt=a></a> to `[!\[a\](y)](href)` so the inner image no longer round-tripped; same for `<a><code>x</code></a>` where literal backslashes appeared inside the code span. - renderTable: GFM tables require one row per source line, so a literal \n inside a cell (from <br>, <p>, etc.) terminates the row early and collapses every downstream row. Collapse intra-cell \r?\n+ to literal `<br>` (inline HTML is permitted inside GFM cells) before pipe-escaping. Tests added: bilibili lookalike + subdomain matrix; markdown emitter `<a><img>` (badge link), `<a><code>` (no stray backslashes), and `<td>line1<br>line2</td>` (table stays intact). 55/55 green.
1 parent b799c28 commit 122b780

9 files changed

Lines changed: 1330 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Tests for the Bilibili 专栏 HTML sanitizer. The sanitizer runs in a real
3+
* (happy-dom) browser-like environment so it exercises the actual DOMParser
4+
* + Element APIs the production code uses. Each test parses the cleaned
5+
* output back through DOMParser and asserts on the resulting tree shape.
6+
*/
7+
import { describe, it, expect } from "vitest";
8+
import { toBilibiliHtml } from "../bilibili";
9+
10+
function parseFragment(html: string): HTMLBodyElement {
11+
return new DOMParser().parseFromString(`<body>${html}</body>`, "text/html")
12+
.body as HTMLBodyElement;
13+
}
14+
15+
describe("toBilibiliHtml — whitelist enforcement", () => {
16+
it("strips <script>/<iframe>/<style> wrappers entirely", () => {
17+
const html = toBilibiliHtml(
18+
"<p>keep</p><script>alert(1)</script><iframe></iframe><style>p{}</style>",
19+
);
20+
const body = parseFragment(html);
21+
expect(body.querySelector("script")).toBeNull();
22+
expect(body.querySelector("iframe")).toBeNull();
23+
expect(body.querySelector("style")).toBeNull();
24+
expect(body.querySelector("p")?.textContent).toBe("keep");
25+
});
26+
27+
it("unwraps <div>/<section> but keeps their children", () => {
28+
const html = toBilibiliHtml(
29+
"<div><section><p>a</p></section><p>b</p></div>",
30+
);
31+
const body = parseFragment(html);
32+
expect(body.querySelector("div")).toBeNull();
33+
expect(body.querySelector("section")).toBeNull();
34+
const ps = Array.from(body.querySelectorAll("p")).map((p) => p.textContent);
35+
expect(ps).toEqual(["a", "b"]);
36+
});
37+
38+
it("preserves headings, lists, code, blockquotes, hr, br", () => {
39+
const html = toBilibiliHtml(
40+
"<h2>Title</h2><ul><li>x</li></ul><pre><code>k</code></pre><blockquote>q</blockquote><hr><br>",
41+
);
42+
const body = parseFragment(html);
43+
expect(body.querySelector("h2")?.textContent).toBe("Title");
44+
expect(body.querySelector("ul li")?.textContent).toBe("x");
45+
expect(body.querySelector("pre code")?.textContent).toBe("k");
46+
expect(body.querySelector("blockquote")?.textContent).toBe("q");
47+
expect(body.querySelector("hr")).not.toBeNull();
48+
expect(body.querySelector("br")).not.toBeNull();
49+
});
50+
});
51+
52+
describe("toBilibiliHtml — attribute filtering", () => {
53+
it("drops all attributes except those in the allow-list", () => {
54+
const html = toBilibiliHtml(
55+
'<p class="foo" data-x="y" onclick="alert(1)" id="z">hi</p>',
56+
);
57+
const p = parseFragment(html).querySelector("p");
58+
expect(p?.getAttribute("class")).toBeNull();
59+
expect(p?.getAttribute("data-x")).toBeNull();
60+
expect(p?.getAttribute("onclick")).toBeNull();
61+
expect(p?.getAttribute("id")).toBeNull();
62+
expect(p?.textContent).toBe("hi");
63+
});
64+
65+
it("keeps href and title on <a>, drops javascript: hrefs", () => {
66+
const html = toBilibiliHtml(
67+
'<p><a href="https://example.com" title="t">ok</a> ' +
68+
'<a href="javascript:alert(1)">bad</a></p>',
69+
);
70+
const anchors = Array.from(parseFragment(html).querySelectorAll("a"));
71+
expect(anchors[0].getAttribute("href")).toBe("https://example.com");
72+
expect(anchors[0].getAttribute("title")).toBe("t");
73+
expect(anchors[1].hasAttribute("href")).toBe(false);
74+
});
75+
76+
it("keeps only language-* classes on <code>", () => {
77+
const html = toBilibiliHtml(
78+
'<pre><code class="hljs language-ts other">x</code></pre>',
79+
);
80+
const code = parseFragment(html).querySelector("code");
81+
// Whitespace allowed, but the only token retained is language-ts.
82+
const tokens = (code?.getAttribute("class") ?? "")
83+
.split(/\s+/)
84+
.filter(Boolean);
85+
expect(tokens).toEqual(["language-ts"]);
86+
});
87+
88+
it("removes the class attribute entirely when no language-* token survives", () => {
89+
const html = toBilibiliHtml(
90+
'<pre><code class="hljs token-keyword">x</code></pre>',
91+
);
92+
const code = parseFragment(html).querySelector("code");
93+
expect(code?.hasAttribute("class")).toBe(false);
94+
});
95+
});
96+
97+
describe("toBilibiliHtml — image rewriting", () => {
98+
it("leaves bilibili CDN images intact", () => {
99+
const html = toBilibiliHtml(
100+
'<p><img src="https://i0.hdslb.com/x.png" alt="a"></p>',
101+
);
102+
const img = parseFragment(html).querySelector("img");
103+
expect(img?.getAttribute("src")).toBe("https://i0.hdslb.com/x.png");
104+
expect(img?.getAttribute("alt")).toBe("a");
105+
expect(img?.hasAttribute("data-bili-placeholder")).toBe(false);
106+
});
107+
108+
it("replaces non-CDN images with a placeholder SVG and stashes the original src", () => {
109+
const html = toBilibiliHtml(
110+
'<p><img src="https://imgur.com/foo.png" alt="ext"></p>',
111+
);
112+
const img = parseFragment(html).querySelector("img");
113+
expect(img?.getAttribute("data-original-src")).toBe(
114+
"https://imgur.com/foo.png",
115+
);
116+
expect(img?.getAttribute("data-bili-placeholder")).toBe("true");
117+
expect(img?.getAttribute("src")).toMatch(/^data:image\/svg\+xml/);
118+
expect(img?.getAttribute("alt")).toBe("ext");
119+
});
120+
121+
it("rejects lookalike hostnames (no subdomain dot before the literal)", () => {
122+
// `evilhdslb.com` and `notbilibili.com` would slip past a `[^/]*` regex
123+
// and skip the placeholder swap — both must be treated as non-CDN.
124+
for (const src of [
125+
"https://evilhdslb.com/foo.png",
126+
"https://notbilibili.com/foo.png",
127+
]) {
128+
const html = toBilibiliHtml(`<p><img src="${src}" alt="x"></p>`);
129+
const img = parseFragment(html).querySelector("img");
130+
expect(img?.getAttribute("data-bili-placeholder")).toBe("true");
131+
expect(img?.getAttribute("data-original-src")).toBe(src);
132+
}
133+
});
134+
135+
it("accepts subdomains of the bilibili CDN", () => {
136+
for (const src of [
137+
"https://i0.hdslb.com/bfs/x.png",
138+
"https://album.bilibili.com/x.png",
139+
"https://hdslb.com/x.png", // bare apex
140+
"https://bilibili.com/foo.png", // bare apex
141+
]) {
142+
const html = toBilibiliHtml(`<p><img src="${src}" alt="x"></p>`);
143+
const img = parseFragment(html).querySelector("img");
144+
expect(img?.hasAttribute("data-bili-placeholder")).toBe(false);
145+
expect(img?.getAttribute("src")).toBe(src);
146+
}
147+
});
148+
});

0 commit comments

Comments
 (0)