#[cfg(feature = "npm")]
use napi_derive::napi;
use serde::{Deserialize, Serialize};
#[cfg_attr(feature = "npm", napi(string_enum))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum HostingProvider {
GithubPages,
Vercel,
Netlify,
CloudflarePages,
Custom,
}
#[cfg_attr(feature = "npm", napi(object))]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BaseDirInfo {
pub provider: HostingProvider,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub site_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_prefix: Option<String>,
}
impl HostingProvider {
pub fn id(&self) -> &'static str {
match self {
HostingProvider::GithubPages => "github-pages",
HostingProvider::Vercel => "vercel",
HostingProvider::Netlify => "netlify",
HostingProvider::CloudflarePages => "cloudflare-pages",
HostingProvider::Custom => "custom",
}
}
}
impl BaseDirInfo {
pub fn new(provider: HostingProvider, repo: Option<String>, site_url: Option<String>) -> Self {
Self {
provider,
repo,
site_url,
path_prefix: None,
}
}
pub fn base_url(&self) -> String {
let mut segments: Vec<String> = Vec::new();
if self.provider == HostingProvider::GithubPages
&& let Some(repo) = &self.repo
{
let repo = repo.trim_matches('/');
if !repo.is_empty() && !repo.ends_with(".github.io") {
segments.push(repo.to_string());
}
}
if let Some(prefix) = &self.path_prefix {
for seg in prefix.trim_matches('/').split('/') {
if !seg.is_empty() && !segments.iter().any(|s| s == seg) {
segments.push(seg.to_string());
}
}
}
if segments.is_empty() {
"/".to_string()
} else {
format!("/{}/", segments.join("/"))
}
}
pub fn site_url(&self) -> Option<String> {
if let Some(url) = &self.site_url {
return Some(url.trim_end_matches('/').to_string());
}
match &self.provider {
HostingProvider::GithubPages => match &self.repo {
Some(r) if r.ends_with(".github.io") => {
Some(format!("https://{}", r.trim_end_matches('/')))
}
_ => None,
},
_ => None,
}
}
pub fn absolute_url(&self, path: &str) -> String {
let origin = self.site_url().unwrap_or_default();
let base = self.base_url();
let path = path.trim_start_matches('/');
format!("{origin}{base}{path}")
}
pub fn detect(url: &str) -> Self {
let lower = url.to_lowercase();
if lower.contains("github.io") {
let path = url
.split("github.io")
.nth(1)
.unwrap_or("")
.trim_matches('/');
let repo = path.split('/').next().filter(|s| !s.is_empty());
return Self::new(HostingProvider::GithubPages, repo.map(str::to_string), None);
}
let provider = if lower.contains("vercel.app") {
HostingProvider::Vercel
} else if lower.contains("netlify.app") {
HostingProvider::Netlify
} else if lower.contains("pages.dev") {
HostingProvider::CloudflarePages
} else {
HostingProvider::Custom
};
Self::new(provider, None, Some(url.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn github_pages_user_site_serves_at_root() {
let info = BaseDirInfo::new(
HostingProvider::GithubPages,
Some("user.github.io".into()),
None,
);
assert_eq!(info.base_url(), "/");
assert_eq!(info.site_url().unwrap(), "https://user.github.io");
}
#[test]
fn github_pages_project_site_uses_repo_prefix() {
let info = BaseDirInfo::new(HostingProvider::GithubPages, Some("my-repo".into()), None);
assert_eq!(info.base_url(), "/my-repo/");
assert_eq!(info.site_url(), None);
}
#[test]
fn platform_hosts_serve_at_root() {
for p in [
HostingProvider::Vercel,
HostingProvider::Netlify,
HostingProvider::CloudflarePages,
] {
let info = BaseDirInfo::new(p, Some("ignored-repo".into()), None);
assert_eq!(info.base_url(), "/", "{:?} serves at root", p);
}
}
#[test]
fn custom_uses_configured_prefix() {
let mut info = BaseDirInfo::new(HostingProvider::Custom, None, None);
info.path_prefix = Some("subpath".into());
assert_eq!(info.base_url(), "/subpath/");
}
#[test]
fn absolute_urls_compose() {
let info = BaseDirInfo::new(HostingProvider::GithubPages, Some("my-repo".into()), None);
assert_eq!(info.absolute_url("docs/intro"), "/my-repo/docs/intro");
let with_origin = BaseDirInfo::new(
HostingProvider::Custom,
None,
Some("https://example.com/".into()),
);
assert_eq!(with_origin.site_url().unwrap(), "https://example.com");
assert_eq!(
with_origin.absolute_url("docs/intro"),
"https://example.com/docs/intro"
);
}
#[test]
fn detection_from_urls() {
let gh = BaseDirInfo::detect("https://user.github.io/my-repo/");
assert_eq!(gh.provider, HostingProvider::GithubPages);
assert_eq!(gh.repo.as_deref(), Some("my-repo"));
let vercel = BaseDirInfo::detect("https://demo.vercel.app");
assert_eq!(vercel.provider, HostingProvider::Vercel);
let netlify = BaseDirInfo::detect("https://wonderful.netlify.app/");
assert_eq!(netlify.provider, HostingProvider::Netlify);
let cf = BaseDirInfo::detect("https://abc.pages.dev");
assert_eq!(cf.provider, HostingProvider::CloudflarePages);
let custom = BaseDirInfo::detect("https://example.com/blog/");
assert_eq!(custom.provider, HostingProvider::Custom);
}
}