use crate::types::{ArtDescriptor, ArtVariant, CsfOutput};
pub fn transform_to_csf(art: &ArtDescriptor<'_>) -> CsfOutput {
let mut output = String::new();
output.push_str(&generate_imports(art));
output.push('\n');
output.push_str(&generate_meta(art));
output.push('\n');
for variant in &art.variants {
output.push_str(&generate_story(variant, art));
output.push('\n');
}
let base_name = art
.filename
.trim_end_matches(".art.vue")
.rsplit('/')
.next()
.unwrap_or("Component");
CsfOutput {
code: output,
filename: format!("{}.stories.ts", base_name),
}
}
fn generate_imports(art: &ArtDescriptor<'_>) -> String {
let mut imports = String::new();
imports.push_str("import type { Meta, StoryObj } from '@storybook/vue3';\n");
let component_path = art.metadata.component.unwrap_or("./Component.vue");
imports.push_str(&format!("import Component from '{}';\n", component_path));
if let Some(script) = &art.script_setup {
for line in script.content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("import ") && !trimmed.contains("Component") {
imports.push_str(trimmed);
imports.push('\n');
}
}
}
imports
}
fn generate_meta(art: &ArtDescriptor<'_>) -> String {
let mut meta = String::new();
let title = if let Some(ref category) = art.metadata.category {
format!("{}/{}", category, art.metadata.title)
} else {
art.metadata.title.to_string()
};
meta.push_str("const meta: Meta<typeof Component> = {\n");
meta.push_str(&format!(" title: '{}',\n", escape_string(&title)));
meta.push_str(" component: Component,\n");
let mut tags = vec!["autodocs".to_string()];
for tag in &art.metadata.tags {
tags.push(tag.to_string());
}
meta.push_str(&format!(
" tags: [{}],\n",
tags.iter()
.map(|t| format!("'{}'", t))
.collect::<Vec<_>>()
.join(", ")
));
if let Some(desc) = art.metadata.description {
meta.push_str(" parameters: {\n");
meta.push_str(" docs: {\n");
meta.push_str(" description: {\n");
meta.push_str(&format!(" component: '{}',\n", escape_string(desc)));
meta.push_str(" },\n");
meta.push_str(" },\n");
meta.push_str(" },\n");
}
meta.push_str("};\n\n");
meta.push_str("export default meta;\n");
meta.push_str("type Story = StoryObj<typeof meta>;\n");
meta
}
fn generate_story(variant: &ArtVariant<'_>, _art: &ArtDescriptor<'_>) -> String {
let mut story = String::new();
let export_name = to_pascal_case(variant.name);
story.push_str(&format!("export const {}: Story = {{\n", export_name));
if export_name != variant.name {
story.push_str(&format!(" name: '{}',\n", escape_string(variant.name)));
}
if !variant.args.is_empty() {
story.push_str(" args: {\n");
for (key, value) in &variant.args {
let value_str =
serde_json::to_string(value).unwrap_or_else(|_| "undefined".to_string());
story.push_str(&format!(" {}: {},\n", key, value_str));
}
story.push_str(" },\n");
}
story.push_str(" render: (args) => ({\n");
story.push_str(" components: { Component },\n");
story.push_str(" setup() {\n");
story.push_str(" return { args };\n");
story.push_str(" },\n");
let template = variant.template.trim();
story.push_str(&format!(" template: `{}`,\n", escape_template(template)));
story.push_str(" }),\n");
if variant.is_default {
story.push_str(" parameters: {\n");
story.push_str(" docs: {\n");
story.push_str(" canvas: { sourceState: 'shown' },\n");
story.push_str(" },\n");
story.push_str(" },\n");
}
story.push_str("};\n");
story
}
fn to_pascal_case(s: &str) -> String {
s.split(|c: char| c.is_whitespace() || c == '-' || c == '_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect()
}
fn escape_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\'', "\\'")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn escape_template(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('`', "\\`")
.replace("${", "\\${")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::parse_art;
use crate::types::ArtParseOptions;
use vize_carton::Bump;
#[test]
fn test_transform_simple() {
let allocator = Bump::new();
let source = r#"
<art title="Button" component="./Button.vue">
<variant name="Primary" default>
<Button variant="primary">Click me</Button>
</variant>
</art>
"#;
let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
let csf = transform_to_csf(&art);
assert!(csf.code.contains("import type { Meta, StoryObj }"));
assert!(csf.code.contains("import Component from './Button.vue'"));
assert!(csf.code.contains("title: 'Button'"));
assert!(csf.code.contains("export const Primary: Story"));
assert!(csf.filename.ends_with(".stories.ts"));
}
#[test]
fn test_transform_with_category() {
let allocator = Bump::new();
let source = r#"
<art title="Button" category="atoms" component="./Button.vue">
<variant name="Default">
<Button>Click</Button>
</variant>
</art>
"#;
let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
let csf = transform_to_csf(&art);
assert!(csf.code.contains("title: 'atoms/Button'"));
}
#[test]
fn test_transform_multiple_variants() {
let allocator = Bump::new();
let source = r#"
<art title="Button" component="./Button.vue">
<variant name="Primary">
<Button variant="primary">Primary</Button>
</variant>
<variant name="Secondary">
<Button variant="secondary">Secondary</Button>
</variant>
</art>
"#;
let art = parse_art(&allocator, source, ArtParseOptions::default()).unwrap();
let csf = transform_to_csf(&art);
assert!(csf.code.contains("export const Primary: Story"));
assert!(csf.code.contains("export const Secondary: Story"));
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("primary"), "Primary");
assert_eq!(to_pascal_case("with icon"), "WithIcon");
assert_eq!(to_pascal_case("my-button"), "MyButton");
assert_eq!(to_pascal_case("my_button"), "MyButton");
}
#[test]
fn test_escape_string() {
assert_eq!(escape_string("hello"), "hello");
assert_eq!(escape_string("it's"), "it\\'s");
assert_eq!(escape_string("line\nbreak"), "line\\nbreak");
}
#[test]
fn test_escape_template() {
assert_eq!(escape_template("hello"), "hello");
assert_eq!(escape_template("`code`"), "\\`code\\`");
assert_eq!(escape_template("${var}"), "\\${var}");
}
}