use anyhow::{Context, Result};
use oxc_allocator::Allocator;
use oxc_codegen::Codegen;
use oxc_isolated_declarations::{IsolatedDeclarations, IsolatedDeclarationsOptions};
use oxc_parser::Parser;
use oxc_span::SourceType as OxcSourceType;
use rolldown_common::{Output, OutputAsset};
use rolldown_plugin::{HookGenerateBundleArgs, HookNoopReturn, Plugin, PluginContext};
use std::borrow::Cow;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct DtsEmitPlugin {
strip_internal: bool,
sourcemap: bool,
dts_dir: Option<PathBuf>,
}
impl DtsEmitPlugin {
pub fn new(strip_internal: bool, sourcemap: bool, dts_dir: Option<PathBuf>) -> Self {
Self {
strip_internal,
sourcemap,
dts_dir,
}
}
}
impl Plugin for DtsEmitPlugin {
fn name(&self) -> Cow<'static, str> {
"fob-dts-emit".into()
}
fn register_hook_usage(&self) -> rolldown_plugin::HookUsage {
use rolldown_plugin::HookUsage;
HookUsage::GenerateBundle
}
fn generate_bundle(
&self,
_ctx: &PluginContext,
args: &mut HookGenerateBundleArgs<'_>,
) -> impl std::future::Future<Output = HookNoopReturn> + Send {
let strip_internal = self.strip_internal;
let sourcemap = self.sourcemap;
let dts_dir = self.dts_dir.clone();
async move {
let mut dts_assets = Vec::new();
for output in args.bundle.iter() {
if let Output::Chunk(chunk) = output {
for module_id in &chunk.modules.keys {
if !is_typescript_module(module_id.as_ref()) {
continue;
}
let source = match std::fs::read_to_string(module_id.as_ref()) {
Ok(s) => s,
Err(e) => {
eprintln!(
"Warning: Failed to read TypeScript file for .d.ts generation: {} - {}",
module_id.as_ref(),
e
);
continue;
}
};
let dts_content =
match generate_dts(&source, module_id.as_ref(), strip_internal) {
Ok(content) => content,
Err(e) => {
eprintln!(
"Warning: Failed to generate .d.ts for {}: {}",
module_id.as_ref(),
e
);
continue;
}
};
let dts_filename = compute_dts_filename(
&chunk.filename,
module_id.as_ref(),
dts_dir.as_deref(),
);
let asset = OutputAsset {
names: vec![],
original_file_names: vec![module_id.to_string()],
filename: dts_filename.into(),
source: dts_content.into(),
};
dts_assets.push(Output::Asset(Arc::new(asset)));
if sourcemap {
}
}
}
}
args.bundle.extend(dts_assets);
Ok(())
}
}
}
fn is_typescript_module(path: &str) -> bool {
Path::new(path)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| matches!(ext, "ts" | "tsx" | "mts" | "cts"))
.unwrap_or(false)
}
fn generate_dts(source: &str, file_path: &str, strip_internal: bool) -> Result<String> {
let allocator = Allocator::default();
let source_type = OxcSourceType::from_path(file_path)
.with_context(|| format!("Invalid TypeScript file: {}", file_path))?;
let parser = Parser::new(&allocator, source, source_type);
let parse_result = parser.parse();
if !parse_result.errors.is_empty() {
let error_messages: Vec<String> = parse_result
.errors
.iter()
.map(|e| format!("{:?}", e))
.collect();
anyhow::bail!(
"Failed to parse TypeScript file {}: {}",
file_path,
error_messages.join(", ")
);
}
let options = IsolatedDeclarationsOptions { strip_internal };
let isolated_dts = IsolatedDeclarations::new(&allocator, options);
let dts_result = isolated_dts.build(&parse_result.program);
if !dts_result.errors.is_empty() {
let error_messages: Vec<String> = dts_result
.errors
.iter()
.map(|e| format!("{:?}", e))
.collect();
anyhow::bail!(
"Errors generating declarations for {}: {}",
file_path,
error_messages.join(", ")
);
}
let codegen = Codegen::new();
let generated = codegen.build(&dts_result.program);
Ok(generated.code)
}
fn compute_dts_filename(chunk_filename: &str, _module_id: &str, dts_dir: Option<&Path>) -> String {
let base = Path::new(chunk_filename)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("index");
let filename = format!("{}.d.ts", base);
if let Some(dir) = dts_dir {
format!("{}/{}", dir.display(), filename)
} else {
filename
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_typescript_module() {
assert!(is_typescript_module("index.ts"));
assert!(is_typescript_module("component.tsx"));
assert!(is_typescript_module("module.mts"));
assert!(is_typescript_module("types.cts"));
assert!(!is_typescript_module("index.js"));
assert!(!is_typescript_module("index.jsx"));
assert!(!is_typescript_module("style.css"));
}
#[test]
fn test_compute_dts_filename() {
assert_eq!(
compute_dts_filename("index.js", "src/index.ts", None),
"index.d.ts"
);
assert_eq!(
compute_dts_filename("index.js", "src/index.ts", Some(Path::new("types"))),
"types/index.d.ts"
);
assert_eq!(
compute_dts_filename("bundle.js", "src/lib.ts", Some(Path::new("dist/types"))),
"dist/types/bundle.d.ts"
);
}
#[test]
fn test_generate_dts_basic() {
let source = r#"
export function greet(name: string): string {
return `Hello, ${name}!`;
}
"#;
let result = generate_dts(source, "test.ts", false);
assert!(result.is_ok());
let dts = result.unwrap();
assert!(dts.contains("export"));
assert!(dts.contains("function greet"));
assert!(dts.contains("string"));
}
#[test]
fn test_generate_dts_with_strip_internal() {
let source = r#"
/** @internal */
export function _internalFn(): void {}
export function publicFn(): void {}
"#;
let result = generate_dts(source, "test.ts", true);
assert!(result.is_ok());
let dts = result.unwrap();
assert!(!dts.contains("_internalFn"));
assert!(dts.contains("publicFn"));
let result = generate_dts(source, "test.ts", false);
assert!(result.is_ok());
let dts = result.unwrap();
assert!(dts.contains("_internalFn"));
assert!(dts.contains("publicFn"));
}
}