vize_musea/transform/
to_csf.rs1#![allow(clippy::disallowed_macros)]
7
8use crate::types::{ArtDescriptor, ArtVariant, CsfOutput};
9use vize_carton::{String, ToCompactString, append, cstr};
10
11pub fn transform_to_csf(art: &ArtDescriptor<'_>) -> CsfOutput {
31 let mut output = String::default();
32
33 output.push_str(&generate_imports(art));
35 output.push('\n');
36
37 output.push_str(&generate_meta(art));
39 output.push('\n');
40
41 for variant in &art.variants {
43 output.push_str(&generate_story(variant, art));
44 output.push('\n');
45 }
46
47 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
61fn generate_imports(art: &ArtDescriptor<'_>) -> String {
63 let mut imports = String::default();
64
65 imports.push_str("import type { Meta, StoryObj } from '@storybook/vue3';\n");
67
68 let component_path = art.metadata.component.unwrap_or("./Component.vue");
70
71 append!(imports, "import Component from '{component_path}';\n");
72
73 if let Some(script) = &art.script_setup {
75 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
88fn generate_meta(art: &ArtDescriptor<'_>) -> String {
90 let mut meta = String::default();
91
92 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 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 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
135fn generate_story(variant: &ArtVariant<'_>, _art: &ArtDescriptor<'_>) -> String {
137 let mut story = String::default();
138
139 let export_name = to_pascal_case(variant.name);
141
142 append!(story, "export const {export_name}: Story = {{\n");
143
144 if export_name != variant.name {
146 append!(story, " name: '{}',\n", escape_string(variant.name));
147 }
148
149 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 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 let template = variant.template.trim();
168 append!(story, " template: `{}`,\n", escape_template(template));
169
170 story.push_str(" }),\n");
171
172 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
186fn 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
206fn 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
216fn 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}