diff --git a/packages/cli/script/build.ts b/packages/cli/script/build.ts index e81731d7a..d7a71c0de 100644 --- a/packages/cli/script/build.ts +++ b/packages/cli/script/build.ts @@ -50,7 +50,7 @@ const NODE_VERSION = "lts"; /** Files whose lazy relative imports must be included in the native bundle. */ const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema)|formatters[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; /** Build-time constants injected into the binary */ diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index d6624cfca..79a2f163c 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -167,9 +167,10 @@ const sentrySourcemapPlugin: Plugin = { * a devDependency, not shipped). `node:sqlite` in the same file stays a * builtin and is left external by esbuild regardless. * `formatters/json` loads its generated parser only when --fields is parsed. + * `formatters/highlight` loads cli-highlight only on the first code block. */ const REQUIRE_ALIAS_FILTER = - /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/]json|list-command|telemetry)\.ts$/; + /(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/; const REQUIRE_ALIAS_RE = /\b_require\(/g; const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/; // Match both the prefixed and bare specifiers — bundled dependencies (e.g. diff --git a/packages/cli/src/lib/formatters/highlight.ts b/packages/cli/src/lib/formatters/highlight.ts new file mode 100644 index 000000000..722664edc --- /dev/null +++ b/packages/cli/src/lib/formatters/highlight.ts @@ -0,0 +1,50 @@ +/** + * Syntax highlighting for fenced code blocks in rendered markdown. + * + * This is the only module that loads `cli-highlight`, which brings all of + * highlight.js and parse5 (~1.2 MB of the npm bundle, and ~50 ms of startup + * when imported eagerly). Every command reaches this module through + * `buildCommand` and the formatters, so the cost is kept off that path in two + * ways: + * + * - `cli-highlight` is required on the first code block, not at import. The + * CLI bundle still contains it (script/bundle.ts and script/build.ts list + * this file in `REQUIRE_ALIAS_FILTER`), but only runs it when needed. + * - A bundle that doesn't need colored code blocks, such as the MCP server, + * sets `define: { __SENTRY_CLI_SYNTAX_HIGHLIGHT__: "false" }`. esbuild then + * drops the `require()` with its dead branch, so highlight.js and parse5 + * never enter the bundle. Code blocks render in the fallback color. + */ + +import { createRequire } from "node:module"; +import chalk from "chalk"; +import { COLORS } from "./colors.js"; + +/** Build-time switch; undefined (enabled) outside bundles that define it. */ +declare const __SENTRY_CLI_SYNTAX_HIGHLIGHT__: boolean | undefined; + +const _require = createRequire(import.meta.url); + +type CliHighlight = typeof import("cli-highlight"); + +let cliHighlight: CliHighlight["highlight"] | undefined; + +/** + * Syntax-highlight a code block. Falls back to uniform yellow if the + * language is unknown, highlighting fails, or highlighting is compiled out. + */ +export function highlightCode(code: string, language?: string): string { + // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. + try { + if ( + typeof __SENTRY_CLI_SYNTAX_HIGHLIGHT__ === "undefined" || + __SENTRY_CLI_SYNTAX_HIGHLIGHT__ + ) { + cliHighlight ??= (_require("cli-highlight") as CliHighlight).highlight; + return cliHighlight(code, { language, ignoreIllegals: true }); + } + } catch { + // Fall through to the uniform color below. + } + return chalk.hex(COLORS.yellow)(code); +} diff --git a/packages/cli/src/lib/formatters/markdown.ts b/packages/cli/src/lib/formatters/markdown.ts index 807fd321e..8fdd8240e 100644 --- a/packages/cli/src/lib/formatters/markdown.ts +++ b/packages/cli/src/lib/formatters/markdown.ts @@ -19,10 +19,10 @@ */ import chalk from "chalk"; -import { highlight as cliHighlight } from "cli-highlight"; import { marked, type Token, type Tokens } from "marked"; import stringWidth from "string-width"; import { COLORS, muted, terminalLink } from "./colors.js"; +import { highlightCode } from "./highlight.js"; import { isPlainOutput, stripAnsi } from "./plain-detect.js"; import { type Alignment, renderTextTable } from "./text-table.js"; @@ -232,19 +232,6 @@ function renderHtmlToken(raw: string): string { return ""; } -/** - * Syntax-highlight a code block. Falls back to uniform yellow if the - * language is unknown or highlighting fails. - */ -function highlightCode(code: string, language?: string): string { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - return cliHighlight(code, { language, ignoreIllegals: true }); - } catch { - return chalk.hex(COLORS.yellow)(code); - } -} - /** * Flatten a top-level token's inline content. Paragraphs and other block * tokens that wrap inline tokens are unwrapped; bare inline tokens pass diff --git a/packages/cli/src/lib/platform-grid.ts b/packages/cli/src/lib/platform-grid.ts index 177258b5b..b8cae8587 100644 --- a/packages/cli/src/lib/platform-grid.ts +++ b/packages/cli/src/lib/platform-grid.ts @@ -4,7 +4,7 @@ * Deliberately kept out of `platforms.ts`: that module is statically * imported by `complete.ts`'s shell-completion fast path (no Stricli boot, * no heavy deps), and pulling in the markdown/table formatters here would - * drag chalk/marked/cli-highlight/string-width/wrap-ansi into every + * drag chalk/marked/string-width/wrap-ansi into every * `__complete` invocation. Only `project create`'s error message and * `platform list` need this rendering — neither is on the completion path. */ diff --git a/packages/cli/test/lib/formatters/highlight.test.ts b/packages/cli/test/lib/formatters/highlight.test.ts new file mode 100644 index 000000000..e330bf800 --- /dev/null +++ b/packages/cli/test/lib/formatters/highlight.test.ts @@ -0,0 +1,52 @@ +/** + * Tests for the lazy, build-time-removable syntax highlighting boundary. + */ + +import { createRequire } from "node:module"; +import chalk from "chalk"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { COLORS } from "../../../src/lib/formatters/colors.js"; +import { highlightCode } from "../../../src/lib/formatters/highlight.js"; + +const require = createRequire(import.meta.url); +const cliHighlightPath = require.resolve("cli-highlight"); +const { highlight } = require( + cliHighlightPath, +) as typeof import("cli-highlight"); +/** cli-highlight colors through its own chalk instance. */ +const cliHighlightChalk = createRequire(cliHighlightPath)("chalk") as { + level: number; +}; + +const CODE = "def greet(name):\n return f'hello {name}'"; + +let savedLevels: [typeof chalk.level, number]; + +beforeEach(() => { + savedLevels = [chalk.level, cliHighlightChalk.level]; + chalk.level = 3; + cliHighlightChalk.level = 3; +}); + +afterEach(() => { + [chalk.level, cliHighlightChalk.level] = savedLevels; + vi.unstubAllGlobals(); +}); + +describe("highlightCode", () => { + test("matches cli-highlight output when highlighting is available", () => { + const expected = highlight(CODE, { + language: "python", + ignoreIllegals: true, + }); + expect(expected).not.toBe(CODE); + expect(highlightCode(CODE, "python")).toBe(expected); + }); + + test("renders the fallback color when highlighting is compiled out", () => { + vi.stubGlobal("__SENTRY_CLI_SYNTAX_HIGHLIGHT__", false); + const expected = chalk.hex(COLORS.yellow)(CODE); + expect(expected).not.toBe(CODE); + expect(highlightCode(CODE, "python")).toBe(expected); + }); +}); diff --git a/packages/cli/test/script/syntax-highlight-bundle.test.ts b/packages/cli/test/script/syntax-highlight-bundle.test.ts new file mode 100644 index 000000000..5a1a26081 --- /dev/null +++ b/packages/cli/test/script/syntax-highlight-bundle.test.ts @@ -0,0 +1,67 @@ +/** + * Keep cli-highlight (all of highlight.js and parse5) behind + * `formatters/highlight.ts`: lazy in the CLI bundle, and removable with + * `__SENTRY_CLI_SYNTAX_HIGHLIGHT__ = false` for bundles like the MCP server. + */ + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { build, type Metafile } from "esbuild"; +import { expect, test } from "vitest"; + +const HIGHLIGHT_MODULE_RE = /formatters[\\/]highlight\.ts$/; +const HIGHLIGHT_STACK_RE = + /node_modules[\\/].*(?:cli-highlight|highlight\.js|parse5)/; + +/** Bundle the markdown renderer the way script/bundle.ts does. */ +async function bundleMarkdown(define: Record = {}) { + const { metafile } = await build({ + entryPoints: [ + join(import.meta.dirname, "../../src/lib/formatters/markdown.ts"), + ], + bundle: true, + write: false, + platform: "node", + format: "cjs", + metafile: true, + logLevel: "silent", + define, + plugins: [ + { + // Mirrors the require-alias plugin in script/bundle.ts. + name: "require-alias", + setup(builder) { + builder.onLoad({ filter: HIGHLIGHT_MODULE_RE }, async (args) => ({ + contents: (await readFile(args.path, "utf-8")).replace( + /\b_require\(/g, + "require(", + ), + loader: "ts", + })); + }, + }, + ], + }); + return metafile; +} + +function importsOf(metafile: Metafile, specifier: string) { + return Object.values(metafile.inputs).flatMap((input) => + input.imports.filter((imp) => imp.original === specifier), + ); +} + +test("cli-highlight is only reached through a lazy require", async () => { + const metafile = await bundleMarkdown(); + const imports = importsOf(metafile, "cli-highlight"); + expect(imports.length).toBeGreaterThan(0); + expect(imports.every((imp) => imp.kind === "require-call")).toBe(true); +}); + +test("the build-time define drops highlight.js and parse5", async () => { + const metafile = await bundleMarkdown({ + __SENTRY_CLI_SYNTAX_HIGHLIGHT__: "false", + }); + const bundled = Object.keys(metafile.inputs); + expect(bundled.filter((path) => HIGHLIGHT_STACK_RE.test(path))).toEqual([]); +});