use anyhow::Result;
use clap::Subcommand;
use regex::Regex;
use std::collections::HashMap;
use std::net::ToSocketAddrs;
use std::path::Path;
const DESIGN_DIR: &str = ".yana-ai/design";
const DESIGN_FILE: &str = "design-context.json";
#[derive(Subcommand, Debug)]
pub enum DesignAction {
Extract {
source: String,
#[arg(long, default_value = ".")] target: String,
#[arg(long)] json: bool,
#[arg(long)] quiet: bool,
},
Show {
#[arg(long, default_value = ".")] target: String,
#[arg(long)] json: bool,
},
Init {
#[arg(long, default_value = ".")] target: String,
#[arg(long, value_name = "FILE")] out: Option<String>,
},
}
pub fn dispatch(action: DesignAction) {
let result = match action {
DesignAction::Extract { source, target, json, quiet } =>
cmd_extract(&source, &target, json, quiet),
DesignAction::Show { target, json } =>
cmd_show(&target, json),
DesignAction::Init { target, out } =>
cmd_init(&target, out.as_deref()),
};
if let Err(e) = result {
eprintln!("[design] error: {e}");
std::process::exit(1);
}
}
fn cmd_extract(source: &str, target: &str, as_json: bool, quiet: bool) -> Result<()> {
validate_relative_path(target, "--target")?;
if !quiet { eprintln!("[design] fetching {}…", source); }
let html = fetch_source(source)?;
let css = extract_css(&html);
let tokens = extract_tokens(&css, &html);
let dir = Path::new(target).join(DESIGN_DIR);
std::fs::create_dir_all(&dir)?;
let out = dir.join(DESIGN_FILE);
std::fs::write(&out, serde_json::to_string_pretty(&tokens)?)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&tokens)?);
} else {
print_tokens(&tokens);
if !quiet { eprintln!("\n[design] saved → {}", out.display()); }
}
Ok(())
}
fn cmd_show(target: &str, as_json: bool) -> Result<()> {
validate_relative_path(target, "--target")?;
let path = Path::new(target).join(DESIGN_DIR).join(DESIGN_FILE);
let s = std::fs::read_to_string(&path)
.map_err(|_| anyhow::anyhow!("No design context. Run: yana-rt design extract <url>"))?;
let tokens: DesignTokens = serde_json::from_str(&s)?;
if as_json { println!("{}", serde_json::to_string_pretty(&tokens)?); }
else { print_tokens(&tokens); }
Ok(())
}
fn cmd_init(target: &str, out_path: Option<&str>) -> Result<()> {
validate_relative_path(target, "--target")?;
let path = Path::new(target).join(DESIGN_DIR).join(DESIGN_FILE);
let s = std::fs::read_to_string(&path)
.map_err(|_| anyhow::anyhow!("No design context. Run: yana-rt design extract <url> first"))?;
let tokens: DesignTokens = serde_json::from_str(&s)?;
let md = tokens_to_markdown(&tokens);
let dest = out_path.unwrap_or("DESIGN.md");
validate_relative_path(dest, "--out")?;
std::fs::write(dest, &md)?;
println!("[design] DESIGN.md → {}", dest);
Ok(())
}
fn validate_relative_path(path: &str, label: &str) -> Result<()> {
let p = Path::new(path);
if p.is_absolute() {
anyhow::bail!("{} must be a relative path, got: '{}'", label, path);
}
for component in p.components() {
if matches!(component, std::path::Component::ParentDir) {
anyhow::bail!("{} must not contain '..': '{}'", label, path);
}
}
Ok(())
}
fn extract_url_host(url: &str) -> Option<&str> {
let without_scheme = url.split("://").nth(1)?;
let host_port = without_scheme.split('/').next()?;
Some(host_port.split(':').next()?)
}
fn is_private_ip(ip: std::net::IpAddr) -> bool {
match ip {
std::net::IpAddr::V4(v4) => {
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
}
std::net::IpAddr::V6(v6) => v6.is_loopback() || v6.is_unspecified(),
}
}
fn fetch_source(source: &str) -> Result<String> {
if source.starts_with("http://") || source.starts_with("https://") {
let host = extract_url_host(source)
.ok_or_else(|| anyhow::anyhow!("could not extract host from URL: '{}'", source))?;
let resolved: Vec<_> = format!("{}:80", host)
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("DNS resolution failed for '{}': {}", host, e))?
.collect();
for addr in &resolved {
if is_private_ip(addr.ip()) {
anyhow::bail!(
"SSRF blocked: '{}' resolves to private/internal address {}",
host, addr.ip()
);
}
}
let resp = ureq::get(source)
.header("User-Agent", "yana-rt/0.9 design-extractor")
.call()
.map_err(|e| anyhow::anyhow!("fetch failed: {e}"))?;
Ok(resp.into_body().read_to_string()?)
} else {
validate_relative_path(source, "source")?;
Ok(std::fs::read_to_string(source)?)
}
}
fn extract_css(html: &str) -> String {
let mut css = String::new();
let style_re = Regex::new(r"(?s)<style[^>]*>(.*?)</style>").unwrap();
for cap in style_re.captures_iter(html) {
css.push_str(&cap[1]);
css.push('\n');
}
let attr_re = Regex::new(r#"style="([^"]+)""#).unwrap();
for cap in attr_re.captures_iter(html) {
css.push_str(&cap[1]);
css.push(';');
}
css
}
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct DesignTokens {
pub source: String,
pub colors: Vec<String>,
pub fonts: Vec<String>,
pub font_sizes: Vec<String>,
pub spacing: Vec<String>,
pub border_radius: Vec<String>,
pub shadows: Vec<String>,
pub css_vars: HashMap<String, String>,
}
fn extract_tokens(css: &str, html: &str) -> DesignTokens {
let combined = format!("{}\n{}", css, html);
DesignTokens {
source: String::new(),
colors: unique(extract_colors(&combined)),
fonts: unique(extract_fonts(&combined)),
font_sizes: unique(extract_font_sizes(css)),
spacing: unique(extract_spacing(css)),
border_radius: unique(extract_border_radius(css)),
shadows: unique(extract_shadows(css)),
css_vars: extract_css_vars(css),
}
}
fn extract_colors(text: &str) -> Vec<String> {
let mut out = Vec::new();
let hex = Regex::new(r"#([0-9A-Fa-f]{3,8})\b").unwrap();
for cap in hex.captures_iter(text) { out.push(format!("#{}", &cap[1])); }
let func = Regex::new(r"(?i)(hsl|rgb)a?\([^)]{3,40}\)").unwrap();
for cap in func.captures_iter(text) { out.push(cap[0].trim().to_string()); }
out
}
fn extract_fonts(text: &str) -> Vec<String> {
let re = Regex::new(r#"font-family\s*:\s*([^;}"]+)"#).unwrap();
let mut out = Vec::new();
for cap in re.captures_iter(text) {
for f in cap[1].split(',') {
let clean = f.trim().trim_matches(|c| c == '\'' || c == '"').to_string();
if !clean.is_empty() && clean != "inherit" && clean != "sans-serif" && clean != "serif" {
out.push(clean);
}
}
}
out
}
fn extract_font_sizes(css: &str) -> Vec<String> {
let re = Regex::new(r"font-size\s*:\s*([^;}\s]+)").unwrap();
re.captures_iter(css).map(|c| c[1].to_string()).collect()
}
fn extract_spacing(css: &str) -> Vec<String> {
let re = Regex::new(r"(?:margin|padding)\s*:\s*([^;}{]+)").unwrap();
re.captures_iter(css).map(|c| c[1].trim().to_string()).take(20).collect()
}
fn extract_border_radius(css: &str) -> Vec<String> {
let re = Regex::new(r"border-radius\s*:\s*([^;}{]+)").unwrap();
re.captures_iter(css).map(|c| c[1].trim().to_string()).collect()
}
fn extract_shadows(css: &str) -> Vec<String> {
let re = Regex::new(r"(?:box|text)-shadow\s*:\s*([^;}{]+)").unwrap();
re.captures_iter(css).map(|c| c[1].trim().to_string()).collect()
}
fn extract_css_vars(css: &str) -> HashMap<String, String> {
let re = Regex::new(r"--([\w-]+)\s*:\s*([^;}\n]+)").unwrap();
let mut map = HashMap::new();
for cap in re.captures_iter(css) {
map.insert(format!("--{}", cap[1].trim()), cap[2].trim().to_string());
}
map
}
fn unique(mut v: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
v.retain(|s| seen.insert(s.clone()));
v
}
fn print_tokens(t: &DesignTokens) {
println!("\n Design Tokens\n");
if !t.colors.is_empty() {
println!(" Colors ({}):", t.colors.len());
for c in t.colors.iter().take(12) { println!(" {}", c); }
}
if !t.fonts.is_empty() {
println!("\n Fonts: {}", t.fonts.join(", "));
}
if !t.font_sizes.is_empty() {
println!(" Font sizes: {}", t.font_sizes.iter().take(8).cloned().collect::<Vec<_>>().join(", "));
}
if !t.border_radius.is_empty() {
println!(" Border radius: {}", t.border_radius.iter().take(6).cloned().collect::<Vec<_>>().join(", "));
}
if !t.css_vars.is_empty() {
println!("\n CSS Variables ({}):", t.css_vars.len());
let mut vars: Vec<_> = t.css_vars.iter().collect();
vars.sort_by_key(|(k, _)| k.clone());
for (k, v) in vars.iter().take(15) { println!(" {}: {}", k, v); }
if t.css_vars.len() > 15 { println!(" … and {} more", t.css_vars.len() - 15); }
}
println!();
}
fn tokens_to_markdown(t: &DesignTokens) -> String {
let mut md = String::from("# Design Context\n\n");
md.push_str("> Auto-extracted by `yana-rt design extract`\n\n");
if !t.colors.is_empty() {
md.push_str("## Colors\n\n");
for c in &t.colors { md.push_str(&format!("- `{}`\n", c)); }
md.push('\n');
}
if !t.fonts.is_empty() {
md.push_str(&format!("## Typography\n\n**Fonts:** {}\n\n", t.fonts.join(", ")));
}
if !t.font_sizes.is_empty() {
md.push_str(&format!("**Font sizes:** {}\n\n", t.font_sizes.join(", ")));
}
if !t.border_radius.is_empty() {
md.push_str(&format!("## Border Radius\n\n{}\n\n", t.border_radius.join(", ")));
}
if !t.shadows.is_empty() {
md.push_str("## Shadows\n\n");
for s in &t.shadows { md.push_str(&format!("- `{}`\n", s)); }
md.push('\n');
}
if !t.css_vars.is_empty() {
md.push_str("## CSS Variables\n\n```css\n:root {\n");
let mut vars: Vec<_> = t.css_vars.iter().collect();
vars.sort_by_key(|(k, _)| k.clone());
for (k, v) in &vars { md.push_str(&format!(" {}: {};\n", k, v)); }
md.push_str("}\n```\n");
}
md
}