diff --git a/crates/mdbook-html/src/html_handlebars/static_files.rs b/crates/mdbook-html/src/html_handlebars/static_files.rs
index e0415cb40b..7ba0f61880 100644
--- a/crates/mdbook-html/src/html_handlebars/static_files.rs
+++ b/crates/mdbook-html/src/html_handlebars/static_files.rs
@@ -11,6 +11,63 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::debug;
+/// Returns the directory component of a forward-slash path (no trailing slash).
+/// Returns `""` for paths with no directory component.
+fn url_parent_dir(path: &str) -> &str {
+ match path.rfind('/') {
+ Some(i) => &path[..i],
+ None => "",
+ }
+}
+
+/// Normalizes a slash-separated path by resolving `.` and `..` components.
+fn normalize_url_path(path: &str) -> String {
+ let mut parts: Vec<&str> = Vec::new();
+ for component in path.split('/') {
+ match component {
+ "" | "." => {}
+ ".." => {
+ parts.pop();
+ }
+ c => parts.push(c),
+ }
+ }
+ parts.join("/")
+}
+
+/// Expresses `target` as a path relative to `from_dir`.
+/// Both are root-relative slash-separated paths.
+fn make_url_relative(from_dir: &str, target: &str) -> String {
+ let from: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
+ let to: Vec<&str> = target.split('/').filter(|s| !s.is_empty()).collect();
+ let common = from
+ .iter()
+ .zip(to.iter())
+ .take_while(|(a, b)| a == b)
+ .count();
+ let ups = from.len() - common;
+ let mut parts: Vec<&str> = Vec::new();
+ for _ in 0..ups {
+ parts.push("..");
+ }
+ parts.extend_from_slice(&to[common..]);
+ if parts.is_empty() {
+ ".".to_string()
+ } else {
+ parts.join("/")
+ }
+}
+
+/// Returns `true` if a CSS `url()` value is absolute and should not be rewritten.
+fn is_css_url_absolute(url: &str) -> bool {
+ url.starts_with("http://")
+ || url.starts_with("https://")
+ || url.starts_with("data:")
+ || url.starts_with("//")
+ || url.starts_with('/')
+ || url.starts_with('#')
+}
+
/// Map static files to their final names and contents.
///
/// It performs [fingerprinting], if you call the `hash_files` method.
@@ -104,19 +161,23 @@ impl StaticFiles {
this.static_files.push(StaticFile::Additional {
input_location,
+ // Always use forward slashes for consistent hash_map keys across platforms.
filename: custom_file
.to_str()
.with_context(|| "resource file names must be valid utf8")?
- .to_owned(),
+ .replace('\\', "/"),
});
}
for input_location in theme.font_files.iter().cloned() {
- let filename = Path::new("fonts")
- .join(input_location.file_name().unwrap())
- .to_str()
- .with_context(|| "resource file names must be valid utf8")?
- .to_owned();
+ let filename = format!(
+ "fonts/{}",
+ input_location
+ .file_name()
+ .unwrap()
+ .to_str()
+ .with_context(|| "resource file names must be valid utf8")?
+ );
this.static_files.push(StaticFile::Additional {
input_location,
filename,
@@ -195,12 +256,19 @@ impl StaticFiles {
// The `{{ resource "name" }}` directive in static resources look like
// handlebars syntax, even if they technically aren't.
static_regex!(RESOURCE, bytes, r#"\{\{ resource "([^"]+)" \}\}"#);
+ // CSS url() with double-quoted, single-quoted, or unquoted paths.
+ // Capture groups: 1=double-quoted path, 2=single-quoted path, 3=unquoted path.
+ static_regex!(
+ CSS_URL,
+ bytes,
+ r#"url\(\s*(?:"([^"]*?)"|'([^']*?)'|([^'"\s()]+))\s*\)"#
+ );
fn replace_all<'a>(
hash_map: &HashMap,
data: &'a [u8],
filename: &str,
) -> Cow<'a, [u8]> {
- RESOURCE.replace_all(data, move |captures: &Captures<'_>| {
+ let data = RESOURCE.replace_all(data, move |captures: &Captures<'_>| {
let name = captures
.get(1)
.expect("capture 1 in resource regex")
@@ -211,7 +279,53 @@ impl StaticFiles {
format!("{}{}", path_to_root, resource_filename)
.as_bytes()
.to_owned()
- })
+ });
+ // Convert to owned to break the borrow chain before the second pass.
+ let data = data.into_owned();
+ let css_dir = url_parent_dir(filename);
+ let data = CSS_URL.replace_all(&data, move |captures: &Captures<'_>| {
+ let (url_bytes, quote) = if let Some(m) = captures.get(1) {
+ (m.as_bytes(), "\"")
+ } else if let Some(m) = captures.get(2) {
+ (m.as_bytes(), "'")
+ } else {
+ (
+ captures
+ .get(3)
+ .expect("capture group 3 in CSS url regex")
+ .as_bytes(),
+ "",
+ )
+ };
+ let url_str = match std::str::from_utf8(url_bytes) {
+ Ok(s) => s,
+ Err(_) => return captures[0].to_owned(),
+ };
+ if is_css_url_absolute(url_str) {
+ return captures[0].to_owned();
+ }
+ // Resolve the URL relative to the CSS file's directory to get
+ // the root-relative path for hash_map lookup.
+ let resolved = if css_dir.is_empty() {
+ normalize_url_path(url_str)
+ } else {
+ normalize_url_path(&format!("{}/{}", css_dir, url_str))
+ };
+ if let Some(hashed) = hash_map.get(&resolved) {
+ // Express the hashed path relative to the CSS file's directory.
+ let relative = if css_dir.is_empty() {
+ hashed.clone()
+ } else {
+ make_url_relative(css_dir, hashed)
+ };
+ format!("url({quote}{relative}{quote})")
+ .as_bytes()
+ .to_owned()
+ } else {
+ captures[0].to_owned()
+ }
+ });
+ Cow::Owned(data.into_owned())
}
for static_file in &self.static_files {
match static_file {
@@ -317,4 +431,246 @@ mod tests {
let book_js_content = fs::read_to_string(temp_dir.path().join("book-e3b0c442.js")).unwrap();
assert_eq!("", book_js_content);
}
+
+ // ── helper ──────────────────────────────────────────────────────────────
+ /// Sets up a temp dir with a theme font at `theme/fonts/test-font.woff2`
+ /// and returns (temp_dir, theme).
+ fn setup_theme_with_font() -> (TempDir, Theme) {
+ let temp_dir = TempDir::with_prefix("mdbook-").unwrap();
+ let theme_dir = temp_dir.path().join("theme");
+ fs::create_dir_all(theme_dir.join("fonts")).unwrap();
+ fs::write(theme_dir.join("fonts/test-font.woff2"), b"font-data").unwrap();
+ let theme = Theme::new(&theme_dir);
+ (temp_dir, theme)
+ }
+
+ /// Reads the content of the single CSS file whose name starts with `prefix`
+ /// inside `dir`. Panics if none or more than one is found.
+ fn read_hashed_css(dir: &Path, prefix: &str) -> String {
+ let matches: Vec<_> = std::fs::read_dir(dir)
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .map(|e| e.file_name().to_string_lossy().to_string())
+ .filter(|name| name.starts_with(prefix) && name.ends_with(".css"))
+ .collect();
+ assert_eq!(
+ matches.len(),
+ 1,
+ "expected exactly one {prefix}*.css, got {matches:?}"
+ );
+ fs::read_to_string(dir.join(&matches[0])).unwrap()
+ }
+
+ // ── unit tests for URL helper functions ─────────────────────────────────
+
+ #[test]
+ fn test_url_parent_dir() {
+ assert_eq!(url_parent_dir("css/custom.css"), "css");
+ assert_eq!(url_parent_dir("a/b/c.css"), "a/b");
+ assert_eq!(url_parent_dir("custom.css"), "");
+ assert_eq!(url_parent_dir(""), "");
+ }
+
+ #[test]
+ fn test_normalize_url_path() {
+ assert_eq!(normalize_url_path("fonts/test.woff2"), "fonts/test.woff2");
+ assert_eq!(
+ normalize_url_path("css/../fonts/test.woff2"),
+ "fonts/test.woff2"
+ );
+ assert_eq!(normalize_url_path("a/b/../../c"), "c");
+ assert_eq!(normalize_url_path("./fonts/test.woff2"), "fonts/test.woff2");
+ assert_eq!(normalize_url_path("fonts//test.woff2"), "fonts/test.woff2");
+ }
+
+ #[test]
+ fn test_make_url_relative() {
+ // CSS in css/, font in fonts/ → go up one, then into fonts/
+ assert_eq!(
+ make_url_relative("css", "fonts/test-abc.woff2"),
+ "../fonts/test-abc.woff2"
+ );
+ // CSS in fonts/, font also in fonts/ → same directory
+ assert_eq!(
+ make_url_relative("fonts", "fonts/test-abc.woff2"),
+ "test-abc.woff2"
+ );
+ // CSS in a/b/, font in a/c/ → sibling directory
+ assert_eq!(make_url_relative("a/b", "a/c/d.woff2"), "../c/d.woff2");
+ // Same directory, different file
+ assert_eq!(make_url_relative("css", "css/other.css"), "other.css");
+ }
+
+ // ── integration tests for CSS url() rewriting ────────────────────────────
+
+ /// Regression test for https://github.com/rust-lang/mdBook/issues/2958:
+ /// a root-level CSS with a double-quoted url() reference to a custom font.
+ #[test]
+ fn test_css_url_double_quoted() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let custom_css = Path::new("custom.css");
+ fs::write(
+ temp_dir.path().join(custom_css),
+ br#"@font-face { src: url("fonts/test-font.woff2") format("woff2"); }"#,
+ )
+ .unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(temp_dir.path(), "custom-");
+ assert!(
+ content.contains("url(\"fonts/test-font-"),
+ "url not rewritten: {content}"
+ );
+ assert!(!content.contains("url(\"fonts/test-font.woff2\")"));
+ }
+
+ /// Single-quoted `url('…')` references are rewritten.
+ #[test]
+ fn test_css_url_single_quoted() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let custom_css = Path::new("custom.css");
+ fs::write(
+ temp_dir.path().join(custom_css),
+ br#"@font-face { src: url('fonts/test-font.woff2') format('woff2'); }"#,
+ )
+ .unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(temp_dir.path(), "custom-");
+ assert!(
+ content.contains("url('fonts/test-font-"),
+ "url not rewritten: {content}"
+ );
+ assert!(!content.contains("url('fonts/test-font.woff2')"));
+ }
+
+ /// Unquoted `url(…)` references are rewritten.
+ #[test]
+ fn test_css_url_unquoted() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let custom_css = Path::new("custom.css");
+ fs::write(
+ temp_dir.path().join(custom_css),
+ b"@font-face { src: url(fonts/test-font.woff2); }",
+ )
+ .unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(temp_dir.path(), "custom-");
+ assert!(
+ content.contains("url(fonts/test-font-"),
+ "url not rewritten: {content}"
+ );
+ assert!(!content.contains("url(fonts/test-font.woff2)"));
+ }
+
+ /// A CSS file in a subdirectory uses a path relative to itself (`../fonts/…`).
+ /// The rewritten URL must also be relative to the CSS file's location.
+ #[test]
+ fn test_css_url_subdirectory() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let custom_css = Path::new("css/custom.css");
+ fs::create_dir_all(temp_dir.path().join("css")).unwrap();
+ // Correct relative path from css/ to fonts/ is ../fonts/
+ fs::write(
+ temp_dir.path().join(custom_css),
+ br#"@font-face { src: url("../fonts/test-font.woff2") format("woff2"); }"#,
+ )
+ .unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(&temp_dir.path().join("css"), "custom-");
+ // The rewritten URL must still be relative to css/, so ../fonts/test-font-.woff2
+ assert!(
+ content.contains("url(\"../fonts/test-font-"),
+ "url not rewritten: {content}"
+ );
+ assert!(!content.contains("url(\"../fonts/test-font.woff2\")"));
+ }
+
+ /// Absolute URLs (`https://`, `data:`, `//`, `/`) are left untouched.
+ #[test]
+ fn test_css_url_absolute_unchanged() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let css_content = concat!(
+ r#"@import url("https://fonts.googleapis.com/css2?family=Test");"#,
+ "\n",
+ r#"div { background: url("//cdn.example.com/img.png"); }"#,
+ "\n",
+ r#"div { background: url("/absolute/path.png"); }"#,
+ "\n",
+ r#"div { background: url("data:image/png;base64,abc"); }"#,
+ );
+
+ let custom_css = Path::new("custom.css");
+ fs::write(temp_dir.path().join(custom_css), css_content.as_bytes()).unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(temp_dir.path(), "custom-");
+ assert!(content.contains("url(\"https://fonts.googleapis.com/css2?family=Test\")"));
+ assert!(content.contains("url(\"//cdn.example.com/img.png\")"));
+ assert!(content.contains("url(\"/absolute/path.png\")"));
+ assert!(content.contains("url(\"data:image/png;base64,abc\")"));
+ }
+
+ /// A `url()` path that is not a hashed asset (not in the hash map) is left untouched.
+ #[test]
+ fn test_css_url_unknown_path_unchanged() {
+ let (temp_dir, theme) = setup_theme_with_font();
+
+ let custom_css = Path::new("custom.css");
+ fs::write(
+ temp_dir.path().join(custom_css),
+ br#"div { background: url("images/bg.png"); }"#,
+ )
+ .unwrap();
+
+ let mut html_config = HtmlConfig::default();
+ html_config.additional_css.push(custom_css.to_owned());
+
+ let mut sf = StaticFiles::new(&theme, &html_config, temp_dir.path()).unwrap();
+ sf.hash_files().unwrap();
+ sf.write_files(temp_dir.path()).unwrap();
+
+ let content = read_hashed_css(temp_dir.path(), "custom-");
+ assert!(
+ content.contains("url(\"images/bg.png\")"),
+ "should be unchanged: {content}"
+ );
+ }
}