Repository navigation
Conversation
The parser handed on the text of a code span with the line ending in it, and only the HTML renderer wrote the space that CommonMark 6.1 asks for. `Bun.markdown.react()`, `render()` and `ansi()` got the line ending, and the id of a heading was made from it. The parser now hands on the text with a space for each line ending, in one text event as before. A consumer that wants the source (`bun fmt`, `bun lint`) gets the text as it is written. The HTML renderer loses its own conversion.
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Parser and rendering normalization src/md/parser.rs, src/md/inlines.rs, src/md/html_renderer.rs, src/md/types.rs |
The parser marks line endings, and code-span text passes through a helper that replaces newlines with spaces under the documented conditions. The HTML renderer no longer performs newline replacement. |
Regression coverage and documented behavior test/js/bun/md/*, test/cli/run/markdown-entrypoint.test.ts, docs/runtime/markdown.mdx, packages/bun-types/bun.d.ts |
Tests cover normalization across input forms and Markdown contexts. Runtime and type documentation state that inline code line breaks are represented as spaces. |
Suggested reviewers: jarred-sumner
Priority:
Merge Risk: 
Minimal · up to 9e08b
-
No merge-blocking risk is established; the code-span behavior matches the documented contract.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description clearly explains the problem, implementation, behavior changes, performance impact, scope, tests, and verification results. It does not use the template headings exactly, but it provides the required information in equivalent sections.
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands.
|
Updated 11:30 AM PT - Oct 27th, 2026
✅ @robobun, your commit 9e08bd2ac38743f3146a9c1891ce0581cb2c51c7 passed in 🧪 To try this PR locally: bunx bun-pr 46402That installs a local version of the PR into your bun-46402 --bun |
|
Status: ready for a maintainer review. The diff is green on CI so far. In build 133788 for 9e08bd2, 179 of 181 jobs passed and none failed. Two macOS x64 test jobs still wait for a machine. Three tests failed once in that build and passed when they ran again: The branch has main (9b8347a) merged in, with no conflict. The last commit, 9e08bd2, takes the two suggestions of the review. How I reproduced the problem on a release build of main (9b4284f): const text = "`a\nb`";
const log = (name, value) => console.log(name, JSON.stringify(value));
log("html", Bun.markdown.html(text));
log("react", Bun.markdown.react(text).props.children[0].props.children[0].props.children);
log("render", Bun.markdown.render(text, { codespan: code => "[" + code + "]" }));
log("ansi", Bun.markdown.ansi("> x `a\n> b` y", { colors: false }));
The five changed test files have 53 tests that fail on main and pass on this branch. The same holds after the merge. A debug build with the |
…ode-span-line-endings
… memory error `emit_code_text` takes no text type. Only a code span emits through it, so the `debug_assert` in `emit_text` and the doc of `RendererImpl::text` name `Code` alone. The copy of a span with a line ending reserves its space fallibly and returns `ParserError::OutOfMemory`, as the output buffers of the renderers do.
Problem
Bun.markdown.react(),render()andansi()keep the line ending inside an inline code span:"`a\nb`"gives["a\nb"], wherehtml()writesa b. No user reported this. markdown: hand react(), render() and ansi() the resolved href and title through SpanDetail accessors #46069 left it out.bun ./CLAUDE.mdprints line 14 at column 0, outside its list item. 71 of 6,191 Markdown files here have such a span.\nthat joins the lines (src/md/inlines.rs:469). OnlyHtmlRenderer::textwrote a space.Fix
HtmlRendererloses its loop.bun fmtandbun lint(wants_source()) get the text as written: remark's tree keeps the line ending.test/js/bun/md/andtest/cli/run/markdown-entrypoint.test.ts(53 tests fail without the fix). Alsobun fmtconformance,bun lintMarkdown tests.Background
enter_span,text,leave_span) to oneRendererImplper output.Codearm in each renderer: four copies of the rule, a scan per span each. md4c and cmark convert in the parser.Downsides
codespanortextcallback, or acodecomponent, gets a space where it got a line feed. With heading ids, a setext heading with such a span getsa-b, notab.react(),render()andansi()7 more instructions, 33 to 82 in a block of several lines.html()saves 10 or more, or pays up to 14 for a short span there.Notes
Where it came from. No user reported this and no issue exists. #46069 lists it under "Not in this change".
Where the difference shows
ansi()andbun ./file.md: always. The span is printed on two lines, and the second line has no quote bar and no list indent.react(), and arender()that builds HTML: under a style sheet that keeps white space incode(white-space: pre-wraporbreak-spaces), and for code that reads the text.Values, before and after. The text that
html()writes does not change. Its heading id does: see the last two rows."`a\nb`"react(), children ofcode["a\nb"]["a b"]render(),codespanandtextcallbacks"a\nb""a b"ansi(), no colors"a\nb\n""a b\n""> x `a\n> b` y"ansi(), no colors"| x a\nb y\n""| x a b y\n"""react(),alt"a\nb""a b""`a\nb`\n==="withheadings: { ids: true }orheadings: truehtml(),react(),render()id="ab"id="a-b""`a\nb`\n===\n\n## a b\n\n## ab\n", the three idsab,a-b,ab-1a-b,a-b-1,abCRLF and CR inputs give the same values: the parser joins the lines of a block with
\n. The last row: a heading beside the span can change its id too, because the count of equal ids moves. Both ids still exist and now lead to another heading.Design
RendererImplandHeadingIdTracker. One of the seven converted. Now none has to.Parser::emit_code_textis the only door forCodetext.emit_texthas adebug_assertagainstCode.\nis a marked byte with its own bit,MARK_LINE_END. A block on one line does not have the bit inmarks_seen, so its spans are not searched. In a longer block,bun_core::strings::index_of_char_usizesearches the span once.Parser::code_text, which the parse keeps. If the copy cannot be allocated, the call throws an out of memory error, as it does for the output buffers of the renderers.Parser::track, which isRendererImpl::wants_source().process_table_rowuses the same test for\|and for short rows.react()and three calls of thetextcallback.Three forms of the test for a line ending. Instructions per code span, against main. The last column is this PR.
index_of_charfor every spanindex_of_charrender()andansi(), span of 4 / 11 bytesrender(), span of 4 / 16 / 256 bytesrender(),a.b.c d.e.f: default,autolinks,collapseWhitespace, bothansi():a.b.c d.e.f,bun add @types/node@22.1.0, a URLansi(), 256 bytes ofa.html(), one-line block, span of 4 / 16 byteshtml(), two-line block, span of 4 / 16 bytesrender()ansi()marks.,:and@.Numbers (release builds of 9b4284f and of c06f5b6, the first commit of this branch on it, x64,
BUN_JSC_useJIT=0). The later commit 9e08bd2 changes only the path of a span over a line ending: onetry_reservebefore the copy.code(perf_event_openinstructions:u, 200 renders of 1,000 paragraphs, the run with 0 renders subtracted, minimum of 3):html()1,615 -> 1,614,render()1,527 -> 1,527,render()withtext2,382 -> 2,382,ansi()2,931 -> 2,931.html()2,904 -> 2,875,render()2,671 -> 2,678, withtext5,348 -> 5,355,ansi()4,469 -> 4,476.html()4,032 -> 4,042,render()3,703 -> 3,750, withtext7,199 -> 7,246,ansi()6,056 -> 6,103.html()3,667 -> 3,631,render()3,312 -> 3,428, withtext5,992 -> 6,108,ansi()5,236 -> 5,354.react()allocates JS objects and its count moves by about 50 between repeats: +6, +98 and +169 in the three cases with a span.html()by the length of the span, PR minus main. One-line block, 1 / 4 / 8 / 16 / 64 / 256 bytes: -10 / -29 / -53 / -101 / -389 / -1,539. Two-line block: +14 / +10 / +6 / -45 / -336 / -1,471.render(), 4 / 20 / 64 / 256 bytes: +116 / +179 / +146 / +178.html(), 4 / 20 / 30 / 40 / 64 bytes: -36 / +453 / +630 / -262 / -2,108. The two rises are one piece of 16 bytes or more where main wrote two shorter pieces:write_html_escapedcosts about 500 more from 16 bytes on, on main too.html(),react(),render()andansi(). +1 per render with 1,000 spans over a line ending.size_of::<Parser>(): 1,296 bytes (-Zprint-type-sizes, release profile), 24 more than main: oneVec<u8>.Bun.markdown.html("x"): 4,038 -> 4,041 instructions per call..text81,904,485 bytes and.rodata22,619,084 bytes on both builds (size -A), and the stripped file has the same size.process_inline_content12,460 -> 12,556 bytes,HtmlRenderer::text3,292 -> 2,960, the cold copy path 294 (nm -S).bench/snippets/markdown.mjshtml()-0.02% to -0.08%,render()+0.01% to +0.04%,ansi()+0.01% to +0.04%.react()is between -1.2% and +0.06%, inside its own spread.Bun.markdown, are 0.944 to 1.049.html()on the large document: 466,498 -> 476,699 (minimum of 9 rounds in turn), at 0.08% fewer instructions. The builds of the three forms above are at 475,037, 492,597 and 476,699, all within 0.13% in instructions. The cycles do not follow the instructions, so a difference of this size comes from the layout of the build.What does not change
html()over the 827 examples oftest/js/bun/md/*.txt: 0 differ, with default options and with heading ids.html()output is the same for all, with default options and with heading ids. No file has a setext heading with a code span over its line ending.render()andansi()differ in 71 files. Inrender()the only bytes that differ are 247 line feeds that are spaces now.bun fmt: the output of--run-prettier-tests(45,692 of 45,778 checks) and of--run-oxfmt-testsis byte-identical on both release builds.bun lint --print-markdown-treesover the 3,308 recorded trees: byte-identical.Tests
md-react.test.ts,md-render-callback.test.ts: a table of spans with the text written out, as a string and as aBuffer. A second table compares a document with the same document on one line: block quote, list item, link, emphasis, image, setext heading, a span between two other spans.md-edge-cases.test.ts:html()rows with exact output, becausemd-spec.test.tscompares with white space collapsed. A sweep compares the text of every code span ofspec.txtin thereact()tree with whathtml()writes.ansi()is compared with the span on one line for five themes.md-edge-cases.test.ts, "bun lint keeps the line ending": the one test for the opt-out. Its tree is what mdast-util-from-markdown 2.0.2 gives. With thetracktest taken out it fails (alt="a b"), and thebun fmtconformance does not notice. It is not intest/cli/lint/js-parsers.test.tsbeside the recorded trees: that test times out under a debug build on main.md-heading-ids.test.ts,markdown-entrypoint.test.ts: one test each.react()tree into therender()output stays green.react()tree withhtml(). For this fix it finds the same four examples ofspec.txtas the sweep.md-spec.test.ts,gfm-compat.test.ts,test/cli/format/conformance.test.ts,test/cli/lint/js-parsers.test.ts(Markdown),test/cli/lint/explain.test.ts,test/internal/source-lints/,test/integration/bun-types/bun-types.test.ts.Related pull requests
HtmlRendererarm that is deleted here. This change supersedes its code-span half. When it is rebased, it drops theCodehalf of bothTextType::Code | TextType::Latexmatharms. For math text it can giveemit_code_texta text type.Not in this change (the same on both builds)
"foo \n baz\n"gives<p>foo \nbaz</p>fromhtml(), where example 649 of the spec has<p>foo\nbaz</p>.md-spec.test.tscollapses white space, and Bun.markdown: implement the hardSoftBreaks and collapseWhitespace options #39495 edits those lines.ansi()lines outside their container for an HTML block or text behind a code block: markdown: in ansi(), write a line's quote bars and list indent with its first content #46348.html()writes a space andreact()andrender()a line feed. For a hard breakreact()writes nothing (alt="ab"). After this change""givesa b c\ndfromreact()anda b c dfromhtml().render()callback can write into its inputBuffer. If it writes a line feed into a later code span of the same one-line block, that span hands it on as written, as before.