Skip to content

Commit bc87d07

Browse files
committed
refactor: replace regex-based stripMarkdownInline with render-then-strip-ANSI
Eliminate the entire stripMarkdownInline() function and its accumulated edge-case bugs (code span precedence, underscore corruption, backslash escapes). Instead, reuse the existing marked.lexer() → renderInline() pipeline for both TTY and plain modes, stripping ANSI from the result in plain mode — the same approach renderMarkdown() already uses. Key changes: - renderInlineMarkdown(): always renders through marked, strips ANSI in plain mode instead of calling stripMarkdownInline() - renderCodespan(): extracted helper; skips padding spaces in plain mode so table column widths aren't inflated - formatTable/formatTraceTable/formatLogTable: merged TTY and plain branches into a single renderInlineMarkdown() call - mdRow(): same — single code path for both modes - Deleted stripMarkdownInline() entirely
1 parent 2a5c827 commit bc87d07

File tree

5 files changed

+37
-93
lines changed

5 files changed

+37
-93
lines changed

src/lib/formatters/log.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,11 @@ import {
1010
colorTag,
1111
escapeMarkdownCell,
1212
escapeMarkdownInline,
13-
isPlainOutput,
1413
mdKvTable,
1514
mdRow,
1615
mdTableHeader,
1716
renderInlineMarkdown,
1817
renderMarkdown,
19-
stripMarkdownInline,
2018
} from "./markdown.js";
2119
import {
2220
renderTextTable,
@@ -175,16 +173,6 @@ export function formatLogsHeader(): string {
175173
*/
176174
export function formatLogTable(logs: LogLike[], includeTrace = true): string {
177175
const headers = [...LOG_TABLE_COLS];
178-
179-
if (isPlainOutput()) {
180-
const rows = logs.map((log) =>
181-
buildLogRowCells(log, false, includeTrace).map((c) =>
182-
stripMarkdownInline(c)
183-
)
184-
);
185-
return renderTextTable(headers, rows);
186-
}
187-
188176
const rows = logs.map((log) =>
189177
buildLogRowCells(log, false, includeTrace).map((c) =>
190178
renderInlineMarkdown(c)

src/lib/formatters/markdown.ts

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,11 @@ export function mdTableHeader(cols: readonly string[]): string {
9494
* in rendered mode applies inline styling and replaces `|` with `│`.
9595
*/
9696
export function mdRow(cells: readonly string[]): string {
97-
if (isPlainOutput()) {
98-
// Strip markdown syntax, then replace literal pipes with box-drawing │
99-
// to prevent them from breaking the pipe-delimited table format.
100-
const stripped = cells.map((c) =>
101-
stripMarkdownInline(c).replace(/\|/g, "\u2502")
102-
);
103-
return `| ${stripped.join(" | ")} |\n`;
104-
}
97+
// Both modes render through the same pipeline; plain mode strips ANSI.
98+
// Literal pipes are replaced with box-drawing │ to prevent breaking the
99+
// pipe-delimited table format.
105100
const out = cells.map((c) =>
106-
renderInline(marked.lexer(c).flatMap(flattenInline)).replace(
107-
/\|/g,
108-
"\u2502"
109-
)
101+
renderInlineMarkdown(c).replace(/\|/g, "\u2502")
110102
);
111103
return `| ${out.join(" | ")} |\n`;
112104
}
@@ -216,32 +208,6 @@ export function stripColorTags(text: string): string {
216208
return result;
217209
}
218210

219-
/**
220-
* Strip inline markdown syntax to produce plain text.
221-
*
222-
* Removes bold/italic markers, link syntax (keeps display text), code
223-
* backticks, and color tags. Used for plain-mode output that should be
224-
* human-readable without any markdown artifacts.
225-
*/
226-
export function stripMarkdownInline(md: string): string {
227-
let text = stripColorTags(md);
228-
// Links: [text](url) → text
229-
text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1");
230-
// Code spans first (higher precedence than emphasis in CommonMark):
231-
// `text` → text. Content inside backticks is literal, so bold/italic
232-
// markers inside code spans must not be stripped.
233-
text = text.replace(/`([^`]+)`/g, "$1");
234-
// Bold: **text** → text. Only strip asterisk-based emphasis, not
235-
// underscore-based (_text_) because underscores appear in identifiers
236-
// (e.g. payment_service_handler) and would be corrupted.
237-
text = text.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1");
238-
// Backslash escapes: \| \< \> \\ \_ \* \[ \] \` → literal character.
239-
// escapeMarkdownCell/escapeMarkdownInline add these for the markdown parser;
240-
// the TTY path unescapes via marked.lexer(), but plain mode must do it here.
241-
text = text.replace(/\\([|<>\\*_`[\]])/g, "$1");
242-
return text;
243-
}
244-
245211
/**
246212
* Render an inline HTML token as a color-tagged string.
247213
*
@@ -296,19 +262,31 @@ function flattenInline(token: Token): Token[] {
296262
return [token];
297263
}
298264

265+
/**
266+
* Render a code span token.
267+
*
268+
* Plain mode: raw text without padding so table column widths aren't inflated.
269+
* TTY mode: styled with background color and padded spaces for visual weight.
270+
*/
271+
function renderCodespan(token: Tokens.Codespan): string {
272+
if (isPlainOutput()) {
273+
return token.text;
274+
}
275+
return chalk.bgHex(COLORS.codeBg).hex(COLORS.codeFg)(` ${token.text} `);
276+
}
277+
299278
/**
300279
* Render a single inline token to an ANSI string.
301280
*/
281+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inline token switch is inherently branchy
302282
function renderOneInline(token: Token): string {
303283
switch (token.type) {
304284
case "strong":
305285
return chalk.bold(renderInline((token as Tokens.Strong).tokens));
306286
case "em":
307287
return chalk.italic(renderInline((token as Tokens.Em).tokens));
308288
case "codespan":
309-
return chalk.bgHex(COLORS.codeBg).hex(COLORS.codeFg)(
310-
` ${(token as Tokens.Codespan).text} `
311-
);
289+
return renderCodespan(token as Tokens.Codespan);
312290
case "link": {
313291
const link = token as Tokens.Link;
314292
let linkText = renderInline(link.tokens);
@@ -584,13 +562,16 @@ export function renderMarkdown(md: string): string {
584562
* Render inline markdown (bold, code spans, emphasis, links) as styled
585563
* terminal output, or return plain text when in plain mode.
586564
*
565+
* Both modes use the same `marked.lexer()` → `renderInline()` pipeline.
566+
* In plain mode, ANSI codes are stripped from the result, producing
567+
* clean text. This avoids maintaining a separate regex-based stripping
568+
* function that would need to replicate the markdown parser's rules.
569+
*
587570
* @param md - Inline markdown text
588-
* @returns Styled string (TTY) or plain text with markdown syntax stripped (non-TTY / plain mode)
571+
* @returns Styled string (TTY) or plain text (non-TTY / plain mode)
589572
*/
590573
export function renderInlineMarkdown(md: string): string {
591-
if (isPlainOutput()) {
592-
return stripMarkdownInline(md);
593-
}
594574
const tokens = marked.lexer(md);
595-
return renderInline(tokens.flatMap(flattenInline));
575+
const rendered = renderInline(tokens.flatMap(flattenInline));
576+
return isPlainOutput() ? stripAnsi(rendered) : rendered;
596577
}

src/lib/formatters/table.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import {
1515
isPlainOutput,
1616
renderInlineMarkdown,
1717
stripColorTags,
18-
stripMarkdownInline,
1918
} from "./markdown.js";
2019
import { type Alignment, renderTextTable } from "./text-table.js";
2120

@@ -94,9 +93,9 @@ export type WriteTableOptions = {
9493
* Format items as a table string.
9594
*
9695
* Returns the rendered table instead of writing to a stream.
97-
* In plain/non-TTY mode emits a human-readable aligned table with
98-
* Unicode box-drawing borders but no ANSI escape codes. In TTY mode
99-
* emits a styled table with ANSI colors and hyperlinks.
96+
* Cell values are markdown strings rendered through {@link renderInlineMarkdown},
97+
* which produces ANSI-styled text in TTY mode and clean plain text when piped.
98+
* In plain mode, row separator ANSI coloring is stripped to `true` (plain borders).
10099
*/
101100
export function formatTable<T>(
102101
items: T[],
@@ -108,23 +107,6 @@ export function formatTable<T>(
108107
const minWidths = columns.map((c) => c.minWidth ?? 0);
109108
const shrinkable = columns.map((c) => c.shrinkable ?? true);
110109

111-
if (isPlainOutput()) {
112-
// Strip markdown syntax so plain output is human-readable (no **bold**,
113-
// [link](url), or `code` artifacts). Use renderTextTable for proper
114-
// column alignment with box-drawing borders.
115-
const rows = items.map((item) =>
116-
columns.map((c) => stripMarkdownInline(c.value(item)))
117-
);
118-
return renderTextTable(headers, rows, {
119-
alignments,
120-
minWidths,
121-
shrinkable,
122-
truncate: options?.truncate,
123-
// Strip ANSI color from row separators in plain mode
124-
rowSeparator: Boolean(options?.rowSeparator),
125-
});
126-
}
127-
128110
const rows = items.map((item) =>
129111
columns.map((c) => renderInlineMarkdown(c.value(item)))
130112
);
@@ -134,7 +116,10 @@ export function formatTable<T>(
134116
minWidths,
135117
shrinkable,
136118
truncate: options?.truncate,
137-
rowSeparator: options?.rowSeparator,
119+
// Strip ANSI color from row separators in plain mode
120+
rowSeparator: isPlainOutput()
121+
? Boolean(options?.rowSeparator)
122+
: options?.rowSeparator,
138123
});
139124
}
140125

src/lib/formatters/trace.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,11 @@ import {
1414
colorTag,
1515
escapeMarkdownCell,
1616
escapeMarkdownInline,
17-
isPlainOutput,
1817
mdKvTable,
1918
mdRow,
2019
mdTableHeader,
2120
renderInlineMarkdown,
2221
renderMarkdown,
23-
stripMarkdownInline,
2422
} from "./markdown.js";
2523
import { type Column, formatTable } from "./table.js";
2624
import { renderTextTable } from "./text-table.js";
@@ -125,14 +123,6 @@ export function formatTraceTable(items: TransactionListItem[]): string {
125123
const alignments = TRACE_TABLE_COLS.map((c) =>
126124
c.endsWith(":") ? ("right" as const) : ("left" as const)
127125
);
128-
129-
if (isPlainOutput()) {
130-
const rows = items.map((item) =>
131-
buildTraceRowCells(item).map((c) => stripMarkdownInline(c))
132-
);
133-
return renderTextTable(headers, rows, { alignments });
134-
}
135-
136126
const rows = items.map((item) =>
137127
buildTraceRowCells(item).map((c) => renderInlineMarkdown(c))
138128
);

test/commands/project/create.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,9 +490,9 @@ describe("project create", () => {
490490
await func.call(context, { json: false }, "my-app", "node");
491491

492492
const output = stdoutWrite.mock.calls.map((c) => c[0]).join("");
493-
// Plain mode strips backtick code spans
494-
expect(output).toContain("Slug my-app-0g was assigned");
495-
expect(output).toContain("my-app is already taken");
493+
// Plain mode renders code spans as plain text without padding
494+
expect(output).toContain("Slug my-app-0g was assigned");
495+
expect(output).toContain("my-app is already taken");
496496
});
497497

498498
test("does not show slug note when slug matches name", async () => {

0 commit comments

Comments
 (0)