From d3f77a0d5ee44b32fd492105b3f94a965e59c94e Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 3 Apr 2026 22:21:28 +0200 Subject: [PATCH 1/3] fix: render module doc body before examples in TOC The module doc body (README) is rendered before @example sections in the page HTML, but the TOC entries were added in the opposite order. This caused README headings to appear after Examples entries in the table of contents, and the shared offset state inflated their nesting level. Swap the order so jsdoc_body_to_html runs before jsdoc_examples, making the TOC match the page layout. Closes jsr-io/jsr#486 Co-Authored-By: Claude Opus 4.6 --- src/html/jsdoc.rs | 4 +- tests/html_test.rs | 92 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/html/jsdoc.rs b/src/html/jsdoc.rs index 1b0edecb..d20e392f 100644 --- a/src/html/jsdoc.rs +++ b/src/html/jsdoc.rs @@ -818,12 +818,12 @@ impl ModuleDocCtx { } }); + let html = jsdoc_body_to_html(render_ctx, js_doc, summary); + if let Some(examples) = jsdoc_examples(render_ctx, js_doc) { sections.push(examples); } - let html = jsdoc_body_to_html(render_ctx, js_doc, summary); - (deprecated, html) } else { (None, None) diff --git a/tests/html_test.rs b/tests/html_test.rs index 0392376b..4ec8f1ab 100644 --- a/tests/html_test.rs +++ b/tests/html_test.rs @@ -831,3 +831,95 @@ async fn diff_comprehensive() { insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages); } + +/// Verify that README headings in the module doc TOC appear before +/// @example entries, matching the rendered page order where the +/// markdown body is displayed before the Examples section. +#[tokio::test] +async fn readme_toc_order_with_examples() { + let source = r#" +/** + * ## Installation + * + * Install the library. + * + * ## Usage + * + * Use the library. + * + * ## API Reference + * + * The API reference. + * + * @example My Example + * ```ts + * hello(); + * ``` + * + * @module + */ + +/** A simple function. */ +export function hello(): string { + return "hello"; +} +"#; + + let doc_nodes_by_url = parse_source(source).await; + + let specifier = ModuleSpecifier::parse("file:///mod.ts").unwrap(); + + let ctx = GenerateCtx::create_basic( + GenerateOptions { + package_name: None, + main_entrypoint: Some(specifier), + href_resolver: Arc::new(EmptyResolver), + usage_composer: Some(Arc::new(EmptyResolver)), + rewrite_map: None, + category_docs: None, + disable_search: false, + symbol_redirect_map: None, + default_symbol_map: None, + markdown_renderer: comrak::create_renderer(None, None, None), + markdown_stripper: Arc::new(comrak::strip), + head_inject: None, + id_prefix: None, + diff_only: false, + }, + doc_nodes_by_url, + None, + ) + .unwrap(); + + let files = generate(ctx).unwrap(); + let index_html = files.get("./index.html").unwrap(); + + // README headings should appear before the Examples section in the TOC, + // matching the page layout where the markdown body comes before @example sections. + let readme_heading_pos = index_html + .find("title=\"Installation\"") + .expect("Installation heading not found in TOC"); + let examples_pos = index_html + .find("title=\"Examples\"") + .expect("Examples heading not found in TOC"); + + assert!( + readme_heading_pos < examples_pos, + "README headings should appear before Examples in the TOC" + ); + + // Verify README headings are in document order + let headings = ["Installation", "Usage", "API Reference"]; + let positions: Vec = headings + .iter() + .map(|h| { + index_html + .find(&format!("title=\"{}\"", h)) + .unwrap_or_else(|| panic!("heading '{}' not found in TOC", h)) + }) + .collect(); + + for window in positions.windows(2) { + assert!(window[0] < window[1], "TOC headings are not in document order"); + } +} From e876122989ca24bd25ce3d253a817564a69c0f38 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 3 Apr 2026 22:27:13 +0200 Subject: [PATCH 2/3] 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 --- src/html/jsdoc.rs | 8 ++++++-- tests/html_test.rs | 28 +++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/html/jsdoc.rs b/src/html/jsdoc.rs index d20e392f..b256f479 100644 --- a/src/html/jsdoc.rs +++ b/src/html/jsdoc.rs @@ -180,16 +180,20 @@ fn render_markdown_inner( let toc = render_ctx.toc.clone(); let no_toc = render_options.no_toc; + // Snapshot the offset now so that any add_entry calls that happen after + // this point (e.g. for Examples sections) don't retroactively inflate the + // heading levels of this markdown block. + let offset = *toc.offset.lock().unwrap(); + let anchorizer = move |content: String, level: u8| { let mut anchorizer = toc.anchorizer.lock().unwrap(); - let offset = toc.offset.lock().unwrap(); let anchor = anchorizer.anchorize(&content); if !no_toc { let mut toc = toc.toc.lock().unwrap(); toc.push(crate::html::render_context::ToCEntry { - level: level + *offset, + level: level + offset, content, anchor: anchor.clone(), }); diff --git a/tests/html_test.rs b/tests/html_test.rs index 4ec8f1ab..b3fe8752 100644 --- a/tests/html_test.rs +++ b/tests/html_test.rs @@ -832,9 +832,9 @@ async fn diff_comprehensive() { insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages); } -/// Verify that README headings in the module doc TOC appear before -/// @example entries, matching the rendered page order where the -/// markdown body is displayed before the Examples section. +/// Verify that README headings in the module doc TOC: +/// 1. Appear before @example entries (matching the rendered page order) +/// 2. Are not inflated to deeper nesting levels by the offset state #[tokio::test] async fn readme_toc_order_with_examples() { let source = r#" @@ -922,4 +922,26 @@ export function hello(): string { for window in positions.windows(2) { assert!(window[0] < window[1], "TOC headings are not in document order"); } + + // Verify heading levels aren't inflated: README h2 headings should NOT be + // nested deeper than the Examples section (level 1). If the offset leaked, + // they'd be at level 4 and appear as deeply nested sub-items. + // In the correct output, README headings at level 2 nest directly under the + // top-level list, not under a third-level nested list. + let nav_start = index_html.find("documentNavigation").unwrap(); + let nav_section = &index_html[nav_start..]; + let nav_end = nav_section.find("").unwrap(); + let nav_html = &nav_section[..nav_end]; + + // Count nesting depth of the first README heading (Installation). + // It should be in at most one
    nesting (the root
      + one sub-
        + // for level 2), not two or more sub-
          s which would indicate inflated levels. + let before_installation = + &nav_html[..nav_html.find("Installation").unwrap()]; + let ul_depth = before_installation.matches("
            ").count(); + assert!( + ul_depth <= 2, + "README headings are nested too deeply (depth {}), offset likely leaked from Examples", + ul_depth + ); } From 9e27701dd8a2f02d306e35aba722693aa55abfef Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 17 Apr 2026 13:33:13 +0200 Subject: [PATCH 3/3] fmt --- tests/html_test.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/html_test.rs b/tests/html_test.rs index b3fe8752..4ac3b60f 100644 --- a/tests/html_test.rs +++ b/tests/html_test.rs @@ -920,7 +920,10 @@ export function hello(): string { .collect(); for window in positions.windows(2) { - assert!(window[0] < window[1], "TOC headings are not in document order"); + assert!( + window[0] < window[1], + "TOC headings are not in document order" + ); } // Verify heading levels aren't inflated: README h2 headings should NOT be @@ -936,8 +939,7 @@ export function hello(): string { // Count nesting depth of the first README heading (Installation). // It should be in at most one
              nesting (the root
                + one sub-
                  // for level 2), not two or more sub-
                    s which would indicate inflated levels. - let before_installation = - &nav_html[..nav_html.find("Installation").unwrap()]; + let before_installation = &nav_html[..nav_html.find("Installation").unwrap()]; let ul_depth = before_installation.matches("
                      ").count(); assert!( ul_depth <= 2,