Skip to content

Commit 2bd8b38

Browse files
committed
fix: make piped output human-readable instead of raw CommonMark
Two problems are fixed: 1. FORCE_COLOR leaking ANSI to pipes: When FORCE_COLOR=1 is set (common in VS Code terminals), ANSI escape codes leaked through to less/cat because isPlainOutput() treated FORCE_COLOR as higher priority than TTY detection. Now FORCE_COLOR only applies when stdout IS a TTY. Users who truly want color in pipes can use SENTRY_PLAIN_OUTPUT=0. 2. Plain mode outputs raw CommonMark: When piped, the CLI produced raw markdown syntax (| --- |, **bold**, [link](url), `code`) which is unreadable in a pager. Now plain mode: - renderMarkdown() parses and renders to structured plain text (headings, aligned tables, blockquote indentation) then strips ANSI - formatTable() uses renderTextTable() with box-drawing borders instead of buildMarkdownTable() with raw CommonMark syntax - renderInlineMarkdown() strips markdown syntax via stripMarkdownInline() - terminalLink() returns plain text (no OSC 8 sequences) when piped - divider() and footer/hint text use plainSafeMuted() to avoid ANSI Architecture: isPlainOutput() extracted to plain-detect.ts to avoid circular dependency between markdown.ts and colors.ts. Re-exported from markdown.ts for backward compatibility.
1 parent 0cf6f36 commit 2bd8b38

File tree

16 files changed

+367
-185
lines changed

16 files changed

+367
-185
lines changed

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,8 @@ All non-trivial human output must use the markdown rendering pipeline:
283283
- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `escapeMarkdownCell()`, `renderMarkdown()`
284284
- **NEVER** use raw `muted()` / chalk in output strings — use `colorTag("muted", text)` inside markdown
285285
- Tree-structured output (box-drawing characters) that can't go through `renderMarkdown()` should use the `plainSafeMuted` pattern: `isPlainOutput() ? text : muted(text)`
286-
- `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` > `!isTTY`
286+
- `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` (TTY only) > `!isTTY`
287+
- `isPlainOutput()` lives in `src/lib/formatters/plain-detect.ts` (re-exported from `markdown.ts` for compat)
287288

288289
Reference: `formatters/trace.ts` (`formatAncestorChain`), `formatters/human.ts` (`plainSafeMuted`)
289290

biome.jsonc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@
6262
// db/index.ts exports db connection utilities - not a barrel file but triggers the rule
6363
// api-client.ts is a barrel that re-exports from src/lib/api/ domain modules
6464
// to preserve the existing import path for all consumers
65-
"includes": ["src/lib/db/index.ts", "src/lib/api-client.ts"],
65+
// markdown.ts re-exports isPlainOutput from plain-detect.ts for backward compat
66+
"includes": [
67+
"src/lib/db/index.ts",
68+
"src/lib/api-client.ts",
69+
"src/lib/formatters/markdown.ts"
70+
],
6671
"linter": {
6772
"rules": {
6873
"performance": {

src/lib/formatters/colors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import chalk from "chalk";
88
import type { IssueLevel, IssueStatus } from "../../types/index.js";
9+
import { isPlainOutput } from "./plain-detect.js";
910

1011
// Color Palette (Full Sentinel palette)
1112

@@ -57,6 +58,9 @@ export const boldUnderline = (text: string): string =>
5758
* @returns Text wrapped in OSC 8 hyperlink escape sequences
5859
*/
5960
export function terminalLink(text: string, url: string = text): string {
61+
if (isPlainOutput()) {
62+
return text;
63+
}
6064
// OSC 8 ; params ; URI BEL text OSC 8 ; ; BEL
6165
// \x1b] opens the OSC sequence; \x07 (BEL) terminates it.
6266
// Using BEL instead of ST (\x1b\\) for broad terminal compatibility.

src/lib/formatters/markdown.ts

Lines changed: 46 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -13,76 +13,21 @@
1313
* Pre-rendered ANSI escape codes embedded in markdown source are preserved
1414
* — `string-width` correctly treats them as zero-width.
1515
*
16-
* ## Output mode resolution (highest → lowest priority)
16+
* ## Output mode resolution
1717
*
18-
* 1. `SENTRY_PLAIN_OUTPUT=1` → plain (raw CommonMark)
19-
* 2. `SENTRY_PLAIN_OUTPUT=0` → rendered (force rich, even when piped)
20-
* 3. `NO_COLOR=1` (or any truthy value) → plain
21-
* 4. `NO_COLOR=0` (or any falsy value) → rendered
22-
* 5. `!process.stdout.isTTY` → plain
23-
* 6. default (TTY, no overrides) → rendered
18+
* See {@link isPlainOutput} in `plain-detect.ts` for the full priority chain.
2419
*/
2520

2621
import chalk from "chalk";
2722
import { highlight as cliHighlight } from "cli-highlight";
2823
import { marked, type Token, type Tokens } from "marked";
2924
import stringWidth from "string-width";
3025
import { COLORS, muted, terminalLink } from "./colors.js";
26+
import { isPlainOutput, stripAnsi } from "./plain-detect.js";
3127
import { type Alignment, renderTextTable } from "./text-table.js";
3228

33-
// ──────────────────────────── Environment ─────────────────────────────
34-
35-
/**
36-
* Returns true if an env var value should be treated as "truthy" for
37-
* purposes of enabling/disabling output modes.
38-
*
39-
* Falsy values: `"0"`, `"false"`, `""` (case-insensitive).
40-
* Everything else (e.g. `"1"`, `"true"`, `"yes"`) is truthy.
41-
*/
42-
function isTruthyEnv(val: string): boolean {
43-
const normalized = val.toLowerCase().trim();
44-
return normalized !== "0" && normalized !== "false" && normalized !== "";
45-
}
46-
47-
/**
48-
* Determines whether output should be plain CommonMark markdown (no ANSI).
49-
*
50-
* Evaluated fresh on each call so tests can flip env vars between assertions
51-
* and changes to `process.stdout.isTTY` are picked up immediately.
52-
*
53-
* Priority (highest first):
54-
* 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override (custom
55-
* semantics: `"0"` / `"false"` / `""` force color on)
56-
* 2. `NO_COLOR` — follows the no-color.org spec: any **non-empty** value
57-
* disables color, regardless of its content (including `"0"` / `"false"`)
58-
* 3. `FORCE_COLOR` — follows chalk/supports-color convention: `"0"` forces
59-
* color off (plain), any other non-empty value (e.g. `"1"`) forces color on.
60-
* 4. `process.stdout.isTTY` — auto-detect interactive terminal
61-
*/
62-
export function isPlainOutput(): boolean {
63-
const plain = process.env.SENTRY_PLAIN_OUTPUT;
64-
if (plain !== undefined) {
65-
return isTruthyEnv(plain);
66-
}
67-
68-
// no-color.org spec: presence of a non-empty value disables color.
69-
// Unlike SENTRY_PLAIN_OUTPUT, "0" and "false" still mean "disable color".
70-
const noColor = process.env.NO_COLOR;
71-
if (noColor !== undefined) {
72-
return noColor !== "";
73-
}
74-
75-
// FORCE_COLOR follows the chalk/supports-color convention:
76-
// "0" → force disable color (plain output)
77-
// "1"/"2"/"3" or any other non-empty, non-"0" → force enable color
78-
// Checked after NO_COLOR so that NO_COLOR always wins if both are set.
79-
const forceColor = process.env.FORCE_COLOR;
80-
if (forceColor !== undefined && forceColor !== "") {
81-
return forceColor === "0";
82-
}
83-
84-
return !process.stdout.isTTY;
85-
}
29+
// Re-export isPlainOutput so existing importers don't break
30+
export { isPlainOutput } from "./plain-detect.js";
8631

8732
// ──────────────────────────── Escape helpers ──────────────────────────
8833

@@ -150,7 +95,7 @@ export function mdTableHeader(cols: readonly string[]): string {
15095
*/
15196
export function mdRow(cells: readonly string[]): string {
15297
if (isPlainOutput()) {
153-
return `| ${cells.map(stripColorTags).join(" | ")} |\n`;
98+
return `| ${cells.map(stripMarkdownInline).join(" | ")} |\n`;
15499
}
155100
const out = cells.map((c) =>
156101
renderInline(marked.lexer(c).flatMap(flattenInline)).replace(
@@ -191,10 +136,11 @@ export function mdKvTable(
191136
}
192137

193138
/**
194-
* Render a muted horizontal rule.
139+
* Render a horizontal rule. Muted (dimmed) in TTY mode, plain in pipes.
195140
*/
196141
export function divider(width = 80): string {
197-
return muted("\u2500".repeat(width));
142+
const line = "\u2500".repeat(width);
143+
return isPlainOutput() ? line : muted(line);
198144
}
199145

200146
// ──────────────────────── Inline token rendering ─────────────────────
@@ -265,6 +211,25 @@ export function stripColorTags(text: string): string {
265211
return result;
266212
}
267213

214+
/**
215+
* Strip inline markdown syntax to produce plain text.
216+
*
217+
* Removes bold/italic markers, link syntax (keeps display text), code
218+
* backticks, and color tags. Used for plain-mode output that should be
219+
* human-readable without any markdown artifacts.
220+
*/
221+
export function stripMarkdownInline(md: string): string {
222+
let text = stripColorTags(md);
223+
// Links: [text](url) → text
224+
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
225+
// Bold/italic: **text** or __text__ → text, *text* or _text_ → text
226+
text = text.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1");
227+
text = text.replace(/_{1,2}([^_]+)_{1,2}/g, "$1");
228+
// Code spans: `text` → text
229+
text = text.replace(/`([^`]+)`/g, "$1");
230+
return text;
231+
}
232+
268233
/**
269234
* Render an inline HTML token as a color-tagged string.
270235
*
@@ -554,9 +519,6 @@ function renderList(list: Tokens.List, depth = 0): string {
554519
*
555520
* Converts marked's `Tokens.Table` into headers + rows + alignments and
556521
* delegates to `renderTextTable()` for column fitting and box drawing.
557-
*
558-
* Empty header rows (e.g. from {@link mdKvTable} without a heading) are
559-
* auto-hidden by `renderTextTable` — no explicit detection needed here.
560522
*/
561523
function renderTableToken(table: Tokens.Table): string {
562524
const headers = table.header.map((cell) => renderInline(cell.tokens));
@@ -580,37 +542,42 @@ function renderTableToken(table: Tokens.Table): string {
580542
// ──────────────────────── Public API ─────────────────────────────────
581543

582544
/**
583-
* Render a full markdown document as styled terminal output, or return
584-
* the raw CommonMark string when in plain mode.
545+
* Render a full markdown document as styled terminal output.
546+
*
547+
* In TTY mode: uses `marked.lexer()` to tokenize and a custom block/inline
548+
* renderer for ANSI output. Tables are rendered with Unicode box-drawing
549+
* borders via the text-table module.
585550
*
586-
* Uses `marked.lexer()` to tokenize and a custom block/inline renderer
587-
* for ANSI output. Tables are rendered with Unicode box-drawing borders
588-
* via the text-table module.
551+
* In plain mode: parses and renders the same way (preserving structural
552+
* formatting — headings, aligned tables, blockquote indentation, lists),
553+
* then strips ANSI codes. This produces human-readable output rather than
554+
* raw CommonMark source.
589555
*
590556
* @param md - Markdown source text
591-
* @returns Styled terminal string (TTY) or raw CommonMark (non-TTY / plain mode)
557+
* @returns Styled terminal string (TTY) or clean plain text (non-TTY / plain mode)
592558
*/
593559
export function renderMarkdown(md: string): string {
594560
if (isPlainOutput()) {
595-
// Strip color tags so <red>text</red> doesn't leak as literal markup in
596-
// piped / CI / redirected output (documented "plain mode" contract).
597-
return stripColorTags(md).trimEnd();
561+
// Parse and render to get structural formatting (headings, tables, lists),
562+
// then strip ANSI codes. This produces human-readable output with aligned
563+
// tables and proper heading emphasis, without ANSI escape sequences.
564+
const tokens = marked.lexer(md);
565+
return stripAnsi(renderBlocks(tokens)).trimEnd();
598566
}
599567
const tokens = marked.lexer(md);
600568
return renderBlocks(tokens).trimEnd();
601569
}
602570

603571
/**
604572
* Render inline markdown (bold, code spans, emphasis, links) as styled
605-
* terminal output, or return the raw markdown string when in plain mode.
573+
* terminal output, or return plain text when in plain mode.
606574
*
607575
* @param md - Inline markdown text
608-
* @returns Styled string (TTY) or raw markdown text (non-TTY / plain mode)
576+
* @returns Styled string (TTY) or plain text with markdown syntax stripped (non-TTY / plain mode)
609577
*/
610578
export function renderInlineMarkdown(md: string): string {
611579
if (isPlainOutput()) {
612-
// Strip color tags for the same reason as renderMarkdown.
613-
return stripColorTags(md);
580+
return stripMarkdownInline(md);
614581
}
615582
const tokens = marked.lexer(md);
616583
return renderInline(tokens.flatMap(flattenInline));

src/lib/formatters/output.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import type { Writer } from "../../types/index.js";
3030
import { muted } from "./colors.js";
3131
import { formatJson, writeJson } from "./json.js";
32+
import { isPlainOutput } from "./plain-detect.js";
3233

3334
// ---------------------------------------------------------------------------
3435
// Shared option types
@@ -326,17 +327,22 @@ export function writeOutput<T>(
326327
stdout.write(`${text}\n`);
327328

328329
if (options.hint) {
329-
stdout.write(`\n${muted(options.hint)}\n`);
330+
stdout.write(`\n${plainSafeMuted(options.hint)}\n`);
330331
}
331332

332333
if (options.footer) {
333334
writeFooter(stdout, options.footer);
334335
}
335336
}
336337

337-
/** Format footer text (muted, with surrounding newlines). */
338+
/** Apply muted styling only in TTY mode; return plain text when piped. */
339+
function plainSafeMuted(text: string): string {
340+
return isPlainOutput() ? text : muted(text);
341+
}
342+
343+
/** Format footer text (muted in TTY, plain when piped, with surrounding newlines). */
338344
export function formatFooter(text: string): string {
339-
return `\n${muted(text)}\n`;
345+
return `\n${plainSafeMuted(text)}\n`;
340346
}
341347

342348
/**

src/lib/formatters/plain-detect.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Plain-output detection and ANSI stripping utilities.
3+
*
4+
* Extracted to its own module to avoid circular dependencies between
5+
* `markdown.ts` (which imports from `colors.ts`) and `colors.ts`
6+
* (which needs `isPlainOutput()` to gate terminal hyperlinks).
7+
*
8+
* ## Output mode resolution (highest → lowest priority)
9+
*
10+
* 1. `SENTRY_PLAIN_OUTPUT=1` → plain
11+
* 2. `SENTRY_PLAIN_OUTPUT=0` → rendered (force rich, even when piped)
12+
* 3. `NO_COLOR` (any non-empty value) → plain
13+
* 4. `FORCE_COLOR=0` → plain (only when stdout is a TTY)
14+
* 5. `FORCE_COLOR=1` on a TTY → rendered
15+
* 6. `!process.stdout.isTTY` → plain
16+
* 7. default (TTY, no overrides) → rendered
17+
*/
18+
19+
/**
20+
* Returns true if an env var value should be treated as "truthy" for
21+
* purposes of enabling/disabling output modes.
22+
*
23+
* Falsy values: `"0"`, `"false"`, `""` (case-insensitive).
24+
* Everything else (e.g. `"1"`, `"true"`, `"yes"`) is truthy.
25+
*/
26+
function isTruthyEnv(val: string): boolean {
27+
const normalized = val.toLowerCase().trim();
28+
return normalized !== "0" && normalized !== "false" && normalized !== "";
29+
}
30+
31+
/**
32+
* Determines whether output should be plain (no ANSI codes, no raw
33+
* markdown syntax).
34+
*
35+
* Evaluated fresh on each call so tests can flip env vars between assertions
36+
* and changes to `process.stdout.isTTY` are picked up immediately.
37+
*
38+
* Priority (highest first):
39+
* 1. `SENTRY_PLAIN_OUTPUT` — explicit project-specific override (custom
40+
* semantics: `"0"` / `"false"` / `""` force color on)
41+
* 2. `NO_COLOR` — follows the no-color.org spec: any **non-empty** value
42+
* disables color, regardless of its content (including `"0"` / `"false"`)
43+
* 3. `FORCE_COLOR` — follows chalk/supports-color convention, but only
44+
* applies to interactive terminals. When stdout is piped, FORCE_COLOR
45+
* is ignored so that `cmd | less` always produces clean output.
46+
* Users who truly want color in pipes can use `SENTRY_PLAIN_OUTPUT=0`.
47+
* 4. `process.stdout.isTTY` — auto-detect interactive terminal
48+
*/
49+
export function isPlainOutput(): boolean {
50+
const plain = process.env.SENTRY_PLAIN_OUTPUT;
51+
if (plain !== undefined) {
52+
return isTruthyEnv(plain);
53+
}
54+
55+
// no-color.org spec: presence of a non-empty value disables color.
56+
// Unlike SENTRY_PLAIN_OUTPUT, "0" and "false" still mean "disable color".
57+
const noColor = process.env.NO_COLOR;
58+
if (noColor !== undefined) {
59+
return noColor !== "";
60+
}
61+
62+
// FORCE_COLOR only applies to interactive terminals. When stdout is
63+
// piped/redirected, FORCE_COLOR is ignored so that `cmd | less` always
64+
// produces clean output without ANSI codes.
65+
const forceColor = process.env.FORCE_COLOR;
66+
if (process.stdout.isTTY && forceColor !== undefined && forceColor !== "") {
67+
return forceColor === "0";
68+
}
69+
70+
return !process.stdout.isTTY;
71+
}
72+
73+
/**
74+
* Strip ANSI escape sequences from a string.
75+
*
76+
* Handles SGR codes (`\x1b[...m`) and OSC 8 terminal hyperlink sequences
77+
* (`\x1b]8;;url\x07text\x1b]8;;\x07`).
78+
*/
79+
export function stripAnsi(text: string): string {
80+
return (
81+
text
82+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape detection requires matching \x1b and \x07
83+
.replace(/\x1b\[[0-9;]*m/g, "")
84+
// biome-ignore lint/suspicious/noControlCharactersInRegex: OSC 8 hyperlink sequences use \x1b and \x07
85+
.replace(/\x1b\]8;;[^\x07]*\x07/g, "")
86+
);
87+
}

0 commit comments

Comments
 (0)