Rust library to generate static websites
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: a bunch of things again (#38)

* feat: a bunch of things again

* feat: rework assets options

* nit: comments

* refactor: unnecessary dir creation

* fix: doc

authored by

Erika and committed by
GitHub
063ed7bf d34e3444

+434 -526
-115
.sampo/README.md
··· 1 - # Sampo 2 - 3 - Automate changelogs, versioning, and publishing—even for monorepos across multiple package registries. Currently supported ecosystems: Rust ([Crates.io](https://crates.io))... And more coming soon! 4 - 5 - ## Getting Started 6 - 7 - Install Sampo using Cargo: 8 - 9 - ```bash 10 - cargo install sampo 11 - ``` 12 - 13 - Initialize Sampo in your repository: 14 - 15 - ```bash 16 - sampo init 17 - ``` 18 - 19 - This command creates a `.sampo` directory at your repository root: 20 - 21 - ``` 22 - .sampo/ 23 - ├─ changesets/ <- Individual changeset files describing pending changes 24 - ├─ config.toml <- Sampo configuration (package settings, registry options) 25 - └─ README.md <- A copy of this documentation 26 - ``` 27 - 28 - ### Main concepts 29 - 30 - **Version bump**: Sampo enforces [Semantic Versioning](https://semver.org/) (SemVer) to indicate the nature of changes in each release. Versions follow the `MAJOR.MINOR.PATCH` format where: 31 - - **patch**: Bug fixes and backwards-compatible changes 32 - - **minor**: New features that are backwards-compatible 33 - - **major**: Breaking changes that are not backwards-compatible 34 - 35 - For example, a user can safely update from version `1.2.3` to `1.2.4` (patch) or `1.3.0` (minor), but should review changes before updating to `2.0.0` (major). 36 - 37 - **Changeset**: A markdown file describing what changed and how to version affected packages. Each changeset specifies which packages to bump and if it should be a patch, minor, or major update. 38 - 39 - ``` 40 - --- 41 - packages: 42 - - example 43 - release: minor 44 - --- 45 - 46 - A helpful description of the changes. 47 - ``` 48 - 49 - **Changelog**: Automatically generated file listing all changes for each package version. Sampo consumes changesets to build comprehensive changelogs with semantic versioning. 50 - 51 - **Release**: The process of consuming changesets to bump package versions, update changelogs, and create git tags. Sampo works seamlessly with **monorepos** containing multiple packages and supports publishing to **multiple registries** across different ecosystems. 52 - 53 - **Internal dependencies**: Sampo detects packages within the same repository that depend on each other and automatically manages their versions. 54 - - By default, dependent packages are automatically patched when an internal dependency is updated. For example: if `a@0.1.0` depends on `b@0.1.0` and `b` is updated to `0.2.0`, then `a` will be automatically bumped to `0.1.1` (patch). If `a` needs a major or minor change due to `b`'s update, it should be explicitly specified in a changeset. 55 - - **Fixed dependencies** (see [configuration](#configuration)) always bump together with the same version, even if not directly affected. For example: if `a@1.0.0` and `b@1.0.0` are in a fixed group and `b` is updated to `2.0.0`, then `a` will also be bumped to `2.0.0`. 56 - - **Linked dependencies** (see [configuration](#configuration)) apply the highest bump level to affected packages and their dependents. For example: if `a@1.0.0` depends on `b@1.0.0` in a linked group and `b` is updated to `2.0.0` (major), then `a` will also be bumped to `2.0.0`. If `a` is later updated to `2.1.0` (minor), `b` remains at `2.0.0` since it's not affected. Finally, if `b` has a patch update, both `a` and `b` will be bumped with patch level (the highest in the group). 57 - 58 - ### Usage 59 - 60 - **Creating a changeset**: Use `sampo add` to create a new changeset file. The command guides you through selecting packages and describing changes. Use [Sampo GitHub bot](https://github.com/bruits/sampo/tree/main/crates/sampo-github-bot) to get reminders on each PR without a changeset. 61 - 62 - **Consuming changesets**: Run `sampo release` to process all pending changesets, bump package versions, and update changelogs. This can be automated in CI/CD pipelines using [Sampo GitHub Action](../sampo-github-action). 63 - 64 - As long as the release is not finalized, you can continue to add changesets and re-run the `sampo release` command. Sampo will update package versions and pending changelogs accordingly. 65 - 66 - **Publishing**: After running `sampo release`, use `sampo publish` to publish updated packages to their respective registries and tag the current versions. This step can also be automated in CI/CD pipelines using [Sampo GitHub Action](../sampo-github-action). 67 - 68 - ## Configuration 69 - 70 - The `.sampo/config.toml` file allows you to customize Sampo's behavior. Example configuration: 71 - 72 - ```toml 73 - [github] 74 - repository = "owner/repo" 75 - 76 - [changelog] 77 - show_commit_hash = true 78 - show_acknowledgments = true 79 - 80 - [packages] 81 - fixed_dependencies = [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]] 82 - linked_dependencies = [["pkg-e", "pkg-f"], ["pkg-g", "pkg-h"]] 83 - ``` 84 - 85 - ### `[github]` section 86 - 87 - `repository`: The GitHub repository slug in the format "owner/repo". If not set, Sampo uses the `GITHUB_REPOSITORY` environment variable or attempts to detect it from the `origin` git remote. This setting is used to enrich changelog messages with commit hash links and author acknowledgments, especially for first-time contributors. 88 - 89 - ### `[changelog]` section 90 - 91 - `show_commit_hash`: Whether to include commit hash links in changelog entries (default: `true`). When enabled, changelog entries include clickable commit hash links that point to the commit on GitHub. 92 - 93 - `show_acknowledgments`: Whether to include author acknowledgments in changelog entries (default: `true`). When enabled, changelog entries include author acknowledgments with special messages for first-time contributors. 94 - 95 - ### `[packages]` section 96 - 97 - `fixed_dependencies`: An array of dependency groups (default: `[]`) where packages in each group are bumped together with the same version level. Each group is an array of package names. When any package in a group is updated, all other packages in the same group receive the same version bump, regardless of actual dependencies. For example: if `fixed_dependencies = [["a", "b"], ["c", "d"]]` and `a` is updated to `2.0.0` (major), then `b` will also be bumped to `2.0.0`, but `c` and `d` remain unchanged. 98 - 99 - `linked_dependencies`: An array of dependency groups (default: `[]`) where affected packages and their dependents are bumped together using the highest bump level in the group. Each group is an array of package names. When any package in a group is updated, all packages in the same group that are affected or have internal dependencies within the group receive the highest version bump level from the group. For example: if `linked_dependencies = [["a", "b"]]` where `a` depends on `b`, when `b` is updated to `2.0.0` (major), then `a` will also be bumped to `2.0.0`. If `a` is later updated to `2.1.0` (minor), `b` remains at `2.0.0` since it's not affected. Finally, if `b` has a patch update, both `a` and `b` will be bumped with patch level since it's the highest bump in the group. 100 - 101 - Note: Packages cannot appear in both `fixed_dependencies` and `linked_dependencies` configurations. 102 - 103 - ## Commands 104 - 105 - All commands should be run from the root of the repository: 106 - 107 - | Command | Description | 108 - | --------------- | ------------------------------------------------------------------------- | 109 - | `sampo help` | Show commands or the help of the given subcommand(s) | 110 - | `sampo init` | Initialize Sampo in the current repository | 111 - | `sampo add` | Create a new changeset | 112 - | `sampo release` | Consume changesets, and prepare release(s) (bump versions and changelogs) | 113 - | `sampo publish` | Publish packages to registries and tag current versions | 114 - 115 - For detailed command options, use `sampo help <command>` or `sampo <command> --help`.
-4
Cargo.toml
··· 7 7 oubli = { path = "crates/oubli", version = "*" } 8 8 maud = { version = "0.26.0" } 9 9 serde = { version = "1.0.216" } 10 - 11 - [profile.dev] 12 - debug = 0 13 - strip = "debuginfo"
+3
benchmarks/README.md
··· 1 + # Benchmarks 2 + 3 + This directory contains various benchmarks for Maudit.
+101 -83
crates/maudit/src/assets.rs
··· 1 - use ::image::image_dimensions; 2 1 use dyn_eq::DynEq; 3 2 use log::debug; 4 3 use rustc_hash::FxHashSet; ··· 15 14 pub use script::Script; 16 15 pub use style::{Style, StyleOptions}; 17 16 17 + use crate::AssetHashingStrategy; 18 + use crate::build::options::AssetsOptions; 19 + 18 20 #[derive(Default)] 19 21 pub struct PageAssets { 20 22 pub images: FxHashSet<Image>, 21 23 pub scripts: FxHashSet<Script>, 22 24 pub styles: FxHashSet<Style>, 23 25 24 - pub(crate) assets_dir: PathBuf, 26 + pub(crate) options: PageAssetsOptions, 27 + } 28 + 29 + #[derive(Clone)] 30 + pub struct PageAssetsOptions { 31 + pub assets_dir: PathBuf, 32 + pub hashing_strategy: AssetHashingStrategy, 33 + } 34 + 35 + impl Default for PageAssetsOptions { 36 + fn default() -> Self { 37 + let default_assets_options = AssetsOptions::default(); 38 + Self { 39 + assets_dir: default_assets_options.assets_dir, 40 + hashing_strategy: default_assets_options.hashing_strategy, 41 + } 42 + } 25 43 } 26 44 27 45 impl PageAssets { 28 - pub fn new(assets_dir: PathBuf) -> Self { 46 + pub fn new(assets_options: &PageAssetsOptions) -> Self { 29 47 Self { 30 - assets_dir, 48 + options: assets_options.clone(), 31 49 ..Default::default() 32 50 } 33 51 } ··· 71 89 return image.clone(); 72 90 } 73 91 74 - let (width, height) = image_dimensions(&image_path).unwrap_or((0, 0)); 75 - 76 92 let image = Image { 77 93 path: image_path.clone(), 78 - width, 79 - height, 80 - assets_dir: self.assets_dir.clone(), 81 - // TODO: This is gonna re-read the file, even though we already had to in order to get dimensions, perhaps we can re-use the same data? 82 - hash: calculate_hash(&image_path, Some(HashConfig::Image(&options))), 94 + assets_dir: self.options.assets_dir.clone(), 95 + hash: calculate_hash( 96 + &image_path, 97 + Some(&HashConfig { 98 + asset_type: HashAssetType::Image(&options), 99 + hashing_strategy: &self.options.hashing_strategy, 100 + }), 101 + ), 83 102 options: if options == ImageOptions::default() { 84 103 None 85 104 } else { ··· 112 131 let path = script_path.into(); 113 132 let script = Script { 114 133 path: path.clone(), 115 - assets_dir: self.assets_dir.clone(), 134 + assets_dir: self.options.assets_dir.clone(), 116 135 hash: calculate_hash(&path, None), 117 136 included: false, 118 137 }; ··· 134 153 let path = script_path.into(); 135 154 let script = Script { 136 155 path: path.clone(), 137 - assets_dir: self.assets_dir.clone(), 156 + assets_dir: self.options.assets_dir.clone(), 138 157 hash: calculate_hash(&path, None), 139 158 included: true, 140 159 }; ··· 167 186 let path = style_path.into(); 168 187 let style = Style { 169 188 path: path.clone(), 170 - assets_dir: self.assets_dir.clone(), 171 - hash: calculate_hash(&path, Some(HashConfig::Style(&options))), 189 + assets_dir: self.options.assets_dir.clone(), 190 + hash: calculate_hash( 191 + &path, 192 + Some(&HashConfig { 193 + asset_type: HashAssetType::Style(&options), 194 + hashing_strategy: &self.options.hashing_strategy, 195 + }), 196 + ), 172 197 tailwind: options.tailwind, 173 198 included: false, 174 199 }; ··· 200 225 P: Into<PathBuf>, 201 226 { 202 227 let path = style_path.into(); 203 - let hash = calculate_hash(&path, Some(HashConfig::Style(&options))); 228 + let hash = calculate_hash( 229 + &path, 230 + Some(&HashConfig { 231 + asset_type: HashAssetType::Style(&options), 232 + hashing_strategy: &self.options.hashing_strategy, 233 + }), 234 + ); 204 235 let style = Style { 205 236 path: path.clone(), 206 - assets_dir: self.assets_dir.clone(), 237 + assets_dir: self.options.assets_dir.clone(), 207 238 hash, 208 239 tailwind: options.tailwind, 209 240 included: true, ··· 221 252 fn url(&self) -> Option<String>; 222 253 fn path(&self) -> &PathBuf; 223 254 224 - fn hash(&self) -> String { 225 - // This will be overridden by each implementation to return the cached hash 226 - String::new() 227 - } 255 + fn hash(&self) -> String; 228 256 229 257 // 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. 230 258 // 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 ··· 257 285 } 258 286 } 259 287 260 - enum HashConfig<'a> { 288 + struct HashConfig<'a> { 289 + asset_type: HashAssetType<'a>, 290 + hashing_strategy: &'a AssetHashingStrategy, 291 + } 292 + 293 + enum HashAssetType<'a> { 261 294 Image(&'a ImageOptions), 262 295 Style(&'a StyleOptions), 263 296 } 264 297 265 - fn calculate_hash(path: &PathBuf, options: Option<HashConfig>) -> String { 298 + fn calculate_hash(path: &PathBuf, options: Option<&HashConfig>) -> String { 266 299 let start_time = Instant::now(); 267 - let content = 268 - fs::read(path).unwrap_or_else(|_| panic!("Failed to read asset file: {:?}", path)); 300 + let content = if options 301 + .is_some_and(|cfg| *cfg.hashing_strategy == AssetHashingStrategy::FastImprecise) 302 + { 303 + let metadata = fs::metadata(path).unwrap(); 304 + 305 + let mut buf = Vec::with_capacity(16); 306 + buf.extend_from_slice( 307 + &metadata 308 + .modified() 309 + .unwrap() 310 + .duration_since(std::time::UNIX_EPOCH) 311 + .unwrap() 312 + .as_secs() 313 + .to_le_bytes(), 314 + ); 315 + 316 + buf.extend_from_slice(&metadata.len().to_le_bytes()); 317 + 318 + buf 319 + } else { 320 + fs::read(path).unwrap_or_else(|_| panic!("Failed to read asset file: {:?}", path)) 321 + }; 269 322 270 323 // Pre-allocate a single buffer to hash at once 271 324 let mut buf = Vec::with_capacity(content.len() + 256); ··· 273 326 buf.extend_from_slice(path.to_string_lossy().as_bytes()); 274 327 275 328 if let Some(options) = options { 276 - match options { 277 - HashConfig::Image(opts) => { 329 + match options.asset_type { 330 + HashAssetType::Image(opts) => { 278 331 if let Some(width) = opts.width { 279 332 buf.extend_from_slice(&width.to_le_bytes()); 280 333 } ··· 285 338 buf.extend_from_slice(&format.to_hash_value().to_le_bytes()); 286 339 } 287 340 } 288 - HashConfig::Style(opts) => { 341 + HashAssetType::Style(opts) => { 289 342 buf.push(opts.tailwind as u8); 290 343 } 291 344 } ··· 305 358 } 306 359 307 360 trait InternalAsset { 308 - fn assets_dir(&self) -> PathBuf; 361 + fn assets_dir(&self) -> &PathBuf; 309 362 } 310 363 311 364 impl Hash for dyn Asset { ··· 335 388 #[test] 336 389 fn test_add_style() { 337 390 let temp_dir = setup_temp_dir(); 338 - let mut page_assets = PageAssets { 339 - assets_dir: PathBuf::from("assets"), 340 - ..Default::default() 341 - }; 391 + let mut page_assets = PageAssets::default(); 342 392 page_assets.add_style(temp_dir.join("style.css")); 343 393 344 394 assert!(page_assets.styles.len() == 1); ··· 347 397 #[test] 348 398 fn test_include_style() { 349 399 let temp_dir = setup_temp_dir(); 350 - let mut page_assets = PageAssets { 351 - assets_dir: PathBuf::from("assets"), 352 - ..Default::default() 353 - }; 400 + let mut page_assets = PageAssets::default(); 354 401 355 402 page_assets.include_style(temp_dir.join("style.css")); 356 403 ··· 361 408 #[test] 362 409 fn test_add_script() { 363 410 let temp_dir = setup_temp_dir(); 364 - let mut page_assets = PageAssets { 365 - assets_dir: PathBuf::from("assets"), 366 - ..Default::default() 367 - }; 411 + let mut page_assets = PageAssets::default(); 368 412 369 413 page_assets.add_script(temp_dir.join("script.js")); 370 414 assert!(page_assets.scripts.len() == 1); ··· 373 417 #[test] 374 418 fn test_include_script() { 375 419 let temp_dir = setup_temp_dir(); 376 - let mut page_assets = PageAssets { 377 - assets_dir: PathBuf::from("assets"), 378 - ..Default::default() 379 - }; 420 + let mut page_assets = PageAssets::default(); 380 421 381 422 page_assets.include_script(temp_dir.join("script.js")); 382 423 ··· 387 428 #[test] 388 429 fn test_add_image() { 389 430 let temp_dir = setup_temp_dir(); 390 - let mut page_assets = PageAssets { 391 - assets_dir: PathBuf::from("assets"), 392 - ..Default::default() 393 - }; 431 + let mut page_assets = PageAssets::default(); 394 432 395 433 page_assets.add_image(temp_dir.join("image.png")); 396 434 assert!(page_assets.images.len() == 1); ··· 399 437 #[test] 400 438 fn test_asset_has_leading_slash() { 401 439 let temp_dir = setup_temp_dir(); 402 - let mut page_assets = PageAssets { 403 - assets_dir: PathBuf::from("assets"), 404 - ..Default::default() 405 - }; 440 + let mut page_assets = PageAssets::default(); 406 441 407 442 let image = page_assets.add_image(temp_dir.join("image.png")); 408 443 assert_eq!(image.url().unwrap().chars().next(), Some('/')); ··· 417 452 #[test] 418 453 fn test_asset_url_include_hash() { 419 454 let temp_dir = setup_temp_dir(); 420 - let mut page_assets = PageAssets { 421 - assets_dir: PathBuf::from("assets"), 422 - ..Default::default() 423 - }; 455 + let mut page_assets = PageAssets::default(); 424 456 425 457 let image = page_assets.add_image(temp_dir.join("image.png")); 426 458 let image_hash = image.hash.clone(); ··· 438 470 #[test] 439 471 fn test_asset_path_include_hash() { 440 472 let temp_dir = setup_temp_dir(); 441 - let mut page_assets = PageAssets { 442 - assets_dir: PathBuf::from("assets"), 443 - ..Default::default() 444 - }; 473 + let mut page_assets = PageAssets::default(); 445 474 446 475 let image = page_assets.add_image(temp_dir.join("image.png")); 447 476 let image_hash = image.hash.clone(); ··· 471 500 ]; 472 501 std::fs::write(&image_path, png_data).unwrap(); 473 502 474 - let mut page_assets = PageAssets { 475 - assets_dir: PathBuf::from("assets"), 476 - ..Default::default() 477 - }; 503 + let mut page_assets = PageAssets::default(); 478 504 479 505 // Test that different options produce different hashes 480 506 let image_default = page_assets.add_image(&image_path); ··· 537 563 ]; 538 564 std::fs::write(&image_path, png_data).unwrap(); 539 565 540 - let mut page_assets = PageAssets { 541 - assets_dir: PathBuf::from("assets"), 542 - ..Default::default() 543 - }; 566 + let mut page_assets = PageAssets::default(); 544 567 545 568 // Same options should produce same hash 546 569 let image1 = page_assets.add_image_with_options( ··· 572 595 let temp_dir = setup_temp_dir(); 573 596 let style_path = temp_dir.join("style.css"); 574 597 575 - let mut page_assets = PageAssets { 576 - assets_dir: PathBuf::from("assets"), 577 - ..Default::default() 578 - }; 598 + let mut page_assets = PageAssets::new(&PageAssetsOptions::default()); 579 599 580 600 // Test that different tailwind options produce different hashes 581 601 let style_default = page_assets.add_style(&style_path); ··· 600 620 std::fs::write(&style1_path, content).unwrap(); 601 621 std::fs::write(&style2_path, content).unwrap(); 602 622 603 - let mut page_assets = PageAssets { 604 - assets_dir: PathBuf::from("assets"), 605 - ..Default::default() 606 - }; 623 + let mut page_assets = PageAssets::new(&PageAssetsOptions::default()); 607 624 608 625 let style1 = page_assets.add_style(&style1_path); 609 626 let style2 = page_assets.add_style(&style2_path); ··· 619 636 let temp_dir = setup_temp_dir(); 620 637 let style_path = temp_dir.join("dynamic_style.css"); 621 638 622 - let mut page_assets = PageAssets { 623 - assets_dir: PathBuf::from("assets"), 624 - ..Default::default() 625 - }; 639 + let assets_options = PageAssetsOptions::default(); 640 + let mut page_assets = PageAssets::new(&assets_options); 626 641 627 642 // Write first content and get hash 628 643 std::fs::write(&style_path, "body { background: red; }").unwrap(); ··· 633 648 std::fs::write(&style_path, "body { background: green; }").unwrap(); 634 649 let style2 = Style { 635 650 path: style_path.clone(), 636 - assets_dir: PathBuf::from("assets"), 651 + assets_dir: assets_options.assets_dir.clone(), 637 652 hash: calculate_hash( 638 653 &style_path, 639 - Some(HashConfig::Style(&StyleOptions::default())), 654 + Some(&HashConfig { 655 + asset_type: HashAssetType::Style(&StyleOptions::default()), 656 + hashing_strategy: &AssetHashingStrategy::Precise, 657 + }), 640 658 ), 641 659 tailwind: false, 642 660 included: false,
+7 -5
crates/maudit/src/assets/image.rs
··· 2 2 use std::{path::PathBuf, sync::OnceLock, time::Instant}; 3 3 4 4 use base64::Engine; 5 - use image::GenericImageView; 5 + use image::{GenericImageView, image_dimensions}; 6 6 use log::debug; 7 7 use thumbhash::{rgba_to_thumb_hash, thumb_hash_to_average_rgba, thumb_hash_to_rgba}; 8 8 ··· 63 63 #[derive(Clone, Debug, Hash, PartialEq, Eq)] 64 64 pub struct Image { 65 65 pub path: PathBuf, 66 - pub width: u32, 67 - pub height: u32, 68 66 pub(crate) assets_dir: PathBuf, 69 67 pub(crate) hash: String, 70 68 pub(crate) options: Option<ImageOptions>, ··· 76 74 /// This uses the [ThumbHash](https://evanw.github.io/thumbhash/) algorithm to generate a very small placeholder image. 77 75 pub fn placeholder(&self) -> ImagePlaceholder { 78 76 get_placeholder(&self.path) 77 + } 78 + 79 + pub fn dimensions(&self) -> (u32, u32) { 80 + image_dimensions(&self.path).unwrap_or((0, 0)) 79 81 } 80 82 } 81 83 ··· 380 382 } 381 383 382 384 impl InternalAsset for Image { 383 - fn assets_dir(&self) -> PathBuf { 384 - self.assets_dir.clone() 385 + fn assets_dir(&self) -> &PathBuf { 386 + &self.assets_dir 385 387 } 386 388 } 387 389
+1
crates/maudit/src/assets/image_cache.rs
··· 8 8 use log::debug; 9 9 use rustc_hash::FxHashMap; 10 10 11 + // TODO: Make this configurable 11 12 pub const IMAGE_CACHE_DIR: &str = "target/maudit_cache/images"; 12 13 pub const MANIFEST_VERSION: u32 = 1; 13 14
+2 -2
crates/maudit/src/assets/script.rs
··· 12 12 } 13 13 14 14 impl InternalAsset for Script { 15 - fn assets_dir(&self) -> PathBuf { 16 - self.assets_dir.clone() 15 + fn assets_dir(&self) -> &PathBuf { 16 + &self.assets_dir 17 17 } 18 18 } 19 19
+2 -2
crates/maudit/src/assets/style.rs
··· 18 18 } 19 19 20 20 impl InternalAsset for Style { 21 - fn assets_dir(&self) -> PathBuf { 22 - self.assets_dir.clone() 21 + fn assets_dir(&self) -> &PathBuf { 22 + &self.assets_dir 23 23 } 24 24 } 25 25
+30 -25
crates/maudit/src/build.rs
··· 5 5 io::{self}, 6 6 path::{Path, PathBuf}, 7 7 process::Command, 8 - str::FromStr, 9 8 sync::Arc, 10 9 time::{Instant, SystemTime, UNIX_EPOCH}, 11 10 }; ··· 44 43 45 44 #[derive(Debug)] 46 45 struct TailwindPlugin { 47 - tailwind_path: String, 46 + tailwind_path: PathBuf, 48 47 tailwind_entries: Vec<PathBuf>, 49 48 } 50 49 ··· 93 92 }; 94 93 panic!( 95 94 "Failed to execute Tailwind CSS command, is it installed and is the path to its binary correct?\nCommand: '{}', Args: {}. Error: {}", 96 - &self.tailwind_path, 95 + &self.tailwind_path.display(), 97 96 args_str, 98 97 e 99 98 ) ··· 156 155 157 156 // Create a directory for the output 158 157 trace!(target: "build", "Setting up required directories..."); 159 - let dist_dir = PathBuf::from_str(&options.output_dir)?; 160 - let final_assets_dir = PathBuf::from_str(&options.output_dir)?.join(&options.assets_dir); 161 - let static_dir = PathBuf::from_str(&options.static_dir)?; 162 158 163 159 let old_dist_tmp_dir = if options.clean_output_dir { 164 160 let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap(); 165 161 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000; 166 162 let new_dir_for_old_dist = env::temp_dir().join(format!("maudit_old_dist_{}", num)); 167 - let _ = fs::rename(&dist_dir, &new_dir_for_old_dist); 163 + let _ = fs::rename(&options.output_dir, &new_dir_for_old_dist); 168 164 Some(new_dir_for_old_dist) 169 165 } else { 170 166 None ··· 177 173 } 178 174 }); 179 175 180 - fs::create_dir_all(&dist_dir)?; 181 - fs::create_dir_all(&final_assets_dir)?; 176 + let page_assets_options = options.page_assets_options(); 182 177 183 - info!(target: "build", "Output directory: {}", dist_dir.to_string_lossy()); 178 + info!(target: "build", "Output directory: {}", options.output_dir.display()); 184 179 185 180 let content_sources_start = Instant::now(); 186 181 print_title("initializing content sources"); ··· 234 229 let route_start = Instant::now(); 235 230 236 231 let content = PageContent::new(content_sources); 237 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 232 + let mut page_assets = PageAssets::new(&page_assets_options); 238 233 239 234 let params = RouteParams::default(); 240 235 let url = route.url(&params); ··· 242 237 let result = route.build(&mut RouteContext::from_static_route( 243 238 &content, 244 239 &mut page_assets, 245 - url.clone(), 240 + &url, 246 241 ))?; 247 242 248 - let file_path = &dist_dir.join(route.file_path(&params)); 243 + let file_path = route.file_path(&params, &options.output_dir); 249 244 250 - write_route_file(&result, file_path)?; 245 + write_route_file(&result, &file_path)?; 251 246 252 247 info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 253 248 ··· 264 259 page_count += 1; 265 260 } 266 261 RouteType::Dynamic => { 267 - let routes = route.routes_internal(&DynamicRouteContext { 262 + let routes = route.get_routes(&DynamicRouteContext { 268 263 content: &PageContent::new(content_sources), 269 264 }); 270 265 ··· 279 274 for dynamic_route in routes { 280 275 let route_start = Instant::now(); 281 276 282 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 277 + let mut page_assets = PageAssets::new(&page_assets_options); 283 278 284 279 let url = route.url(&dynamic_route.0); 285 280 ··· 287 282 &dynamic_route, 288 283 &content, 289 284 &mut page_assets, 290 - url, 285 + &url, 291 286 ))?; 292 287 293 - let file_path = &dist_dir.join(route.file_path(&dynamic_route.0)); 288 + let file_path = route.file_path(&dynamic_route.0, &options.output_dir); 294 289 295 - write_route_file(&content, file_path)?; 290 + write_route_file(&content, &file_path)?; 296 291 297 292 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options)); 298 293 ··· 313 308 } 314 309 315 310 info!(target: "pages", "{}", format!("generated {} pages in {}", page_count, format_elapsed_time(pages_start.elapsed(), &section_format_options)).bold()); 311 + 312 + if (!build_pages_images.is_empty()) 313 + || !build_pages_styles.is_empty() 314 + || !build_pages_scripts.is_empty() 315 + { 316 + fs::create_dir_all(&page_assets_options.assets_dir)?; 317 + } 316 318 317 319 if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() { 318 320 let assets_start = Instant::now(); ··· 363 365 BundlerOptions { 364 366 input: Some(bundler_inputs), 365 367 minify: Some(rolldown::RawMinifyOptions::Bool(!is_dev())), 366 - dir: Some(final_assets_dir.to_string_lossy().to_string()), 368 + dir: Some(page_assets_options.assets_dir.to_string_lossy().to_string()), 367 369 module_types: Some(module_types_hashmap), 368 - 369 370 ..Default::default() 370 371 }, 371 372 vec![Arc::new(TailwindPlugin { 372 - tailwind_path: options.tailwind_binary_path.clone(), 373 + tailwind_path: options.assets.tailwind_binary_path.clone(), 373 374 tailwind_entries: build_pages_styles 374 375 .iter() 375 376 .filter_map(|style| { ··· 399 400 let start_time = Instant::now(); 400 401 build_pages_images.par_iter().for_each(|image| { 401 402 let start_process = Instant::now(); 402 - let dest_path = final_assets_dir.join(image.final_file_name()); 403 + let dest_path: PathBuf = image.build_path(); 403 404 404 405 if let Some(image_options) = &image.options { 405 406 let final_filename = image.final_file_name(); ··· 444 445 } 445 446 446 447 // Check if static directory exists 447 - if static_dir.exists() { 448 + if options.static_dir.exists() { 448 449 let assets_start = Instant::now(); 449 450 print_title("copying assets"); 450 451 451 452 // Copy the static directory to the dist directory 452 - copy_recursively(&static_dir, &dist_dir, &mut build_metadata)?; 453 + copy_recursively( 454 + &options.static_dir, 455 + &options.output_dir, 456 + &mut build_metadata, 457 + )?; 453 458 454 459 info!(target: "build", "{}", format!("Assets copied in {}", format_elapsed_time(assets_start.elapsed(), &FormatElapsedTimeOptions::default())).bold()); 455 460 }
+69 -13
crates/maudit/src/build/options.rs
··· 1 + use std::path::PathBuf; 2 + 3 + use crate::{assets::PageAssetsOptions, is_dev}; 4 + 1 5 /// Maudit build options. Should be passed to [`coronate()`](crate::coronate()). 2 6 /// 3 7 /// ## Examples ··· 27 31 /// content_sources![], 28 32 /// BuildOptions { 29 33 /// output_dir: "public".to_string(), 30 - /// assets_dir: "_assets".to_string(), 31 34 /// static_dir: "static".to_string(), 32 - /// tailwind_binary_path: "./node_modules/.bin/tailwindcss".to_string(), 35 + /// assets: AssetsOptions { 36 + /// assets_dir: "_assets".to_string(), 37 + /// tailwind_binary_path: "./node_modules/.bin/tailwindcss".to_string(), 38 + /// ..Default::default() 39 + /// }, 33 40 /// ..Default::default() 34 41 /// }, 35 42 /// ) 36 43 /// } 37 44 /// ``` 38 45 pub struct BuildOptions { 39 - pub output_dir: String, 40 - pub assets_dir: String, 41 - pub static_dir: String, 42 - /// 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`. 43 - /// 44 - /// This is commonly set to `./node_modules/.bin/tailwindcss` or similar, in order to use a locally installed version. 45 - pub tailwind_binary_path: String, 46 + pub output_dir: PathBuf, 47 + pub static_dir: PathBuf, 48 + 46 49 /// Whether to clean the output directory before building. 47 50 /// 48 51 /// At the speed Maudit operates at, not cleaning the output directory may offer a significant performance improvement at the cost of potentially serving stale content. 49 52 pub clean_output_dir: bool, 53 + 54 + pub assets: AssetsOptions, 55 + } 56 + 57 + impl BuildOptions { 58 + /// Returns the fully resolved assets options, with the `assets_dir` set to be inside the `output_dir`. 59 + /// e.g. if `output_dir` is `dist` and `assets.assets_dir` is `_maudit`, this will return `dist/_maudit`. 60 + pub fn page_assets_options(&self) -> PageAssetsOptions { 61 + PageAssetsOptions { 62 + assets_dir: self.output_dir.join(&self.assets.assets_dir), 63 + hashing_strategy: self.assets.hashing_strategy, 64 + } 65 + } 66 + } 67 + 68 + #[derive(Clone)] 69 + pub struct AssetsOptions { 70 + /// 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`. 71 + /// 72 + /// This is commonly set to `./node_modules/.bin/tailwindcss` or similar, in order to use a locally installed version. 73 + pub tailwind_binary_path: PathBuf, 74 + 75 + /// Directory inside the output directory to place built assets in. 76 + /// Defaults to `_maudit`. 77 + /// 78 + /// Note that this value is not automatically joined with the `output_dir` in `BuildOptions`. Use [`BuildOptions::page_assets_options()`] to get a `PageAssetsOptions` with the correct final path. 79 + pub assets_dir: PathBuf, 80 + 81 + /// Strategy to use when hashing assets for fingerprinting. 82 + /// 83 + /// Defaults to [`AssetHashingStrategy::Precise`] in production builds, and [`AssetHashingStrategy::FastImprecise`] in development builds. Note that this means that the cache isn't shared between dev and prod builds by default, if you have a lot of assets you may want to set this to the same value in both environments. 84 + pub hashing_strategy: AssetHashingStrategy, 85 + } 86 + 87 + #[derive(PartialEq, Eq, Clone, Copy)] 88 + pub enum AssetHashingStrategy { 89 + /// Hash assets based on their full content, path and options (e.g. whether TailwindCSS is enabled for styles). 90 + Precise, 91 + /// Hash assets based on their modified time, size, path and options. This is much faster, but may lead to stale assets and sometimes unnecessary rebuilds. 92 + FastImprecise, 93 + } 94 + 95 + impl Default for AssetsOptions { 96 + fn default() -> Self { 97 + Self { 98 + tailwind_binary_path: "tailwindcss".into(), 99 + assets_dir: "_maudit".into(), 100 + hashing_strategy: if is_dev() { 101 + AssetHashingStrategy::FastImprecise 102 + } else { 103 + AssetHashingStrategy::Precise 104 + }, 105 + } 106 + } 50 107 } 51 108 52 109 /// Provides default values for [`crate::coronate()`]. Designed to work for most projects. ··· 68 125 impl Default for BuildOptions { 69 126 fn default() -> Self { 70 127 Self { 71 - output_dir: "dist".to_string(), 72 - assets_dir: "_maudit".to_string(), 73 - static_dir: "static".to_string(), 74 - tailwind_binary_path: "tailwindcss".to_string(), 128 + output_dir: "dist".into(), 129 + static_dir: "static".into(), 75 130 clean_output_dir: true, 131 + assets: AssetsOptions::default(), 76 132 } 77 133 } 78 134 }
+1 -1
crates/maudit/src/content.rs
··· 196 196 } 197 197 } 198 198 199 - /// Represents a single entry in a [`ContentSource`]. 199 + /// A single entry of a [`ContentSource`]. 200 200 /// 201 201 /// ## Example 202 202 /// ```rs
+3 -3
crates/maudit/src/content/highlight.rs
··· 1 1 use std::sync::OnceLock; 2 2 use syntect::{ 3 + Error, 3 4 easy::HighlightLines, 4 5 highlighting::ThemeSet, 5 - html::{styled_line_to_highlighted_html, IncludeBackground}, 6 + html::{IncludeBackground, styled_line_to_highlighted_html}, 6 7 parsing::SyntaxSet, 7 8 util::LinesWithEndings, 8 - Error, 9 9 }; 10 10 11 11 static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new(); ··· 88 88 89 89 let mut highlighted = String::new(); 90 90 for line in LinesWithEndings::from(content) { 91 - let regions = h.highlight_line(line, ss).unwrap(); 91 + let regions = h.highlight_line(line, ss)?; 92 92 let html = styled_line_to_highlighted_html(&regions, IncludeBackground::No)?; // TODO: Handle the background coloring 93 93 highlighted.push_str(&html); 94 94 }
+1 -1
crates/maudit/src/content/markdown/components.rs
··· 483 483 #[cfg(test)] 484 484 mod tests { 485 485 use super::*; 486 - use crate::content::{MarkdownOptions, render_markdown}; 486 + use crate::content::{render_markdown, MarkdownOptions}; 487 487 488 488 struct TestCustomHeading; 489 489
+5 -5
crates/maudit/src/content/markdown/shortcodes_tests.rs
··· 1 1 #[cfg(test)] 2 2 mod tests { 3 3 use crate::{ 4 + assets::PageAssetsOptions, 4 5 content::shortcodes::{MarkdownShortcodes, preprocess_shortcodes}, 5 6 page::RouteContext, 6 7 }; ··· 60 61 assets::PageAssets, 61 62 content::{ContentSources, PageContent}, 62 63 }; 63 - use std::path::PathBuf; 64 64 65 65 let content_sources = ContentSources::new(vec![]); 66 66 let content = PageContent::new(&content_sources); 67 - let mut page_assets = PageAssets { 68 - assets_dir: PathBuf::from("assets"), 67 + let mut page_assets = PageAssets::new(&PageAssetsOptions { 68 + assets_dir: "assets".into(), 69 69 ..Default::default() 70 - }; 70 + }); 71 71 72 72 let mut ctx = RouteContext { 73 73 content: &content, 74 74 assets: &mut page_assets, 75 - current_url: "/test".to_string(), 75 + current_url: &"/test".to_string(), 76 76 params: &(), 77 77 props: &(), 78 78 };
+3 -1
crates/maudit/src/errors.rs
··· 26 26 27 27 #[derive(Error)] 28 28 pub enum BuildError { 29 - #[error("`{route}` returns `RenderResult::Raw`, but includes styles or scripts, which can only be included in HTML. If you meant to return HTML, use `RenderResult::Text` instead. Alternatively, if you meant to add a reference to a script or style without including it directly, use the `add_script` or `add_style` methods instead.")] 29 + #[error( 30 + "`{route}` returns `RenderResult::Raw`, but includes styles or scripts, which can only be included in HTML. If you meant to return HTML, use `RenderResult::Text` instead. Alternatively, if you meant to add a reference to a script or style without including it directly, use the `add_script` or `add_style` methods instead." 31 + )] 30 32 InvalidRenderResult { route: String }, 31 33 } 32 34
+11 -3
crates/maudit/src/lib.rs
··· 15 15 16 16 // Exports for end-users 17 17 pub use build::metadata::{BuildOutput, PageOutput, StaticAssetOutput}; 18 - pub use build::options::BuildOptions; 18 + pub use build::options::{AssetHashingStrategy, AssetsOptions, BuildOptions}; 19 19 20 - // Re-exported dependencies for user convenience 20 + // Re-export FxHashMap so that macro-generated code can use it without requiring users to add it as a dependency. 21 + #[doc(hidden)] 21 22 pub use rustc_hash::FxHashMap; 22 23 23 24 mod build; ··· 26 27 #[cfg(feature = "maud")] 27 28 #[cfg_attr(docsrs, doc(cfg(feature = "maud")))] 28 29 pub mod maud { 29 - //! Allows to use [Maud](https://maud.lambda.xyz), a macro for writing HTML templates, ergonomically in your Maudit pages. 30 + //! Traits and methods for [Maud](https://maud.lambda.xyz), a macro for writing HTML templates. 30 31 //! 31 32 //! ## Example 32 33 //! ```rs ··· 174 175 /// The version of Maudit being used. 175 176 /// 176 177 /// Can be used to create a generator tag in the output HTML. 178 + /// 179 + /// ## Example 180 + /// ```rs 181 + /// use maudit::GENERATOR; 182 + /// 183 + /// format!("<meta name=\"generator\" content=\"{}\">", GENERATOR); 184 + /// ``` 177 185 pub const GENERATOR: &str = concat!("Maudit v", env!("CARGO_PKG_VERSION")); 178 186 179 187 /// 👑 Maudit entrypoint. Starts the build process and generates the output files.
+43 -14
crates/maudit/src/page.rs
··· 9 9 }; 10 10 use rustc_hash::FxHashMap; 11 11 use std::any::Any; 12 + use std::path::{Path, PathBuf}; 12 13 13 - /// Represents the result of a page render, can be either text or raw bytes. 14 + /// The result of a page render, can be either text or raw bytes. 14 15 /// 15 16 /// Typically used through the [`Into<RenderResult>`](std::convert::Into) and [`From<RenderResult>`](std::convert::From) implementations for common types. 16 17 /// End users should rarely need to interact with this enum directly. ··· 210 211 pub props: &'a dyn Any, 211 212 pub content: &'a PageContent<'a>, 212 213 pub assets: &'a mut PageAssets, 213 - pub current_url: String, 214 + pub current_url: &'a String, 214 215 } 215 216 216 217 impl<'a> RouteContext<'a> { 217 218 pub fn from_static_route( 218 219 content: &'a PageContent, 219 220 assets: &'a mut PageAssets, 220 - current_url: String, 221 + current_url: &'a String, 221 222 ) -> Self { 222 223 Self { 223 224 params: &(), ··· 232 233 dynamic_route: &'a RouteResult, 233 234 content: &'a PageContent, 234 235 assets: &'a mut PageAssets, 235 - current_url: String, 236 + current_url: &'a String, 236 237 ) -> Self { 237 238 Self { 238 239 params: dynamic_route.1.as_ref(), ··· 425 426 route 426 427 } 427 428 428 - fn file_path(&self, params: &RouteParams) -> String { 429 + fn file_path(&self, params: &RouteParams, output_dir: &Path) -> PathBuf { 429 430 let params_def = extract_params_from_raw_route(&self.route_raw()); 430 431 let mut route = self.route_raw(); 431 432 ··· 448 449 449 450 let cleaned_raw_route = route.trim_start_matches('/').to_string(); 450 451 451 - match self.is_endpoint() { 452 + output_dir.join(match self.is_endpoint() { 452 453 true => cleaned_raw_route, 453 454 false => match cleaned_raw_route.is_empty() { 454 - true => "index.html".to_string(), 455 + true => "index.html".into(), 455 456 false => format!("{}/index.html", cleaned_raw_route), 456 457 }, 457 - } 458 + }) 459 + } 460 + } 461 + 462 + /// Extension trait providing generic convenience methods on an instance of a page 463 + pub trait PageExt<Params = RouteParams, Props = (), T = RenderResult>: 464 + Page<Params, Props, T> + InternalPage 465 + where 466 + Params: Into<RouteParams>, 467 + Props: 'static, 468 + T: Into<RenderResult>, 469 + { 470 + /// Get the URL for this page with the given parameters 471 + /// 472 + /// Note that this method merely generates the URL based on the route pattern and parameters, it does not verify if a corresponding route actually exists. 473 + fn url(&self, params: Params) -> String { 474 + InternalPage::url(self, &params.into()) 458 475 } 476 + } 477 + 478 + // Blanket implementation for all Page implementors that also implement InternalPage 479 + impl<U, Params, Props, T> PageExt<Params, Props, T> for U 480 + where 481 + U: Page<Params, Props, T> + InternalPage, 482 + Params: Into<RouteParams>, 483 + Props: 'static, 484 + T: Into<RenderResult>, 485 + { 459 486 } 460 487 461 488 /// Used internally by Maudit and should not be implemented by the user. 462 489 /// We expose it because [`maudit_macros::route`] implements it for the user behind the scenes. 463 490 pub trait FullPage: InternalPage + Sync + Send { 491 + #[doc(hidden)] 464 492 fn render_internal(&self, ctx: &mut RouteContext) -> RenderResult; 493 + #[doc(hidden)] 465 494 fn routes_internal(&self, context: &DynamicRouteContext) -> RoutesResult; 495 + 496 + fn get_routes(&self, context: &DynamicRouteContext) -> RoutesResult { 497 + self.routes_internal(context) 498 + } 466 499 467 500 fn build(&self, ctx: &mut RouteContext) -> Result<Vec<u8>, Box<dyn std::error::Error>> { 468 501 let result = self.render_internal(ctx); ··· 478 511 pub type RouteProps = Box<dyn Any + Send + Sync>; 479 512 pub type RouteTypedParams = Box<dyn Any + Send + Sync>; 480 513 481 - pub fn get_page_url<T: Into<RouteParams>>(route: &impl FullPage, params: T) -> String { 482 - format!("/{}", route.url(&params.into()).trim_start_matches('/')) 483 - } 484 - 485 514 pub mod prelude { 486 515 //! Re-exports of the most commonly used types and traits for defining pages. 487 516 //! ··· 492 521 //! use maudit::page::prelude::*; 493 522 //! ``` 494 523 pub use super::{ 495 - DynamicRouteContext, Page, PaginationMeta, RenderResult, Route, RouteContext, RouteParams, 496 - Routes, get_page_slice, get_page_url, paginate_content, 524 + DynamicRouteContext, FullPage, Page, PageExt, PaginationMeta, RenderResult, Route, 525 + RouteContext, RouteParams, Routes, get_page_slice, paginate_content, 497 526 }; 498 527 pub use crate::assets::{Asset, Image, Style, StyleOptions}; 499 528 pub use crate::content::MarkdownContent;
+26 -34
crates/maudit/src/route.rs
··· 11 11 12 12 pub fn extract_params_from_raw_route(raw_route: &str) -> Vec<ParameterDef> { 13 13 let mut params = Vec::new(); 14 - let mut start = false; 15 - let mut escape = false; 16 - let mut current_value = String::new(); 14 + let mut start = 0; 17 15 18 - for (i, c) in raw_route.char_indices() { 19 - if escape { 20 - escape = false; 21 - if start { 22 - current_value.push(c); 23 - } 16 + while let Some(bracket_pos) = raw_route[start..].find('[') { 17 + let abs_pos = start + bracket_pos; 18 + 19 + // Check if escaped by counting preceding backslashes 20 + let backslash_count = raw_route[..abs_pos] 21 + .chars() 22 + .rev() 23 + .take_while(|&c| c == '\\') 24 + .count(); 25 + 26 + if backslash_count % 2 == 1 { 27 + start = abs_pos + 1; 24 28 continue; 25 29 } 26 30 27 - match c { 28 - '\\' => { 29 - escape = true; 30 - } 31 - '[' => { 32 - if !escape { 33 - start = true; 34 - current_value.clear(); 35 - } 36 - } 37 - ']' => { 38 - if start { 39 - params.push(ParameterDef { 40 - key: current_value.clone(), 41 - index: i - (current_value.len() + 1), // -1 for the starting [ 42 - length: current_value.len() + 2, // +2 for the [ and ] 43 - }); 44 - start = false; 45 - } 46 - } 47 - _ => { 48 - if start { 49 - current_value.push(c); 50 - } 51 - } 31 + if let Some(end_bracket) = raw_route[abs_pos + 1..].find(']') { 32 + let end_pos = abs_pos + 1 + end_bracket; 33 + let key = raw_route[abs_pos + 1..end_pos].to_string(); 34 + 35 + params.push(ParameterDef { 36 + key, 37 + index: abs_pos, 38 + length: end_pos - abs_pos + 1, 39 + }); 40 + 41 + start = end_pos + 1; 42 + } else { 43 + break; 52 44 } 53 45 } 54 46
+2 -1
crates/maudit/src/templating/maud_ext.rs
··· 23 23 24 24 impl Render for Image { 25 25 fn render(&self) -> Markup { 26 + let (width, height) = self.dimensions(); 26 27 html! { 27 - img src=(self.url().unwrap()) width=(self.width) height=(self.height) loading="lazy" decoding="async"; 28 + img src=(self.url().unwrap()) width=(width) height=(height) loading="lazy" decoding="async"; 28 29 } 29 30 } 30 31 }
+1 -1
crates/oubli/src/archetypes/blog.rs
··· 19 19 let markup = html! { 20 20 main { 21 21 @for entry in &blog_entries.entries { 22 - a href=(get_page_url(&route, BlogEntryParams { entry: entry.id.clone() })) { 22 + a href=(route.url(&BlogEntryParams { entry: entry.id.clone() }.into())) { 23 23 h2 { (entry.data(ctx).title) } 24 24 p { (entry.data(ctx).description) } 25 25 }
+1 -1
examples/blog/src/pages/index.rs
··· 18 18 ul { 19 19 @for entry in &articles.entries { 20 20 li { 21 - a href=(get_page_url(&Article, ArticleParams { article: entry.id.clone() })) { 21 + a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 22 22 h2 { (entry.data(ctx).title) } 23 23 } 24 24 p { (entry.data(ctx).description) }
+5 -2
examples/kitchen-sink/src/main.rs
··· 1 - use maudit::{coronate, routes, BuildOptions, BuildOutput}; 1 + use maudit::{coronate, routes, AssetsOptions, BuildOptions, BuildOutput}; 2 2 3 3 mod pages { 4 4 mod dynamic; ··· 14 14 routes![pages::Index, pages::DynamicExample, pages::Endpoint], 15 15 vec![].into(), 16 16 BuildOptions { 17 - tailwind_binary_path: "../../node_modules/.bin/tailwindcss".to_string(), 17 + assets: AssetsOptions { 18 + tailwind_binary_path: "../../node_modules/.bin/tailwindcss".into(), 19 + ..Default::default() 20 + }, 18 21 ..Default::default() 19 22 }, 20 23 )
+1 -2
examples/kitchen-sink/src/pages/index.rs
··· 15 15 .assets 16 16 .add_style_with_options("data/tailwind.css", StyleOptions { tailwind: true }); 17 17 18 - let link_to_first_dynamic = 19 - get_page_url(&DynamicExample, DynamicExampleParams { page: 1 }); 18 + let link_to_first_dynamic = DynamicExample.url(DynamicExampleParams { page: 1 }); 20 19 21 20 html! { 22 21 head {
+33 -50
examples/library/src/build.rs
··· 1 - use std::path::PathBuf; 2 - use std::str::FromStr; 3 - use std::{collections::HashSet, fs}; 1 + use std::fs; 4 2 5 - use maudit::page::DynamicRouteContext; 6 3 use maudit::{ 7 4 assets::PageAssets, 8 5 content::{ContentSources, PageContent}, 9 - page::{FullPage, RouteContext, RouteParams, RouteType}, 6 + page::{DynamicRouteContext, FullPage, RouteContext, RouteParams, RouteType}, 10 7 BuildOptions, 11 8 }; 12 9 ··· 15 12 mut content_sources: ContentSources, 16 13 options: BuildOptions, 17 14 ) -> Result<(), Box<dyn std::error::Error>> { 18 - let dist_dir = PathBuf::from_str(&options.output_dir)?; 19 - 20 15 // Initialize all the content sources; 21 16 content_sources.init_all(); 22 17 23 - let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new(); 18 + // Options we'll be passing to PageAssets instances. 19 + // This value automatically has the paths joined based on the output directory in BuildOptions for us, so we don't have to do it ourselves. 20 + let page_assets_options = options.page_assets_options(); 21 + 22 + // Create the assets directory if it doesn't exist. 23 + fs::create_dir_all(&page_assets_options.assets_dir)?; 24 24 25 25 for route in routes { 26 26 match route.route_type() { 27 27 RouteType::Static => { 28 28 // Our page does not include content or assets, but we'll set those up for future use. 29 29 let content = PageContent::new(&content_sources); 30 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 30 + let mut page_assets = PageAssets::new(&page_assets_options); 31 31 32 32 // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 33 33 // As such, we can just pass an empty set of parameters (the default for RouteParams). 34 34 let params = RouteParams::default(); 35 35 36 36 // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets. 37 - let mut ctx = RouteContext::from_static_route( 38 - &content, 39 - &mut page_assets, 40 - route.url(&params).clone(), 41 - ); 37 + let url = route.url(&params); 38 + let mut ctx = RouteContext::from_static_route(&content, &mut page_assets, &url); 42 39 43 40 let content = route.build(&mut ctx)?; 44 41 45 - // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir. 46 - let final_filepath = dist_dir.join(route.file_path(&params)); 42 + let route_filepath = route.file_path(&params, &options.output_dir); 47 43 48 - // On some platforms, creating a file in a nested directory requires that the directory already exists. 49 - if let Some(parent_dir) = final_filepath.parent() { 44 + // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 45 + if let Some(parent_dir) = route_filepath.parent() { 50 46 fs::create_dir_all(parent_dir)? 51 47 } 52 48 53 - fs::write(final_filepath, content)?; 49 + fs::write(route_filepath, content)?; 54 50 55 - // Collect all assets used by this page. 56 - all_assets.extend(page_assets.assets().map(|asset| { 57 - ( 58 - dist_dir.join(asset.build_path()), 59 - asset.path().to_path_buf(), 60 - ) 61 - })); 51 + // Copy all assets used by this page. 52 + for asset in page_assets.assets() { 53 + fs::copy(asset.path(), asset.build_path())?; 54 + } 62 55 } 63 56 RouteType::Dynamic => { 64 - // The `routes` method returns all the possible routes for this page, along with their parameters and properties. 57 + // The `get_routes` method returns all the possible routes for this page, along with their parameters and properties. 65 58 // It is very common for dynamic pages to be based on content, for instance a blog post page that has one route per blog post. 66 59 // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes. 67 60 ··· 69 62 content: &PageContent::new(&content_sources), 70 63 }; 71 64 72 - let routes = route.routes_internal(&dynamic_ctx); 65 + let routes = route.get_routes(&dynamic_ctx); 73 66 74 - // Every page can share the same PageContent instance, as it is just a view into the content sources. 67 + // Every page can share a reference to the same PageContent instance, as it is just a view into the content sources. 75 68 let content = PageContent::new(&content_sources); 76 69 77 70 for dynamic_route in routes { 78 71 // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route. 79 72 // This is especially relevant if we were to parallelize this loop in the future. 80 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 73 + let mut page_assets = PageAssets::new(&page_assets_options); 81 74 82 75 // The dynamic route includes the parameters for this specific route. 83 76 let params = &dynamic_route.0; 84 77 85 78 // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 79 + let url = route.url(params); 86 80 let mut ctx = RouteContext::from_dynamic_route( 87 81 &dynamic_route, 88 82 &content, 89 83 &mut page_assets, 90 - route.url(params), 84 + &url, 91 85 ); 92 86 93 - // Everything from here is the same as for static routes. 87 + // Everything below here is the same as for static routes. 88 + 94 89 let content = route.build(&mut ctx)?; 95 90 96 - let final_file_path = &dist_dir.join(route.file_path(params)); 91 + let route_filepath = route.file_path(params, &options.output_dir); 97 92 98 - if let Some(parent_dir) = final_file_path.parent() { 93 + if let Some(parent_dir) = route_filepath.parent() { 99 94 fs::create_dir_all(parent_dir)? 100 95 } 101 96 102 - fs::write(final_file_path, content)?; 97 + fs::write(route_filepath, content)?; 103 98 104 - // Collect all assets used by this page. 105 - all_assets.extend(page_assets.assets().map(|asset| { 106 - ( 107 - dist_dir.join(asset.build_path()), 108 - asset.path().to_path_buf(), 109 - ) 110 - })); 99 + for asset in page_assets.assets() { 100 + fs::copy(asset.path(), asset.build_path())?; 101 + } 111 102 } 112 103 } 113 104 } 114 - } 115 - 116 - // Copy all assets to the output directory. 117 - for (dest_path, src_path) in all_assets { 118 - if let Some(parent) = dest_path.parent() { 119 - fs::create_dir_all(parent)?; 120 - } 121 - fs::copy(src_path, dest_path)?; 122 105 } 123 106 124 107 Ok(())
+1 -1
examples/library/src/pages/index.rs
··· 20 20 ul { 21 21 @for entry in &articles.entries { 22 22 li { 23 - a href=(get_page_url(&Article, ArticleParams { article: entry.id.clone() })) { 23 + a href=(&Article.url(ArticleParams { article: entry.id.clone() })) { 24 24 h2 { (entry.data(ctx).title) } 25 25 } 26 26 p { (entry.data(ctx).description) }
+2 -1
examples/markdown-components/src/pages.rs
··· 31 31 } 32 32 } 33 33 } 34 - }.into() 34 + } 35 + .into() 35 36 } 36 37 }
+1 -1
website/content/docs/content.md
··· 166 166 167 167 ## Markdown rendering 168 168 169 - Either through loaders or by using the `render_markdown` function directly, Maudit supports rendering local and remote Markdown and enriching it with shortcodes and custom components. 169 + Either through loaders or by using the [`render_markdown`](https://docs.rs/maudit/latest/maudit/content/markdown/fn.render_markdown.html) function directly, Maudit supports rendering local and remote Markdown and enriching it with shortcodes and custom components. 170 170 171 171 ### Shortcodes 172 172
+73 -152
website/content/docs/library.md
··· 6 6 7 7 [Maudit is built as a library, not a framework](/docs/philosophy/#maudit-is-a-library-not-a-framework). It is absolutely primordial to us that Maudit does not feel like this black box that you cannot inspect or understand. You should be able to see how everything works, and make it work for you. 8 8 9 - As such, in this guide, we'll be building our own minimal [entrypoint](/docs/entrypoint/) to replace the built-in [`coronate`](https://docs.rs/maudit/latest/maudit/fn.coronate.html) function. This will give us a better understanding of how Maudit works, and allow us to customize it to our needs. 9 + As such, in this guide, we'll be building our own minimal [entrypoint](/docs/entrypoint/) to replace the built-in [`coronate`](https://docs.rs/maudit/latest/maudit/fn.coronate.html) function. This will give us a better understanding of how Maudit works, and allow us to pick apart the different pieces and customize them to our needs. 10 10 11 11 > The result of this guide is available in the [library example](https://github.com/bruits/maudit/tree/main/examples/library) in the Maudit repository. 12 12 13 - ## Setting up the project 13 + ## Function signature 14 14 15 - We'll start by creating a new Rust project with Maudit as a dependency: 16 - 17 - ```bash 18 - cargo new library --bin 19 - cd library 20 - cargo add maudit 21 - ``` 22 - 23 - and we'll create a simple Maudit page in `src/pages/index.rs`: 15 + The built-in `coronate` function takes a list of routes (which all implements the [FullPage](https://docs.rs/maudit/latest/maudit/page/trait.FullPage.html) trait), content sources, and some build options. We'll do the same. 24 16 25 17 ```rs 26 - use maudit::page::prelude::*; 27 - 28 - #[route("/")] 29 - pub struct Index; 30 - 31 - impl Page for Index { 32 - fn render(&self, _: &mut RouteContext) -> RenderResult { 33 - "Hello, Maudit!".into() 34 - } 35 - } 36 - ``` 37 - 38 - We'll now start building our own entrypoint in `src/build.rs`, which will contain a `build_website` function, taking the same parameters as `coronate`: 39 - 40 - ```rs 41 - use maudit::page::FullPage; 42 - use maudit::{content::ContentSources, BuildOptions}; 18 + use maudit::{ 19 + content::ContentSources, 20 + page::{FullPage, PageAssets, PageContent}, 21 + route::{DynamicRouteContext, RouteContext, RouteParams, RouteType}, 22 + BuildOptions, 23 + }; 43 24 44 25 pub fn build_website( 45 26 routes: &[&dyn FullPage], 46 27 mut content_sources: ContentSources, 47 - options: BuildOptions, 28 + options: BuildOptions 48 29 ) -> Result<(), Box<dyn std::error::Error>> { 49 - // Implementation will go here 50 - 30 + // We'll fill this in later. 51 31 Ok(()) 52 32 } 53 33 ``` 54 34 55 - Finally, we'll modify `src/main.rs` to call our `build_website` function: 56 - 57 - ```rs 58 - mod build; 59 - 60 - mod pages { 61 - mod index; 62 - pub use index::Index; 63 - } 64 - 65 - fn main() { 66 - let _ = build_website( 67 - routes![Index], 68 - content_sources![], 69 - BuildOptions::default(), 70 - ); 71 - } 72 - ``` 73 - 74 - Now that we have our project set up, let's implement the `build_website` function step by step. 35 + `Box<dyn std::error::Error>` is typically seen as an anti-pattern in Rust, as it makes it hard to handle specific error types. But, for the sake of simplicity, we'll use it here. 75 36 76 37 ## Building pages 77 38 78 39 The first step in building our own entrypoint is to iterate over the routes and build each page. Routes can either be static (i.e. `/index`) or dynamic (i.e. `/articles/[id]`). For now, we'll only handle static routes. 79 40 80 41 ```rs 81 - use std::fs; 82 - use std::path::PathBuf; 83 - use std::str::FromStr; 84 - 85 - use maudit::{ 86 - assets::PageAssets, 87 - content::{ContentSources, PageContent}, 88 - page::{FullPage, RouteContext, RouteParams, RouteType}, 89 - BuildOptions, 90 - }; 91 - 92 42 pub fn build_website( 93 43 routes: &[&dyn FullPage], 94 44 mut content_sources: ContentSources, 95 45 options: BuildOptions, 96 46 ) -> Result<(), Box<dyn std::error::Error>> { 97 - let dist_dir = PathBuf::from_str(&options.output_dir)?; 47 + 48 + // Options we'll be passing to PageAssets instances. 49 + // This value automatically has the paths joined based on the output directory in BuildOptions for us, so we don't have to do it ourselves. 50 + let page_assets_options = options.page_assets_options(); 98 51 99 52 for route in routes { 100 53 match route.route_type() { 101 54 RouteType::Static => { 102 55 // Our page does not include content or assets, but we'll set those up for future use. 103 56 let content = PageContent::new(&content_sources); 104 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 57 + let mut page_assets = PageAssets::new(&page_assets_options); 105 58 106 59 // Static and dynamic routes share the same interface for building, but static routes do not require any parameters. 107 60 // As such, we can just pass an empty set of parameters (the default for RouteParams). 108 61 let params = RouteParams::default(); 109 62 110 63 // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets. 111 - let mut ctx = RouteContext::from_static_route( 112 - &content, 113 - &mut page_assets, 114 - route.url(&params).clone(), 115 - ); 64 + let url = route.url(&params); 65 + let mut ctx = RouteContext::from_static_route(&content, &mut page_assets, &url); 116 66 117 67 let content = route.build(&mut ctx)?; 118 68 119 - // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir. 120 - let final_filepath = dist_dir.join(route.file_path(&params)); 69 + let route_filepath = route.file_path(&params, &options.output_dir); 121 70 122 - // On some platforms, creating a file in a nested directory requires that the directory already exists or `fs::write` will fail. 123 - if let Some(parent_dir) = final_filepath.parent() { 124 - fs::create_dir_all(parent_dir)? 71 + // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail. 72 + if let Some(parent_dir) = route_filepath.parent() { 73 + fs::create_dir_all(parent_dir)? 125 74 } 126 75 127 - fs::write(final_filepath, content)?; 76 + fs::write(route_filepath, content)?; 128 77 } 129 78 RouteType::Dynamic => { 130 79 unimplemented!("We'll handle dynamic routes later"); ··· 136 85 } 137 86 ``` 138 87 139 - And with just this code, we can already build our first page! Running `cargo run` should create a `dist/index.html` file with the content `Hello, Maudit!`. But, if you try to use assets, you'll notice that they are not copied to the output directory. And similarly, if you try to use content, you'll notice that it'll always throw an error saying that the content source is not found. Let's fix that! 88 + And with just this code, we can already build our first page! Adding a static Maudit page to the routes and running your custom entrypoint will generate the page in the output directory, as expected. 89 + 90 + But, if you try to use assets, you'll notice that your pages are pointing to non-existing assets. And similarly, if you try to use content in your page, you'll never be able to get any entries from your sources. Let's fix that! 140 91 141 92 ## Handling assets 142 93 143 - We won't be implementing any asset processing (like bundling or minification) in this guide, but we'll be implementing the logic to copy assets from the assets directory to the output directory. 94 + Implementing asset processing is a bit outside of the scope of this guide, but we'll at least make sure that assets are working by copying them to the output directory. 95 + 96 + This can be done by iterating over the assets registered in `page_assets` and copying them to their build path after having called `route.build()` (which registers the assets used by the page): 144 97 145 98 ```rs 146 - pub fn build_website( 147 - routes: &[&dyn FullPage], 148 - mut content_sources: ContentSources, 149 - options: BuildOptions, 150 - ) -> Result<(), Box<dyn std::error::Error>> { 151 - let dist_dir = PathBuf::from_str(&options.output_dir)?; 152 - 153 - let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new(); 154 - 155 - for route in routes { 156 - match route.route_type() { 157 - RouteType::Static => { 158 - // ... Same as before ... 159 - 160 - // Collect all assets used by this page. 161 - all_assets.extend(page_assets.assets().map(|asset| { 162 - ( 163 - dist_dir.join(asset.build_path()), 164 - asset.path().to_path_buf(), 165 - ) 166 - })); 167 - } 168 - RouteType::Dynamic => { 169 - unimplemented!("We'll handle dynamic routes later"); 170 - } 171 - } 172 - } 173 - 174 - // Copy all assets to the output directory. 175 - for (dest_path, src_path) in all_assets { 176 - // Similar to pages, we need to ensure the parent directory exists or `fs::copy` will fail. 177 - if let Some(parent) = dest_path.parent() { 178 - fs::create_dir_all(parent)?; 179 - } 180 - fs::copy(src_path, dest_path)?; 181 - } 182 - 183 - Ok(()) 99 + for asset in page_assets.assets() { 100 + fs::copy(asset.path(), asset.build_path())?; 184 101 } 185 102 ``` 186 103 187 - This is enough to get us started with assets. Any pages using assets will now have them copied to the output directory. This is a basic implementation, but it is all we need to get assets working. `route.build` already takes care of automatically including scripts and styles in the page, so we don't need to do anything special for that. Additionally, `asset.build_path()` already takes care of adding hashes to filenames in a performant way, so we don't need to worry about that either. 104 + And that's it! Now, any asset used in a page will be copied to the output directory when building the page. Onto content. 188 105 189 106 ## Handling content 190 107 191 - As said previously, our current implementation does not handle content sources. Currently any pages trying to use content would panic with an error saying the requested content source does not exist. To fix this, we need to make sure that the content sources are properly loaded before building the pages. 108 + In the current implementation, trying to use content will result in an empty list of entries. Despite what the syntax might suggest, content sources are not automatically initialized when creating a `ContentSources` instance through the `content_sources![]` macro. 109 + 110 + If you've copied the previous snippets, you might have noticed that Rust has been complaining about `content_sources` being mutable but never mutated. 192 111 193 - If you've copied the previous snippets, you might have noticed that Rust has been complaining about `content_sources` being mutable but never mutated. We'll fix that now by initializing each content source before building the pages: 112 + We'll fix that now by initializing each content source by adding the following line before the loop over routes: 194 113 195 114 ```rs 196 115 content_sources.init_all(); 197 116 ``` 198 117 199 - That's all. Now, any content source used in a page will be properly loaded and available for use. This is the most straightforward way to initialize content sources, but a more advanced implementation could for instance initialize content sources in parallel, lazily when a page actually requests content from a source or using advanced caching strategies. 118 + That's all! Now, any content source used in a page will be properly loaded and available for use. This is the most straightforward way to initialize content sources, but a more advanced implementation could for instance initialize content sources in parallel, lazily when a page actually requests content from a source or using advanced caching strategies. 200 119 201 120 ## Dynamic routes 202 121 203 - A dynamic route is a route that generates multiple pages based on some parameters. For instance, a blog post page might have a dynamic route `/posts/[id]`, where `[id]` is a parameter that can take different values for each blog post. Each individual page is essentially a static route, but it has a slightly different context available to it. 122 + A dynamic route is a route that generates multiple pages based on parameters. For instance, a blog might have a dynamic route `/posts/[slug]`, where `[slug]` is a parameter that can take different values for each blog post. 123 + 124 + Each individual page is essentially a static route, but it has a slightly different context available to it. 204 125 205 126 ```rs 206 - for route in routes { 207 - match route.route_type() { 208 - RouteType::Static => { 209 - // No changes here, same as before. 210 - } 211 - RouteType::Dynamic => { 212 - // The `routes` method returns all the possible routes for this page, along with their parameters and properties. 213 - // It is very common for dynamic pages to be based on content, for instance a blog post page that has one route per blog post. 214 - // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes. 127 + // No changes before this block. 215 128 216 - let dynamic_ctx = DynamicRouteContext { 217 - content: &PageContent::new(&content_sources), 218 - }; 129 + RouteType::Dynamic => { 130 + // The `get_routes` method returns all the possible routes for this page, along with their parameters and properties. 131 + // It is very common for dynamic pages to be based on content, for instance a blog post page that has one route per blog post. 132 + // As such, we create essentially a mini `RouteContext` through `DynamicRouteContext` that includes the content sources, so that the page can use them to generate its routes. 219 133 220 - let routes = route.routes_internal(&dynamic_ctx); 134 + let dynamic_ctx = DynamicRouteContext { 135 + content: &PageContent::new(&content_sources), 136 + }; 221 137 222 - // Every page can share the same PageContent instance, as it is just a view into the content sources. 223 - let content = PageContent::new(&content_sources); 138 + let routes = route.get_routes(&dynamic_ctx); 224 139 225 - for dynamic_route in routes { 226 - // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route. 227 - // This is especially relevant if we were to parallelize this loop in the future. 228 - let mut page_assets = PageAssets::new(options.assets_dir.clone().into()); 140 + // Every page can share a reference to the same PageContent instance, as it is just a view into the content sources. 141 + let content = PageContent::new(&content_sources); 229 142 230 - // The dynamic route includes the parameters for this specific route. 231 - let params = &dynamic_route.0; 143 + for dynamic_route in routes { 144 + // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route. 145 + // This is especially relevant if we were to parallelize this loop in the future. 146 + let mut page_assets = PageAssets::new(&page_assets_options); 232 147 233 - // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 234 - let mut ctx = RouteContext::from_dynamic_route( 235 - &dynamic_route, 236 - &content, 237 - &mut page_assets, 238 - route.url(params), 239 - ); 148 + // The dynamic route includes the parameters for this specific route. 149 + let params = &dynamic_route.0; 240 150 241 - // Everything after this is the same as for static routes, making sure to use the route parameters when getting the file path. 242 - } 243 - } 151 + // Here the context is created from a dynamic route, as the context has to include the route parameters and properties. 152 + let url = route.url(params); 153 + let mut ctx = RouteContext::from_dynamic_route( 154 + &dynamic_route, 155 + &content, 156 + &mut page_assets, 157 + &url, 158 + ); 159 + 160 + // Everything after this is the same as for static routes. 244 161 } 245 162 } 246 163 ``` 247 164 248 - And with that, you've succesfully rebuilt Maudit at home! There's a few more things that can be done to improve this implementation, like adding logging, copying static assets, asset processing, better error handling, parallelization, caching, etc, etc. But, this is a fully functional implementation that can be used as a starting point for more advanced use cases... or just as a learning exercise to understand how Maudit works under the hood. 165 + ## Conclusion 166 + 167 + And with that, you've succesfully rebuilt Maudit at home! There's a few more things that can be done to improve this implementation, like adding logging, copying static assets (from `options.static_dir`), asset processing, better error handling, parallelization, caching, etc, etc. 168 + 169 + But, this is a fully functional implementation that can be used as a starting point for more advanced use cases... or just as a learning exercise to understand how Maudit works under the hood.
+1 -1
website/src/layout/docs_sidebars.rs
··· 41 41 h2.text-lg.font-bold { (section) } 42 42 ul { 43 43 @for entry in entries { 44 - @let url = format!("/docs/{}", entry.id); 44 + @let url = &format!("/docs/{}", entry.id); 45 45 @let is_current_page = url == ctx.current_url; 46 46 li."border-l-2"."hover:border-brand-red"."pl-3"."py-1".(if is_current_page { "text-brand-red border-brand-red" } else { "border-borders" }) { 47 47 a.block href=(format!("/docs/{}/", entry.id)) { (entry.data(ctx).title) } // TODO: Use type-safe routing
+5 -2
website/src/main.rs
··· 1 1 use content::content_sources; 2 - use maudit::{coronate, routes, BuildOptions, BuildOutput}; 2 + use maudit::{coronate, routes, AssetsOptions, BuildOptions, BuildOutput}; 3 3 4 4 mod content; 5 5 mod layout; ··· 21 21 ], 22 22 content_sources(), 23 23 BuildOptions { 24 - tailwind_binary_path: "../node_modules/.bin/tailwindcss".to_string(), 24 + assets: AssetsOptions { 25 + tailwind_binary_path: "../node_modules/.bin/tailwindcss".into(), 26 + ..Default::default() 27 + }, 25 28 ..Default::default() 26 29 }, 27 30 )