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 7f55c8cef..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,14 +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"; @@ -559,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; } @@ -806,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 @@ -839,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]); @@ -877,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 ?? ""]); @@ -902,7 +897,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> ")}`, ); } @@ -930,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(""); @@ -955,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"; @@ -1033,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()); } @@ -1378,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(""); @@ -1393,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) { @@ -1474,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(), ); } } @@ -1497,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()]); @@ -1542,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"}\``]); @@ -1564,7 +1552,7 @@ export function formatProjectDetails( ); kvRows.push([ "Organization", - `${escapeMarkdownInline(orgName)} (${safeCodeSpan(project.organization.slug)})`, + `${text(orgName)} (${inlineCode(project.organization.slug)})`, ]); } if (project.firstEvent) { @@ -1775,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")); } @@ -1805,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`; } @@ -1829,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) { @@ -1849,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", @@ -1875,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 ""; @@ -1905,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*`); @@ -1917,7 +1901,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)); } } @@ -1975,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")}`; @@ -2032,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) { @@ -2055,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]); @@ -2132,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)}).`, ); } @@ -2183,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}`); } @@ -2208,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}`); @@ -2246,7 +2228,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,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)}${escapeMarkdownInline(" (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; @@ -2435,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)}`); } } @@ -2466,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}`, ]; @@ -2483,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) { @@ -2506,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}`, ]; @@ -2541,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) { @@ -2595,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], ]; } @@ -2622,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))}`, ); } @@ -2630,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`, ); } @@ -2680,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}`; } /** @@ -2692,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) { @@ -2702,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(""); } @@ -2722,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 8dbfb50f8..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,7 @@ import { stringifyUnknown } from "../errors.js"; import { buildTraceUrl } from "../sentry-web-urls.js"; import { colorTag, - escapeMarkdownCell, - escapeMarkdownInline, + escapeMarkdownLines, mdKvTable, mdRow, mdTableHeader, @@ -149,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}`]; @@ -157,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) : "", ), ); @@ -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 @@ -464,8 +464,10 @@ export function formatLogDetails( lines.push( mdKvTable( customAttrs.map((a) => [ - a.name, - a.type === "array" ? JSON.stringify(a.value) : String(a.value), + text(a.name), + text( + a.type === "array" ? JSON.stringify(a.value) : String(a.value), + ), ]), "Custom Attributes", ), @@ -475,7 +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) => [f, 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 8fdd8240e..e68fda4e4 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 { 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,27 @@ import { type Alignment, renderTextTable } from "./text-table.js"; export { isPlainOutput } from "./plain-detect.js"; // ──────────────────────────── Escape helpers ────────────────────────── +// +// 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. -/** - * 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, "\\>"); -} +/** 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 `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 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 ───────────────────── @@ -103,6 +81,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. * @@ -120,14 +120,7 @@ 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")} |`, - ); + lines.push(`| **${kvTableCell(label)}** | ${kvTableCell(value)} |`); } return lines.join("\n"); } diff --git a/packages/cli/src/lib/formatters/meta-table.ts b/packages/cli/src/lib/formatters/meta-table.ts index 2507edc2b..845a44bf3 100644 --- a/packages/cli/src/lib/formatters/meta-table.ts +++ b/packages/cli/src/lib/formatters/meta-table.ts @@ -8,8 +8,8 @@ * for numeric fields. */ +import { tableCell } from "@sentry/toolkit-core/markdown"; import { stringifyUnknown } from "../errors.js"; -import { escapeMarkdownCell } from "./markdown.js"; import { appendUnitSuffix, formatNumber } from "./numbers.js"; import type { Column } from "./table.js"; @@ -48,7 +48,7 @@ export function formatCellValue( } return formatNumber(value); } - return escapeMarkdownCell(stringifyUnknown(value)); + return tableCell(stringifyUnknown(value)); } /** diff --git a/packages/cli/src/lib/formatters/replay.ts b/packages/cli/src/lib/formatters/replay.ts index b0eb1b7d5..5d5992603 100644 --- a/packages/cli/src/lib/formatters/replay.ts +++ b/packages/cli/src/lib/formatters/replay.ts @@ -4,6 +4,7 @@ * Human-readable formatting for Session Replay data in the CLI. */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import type { ListProjectReplayRecordingSegmentsResponse } from "@sentry/api"; import type { @@ -14,12 +15,7 @@ import type { } from "../../types/index.js"; import { getReplayUserLabel } from "../replay-search.js"; import { buildReplayUrl } from "../sentry-web-urls.js"; -import { - escapeMarkdownCell, - escapeMarkdownInline, - mdKvTable, - renderMarkdown, -} from "./markdown.js"; +import { mdKvTable, renderMarkdown } from "./markdown.js"; import { formatDurationCompactMs, formatDurationVerbose, @@ -293,7 +289,7 @@ function formatJoinedMarkdown( values: Array, ): string | undefined { const joined = values.filter(Boolean).join(" "); - return joined ? escapeMarkdownCell(joined) : undefined; + return joined ? tableCell(joined) : undefined; } function formatReplayLocation(replay: ReplayDetails): string | undefined { @@ -305,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( @@ -339,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, @@ -356,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)); @@ -379,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; @@ -428,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; } @@ -472,7 +464,7 @@ function pushTagsSection(lines: string[], replay: ReplayDetails): void { a.localeCompare(b), )) { lines.push( - `- \`${escapeMarkdownInline(key)}\`: ${values.map((value) => `\`${escapeMarkdownInline(value)}\``).join(", ")}`, + `- ${inlineCode(key)}: ${values.map((value) => inlineCode(value)).join(", ")}`, ); } } @@ -507,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}\`${escapeMarkdownInline(event.label)}\`${details}`); + lines.push(`- ${prefix}${inlineCode(event.label)}${details}`); } } @@ -521,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/log.test.ts b/packages/cli/test/lib/formatters/log.test.ts index ccdfa40ef..9c025808b 100644 --- a/packages/cli/test/lib/formatters/log.test.ts +++ b/packages/cli/test/lib/formatters/log.test.ts @@ -563,6 +563,18 @@ describe("formatLogDetails", () => { 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"] }, @@ -622,7 +634,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..e10c47ace 100644 --- a/packages/cli/test/lib/formatters/markdown.test.ts +++ b/packages/cli/test/lib/formatters/markdown.test.ts @@ -6,19 +6,18 @@ * renderInlineMarkdown(). */ +import { inlineCode, tableCell, text } from "@sentry/toolkit-core/markdown"; import { describe, expect, test } from "vitest"; import { colorTag, divider, - escapeMarkdownCell, - escapeMarkdownInline, + escapeMarkdownLines, isPlainOutput, mdKvTable, mdRow, mdTableHeader, renderInlineMarkdown, renderMarkdown, - safeCodeSpan, } from "../../../src/lib/formatters/markdown.js"; // --------------------------------------------------------------------------- @@ -371,38 +370,37 @@ describe("renderInlineMarkdown", () => { }); // --------------------------------------------------------------------------- -// escapeMarkdownCell +// tableCell // --------------------------------------------------------------------------- -describe("escapeMarkdownCell", () => { - test("escapes pipe characters", () => { - expect(escapeMarkdownCell("foo|bar")).toBe("foo\\|bar"); - }); - - test("escapes backslashes before pipes", () => { - expect(escapeMarkdownCell("a\\|b")).toBe("a\\\\\\|b"); - }); - - test("returns unchanged string when no special chars", () => { - expect(escapeMarkdownCell("hello world")).toBe("hello world"); - }); +describe("tableCell", () => { + /** 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("handles empty string", () => { - expect(escapeMarkdownCell("")).toBe(""); + test("escapes pipes so the value stays in its cell", () => { + 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("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("escapes Markdown syntax as well as pipes", () => { + expect(tableCell("*bold* ")).toBe("\\*bold\\* \\"); + expect(renderCell(tableCell("*bold* "))).toContain("*bold* "); }); - test("handles multiple pipes", () => { - const result = escapeMarkdownCell("a|b|c"); - expect(result).toBe("a\\|b\\|c"); + test("puts multi-line values on one line", () => { + expect(tableCell("a\nb\nc")).toBe("a b c"); }); - test("escapes angle brackets with backslash", () => { - expect(escapeMarkdownCell("")).toBe("\\"); + test("handles empty string", () => { + expect(tableCell("")).toBe(""); }); }); @@ -495,6 +493,28 @@ describe("mdKvTable", () => { const result = mdKvTable([["Only", "Row"]]); expect(result).toContain("| **Only** | Row |"); }); + + test("keeps a literal backslash before a pipe in a code span or raw value", () => { + expect(mdKvTable([["Culprit", inlineCode("a\\|b")]])).toContain( + "| **Culprit** | `a\\\u2502b` |", + ); + expect(mdKvTable([["Raw", "a\\\\|b `\\`|`"]])).toContain( + "| **Raw** | a\\\\\u2502b `\\`\u2502` |", + ); + }); + + test("keeps a pipe in a label inside its cell", () => { + expect(mdKvTable([[text("a|b"), "v"]])).toContain("| **a\u2502b** | v |"); + }); + + test("shows a pipe from tableCell without its backslash", () => { + expect(mdKvTable([["Org", tableCell("a|b")]])).toContain( + "| **Org** | a\u2502b |", + ); + expect(mdKvTable([["Path", text("a\\|b")]])).toContain( + "| **Path** | a\\\\\u2502b |", + ); + }); }); // --------------------------------------------------------------------------- @@ -543,84 +563,83 @@ describe("colorTag", () => { }); // --------------------------------------------------------------------------- -// escapeMarkdownInline +// text // --------------------------------------------------------------------------- -describe("escapeMarkdownInline", () => { - test("escapes underscores", () => { - expect(escapeMarkdownInline("hello_world")).toBe("hello\\_world"); - }); - - test("escapes asterisks", () => { - expect(escapeMarkdownInline("*bold*")).toBe("\\*bold\\*"); - }); +describe("text", () => { + /** 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(text(value))).toBe(value); }); - test("escapes square brackets", () => { - expect(escapeMarkdownInline("[link]")).toBe("\\[link\\]"); + test("leaves underscores inside words unescaped", () => { + expect(text("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(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"); - }); - - test("escapes angle brackets with backslash", () => { - expect(escapeMarkdownInline("")).toBe("\\"); + 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(""); }); }); +}); - 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", +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("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"); }); }); }); // --------------------------------------------------------------------------- -// safeCodeSpan +// inlineCode // --------------------------------------------------------------------------- -describe("safeCodeSpan", () => { +describe("inlineCode", () => { test("wraps value in backticks", () => { - expect(safeCodeSpan("hello")).toBe("`hello`"); + expect(inlineCode("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(inlineCode("a`b|c")).toBe("``a`b|c``"); + withEnv({ SENTRY_PLAIN_OUTPUT: "1" }, false, () => { + expect(renderInlineMarkdown(inlineCode("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(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 ceccc1f2e..102fc0c69 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -170,6 +170,10 @@ 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 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 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..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}[.)])/; @@ -75,14 +81,28 @@ 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 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. + */ +export function tableCell(value: string): string { + 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(INLINE_SYNTAX, (char) => `\\${char}`) + .replace(syntax, (char) => `\\${char}`) .replace( LINE_START_SYNTAX, - (syntax) => `${syntax.slice(0, -1)}\\${syntax.slice(-1)}`, + (match) => `${match.slice(0, -1)}\\${match.slice(-1)}`, ); }