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 }
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 (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 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 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 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 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 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}