use std::path::Path;
use fallow_graph::resolve::extract_package_name;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Argument, CallExpression, Expression, ObjectExpression};
use oxc_ast_visit::{Visit, walk};
use oxc_parser::Parser;
use oxc_span::SourceType;
use super::{Plugin, PluginResult, config_parser};
const ENABLERS: &[&str] = &["velite"];
const CONFIG_PATTERNS: &[&str] = &["velite.config.{ts,mts,cts,js,mjs,cjs}"];
const ALWAYS_USED: &[&str] = &["velite.config.{ts,mts,cts,js,mjs,cjs}", ".velite/**"];
const DISCOVERY_HIDDEN_DIRS: &[&str] = &[".velite"];
const TOOLING_DEPENDENCIES: &[&str] = &["velite"];
const CONFIG_EXTENSIONS: &[&str] = &["ts", "mts", "cts", "js", "mjs", "cjs"];
const CONTENT_EXTENSIONS: &str = "{md,mdx,yml,yaml,json}";
const DEFAULT_ROOT: &str = "content";
const DEFAULT_OUTPUT_DATA: &str = ".velite";
pub struct VelitePlugin;
impl Plugin for VelitePlugin {
fn name(&self) -> &'static str {
"velite"
}
fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}
fn is_enabled_with_deps(&self, deps: &[String], root: &Path) -> bool {
deps.iter()
.any(|dep| ENABLERS.iter().any(|enabler| dep == enabler))
|| CONFIG_EXTENSIONS
.iter()
.any(|ext| root.join(format!("velite.config.{ext}")).is_file())
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn discovery_hidden_dirs(&self) -> &'static [&'static str] {
DISCOVERY_HIDDEN_DIRS
}
fn tooling_dependencies(&self) -> &'static [&'static str] {
TOOLING_DEPENDENCIES
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
let mut result = PluginResult::default();
for specifier in config_parser::extract_imports(source, config_path) {
let package_name = extract_package_name(&specifier);
if !package_name.is_empty()
&& !package_name.starts_with('.')
&& !package_name.starts_with('/')
{
result.referenced_dependencies.push(package_name);
}
}
result.referenced_dependencies.sort();
result.referenced_dependencies.dedup();
let collected = collect_config(source, config_path);
let root_dir = collected
.root_dir
.as_deref()
.and_then(|raw| config_parser::normalize_config_path(raw, config_path, root))
.or_else(|| config_parser::normalize_config_path(DEFAULT_ROOT, config_path, root));
if let Some(root_dir) = root_dir {
let positive: Vec<&str> = collected
.patterns
.iter()
.filter(|pattern| !pattern.starts_with('!'))
.map(|pattern| pattern.trim_start_matches("./"))
.filter(|pattern| !pattern.is_empty())
.collect();
if positive.is_empty() {
result.push_entry_pattern(format!("{root_dir}/**/*.{CONTENT_EXTENSIONS}"));
} else {
for pattern in positive {
result.push_entry_pattern(format!("{root_dir}/{pattern}"));
}
}
}
if let Some(output_dir) = collected
.output_data
.as_deref()
.filter(|raw| raw.trim_start_matches("./") != DEFAULT_OUTPUT_DATA)
.and_then(|raw| config_parser::normalize_config_path(raw, config_path, root))
{
result.always_used_files.push(format!("{output_dir}/**"));
}
result
}
}
#[derive(Default)]
struct CollectedConfig {
root_dir: Option<String>,
output_data: Option<String>,
patterns: Vec<String>,
}
fn collect_config(source: &str, config_path: &Path) -> CollectedConfig {
let source_type = SourceType::from_path(config_path).unwrap_or_default();
let allocator = Allocator::default();
let parsed = Parser::new(&allocator, source, source_type).parse();
let mut collector = ConfigCollector::default();
if let Some(config) = config_parser::find_config_object_pub(&parsed.program) {
collector.root_dir = config_parser::find_property(config, "root")
.and_then(|prop| config_parser::expression_to_path_string(&prop.value));
collector.output_data = config_parser::find_property(config, "output")
.and_then(|prop| config_parser::object_expression(&prop.value))
.and_then(|output| config_parser::find_property(output, "data"))
.and_then(|prop| config_parser::expression_to_path_string(&prop.value));
}
collector.visit_program(&parsed.program);
collector.patterns.sort();
collector.patterns.dedup();
CollectedConfig {
root_dir: collector.root_dir,
output_data: collector.output_data,
patterns: collector.patterns,
}
}
#[derive(Default)]
struct ConfigCollector {
root_dir: Option<String>,
output_data: Option<String>,
patterns: Vec<String>,
}
impl<'a> Visit<'a> for ConfigCollector {
fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
if call_name(call) == Some("defineCollection")
&& let Some(Expression::ObjectExpression(options)) =
call.arguments.first().and_then(Argument::as_expression)
{
self.collect_pattern(options);
}
walk::walk_call_expression(self, call);
}
}
impl ConfigCollector {
fn collect_pattern(&mut self, options: &ObjectExpression<'_>) {
let Some(prop) = config_parser::find_property(options, "pattern") else {
return;
};
push_string_or_array(&prop.value, &mut self.patterns);
}
}
fn push_string_or_array(expr: &Expression<'_>, out: &mut Vec<String>) {
match expr {
Expression::ArrayExpression(array) => {
for element in array.elements.iter().filter_map(|el| el.as_expression()) {
if let Some(value) = config_parser::expression_to_string(element) {
out.push(value);
}
}
}
_ => {
if let Some(value) = config_parser::expression_to_string(expr) {
out.push(value);
}
}
}
}
fn call_name<'a>(call: &'a CallExpression<'a>) -> Option<&'a str> {
match &call.callee {
Expression::Identifier(identifier) => Some(identifier.name.as_str()),
Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn activates_from_packages_or_config_file() {
let plugin = VelitePlugin;
let tmp = tempfile::tempdir().expect("temp dir");
assert!(plugin.is_enabled_with_deps(&["velite".to_string()], tmp.path()));
assert!(!plugin.is_enabled_with_deps(&["next".to_string()], tmp.path()));
for ext in CONFIG_EXTENSIONS {
let cfg = tmp.path().join(format!("velite.config.{ext}"));
std::fs::write(&cfg, "export default {};\n").expect("config");
assert!(
plugin.is_enabled_with_deps(&[], tmp.path()),
"velite.config.{ext} should activate the plugin"
);
std::fs::remove_file(&cfg).expect("remove config");
}
}
#[test]
fn exposes_static_velite_conventions() {
let plugin = VelitePlugin;
assert_eq!(plugin.config_patterns(), CONFIG_PATTERNS);
assert!(
plugin
.always_used()
.contains(&"velite.config.{ts,mts,cts,js,mjs,cjs}")
);
assert!(plugin.always_used().contains(&".velite/**"));
assert_eq!(plugin.discovery_hidden_dirs(), DISCOVERY_HIDDEN_DIRS);
assert!(plugin.tooling_dependencies().contains(&"velite"));
}
fn patterns_of(result: &PluginResult) -> Vec<String> {
result
.entry_patterns
.iter()
.map(|rule| rule.pattern.clone())
.collect()
}
#[test]
fn extracts_content_roots_and_imported_config_packages() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = r"
import { defineConfig, defineCollection, s } from 'velite';
import rehypeShiki from '@shikijs/rehype';
const posts = defineCollection({
name: 'Post',
pattern: 'blog/**/*.mdx',
schema: s.object({}),
});
export default defineConfig({
root: 'content',
output: { data: '.velite', assets: 'public/static' },
collections: { posts },
});
";
let result = plugin.resolve_config(&config_path, source, root);
let patterns = patterns_of(&result);
assert!(patterns.contains(&"content/blog/**/*.mdx".to_string()));
assert!(
result
.referenced_dependencies
.contains(&"velite".to_string())
);
assert!(
result
.referenced_dependencies
.contains(&"@shikijs/rehype".to_string())
);
assert!(result.always_used_files.is_empty());
}
#[test]
fn defaults_root_to_content_when_omitted() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
collections: {
docs: defineCollection({ pattern: 'docs/**/*.md', schema: {} }),
},
});
";
let patterns = patterns_of(&plugin.resolve_config(&config_path, source, root));
assert!(patterns.contains(&"content/docs/**/*.md".to_string()));
}
#[test]
fn honors_explicit_root_and_array_patterns() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
root: './src/content',
collections: {
mixed: defineCollection({ pattern: ['posts/*.md', 'pages/*.mdx'] }),
},
});
";
let patterns = patterns_of(&plugin.resolve_config(&config_path, source, root));
assert!(patterns.contains(&"src/content/posts/*.md".to_string()));
assert!(patterns.contains(&"src/content/pages/*.mdx".to_string()));
}
#[test]
fn custom_output_data_is_credited_as_always_used() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
output: { data: 'generated/velite' },
collections: {
docs: defineCollection({ pattern: 'docs/**/*.md' }),
},
});
";
let result = plugin.resolve_config(&config_path, source, root);
assert!(
result
.always_used_files
.contains(&"generated/velite/**".to_string())
);
}
#[test]
fn negation_only_pattern_falls_back_to_root_glob() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
collections: {
docs: defineCollection({ pattern: ['!private/**'] }),
},
});
";
let patterns = patterns_of(&plugin.resolve_config(&config_path, source, root));
assert!(patterns.contains(&format!("content/**/*.{CONTENT_EXTENSIONS}")));
assert!(!patterns.iter().any(|p| p.contains('!')));
}
#[test]
fn default_output_data_adds_no_redundant_always_used_entry() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("apps/web/velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
output: { data: '.velite' },
collections: { docs: defineCollection({ pattern: 'docs/**/*.md' }) },
});
";
let result = plugin.resolve_config(&config_path, source, root);
assert!(
result.always_used_files.is_empty(),
"default output.data must not add a redundant entry: {:?}",
result.always_used_files
);
}
#[test]
fn custom_output_data_in_workspace_is_scoped_to_package() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("apps/web/velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
output: { data: 'generated/velite' },
collections: { docs: defineCollection({ pattern: 'docs/**/*.md' }) },
});
";
let result = plugin.resolve_config(&config_path, source, root);
assert!(
result
.always_used_files
.contains(&"apps/web/generated/velite/**".to_string()),
"custom output.data must be credited config-relative: {:?}",
result.always_used_files
);
}
#[test]
fn nested_workspace_config_scopes_patterns_to_package() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("apps/web/velite.config.ts");
let source = r"
import { defineConfig, defineCollection } from 'velite';
export default defineConfig({
root: 'content',
collections: { posts: defineCollection({ pattern: 'posts/**/*.md' }) },
});
";
let patterns = patterns_of(&plugin.resolve_config(&config_path, source, root));
assert!(patterns.contains(&"apps/web/content/posts/**/*.md".to_string()));
assert!(
!patterns.iter().any(|p| p.starts_with("content/")),
"patterns must be scoped to the config's package: {patterns:?}"
);
}
#[test]
fn malformed_config_falls_back_to_default_root_glob() {
let plugin = VelitePlugin;
let root = Path::new("/repo");
let config_path = root.join("velite.config.ts");
let source = "export default someFactory();\n";
let patterns = patterns_of(&plugin.resolve_config(&config_path, source, root));
assert!(patterns.contains(&format!("content/**/*.{CONTENT_EXTENSIONS}")));
}
}