Skip to main content

typub_adapter_wordpress/
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, debug, downcast_payload,
10    ensure_no_unresolved_image_markers, image_utils, info, mock_materialize_and_resolve_urls,
11    prepare_deferred_assets, render_config_for_png_math, resolve_asset_urls, warn,
12};
13use typub_config::{Config, PlatformConfig};
14use typub_core::{AssetStrategy, MathRendering};
15use typub_html::{SerializeOptions, document_to_html_with_options};
16use typub_ir::Document;
17use typub_storage::{PublishResult, build_resolved_url_map, to_data_uri};
18
19use crate::client::WordPressClient;
20use crate::config::{CAPABILITY, resolve_asset_strategy, resolve_math_rendering};
21use crate::model::ID;
22use crate::types::WordPressPayload;
23
24pub struct WordPressAdapter {
25    client: Client,
26    base_url: String,
27    api_key: Option<String>,
28    asset_strategy: AssetStrategy,
29    math_rendering: MathRendering,
30}
31
32impl WordPressAdapter {
33    pub fn new(config: &Config) -> Result<Self> {
34        let platform_config = config.get_platform(ID);
35
36        let base_url = platform_config
37            .and_then(|c| c.get_str("base_url"))
38            .unwrap_or_else(|| "https://example.com".to_string())
39            .trim_end_matches('/')
40            .to_string();
41
42        let api_key = platform_config
43            .and_then(|c| c.get_str("api_key"))
44            .or_else(|| std::env::var("WORDPRESS_API_KEY").ok());
45
46        let asset_strategy = resolve_asset_strategy(platform_config)?;
47        let math_rendering = resolve_math_rendering(platform_config)?;
48
49        Ok(Self {
50            client: Client::new(),
51            base_url,
52            api_key,
53            asset_strategy,
54            math_rendering,
55        })
56    }
57
58    fn client(&self) -> WordPressClient<'_> {
59        WordPressClient::new(&self.client, &self.base_url, self.api_key.as_deref())
60    }
61
62    async fn build_asset_map(
63        &self,
64        content_info: &ContentInfo,
65    ) -> Result<HashMap<PathBuf, String>> {
66        let mut url_map = HashMap::new();
67        for asset_path in &content_info.assets {
68            let absolute_path = if asset_path.is_absolute() {
69                asset_path.clone()
70            } else {
71                content_info.path.join(asset_path)
72            };
73            let url = match self.asset_strategy {
74                AssetStrategy::Upload => self.client().upload_media(&absolute_path).await?,
75                AssetStrategy::Embed => {
76                    let data = std::fs::read(&absolute_path).with_context(|| {
77                        format!(
78                            "Failed to read local asset for wordpress: {}",
79                            absolute_path.display()
80                        )
81                    })?;
82                    to_data_uri(&data, &absolute_path)
83                }
84                AssetStrategy::Copy | AssetStrategy::External => {
85                    if let Ok(rel) = asset_path.strip_prefix(&content_info.path) {
86                        rel.to_string_lossy().replace('\\', "/")
87                    } else if asset_path.is_relative() {
88                        asset_path.to_string_lossy().replace('\\', "/")
89                    } else {
90                        absolute_path.to_string_lossy().replace('\\', "/")
91                    }
92                }
93            };
94            url_map.insert(asset_path.clone(), url);
95        }
96        Ok(url_map)
97    }
98
99    async fn upload_assets_impl(
100        &self,
101        pending: &typub_storage::PendingAssetList,
102    ) -> Result<std::collections::HashMap<usize, String>> {
103        let mut url_map = std::collections::HashMap::new();
104        for asset in &pending.assets {
105            let url = self.client().upload_media(&asset.local_path).await?;
106            url_map.insert(asset.index, url);
107        }
108        Ok(url_map)
109    }
110
111    #[cfg(test)]
112    pub(crate) fn new_for_test() -> WordPressAdapter {
113        WordPressAdapter::new_for_test_with(
114            "http://localhost",
115            Some("token".to_string()),
116            AssetStrategy::Upload,
117            MathRendering::Svg,
118        )
119    }
120
121    #[cfg(test)]
122    pub(crate) fn new_for_test_with(
123        base_url: &str,
124        api_key: Option<String>,
125        asset_strategy: AssetStrategy,
126        math_rendering: MathRendering,
127    ) -> WordPressAdapter {
128        WordPressAdapter {
129            client: Client::new(),
130            base_url: base_url.to_string(),
131            api_key,
132            asset_strategy,
133            math_rendering,
134        }
135    }
136}
137
138#[async_trait(?Send)]
139impl PlatformAdapter for WordPressAdapter {
140    fn id(&self) -> &'static str {
141        ID
142    }
143
144    fn name(&self) -> &'static str {
145        "WordPress"
146    }
147
148    fn required_format(&self) -> OutputFormat {
149        OutputFormat::Html
150    }
151
152    fn asset_strategy(&self) -> AssetStrategy {
153        self.asset_strategy
154    }
155
156    fn validate_config(&self, config: &PlatformConfig) -> Result<()> {
157        let base_url = config.get_str("base_url").unwrap_or_default();
158        if base_url.trim().is_empty() {
159            anyhow::bail!("wordpress.base_url is required");
160        }
161
162        let api_key = config.get_str("api_key").unwrap_or_default();
163        if api_key.trim().is_empty() && self.api_key.is_none() {
164            anyhow::bail!(
165                "WORDPRESS_API_KEY not set (configure wordpress.api_key or set WORDPRESS_API_KEY env var)"
166            );
167        }
168
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 slug = content_info
187            .get_platform_str("slug")
188            .unwrap_or_else(|| content_info.slug.clone());
189        let tags = ctx.normalize_terms(&content_info.tags);
190        let categories = ctx.normalize_terms(&content_info.categories);
191
192        // Handle PNG math rendering based on asset strategy.
193        // Per [[WI-2026-02-17-002]].
194        (elements, _) = convert_png_math_for_strategy(
195            elements,
196            self.asset_strategy,
197            self.math_rendering,
198            &content_info.path,
199            &content_info.slug,
200        )?;
201
202        if !self.asset_strategy.requires_deferred_upload() {
203            let asset_map = self.build_asset_map(content_info).await?;
204            let url_map = image_utils::build_image_marker_url_map(&content_info.path, &asset_map);
205            resolve_asset_urls(&mut elements, &url_map);
206        }
207
208        let deferred = prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path);
209
210        Ok(AdapterPayload::new(
211            WordPressPayload {
212                title: content_info.title.clone(),
213                slug,
214                tags,
215                categories,
216                tag_ids: Vec::new(),
217                category_ids: Vec::new(),
218                final_body: None,
219                existing_id: ctx.get_platform_id(&content_info.slug, ID)?,
220            },
221            content_info.clone(),
222            deferred,
223            elements,
224        ))
225    }
226
227    async fn upload_assets(
228        &self,
229        pending: &typub_storage::PendingAssetList,
230    ) -> Result<std::collections::HashMap<usize, String>> {
231        self.upload_assets_impl(pending).await
232    }
233
234    async fn materialize_payload(
235        &self,
236        mut payload: AdapterPayload,
237        ctx: &dyn AdapterContext,
238    ) -> Result<AdapterPayload> {
239        // Dry-run mode: generate mock URLs without file I/O
240        if ctx.is_dry_run() {
241            mock_materialize_and_resolve_urls(&mut payload, ctx)?;
242            return Ok(payload);
243        }
244
245        if payload.assets.needs_materialize() {
246            match payload.assets.strategy {
247                AssetStrategy::Upload => {
248                    info!(
249                        "[1/2] Uploading {} assets to WordPress...",
250                        payload.assets.pending.assets.len()
251                    );
252                    let url_map = self.upload_assets_impl(&payload.assets.pending).await?;
253                    payload.assets.resolved = url_map;
254                    info!("[2/2] Assets uploaded");
255                }
256                AssetStrategy::External => {
257                    let _storage_config = ctx.storage_config().ok_or_else(|| {
258                        anyhow::anyhow!(
259                            "External asset strategy requires [storage] configuration. See RFC-0004."
260                        )
261                    })?;
262                    debug!("External asset strategy defers to pipeline for StatusTracker access");
263                }
264                _ => {}
265            }
266
267            if !payload.assets.resolved.is_empty() {
268                let url_map = build_resolved_url_map(&payload.assets, &payload.content_info.path);
269                resolve_asset_urls(&mut payload.document, &url_map);
270            }
271        }
272
273        let inner = payload
274            .downcast_mut::<WordPressPayload>()
275            .ok_or_else(|| anyhow::anyhow!("Invalid WordPress payload type"))?;
276        inner.tag_ids = self.client().resolve_tag_ids(&inner.tags).await?;
277        inner.category_ids = self
278            .client()
279            .resolve_category_ids(&inner.categories)
280            .await?;
281
282        Ok(payload)
283    }
284
285    async fn serialize_payload(
286        &self,
287        mut payload: AdapterPayload,
288        _ctx: &dyn AdapterContext,
289    ) -> Result<AdapterPayload> {
290        ensure_no_unresolved_image_markers(self.id(), self.asset_strategy, &payload.document)?;
291        let serialize_options = SerializeOptions {
292            use_code_highlight: CAPABILITY.code_highlight,
293            ..Default::default()
294        };
295        let final_body = document_to_html_with_options(&payload.document, &serialize_options);
296
297        let inner = payload
298            .downcast_mut::<WordPressPayload>()
299            .ok_or_else(|| anyhow::anyhow!("Invalid WordPress payload type"))?;
300        inner.final_body = Some(final_body);
301
302        Ok(payload)
303    }
304
305    async fn publish_payload(
306        &self,
307        payload: AdapterPayload,
308        ctx: &dyn AdapterContext,
309    ) -> Result<PublishResult> {
310        let slug = payload.content_info.slug.clone();
311        let payload = downcast_payload::<WordPressPayload>(payload, "WordPress")?;
312
313        let final_body = payload.final_body.as_deref().unwrap_or("");
314
315        let status = if ctx.published() { "publish" } else { "draft" };
316        debug!("WordPress: resolved status='{}' for '{}'", status, slug);
317
318        let update_target_id = if let Some(existing_id) = payload.existing_id.as_deref() {
319            match self.client().find_post_by_id(existing_id).await? {
320                Some((id, _)) => Some(id),
321                None => {
322                    warn!(
323                        "Cached WordPress post id '{}' for slug '{}' not found; falling back to slug lookup",
324                        existing_id, payload.slug
325                    );
326                    None
327                }
328            }
329        } else {
330            None
331        };
332
333        let update_target_id = match update_target_id {
334            Some(id) => Some(id),
335            None => self
336                .client()
337                .find_post_by_slug(&payload.slug)
338                .await?
339                .map(|(id, _url)| id),
340        };
341
342        let (post_id, url) = self
343            .client()
344            .upsert_post(
345                update_target_id.as_deref(),
346                &payload.title,
347                &payload.slug,
348                final_body,
349                &payload.tag_ids,
350                &payload.category_ids,
351                status,
352            )
353            .await?;
354
355        Ok(PublishResult {
356            url: Some(url),
357            platform_id: Some(post_id),
358            published_at: Utc::now(),
359        })
360    }
361
362    fn build_preview(
363        &self,
364        _title: &str,
365        elements: Document,
366        ctx: &dyn AdapterContext,
367    ) -> Result<PathBuf> {
368        let content_info = ctx.content_info();
369        if self.asset_strategy == AssetStrategy::Upload {
370            warn!(
371                "WordPress preview uses local file:// image URLs; publish uploads media and rewrites to remote URLs."
372            );
373        }
374
375        // Use unified preview builder with MathJax support for LaTeX
376        build_unified_preview(
377            &elements,
378            content_info,
379            ID,
380            "WordPress",
381            None, // WordPress doesn't use theme CSS in preview
382            Some(&PlatformBranding::new("#ffffff", "#21759b")),
383        )
384    }
385
386    async fn check_status(&self, slug: &str) -> Result<bool> {
387        if self.api_key.is_none() {
388            return Ok(false);
389        }
390        Ok(self.client().find_post_by_slug(slug).await?.is_some())
391    }
392}