Summary
Replace the dead marked-terminal dependency with a custom terminal renderer so we can upgrade marked from 9.1.6 to 15.x (or later).
Motivation
marked-terminal (mikaelbr) is the only markdown-to-terminal renderer on npm, but it has been unmaintained since October 2024 (last release v7.3.0). Its latest version (7.3.0) declares a peer dependency of marked >=1 <17, but the renderer code is incompatible with marked 10+ — the renderer.space() method it relies on was removed in marked 10, causing a runtime crash:
TypeError: this.renderer.space is not a function
This means we're stuck on marked 9.1.6 and cannot benefit from security fixes, performance improvements, or new markdown features in newer versions.
Proposed Solution
Build a custom terminal renderer that extends marked.Renderer and converts HTML output to ANSI terminal text. The renderer must handle all markdown elements and behaviors currently provided by marked-terminal.
Implementation Requirements
The renderer must implement the following renderer methods with parity to marked-terminal's behavior:
- Headings (h1-h6) — with section prefix (e.g., "## ") and configurable styling
- Paragraphs — with optional text reflow to terminal width
- Bold and italic text — with configurable chalk styles
- Inline code and code blocks — with syntax highlighting via
cli-highlight and chalk.level detection
- Links — with hyperlinks via
ansi-escapes.link() when terminal supports it (detected via supports-hyperlinks)
- Unordered and ordered lists — with proper nested list handling (preventing visual joining of parent lines) and ordered list numbering continuation
- Blockquotes — with configurable styling
- Task checkboxes — rendered as "[X]" or "[ ]"
- Tables — via
cli-table3 for cell alignment, borders, and multi-line cell support
- Strikethrough — with configurable styling
- Images — rendered as fallback text
[alt](href)
- HR — rendered as a line of dashes spanning terminal width
Required Infrastructure (Beyond Renderer Methods)
The following infrastructure is required for correct terminal output. These are not optional:
- Text reflow — Wrap text to terminal width while respecting ANSI escape codes. Must use ANSI-aware text length measurement (strip escape codes before measuring).
- Emoji rendering — Convert
:emoji-name: syntax to actual emoji characters using node-emoji.
- Hyperlink support — Detect terminal hyperlink support via
supports-hyperlinks and wrap links with ansi-escapes.link() when available.
- Tab handling — Configurable tab width (default 4 spaces) with sanitization.
- Hard vs soft line breaks — Distinguish
\r (hard break, no reflow) from \n (soft break, reflow allowed) and handle <br /> in GFM mode.
- HTML entity unescaping — Convert
&, <, >, ", ' back to character equivalents.
- Colon escaping in code spans — Replace
: with *#COLON|* internally before emoji processing, then restore after. Prevents emoji matching on colons inside code.
- ANSI-aware text length — Strip ANSI escape codes before measuring string length for reflow and wrapping.
- Nested list handling — Prevent nested list items from visually joining their parent's last line. Track numbering across nested ordered lists.
- Configurable styling — Every rendered element must have a configurable chalk style (heading, blockquote, code, del, etc.), matching
marked-terminal's defaultOptions pattern.
- Chalk level detection — Check
chalk.level === 0 before applying code highlighting, falling back to plain text in color-disabled environments.
Implementation Sketch
import { Renderer } from 'marked';
import chalk from 'chalk';
import { highlight as highlightCli } from 'cli-highlight';
import * as emoji from 'node-emoji';
import ansiEscapes from 'ansi-escapes';
import supportsHyperlinks from 'supports-hyperlinks';
import ansiRegex from 'ansi-regex';
import Table from 'cli-table3';
const ANSI_REGEXP = ansiRegex();
// ANSI-aware text length measurement
function textLength(str) {
return str.replace(ANSI_REGEXP, '').length;
}
// HTML entity unescaping
function unescapeEntities(html) {
return html
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'");
}
// Colon escaping for code spans (prevents emoji matching)
const COLON_REPLACER = '*#COLON|*';
function escapeColon(text) {
return text.replace(/:/g, COLON_REPLACER);
}
function undoColon(str) {
return str.replace(new RegExp(COLON_REPLACER, 'g'), ':');
}
// Text reflow with ANSI awareness
function reflowText(text, width, gfm) {
const HARD_RETURN = '\r';
const HARD_RETURN_RE = new RegExp(HARD_RETURN);
const HARD_RETURN_GFM_RE = new RegExp(HARD_RETURN + '|<br />');
const splitRe = gfm ? HARD_RETURN_GFM_RE : HARD_RETURN_RE;
const sections = text.split(splitRe);
const reflowed = [];
sections.forEach((section) => {
const fragments = section.split(/(\u001b\[(?:\d{1,3})(?:;\d{1,3})*m)/g);
let column = 0;
let currentLine = '';
let lastWasEscapeChar = false;
while (fragments.length) {
const fragment = fragments[0];
if (fragment === '') {
fragments.splice(0, 1);
lastWasEscapeChar = false;
continue;
}
if (!textLength(fragment)) {
currentLine += fragment;
fragments.splice(0, 1);
lastWasEscapeChar = true;
continue;
}
const words = fragment.split(/[ \t\n]+/);
for (let i = 0; i < words.length; i++) {
const word = words[i];
const addSpace = column != 0;
if (lastWasEscapeChar) addSpace = false;
if (column + word.length + addSpace > width) {
if (word.length <= width) {
reflowed.push(currentLine);
currentLine = word;
column = word.length;
} else {
const w = word.substr(0, width - column - addSpace);
if (addSpace) currentLine += ' ';
currentLine += w;
reflowed.push(currentLine);
currentLine = '';
column = 0;
word = word.substr(w.length);
while (word.length) {
const w = word.substr(0, width);
if (!w.length) break;
if (w.length < width) {
currentLine = w;
column = w.length;
break;
} else {
reflowed.push(w);
word = word.substr(width);
}
}
}
} else {
if (addSpace) currentLine += ' ';
currentLine += word;
column += word.length;
}
lastWasEscapeChar = false;
}
}
if (textLength(currentLine)) reflowed.push(currentLine);
});
return reflowed.join('\n');
}
// Nested list handling
const BULLET_POINT_REGEX = '\\*';
const NUMBERED_POINT_REGEX = '\\d+\\.';
const POINT_REGEX = '(?:' + [BULLET_POINT_REGEX, NUMBERED_POINT_REGEX].join('|') + ')';
function fixNestedLists(body, indent) {
const regex = new RegExp(
'(\\S(?: | )?)' +
'((?:' + indent + ')+)' +
'(' + POINT_REGEX + '(?:.*)+)$',
'gm'
);
return body.replace(regex, '$1\n' + indent + '$2$3');
}
function bulletPointLines(lines, indent) {
const BULLET_POINT = '* ';
const isPointedLine = (line) => line.match('^(?:' + indent + ')*' + POINT_REGEX);
return lines
.split('\n')
.filter(Boolean)
.map((line) => {
return isPointedLine(line) ? line : ' '.repeat(BULLET_POINT.length) + line;
})
.join('\n');
}
function numberedLines(lines, indent) {
const isPointedLine = (line) => line.match('^(?:' + indent + ')*' + POINT_REGEX);
let num = 0;
return lines
.split('\n')
.filter(Boolean)
.map((line) => {
if (isPointedLine(line)) {
num++;
return line.replace(/\d+\./, num + '.');
}
return ' '.repeat((num + 1).toString().length + 2) + line;
})
.join('\n');
}
function list(body, ordered, indent) {
body = body.trim();
body = ordered ? numberedLines(body, indent) : bulletPointLines(body, indent);
return body;
}
// Default options matching marked-terminal's defaults
const defaultOptions = {
code: chalk.yellow,
blockquote: chalk.gray.italic,
html: chalk.gray,
heading: chalk.green.bold,
firstHeading: chalk.magenta.underline.bold,
hr: chalk.reset,
listitem: chalk.reset,
list: (body, ordered, indent) => list(body, ordered, indent),
table: chalk.reset,
paragraph: chalk.reset,
strong: chalk.bold,
em: chalk.italic,
codespan: chalk.yellow,
del: chalk.dim.gray.strikethrough,
link: chalk.blue,
href: chalk.blue.underline,
text: (t) => t,
unescape: true,
emoji: true,
width: 80,
showSectionPrefix: true,
tab: 4,
tableOptions: {}
};
class TerminalRenderer extends Renderer {
constructor(options = {}) {
super();
this.o = { ...defaultOptions, ...options };
this.tab = typeof this.o.tab === 'number'
? ' '.repeat(this.o.tab)
: ' '.repeat(this.o.tab.length || 4);
this.emoji = this.o.emoji ? insertEmojis : (t) => t;
this.unescape = this.o.unescape ? unescapeEntities : (t) => t;
this.transform = (t) => undoColon(this.unescape(this.emoji(t)));
}
heading(text, level) {
let processed = this.transform(text);
const prefix = this.o.showSectionPrefix
? new Array(level + 1).join('#') + ' '
: '';
processed = prefix + processed;
if (this.o.reflowText) {
processed = reflowText(processed, this.o.width, this.options?.gfm);
}
const style = level === 1 ? this.o.firstHeading : this.o.heading;
return style(processed) + '\n\n';
}
paragraph(text) {
let processed = this.parser.parseInline(text.tokens);
processed = this.transform(processed);
if (this.o.reflowText) {
processed = reflowText(processed, this.o.width, this.options?.gfm);
}
return this.o.paragraph(processed) + '\n\n';
}
strong(text) {
const processed = this.parser.parseInline(text.tokens);
return this.o.strong(processed);
}
em(text) {
let processed = this.parser.parseInline(text.tokens);
processed = fixHardReturn(processed, this.o.reflowText);
return this.o.em(processed);
}
codespan(text) {
let processed = text.text;
processed = fixHardReturn(processed, this.o.reflowText);
return this.o.codespan(escapeColon(processed));
}
code(code, lang) {
if (typeof code === 'object') {
lang = code.lang;
code = code.text;
}
code = fixHardReturn(code, this.o.reflowText);
if (chalk.level === 0) {
return this.o.code(code) + '\n\n';
}
try {
const highlighted = highlightCli(code, { language: lang });
return this.o.code(highlighted) + '\n\n';
} catch {
return this.o.code(code) + '\n\n';
}
}
blockquote(quote) {
let processed = typeof quote === 'object'
? this.parser.parse(quote.tokens)
: quote;
processed = indentify(this.tab, processed.trim());
return this.o.blockquote(processed) + '\n\n';
}
link(href, title, text) {
if (typeof href === 'object') {
title = href.title;
text = this.parser.parseInline(href.tokens);
href = href.href;
}
if (this.options?.sanitize) {
try {
const prot = decodeURIComponent(unescape(href))
.replace(/[^\w:]/g, '')
.toLowerCase();
if (prot.indexOf('javascript:') === 0) return '';
} catch {
return '';
}
}
const hasText = text && text !== href;
let out = '';
if (supportsHyperlinks.stdout) {
const linkText = hasText ? this.emoji(text) : this.emoji(href);
const link = this.o.href(linkText);
out = ansiEscapes.link(
link,
href.replace(/\+/g, '%20')
);
} else {
if (hasText) out += this.emoji(text) + ' (';
out += this.o.href(href);
if (hasText) out += ')';
}
return this.o.link(out);
}
image(href, title, text) {
if (typeof href === 'object') {
title = href.title;
text = href.text;
href = href.href;
}
let out = '\n';
}
list(body, ordered) {
if (typeof body === 'object') {
const listToken = body;
const start = listToken.start;
const loose = listToken.loose;
ordered = listToken.ordered;
body = '';
for (let j = 0; j < listToken.items.length; j++) {
body += this.listitem(listToken.items[j]);
}
}
body = this.o.list(body, ordered, this.tab);
return fixNestedLists(indentLines(this.tab, body), this.tab) + '\n\n';
}
listitem(item) {
let text = '';
if (item.task) {
const checkbox = this.checkbox({ checked: !!item.checked });
if (item.loose) {
if (item.tokens.length > 0 && item.tokens[0].type === 'paragraph') {
item.tokens[0].text = checkbox + ' ' + item.tokens[0].text;
if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') {
item.tokens[0].tokens[0].text = checkbox + ' ' + item.tokens[0].tokens[0].text;
}
} else {
item.tokens.unshift({
type: 'text',
raw: checkbox + ' ',
text: checkbox + ' '
});
}
} else {
text += checkbox + ' ';
}
}
text += this.parser.parse(item.tokens, !!item.loose);
var transform = (t) => this.o.listitem(t);
var isNested = text.indexOf('\n') !== -1;
if (isNested) text = text.trim();
return '\n' + '* ' + transform(text);
}
checkbox(checked) {
if (typeof checked === 'object') {
checked = checked.checked;
}
return '[' + (checked ? 'X' : ' ') + '] ';
}
table(header, body) {
if (typeof header === 'object') {
const token = header;
let cell = '';
for (let j = 0; j < token.header.length; j++) {
cell += this.tablecell(token.header[j]);
}
header = this.tablerow({ text: cell });
body = '';
for (let j = 0; j < token.rows.length; j++) {
const row = token.rows[j];
cell = '';
for (let k = 0; k < row.length; k++) {
cell += this.tablecell(row[k]);
}
body += this.tablerow({ text: cell });
}
}
const table = new Table(
Object.assign(
{},
{ head: generateTableRow(header)[0] },
this.o.tableOptions
)
);
generateTableRow(body, this.transform).forEach((row) => {
table.push(row);
});
return this.o.table(table.toString()) + '\n\n';
}
tablerow(content) {
if (typeof content === 'object') {
content = content.text;
}
return '*|*|*|*' + content + '*|*|*|*\n';
}
tablecell(content) {
if (typeof content === 'object') {
content = this.parser.parseInline(content.tokens);
}
return content + '^*||*^';
}
hr() {
const width = this.o.reflowText ? this.o.width : process.stdout.columns;
const line = new Array(width + 1).join('-');
return this.o.hr(line) + '\n\n';
}
del(text) {
const processed = this.parser.parseInline(text.tokens);
return this.o.del(processed);
}
br() {
return this.o.reflowText ? '\r' : '\n';
}
html(html) {
if (typeof html === 'object') {
html = html.text;
}
return this.o.html(html);
}
text(text) {
if (typeof text === 'object') {
text = text.text;
}
return this.o.text(text);
}
unescape(text) {
if (typeof text === 'object') {
text = text.text;
}
return this.o.unescape ? unescapeEntities(text) : text;
}
}
function fixHardReturn(text, reflow) {
return reflow ? text.replace(/\r/g, '\n') : text;
}
function indentify(indent, text) {
if (!text) return text;
return indent + text.split('\n').join('\n' + indent);
}
function indentLines(indent, text) {
return text.replace(/(^|\n)(.+)/g, '$1' + indent + '$2');
}
function insertEmojis(text) {
return text.replace(/:([A-Za-z0-9_\-\+]+?):/g, (emojiString) => {
const emojiSign = emoji.get(emojiString);
if (!emojiSign) return emojiString;
return emojiSign + ' ';
});
}
function generateTableRow(text, escape) {
if (!text) return [];
escape = escape || ((t) => t);
const lines = escape(text).split('\n');
const data = [];
lines.forEach((line) => {
if (!line) return;
const parsed = line
.replace(/\*[\|]+\*/g, '')
.split(/\^[\*]+\|\*[\^]/);
data.push(parsed.splice(0, parsed.length - 1));
});
return data;
}
// Export for use with marked.setOptions()
export function createTerminalRenderer(options = {}) {
return new TerminalRenderer(options);
}
Key design decisions:
- Use
chalk for ANSI styling (already a transitive dependency via marked-terminal)
- Use
cli-highlight for code block syntax highlighting (already a transitive dependency)
- Use
cli-table3 for table rendering (cell alignment, borders, multi-line cells)
- Use
node-emoji for emoji rendering (:emoji-name: syntax)
- Use
supports-hyperlinks for terminal hyperlink detection
- Use
ansi-escapes for hyperlink wrapping
- Keep the API compatible with
marked.setOptions({ renderer: new TerminalRenderer() })
- No external dependencies beyond what marked-terminal already pulled in
Estimated effort: ~400-600 lines for full parity with marked-terminal's behavior. A minimal version covering only basic formatting (no reflow, no emoji, no hyperlinks, basic tables) would be ~150-200 lines but would produce noticeably worse output.
Alternatives Considered
- Stay on marked 9.1.6 — No breaking changes, everything works. Rejected because we miss years of security fixes and improvements.
- Strip HTML entirely — Use
marked.parse() for HTML, then strip tags and render plain text. Rejected because we lose all formatting (bold, italic, code, links, lists).
- Use a different markdown parser — e.g.,
markdown-it. Rejected because it would require rewriting the entire markdown pipeline and we'd lose compatibility with existing markdown content.
OpenSpec Note
This project uses OpenSpec for feature development. If this request is approved, I will:
- Run
/opsx:propose to generate a full proposal with specs and tasks
- Iterate on the design before any code is written
- Follow the task-driven implementation workflow
Additional Context
Current dependency chain:
marked@9.1.6 → marked-terminal@7.3.0 (peer dep marked >=1 <16)
marked-terminal depends on chalk@5.4.1, cli-highlight@2.1.11, ansi-escapes@7.0.0, cli-table3@0.6.5, node-emoji@2.2.0, supports-hyperlinks@3.1.0, ansi-regex@6.1.0
Files that would need changes:
src/tui/markdownText.js — Replace marked-terminal import with custom renderer
package.json — Remove marked-terminal, update marked version, add node-emoji and supports-hyperlinks if not already present
Test impact:
tests/unit/tui.test.js — Tests that use MarkdownTextInner will need updating since the renderer output format will change
Environment:
- OS: Linux 7.0.6-2-pve
- Node.js: v25.8.1
- madz version: 1.35.0
Audit Findings (for Issue #595)
- src/tui/markdownText.js — Single file using both
marked and marked-terminal. The markedTerminal() call at line 4 and setOptions() at line 7 are the only integration points. Replacing with a custom renderer is a straightforward swap.
- tests/unit/tui.test.js — Tests at lines 803-836 exercise
MarkdownTextInner with various markdown inputs. These tests assert on React element structure (type, color, children) rather than rendered output strings, so they should survive the renderer swap with minimal changes.
- package.json —
marked-terminal is a direct dependency with no other package depending on it. Safe to remove.
- No other code paths —
marked and marked-terminal are only imported in markdownText.js. No other files reference them.
Summary
Replace the dead
marked-terminaldependency with a custom terminal renderer so we can upgrademarkedfrom 9.1.6 to 15.x (or later).Motivation
marked-terminal(mikaelbr) is the only markdown-to-terminal renderer on npm, but it has been unmaintained since October 2024 (last release v7.3.0). Its latest version (7.3.0) declares a peer dependency ofmarked >=1 <17, but the renderer code is incompatible with marked 10+ — therenderer.space()method it relies on was removed in marked 10, causing a runtime crash:This means we're stuck on marked 9.1.6 and cannot benefit from security fixes, performance improvements, or new markdown features in newer versions.
Proposed Solution
Build a custom terminal renderer that extends
marked.Rendererand converts HTML output to ANSI terminal text. The renderer must handle all markdown elements and behaviors currently provided bymarked-terminal.Implementation Requirements
The renderer must implement the following renderer methods with parity to
marked-terminal's behavior:cli-highlightandchalk.leveldetectionansi-escapes.link()when terminal supports it (detected viasupports-hyperlinks)cli-table3for cell alignment, borders, and multi-line cell support[alt](href)Required Infrastructure (Beyond Renderer Methods)
The following infrastructure is required for correct terminal output. These are not optional:
:emoji-name:syntax to actual emoji characters usingnode-emoji.supports-hyperlinksand wrap links withansi-escapes.link()when available.\r(hard break, no reflow) from\n(soft break, reflow allowed) and handle<br />in GFM mode.&,<,>,",'back to character equivalents.:with*#COLON|*internally before emoji processing, then restore after. Prevents emoji matching on colons inside code.marked-terminal'sdefaultOptionspattern.chalk.level === 0before applying code highlighting, falling back to plain text in color-disabled environments.Implementation Sketch
Key design decisions:
chalkfor ANSI styling (already a transitive dependency via marked-terminal)cli-highlightfor code block syntax highlighting (already a transitive dependency)cli-table3for table rendering (cell alignment, borders, multi-line cells)node-emojifor emoji rendering (:emoji-name:syntax)supports-hyperlinksfor terminal hyperlink detectionansi-escapesfor hyperlink wrappingmarked.setOptions({ renderer: new TerminalRenderer() })Estimated effort: ~400-600 lines for full parity with
marked-terminal's behavior. A minimal version covering only basic formatting (no reflow, no emoji, no hyperlinks, basic tables) would be ~150-200 lines but would produce noticeably worse output.Alternatives Considered
marked.parse()for HTML, then strip tags and render plain text. Rejected because we lose all formatting (bold, italic, code, links, lists).markdown-it. Rejected because it would require rewriting the entire markdown pipeline and we'd lose compatibility with existing markdown content.OpenSpec Note
This project uses OpenSpec for feature development. If this request is approved, I will:
/opsx:proposeto generate a full proposal with specs and tasksAdditional Context
Current dependency chain:
marked@9.1.6→marked-terminal@7.3.0(peer depmarked >=1 <16)marked-terminaldepends onchalk@5.4.1,cli-highlight@2.1.11,ansi-escapes@7.0.0,cli-table3@0.6.5,node-emoji@2.2.0,supports-hyperlinks@3.1.0,ansi-regex@6.1.0Files that would need changes:
src/tui/markdownText.js— Replacemarked-terminalimport with custom rendererpackage.json— Removemarked-terminal, updatemarkedversion, addnode-emojiandsupports-hyperlinksif not already presentTest impact:
tests/unit/tui.test.js— Tests that useMarkdownTextInnerwill need updating since the renderer output format will changeEnvironment:
Audit Findings (for Issue #595)
markedandmarked-terminal. ThemarkedTerminal()call at line 4 andsetOptions()at line 7 are the only integration points. Replacing with a custom renderer is a straightforward swap.MarkdownTextInnerwith various markdown inputs. These tests assert on React element structure (type, color, children) rather than rendered output strings, so they should survive the renderer swap with minimal changes.marked-terminalis a direct dependency with no other package depending on it. Safe to remove.markedandmarked-terminalare only imported inmarkdownText.js. No other files reference them.