#![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 RemixVersionFamily {
Remix1,
Remix2,
}
impl RemixVersionFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Remix1 => "remix1",
Self::Remix2 => "remix2",
}
}
}
impl fmt::Display for RemixVersionFamily {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixVersionFamily {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"remix1" | "1" => Ok(Self::Remix1),
"remix2" | "2" => Ok(Self::Remix2),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixRouteKind {
PageRoute,
ResourceRoute,
LayoutRoute,
IndexRoute,
PathlessLayoutRoute,
}
impl RemixRouteKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PageRoute => "page-route",
Self::ResourceRoute => "resource-route",
Self::LayoutRoute => "layout-route",
Self::IndexRoute => "index-route",
Self::PathlessLayoutRoute => "pathless-layout-route",
}
}
#[must_use]
pub const fn is_index_route(self) -> bool {
matches!(self, Self::IndexRoute)
}
#[must_use]
pub const fn is_resource_route(self) -> bool {
matches!(self, Self::ResourceRoute)
}
#[must_use]
pub const fn is_layout_route(self) -> bool {
matches!(self, Self::LayoutRoute | Self::PathlessLayoutRoute)
}
}
impl fmt::Display for RemixRouteKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixRouteKind {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"pageroute" | "page" => Ok(Self::PageRoute),
"resourceroute" | "resource" => Ok(Self::ResourceRoute),
"layoutroute" | "layout" => Ok(Self::LayoutRoute),
"indexroute" | "index" => Ok(Self::IndexRoute),
"pathlesslayoutroute" | "pathlesslayout" => Ok(Self::PathlessLayoutRoute),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixFileKind {
Route,
Root,
EntryClient,
EntryServer,
Config,
Styles,
ErrorBoundary,
}
impl RemixFileKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Route => "route",
Self::Root => "root",
Self::EntryClient => "entry-client",
Self::EntryServer => "entry-server",
Self::Config => "config",
Self::Styles => "styles",
Self::ErrorBoundary => "error-boundary",
}
}
}
impl fmt::Display for RemixFileKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixFileKind {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"route" => Ok(Self::Route),
"root" => Ok(Self::Root),
"entryclient" => Ok(Self::EntryClient),
"entryserver" => Ok(Self::EntryServer),
"config" => Ok(Self::Config),
"styles" | "style" => Ok(Self::Styles),
"errorboundary" => Ok(Self::ErrorBoundary),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixDirectoryKind {
App,
Routes,
Components,
Styles,
Public,
Server,
}
impl RemixDirectoryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Routes => "routes",
Self::Components => "components",
Self::Styles => "styles",
Self::Public => "public",
Self::Server => "server",
}
}
}
impl fmt::Display for RemixDirectoryKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixDirectoryKind {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"app" => Ok(Self::App),
"routes" => Ok(Self::Routes),
"components" => Ok(Self::Components),
"styles" | "style" => Ok(Self::Styles),
"public" => Ok(Self::Public),
"server" => Ok(Self::Server),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixRenderingMode {
Ssr,
Spa,
Static,
Hybrid,
}
impl RemixRenderingMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ssr => "ssr",
Self::Spa => "spa",
Self::Static => "static",
Self::Hybrid => "hybrid",
}
}
}
impl fmt::Display for RemixRenderingMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixRenderingMode {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"ssr" => Ok(Self::Ssr),
"spa" => Ok(Self::Spa),
"static" => Ok(Self::Static),
"hybrid" => Ok(Self::Hybrid),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixConfigFile {
RemixConfigJs,
RemixConfigMjs,
ViteConfigTs,
ViteConfigJs,
}
impl RemixConfigFile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::RemixConfigJs => "remix.config.js",
Self::RemixConfigMjs => "remix.config.mjs",
Self::ViteConfigTs => "vite.config.ts",
Self::ViteConfigJs => "vite.config.js",
}
}
}
impl fmt::Display for RemixConfigFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixConfigFile {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"remixconfigjs" => Ok(Self::RemixConfigJs),
"remixconfigmjs" => Ok(Self::RemixConfigMjs),
"viteconfigts" => Ok(Self::ViteConfigTs),
"viteconfigjs" => Ok(Self::ViteConfigJs),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum RemixDataFunctionKind {
Loader,
Action,
ClientLoader,
ClientAction,
}
impl RemixDataFunctionKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Loader => "loader",
Self::Action => "action",
Self::ClientLoader => "client-loader",
Self::ClientAction => "client-action",
}
}
}
impl fmt::Display for RemixDataFunctionKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixDataFunctionKind {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"loader" => Ok(Self::Loader),
"action" => Ok(Self::Action),
"clientloader" => Ok(Self::ClientLoader),
"clientaction" => Ok(Self::ClientAction),
_ => Err(RemixNameError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemixRoutePath(String);
impl RemixRoutePath {
pub fn new(input: &str) -> Result<Self, RemixNameError> {
validate_non_empty_text(input).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RemixRoutePath {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixRoutePath {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for RemixRoutePath {
type Error = RemixNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemixRouteFileName(String);
impl RemixRouteFileName {
pub fn new(input: &str) -> Result<Self, RemixNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(RemixNameError::Empty);
}
if trimmed.chars().any(char::is_whitespace) {
return Err(RemixNameError::ContainsWhitespace);
}
if let Some(character) = trimmed
.chars()
.find(|character| !is_route_file_character(*character))
{
return Err(RemixNameError::InvalidCharacter { character });
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RemixRouteFileName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixRouteFileName {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for RemixRouteFileName {
type Error = RemixNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct RemixResourceRouteName(String);
impl RemixResourceRouteName {
pub fn new(input: &str) -> Result<Self, RemixNameError> {
validate_non_empty_text(input).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RemixResourceRouteName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for RemixResourceRouteName {
type Err = RemixNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for RemixResourceRouteName {
type Error = RemixNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RemixNameError {
Empty,
ContainsWhitespace,
InvalidCharacter { character: char },
UnknownLabel,
}
impl fmt::Display for RemixNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Remix metadata text cannot be empty"),
Self::ContainsWhitespace => {
formatter.write_str("Remix metadata text cannot contain whitespace")
}
Self::InvalidCharacter { character } => {
write!(formatter, "invalid Remix metadata character `{character}`")
}
Self::UnknownLabel => formatter.write_str("unknown Remix metadata label"),
}
}
}
impl Error for RemixNameError {}
fn validate_non_empty_text(input: &str) -> Result<String, RemixNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(RemixNameError::Empty);
}
if let Some(character) = trimmed.chars().find(|character| character.is_control()) {
return Err(RemixNameError::InvalidCharacter { character });
}
Ok(trimmed.to_string())
}
const fn is_route_file_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-' | '$')
}
fn normalized_label(input: &str) -> Result<String, RemixNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(RemixNameError::Empty);
}
Ok(trimmed
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
.flat_map(char::to_lowercase)
.collect())
}
#[cfg(test)]
mod tests {
use super::{
RemixConfigFile, RemixDataFunctionKind, RemixDirectoryKind, RemixFileKind, RemixNameError,
RemixRenderingMode, RemixResourceRouteName, RemixRouteFileName, RemixRouteKind,
RemixRoutePath, RemixVersionFamily,
};
#[test]
fn validates_route_paths() -> Result<(), RemixNameError> {
let route = RemixRoutePath::new("/products/$productId")?;
assert_eq!(route.as_str(), "/products/$productId");
assert_eq!(RemixRoutePath::new(""), Err(RemixNameError::Empty));
assert_eq!(
RemixRoutePath::new("/bad\nroute"),
Err(RemixNameError::InvalidCharacter { character: '\n' })
);
Ok(())
}
#[test]
fn validates_route_file_names() -> Result<(), RemixNameError> {
assert_eq!(
RemixRouteFileName::new("products.$id")?.as_str(),
"products.$id"
);
assert_eq!(RemixRouteFileName::new("_index")?.as_str(), "_index");
assert_eq!(
RemixRouteFileName::new("products id"),
Err(RemixNameError::ContainsWhitespace)
);
assert_eq!(
RemixRouteFileName::new("routes/products"),
Err(RemixNameError::InvalidCharacter { character: '/' })
);
Ok(())
}
#[test]
fn validates_resource_route_names() -> Result<(), RemixNameError> {
let resource = RemixResourceRouteName::new("sitemap.xml")?;
assert_eq!(resource.as_str(), "sitemap.xml");
assert_eq!(RemixResourceRouteName::new(""), Err(RemixNameError::Empty));
Ok(())
}
#[test]
fn route_kind_helpers_work() {
assert!(RemixRouteKind::IndexRoute.is_index_route());
assert!(RemixRouteKind::ResourceRoute.is_resource_route());
assert!(RemixRouteKind::LayoutRoute.is_layout_route());
assert!(RemixRouteKind::PathlessLayoutRoute.is_layout_route());
assert!(!RemixRouteKind::PageRoute.is_resource_route());
}
#[test]
fn parses_labels() -> Result<(), RemixNameError> {
assert_eq!(
"remix2".parse::<RemixVersionFamily>()?,
RemixVersionFamily::Remix2
);
assert_eq!(
"resource-route".parse::<RemixRouteKind>()?,
RemixRouteKind::ResourceRoute
);
assert_eq!(
"entry-client".parse::<RemixFileKind>()?,
RemixFileKind::EntryClient
);
assert_eq!(
"routes".parse::<RemixDirectoryKind>()?,
RemixDirectoryKind::Routes
);
assert_eq!(
"ssr".parse::<RemixRenderingMode>()?,
RemixRenderingMode::Ssr
);
assert_eq!(
"vite.config.ts".parse::<RemixConfigFile>()?,
RemixConfigFile::ViteConfigTs
);
assert_eq!(
"client-loader".parse::<RemixDataFunctionKind>()?,
RemixDataFunctionKind::ClientLoader
);
assert_eq!(RemixRouteKind::PageRoute.to_string(), "page-route");
Ok(())
}
}