Skip to main content

typub_adapter_astro/
adapter.rs

1//! Astro adapter - outputs Markdown with YAML front-matter for Astro Content Collections.
2//!
3//! Per [[ADR-0009]], this adapter generates Markdown files compatible with
4//! Astro's Content Collections system, enabling users to integrate typub output
5//! into their Astro projects.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10use anyhow::Result;
11use async_trait::async_trait;
12use chrono::{DateTime, Utc};
13
14use typub_adapters_core::{
15    AdapterContext, AdapterPayload, ContentInfo, MarkdownRenderOptions, OutputFormat,
16    PlatformAdapter, PlatformBranding, RenderConfig, build_unified_preview,
17    convert_png_math_for_strategy, debug, document_to_markdown_with_options, downcast_payload,
18    ensure_no_unresolved_image_markers, materialize_and_resolve_urls,
19    mock_materialize_and_resolve_urls, prepare_deferred_assets, render_config_for_png_math,
20    resolve_asset_urls,
21};
22use typub_config::Config;
23use typub_core::{AssetStrategy, MathDelimiters, MathRendering};
24use typub_ir::Document;
25use typub_storage::{PublishResult, build_image_marker_url_map, to_data_uri};
26
27use crate::config::{ID, resolve_math_rendering, resolve_strategy};
28
29pub struct AstroAdapter {
30    output_dir: PathBuf,
31    asset_strategy: AssetStrategy,
32    math_rendering: MathRendering,
33}
34
35#[derive(Debug)]
36pub struct AstroPayload {
37    pub slug: String,
38    pub markdown: String,
39    pub frontmatter: FrontMatter,
40}
41
42#[derive(Debug, Default)]
43pub struct FrontMatter {
44    pub title: String,
45    pub date: Option<DateTime<Utc>>,
46    pub draft: bool,
47    pub tags: Vec<String>,
48    pub categories: Vec<String>,
49}
50
51impl AstroAdapter {
52    pub fn new(config: &Config) -> Result<Self> {
53        let platform_config = config.get_platform(ID);
54
55        let output_dir = platform_config
56            .and_then(|c| c.get_str("output_dir"))
57            .map(PathBuf::from)
58            .unwrap_or_else(|| config.output_dir.join(ID));
59
60        let asset_strategy = resolve_strategy(platform_config)?;
61        let math_rendering = resolve_math_rendering(platform_config)?;
62
63        Ok(Self {
64            output_dir,
65            asset_strategy,
66            math_rendering,
67        })
68    }
69
70    #[cfg(test)]
71    #[allow(clippy::expect_used)]
72    pub fn new_for_test() -> Self {
73        Self::new_for_test_with(
74            PathBuf::from("/tmp/astro"),
75            AssetStrategy::Copy,
76            MathRendering::Svg,
77        )
78    }
79
80    #[cfg(test)]
81    #[allow(clippy::expect_used)]
82    pub fn new_for_test_with(
83        output_dir: PathBuf,
84        asset_strategy: AssetStrategy,
85        math_rendering: MathRendering,
86    ) -> Self {
87        Self {
88            output_dir,
89            asset_strategy,
90            math_rendering,
91        }
92    }
93
94    async fn build_asset_map(
95        &self,
96        content_info: &ContentInfo,
97    ) -> Result<HashMap<PathBuf, String>> {
98        let mut url_map = HashMap::new();
99
100        match self.asset_strategy {
101            AssetStrategy::Copy => {
102                let dest_dir = self.output_dir.join(&content_info.slug).join("assets");
103                std::fs::create_dir_all(&dest_dir)?;
104
105                for asset_path in &content_info.assets {
106                    let file_name = asset_path.file_name().ok_or_else(|| {
107                        anyhow::anyhow!("Invalid asset filename: {}", asset_path.display())
108                    })?;
109                    let dest_path = dest_dir.join(file_name);
110                    std::fs::copy(asset_path, &dest_path)?;
111                    url_map.insert(
112                        asset_path.clone(),
113                        format!("./assets/{}", file_name.to_string_lossy()),
114                    );
115                }
116            }
117            AssetStrategy::Embed => {
118                for asset_path in &content_info.assets {
119                    let data = std::fs::read(asset_path)?;
120                    let data_uri = to_data_uri(&data, asset_path);
121                    url_map.insert(asset_path.clone(), data_uri);
122                }
123            }
124            AssetStrategy::External | AssetStrategy::Upload => {
125                // These strategies require deferred upload, handled in materialize_payload
126            }
127        }
128
129        Ok(url_map)
130    }
131
132    fn format_frontmatter(fm: &FrontMatter) -> String {
133        let mut lines = vec!["---".to_string()];
134
135        // Title - escape if contains special characters
136        let title = if fm.title.contains(':') || fm.title.contains('\n') || fm.title.contains('"') {
137            format!("title: {:?}", fm.title)
138        } else {
139            format!("title: {}", fm.title)
140        };
141        lines.push(title);
142
143        if let Some(date) = &fm.date {
144            lines.push(format!("date: {}", date.format("%Y-%m-%d")));
145        }
146
147        if fm.draft {
148            lines.push("draft: true".to_string());
149        }
150
151        if !fm.tags.is_empty() {
152            lines.push("tags:".to_string());
153            for tag in &fm.tags {
154                lines.push(format!("  - {}", tag));
155            }
156        }
157
158        if !fm.categories.is_empty() {
159            lines.push("categories:".to_string());
160            for cat in &fm.categories {
161                lines.push(format!("  - {}", cat));
162            }
163        }
164
165        lines.push("---".to_string());
166        lines.push(String::new()); // Empty line after frontmatter
167
168        lines.join("\n")
169    }
170}
171
172#[async_trait(?Send)]
173impl PlatformAdapter for AstroAdapter {
174    fn id(&self) -> &'static str {
175        ID
176    }
177
178    fn name(&self) -> &'static str {
179        "Astro Content Collection"
180    }
181
182    fn required_format(&self) -> OutputFormat {
183        OutputFormat::Html // We still render HTML first, then convert to Markdown
184    }
185
186    fn asset_strategy(&self) -> AssetStrategy {
187        self.asset_strategy
188    }
189
190    fn validate_config(&self, _config: &typub_config::PlatformConfig) -> Result<()> {
191        Ok(())
192    }
193
194    fn render_config(&self, _content_info: &ContentInfo) -> RenderConfig {
195        render_config_for_png_math(self.asset_strategy, self.math_rendering)
196    }
197
198    async fn specialize_payload(
199        &self,
200        mut elements: Document,
201        ctx: &dyn AdapterContext,
202    ) -> Result<AdapterPayload> {
203        let content_info = ctx.content_info();
204        let slug = content_info
205            .get_platform_str("slug")
206            .unwrap_or_else(|| content_info.slug.clone());
207
208        // Handle PNG math rendering based on asset strategy
209        (elements, _) = convert_png_math_for_strategy(
210            elements,
211            self.asset_strategy,
212            self.math_rendering,
213            &content_info.path,
214            &content_info.slug,
215        )?;
216
217        // For strategies that don't need deferred upload, resolve assets immediately
218        if !self.asset_strategy.requires_deferred_upload() {
219            let url_map_raw = self.build_asset_map(content_info).await?;
220            debug!(count = url_map_raw.len(), "Processed assets");
221            let url_map = build_image_marker_url_map(&content_info.path, &url_map_raw);
222            resolve_asset_urls(&mut elements, &url_map);
223        }
224
225        // Build deferred assets using helper
226        let deferred = prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path);
227
228        // Build frontmatter from content info
229        let frontmatter = FrontMatter {
230            title: content_info.title.clone(),
231            date: Some(Utc::now()),
232            draft: false,
233            tags: content_info.tags.clone(),
234            categories: content_info.categories.clone(),
235        };
236
237        Ok(AdapterPayload::new(
238            AstroPayload {
239                slug,
240                markdown: String::new(),
241                frontmatter,
242            },
243            content_info.clone(),
244            deferred,
245            elements,
246        ))
247    }
248
249    async fn materialize_payload(
250        &self,
251        mut payload: AdapterPayload,
252        ctx: &dyn AdapterContext,
253    ) -> Result<AdapterPayload> {
254        // Dry-run mode: mock asset uploads by copying to temp dir
255        if ctx.is_dry_run() {
256            mock_materialize_and_resolve_urls(&mut payload, ctx)?;
257            return Ok(payload);
258        }
259
260        materialize_and_resolve_urls(&mut payload, ctx).await?;
261        Ok(payload)
262    }
263
264    async fn serialize_payload(
265        &self,
266        mut payload: AdapterPayload,
267        _ctx: &dyn AdapterContext,
268    ) -> Result<AdapterPayload> {
269        ensure_no_unresolved_image_markers(self.id(), self.asset_strategy, &payload.document)?;
270
271        // Convert HTML elements to Markdown
272        let md_options = MarkdownRenderOptions {
273            math_delimiters: MathDelimiters::Dollar,
274            use_inline_html_for_sized_images: true,
275            ..Default::default()
276        };
277        let md = document_to_markdown_with_options(&payload.document, &md_options)?;
278
279        let inner = payload
280            .downcast_mut::<AstroPayload>()
281            .ok_or_else(|| anyhow::anyhow!("Invalid Astro publish payload type"))?;
282        inner.markdown = md;
283        Ok(payload)
284    }
285
286    async fn publish_payload(
287        &self,
288        payload: AdapterPayload,
289        _ctx: &dyn AdapterContext,
290    ) -> Result<PublishResult> {
291        let payload = downcast_payload::<AstroPayload>(payload, "Astro")?;
292
293        // Create output directory
294        let dest_dir = self.output_dir.join(&payload.slug);
295        std::fs::create_dir_all(&dest_dir)?;
296
297        // Write Markdown file with frontmatter
298        let frontmatter_str = Self::format_frontmatter(&payload.frontmatter);
299        let content = format!("{}{}", frontmatter_str, payload.markdown);
300        let md_path = dest_dir.join("index.md");
301        std::fs::write(&md_path, &content)?;
302
303        Ok(PublishResult {
304            url: Some(format!(
305                "file://{}",
306                md_path.to_string_lossy().replace('\\', "/")
307            )),
308            platform_id: Some(payload.slug),
309            published_at: Utc::now(),
310        })
311    }
312
313    fn build_preview(
314        &self,
315        _title: &str,
316        elements: Document,
317        ctx: &dyn AdapterContext,
318    ) -> Result<PathBuf> {
319        let content_info = ctx.content_info();
320
321        // Preview as rendered HTML (not Markdown) for browser viewing
322        build_unified_preview(
323            &elements,
324            content_info,
325            ID,
326            "Astro",
327            None,
328            Some(&PlatformBranding::new("#ffffff", "#ff5d01")),
329        )
330    }
331
332    async fn check_status(&self, slug: &str) -> Result<bool> {
333        let dest_path = self.output_dir.join(slug).join("index.md");
334        Ok(dest_path.exists())
335    }
336}
337
338#[cfg(test)]
339#[allow(clippy::expect_used)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn test_adapter_creation() {
345        let adapter = AstroAdapter::new_for_test();
346        assert_eq!(adapter.id(), "astro");
347        assert_eq!(adapter.name(), "Astro Content Collection");
348        assert_eq!(adapter.asset_strategy(), AssetStrategy::Copy);
349    }
350
351    #[test]
352    fn test_adapter_with_external_strategy() {
353        let adapter = AstroAdapter::new_for_test_with(
354            PathBuf::from("/tmp/astro"),
355            AssetStrategy::External,
356            MathRendering::Svg,
357        );
358        assert_eq!(adapter.asset_strategy(), AssetStrategy::External);
359        assert!(adapter.asset_strategy().requires_deferred_upload());
360    }
361
362    #[test]
363    fn test_format_frontmatter() {
364        let fm = FrontMatter {
365            title: "Hello World".to_string(),
366            date: Some(
367                DateTime::parse_from_rfc3339("2026-02-17T12:00:00Z")
368                    .expect("parse date")
369                    .with_timezone(&Utc),
370            ),
371            draft: false,
372            tags: vec!["rust".to_string(), "typst".to_string()],
373            categories: vec!["programming".to_string()],
374        };
375
376        let yaml = AstroAdapter::format_frontmatter(&fm);
377        assert!(yaml.starts_with("---\n"));
378        assert!(yaml.contains("title:"));
379        assert!(yaml.contains("Hello World"));
380        assert!(yaml.contains("date: 2026-02-17"));
381        assert!(yaml.contains("tags:"));
382        assert!(yaml.contains("categories:"));
383        // Should end with closing --- and newline for markdown content
384        assert!(yaml.contains("---\n"));
385        // Check there's a blank line after frontmatter
386        let parts: Vec<&str> = yaml.split("---").collect();
387        assert!(parts.len() >= 3, "Should have opening and closing ---");
388    }
389
390    #[test]
391    fn test_format_frontmatter_draft() {
392        let fm = FrontMatter {
393            title: "Draft Post".to_string(),
394            date: None,
395            draft: true,
396            tags: vec![],
397            categories: vec![],
398        };
399
400        let yaml = AstroAdapter::format_frontmatter(&fm);
401        assert!(yaml.contains("draft: true"));
402        assert!(!yaml.contains("tags:"));
403        assert!(!yaml.contains("categories:"));
404    }
405}