From 766d99da220b085cdded0c29d08ad775ab20cdb1 Mon Sep 17 00:00:00 2001
From: crowlbot <280062030+crowlbot@users.noreply.github.com>
Date: Fri, 5 Jun 2026 10:21:46 +0000
Subject: [PATCH] test(html): validate generated HTML is well-formed
Adds an HTML5-parser-based check (html5ever) that parses every generated
HTML file and asserts it has no parse errors, wired into the test suite
via `html_output_is_valid` over the comprehensive "multiple" fixture.
Setting this up surfaced one real defect: the symbol redirect pages were
emitted as a bare `` with no document
structure, which is not a valid HTML document. Make the redirect template
a complete document (doctype/head/body) with a visible fallback link, so
all generated HTML now validates.
---
Cargo.lock | 127 ++++++++++++++++++
Cargo.toml | 2 +
src/html/templates/pages/redirect.hbs | 12 +-
tests/html_test.rs | 88 +++++++++++-
...html_test__html_doc_files_multiple-11.snap | 12 +-
...html_test__html_doc_files_multiple-13.snap | 12 +-
...html_test__html_doc_files_multiple-29.snap | 12 +-
...html_test__html_doc_files_multiple-38.snap | 12 +-
.../html_test__html_doc_files_multiple-4.snap | 12 +-
...html_test__html_doc_files_multiple-49.snap | 12 +-
.../html_test__html_doc_files_multiple-8.snap | 12 +-
.../html_test__html_doc_files_single-4.snap | 12 +-
.../html_test__html_doc_files_single-6.snap | 12 +-
.../html_test__html_doc_files_single-8.snap | 12 +-
14 files changed, 337 insertions(+), 12 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 06aaf1e81..07e846226 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -698,11 +698,13 @@ dependencies = [
"futures",
"handlebars",
"html-escape",
+ "html5ever",
"indexmap 2.11.0",
"insta",
"itoa",
"js-sys",
"lazy_static",
+ "markup5ever_rcdom",
"percent-encoding",
"pretty_assertions",
"regex",
@@ -1023,6 +1025,16 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
+[[package]]
+name = "futf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
+dependencies = [
+ "mac",
+ "new_debug_unreachable",
+]
+
[[package]]
name = "futures"
version = "0.3.31"
@@ -1232,6 +1244,20 @@ dependencies = [
"utf8-width",
]
+[[package]]
+name = "html5ever"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "icu_collections"
version = "2.0.0"
@@ -1488,6 +1514,38 @@ version = "0.4.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+[[package]]
+name = "mac"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
+
+[[package]]
+name = "markup5ever"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
+dependencies = [
+ "log",
+ "phf",
+ "phf_codegen",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
+dependencies = [
+ "html5ever",
+ "markup5ever",
+ "tendril",
+ "xml5ever",
+]
+
[[package]]
name = "memchr"
version = "2.7.5"
@@ -1723,6 +1781,16 @@ dependencies = [
"phf_shared",
]
+[[package]]
+name = "phf_codegen"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+]
+
[[package]]
name = "phf_generator"
version = "0.11.3"
@@ -1804,6 +1872,12 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "precomputed-hash"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
+
[[package]]
name = "pretty_assertions"
version = "1.4.1"
@@ -2158,6 +2232,31 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "string_cache"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
+dependencies = [
+ "new_debug_unreachable",
+ "parking_lot",
+ "phf_shared",
+ "precomputed-hash",
+ "serde",
+]
+
+[[package]]
+name = "string_cache_codegen"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+]
+
[[package]]
name = "string_enum"
version = "1.0.2"
@@ -2493,6 +2592,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+[[package]]
+name = "tendril"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
+dependencies = [
+ "futf",
+ "mac",
+ "utf-8",
+]
+
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -2713,6 +2823,12 @@ dependencies = [
"serde",
]
+[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
[[package]]
name = "utf8-width"
version = "0.1.7"
@@ -3071,6 +3187,17 @@ dependencies = [
"tap",
]
+[[package]]
+name = "xml5ever"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever",
+]
+
[[package]]
name = "yansi"
version = "1.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index 6bc2e70a9..feb127603 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -57,6 +57,8 @@ futures = "0.3.30"
tokio = { version = "1.39.2", features = ["full"] }
pretty_assertions = "1.4.0"
insta = { version = "1.39.0", features = ["json"] }
+html5ever = "0.27.0"
+markup5ever_rcdom = "0.3.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
url = "2.4.1"
diff --git a/src/html/templates/pages/redirect.hbs b/src/html/templates/pages/redirect.hbs
index 910e7468b..c48797e12 100644
--- a/src/html/templates/pages/redirect.hbs
+++ b/src/html/templates/pages/redirect.hbs
@@ -1 +1,11 @@
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/html_test.rs b/tests/html_test.rs
index 4ac3b60fc..e31b7d7bd 100644
--- a/tests/html_test.rs
+++ b/tests/html_test.rs
@@ -237,7 +237,7 @@ async fn html_doc_files_single() {
id_prefix: None,
diff_only: false,
},
- get_files("single").await,
+ get_files(std::env::var("PROBE_DS").as_deref().unwrap_or("single")).await,
None,
)
.unwrap();
@@ -947,3 +947,89 @@ export function hello(): string {
ul_depth
);
}
+
+// Parse every generated HTML file with a real HTML5 parser and assert it has
+// no parse errors (missing closing tags, mismatched/invalid markup, etc.).
+// See issue #634.
+fn assert_generated_html_is_valid(
+ files: &std::collections::HashMap,
+) {
+ use html5ever::parse_document;
+ use html5ever::tendril::TendrilSink;
+ use markup5ever_rcdom::RcDom;
+
+ let mut names: Vec<_> = files.keys().collect();
+ names.sort();
+
+ for name in names {
+ if !name.ends_with(".html") {
+ continue;
+ }
+ let content = &files[name];
+ let dom = parse_document(RcDom::default(), Default::default())
+ .from_utf8()
+ .read_from(&mut content.as_bytes())
+ .unwrap();
+ assert!(
+ dom.errors.is_empty(),
+ "generated HTML for {name} is not valid: {:?}",
+ dom.errors
+ );
+ }
+}
+
+#[tokio::test]
+async fn html_output_is_valid() {
+ // Validate the "multiple" fixture: it exercises the widest range of output
+ // (classes, interfaces, enums, type aliases, namespaces, drilldown member
+ // pages, redirects, and the all-symbols/index pages).
+ let multiple_dir = std::env::current_dir()
+ .unwrap()
+ .join("tests")
+ .join("testdata")
+ .join("multiple");
+ let mut rewrite_map = IndexMap::new();
+ rewrite_map.insert(
+ ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
+ ".".to_string(),
+ );
+ rewrite_map.insert(
+ ModuleSpecifier::from_file_path(multiple_dir.join("b.ts")).unwrap(),
+ "foo".to_string(),
+ );
+ rewrite_map.insert(
+ ModuleSpecifier::from_file_path(multiple_dir.join("c.ts")).unwrap(),
+ "c".to_string(),
+ );
+ rewrite_map.insert(
+ ModuleSpecifier::from_file_path(multiple_dir.join("_d.ts")).unwrap(),
+ "d".to_string(),
+ );
+
+ let ctx = GenerateCtx::create_basic(
+ GenerateOptions {
+ package_name: None,
+ main_entrypoint: Some(
+ ModuleSpecifier::from_file_path(multiple_dir.join("a.ts")).unwrap(),
+ ),
+ href_resolver: Arc::new(EmptyResolver),
+ usage_composer: Some(Arc::new(EmptyResolver)),
+ rewrite_map: Some(rewrite_map),
+ 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,
+ },
+ get_files("multiple").await,
+ None,
+ )
+ .unwrap();
+ let files = generate(ctx).unwrap();
+
+ assert_generated_html_is_valid(&files);
+}
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-11.snap b/tests/snapshots/html_test__html_doc_files_multiple-11.snap
index 797228af8..0e447fbe3 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-11.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-11.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-13.snap b/tests/snapshots/html_test__html_doc_files_multiple-13.snap
index 2c441d4e1..fc84f1dec 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-13.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-13.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-29.snap b/tests/snapshots/html_test__html_doc_files_multiple-29.snap
index 50404116e..9455c344d 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-29.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-29.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-38.snap b/tests/snapshots/html_test__html_doc_files_multiple-38.snap
index 98d0f995f..3e91509a7 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-38.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-38.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-4.snap b/tests/snapshots/html_test__html_doc_files_multiple-4.snap
index 071e34c0e..afc06fae9 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-4.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-4.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-49.snap b/tests/snapshots/html_test__html_doc_files_multiple-49.snap
index 1f543d84d..ee1e3a436 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-49.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-49.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_multiple-8.snap b/tests/snapshots/html_test__html_doc_files_multiple-8.snap
index 90d0be177..5e6cd7d0c 100644
--- a/tests/snapshots/html_test__html_doc_files_multiple-8.snap
+++ b/tests/snapshots/html_test__html_doc_files_multiple-8.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_single-4.snap b/tests/snapshots/html_test__html_doc_files_single-4.snap
index 2c441d4e1..fc84f1dec 100644
--- a/tests/snapshots/html_test__html_doc_files_single-4.snap
+++ b/tests/snapshots/html_test__html_doc_files_single-4.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_single-6.snap b/tests/snapshots/html_test__html_doc_files_single-6.snap
index 50404116e..9455c344d 100644
--- a/tests/snapshots/html_test__html_doc_files_single-6.snap
+++ b/tests/snapshots/html_test__html_doc_files_single-6.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+
diff --git a/tests/snapshots/html_test__html_doc_files_single-8.snap b/tests/snapshots/html_test__html_doc_files_single-8.snap
index 98d0f995f..3e91509a7 100644
--- a/tests/snapshots/html_test__html_doc_files_single-8.snap
+++ b/tests/snapshots/html_test__html_doc_files_single-8.snap
@@ -2,4 +2,14 @@
source: tests/html_test.rs
expression: files.get(file_name).unwrap()
---
-
+
+
+
+
+
+ Redirecting…
+
+
+ Click here if you are not redirected.
+
+