Skip to content

Add markdown ANSI pretty-printer for bun ./file.md - #28833

Merged
Jarred-Sumner merged 56 commits into
mainfrom
farm/d0772b7f/md-ansi-renderer
Apr 9, 2026
Merged

Jarred-Sumner merged 56 commits into
mainfrom
farm/d0772b7f/md-ansi-renderer

Conversation

@robobun

@robobun robobun commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator

When the entrypoint loader is .md, bun now reads the file, renders it
to ANSI, prints to stdout, and exits — no JavaScript VM spin-up.

Supported

  • Headings (h1-h6) with colored underlines
  • Bold, italic, strikethrough, underline, `inline code`
  • Ordered / unordered / task lists (with nesting)
  • Blockquotes (nested)
  • Horizontal rules
  • Fenced code blocks with syntax highlighting for JS/TS/JSX/TSX via
    `QuickAndDirtyJavaScriptSyntaxHighlighter`
  • Tables with per-column alignment + box-drawing borders. Column widths
    are computed globally so CJK, emoji, and combining characters align
    correctly
    (uses `bun.strings.visible.width.exclude_ansi_colors.utf8`)
  • OSC 8 hyperlinks (with "text (url)" fallback when stdout isn't a TTY)
  • Images as alt text with the src as an OSC 8 link
  • Wikilinks
  • Autolinks (url, www, email)
  • Word-wrapping respecting COLUMNS
  • Light / dark theme selection via `COLORFGBG`, `NO_COLOR`, `FORCE_COLOR`

Wiring

  • `.md` added to `default_loaders` so `bun ./file.md` resolves it.
  • `_bootAndHandleError` short-circuits when the loader resolves to
    `.md`, calls the renderer, flushes, and exits.
  • No VM boot, no JSC init — much faster than spinning up a runtime.

Tests

`test/cli/run/markdown-entrypoint.test.ts` — 20 snapshot tests covering
every feature above plus NO_COLOR mode and the `.markdown` extension.
Tables specifically include CJK, emoji, and combining-character rows to
verify multi-width grapheme alignment.

@github-actions github-actions Bot added the claude label Apr 4, 2026
@robobun

robobun commented Apr 4, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:46 PM PT - Apr 8th, 2026

❌ @Jarred-Sumner, your commit 9e013d6 has 1 failures in Build #44660 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28833

That installs a local version of the PR into your bun-28833 executable, so you can run:

bun-28833 --bun

@coderabbitai

coderabbitai Bot commented Apr 4, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a Markdown→ANSI renderer and integrates Markdown entrypoint handling into the CLI: .md/.markdown files can be resolved as runnable, are rendered to ANSI using terminal-derived theme/options (including OSC 8 hyperlinks and light-background detection), written to stdout, and the process exits without booting the JS runtime.

Changes

Cohort / File(s) Summary
CLI integration & defaults
src/cli/run_command.zig, src/options.zig
Added renderMarkdownFileAndExit(path); _bootAndHandleError now resolves null loaders via bun.options.defaultLoaders and routes .md resolutions to the new renderer; expanded fast-run eligibility for .md; added ".md"/".markdown" default loader mappings.
Markdown ANSI renderer & root exports
src/md/ansi_renderer.zig, src/md/root.zig, src/md/inlines.zig
New ANSI renderer (AnsiRenderer, Theme) with wrapping, headings, lists, blockquotes, code fences (with JS/TS highlight), tables (column sizing, alignment, Unicode box grid), links/OSC 8, images (alt capture), and detectLightBackground/renderToAnsi; root.zig re-exports APIs; inline wiki-link parsing adjusted to avoid double-emitting on failed match.
JS API & Type Definitions
src/bun.js/api/MarkdownObject.zig, packages/bun-types/bun.d.ts
Registered Bun.markdown.ansi(text, theme?) in the JS API (object capacity increased) and added AnsiTheme/ansi typings; theme options (colors, hyperlinks, light, columns) are parsed and validated; render errors mapped to OOM where applicable.
Tests
test/cli/run/markdown-entrypoint.test.ts
Added comprehensive CLI tests running Markdown files (.md/.markdown) covering headings, inline styles, lists, nested structures, rules, code blocks, links/images/wikilinks, tables (CJK/emoji/grapheme cases), NO_COLOR behavior, and exit-code assertions.
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding a markdown ANSI pretty-printer feature for running markdown files via bun ./file.md.
Description check ✅ Passed The description comprehensively covers what the PR does and how it was verified, with detailed feature list, implementation wiring, and extensive snapshot test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/run_command.zig`:
- Around line 1587-1590: The loader fallback currently defaults
missing-extension lookups to .tsx via
this_transpiler.options.loaders.get(path.name.ext) orelse .tsx which can
misclassify markdown entrypoints; change the lookup so that when the
per-transpiler map misses an extension you consult the global/default mapping
(options.defaultLoaders) or explicitly check path.name.ext for "md"/"markdown"
and map those to the .md loader before falling back to .tsx; update the branch
that uses loader and the call sites (_bootAndHandleError,
renderMarkdownFileAndExit) so resolved markdown paths invoke the markdown render
path instead of booting JSC.

In `@src/md/ansi_renderer.zig`:
- Around line 475-490: The nested-span close paths (codeSpanClose() and the
link-close branch) currently emit a full reset (reset()/\x1b[0m), which clears
outer styles; change them to avoid emitting a global reset and instead stop only
the nested attribute and then reapply any outer styles indicated by span_flags.
Concretely: update codeSpanClose() to remove or stop emitting reset() and
instead clear SPAN_CODE from self.span_flags (you already do this) and call a
helper (or inline logic) that writes the minimal off-sequence for code and then
re-emits active styles based on self.span_flags (bold/italic/underline colors)
using writeStyled; do the same in the link-close branch (remove the final
writeStyled(reset(), "") and instead reapply outer styles from span_flags after
writing the hyperlink termination or underline-off sequence). Use the existing
symbols: codeSpanClose(), reset(), SPAN_CODE, span_flags, link_depth,
writeStyled, writeRawNoColor, link_href, and theme.hyperlinks to locate and
implement this behavior.
- Around line 422-442: The code currently uses string literals as OOM fallbacks
(e.g., using catch "" for link_href, image_src, image_title after
resolveHref/allocator.dupe), but leaveSpan() always frees these fields causing
invalid frees; change the fallback to a distinct "no-allocation" sentinel (e.g.,
set fields to null or an optional slice like ?[]u8) instead of a literal, and
update leaveSpan() to check for that sentinel before calling free; alternatively
allocate an actual empty owned buffer on failure (using allocator.dupe for an
empty slice) so free is always safe—apply the change where resolveHref(...)
catch "" and allocator.dupe(...) catch "" are used and adjust leaveSpan(),
link_href, image_src, and image_title handling accordingly.

In `@test/cli/run/markdown-entrypoint.test.ts`:
- Around line 129-132: The test "renders combining characters without breaking
alignment" uses precomposed NFC strings and thus doesn't exercise the
combining-character path; update the fixture passed to runMd in
markdown-entrypoint.test.ts to use decomposed sequences (e.g., replace "café"
with "cafe\u0301" and "naïve" with "nai\u0308ve") so the table rendering code
that handles combining marks is actually exercised; ensure the test string array
(the one given to runMd) is changed accordingly and keep the test name and
snapshot assertion intact.
- Around line 4-17: The helper runMd currently asserts stderr is empty and
asserts exitCode immediately, which is flaky on ASAN and forces exit assertions
before snapshots; remove the expect(stderr).toBe("") and stop asserting exitCode
inside runMd, instead return the collected stdout/stderr/exitCode (or at minimum
both stdout and exitCode) so callers can perform snapshot/content checks first
and assert expect(exitCode).toBe(0) last; apply the same change to the other
test helper usages in this file where runMd-like helpers exist.
- Around line 68-76: The OSC 8 branch isn't being tested because runMd(...)
always runs with a non-TTY stdout; update the first test ("renders hyperlinks
with OSC 8 escape sequence") to invoke runMd in a TTY mode so the OSC 8 renderer
is exercised — e.g. call runMd("Visit [Bun](https://bun.com) today.\n", {
stdoutIsTTY: true }) or use whatever runMd option enables a TTY (refer to the
runMd helper) so that one test runs with a TTY and the other remains non-TTY.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7eb6acd7-1e09-412b-9ece-9eba1f3f5433

📥 Commits

Reviewing files that changed from the base of the PR and between 9053830 and 9dcfdfc.

⛔ Files ignored due to path filters (1)
  • test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • src/cli/run_command.zig
  • src/md/ansi_renderer.zig
  • src/md/root.zig
  • src/options.zig
  • test/cli/run/markdown-entrypoint.test.ts

Comment thread src/cli/run_command.zig Outdated
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread test/cli/run/markdown-entrypoint.test.ts
Comment thread test/cli/run/markdown-entrypoint.test.ts Outdated
Comment thread test/cli/run/markdown-entrypoint.test.ts Outdated
Comment thread src/md/ansi_renderer.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
test/cli/run/markdown-entrypoint.test.ts (1)

10-15: ⚠️ Potential issue | 🟠 Major

This still doesn't exercise the OSC 8 renderer path.

runMd() always spawns Bun with stdout: "pipe", so Output.isStdoutTTY() is false and the markdown runner disables hyperlinks. This snapshot is currently another non-TTY fallback case, not coverage for OSC 8 output. Run this case under a PTY/TTY, or rename it to match the behavior being tested.

Also applies to: 80-82

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/cli/run/markdown-entrypoint.test.ts` around lines 10 - 15, The test
currently uses runMd() which spawns Bun with stdout: "pipe" (via Bun.spawn) so
Output.isStdoutTTY() returns false and OSC 8 hyperlink rendering is not
exercised; either modify the spawn in runMd() to run under a PTY/TTY (so stdout
is a terminal) when invoking Bun.spawn (or switch to the project helper that
allocates a pty) so the markdown runner enables hyperlinks, or if you intend to
test the non-TTY fallback keep stdout: "pipe" but rename the test to reflect
that it is the non-TTY case; target symbols: runMd(), Bun.spawn, and
Output.isStdoutTTY() (and the FORCE_COLOR env override) when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/run_command.zig`:
- Around line 1256-1260: The renderer currently caps terminal width to 120 via
the columns calculation in the brk block (using `@min`(c, 120)), causing wrapping
even on wider terminals; update the logic in the columns computation (the const
columns: u16 block that reads Output.terminal_size.col) to remove the hard cap
and instead use the actual terminal width when c != 0, falling back to 80 only
when c == 0, so tables/headings/paragraphs can use the full terminal width.

In `@src/md/ansi_renderer.zig`:
- Around line 476-489: The temporary ANSI resets are not restoring full inline
context: reapplyStyles() only reapplies span_flags while writeIndent() emits a
trailing "\x1b[0m" that wipes link/inline colors; implement a single helper
(e.g., reapplyInlineStylesAndLinks()) that re-applies both active span styles
(span_flags handling used by reapplyStyles()) and any active link styling state,
then call this helper instead of reapplyStyles() after closing
SPAN_CODE/SPAN_U/SPAN_DEL and use it from writeIndent() to restore styling after
the indent reset; update calls to writeStyled(...) for the code/u/del branches
(the blocks that clear SPAN_CODE/SPAN_U/SPAN_DEL and call writeStyled) to invoke
the new helper so inline colors and link styles are consistently restored (also
apply the same change to the other occurrence mentioned around lines 753-782).
- Around line 246-255: The horizontal rule and other UI chrome currently always
emit Unicode box-drawing characters and emoji (e.g., the dash "─" in the .hr
arm) even when theme.colors is false; update the rendering to check theme.colors
(or the equivalent enable_ansi_colors flag) before emitting Unicode and fallback
to ASCII/plain-text alternatives when false. Specifically, in ansi_renderer.zig
adjust the .hr arm (and the other similar blocks you noted) to use a conditional
dash variable (e.g., if theme.colors then "─" else "-") and avoid writing
emoji/box-drawing characters when theme.colors is false; keep using
ensureBlankLine(), writeIndent(), writeStyled/reset() and writeRaw(), but gate
the Unicode strings so plain-text/non-TTY output only emits ASCII.
- Around line 215-245: The .li block pushes a BlockContext without recording the
rendered marker width, so wrapped lines use parent list indent and misalign;
modify the .li handling in ansi_renderer.zig (inside the .li branch) to compute
the marker string width used for the first line (for task markers, numeric "N. "
via std.fmt.bufPrint, or the bullet "• "), store that width into entry.indent
(or a new field on BlockContext if needed), and then append the entry as before;
ensure findParentList(), entry.index, taskMark handling, and the code paths that
write glyph/number call the same measurement logic so continuation lines can
align under the first content column.

---

Duplicate comments:
In `@test/cli/run/markdown-entrypoint.test.ts`:
- Around line 10-15: The test currently uses runMd() which spawns Bun with
stdout: "pipe" (via Bun.spawn) so Output.isStdoutTTY() returns false and OSC 8
hyperlink rendering is not exercised; either modify the spawn in runMd() to run
under a PTY/TTY (so stdout is a terminal) when invoking Bun.spawn (or switch to
the project helper that allocates a pty) so the markdown runner enables
hyperlinks, or if you intend to test the non-TTY fallback keep stdout: "pipe"
but rename the test to reflect that it is the non-TTY case; target symbols:
runMd(), Bun.spawn, and Output.isStdoutTTY() (and the FORCE_COLOR env override)
when making the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 580781e4-243c-4703-bb7d-9a8eea4542ea

📥 Commits

Reviewing files that changed from the base of the PR and between 9dcfdfc and ed4b159.

⛔ Files ignored due to path filters (1)
  • test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • src/cli/run_command.zig
  • src/md/ansi_renderer.zig
  • test/cli/run/markdown-entrypoint.test.ts

Comment thread src/cli/run_command.zig
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snap
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread test/cli/run/markdown-entrypoint.test.ts
@robobun
robobun requested a review from alii as a code owner April 4, 2026 02:24

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
src/md/ansi_renderer.zig (1)

898-903: ⚠️ Potential issue | 🟠 Major

Inline closes inside buffered content drop the outer heading/header style.

These blocks rely on one outer bold/color wrapper, but the buffered inline content already contains closes like 22m and 39m. Inputs such as # Hello **bold** world or a header cell with a link/code span will lose the heading/header styling after the nested span closes unless the block-level style is also restored.

Also applies to: 1091-1110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/md/ansi_renderer.zig` around lines 898 - 903, The buffered inline content
may contain ANSI SGR resets (e.g. 0m, 22m, 39m) that close the outer heading
bold/color wrapper; update the write path that currently calls
self.out.write(content) (the block that checks self.theme.colors and writes
"\x1b[1m" and heading_color) to sanitize/transform content first: implement a
helper (e.g. restore_wrapped_styles_in_buffer) that scans the content for SGR
sequences that reset bold or color (0, 22, 39) and injects the outer style codes
(bold + heading_color) immediately after those reset sequences so the heading
style is restored; call that helper in place of writing content in the heading
rendering branch (and apply the same change to the other similar block
mentioned) so nested inline resets do not drop the outer heading style.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/cli/run_command.zig`:
- Around line 1280-1293: The markdown render path incorrectly collapses
OutOfMemory into a generic render failure; change the call to use
bun.handleOom(bun.md.renderToAnsi(...)) so that error.OutOfMemory is converted
to a crash and only non-OOM errors are caught and handled: call bun.handleOom
around bun.md.renderToAnsi with the same arguments, then use a catch block that
logs the generic "failed to render markdown" via
Output.prettyErrorln/Output.flush and Global.exit(1) for other errors,
referencing the bun.md.renderToAnsi and bun.handleOom symbols and keeping the
existing Output.prettyErrorln/Global.exit calls for non-OOM failures.

In `@src/md/ansi_renderer.zig`:
- Around line 907-919: The underline for headings (level 1/2) is written without
applying indentation, causing underlines to start at column 0; update the
heading rendering in ansi_renderer.zig (the block that checks "if (level == 1 or
level == 2)") to call the renderer's writeIndent() before writing the underline
characters (i.e., before writing color(.dim) / the underline chars and newline)
so the underline honors current indentation for blockquotes/lists; keep existing
width calculation and color handling, just prefix the underline write path with
writeIndent().

In `@src/md/inlines.zig`:
- Around line 203-212: Add a regression test that exercises the fallback branch
when an unmatched "[[" occurs so the surrounding text is not double-emitted:
construct input that contains a literal unmatched "[[" (e.g., "before [[ after")
with wiki_links enabled, run the same markdown/CLI rendering path that invokes
processWikiLink and emitText (the parser in inlines.zig), and assert or snapshot
the rendered output contains the "before " and " after" text exactly once and
that no duplicate text or missing pieces occur; place the test alongside the
existing CLI snapshot tests and mirror their pattern so it fails if the fallback
branch regresses.

---

Duplicate comments:
In `@src/md/ansi_renderer.zig`:
- Around line 898-903: The buffered inline content may contain ANSI SGR resets
(e.g. 0m, 22m, 39m) that close the outer heading bold/color wrapper; update the
write path that currently calls self.out.write(content) (the block that checks
self.theme.colors and writes "\x1b[1m" and heading_color) to sanitize/transform
content first: implement a helper (e.g. restore_wrapped_styles_in_buffer) that
scans the content for SGR sequences that reset bold or color (0, 22, 39) and
injects the outer style codes (bold + heading_color) immediately after those
reset sequences so the heading style is restored; call that helper in place of
writing content in the heading rendering branch (and apply the same change to
the other similar block mentioned) so nested inline resets do not drop the outer
heading style.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6e63bbee-3407-42f2-8be8-5f1a8717ee28

📥 Commits

Reviewing files that changed from the base of the PR and between ed4b159 and dd4dec7.

⛔ Files ignored due to path filters (1)
  • test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • src/cli/run_command.zig
  • src/md/ansi_renderer.zig
  • src/md/inlines.zig
  • test/cli/run/markdown-entrypoint.test.ts

Comment thread src/cli/run_command.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/inlines.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/md/ansi_renderer.zig (4)

781-794: ⚠️ Potential issue | 🟠 Major

Restore heading styling in reapplyStyles().

While heading text is buffered, nested link/code closes inject 39m / 49m into that buffer. Because this helper only reapplies inline/link state, # before [x](u) after renders after without the heading color. Since heading_level is still active during buffering, this helper can restore the heading style here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/md/ansi_renderer.zig` around lines 781 - 794, reapplyStyles currently
only restores inline and link styles and misses restoring heading color/style
when heading text is buffered; update reapplyStyles to check the renderer's
heading_level and, when > 0 and theme.colors is set, reapply the heading
color/style via the same emitInline(color(...)) / emitInline(style(...)) calls
so buffered text regains the heading styling; reference reapplyStyles,
heading_level, emitInline, color, and style to locate where to add the
conditional restore.

234-240: ⚠️ Potential issue | 🟠 Major

Use ASCII task markers when colors is false.

Line 236 still emits ☒ / ☐ in plain-text mode, so NO_COLOR/non-TTY output is no longer ASCII-only like the rest of this renderer. Please fall back to [x] / [ ] here as well.

Based on learnings, in the Bun codebase enable_ansi_colors flags are used to gate both ANSI color codes and Unicode box-drawing characters/emoji.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/md/ansi_renderer.zig` around lines 234 - 240, The code always emits
Unicode task markers (☒/☐) regardless of ANSI/colors setting; change the task
marker selection in the task_mark handling (the block using
types.isTaskChecked(task_mark), glyph, color(.green)/color(.dim), writeStyled,
reset(), marker_width) to choose ASCII markers "[x] " and "[ ] " when colors are
disabled (e.g., when self.colors or the renderer's ANSI flag is false), and only
use the Unicode glyphs and colored styles when colors are enabled; ensure
marker_width still uses visibleWidth(glyph) and the same write/writeStyled calls
are used so output remains correct in both modes.

567-572: ⚠️ Potential issue | 🟠 Major

Reapply outer styles after dimmed inline HTML.

Line 571 emits a full reset and never restores the surrounding span state, so **before <i>x</i> after** or [<b>x</b> y](...) drops formatting after the HTML node. Call reapplyStyles() after the reset, or avoid \x1b[0m here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/md/ansi_renderer.zig` around lines 567 - 572, The html branch in the
switch uses writeStyled(reset(), "") which emits a full ANSI reset and clears
any surrounding styling (causing surrounding spans to be lost for constructs
like **before <i>x</i> after**); update this branch in ansi_renderer.zig to
restore the outer styles after rendering inline HTML by either avoiding the
reset or calling reapplyStyles() immediately after writeStyled(reset(), "")
(i.e., use writeStyled(reset(), ""); reapplyStyles(); or replace the reset with
a call that re-applies the current styles), keeping writeStyled, writeContent,
reset(), and reapplyStyles() as the reference points to locate and change the
code.

923-934: ⚠️ Potential issue | 🟠 Major

Indent the underline for nested headings.

Line 923 writes the h1/h2 underline at column 0. In blockquotes and list items the heading text is indented, but the underline is not, so the heading splits visually across two columns.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/md/ansi_renderer.zig` around lines 923 - 934, The underline is written at
column 0, causing misalignment for indented headings; change the h1/h2 underline
logic in ansi_renderer.zig (the block using level, width, visibleWidth(content),
self.out.write, and char) to emit the same leading indentation as the heading
text before drawing the underline: compute the leading whitespace count of
content (number of space characters before the first visible rune), write that
many spaces to self.out (respecting color dimming), then draw the underline of
length width as now so the underline is indented to match nested
blockquotes/list items.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/md/ansi_renderer.zig`:
- Around line 262-269: The horizontal-rule branch (.hr) uses const width: u32 =
`@min`(self.theme.columns, 60) which treats theme.columns == 0 (wrapping disabled)
as zero width and makes rules disappear; change the width calculation in that
block to fall back to a nonzero clamp when self.theme.columns == 0 (use the same
fallback/clamp logic as the h1/h2 underline path) so width uses a sensible
default (e.g., 60) instead of zero before the loop that calls
self.writeRaw(dash).

---

Duplicate comments:
In `@src/md/ansi_renderer.zig`:
- Around line 781-794: reapplyStyles currently only restores inline and link
styles and misses restoring heading color/style when heading text is buffered;
update reapplyStyles to check the renderer's heading_level and, when > 0 and
theme.colors is set, reapply the heading color/style via the same
emitInline(color(...)) / emitInline(style(...)) calls so buffered text regains
the heading styling; reference reapplyStyles, heading_level, emitInline, color,
and style to locate where to add the conditional restore.
- Around line 234-240: The code always emits Unicode task markers (☒/☐)
regardless of ANSI/colors setting; change the task marker selection in the
task_mark handling (the block using types.isTaskChecked(task_mark), glyph,
color(.green)/color(.dim), writeStyled, reset(), marker_width) to choose ASCII
markers "[x] " and "[ ] " when colors are disabled (e.g., when self.colors or
the renderer's ANSI flag is false), and only use the Unicode glyphs and colored
styles when colors are enabled; ensure marker_width still uses
visibleWidth(glyph) and the same write/writeStyled calls are used so output
remains correct in both modes.
- Around line 567-572: The html branch in the switch uses writeStyled(reset(),
"") which emits a full ANSI reset and clears any surrounding styling (causing
surrounding spans to be lost for constructs like **before <i>x</i> after**);
update this branch in ansi_renderer.zig to restore the outer styles after
rendering inline HTML by either avoiding the reset or calling reapplyStyles()
immediately after writeStyled(reset(), "") (i.e., use writeStyled(reset(), "");
reapplyStyles(); or replace the reset with a call that re-applies the current
styles), keeping writeStyled, writeContent, reset(), and reapplyStyles() as the
reference points to locate and change the code.
- Around line 923-934: The underline is written at column 0, causing
misalignment for indented headings; change the h1/h2 underline logic in
ansi_renderer.zig (the block using level, width, visibleWidth(content),
self.out.write, and char) to emit the same leading indentation as the heading
text before drawing the underline: compute the leading whitespace count of
content (number of space characters before the first visible rune), write that
many spaces to self.out (respecting color dimming), then draw the underline of
length width as now so the underline is indented to match nested
blockquotes/list items.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2ac1fbae-4727-4d90-996b-64beeaeddedc

📥 Commits

Reviewing files that changed from the base of the PR and between a694fe1 and 875d039.

⛔ Files ignored due to path filters (1)
  • test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (1)
  • src/md/ansi_renderer.zig

Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/bun.js/api/MarkdownObject.zig Outdated
Comment thread packages/bun-types/bun.d.ts
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig
@robobun
robobun force-pushed the farm/d0772b7f/md-ansi-renderer branch from d70db4c to 4d85538 Compare April 4, 2026 04:44
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
@robobun
robobun force-pushed the farm/d0772b7f/md-ansi-renderer branch from 1880007 to 100ac5a Compare April 4, 2026 05:35
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
@robobun
robobun force-pushed the farm/d0772b7f/md-ansi-renderer branch from a8273a3 to 100ac5a Compare April 4, 2026 06:11
Comment thread src/cli/run_command.zig
Comment thread src/md/ansi_renderer.zig Outdated
@robobun

robobun commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator Author

Gate's container has a stale src/bun.js/api/cron.classes.ts file from a prior run (probably from my earlier merge attempt) that isn't tracked by this branch. The gate's codegen picks it up, generates ZigGeneratedClasses.zig references to a CronJob class whose Zig type (added on main) doesn't exist in this branch, and Zig compile fails.

This branch is diverged enough from main that porting over just the cron support would cascade into JSPromise.rejectWithAsyncStack and several other helpers that were added concurrently on main.

CI is passing green on the same sha (see debian-13-x64-asan-test-bun, 9m22s). Could this branch get rebased onto current main by a maintainer (or the gate's working dir cleaned)? The markdown changes themselves compile + pass locally + pass on CI.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix data: URL handling.
  • Only show alt text for images when the image is not found or not a valid link.
  • Add http:// and https:// image support when run as an entrypoint. To do this, when run directly as an entry point, let's have it quickly scan for images by doing strings.indexOf ![ and then if any matches, do one pass with the markdown parser just to collect all the image URls and then fetch all the images at once (following a similar sort of pattern as we download over http in upgrade_command.zig) and then add each of the images into a HashMap. Then, we do the original visiting pass and associate the images in the HashMap with the iamges to resolve locally for Kitty to process.

Comment thread src/md/ansi_renderer.zig
Comment thread src/md/ansi_renderer.zig Outdated
@robobun
robobun force-pushed the farm/d0772b7f/md-ansi-renderer branch from bca670c to 7b8a0fe Compare April 4, 2026 07:21
Comment thread src/md/ansi_renderer.zig Outdated
Comment thread src/md/ansi_renderer.zig
@robobun
robobun force-pushed the farm/d0772b7f/md-ansi-renderer branch from d683d5a to 50d2005 Compare April 4, 2026 07:47
robobun and others added 18 commits April 8, 2026 17:02
Previously prefetchRemoteImages() looped over URLs calling
AsyncHTTP.sendSync() one at a time, blocking the main thread
serially on every request. Switch to async: kick off every
download via AsyncHTTP.init + schedule into a single ThreadPool
batch, then wait on a Channel until all tasks report back. Disk
I/O runs as a second pass after every network request has
settled.
…ne breaks

Previously writeRowCells emitted a blanket \x1b[0m after each cell
segment to stop bleed into padding/borders. That lost SGR state on
continuation lines (bold/italic in a wrapped cell reverted to normal)
and didn't close OSC 8 (so the trailing space + border became part
of the link destination).

New CellAnsiState scans the embedded SGR + OSC 8 as cells are split,
snapshots the active state at each wrap boundary, and:
  * re-emits the opens at the start of each continuation line
  * emits \x1b[0m AND \x1b]8;;\x1b\\ at each segment end — only
    when there's actual state to close, so plain cells stay minimal
CellAnsiState.applySgr had no case for SGR 2 (dim/faint), so when a
table cell wrapped mid-dim, the continuation line lost the dim styling.
Affects text(.html) wrapping content in \x1b[2m and leaveSpan(.a)
rendering URL fallbacks in dim.

Add DIM flag bit, parse SGR 2 into it, emit \x1b[2m in emitOpens, and
clear BOTH BOLD and DIM on SGR 22 per ECMA-48 §8.3.117 (SGR 22 =
'normal intensity').

Also switch the OSC 8 parser in CellAnsiState.scan to the bun.strings
helpers (hasPrefixComptime, indexOfChar) matching the house style.
';' is 0x3B which is already below the 0x40-0x7E final-byte range, so
the short-circuit evaluation never reaches the extra clause. Leave a
comment explaining why the range check is sufficient.
Previous gate invocation failed release build with SSL cert error on
the WebKit prebuilt fetch; local release WebKit cache is now in place
so the next run should proceed.
writeRowCells used bun.strings.lastIndexOfChar(rest[0..cut], ' ') to
refine its wrap point to the last space inside the computed cut.
lastIndexOfChar is a raw byte scan, so when an OSC 8 URL contains a
literal space (valid CommonMark via angle-bracket syntax:
[text](<https://host/my file.png>)) and the URL space is the only
space in the cut window, the break point lands INSIDE the escape
sequence. The first segment then ends mid-opener with no ST, and the
terminal stays stuck in persistent hyperlink mode — subsequent cell
padding, borders, and rows all get rendered as clickable text.

Add lastWordBreakOutsideEscapes() — an escape-aware scanner that
skips CSI and OSC regions before looking for spaces — and use it in
place of the raw lastIndexOfChar in writeRowCells.

Regression test hides the URL space inside a cell whose tail text is
space-free, so the URL space is the only candidate break point and
the bug triggers deterministically.
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/d0772b7f/md-ansi-renderer branch from 25e4d19 to 9e013d6 Compare April 9, 2026 00:13
@Jarred-Sumner
Jarred-Sumner merged commit fa6f69f into main Apr 9, 2026
36 of 47 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/d0772b7f/md-ansi-renderer branch April 9, 2026 00:42
Comment thread src/md/ansi_renderer.zig
Comment on lines +940 to +948
fn wrapBreak(self: *AnsiRenderer) void {
const has_style = self.span_flags != 0 or self.link_depth > 0;
if (self.theme.colors and has_style) self.out.write("\x1b[39m\x1b[49m");
self.out.writeByte('\n');
self.last_was_newline = true;
self.col = 0;
self.writeIndent();
if (has_style) self.reapplyStyles();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 When theme.hyperlinks is enabled, wrapBreak() resets SGR styles but never emits the OSC 8 close sequence (\x1b]8;;\x1b\) before the soft-wrap newline, leaving the active hyperlink open across the line boundary. As a result, writeIndent() emits blockquote/list-prefix characters that are incorrectly included in the clickable link region, and reapplyStyles() restores only SGR blue+underline without re-emitting the OSC 8 opener, so the continuation line's text is never actually linked.

Extended reasoning...

Bug: wrapBreak() leaks OSC 8 hyperlink state across soft-wrap newlines

wrapBreak() handles the case where a paragraph line must be soft-wrapped to fit the terminal width. Its implementation is:

fn wrapBreak(self: *AnsiRenderer) void {
    const has_style = self.span_flags \!= 0 or self.link_depth > 0;
    if (self.theme.colors and has_style) self.out.write("\x1b[39m\x1b[49m");
    self.out.writeByte('\n');
    self.last_was_newline = true;
    self.col = 0;
    self.writeIndent();
    if (has_style) self.reapplyStyles();
}

When link_depth > 0 and theme.hyperlinks = true, an OSC 8 hyperlink was opened by enterSpan(.a) with a sequence like \x1b]8;;https://example.com\x1b\. The function emits \x1b[39m\x1b[49m (SGR foreground/background reset) before the newline, but never emits the OSC 8 close sequence \x1b]8;;\x1b\. The hyperlink is therefore still active when the newline is written.

Why this causes two distinct problems:

First, writeIndent() is called immediately after the newline while the OSC 8 terminal state is still open. Any blockquote bar characters (│) or list hanging-indent spaces emitted by writeIndent() are rendered as part of the clickable hyperlink region in the terminal, even though they are purely structural decoration.

Second, reapplyStyles() is called to restore visual styling on the continuation line. Examining that function:

if (self.link_depth > 0) {
    self.emitInline(color(.blue));
    self.emitInline(style(.underline));
    // OSC 8 opener is never re-emitted here
}

It correctly restores SGR blue+underline so the text appears visually linked, but it never re-emits \x1b]8;;href\x1b\. The continuation text is underlined and blue but has no OSC 8 hyperlink associated with it — clicking it will not navigate anywhere in terminals that support OSC 8. The struct field link_href: ?[]const u8 holds the URL throughout the span's lifetime but is unused in both wrapBreak() and reapplyStyles().

Concrete proof (step by step):

Suppose theme.hyperlinks = true, terminal width = 40, and the input markdown contains a long hyperlink like [Click here to visit the documentation page](https://example.com) inside a blockquote.

  1. enterSpan(.a) fires: emits \x1b]8;;https://example.com\x1b\, sets link_href and link_depth = 1.
  2. Text "Click here to visit the documenta" is emitted (col reaches 40).
  3. wrapBreak() fires: emits \x1b[39m\x1b[49m then \n. OSC 8 is still open.
  4. writeIndent() emits │ — these two characters are inside the OSC 8 link.
  5. reapplyStyles() emits \x1b[34m\x1b[4m (blue, underline) — no OSC 8 re-opener.
  6. "tion page" is rendered underlined+blue but with no active OSC 8 link.
  7. leaveSpan(.a) emits \x1b]8;;\x1b\ to close — but from the terminal's perspective, OSC 8 was never properly closed after step 3 or reopened after step 5.

Fix: In wrapBreak(), before writing the newline, emit the OSC 8 close if a hyperlink is active and hyperlinks are enabled. After writeIndent(), re-emit the OSC 8 opener using the stored link_href. The guard should be self.theme.colors and self.theme.hyperlinks and self.link_depth > 0. Alternatively, centralise this logic inside reapplyStyles() so it handles the OSC 8 re-open alongside the SGR re-open.

structwafel pushed a commit to structwafel/bun that referenced this pull request Apr 25, 2026
When the entrypoint loader is `.md`, bun now reads the file, renders it
to ANSI, prints to stdout, and exits — no JavaScript VM spin-up.

### Supported

- Headings (h1-h6) with colored underlines
- **Bold**, *italic*, ~~strikethrough~~, underline, \`inline code\`
- Ordered / unordered / task lists (with nesting)
- Blockquotes (nested)
- Horizontal rules
- Fenced code blocks with syntax highlighting for JS/TS/JSX/TSX via
  \`QuickAndDirtyJavaScriptSyntaxHighlighter\`
- Tables with per-column alignment + box-drawing borders. Column widths
  are computed globally so **CJK, emoji, and combining characters align
correctly** (uses
\`bun.strings.visible.width.exclude_ansi_colors.utf8\`)
- OSC 8 hyperlinks (with \"text (url)\" fallback when stdout isn't a
TTY)
- Images as alt text with the src as an OSC 8 link
- Wikilinks
- Autolinks (url, www, email)
- Word-wrapping respecting COLUMNS
- Light / dark theme selection via \`COLORFGBG\`, \`NO_COLOR\`,
\`FORCE_COLOR\`

### Wiring

- \`.md\` added to \`default_loaders\` so \`bun ./file.md\` resolves it.
- \`_bootAndHandleError\` short-circuits when the loader resolves to
  \`.md\`, calls the renderer, flushes, and exits.
- No VM boot, no JSC init — much faster than spinning up a runtime.

### Tests

\`test/cli/run/markdown-entrypoint.test.ts\` — 20 snapshot tests
covering
every feature above plus NO_COLOR mode and the \`.markdown\` extension.
Tables specifically include CJK, emoji, and combining-character rows to
verify multi-width grapheme alignment.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <robobun@bun.sh>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
When the entrypoint loader is `.md`, bun now reads the file, renders it
to ANSI, prints to stdout, and exits — no JavaScript VM spin-up.

### Supported

- Headings (h1-h6) with colored underlines
- **Bold**, *italic*, ~~strikethrough~~, underline, \`inline code\`
- Ordered / unordered / task lists (with nesting)
- Blockquotes (nested)
- Horizontal rules
- Fenced code blocks with syntax highlighting for JS/TS/JSX/TSX via
  \`QuickAndDirtyJavaScriptSyntaxHighlighter\`
- Tables with per-column alignment + box-drawing borders. Column widths
  are computed globally so **CJK, emoji, and combining characters align
correctly** (uses
\`bun.strings.visible.width.exclude_ansi_colors.utf8\`)
- OSC 8 hyperlinks (with \"text (url)\" fallback when stdout isn't a
TTY)
- Images as alt text with the src as an OSC 8 link
- Wikilinks
- Autolinks (url, www, email)
- Word-wrapping respecting COLUMNS
- Light / dark theme selection via \`COLORFGBG\`, \`NO_COLOR\`,
\`FORCE_COLOR\`

### Wiring

- \`.md\` added to \`default_loaders\` so \`bun ./file.md\` resolves it.
- \`_bootAndHandleError\` short-circuits when the loader resolves to
  \`.md\`, calls the renderer, flushes, and exits.
- No VM boot, no JSC init — much faster than spinning up a runtime.

### Tests

\`test/cli/run/markdown-entrypoint.test.ts\` — 20 snapshot tests
covering
every feature above plus NO_COLOR mode and the \`.markdown\` extension.
Tables specifically include CJK, emoji, and combining-character rows to
verify multi-width grapheme alignment.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: robobun <robobun@bun.sh>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants