Skip to content

Commit e876122

Browse files
crowlKatsclaude
andcommitted
fix: snapshot TOC offset to prevent level inflation
The offset in HeadingToCAdapter is shared mutable state set as a side effect of add_entry. Previously, render_markdown_inner read it lazily inside the anchorizer closure, so any add_entry call before the markdown was fully rendered could inflate heading levels. Snapshot the offset at the start of render_markdown_inner so it is immune to later mutations. This makes the heading level calculation deterministic regardless of call ordering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d3f77a0 commit e876122

2 files changed

Lines changed: 31 additions & 5 deletions

File tree

src/html/jsdoc.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,16 +180,20 @@ fn render_markdown_inner(
180180
let toc = render_ctx.toc.clone();
181181
let no_toc = render_options.no_toc;
182182

183+
// Snapshot the offset now so that any add_entry calls that happen after
184+
// this point (e.g. for Examples sections) don't retroactively inflate the
185+
// heading levels of this markdown block.
186+
let offset = *toc.offset.lock().unwrap();
187+
183188
let anchorizer = move |content: String, level: u8| {
184189
let mut anchorizer = toc.anchorizer.lock().unwrap();
185-
let offset = toc.offset.lock().unwrap();
186190

187191
let anchor = anchorizer.anchorize(&content);
188192

189193
if !no_toc {
190194
let mut toc = toc.toc.lock().unwrap();
191195
toc.push(crate::html::render_context::ToCEntry {
192-
level: level + *offset,
196+
level: level + offset,
193197
content,
194198
anchor: anchor.clone(),
195199
});

tests/html_test.rs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,9 +832,9 @@ async fn diff_comprehensive() {
832832
insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages);
833833
}
834834

835-
/// Verify that README headings in the module doc TOC appear before
836-
/// @example entries, matching the rendered page order where the
837-
/// markdown body is displayed before the Examples section.
835+
/// Verify that README headings in the module doc TOC:
836+
/// 1. Appear before @example entries (matching the rendered page order)
837+
/// 2. Are not inflated to deeper nesting levels by the offset state
838838
#[tokio::test]
839839
async fn readme_toc_order_with_examples() {
840840
let source = r#"
@@ -922,4 +922,26 @@ export function hello(): string {
922922
for window in positions.windows(2) {
923923
assert!(window[0] < window[1], "TOC headings are not in document order");
924924
}
925+
926+
// Verify heading levels aren't inflated: README h2 headings should NOT be
927+
// nested deeper than the Examples section (level 1). If the offset leaked,
928+
// they'd be at level 4 and appear as deeply nested sub-items.
929+
// In the correct output, README headings at level 2 nest directly under the
930+
// top-level list, not under a third-level nested list.
931+
let nav_start = index_html.find("documentNavigation").unwrap();
932+
let nav_section = &index_html[nav_start..];
933+
let nav_end = nav_section.find("</nav>").unwrap();
934+
let nav_html = &nav_section[..nav_end];
935+
936+
// Count nesting depth of the first README heading (Installation).
937+
// It should be in at most one <ul> nesting (the root <ul> + one sub-<ul>
938+
// for level 2), not two or more sub-<ul>s which would indicate inflated levels.
939+
let before_installation =
940+
&nav_html[..nav_html.find("Installation").unwrap()];
941+
let ul_depth = before_installation.matches("<ul>").count();
942+
assert!(
943+
ul_depth <= 2,
944+
"README headings are nested too deeply (depth {}), offset likely leaked from Examples",
945+
ul_depth
946+
);
925947
}

0 commit comments

Comments
 (0)