Skip to main content

typub_adapter_copypaste/
adapter.rs

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
25/// A generic copy-paste adapter parameterized by a platform profile.
26///
27/// One Rust type backs all copy-paste platforms.  The registry creates one
28/// instance per enabled profile, stored as `Box<dyn PlatformAdapter>`.
29pub struct CopyPasteAdapter {
30    profile_id: &'static str,
31    profile_name: &'static str,
32    editor_url: &'static str,
33    format: CopyFormat,
34    /// Serialization rules per [[RFC-0002:C-PIPELINE-STAGES]].
35    serialize_rules: typub_html::SerializeRules,
36
37    output_dir: PathBuf,
38    /// Fallback theme (used when no theme resolved from 5-level chain)
39    fallback_theme: Theme,
40    /// Profile default theme (layer 5 in theme resolution)
41    profile_default_theme: Option<&'static str>,
42    /// Theme registry for runtime resolution
43    theme_registry: ThemeRegistry,
44    asset_strategy: AssetStrategy,
45    /// Math delimiter syntax for Markdown output.
46    math_delimiters: MathDelimiters,
47    /// How to render math equations.
48    /// Per [[WI-2026-02-13-026]], Png variant rasterizes SVG to PNG.
49    math_rendering: MathRendering,
50    /// Whether to use inline HTML (`<img>` tags) for images with dimensions.
51    /// Only relevant when format is Markdown.
52    use_inline_html_for_sized_images: bool,
53    /// Markdown post-processing rules.
54    /// Used for platform-specific editor quirks like blank line stripping.
55    /// Only relevant when format is Markdown.
56    markdown_processing_rules: MarkdownProcessingRules,
57    /// Whether lists should be tight (no blank lines between items).
58    /// Only relevant when format is Markdown.
59    tight_lists: bool,
60}
61
62impl CopyPasteAdapter {
63    /// Create from a built-in profile (generated from `profiles.toml`).
64    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    /// Create from a user-defined `type = "manual"` platform in typub.toml.
93    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        // Leak the strings so they have 'static lifetime.
111        // These are one-time config-driven allocations per custom platform.
112        let name: &'static str = Box::leak(name.into_boxed_str());
113        let editor_url: &'static str = Box::leak(editor_url.into_boxed_str());
114        // Parse math_rendering from config, fallback to format-based default
115        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        // Manual platforms default to false for use_inline_html_for_sized_images
128        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        // Manual platforms default to empty markdown processing rules
133        let markdown_processing_rules = MarkdownProcessingRules::empty();
134        // Manual platforms default to true for tight_lists
135        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(), // manual platforms have no serialize rules
145            None,                                // manual platforms have no profile default_theme
146            AssetStrategy::Embed,                // manual platforms default to embed
147            MathDelimiters::Dollar,              // manual platforms default to dollar
148            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        // Fallback theme when 5-level resolution finds nothing
181        let fallback_theme = registry.get_or_default("elegant")?.clone();
182
183        // Copy-paste platforms support Embed (base64 data URIs) and External (S3/R2 URLs).
184        // Per [[RFC-0004:C-EXTERNAL-STRATEGY]]
185        // Use profile's default instead of hardcoded Embed
186        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    /// Whether to use syntax-highlighted HTML in code blocks.
213    /// Per [[ADR-0001]]. Derived from format: HTML profiles use highlighting,
214    /// Markdown profiles don't need it (code blocks are native).
215    pub fn code_highlight(&self) -> bool {
216        matches!(self.format, CopyFormat::StyledHtml)
217    }
218
219    /// Build a base64 data-URI map for all assets.
220    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    /// Finalize as styled HTML (WeChat, Zhihu, Toutiao, …).
234    ///
235    /// Applies theme CSS inlining. Serialization rules are applied at serialization
236    /// time via SerializeOptions, not here.
237    fn finalize_styled_html(&self, body: &str, theme: &Theme) -> Result<String> {
238        apply_theme(body, theme, true)
239    }
240
241    /// Load theme using pre-resolved theme_id from ResolvedConfig.
242    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    /// Generate the preview HTML page with copy button and editor link.
252    fn build_preview_html(&self, title: &str, theme_name: &str, content_html: &str) -> String {
253        // Hardcoded i18n strings (extracted from main crate)
254        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        // Load preview CSS (builtins embedded at compile time, user override supported)
285        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    /// Build preview from AST elements.
332    ///
333    /// Applies serialization rules, theming, then generates preview HTML.
334    /// Per [[RFC-0002:C-PIPELINE-STAGES]], serialization rules are applied at serialization.
335    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(()) // No API keys needed.
397    }
398
399    /// Copypaste adapters support internal link rewriting via cross-platform resolution.
400    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    /// Stage 5 (Specialize): resolve image URLs and flatten SVGs in AST.
409    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        // Flatten SVG <use> references for clipboard compatibility.
417        // WeChat and similar platforms may not preserve xlink:href references.
418        // Only needed for HTML output; Markdown platforms extract math_src as LaTeX.
419        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        // Handle PNG math rendering based on asset strategy.
430        // Per [[WI-2026-02-17-001]].
431        (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        // Handle assets based on strategy
440        let deferred = if self.asset_strategy == AssetStrategy::External {
441            // External strategy: use helper to prepare deferred assets
442            prepare_deferred_assets(self.asset_strategy, &elements, &content_info.path)
443        } else {
444            // Embed strategy: resolve base64 data URIs into AST immediately
445            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(), // Filled by Serialize stage
455                format: self.format,
456            },
457            content_info.clone(),
458            deferred,
459            elements,
460        ))
461    }
462
463    /// Stage 7 (Materialize): upload assets for External strategy.
464    /// Per [[RFC-0004:C-PIPELINE-INTEGRATION]]
465    async fn materialize_payload(
466        &self,
467        mut payload: AdapterPayload,
468        ctx: &dyn AdapterContext,
469    ) -> Result<AdapterPayload> {
470        // Dry-run mode: generate mock URLs without file I/O
471        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    /// Stage 8 (Serialize): convert AST to target format (styled HTML or Markdown).
484    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                // Apply serialization rules and code highlighting from profile config
492                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                // Use pre-resolved theme_id from AdapterContext
508                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        // Copy content to clipboard
548        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        // Open editor URL
565        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    /// Build preview from pre-parsed AST (used by unified pipeline).
579    ///
580    /// Receives AST that has already been parsed and transformed by shared pipeline stages.
581    /// Applies copypaste-specific transforms (SVG flattening, theming) and generates preview.
582    fn build_preview(
583        &self,
584        title: &str,
585        elements: Document,
586        ctx: &dyn AdapterContext,
587    ) -> Result<PathBuf> {
588        let _ = title; // We use content_info.title instead for consistency
589        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
604// ============================================================================
605// HTML helpers
606// ============================================================================
607
608/// Minimal HTML escaping for text rendered inside `<pre>`.
609fn html_escape(s: &str) -> String {
610    s.replace('&', "&amp;")
611        .replace('<', "&lt;")
612        .replace('>', "&gt;")
613        .replace('"', "&quot;")
614}
615
616/// Generate copy script for StyledHtml: writes rich HTML to clipboard via ClipboardItem.
617fn 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
653/// Generate copy script for Markdown: writes plain text to clipboard.
654fn 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>"), "&lt;script&gt;");
691        assert_eq!(html_escape("a & b"), "a &amp; b");
692        assert_eq!(html_escape("say \"hi\""), "say &quot;hi&quot;");
693    }
694}