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 (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 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 build_unified_preview(
377 &elements,
378 content_info,
379 ID,
380 "WordPress",
381 None, 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}