Skip to content

Commit 4e75be5

Browse files
crowlKatsclaude
andauthored
fix: render module doc body before examples in TOC (#792)
* 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 <noreply@anthropic.com> * 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> * fmt --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6460f5c commit 4e75be5

2 files changed

Lines changed: 124 additions & 4 deletions

File tree

src/html/jsdoc.rs

Lines changed: 8 additions & 4 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
});
@@ -818,12 +822,12 @@ impl ModuleDocCtx {
818822
}
819823
});
820824

825+
let html = jsdoc_body_to_html(render_ctx, js_doc, summary);
826+
821827
if let Some(examples) = jsdoc_examples(render_ctx, js_doc) {
822828
sections.push(examples);
823829
}
824830

825-
let html = jsdoc_body_to_html(render_ctx, js_doc, summary);
826-
827831
(deprecated, html)
828832
} else {
829833
(None, None)

tests/html_test.rs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,3 +831,119 @@ async fn diff_comprehensive() {
831831

832832
insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages);
833833
}
834+
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
838+
#[tokio::test]
839+
async fn readme_toc_order_with_examples() {
840+
let source = r#"
841+
/**
842+
* ## Installation
843+
*
844+
* Install the library.
845+
*
846+
* ## Usage
847+
*
848+
* Use the library.
849+
*
850+
* ## API Reference
851+
*
852+
* The API reference.
853+
*
854+
* @example My Example
855+
* ```ts
856+
* hello();
857+
* ```
858+
*
859+
* @module
860+
*/
861+
862+
/** A simple function. */
863+
export function hello(): string {
864+
return "hello";
865+
}
866+
"#;
867+
868+
let doc_nodes_by_url = parse_source(source).await;
869+
870+
let specifier = ModuleSpecifier::parse("file:///mod.ts").unwrap();
871+
872+
let ctx = GenerateCtx::create_basic(
873+
GenerateOptions {
874+
package_name: None,
875+
main_entrypoint: Some(specifier),
876+
href_resolver: Arc::new(EmptyResolver),
877+
usage_composer: Some(Arc::new(EmptyResolver)),
878+
rewrite_map: None,
879+
category_docs: None,
880+
disable_search: false,
881+
symbol_redirect_map: None,
882+
default_symbol_map: None,
883+
markdown_renderer: comrak::create_renderer(None, None, None),
884+
markdown_stripper: Arc::new(comrak::strip),
885+
head_inject: None,
886+
id_prefix: None,
887+
diff_only: false,
888+
},
889+
doc_nodes_by_url,
890+
None,
891+
)
892+
.unwrap();
893+
894+
let files = generate(ctx).unwrap();
895+
let index_html = files.get("./index.html").unwrap();
896+
897+
// README headings should appear before the Examples section in the TOC,
898+
// matching the page layout where the markdown body comes before @example sections.
899+
let readme_heading_pos = index_html
900+
.find("title=\"Installation\"")
901+
.expect("Installation heading not found in TOC");
902+
let examples_pos = index_html
903+
.find("title=\"Examples\"")
904+
.expect("Examples heading not found in TOC");
905+
906+
assert!(
907+
readme_heading_pos < examples_pos,
908+
"README headings should appear before Examples in the TOC"
909+
);
910+
911+
// Verify README headings are in document order
912+
let headings = ["Installation", "Usage", "API Reference"];
913+
let positions: Vec<usize> = headings
914+
.iter()
915+
.map(|h| {
916+
index_html
917+
.find(&format!("title=\"{}\"", h))
918+
.unwrap_or_else(|| panic!("heading '{}' not found in TOC", h))
919+
})
920+
.collect();
921+
922+
for window in positions.windows(2) {
923+
assert!(
924+
window[0] < window[1],
925+
"TOC headings are not in document order"
926+
);
927+
}
928+
929+
// Verify heading levels aren't inflated: README h2 headings should NOT be
930+
// nested deeper than the Examples section (level 1). If the offset leaked,
931+
// they'd be at level 4 and appear as deeply nested sub-items.
932+
// In the correct output, README headings at level 2 nest directly under the
933+
// top-level list, not under a third-level nested list.
934+
let nav_start = index_html.find("documentNavigation").unwrap();
935+
let nav_section = &index_html[nav_start..];
936+
let nav_end = nav_section.find("</nav>").unwrap();
937+
let nav_html = &nav_section[..nav_end];
938+
939+
// Count nesting depth of the first README heading (Installation).
940+
// It should be in at most one <ul> nesting (the root <ul> + one sub-<ul>
941+
// for level 2), not two or more sub-<ul>s which would indicate inflated levels.
942+
let before_installation = &nav_html[..nav_html.find("Installation").unwrap()];
943+
let ul_depth = before_installation.matches("<ul>").count();
944+
assert!(
945+
ul_depth <= 2,
946+
"README headings are nested too deeply (depth {}), offset likely leaked from Examples",
947+
ul_depth
948+
);
949+
}

0 commit comments

Comments
 (0)