Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,17 @@ jobs:
- name: Cache Cargo registry
uses: Swatinem/rust-cache@v2

- name: Install cargo-nextest
uses: taiki-e/install-action@v2
with:
tool: nextest

- name: Build
run: cargo +${{ matrix.rust }} build

- name: Test
run: cargo +${{ matrix.rust }} nextest run --workspace

- name: Test docs
run: cargo +${{ matrix.rust }} test

Expand Down
15 changes: 15 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ grass = "0.13"
lightningcss = "1.0.0-alpha.68"
proc-macro2 = "1"
quote = "1"
regex = "1.12.3"
serde = "1"
serde_yml = "0.0.12"
semver = "1.0.27"
syn = "2"
syntect = "5"
thiserror = "2.0.18"

[features]
nightly = ["cot-site-macros/nightly"]
Expand Down
2 changes: 2 additions & 0 deletions cot-site-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ edition = "2024"
[dependencies]
comrak.workspace = true
serde.workspace = true
semver.workspace = true
thiserror.workspace = true
6 changes: 6 additions & 0 deletions cot-site-common/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
pub mod md_pages;
mod utils;
pub use utils::{Version, VersionError};

pub const MASTER_VERSION: &str = "master";
pub const LATEST_VERSION: &str = "v0.5";
pub const ALL_VERSIONS: &[&str] = &[MASTER_VERSION, "v0.5", "v0.4", "v0.3", "v0.2", "v0.1"];
127 changes: 127 additions & 0 deletions cot-site-common/src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
use std::fmt::Display;
use std::str::FromStr;

use semver::Version as SemverVersion;
use thiserror::Error;

use crate::{LATEST_VERSION, MASTER_VERSION};

/// Errors related to version parsing and handling.
#[derive(Debug, Error)]
pub enum VersionError {
/// Error parsing version string.
#[error("invalid version string: {0}")]
InvalidVersion(#[from] semver::Error),
}

/// A semver version type.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Version(SemverVersion);

impl Version {
/// Creates a new version from major, minor, and patch numbers.
///
/// # Example
/// ```
/// let v = Version::new(0, 5, 0);
/// assert_eq!(v.to_string(), "0.5.0");
/// ```
pub fn new(major: u64, minor: u64, patch: u64) -> Self {
Version(SemverVersion::new(major, minor, patch))
}

/// Returns the major version number.
///
/// # Example
/// ```
/// let v = Version::new(0, 5, 0);
/// assert_eq!(v.major(), 0);
/// ```
pub fn major(&self) -> u64 {
self.0.major
}

/// Returns the minor version number.
///
/// # Example
/// ```
/// let v = Version::new(0, 5, 0);
/// assert_eq!(v.minor(), 5);
/// ```
pub fn minor(&self) -> u64 {
self.0.minor
}

/// Returns the patch version number.
///
/// # Example
/// ```
/// let v = Version::new(0, 5, 0);
/// assert_eq!(v.patch(), 0);
/// ```
pub fn patch(&self) -> u64 {
self.0.patch
}
Comment on lines +33 to +64
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we use these methods anywhere? If not, there's no point in keeping dead code on the repo.

}

impl Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.to_string())
}
}

impl FromStr for Version {
type Err = VersionError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
// replace "master" with latest version
let s = if s == MASTER_VERSION || s == "" {
LATEST_VERSION
} else {
s
};

// canonicalize version string by adding ".0" for missing minor/patch
let s = canonicalize_version_string(s);

let semver_version = SemverVersion::parse(s.as_str())?;
Ok(Version(semver_version))
}
}

fn canonicalize_version_string(s: &str) -> String {
Copy link
Member

@m4tx m4tx Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not exactly sure if this is the way we want to handle this. Suppose a user sees 0.5 version of the framework, but the latest version is 0.5.31. The URL generated will point specifically to 0.5.0, possibly omitting the changes made in the patch versions.

docs.rs is capable of redirecting to the latest patch version by itself, maybe we should use that instead? For instance, see that https://docs.rs/cot/0.3/cot/ redirects to 0.3.1, not 0.3.0.

I'm not even sure we need to parse the version. The content changes rarely and it's easy to spot the version might be wrong. Therefore, I think keeping it as String (wrapped in the Version newtype) would be enough, but I'll leave it to you to decide.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably must have missed something initially while playing around the rustdocs link structure using the semver versioning which didnt work initially and influenced this function. But, you're right. I tried this again and it works. Which means we dont need this at all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I just remembered another reason for this function. The semver crate requires at least a three-component version (major, minor, patch) to construct a valid smever::Version type. However, throughout the codebase, versions are typically specified with only the major and minor components.
To accommodate this, we canonicalize to the “floor” (i.e., append a patch component, typically 0) as a best-effort strategy to produce a valid semver::Version when the patch component is not explicitly provided.
I think it makes sense to retain this behavior, and then when generating the Rustdoc link, we can use only the major and minor components. Rustdoc should then resolve the appropriate route to the latest compatible patch version, as you pointed out.

let s = s.trim_start_matches('v');
let parts: Vec<&str> = s.split('.').collect();
match parts.len() {
1 => format!("{}.0.0", parts[0]),
2 => format!("{}.{}.0", parts[0], parts[1]),
_ => s.to_string(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't SemVer err if there are more than 4 parts? If so, I'd throw an error in that case and add tests coverage

Copy link
Contributor Author

@ElijahAhianyo ElijahAhianyo Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The minimum number of parts that Semver crate supports is 3. This is only a best effort to canonicalize any format less than 3 to have the standard major, minor, and patch. For cases > than 3, the idea is to route them to the SemVer::Version::parse constructor, which should validate them and throw an error when necessary

}
}

#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_parsing() {
let v = Version::from_str("v0.5").unwrap();
assert_eq!(v.to_string(), "0.5.0");

let v = Version::from_str("0.5").unwrap();
assert_eq!(v.to_string(), "0.5.0");

let v = Version::from_str("master").unwrap();
assert_eq!(v.to_string(), canonicalize_version_string(LATEST_VERSION));

let v = Version::from_str("").unwrap();
assert_eq!(v.to_string(), canonicalize_version_string(LATEST_VERSION));
}

#[test]
fn test_canonicalize_version_string() {
assert_eq!(canonicalize_version_string("v0.5"), "0.5.0");
assert_eq!(canonicalize_version_string("0.5"), "0.5.0");
assert_eq!(canonicalize_version_string("v0.5.1"), "0.5.1");
assert_eq!(canonicalize_version_string("0.5.1"), "0.5.1");
}
}
1 change: 1 addition & 0 deletions cot-site-macros/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ quote.workspace = true
serde_yml.workspace = true
syn.workspace = true
syntect = { workspace = true, features = ["dump-load"] }
regex = { workspace = true, features = ["default"] }

[build-dependencies]
syntect = { workspace = true, features = ["dump-create"] }
2 changes: 0 additions & 2 deletions cot-site-macros/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
use proc_macro::TokenStream;

use crate::md_pages::MdPageInput;

mod md_pages;

#[proc_macro]
pub fn md_page(input: TokenStream) -> TokenStream {
let input = proc_macro2::TokenStream::from(input);
Expand Down
5 changes: 4 additions & 1 deletion cot-site-macros/src/md_pages.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod rendering;

use std::path::Path;
use std::str::FromStr;
use std::sync::Mutex;

use cot_site_common::Version;
use cot_site_common::md_pages::{FrontMatter, MdPage, MdPageHeadingAdapter, Section};
use proc_macro2::TokenStream;
use quote::quote;
Expand Down Expand Up @@ -119,7 +121,8 @@ pub(super) fn parse_md_page(prefix: &str, link: &str) -> MdPage {
.render(render_plugins)
.build();

let md_page_content = markdown_to_html(&md_page_content, &options, &plugins);
let version = Version::from_str(prefix).expect("invalid version in md page prefix");
let md_page_content = markdown_to_html(&md_page_content, &options, &plugins, version);
let sections = heading_adapter.sections.lock().unwrap().clone();
let root_section = fix_section_children(&sections);

Expand Down
Loading