Skip to main content

vize_musea/transform/
to_csf.rs

1//! Transform Art to Storybook CSF 3.0 format.
2//!
3//! This module generates Storybook-compatible Component Story Format (CSF) files
4//! from Art descriptors.
5
6#![allow(clippy::disallowed_macros)]
7
8use crate::types::{ArtDescriptor, ArtVariant, CsfOutput};
9use vize_carton::{String, ToCompactString, append, cstr};
10
11/// Transform an Art descriptor to Storybook CSF 3.0 format.
12///
13/// # Example
14///
15/// ```ignore
16/// use vize_musea::transform::transform_to_csf;
17/// use vize_musea::parse::parse_art;
18///
19/// let source = r#"
20/// <art title="Button" component="./Button.vue">
21///   <variant name="Primary" default>
22///     <Button>Click</Button>
23///   </variant>
24/// </art>
25/// "#;
26///
27/// let art = parse_art(source, Default::default()).unwrap();
28/// let csf = transform_to_csf(&art);
29/// ```
30pub fn transform_to_csf(art: &ArtDescriptor<'_>) -> CsfOutput {
31    let mut output = String::default();
32
33    // Generate imports
34    output.push_str(&generate_imports(art));
35    output.push('\n');
36
37    // Generate meta (default export)
38    output.push_str(&generate_meta(art));
39    output.push('\n');
40
41    // Generate stories (named exports)
42    for variant in &art.variants {
43        output.push_str(&generate_story(variant, art));
44        output.push('\n');
45    }
46
47    // Determine filename
48    let base_name = art
49        .filename
50        .trim_end_matches(".art.vue")
51        .rsplit('/')
52        .next()
53        .unwrap_or("Component");
54
55    CsfOutput {
56        code: output,
57        filename: cstr!("{}.stories.ts", base_name),
58    }
59}
60
61/// Generate import statements.
62fn generate_imports(art: &ArtDescriptor<'_>) -> String {
63    let mut imports = String::default();
64
65    // Import from Storybook
66    imports.push_str("import type { Meta, StoryObj } from '@storybook/vue3';\n");
67
68    // Import the component
69    let component_path = art.metadata.component.unwrap_or("./Component.vue");
70
71    append!(imports, "import Component from '{component_path}';\n");
72
73    // Add script imports if present
74    if let Some(script) = &art.script_setup {
75        // Extract imports from script setup
76        for line in script.content.lines() {
77            let trimmed = line.trim();
78            if trimmed.starts_with("import ") && !trimmed.contains("Component") {
79                imports.push_str(trimmed);
80                imports.push('\n');
81            }
82        }
83    }
84
85    imports
86}
87
88/// Generate meta (default export).
89fn generate_meta(art: &ArtDescriptor<'_>) -> String {
90    let mut meta = String::default();
91
92    // Build the title path
93    let title = if let Some(ref category) = art.metadata.category {
94        cstr!("{}/{}", category, art.metadata.title)
95    } else {
96        art.metadata.title.to_compact_string()
97    };
98
99    meta.push_str("const meta: Meta<typeof Component> = {\n");
100    append!(meta, "  title: '{}',\n", escape_string(&title));
101    meta.push_str("  component: Component,\n");
102
103    // Add tags
104    let mut tags: Vec<String> = vec!["autodocs".to_compact_string()];
105    for tag in &art.metadata.tags {
106        tags.push(tag.to_compact_string());
107    }
108    append!(
109        meta,
110        "  tags: [{}],\n",
111        tags.iter()
112            .map(|t| cstr!("'{}'", t))
113            .collect::<Vec<_>>()
114            .join(", ")
115    );
116
117    // Add parameters for description
118    if let Some(desc) = art.metadata.description {
119        meta.push_str("  parameters: {\n");
120        meta.push_str("    docs: {\n");
121        meta.push_str("      description: {\n");
122        append!(meta, "        component: '{}',\n", escape_string(desc));
123        meta.push_str("      },\n");
124        meta.push_str("    },\n");
125        meta.push_str("  },\n");
126    }
127
128    meta.push_str("};\n\n");
129    meta.push_str("export default meta;\n");
130    meta.push_str("type Story = StoryObj<typeof meta>;\n");
131
132    meta
133}
134
135/// Generate a story (named export) from a variant.
136fn generate_story(variant: &ArtVariant<'_>, _art: &ArtDescriptor<'_>) -> String {
137    let mut story = String::default();
138
139    // Convert variant name to PascalCase for export name
140    let export_name = to_pascal_case(variant.name);
141
142    append!(story, "export const {export_name}: Story = {{\n");
143
144    // Add name if different from export name
145    if export_name != variant.name {
146        append!(story, "  name: '{}',\n", escape_string(variant.name));
147    }
148
149    // Add args if present
150    if !variant.args.is_empty() {
151        story.push_str("  args: {\n");
152        for (key, value) in &variant.args {
153            let value_str = serde_json::to_string(value).unwrap_or_else(|_| "undefined".into());
154            append!(story, "    {key}: {value_str},\n");
155        }
156        story.push_str("  },\n");
157    }
158
159    // Add render function with template
160    story.push_str("  render: (args) => ({\n");
161    story.push_str("    components: { Component },\n");
162    story.push_str("    setup() {\n");
163    story.push_str("      return { args };\n");
164    story.push_str("    },\n");
165
166    // Use the variant's template
167    let template = variant.template.trim();
168    append!(story, "    template: `{}`,\n", escape_template(template));
169
170    story.push_str("  }),\n");
171
172    // Add parameters for default story
173    if variant.is_default {
174        story.push_str("  parameters: {\n");
175        story.push_str("    docs: {\n");
176        story.push_str("      canvas: { sourceState: 'shown' },\n");
177        story.push_str("    },\n");
178        story.push_str("  },\n");
179    }
180
181    story.push_str("};\n");
182
183    story
184}
185
186/// Convert a string to PascalCase.
187fn to_pascal_case(s: &str) -> String {
188    let mut result = String::default();
189    for part in s
190        .split(|c: char| c.is_whitespace() || c == '-' || c == '_')
191        .filter(|p| !p.is_empty())
192    {
193        let mut chars = part.chars();
194        if let Some(first) = chars.next() {
195            for uc in first.to_uppercase() {
196                result.push(uc);
197            }
198            for ch in chars {
199                result.push(ch);
200            }
201        }
202    }
203    result
204}
205
206/// Escape a string for JavaScript string literal.
207fn escape_string(s: &str) -> String {
208    s.replace('\\', "\\\\")
209        .replace('\'', "\\'")
210        .replace('\n', "\\n")
211        .replace('\r', "\\r")
212        .replace('\t', "\\t")
213        .into()
214}
215
216/// Escape a template string for JavaScript template literal.
217fn escape_template(s: &str) -> String {
218    s.replace('\\', "\\\\")
219        .replace('`', "\\`")
220        .replace("${", "\\${")
221        .into()
222}
223
224#[cfg(test)]
225mod tests {
226    use super::{escape_string, escape_template, to_pascal_case, transform_to_csf};
227    use crate::parse::parse_art;
228    use crate::types::ArtParseOptions;
229    use vize_carton::Bump;
230
231    #[test]
232    fn test_transform_simple() {
233        let allocator = Bump::new();
234        let source = r#"
235<art title="Button" component="./Button.vue">
236  <variant name="Primary" default>
237    <Button variant="primary">Click me</Button>
238  </variant>
239</art>
240"#;
241
242        let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
243        let csf = transform_to_csf(&art);
244
245        insta::assert_debug_snapshot!(csf);
246    }
247
248    #[test]
249    fn test_transform_with_category() {
250        let allocator = Bump::new();
251        let source = r#"
252<art title="Button" category="atoms" component="./Button.vue">
253  <variant name="Default">
254    <Button>Click</Button>
255  </variant>
256</art>
257"#;
258
259        let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
260        let csf = transform_to_csf(&art);
261
262        insta::assert_debug_snapshot!(csf);
263    }
264
265    #[test]
266    fn test_transform_multiple_variants() {
267        let allocator = Bump::new();
268        let source = r#"
269<art title="Button" component="./Button.vue">
270  <variant name="Primary">
271    <Button variant="primary">Primary</Button>
272  </variant>
273  <variant name="Secondary">
274    <Button variant="secondary">Secondary</Button>
275  </variant>
276</art>
277"#;
278
279        let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
280        let csf = transform_to_csf(&art);
281
282        insta::assert_debug_snapshot!(csf);
283    }
284
285    #[test]
286    fn test_to_pascal_case() {
287        assert_eq!(to_pascal_case("primary"), "Primary");
288        assert_eq!(to_pascal_case("with icon"), "WithIcon");
289        assert_eq!(to_pascal_case("my-button"), "MyButton");
290        assert_eq!(to_pascal_case("my_button"), "MyButton");
291    }
292
293    #[test]
294    fn test_escape_string() {
295        assert_eq!(escape_string("hello"), "hello");
296        assert_eq!(escape_string("it's"), "it\\'s");
297        assert_eq!(escape_string("line\nbreak"), "line\\nbreak");
298    }
299
300    #[test]
301    fn test_escape_template() {
302        assert_eq!(escape_template("hello"), "hello");
303        assert_eq!(escape_template("`code`"), "\\`code\\`");
304        assert_eq!(escape_template("${var}"), "\\${var}");
305    }
306}