diff --git a/src/html/jsdoc.rs b/src/html/jsdoc.rs index 1b0edecb..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(), }); @@ -818,12 +822,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..4ac3b60f 100644 --- a/tests/html_test.rs +++ b/tests/html_test.rs @@ -831,3 +831,119 @@ async fn diff_comprehensive() { insta::assert_json_snapshot!("diff_comprehensive_diff_only", pages); } + +/// 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#" +/** + * ## 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" + ); + } + + // 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