// The clipper's converter (shared/htmlToMarkdown.ts): the dozen tags an
// article is made of come through as Markdown, everything that is not
// content is dropped, and prose that happens to contain Markdown's own marks
// stays prose.
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { decodeEntities, htmlTitle, htmlToMarkdown } from "../shared/htmlToMarkdown.ts";
const md = (html: string, baseUrl?: string): string => htmlToMarkdown(html, { baseUrl });
describe("htmlToMarkdown", () => {
it("headings, paragraphs and rules", () => {
assert.equal(
md("
Title
One.
Two\n three.
Sub head
"),
"# Title\n\nOne.\n\nTwo three.\n\n---\n\n### Sub *head*",
);
});
it("bold, italic, code, strikethrough — with the page's own spacing kept outside the marks", () => {
assert.equal(md("
a bold b itx `y` zgone
"), "a **bold** b *it* `` x `y` z `` ~~gone~~");
assert.equal(md("
empty
"), "empty");
});
it("links resolve against the page, and only to somewhere a reader can go", () => {
assert.equal(md('rel', "https://ex.org/dir/page"), "[rel](https://ex.org/x?a=1)");
assert.equal(md('js'), "js");
assert.equal(md('', "https://ex.org/p"), "[](https://ex.org/)");
assert.equal(md(''), "[https://ex.org/](https://ex.org/)");
// A outranks the caller's base.
assert.equal(md('q', "https://a.org/"), "[q](https://b.org/q)");
});
it("images by URL, never inlined", () => {
assert.equal(md('', "https://ex.org/a/"), "");
assert.equal(md(''), "");
});
it("lists nest, number, and keep their start", () => {
assert.equal(md("
a
b
c
"), "- a\n- b\n - c");
assert.equal(md('
x
y
'), "3. x\n4. y");
// Unclosed
, as pages actually write them.
assert.equal(md("
a
b
"), "- a\n- b");
// A loose item keeps its paragraphs, hung under the marker.
assert.equal(md("
"), "a");
});
it("prefers , then , then the body; a fragment is itself", () => {
assert.equal(md("
art
"), "art");
assert.equal(md("
whole
"), "whole");
assert.equal(md("
frag
ment
"), "frag\n\nment");
});
it("escapes Markdown's own marks in prose, but not mid-word underscores", () => {
assert.equal(md("
2 * 3 [cite] snake_case _lead trail_ a<b
"), "2 \\* 3 \\[cite\\] snake_case \\_lead trail\\_ a\\# not a heading
> not a quote
1. not a list
- nor this
"), "\\# not a heading\n\n\\> not a quote\n\n1\\. not a list\n\n\\- nor this");
assert.equal(md("
a > b and #tag stay
"), "a > b and #tag stay");
});
it("decodes entities, keeps unknown ones visible, and honours ", () => {
assert.equal(decodeEntities("&AB— &bogus;"), "&AB\u2014\u00a0&bogus;");
assert.equal(md("
a b
"), "a \nb");
});
it("names the page", () => {
assert.equal(htmlTitle(" A &\n B "), "A & B");
assert.equal(htmlTitle("
none
"), null);
});
it("closes an unclosed row when the next one opens, so every row of an old-style table survives", () => {
assert.equal(
htmlToMarkdown("
a
b
c
d
"),
"| a | b |\n| --- | --- |\n| c | d |",
);
assert.equal(htmlToMarkdown("
one
inner
inner2
two
"), "- one\n - inner\n - inner2\n- two");
});
it("caps the depth a crafted page can nest, and keeps the words", () => {
const deep = `${"
".repeat(50_000)}deep${"
".repeat(50_000)}`;
assert.equal(htmlToMarkdown(deep), "deep");
const open = `${"".repeat(50_000)}still here`;
assert.equal(htmlToMarkdown(open), "still here");
});
it("never throws on rubbish", () => {
for (const junk of ["<", "<<>>", "", "