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#[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 #[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#[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 #[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#[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 #[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#[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 #[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#[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 #[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#[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 #[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#[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 #[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#[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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
415pub struct StorybookStoryName(String);
416
417impl StorybookStoryName {
418 pub fn new(input: &str) -> Result<Self, StorybookNameError> {
424 validate_free_text(input).map(Self)
425 }
426
427 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
458pub struct StorybookComponentTitle(String);
459
460impl StorybookComponentTitle {
461 pub fn new(input: &str) -> Result<Self, StorybookNameError> {
467 validate_free_text(input).map(Self)
468 }
469
470 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
501pub struct StorybookArgName(String);
502
503impl StorybookArgName {
504 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 #[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#[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}