From ab6c2e75d11726f9e67e0698d08b6f51e234bfa4 Mon Sep 17 00:00:00 2001 From: Stephen Hellicar Date: Wed, 8 Apr 2026 03:08:05 +1000 Subject: [PATCH] Stop highlight.js unknown-language warnings bleeding into the terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli-highlight writes to stderr before throwing when a language isn't registered. The existing try/catch swallowed the throw but not the warning, so the text appeared inline in the alt buffer every time a code fence with an unrecognised language (e.g. jsonl) was rendered. Fix: gate the highlight() call behind supportsLanguage(). Unknown languages fall back to plain rendering with no stderr output at all. Also add a LANGUAGE_ALIASES map. jsonl maps to json so those fences get JSON syntax colouring rather than a plain fallback. The fence header still shows the original language name — only the highlighter sees the alias. Closes #216 --- apps/claude-sdk-cli/src/renderConversation.ts | 31 +++++++++++++------ .../test/renderConversation.spec.ts | 27 ++++++++++++++++ 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/claude-sdk-cli/src/renderConversation.ts b/apps/claude-sdk-cli/src/renderConversation.ts index b51b311..aeea77c 100644 --- a/apps/claude-sdk-cli/src/renderConversation.ts +++ b/apps/claude-sdk-cli/src/renderConversation.ts @@ -1,6 +1,6 @@ import { DIM, RESET } from '@shellicar/claude-core/ansi'; import { wrapLine } from '@shellicar/claude-core/reflow'; -import { highlight } from 'cli-highlight'; +import { highlight, supportsLanguage } from 'cli-highlight'; import type { Block, ConversationState } from './ConversationState.js'; const FILL = '\u2500'; @@ -27,6 +27,24 @@ const CONTENT_INDENT = ' '; const CODE_FENCE_RE = /```(\w*)\n([\s\S]*?)```/g; +// Some fence language identifiers don't match highlight.js names. +// Map them so we get proper syntax colouring instead of a silent fallback. +const LANGUAGE_ALIASES: Record = { + jsonl: 'json', +}; + +function getHighlighted(code: string, lang: string): string[] { + const hlLang = LANGUAGE_ALIASES[lang] ?? lang; + if (!supportsLanguage(hlLang)) { + return code.split('\n'); + } + try { + return highlight(code, { language: hlLang, ignoreIllegals: true }).split('\n'); + } catch { + return code.split('\n'); + } +} + function renderBlockContent(content: string, cols: number): string[] { const result: string[] = []; let lastIndex = 0; @@ -46,15 +64,8 @@ function renderBlockContent(content: string, cols: number): string[] { const lang = match[1] || 'plaintext'; const code = (match[2] ?? '').trimEnd(); result.push(`${CONTENT_INDENT}\`\`\`${lang}`); - try { - const highlighted = highlight(code, { language: lang, ignoreIllegals: true }); - for (const line of highlighted.split('\n')) { - result.push(CONTENT_INDENT + line); - } - } catch { - for (const line of code.split('\n')) { - result.push(CONTENT_INDENT + line); - } + for (const line of getHighlighted(code, lang)) { + result.push(CONTENT_INDENT + line); } result.push(`${CONTENT_INDENT}\`\`\``); lastIndex = match.index + match[0].length; diff --git a/apps/claude-sdk-cli/test/renderConversation.spec.ts b/apps/claude-sdk-cli/test/renderConversation.spec.ts index 3777a8f..7331c61 100644 --- a/apps/claude-sdk-cli/test/renderConversation.spec.ts +++ b/apps/claude-sdk-cli/test/renderConversation.spec.ts @@ -147,3 +147,30 @@ describe('buildDivider', () => { expect(actual).toBe(true); }); }); + +describe('renderConversation — code fence highlighting', () => { + it('renders code from an unknown language without warning (plain fallback)', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: '```unknownxyz\nfoo bar\n```' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('foo bar')); + expect(actual).toBe(true); + }); + + it('preserves the original fence label even when an alias is used for highlighting', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: '```jsonl\n{"key": 1}\n```' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + // Fence header should show the original language name, not the alias + const actual = lines.some((l) => l.includes('```jsonl')); + expect(actual).toBe(true); + }); + + it('renders jsonl code content', () => { + const state = new ConversationState(); + state.addBlocks([{ type: 'response', content: '```jsonl\n{"key": 1}\n```' }]); + const lines = renderConversation(state, 80).map(stripAnsi); + const actual = lines.some((l) => l.includes('"key"')); + expect(actual).toBe(true); + }); +});