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 (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 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 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 let url_map = self.upload_assets(&payload.assets.pending).await?;
238 payload.assets.resolved = url_map;
239 }
240 AssetStrategy::External => {
241 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_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}