use std::path::Path;
use super::{PathRule, Plugin, PluginResult, UsedExportRule, config_parser};
const ENABLERS: &[&str] = &[
"@tanstack/react-router",
"@tanstack/solid-router",
"@tanstack/start",
"@tanstack/react-start",
"@tanstack/solid-start",
];
const DEFAULT_ROUTE_DIRS: &[&str] = &["src/routes", "app/routes"];
const SUPPORTING_ENTRY_PATTERNS: &[&str] = &[
"src/server.{ts,tsx,js,jsx}",
"src/client.{ts,tsx,js,jsx}",
"src/router.{ts,tsx,js,jsx}",
];
const DEFAULT_GENERATED_ROUTE_TREE_PATTERNS: &[&str] =
&["src/routeTree.gen.ts", "src/routeTree.gen.js"];
const ENTRY_PATTERNS: &[&str] = &[
"src/routes/**/*.{ts,tsx,js,jsx}",
"app/routes/**/*.{ts,tsx,js,jsx}",
"src/server.{ts,tsx,js,jsx}",
"src/client.{ts,tsx,js,jsx}",
"src/router.{ts,tsx,js,jsx}",
"src/routeTree.gen.ts",
"src/routeTree.gen.js",
];
const CONFIG_PATTERNS: &[&str] = &["tsr.config.json"];
const ALWAYS_USED: &[&str] = &["tsr.config.json", "app.config.{ts,js}"];
const TOOLING_DEPENDENCIES: &[&str] = &[
"@tanstack/react-router",
"@tanstack/react-router-devtools",
"@tanstack/solid-router",
"@tanstack/solid-router-devtools",
"@tanstack/start",
"@tanstack/react-start",
"@tanstack/solid-start",
"@tanstack/router-cli",
"@tanstack/router-vite-plugin",
];
const ROUTE_EXPORTS: &[&str] = &[
"default",
"Route",
"loader",
"action",
"component",
"errorComponent",
"pendingComponent",
"notFoundComponent",
"beforeLoad",
];
const LAZY_ROUTE_EXPORTS: &[&str] = &[
"Route",
"component",
"errorComponent",
"pendingComponent",
"notFoundComponent",
];
const DEFAULT_ROUTE_FILE_IGNORE_PREFIX: &str = "-";
const ROUTE_FILE_EXTENSIONS: &str = "{ts,tsx,js,jsx}";
pub struct TanstackRouterPlugin;
impl Plugin for TanstackRouterPlugin {
fn name(&self) -> &'static str {
"tanstack-router"
}
fn enablers(&self) -> &'static [&'static str] {
ENABLERS
}
fn entry_patterns(&self) -> &'static [&'static str] {
ENTRY_PATTERNS
}
fn entry_pattern_rules(&self) -> Vec<PathRule> {
let mut rules = DEFAULT_ROUTE_DIRS
.iter()
.flat_map(|route_dir| {
[
route_dir_rule(
route_dir,
"",
DEFAULT_ROUTE_FILE_IGNORE_PREFIX,
None,
RouteFileKind::Standard,
),
route_dir_rule(
route_dir,
"",
DEFAULT_ROUTE_FILE_IGNORE_PREFIX,
None,
RouteFileKind::Lazy,
),
]
})
.collect::<Vec<_>>();
rules.extend(
DEFAULT_GENERATED_ROUTE_TREE_PATTERNS
.iter()
.chain(SUPPORTING_ENTRY_PATTERNS.iter())
.map(|pattern| PathRule::from_static(pattern)),
);
rules
}
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 used_exports(&self) -> Vec<(&'static str, &'static [&'static str])> {
vec![
("src/routes/**/*.{ts,tsx,js,jsx}", ROUTE_EXPORTS),
("app/routes/**/*.{ts,tsx,js,jsx}", ROUTE_EXPORTS),
("src/routes/**/*.lazy.{ts,tsx,js,jsx}", LAZY_ROUTE_EXPORTS),
("app/routes/**/*.lazy.{ts,tsx,js,jsx}", LAZY_ROUTE_EXPORTS),
]
}
fn used_export_rules(&self) -> Vec<UsedExportRule> {
DEFAULT_ROUTE_DIRS
.iter()
.flat_map(|route_dir| {
[
route_dir_used_export_rule(
route_dir,
"",
DEFAULT_ROUTE_FILE_IGNORE_PREFIX,
None,
),
lazy_route_rule(route_dir, "", DEFAULT_ROUTE_FILE_IGNORE_PREFIX, None),
]
})
.collect()
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
let mut result = PluginResult {
replace_entry_patterns: true,
replace_used_export_rules: true,
..PluginResult::default()
};
let route_dir =
config_parser::extract_config_string(source, config_path, &["routesDirectory"])
.as_deref()
.and_then(|raw| config_parser::normalize_config_path(raw, config_path, root))
.unwrap_or_else(|| "src/routes".to_string());
let route_file_prefix =
config_parser::extract_config_string(source, config_path, &["routeFilePrefix"])
.unwrap_or_default();
let route_file_ignore_prefix =
config_parser::extract_config_string(source, config_path, &["routeFileIgnorePrefix"])
.unwrap_or_else(|| DEFAULT_ROUTE_FILE_IGNORE_PREFIX.to_string());
let route_file_ignore_pattern =
config_parser::extract_config_string(source, config_path, &["routeFileIgnorePattern"]);
add_route_dir_patterns(
&mut result,
&route_dir,
&route_file_prefix,
&route_file_ignore_prefix,
route_file_ignore_pattern.as_deref(),
);
let generated_route_tree =
config_parser::extract_config_string(source, config_path, &["generatedRouteTree"])
.as_deref()
.and_then(|raw| config_parser::normalize_config_path(raw, config_path, root));
if let Some(route_tree) = generated_route_tree {
result.push_entry_pattern(route_tree);
} else {
result.extend_entry_patterns(DEFAULT_GENERATED_ROUTE_TREE_PATTERNS.iter().copied());
}
result.extend_entry_patterns(SUPPORTING_ENTRY_PATTERNS.iter().copied());
result
}
}
fn add_route_dir_patterns(
result: &mut PluginResult,
route_dir: &str,
route_file_prefix: &str,
route_file_ignore_prefix: &str,
route_file_ignore_pattern: Option<&str>,
) {
result.entry_patterns.push(route_dir_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
RouteFileKind::Standard,
));
result.entry_patterns.push(route_dir_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
RouteFileKind::Lazy,
));
result.used_exports.push(route_dir_used_export_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
));
result.used_exports.push(lazy_route_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
));
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum RouteFileKind {
Standard,
Lazy,
}
#[derive(Default)]
struct RouteDirExclusions {
globs: Vec<String>,
segment_regexes: Vec<String>,
}
fn route_dir_rule(
route_dir: &str,
route_file_prefix: &str,
route_file_ignore_prefix: &str,
route_file_ignore_pattern: Option<&str>,
file_kind: RouteFileKind,
) -> PathRule {
let mut exclusions = route_dir_exclusions(
route_dir,
route_file_ignore_prefix,
route_file_ignore_pattern,
);
if file_kind == RouteFileKind::Standard {
exclusions.globs.push(route_file_pattern(
route_dir,
route_file_prefix,
RouteFileKind::Lazy,
));
}
PathRule::new(route_file_pattern(route_dir, route_file_prefix, file_kind))
.with_excluded_globs(exclusions.globs)
.with_excluded_segment_regexes(exclusions.segment_regexes)
}
fn route_dir_used_export_rule(
route_dir: &str,
route_file_prefix: &str,
route_file_ignore_prefix: &str,
route_file_ignore_pattern: Option<&str>,
) -> UsedExportRule {
used_export_rule_from_path_rule(
route_dir_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
RouteFileKind::Standard,
),
ROUTE_EXPORTS,
)
}
fn lazy_route_rule(
route_dir: &str,
route_file_prefix: &str,
route_file_ignore_prefix: &str,
route_file_ignore_pattern: Option<&str>,
) -> UsedExportRule {
used_export_rule_from_path_rule(
route_dir_rule(
route_dir,
route_file_prefix,
route_file_ignore_prefix,
route_file_ignore_pattern,
RouteFileKind::Lazy,
),
LAZY_ROUTE_EXPORTS,
)
}
fn route_dir_exclusions(
route_dir: &str,
route_file_ignore_prefix: &str,
route_file_ignore_pattern: Option<&str>,
) -> RouteDirExclusions {
let mut exclusions = RouteDirExclusions::default();
if !route_file_ignore_prefix.is_empty() {
exclusions
.globs
.push(format!("{route_dir}/**/{route_file_ignore_prefix}*"));
exclusions
.globs
.push(format!("{route_dir}/**/{route_file_ignore_prefix}*/**/*"));
}
if let Some(pattern) = route_file_ignore_pattern {
exclusions.segment_regexes.push(pattern.to_string());
}
exclusions
}
fn route_file_pattern(
route_dir: &str,
route_file_prefix: &str,
file_kind: RouteFileKind,
) -> String {
match file_kind {
RouteFileKind::Standard => {
format!("{route_dir}/**/{route_file_prefix}*.{ROUTE_FILE_EXTENSIONS}")
}
RouteFileKind::Lazy => {
format!("{route_dir}/**/{route_file_prefix}*.lazy.{ROUTE_FILE_EXTENSIONS}")
}
}
}
fn used_export_rule_from_path_rule(
rule: PathRule,
exports: &'static [&'static str],
) -> UsedExportRule {
UsedExportRule::new(rule.pattern, exports.iter().copied())
.with_excluded_globs(rule.exclude_globs)
.with_excluded_regexes(rule.exclude_regexes)
.with_excluded_segment_regexes(rule.exclude_segment_regexes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn used_exports_cover_lazy_routes_without_inheriting_non_lazy_exports() {
let lazy_rule = lazy_route_rule("src/routes", "", DEFAULT_ROUTE_FILE_IGNORE_PREFIX, None);
let broad_rule =
route_dir_used_export_rule("src/routes", "", DEFAULT_ROUTE_FILE_IGNORE_PREFIX, None);
assert_eq!(
lazy_rule.path.pattern,
"src/routes/**/*.lazy.{ts,tsx,js,jsx}"
);
assert!(lazy_rule.exports.contains(&"Route".to_string()));
assert!(lazy_rule.exports.contains(&"component".to_string()));
assert!(
broad_rule
.path
.exclude_globs
.contains(&"src/routes/**/*.lazy.{ts,tsx,js,jsx}".to_string())
);
}
#[test]
fn resolve_config_uses_custom_routes_directory() {
let plugin = TanstackRouterPlugin;
let result = plugin.resolve_config(
Path::new("/project/tsr.config.json"),
r#"{
"routesDirectory": "./app/pages",
"generatedRouteTree": "./app/routeTree.gen.ts",
"routeFileIgnorePrefix": "-"
}"#,
Path::new("/project"),
);
assert!(result.replace_entry_patterns);
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "app/pages/**/*.{ts,tsx,js,jsx}"),
"entry patterns: {:?}",
result.entry_patterns
);
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "app/routeTree.gen.ts")
);
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == "src/router.{ts,tsx,js,jsx}")
);
}
#[test]
fn resolve_config_keeps_default_supporting_entries_with_custom_route_dir() {
let plugin = TanstackRouterPlugin;
let result = plugin.resolve_config(
Path::new("/project/tsr.config.json"),
r#"{
"routesDirectory": "./app/pages"
}"#,
Path::new("/project"),
);
for expected in [
"app/pages/**/*.{ts,tsx,js,jsx}",
"src/routeTree.gen.ts",
"src/routeTree.gen.js",
"src/server.{ts,tsx,js,jsx}",
"src/client.{ts,tsx,js,jsx}",
"src/router.{ts,tsx,js,jsx}",
] {
assert!(
result
.entry_patterns
.iter()
.any(|rule| rule.pattern == expected),
"missing supporting entry pattern {expected}: {:?}",
result.entry_patterns
);
}
}
#[test]
fn route_rules_honor_route_file_prefix() {
let route_rule = route_dir_used_export_rule(
"app/pages",
"route-",
DEFAULT_ROUTE_FILE_IGNORE_PREFIX,
None,
);
assert_eq!(
route_rule.path.pattern,
"app/pages/**/route-*.{ts,tsx,js,jsx}"
);
assert!(
route_rule
.path
.exclude_globs
.contains(&"app/pages/**/route-*.lazy.{ts,tsx,js,jsx}".to_string())
);
}
#[test]
fn route_rules_preserve_segment_ignore_regexes() {
let route_rule = route_dir_used_export_rule(
"app/pages",
"",
DEFAULT_ROUTE_FILE_IGNORE_PREFIX,
Some("^ignored\\."),
);
assert!(
route_rule
.path
.exclude_globs
.contains(&"app/pages/**/-*".to_string())
);
assert_eq!(
route_rule.path.exclude_segment_regexes,
vec!["^ignored\\.".to_string()]
);
assert!(route_rule.path.exclude_regexes.is_empty());
}
}