···11use dyn_eq::DynEq;
22-use log::info;
32use rustc_hash::FxHashSet;
43use std::hash::Hash;
54use std::path::Path;
66-use std::process::Command;
77-use std::time::SystemTime;
85use std::{fs, path::PathBuf};
96107#[derive(Default)]
···1714 pub(crate) included_scripts: Vec<Script>,
18151916 pub(crate) assets_dir: PathBuf,
2020- pub(crate) tailwind_path: PathBuf,
2117}
22182319impl PageAssets {
2424- /// Add an image to the page assets, causing the file to be created in the output directory.
2020+ /// Add an image to the page assets, causing the file to be created in the output directory. The image is resolved relative to the current working directory.
2521 ///
2622 /// The image will not automatically be included in the page, but can be included through the `.url()` method on the returned `Image` object.
2723 ///
···5349 *image
5450 }
55515656- /// Add a script to the page assets, causing the file to be created in the output directory.
5252+ /// Add a script to the page assets, causing the file to be created in the output directory. The script is resolved relative to the current working directory.
5753 ///
5854 /// The script will not automatically be included in the page, but can be included through the `.url()` method on the returned `Script` object.
5955 /// Alternatively, a script can be included automatically using the [PageAssets::include_script] method instead.
···7571 script
7672 }
77737878- /// Include a script in the page
7474+ /// Include a script in the page. The script is resolved relative to the current working directory.
7975 ///
8076 /// This method will automatically include the script in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail.
8177 ///
···9591 self.included_scripts.push(script);
9692 }
97939898- /// Add a style to the page assets, causing the file to be created in the output directory.
9494+ /// Add a style to the page assets, causing the file to be created in the output directory. The style is resolved relative to the current working directory.
9595+ ///
9696+ /// The style will not automatically be included in the page, but can be included through the `.url()` method on the returned `Style` object.
9797+ /// Alternatively, a style can be included automatically using the [PageAssets::include_style] method instead.
9898+ ///
9999+ /// Subsequent calls to this method using the same path will return the same style, as such, the value returned by this method can be cloned and used multiple times without issue. this method is equivalent to calling `add_style_with_options` with the default `StyleOptions` and is purely provided for convenience.
100100+ pub fn add_style<P>(&mut self, style_path: P) -> Style
101101+ where
102102+ P: Into<PathBuf>,
103103+ {
104104+ self.add_style_with_options(style_path, StyleOptions::default())
105105+ }
106106+107107+ /// Add a style to the page assets, causing the file to be created in the output directory. The style is resolved relative to the current working directory.
99108 ///
100109 /// The style will not automatically be included in the page, but can be included through the `.url()` method on the returned `Style` object.
101110 ///
102102- /// Subsequent calls to this function using the same path will return the same style, as such, the value returned by this function can be cloned and used multiple times without issue.
103103- pub fn add_style<P>(&mut self, style_path: P, tailwind: bool) -> Style
111111+ /// Subsequent calls to this method using the same path will return the same style, as such, the value returned by this method can be cloned and used multiple times without issue.
112112+ pub fn add_style_with_options<P>(&mut self, style_path: P, options: StyleOptions) -> Style
104113 where
105114 P: Into<PathBuf>,
106115 {
107116 let path = style_path.into();
108117 let style = Style {
109118 path: path.clone(),
110110- tailwind,
111119 assets_dir: self.assets_dir.clone(),
112112- tailwind_path: self.tailwind_path.clone(),
113120 hash: calculate_hash(&path),
121121+ tailwind: options.tailwind,
114122 };
115123116124 self.styles.insert(style.clone());
···122130 ///
123131 /// This method will automatically include the style in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail.
124132 ///
125125- /// Subsequent calls to this function using the same path will result in the same style being included multiple times.
126126- pub fn include_style<P>(&mut self, style_path: P, tailwind: bool)
133133+ /// Subsequent calls to this method using the same path will result in the same style being included multiple times. This method is equivalent to calling `include_style_with_options` with the default `StyleOptions` and is purely provided for convenience.
134134+ pub fn include_style<P>(&mut self, style_path: P)
135135+ where
136136+ P: Into<PathBuf>,
137137+ {
138138+ self.include_style_with_options(style_path, StyleOptions::default())
139139+ }
140140+141141+ /// Include a style in the page
142142+ ///
143143+ /// This method will automatically include the style in the `<head>` of the page, if it exists. If the page does not include a `<head>` tag, at this time this method will silently fail.
144144+ ///
145145+ /// Subsequent calls to this method using the same path will result in the same style being included multiple times.
146146+ pub fn include_style_with_options<P>(&mut self, style_path: P, options: StyleOptions)
127147 where
128148 P: Into<PathBuf>,
129149 {
130150 let path = style_path.into();
131151 let style = Style {
132152 path: path.clone(),
133133- tailwind,
134153 assets_dir: self.assets_dir.clone(),
135135- tailwind_path: self.tailwind_path.clone(),
136154 hash: calculate_hash(&path),
155155+ tailwind: options.tailwind,
137156 };
138157139158 self.styles.insert(style.clone());
···158177 String::new()
159178 }
160179161161- fn final_file_name(&self) -> String {
162162- let file_stem = self.path().file_stem().unwrap().to_str().unwrap();
163163- let extension = self
164164- .path()
180180+ // TODO: I don't like these next two methods for scripts and styles, we should get this from Rolldown somehow, but I don't know how.
181181+ // Our architecture is such that bundling runs after pages, so we can't know the final extension until then. We can't, and I don't want
182182+ // to make it so we get assets beforehand because it'd make it less convenient and essentially cause us to act like a bundling framework.
183183+ //
184184+ // Perhaps it should be done as a post-processing step, like includes, but that'd require moving route finalization to after bundling,
185185+ // which I'm not sure I want to do either. Plus, it'd be pretty slow if you have a layout on every page that includes a style/script (a fairly common case).
186186+ //
187187+ // An additional benefit would with that would also be to be able to avoid generating hashes for these files, but that's a smaller win.
188188+ //
189189+ // I don't know! - erika, 2025-09-01
190190+191191+ fn final_extension(&self) -> String {
192192+ self.path()
165193 .extension()
166194 .map(|ext| ext.to_str().unwrap())
167167- .unwrap_or("");
195195+ .unwrap_or_default()
196196+ .to_owned()
197197+ }
198198+199199+ fn final_file_name(&self) -> String {
200200+ let file_stem = self.path().file_stem().unwrap().to_str().unwrap();
201201+ let extension = self.final_extension();
168202169203 if extension.is_empty() {
170204 format!("{}.{}", file_stem, self.hash())
···177211fn calculate_hash(path: &PathBuf) -> String {
178212 let content = fs::read(path).unwrap();
179213214214+ // TODO: Consider using xxhash for both performance and to match Rolldown's hashing
180215 let mut hasher = blake3::Hasher::new();
181216 hasher.update(&content);
182217 hasher.update(path.to_string_lossy().as_bytes());
···269304 fn hash(&self) -> String {
270305 self.hash.clone()
271306 }
307307+308308+ fn final_extension(&self) -> String {
309309+ let current_extension = self
310310+ .path()
311311+ .extension()
312312+ .and_then(|ext| ext.to_str())
313313+ .unwrap_or_default();
314314+315315+ match current_extension {
316316+ "ts" => "js",
317317+ ext => ext,
318318+ }
319319+ .to_string()
320320+ }
321321+}
322322+323323+#[derive(Clone, PartialEq, Eq, Hash, Default)]
324324+pub struct StyleOptions {
325325+ pub tailwind: bool,
272326}
273327274328#[derive(Clone, PartialEq, Eq, Hash)]
275329#[non_exhaustive]
276330pub struct Style {
277331 pub path: PathBuf,
278278- pub(crate) tailwind: bool,
279332 pub(crate) assets_dir: PathBuf,
280280- pub(crate) tailwind_path: PathBuf,
281333 pub(crate) hash: String,
334334+ pub(crate) tailwind: bool,
282335}
283336284337impl InternalAsset for Style {
···305358 self.hash.clone()
306359 }
307360308308- fn process(&self, _: &Path, tmp_dir: &Path) -> Option<String> {
309309- // TODO: Detect tailwind automatically
310310- if self.tailwind {
311311- let tmp_path = tmp_dir.join(self.path.file_name().unwrap());
312312- let tmp_path_str = tmp_path.to_str().unwrap().to_string();
313313-314314- let start_tailwind = SystemTime::now();
315315- let tailwind_output = Command::new(self.tailwind_path.clone())
316316- .args(["--input", self.path.to_str().unwrap()])
317317- .args(["--output", &tmp_path_str])
318318- .arg("--minify") // TODO: Allow disabling minification
319319- .output()
320320- .expect("failed to execute process");
321321-322322- info!("Tailwind took {:?}", start_tailwind.elapsed().unwrap());
323323-324324- if tailwind_output.status.success() {
325325- return Some(tmp_path_str);
326326- }
327327- }
328328-361361+ fn process(&self, _: &Path, _: &Path) -> Option<String> {
329362 None
330363 }
331364}
···351384 let temp_dir = setup_temp_dir();
352385 let mut page_assets = PageAssets {
353386 assets_dir: PathBuf::from("assets"),
354354- tailwind_path: PathBuf::from("tailwind"),
355387 ..Default::default()
356388 };
357357-358358- page_assets.add_style(temp_dir.join("style.css"), false);
389389+ page_assets.add_style(temp_dir.join("style.css"));
359390360391 assert!(page_assets.styles.len() == 1);
361392 }
···365396 let temp_dir = setup_temp_dir();
366397 let mut page_assets = PageAssets {
367398 assets_dir: PathBuf::from("assets"),
368368- tailwind_path: PathBuf::from("tailwind"),
369399 ..Default::default()
370400 };
371401372372- page_assets.include_style(temp_dir.join("style.css"), false);
402402+ page_assets.include_style(temp_dir.join("style.css"));
373403374404 assert!(page_assets.styles.len() == 1);
375405 assert!(page_assets.included_styles.len() == 1);
···380410 let temp_dir = setup_temp_dir();
381411 let mut page_assets = PageAssets {
382412 assets_dir: PathBuf::from("assets"),
383383- tailwind_path: PathBuf::from("tailwind"),
384413 ..Default::default()
385414 };
386415···393422 let temp_dir = setup_temp_dir();
394423 let mut page_assets = PageAssets {
395424 assets_dir: PathBuf::from("assets"),
396396- tailwind_path: PathBuf::from("tailwind"),
397425 ..Default::default()
398426 };
399427···408436 let temp_dir = setup_temp_dir();
409437 let mut page_assets = PageAssets {
410438 assets_dir: PathBuf::from("assets"),
411411- tailwind_path: PathBuf::from("tailwind"),
412439 ..Default::default()
413440 };
414441···421448 let temp_dir = setup_temp_dir();
422449 let mut page_assets = PageAssets {
423450 assets_dir: PathBuf::from("assets"),
424424- tailwind_path: PathBuf::from("tailwind"),
425451 ..Default::default()
426452 };
427453···431457 let script = page_assets.add_script(temp_dir.join("script.js"));
432458 assert_eq!(script.url().unwrap().chars().next(), Some('/'));
433459434434- let style = page_assets.add_style(temp_dir.join("style.css"), false);
460460+ let style = page_assets.add_style(temp_dir.join("style.css"));
435461 assert_eq!(style.url().unwrap().chars().next(), Some('/'));
436462 }
437463···440466 let temp_dir = setup_temp_dir();
441467 let mut page_assets = PageAssets {
442468 assets_dir: PathBuf::from("assets"),
443443- tailwind_path: PathBuf::from("tailwind"),
444469 ..Default::default()
445470 };
446471···452477 let script_hash = script.hash.clone();
453478 assert!(script.url().unwrap().contains(&script_hash));
454479455455- let style = page_assets.add_style(temp_dir.join("style.css"), false);
480480+ let style = page_assets.add_style(temp_dir.join("style.css"));
456481 let style_hash = style.hash.clone();
457482 assert!(style.url().unwrap().contains(&style_hash));
458483 }
···462487 let temp_dir = setup_temp_dir();
463488 let mut page_assets = PageAssets {
464489 assets_dir: PathBuf::from("assets"),
465465- tailwind_path: PathBuf::from("tailwind"),
466490 ..Default::default()
467491 };
468492···474498 let script_hash = script.hash.clone();
475499 assert!(script.build_path().to_string_lossy().contains(&script_hash));
476500477477- let style = page_assets.add_style(temp_dir.join("style.css"), false);
501501+ let style = page_assets.add_style(temp_dir.join("style.css"));
478502 let style_hash = style.hash.clone();
479503 assert!(style.build_path().to_string_lossy().contains(&style_hash));
480504 }
···3939 pub output_dir: String,
4040 pub assets_dir: String,
4141 pub static_dir: String,
4242+ /// Path to [the TailwindCSS CLI binary](https://tailwindcss.com/docs/installation/tailwind-cli). By default `tailwindcss`, which assumes you've installed it globally (for example, through Homebrew) and that it is in your `PATH`.
4343+ ///
4444+ /// This is commonly set to `./node_modules/.bin/tailwindcss` or similar, in order to use a locally installed version.
4245 pub tailwind_binary_path: String,
4346 /// Whether to clean the output directory before building.
4447 ///
···6871 output_dir: "dist".to_string(),
6972 assets_dir: "_maudit".to_string(),
7073 static_dir: "static".to_string(),
7171- tailwind_binary_path: "./node_modules/.bin/tailwindcss".to_string(),
7474+ tailwind_binary_path: "tailwindcss".to_string(),
7275 clean_output_dir: true,
7376 }
7477 }
+1-1
crates/maudit/src/lib.rs
···66//! </div>
7788// Modules the end-user will interact directly or indirectly with
99-mod assets;
99+pub mod assets;
1010pub mod content;
1111pub mod errors;
1212pub mod page;
+2-1
crates/maudit/src/page.rs
···260260 pub use super::{
261261 get_page_url, DynamicRouteContext, Page, RenderResult, RouteContext, RouteParams,
262262 };
263263+ // TODO: Remove this internal re-export when possible
263264 #[doc(hidden)]
264265 pub use super::{FullPage, InternalPage};
265265- pub use crate::assets::Asset;
266266+ pub use crate::assets::{Asset, Image, Style, StyleOptions};
266267 pub use crate::content::MarkdownContent;
267268 pub use maudit_macros::{route, Params};
268269}
···1111 fn render(&self, ctx: &mut RouteContext) -> RenderResult {
1212 let image = ctx.assets.add_image("data/logo.svg");
1313 let script = ctx.assets.add_script("data/some_other_script.js");
1414- let style = ctx.assets.add_style("data/tailwind.css", true);
1414+ let style = ctx
1515+ .assets
1616+ .add_style_with_options("data/tailwind.css", StyleOptions { tailwind: true });
15171618 let link_to_first_dynamic =
1719 get_page_url(&DynamicExample, &DynamicExampleParams { page: 1 });
+6-4
website/content/docs/styling.md
···19192020impl Page<RouteParams, Markup> for Blog {
2121 fn render(&self, ctx: &mut RouteContext) -> Markup {
2222- let style = ctx.assets.add_style("style.css", false);
2222+ let style = ctx.assets.add_style("style.css");
23232424 html! {
2525 (style) // Generates <link rel="stylesheet" href="STYLE_URL" />
···32323333```rs
3434fn render(&self, ctx: &mut RouteContext) -> Markup {
3535- ctx.assets.include_style("style.css", false);
3535+ ctx.assets.include_style("style.css");
36363737 layout(
3838 html! {
···46464747#### Tailwind support
48484949-Maudit includes built-in support for [Tailwind CSS](https://tailwindcss.com/). To use it, pass `true` as the second argument to `add_style()` or `include_style()`. In the future, Maudit will automatically detect Tailwind CSS and enable it when needed.
4949+Maudit includes built-in support for [Tailwind CSS](https://tailwindcss.com/). To use it, use `add_style_with_options()` or `include_style_with_options()` with the `StyleOptions { tailwind: true }` option.
50505151```rs
5252fn render(&self, ctx: &mut RouteContext) -> Markup {
5353- ctx.assets.add_style("style.css", true);
5353+ ctx.assets.add_style_with_options("style.css", StyleOptions { tailwind: true });
54545555 html! {
5656 div.bg-red-500 {
···5959 }
6060}
6161```
6262+6363+Maudit will automatically run Tailwind (using the binary provided at [`BuildOptions#tailwind_binary_path`](https://docs.rs/maudit/0.3.2/maudit/struct.BuildOptions.html#structfield.tailwind_binary_path)) on the specified CSS file.
62646365Tailwind can then be configured normally, through native CSS in Tailwind 4.0, or through a `tailwind.config.js` file in earlier versions.
6466