Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
50 changes: 50 additions & 0 deletions packages/cli/src/lib/formatters/highlight.ts
Original file line number Diff line number Diff line change
@@ -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);
}
15 changes: 1 addition & 14 deletions packages/cli/src/lib/formatters/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/platform-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/test/lib/formatters/highlight.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
67 changes: 67 additions & 0 deletions packages/cli/test/script/syntax-highlight-bundle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}) {
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([]);
});
Loading