fob_plugin_astro/lib.rs
1//! Rolldown plugin for Astro components
2//!
3//! This plugin extracts and processes frontmatter and `<script>` blocks from Astro
4//! components, making them available to the bundler as JavaScript/TypeScript modules.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! .astro file → load() hook → AstroExtractor → Extract frontmatter + scripts → JS/TS → Rolldown
10//! ```
11//!
12//! ## Why the `load` hook?
13//!
14//! We use the `load` hook (not `transform`) because:
15//! - Astro components aren't valid JavaScript/TypeScript that Rolldown can parse
16//! - We must intercept files before Rolldown's parser runs
17//! - The `load` hook is designed for custom file formats
18//! - We return extracted JavaScript with the appropriate `ModuleType`
19//!
20//! ## Handling Frontmatter and Scripts
21//!
22//! Astro components can contain:
23//! - One frontmatter block at the start (delimited by `---`, TypeScript by default)
24//! - Multiple `<script>` tags in the template (JavaScript by default)
25//!
26//! Frontmatter runs on the server during build/SSR, while script tags run in the browser.
27//! When both exist, we combine them with frontmatter first, as it executes during the
28//! component's module loading phase.
29//!
30//! ## Example Usage
31//!
32//! ```rust,no_run
33//! use fob_plugin_astro::FobAstroPlugin;
34//! use std::sync::Arc;
35//!
36//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
37//! let plugin = Arc::new(FobAstroPlugin::new());
38//! // Add to your Rolldown bundler configuration
39//! # Ok(())
40//! # }
41//! ```
42
43use anyhow::Context;
44use fob_bundler::{
45 HookLoadArgs, HookLoadOutput, HookLoadReturn, ModuleType, Plugin, PluginContext,
46};
47use fob_graph::analysis::extractors::{AstroExtractor, ExtractedScript, Extractor};
48use std::borrow::Cow;
49
50/// Rolldown plugin that extracts JavaScript/TypeScript from Astro components
51///
52/// # Features
53///
54/// - Supports frontmatter (delimited by `---` at file start)
55/// - Supports multiple `<script>` tags in the template
56/// - Frontmatter is TypeScript by default
57/// - Script tags are JavaScript by default
58/// - Accurate source mapping for error reporting
59/// - Security: file size limits, tag count limits, no ReDoS vulnerabilities
60///
61/// # Security
62///
63/// The plugin enforces limits to prevent DoS attacks:
64/// - Max file size: 10MB
65/// - Max script tags: 100
66/// - Uses memchr (not regex) to avoid ReDoS
67/// - Never panics on malformed input
68#[derive(Debug, Clone, Default)]
69pub struct FobAstroPlugin {
70 // Future: add configuration options here
71 // - Custom file size limits
72 // - Preprocessor configuration
73 // - Source map generation settings
74}
75
76impl FobAstroPlugin {
77 /// Creates a new Astro plugin with default settings
78 ///
79 /// # Example
80 ///
81 /// ```rust
82 /// use fob_plugin_astro::FobAstroPlugin;
83 ///
84 /// let plugin = FobAstroPlugin::new();
85 /// ```
86 pub fn new() -> Self {
87 Self::default()
88 }
89}
90
91impl Plugin for FobAstroPlugin {
92 /// Returns the plugin name for debugging and logging
93 fn name(&self) -> Cow<'static, str> {
94 "fob-astro".into()
95 }
96
97 /// Declares which hooks this plugin uses
98 ///
99 /// This allows Rolldown to optimize by skipping unused hooks.
100 fn register_hook_usage(&self) -> fob_bundler::HookUsage {
101 use fob_bundler::HookUsage;
102 // We only use the load hook
103 HookUsage::Load
104 }
105
106 /// Load hook - intercepts `.astro` files and extracts JavaScript
107 ///
108 /// This is the core of the plugin. It:
109 /// 1. Checks if the file is a `.astro` file
110 /// 2. Reads the file from disk
111 /// 3. Parses and extracts frontmatter and script blocks
112 /// 4. Combines multiple scripts if needed
113 /// 5. Returns JavaScript/TypeScript to Rolldown
114 ///
115 /// # Returns
116 ///
117 /// - `Ok(Some(output))` - Successfully extracted JavaScript
118 /// - `Ok(None)` - Not an Astro file, let Rolldown handle it
119 /// - `Err(e)` - Parse error or I/O error
120 ///
121 /// # Script Combination
122 ///
123 /// When an Astro component has frontmatter and scripts:
124 /// ```astro
125 /// ---
126 /// const title = 'My Page'
127 /// const data = await fetchData()
128 /// ---
129 /// <html>
130 /// <head><title>{title}</title></head>
131 /// <body>
132 /// <script>
133 /// console.log('Client-side code')
134 /// </script>
135 /// </body>
136 /// </html>
137 /// ```
138 ///
139 /// We combine them as:
140 /// ```js
141 /// const title = 'My Page' // Frontmatter runs first (server-side)
142 /// const data = await fetchData()
143 ///
144 /// console.log('Client-side code') // Scripts run in browser
145 /// ```
146 ///
147 /// # Module Type Detection
148 ///
149 /// - Frontmatter is always TypeScript
150 /// - Scripts are JavaScript by default
151 /// - If any source is TypeScript, the combined output is TypeScript
152 fn load(
153 &self,
154 _ctx: &PluginContext,
155 args: &HookLoadArgs<'_>,
156 ) -> impl std::future::Future<Output = HookLoadReturn> + Send {
157 // Capture data for async block
158 let id = args.id.to_string();
159
160 async move {
161 // Only handle .astro files
162 if !id.ends_with(".astro") {
163 return Ok(None);
164 }
165
166 // Read the Astro component source file
167 let source = std::fs::read_to_string(&id)
168 .with_context(|| format!("Failed to read Astro file: {}", id))?;
169
170 // Parse and extract frontmatter and script blocks
171 let scripts = AstroExtractor
172 .extract(&source)
173 .with_context(|| format!("Failed to parse Astro file: {}", id))?;
174
175 // Handle no scripts case
176 if scripts.is_empty() {
177 // Return empty JavaScript module
178 return Ok(Some(HookLoadOutput {
179 code: "export default {}".into(),
180 module_type: Some(ModuleType::Js),
181 ..Default::default()
182 }));
183 }
184
185 // Combine scripts and determine module type
186 let (combined_code, module_type) = combine_scripts(&scripts);
187
188 // Return the extracted JavaScript/TypeScript
189 Ok(Some(HookLoadOutput {
190 code: combined_code.into(),
191 module_type: Some(module_type),
192 ..Default::default()
193 }))
194 }
195 }
196}
197
198/// Combines multiple script blocks and determines the appropriate module type.
199///
200/// # Algorithm
201///
202/// 1. If only one script: return it as-is with its module type
203/// 2. If multiple scripts: combine with frontmatter first, then scripts in order
204/// 3. Module type priority: ts > js (if any source is TS, output is TS)
205///
206/// # Examples
207///
208/// Single frontmatter:
209/// ```ignore
210/// scripts = [JavaScriptSource { source_text: "const x = 1", is_frontmatter: true, lang: "ts" }]
211/// → ("const x = 1", ModuleType::Ts)
212/// ```
213///
214/// Multiple scripts:
215/// ```ignore
216/// scripts = [
217/// JavaScriptSource { source_text: "const x = 1", is_frontmatter: true, lang: "ts" },
218/// JavaScriptSource { source_text: "console.log(x)", is_frontmatter: false, lang: "js" },
219/// ]
220/// → ("const x = 1\n\nconsole.log(x)", ModuleType::Ts)
221/// ```
222fn combine_scripts(scripts: &[ExtractedScript]) -> (String, ModuleType) {
223 // Single script case
224 if scripts.len() == 1 {
225 let script = &scripts[0];
226 return (
227 script.source_text.to_string(),
228 determine_module_type(script.lang),
229 );
230 }
231
232 // Multiple scripts: combine frontmatter first, then scripts in order
233 let mut combined = String::new();
234 let mut has_typescript = false;
235
236 for (i, script) in scripts.iter().enumerate() {
237 if i > 0 && !combined.is_empty() {
238 combined.push_str("\n\n"); // Separate scripts with blank lines
239 }
240
241 combined.push_str(script.source_text);
242
243 // Track if any script is TypeScript
244 if script.lang == "ts" || script.lang == "typescript" {
245 has_typescript = true;
246 }
247 }
248
249 let module_type = if has_typescript {
250 ModuleType::Ts
251 } else {
252 ModuleType::Js
253 };
254
255 (combined, module_type)
256}
257
258/// Determines the Rolldown module type from a language identifier.
259///
260/// # Language Mapping
261///
262/// - "ts" or "typescript" → TypeScript
263/// - "js" or anything else → JavaScript
264fn determine_module_type(lang: &str) -> ModuleType {
265 match lang {
266 "ts" | "typescript" => ModuleType::Ts,
267 _ => ModuleType::Js,
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_plugin_creation() {
277 let plugin = FobAstroPlugin::new();
278 assert_eq!(plugin.name(), "fob-astro");
279 }
280
281 #[test]
282 fn test_plugin_default() {
283 let plugin = FobAstroPlugin::default();
284 assert_eq!(plugin.name(), "fob-astro");
285 }
286
287 #[test]
288 fn test_determine_module_type() {
289 assert!(matches!(determine_module_type("js"), ModuleType::Js));
290 assert!(matches!(determine_module_type("ts"), ModuleType::Ts));
291 assert!(matches!(
292 determine_module_type("typescript"),
293 ModuleType::Ts
294 ));
295 }
296
297 #[test]
298 fn test_combine_single_script() {
299 use fob_graph::analysis::extractors::ScriptContext;
300 let scripts = vec![ExtractedScript::new(
301 "const x = 1;",
302 4,
303 ScriptContext::AstroFrontmatter,
304 "ts",
305 )];
306 let (code, module_type) = combine_scripts(&scripts);
307 assert_eq!(code, "const x = 1;");
308 assert!(matches!(module_type, ModuleType::Ts));
309 }
310
311 #[test]
312 fn test_combine_multiple_scripts() {
313 use fob_graph::analysis::extractors::ScriptContext;
314 let scripts = vec![
315 ExtractedScript::new(
316 "const title = 'Page'",
317 4,
318 ScriptContext::AstroFrontmatter,
319 "ts",
320 ),
321 ExtractedScript::new("console.log(title)", 100, ScriptContext::AstroScript, "js"),
322 ExtractedScript::new("alert('hello')", 200, ScriptContext::AstroScript, "js"),
323 ];
324 let (code, module_type) = combine_scripts(&scripts);
325 // Frontmatter should come first
326 assert!(code.starts_with("const title = 'Page'"));
327 assert!(code.contains("console.log(title)"));
328 assert!(code.contains("alert('hello')"));
329 // Should be TypeScript (frontmatter is TS)
330 assert!(matches!(module_type, ModuleType::Ts));
331 }
332
333 #[test]
334 fn test_combine_only_scripts() {
335 use fob_graph::analysis::extractors::ScriptContext;
336 let scripts = vec![
337 ExtractedScript::new("console.log('a')", 50, ScriptContext::AstroScript, "js"),
338 ExtractedScript::new("console.log('b')", 100, ScriptContext::AstroScript, "js"),
339 ];
340 let (code, module_type) = combine_scripts(&scripts);
341 assert!(code.contains("console.log('a')"));
342 assert!(code.contains("console.log('b')"));
343 assert!(matches!(module_type, ModuleType::Js));
344 }
345}