···11-# Sampo
22-33-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!
44-55-## Getting Started
66-77-Install Sampo using Cargo:
88-99-```bash
1010-cargo install sampo
1111-```
1212-1313-Initialize Sampo in your repository:
1414-1515-```bash
1616-sampo init
1717-```
1818-1919-This command creates a `.sampo` directory at your repository root:
2020-2121-```
2222-.sampo/
2323-├─ changesets/ <- Individual changeset files describing pending changes
2424-├─ config.toml <- Sampo configuration (package settings, registry options)
2525-└─ README.md <- A copy of this documentation
2626-```
2727-2828-### Main concepts
2929-3030-**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:
3131-- **patch**: Bug fixes and backwards-compatible changes
3232-- **minor**: New features that are backwards-compatible
3333-- **major**: Breaking changes that are not backwards-compatible
3434-3535-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).
3636-3737-**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.
3838-3939-```
4040----
4141-packages:
4242- - example
4343-release: minor
4444----
4545-4646-A helpful description of the changes.
4747-```
4848-4949-**Changelog**: Automatically generated file listing all changes for each package version. Sampo consumes changesets to build comprehensive changelogs with semantic versioning.
5050-5151-**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.
5252-5353-**Internal dependencies**: Sampo detects packages within the same repository that depend on each other and automatically manages their versions.
5454- - 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.
5555- - **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`.
5656- - **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).
5757-5858-### Usage
5959-6060-**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.
6161-6262-**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).
6363-6464-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.
6565-6666-**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).
6767-6868-## Configuration
6969-7070-The `.sampo/config.toml` file allows you to customize Sampo's behavior. Example configuration:
7171-7272-```toml
7373-[github]
7474-repository = "owner/repo"
7575-7676-[changelog]
7777-show_commit_hash = true
7878-show_acknowledgments = true
7979-8080-[packages]
8181-fixed_dependencies = [["pkg-a", "pkg-b"], ["pkg-c", "pkg-d"]]
8282-linked_dependencies = [["pkg-e", "pkg-f"], ["pkg-g", "pkg-h"]]
8383-```
8484-8585-### `[github]` section
8686-8787-`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.
8888-8989-### `[changelog]` section
9090-9191-`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.
9292-9393-`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.
9494-9595-### `[packages]` section
9696-9797-`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.
9898-9999-`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.
100100-101101-Note: Packages cannot appear in both `fixed_dependencies` and `linked_dependencies` configurations.
102102-103103-## Commands
104104-105105-All commands should be run from the root of the repository:
106106-107107-| Command | Description |
108108-| --------------- | ------------------------------------------------------------------------- |
109109-| `sampo help` | Show commands or the help of the given subcommand(s) |
110110-| `sampo init` | Initialize Sampo in the current repository |
111111-| `sampo add` | Create a new changeset |
112112-| `sampo release` | Consume changesets, and prepare release(s) (bump versions and changelogs) |
113113-| `sampo publish` | Publish packages to registries and tag current versions |
114114-115115-For detailed command options, use `sampo help <command>` or `sampo <command> --help`.
···55 io::{self},
66 path::{Path, PathBuf},
77 process::Command,
88- str::FromStr,
98 sync::Arc,
109 time::{Instant, SystemTime, UNIX_EPOCH},
1110};
···44434544#[derive(Debug)]
4645struct TailwindPlugin {
4747- tailwind_path: String,
4646+ tailwind_path: PathBuf,
4847 tailwind_entries: Vec<PathBuf>,
4948}
5049···9392 };
9493 panic!(
9594 "Failed to execute Tailwind CSS command, is it installed and is the path to its binary correct?\nCommand: '{}', Args: {}. Error: {}",
9696- &self.tailwind_path,
9595+ &self.tailwind_path.display(),
9796 args_str,
9897 e
9998 )
···156155157156 // Create a directory for the output
158157 trace!(target: "build", "Setting up required directories...");
159159- let dist_dir = PathBuf::from_str(&options.output_dir)?;
160160- let final_assets_dir = PathBuf::from_str(&options.output_dir)?.join(&options.assets_dir);
161161- let static_dir = PathBuf::from_str(&options.static_dir)?;
162158163159 let old_dist_tmp_dir = if options.clean_output_dir {
164160 let duration = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
165161 let num = (duration.as_secs() + duration.subsec_nanos() as u64) % 100000;
166162 let new_dir_for_old_dist = env::temp_dir().join(format!("maudit_old_dist_{}", num));
167167- let _ = fs::rename(&dist_dir, &new_dir_for_old_dist);
163163+ let _ = fs::rename(&options.output_dir, &new_dir_for_old_dist);
168164 Some(new_dir_for_old_dist)
169165 } else {
170166 None
···177173 }
178174 });
179175180180- fs::create_dir_all(&dist_dir)?;
181181- fs::create_dir_all(&final_assets_dir)?;
176176+ let page_assets_options = options.page_assets_options();
182177183183- info!(target: "build", "Output directory: {}", dist_dir.to_string_lossy());
178178+ info!(target: "build", "Output directory: {}", options.output_dir.display());
184179185180 let content_sources_start = Instant::now();
186181 print_title("initializing content sources");
···234229 let route_start = Instant::now();
235230236231 let content = PageContent::new(content_sources);
237237- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
232232+ let mut page_assets = PageAssets::new(&page_assets_options);
238233239234 let params = RouteParams::default();
240235 let url = route.url(¶ms);
···242237 let result = route.build(&mut RouteContext::from_static_route(
243238 &content,
244239 &mut page_assets,
245245- url.clone(),
240240+ &url,
246241 ))?;
247242248248- let file_path = &dist_dir.join(route.file_path(¶ms));
243243+ let file_path = route.file_path(¶ms, &options.output_dir);
249244250250- write_route_file(&result, file_path)?;
245245+ write_route_file(&result, &file_path)?;
251246252247 info!(target: "pages", "{} -> {} {}", url, file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options));
253248···264259 page_count += 1;
265260 }
266261 RouteType::Dynamic => {
267267- let routes = route.routes_internal(&DynamicRouteContext {
262262+ let routes = route.get_routes(&DynamicRouteContext {
268263 content: &PageContent::new(content_sources),
269264 });
270265···279274 for dynamic_route in routes {
280275 let route_start = Instant::now();
281276282282- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
277277+ let mut page_assets = PageAssets::new(&page_assets_options);
283278284279 let url = route.url(&dynamic_route.0);
285280···287282 &dynamic_route,
288283 &content,
289284 &mut page_assets,
290290- url,
285285+ &url,
291286 ))?;
292287293293- let file_path = &dist_dir.join(route.file_path(&dynamic_route.0));
288288+ let file_path = route.file_path(&dynamic_route.0, &options.output_dir);
294289295295- write_route_file(&content, file_path)?;
290290+ write_route_file(&content, &file_path)?;
296291297292 info!(target: "pages", "├─ {} {}", file_path.to_string_lossy().dimmed(), format_elapsed_time(route_start.elapsed(), &route_format_options));
298293···313308 }
314309315310 info!(target: "pages", "{}", format!("generated {} pages in {}", page_count, format_elapsed_time(pages_start.elapsed(), §ion_format_options)).bold());
311311+312312+ if (!build_pages_images.is_empty())
313313+ || !build_pages_styles.is_empty()
314314+ || !build_pages_scripts.is_empty()
315315+ {
316316+ fs::create_dir_all(&page_assets_options.assets_dir)?;
317317+ }
316318317319 if !build_pages_styles.is_empty() || !build_pages_scripts.is_empty() {
318320 let assets_start = Instant::now();
···363365 BundlerOptions {
364366 input: Some(bundler_inputs),
365367 minify: Some(rolldown::RawMinifyOptions::Bool(!is_dev())),
366366- dir: Some(final_assets_dir.to_string_lossy().to_string()),
368368+ dir: Some(page_assets_options.assets_dir.to_string_lossy().to_string()),
367369 module_types: Some(module_types_hashmap),
368368-369370 ..Default::default()
370371 },
371372 vec).
26///
37/// ## Examples
···2731/// content_sources![],
2832/// BuildOptions {
2933/// output_dir: "public".to_string(),
3030-/// assets_dir: "_assets".to_string(),
3134/// static_dir: "static".to_string(),
3232-/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".to_string(),
3535+/// assets: AssetsOptions {
3636+/// assets_dir: "_assets".to_string(),
3737+/// tailwind_binary_path: "./node_modules/.bin/tailwindcss".to_string(),
3838+/// ..Default::default()
3939+/// },
3340/// ..Default::default()
3441/// },
3542/// )
3643/// }
3744/// ```
3845pub struct BuildOptions {
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.
4545- pub tailwind_binary_path: String,
4646+ pub output_dir: PathBuf,
4747+ pub static_dir: PathBuf,
4848+4649 /// Whether to clean the output directory before building.
4750 ///
4851 /// 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.
4952 pub clean_output_dir: bool,
5353+5454+ pub assets: AssetsOptions,
5555+}
5656+5757+impl BuildOptions {
5858+ /// Returns the fully resolved assets options, with the `assets_dir` set to be inside the `output_dir`.
5959+ /// e.g. if `output_dir` is `dist` and `assets.assets_dir` is `_maudit`, this will return `dist/_maudit`.
6060+ pub fn page_assets_options(&self) -> PageAssetsOptions {
6161+ PageAssetsOptions {
6262+ assets_dir: self.output_dir.join(&self.assets.assets_dir),
6363+ hashing_strategy: self.assets.hashing_strategy,
6464+ }
6565+ }
6666+}
6767+6868+#[derive(Clone)]
6969+pub struct AssetsOptions {
7070+ /// 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`.
7171+ ///
7272+ /// This is commonly set to `./node_modules/.bin/tailwindcss` or similar, in order to use a locally installed version.
7373+ pub tailwind_binary_path: PathBuf,
7474+7575+ /// Directory inside the output directory to place built assets in.
7676+ /// Defaults to `_maudit`.
7777+ ///
7878+ /// 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.
7979+ pub assets_dir: PathBuf,
8080+8181+ /// Strategy to use when hashing assets for fingerprinting.
8282+ ///
8383+ /// 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.
8484+ pub hashing_strategy: AssetHashingStrategy,
8585+}
8686+8787+#[derive(PartialEq, Eq, Clone, Copy)]
8888+pub enum AssetHashingStrategy {
8989+ /// Hash assets based on their full content, path and options (e.g. whether TailwindCSS is enabled for styles).
9090+ Precise,
9191+ /// 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.
9292+ FastImprecise,
9393+}
9494+9595+impl Default for AssetsOptions {
9696+ fn default() -> Self {
9797+ Self {
9898+ tailwind_binary_path: "tailwindcss".into(),
9999+ assets_dir: "_maudit".into(),
100100+ hashing_strategy: if is_dev() {
101101+ AssetHashingStrategy::FastImprecise
102102+ } else {
103103+ AssetHashingStrategy::Precise
104104+ },
105105+ }
106106+ }
50107}
5110852109/// Provides default values for [`crate::coronate()`]. Designed to work for most projects.
···68125impl Default for BuildOptions {
69126 fn default() -> Self {
70127 Self {
7171- output_dir: "dist".to_string(),
7272- assets_dir: "_maudit".to_string(),
7373- static_dir: "static".to_string(),
7474- tailwind_binary_path: "tailwindcss".to_string(),
128128+ output_dir: "dist".into(),
129129+ static_dir: "static".into(),
75130 clean_output_dir: true,
131131+ assets: AssetsOptions::default(),
76132 }
77133 }
78134}
+1-1
crates/maudit/src/content.rs
···196196 }
197197}
198198199199-/// Represents a single entry in a [`ContentSource`].
199199+/// A single entry of a [`ContentSource`].
200200///
201201/// ## Example
202202/// ```rs
+3-3
crates/maudit/src/content/highlight.rs
···11use std::sync::OnceLock;
22use syntect::{
33+ Error,
34 easy::HighlightLines,
45 highlighting::ThemeSet,
55- html::{styled_line_to_highlighted_html, IncludeBackground},
66+ html::{IncludeBackground, styled_line_to_highlighted_html},
67 parsing::SyntaxSet,
78 util::LinesWithEndings,
88- Error,
99};
10101111static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
···88888989 let mut highlighted = String::new();
9090 for line in LinesWithEndings::from(content) {
9191- let regions = h.highlight_line(line, ss).unwrap();
9191+ let regions = h.highlight_line(line, ss)?;
9292 let html = styled_line_to_highlighted_html(®ions, IncludeBackground::No)?; // TODO: Handle the background coloring
9393 highlighted.push_str(&html);
9494 }
+1-1
crates/maudit/src/content/markdown/components.rs
···483483#[cfg(test)]
484484mod tests {
485485 use super::*;
486486- use crate::content::{MarkdownOptions, render_markdown};
486486+ use crate::content::{render_markdown, MarkdownOptions};
487487488488 struct TestCustomHeading;
489489
···26262727#[derive(Error)]
2828pub enum BuildError {
2929- #[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.")]
2929+ #[error(
3030+ "`{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."
3131+ )]
3032 InvalidRenderResult { route: String },
3133}
3234
+11-3
crates/maudit/src/lib.rs
···15151616// Exports for end-users
1717pub use build::metadata::{BuildOutput, PageOutput, StaticAssetOutput};
1818-pub use build::options::BuildOptions;
1818+pub use build::options::{AssetHashingStrategy, AssetsOptions, BuildOptions};
19192020-// Re-exported dependencies for user convenience
2020+// Re-export FxHashMap so that macro-generated code can use it without requiring users to add it as a dependency.
2121+#[doc(hidden)]
2122pub use rustc_hash::FxHashMap;
22232324mod build;
···2627#[cfg(feature = "maud")]
2728#[cfg_attr(docsrs, doc(cfg(feature = "maud")))]
2829pub mod maud {
2929- //! Allows to use [Maud](https://maud.lambda.xyz), a macro for writing HTML templates, ergonomically in your Maudit pages.
3030+ //! Traits and methods for [Maud](https://maud.lambda.xyz), a macro for writing HTML templates.
3031 //!
3132 //! ## Example
3233 //! ```rs
···174175/// The version of Maudit being used.
175176///
176177/// Can be used to create a generator tag in the output HTML.
178178+///
179179+/// ## Example
180180+/// ```rs
181181+/// use maudit::GENERATOR;
182182+///
183183+/// format!("<meta name=\"generator\" content=\"{}\">", GENERATOR);
184184+/// ```
177185pub const GENERATOR: &str = concat!("Maudit v", env!("CARGO_PKG_VERSION"));
178186179187/// 👑 Maudit entrypoint. Starts the build process and generates the output files.
+43-14
crates/maudit/src/page.rs
···99};
1010use rustc_hash::FxHashMap;
1111use std::any::Any;
1212+use std::path::{Path, PathBuf};
12131313-/// Represents the result of a page render, can be either text or raw bytes.
1414+/// The result of a page render, can be either text or raw bytes.
1415///
1516/// Typically used through the [`Into<RenderResult>`](std::convert::Into) and [`From<RenderResult>`](std::convert::From) implementations for common types.
1617/// End users should rarely need to interact with this enum directly.
···210211 pub props: &'a dyn Any,
211212 pub content: &'a PageContent<'a>,
212213 pub assets: &'a mut PageAssets,
213213- pub current_url: String,
214214+ pub current_url: &'a String,
214215}
215216216217impl<'a> RouteContext<'a> {
217218 pub fn from_static_route(
218219 content: &'a PageContent,
219220 assets: &'a mut PageAssets,
220220- current_url: String,
221221+ current_url: &'a String,
221222 ) -> Self {
222223 Self {
223224 params: &(),
···232233 dynamic_route: &'a RouteResult,
233234 content: &'a PageContent,
234235 assets: &'a mut PageAssets,
235235- current_url: String,
236236+ current_url: &'a String,
236237 ) -> Self {
237238 Self {
238239 params: dynamic_route.1.as_ref(),
···425426 route
426427 }
427428428428- fn file_path(&self, params: &RouteParams) -> String {
429429+ fn file_path(&self, params: &RouteParams, output_dir: &Path) -> PathBuf {
429430 let params_def = extract_params_from_raw_route(&self.route_raw());
430431 let mut route = self.route_raw();
431432···448449449450 let cleaned_raw_route = route.trim_start_matches('/').to_string();
450451451451- match self.is_endpoint() {
452452+ output_dir.join(match self.is_endpoint() {
452453 true => cleaned_raw_route,
453454 false => match cleaned_raw_route.is_empty() {
454454- true => "index.html".to_string(),
455455+ true => "index.html".into(),
455456 false => format!("{}/index.html", cleaned_raw_route),
456457 },
457457- }
458458+ })
459459+ }
460460+}
461461+462462+/// Extension trait providing generic convenience methods on an instance of a page
463463+pub trait PageExt<Params = RouteParams, Props = (), T = RenderResult>:
464464+ Page<Params, Props, T> + InternalPage
465465+where
466466+ Params: Into<RouteParams>,
467467+ Props: 'static,
468468+ T: Into<RenderResult>,
469469+{
470470+ /// Get the URL for this page with the given parameters
471471+ ///
472472+ /// 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.
473473+ fn url(&self, params: Params) -> String {
474474+ InternalPage::url(self, ¶ms.into())
458475 }
476476+}
477477+478478+// Blanket implementation for all Page implementors that also implement InternalPage
479479+impl<U, Params, Props, T> PageExt<Params, Props, T> for U
480480+where
481481+ U: Page<Params, Props, T> + InternalPage,
482482+ Params: Into<RouteParams>,
483483+ Props: 'static,
484484+ T: Into<RenderResult>,
485485+{
459486}
460487461488/// Used internally by Maudit and should not be implemented by the user.
462489/// We expose it because [`maudit_macros::route`] implements it for the user behind the scenes.
463490pub trait FullPage: InternalPage + Sync + Send {
491491+ #[doc(hidden)]
464492 fn render_internal(&self, ctx: &mut RouteContext) -> RenderResult;
493493+ #[doc(hidden)]
465494 fn routes_internal(&self, context: &DynamicRouteContext) -> RoutesResult;
495495+496496+ fn get_routes(&self, context: &DynamicRouteContext) -> RoutesResult {
497497+ self.routes_internal(context)
498498+ }
466499467500 fn build(&self, ctx: &mut RouteContext) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
468501 let result = self.render_internal(ctx);
···478511pub type RouteProps = Box<dyn Any + Send + Sync>;
479512pub type RouteTypedParams = Box<dyn Any + Send + Sync>;
480513481481-pub fn get_page_url<T: Into<RouteParams>>(route: &impl FullPage, params: T) -> String {
482482- format!("/{}", route.url(¶ms.into()).trim_start_matches('/'))
483483-}
484484-485514pub mod prelude {
486515 //! Re-exports of the most commonly used types and traits for defining pages.
487516 //!
···492521 //! use maudit::page::prelude::*;
493522 //! ```
494523 pub use super::{
495495- DynamicRouteContext, Page, PaginationMeta, RenderResult, Route, RouteContext, RouteParams,
496496- Routes, get_page_slice, get_page_url, paginate_content,
524524+ DynamicRouteContext, FullPage, Page, PageExt, PaginationMeta, RenderResult, Route,
525525+ RouteContext, RouteParams, Routes, get_page_slice, paginate_content,
497526 };
498527 pub use crate::assets::{Asset, Image, Style, StyleOptions};
499528 pub use crate::content::MarkdownContent;
+26-34
crates/maudit/src/route.rs
···11111212pub fn extract_params_from_raw_route(raw_route: &str) -> Vec<ParameterDef> {
1313 let mut params = Vec::new();
1414- let mut start = false;
1515- let mut escape = false;
1616- let mut current_value = String::new();
1414+ let mut start = 0;
17151818- for (i, c) in raw_route.char_indices() {
1919- if escape {
2020- escape = false;
2121- if start {
2222- current_value.push(c);
2323- }
1616+ while let Some(bracket_pos) = raw_route[start..].find('[') {
1717+ let abs_pos = start + bracket_pos;
1818+1919+ // Check if escaped by counting preceding backslashes
2020+ let backslash_count = raw_route[..abs_pos]
2121+ .chars()
2222+ .rev()
2323+ .take_while(|&c| c == '\\')
2424+ .count();
2525+2626+ if backslash_count % 2 == 1 {
2727+ start = abs_pos + 1;
2428 continue;
2529 }
26302727- match c {
2828- '\\' => {
2929- escape = true;
3030- }
3131- '[' => {
3232- if !escape {
3333- start = true;
3434- current_value.clear();
3535- }
3636- }
3737- ']' => {
3838- if start {
3939- params.push(ParameterDef {
4040- key: current_value.clone(),
4141- index: i - (current_value.len() + 1), // -1 for the starting [
4242- length: current_value.len() + 2, // +2 for the [ and ]
4343- });
4444- start = false;
4545- }
4646- }
4747- _ => {
4848- if start {
4949- current_value.push(c);
5050- }
5151- }
3131+ if let Some(end_bracket) = raw_route[abs_pos + 1..].find(']') {
3232+ let end_pos = abs_pos + 1 + end_bracket;
3333+ let key = raw_route[abs_pos + 1..end_pos].to_string();
3434+3535+ params.push(ParameterDef {
3636+ key,
3737+ index: abs_pos,
3838+ length: end_pos - abs_pos + 1,
3939+ });
4040+4141+ start = end_pos + 1;
4242+ } else {
4343+ break;
5244 }
5345 }
5446
···11-use std::path::PathBuf;
22-use std::str::FromStr;
33-use std::{collections::HashSet, fs};
11+use std::fs;
4255-use maudit::page::DynamicRouteContext;
63use maudit::{
74 assets::PageAssets,
85 content::{ContentSources, PageContent},
99- page::{FullPage, RouteContext, RouteParams, RouteType},
66+ page::{DynamicRouteContext, FullPage, RouteContext, RouteParams, RouteType},
107 BuildOptions,
118};
129···1512 mut content_sources: ContentSources,
1613 options: BuildOptions,
1714) -> Result<(), Box<dyn std::error::Error>> {
1818- let dist_dir = PathBuf::from_str(&options.output_dir)?;
1919-2015 // Initialize all the content sources;
2116 content_sources.init_all();
22172323- let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new();
1818+ // Options we'll be passing to PageAssets instances.
1919+ // 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.
2020+ let page_assets_options = options.page_assets_options();
2121+2222+ // Create the assets directory if it doesn't exist.
2323+ fs::create_dir_all(&page_assets_options.assets_dir)?;
24242525 for route in routes {
2626 match route.route_type() {
2727 RouteType::Static => {
2828 // Our page does not include content or assets, but we'll set those up for future use.
2929 let content = PageContent::new(&content_sources);
3030- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
3030+ let mut page_assets = PageAssets::new(&page_assets_options);
31313232 // Static and dynamic routes share the same interface for building, but static routes do not require any parameters.
3333 // As such, we can just pass an empty set of parameters (the default for RouteParams).
3434 let params = RouteParams::default();
35353636 // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets.
3737- let mut ctx = RouteContext::from_static_route(
3838- &content,
3939- &mut page_assets,
4040- route.url(¶ms).clone(),
4141- );
3737+ let url = route.url(¶ms);
3838+ let mut ctx = RouteContext::from_static_route(&content, &mut page_assets, &url);
42394340 let content = route.build(&mut ctx)?;
44414545- // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir.
4646- let final_filepath = dist_dir.join(route.file_path(¶ms));
4242+ let route_filepath = route.file_path(¶ms, &options.output_dir);
47434848- // On some platforms, creating a file in a nested directory requires that the directory already exists.
4949- if let Some(parent_dir) = final_filepath.parent() {
4444+ // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail.
4545+ if let Some(parent_dir) = route_filepath.parent() {
5046 fs::create_dir_all(parent_dir)?
5147 }
52485353- fs::write(final_filepath, content)?;
4949+ fs::write(route_filepath, content)?;
54505555- // Collect all assets used by this page.
5656- all_assets.extend(page_assets.assets().map(|asset| {
5757- (
5858- dist_dir.join(asset.build_path()),
5959- asset.path().to_path_buf(),
6060- )
6161- }));
5151+ // Copy all assets used by this page.
5252+ for asset in page_assets.assets() {
5353+ fs::copy(asset.path(), asset.build_path())?;
5454+ }
6255 }
6356 RouteType::Dynamic => {
6464- // The `routes` method returns all the possible routes for this page, along with their parameters and properties.
5757+ // The `get_routes` method returns all the possible routes for this page, along with their parameters and properties.
6558 // 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.
6659 // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes.
6760···6962 content: &PageContent::new(&content_sources),
7063 };
71647272- let routes = route.routes_internal(&dynamic_ctx);
6565+ let routes = route.get_routes(&dynamic_ctx);
73667474- // Every page can share the same PageContent instance, as it is just a view into the content sources.
6767+ // Every page can share a reference to the same PageContent instance, as it is just a view into the content sources.
7568 let content = PageContent::new(&content_sources);
76697770 for dynamic_route in routes {
7871 // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route.
7972 // This is especially relevant if we were to parallelize this loop in the future.
8080- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
7373+ let mut page_assets = PageAssets::new(&page_assets_options);
81748275 // The dynamic route includes the parameters for this specific route.
8376 let params = &dynamic_route.0;
84778578 // Here the context is created from a dynamic route, as the context has to include the route parameters and properties.
7979+ let url = route.url(params);
8680 let mut ctx = RouteContext::from_dynamic_route(
8781 &dynamic_route,
8882 &content,
8983 &mut page_assets,
9090- route.url(params),
8484+ &url,
9185 );
92869393- // Everything from here is the same as for static routes.
8787+ // Everything below here is the same as for static routes.
8888+9489 let content = route.build(&mut ctx)?;
95909696- let final_file_path = &dist_dir.join(route.file_path(params));
9191+ let route_filepath = route.file_path(params, &options.output_dir);
97929898- if let Some(parent_dir) = final_file_path.parent() {
9393+ if let Some(parent_dir) = route_filepath.parent() {
9994 fs::create_dir_all(parent_dir)?
10095 }
10196102102- fs::write(final_file_path, content)?;
9797+ fs::write(route_filepath, content)?;
10398104104- // Collect all assets used by this page.
105105- all_assets.extend(page_assets.assets().map(|asset| {
106106- (
107107- dist_dir.join(asset.build_path()),
108108- asset.path().to_path_buf(),
109109- )
110110- }));
9999+ for asset in page_assets.assets() {
100100+ fs::copy(asset.path(), asset.build_path())?;
101101+ }
111102 }
112103 }
113104 }
114114- }
115115-116116- // Copy all assets to the output directory.
117117- for (dest_path, src_path) in all_assets {
118118- if let Some(parent) = dest_path.parent() {
119119- fs::create_dir_all(parent)?;
120120- }
121121- fs::copy(src_path, dest_path)?;
122105 }
123106124107 Ok(())
+1-1
examples/library/src/pages/index.rs
···2020 ul {
2121 @for entry in &articles.entries {
2222 li {
2323- a href=(get_page_url(&Article, ArticleParams { article: entry.id.clone() })) {
2323+ a href=(&Article.url(ArticleParams { article: entry.id.clone() })) {
2424 h2 { (entry.data(ctx).title) }
2525 }
2626 p { (entry.data(ctx).description) }
···166166167167## Markdown rendering
168168169169-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.
169169+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.
170170171171### Shortcodes
172172
+73-152
website/content/docs/library.md
···6677[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.
8899-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.
99+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.
10101111> The result of this guide is available in the [library example](https://github.com/bruits/maudit/tree/main/examples/library) in the Maudit repository.
12121313-## Setting up the project
1313+## Function signature
14141515-We'll start by creating a new Rust project with Maudit as a dependency:
1616-1717-```bash
1818-cargo new library --bin
1919-cd library
2020-cargo add maudit
2121-```
2222-2323-and we'll create a simple Maudit page in `src/pages/index.rs`:
1515+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.
24162517```rs
2626-use maudit::page::prelude::*;
2727-2828-#[route("/")]
2929-pub struct Index;
3030-3131-impl Page for Index {
3232- fn render(&self, _: &mut RouteContext) -> RenderResult {
3333- "Hello, Maudit!".into()
3434- }
3535-}
3636-```
3737-3838-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`:
3939-4040-```rs
4141-use maudit::page::FullPage;
4242-use maudit::{content::ContentSources, BuildOptions};
1818+use maudit::{
1919+ content::ContentSources,
2020+ page::{FullPage, PageAssets, PageContent},
2121+ route::{DynamicRouteContext, RouteContext, RouteParams, RouteType},
2222+ BuildOptions,
2323+};
43244425pub fn build_website(
4526 routes: &[&dyn FullPage],
4627 mut content_sources: ContentSources,
4747- options: BuildOptions,
2828+ options: BuildOptions
4829) -> Result<(), Box<dyn std::error::Error>> {
4949- // Implementation will go here
5050-3030+ // We'll fill this in later.
5131 Ok(())
5232}
5333```
54345555-Finally, we'll modify `src/main.rs` to call our `build_website` function:
5656-5757-```rs
5858-mod build;
5959-6060-mod pages {
6161- mod index;
6262- pub use index::Index;
6363-}
6464-6565-fn main() {
6666- let _ = build_website(
6767- routes![Index],
6868- content_sources![],
6969- BuildOptions::default(),
7070- );
7171-}
7272-```
7373-7474-Now that we have our project set up, let's implement the `build_website` function step by step.
3535+`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.
75367637## Building pages
77387839The 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.
79408041```rs
8181-use std::fs;
8282-use std::path::PathBuf;
8383-use std::str::FromStr;
8484-8585-use maudit::{
8686- assets::PageAssets,
8787- content::{ContentSources, PageContent},
8888- page::{FullPage, RouteContext, RouteParams, RouteType},
8989- BuildOptions,
9090-};
9191-9242pub fn build_website(
9343 routes: &[&dyn FullPage],
9444 mut content_sources: ContentSources,
9545 options: BuildOptions,
9646) -> Result<(), Box<dyn std::error::Error>> {
9797- let dist_dir = PathBuf::from_str(&options.output_dir)?;
4747+4848+ // Options we'll be passing to PageAssets instances.
4949+ // 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.
5050+ let page_assets_options = options.page_assets_options();
98519952 for route in routes {
10053 match route.route_type() {
10154 RouteType::Static => {
10255 // Our page does not include content or assets, but we'll set those up for future use.
10356 let content = PageContent::new(&content_sources);
104104- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
5757+ let mut page_assets = PageAssets::new(&page_assets_options);
1055810659 // Static and dynamic routes share the same interface for building, but static routes do not require any parameters.
10760 // As such, we can just pass an empty set of parameters (the default for RouteParams).
10861 let params = RouteParams::default();
1096211063 // Every page has a RouteContext, which contains information about the current route, as well as access to content and assets.
111111- let mut ctx = RouteContext::from_static_route(
112112- &content,
113113- &mut page_assets,
114114- route.url(¶ms).clone(),
115115- );
6464+ let url = route.url(¶ms);
6565+ let mut ctx = RouteContext::from_static_route(&content, &mut page_assets, &url);
1166611767 let content = route.build(&mut ctx)?;
11868119119- // FullPage.file_path() returns a path that does not include the output directory, so we need to join it with dist_dir.
120120- let final_filepath = dist_dir.join(route.file_path(¶ms));
6969+ let route_filepath = route.file_path(¶ms, &options.output_dir);
12170122122- // On some platforms, creating a file in a nested directory requires that the directory already exists or `fs::write` will fail.
123123- if let Some(parent_dir) = final_filepath.parent() {
124124- fs::create_dir_all(parent_dir)?
7171+ // On some platforms, creating a file in a nested directory requires that the directory already exists or the file creation will fail.
7272+ if let Some(parent_dir) = route_filepath.parent() {
7373+ fs::create_dir_all(parent_dir)?
12574 }
12675127127- fs::write(final_filepath, content)?;
7676+ fs::write(route_filepath, content)?;
12877 }
12978 RouteType::Dynamic => {
13079 unimplemented!("We'll handle dynamic routes later");
···13685}
13786```
13887139139-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!
8888+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.
8989+9090+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!
1409114192## Handling assets
14293143143-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.
9494+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.
9595+9696+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):
1449714598```rs
146146-pub fn build_website(
147147- routes: &[&dyn FullPage],
148148- mut content_sources: ContentSources,
149149- options: BuildOptions,
150150-) -> Result<(), Box<dyn std::error::Error>> {
151151- let dist_dir = PathBuf::from_str(&options.output_dir)?;
152152-153153- let mut all_assets: HashSet<(PathBuf, PathBuf)> = HashSet::new();
154154-155155- for route in routes {
156156- match route.route_type() {
157157- RouteType::Static => {
158158- // ... Same as before ...
159159-160160- // Collect all assets used by this page.
161161- all_assets.extend(page_assets.assets().map(|asset| {
162162- (
163163- dist_dir.join(asset.build_path()),
164164- asset.path().to_path_buf(),
165165- )
166166- }));
167167- }
168168- RouteType::Dynamic => {
169169- unimplemented!("We'll handle dynamic routes later");
170170- }
171171- }
172172- }
173173-174174- // Copy all assets to the output directory.
175175- for (dest_path, src_path) in all_assets {
176176- // Similar to pages, we need to ensure the parent directory exists or `fs::copy` will fail.
177177- if let Some(parent) = dest_path.parent() {
178178- fs::create_dir_all(parent)?;
179179- }
180180- fs::copy(src_path, dest_path)?;
181181- }
182182-183183- Ok(())
9999+for asset in page_assets.assets() {
100100+ fs::copy(asset.path(), asset.build_path())?;
184101}
185102```
186103187187-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.
104104+And that's it! Now, any asset used in a page will be copied to the output directory when building the page. Onto content.
188105189106## Handling content
190107191191-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.
108108+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.
109109+110110+If you've copied the previous snippets, you might have noticed that Rust has been complaining about `content_sources` being mutable but never mutated.
192111193193-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:
112112+We'll fix that now by initializing each content source by adding the following line before the loop over routes:
194113195114```rs
196115content_sources.init_all();
197116```
198117199199-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.
118118+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.
200119201120## Dynamic routes
202121203203-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.
122122+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.
123123+124124+Each individual page is essentially a static route, but it has a slightly different context available to it.
204125205126```rs
206206-for route in routes {
207207- match route.route_type() {
208208- RouteType::Static => {
209209- // No changes here, same as before.
210210- }
211211- RouteType::Dynamic => {
212212- // The `routes` method returns all the possible routes for this page, along with their parameters and properties.
213213- // 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.
214214- // As such, we create a mini RouteContext that includes the content sources, so that the page can use them to generate its routes.
127127+// No changes before this block.
215128216216- let dynamic_ctx = DynamicRouteContext {
217217- content: &PageContent::new(&content_sources),
218218- };
129129+RouteType::Dynamic => {
130130+ // The `get_routes` method returns all the possible routes for this page, along with their parameters and properties.
131131+ // 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.
132132+ // 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.
219133220220- let routes = route.routes_internal(&dynamic_ctx);
134134+ let dynamic_ctx = DynamicRouteContext {
135135+ content: &PageContent::new(&content_sources),
136136+ };
221137222222- // Every page can share the same PageContent instance, as it is just a view into the content sources.
223223- let content = PageContent::new(&content_sources);
138138+ let routes = route.get_routes(&dynamic_ctx);
224139225225- for dynamic_route in routes {
226226- // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route.
227227- // This is especially relevant if we were to parallelize this loop in the future.
228228- let mut page_assets = PageAssets::new(options.assets_dir.clone().into());
140140+ // Every page can share a reference to the same PageContent instance, as it is just a view into the content sources.
141141+ let content = PageContent::new(&content_sources);
229142230230- // The dynamic route includes the parameters for this specific route.
231231- let params = &dynamic_route.0;
143143+ for dynamic_route in routes {
144144+ // However, since page assets is a mutable structure that tracks which assets have been used, we need a new instance for each route.
145145+ // This is especially relevant if we were to parallelize this loop in the future.
146146+ let mut page_assets = PageAssets::new(&page_assets_options);
232147233233- // Here the context is created from a dynamic route, as the context has to include the route parameters and properties.
234234- let mut ctx = RouteContext::from_dynamic_route(
235235- &dynamic_route,
236236- &content,
237237- &mut page_assets,
238238- route.url(params),
239239- );
148148+ // The dynamic route includes the parameters for this specific route.
149149+ let params = &dynamic_route.0;
240150241241- // Everything after this is the same as for static routes, making sure to use the route parameters when getting the file path.
242242- }
243243- }
151151+ // Here the context is created from a dynamic route, as the context has to include the route parameters and properties.
152152+ let url = route.url(params);
153153+ let mut ctx = RouteContext::from_dynamic_route(
154154+ &dynamic_route,
155155+ &content,
156156+ &mut page_assets,
157157+ &url,
158158+ );
159159+160160+ // Everything after this is the same as for static routes.
244161 }
245162}
246163```
247164248248-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.
165165+## Conclusion
166166+167167+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.
168168+169169+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.