use std::path::Path;
use super::config_parser;
use super::{Plugin, PluginResult};
const ENABLERS: &[&str] = &["nuxt"];
const MODULE_AUTHORING_ENABLER: &str = "@nuxt/kit";
const ENTRY_PATTERNS: &[&str] = &[
"pages/**/*.{vue,ts,tsx,js,jsx}",
"layouts/**/*.{vue,ts,tsx,js,jsx}",
"middleware/**/*.{ts,js}",
"server/api/**/*.{ts,js}",
"server/routes/**/*.{ts,js}",
"server/middleware/**/*.{ts,js}",
"server/plugins/**/*.{ts,js}",
"server/utils/**/*.{ts,js}",
"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"utils/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"components/**/*.{vue,ts,tsx,js,jsx}",
"modules/**/*.{ts,js}",
"app/pages/**/*.{vue,ts,tsx,js,jsx}",
"app/layouts/**/*.{vue,ts,tsx,js,jsx}",
"app/middleware/**/*.{ts,js}",
"app/plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/utils/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/components/**/*.{vue,ts,tsx,js,jsx}",
"app/modules/**/*.{ts,js}",
];
const SRC_DIR_ENTRY_PATTERNS: &[&str] = &[
"pages/**/*.{vue,ts,tsx,js,jsx}",
"layouts/**/*.{vue,ts,tsx,js,jsx}",
"middleware/**/*.{ts,js}",
"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"utils/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"components/**/*.{vue,ts,tsx,js,jsx}",
];
const CONFIG_PATTERNS: &[&str] = &[
"nuxt.config.{ts,js}",
"src/module.{ts,js}",
];
const ALWAYS_USED: &[&str] = &[
"nuxt.config.{ts,js}",
"app.vue",
"app.config.{ts,js}",
"error.vue",
"app/app.vue",
"app/app.config.{ts,js}",
"app/error.vue",
"src/module.{ts,js}",
];
const SRC_DIR_ALWAYS_USED: &[&str] = &["app.vue", "app.config.{ts,js}", "error.vue"];
const COMPONENT_ENTRY_GLOB: &str = "vue,ts,tsx,js,jsx";
const SCRIPT_ENTRY_GLOB: &str = "ts,js,mts,cts,mjs,cjs";
const SCRIPT_ENTRY_EXTENSIONS: &[&str] = &["ts", "js", "mts", "cts", "mjs", "cjs"];
const TOOLING_DEPENDENCIES: &[&str] = &[
"nuxt",
"@nuxt/devtools",
"@nuxt/test-utils",
"@nuxt/schema",
"@nuxt/kit",
"vue",
"vue-router",
"ofetch",
"h3",
"@unhead/vue",
"@unhead/schema",
"nitropack",
"defu",
"hookable",
"ufo",
"unctx",
"unenv",
"ohash",
"pathe",
"scule",
"unimport",
"unstorage",
"radix3",
"cookie-es",
"crossws",
"consola",
];
const USED_EXPORTS_SERVER_API: &[&str] = &["default", "defineEventHandler"];
const USED_EXPORTS_MIDDLEWARE: &[&str] = &["default"];
const USED_EXPORTS_DEFAULT: &[&str] = &["default"];
const DEFAULT_EXPORT_ENTRY_PATTERNS: &[&str] = &[
"pages/**/*.{vue,ts,tsx,js,jsx}",
"layouts/**/*.{vue,ts,tsx,js,jsx}",
"components/**/*.{vue,ts,tsx,js,jsx}",
"modules/**/*.{ts,js}",
"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/pages/**/*.{vue,ts,tsx,js,jsx}",
"app/layouts/**/*.{vue,ts,tsx,js,jsx}",
"app/components/**/*.{vue,ts,tsx,js,jsx}",
"app/modules/**/*.{ts,js}",
"app/plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"app/plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"server/routes/**/*.{ts,js}",
"middleware/**/*.{ts,js}",
"app/middleware/**/*.{ts,js}",
"server/middleware/**/*.{ts,js}",
"server/plugins/**/*.{ts,js}",
"app.vue",
"app.config.{ts,js}",
"error.vue",
"app/app.vue",
"app/app.config.{ts,js}",
"app/error.vue",
];
const SRC_DIR_DEFAULT_EXPORT_PATTERNS: &[&str] = &[
"pages/**/*.{vue,ts,tsx,js,jsx}",
"layouts/**/*.{vue,ts,tsx,js,jsx}",
"components/**/*.{vue,ts,tsx,js,jsx}",
"modules/**/*.{ts,js}",
"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
];
const VIRTUAL_MODULE_PREFIXES: &[&str] = &["#"];
pub struct NuxtPlugin;
impl Plugin for NuxtPlugin {
fn name(&self) -> &'static str {
"nuxt"
}
fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}
fn is_enabled_with_deps(&self, deps: &[String], root: &Path) -> bool {
deps.iter()
.any(|d| d == "nuxt" || d == MODULE_AUTHORING_ENABLER)
|| root.join("nuxt.config.ts").exists()
|| root.join("nuxt.config.js").exists()
}
fn entry_patterns(&self) -> &'static [&'static str] {
ENTRY_PATTERNS
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
ALWAYS_USED
}
fn tooling_dependencies(&self) -> &'static [&'static str] {
TOOLING_DEPENDENCIES
}
fn virtual_module_prefixes(&self) -> &'static [&'static str] {
VIRTUAL_MODULE_PREFIXES
}
fn path_aliases(&self, root: &Path) -> Vec<(&'static str, String)> {
let src_dir = if root.join("app").is_dir() {
"app".to_string()
} else {
String::new()
};
let mut aliases = vec![
("~/", src_dir.clone()),
("@/", src_dir),
("~~/", String::new()),
("@@/", String::new()),
("#shared/", "shared".to_string()),
("#server/", "server".to_string()),
];
aliases.push(("#shared", "shared".to_string()));
aliases.push(("#server", "server".to_string()));
aliases
}
fn used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
let mut exports = Vec::with_capacity(DEFAULT_EXPORT_ENTRY_PATTERNS.len() + 3);
exports.push(("server/api/**/*.{ts,js}", USED_EXPORTS_SERVER_API));
exports.push(("middleware/**/*.{ts,js}", USED_EXPORTS_MIDDLEWARE));
exports.push(("app/middleware/**/*.{ts,js}", USED_EXPORTS_MIDDLEWARE));
exports.extend(
DEFAULT_EXPORT_ENTRY_PATTERNS
.iter()
.copied()
.map(|pattern| (pattern, USED_EXPORTS_DEFAULT)),
);
exports
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
let mut result = PluginResult::default();
if config_path.file_stem().is_some_and(|stem| stem == "module") {
add_module_runtime_patterns(&mut result, root);
let imports = config_parser::extract_imports(source, config_path);
for imp in &imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
return result;
}
let default_src_dir = default_nuxt_src_dir(root);
let configured_src_dir = extract_nuxt_src_dir(source, config_path, root);
let src_dir = configured_src_dir
.clone()
.unwrap_or_else(|| default_src_dir.clone());
if let Some(configured_src_dir) = configured_src_dir.as_deref()
&& configured_src_dir != default_src_dir.as_str()
{
add_src_dir_support(&mut result, configured_src_dir);
}
let imports = config_parser::extract_imports(source, config_path);
for imp in &imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
let modules = config_parser::extract_config_string_array(source, config_path, &["modules"]);
for module in &modules {
let dep = crate::resolve::extract_package_name(module);
result.referenced_dependencies.push(dep);
}
let css = config_parser::extract_config_string_array(source, config_path, &["css"]);
for entry in &css {
if let Some(stripped) = entry.strip_prefix("~/") {
result
.always_used_files
.push(prefix_with_src_dir(&src_dir, stripped));
} else if let Some(stripped) = entry.strip_prefix("~~/") {
result.always_used_files.push(stripped.to_string());
} else if entry.starts_with('.') || entry.starts_with('/') {
result.always_used_files.push(entry.clone());
} else {
let dep = crate::resolve::extract_package_name(entry);
result.referenced_dependencies.push(dep);
}
}
let postcss_plugins =
config_parser::extract_config_object_keys(source, config_path, &["postcss", "plugins"]);
for plugin in &postcss_plugins {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(plugin));
}
let mut plugins =
config_parser::extract_config_string_array(source, config_path, &["plugins"]);
plugins.extend(config_parser::extract_config_array_object_strings(
source,
config_path,
&["plugins"],
"src",
));
for plugin in plugins {
if let Some(normalized) = normalize_nuxt_path(&plugin, config_path, root, &src_dir) {
let pattern = script_entry_pattern(&normalized);
add_default_used_export(&mut result, &pattern);
result.push_entry_pattern(pattern);
}
}
for (find, replacement) in
config_parser::extract_config_aliases(source, config_path, &["alias"])
{
if let Some(normalized) = normalize_nuxt_path(&replacement, config_path, root, &src_dir)
{
result.path_aliases.push((find, normalized));
}
}
for dir in
config_parser::extract_config_string_array(source, config_path, &["imports", "dirs"])
{
if let Some(pattern) = normalize_imports_dir_pattern(&dir, config_path, root, &src_dir)
{
result.push_entry_pattern(pattern);
}
}
let mut component_dirs =
config_parser::extract_config_string_array(source, config_path, &["components"]);
component_dirs.extend(config_parser::extract_config_array_object_strings(
source,
config_path,
&["components"],
"path",
));
component_dirs.extend(config_parser::extract_config_array_object_strings(
source,
config_path,
&["components", "dirs"],
"path",
));
component_dirs.extend(config_parser::extract_config_string_array(
source,
config_path,
&["components", "dirs"],
));
for dir in component_dirs {
if let Some(normalized) = normalize_nuxt_path(&dir, config_path, root, &src_dir) {
let pattern = component_dir_pattern(&normalized);
add_default_used_export(&mut result, &pattern);
result.push_entry_pattern(pattern);
}
}
let extends = config_parser::extract_config_string_array(source, config_path, &["extends"]);
for ext in &extends {
result
.referenced_dependencies
.push(crate::resolve::extract_package_name(ext));
}
result
}
}
fn add_module_runtime_patterns(result: &mut PluginResult, root: &Path) {
let runtime_dir = if root.join("src/runtime").is_dir() {
"src/runtime"
} else if root.join("runtime").is_dir() {
"runtime"
} else {
return;
};
let components = format!("{runtime_dir}/components/**/*.{{{COMPONENT_ENTRY_GLOB}}}");
add_default_used_export(result, &components);
result.push_entry_pattern(components);
let composables = format!("{runtime_dir}/composables/*.{{{SCRIPT_ENTRY_GLOB}}}");
result.push_entry_pattern(composables);
let utils = format!("{runtime_dir}/utils/*.{{{SCRIPT_ENTRY_GLOB}}}");
result.push_entry_pattern(utils);
let plugins = format!("{runtime_dir}/plugins/*.{{{SCRIPT_ENTRY_GLOB}}}");
add_default_used_export(result, &plugins);
result.push_entry_pattern(plugins);
let locale_dir = root.join(runtime_dir).join("locale");
if locale_dir.is_dir() {
let locale = format!("{runtime_dir}/locale/*.{{{SCRIPT_ENTRY_GLOB}}}");
result.push_entry_pattern(locale);
}
let types_dir = root.join(runtime_dir).join("types");
if types_dir.is_dir() {
let types = format!("{runtime_dir}/types/*.{{{SCRIPT_ENTRY_GLOB}}}");
result.push_entry_pattern(types);
}
let vue_dir = root.join(runtime_dir).join("vue");
if vue_dir.is_dir() {
let vue_components = format!("{runtime_dir}/vue/**/*.{{{COMPONENT_ENTRY_GLOB}}}");
add_default_used_export(result, &vue_components);
result.push_entry_pattern(vue_components);
}
}
fn default_nuxt_src_dir(root: &Path) -> String {
if root.join("app").is_dir() {
"app".to_string()
} else {
String::new()
}
}
fn extract_nuxt_src_dir(source: &str, config_path: &Path, root: &Path) -> Option<String> {
let raw = config_parser::extract_config_string(source, config_path, &["srcDir"])?;
normalize_nuxt_src_dir(&raw, config_path, root)
}
fn normalize_nuxt_src_dir(raw: &str, config_path: &Path, root: &Path) -> Option<String> {
let trimmed = raw.trim().trim_end_matches('/');
if trimmed.is_empty() || trimmed == "." {
return Some(String::new());
}
config_parser::normalize_config_path(trimmed, config_path, root)
}
fn add_src_dir_support(result: &mut PluginResult, src_dir: &str) {
result
.path_aliases
.push(("~/".to_string(), src_dir.to_string()));
result
.path_aliases
.push(("@/".to_string(), src_dir.to_string()));
if src_dir.is_empty() {
return;
}
result.extend_entry_patterns(
SRC_DIR_ENTRY_PATTERNS
.iter()
.map(|pattern| prefix_with_src_dir(src_dir, pattern)),
);
extend_prefixed_patterns(&mut result.always_used_files, src_dir, SRC_DIR_ALWAYS_USED);
add_prefixed_default_used_exports(result, src_dir, SRC_DIR_DEFAULT_EXPORT_PATTERNS);
add_default_used_export(
result,
prefix_with_src_dir(src_dir, "middleware/**/*.{ts,js}"),
);
add_prefixed_default_used_exports(result, src_dir, SRC_DIR_ALWAYS_USED);
}
fn add_default_used_export(result: &mut PluginResult, pattern: impl Into<String>) {
result.push_used_export_rule(pattern, ["default"]);
}
fn add_prefixed_default_used_exports(result: &mut PluginResult, prefix: &str, patterns: &[&str]) {
for pattern in patterns {
add_default_used_export(result, prefix_with_src_dir(prefix, pattern));
}
}
fn extend_prefixed_patterns(target: &mut Vec<String>, prefix: &str, patterns: &[&str]) {
target.extend(
patterns
.iter()
.map(|pattern| prefix_with_src_dir(prefix, pattern)),
);
}
fn component_dir_pattern(dir: &str) -> String {
format!("{dir}/**/*.{{{COMPONENT_ENTRY_GLOB}}}")
}
fn script_entry_pattern(path: &str) -> String {
if has_supported_extension(path, SCRIPT_ENTRY_EXTENSIONS) {
path.to_string()
} else {
format!("{path}.{{{SCRIPT_ENTRY_GLOB}}}")
}
}
fn has_supported_extension(path: &str, supported_extensions: &[&str]) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| supported_extensions.contains(&ext))
}
fn normalize_nuxt_path(
raw: &str,
config_path: &Path,
root: &Path,
src_dir: &str,
) -> Option<String> {
if let Some(stripped) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("@/")) {
return Some(prefix_with_src_dir(src_dir, stripped));
}
if let Some(stripped) = raw.strip_prefix("~~/").or_else(|| raw.strip_prefix("@@/")) {
return Some(stripped.to_string());
}
config_parser::normalize_config_path(raw, config_path, root)
}
fn normalize_imports_dir_pattern(
raw: &str,
config_path: &Path,
root: &Path,
src_dir: &str,
) -> Option<String> {
let normalized = normalize_nuxt_path(raw, config_path, root, src_dir)?;
Some(imports_dir_pattern(&normalized))
}
fn imports_dir_pattern(normalized: &str) -> String {
let normalized = normalized.trim_end_matches('/');
if normalized.is_empty() {
return "*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}".to_string();
}
if has_glob_syntax(normalized) {
if path_looks_like_file_pattern(normalized) {
normalized.to_string()
} else {
format!("{normalized}/*.{{ts,tsx,js,jsx,mts,cts,mjs,cjs}}")
}
} else {
format!("{normalized}/*.{{ts,tsx,js,jsx,mts,cts,mjs,cjs}}")
}
}
fn has_glob_syntax(pattern: &str) -> bool {
pattern.contains('*') || pattern.contains('?') || pattern.contains('[') || pattern.contains('{')
}
fn path_looks_like_file_pattern(pattern: &str) -> bool {
pattern
.rsplit('/')
.next()
.is_some_and(|segment| segment.contains('.'))
}
fn prefix_with_src_dir(src_dir: &str, path: &str) -> String {
if src_dir.is_empty() {
path.to_string()
} else {
format!("{src_dir}/{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn has_entry_pattern(result: &PluginResult, pattern: &str) -> bool {
result
.entry_patterns
.iter()
.any(|entry_pattern| entry_pattern.pattern == pattern)
}
fn has_used_export_rule(result: &PluginResult, pattern: &str, exports: &[&str]) -> bool {
result.used_exports.iter().any(|rule| {
rule.path.pattern == pattern
&& exports
.iter()
.all(|expected| rule.exports.iter().any(|actual| actual == expected))
})
}
#[test]
fn enabler_is_nuxt() {
let plugin = NuxtPlugin;
assert_eq!(plugin.enablers(), &["nuxt"]);
}
#[test]
fn is_enabled_with_nuxt_dep() {
let plugin = NuxtPlugin;
let deps = vec!["nuxt".to_string()];
assert!(plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn is_enabled_with_nuxt_kit_dep() {
let plugin = NuxtPlugin;
let deps = vec!["@nuxt/kit".to_string()];
assert!(
plugin.is_enabled_with_deps(&deps, Path::new("/project")),
"@nuxt/kit should activate the Nuxt plugin for module authoring"
);
}
#[test]
fn is_not_enabled_without_nuxt() {
let plugin = NuxtPlugin;
let deps = vec!["vue".to_string()];
assert!(!plugin.is_enabled_with_deps(&deps, Path::new("/project")));
}
#[test]
fn entry_patterns_include_nuxt_conventions() {
let plugin = NuxtPlugin;
let patterns = plugin.entry_patterns();
assert!(patterns.iter().any(|p| p.starts_with("pages/")));
assert!(patterns.iter().any(|p| p.starts_with("layouts/")));
assert!(patterns.iter().any(|p| p.starts_with("server/api/")));
assert!(patterns.contains(&"composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"));
assert!(patterns.iter().any(|p| p.starts_with("components/")));
}
#[test]
fn entry_patterns_include_app_dir_variants() {
let plugin = NuxtPlugin;
let patterns = plugin.entry_patterns();
assert!(
patterns.iter().any(|p| p.starts_with("app/pages/")),
"should include Nuxt 3 app/ directory variants"
);
}
#[test]
fn virtual_module_prefixes_includes_hash() {
let plugin = NuxtPlugin;
assert_eq!(plugin.virtual_module_prefixes(), &["#"]);
}
#[test]
fn path_aliases_include_nuxt_at_variants() {
let plugin = NuxtPlugin;
let aliases = plugin.path_aliases(Path::new("/project"));
assert!(aliases.iter().any(|(prefix, _)| *prefix == "@/"));
assert!(aliases.iter().any(|(prefix, _)| *prefix == "@@/"));
}
#[test]
fn used_exports_for_server_api() {
let plugin = NuxtPlugin;
let exports = plugin.used_exports();
let api_entry = exports
.iter()
.find(|(pat, _)| *pat == "server/api/**/*.{ts,js}");
assert!(api_entry.is_some());
let (_, names) = api_entry.unwrap();
assert!(names.contains(&"default"));
assert!(names.contains(&"defineEventHandler"));
}
#[test]
fn used_exports_cover_runtime_default_exports() {
let plugin = NuxtPlugin;
let exports = plugin.used_exports();
for pattern in [
"pages/**/*.{vue,ts,tsx,js,jsx}",
"layouts/**/*.{vue,ts,tsx,js,jsx}",
"components/**/*.{vue,ts,tsx,js,jsx}",
"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"modules/**/*.{ts,js}",
"server/routes/**/*.{ts,js}",
"server/plugins/**/*.{ts,js}",
"app/components/**/*.{vue,ts,tsx,js,jsx}",
"app.vue",
"app.config.{ts,js}",
"app/app.vue",
] {
let entry = exports
.iter()
.find(|(candidate, _)| *candidate == pattern)
.unwrap_or_else(|| panic!("missing used_exports rule for {pattern}"));
assert!(
entry.1.contains(&"default"),
"{pattern} should keep the default export alive"
);
}
}
#[test]
fn resolve_config_modules_as_deps() {
let source = r#"
export default defineNuxtConfig({
modules: ["@nuxtjs/tailwindcss", "@pinia/nuxt"]
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result
.referenced_dependencies
.contains(&"@nuxtjs/tailwindcss".to_string())
);
assert!(
result
.referenced_dependencies
.contains(&"@pinia/nuxt".to_string())
);
}
#[test]
fn resolve_config_css_tilde_resolves_to_root() {
let source = r#"
export default defineNuxtConfig({
css: ["~/assets/main.css"]
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("nuxt.config.ts"),
source,
Path::new("/nonexistent"),
);
assert!(
result
.always_used_files
.contains(&"assets/main.css".to_string()),
"~/assets/main.css should resolve to assets/main.css without app/ dir: {:?}",
result.always_used_files
);
}
#[test]
fn resolve_config_css_double_tilde_always_root() {
let source = r#"
export default defineNuxtConfig({
css: ["~~/shared/global.css"]
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("nuxt.config.ts"),
source,
Path::new("/nonexistent"),
);
assert!(
result
.always_used_files
.contains(&"shared/global.css".to_string()),
"~~/shared/global.css should resolve to shared/global.css"
);
}
#[test]
fn resolve_config_css_npm_package() {
let source = r#"
export default defineNuxtConfig({
css: ["@unocss/reset/tailwind.css"]
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result
.referenced_dependencies
.contains(&"@unocss/reset".to_string()),
"npm package CSS should be tracked as referenced dependency"
);
}
#[test]
fn resolve_config_postcss_plugins_as_deps() {
let source = r#"
export default defineNuxtConfig({
postcss: {
plugins: {
autoprefixer: {},
"postcss-nested": {}
}
}
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result
.referenced_dependencies
.contains(&"autoprefixer".to_string())
);
assert!(
result
.referenced_dependencies
.contains(&"postcss-nested".to_string())
);
}
#[test]
fn resolve_config_extends_as_deps() {
let source = r#"
export default defineNuxtConfig({
extends: ["@nuxt/ui-pro"]
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result
.referenced_dependencies
.contains(&"@nuxt/ui-pro".to_string())
);
}
#[test]
fn resolve_config_import_sources_as_deps() {
let source = r#"
import { defineNuxtConfig } from "nuxt/config";
export default defineNuxtConfig({});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result.referenced_dependencies.contains(&"nuxt".to_string()),
"import source should be extracted as a referenced dependency"
);
}
#[test]
fn resolve_config_empty_source() {
let plugin = NuxtPlugin;
let result = plugin.resolve_config(Path::new("nuxt.config.ts"), "", Path::new("/project"));
assert!(result.referenced_dependencies.is_empty());
assert!(result.always_used_files.is_empty());
assert!(result.entry_patterns.is_empty());
}
#[test]
fn resolve_config_css_relative_path() {
let source = r#"
export default defineNuxtConfig({
css: ["./assets/global.css"]
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(Path::new("nuxt.config.ts"), source, Path::new("/project"));
assert!(
result
.always_used_files
.contains(&"./assets/global.css".to_string()),
"relative CSS path should be an always-used file"
);
}
#[test]
fn resolve_config_extracts_custom_aliases_and_dirs() {
let source = r#"
export default defineNuxtConfig({
srcDir: "app/",
alias: {
"@shared": "./app/shared"
},
imports: {
dirs: ["~/custom/composables"]
},
components: [
{ path: "@/feature-components" }
]
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("/project/nuxt.config.ts"),
source,
Path::new("/project"),
);
assert!(
result
.path_aliases
.contains(&("@shared".to_string(), "app/shared".to_string()))
);
assert!(
result
.path_aliases
.contains(&("~/".to_string(), "app".to_string()))
);
assert!(
result
.path_aliases
.contains(&("@/".to_string(), "app".to_string()))
);
assert!(has_entry_pattern(
&result,
"app/custom/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"
));
assert!(has_entry_pattern(
&result,
"app/feature-components/**/*.{vue,ts,tsx,js,jsx}"
));
assert!(
has_used_export_rule(
&result,
"app/feature-components/**/*.{vue,ts,tsx,js,jsx}",
&["default"],
),
"custom component dirs should contribute default-export used rules"
);
assert!(
result
.always_used_files
.contains(&"app/app.config.{ts,js}".to_string())
);
}
#[test]
fn resolve_config_plugins_supports_string_and_object_entries() {
let source = r#"
export default defineNuxtConfig({
srcDir: "app/",
plugins: [
"~/runtime/plain-plugin",
{ src: "@/runtime/object-plugin", mode: "client" }
]
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("/project/nuxt.config.ts"),
source,
Path::new("/project"),
);
for pattern in [
"app/runtime/plain-plugin.{ts,js,mts,cts,mjs,cjs}",
"app/runtime/object-plugin.{ts,js,mts,cts,mjs,cjs}",
] {
assert!(
has_entry_pattern(&result, pattern),
"expected configured plugin entry pattern {pattern}, got {:?}",
result.entry_patterns
);
assert!(
has_used_export_rule(&result, pattern, &["default"]),
"configured plugin pattern {pattern} should keep default exports alive"
);
}
}
#[test]
fn resolve_config_components_dirs_supports_nested_object_entries() {
let source = r#"
export default defineNuxtConfig({
srcDir: "app/",
components: {
dirs: [
{ path: "~/feature/ui" }
]
}
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("/project/nuxt.config.ts"),
source,
Path::new("/project"),
);
let expected = "app/feature/ui/**/*.{vue,ts,tsx,js,jsx}".to_string();
assert!(
has_entry_pattern(&result, &expected),
"nested components.dirs object entries should add entry patterns"
);
assert!(
has_used_export_rule(&result, &expected, &["default"]),
"nested components.dirs object entries should keep default component exports alive"
);
}
#[test]
fn resolve_config_src_dir_overrides_default_app_aliases() {
let source = r#"
export default defineNuxtConfig({
srcDir: "."
});
"#;
let plugin = NuxtPlugin;
let temp = tempfile::tempdir().expect("temp dir should be created");
std::fs::create_dir(temp.path().join("app")).expect("app dir should exist");
let config_path = temp.path().join("nuxt.config.ts");
let result = plugin.resolve_config(&config_path, source, temp.path());
assert!(
result
.path_aliases
.contains(&("~/".to_string(), String::new())),
"srcDir='.' should remap ~/ to the project root"
);
assert!(
result
.path_aliases
.contains(&("@/".to_string(), String::new())),
"srcDir='.' should remap @/ to the project root"
);
}
#[test]
fn resolve_config_src_dir_adds_custom_source_roots() {
let source = r#"
export default defineNuxtConfig({
srcDir: "src/",
imports: {
dirs: ["~/custom/composables"]
},
components: [
{ path: "@/feature-components" }
]
});
"#;
let plugin = NuxtPlugin;
let result = plugin.resolve_config(
Path::new("/project/nuxt.config.ts"),
source,
Path::new("/project"),
);
assert!(
result
.path_aliases
.contains(&("~/".to_string(), "src".to_string())),
"srcDir should remap ~/ to the configured source root"
);
assert!(
result
.path_aliases
.contains(&("@/".to_string(), "src".to_string())),
"srcDir should remap @/ to the configured source root"
);
assert!(has_entry_pattern(
&result,
"src/custom/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"
));
assert!(has_entry_pattern(
&result,
"src/feature-components/**/*.{vue,ts,tsx,js,jsx}"
));
for expected in [
"src/middleware/**/*.{ts,js}",
"src/plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"src/plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"src/components/**/*.{vue,ts,tsx,js,jsx}",
] {
assert!(
has_used_export_rule(&result, expected, &["default"]),
"{expected} should keep default exports alive under srcDir"
);
}
assert!(
result
.always_used_files
.contains(&"src/app.vue".to_string()),
"srcDir should add app.vue under the configured source root"
);
assert!(
result
.always_used_files
.contains(&"src/app.config.{ts,js}".to_string()),
"srcDir should add app.config under the configured source root"
);
assert!(
result
.always_used_files
.contains(&"src/error.vue".to_string()),
"srcDir should add error.vue under the configured source root"
);
}
#[test]
fn imports_dirs_glob_can_scan_nested_files() {
assert_eq!(
imports_dir_pattern("app/composables"),
"app/composables/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"
);
assert_eq!(
imports_dir_pattern("app/composables/**"),
"app/composables/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"
);
assert_eq!(
imports_dir_pattern("app/composables/*/index.{ts,js,mjs,mts}"),
"app/composables/*/index.{ts,js,mjs,mts}"
);
}
#[test]
fn entry_patterns_keep_nested_plugin_index_only() {
let plugin = NuxtPlugin;
let patterns = plugin.entry_patterns();
assert!(patterns.contains(&"plugins/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"));
assert!(patterns.contains(&"plugins/**/index.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"));
assert!(!patterns.contains(&"plugins/**/*.{ts,js}"));
}
#[test]
fn module_authoring_resolve_config_adds_runtime_patterns() {
let temp = tempfile::tempdir().unwrap();
let runtime = temp.path().join("src/runtime");
std::fs::create_dir_all(runtime.join("components")).unwrap();
std::fs::create_dir_all(runtime.join("composables")).unwrap();
std::fs::create_dir_all(runtime.join("plugins")).unwrap();
std::fs::create_dir_all(runtime.join("utils")).unwrap();
let source = r"
import { defineNuxtModule, addComponentsDir } from '@nuxt/kit';
export default defineNuxtModule({ setup() {} });
";
let plugin = NuxtPlugin;
let result = plugin.resolve_config(&temp.path().join("src/module.ts"), source, temp.path());
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/components/")),
"should add runtime components: {:?}",
result.entry_patterns
);
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/composables/")),
"should add runtime composables: {:?}",
result.entry_patterns
);
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/plugins/")),
"should add runtime plugins: {:?}",
result.entry_patterns
);
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/utils/")),
"should add runtime utils: {:?}",
result.entry_patterns
);
}
#[test]
fn module_authoring_detects_locale_and_types_dirs() {
let temp = tempfile::tempdir().unwrap();
let runtime = temp.path().join("src/runtime");
std::fs::create_dir_all(runtime.join("components")).unwrap();
std::fs::create_dir_all(runtime.join("locale")).unwrap();
std::fs::create_dir_all(runtime.join("types")).unwrap();
let source = "";
let plugin = NuxtPlugin;
let result = plugin.resolve_config(&temp.path().join("src/module.ts"), source, temp.path());
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/locale/")),
"should detect locale dir: {:?}",
result.entry_patterns
);
assert!(
result
.entry_patterns
.iter()
.any(|p| p.starts_with("src/runtime/types/")),
"should detect types dir: {:?}",
result.entry_patterns
);
}
#[test]
fn module_authoring_no_runtime_dir_is_noop() {
let temp = tempfile::tempdir().unwrap();
let source = "";
let plugin = NuxtPlugin;
let result = plugin.resolve_config(&temp.path().join("src/module.ts"), source, temp.path());
assert!(
result.entry_patterns.is_empty(),
"no runtime dir should produce no patterns"
);
}
#[test]
fn module_authoring_extracts_import_deps() {
let temp = tempfile::tempdir().unwrap();
let source = r"
import { defineNuxtModule, addComponentsDir } from '@nuxt/kit';
import defu from 'defu';
";
let plugin = NuxtPlugin;
let result = plugin.resolve_config(&temp.path().join("src/module.ts"), source, temp.path());
assert!(
result
.referenced_dependencies
.contains(&"@nuxt/kit".to_string()),
"@nuxt/kit should be a referenced dependency"
);
assert!(
result.referenced_dependencies.contains(&"defu".to_string()),
"defu should be a referenced dependency"
);
}
#[test]
fn nuxt_config_not_treated_as_module() {
let temp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(temp.path().join("src/runtime/components")).unwrap();
let source = r#"
export default defineNuxtConfig({
modules: ["@nuxtjs/tailwindcss"]
});
"#;
let plugin = NuxtPlugin;
let result =
plugin.resolve_config(&temp.path().join("nuxt.config.ts"), source, temp.path());
assert!(
!result.entry_patterns.iter().any(|p| p.contains("runtime")),
"nuxt.config.ts should not add runtime patterns: {:?}",
result.entry_patterns
);
}
}