#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AstroVersionFamily {
Astro2,
Astro3,
Astro4,
Astro5,
}
impl AstroVersionFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Astro2 => "astro2",
Self::Astro3 => "astro3",
Self::Astro4 => "astro4",
Self::Astro5 => "astro5",
}
}
}
impl fmt::Display for AstroVersionFamily {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroVersionFamily {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"astro2" | "2" => Ok(Self::Astro2),
"astro3" | "3" => Ok(Self::Astro3),
"astro4" | "4" => Ok(Self::Astro4),
"astro5" | "5" => Ok(Self::Astro5),
_ => Err(AstroTextError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AstroFileKind {
Page,
Layout,
Component,
Content,
Endpoint,
Middleware,
Config,
}
impl AstroFileKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Page => "page",
Self::Layout => "layout",
Self::Component => "component",
Self::Content => "content",
Self::Endpoint => "endpoint",
Self::Middleware => "middleware",
Self::Config => "config",
}
}
}
impl fmt::Display for AstroFileKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroFileKind {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"page" => Ok(Self::Page),
"layout" => Ok(Self::Layout),
"component" => Ok(Self::Component),
"content" => Ok(Self::Content),
"endpoint" => Ok(Self::Endpoint),
"middleware" => Ok(Self::Middleware),
"config" => Ok(Self::Config),
_ => Err(AstroTextError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AstroDirectoryKind {
Pages,
Layouts,
Components,
Content,
Public,
Src,
Integrations,
}
impl AstroDirectoryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Pages => "pages",
Self::Layouts => "layouts",
Self::Components => "components",
Self::Content => "content",
Self::Public => "public",
Self::Src => "src",
Self::Integrations => "integrations",
}
}
}
impl fmt::Display for AstroDirectoryKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroDirectoryKind {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"pages" => Ok(Self::Pages),
"layouts" => Ok(Self::Layouts),
"components" => Ok(Self::Components),
"content" => Ok(Self::Content),
"public" => Ok(Self::Public),
"src" => Ok(Self::Src),
"integrations" => Ok(Self::Integrations),
_ => Err(AstroTextError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AstroRenderingMode {
Static,
Server,
Hybrid,
}
impl AstroRenderingMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Static => "static",
Self::Server => "server",
Self::Hybrid => "hybrid",
}
}
}
impl fmt::Display for AstroRenderingMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroRenderingMode {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"static" => Ok(Self::Static),
"server" | "ssr" => Ok(Self::Server),
"hybrid" => Ok(Self::Hybrid),
_ => Err(AstroTextError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum AstroConfigFile {
AstroConfigJs,
AstroConfigMjs,
AstroConfigTs,
AstroConfigMts,
}
impl AstroConfigFile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::AstroConfigJs => "astro.config.js",
Self::AstroConfigMjs => "astro.config.mjs",
Self::AstroConfigTs => "astro.config.ts",
Self::AstroConfigMts => "astro.config.mts",
}
}
}
impl fmt::Display for AstroConfigFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroConfigFile {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"astroconfigjs" | "astro.config.js" => Ok(Self::AstroConfigJs),
"astroconfigmjs" | "astro.config.mjs" => Ok(Self::AstroConfigMjs),
"astroconfigts" | "astro.config.ts" => Ok(Self::AstroConfigTs),
"astroconfigmts" | "astro.config.mts" => Ok(Self::AstroConfigMts),
_ => Err(AstroTextError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AstroIntegrationName(String);
impl AstroIntegrationName {
pub fn new(input: &str) -> Result<Self, AstroTextError> {
validate_text(input, is_integration_character).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AstroIntegrationName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroIntegrationName {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for AstroIntegrationName {
type Error = AstroTextError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct AstroContentCollectionName(String);
impl AstroContentCollectionName {
pub fn new(input: &str) -> Result<Self, AstroTextError> {
validate_text(input, is_collection_character).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for AstroContentCollectionName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for AstroContentCollectionName {
type Err = AstroTextError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for AstroContentCollectionName {
type Error = AstroTextError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AstroTextError {
Empty,
ContainsWhitespace,
InvalidCharacter { character: char },
UnknownLabel,
}
impl fmt::Display for AstroTextError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Astro metadata text cannot be empty"),
Self::ContainsWhitespace => {
formatter.write_str("Astro metadata text cannot contain whitespace")
}
Self::InvalidCharacter { character } => {
write!(formatter, "invalid Astro metadata character `{character}`")
}
Self::UnknownLabel => formatter.write_str("unknown Astro metadata label"),
}
}
}
impl Error for AstroTextError {}
fn validate_text(input: &str, is_allowed: fn(char) -> bool) -> Result<String, AstroTextError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AstroTextError::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(AstroTextError::ContainsWhitespace);
}
if let Some(character) = trimmed.chars().find(|character| !is_allowed(*character)) {
return Err(AstroTextError::InvalidCharacter { character });
}
Ok(trimmed.to_string())
}
const fn is_integration_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '@' | '/' | '.' | '_' | '-')
}
const fn is_collection_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}
fn normalized_label(input: &str) -> Result<String, AstroTextError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(AstroTextError::Empty);
}
Ok(trimmed
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' '))
.flat_map(char::to_lowercase)
.collect())
}
#[cfg(test)]
mod tests {
use super::{
AstroConfigFile, AstroContentCollectionName, AstroDirectoryKind, AstroFileKind,
AstroIntegrationName, AstroRenderingMode, AstroTextError, AstroVersionFamily,
};
#[test]
fn validates_integration_names() -> Result<(), AstroTextError> {
let integration = AstroIntegrationName::new("@astrojs/mdx")?;
assert_eq!(integration.as_str(), "@astrojs/mdx");
assert_eq!(AstroIntegrationName::new(""), Err(AstroTextError::Empty));
assert_eq!(
AstroIntegrationName::new("astro mdx"),
Err(AstroTextError::ContainsWhitespace)
);
assert_eq!(
AstroIntegrationName::new("astro💫"),
Err(AstroTextError::InvalidCharacter { character: '💫' })
);
Ok(())
}
#[test]
fn validates_collection_names() -> Result<(), AstroTextError> {
let collection = AstroContentCollectionName::new("blog_posts")?;
assert_eq!(collection.as_str(), "blog_posts");
assert_eq!(
AstroContentCollectionName::new("blog/posts"),
Err(AstroTextError::InvalidCharacter { character: '/' })
);
Ok(())
}
#[test]
fn parses_labels() -> Result<(), AstroTextError> {
assert_eq!(
"astro5".parse::<AstroVersionFamily>()?,
AstroVersionFamily::Astro5
);
assert_eq!("page".parse::<AstroFileKind>()?, AstroFileKind::Page);
assert_eq!(
"src".parse::<AstroDirectoryKind>()?,
AstroDirectoryKind::Src
);
assert_eq!(
"server".parse::<AstroRenderingMode>()?,
AstroRenderingMode::Server
);
assert_eq!(
"astro.config.ts".parse::<AstroConfigFile>()?,
AstroConfigFile::AstroConfigTs
);
assert_eq!(AstroRenderingMode::Hybrid.to_string(), "hybrid");
Ok(())
}
}