Skip to main content

typub_adapter_ghost/
adapter.rs

1use anyhow::{Context, Result};
2use async_trait::async_trait;
3use chrono::Utc;
4use reqwest::Client;
5use std::collections::HashMap;
6use std::path::PathBuf;
7use typub_adapters_core::{
8    AdapterContext, AdapterPayload, ContentInfo, OutputFormat, PlatformAdapter, PlatformBranding,
9    RenderConfig, build_unified_preview, convert_png_math_for_strategy, downcast_payload,
10    mock_materialize_and_resolve_urls, prepare_deferred_assets, render_config_for_png_math,
11    resolve_asset_urls, warn,
12};
13use typub_assets_ast::ensure_no_unresolved_image_markers;
14use typub_config::{Config, PlatformConfig};
15use typub_core::{AssetStrategy, MathRendering};
16use typub_html::{SerializeOptions, document_to_html_with_options};
17use typub_ir::Document;
18use typub_storage::{
19    PendingAssetList, PublishResult, build_image_marker_url_map, build_resolved_url_map,
20    to_data_uri,
21};
22
23use crate::client::GhostClient;
24use crate::config::{resolve_asset_strategy, resolve_math_rendering};
25use crate::model::{GhostPayload, ID};
26
27pub struct GhostAdapter {
28    client: Client,
29    base_url: String,
30    api_key: Option<String>,
31    asset_strategy: AssetStrategy,
32    math_rendering: MathRendering,
33}
34
35impl GhostAdapter {
36    pub fn new(config: &Config) -> Result<Self> {
37        let platform_config = config.get_platform(ID);
38        let base_url = platform_config
39            .and_then(|c| c.get_str("base_url"))
40            .or_else(|| platform_config.and_then(|c| c.get_str("api_base")))
41            .unwrap_or_else(|| "http://localhost:2368".to_string())
42            .trim_end_matches('/')
43            .to_string();
44        let api_key = platform_config
45            .and_then(|c| c.get_str("api_key"))
46            .or_else(|| std::env::var("GHOST_ADMIN_API_KEY").ok());
47
48        let asset_strategy = resolve_asset_strategy(platform_config)?;
49        let math_rendering = resolve_math_rendering(platform_config)?;
50
51        Ok(Self {
52            client: Client::new(),
53            base_url,
54            api_key,
55            asset_strategy,
56            math_rendering,
57        })
58    }
59
60    #[cfg(test)]
61    pub(crate) fn new_for_test() -> GhostAdapter {
62        GhostAdapter::new_for_test_with(
63            "http://localhost:2368",
64            Some("ghost_api_key".to_string()),
65            AssetStrategy::Upload,
66            MathRendering::Svg,
67        )
68    }
69
70    #[cfg(test)]
71    pub(crate) fn new_for_test_with(
72        base_url: &str,
73        api_key: Option<String>,
74        asset_strategy: AssetStrategy,
75        math_rendering: MathRendering,
76    ) -> GhostAdapter {
77        GhostAdapter {
78            client: Client::new(),
79            base_url: base_url.to_string(),
80            api_key,
81            asset_strategy,
82            math_rendering,
83        }
84    }
85
86    fn client(&self, published: bool) -> GhostClient<'_> {
87        GhostClient::new(
88            &self.client,
89            &self.base_url,
90            self.api_key.as_deref(),
91            published,
92        )
93    }
94
95    async fn build_asset_map(
96        &self,
97        content_info: &ContentInfo,
98    ) -> Result<HashMap<PathBuf, String>> {
99        let mut map = HashMap::new();
100
101        for asset in &content_info.assets {
102            let full = if asset.is_absolute() {
103                asset.clone()
104            } else {
105                content_info.path.join(asset)
106            };
107            let mapped = match self.asset_strategy {
108                AssetStrategy::Embed => {
109                    let data = std::fs::read(&full).with_context(|| {
110                        format!("Failed to read local asset for ghost: {}", full.display())
111                    })?;
112                    to_data_uri(&data, &full)
113                }
114                _ => {
115                    if let Ok(rel) = asset.strip_prefix(&content_info.path) {
116                        rel.to_string_lossy().replace('\\', "/")
117                    } else if asset.is_relative() {
118                        asset.to_string_lossy().replace('\\', "/")
119                    } else {
120                        full.to_string_lossy().replace('\\', "/")
121                    }
122                }
123            };
124            map.insert(asset.clone(), mapped);
125        }
126        Ok(map)
127    }
128
129    async fn upload_assets(&self, pending: &PendingAssetList) -> Result<HashMap<usize, String>> {
130        let client = self.client(false);
131        let mut url_map = HashMap::new();
132        for asset in &pending.assets {
133            let url = client.upload_image(&asset.local_path).await?;
134            url_map.insert(asset.index, url);
135        }
136        Ok(url_map)
137    }
138
139    fn html_to_lexical(html: &str) -> Result<String> {
140        let escaped_html = serde_json::to_string(html)
141            .context("Failed to encode HTML for Ghost Lexical payload")?;
142        Ok(format!(
143            r#"{{"root":{{"children":[{{"type":"html","version":1,"html":{}}}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}}}"#,
144            escaped_html
145        ))
146    }
147}
148
149#[async_trait(?Send)]
150impl PlatformAdapter for GhostAdapter {
151    fn id(&self) -> &'static str {
152        ID
153    }
154
155    fn name(&self) -> &'static str {
156        "Ghost"
157    }
158
159    fn required_format(&self) -> OutputFormat {
160        OutputFormat::Html
161    }
162
163    fn asset_strategy(&self) -> AssetStrategy {
164        self.asset_strategy
165    }
166
167    fn validate_config(&self, _config: &PlatformConfig) -> Result<()> {
168        let _ = self.client(true).auth_key()?;
169        Ok(())
170    }
171
172    fn supports_shared_link_rewrite(&self) -> bool {
173        true
174    }
175
176    fn render_config(&self, _content_info: &ContentInfo) -> RenderConfig {
177        render_config_for_png_math(self.asset_strategy, self.math_rendering)
178    }
179
180    async fn specialize_payload(
181        &self,
182        mut elements: Document,
183        ctx: &dyn AdapterContext,
184    ) -> Result<AdapterPayload> {
185        let content_info = ctx.content_info();
186        let normalized_tags = ctx.normalize_terms(&content_info.tags);
187        let tags: Vec<String> = normalized_tags.into_iter().take(10).collect();
188        let existing_id = ctx.get_platform_id(&content_info.slug, ID)?;
189
190        // Handle PNG math rendering based on asset strategy.
191        // Per [[WI-2026-02-17-002]].
192        (elements, _) = convert_png_math_for_strategy(
193            elements,
194            self.asset_strategy,
195            self.math_rendering,
196            &content_info.path,
197            &content_info.slug,
198        )?;
199
200        if !self.asset_strategy.requires_deferred_upload() {
201            let asset_map = self.build_asset_map(content_info).await?;
202            let url_map = build_image_marker_url_map(&content_info.path, &asset_map);
203            resolve_asset_urls(&mut elements, &url_map);
204        }
205
206        // Use helper for deferred assets (handles both deferred and immediate strategies)
207        let deferred = prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path);
208
209        Ok(AdapterPayload::new(
210            GhostPayload {
211                title: content_info.title.clone(),
212                lexical: None,
213                tags,
214                existing_id,
215            },
216            content_info.clone(),
217            deferred,
218            elements,
219        ))
220    }
221
222    async fn materialize_payload(
223        &self,
224        mut payload: AdapterPayload,
225        ctx: &dyn AdapterContext,
226    ) -> Result<AdapterPayload> {
227        // Dry-run mode: generate mock URLs without file I/O
228        if ctx.is_dry_run() {
229            mock_materialize_and_resolve_urls(&mut payload, ctx)?;
230            return Ok(payload);
231        }
232
233        if payload.assets.needs_materialize() {
234            match payload.assets.strategy {
235                AssetStrategy::Upload => {
236                    // Ghost uses its native upload API, not S3
237                    let url_map = self.upload_assets(&payload.assets.pending).await?;
238                    payload.assets.resolved = url_map;
239                }
240                AssetStrategy::External => {
241                    // External uses S3 storage with standard helper
242                    let storage_config = ctx.storage_config().ok_or_else(|| {
243                        anyhow::anyhow!(
244                            "External asset strategy requires [storage] configuration. See RFC-0004."
245                        )
246                    })?;
247                    typub_storage::materialize_external_assets(&mut payload.assets, storage_config)
248                        .await?;
249                }
250                _ => {}
251            }
252
253            if !payload.assets.resolved.is_empty() {
254                let url_map = build_resolved_url_map(&payload.assets, &payload.content_info.path);
255                resolve_asset_urls(&mut payload.document, &url_map);
256            }
257        }
258
259        Ok(payload)
260    }
261
262    async fn serialize_payload(
263        &self,
264        mut payload: AdapterPayload,
265        _ctx: &dyn AdapterContext,
266    ) -> Result<AdapterPayload> {
267        // Ensure all local assets have resolved publish URLs before serialize
268        // Per [[RFC-0009:C-ASSET-REFERENCE]], assets referenced by ID must have resolved variants
269        ensure_no_unresolved_image_markers(ID, self.asset_strategy, &payload.document)
270            .context("[{ID}] Serialize stage validation")?;
271
272        let serialize_options = SerializeOptions {
273            use_code_highlight: crate::config::CAPABILITY.code_highlight,
274            ..Default::default()
275        };
276        let html = document_to_html_with_options(&payload.document, &serialize_options);
277        let lexical = Self::html_to_lexical(&html)?;
278
279        let inner = payload
280            .downcast_mut::<GhostPayload>()
281            .ok_or_else(|| anyhow::anyhow!("Invalid Ghost payload type"))?;
282        inner.lexical = Some(lexical);
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::<GhostPayload>(payload, "Ghost")?;
292        let published = ctx.published();
293        let lexical = payload.lexical.as_deref().unwrap_or("");
294
295        let post = if let Some(id) = payload.existing_id {
296            match self.client(published).get_post(&id).await? {
297                Some(current) => {
298                    let updated_at = current
299                        .updated_at
300                        .as_ref()
301                        .ok_or_else(|| anyhow::anyhow!("Ghost post missing updated_at field"))?;
302
303                    match self
304                        .client(published)
305                        .update_post(&id, &payload.title, lexical, &payload.tags, updated_at)
306                        .await?
307                    {
308                        Some(updated) => updated,
309                        None => {
310                            warn!(
311                                "Ghost post id '{}' returned 404 during update; trying title lookup",
312                                id
313                            );
314                            self.client(published)
315                                .update_or_create_by_title(&payload.title, lexical, &payload.tags)
316                                .await?
317                        }
318                    }
319                }
320                None => {
321                    warn!(
322                        "Cached Ghost post id '{}' for '{}' is no longer valid; trying title lookup",
323                        id, payload.title
324                    );
325                    self.client(published)
326                        .update_or_create_by_title(&payload.title, lexical, &payload.tags)
327                        .await?
328                }
329            }
330        } else {
331            self.client(published)
332                .update_or_create_by_title(&payload.title, lexical, &payload.tags)
333                .await?
334        };
335
336        Ok(PublishResult {
337            url: Some(post.url),
338            platform_id: Some(post.id),
339            published_at: Utc::now(),
340        })
341    }
342
343    fn build_preview(
344        &self,
345        _title: &str,
346        elements: Document,
347        ctx: &dyn AdapterContext,
348    ) -> Result<PathBuf> {
349        let content_info = ctx.content_info();
350        build_unified_preview(
351            &elements,
352            content_info,
353            ID,
354            "Ghost",
355            None,
356            Some(&PlatformBranding::new("#ffffff", "#15171A")),
357        )
358    }
359
360    async fn check_status(&self, _slug: &str) -> Result<bool> {
361        Ok(false)
362    }
363}