diff --git a/crates/core/src/assets/mod.rs b/crates/core/src/assets/mod.rs index 19d6c79e..e00ddc56 100644 --- a/crates/core/src/assets/mod.rs +++ b/crates/core/src/assets/mod.rs @@ -193,6 +193,42 @@ impl<'a> AssetResolver<'a> { paths.join(", ") ))); } + // No on-disk source: fall back to the plugin's embedded default + // (if any), written as a real file so it is copied + linked like + // any other asset instead of being inlined. + if let Some(embedded) = asset_config.default_content { + let rel = embedded.name.to_string(); + if let Some(prev) = seen_relative_paths.get(&rel) { + return Err(RheoError::project_config(format!( + "asset path collision: output '{}' would be written by both '{}' and the embedded default for '{}'", + rel, + prev.display(), + asset_config.name + ))); + } + let dest = self.plugin_output_dir.join(&rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + RheoError::io( + e, + format!("creating directory for embedded default '{}'", rel), + ) + })?; + } + std::fs::write(&dest, embedded.content).map_err(|e| { + RheoError::io(e, format!("writing embedded default asset to {:?}", dest)) + })?; + seen_relative_paths.insert(rel.clone(), dest.clone()); + resolved.insert( + asset_config.name, + vec![Asset { + config: asset_config.clone(), + source_path: dest.clone(), + resolved_path: dest.clone(), + built_relative_path: rel, + }], + ); + } continue; } @@ -360,6 +396,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let section = PluginSection::default(); @@ -387,6 +424,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let mut asset_extra = toml::map::Map::new(); @@ -423,6 +461,7 @@ mod tests { name: "missing_asset", default_path: "nonexistent.css", required: true, + default_content: None, }], }; let section = PluginSection::default(); @@ -450,6 +489,7 @@ mod tests { name: "optional_asset", default_path: "nonexistent.css", required: false, + default_content: None, }], }; let section = PluginSection::default(); @@ -479,6 +519,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let mut asset_extra = toml::map::Map::new(); @@ -522,6 +563,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; @@ -570,6 +612,7 @@ mod tests { name: "missing_asset", default_path: "nonexistent.css", required: true, + default_content: None, }], }; let section = PluginSection::default(); @@ -599,6 +642,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: true, + default_content: None, }], }; @@ -652,6 +696,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; @@ -714,6 +759,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; @@ -758,6 +804,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; @@ -799,6 +846,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; @@ -851,6 +899,7 @@ mod tests { name: "js_scripts", default_path: "script.js", required: false, + default_content: None, }], }; @@ -897,6 +946,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let section = PluginSection::default(); @@ -939,6 +989,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let section = PluginSection::default(); @@ -984,6 +1035,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; let mut user_extra = toml::map::Map::new(); @@ -1035,6 +1087,7 @@ mod tests { name: "css_stylesheet", default_path: "style.css", required: false, + default_content: None, }], }; // User declares custom.css for dest "pkg" diff --git a/crates/core/src/build.rs b/crates/core/src/build.rs index aa8a8eac..0fe4be82 100644 --- a/crates/core/src/build.rs +++ b/crates/core/src/build.rs @@ -320,8 +320,6 @@ impl Build { // Inject CSS/JS link tags into each HTML entry in memory. let needs_injection = !css_paths.is_empty() || !js_paths.is_empty(); if needs_injection { - let css_refs: Vec<&str> = css_paths.iter().map(|s| s.as_str()).collect(); - let js_refs: Vec<&str> = js_paths.iter().map(|s| s.as_str()).collect(); let injected: Result = virtual_fs .into_iter() .map(|(vpath, bytes)| { @@ -329,7 +327,15 @@ impl Build { if path_str.ends_with(".html") { let html = String::from_utf8_lossy(&bytes); let mut dom = crate::util::html::HtmlDom::parse(&html)?; - dom.inject_head_links(&[], &css_refs, &js_refs)?; + // Depth-relative asset refs so nested pages resolve them. + let prefix = crate::util::html::depth_prefix(&path_str); + let css_refs: Vec = + css_paths.iter().map(|s| format!("{prefix}{s}")).collect(); + let js_refs: Vec = + js_paths.iter().map(|s| format!("{prefix}{s}")).collect(); + let css: Vec<&str> = css_refs.iter().map(|s| s.as_str()).collect(); + let js: Vec<&str> = js_refs.iter().map(|s| s.as_str()).collect(); + dom.inject_head_links(&[], &css, &js)?; let modified = dom.serialize()?; Ok((vpath, typst::foundations::Bytes::new(modified.into_bytes()))) } else { diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 6896c6f0..5ad75758 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -34,8 +34,8 @@ pub use config::{AssetsField, PluginAssets, PluginSection, Spine}; // Plugin trait and context pub use parser::RheoValue; pub use plugins::{ - AssetConfig, CastVertebra, FormatInitTemplate, FormatPlugin, OpenHandle, PackageAssets, - PluginContext, ResolvedPackage, ServerHandle, SpineLayoutKind, TypstFormat, + AssetConfig, CastVertebra, EmbeddedDefault, FormatInitTemplate, FormatPlugin, OpenHandle, + PackageAssets, PluginContext, ResolvedPackage, ServerHandle, SpineLayoutKind, TypstFormat, }; // HTML/PDF export utilities diff --git a/crates/core/src/plugins/mod.rs b/crates/core/src/plugins/mod.rs index 264f1a92..1716ad0e 100644 --- a/crates/core/src/plugins/mod.rs +++ b/crates/core/src/plugins/mod.rs @@ -33,6 +33,18 @@ pub enum OpenHandle { use crate::config::PluginAssets; +/// An embedded fallback for an asset: content the plugin ships built in, written +/// into the output directory (under `name`) when no on-disk source resolves, then +/// linked like any copied asset. Lets a plugin ship a default (e.g. the HTML +/// default stylesheet) as a real linked file rather than inlining it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EmbeddedDefault { + /// Output filename (relative to the plugin output dir) to write the content as. + pub name: &'static str, + /// The verbatim bytes to write. + pub content: &'static str, +} + /// Declares an additional non-Typst input file needed from the project directory. #[derive(Debug, Clone)] pub struct AssetConfig { @@ -43,6 +55,9 @@ pub struct AssetConfig { pub default_path: &'static str, /// If true, a missing file is a compile error; if false, it is absent from ctx.inputs pub required: bool, + /// Embedded fallback written to the output dir and linked when no on-disk + /// source (default path or override) resolves. `None` = no fallback. + pub default_content: Option, } #[derive(Debug, Clone)] diff --git a/crates/core/src/util/html.rs b/crates/core/src/util/html.rs index 1592f782..58c729ce 100644 --- a/crates/core/src/util/html.rs +++ b/crates/core/src/util/html.rs @@ -120,29 +120,12 @@ impl HtmlDom { Ok(()) } - /// Embed CSS content directly into the HTML `` as `"); - } - - if let Some(pos) = html.find("") { - let mut result = String::with_capacity(html.len() + styles.len()); - result.push_str(&html[..pos]); - result.push_str(&styles); - result.push_str(&html[pos..]); - Ok(result) - } else { - Err(RheoError::HtmlGeneration { - count: 1, - errors: "HTML document does not contain a element".to_string(), - }) - } +/// The `../` prefix that makes a build-root asset ref resolve from a page at the +/// given output path. `index.html` → `""`; `chapters/ch1.html` → `"../"`; +/// `a/b/c.html` → `"../../"`. Assets are written relative to the plugin output +/// root, so a page one directory deep must climb one level to reach them. +pub fn depth_prefix(output_rel_path: &str) -> String { + "../".repeat(output_rel_path.matches('/').count()) } // ─── Tests ──────────────────────────────────────────────────────────────────── @@ -549,70 +496,11 @@ mod tests { assert!(serialized.contains("")); } - // inject_inline_styles tests (free fn — kept for backwards compat) - - #[test] - fn test_inject_inline_styles_basic() { - let html = ""; - let result = inject_inline_styles(html, &["body { color: red; }"]).unwrap(); - assert!(result.contains("")); - } - #[test] - fn test_inject_inline_styles_empty() { - let html = ""; - let result = inject_inline_styles(html, &[]).unwrap(); - assert_eq!(result, html); - } - - #[test] - fn test_inject_inline_styles_no_head() { - let html = ""; - let result = inject_inline_styles(html, &["body {}"]); - assert!(result.is_err()); - } - - // HtmlDom::inject_inline_styles tests - - #[test] - fn test_dom_inject_inline_styles_basic() { - let html = ""; - let mut dom = HtmlDom::parse(html).unwrap(); - dom.inject_inline_styles(&["body { color: red; }"]).unwrap(); - let result = dom.serialize().unwrap(); - assert!(result.contains("")); - } - - #[test] - fn test_dom_inject_inline_styles_raw_chars_unescaped() { - let html = ""; - let mut dom = HtmlDom::parse(html).unwrap(); - dom.inject_inline_styles(&["p > span { content: \"a & b\"; }"]) - .unwrap(); - let result = dom.serialize().unwrap(); - assert!(result.contains("p > span { content: \"a & b\"; }")); - assert!(!result.contains(">")); - assert!(!result.contains("&")); - } - - #[test] - fn test_dom_inject_inline_styles_empty() { - let html = ""; - let mut dom = HtmlDom::parse(html).unwrap(); - dom.inject_inline_styles(&[]).unwrap(); - let result = dom.serialize().unwrap(); - assert!(!result.contains("")); - assert!(result.contains("")); + fn test_depth_prefix() { + assert_eq!(depth_prefix("index.html"), ""); + assert_eq!(depth_prefix("chapters/ch1.html"), "../"); + assert_eq!(depth_prefix("a/b/c.html"), "../../"); } // inject_head_links tests (via HtmlDom) diff --git a/crates/html/src/lib.rs b/crates/html/src/lib.rs index f1fc6ced..05055ce0 100644 --- a/crates/html/src/lib.rs +++ b/crates/html/src/lib.rs @@ -7,9 +7,13 @@ use serde::Deserialize; /// Used when the project doesn't provide its own style.css. pub const DEFAULT_STYLESHEET: &str = include_str!("templates/style.css"); +/// Output filename for the bundled default stylesheet when no user CSS resolves. +/// Distinct from `style.css` so it never clashes with a user's own stylesheet. +pub const DEFAULT_STYLESHEET_NAME: &str = "rheo-default.css"; + use rheo_core::{ - AssetConfig, CastVertebra, FormatInitTemplate, FormatPlugin, OpenHandle, PluginContext, Result, - RheoError, ServerHandle, + AssetConfig, CastVertebra, EmbeddedDefault, FormatInitTemplate, FormatPlugin, OpenHandle, + PluginContext, Result, RheoError, ServerHandle, }; use std::path::Path; use tracing::{debug, info, warn}; @@ -93,11 +97,18 @@ impl FormatPlugin for HtmlPlugin { name: STYLESHEETS, default_path: "style.css", required: false, + // No user/override stylesheet → ship the built-in default as a + // real linked file (rheo-default.css) rather than an inline