From a7219348372ce458f6a7836c9283a3a6447bc2df Mon Sep 17 00:00:00 2001
From: crowlbot <280062030+crowlbot@users.noreply.github.com>
Date: Wed, 3 Jun 2026 12:03:14 +0000
Subject: [PATCH] fix: don't over-escape page title
The page
was rendered with the registry-wide handlebars escaper
(html_escape::encode_safe), which escapes characters that are harmless in
text content such as '/'. This turned titles like "I/O" into "I/O".
Render the title via a dedicated escape_text helper that only escapes the
characters meaningful in text content (&, < and >), so a title reads as
"I/O" while HTML-special characters in symbol names stay escaped.
---
src/html/mod.rs | 7 +
src/html/templates/pages/html_head.hbs | 2 +-
tests/html_test.rs | 123 ++++++++++++++++++
...html_test__html_doc_files_multiple-24.snap | 2 +-
4 files changed, 132 insertions(+), 2 deletions(-)
diff --git a/src/html/mod.rs b/src/html/mod.rs
index 76620d033..356a0c261 100644
--- a/src/html/mod.rs
+++ b/src/html/mod.rs
@@ -96,6 +96,13 @@ fn setup_hbs() -> Result, anyhow::Error> {
handlebars_helper!(print: |a: Json| println!("{a:#?}"));
reg.register_helper("print", Box::new(print));
+ // Escape a value for use in plain text contexts (such as ``), where
+ // only `&`, `<` and `>` need escaping. The registry-wide escaper is the
+ // stricter `encode_safe`, which also escapes characters like `/` that are
+ // harmless in text content, turning e.g. `I/O` into `I/O`.
+ handlebars_helper!(escape_text: |a: str| html_escape::encode_text(a).into_owned());
+ reg.register_helper("escape_text", Box::new(escape_text));
+
reg.register_template_string(
ToCCtx::TEMPLATE,
include_str!("./templates/toc.hbs"),
diff --git a/src/html/templates/pages/html_head.hbs b/src/html/templates/pages/html_head.hbs
index 1be0a5c12..f1360b4eb 100644
--- a/src/html/templates/pages/html_head.hbs
+++ b/src/html/templates/pages/html_head.hbs
@@ -1,7 +1,7 @@
- {{title}}
+ {{{escape_text title}}}
diff --git a/tests/html_test.rs b/tests/html_test.rs
index 0b90708bf..71aa826fd 100644
--- a/tests/html_test.rs
+++ b/tests/html_test.rs
@@ -1097,3 +1097,126 @@ async fn html_output_is_valid() {
assert_generated_html_is_valid(&files);
}
+
+/// The page `` should only escape characters that are unsafe in text
+/// content (`&`, `<`, `>`). Characters like `/` are harmless and must not be
+/// over-escaped into entities like `/` (regression test for `I/O`
+/// rendering as `I/O`).
+#[tokio::test]
+async fn title_does_not_over_escape_slash() {
+ let source = r#"
+/** 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 {
+ // A scoped package name naturally contains a `/`.
+ package_name: Some("@deno/cool".to_string()),
+ 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();
+
+ // Scope the check to the `` element itself: `/` is still expected
+ // elsewhere on the page (e.g. in attribute/URL values escaped by the
+ // registry-wide escaper), but the title must read `@deno/cool`, not
+ // `@deno/cool`.
+ let title_start = index_html.find("").expect("title tag");
+ let title_end = index_html.find("").expect("title close tag");
+ let title = &index_html[title_start..title_end];
+
+ assert_eq!(
+ title, "@deno/cool documentation",
+ "title should contain an unescaped `/`"
+ );
+ assert!(
+ !title.contains("/"),
+ "title should not over-escape `/` into `/`"
+ );
+}
+
+/// Symbol names containing HTML-special characters must still be escaped in
+/// the `` so they cannot break out of the element.
+#[tokio::test]
+async fn title_escapes_html_special_chars() {
+ let source = r#"
+/** A class with a dangerous property name. */
+export class Foo {
+ "