Add markdown ANSI pretty-printer for bun ./file.md - #28833
Conversation
|
Updated 9:46 PM PT - Apr 8th, 2026
❌ @Jarred-Sumner, your commit 9e013d6 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 28833That installs a local version of the PR into your bun-28833 --bun |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds a Markdown→ANSI renderer and integrates Markdown entrypoint handling into the CLI: Changes
🚥 Pre-merge checks | ✅ 2✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (5)
src/cli/run_command.zigsrc/md/ansi_renderer.zigsrc/md/root.zigsrc/options.zigtest/cli/run/markdown-entrypoint.test.ts
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (1)
test/cli/run/markdown-entrypoint.test.ts (1)
10-15:⚠️ Potential issue | 🟠 MajorThis still doesn't exercise the OSC 8 renderer path.
runMd()always spawns Bun withstdout: "pipe", soOutput.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
⛔ Files ignored due to path filters (1)
test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (3)
src/cli/run_command.zigsrc/md/ansi_renderer.zigtest/cli/run/markdown-entrypoint.test.ts
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
src/md/ansi_renderer.zig (1)
898-903:⚠️ Potential issue | 🟠 MajorInline 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
22mand39m. Inputs such as# Hello **bold** worldor 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
⛔ Files ignored due to path filters (1)
test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (4)
src/cli/run_command.zigsrc/md/ansi_renderer.zigsrc/md/inlines.zigtest/cli/run/markdown-entrypoint.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (4)
src/md/ansi_renderer.zig (4)
781-794:⚠️ Potential issue | 🟠 MajorRestore heading styling in
reapplyStyles().While heading text is buffered, nested link/code closes inject
39m/49minto that buffer. Because this helper only reapplies inline/link state,# before [x](u) afterrendersafterwithout the heading color. Sinceheading_levelis 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 | 🟠 MajorUse ASCII task markers when
colorsis 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_colorsflags 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 | 🟠 MajorReapply 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. CallreapplyStyles()after the reset, or avoid\x1b[0mhere.🤖 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 | 🟠 MajorIndent 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
⛔ Files ignored due to path filters (1)
test/cli/run/__snapshots__/markdown-entrypoint.test.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (1)
src/md/ansi_renderer.zig
d70db4c to
4d85538
Compare
1880007 to
100ac5a
Compare
a8273a3 to
100ac5a
Compare
|
Gate's container has a stale 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
left a comment
There was a problem hiding this comment.
- 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.
bca670c to
7b8a0fe
Compare
d683d5a to
50d2005
Compare
…ns atomic + color across wraps
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.
… wrap-test fixture paths
…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.
25e4d19 to
9e013d6
Compare
| 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(); | ||
| } |
There was a problem hiding this comment.
🔴 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.
enterSpan(.a)fires: emits\x1b]8;;https://example.com\x1b\, setslink_hrefandlink_depth = 1.- Text "Click here to visit the documenta" is emitted (col reaches 40).
wrapBreak()fires: emits\x1b[39m\x1b[49mthen\n. OSC 8 is still open.writeIndent()emits│— these two characters are inside the OSC 8 link.reapplyStyles()emits\x1b[34m\x1b[4m(blue, underline) — no OSC 8 re-opener.- "tion page" is rendered underlined+blue but with no active OSC 8 link.
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.
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>
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>
When the entrypoint loader is
.md, bun now reads the file, renders itto ANSI, prints to stdout, and exits — no JavaScript VM spin-up.
Supported
strikethrough, underline, `inline code``QuickAndDirtyJavaScriptSyntaxHighlighter`
are computed globally so CJK, emoji, and combining characters align
correctly (uses `bun.strings.visible.width.exclude_ansi_colors.utf8`)
Wiring
`.md`, calls the renderer, flushes, and exits.
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.