use super::{CatalogOutput, DocOptions};
use crate::types::{ArtDescriptor, ArtStatus};
use serde::{Deserialize, Serialize};
use vize_carton::FxHashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntry {
pub title: String,
pub description: Option<String>,
pub category: Option<String>,
pub tags: Vec<String>,
pub status: ArtStatus,
pub variant_count: usize,
pub doc_path: String,
pub source_path: String,
pub order: Option<u32>,
}
impl CatalogEntry {
pub fn from_descriptor(art: &ArtDescriptor<'_>, base_path: &str) -> Self {
let slug = slugify(art.metadata.title);
let doc_path = if base_path.is_empty() {
format!("{}.md", slug)
} else {
format!("{}/{}.md", base_path.trim_end_matches('/'), slug)
};
Self {
title: art.metadata.title.to_string(),
description: art.metadata.description.map(|s| s.to_string()),
category: art.metadata.category.map(|s| s.to_string()),
tags: art.metadata.tags.iter().map(|s| s.to_string()).collect(),
status: art.metadata.status,
variant_count: art.variants.len(),
doc_path,
source_path: art.filename.to_string(),
order: art.metadata.order,
}
}
}
pub fn generate_catalog(entries: &[CatalogEntry], options: &DocOptions) -> CatalogOutput {
let mut md = String::with_capacity(8192);
let title = options.title.as_deref().unwrap_or("Component Catalog");
md.push_str("# ");
md.push_str(title);
md.push_str("\n\n");
let categories = collect_categories(entries);
let tags = collect_tags(entries);
md.push_str(&format!(
"> **{}** components across **{}** categories\n\n",
entries.len(),
categories.len()
));
if !categories.is_empty() {
md.push_str("## Categories\n\n");
for category in &categories {
let anchor = slugify(category);
md.push_str(&format!("- [{}](#{})\n", category, anchor));
}
md.push('\n');
}
let by_category = group_by_category(entries);
for category in &categories {
md.push_str(&format!("## {}\n\n", category));
if let Some(category_entries) = by_category.get(category.as_str()) {
md.push_str(&generate_component_table(category_entries));
}
}
let uncategorized: Vec<_> = entries.iter().filter(|e| e.category.is_none()).collect();
if !uncategorized.is_empty() {
md.push_str("## Uncategorized\n\n");
md.push_str(&generate_component_table(&uncategorized));
}
CatalogOutput {
markdown: md,
filename: "README.md".to_string(),
component_count: entries.len(),
categories,
tags,
}
}
pub fn generate_category_index(
entries: &[CatalogEntry],
category: &str,
options: &DocOptions,
) -> CatalogOutput {
let mut md = String::with_capacity(4096);
let filtered: Vec<_> = entries
.iter()
.filter(|e| e.category.as_deref() == Some(category))
.collect();
md.push_str("# ");
md.push_str(category);
md.push_str("\n\n");
md.push_str(&format!("> **{}** components\n\n", filtered.len()));
md.push_str(&generate_component_table(&filtered));
let tags = collect_tags_from_entries(&filtered);
if !tags.is_empty() && options.include_metadata {
md.push_str("## Tags\n\n");
for tag in &tags {
md.push_str(&format!("- `{}`\n", tag));
}
md.push('\n');
}
CatalogOutput {
markdown: md,
filename: format!("{}.md", slugify(category)),
component_count: filtered.len(),
categories: vec![category.to_string()],
tags,
}
}
pub fn generate_tags_index(entries: &[CatalogEntry], _options: &DocOptions) -> CatalogOutput {
let mut md = String::with_capacity(4096);
md.push_str("# Tags Index\n\n");
let by_tag = group_by_tag(entries);
let mut tags: Vec<_> = by_tag.keys().collect();
tags.sort();
md.push_str(&format!("> **{}** tags\n\n", tags.len()));
md.push_str("## All Tags\n\n");
for tag in &tags {
let count = by_tag.get(*tag).map(|v| v.len()).unwrap_or(0);
let anchor = slugify(tag);
md.push_str(&format!("- [`{}`](#{}) ({})\n", tag, anchor, count));
}
md.push('\n');
for tag in &tags {
md.push_str(&format!("## {}\n\n", tag));
if let Some(tag_entries) = by_tag.get(*tag) {
md.push_str("| Component | Category | Variants |\n");
md.push_str("|-----------|----------|----------|\n");
for entry in tag_entries {
md.push_str(&format!(
"| [{}]({}) | {} | {} |\n",
entry.title,
entry.doc_path,
entry.category.as_deref().unwrap_or("-"),
entry.variant_count
));
}
md.push('\n');
}
}
let all_tags: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
CatalogOutput {
markdown: md,
filename: "tags.md".to_string(),
component_count: entries.len(),
categories: vec![],
tags: all_tags,
}
}
fn generate_component_table(entries: &[&CatalogEntry]) -> String {
let mut md = String::new();
md.push_str("| Component | Description | Variants | Status |\n");
md.push_str("|-----------|-------------|----------|--------|\n");
let mut sorted: Vec<_> = entries.iter().collect();
sorted.sort_by(|a, b| match (a.order, b.order) {
(Some(a_order), Some(b_order)) => a_order.cmp(&b_order),
(Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => a.title.cmp(&b.title),
});
for entry in sorted {
let desc = entry
.description
.as_deref()
.unwrap_or("-")
.chars()
.take(50)
.collect::<String>();
let desc = if entry.description.as_ref().map(|d| d.len()).unwrap_or(0) > 50 {
format!("{}...", desc)
} else {
desc
};
let status = match entry.status {
ArtStatus::Ready => "✅",
ArtStatus::Draft => "🚧",
ArtStatus::Deprecated => "⚠️",
};
md.push_str(&format!(
"| [{}]({}) | {} | {} | {} |\n",
entry.title, entry.doc_path, desc, entry.variant_count, status
));
}
md.push('\n');
md
}
fn collect_categories(entries: &[CatalogEntry]) -> Vec<String> {
let mut categories: Vec<_> = entries.iter().filter_map(|e| e.category.clone()).collect();
categories.sort();
categories.dedup();
categories
}
fn collect_tags(entries: &[CatalogEntry]) -> Vec<String> {
let mut tags: Vec<_> = entries.iter().flat_map(|e| e.tags.clone()).collect();
tags.sort();
tags.dedup();
tags
}
fn collect_tags_from_entries(entries: &[&CatalogEntry]) -> Vec<String> {
let mut tags: Vec<_> = entries.iter().flat_map(|e| e.tags.clone()).collect();
tags.sort();
tags.dedup();
tags
}
fn group_by_category(entries: &[CatalogEntry]) -> FxHashMap<&str, Vec<&CatalogEntry>> {
let mut map: FxHashMap<&str, Vec<&CatalogEntry>> = FxHashMap::default();
for entry in entries {
if let Some(ref category) = entry.category {
map.entry(category.as_str()).or_default().push(entry);
}
}
map
}
fn group_by_tag(entries: &[CatalogEntry]) -> FxHashMap<&str, Vec<&CatalogEntry>> {
let mut map: FxHashMap<&str, Vec<&CatalogEntry>> = FxHashMap::default();
for entry in entries {
for tag in &entry.tags {
map.entry(tag.as_str()).or_default().push(entry);
}
}
map
}
#[inline]
fn slugify(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
fn make_entry(title: &str, category: Option<&str>, tags: &[&str]) -> CatalogEntry {
CatalogEntry {
title: title.to_string(),
description: Some(format!("{} description", title)),
category: category.map(|s| s.to_string()),
tags: tags.iter().map(|s| s.to_string()).collect(),
status: ArtStatus::Ready,
variant_count: 2,
doc_path: format!("{}.md", slugify(title)),
source_path: format!("{}.art.vue", slugify(title)),
order: None,
}
}
#[test]
fn test_generate_catalog() {
let entries = vec![
make_entry("Button", Some("atoms"), &["ui", "input"]),
make_entry("Card", Some("molecules"), &["layout"]),
make_entry("Icon", Some("atoms"), &["ui"]),
];
let output = generate_catalog(&entries, &DocOptions::default());
assert!(output.markdown.contains("# Component Catalog"));
assert!(output.markdown.contains("## atoms"));
assert!(output.markdown.contains("## molecules"));
assert_eq!(output.component_count, 3);
assert_eq!(output.categories.len(), 2);
}
#[test]
fn test_generate_tags_index() {
let entries = vec![
make_entry("Button", Some("atoms"), &["ui", "input"]),
make_entry("Input", Some("atoms"), &["ui", "input", "form"]),
];
let output = generate_tags_index(&entries, &DocOptions::default());
assert!(output.markdown.contains("# Tags Index"));
assert!(output.markdown.contains("`ui`"));
assert!(output.markdown.contains("`input`"));
assert!(output.markdown.contains("`form`"));
}
#[test]
fn test_collect_categories() {
let entries = vec![
make_entry("A", Some("atoms"), &[]),
make_entry("B", Some("molecules"), &[]),
make_entry("C", Some("atoms"), &[]),
];
let categories = collect_categories(&entries);
assert_eq!(categories, vec!["atoms", "molecules"]);
}
}