Skip to main content

typub_adapter_static/
adapter.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use anyhow::Result;
5use async_trait::async_trait;
6use chrono::Utc;
7
8use typub_adapters_core::{
9    AdapterContext, AdapterPayload, ContentInfo, OutputFormat, PlatformAdapter, PlatformBranding,
10    RenderConfig, build_unified_preview, convert_png_math_for_strategy, debug, downcast_payload,
11    ensure_no_unresolved_image_markers, materialize_and_resolve_urls,
12    mock_materialize_and_resolve_urls, prepare_deferred_assets, render_config_for_png_math,
13    resolve_asset_urls,
14};
15use typub_config::Config;
16use typub_core::{AssetStrategy, MathRendering};
17use typub_html::{SerializeOptions, document_to_html_with_options};
18use typub_ir::Document;
19use typub_storage::{PublishResult, build_image_marker_url_map, to_data_uri};
20use typub_theme::{Theme, ThemeRegistry, apply_theme_full_document, load_theme};
21
22use crate::config::{CAPABILITY, ID, resolve_math_rendering, resolve_strategy};
23
24pub struct StaticAdapter {
25    output_dir: PathBuf,
26    fallback_theme: Theme,
27    theme_registry: ThemeRegistry,
28    asset_strategy: AssetStrategy,
29    math_rendering: MathRendering,
30}
31
32#[derive(Debug)]
33pub struct StaticPayload {
34    pub slug: String,
35    pub themed_html: String,
36}
37
38impl StaticAdapter {
39    pub fn new(config: &Config) -> Result<Self> {
40        let platform_config = config.get_platform(ID);
41
42        let output_dir = platform_config
43            .and_then(|c| c.get_str("output_dir"))
44            .map(PathBuf::from)
45            .unwrap_or_else(|| config.output_dir.join(ID));
46
47        let registry = ThemeRegistry::new()?;
48        let fallback_theme = registry.get_or_default("minimal")?.clone();
49
50        let asset_strategy = resolve_strategy(platform_config)?;
51        let math_rendering = resolve_math_rendering(platform_config)?;
52
53        Ok(Self {
54            output_dir,
55            fallback_theme,
56            theme_registry: registry,
57            asset_strategy,
58            math_rendering,
59        })
60    }
61
62    #[cfg(test)]
63    #[allow(clippy::expect_used)]
64    pub fn new_for_test() -> Self {
65        Self::new_for_test_with(
66            PathBuf::from("/tmp/static"),
67            AssetStrategy::Copy,
68            MathRendering::Svg,
69        )
70    }
71
72    #[cfg(test)]
73    #[allow(clippy::expect_used)]
74    pub fn new_for_test_with(
75        output_dir: PathBuf,
76        asset_strategy: AssetStrategy,
77        math_rendering: MathRendering,
78    ) -> Self {
79        let registry = ThemeRegistry::new().expect("registry");
80        let fallback_theme = registry.get_or_default("minimal").expect("theme").clone();
81        Self {
82            output_dir,
83            fallback_theme,
84            theme_registry: registry,
85            asset_strategy,
86            math_rendering,
87        }
88    }
89
90    async fn build_asset_map(
91        &self,
92        content_info: &ContentInfo,
93    ) -> Result<HashMap<PathBuf, String>> {
94        let mut url_map = HashMap::new();
95
96        match self.asset_strategy {
97            AssetStrategy::Copy => {
98                let dest_dir = self.output_dir.join(&content_info.slug).join("assets");
99                std::fs::create_dir_all(&dest_dir)?;
100
101                for asset_path in &content_info.assets {
102                    let file_name = asset_path.file_name().ok_or_else(|| {
103                        anyhow::anyhow!("Invalid asset filename: {}", asset_path.display())
104                    })?;
105                    let dest_path = dest_dir.join(file_name);
106                    std::fs::copy(asset_path, &dest_path)?;
107                    url_map.insert(
108                        asset_path.clone(),
109                        format!("./assets/{}", file_name.to_string_lossy()),
110                    );
111                }
112            }
113            AssetStrategy::Embed => {
114                for asset_path in &content_info.assets {
115                    let data = std::fs::read(asset_path)?;
116                    let data_uri = to_data_uri(&data, asset_path);
117                    url_map.insert(asset_path.clone(), data_uri);
118                }
119            }
120            AssetStrategy::External | AssetStrategy::Upload => {
121                // These strategies require deferred upload, handled in materialize_payload
122                // Nothing to do here - build_asset_map is only called for non-deferred strategies
123            }
124        }
125
126        Ok(url_map)
127    }
128}
129
130#[async_trait(?Send)]
131impl PlatformAdapter for StaticAdapter {
132    fn id(&self) -> &'static str {
133        ID
134    }
135
136    fn name(&self) -> &'static str {
137        "Static Site"
138    }
139
140    fn required_format(&self) -> OutputFormat {
141        OutputFormat::Html
142    }
143
144    fn asset_strategy(&self) -> AssetStrategy {
145        self.asset_strategy
146    }
147
148    fn validate_config(&self, _config: &typub_config::PlatformConfig) -> Result<()> {
149        Ok(())
150    }
151
152    fn render_config(&self, _content_info: &ContentInfo) -> RenderConfig {
153        render_config_for_png_math(self.asset_strategy, self.math_rendering)
154    }
155
156    async fn specialize_payload(
157        &self,
158        mut elements: Document,
159        ctx: &dyn AdapterContext,
160    ) -> Result<AdapterPayload> {
161        let content_info = ctx.content_info();
162        let slug = content_info
163            .get_platform_str("slug")
164            .unwrap_or_else(|| content_info.slug.clone());
165
166        // Handle PNG math rendering based on asset strategy.
167        (elements, _) = convert_png_math_for_strategy(
168            elements,
169            self.asset_strategy,
170            self.math_rendering,
171            &content_info.path,
172            &content_info.slug,
173        )?;
174
175        // For strategies that don't need deferred upload, resolve assets immediately
176        if !self.asset_strategy.requires_deferred_upload() {
177            let url_map_raw = self.build_asset_map(content_info).await?;
178            debug!(count = url_map_raw.len(), "Processed assets");
179            let url_map = build_image_marker_url_map(&content_info.path, &url_map_raw);
180            resolve_asset_urls(&mut elements, &url_map);
181        }
182
183        // Build deferred assets using helper (handles both deferred and immediate strategies)
184        let deferred = prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path);
185
186        Ok(AdapterPayload::new(
187            StaticPayload {
188                slug,
189                themed_html: String::new(),
190            },
191            content_info.clone(),
192            deferred,
193            elements,
194        ))
195    }
196
197    async fn materialize_payload(
198        &self,
199        mut payload: AdapterPayload,
200        ctx: &dyn AdapterContext,
201    ) -> Result<AdapterPayload> {
202        // Dry-run mode: generate mock URLs without file I/O
203        if ctx.is_dry_run() {
204            mock_materialize_and_resolve_urls(&mut payload, ctx)?;
205            return Ok(payload);
206        }
207
208        materialize_and_resolve_urls(&mut payload, ctx).await?;
209        Ok(payload)
210    }
211
212    async fn serialize_payload(
213        &self,
214        mut payload: AdapterPayload,
215        ctx: &dyn AdapterContext,
216    ) -> Result<AdapterPayload> {
217        ensure_no_unresolved_image_markers(self.id(), self.asset_strategy, &payload.document)?;
218
219        let serialize_options = SerializeOptions {
220            use_code_highlight: CAPABILITY.code_highlight,
221            ..Default::default()
222        };
223        let body_html = document_to_html_with_options(&payload.document, &serialize_options);
224        let theme = load_theme(
225            ctx.theme_id(),
226            None,
227            &self.theme_registry,
228            &self.fallback_theme,
229        );
230        let themed_html =
231            apply_theme_full_document(&body_html, &theme, &payload.content_info.title, false)?;
232
233        let inner = payload
234            .downcast_mut::<StaticPayload>()
235            .ok_or_else(|| anyhow::anyhow!("Invalid Static publish payload type"))?;
236        inner.themed_html = themed_html;
237        Ok(payload)
238    }
239
240    async fn publish_payload(
241        &self,
242        payload: AdapterPayload,
243        _ctx: &dyn AdapterContext,
244    ) -> Result<PublishResult> {
245        let payload = downcast_payload::<StaticPayload>(payload, "Static")?;
246        let dest_dir = self.output_dir.join(&payload.slug);
247        std::fs::create_dir_all(&dest_dir)?;
248        let html_path = dest_dir.join("index.html");
249        std::fs::write(&html_path, &payload.themed_html)?;
250
251        Ok(PublishResult {
252            // Convert path separators to forward slashes for URL compatibility
253            url: Some(format!(
254                "file://{}",
255                html_path.to_string_lossy().replace('\\', "/")
256            )),
257            platform_id: Some(payload.slug),
258            published_at: Utc::now(),
259        })
260    }
261
262    fn build_preview(
263        &self,
264        _title: &str,
265        elements: Document,
266        ctx: &dyn AdapterContext,
267    ) -> Result<PathBuf> {
268        let content_info = ctx.content_info();
269        let theme = load_theme(
270            ctx.theme_id(),
271            None,
272            &self.theme_registry,
273            &self.fallback_theme,
274        );
275
276        // Use unified preview builder with MathJax support for LaTeX
277        build_unified_preview(
278            &elements,
279            content_info,
280            ID,
281            "Static Site",
282            Some(&theme.css),
283            Some(&PlatformBranding::new("#ffffff", "#4a5568")),
284        )
285    }
286
287    async fn check_status(&self, slug: &str) -> Result<bool> {
288        let dest_path = self.output_dir.join(slug).join("index.html");
289        Ok(dest_path.exists())
290    }
291}
292
293#[cfg(test)]
294#[allow(clippy::expect_used)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_adapter_creation() {
300        let adapter = StaticAdapter::new_for_test();
301        assert_eq!(adapter.id(), "static");
302        assert_eq!(adapter.name(), "Static Site");
303        assert_eq!(adapter.asset_strategy(), AssetStrategy::Copy);
304    }
305
306    #[test]
307    fn test_adapter_with_external_strategy() {
308        let adapter = StaticAdapter::new_for_test_with(
309            PathBuf::from("/tmp/static"),
310            AssetStrategy::External,
311            MathRendering::Svg,
312        );
313        assert_eq!(adapter.asset_strategy(), AssetStrategy::External);
314        assert!(adapter.asset_strategy().requires_deferred_upload());
315    }
316}