fix(make-pdf): stop URLs from swallowing smartypants placeholders

URL_RE (/\bhttps?:\/\/\S+/g) is carved after TAG_RE, but the placeholder
TAG_RE leaves behind (\u0000SMARTPANTS_PRESERVED_N\u0000) contains no
whitespace. A URL sitting flush against a tag therefore matches the URL
plus the following placeholder:

  <p>see <a href="https://ex.com">https://ex.com</a> ok</p>

carves to `...PH_1https://ex.comPH_2 ok...`, and \S+ grabs
`https://ex.comPH_2`. Restore is a single pass, so the nested PH_2 never
restores: the literal text SMARTPANTS_PRESERVED_2 renders in the PDF and
the `</a>` is gone, leaving an unclosed anchor that bleeds link-blue
through every following paragraph until some later `</a>` closes it. A
bold URL swallows `</a></strong>` and leaks bold too.

Exclude the sentinel from URL_RE so carved placeholders survive.

The existing "does NOT touch URLs" test missed this because its URL is
followed by a space, not a tag.

Fixes #2084

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mars Huang 2026-07-16 11:06:19 +08:00
parent a3259400a3
commit 77067ee803
2 changed files with 34 additions and 1 deletions

View File

@ -20,7 +20,12 @@
const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
const TAG_RE = /<[^>]+>/g;
const URL_RE = /\bhttps?:\/\/\S+/g;
// \u0000 is the placeholder sentinel (see PLACEHOLDER below). It must be
// excluded here: URLs are carved AFTER tags, so a URL sitting flush against
// a tag (`<a href="...">https://ex.com</a>`) would otherwise let \S+ swallow
// the placeholder standing in for `</a>` — that placeholder then never
// restores (restore is a single pass) and the tag is lost.
const URL_RE = /\bhttps?:\/\/[^\s\u0000]+/g;
/**
* Apply smartypants to an HTML string. Zones that should not be touched:

View File

@ -56,6 +56,34 @@ describe("smartypants", () => {
expect(out).toContain("\u201cdetails\u201d");
});
// A URL flush against a following tag (no whitespace between) used to let
// URL_RE's \S+ swallow the placeholder standing in for that tag. The
// placeholder never restored, so the tag vanished and its styling bled
// into the rest of the document. The test above misses this because its
// URL is followed by a space.
test("does not leak placeholders for a bare autolinked URL", () => {
const input = `<p>see <a href="https://ex.com">https://ex.com</a> ok</p>`;
const out = smartypants(input);
expect(out).not.toContain("SMARTPANTS_PRESERVED");
expect(out).toContain("</a>");
expect(out).toBe(input);
});
test("does not leak placeholders for a bold URL", () => {
const input = `<p>see <strong><a href="https://ex.com">https://ex.com</a></strong> ok</p>`;
const out = smartypants(input);
expect(out).not.toContain("SMARTPANTS_PRESERVED");
expect(out).toContain("</a></strong>");
expect(out).toBe(input);
});
test("does not leak placeholders when a URL is followed by punctuation", () => {
const input = `<p>see <a href="https://ex.com">https://ex.com</a>, ok</p>`;
const out = smartypants(input);
expect(out).not.toContain("SMARTPANTS_PRESERVED");
expect(out).toContain("</a>");
});
test("does NOT touch HTML attribute values", () => {
const out = smartypants(`<a href="it's-a-test.html">link</a>`);
expect(out).toContain(`href="it's-a-test.html"`);