From 194e14571292468f0eb0e68843ae447d7b3b5d9f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:26:11 +0000 Subject: [PATCH 1/5] ref(cli): Escape Markdown with the shared toolkit-core builders The CLI's escapeMarkdownInline, escapeMarkdownCell, and safeCodeSpan are now toolkit-core's text, tableCell (new), and inlineCode, so the CLI and MCP escape untrusted values the same way, verified by property tests against the same marked version the CLI renders with. Behavior changes in CLI output: - Cells escape emphasis, links, and the rest of inline syntax, not only pipes and angle brackets. - Code spans keep backticks and pipes instead of replacing them with look-alike characters. - Underscores inside words stay unescaped. - URLs in escaped text render as plain text rather than autolinks. escapeMarkdownLines keeps line breaks for the multi-line values (blockquoted messages, fix instructions, credential errors). mdKvTable no longer shows the backslash of an escaped pipe. Replay tags and breadcrumb labels use code spans instead of escaped text inside backticks, where the backslashes showed. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/lib/formatters/human.ts | 9 +- packages/cli/src/lib/formatters/log.ts | 4 +- packages/cli/src/lib/formatters/markdown.ts | 80 +++++------ packages/cli/src/lib/formatters/replay.ts | 5 +- packages/cli/test/lib/formatters/log.test.ts | 2 +- .../cli/test/lib/formatters/markdown.test.ts | 126 ++++++++++-------- packages/toolkit-core/README.md | 6 + .../src/markdown.property.test.ts | 73 +++++++++- packages/toolkit-core/src/markdown.test.ts | 8 +- packages/toolkit-core/src/markdown.ts | 14 +- 10 files changed, 212 insertions(+), 115 deletions(-) diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index 7f55c8cef..c886f20a2 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -39,6 +39,7 @@ import { colorTag, escapeMarkdownCell, escapeMarkdownInline, + escapeMarkdownLines, isPlainOutput, mdKvTable, mdRow, @@ -902,7 +903,7 @@ export function formatIssueDetails(issue: SentryIssue): string { lines.push("**Message:**"); lines.push(""); lines.push( - `> ${escapeMarkdownInline(issue.metadata.value).replace(/\n/g, "\n> ")}`, + `> ${escapeMarkdownLines(issue.metadata.value).replace(/\n/g, "\n> ")}`, ); } @@ -1917,7 +1918,7 @@ function formatVerificationSection( lines.push(`### ${colorTag("red", "✗")} Could not verify credentials`); if (verification.error) { lines.push(""); - lines.push(escapeMarkdownInline(verification.error)); + lines.push(escapeMarkdownLines(verification.error)); } } @@ -2246,7 +2247,7 @@ export function formatFixResult(data: FixResult): string { lines.push(""); lines.push("---"); lines.push(""); - lines.push(escapeMarkdownInline(data.instructions)); + lines.push(escapeMarkdownLines(data.instructions)); } // Summary @@ -2393,7 +2394,7 @@ export function formatUpgradeResult(data: UpgradeResult): string { const verb = ACTION_DESCRIPTIONS[data.action]; if (data.offline) { lines.push( - `${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)}${escapeMarkdownInline(" (offline, from cache)")}`, + `${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)} (offline, from cache)`, ); } else if (data.currentVersion !== data.targetVersion) { lines.push( diff --git a/packages/cli/src/lib/formatters/log.ts b/packages/cli/src/lib/formatters/log.ts index 8dbfb50f8..2120049ff 100644 --- a/packages/cli/src/lib/formatters/log.ts +++ b/packages/cli/src/lib/formatters/log.ts @@ -14,7 +14,7 @@ import { buildTraceUrl } from "../sentry-web-urls.js"; import { colorTag, escapeMarkdownCell, - escapeMarkdownInline, + escapeMarkdownLines, mdKvTable, mdRow, mdTableHeader, @@ -369,7 +369,7 @@ export function formatLogDetails( lines.push(""); lines.push("**Message:**"); lines.push(""); - lines.push(`> ${escapeMarkdownInline(log.message).replace(/\n/g, "\n> ")}`); + lines.push(`> ${escapeMarkdownLines(log.message).replace(/\n/g, "\n> ")}`); } // Context section diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 8fdd8240e..310281ae8 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -18,6 +18,7 @@ * See {@link isPlainOutput} in `plain-detect.ts` for the full priority chain. */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import chalk from "chalk"; import { marked, type Token, type Tokens } from "marked"; import stringWidth from "string-width"; @@ -30,50 +31,35 @@ import { type Alignment, renderTextTable } from "./text-table.js"; export { isPlainOutput } from "./plain-detect.js"; // ──────────────────────────── Escape helpers ────────────────────────── +// +// These are the shared builders from `@sentry/toolkit-core/markdown`, which +// MCP uses too; their property tests parse the output with the same `marked` +// this renderer uses. Escaped text never becomes a link: a URL in API data +// renders as plain text, and deliberate links use explicit link syntax. -/** - * Escape a string for safe use inside a markdown table cell. - * - * Collapses newlines, escapes backslashes, pipes, and angle brackets. - * Angle brackets are backslash-escaped (`\<`/`\>`) so that user-supplied - * content (e.g. `Expected `) is not parsed as an HTML tag by - * `marked`, which would silently drop the content via `renderHtmlToken`. - */ -export function escapeMarkdownCell(value: string): string { - return value - .replace(/\n/g, " ") - .replace(/\\/g, "\\\\") - .replace(/\|/g, "\\|") - .replace(//g, "\\>"); -} +/** Escapes a value for a markdown table cell. See `tableCell`. */ +export const escapeMarkdownCell = tableCell; + +/** Escapes a value so it renders as the same literal text. See `text`. */ +export const escapeMarkdownInline = text; + +/** Formats a value as a code span that keeps every character. See `inlineCode`. */ +export const safeCodeSpan = inlineCode; + +/** Indentation a line keeps in {@link escapeMarkdownLines}; four spaces would start a code block. */ +const LINE_INDENT = /^ {0,3}/; /** - * Escape CommonMark inline emphasis characters. + * Escapes a multi-line value as literal text, one line at a time. * - * Prevents `_`, `*`, `` ` ``, `[`, `]`, `<`, and `>` from being consumed - * by the parser. Angle brackets are backslash-escaped (`\<`/`\>`) so that - * user-supplied content (e.g. `Expected got `) is not - * silently dropped when `marked` parses the text as HTML tokens. + * Unlike {@link escapeMarkdownInline}, which puts a value on one line, this + * keeps line breaks and up to three spaces of indentation. */ -export function escapeMarkdownInline(value: string): string { +export function escapeMarkdownLines(value: string): string { return value - .replace(/\\/g, "\\\\") - .replace(/\*/g, "\\*") - .replace(/_/g, "\\_") - .replace(/`/g, "\\`") - .replace(/\[/g, "\\[") - .replace(/\]/g, "\\]") - .replace(//g, "\\>"); -} - -/** - * Wrap a string in a backtick code span, sanitising characters that - * would break the span or surrounding table structure. - */ -export function safeCodeSpan(value: string): string { - return `\`${value.replace(/`/g, "\u02CB").replace(/\|/g, "\u2502").replace(/\n/g, " ")}\``; + .split(/\r\n?|\n/) + .map((line) => `${LINE_INDENT.exec(line)?.[0] ?? ""}${text(line)}`) + .join("\n"); } // ──────────────────────── Streaming table helpers ───────────────────── @@ -120,14 +106,16 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { - // Escape backslashes first, then replace pipes with a Unicode box character - // so that backslash-pipe sequences in values don't produce escaped pipes in - // the rendered output. Newlines are collapsed to spaces. - // Only replace structural characters that break table syntax. - // Content escaping (<>, \, _*`) is the caller's responsibility. - lines.push( - `| **${label}** | ${value.replace(/\n/g, " ").replace(/\|/g, "\u2502")} |`, - ); + // Pipes become a box-drawing │ so they cannot end the cell, even inside a + // code span. A pipe escaped by an odd run of backslashes (as from + // escapeMarkdownCell) drops that escape, so no backslash shows. Newlines + // are collapsed to spaces. Content escaping is the caller's job. + const cell = value + .replace(/\n/g, " ") + .replace(/(\\*)\|/g, (_, slashes: string) => + slashes.length % 2 ? `${slashes.slice(1)}\u2502` : `${slashes}\u2502`, + ); + lines.push(`| **${label}** | ${cell} |`); } return lines.join("\n"); } diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index b0eb1b7d5..3e007344a 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -19,6 +19,7 @@ import { escapeMarkdownInline, mdKvTable, renderMarkdown, + safeCodeSpan, } from "./markdown.js"; import { formatDurationCompactMs, @@ -472,7 +473,7 @@ function pushTagsSection(lines: string[], replay: ReplayDetails): void { a.localeCompare(b), )) { lines.push( - `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}`, + `- ${safeCodeSpan(key)}: ${values.map((value) => safeCodeSpan(value)).join(", ")}`, ); } } @@ -509,7 +510,7 @@ function pushActivitySection( event.details.length > 0 ? ` · ${event.details.map((detail) => escapeMarkdownInline(detail)).join(" · ")}` : ""; - lines.push(`- ${prefix}\`${escapeMarkdownInline(event.label)}\`${details}`); + lines.push(`- ${prefix}${safeCodeSpan(event.label)}${details}`); } } diff --git a/packages/cli/test/lib/formatters/log.test.ts b/packages/cli/test/lib/formatters/log.test.ts index ccdfa40ef..d7db88ae2 100644 --- a/packages/cli/test/lib/formatters/log.test.ts +++ b/packages/cli/test/lib/formatters/log.test.ts @@ -622,7 +622,7 @@ describe("buildLogRowCells", () => { const log = { ...createTestLog(), email: "user@example.com" }; const cells = buildLogRowCells(log, true, true, ["email"]); expect(cells.length).toBe(5); - expect(cells[4]).toContain("user@example.com"); + expect(renderInlineMarkdown(cells[4] ?? "")).toContain("user@example.com"); }); test("extra field shows empty for null values", () => { diff --git a/packages/cli/test/lib/formatters/markdown.test.ts b/packages/cli/test/lib/formatters/markdown.test.ts index 0ece61fd8..78b716881 100644 --- a/packages/cli/test/lib/formatters/markdown.test.ts +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -12,6 +12,7 @@ import { divider, escapeMarkdownCell, escapeMarkdownInline, + escapeMarkdownLines, isPlainOutput, mdKvTable, mdRow, @@ -375,35 +376,36 @@ describe("renderInlineMarkdown", () => { // --------------------------------------------------------------------------- describe("escapeMarkdownCell", () => { - test("escapes pipe characters", () => { + /** Renders `cell` as the first cell of a one-row table, in plain mode. */ + function renderCell(cell: string): string { + let out = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { + out = renderMarkdown(`| a | b |\n|---|---|\n| ${cell} | end |`); + }); + return out; + } + + test("escapes pipes so the value stays in its cell", () => { expect(escapeMarkdownCell("foo|bar")).toBe("foo\\|bar"); + const rendered = renderCell(escapeMarkdownCell("a|b\\|c")); + expect(rendered).toContain("a|b\\|c"); + expect(rendered).toContain("end"); }); - test("escapes backslashes before pipes", () => { - expect(escapeMarkdownCell("a\\|b")).toBe("a\\\\\\|b"); + test("escapes Markdown syntax as well as pipes", () => { + expect(escapeMarkdownCell("*bold* ")).toBe("\\*bold\\* \\"); + expect(renderCell(escapeMarkdownCell("*bold* "))).toContain( + "*bold* ", + ); }); - test("returns unchanged string when no special chars", () => { - expect(escapeMarkdownCell("hello world")).toBe("hello world"); + test("puts multi-line values on one line", () => { + expect(escapeMarkdownCell("a\nb\nc")).toBe("a b c"); }); test("handles empty string", () => { expect(escapeMarkdownCell("")).toBe(""); }); - - test("replaces newlines with a space to preserve row structure", () => { - expect(escapeMarkdownCell("line1\nline2")).toBe("line1 line2"); - expect(escapeMarkdownCell("a\nb\nc")).toBe("a b c"); - }); - - test("handles multiple pipes", () => { - const result = escapeMarkdownCell("a|b|c"); - expect(result).toBe("a\\|b\\|c"); - }); - - test("escapes angle brackets with backslash", () => { - expect(escapeMarkdownCell("")).toBe("\\"); - }); }); // --------------------------------------------------------------------------- @@ -495,6 +497,15 @@ describe("mdKvTable", () => { const result = mdKvTable([["Only", "Row"]]); expect(result).toContain("| **Only** | Row |"); }); + + test("shows a pipe from escapeMarkdownCell without its backslash", () => { + expect(mdKvTable([["Org", escapeMarkdownCell("a|b")]])).toContain( + "| **Org** | a\u2502b |", + ); + expect(mdKvTable([["Path", escapeMarkdownInline("a\\|b")]])).toContain( + "| **Path** | a\\\\\u2502b |", + ); + }); }); // --------------------------------------------------------------------------- @@ -547,34 +558,38 @@ describe("colorTag", () => { // --------------------------------------------------------------------------- describe("escapeMarkdownInline", () => { - test("escapes underscores", () => { - expect(escapeMarkdownInline("hello_world")).toBe("hello\\_world"); - }); - - test("escapes asterisks", () => { - expect(escapeMarkdownInline("*bold*")).toBe("\\*bold\\*"); - }); + /** Renders inline Markdown in plain mode. */ + function renderPlain(md: string): string { + let out = ""; + withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { + out = renderInlineMarkdown(md); + }); + return out; + } - test("escapes backticks", () => { - expect(escapeMarkdownInline("`code`")).toBe("\\`code\\`"); + test.each([ + "*bold* _em_ `code` [link](url)", + "Expected got ", + "a\\b ~~strike~~ & # not a heading", + "https://spotlightjs.com/_astro/ui-core.js", + ])("renders %j as the same literal text", (value) => { + expect(renderPlain(escapeMarkdownInline(value))).toBe(value); }); - test("escapes square brackets", () => { - expect(escapeMarkdownInline("[link]")).toBe("\\[link\\]"); + test("leaves underscores inside words unescaped", () => { + expect(escapeMarkdownInline("hello_world")).toBe("hello_world"); }); - test("escapes backslashes", () => { - expect(escapeMarkdownInline("a\\b")).toBe("a\\\\b"); + test("does not turn a URL into a link", () => { + expect(escapeMarkdownInline("see https://example.com")).toBe( + "see https\\://example.com", + ); }); test("returns unchanged string with no special chars", () => { expect(escapeMarkdownInline("hello world")).toBe("hello world"); }); - test("escapes angle brackets with backslash", () => { - expect(escapeMarkdownInline("")).toBe("\\"); - }); - test("angle brackets survive round-trip through renderInlineMarkdown", () => { withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { const escaped = escapeMarkdownInline(""); @@ -582,15 +597,20 @@ describe("escapeMarkdownInline", () => { expect(result).toBe(""); }); }); +}); + +describe("escapeMarkdownLines", () => { + test("keeps line breaks and indentation up to three spaces", () => { + expect(escapeMarkdownLines("Run:\n chown *x*\n deep")).toBe( + "Run:\n chown \\*x\\*\n deep", + ); + }); - test("URLs with underscores render without backslashes", () => { - withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { - const escaped = escapeMarkdownInline( - "https://spotlightjs.com/_astro/ui-core.js", + test("renders each line as the same literal text", () => { + withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { + expect(renderMarkdown(escapeMarkdownLines("# one\n- two"))).toBe( + "# one\n- two", ); - const result = stripAnsi(renderInlineMarkdown(escaped)); - expect(result).not.toContain("\\_"); - expect(result).toContain("_astro"); }); }); }); @@ -604,23 +624,15 @@ describe("safeCodeSpan", () => { expect(safeCodeSpan("hello")).toBe("`hello`"); }); - test("replaces internal backticks with modifier letter", () => { - const result = safeCodeSpan("a`b"); - expect(result).not.toContain("`b"); - expect(result.startsWith("`")).toBe(true); - expect(result.endsWith("`")).toBe(true); - }); - - test("replaces pipe with unicode vertical bar", () => { - const result = safeCodeSpan("a|b"); - expect(result).not.toContain("|"); - expect(result).toContain("\u2502"); + test("keeps backticks and pipes in the value", () => { + expect(safeCodeSpan("a`b|c")).toBe("``a`b|c``"); + withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { + expect(renderInlineMarkdown(safeCodeSpan("a`b|c"))).toBe("a`b|c"); + }); }); test("replaces newlines with spaces", () => { - const result = safeCodeSpan("line1\nline2"); - expect(result).not.toContain("\n"); - expect(result).toContain("line1 line2"); + expect(safeCodeSpan("line1\nline2")).toBe("`line1 line2`"); }); }); diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index ceccc1f2e..c899c001f 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -170,6 +170,12 @@ parser the CLI renders with (same version, from the workspace catalog), and with micromark, the CommonMark reference parser, and check that any input round-trips as the same literal value. +`tableCell` is `text` plus escaped pipes, for a value in a GFM table cell. The +CLI's `escapeMarkdownInline`, `escapeMarkdownCell`, and `safeCodeSpan` are +`text`, `tableCell`, and `inlineCode`, so CLI and MCP output escape the same +way. Escaped text never becomes a literal autolink: a URL in upstream data +stays plain text. + ## Resource identity The `resource-identity` types describe the identifiers shared by organization, diff --git a/packages/toolkit-core/src/markdown.property.test.ts b/packages/toolkit-core/src/markdown.property.test.ts index 835e1f1cb..268ea918d 100644 --- a/packages/toolkit-core/src/markdown.property.test.ts +++ b/packages/toolkit-core/src/markdown.property.test.ts @@ -13,7 +13,7 @@ import { fromMarkdown } from "mdast-util-from-markdown"; import { gfmFromMarkdown } from "mdast-util-gfm"; import { gfm } from "micromark-extension-gfm"; import { describe, expect, test } from "vitest"; -import { codeBlock, inlineCode, text } from "./markdown.js"; +import { codeBlock, inlineCode, tableCell, text } from "./markdown.js"; const NUM_RUNS = 500; @@ -228,6 +228,77 @@ describe.each([ }); }); +/** A two-column table whose only body row holds `cell` and then `end`. */ +const tableRow = (cell: string) => + `| a | b |\n| --- | --- |\n| ${cell} | end |\n`; + +/** Parses a table with micromark; returns the body row's cells as literal text. */ +function tableMicromark(markdown: string): string[] { + const root = fromMarkdown(markdown, { + extensions: [gfm()], + mdastExtensions: [gfmFromMarkdown()], + }); + const [table] = root.children; + expect(root.children).toHaveLength(1); + if (table?.type !== "table") { + throw new Error(`expected table, got ${table?.type}`); + } + expect(table.children).toHaveLength(2); + return (table.children[1]?.children ?? []).map((cell) => + cell.children + .map((node) => { + if (node.type === "text") return node.value; + // See parseMicromark: mdast-util-gfm links literal URLs after parsing. + if (node.type === "link") { + return node.children + .map((c) => ("value" in c ? c.value : "")) + .join(""); + } + throw new Error(`unexpected ${node.type} node`); + }) + .join(""), + ); +} + +/** Parses a table with marked; returns the body row's cells as literal text. */ +function tableMarked(markdown: string): string[] { + const tokens = marked.lexer(markdown).filter((t) => t.type !== "space"); + const [table] = tokens; + expect(tokens).toHaveLength(1); + if (table?.type !== "table") { + throw new Error(`expected table, got ${table?.type}`); + } + expect(table.rows).toHaveLength(1); + return (table.rows[0] as { tokens: Token[] }[]).map((cell) => + cell.tokens + .map((token) => { + if (token.type === "text" || token.type === "escape") return token.text; + throw new Error(`unexpected ${token.type} token`); + }) + .join(""), + ); +} + +describe.each([ + ["marked", tableMarked], + ["micromark", tableMicromark], +] as const)("tableCell with %s", (_, parseTable) => { + test("round-trips any value inside its cell", () => { + fcAssert( + property( + markdownArb.filter((value) => singleLine(value).trim() !== ""), + (value) => { + expect(parseTable(tableRow(tableCell(value)))).toEqual([ + singleLine(value).trim(), + "end", + ]); + }, + ), + { numRuns: NUM_RUNS }, + ); + }); +}); + describe("codeBlock", () => { test("round-trips any value as one code block with micromark", () => { fcAssert( diff --git a/packages/toolkit-core/src/markdown.test.ts b/packages/toolkit-core/src/markdown.test.ts index 0f23375e9..55215106f 100644 --- a/packages/toolkit-core/src/markdown.test.ts +++ b/packages/toolkit-core/src/markdown.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { codeBlock, inlineCode, text } from "./markdown.js"; +import { codeBlock, inlineCode, tableCell, text } from "./markdown.js"; describe("inlineCode", () => { test("wraps a value without escaping Markdown syntax", () => { @@ -73,3 +73,9 @@ describe("text", () => { expect(text(" + two ")).toBe("\\+ two"); }); }); + +describe("tableCell", () => { + test("escapes pipes as well as text syntax", () => { + expect(tableCell("a | *b* \\| c")).toBe("a \\| \\*b\\* \\\\\\| c"); + }); +}); diff --git a/packages/toolkit-core/src/markdown.ts b/packages/toolkit-core/src/markdown.ts index bc16157cf..dbf1fbf5d 100644 --- a/packages/toolkit-core/src/markdown.ts +++ b/packages/toolkit-core/src/markdown.ts @@ -75,7 +75,8 @@ export function codeBlock(value: string, language = ""): string { * and syntax that only counts at the start of a line. Line breaks become * spaces and surrounding whitespace is dropped, so the value stays on the * line it is placed in. Escapes are backslashes only, which `marked` (the - * CLI renderer) and micromark both resolve. Pipes are not escaped. + * CLI renderer) and micromark both resolve. Pipes are not escaped; use + * `tableCell` inside a table. */ export function text(value: string): string { return toSingleLine(value) @@ -86,3 +87,14 @@ export function text(value: string): string { (syntax) => `${syntax.slice(0, -1)}\\${syntax.slice(-1)}`, ); } + +/** + * Escapes a value as `text` does, plus pipes, so it stays in its GFM table + * cell and renders as the same literal text. + * + * `text` doubles every backslash, so each `\|` written here is an escaped + * pipe, not an escaped backslash followed by a cell delimiter. + */ +export function tableCell(value: string): string { + return text(value).replace(/\|/g, "\\|"); +} From 1bd97bd978c055375a23b68f0194fcbbae68f247 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:39:38 +0000 Subject: [PATCH 2/5] ref(toolkit-core): Escape table-cell pipes in the same pass as text syntax tableCell escaped pipes in a second replace after text. That was correct, since text had already doubled every backslash, but CodeQL flagged it as incomplete escaping. Both helpers now share one escape pass, and tableCell adds the pipe to the pattern. --- packages/toolkit-core/src/markdown.ts | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/toolkit-core/src/markdown.ts b/packages/toolkit-core/src/markdown.ts index dbf1fbf5d..004db0929 100644 --- a/packages/toolkit-core/src/markdown.ts +++ b/packages/toolkit-core/src/markdown.ts @@ -20,6 +20,12 @@ const LINE_BREAK = /\r\n?|\n/g; const INLINE_SYNTAX = /[\\`*[\]<>~@]|&(?=#?[a-z0-9]+;)|:(?=\/\/)|(?<=www)\.|(?<=\s)#(?=#*$)|(?<=^|[^\p{L}\p{N}])_|_(?=$|[^\p{L}\p{N}])/giu; +/** `INLINE_SYNTAX` plus the pipe that would end a GFM table cell. */ +const TABLE_CELL_SYNTAX = new RegExp( + `\\||${INLINE_SYNTAX.source}`, + INLINE_SYNTAX.flags, +); + /** Syntax that only counts at the start of a line: headings, quotes, lists, rules. */ const LINE_START_SYNTAX = /^(?:[#+=-]|\d{1,9}[.)])/; @@ -79,22 +85,24 @@ export function codeBlock(value: string, language = ""): string { * `tableCell` inside a table. */ export function text(value: string): string { - return toSingleLine(value) - .trim() - .replace(INLINE_SYNTAX, (char) => `\\${char}`) - .replace( - LINE_START_SYNTAX, - (syntax) => `${syntax.slice(0, -1)}\\${syntax.slice(-1)}`, - ); + return escapeSyntax(value, INLINE_SYNTAX); } /** * Escapes a value as `text` does, plus pipes, so it stays in its GFM table * cell and renders as the same literal text. - * - * `text` doubles every backslash, so each `\|` written here is an escaped - * pipe, not an escaped backslash followed by a cell delimiter. */ export function tableCell(value: string): string { - return text(value).replace(/\|/g, "\\|"); + return escapeSyntax(value, TABLE_CELL_SYNTAX); +} + +/** Puts a value on one line and backslash-escapes `syntax` and line-start syntax. */ +function escapeSyntax(value: string, syntax: RegExp): string { + return toSingleLine(value) + .trim() + .replace(syntax, (char) => `\\${char}`) + .replace( + LINE_START_SYNTAX, + (match) => `${match.slice(0, -1)}\\${match.slice(-1)}`, + ); } From 3f8880d20332126e1e0f8450cd7541a9600c0ea8 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:57:40 +0000 Subject: [PATCH 3/5] fix(cli): Keep literal backslashes before pipes in key-value tables mdKvTable dropped any backslash before a pipe, including a literal one inside a code span (culprit, location) or in a raw value. It now scans escapes, code spans, and pipes in order: only an escaped pipe loses its backslash, and code-span content keeps every backslash. Log custom attribute names and values are now escaped like other API data. --- packages/cli/src/lib/formatters/log.ts | 7 ++-- packages/cli/src/lib/formatters/markdown.ts | 33 +++++++++++++------ .../cli/test/lib/formatters/markdown.test.ts | 9 +++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/lib/formatters/log.ts b/packages/cli/src/lib/formatters/log.ts index 2120049ff..f31b6f1f0 100644 --- a/packages/cli/src/lib/formatters/log.ts +++ b/packages/cli/src/lib/formatters/log.ts @@ -14,6 +14,7 @@ import { buildTraceUrl } from "../sentry-web-urls.js"; import { colorTag, escapeMarkdownCell, + escapeMarkdownInline, escapeMarkdownLines, mdKvTable, mdRow, @@ -464,8 +465,10 @@ export function formatLogDetails( lines.push( mdKvTable( customAttrs.map((a) => [ - a.name, - a.type === "array" ? JSON.stringify(a.value) : String(a.value), + escapeMarkdownInline(a.name), + escapeMarkdownInline( + a.type === "array" ? JSON.stringify(a.value) : String(a.value), + ), ]), "Custom Attributes", ), diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 310281ae8..3ada98cc3 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -89,6 +89,28 @@ export function mdRow(cells: readonly string[]): string { return `| ${out.join(" | ")} |\n`; } +/** + * A backslash escape, a code span, or a pipe, scanned left to right so an + * escaped backtick cannot open a code span and backslashes inside a code span + * stay literal. + */ +const KV_CELL_TOKEN = /\\[\s\S]|(? { + if (token === "|" || token === "\\|") return "\u2502"; + return token.startsWith("`") ? token.replace(/\|/g, "\u2502") : token; + }); +} + /** * Build a key-value markdown table section with an optional heading. * @@ -106,16 +128,7 @@ export function mdKvTable( lines.push("| | |"); lines.push("|---|---|"); for (const [label, value] of rows) { - // Pipes become a box-drawing │ so they cannot end the cell, even inside a - // code span. A pipe escaped by an odd run of backslashes (as from - // escapeMarkdownCell) drops that escape, so no backslash shows. Newlines - // are collapsed to spaces. Content escaping is the caller's job. - const cell = value - .replace(/\n/g, " ") - .replace(/(\\*)\|/g, (_, slashes: string) => - slashes.length % 2 ? `${slashes.slice(1)}\u2502` : `${slashes}\u2502`, - ); - lines.push(`| **${label}** | ${cell} |`); + lines.push(`| **${label}** | ${kvTableCell(value)} |`); } return lines.join("\n"); } diff --git a/packages/cli/test/lib/formatters/markdown.test.ts b/packages/cli/test/lib/formatters/markdown.test.ts index 78b716881..07449ad38 100644 --- a/packages/cli/test/lib/formatters/markdown.test.ts +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -498,6 +498,15 @@ describe("mdKvTable", () => { expect(result).toContain("| **Only** | Row |"); }); + test("keeps a literal backslash before a pipe in a code span or raw value", () => { + expect(mdKvTable([["Culprit", safeCodeSpan("a\\|b")]])).toContain( + "| **Culprit** | `a\\\u2502b` |", + ); + expect(mdKvTable([["Raw", "a\\\\|b `\\`|`"]])).toContain( + "| **Raw** | a\\\\\u2502b `\\`\u2502` |", + ); + }); + test("shows a pipe from escapeMarkdownCell without its backslash", () => { expect(mdKvTable([["Org", escapeMarkdownCell("a|b")]])).toContain( "| **Org** | a\u2502b |", From 2987c7e45e196b25275d1f9361e8eedd183e1a7d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:23:53 +0000 Subject: [PATCH 4/5] fix(cli): Escape fallback log attributes and key-value table labels The extraFields fallback in log details now escapes attribute names and values like the allAttributes path. mdKvTable now runs labels through the same cell sanitizer as values, so a pipe in a label cannot split the row. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/lib/formatters/log.ts | 5 ++++- packages/cli/src/lib/formatters/markdown.ts | 4 ++-- packages/cli/test/lib/formatters/log.test.ts | 12 ++++++++++++ packages/cli/test/lib/formatters/markdown.test.ts | 6 ++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/lib/formatters/log.ts b/packages/cli/src/lib/formatters/log.ts index f31b6f1f0..53f2a69a9 100644 --- a/packages/cli/src/lib/formatters/log.ts +++ b/packages/cli/src/lib/formatters/log.ts @@ -478,7 +478,10 @@ export function formatLogDetails( // Fallback: no trace-items detail available, show only explicitly requested fields const customRows: [string, string][] = extraFields .filter((f) => log[f] !== null && log[f] !== undefined) - .map((f) => [f, String(log[f])]); + .map((f) => [ + escapeMarkdownInline(f), + escapeMarkdownInline(String(log[f])), + ]); if (customRows.length > 0) { lines.push(""); lines.push(mdKvTable(customRows, "Custom Attributes")); diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 3ada98cc3..60c61fe08 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -97,7 +97,7 @@ export function mdRow(cells: readonly string[]): string { const KV_CELL_TOKEN = /\\[\s\S]|(? { expect(result).toContain("u_99"); }); + test("fallback: shows Markdown in extraFields names and values literally", () => { + const log = createDetailedTestLog({ + "a|b": "**bold** [link](https://example.com)", + } as DetailedSentryLog); + const result = stripAnsi( + formatLogDetails(log, "test-org", undefined, ["a|b"]), + ); + + expect(result).toContain("a│b"); + expect(result).toContain("**bold** [link](https://example.com)"); + }); + test("renders array attributes with JSON.stringify instead of [object Object]", () => { const attrsWithArray: TraceItemAttribute[] = [ { name: "tags", type: "array", value: ["prod", "web"] }, diff --git a/packages/cli/test/lib/formatters/markdown.test.ts b/packages/cli/test/lib/formatters/markdown.test.ts index 07449ad38..0b6da89cf 100644 --- a/packages/cli/test/lib/formatters/markdown.test.ts +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -507,6 +507,12 @@ describe("mdKvTable", () => { ); }); + test("keeps a pipe in a label inside its cell", () => { + expect(mdKvTable([[escapeMarkdownInline("a|b"), "v"]])).toContain( + "| **a\u2502b** | v |", + ); + }); + test("shows a pipe from escapeMarkdownCell without its backslash", () => { expect(mdKvTable([["Org", escapeMarkdownCell("a|b")]])).toContain( "| **Org** | a\u2502b |", From fae38c83f1cd90f664831fd246d81366dc1e5ac6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:01:03 +0000 Subject: [PATCH 5/5] ref(cli): Import Markdown escape helpers from toolkit-core directly Drop the escapeMarkdownCell, escapeMarkdownInline, and safeCodeSpan aliases. Call sites now import tableCell, text, and inlineCode from @sentry/toolkit-core/markdown, as MCP does. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/AGENTS.md | 2 +- .../cli/src/commands/alert/issues/list.ts | 8 +- .../cli/src/commands/alert/metrics/list.ts | 8 +- packages/cli/src/commands/dashboard/list.ts | 5 +- .../cli/src/commands/dashboard/restore.ts | 7 +- .../cli/src/commands/dashboard/revisions.ts | 9 +- .../src/commands/debug-files/print-sources.ts | 11 +- .../cli/src/commands/event/shared-events.ts | 9 +- .../cli/src/commands/integration/delete.ts | 5 +- packages/cli/src/commands/integration/list.ts | 8 +- packages/cli/src/commands/monitor/list.ts | 6 +- packages/cli/src/commands/org/list.ts | 4 +- packages/cli/src/commands/project/list.ts | 4 +- packages/cli/src/commands/release/archive.ts | 9 +- packages/cli/src/commands/release/create.ts | 9 +- packages/cli/src/commands/release/delete.ts | 7 +- packages/cli/src/commands/release/deploy.ts | 11 +- packages/cli/src/commands/release/finalize.ts | 9 +- packages/cli/src/commands/release/list.ts | 8 +- packages/cli/src/commands/release/restore.ts | 9 +- .../cli/src/commands/release/set-commits.ts | 12 +- packages/cli/src/commands/release/view.ts | 14 +- packages/cli/src/commands/replay/list.ts | 9 +- packages/cli/src/commands/repo/list.ts | 6 +- .../cli/src/commands/sourcemap/resolve.ts | 8 +- packages/cli/src/commands/team/list.ts | 4 +- packages/cli/src/lib/event-attachments.ts | 11 +- packages/cli/src/lib/formatters/accounts.ts | 9 +- packages/cli/src/lib/formatters/alerts.ts | 11 +- packages/cli/src/lib/formatters/attribute.ts | 10 +- .../cli/src/lib/formatters/conversation.ts | 45 ++--- packages/cli/src/lib/formatters/dsn.ts | 8 +- .../src/lib/formatters/explore-timeseries.ts | 19 +- packages/cli/src/lib/formatters/feedback.ts | 16 +- packages/cli/src/lib/formatters/human.ts | 191 ++++++++---------- .../cli/src/lib/formatters/issue-links.ts | 7 +- packages/cli/src/lib/formatters/issue-tags.ts | 7 +- packages/cli/src/lib/formatters/log.ts | 16 +- packages/cli/src/lib/formatters/markdown.ts | 26 +-- packages/cli/src/lib/formatters/meta-table.ts | 4 +- packages/cli/src/lib/formatters/replay.ts | 39 ++-- packages/cli/src/lib/formatters/seer.ts | 17 +- packages/cli/src/lib/formatters/table.ts | 4 +- packages/cli/src/lib/formatters/trace.ts | 24 +-- .../cli/test/lib/formatters/markdown.test.ts | 62 +++--- .../cli/test/lib/formatters/table.test.ts | 4 +- packages/toolkit-core/README.md | 4 +- 47 files changed, 317 insertions(+), 418 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 7ae9105d6..95dce22b3 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -531,7 +531,7 @@ Reference: `project/create.ts`, `release/set-commits.ts` (`--path`), `auth/login All non-trivial human output must use the markdown rendering pipeline: -- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `escapeMarkdownCell()`, `renderMarkdown()` +- Build markdown strings with helpers: `mdKvTable()`, `colorTag()`, `renderMarkdown()`, and `text()`, `tableCell()`, `inlineCode()` from `@sentry/toolkit-core/markdown` - **NEVER** use raw `muted()` / chalk in output strings — use `colorTag("muted", text)` inside markdown - Tree-structured output (box-drawing characters) that can't go through `renderMarkdown()` should use the `plainSafeMuted` pattern: `isPlainOutput() ? text : muted(text)` - `isPlainOutput()` precedence: `SENTRY_PLAIN_OUTPUT` > `NO_COLOR` > `FORCE_COLOR` (TTY only) > `!isTTY` diff --git a/packages/cli/src/commands/alert/issues/list.ts b/packages/cli/src/commands/alert/issues/list.ts index bf0df7adf..dab6c4add 100644 --- a/packages/cli/src/commands/alert/issues/list.ts +++ b/packages/cli/src/commands/alert/issues/list.ts @@ -10,6 +10,7 @@ * - project-search → find project by slug across all orgs */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../../context.js"; import { buildProjectAliasMap } from "../../../lib/alias.js"; import type { IssueAlertRule } from "../../../lib/api/alerts.js"; @@ -39,10 +40,7 @@ import { ValidationError, withAuthGuard, } from "../../../lib/errors.js"; -import { - colorTag, - escapeMarkdownCell, -} from "../../../lib/formatters/markdown.js"; +import { colorTag } from "../../../lib/formatters/markdown.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { type Column, writeTable } from "../../../lib/formatters/table.js"; import { @@ -454,7 +452,7 @@ function formatIssueAlertListHuman(result: IssueAlertListResult): string { const tableRows: Row[] = rows.map(({ rule: r, target }) => ({ id: r.id, - name: escapeMarkdownCell(r.name ?? "(untitled)"), + name: tableCell(r.name ?? "(untitled)"), ...(isMultiProject && { project: `${target.org}/${target.project}`, }), diff --git a/packages/cli/src/commands/alert/metrics/list.ts b/packages/cli/src/commands/alert/metrics/list.ts index 44f127e5a..a71f8c430 100644 --- a/packages/cli/src/commands/alert/metrics/list.ts +++ b/packages/cli/src/commands/alert/metrics/list.ts @@ -10,6 +10,7 @@ * - project-search → find project across orgs, use its org */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../../context.js"; import type { MetricAlertRule } from "../../../lib/api/alerts.js"; import { paginate } from "../../../lib/api/infrastructure.js"; @@ -36,10 +37,7 @@ import { ValidationError, withAuthGuard, } from "../../../lib/errors.js"; -import { - colorTag, - escapeMarkdownCell, -} from "../../../lib/formatters/markdown.js"; +import { colorTag } from "../../../lib/formatters/markdown.js"; import { CommandOutput } from "../../../lib/formatters/output.js"; import { type Column, writeTable } from "../../../lib/formatters/table.js"; import { @@ -465,7 +463,7 @@ function formatMetricAlertListHuman(result: MetricAlertListResult): string { const tableRows: Row[] = rows.map(({ rule: r, orgSlug }) => ({ id: r.id, - name: escapeMarkdownCell(r.name ?? "(untitled)"), + name: tableCell(r.name ?? "(untitled)"), ...(isMultiOrg && { org: orgSlug }), aggregate: r.aggregate, dataset: r.dataset, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 8e8a68c2d..d9538d768 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -5,6 +5,7 @@ * and optional client-side glob filtering by title. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import picomatch from "picomatch"; import type { SentryContext } from "../../context.js"; import { MAX_PAGINATION_PAGES } from "../../lib/api/infrastructure.js"; @@ -20,7 +21,7 @@ import { resolveCursor, } from "../../lib/db/pagination.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { fuzzyMatch } from "../../lib/fuzzy.js"; @@ -153,7 +154,7 @@ function formatDashboardListHuman(result: DashboardListResult): string { const url = buildDashboardUrl(result.orgSlug, d.id); return { id: d.id, - title: `${escapeMarkdownCell(d.title ?? "(untitled)")}\n${colorTag("muted", url)}`, + title: `${tableCell(d.title ?? "(untitled)")}\n${colorTag("muted", url)}`, widgets: String(d.widgetDisplay?.length ?? 0), }; }); diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 15e86bb41..031e6fb0f 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -4,12 +4,13 @@ * Restore a dashboard to a previous revision. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { restoreDashboardRevision } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { textParser } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; -import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { withProgress } from "../../lib/polling.js"; @@ -37,11 +38,11 @@ function formatRestoreHuman(result: RestoreResult): string { const created = formatRelativeTime(d.dateCreated); return ( - `Restored dashboard **${escapeMarkdownCell(d.title)}** to revision ${result.revisionId}.\n\n` + + `Restored dashboard **${tableCell(d.title)}** to revision ${result.revisionId}.\n\n` + "| Field | Value |\n" + "|-------|-------|\n" + `| ID | ${d.id} |\n` + - `| Title | ${escapeMarkdownCell(d.title)} |\n` + + `| Title | ${tableCell(d.title)} |\n` + `| Widgets | ${widgetCount} |\n` + `| Created | ${created} |\n` + `| URL | ${colorTag("muted", url)} |` diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 713777449..874890048 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -4,6 +4,7 @@ * List revision history for a Sentry dashboard with cursor-based pagination. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { paginate } from "../../lib/api/infrastructure.js"; import { listDashboardRevisionsPaginated } from "../../lib/api-client.js"; @@ -15,7 +16,7 @@ import { resolveCursor, } from "../../lib/db/pagination.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; @@ -63,11 +64,11 @@ function formatRevisionsHuman(result: RevisionsResult): string { const rows: RevisionRow[] = result.revisions.map((r) => ({ id: r.id, - title: escapeMarkdownCell(r.title), - author: escapeMarkdownCell( + title: tableCell(r.title), + author: tableCell( r.createdBy?.name ?? r.createdBy?.email ?? r.createdBy?.id ?? "—", ), - created: `${escapeMarkdownCell(formatRelativeTime(r.dateCreated))}\n${colorTag("muted", r.dateCreated)}`, + created: `${tableCell(formatRelativeTime(r.dateCreated))}\n${colorTag("muted", r.dateCreated)}`, })); const columns: Column[] = [ diff --git a/packages/cli/src/commands/debug-files/print-sources.ts b/packages/cli/src/commands/debug-files/print-sources.ts index 1dcb24fff..06fe188a4 100644 --- a/packages/cli/src/commands/debug-files/print-sources.ts +++ b/packages/cli/src/commands/debug-files/print-sources.ts @@ -11,6 +11,7 @@ * the bundled `symbolic` WASM module (see `src/lib/dif/`). */ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import { existsSync } from "node:fs"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; @@ -21,11 +22,7 @@ import { } from "../../lib/dif/index.js"; import { parseFilePath } from "../../lib/completion-target.js"; import { ValidationError } from "../../lib/errors.js"; -import { - colorTag, - renderMarkdown, - safeCodeSpan, -} from "../../lib/formatters/markdown.js"; +import { colorTag, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { readDebugFile } from "./read-file.js"; @@ -82,7 +79,7 @@ function formatPrintSources(data: PrintSourcesResult): string { } const sections = data.objects.map((object) => { - const header = `${object.fileFormat} ${safeCodeSpan(object.debugId)}`; + const header = `${object.fileFormat} ${inlineCode(object.debugId)}`; if (object.enumerationError !== null) { return `${header} — ${colorTag("muted", `could not read sources: ${object.enumerationError}`)}`; } @@ -90,7 +87,7 @@ function formatPrintSources(data: PrintSourcesResult): string { return `${header} — ${colorTag("muted", "no referenced sources")}`; } const lines = object.files.map( - (file) => `- ${safeCodeSpan(file.path)} — ${describeSource(file)}`, + (file) => `- ${inlineCode(file.path)} — ${describeSource(file)}`, ); const count = object.files.length; return `${header} — ${count} source file${count === 1 ? "" : "s"}:\n\n${lines.join("\n")}`; diff --git a/packages/cli/src/commands/event/shared-events.ts b/packages/cli/src/commands/event/shared-events.ts index e698c93ae..a7f3490f5 100644 --- a/packages/cli/src/commands/event/shared-events.ts +++ b/packages/cli/src/commands/event/shared-events.ts @@ -6,10 +6,11 @@ * identities and pagination hints. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { validateLimit } from "../../lib/arg-parsing.js"; import { textParser } from "../../lib/completion-target.js"; import { filterFields } from "../../lib/formatters/json.js"; -import { colorTag, escapeMarkdownCell } from "../../lib/formatters/markdown.js"; +import { colorTag } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { @@ -82,7 +83,7 @@ function getUserLabel(event: IssueEvent): string { event.user.username ?? event.user.ip_address ?? event.user.id; - return label ? escapeMarkdownCell(label) : colorTag("muted", "—"); + return label ? tableCell(label) : colorTag("muted", "—"); } /** @@ -105,12 +106,12 @@ export const EVENT_COLUMNS: Column[] = [ }, { header: "TITLE", - value: (e) => escapeMarkdownCell(e.title), + value: (e) => tableCell(e.title), truncate: true, }, { header: "PLATFORM", - value: (e) => escapeMarkdownCell(e.platform ?? "—"), + value: (e) => tableCell(e.platform ?? "—"), }, { header: "USER", diff --git a/packages/cli/src/commands/integration/delete.ts b/packages/cli/src/commands/integration/delete.ts index 975619f97..8c0b598c9 100644 --- a/packages/cli/src/commands/integration/delete.ts +++ b/packages/cli/src/commands/integration/delete.ts @@ -7,11 +7,12 @@ * flags and enforces the non-interactive guard before `func()` runs. */ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { deleteIntegration, getIntegration } from "../../lib/api-client.js"; import { ORG_TARGET } from "../../lib/completion-target.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; -import { renderMarkdown, safeCodeSpan } from "../../lib/formatters/markdown.js"; +import { renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { ID_KINDS, parseIdArguments } from "../../lib/id-arguments.js"; import { runInteractiveLogin } from "../../lib/interactive-login.js"; @@ -61,7 +62,7 @@ type DeleteFlags = { }; function formatIntegrationDeleted(result: DeleteResult): string { - const label = `${result.provider} integration ${safeCodeSpan(result.name)} (id ${result.id})`; + const label = `${result.provider} integration ${inlineCode(result.name)} (id ${result.id})`; if (result.dryRun) { return renderMarkdown( `Would remove ${label} from **${result.org}**. (dry run)`, diff --git a/packages/cli/src/commands/integration/list.ts b/packages/cli/src/commands/integration/list.ts index 2ec4d2f23..bdd06ac51 100644 --- a/packages/cli/src/commands/integration/list.ts +++ b/packages/cli/src/commands/integration/list.ts @@ -5,11 +5,11 @@ * and cursor pagination. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { listIntegrations, listIntegrationsPaginated, } from "../../lib/api-client.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -31,10 +31,10 @@ type IntegrationWithOrg = SentryIntegration & { orgSlug?: string }; const INTEGRATION_COLUMNS: Column[] = [ { header: "ORG", value: (i) => i.orgSlug || "" }, { header: "ID", value: (i) => i.id }, - { header: "PROVIDER", value: (i) => escapeMarkdownCell(i.provider.name) }, - { header: "NAME", value: (i) => escapeMarkdownCell(i.name) }, + { header: "PROVIDER", value: (i) => tableCell(i.provider.name) }, + { header: "NAME", value: (i) => tableCell(i.name) }, { header: "STATUS", value: (i) => i.organizationIntegrationStatus }, - { header: "DOMAIN", value: (i) => escapeMarkdownCell(i.domainName || "") }, + { header: "DOMAIN", value: (i) => tableCell(i.domainName || "") }, ]; /** Shared config that plugs into the org-list framework. */ diff --git a/packages/cli/src/commands/monitor/list.ts b/packages/cli/src/commands/monitor/list.ts index 78336b937..343e74f09 100644 --- a/packages/cli/src/commands/monitor/list.ts +++ b/packages/cli/src/commands/monitor/list.ts @@ -12,8 +12,8 @@ * - Cross-org project search (e.g., sentry) */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { listMonitors, listMonitorsPaginated } from "../../lib/api-client.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -53,9 +53,9 @@ export function formatSchedule(monitor: MonitorWithOrg): string { const MONITOR_COLUMNS: Column[] = [ { header: "ID", value: (m) => m.id }, { header: "SLUG", value: (m) => m.slug }, - { header: "NAME", value: (m) => escapeMarkdownCell(m.name) }, + { header: "NAME", value: (m) => tableCell(m.name) }, { header: "STATUS", value: (m) => m.status }, - { header: "SCHEDULE", value: (m) => escapeMarkdownCell(formatSchedule(m)) }, + { header: "SCHEDULE", value: (m) => tableCell(formatSchedule(m)) }, ]; /** Shared config that plugs into the org-list framework. */ diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 8ba71a743..e9fc54273 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -5,6 +5,7 @@ * when several are logged in. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; @@ -34,7 +35,6 @@ import { getAllOrgRegions } from "../../lib/db/regions.js"; import { ApiError, AuthError } from "../../lib/errors.js"; import { formatAccountLabel } from "../../lib/formatters/accounts.js"; import { getNestedValue } from "../../lib/formatters/json.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; import { filterByName } from "../../lib/list-filter.js"; @@ -185,7 +185,7 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { ) .map(([header, path]) => ({ header, - value: (e) => escapeMarkdownCell(cellOf(e, path) ?? ""), + value: (e) => tableCell(cellOf(e, path) ?? ""), })); const parts: string[] = []; diff --git a/packages/cli/src/commands/project/list.ts b/packages/cli/src/commands/project/list.ts index 085a685d8..b6d9ca618 100644 --- a/packages/cli/src/commands/project/list.ts +++ b/packages/cli/src/commands/project/list.ts @@ -10,6 +10,7 @@ * - Cross-org project search (e.g., sentry) */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { getProject, @@ -35,7 +36,6 @@ import { resolveCursor, } from "../../lib/db/pagination.js"; import { ResolutionError, withAuthGuard } from "../../lib/errors.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput, type OutputConfig, @@ -310,7 +310,7 @@ async function resolveOrgsForAutoDetect(cwd: string): Promise { const PROJECT_COLUMNS: Column[] = [ { header: "ORG", value: (p) => p.orgSlug || "" }, { header: "PROJECT", value: (p) => p.slug || "" }, - { header: "NAME", value: (p) => escapeMarkdownCell(p.name) }, + { header: "NAME", value: (p) => tableCell(p.name) }, { header: "PLATFORM", value: (p) => p.platform || "" }, ]; diff --git a/packages/cli/src/commands/release/archive.ts b/packages/cli/src/commands/release/archive.ts index 55794bf5f..8b2f20ae6 100644 --- a/packages/cli/src/commands/release/archive.ts +++ b/packages/cli/src/commands/release/archive.ts @@ -6,15 +6,14 @@ * `sentry release restore`. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { updateRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag, - escapeMarkdownInline, mdKvTable, renderMarkdown, - safeCodeSpan, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; @@ -31,15 +30,15 @@ const USAGE_HINT = "sentry release archive [/]"; function formatReleaseArchived(data: Record): string { if (data.dryRun) { return renderMarkdown( - `Would archive release ${safeCodeSpan(String(data.version))} (dry run)`, + `Would archive release ${inlineCode(String(data.version))} (dry run)`, ); } const release = data as unknown as SentryRelease; const lines: string[] = []; - lines.push(`## Release Archived: ${escapeMarkdownInline(release.version)}`); + lines.push(`## Release Archived: ${text(release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); kvRows.push(["Status", colorTag("muted", "Archived")]); lines.push(mdKvTable(kvRows)); return renderMarkdown(lines.join("\n")); diff --git a/packages/cli/src/commands/release/create.ts b/packages/cli/src/commands/release/create.ts index 236a0c7e9..000957fae 100644 --- a/packages/cli/src/commands/release/create.ts +++ b/packages/cli/src/commands/release/create.ts @@ -4,6 +4,7 @@ * Create a new Sentry release. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { createRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; @@ -14,10 +15,8 @@ import { } from "../../lib/completion-target.js"; import { colorTag, - escapeMarkdownInline, mdKvTable, renderMarkdown, - safeCodeSpan, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; @@ -28,10 +27,10 @@ const USAGE_HINT = "sentry release create [/]"; function formatReleaseCreated(release: SentryRelease): string { const lines: string[] = []; - lines.push(`## Release Created: ${escapeMarkdownInline(release.version)}`); + lines.push(`## Release Created: ${text(release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); kvRows.push([ "Status", release.dateReleased @@ -101,7 +100,7 @@ export const createCommand = buildCommand({ if (data.dryRun) { const projects = (data.projects as string[]) || []; return renderMarkdown( - `Would create release ${safeCodeSpan(String(data.version))}` + + `Would create release ${inlineCode(String(data.version))}` + (projects.length > 0 ? ` in projects: ${projects.join(", ")}` : "") + diff --git a/packages/cli/src/commands/release/delete.ts b/packages/cli/src/commands/release/delete.ts index 7907494cf..5a56aa8f8 100644 --- a/packages/cli/src/commands/release/delete.ts +++ b/packages/cli/src/commands/release/delete.ts @@ -7,10 +7,11 @@ * flags and enforces the non-interactive guard before `func()` runs. */ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { deleteRelease, getRelease } from "../../lib/api-client.js"; import { ApiError } from "../../lib/errors.js"; -import { renderMarkdown, safeCodeSpan } from "../../lib/formatters/markdown.js"; +import { renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { buildDeleteCommand, @@ -32,14 +33,14 @@ type DeleteResult = { function formatReleaseDeleted(result: DeleteResult): string { if (result.dryRun) { return renderMarkdown( - `Would delete release ${safeCodeSpan(result.version)} from **${result.org}**. (dry run)`, + `Would delete release ${inlineCode(result.version)} from **${result.org}**. (dry run)`, ); } if (!result.deleted) { return "Cancelled."; } return renderMarkdown( - `Release ${safeCodeSpan(result.version)} deleted from **${result.org}**.`, + `Release ${inlineCode(result.version)} deleted from **${result.org}**.`, ); } diff --git a/packages/cli/src/commands/release/deploy.ts b/packages/cli/src/commands/release/deploy.ts index 4af686c51..14d4ee105 100644 --- a/packages/cli/src/commands/release/deploy.ts +++ b/packages/cli/src/commands/release/deploy.ts @@ -5,6 +5,7 @@ * Environment is the first positional arg (required), deploy name is optional second. */ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { createReleaseDeploy } from "../../lib/api-client.js"; import { buildCommand, numberParser } from "../../lib/command.js"; @@ -14,11 +15,7 @@ import { parseUrl, } from "../../lib/completion-target.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; -import { - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "../../lib/formatters/markdown.js"; +import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { formatRelativeTime } from "../../lib/formatters/time-utils.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; @@ -31,7 +28,7 @@ const USAGE_HINT = function formatDeployCreated(data: Record): string { if (data.dryRun) { return renderMarkdown( - `Would create deploy for ${safeCodeSpan(String(data.environment))} environment (dry run)`, + `Would create deploy for ${inlineCode(String(data.environment))} environment (dry run)`, ); } const deploy = data as unknown as SentryDeploy; @@ -40,7 +37,7 @@ function formatDeployCreated(data: Record): string { lines.push(""); const kvRows: [string, string][] = []; kvRows.push(["ID", deploy.id]); - kvRows.push(["Environment", safeCodeSpan(deploy.environment)]); + kvRows.push(["Environment", inlineCode(deploy.environment)]); kvRows.push(["Name", deploy.name || "—"]); kvRows.push(["URL", deploy.url || "—"]); kvRows.push([ diff --git a/packages/cli/src/commands/release/finalize.ts b/packages/cli/src/commands/release/finalize.ts index 92f3cae8c..9fcbab56c 100644 --- a/packages/cli/src/commands/release/finalize.ts +++ b/packages/cli/src/commands/release/finalize.ts @@ -4,16 +4,15 @@ * Finalize a release by setting its dateReleased to now. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { updateRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { parseText, parseUrl } from "../../lib/completion-target.js"; import { colorTag, - escapeMarkdownInline, mdKvTable, renderMarkdown, - safeCodeSpan, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; @@ -25,15 +24,15 @@ const USAGE_HINT = "sentry release finalize [/]"; function formatReleaseFinalized(data: Record): string { if (data.dryRun) { return renderMarkdown( - `Would finalize release ${safeCodeSpan(String(data.version))} (dry run)`, + `Would finalize release ${inlineCode(String(data.version))} (dry run)`, ); } const release = data as unknown as SentryRelease; const lines: string[] = []; - lines.push(`## Release Finalized: ${escapeMarkdownInline(release.version)}`); + lines.push(`## Release Finalized: ${text(release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); kvRows.push(["Status", colorTag("green", "Finalized")]); kvRows.push(["Released", release.dateReleased || "—"]); lines.push(mdKvTable(kvRows)); diff --git a/packages/cli/src/commands/release/list.ts b/packages/cli/src/commands/release/list.ts index 6bcea823e..6d1b638e1 100644 --- a/packages/cli/src/commands/release/list.ts +++ b/packages/cli/src/commands/release/list.ts @@ -5,6 +5,7 @@ * project scoping, environment filtering, and rich terminal styling. */ +import { text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { type ListReleasesOptions, @@ -19,10 +20,7 @@ import { parseEnvironmentList, parseRelativePeriod, } from "../../lib/completion-target.js"; -import { - colorTag, - escapeMarkdownInline, -} from "../../lib/formatters/markdown.js"; +import { colorTag } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { sparkline } from "../../lib/formatters/sparkline.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; @@ -135,7 +133,7 @@ function extractSessionPoints(stats?: Record): number[] { * Second line: muted "age | last-deploy-env". */ function formatVersionCell(r: ReleaseWithOrg): string { - const version = escapeMarkdownInline(r.shortVersion || r.version); + const version = text(r.shortVersion || r.version); const org = r.orgSlug || ""; const linked = org ? `[**${version}**](${buildReleaseUrl(org, r.version)})` diff --git a/packages/cli/src/commands/release/restore.ts b/packages/cli/src/commands/release/restore.ts index 7fe05cc24..a85108bec 100644 --- a/packages/cli/src/commands/release/restore.ts +++ b/packages/cli/src/commands/release/restore.ts @@ -5,15 +5,14 @@ * visible in the default release list again. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { updateRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag, - escapeMarkdownInline, mdKvTable, renderMarkdown, - safeCodeSpan, } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { DRY_RUN_ALIASES, DRY_RUN_FLAG } from "../../lib/mutate-command.js"; @@ -30,15 +29,15 @@ const USAGE_HINT = "sentry release restore [/]"; function formatReleaseRestored(data: Record): string { if (data.dryRun) { return renderMarkdown( - `Would restore release ${safeCodeSpan(String(data.version))} (dry run)`, + `Would restore release ${inlineCode(String(data.version))} (dry run)`, ); } const release = data as unknown as SentryRelease; const lines: string[] = []; - lines.push(`## Release Restored: ${escapeMarkdownInline(release.version)}`); + lines.push(`## Release Restored: ${text(release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); kvRows.push(["Status", colorTag("green", "Open")]); lines.push(mdKvTable(kvRows)); return renderMarkdown(lines.join("\n")); diff --git a/packages/cli/src/commands/release/set-commits.ts b/packages/cli/src/commands/release/set-commits.ts index 28ec894a7..4a5db7ed2 100644 --- a/packages/cli/src/commands/release/set-commits.ts +++ b/packages/cli/src/commands/release/set-commits.ts @@ -4,6 +4,7 @@ * Associate commits with a release using auto-discovery or local git history. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { NO_REPO_INTEGRATIONS_MESSAGE, @@ -24,12 +25,7 @@ import { ValidationError, validationError, } from "../../lib/errors.js"; -import { - escapeMarkdownInline, - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "../../lib/formatters/markdown.js"; +import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { getCommitLog, @@ -338,10 +334,10 @@ function parseLocalScope(flags: { function formatCommitsSet(release: SentryRelease): string { const lines: string[] = []; - lines.push(`## Commits Set: ${escapeMarkdownInline(release.version)}`); + lines.push(`## Commits Set: ${text(release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); kvRows.push(["Commits", String(release.commitCount ?? 0)]); lines.push(mdKvTable(kvRows)); return renderMarkdown(lines.join("\n")); diff --git a/packages/cli/src/commands/release/view.ts b/packages/cli/src/commands/release/view.ts index 89acbade4..1662d2657 100644 --- a/packages/cli/src/commands/release/view.ts +++ b/packages/cli/src/commands/release/view.ts @@ -5,17 +5,15 @@ * health and adoption metrics when available. */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { getRelease } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { colorTag, - escapeMarkdownCell, - escapeMarkdownInline, mdKvTable, mdTableHeader, renderMarkdown, - safeCodeSpan, } from "../../lib/formatters/markdown.js"; import { fmtCount, fmtPct } from "../../lib/formatters/numbers.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -81,7 +79,7 @@ function formatProjectHealthTable(release: SentryRelease): string { for (const project of projects) { const h = project.healthData; const cells = [ - escapeMarkdownCell(project.slug), + tableCell(project.slug), mutedIfDash(fmtPct(h?.adoption)), fmtCrashFree(h?.crashFreeUsers), fmtCrashFree(h?.crashFreeSessions), @@ -97,15 +95,13 @@ function formatProjectHealthTable(release: SentryRelease): string { function formatReleaseDetails(release: SentryRelease): string { const lines: string[] = []; - lines.push( - `## Release ${escapeMarkdownInline(release.shortVersion || release.version)}`, - ); + lines.push(`## Release ${text(release.shortVersion || release.version)}`); lines.push(""); const kvRows: [string, string][] = []; - kvRows.push(["Version", safeCodeSpan(release.version)]); + kvRows.push(["Version", inlineCode(release.version)]); if (release.shortVersion && release.shortVersion !== release.version) { - kvRows.push(["Short Version", safeCodeSpan(release.shortVersion)]); + kvRows.push(["Short Version", inlineCode(release.shortVersion)]); } kvRows.push([ "Status", diff --git a/packages/cli/src/commands/replay/list.ts b/packages/cli/src/commands/replay/list.ts index 2a0028c61..3fc592a54 100644 --- a/packages/cli/src/commands/replay/list.ts +++ b/packages/cli/src/commands/replay/list.ts @@ -4,6 +4,7 @@ * List Session Replays from Sentry. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { SentryContext } from "../../context.js"; import { isReplaySortValue, @@ -23,11 +24,7 @@ import { resolveCursor, } from "../../lib/db/pagination.js"; import { toSearchQueryError } from "../../lib/errors.js"; -import { - escapeMarkdownCell, - formatRelativeTime, - formatTable, -} from "../../lib/formatters/index.js"; +import { formatRelativeTime, formatTable } from "../../lib/formatters/index.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import type { Column } from "../../lib/formatters/table.js"; @@ -165,7 +162,7 @@ const REPLAY_COLUMNS: Column[] = [ }, { header: "USER", - value: (replay) => escapeMarkdownCell(replayUserLabel(replay)), + value: (replay) => tableCell(replayUserLabel(replay)), minWidth: 16, truncate: true, }, diff --git a/packages/cli/src/commands/repo/list.ts b/packages/cli/src/commands/repo/list.ts index b31dd4edd..5f881fbcd 100644 --- a/packages/cli/src/commands/repo/list.ts +++ b/packages/cli/src/commands/repo/list.ts @@ -10,11 +10,11 @@ * - Bare org slug (e.g., sentry) - lists repos for that org */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { listRepositories, listRepositoriesPaginated, } from "../../lib/api-client.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -35,10 +35,10 @@ type RepositoryWithOrg = SentryRepository & { orgSlug?: string }; /** Column definitions for the repository table. */ const REPO_COLUMNS: Column[] = [ { header: "ORG", value: (r) => r.orgSlug || "" }, - { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, + { header: "NAME", value: (r) => tableCell(r.name) }, { header: "PROVIDER", value: (r) => r.provider.name }, { header: "STATUS", value: (r) => r.status }, - { header: "URL", value: (r) => escapeMarkdownCell(r.url || "") }, + { header: "URL", value: (r) => tableCell(r.url || "") }, ]; /** Shared config that plugs into the org-list framework. */ diff --git a/packages/cli/src/commands/sourcemap/resolve.ts b/packages/cli/src/commands/sourcemap/resolve.ts index e812826f0..b84503c3e 100644 --- a/packages/cli/src/commands/sourcemap/resolve.ts +++ b/packages/cli/src/commands/sourcemap/resolve.ts @@ -7,6 +7,7 @@ * `sentry-cli sourcemaps resolve` command without mutating any files. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { relative, resolve as resolvePath } from "node:path"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; @@ -18,7 +19,6 @@ import { } from "../../lib/completion-target.js"; import { colorTag, - escapeMarkdownCell, mdKvTable, renderMarkdown, } from "../../lib/formatters/markdown.js"; @@ -64,7 +64,7 @@ type ResolveCommandResult = { */ function describeMapStatus(file: ResolveFileEntry): string { if (file.mapPath) { - return escapeMarkdownCell(file.mapPath); + return tableCell(file.mapPath); } if (file.inline) { return colorTag("muted", "inline (data: URL)"); @@ -97,10 +97,10 @@ function formatResolveResult(data: ResolveCommandResult): string { lines.push("| --- | --- | --- |"); for (const file of data.files) { const debugId = file.debugId - ? escapeMarkdownCell(file.debugId) + ? tableCell(file.debugId) : colorTag("muted", "—"); lines.push( - `| ${escapeMarkdownCell(file.path)} | ${describeMapStatus(file)} | ${debugId} |`, + `| ${tableCell(file.path)} | ${describeMapStatus(file)} | ${debugId} |`, ); } } diff --git a/packages/cli/src/commands/team/list.ts b/packages/cli/src/commands/team/list.ts index 50b5ac6a3..112e75936 100644 --- a/packages/cli/src/commands/team/list.ts +++ b/packages/cli/src/commands/team/list.ts @@ -10,12 +10,12 @@ * - Cross-org project search (e.g., sentry) */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { listProjectTeams, listTeams, listTeamsPaginated, } from "../../lib/api-client.js"; -import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { type Column, formatTable } from "../../lib/formatters/table.js"; import { buildOrgListCommand, @@ -34,7 +34,7 @@ type TeamWithOrg = SentryTeam & { orgSlug?: string }; const TEAM_COLUMNS: Column[] = [ { header: "ORG", value: (t) => t.orgSlug || "" }, { header: "SLUG", value: (t) => t.slug }, - { header: "NAME", value: (t) => escapeMarkdownCell(t.name) }, + { header: "NAME", value: (t) => tableCell(t.name) }, { header: "MEMBERS", value: (t) => String(t.memberCount ?? ""), diff --git a/packages/cli/src/lib/event-attachments.ts b/packages/cli/src/lib/event-attachments.ts index 4580ddacb..5f5d864ce 100644 --- a/packages/cli/src/lib/event-attachments.ts +++ b/packages/cli/src/lib/event-attachments.ts @@ -5,14 +5,11 @@ * absolute authenticated API URL. JSON output never carries file bytes. */ +import { inlineCode, text } from "@sentry/toolkit-core/markdown"; import type { EventAttachmentDetailsResponse } from "@sentry/api"; import type { SentryEvent } from "../types/index.js"; import { listEventAttachments } from "./api-client.js"; -import { - escapeMarkdownInline, - renderMarkdown, - safeCodeSpan, -} from "./formatters/markdown.js"; +import { renderMarkdown } from "./formatters/markdown.js"; import { formatBytes } from "./formatters/numbers.js"; import { logger } from "./logger.js"; import { resolveOrgRegion } from "./region.js"; @@ -144,8 +141,8 @@ function formatAttachment(attachment: EventAttachmentDetailsResponse): string { const details = [attachment.mimetype, formatBytes(attachment.size)] .filter(Boolean) .join(", "); - const name = escapeMarkdownInline(attachment.name); - return `- ${name}${details ? ` (${escapeMarkdownInline(details)})` : ""} — ${safeCodeSpan(attachment.id)}`; + const name = text(attachment.name); + return `- ${name}${details ? ` (${text(details)})` : ""} — ${inlineCode(attachment.id)}`; } /** diff --git a/packages/cli/src/lib/formatters/accounts.ts b/packages/cli/src/lib/formatters/accounts.ts index 9ab42bade..41e9cbca4 100644 --- a/packages/cli/src/lib/formatters/accounts.ts +++ b/packages/cli/src/lib/formatters/accounts.ts @@ -3,8 +3,9 @@ * `logout`). */ +import { text } from "@sentry/toolkit-core/markdown"; import { type AccountSummary, hostnameOf } from "../db/accounts.js"; -import { colorTag, escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { colorTag, renderMarkdown } from "./markdown.js"; import { formatTable } from "./table.js"; /** @@ -60,11 +61,11 @@ export function formatAccountList(data: AccountListResult): string { { header: "", value: (a) => (a.active ? "*" : ""), minWidth: 1 }, { header: "ACCOUNT", - value: (a) => escapeMarkdownInline(formatAccountIdentity(a)), + value: (a) => text(formatAccountIdentity(a)), }, { header: "SERVER", - value: (a) => escapeMarkdownInline(hostnameOf(a.host) ?? "unknown"), + value: (a) => text(hostnameOf(a.host) ?? "unknown"), }, { header: "STATUS", value: accountStatus }, ]); @@ -79,7 +80,7 @@ export type AccountSwitchResult = { /** Human output for `sentry auth switch`. */ export function formatAccountSwitch(data: AccountSwitchResult): string { - const label = escapeMarkdownInline(formatAccountLabel(data.account)); + const label = text(formatAccountLabel(data.account)); const line = data.switched ? `${colorTag("green", "✓")} Switched to ${label}.` : `Already using ${label}.`; diff --git a/packages/cli/src/lib/formatters/alerts.ts b/packages/cli/src/lib/formatters/alerts.ts index 53f98d3d0..eda07e896 100644 --- a/packages/cli/src/lib/formatters/alerts.ts +++ b/packages/cli/src/lib/formatters/alerts.ts @@ -1,9 +1,10 @@ /** Human-readable inspection of metric detectors and linked workflows. */ +import { text } from "@sentry/toolkit-core/markdown"; import type { GetOrganizationWorkflowResponse } from "@sentry/api"; import { METRIC_MONITOR_PRIORITY } from "@sentry/toolkit-core/metric-monitor"; import { metricAlertStatusLabel } from "../../commands/alert/metrics/status.js"; import type { MetricAlertRule } from "../api/alerts.js"; -import { escapeMarkdownInline, mdKvTable, renderMarkdown } from "./markdown.js"; +import { mdKvTable, renderMarkdown } from "./markdown.js"; export type MetricAlertViewResult = { orgSlug: string; @@ -38,7 +39,7 @@ function valueText(value: unknown): string { function table(rows: Row[], heading?: string): string { return mdKvTable( - rows.map(([label, value]) => [label, escapeMarkdownInline(value)]), + rows.map(([label, value]) => [label, text(value)]), heading, ); } @@ -137,7 +138,7 @@ function workflowGroupSections(value: unknown, heading: string): string[] { function workflowSections(workflow: GetOrganizationWorkflowResponse): string[] { const sections = [ - `## Workflow ${escapeMarkdownInline(workflow.id)}: ${escapeMarkdownInline(workflow.name)}`, + `## Workflow ${text(workflow.id)}: ${text(workflow.name)}`, table([ ["Status", workflow.enabled ? "enabled" : "disabled"], ["Environment", workflow.environment ?? "all"], @@ -161,7 +162,7 @@ function workflowSections(workflow: GetOrganizationWorkflowResponse): string[] { export function formatMetricAlertView(data: MetricAlertViewResult): string { const { orgSlug, rule, workflows, workflowErrors } = data; const sections = [ - `## Metric alert rule in ${escapeMarkdownInline(orgSlug)}`, + `## Metric alert rule in ${text(orgSlug)}`, table([ ["ID", rule.id], ["Name", rule.name], @@ -200,7 +201,7 @@ export function formatMetricAlertView(data: MetricAlertViewResult): string { const reason = error.status === 403 ? "access denied" : "not found or inaccessible"; sections.push( - `Workflow ${escapeMarkdownInline(error.id)}: ${reason} (HTTP ${error.status}); notification configuration is unverified.`, + `Workflow ${text(error.id)}: ${reason} (HTTP ${error.status}); notification configuration is unverified.`, ); } return renderMarkdown(sections.join("\n\n")); diff --git a/packages/cli/src/lib/formatters/attribute.ts b/packages/cli/src/lib/formatters/attribute.ts index c1365ac42..f8f1254f4 100644 --- a/packages/cli/src/lib/formatters/attribute.ts +++ b/packages/cli/src/lib/formatters/attribute.ts @@ -4,12 +4,12 @@ * Provides formatting utilities for displaying Sentry attributes in the CLI. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { TraceItemAttributeContext, TraceItemAttributeDefinition, } from "../api/traces.js"; import type { ListResult } from "../org-list.js"; -import { escapeMarkdownCell } from "./markdown.js"; import { type Column, formatTable } from "./table.js"; /** @@ -84,7 +84,7 @@ const KEY_COLUMNS: Column[] = [ // The exact key, not the display name: custom non-string attributes only // work in --query/--fields in their typed form, e.g. tags[cart.total,number]. header: "KEY", - value: (item) => escapeMarkdownCell(item.key), + value: (item) => tableCell(item.key), minWidth: 24, }, { header: "TYPE", value: (item) => item.type, minWidth: 7 }, @@ -93,7 +93,7 @@ const KEY_COLUMNS: Column[] = [ const ALIASES_COLUMN: Column = { header: "ALIASES", - value: (item) => escapeMarkdownCell(item.aliases?.join(", ") ?? ""), + value: (item) => tableCell(item.aliases?.join(", ") ?? ""), }; const COLUMNS: Column[] = [...KEY_COLUMNS, ALIASES_COLUMN]; @@ -104,13 +104,13 @@ const CONTEXT_COLUMNS: Column[] = [ { header: "DESCRIPTION", value: (item) => - escapeMarkdownCell(item.context ? describeAttribute(item.context) : ""), + tableCell(item.context ? describeAttribute(item.context) : ""), minWidth: 20, }, { header: "EXAMPLES", value: (item) => - escapeMarkdownCell( + tableCell( (item.context?.examples ?? []) .slice(0, MAX_EXAMPLES) .map(String) diff --git a/packages/cli/src/lib/formatters/conversation.ts b/packages/cli/src/lib/formatters/conversation.ts index 27a1db5ee..7db3fa8d2 100644 --- a/packages/cli/src/lib/formatters/conversation.ts +++ b/packages/cli/src/lib/formatters/conversation.ts @@ -5,19 +5,13 @@ * Transcript parsing logic ported from sentry-mcp get-ai-conversation-details. */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import type { AgentConversationSpan, ConversationListItem, ConversationStats, } from "../../types/conversation.js"; -import { - colorTag, - escapeMarkdownCell, - escapeMarkdownInline, - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "./markdown.js"; +import { colorTag, mdKvTable, renderMarkdown } from "./markdown.js"; import { type Column, formatTable } from "./table.js"; // --------------------------------------------------------------------------- @@ -58,7 +52,7 @@ const WIDE_TABLE_MIN_TERM_WIDTH = 100; const ID_COLUMN: Column = { header: "ID", value: (c) => { - const id = escapeMarkdownCell(truncate(c.conversationId, 40)); + const id = tableCell(truncate(c.conversationId, 40)); return c.webUrl ? `[${id}](${c.webUrl})` : id; }, truncate: true, @@ -67,7 +61,7 @@ const ID_COLUMN: Column = { const TITLE_COLUMN: Column = { header: "Title", - value: (c) => (c.title ? escapeMarkdownCell(truncate(c.title, 40)) : "—"), + value: (c) => (c.title ? tableCell(truncate(c.title, 40)) : "—"), truncate: true, }; @@ -102,13 +96,12 @@ const ERRS_COLUMN: Column = { const USER_COLUMN: Column = { header: "User", - value: (c) => escapeMarkdownCell(c.user?.email ?? c.user?.username ?? "—"), + value: (c) => tableCell(c.user?.email ?? c.user?.username ?? "—"), }; const FIRST_INPUT_COLUMN: Column = { header: "First Input", - value: (c) => - c.firstInput ? escapeMarkdownCell(truncate(c.firstInput)) : "—", + value: (c) => (c.firstInput ? tableCell(truncate(c.firstInput)) : "—"), truncate: true, }; @@ -405,15 +398,15 @@ function appendContentBlock( lines.push(`**${label}**`); lines.push(""); for (const line of truncate(content, 600).split("\n")) { - lines.push(escapeMarkdownInline(line)); + lines.push(text(line)); } lines.push(""); } function formatTurnHuman(turn: ConversationTurn): string { const meta = [ - turn.model ? escapeMarkdownInline(turn.model) : null, - turn.agentName ? escapeMarkdownInline(turn.agentName) : null, + turn.model ? text(turn.model) : null, + turn.agentName ? text(turn.agentName) : null, turn.totalTokens > 0 ? `${turn.totalTokens} tokens` : null, formatDuration(turn.durationMs), ] @@ -440,11 +433,9 @@ function formatTurnHuman(turn: ConversationTurn): string { lines.push(""); for (const tc of turn.toolCalls) { const status = - tc.status && tc.status !== "ok" - ? ` (${escapeMarkdownInline(tc.status)})` - : ""; + tc.status && tc.status !== "ok" ? ` (${text(tc.status)})` : ""; lines.push( - `- ${safeCodeSpan(tc.name)} — ${formatDuration(tc.durationMs)}${status}`, + `- ${inlineCode(tc.name)} — ${formatDuration(tc.durationMs)}${status}`, ); } lines.push(""); @@ -475,18 +466,16 @@ export type TranscriptResult = { export function formatTranscriptResult(result: TranscriptResult): string { if (result.spanCount === 0) { - const id = escapeMarkdownInline(result.conversationId); - const titled = result.title - ? `${id} (${escapeMarkdownInline(result.title)})` - : id; + const id = text(result.conversationId); + const titled = result.title ? `${id} (${text(result.title)})` : id; return `No spans found for conversation ${titled} in the last 30 days.`; } // Keep the conversation ID in the heading (needed to re-run `conversation // view`). Surface title as a table row so it is not duplicated in the heading. const rows: [string, string][] = [ - ["Org", escapeMarkdownCell(result.org)], - ["Projects", result.projects.map(escapeMarkdownCell).join(", ") || "—"], + ["Org", tableCell(result.org)], + ["Projects", result.projects.map(tableCell).join(", ") || "—"], ["Started", formatEpoch(result.startTimestamp)], ["Ended", formatEpoch(result.endTimestamp)], ["Turns", String(result.turns.length)], @@ -509,11 +498,11 @@ export function formatTranscriptResult(result: TranscriptResult): string { rows.push(["Tool Errors", String(result.toolErrors)]); } if (result.title) { - rows.unshift(["Title", escapeMarkdownCell(result.title)]); + rows.unshift(["Title", tableCell(result.title)]); } const lines: string[] = [ - `# Agent Conversation: ${escapeMarkdownInline(result.conversationId)}`, + `# Agent Conversation: ${text(result.conversationId)}`, "", mdKvTable(rows), "", diff --git a/packages/cli/src/lib/formatters/dsn.ts b/packages/cli/src/lib/formatters/dsn.ts index 2cf71005a..230cfb500 100644 --- a/packages/cli/src/lib/formatters/dsn.ts +++ b/packages/cli/src/lib/formatters/dsn.ts @@ -1,8 +1,8 @@ /** Human output for the public DSN fields shown in Sentry's Client Keys UI. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { ProjectDsn } from "../api/projects.js"; import type { ListResult } from "../org-list.js"; -import { escapeMarkdownCell } from "./markdown.js"; import { type Column, formatTable } from "./table.js"; import { formatRelativeTime } from "./time-utils.js"; @@ -17,12 +17,12 @@ export type DsnListItem = ProjectDsn & { const COLUMNS: Column[] = [ { header: "PROJECT", - value: (item) => escapeMarkdownCell(`${item.org}/${item.project}`), + value: (item) => tableCell(`${item.org}/${item.project}`), minWidth: 24, }, { header: "NAME", - value: (item) => escapeMarkdownCell(item.name), + value: (item) => tableCell(item.name), minWidth: 12, }, { @@ -37,7 +37,7 @@ const COLUMNS: Column[] = [ }, { header: "DSN", - value: (item) => escapeMarkdownCell(item.dsn), + value: (item) => tableCell(item.dsn), shrinkable: false, }, ]; diff --git a/packages/cli/src/lib/formatters/explore-timeseries.ts b/packages/cli/src/lib/formatters/explore-timeseries.ts index 18770fb43..7e16bf877 100644 --- a/packages/cli/src/lib/formatters/explore-timeseries.ts +++ b/packages/cli/src/lib/formatters/explore-timeseries.ts @@ -3,11 +3,12 @@ * Keeps response metadata and bucket values intact for machine consumers. */ +import { text } from "@sentry/toolkit-core/markdown"; import type { EventsTimeSeriesResponse } from "../api/explore.js"; import { formatLegendLabel } from "./chart-presentation.js"; import { formatExploreTimeSeriesChart } from "./explore-timeseries-chart.js"; import { filterFields } from "./json.js"; -import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { renderMarkdown } from "./markdown.js"; import { buildMetaColumns } from "./meta-table.js"; import { formatTable } from "./table.js"; import { @@ -54,9 +55,7 @@ function formatSeries(series: TimeSeries): string { // Every admitted Explore chart has room for its single fitted legend label. // Keep the original expression when its fitted label omits more than syntax. if (!chart || formatLegendLabel(series.yAxis).abbreviated) { - details.push( - `**${escapeMarkdownInline(series.yAxis)}${chart ? "" : " over time"}**`, - ); + details.push(`**${text(series.yAxis)}${chart ? "" : " over time"}**`); } details.push(`Interval: ${formatDurationCompactMs(series.meta.interval)}`); // The typed axis formatter appends units only for duration and size values. @@ -68,7 +67,7 @@ function formatSeries(series: TimeSeries): string { series.meta.valueUnit && series.meta.valueUnit !== "none" ) { - details.push(`Unit: ${escapeMarkdownInline(series.meta.valueUnit)}`); + details.push(`Unit: ${text(series.meta.valueUnit)}`); } if (series.meta.dataScanned === "partial") { @@ -123,7 +122,7 @@ function formatBuckets(series: TimeSeries, buckets: Bucket[]): string { ...column, value: (row: Record) => typeof row.value === "string" - ? escapeMarkdownInline(row.value.replace(/\n/g, " ")) + ? text(row.value.replace(/\n/g, " ")) : column.value(row), }, ); @@ -142,9 +141,7 @@ export function formatExploreTimeSeriesHuman( data: ExploreTimeSeriesData, ): string { const scope = data.project ? `${data.org}/${data.project}` : data.org; - const details = [ - `Querying ${escapeMarkdownInline(data.dataset)} in ${escapeMarkdownInline(scope)}`, - ]; + const details = [`Querying ${text(data.dataset)} in ${text(scope)}`]; if (data.meta?.start !== undefined && data.meta.end !== undefined) { details.push( `Range (UTC): ${formatUtcTimestamp(data.meta.start)} to ${formatUtcTimestamp(data.meta.end)}`, @@ -160,9 +157,7 @@ export function formatExploreTimeSeriesHuman( ), ); if (ingestion && (hasPendingIngestion || ingestion.status === "stalled")) { - ingestionDetails.push( - `Ingestion status: ${escapeMarkdownInline(ingestion.status)}.`, - ); + ingestionDetails.push(`Ingestion status: ${text(ingestion.status)}.`); if (ingestion.delaySeconds != null) { ingestionDetails.push( `Reported ingestion delay: ${formatDurationCompact(ingestion.delaySeconds)}.`, diff --git a/packages/cli/src/lib/formatters/feedback.ts b/packages/cli/src/lib/formatters/feedback.ts index 887592d20..c19060e90 100644 --- a/packages/cli/src/lib/formatters/feedback.ts +++ b/packages/cli/src/lib/formatters/feedback.ts @@ -5,6 +5,7 @@ * passing them to Markdown, table, or shared event renderers. */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import type { EventAttachmentDetailsResponse } from "@sentry/api"; import wrapAnsi from "wrap-ansi"; import type { @@ -16,14 +17,7 @@ import type { } from "../../types/index.js"; import { getReplayIdFromEvent } from "../replay-search.js"; import { formatEventDetails } from "./human.js"; -import { - colorTag, - escapeMarkdownCell, - escapeMarkdownInline, - mdKvTable, - renderMarkdown, - safeCodeSpan, -} from "./markdown.js"; +import { colorTag, mdKvTable, renderMarkdown } from "./markdown.js"; import { formatBytes } from "./numbers.js"; import { stripAnsi } from "./plain-detect.js"; import { type Column, formatTable } from "./table.js"; @@ -72,15 +66,15 @@ function sanitizeFeedbackInline(value: string): string { } function escapeFeedbackInline(value: string): string { - return escapeMarkdownInline(sanitizeFeedbackInline(value)); + return text(sanitizeFeedbackInline(value)); } function escapeFeedbackCell(value: string): string { - return escapeMarkdownCell(sanitizeFeedbackInline(value)); + return tableCell(sanitizeFeedbackInline(value)); } function feedbackCodeSpan(value: string): string { - return safeCodeSpan(sanitizeFeedbackInline(value)); + return inlineCode(sanitizeFeedbackInline(value)); } /** Recursively sanitize event strings and keys before human rendering. */ diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index c886f20a2..af4338384 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -9,6 +9,7 @@ // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node"; +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import prettyMs from "pretty-ms"; import type { DashboardDetail, @@ -37,15 +38,12 @@ import { formatAccountLabel } from "./accounts.js"; import { type FixabilityTier, muted } from "./colors.js"; import { colorTag, - escapeMarkdownCell, - escapeMarkdownInline, escapeMarkdownLines, isPlainOutput, mdKvTable, mdRow, mdTableHeader, renderMarkdown, - safeCodeSpan, } from "./markdown.js"; import { sparkline } from "./sparkline.js"; import { colorizeSql, isDbSpanOp } from "./sql.js"; @@ -560,13 +558,13 @@ function formatIdCell( * @returns Cell string */ function formatIssueCell(issue: SentryIssue, compact = false): string { - const title = `**${escapeMarkdownInline(issue.title)}**`; + const title = `**${text(issue.title)}**`; if (compact) { return title; } const subtitle = formatIssueSubtitle(issue.metadata); if (subtitle) { - return `${title}\n${colorTag("muted", escapeMarkdownInline(subtitle))}`; + return `${title}\n${colorTag("muted", text(subtitle))}`; } return title; } @@ -807,7 +805,7 @@ function formatPriorityLabel(priority?: string | null): string { export function formatIssueDetails(issue: SentryIssue): string { const lines: string[] = []; - lines.push(`## ${issue.shortId}: ${escapeMarkdownInline(issue.title ?? "")}`); + lines.push(`## ${issue.shortId}: ${text(issue.title ?? "")}`); lines.push(""); // Key-value details as a table @@ -840,25 +838,21 @@ export function formatIssueDetails(issue: SentryIssue): string { kvRows.push(["Type", issue.type ?? "unknown"]); kvRows.push([ "Assignee", - escapeMarkdownInline(String(issue.assignedTo?.name ?? "Unassigned")), + text(String(issue.assignedTo?.name ?? "Unassigned")), ]); if (issue.project) { kvRows.push([ "Project", - `${escapeMarkdownInline(issue.project.name ?? "(unknown)")} (${safeCodeSpan(issue.project.slug ?? "")})`, + `${text(issue.project.name ?? "(unknown)")} (${inlineCode(issue.project.slug ?? "")})`, ]); } const firstReleaseVersion = issue.firstRelease?.shortVersion; const lastReleaseVersion = issue.lastRelease?.shortVersion; if (firstReleaseVersion || lastReleaseVersion) { - const first = escapeMarkdownInline( - stringifyUnknown(firstReleaseVersion ?? ""), - ); - const last = escapeMarkdownInline( - stringifyUnknown(lastReleaseVersion ?? ""), - ); + const first = text(stringifyUnknown(firstReleaseVersion ?? "")); + const last = text(stringifyUnknown(lastReleaseVersion ?? "")); if (firstReleaseVersion && lastReleaseVersion) { if (firstReleaseVersion === lastReleaseVersion) { kvRows.push(["Release", first]); @@ -878,20 +872,20 @@ export function formatIssueDetails(issue: SentryIssue): string { if (issue.firstSeen) { let firstSeenLine = new Date(issue.firstSeen).toLocaleString(); if (firstReleaseVersion) { - firstSeenLine += ` (in ${escapeMarkdownCell(stringifyUnknown(firstReleaseVersion))})`; + firstSeenLine += ` (in ${tableCell(stringifyUnknown(firstReleaseVersion))})`; } kvRows.push(["First seen", firstSeenLine]); } if (issue.lastSeen) { let lastSeenLine = new Date(issue.lastSeen).toLocaleString(); if (lastReleaseVersion && lastReleaseVersion !== firstReleaseVersion) { - lastSeenLine += ` (in ${escapeMarkdownCell(stringifyUnknown(lastReleaseVersion))})`; + lastSeenLine += ` (in ${tableCell(stringifyUnknown(lastReleaseVersion))})`; } kvRows.push(["Last seen", lastSeenLine]); } if (issue.culprit) { - kvRows.push(["Culprit", safeCodeSpan(issue.culprit)]); + kvRows.push(["Culprit", inlineCode(issue.culprit)]); } kvRows.push(["Link", issue.permalink ?? ""]); @@ -931,7 +925,7 @@ function formatStackFrameMarkdown(frame: StackFrame): string { const col = frame.colNo ?? "?"; const inAppTag = frame.inApp ? " `[in-app]`" : ""; - lines.push(`${safeCodeSpan(`at ${fn} (${file}:${line}:${col})`)}${inAppTag}`); + lines.push(`${inlineCode(`at ${fn} (${file}:${line}:${col})`)}${inAppTag}`); if (frame.context && frame.context.length > 0) { lines.push(""); @@ -956,7 +950,7 @@ function formatExceptionValueMarkdown(exception: ExceptionValue): string { const type = exception.type || "Error"; const value = exception.value || ""; - lines.push(`**${safeCodeSpan(`${type}: ${value}`)}**`); + lines.push(`**${inlineCode(`${type}: ${value}`)}**`); if (exception.mechanism) { const handled = exception.mechanism.handled ? "handled" : "unhandled"; @@ -1034,8 +1028,8 @@ function buildBreadcrumbsMarkdown(breadcrumbsEntry: BreadcrumbsEntry): string { } // Escape special markdown characters that would break the table cell - const safeMessage = escapeMarkdownCell(message); - const safeCategory = escapeMarkdownCell(breadcrumb.category ?? "default"); + const safeMessage = tableCell(message); + const safeCategory = tableCell(breadcrumb.category ?? "default"); lines.push(mdRow([timestamp, level, safeCategory, safeMessage]).trimEnd()); } @@ -1379,8 +1373,8 @@ export function formatEventDetails( // The heading is copy-pasted into `event view`. A short prefix is not // searchable, so include the full ID unless the caller already did. const heading = header.includes(event.eventID) - ? `## ${escapeMarkdownInline(header)}` - : `## ${escapeMarkdownInline(header)} (\`${event.eventID}\`)`; + ? `## ${text(header)}` + : `## ${text(header)} (\`${event.eventID}\`)`; sections.push(heading); sections.push(""); @@ -1394,12 +1388,12 @@ export function formatEventDetails( ]); } if (event.location) { - infoKvRows.push(["Location", safeCodeSpan(event.location)]); + infoKvRows.push(["Location", inlineCode(event.location)]); } const traceCtx = event.contexts?.trace; if (traceCtx?.trace_id) { - infoKvRows.push(["Trace", safeCodeSpan(traceCtx.trace_id)]); + infoKvRows.push(["Trace", inlineCode(traceCtx.trace_id)]); } if (event.sdk?.name || event.sdk?.version) { @@ -1475,10 +1469,7 @@ export function formatEventDetails( sections.push(mdTableHeader(["Key", "Value"]).trimEnd()); for (const tag of event.tags) { sections.push( - mdRow([ - `\`${tag.key}\``, - escapeMarkdownCell(String(tag.value)), - ]).trimEnd(), + mdRow([`\`${tag.key}\``, tableCell(String(tag.value))]).trimEnd(), ); } } @@ -1498,14 +1489,12 @@ export function formatEventDetails( export function formatOrgDetails(org: SentryOrganization): string { const lines: string[] = []; - lines.push( - `## ${escapeMarkdownInline(org.slug)}: ${escapeMarkdownInline(org.name || "(unnamed)")}`, - ); + lines.push(`## ${text(org.slug)}: ${text(org.name || "(unnamed)")}`); lines.push(""); const kvRows: [string, string][] = []; kvRows.push(["Slug", `\`${org.slug || "(none)"}\``]); - kvRows.push(["Name", escapeMarkdownInline(org.name || "(unnamed)")]); + kvRows.push(["Name", text(org.name || "(unnamed)")]); kvRows.push(["ID", String(org.id)]); if (org.dateCreated) { kvRows.push(["Created", new Date(org.dateCreated).toLocaleString()]); @@ -1543,14 +1532,12 @@ export function formatProjectDetails( ): string { const lines: string[] = []; - lines.push( - `## ${escapeMarkdownInline(project.slug)}: ${escapeMarkdownInline(project.name || "(unnamed)")}`, - ); + lines.push(`## ${text(project.slug)}: ${text(project.name || "(unnamed)")}`); lines.push(""); const kvRows: [string, string][] = []; kvRows.push(["Slug", `\`${project.slug || "(none)"}\``]); - kvRows.push(["Name", escapeMarkdownInline(project.name || "(unnamed)")]); + kvRows.push(["Name", text(project.name || "(unnamed)")]); kvRows.push(["ID", String(project.id)]); kvRows.push(["Platform", project.platform || "Not set"]); kvRows.push(["DSN", `\`${dsn || "No DSN available"}\``]); @@ -1565,7 +1552,7 @@ export function formatProjectDetails( ); kvRows.push([ "Organization", - `${escapeMarkdownInline(orgName)} (${safeCodeSpan(project.organization.slug)})`, + `${text(orgName)} (${inlineCode(project.organization.slug)})`, ]); } if (project.firstEvent) { @@ -1776,21 +1763,19 @@ export function formatLogoutResult(data: LogoutResult): string { removedLabel = data.all ? ` of all ${removed.length} accounts` : ` of ${removed.length} accounts (${removed - .map((a) => escapeMarkdownInline(formatAccountLabel(a))) + .map((a) => text(formatAccountLabel(a))) .join(", ")})`; } else if (only) { - removedLabel = ` of ${escapeMarkdownInline(formatAccountLabel(only))}`; + removedLabel = ` of ${text(formatAccountLabel(only))}`; } lines.push( `${colorTag("green", "✓")} Logged out${removedLabel} successfully.`, ); if (data.configPath) { - lines.push(`Credentials removed from: ${safeCodeSpan(data.configPath)}`); + lines.push(`Credentials removed from: ${inlineCode(data.configPath)}`); } if (data.activeAccount) { - lines.push( - `Now using: ${escapeMarkdownInline(formatAccountLabel(data.activeAccount))}`, - ); + lines.push(`Now using: ${text(formatAccountLabel(data.activeAccount))}`); } return renderMarkdown(lines.join("\n\n")); } @@ -1806,7 +1791,7 @@ type AuthStatusData = import("../../commands/auth/status.js").AuthStatusData; function formatAuthHeader(source: string): string { if (source.startsWith("env:")) { const varName = source.slice("env:".length); - return `## ${colorTag("green", "✓")} Authenticated via ${escapeMarkdownInline(varName)} environment variable`; + return `## ${colorTag("green", "✓")} Authenticated via ${text(varName)} environment variable`; } return `## ${colorTag("green", "✓")} Authenticated`; } @@ -1830,18 +1815,18 @@ function buildAuthDetailRows(data: AuthStatusData): [string, string][] { const isEnv = data.source.startsWith("env:"); if (data.configPath) { - rows.push(["Config", safeCodeSpan(data.configPath)]); + rows.push(["Config", inlineCode(data.configPath)]); } if (data.user) { rows.push(["User", formatUserIdentity(data.user)]); } if (data.host) { - rows.push(["Server", escapeMarkdownInline(data.host)]); + rows.push(["Server", text(data.host)]); } if (data.accounts !== undefined && data.accounts > 1) { rows.push([ "Accounts", - `${data.accounts} stored (${safeCodeSpan("sentry auth list")})`, + `${data.accounts} stored (${inlineCode("sentry auth list")})`, ]); } if (data.token) { @@ -1850,7 +1835,7 @@ function buildAuthDetailRows(data: AuthStatusData): [string, string][] { const isOAuthAccessToken = data.token.refreshEnabled || data.token.expiresAt !== undefined; const tokenLabel = authTokenLabel(isEnv, isOAuthAccessToken); - rows.push([tokenLabel, safeCodeSpan(data.token.display)]); + rows.push([tokenLabel, inlineCode(data.token.display)]); if (data.token.expiresAt) { rows.push([ "Access token expires", @@ -1876,10 +1861,10 @@ function formatDefaultsSection( ): string { const rows: [string, string][] = []; if (defaults.organization) { - rows.push(["Organization", safeCodeSpan(defaults.organization)]); + rows.push(["Organization", inlineCode(defaults.organization)]); } if (defaults.project) { - rows.push(["Project", safeCodeSpan(defaults.project)]); + rows.push(["Project", inlineCode(defaults.project)]); } if (rows.length === 0) { return ""; @@ -1906,9 +1891,7 @@ function formatVerificationSection( if (orgs.length > 0) { lines.push(""); for (const org of orgs.slice(0, MAX_VERIFY_DISPLAY)) { - lines.push( - `- ${escapeMarkdownInline(org.name)} (${safeCodeSpan(org.slug)})`, - ); + lines.push(`- ${text(org.name)} (${inlineCode(org.slug)})`); } if (orgs.length > MAX_VERIFY_DISPLAY) { lines.push(`- *… and ${orgs.length - MAX_VERIFY_DISPLAY} more*`); @@ -1976,20 +1959,18 @@ function formatEnvTokenSection( : "set but not used (using OAuth credentials)"; } const rows: [string, string][] = [ - ["Env var", safeCodeSpan(envToken.envVar)], + ["Env var", inlineCode(envToken.envVar)], ["Status", status], ]; if (envToken.host) { - rows.push(["Server", escapeMarkdownInline(envToken.host)]); + rows.push(["Server", text(envToken.host)]); } const user = envToken.user; const who = user?.name ?? user?.username ?? user?.email; if (who) { rows.push([ "User", - escapeMarkdownInline( - user?.email && user.email !== who ? `${who} <${user.email}>` : who, - ), + text(user?.email && user.email !== who ? `${who} <${user.email}>` : who), ]); } return `\n${mdKvTable(rows, "Environment Token")}`; @@ -2033,8 +2014,8 @@ export type ProjectCreatedResult = { export function formatProjectCreated(result: ProjectCreatedResult): string { const lines: string[] = []; const dry = result.dryRun === true; - const nameEsc = escapeMarkdownInline(result.project.name); - const orgEsc = escapeMarkdownInline(result.orgSlug); + const nameEsc = text(result.project.name); + const orgEsc = text(result.orgSlug); // Heading if (dry) { @@ -2056,28 +2037,28 @@ export function formatProjectCreated(result: ProjectCreatedResult): string { if (result.teamSource === "auto-created") { lines.push( dry - ? `> **Note:** Would create team '${escapeMarkdownInline(result.teamSlug)}' (org has no teams).` - : `> **Note:** Created team '${escapeMarkdownInline(result.teamSlug)}' (org had no teams).`, + ? `> **Note:** Would create team '${text(result.teamSlug)}' (org has no teams).` + : `> **Note:** Created team '${text(result.teamSlug)}' (org had no teams).`, ); lines.push(""); } else if (result.teamSource === "auto-selected") { lines.push( dry - ? `> **Note:** Would use team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\`` - : `> **Note:** Using team '${escapeMarkdownInline(result.teamSlug)}'. See all teams: \`sentry team list\``, + ? `> **Note:** Would use team '${text(result.teamSlug)}'. See all teams: \`sentry team list\`` + : `> **Note:** Using team '${text(result.teamSlug)}'. See all teams: \`sentry team list\``, ); lines.push(""); } const kvRows: [string, string][] = [ ["Project", nameEsc], - ["Slug", safeCodeSpan(result.project.slug)], - ["Org", safeCodeSpan(result.orgSlug)], - ["Team", safeCodeSpan(result.teamSlug)], + ["Slug", inlineCode(result.project.slug)], + ["Org", inlineCode(result.orgSlug)], + ["Team", inlineCode(result.teamSlug)], ["Platform", result.project.platform || result.requestedPlatform], ]; if (result.dsn) { - kvRows.push(["DSN", safeCodeSpan(result.dsn)]); + kvRows.push(["DSN", inlineCode(result.dsn)]); } if (result.url) { kvRows.push(["URL", result.url]); @@ -2133,18 +2114,18 @@ export type ProjectDeleteResult = { * @returns Rendered terminal string */ export function formatProjectDeleted(result: ProjectDeleteResult): string { - const nameEsc = escapeMarkdownInline(result.projectName); + const nameEsc = text(result.projectName); const qualifiedSlug = `${result.orgSlug}/${result.projectSlug}`; if (result.dryRun) { return renderMarkdown( - `Would delete project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).\n\n` + + `Would delete project '${nameEsc}' (${inlineCode(qualifiedSlug)}).\n\n` + `URL: ${result.url}`, ); } return renderMarkdown( - `Deleted project '${nameEsc}' (${safeCodeSpan(qualifiedSlug)}).`, + `Deleted project '${nameEsc}' (${inlineCode(qualifiedSlug)}).`, ); } @@ -2184,9 +2165,9 @@ function formatFixCategory(issues: FixIssue[], heading: string): string { for (const issue of issues) { const marker = issueMarker(issue); - const desc = escapeMarkdownInline(issue.description); + const desc = text(issue.description); if (issue.repairMessage && issue.repaired !== undefined) { - lines.push(`- ${marker} ${escapeMarkdownInline(issue.repairMessage)}`); + lines.push(`- ${marker} ${text(issue.repairMessage)}`); } else { lines.push(`- ${marker} ${desc}`); } @@ -2209,7 +2190,7 @@ export function formatFixResult(data: FixResult): string { const lines: string[] = []; // Header - lines.push(`## Database: ${safeCodeSpan(data.dbPath)}`); + lines.push(`## Database: ${inlineCode(data.dbPath)}`); lines.push(""); lines.push(`Schema version: ${data.schemaVersion}`); @@ -2394,32 +2375,32 @@ export function formatUpgradeResult(data: UpgradeResult): string { const verb = ACTION_DESCRIPTIONS[data.action]; if (data.offline) { lines.push( - `${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)} (offline, from cache)`, + `${colorTag("green", "✓")} ${verb} to ${inlineCode(data.targetVersion)} (offline, from cache)`, ); } else if (data.currentVersion !== data.targetVersion) { lines.push( - `${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)} ${escapeMarkdownInline(`(from ${data.currentVersion})`)}`, + `${colorTag("green", "✓")} ${verb} to ${inlineCode(data.targetVersion)} ${text(`(from ${data.currentVersion})`)}`, ); } else { lines.push( - `${colorTag("green", "✓")} ${verb} to ${safeCodeSpan(data.targetVersion)}`, + `${colorTag("green", "✓")} ${verb} to ${inlineCode(data.targetVersion)}`, ); } break; } case "up-to-date": lines.push( - `${colorTag("green", "✓")} Already up to date (${safeCodeSpan(data.currentVersion)})`, + `${colorTag("green", "✓")} Already up to date (${inlineCode(data.currentVersion)})`, ); break; case "checked": { if (data.currentVersion === data.targetVersion) { lines.push( - `${colorTag("green", "✓")} You are already on the target version (${safeCodeSpan(data.currentVersion)})`, + `${colorTag("green", "✓")} You are already on the target version (${inlineCode(data.currentVersion)})`, ); } else { lines.push( - `Latest: ${safeCodeSpan(data.targetVersion)} (current: ${safeCodeSpan(data.currentVersion)})`, + `Latest: ${inlineCode(data.targetVersion)} (current: ${inlineCode(data.currentVersion)})`, ); } break; @@ -2436,12 +2417,12 @@ export function formatUpgradeResult(data: UpgradeResult): string { // Compact metadata line instead of a full table — method and channel // are lightweight diagnostics that don't warrant box-drawing borders. const meta = `Method: ${data.method} · Channel: ${data.channel}`; - lines.push(` ${colorTag("muted", escapeMarkdownInline(meta))}`); + lines.push(` ${colorTag("muted", text(meta))}`); // Append warnings with ⚠ markers if (data.warnings && data.warnings.length > 0) { for (const warning of data.warnings) { - lines.push(`${colorTag("yellow", "⚠")} ${escapeMarkdownInline(warning)}`); + lines.push(`${colorTag("yellow", "⚠")} ${text(warning)}`); } } @@ -2467,7 +2448,7 @@ export function formatDashboardCreated(result: { url: string; }): string { const lines: string[] = [ - `Created dashboard '${escapeMarkdownInline(result.title)}' (ID: ${result.id})`, + `Created dashboard '${text(result.title)}' (ID: ${result.id})`, "", `URL: ${result.url}`, ]; @@ -2484,7 +2465,7 @@ export function formatWidgetAdded(result: { }): string { const widgetCount = result.dashboard.widgets?.length ?? 0; const lines: string[] = [ - `Added widget '${escapeMarkdownInline(result.widget.title)}' to dashboard (now ${widgetCount} widgets)`, + `Added widget '${text(result.widget.title)}' to dashboard (now ${widgetCount} widgets)`, ]; const layoutLine = formatWidgetLayoutLine(result.widget); if (layoutLine) { @@ -2507,14 +2488,14 @@ export function formatWidgetDeleted(result: { const widgetCount = result.dashboard.widgets?.length ?? 0; if (result.dryRun) { const lines: string[] = [ - `Would remove widget '${escapeMarkdownInline(result.widgetTitle)}' from dashboard (currently ${widgetCount} widgets)`, + `Would remove widget '${text(result.widgetTitle)}' from dashboard (currently ${widgetCount} widgets)`, "", `URL: ${result.url}`, ]; return renderMarkdown(lines.join("\n")); } const lines: string[] = [ - `Removed widget '${escapeMarkdownInline(result.widgetTitle)}' from dashboard (now ${widgetCount} widgets)`, + `Removed widget '${text(result.widgetTitle)}' from dashboard (now ${widgetCount} widgets)`, "", `URL: ${result.url}`, ]; @@ -2542,7 +2523,7 @@ export function formatWidgetEdited(result: { url: string; }): string { const lines: string[] = [ - `Updated widget '${escapeMarkdownInline(result.widget.title)}' in dashboard ${result.dashboard.id}`, + `Updated widget '${text(result.widget.title)}' in dashboard ${result.dashboard.id}`, ]; const layoutLine = formatWidgetLayoutLine(result.widget); if (layoutLine) { @@ -2596,18 +2577,18 @@ function buildDefaultsShowRows(data: DefaultsResult): [string, string][] { const tipsLabel = d.tips ?? "on (default)"; return [ - ["Organization", d.organization ? safeCodeSpan(d.organization) : notSet], - ["Project", d.project ? safeCodeSpan(d.project) : notSet], + ["Organization", d.organization ? inlineCode(d.organization) : notSet], + ["Project", d.project ? inlineCode(d.project) : notSet], [ "Telemetry", - `${escapeMarkdownInline(String(telLabel))}${telemetryOverrideNote(data.telemetryEffective)}`, + `${text(String(telLabel))}${telemetryOverrideNote(data.telemetryEffective)}`, ], - ["Agent Skills", escapeMarkdownInline(String(agentSkillsLabel))], - ["Graphics", escapeMarkdownInline(String(graphicsLabel))], - ["Tips", escapeMarkdownInline(String(tipsLabel))], - ["URL", d.url ? safeCodeSpan(d.url) : notSet], - ["Headers", d.headers ? safeCodeSpan(d.headers) : notSet], - ["CA Certificate", d["ca-cert"] ? safeCodeSpan(d["ca-cert"]) : notSet], + ["Agent Skills", text(String(agentSkillsLabel))], + ["Graphics", text(String(graphicsLabel))], + ["Tips", text(String(tipsLabel))], + ["URL", d.url ? inlineCode(d.url) : notSet], + ["Headers", d.headers ? inlineCode(d.headers) : notSet], + ["CA Certificate", d["ca-cert"] ? inlineCode(d["ca-cert"]) : notSet], ]; } @@ -2623,7 +2604,7 @@ export function formatDefaultsResult(data: DefaultsResult): string { const label = DEFAULT_LABELS[data.changed?.key ?? ""] ?? data.changed?.key; return renderMarkdown( - `${colorTag("green", "✓")} Default ${escapeMarkdownInline(label ?? "setting")} set to ${safeCodeSpan(String(data.changed?.newValue))}`, + `${colorTag("green", "✓")} Default ${text(label ?? "setting")} set to ${inlineCode(String(data.changed?.newValue))}`, ); } @@ -2631,7 +2612,7 @@ export function formatDefaultsResult(data: DefaultsResult): string { const label = DEFAULT_LABELS[data.changed?.key ?? ""] ?? data.changed?.key; return renderMarkdown( - `${colorTag("green", "✓")} Default ${escapeMarkdownInline(label ?? "setting")} cleared`, + `${colorTag("green", "✓")} Default ${text(label ?? "setting")} cleared`, ); } @@ -2681,7 +2662,7 @@ function formatComponentLine(component: StatusComponent): string { const tag = COMPONENT_STATUS_TAGS[component.status] ?? "yellow"; const label = COMPONENT_STATUS_LABELS[component.status] ?? capitalize(component.status); - return `${colorTag(tag, "●")} ${escapeMarkdownInline(component.name)} — ${label}`; + return `${colorTag(tag, "●")} ${text(component.name)} — ${label}`; } /** @@ -2693,9 +2674,7 @@ export function formatSentryStatus(data: SentryStatus): string { const indicatorTag = STATUS_INDICATOR_TAGS[data.indicator] ?? "yellow"; const icon = data.indicator === "none" ? "✓" : "●"; - lines.push( - `## ${colorTag(indicatorTag, icon)} ${escapeMarkdownInline(data.description)}`, - ); + lines.push(`## ${colorTag(indicatorTag, icon)} ${text(data.description)}`); lines.push(""); if (data.incidents.length > 0) { @@ -2703,9 +2682,9 @@ export function formatSentryStatus(data: SentryStatus): string { lines.push(""); for (const incident of data.incidents) { lines.push( - `- **${escapeMarkdownInline(incident.name)}** (${escapeMarkdownInline(incident.impact)} impact, ${escapeMarkdownInline(incident.status)})`, + `- **${text(incident.name)}** (${text(incident.impact)} impact, ${text(incident.status)})`, ); - lines.push(` ${safeCodeSpan(incident.shortlink)}`); + lines.push(` ${inlineCode(incident.shortlink)}`); } lines.push(""); } @@ -2723,7 +2702,7 @@ export function formatSentryStatus(data: SentryStatus): string { lines.push(""); } - lines.push(`See ${safeCodeSpan(data.url)} for full details.`); + lines.push(`See ${inlineCode(data.url)} for full details.`); return renderMarkdown(lines.join("\n")); } diff --git a/packages/cli/src/lib/formatters/issue-links.ts b/packages/cli/src/lib/formatters/issue-links.ts index 212b57903..b303dfbb1 100644 --- a/packages/cli/src/lib/formatters/issue-links.ts +++ b/packages/cli/src/lib/formatters/issue-links.ts @@ -1,12 +1,13 @@ /** Human-readable results for linking and unlinking existing external issues. */ +import { inlineCode } from "@sentry/toolkit-core/markdown"; import type { ExternalIssueLinkResult } from "../issue-links.js"; -import { renderMarkdown, safeCodeSpan } from "./markdown.js"; +import { renderMarkdown } from "./markdown.js"; /** Render the association outcome without implying that either issue was resolved. */ export function formatIssueLinkResult(result: ExternalIssueLinkResult): string { - const external = safeCodeSpan(result.externalIssue.url); - const issue = safeCodeSpan(`${result.org}/${result.issueId}`); + const external = inlineCode(result.externalIssue.url); + const issue = inlineCode(`${result.org}/${result.issueId}`); if (result.dryRun) { const needsChange = result.action === "link" ? !result.linked : result.linked; diff --git a/packages/cli/src/lib/formatters/issue-tags.ts b/packages/cli/src/lib/formatters/issue-tags.ts index 7c1661718..c416da093 100644 --- a/packages/cli/src/lib/formatters/issue-tags.ts +++ b/packages/cli/src/lib/formatters/issue-tags.ts @@ -3,9 +3,10 @@ * as the whole distribution. JSON retains the original API response. */ +import { text } from "@sentry/toolkit-core/markdown"; import type { GetOrganizationIssueTagResponse } from "@sentry/api"; import { sanitize } from "./local.js"; -import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { renderMarkdown } from "./markdown.js"; import { fmtPct } from "./numbers.js"; import { type Column, formatTable } from "./table.js"; @@ -29,7 +30,7 @@ export function formatIssueTags(tag: GetOrganizationIssueTagResponse): string { const values = tag.topValues ?? []; const total = tag.totalValues; const heading = renderMarkdown( - `### ${escapeMarkdownInline(sanitize(tag.key))} — top values\n\n` + + `### ${text(sanitize(tag.key))} — top values\n\n` + `Total retained events: ${total ?? "unknown"}\n`, ); if (values.length === 0) { @@ -40,7 +41,7 @@ export function formatIssueTags(tag: GetOrganizationIssueTagResponse): string { { header: "VALUE", value: (row) => - escapeMarkdownInline( + text( sanitize( row.value === "" ? "(empty or missing)" diff --git a/packages/cli/src/lib/formatters/log.ts b/packages/cli/src/lib/formatters/log.ts index 53f2a69a9..f44435a27 100644 --- a/packages/cli/src/lib/formatters/log.ts +++ b/packages/cli/src/lib/formatters/log.ts @@ -4,6 +4,7 @@ * Provides formatting utilities for displaying Sentry logs in the CLI. */ +import { tableCell, text } from "@sentry/toolkit-core/markdown"; import type { DetailedSentryLog, SentryLog, @@ -13,8 +14,6 @@ import { stringifyUnknown } from "../errors.js"; import { buildTraceUrl } from "../sentry-web-urls.js"; import { colorTag, - escapeMarkdownCell, - escapeMarkdownInline, escapeMarkdownLines, mdKvTable, mdRow, @@ -150,7 +149,7 @@ export function buildLogRowCells( const level = padSeverity ? formatSeverity(log.severity) : formatSeverityLabel(log.severity); - const message = escapeMarkdownCell(log.message ?? ""); + const message = tableCell(log.message ?? ""); const trace = includeTrace && log.trace ? ` \`[${log.trace.slice(0, 8)}]\`` : ""; const cells: string[] = [id, timestamp, level, `${message}${trace}`]; @@ -158,7 +157,7 @@ export function buildLogRowCells( for (const field of extraFields) { const val = log[field]; cells.push( - escapeMarkdownCell( + tableCell( val !== null && val !== undefined ? stringifyUnknown(val) : "", ), ); @@ -465,8 +464,8 @@ export function formatLogDetails( lines.push( mdKvTable( customAttrs.map((a) => [ - escapeMarkdownInline(a.name), - escapeMarkdownInline( + text(a.name), + text( a.type === "array" ? JSON.stringify(a.value) : String(a.value), ), ]), @@ -478,10 +477,7 @@ export function formatLogDetails( // Fallback: no trace-items detail available, show only explicitly requested fields const customRows: [string, string][] = extraFields .filter((f) => log[f] !== null && log[f] !== undefined) - .map((f) => [ - escapeMarkdownInline(f), - escapeMarkdownInline(String(log[f])), - ]); + .map((f) => [text(f), text(String(log[f]))]); if (customRows.length > 0) { lines.push(""); lines.push(mdKvTable(customRows, "Custom Attributes")); diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 60c61fe08..e68fda4e4 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -18,7 +18,7 @@ * See {@link isPlainOutput} in `plain-detect.ts` for the full priority chain. */ -import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; +import { text } from "@sentry/toolkit-core/markdown"; import chalk from "chalk"; import { marked, type Token, type Tokens } from "marked"; import stringWidth from "string-width"; @@ -32,19 +32,11 @@ export { isPlainOutput } from "./plain-detect.js"; // ──────────────────────────── Escape helpers ────────────────────────── // -// These are the shared builders from `@sentry/toolkit-core/markdown`, which -// MCP uses too; their property tests parse the output with the same `marked` -// this renderer uses. Escaped text never becomes a link: a URL in API data -// renders as plain text, and deliberate links use explicit link syntax. - -/** Escapes a value for a markdown table cell. See `tableCell`. */ -export const escapeMarkdownCell = tableCell; - -/** Escapes a value so it renders as the same literal text. See `text`. */ -export const escapeMarkdownInline = text; - -/** Formats a value as a code span that keeps every character. See `inlineCode`. */ -export const safeCodeSpan = inlineCode; +// Escape untrusted values with `text`, `tableCell`, and `inlineCode` from +// `@sentry/toolkit-core/markdown`, which MCP uses too; their property tests +// parse the output with the same `marked` this renderer uses. Escaped text +// never becomes a link: a URL in API data renders as plain text, and +// deliberate links use explicit link syntax. /** Indentation a line keeps in {@link escapeMarkdownLines}; four spaces would start a code block. */ const LINE_INDENT = /^ {0,3}/; @@ -52,8 +44,8 @@ const LINE_INDENT = /^ {0,3}/; /** * Escapes a multi-line value as literal text, one line at a time. * - * Unlike {@link escapeMarkdownInline}, which puts a value on one line, this - * keeps line breaks and up to three spaces of indentation. + * Unlike `text` from `@sentry/toolkit-core/markdown`, which puts a value on + * one line, this keeps line breaks and up to three spaces of indentation. */ export function escapeMarkdownLines(value: string): string { return value @@ -100,7 +92,7 @@ const KV_CELL_TOKEN = /\\[\s\S]|(?, ): string | undefined { const joined = values.filter(Boolean).join(" "); - return joined ? escapeMarkdownCell(joined) : undefined; + return joined ? tableCell(joined) : undefined; } function formatReplayLocation(replay: ReplayDetails): string | undefined { @@ -306,7 +301,7 @@ function formatReplayLocation(replay: ReplayDetails): string | undefined { const location = [geo.city, geo.region, geo.country_code] .filter(Boolean) .join(", "); - return location ? escapeMarkdownCell(location) : undefined; + return location ? tableCell(location) : undefined; } function buildReplayOverviewRows( @@ -340,12 +335,12 @@ function buildReplayOverviewRows( pushMarkdownRow( rows, "Environment", - replay.environment ? escapeMarkdownCell(replay.environment) : undefined, + replay.environment ? tableCell(replay.environment) : undefined, ); pushMarkdownRow( rows, "Platform", - replay.platform ? escapeMarkdownCell(replay.platform) : undefined, + replay.platform ? tableCell(replay.platform) : undefined, ); pushMarkdownRow( rows, @@ -357,7 +352,7 @@ function buildReplayOverviewRows( pushMarkdownRow( rows, "Replay Type", - replay.replay_type ? escapeMarkdownCell(replay.replay_type) : undefined, + replay.replay_type ? tableCell(replay.replay_type) : undefined, ); pushMarkdownRow(rows, "Archived", formatYesNo(replay.is_archived)); pushMarkdownRow(rows, "Viewed", formatYesNo(replay.has_viewed)); @@ -380,20 +375,16 @@ function buildReplayOverviewRows( function buildReplayUserRows(replay: ReplayDetails): MarkdownRow[] { const rows: MarkdownRow[] = []; const userLabel = getReplayUserLabel(replay); - pushMarkdownRow( - rows, - "User", - userLabel ? escapeMarkdownCell(userLabel) : undefined, - ); + pushMarkdownRow(rows, "User", userLabel ? tableCell(userLabel) : undefined); pushMarkdownRow( rows, "Email", - replay.user?.email ? escapeMarkdownCell(replay.user.email) : undefined, + replay.user?.email ? tableCell(replay.user.email) : undefined, ); pushMarkdownRow( rows, "IP", - replay.user?.ip ? escapeMarkdownCell(replay.user.ip) : undefined, + replay.user?.ip ? tableCell(replay.user.ip) : undefined, ); pushMarkdownRow(rows, "Location", formatReplayLocation(replay)); return rows; @@ -429,7 +420,7 @@ function buildReplayClientRows(replay: ReplayDetails): MarkdownRow[] { pushMarkdownRow( rows, "Dist", - replay.dist ? escapeMarkdownCell(replay.dist) : undefined, + replay.dist ? tableCell(replay.dist) : undefined, ); return rows; } @@ -473,7 +464,7 @@ function pushTagsSection(lines: string[], replay: ReplayDetails): void { a.localeCompare(b), )) { lines.push( - `- ${safeCodeSpan(key)}: ${values.map((value) => safeCodeSpan(value)).join(", ")}`, + `- ${inlineCode(key)}: ${values.map((value) => inlineCode(value)).join(", ")}`, ); } } @@ -508,9 +499,9 @@ function pushActivitySection( : ""; const details = event.details.length > 0 - ? ` · ${event.details.map((detail) => escapeMarkdownInline(detail)).join(" · ")}` + ? ` · ${event.details.map((detail) => text(detail)).join(" · ")}` : ""; - lines.push(`- ${prefix}${safeCodeSpan(event.label)}${details}`); + lines.push(`- ${prefix}${inlineCode(event.label)}${details}`); } } @@ -522,7 +513,7 @@ function formatRelatedIssueLine( return `- Event \`${issue.eventId}\``; } - return `- \`${issue.shortId}\`: ${escapeMarkdownInline(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; + return `- \`${issue.shortId}\`: ${text(issue.title)} (view: \`sentry issue view ${org}/${issue.shortId}\`)`; } function buildRelatedTraceStats(trace: ReplayRelatedTrace): string[] { diff --git a/packages/cli/src/lib/formatters/seer.ts b/packages/cli/src/lib/formatters/seer.ts index 10a60e2d3..5b44fbee4 100644 --- a/packages/cli/src/lib/formatters/seer.ts +++ b/packages/cli/src/lib/formatters/seer.ts @@ -5,6 +5,7 @@ * output is built as markdown and rendered via renderMarkdown(). */ +import { text } from "@sentry/toolkit-core/markdown"; import type { AutofixState, RootCause, @@ -13,7 +14,7 @@ import type { import { ApiError, SeerError } from "../errors.js"; import { cyan } from "./colors.js"; import { filterFields } from "./json.js"; -import { escapeMarkdownInline, renderMarkdown } from "./markdown.js"; +import { renderMarkdown } from "./markdown.js"; // Spinner Frames @@ -153,9 +154,7 @@ export type IssueExplainData = { function buildRootCauseMarkdown(cause: RootCause, index: number): string { const lines: string[] = []; - lines.push( - `### Cause #${index}: ${escapeMarkdownInline(cause.description ?? "")}`, - ); + lines.push(`### Cause #${index}: ${text(cause.description ?? "")}`); lines.push(""); if (cause.relevant_repos && cause.relevant_repos.length > 0) { @@ -170,7 +169,7 @@ function buildRootCauseMarkdown(cause: RootCause, index: number): string { lines.push("**Reproduction steps:**"); lines.push(""); for (const step of cause.root_cause_reproduction) { - lines.push(`**${escapeMarkdownInline(step.title)}**`); + lines.push(`**${text(step.title)}**`); lines.push(""); // code_snippet_and_analysis may itself contain markdown (code fences, // inline code, etc.) — pass it through as-is so marked renders it. @@ -224,7 +223,7 @@ export function formatIssueExplain(data: IssueExplainData): string { const sections = data.results.map( (result) => - `# ${escapeMarkdownInline(result.issue)}\n\n${buildRootCauseListMarkdown(result.rootCauses)}`, + `# ${text(result.issue)}\n\n${buildRootCauseListMarkdown(result.rootCauses)}`, ); return renderMarkdown(sections.join("\n\n---\n\n")); } @@ -361,9 +360,7 @@ export function formatSolution(solution: SolutionArtifact): string { lines.push("## Solution"); lines.push(""); - lines.push( - `**Summary:** ${escapeMarkdownInline(solution.data.one_line_summary ?? "")}`, - ); + lines.push(`**Summary:** ${text(solution.data.one_line_summary ?? "")}`); lines.push(""); if (solution.data.steps.length > 0) { @@ -372,7 +369,7 @@ export function formatSolution(solution: SolutionArtifact): string { for (let i = 0; i < solution.data.steps.length; i++) { const step = solution.data.steps[i]; if (step) { - lines.push(`${i + 1}. **${escapeMarkdownInline(step.title)}**`); + lines.push(`${i + 1}. **${text(step.title)}**`); lines.push(""); // step.description may contain markdown — pass it through as-is lines.push(` ${step.description.split("\n").join("\n ")}`); diff --git a/packages/cli/src/lib/formatters/table.ts b/packages/cli/src/lib/formatters/table.ts index d9a655188..50a795c6c 100644 --- a/packages/cli/src/lib/formatters/table.ts +++ b/packages/cli/src/lib/formatters/table.ts @@ -9,9 +9,9 @@ * treats them as zero-width for column sizing. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import type { Writer } from "../../types/index.js"; import { - escapeMarkdownCell, isPlainOutput, renderInlineMarkdown, stripColorTags, @@ -41,7 +41,7 @@ export type Column = { /** * Build a raw CommonMark table string from items and column definitions. * - * Column value functions should call {@link escapeMarkdownCell} on user data so pipe and + * Column value functions should call {@link tableCell} on user data so pipe and * backslash characters in API-supplied strings don't break the table. * * Used for plain/non-TTY output mode. diff --git a/packages/cli/src/lib/formatters/trace.ts b/packages/cli/src/lib/formatters/trace.ts index 82e16f728..ca9760cce 100644 --- a/packages/cli/src/lib/formatters/trace.ts +++ b/packages/cli/src/lib/formatters/trace.ts @@ -5,6 +5,7 @@ * Includes flat span utilities for `span list` and `span view` commands. */ +import { tableCell, text } from "@sentry/toolkit-core/markdown"; import type { SpanListItem, TraceSpan, @@ -16,8 +17,6 @@ import { stringifyUnknown } from "../errors.js"; import { filterFields } from "./json.js"; import { colorTag, - escapeMarkdownCell, - escapeMarkdownInline, mdKvTable, mdRow, mdTableHeader, @@ -78,7 +77,7 @@ export function buildTraceRowCells( ): [string, string, string, string] { return [ `\`${item.trace}\``, - escapeMarkdownCell(item.transaction || "unknown"), + tableCell(item.transaction || "unknown"), formatTraceDuration(item["span.duration"]), formatRelativeTime(item.timestamp), ]; @@ -365,10 +364,7 @@ export function formatTraceSummary(summary: TraceSummary): string { if (summary.rootTransaction) { const opPrefix = summary.rootOp ? `[\`${summary.rootOp}\`] ` : ""; - kvRows.push([ - "Root", - `${opPrefix}${escapeMarkdownCell(summary.rootTransaction)}`, - ]); + kvRows.push(["Root", `${opPrefix}${tableCell(summary.rootTransaction)}`]); } kvRows.push(["Duration", formatTraceDuration(summary.duration)]); kvRows.push(["Spans", String(summary.spanCount)]); @@ -524,7 +520,7 @@ export function spanListItemToFlatSpan( */ const PROJECT_COLUMN: Column = { header: "Project", - value: (s) => escapeMarkdownCell(s.project_slug || "—"), + value: (s) => tableCell(s.project_slug || "—"), minWidth: 8, }; @@ -551,14 +547,14 @@ const TRACE_ID_COLUMN: Column = { const SPAN_TABLE_COLUMNS: Column[] = [ { header: "Op", - value: (s) => escapeMarkdownCell(s.op || "—"), + value: (s) => tableCell(s.op || "—"), minWidth: 6, }, { header: "Description", value: (s) => { const desc = s.description || "(no description)"; - return escapeMarkdownCell(isDbSpanOp(s.op) ? colorizeSql(desc) : desc); + return tableCell(isDbSpanOp(s.op) ? colorizeSql(desc) : desc); }, truncate: true, }, @@ -586,7 +582,7 @@ function buildExtraColumns(extraColumns: string[]): Column[] { if (v === undefined || v === null) { return "—"; } - return escapeMarkdownCell(stringifyUnknown(v)); + return tableCell(stringifyUnknown(v)); }, })); } @@ -650,7 +646,7 @@ function buildSpanKvRows(span: TraceSpan, traceId: string): [string, string][] { const desc = span.description || span.transaction; if (desc) { - kvRows.push(["Description", escapeMarkdownCell(desc)]); + kvRows.push(["Description", tableCell(desc)]); } const durationMs = computeSpanDurationMs(span); @@ -690,7 +686,7 @@ function formatAncestorChain(ancestors: TraceSpan[]): string { const aDesc = a.description || a.transaction || "(no description)"; const colorizedDesc = isDbSpanOp(aOp) ? colorizeSql(aDesc) : aDesc; lines.push( - `${indent}${colorTag("muted", aOp)} — ${escapeMarkdownInline(colorizedDesc)} ${colorTag("muted", `(${a.span_id})`)}`, + `${indent}${colorTag("muted", aOp)} — ${text(colorizedDesc)} ${colorTag("muted", `(${a.span_id})`)}`, ); } return `${renderMarkdown(lines.join("\n"))}\n`; @@ -754,7 +750,7 @@ function appendAttributeRows( const shown = attrs.slice(0, MAX_DISPLAY_ATTRS); for (const attr of shown) { if (attr.value !== undefined && attr.value !== null && attr.value !== "") { - kvRows.push([attr.name, escapeMarkdownCell(String(attr.value))]); + kvRows.push([attr.name, tableCell(String(attr.value))]); } } if (attrs.length > MAX_DISPLAY_ATTRS) { diff --git a/packages/cli/test/lib/formatters/markdown.test.ts b/packages/cli/test/lib/formatters/markdown.test.ts index 0b6da89cf..e10c47ace 100644 --- a/packages/cli/test/lib/formatters/markdown.test.ts +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -6,12 +6,11 @@ * renderInlineMarkdown(). */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import { describe, expect, test } from "vitest"; import { colorTag, divider, - escapeMarkdownCell, - escapeMarkdownInline, escapeMarkdownLines, isPlainOutput, mdKvTable, @@ -19,7 +18,6 @@ import { mdTableHeader, renderInlineMarkdown, renderMarkdown, - safeCodeSpan, } from "../../../src/lib/formatters/markdown.js"; // --------------------------------------------------------------------------- @@ -372,10 +370,10 @@ describe("renderInlineMarkdown", () => { }); // --------------------------------------------------------------------------- -// escapeMarkdownCell +// tableCell // --------------------------------------------------------------------------- -describe("escapeMarkdownCell", () => { +describe("tableCell", () => { /** Renders `cell` as the first cell of a one-row table, in plain mode. */ function renderCell(cell: string): string { let out = ""; @@ -386,25 +384,23 @@ describe("escapeMarkdownCell", () => { } test("escapes pipes so the value stays in its cell", () => { - expect(escapeMarkdownCell("foo|bar")).toBe("foo\\|bar"); - const rendered = renderCell(escapeMarkdownCell("a|b\\|c")); + expect(tableCell("foo|bar")).toBe("foo\\|bar"); + const rendered = renderCell(tableCell("a|b\\|c")); expect(rendered).toContain("a|b\\|c"); expect(rendered).toContain("end"); }); test("escapes Markdown syntax as well as pipes", () => { - expect(escapeMarkdownCell("*bold* ")).toBe("\\*bold\\* \\"); - expect(renderCell(escapeMarkdownCell("*bold* "))).toContain( - "*bold* ", - ); + expect(tableCell("*bold* ")).toBe("\\*bold\\* \\"); + expect(renderCell(tableCell("*bold* "))).toContain("*bold* "); }); test("puts multi-line values on one line", () => { - expect(escapeMarkdownCell("a\nb\nc")).toBe("a b c"); + expect(tableCell("a\nb\nc")).toBe("a b c"); }); test("handles empty string", () => { - expect(escapeMarkdownCell("")).toBe(""); + expect(tableCell("")).toBe(""); }); }); @@ -499,7 +495,7 @@ describe("mdKvTable", () => { }); test("keeps a literal backslash before a pipe in a code span or raw value", () => { - expect(mdKvTable([["Culprit", safeCodeSpan("a\\|b")]])).toContain( + expect(mdKvTable([["Culprit", inlineCode("a\\|b")]])).toContain( "| **Culprit** | `a\\\u2502b` |", ); expect(mdKvTable([["Raw", "a\\\\|b `\\`|`"]])).toContain( @@ -508,16 +504,14 @@ describe("mdKvTable", () => { }); test("keeps a pipe in a label inside its cell", () => { - expect(mdKvTable([[escapeMarkdownInline("a|b"), "v"]])).toContain( - "| **a\u2502b** | v |", - ); + expect(mdKvTable([[text("a|b"), "v"]])).toContain("| **a\u2502b** | v |"); }); - test("shows a pipe from escapeMarkdownCell without its backslash", () => { - expect(mdKvTable([["Org", escapeMarkdownCell("a|b")]])).toContain( + test("shows a pipe from tableCell without its backslash", () => { + expect(mdKvTable([["Org", tableCell("a|b")]])).toContain( "| **Org** | a\u2502b |", ); - expect(mdKvTable([["Path", escapeMarkdownInline("a\\|b")]])).toContain( + expect(mdKvTable([["Path", text("a\\|b")]])).toContain( "| **Path** | a\\\\\u2502b |", ); }); @@ -569,10 +563,10 @@ describe("colorTag", () => { }); // --------------------------------------------------------------------------- -// escapeMarkdownInline +// text // --------------------------------------------------------------------------- -describe("escapeMarkdownInline", () => { +describe("text", () => { /** Renders inline Markdown in plain mode. */ function renderPlain(md: string): string { let out = ""; @@ -588,26 +582,24 @@ describe("escapeMarkdownInline", () => { "a\\b ~~strike~~ & # not a heading", "https://spotlightjs.com/_astro/ui-core.js", ])("renders %j as the same literal text", (value) => { - expect(renderPlain(escapeMarkdownInline(value))).toBe(value); + expect(renderPlain(text(value))).toBe(value); }); test("leaves underscores inside words unescaped", () => { - expect(escapeMarkdownInline("hello_world")).toBe("hello_world"); + expect(text("hello_world")).toBe("hello_world"); }); test("does not turn a URL into a link", () => { - expect(escapeMarkdownInline("see https://example.com")).toBe( - "see https\\://example.com", - ); + expect(text("see https://example.com")).toBe("see https\\://example.com"); }); test("returns unchanged string with no special chars", () => { - expect(escapeMarkdownInline("hello world")).toBe("hello world"); + expect(text("hello world")).toBe("hello world"); }); test("angle brackets survive round-trip through renderInlineMarkdown", () => { withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { - const escaped = escapeMarkdownInline(""); + const escaped = text(""); const result = stripAnsi(renderInlineMarkdown(escaped)); expect(result).toBe(""); }); @@ -631,23 +623,23 @@ describe("escapeMarkdownLines", () => { }); // --------------------------------------------------------------------------- -// safeCodeSpan +// inlineCode // --------------------------------------------------------------------------- -describe("safeCodeSpan", () => { +describe("inlineCode", () => { test("wraps value in backticks", () => { - expect(safeCodeSpan("hello")).toBe("`hello`"); + expect(inlineCode("hello")).toBe("`hello`"); }); test("keeps backticks and pipes in the value", () => { - expect(safeCodeSpan("a`b|c")).toBe("``a`b|c``"); + expect(inlineCode("a`b|c")).toBe("``a`b|c``"); withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { - expect(renderInlineMarkdown(safeCodeSpan("a`b|c"))).toBe("a`b|c"); + expect(renderInlineMarkdown(inlineCode("a`b|c"))).toBe("a`b|c"); }); }); test("replaces newlines with spaces", () => { - expect(safeCodeSpan("line1\nline2")).toBe("`line1 line2`"); + expect(inlineCode("line1\nline2")).toBe("`line1 line2`"); }); }); diff --git a/packages/cli/test/lib/formatters/table.test.ts b/packages/cli/test/lib/formatters/table.test.ts index f1afbaaad..fff9bb872 100644 --- a/packages/cli/test/lib/formatters/table.test.ts +++ b/packages/cli/test/lib/formatters/table.test.ts @@ -5,8 +5,8 @@ * tables. Tests verify content is present rather than exact text alignment. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { describe, expect, test, vi } from "vitest"; -import { escapeMarkdownCell } from "../../../src/lib/formatters/markdown.js"; import { type Column, writeTable } from "../../../src/lib/formatters/table.js"; type Row = { name: string; count: number; status: string }; @@ -261,7 +261,7 @@ describe("writeTable (plain mode)", () => { test("pipe characters in values are preserved in box-drawing table", () => { withPlain(() => { const cols: Column<{ v: string }>[] = [ - { header: "VAL", value: (r) => escapeMarkdownCell(r.v) }, + { header: "VAL", value: (r) => tableCell(r.v) }, ]; const write = vi.fn(() => true); writeTable({ write }, [{ v: "a|b" }], cols); diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index c899c001f..102fc0c69 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -171,9 +171,7 @@ with micromark, the CommonMark reference parser, and check that any input round-trips as the same literal value. `tableCell` is `text` plus escaped pipes, for a value in a GFM table cell. The -CLI's `escapeMarkdownInline`, `escapeMarkdownCell`, and `safeCodeSpan` are -`text`, `tableCell`, and `inlineCode`, so CLI and MCP output escape the same -way. Escaped text never becomes a literal autolink: a URL in upstream data +CLI and MCP both import these helpers, so their output escapes the same way. Escaped text never becomes a literal autolink: a URL in upstream data stays plain text. ## Resource identity