#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::{fmt, str::FromStr};
use std::error::Error;
use use_js_identifier::{JsIdentifier, JsIdentifierError};
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookVersionFamily {
Storybook6,
Storybook7,
Storybook8,
Storybook9,
}
impl StorybookVersionFamily {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Storybook6 => "storybook6",
Self::Storybook7 => "storybook7",
Self::Storybook8 => "storybook8",
Self::Storybook9 => "storybook9",
}
}
}
impl fmt::Display for StorybookVersionFamily {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookVersionFamily {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"storybook6" | "6" => Ok(Self::Storybook6),
"storybook7" | "7" => Ok(Self::Storybook7),
"storybook8" | "8" => Ok(Self::Storybook8),
"storybook9" | "9" => Ok(Self::Storybook9),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookFrameworkKind {
React,
Vue,
Angular,
Svelte,
WebComponents,
Preact,
Ember,
Html,
}
impl StorybookFrameworkKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::React => "react",
Self::Vue => "vue",
Self::Angular => "angular",
Self::Svelte => "svelte",
Self::WebComponents => "web-components",
Self::Preact => "preact",
Self::Ember => "ember",
Self::Html => "html",
}
}
}
impl fmt::Display for StorybookFrameworkKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookFrameworkKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"react" => Ok(Self::React),
"vue" => Ok(Self::Vue),
"angular" => Ok(Self::Angular),
"svelte" => Ok(Self::Svelte),
"webcomponents" => Ok(Self::WebComponents),
"preact" => Ok(Self::Preact),
"ember" => Ok(Self::Ember),
"html" => Ok(Self::Html),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookFileKind {
Story,
MainConfig,
PreviewConfig,
ManagerConfig,
Theme,
Test,
Documentation,
}
impl StorybookFileKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Story => "story",
Self::MainConfig => "main-config",
Self::PreviewConfig => "preview-config",
Self::ManagerConfig => "manager-config",
Self::Theme => "theme",
Self::Test => "test",
Self::Documentation => "documentation",
}
}
}
impl fmt::Display for StorybookFileKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookFileKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"story" => Ok(Self::Story),
"mainconfig" | "main" => Ok(Self::MainConfig),
"previewconfig" | "preview" => Ok(Self::PreviewConfig),
"managerconfig" | "manager" => Ok(Self::ManagerConfig),
"theme" => Ok(Self::Theme),
"test" => Ok(Self::Test),
"documentation" | "docs" => Ok(Self::Documentation),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookStoryKind {
ComponentStory,
DocsStory,
MdxStory,
InteractionTest,
VisualTest,
}
impl StorybookStoryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::ComponentStory => "component-story",
Self::DocsStory => "docs-story",
Self::MdxStory => "mdx-story",
Self::InteractionTest => "interaction-test",
Self::VisualTest => "visual-test",
}
}
}
impl fmt::Display for StorybookStoryKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookStoryKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"componentstory" | "component" => Ok(Self::ComponentStory),
"docsstory" | "docs" => Ok(Self::DocsStory),
"mdxstory" | "mdx" => Ok(Self::MdxStory),
"interactiontest" | "interaction" => Ok(Self::InteractionTest),
"visualtest" | "visual" => Ok(Self::VisualTest),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookAddonKind {
Essentials,
Interactions,
Links,
A11y,
Coverage,
Docs,
Themes,
Viewport,
}
impl StorybookAddonKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Essentials => "essentials",
Self::Interactions => "interactions",
Self::Links => "links",
Self::A11y => "a11y",
Self::Coverage => "coverage",
Self::Docs => "docs",
Self::Themes => "themes",
Self::Viewport => "viewport",
}
}
}
impl fmt::Display for StorybookAddonKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookAddonKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"essentials" => Ok(Self::Essentials),
"interactions" => Ok(Self::Interactions),
"links" => Ok(Self::Links),
"a11y" | "accessibility" => Ok(Self::A11y),
"coverage" => Ok(Self::Coverage),
"docs" => Ok(Self::Docs),
"themes" => Ok(Self::Themes),
"viewport" => Ok(Self::Viewport),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookConfigFile {
MainJs,
MainTs,
PreviewJs,
PreviewTs,
ManagerJs,
ManagerTs,
}
impl StorybookConfigFile {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::MainJs => "main.js",
Self::MainTs => "main.ts",
Self::PreviewJs => "preview.js",
Self::PreviewTs => "preview.ts",
Self::ManagerJs => "manager.js",
Self::ManagerTs => "manager.ts",
}
}
}
impl fmt::Display for StorybookConfigFile {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookConfigFile {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"mainjs" => Ok(Self::MainJs),
"maints" => Ok(Self::MainTs),
"previewjs" => Ok(Self::PreviewJs),
"previewts" => Ok(Self::PreviewTs),
"managerjs" => Ok(Self::ManagerJs),
"managerts" => Ok(Self::ManagerTs),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookControlKind {
Text,
Number,
Boolean,
Select,
Radio,
Check,
Color,
Date,
Object,
Array,
}
impl StorybookControlKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Number => "number",
Self::Boolean => "boolean",
Self::Select => "select",
Self::Radio => "radio",
Self::Check => "check",
Self::Color => "color",
Self::Date => "date",
Self::Object => "object",
Self::Array => "array",
}
}
}
impl fmt::Display for StorybookControlKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookControlKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"text" => Ok(Self::Text),
"number" => Ok(Self::Number),
"boolean" | "bool" => Ok(Self::Boolean),
"select" => Ok(Self::Select),
"radio" => Ok(Self::Radio),
"check" | "checkbox" => Ok(Self::Check),
"color" => Ok(Self::Color),
"date" => Ok(Self::Date),
"object" => Ok(Self::Object),
"array" => Ok(Self::Array),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum StorybookParameterKind {
Actions,
Controls,
Layout,
Backgrounds,
Viewport,
Docs,
A11y,
}
impl StorybookParameterKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Actions => "actions",
Self::Controls => "controls",
Self::Layout => "layout",
Self::Backgrounds => "backgrounds",
Self::Viewport => "viewport",
Self::Docs => "docs",
Self::A11y => "a11y",
}
}
}
impl fmt::Display for StorybookParameterKind {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookParameterKind {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
match normalized_label(input)?.as_str() {
"actions" => Ok(Self::Actions),
"controls" => Ok(Self::Controls),
"layout" => Ok(Self::Layout),
"backgrounds" => Ok(Self::Backgrounds),
"viewport" => Ok(Self::Viewport),
"docs" => Ok(Self::Docs),
"a11y" | "accessibility" => Ok(Self::A11y),
_ => Err(StorybookNameError::UnknownLabel),
}
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct StorybookStoryName(String);
impl StorybookStoryName {
pub fn new(input: &str) -> Result<Self, StorybookNameError> {
validate_free_text(input).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for StorybookStoryName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookStoryName {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for StorybookStoryName {
type Error = StorybookNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct StorybookComponentTitle(String);
impl StorybookComponentTitle {
pub fn new(input: &str) -> Result<Self, StorybookNameError> {
validate_free_text(input).map(Self)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for StorybookComponentTitle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookComponentTitle {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for StorybookComponentTitle {
type Error = StorybookNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct StorybookArgName(String);
impl StorybookArgName {
pub fn new(input: &str) -> Result<Self, StorybookNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(StorybookNameError::Empty);
}
if trimmed.split('.').any(str::is_empty) {
return Err(StorybookNameError::InvalidDottedPath);
}
for segment in trimmed.split('.') {
JsIdentifier::new(segment).map_err(StorybookNameError::Identifier)?;
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for StorybookArgName {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
impl FromStr for StorybookArgName {
type Err = StorybookNameError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
Self::new(input)
}
}
impl TryFrom<&str> for StorybookArgName {
type Error = StorybookNameError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::new(value)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StorybookNameError {
Empty,
InvalidCharacter { character: char },
Identifier(JsIdentifierError),
InvalidDottedPath,
UnknownLabel,
}
impl fmt::Display for StorybookNameError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => formatter.write_str("Storybook metadata text cannot be empty"),
Self::InvalidCharacter { character } => {
write!(
formatter,
"invalid Storybook metadata character `{character}`"
)
}
Self::Identifier(error) => write!(formatter, "{error}"),
Self::InvalidDottedPath => formatter.write_str("invalid Storybook dotted arg path"),
Self::UnknownLabel => formatter.write_str("unknown Storybook metadata label"),
}
}
}
impl Error for StorybookNameError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Identifier(error) => Some(error),
Self::Empty
| Self::InvalidCharacter { .. }
| Self::InvalidDottedPath
| Self::UnknownLabel => None,
}
}
}
fn validate_free_text(input: &str) -> Result<String, StorybookNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(StorybookNameError::Empty);
}
if let Some(character) = trimmed.chars().find(|character| character.is_control()) {
return Err(StorybookNameError::InvalidCharacter { character });
}
Ok(trimmed.to_string())
}
fn normalized_label(input: &str) -> Result<String, StorybookNameError> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(StorybookNameError::Empty);
}
Ok(trimmed
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
.flat_map(char::to_lowercase)
.collect())
}
#[cfg(test)]
mod tests {
use super::{
StorybookAddonKind, StorybookArgName, StorybookComponentTitle, StorybookConfigFile,
StorybookControlKind, StorybookFileKind, StorybookFrameworkKind, StorybookNameError,
StorybookParameterKind, StorybookStoryKind, StorybookStoryName, StorybookVersionFamily,
};
use use_js_identifier::JsIdentifierError;
#[test]
fn validates_story_names() -> Result<(), StorybookNameError> {
let story = StorybookStoryName::new("Primary")?;
assert_eq!(story.as_str(), "Primary");
assert_eq!(StorybookStoryName::new(""), Err(StorybookNameError::Empty));
assert_eq!(
StorybookStoryName::new("Primary\nVariant"),
Err(StorybookNameError::InvalidCharacter { character: '\n' })
);
Ok(())
}
#[test]
fn validates_component_titles() -> Result<(), StorybookNameError> {
let title = StorybookComponentTitle::new("Forms/Button")?;
assert_eq!(title.as_str(), "Forms/Button");
assert_eq!(
StorybookComponentTitle::new("Forms\nButton"),
Err(StorybookNameError::InvalidCharacter { character: '\n' })
);
Ok(())
}
#[test]
fn validates_arg_names() -> Result<(), StorybookNameError> {
let arg = StorybookArgName::new("button.label")?;
assert_eq!(arg.as_str(), "button.label");
assert_eq!(
StorybookArgName::new("button..label"),
Err(StorybookNameError::InvalidDottedPath)
);
assert_eq!(
StorybookArgName::new("1button"),
Err(StorybookNameError::Identifier(
JsIdentifierError::InvalidStart { character: '1' }
))
);
Ok(())
}
#[test]
fn parses_labels() -> Result<(), StorybookNameError> {
assert_eq!(
"storybook8".parse::<StorybookVersionFamily>()?,
StorybookVersionFamily::Storybook8
);
assert_eq!(
"web-components".parse::<StorybookFrameworkKind>()?,
StorybookFrameworkKind::WebComponents
);
assert_eq!(
"preview-config".parse::<StorybookFileKind>()?,
StorybookFileKind::PreviewConfig
);
assert_eq!(
"mdx".parse::<StorybookStoryKind>()?,
StorybookStoryKind::MdxStory
);
assert_eq!(
"a11y".parse::<StorybookAddonKind>()?,
StorybookAddonKind::A11y
);
assert_eq!(
"preview.ts".parse::<StorybookConfigFile>()?,
StorybookConfigFile::PreviewTs
);
assert_eq!(
"select".parse::<StorybookControlKind>()?,
StorybookControlKind::Select
);
assert_eq!(
"backgrounds".parse::<StorybookParameterKind>()?,
StorybookParameterKind::Backgrounds
);
assert_eq!(StorybookControlKind::Boolean.to_string(), "boolean");
Ok(())
}
}