use anyhow::{Context, Result};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
fn find_project_root() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
let mut current = cwd.as_path();
loop {
if current.join("src").join("App.vx").exists() {
return Some(current.to_path_buf());
}
match current.parent() {
Some(parent) => current = parent,
None => return None,
}
}
}
fn split_words(name: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut prev_lower = false;
for ch in name.chars() {
if ch.is_ascii_alphanumeric() {
if ch.is_uppercase() && prev_lower && !current.is_empty() {
words.push(std::mem::take(&mut current));
}
current.push(ch.to_ascii_lowercase());
prev_lower = ch.is_lowercase();
} else if !current.is_empty() {
words.push(std::mem::take(&mut current));
prev_lower = false;
} else {
prev_lower = false;
}
}
if !current.is_empty() {
words.push(current);
}
words
}
fn to_pascal_case(name: &str) -> String {
let mut out = String::new();
for word in split_words(name) {
let mut chars = word.chars();
if let Some(first) = chars.next() {
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
}
}
if out.is_empty() {
out = "Component".to_string();
}
out
}
fn to_kebab_case(name: &str) -> String {
let words = split_words(name);
if words.is_empty() {
return "component".to_string();
}
words.join("-")
}
const DEFAULT_SLOTS: [&str; 1] = ["header"];
fn normalize_slot_name(name: &str) -> Result<String> {
let mut dashed = String::with_capacity(name.len());
for ch in name.chars() {
if ch == '_' {
dashed.push('-');
} else if ch.is_ascii_uppercase() {
dashed.push('-');
dashed.push(ch.to_ascii_lowercase());
} else {
dashed.push(ch);
}
}
let trimmed = dashed.trim_matches('-');
let mut out = String::with_capacity(trimmed.len());
let mut prev_dash = false;
for ch in trimmed.chars() {
if ch == '-' {
if !prev_dash {
out.push(ch);
prev_dash = true;
}
} else {
out.push(ch);
prev_dash = false;
}
}
if out.is_empty() {
anyhow::bail!("invalid slot name '{name}': needs at least one letter or digit");
}
if !out.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
anyhow::bail!("invalid slot name '{name}': use letters, digits and '-' only");
}
Ok(out)
}
fn parse_slot_names(raw: &str) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for part in raw.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let name = normalize_slot_name(part)?;
if name == "default" {
anyhow::bail!(
"'default' is the implicit slot and is always generated; pick another name"
);
}
if !out.contains(&name) {
out.push(name);
}
}
if out.is_empty() {
anyhow::bail!("--slots needs at least one slot name, e.g. --slots header,footer");
}
Ok(out)
}
fn resolve_slots(requested: Option<&str>) -> Result<Vec<String>> {
match requested {
Some(raw) => parse_slot_names(raw),
None => Ok(DEFAULT_SLOTS.iter().map(|s| (*s).to_string()).collect()),
}
}
pub fn add_component(name: &str, slots: Option<&str>) -> Result<PathBuf> {
let root = find_project_root().ok_or_else(|| {
anyhow::anyhow!(
"not inside a Velox project (no src/App.vx found). Run `velox init <name>` first."
)
})?;
let struct_name = to_pascal_case(name);
let kebab = to_kebab_case(&struct_name);
let slots = resolve_slots(slots)?;
if !struct_name.starts_with(|c: char| c.is_ascii_alphabetic()) {
anyhow::bail!("invalid component name '{name}': must start with a letter");
}
let components_dir = root.join("src").join("components");
if let Ok(meta) = fs::symlink_metadata(&components_dir)
&& meta.file_type().is_symlink()
{
anyhow::bail!(
"{} is a symlink; refusing to scaffold through it",
components_dir.display()
);
}
fs::create_dir_all(&components_dir)
.with_context(|| format!("create {}", components_dir.display()))?;
let file_name = format!("{}.vx", struct_name);
let path = components_dir.join(&file_name);
let content = component_template(&struct_name, &kebab, &slots);
let mut file = match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(f) => f,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
anyhow::bail!("component already exists: {}", path.display());
}
Err(e) => return Err(e).with_context(|| format!("create {}", path.display())),
};
file.write_all(content.as_bytes())
.with_context(|| format!("write {}", path.display()))?;
println!("✅ Created component: {}", path.display());
let reported: Vec<&str> = std::iter::once("default")
.chain(slots.iter().map(String::as_str))
.collect();
println!(" Slots: {}", reported.join(", "));
if let Some(first) = slots.first() {
println!(
" Fill one from a parent: <{struct_name}><template v-slot:{first}>…</template></{struct_name}>"
);
println!(" (shorthand: <{struct_name}><template #{first}>…</template></{struct_name}>)");
}
println!(" Import it from a parent component, e.g.:");
println!(" import {struct_name} from './components/{file_name}'");
Ok(path)
}
fn named_slot_blocks(kebab: &str, slots: &[String]) -> String {
let mut out = String::new();
for (i, slot) in slots.iter().enumerate() {
let fallback = if i == 0 {
" <h3>{{ title }}</h3>\n".to_string()
} else {
format!(" <span class=\"{kebab}-empty\">{slot}</span>\n")
};
out.push_str(&format!(
" <div class=\"{kebab}-{slot}\">\n <slot name=\"{slot}\">\n{fallback} </slot>\n </div>\n\n"
));
}
out
}
fn component_template(struct_name: &str, kebab: &str, slots: &[String]) -> String {
let body = r#"<template>
<div class="__KEBAB__">
__SLOTS__ <div class="__KEBAB__-body">
<slot>
<p>Slot content goes here.</p>
</slot>
</div>
</div>
</template>
<script setup>
pub struct State {
pub title: String,
}
impl State {
pub fn new() -> Self {
Self {
title: String::from("__NAME__"),
}
}
pub fn title(&self) -> String {
self.title.clone()
}
}
</script>
<style scoped>
.__KEBAB__ {
padding: 16px;
border-radius: 10px;
background: #16213e;
color: #e6edf3;
}
.__KEBAB__-body {
margin-top: 8px;
}
</style>
"#;
body.replace("__KEBAB__", kebab)
.replace("__NAME__", struct_name)
.replace("__SLOTS__", &named_slot_blocks(kebab, slots))
}
#[cfg(test)]
mod tests {
use super::*;
fn slots(raw: &str) -> Vec<String> {
parse_slot_names(raw).expect("parse slots")
}
#[test]
fn pascal_case_keeps_working() {
assert_eq!(to_pascal_case("my-counter"), "MyCounter");
assert_eq!(to_pascal_case("Todo Item"), "TodoItem");
assert_eq!(to_pascal_case("My Counter2"), "MyCounter2");
assert_eq!(to_pascal_case("!!!"), "Component");
}
#[test]
fn kebab_case_keeps_working() {
assert_eq!(to_kebab_case("MyCounter"), "my-counter");
assert_eq!(to_kebab_case("Card"), "card");
}
#[test]
fn slot_names_fold_to_the_parsers_kebab_case() {
assert_eq!(normalize_slot_name("header").unwrap(), "header");
assert_eq!(normalize_slot_name("footerBar").unwrap(), "footer-bar");
assert_eq!(normalize_slot_name("main_content").unwrap(), "main-content");
assert_eq!(normalize_slot_name("Foo").unwrap(), "foo");
assert_eq!(normalize_slot_name("__a--b__").unwrap(), "a-b");
}
#[test]
fn invalid_slot_names_are_rejected() {
assert!(normalize_slot_name("").is_err());
assert!(normalize_slot_name("---").is_err());
assert!(normalize_slot_name("a.b").is_err());
}
#[test]
fn slot_lists_dedupe_and_keep_order() {
assert_eq!(slots("header,footer"), ["header", "footer"]);
assert_eq!(
slots(" header , footerBar , header "),
["header", "footer-bar"]
);
assert_eq!(slots("a,,b"), ["a", "b"]);
}
#[test]
fn default_slot_cannot_be_requested_explicitly() {
let err = parse_slot_names("default").unwrap_err().to_string();
assert!(err.contains("implicit slot"), "unexpected error: {err}");
assert!(parse_slot_names(" , ").is_err());
}
#[test]
fn no_requested_slots_falls_back_to_the_default_set() {
assert_eq!(resolve_slots(None).unwrap(), DEFAULT_SLOTS);
}
#[test]
fn template_declares_a_named_slot_and_the_implicit_one() {
let t = component_template(
"TodoCard",
"todo-card",
&["header".to_string(), "footer".to_string()],
);
assert!(t.contains("<slot name=\"header\">"), "{t}");
assert!(t.contains("<slot name=\"footer\">"), "{t}");
assert!(t.contains("<slot>\n"), "{t}");
assert!(!t.contains("name=\"default\""), "{t}");
assert!(t.contains("<h3>{{ title }}</h3>"), "{t}");
assert!(!t.contains("__KEBAB__") && !t.contains("__NAME__") && !t.contains("__SLOTS__"));
}
#[test]
fn template_uses_the_kebab_class_and_pascal_title() {
let t = component_template("TodoCard", "todo-card", &slots("header"));
assert!(t.contains("<div class=\"todo-card\">"), "{t}");
assert!(t.contains("String::from(\"TodoCard\")"), "{t}");
}
}