Skip to main content

use_storybook/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6use use_js_identifier::{JsIdentifier, JsIdentifierError};
7
8/// Storybook version-family labels.
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub enum StorybookVersionFamily {
11    Storybook6,
12    Storybook7,
13    Storybook8,
14    Storybook9,
15}
16
17impl StorybookVersionFamily {
18    /// Returns the version-family label.
19    #[must_use]
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Storybook6 => "storybook6",
23            Self::Storybook7 => "storybook7",
24            Self::Storybook8 => "storybook8",
25            Self::Storybook9 => "storybook9",
26        }
27    }
28}
29
30impl fmt::Display for StorybookVersionFamily {
31    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32        formatter.write_str(self.as_str())
33    }
34}
35
36impl FromStr for StorybookVersionFamily {
37    type Err = StorybookNameError;
38
39    fn from_str(input: &str) -> Result<Self, Self::Err> {
40        match normalized_label(input)?.as_str() {
41            "storybook6" | "6" => Ok(Self::Storybook6),
42            "storybook7" | "7" => Ok(Self::Storybook7),
43            "storybook8" | "8" => Ok(Self::Storybook8),
44            "storybook9" | "9" => Ok(Self::Storybook9),
45            _ => Err(StorybookNameError::UnknownLabel),
46        }
47    }
48}
49
50/// Storybook framework labels.
51#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
52pub enum StorybookFrameworkKind {
53    React,
54    Vue,
55    Angular,
56    Svelte,
57    WebComponents,
58    Preact,
59    Ember,
60    Html,
61}
62
63impl StorybookFrameworkKind {
64    /// Returns the framework label.
65    #[must_use]
66    pub const fn as_str(self) -> &'static str {
67        match self {
68            Self::React => "react",
69            Self::Vue => "vue",
70            Self::Angular => "angular",
71            Self::Svelte => "svelte",
72            Self::WebComponents => "web-components",
73            Self::Preact => "preact",
74            Self::Ember => "ember",
75            Self::Html => "html",
76        }
77    }
78}
79
80impl fmt::Display for StorybookFrameworkKind {
81    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
82        formatter.write_str(self.as_str())
83    }
84}
85
86impl FromStr for StorybookFrameworkKind {
87    type Err = StorybookNameError;
88
89    fn from_str(input: &str) -> Result<Self, Self::Err> {
90        match normalized_label(input)?.as_str() {
91            "react" => Ok(Self::React),
92            "vue" => Ok(Self::Vue),
93            "angular" => Ok(Self::Angular),
94            "svelte" => Ok(Self::Svelte),
95            "webcomponents" => Ok(Self::WebComponents),
96            "preact" => Ok(Self::Preact),
97            "ember" => Ok(Self::Ember),
98            "html" => Ok(Self::Html),
99            _ => Err(StorybookNameError::UnknownLabel),
100        }
101    }
102}
103
104/// Storybook file-kind labels.
105#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
106pub enum StorybookFileKind {
107    Story,
108    MainConfig,
109    PreviewConfig,
110    ManagerConfig,
111    Theme,
112    Test,
113    Documentation,
114}
115
116impl StorybookFileKind {
117    /// Returns the file-kind label.
118    #[must_use]
119    pub const fn as_str(self) -> &'static str {
120        match self {
121            Self::Story => "story",
122            Self::MainConfig => "main-config",
123            Self::PreviewConfig => "preview-config",
124            Self::ManagerConfig => "manager-config",
125            Self::Theme => "theme",
126            Self::Test => "test",
127            Self::Documentation => "documentation",
128        }
129    }
130}
131
132impl fmt::Display for StorybookFileKind {
133    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
134        formatter.write_str(self.as_str())
135    }
136}
137
138impl FromStr for StorybookFileKind {
139    type Err = StorybookNameError;
140
141    fn from_str(input: &str) -> Result<Self, Self::Err> {
142        match normalized_label(input)?.as_str() {
143            "story" => Ok(Self::Story),
144            "mainconfig" | "main" => Ok(Self::MainConfig),
145            "previewconfig" | "preview" => Ok(Self::PreviewConfig),
146            "managerconfig" | "manager" => Ok(Self::ManagerConfig),
147            "theme" => Ok(Self::Theme),
148            "test" => Ok(Self::Test),
149            "documentation" | "docs" => Ok(Self::Documentation),
150            _ => Err(StorybookNameError::UnknownLabel),
151        }
152    }
153}
154
155/// Storybook story-kind labels.
156#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
157pub enum StorybookStoryKind {
158    ComponentStory,
159    DocsStory,
160    MdxStory,
161    InteractionTest,
162    VisualTest,
163}
164
165impl StorybookStoryKind {
166    /// Returns the story-kind label.
167    #[must_use]
168    pub const fn as_str(self) -> &'static str {
169        match self {
170            Self::ComponentStory => "component-story",
171            Self::DocsStory => "docs-story",
172            Self::MdxStory => "mdx-story",
173            Self::InteractionTest => "interaction-test",
174            Self::VisualTest => "visual-test",
175        }
176    }
177}
178
179impl fmt::Display for StorybookStoryKind {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        formatter.write_str(self.as_str())
182    }
183}
184
185impl FromStr for StorybookStoryKind {
186    type Err = StorybookNameError;
187
188    fn from_str(input: &str) -> Result<Self, Self::Err> {
189        match normalized_label(input)?.as_str() {
190            "componentstory" | "component" => Ok(Self::ComponentStory),
191            "docsstory" | "docs" => Ok(Self::DocsStory),
192            "mdxstory" | "mdx" => Ok(Self::MdxStory),
193            "interactiontest" | "interaction" => Ok(Self::InteractionTest),
194            "visualtest" | "visual" => Ok(Self::VisualTest),
195            _ => Err(StorybookNameError::UnknownLabel),
196        }
197    }
198}
199
200/// Storybook addon labels.
201#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
202pub enum StorybookAddonKind {
203    Essentials,
204    Interactions,
205    Links,
206    A11y,
207    Coverage,
208    Docs,
209    Themes,
210    Viewport,
211}
212
213impl StorybookAddonKind {
214    /// Returns the addon label.
215    #[must_use]
216    pub const fn as_str(self) -> &'static str {
217        match self {
218            Self::Essentials => "essentials",
219            Self::Interactions => "interactions",
220            Self::Links => "links",
221            Self::A11y => "a11y",
222            Self::Coverage => "coverage",
223            Self::Docs => "docs",
224            Self::Themes => "themes",
225            Self::Viewport => "viewport",
226        }
227    }
228}
229
230impl fmt::Display for StorybookAddonKind {
231    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
232        formatter.write_str(self.as_str())
233    }
234}
235
236impl FromStr for StorybookAddonKind {
237    type Err = StorybookNameError;
238
239    fn from_str(input: &str) -> Result<Self, Self::Err> {
240        match normalized_label(input)?.as_str() {
241            "essentials" => Ok(Self::Essentials),
242            "interactions" => Ok(Self::Interactions),
243            "links" => Ok(Self::Links),
244            "a11y" | "accessibility" => Ok(Self::A11y),
245            "coverage" => Ok(Self::Coverage),
246            "docs" => Ok(Self::Docs),
247            "themes" => Ok(Self::Themes),
248            "viewport" => Ok(Self::Viewport),
249            _ => Err(StorybookNameError::UnknownLabel),
250        }
251    }
252}
253
254/// Common Storybook config file labels.
255#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
256pub enum StorybookConfigFile {
257    MainJs,
258    MainTs,
259    PreviewJs,
260    PreviewTs,
261    ManagerJs,
262    ManagerTs,
263}
264
265impl StorybookConfigFile {
266    /// Returns the config file label.
267    #[must_use]
268    pub const fn as_str(self) -> &'static str {
269        match self {
270            Self::MainJs => "main.js",
271            Self::MainTs => "main.ts",
272            Self::PreviewJs => "preview.js",
273            Self::PreviewTs => "preview.ts",
274            Self::ManagerJs => "manager.js",
275            Self::ManagerTs => "manager.ts",
276        }
277    }
278}
279
280impl fmt::Display for StorybookConfigFile {
281    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
282        formatter.write_str(self.as_str())
283    }
284}
285
286impl FromStr for StorybookConfigFile {
287    type Err = StorybookNameError;
288
289    fn from_str(input: &str) -> Result<Self, Self::Err> {
290        match normalized_label(input)?.as_str() {
291            "mainjs" => Ok(Self::MainJs),
292            "maints" => Ok(Self::MainTs),
293            "previewjs" => Ok(Self::PreviewJs),
294            "previewts" => Ok(Self::PreviewTs),
295            "managerjs" => Ok(Self::ManagerJs),
296            "managerts" => Ok(Self::ManagerTs),
297            _ => Err(StorybookNameError::UnknownLabel),
298        }
299    }
300}
301
302/// Storybook control-kind labels.
303#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
304pub enum StorybookControlKind {
305    Text,
306    Number,
307    Boolean,
308    Select,
309    Radio,
310    Check,
311    Color,
312    Date,
313    Object,
314    Array,
315}
316
317impl StorybookControlKind {
318    /// Returns the control-kind label.
319    #[must_use]
320    pub const fn as_str(self) -> &'static str {
321        match self {
322            Self::Text => "text",
323            Self::Number => "number",
324            Self::Boolean => "boolean",
325            Self::Select => "select",
326            Self::Radio => "radio",
327            Self::Check => "check",
328            Self::Color => "color",
329            Self::Date => "date",
330            Self::Object => "object",
331            Self::Array => "array",
332        }
333    }
334}
335
336impl fmt::Display for StorybookControlKind {
337    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
338        formatter.write_str(self.as_str())
339    }
340}
341
342impl FromStr for StorybookControlKind {
343    type Err = StorybookNameError;
344
345    fn from_str(input: &str) -> Result<Self, Self::Err> {
346        match normalized_label(input)?.as_str() {
347            "text" => Ok(Self::Text),
348            "number" => Ok(Self::Number),
349            "boolean" | "bool" => Ok(Self::Boolean),
350            "select" => Ok(Self::Select),
351            "radio" => Ok(Self::Radio),
352            "check" | "checkbox" => Ok(Self::Check),
353            "color" => Ok(Self::Color),
354            "date" => Ok(Self::Date),
355            "object" => Ok(Self::Object),
356            "array" => Ok(Self::Array),
357            _ => Err(StorybookNameError::UnknownLabel),
358        }
359    }
360}
361
362/// Storybook parameter-kind labels.
363#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
364pub enum StorybookParameterKind {
365    Actions,
366    Controls,
367    Layout,
368    Backgrounds,
369    Viewport,
370    Docs,
371    A11y,
372}
373
374impl StorybookParameterKind {
375    /// Returns the parameter-kind label.
376    #[must_use]
377    pub const fn as_str(self) -> &'static str {
378        match self {
379            Self::Actions => "actions",
380            Self::Controls => "controls",
381            Self::Layout => "layout",
382            Self::Backgrounds => "backgrounds",
383            Self::Viewport => "viewport",
384            Self::Docs => "docs",
385            Self::A11y => "a11y",
386        }
387    }
388}
389
390impl fmt::Display for StorybookParameterKind {
391    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
392        formatter.write_str(self.as_str())
393    }
394}
395
396impl FromStr for StorybookParameterKind {
397    type Err = StorybookNameError;
398
399    fn from_str(input: &str) -> Result<Self, Self::Err> {
400        match normalized_label(input)?.as_str() {
401            "actions" => Ok(Self::Actions),
402            "controls" => Ok(Self::Controls),
403            "layout" => Ok(Self::Layout),
404            "backgrounds" => Ok(Self::Backgrounds),
405            "viewport" => Ok(Self::Viewport),
406            "docs" => Ok(Self::Docs),
407            "a11y" | "accessibility" => Ok(Self::A11y),
408            _ => Err(StorybookNameError::UnknownLabel),
409        }
410    }
411}
412
413/// Validated Storybook story name metadata.
414#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
415pub struct StorybookStoryName(String);
416
417impl StorybookStoryName {
418    /// Creates Storybook story name metadata.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`StorybookNameError`] when `input` is empty or contains control characters.
423    pub fn new(input: &str) -> Result<Self, StorybookNameError> {
424        validate_free_text(input).map(Self)
425    }
426
427    /// Returns the story name.
428    #[must_use]
429    pub fn as_str(&self) -> &str {
430        &self.0
431    }
432}
433
434impl fmt::Display for StorybookStoryName {
435    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
436        formatter.write_str(self.as_str())
437    }
438}
439
440impl FromStr for StorybookStoryName {
441    type Err = StorybookNameError;
442
443    fn from_str(input: &str) -> Result<Self, Self::Err> {
444        Self::new(input)
445    }
446}
447
448impl TryFrom<&str> for StorybookStoryName {
449    type Error = StorybookNameError;
450
451    fn try_from(value: &str) -> Result<Self, Self::Error> {
452        Self::new(value)
453    }
454}
455
456/// Validated Storybook component title metadata.
457#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
458pub struct StorybookComponentTitle(String);
459
460impl StorybookComponentTitle {
461    /// Creates Storybook component title metadata.
462    ///
463    /// # Errors
464    ///
465    /// Returns [`StorybookNameError`] when `input` is empty or contains control characters.
466    pub fn new(input: &str) -> Result<Self, StorybookNameError> {
467        validate_free_text(input).map(Self)
468    }
469
470    /// Returns the component title.
471    #[must_use]
472    pub fn as_str(&self) -> &str {
473        &self.0
474    }
475}
476
477impl fmt::Display for StorybookComponentTitle {
478    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
479        formatter.write_str(self.as_str())
480    }
481}
482
483impl FromStr for StorybookComponentTitle {
484    type Err = StorybookNameError;
485
486    fn from_str(input: &str) -> Result<Self, Self::Err> {
487        Self::new(input)
488    }
489}
490
491impl TryFrom<&str> for StorybookComponentTitle {
492    type Error = StorybookNameError;
493
494    fn try_from(value: &str) -> Result<Self, Self::Error> {
495        Self::new(value)
496    }
497}
498
499/// Validated Storybook arg name metadata.
500#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
501pub struct StorybookArgName(String);
502
503impl StorybookArgName {
504    /// Creates Storybook arg name metadata.
505    ///
506    /// # Errors
507    ///
508    /// Returns [`StorybookNameError`] when `input` is not an ASCII identifier or dotted identifier path.
509    pub fn new(input: &str) -> Result<Self, StorybookNameError> {
510        let trimmed = input.trim();
511        if trimmed.is_empty() {
512            return Err(StorybookNameError::Empty);
513        }
514        if trimmed.split('.').any(str::is_empty) {
515            return Err(StorybookNameError::InvalidDottedPath);
516        }
517        for segment in trimmed.split('.') {
518            JsIdentifier::new(segment).map_err(StorybookNameError::Identifier)?;
519        }
520        Ok(Self(trimmed.to_string()))
521    }
522
523    /// Returns the arg name.
524    #[must_use]
525    pub fn as_str(&self) -> &str {
526        &self.0
527    }
528}
529
530impl fmt::Display for StorybookArgName {
531    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
532        formatter.write_str(self.as_str())
533    }
534}
535
536impl FromStr for StorybookArgName {
537    type Err = StorybookNameError;
538
539    fn from_str(input: &str) -> Result<Self, Self::Err> {
540        Self::new(input)
541    }
542}
543
544impl TryFrom<&str> for StorybookArgName {
545    type Error = StorybookNameError;
546
547    fn try_from(value: &str) -> Result<Self, Self::Error> {
548        Self::new(value)
549    }
550}
551
552/// Error returned when Storybook metadata is invalid.
553#[derive(Clone, Debug, Eq, PartialEq)]
554pub enum StorybookNameError {
555    Empty,
556    InvalidCharacter { character: char },
557    Identifier(JsIdentifierError),
558    InvalidDottedPath,
559    UnknownLabel,
560}
561
562impl fmt::Display for StorybookNameError {
563    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
564        match self {
565            Self::Empty => formatter.write_str("Storybook metadata text cannot be empty"),
566            Self::InvalidCharacter { character } => {
567                write!(
568                    formatter,
569                    "invalid Storybook metadata character `{character}`"
570                )
571            }
572            Self::Identifier(error) => write!(formatter, "{error}"),
573            Self::InvalidDottedPath => formatter.write_str("invalid Storybook dotted arg path"),
574            Self::UnknownLabel => formatter.write_str("unknown Storybook metadata label"),
575        }
576    }
577}
578
579impl Error for StorybookNameError {
580    fn source(&self) -> Option<&(dyn Error + 'static)> {
581        match self {
582            Self::Identifier(error) => Some(error),
583            Self::Empty
584            | Self::InvalidCharacter { .. }
585            | Self::InvalidDottedPath
586            | Self::UnknownLabel => None,
587        }
588    }
589}
590
591fn validate_free_text(input: &str) -> Result<String, StorybookNameError> {
592    let trimmed = input.trim();
593    if trimmed.is_empty() {
594        return Err(StorybookNameError::Empty);
595    }
596    if let Some(character) = trimmed.chars().find(|character| character.is_control()) {
597        return Err(StorybookNameError::InvalidCharacter { character });
598    }
599    Ok(trimmed.to_string())
600}
601
602fn normalized_label(input: &str) -> Result<String, StorybookNameError> {
603    let trimmed = input.trim();
604    if trimmed.is_empty() {
605        return Err(StorybookNameError::Empty);
606    }
607    Ok(trimmed
608        .chars()
609        .filter(|character| !matches!(character, '-' | '_' | ' ' | '.'))
610        .flat_map(char::to_lowercase)
611        .collect())
612}
613
614#[cfg(test)]
615mod tests {
616    use super::{
617        StorybookAddonKind, StorybookArgName, StorybookComponentTitle, StorybookConfigFile,
618        StorybookControlKind, StorybookFileKind, StorybookFrameworkKind, StorybookNameError,
619        StorybookParameterKind, StorybookStoryKind, StorybookStoryName, StorybookVersionFamily,
620    };
621    use use_js_identifier::JsIdentifierError;
622
623    #[test]
624    fn validates_story_names() -> Result<(), StorybookNameError> {
625        let story = StorybookStoryName::new("Primary")?;
626        assert_eq!(story.as_str(), "Primary");
627        assert_eq!(StorybookStoryName::new(""), Err(StorybookNameError::Empty));
628        assert_eq!(
629            StorybookStoryName::new("Primary\nVariant"),
630            Err(StorybookNameError::InvalidCharacter { character: '\n' })
631        );
632        Ok(())
633    }
634
635    #[test]
636    fn validates_component_titles() -> Result<(), StorybookNameError> {
637        let title = StorybookComponentTitle::new("Forms/Button")?;
638        assert_eq!(title.as_str(), "Forms/Button");
639        assert_eq!(
640            StorybookComponentTitle::new("Forms\nButton"),
641            Err(StorybookNameError::InvalidCharacter { character: '\n' })
642        );
643        Ok(())
644    }
645
646    #[test]
647    fn validates_arg_names() -> Result<(), StorybookNameError> {
648        let arg = StorybookArgName::new("button.label")?;
649        assert_eq!(arg.as_str(), "button.label");
650        assert_eq!(
651            StorybookArgName::new("button..label"),
652            Err(StorybookNameError::InvalidDottedPath)
653        );
654        assert_eq!(
655            StorybookArgName::new("1button"),
656            Err(StorybookNameError::Identifier(
657                JsIdentifierError::InvalidStart { character: '1' }
658            ))
659        );
660        Ok(())
661    }
662
663    #[test]
664    fn parses_labels() -> Result<(), StorybookNameError> {
665        assert_eq!(
666            "storybook8".parse::<StorybookVersionFamily>()?,
667            StorybookVersionFamily::Storybook8
668        );
669        assert_eq!(
670            "web-components".parse::<StorybookFrameworkKind>()?,
671            StorybookFrameworkKind::WebComponents
672        );
673        assert_eq!(
674            "preview-config".parse::<StorybookFileKind>()?,
675            StorybookFileKind::PreviewConfig
676        );
677        assert_eq!(
678            "mdx".parse::<StorybookStoryKind>()?,
679            StorybookStoryKind::MdxStory
680        );
681        assert_eq!(
682            "a11y".parse::<StorybookAddonKind>()?,
683            StorybookAddonKind::A11y
684        );
685        assert_eq!(
686            "preview.ts".parse::<StorybookConfigFile>()?,
687            StorybookConfigFile::PreviewTs
688        );
689        assert_eq!(
690            "select".parse::<StorybookControlKind>()?,
691            StorybookControlKind::Select
692        );
693        assert_eq!(
694            "backgrounds".parse::<StorybookParameterKind>()?,
695            StorybookParameterKind::Backgrounds
696        );
697        assert_eq!(StorybookControlKind::Boolean.to_string(), "boolean");
698        Ok(())
699    }
700}