#![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 NextJsVersionFamily {
Next12,
Next13,
Next14,
Next15,
Next16,
}
impl NextJsVersionFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Next12 => "next12",
Self::Next13 => "next13",
Self::Next14 => "next14",
Self::Next15 => "next15",
Self::Next16 => "next16",
}
}
}
impl fmt::Display for NextJsVersionFamily {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsVersionFamily {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"next12" | "12" => Ok(Self::Next12),
"next13" | "13" => Ok(Self::Next13),
"next14" | "14" => Ok(Self::Next14),
"next15" | "15" => Ok(Self::Next15),
"next16" | "16" => Ok(Self::Next16),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsRouterKind {
PagesRouter,
AppRouter,
}
impl NextJsRouterKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::PagesRouter => "pages-router",
Self::AppRouter => "app-router",
}
}
}
impl fmt::Display for NextJsRouterKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsRouterKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"pagesrouter" | "pages" => Ok(Self::PagesRouter),
"approuter" | "app" => Ok(Self::AppRouter),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsDirectoryKind {
App,
Pages,
Components,
Public,
Styles,
Lib,
Api,
Middleware,
}
impl NextJsDirectoryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::App => "app",
Self::Pages => "pages",
Self::Components => "components",
Self::Public => "public",
Self::Styles => "styles",
Self::Lib => "lib",
Self::Api => "api",
Self::Middleware => "middleware",
}
}
}
impl fmt::Display for NextJsDirectoryKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsDirectoryKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"app" => Ok(Self::App),
"pages" => Ok(Self::Pages),
"components" => Ok(Self::Components),
"public" => Ok(Self::Public),
"styles" => Ok(Self::Styles),
"lib" | "library" => Ok(Self::Lib),
"api" => Ok(Self::Api),
"middleware" => Ok(Self::Middleware),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsFileKind {
Page,
Layout,
Loading,
Error,
NotFound,
Route,
Middleware,
Config,
Metadata,
}
impl NextJsFileKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Page => "page",
Self::Layout => "layout",
Self::Loading => "loading",
Self::Error => "error",
Self::NotFound => "not-found",
Self::Route => "route",
Self::Middleware => "middleware",
Self::Config => "config",
Self::Metadata => "metadata",
}
}
}
impl fmt::Display for NextJsFileKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsFileKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"page" => Ok(Self::Page),
"layout" => Ok(Self::Layout),
"loading" => Ok(Self::Loading),
"error" => Ok(Self::Error),
"notfound" => Ok(Self::NotFound),
"route" => Ok(Self::Route),
"middleware" => Ok(Self::Middleware),
"config" => Ok(Self::Config),
"metadata" => Ok(Self::Metadata),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsRenderingMode {
Ssg,
Ssr,
Isr,
Csr,
Rsc,
Hybrid,
}
impl NextJsRenderingMode {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Ssg => "ssg",
Self::Ssr => "ssr",
Self::Isr => "isr",
Self::Csr => "csr",
Self::Rsc => "rsc",
Self::Hybrid => "hybrid",
}
}
}
impl fmt::Display for NextJsRenderingMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsRenderingMode {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"ssg" => Ok(Self::Ssg),
"ssr" => Ok(Self::Ssr),
"isr" => Ok(Self::Isr),
"csr" => Ok(Self::Csr),
"rsc" => Ok(Self::Rsc),
"hybrid" => Ok(Self::Hybrid),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsRouteKind {
Page,
ApiRoute,
RouteHandler,
Middleware,
}
impl NextJsRouteKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Page => "page",
Self::ApiRoute => "api-route",
Self::RouteHandler => "route-handler",
Self::Middleware => "middleware",
}
}
}
impl fmt::Display for NextJsRouteKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsRouteKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"page" => Ok(Self::Page),
"apiroute" | "api" => Ok(Self::ApiRoute),
"routehandler" | "handler" => Ok(Self::RouteHandler),
"middleware" => Ok(Self::Middleware),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsRuntimeKind {
NodeJs,
Edge,
}
impl NextJsRuntimeKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NodeJs => "nodejs",
Self::Edge => "edge",
}
}
}
impl fmt::Display for NextJsRuntimeKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsRuntimeKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"nodejs" | "node" => Ok(Self::NodeJs),
"edge" => Ok(Self::Edge),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsConfigFile {
NextConfigJs,
NextConfigMjs,
NextConfigTs,
}
impl NextJsConfigFile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NextConfigJs => "next.config.js",
Self::NextConfigMjs => "next.config.mjs",
Self::NextConfigTs => "next.config.ts",
}
}
}
impl fmt::Display for NextJsConfigFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsConfigFile {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"nextconfigjs" => Ok(Self::NextConfigJs),
"nextconfigmjs" => Ok(Self::NextConfigMjs),
"nextconfigts" => Ok(Self::NextConfigTs),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum NextJsMetadataKind {
StaticMetadata,
GeneratedMetadata,
FileBasedMetadata,
}
impl NextJsMetadataKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::StaticMetadata => "static-metadata",
Self::GeneratedMetadata => "generated-metadata",
Self::FileBasedMetadata => "file-based-metadata",
}
}
}
impl fmt::Display for NextJsMetadataKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsMetadataKind {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"staticmetadata" | "static" => Ok(Self::StaticMetadata),
"generatedmetadata" | "generated" => Ok(Self::GeneratedMetadata),
"filebasedmetadata" | "filebased" => Ok(Self::FileBasedMetadata),
_ => Err(NextJsRouteError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NextJsRouteSegment(String);
impl NextJsRouteSegment {
pub fn new(input: &str) -> Result<Self, NextJsRouteError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(NextJsRouteError::Empty);
}
if let Some(character) = trimmed
.chars()
.find(|character| character.is_control() || matches!(character, '/' | '\\'))
{
return Err(NextJsRouteError::InvalidCharacter { character });
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NextJsRouteSegment {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsRouteSegment {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for NextJsRouteSegment {
type Error = NextJsRouteError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NextJsDynamicSegment(String);
impl NextJsDynamicSegment {
pub fn new(input: &str) -> Result<Self, NextJsRouteError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(NextJsRouteError::Empty);
}
let Some(inner) = dynamic_segment_inner(trimmed) else {
return Err(NextJsRouteError::InvalidDynamicSegment);
};
if inner.is_empty() || !inner.chars().all(is_segment_name_character) {
return Err(NextJsRouteError::InvalidDynamicSegment);
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NextJsDynamicSegment {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsDynamicSegment {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for NextJsDynamicSegment {
type Error = NextJsRouteError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NextJsParallelRouteName(String);
impl NextJsParallelRouteName {
pub fn new(input: &str) -> Result<Self, NextJsRouteError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(NextJsRouteError::Empty);
}
let Some(name) = trimmed.strip_prefix('@') else {
return Err(NextJsRouteError::InvalidParallelRouteName);
};
if name.is_empty() || !name.chars().all(is_segment_name_character) {
return Err(NextJsRouteError::InvalidParallelRouteName);
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NextJsParallelRouteName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsParallelRouteName {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for NextJsParallelRouteName {
type Error = NextJsRouteError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct NextJsInterceptingRoutePattern(String);
impl NextJsInterceptingRoutePattern {
pub fn new(input: &str) -> Result<Self, NextJsRouteError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(NextJsRouteError::Empty);
}
if !(trimmed.contains('(') && trimmed.contains(')')) {
return Err(NextJsRouteError::InvalidInterceptingRoutePattern);
}
if let Some(character) = trimmed
.chars()
.find(|character| !is_intercepting_route_character(*character))
{
return Err(NextJsRouteError::InvalidCharacter { character });
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for NextJsInterceptingRoutePattern {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for NextJsInterceptingRoutePattern {
type Err = NextJsRouteError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for NextJsInterceptingRoutePattern {
type Error = NextJsRouteError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum NextJsRouteError {
Empty,
InvalidCharacter { character: char },
InvalidDynamicSegment,
InvalidParallelRouteName,
InvalidInterceptingRoutePattern,
UnknownLabel,
}
impl fmt::Display for NextJsRouteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Next.js metadata text cannot be empty"),
Self::InvalidCharacter { character } => {
write!(
formatter,
"invalid Next.js metadata character `{character}`"
)
}
Self::InvalidDynamicSegment => formatter.write_str("invalid Next.js dynamic segment"),
Self::InvalidParallelRouteName => {
formatter.write_str("invalid Next.js parallel route name")
}
Self::InvalidInterceptingRoutePattern => {
formatter.write_str("invalid Next.js intercepting route pattern")
}
Self::UnknownLabel => formatter.write_str("unknown Next.js metadata label"),
}
}
}
impl Error for NextJsRouteError {}
fn dynamic_segment_inner(input: &str) -> Option<&str> {
if let Some(inner) = input
.strip_prefix("[[...")
.and_then(|value| value.strip_suffix("]]"))
{
return Some(inner);
}
if let Some(inner) = input
.strip_prefix("[...")
.and_then(|value| value.strip_suffix(']'))
{
return Some(inner);
}
input
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
}
const fn is_segment_name_character(character: char) -> bool {
character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}
const fn is_intercepting_route_character(character: char) -> bool {
character.is_ascii_alphanumeric()
|| matches!(
character,
'.' | '(' | ')' | '/' | '[' | ']' | '@' | '_' | '-'
)
}
fn normalized_label(input: &str) -> Result<String, NextJsRouteError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(NextJsRouteError::Empty);
}
Ok(trimmed
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
.flat_map(char::to_lowercase)
.collect())
}
#[cfg(test)]
mod tests {
use super::{
NextJsConfigFile, NextJsDirectoryKind, NextJsDynamicSegment, NextJsFileKind,
NextJsInterceptingRoutePattern, NextJsMetadataKind, NextJsParallelRouteName,
NextJsRenderingMode, NextJsRouteError, NextJsRouteKind, NextJsRouteSegment,
NextJsRouterKind, NextJsRuntimeKind, NextJsVersionFamily,
};
#[test]
fn validates_route_segments() -> Result<(), NextJsRouteError> {
let segment = NextJsRouteSegment::new("blog")?;
assert_eq!(segment.as_str(), "blog");
assert_eq!(NextJsRouteSegment::new(""), Err(NextJsRouteError::Empty));
assert_eq!(
NextJsRouteSegment::new("blog/posts"),
Err(NextJsRouteError::InvalidCharacter { character: '/' })
);
Ok(())
}
#[test]
fn validates_dynamic_segments() -> Result<(), NextJsRouteError> {
assert_eq!(NextJsDynamicSegment::new("[id]")?.as_str(), "[id]");
assert_eq!(
NextJsDynamicSegment::new("[...slug]")?.as_str(),
"[...slug]"
);
assert_eq!(
NextJsDynamicSegment::new("[[...slug]]")?.as_str(),
"[[...slug]]"
);
assert_eq!(
NextJsDynamicSegment::new("[]"),
Err(NextJsRouteError::InvalidDynamicSegment)
);
assert_eq!(
NextJsDynamicSegment::new("[slug.part]"),
Err(NextJsRouteError::InvalidDynamicSegment)
);
Ok(())
}
#[test]
fn validates_parallel_and_intercepting_routes() -> Result<(), NextJsRouteError> {
assert_eq!(NextJsParallelRouteName::new("@modal")?.as_str(), "@modal");
assert_eq!(
NextJsParallelRouteName::new("modal"),
Err(NextJsRouteError::InvalidParallelRouteName)
);
assert_eq!(
NextJsParallelRouteName::new("@"),
Err(NextJsRouteError::InvalidParallelRouteName)
);
assert_eq!(
NextJsInterceptingRoutePattern::new("(.)feed")?.as_str(),
"(.)feed"
);
assert_eq!(
NextJsInterceptingRoutePattern::new("feed"),
Err(NextJsRouteError::InvalidInterceptingRoutePattern)
);
Ok(())
}
#[test]
fn parses_labels() -> Result<(), NextJsRouteError> {
assert_eq!(
"next15".parse::<NextJsVersionFamily>()?,
NextJsVersionFamily::Next15
);
assert_eq!(
"app-router".parse::<NextJsRouterKind>()?,
NextJsRouterKind::AppRouter
);
assert_eq!(
"components".parse::<NextJsDirectoryKind>()?,
NextJsDirectoryKind::Components
);
assert_eq!(
"not-found".parse::<NextJsFileKind>()?,
NextJsFileKind::NotFound
);
assert_eq!(
"rsc".parse::<NextJsRenderingMode>()?,
NextJsRenderingMode::Rsc
);
assert_eq!(
"api-route".parse::<NextJsRouteKind>()?,
NextJsRouteKind::ApiRoute
);
assert_eq!(
"node.js".parse::<NextJsRuntimeKind>()?,
NextJsRuntimeKind::NodeJs
);
assert_eq!(
"next.config.ts".parse::<NextJsConfigFile>()?,
NextJsConfigFile::NextConfigTs
);
assert_eq!(
"file-based-metadata".parse::<NextJsMetadataKind>()?,
NextJsMetadataKind::FileBasedMetadata
);
assert_eq!(NextJsRuntimeKind::Edge.to_string(), "edge");
Ok(())
}
}