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}