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, MarkdownProcessingRules, MarkdownRenderOptions,
10 OutputFormat, PlatformAdapter, RenderConfig, convert_png_math_for_strategy, debug,
11 document_to_markdown_with_options, downcast_payload, info, materialize_and_resolve_urls,
12 mock_materialize_and_resolve_urls, prepare_deferred_assets, render_config_for_png_math,
13 resolve_asset_strategy_with_policy, resolve_asset_urls, warn, write_preview_file,
14};
15use typub_config::{Config, PlatformConfig};
16use typub_core::{AssetStrategy, MathDelimiters, MathRendering};
17use typub_html::{SerializeOptions, SerializeRule, document_to_html_with_options};
18use typub_ir::Document;
19use typub_passes::{FlattenSvgPass, PassCtx, run_passes};
20use typub_storage::{DeferredAssets, PublishResult, build_image_marker_url_map, to_data_uri};
21use typub_theme::{Theme, ThemeRegistry, apply_theme, load_theme};
22
23use crate::model::{BuiltinProfile, CopyFormat, CopyPastePayload};
24
25pub struct CopyPasteAdapter {
30 profile_id: &'static str,
31 profile_name: &'static str,
32 editor_url: &'static str,
33 format: CopyFormat,
34 serialize_rules: typub_html::SerializeRules,
36
37 output_dir: PathBuf,
38 fallback_theme: Theme,
40 profile_default_theme: Option<&'static str>,
42 theme_registry: ThemeRegistry,
44 asset_strategy: AssetStrategy,
45 math_delimiters: MathDelimiters,
47 math_rendering: MathRendering,
50 use_inline_html_for_sized_images: bool,
53 markdown_processing_rules: MarkdownProcessingRules,
57 tight_lists: bool,
60}
61
62impl CopyPasteAdapter {
63 pub fn from_profile(profile: &'static BuiltinProfile, config: &Config) -> Result<Self> {
65 let platform_config = config.get_platform(profile.id);
66 Self::build(
67 profile.id,
68 profile.name,
69 profile.editor_url,
70 profile.format,
71 profile.serialize_rules,
72 profile.default_theme,
73 profile.default_asset_strategy(),
74 profile.default_math_delimiters(),
75 profile.default_math_rendering(),
76 profile.use_inline_html_for_sized_images,
77 profile.markdown_processing_rules,
78 profile.tight_lists,
79 platform_config,
80 config,
81 )
82 }
83
84 #[cfg(test)]
85 pub fn new_for_test(profile_id: &str) -> Result<Self> {
86 let config = Config::default();
87 let profile = crate::find_profile(profile_id)
88 .ok_or_else(|| anyhow::anyhow!("Unknown copypaste profile: {}", profile_id))?;
89 Self::from_profile(profile, &config)
90 }
91
92 pub fn from_manual_config(
94 id: &'static str,
95 platform_config: &PlatformConfig,
96 config: &Config,
97 ) -> Result<Self> {
98 let name = platform_config
99 .get_str("name")
100 .unwrap_or_else(|| "Custom Platform".to_string());
101 let editor_url = platform_config.get_str("editor_url").unwrap_or_default();
102 let format = match platform_config
103 .get_str("format")
104 .as_deref()
105 .unwrap_or("html")
106 {
107 "markdown" | "md" => CopyFormat::Markdown,
108 _ => CopyFormat::StyledHtml,
109 };
110 let name: &'static str = Box::leak(name.into_boxed_str());
113 let editor_url: &'static str = Box::leak(editor_url.into_boxed_str());
114 let math_rendering = platform_config
116 .get_str("math_rendering")
117 .and_then(|s| match s.as_str() {
118 "svg" => Some(MathRendering::Svg),
119 "latex" => Some(MathRendering::Latex),
120 "png" => Some(MathRendering::Png),
121 _ => None,
122 })
123 .unwrap_or(match format {
124 CopyFormat::Markdown => MathRendering::Latex,
125 CopyFormat::StyledHtml => MathRendering::Svg,
126 });
127 let use_inline_html_for_sized_images = platform_config
129 .get_str("use_inline_html_for_sized_images")
130 .map(|s| s == "true")
131 .unwrap_or(false);
132 let markdown_processing_rules = MarkdownProcessingRules::empty();
134 let tight_lists = platform_config
136 .get_str("tight_lists")
137 .map(|s| s == "true")
138 .unwrap_or(true);
139 Self::build(
140 id,
141 name,
142 editor_url,
143 format,
144 typub_html::SerializeRules::empty(), None, AssetStrategy::Embed, MathDelimiters::Dollar, math_rendering,
149 use_inline_html_for_sized_images,
150 markdown_processing_rules,
151 tight_lists,
152 Some(platform_config),
153 config,
154 )
155 }
156
157 #[allow(clippy::too_many_arguments)]
158 fn build(
159 id: &'static str,
160 name: &'static str,
161 editor_url: &'static str,
162 format: CopyFormat,
163 serialize_rules: typub_html::SerializeRules,
164 profile_default_theme: Option<&'static str>,
165 profile_default_asset_strategy: AssetStrategy,
166 math_delimiters: MathDelimiters,
167 math_rendering: MathRendering,
168 use_inline_html_for_sized_images: bool,
169 markdown_processing_rules: MarkdownProcessingRules,
170 tight_lists: bool,
171 platform_config: Option<&PlatformConfig>,
172 config: &Config,
173 ) -> Result<Self> {
174 let output_dir = platform_config
175 .and_then(|c| c.get_str("output_dir"))
176 .map(PathBuf::from)
177 .unwrap_or_else(|| config.output_dir.join(id));
178
179 let registry = ThemeRegistry::new()?;
180 let fallback_theme = registry.get_or_default("elegant")?.clone();
182
183 let asset_strategy = resolve_asset_strategy_with_policy(
187 id,
188 platform_config,
189 profile_default_asset_strategy,
190 &[AssetStrategy::Embed, AssetStrategy::External],
191 )?;
192
193 Ok(Self {
194 profile_id: id,
195 profile_name: name,
196 editor_url,
197 format,
198 serialize_rules,
199 output_dir,
200 fallback_theme,
201 profile_default_theme,
202 theme_registry: registry,
203 asset_strategy,
204 math_delimiters,
205 math_rendering,
206 use_inline_html_for_sized_images,
207 markdown_processing_rules,
208 tight_lists,
209 })
210 }
211
212 pub fn code_highlight(&self) -> bool {
216 matches!(self.format, CopyFormat::StyledHtml)
217 }
218
219 fn build_embed_asset_map(
221 &self,
222 content_info: &ContentInfo,
223 ) -> Result<HashMap<PathBuf, String>> {
224 let mut url_map = HashMap::new();
225 for asset_path in &content_info.assets {
226 let data = std::fs::read(asset_path)?;
227 let data_uri = to_data_uri(&data, asset_path);
228 url_map.insert(asset_path.clone(), data_uri);
229 }
230 Ok(url_map)
231 }
232
233 fn finalize_styled_html(&self, body: &str, theme: &Theme) -> Result<String> {
238 apply_theme(body, theme, true)
239 }
240
241 fn load_theme(&self, theme_id: Option<&str>) -> Theme {
243 load_theme(
244 theme_id,
245 self.profile_default_theme,
246 &self.theme_registry,
247 &self.fallback_theme,
248 )
249 }
250
251 fn build_preview_html(&self, title: &str, theme_name: &str, content_html: &str) -> String {
253 let copy_failed_msg = "Copy failed";
255 let copy_button = "Copy";
256 let copy_success = "Copied!";
257 let preview_suffix = "Preview";
258 let open_editor = "Open Editor";
259
260 let (copy_script, content_area) = match self.format {
261 CopyFormat::StyledHtml => (
262 styled_html_copy_script(copy_failed_msg),
263 format!(r#"<div id="content-area">{}</div>"#, content_html),
264 ),
265 CopyFormat::Markdown => (
266 markdown_copy_script(copy_failed_msg),
267 format!(
268 r#"<pre id="content-area" style="white-space:pre-wrap;word-wrap:break-word;font-family:monospace;font-size:14px;background:#f8f8f8;padding:16px;border-radius:4px;overflow-x:auto;">{}</pre>"#,
269 html_escape(content_html)
270 ),
271 ),
272 };
273
274 let editor_link = if self.editor_url.is_empty() {
275 String::new()
276 } else {
277 format!(
278 r#"<a href="{url}" target="_blank" rel="noopener" style="display:inline-block;background:#555;color:white;border:none;padding:10px 20px;border-radius:4px;cursor:pointer;font-size:14px;font-weight:500;text-decoration:none;">{label}</a>"#,
279 url = self.editor_url,
280 label = open_editor
281 )
282 };
283
284 let preview_css = self
286 .theme_registry
287 .load_preview_css("copypaste")
288 .unwrap_or_default();
289
290 format!(
291 r#"<!DOCTYPE html>
292<html>
293<head>
294 <meta charset="utf-8">
295 <title>{title} - {platform} {preview_suffix}</title>
296 <style>
297{preview_css}
298 </style>
299</head>
300<body>
301 <div class="toolbar">
302 <button class="copy-btn" onclick="copyContent()">{copy_button}</button>
303 {editor_link}
304 <span class="theme-info">{platform} · {theme_name}</span>
305 <span class="copy-success" id="copy-success">{copy_success}</span>
306 </div>
307 <div class="preview-container">
308 <div class="preview-header">
309 <div class="preview-title">{title}</div>
310 </div>
311 {content_area}
312 </div>
313 <script>
314{copy_script}
315 </script>
316</body>
317</html>"#,
318 preview_css = preview_css,
319 title = title,
320 platform = self.profile_name,
321 preview_suffix = preview_suffix,
322 theme_name = theme_name,
323 copy_button = copy_button,
324 copy_success = copy_success,
325 editor_link = editor_link,
326 content_area = content_area,
327 copy_script = copy_script,
328 )
329 }
330
331 fn build_preview_from_elements(
336 &self,
337 content_info: &ContentInfo,
338 elements: Document,
339 theme: &Theme,
340 ) -> Result<PathBuf> {
341 let serialize_options = SerializeOptions {
342 li_span_wrap: self.serialize_rules.contains(SerializeRule::LiSpanWrap),
343 use_code_highlight: self.code_highlight(),
344 blockquote_for_admonition: self
345 .serialize_rules
346 .contains(SerializeRule::BlockquoteForAdmonition),
347 sibling_nested_lists: self
348 .serialize_rules
349 .contains(SerializeRule::SiblingNestedLists),
350 definition_list_to_paragraph: self
351 .serialize_rules
352 .contains(SerializeRule::DefinitionListToParagraph),
353 };
354 let body = document_to_html_with_options(&elements, &serialize_options);
355
356 let preview_content = match self.format {
357 CopyFormat::StyledHtml => apply_theme(&body, theme, true)?,
358 CopyFormat::Markdown => {
359 let options = MarkdownRenderOptions {
360 math_delimiters: self.math_delimiters,
361 use_inline_html_for_sized_images: self.use_inline_html_for_sized_images,
362 processing_rules: self.markdown_processing_rules,
363 tight_lists: self.tight_lists,
364 ..Default::default()
365 };
366 document_to_markdown_with_options(&elements, &options)?
367 }
368 };
369
370 let preview_html =
371 self.build_preview_html(&content_info.title, &theme.name, &preview_content);
372
373 write_preview_file(&content_info.slug, self.profile_id, &preview_html)
374 }
375}
376
377#[async_trait(?Send)]
378impl PlatformAdapter for CopyPasteAdapter {
379 fn id(&self) -> &'static str {
380 self.profile_id
381 }
382
383 fn name(&self) -> &'static str {
384 self.profile_name
385 }
386
387 fn required_format(&self) -> OutputFormat {
388 OutputFormat::Html
389 }
390
391 fn asset_strategy(&self) -> AssetStrategy {
392 self.asset_strategy
393 }
394
395 fn validate_config(&self, _config: &PlatformConfig) -> Result<()> {
396 Ok(()) }
398
399 fn supports_shared_link_rewrite(&self) -> bool {
401 true
402 }
403
404 fn render_config(&self, _content_info: &ContentInfo) -> RenderConfig {
405 render_config_for_png_math(self.asset_strategy, self.math_rendering)
406 }
407
408 async fn specialize_payload(
410 &self,
411 elements: Document,
412 ctx: &dyn AdapterContext,
413 ) -> Result<AdapterPayload> {
414 let content_info = ctx.content_info();
415
416 let mut elements = match self.format {
420 CopyFormat::StyledHtml => {
421 let mut doc = elements;
422 let mut pass = FlattenSvgPass;
423 run_passes(&mut doc, &mut PassCtx::default(), &mut [&mut pass])?;
424 doc
425 }
426 CopyFormat::Markdown => elements,
427 };
428
429 (elements, _) = convert_png_math_for_strategy(
432 elements,
433 self.asset_strategy,
434 self.math_rendering,
435 &content_info.path,
436 &content_info.slug,
437 )?;
438
439 let deferred = if self.asset_strategy == AssetStrategy::External {
441 prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path)
443 } else {
444 let asset_map = self.build_embed_asset_map(content_info)?;
446 let url_map = build_image_marker_url_map(&content_info.path, &asset_map);
447 resolve_asset_urls(&mut elements, &url_map);
448 DeferredAssets::empty()
449 };
450
451 Ok(AdapterPayload::new(
452 CopyPastePayload {
453 slug: content_info.slug.clone(),
454 content: String::new(), format: self.format,
456 },
457 content_info.clone(),
458 deferred,
459 elements,
460 ))
461 }
462
463 async fn materialize_payload(
466 &self,
467 mut payload: AdapterPayload,
468 ctx: &dyn AdapterContext,
469 ) -> Result<AdapterPayload> {
470 if ctx.is_dry_run() {
472 mock_materialize_and_resolve_urls(&mut payload, ctx)?;
473 return Ok(payload);
474 }
475
476 if self.asset_strategy == AssetStrategy::External {
477 materialize_and_resolve_urls(&mut payload, ctx).await?;
478 }
479
480 Ok(payload)
481 }
482
483 async fn serialize_payload(
485 &self,
486 mut payload: AdapterPayload,
487 ctx: &dyn AdapterContext,
488 ) -> Result<AdapterPayload> {
489 let final_content = match self.format {
490 CopyFormat::StyledHtml => {
491 let serialize_options = SerializeOptions {
493 li_span_wrap: self.serialize_rules.contains(SerializeRule::LiSpanWrap),
494 use_code_highlight: self.code_highlight(),
495 blockquote_for_admonition: self
496 .serialize_rules
497 .contains(SerializeRule::BlockquoteForAdmonition),
498 sibling_nested_lists: self
499 .serialize_rules
500 .contains(SerializeRule::SiblingNestedLists),
501 definition_list_to_paragraph: self
502 .serialize_rules
503 .contains(SerializeRule::DefinitionListToParagraph),
504 };
505 let body_html =
506 document_to_html_with_options(&payload.document, &serialize_options);
507 let theme = self.load_theme(ctx.theme_id());
509 self.finalize_styled_html(&body_html, &theme)?
510 }
511 CopyFormat::Markdown => {
512 let options = MarkdownRenderOptions {
513 math_delimiters: self.math_delimiters,
514 use_inline_html_for_sized_images: self.use_inline_html_for_sized_images,
515 processing_rules: self.markdown_processing_rules,
516 tight_lists: self.tight_lists,
517 ..Default::default()
518 };
519 document_to_markdown_with_options(&payload.document, &options)?
520 }
521 };
522
523 let inner = payload
524 .downcast_mut::<CopyPastePayload>()
525 .ok_or_else(|| anyhow::anyhow!("Invalid CopyPaste payload type"))?;
526 inner.content = final_content;
527 Ok(payload)
528 }
529
530 async fn publish_payload(
531 &self,
532 payload: AdapterPayload,
533 _ctx: &dyn AdapterContext,
534 ) -> Result<PublishResult> {
535 let payload = downcast_payload::<CopyPastePayload>(payload, self.profile_name)?;
536 std::fs::create_dir_all(&self.output_dir)?;
537
538 let ext = match payload.format {
539 CopyFormat::StyledHtml => "html",
540 CopyFormat::Markdown => "md",
541 };
542 let output_path = self.output_dir.join(format!("{}.{}", payload.slug, ext));
543 std::fs::write(&output_path, &payload.content)?;
544
545 debug!("Saved to: {}", output_path.display());
546
547 let mut clipboard = arboard::Clipboard::new()
549 .map_err(|e| anyhow::anyhow!("Failed to access clipboard: {}", e))?;
550 match payload.format {
551 CopyFormat::StyledHtml => {
552 clipboard
553 .set_html(&payload.content, Some(&payload.content))
554 .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
555 }
556 CopyFormat::Markdown => {
557 clipboard
558 .set_text(&payload.content)
559 .map_err(|e| anyhow::anyhow!("Failed to copy to clipboard: {}", e))?;
560 }
561 }
562 info!("Copied to clipboard for {}", self.profile_name);
563
564 if !self.editor_url.is_empty()
566 && let Err(e) = open::that(self.editor_url)
567 {
568 warn!("Failed to open {}: {}", self.editor_url, e);
569 }
570
571 Ok(PublishResult {
572 url: Some(self.editor_url.to_string()),
573 platform_id: Some(payload.slug),
574 published_at: Utc::now(),
575 })
576 }
577
578 fn build_preview(
583 &self,
584 title: &str,
585 elements: Document,
586 ctx: &dyn AdapterContext,
587 ) -> Result<PathBuf> {
588 let _ = title; let content_info = ctx.content_info();
590 let theme = self.load_theme(ctx.theme_id());
591 self.build_preview_from_elements(content_info, elements, &theme)
592 }
593
594 async fn check_status(&self, slug: &str) -> Result<bool> {
595 let ext = match self.format {
596 CopyFormat::StyledHtml => "html",
597 CopyFormat::Markdown => "md",
598 };
599 let output_path = self.output_dir.join(format!("{}.{}", slug, ext));
600 Ok(output_path.exists())
601 }
602}
603
604fn html_escape(s: &str) -> String {
610 s.replace('&', "&")
611 .replace('<', "<")
612 .replace('>', ">")
613 .replace('"', """)
614}
615
616fn styled_html_copy_script(copy_failed_msg: &str) -> String {
618 format!(
619 r#"
620 async function copyContent() {{
621 const content = document.getElementById('content-area');
622 const htmlContent = content.innerHTML;
623 try {{
624 await navigator.clipboard.write([
625 new ClipboardItem({{
626 'text/html': new Blob([htmlContent], {{ type: 'text/html' }}),
627 'text/plain': new Blob([content.textContent || ''], {{ type: 'text/plain' }})
628 }})
629 ]);
630 showSuccess();
631 }} catch (err) {{
632 console.error('Clipboard API failed:', err);
633 const range = document.createRange();
634 range.selectNodeContents(content);
635 const sel = window.getSelection();
636 sel.removeAllRanges();
637 sel.addRange(range);
638 try {{ document.execCommand('copy'); showSuccess(); }}
639 catch (e) {{ alert('{copy_failed_msg}'); }}
640 sel.removeAllRanges();
641 }}
642 }}
643 function showSuccess() {{
644 const el = document.getElementById('copy-success');
645 el.classList.add('show');
646 setTimeout(() => el.classList.remove('show'), 2000);
647 }}
648"#,
649 copy_failed_msg = copy_failed_msg
650 )
651}
652
653fn markdown_copy_script(copy_failed_msg: &str) -> String {
655 format!(
656 r#"
657 async function copyContent() {{
658 const content = document.getElementById('content-area');
659 try {{
660 await navigator.clipboard.writeText(content.textContent || '');
661 showSuccess();
662 }} catch (err) {{
663 console.error('Clipboard API failed:', err);
664 const range = document.createRange();
665 range.selectNodeContents(content);
666 const sel = window.getSelection();
667 sel.removeAllRanges();
668 sel.addRange(range);
669 try {{ document.execCommand('copy'); showSuccess(); }}
670 catch (e) {{ alert('{copy_failed_msg}'); }}
671 sel.removeAllRanges();
672 }}
673 }}
674 function showSuccess() {{
675 const el = document.getElementById('copy-success');
676 el.classList.add('show');
677 setTimeout(() => el.classList.remove('show'), 2000);
678 }}
679"#,
680 copy_failed_msg = copy_failed_msg
681 )
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn test_html_escape() {
690 assert_eq!(html_escape("<script>"), "<script>");
691 assert_eq!(html_escape("a & b"), "a & b");
692 assert_eq!(html_escape("say \"hi\""), "say "hi"");
693 }
694}