Skip to content

feat: replace marked-terminal with custom terminal renderer #595

Description

@avoidwork

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:

  1. Text reflow — Wrap text to terminal width while respecting ANSI escape codes. Must use ANSI-aware text length measurement (strip escape codes before measuring).
  2. Emoji rendering — Convert :emoji-name: syntax to actual emoji characters using node-emoji.
  3. Hyperlink support — Detect terminal hyperlink support via supports-hyperlinks and wrap links with ansi-escapes.link() when available.
  4. Tab handling — Configurable tab width (default 4 spaces) with sanitization.
  5. Hard vs soft line breaks — Distinguish \r (hard break, no reflow) from \n (soft break, reflow allowed) and handle <br /> in GFM mode.
  6. HTML entity unescaping — Convert &amp;, &lt;, &gt;, &quot;, &#39; back to character equivalents.
  7. Colon escaping in code spans — Replace : with *#COLON|* internally before emoji processing, then restore after. Prevents emoji matching on colons inside code.
  8. ANSI-aware text length — Strip ANSI escape codes before measuring string length for reflow and wrapping.
  9. Nested list handling — Prevent nested list items from visually joining their parent's last line. Track numbering across nested ordered lists.
  10. Configurable styling — Every rendered element must have a configurable chalk style (heading, blockquote, code, del, etc.), matching marked-terminal's defaultOptions pattern.
  11. 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(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/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 = '![' + text;
    if (title) out += ' – ' + title;
    return out + '](' + href + ')\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

  1. Stay on marked 9.1.6 — No breaking changes, everything works. Rejected because we miss years of security fixes and improvements.
  2. 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).
  3. 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:

  1. Run /opsx:propose to generate a full proposal with specs and tasks
  2. Iterate on the design before any code is written
  3. Follow the task-driven implementation workflow

Additional Context

Current dependency chain:

  • marked@9.1.6marked-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.jsonmarked-terminal is a direct dependency with no other package depending on it. Safe to remove.
  • No other code pathsmarked and marked-terminal are only imported in markdownText.js. No other files reference them.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions