1use 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 }
127 }
128
129 Ok(url_map)
130 }
131
132 fn format_frontmatter(fm: &FrontMatter) -> String {
133 let mut lines = vec!["---".to_string()];
134
135 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()); 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 }
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 (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 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 let deferred = prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path);
227
228 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 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 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 let dest_dir = self.output_dir.join(&payload.slug);
295 std::fs::create_dir_all(&dest_dir)?;
296
297 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 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 assert!(yaml.contains("---\n"));
385 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}