Files
paseo/packages/app/src/utils/markdown-parser.test.ts
T
Christoph LeiterandClaude Opus 5 3d69b0f458 fix(app): render markdown verbatim instead of substituting characters (#3253)
markdown-it's typographer option rewrites characters after parsing, so
a literal (c) came out as ©. Agents label steps and options (a), (b),
(c) all the time, and an ordinary list rendering as (a), (b), © stops
your eye every time.

The same option turns ... into an ellipsis, --- into an em dash, and
straight quotes into curly ones. Agent output is text people paste
back into a shell or a file, so a curly quote in --name="my repo"
looks correct on screen and fails when pasted. The markdown file
preview had the same problem, showing characters that are not in the
file on disk.

Every in-app markdown surface now goes through one factory in
utils/markdown-parser.ts, which leaves typographer off: agent chat,
plan cards, pull request comment bodies and the file preview. The plan
card was rendering through its library's built-in parser because it
never passed a markdownit prop, and that default has typographer on.

Link behaviour is unchanged. The assistant parser keeps its file://
permission and no other surface gains it, and linkify stays on for
chat and the default renderer and off for plan cards, matching what
each did before.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:58:27 +00:00

76 lines
2.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { createMarkdownParser } from "./markdown-parser";
// Every string markdown-it's typographer would rewrite, with the character it
// would rewrite it to. Sourced from markdown-it/lib/rules_core/replacements.js
// and smartquotes.js. `--flag` is deliberately absent: the en-dash rules need
// whitespace or a word character on both sides, so a CLI flag after a space is
// never touched and asserting on it would prove nothing.
const REWRITTEN_BY_TYPOGRAPHER = [
"(c)",
"(C)",
"(r)",
"(R)",
"(tm)",
"(TM)",
"(p)",
"(P)",
"+-",
"two dots .. here",
"wait for it...",
"what????",
"stop!!!!",
"hmm,, ok",
"a -- b",
"word--word",
"a --- b",
'run --name="my repo"',
"it's fine",
];
describe("createMarkdownParser", () => {
it("renders every typographer-rewritten sequence verbatim", () => {
const parser = createMarkdownParser({ linkify: true });
for (const source of REWRITTEN_BY_TYPOGRAPHER) {
expect(parser.renderInline(source)).toBe(escapeHtml(source));
}
});
it("would fail if typographer were switched back on", () => {
// Guards the assertion above: proves the fixture actually exercises the
// rules, so a future refactor can't quietly make the test vacuous.
const typographer = createMarkdownParser({ linkify: true });
typographer.set({ typographer: true });
for (const source of REWRITTEN_BY_TYPOGRAPHER) {
expect(typographer.renderInline(source)).not.toBe(escapeHtml(source));
}
});
it("rejects file:// links", () => {
const parser = createMarkdownParser({ linkify: true });
expect(parser.render("[open](file:///tmp/a.ts)")).not.toContain("href");
});
it("rejects javascript: links", () => {
const parser = createMarkdownParser({ linkify: true });
expect(parser.render("[x](javascript:alert(1))")).not.toContain("href");
});
it("linkifies bare URLs only when asked", () => {
expect(createMarkdownParser({ linkify: true }).render("see https://paseo.sh now")).toContain(
'href="https://paseo.sh"',
);
expect(
createMarkdownParser({ linkify: false }).render("see https://paseo.sh now"),
).not.toContain("href");
});
});
function escapeHtml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;");
}