Skip to content

Commit 985233f

Browse files
authored
fix: marked v15 + marked-terminal v7 incompat in markdown-renderer (#138)
marked v15's use() iterates 'for (prop in pack.renderer)' and validates every enumerable key against its known renderer method list, throwing "renderer 'o' does not exist" at module init. The legacy 'new TerminalRenderer(opts)' route assigns config to own enumerable properties (this.o, this.tab, ...), so the first iteration hits an unknown key and crashes. This broke the agent on every 'just start' since PR #135 landed; CI never noticed because no test imports the module. Switch to the modern markedTerminal() factory which returns a clean MarkedExtension containing only renderer method keys, and add a regression test that import-loads the module and smoke-tests rendering so a future bump can't reintroduce this class of crash. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
1 parent b646352 commit 985233f

2 files changed

Lines changed: 118 additions & 16 deletions

File tree

src/agent/markdown-renderer.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
// import { renderMarkdown } from "./markdown-renderer.js";
99
// console.log(renderMarkdown("# Hello\n**bold** and `code`"));
1010

11-
import { Marked, type MarkedOptions } from "marked";
12-
import TerminalRenderer from "marked-terminal";
11+
import { Marked, type MarkedExtension } from "marked";
12+
import { markedTerminal } from "marked-terminal";
1313
import { createRequire } from "node:module";
1414
import { resolve } from "node:path";
1515
import kqlLanguage from "./hljs-kql.js";
@@ -28,20 +28,27 @@ hljsInstance.registerLanguage("kql", kqlLanguage);
2828
// Use a local Marked instance so we don't mutate the global marked
2929
// singleton — other code importing marked won't accidentally get
3030
// terminal-rendered output instead of HTML.
31-
const terminalRenderer = new TerminalRenderer({
32-
// Indent code blocks for visual separation
33-
tab: 2,
34-
// Show URLs inline rather than as footnotes
35-
showSectionPrefix: true,
36-
// Convert HTML entities back to characters
37-
unescape: true,
38-
});
39-
40-
// marked-terminal's renderer type doesn't match marked v15's _Renderer
41-
// exactly, but it works at runtime. Cast to satisfy the type checker.
42-
const localMarked = new Marked({
43-
renderer: terminalRenderer as unknown as MarkedOptions["renderer"],
44-
});
31+
//
32+
// `markedTerminal()` returns a MarkedExtension (`{ renderer, useNewRenderer }`)
33+
// suitable for marked v15's strict `use()` validation. Constructing a
34+
// `TerminalRenderer` instance directly and passing it as `{ renderer }` no
35+
// longer works in marked >=15 because TerminalRenderer's constructor sets
36+
// own enumerable properties (`this.o`, `this.tab`, …) and marked iterates
37+
// every enumerable key of the renderer object, rejecting anything that
38+
// isn't a known renderer method.
39+
//
40+
// The @types/marked-terminal declaration mis-types the return as
41+
// `TerminalRenderer`; cast through `unknown` to the correct shape.
42+
const localMarked = new Marked(
43+
markedTerminal({
44+
// Indent code blocks for visual separation
45+
tab: 2,
46+
// Show URLs inline rather than as footnotes
47+
showSectionPrefix: true,
48+
// Convert HTML entities back to characters
49+
unescape: true,
50+
}) as unknown as MarkedExtension,
51+
);
4552

4653
/**
4754
* Render a markdown string as ANSI-formatted terminal output.

tests/markdown-renderer.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// ── Markdown Renderer Tests ────────────────────────────────────────
2+
//
3+
// These tests exist primarily to detect *module-load crashes* in
4+
// `markdown-renderer.ts`. The module wires `marked` + `marked-terminal`
5+
// at import time and any incompatibility between the two pinned versions
6+
// will throw on first require — long before any test exercises the
7+
// rendered output.
8+
//
9+
// Regression: in marked v15 + marked-terminal v7, passing a
10+
// `TerminalRenderer` *instance* via `new Marked({ renderer })` throws
11+
// "renderer 'o' does not exist" because marked enumerates every key on
12+
// the renderer object and the legacy `Renderer` constructor stores
13+
// config as own properties (`this.o`, `this.tab`, …). The fix is to
14+
// use the `markedTerminal()` factory which returns a clean
15+
// `MarkedExtension`. These tests would have caught that bug.
16+
//
17+
// ────────────────────────────────────────────────────────────────────
18+
19+
import { describe, it, expect } from "vitest";
20+
import {
21+
renderMarkdown,
22+
looksLikeMarkdown,
23+
} from "../src/agent/markdown-renderer.js";
24+
25+
describe("renderMarkdown", () => {
26+
it("renders plain text without crashing", () => {
27+
// The smoke test that matters most — if module init fails this
28+
// throws before reaching the assertion.
29+
const out = renderMarkdown("hello world");
30+
expect(typeof out).toBe("string");
31+
expect(out.length).toBeGreaterThan(0);
32+
});
33+
34+
it("renders headings, code, lists and code fences", () => {
35+
const md = [
36+
"# Title",
37+
"",
38+
"**bold** and `inline code`",
39+
"",
40+
"- one",
41+
"- two",
42+
"",
43+
"```js",
44+
"const x = 1;",
45+
"```",
46+
].join("\n");
47+
48+
const out = renderMarkdown(md);
49+
50+
// We don't assert on ANSI codes (they vary by terminal capability)
51+
// — just that the meaningful content survived rendering.
52+
expect(out).toContain("Title");
53+
expect(out).toContain("bold");
54+
expect(out).toContain("inline code");
55+
expect(out).toContain("one");
56+
expect(out).toContain("two");
57+
expect(out).toContain("const x = 1");
58+
});
59+
60+
it("trims trailing newlines", () => {
61+
const out = renderMarkdown("hello\n\n");
62+
expect(out.endsWith("\n")).toBe(false);
63+
});
64+
65+
it("handles empty input", () => {
66+
expect(renderMarkdown("")).toBe("");
67+
});
68+
});
69+
70+
describe("looksLikeMarkdown", () => {
71+
it("detects headings", () => {
72+
expect(looksLikeMarkdown("# Heading")).toBe(true);
73+
expect(looksLikeMarkdown("###### h6")).toBe(true);
74+
});
75+
76+
it("detects fenced code blocks", () => {
77+
expect(looksLikeMarkdown("```js\nconst x = 1;\n```")).toBe(true);
78+
});
79+
80+
it("detects tables", () => {
81+
expect(looksLikeMarkdown("| a | b |\n| - | - |\n| 1 | 2 |")).toBe(true);
82+
});
83+
84+
it("detects ordered lists", () => {
85+
expect(looksLikeMarkdown("1. first\n2. second")).toBe(true);
86+
});
87+
88+
it("rejects plain text and weak signals", () => {
89+
// Bold markers and unordered bullets alone are too noisy to count
90+
// as markdown (false positives on git output, log lines, etc.).
91+
expect(looksLikeMarkdown("just a sentence")).toBe(false);
92+
expect(looksLikeMarkdown("**not strong enough**")).toBe(false);
93+
expect(looksLikeMarkdown("- bullet alone")).toBe(false);
94+
});
95+
});

0 commit comments

Comments
 (0)