Skip to main content

asciidoc_parser/parser/
inline_substitution_renderer.rs

1use std::{fmt::Debug, sync::LazyLock};
2
3use regex::Regex;
4
5use crate::{Parser, attributes::Attrlist, parser::ResolvedReference};
6
7/// An implementation of `InlineSubstitutionRenderer` is used when converting
8/// the basic raw text of a simple block to the format which will ultimately be
9/// presented in the final converted output.
10///
11/// An implementation is provided for HTML output; alternative implementations
12/// (not provided in this crate) could support other output formats.
13pub trait InlineSubstitutionRenderer: Debug {
14    /// Renders the substitution for a special character.
15    ///
16    /// The renderer should write the appropriate rendering to `dest`.
17    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String);
18
19    /// Renders the content of a [quote substitution].
20    ///
21    /// The renderer should write the appropriate rendering to `dest`.
22    ///
23    /// [quote substitution]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
24    fn render_quoted_substitition(
25        &self,
26        type_: QuoteType,
27        scope: QuoteScope,
28        attrlist: Option<Attrlist<'_>>,
29        id: Option<String>,
30        body: &str,
31        dest: &mut String,
32    );
33
34    /// Renders the content of a [character replacement].
35    ///
36    /// The renderer should write the appropriate rendering to `dest`.
37    ///
38    /// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
39    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String);
40
41    /// Renders a line break.
42    ///
43    /// The renderer should write an appropriate rendering of line break to
44    /// `dest`.
45    ///
46    /// This is used in the implementation of [post-replacement substitutions].
47    ///
48    /// [post-replacement substitutions]: https://docs.asciidoctor.org/asciidoc/latest/subs/post-replacements/
49    fn render_line_break(&self, dest: &mut String);
50
51    /// Renders an image.
52    ///
53    /// The renderer should write an appropriate rendering of the specified
54    /// image to `dest`.
55    fn render_image(&self, params: &ImageRenderParams, dest: &mut String);
56
57    /// Construct a URI reference or data URI to the target image.
58    ///
59    /// If the `target_image_path` is a URI reference, then leave it untouched.
60    ///
61    /// The `target_image_path` is resolved relative to the directory retrieved
62    /// from the specified document-scoped attribute key, if provided.
63    ///
64    /// NOT YET IMPLEMENTED:
65    /// If the `data-uri` attribute is set on the document, and the safe mode
66    /// level is less than `SafeMode::SECURE`, the image will be safely
67    /// converted to a data URI by reading it from the same directory. If
68    /// neither of these conditions are satisfied, a relative path (i.e., URL)
69    /// will be returned.
70    ///
71    /// ## Parameters
72    ///
73    /// * `target_image_path`: path to the target image
74    /// * `parser`: Current document parser state
75    /// * `asset_dir_key`: If provided, the attribute key used to look up the
76    ///   directory where the image is located. If not provided, `imagesdir` is
77    ///   used.
78    ///
79    /// ## Return
80    ///
81    /// Returns a string reference or data URI for the target image that can be
82    /// safely used in an image tag.
83    fn image_uri(
84        &self,
85        target_image_path: &str,
86        parser: &Parser,
87        asset_dir_key: Option<&str>,
88    ) -> String;
89
90    /// Renders an icon.
91    ///
92    /// The renderer should write an appropriate rendering of the specified
93    /// icon to `dest`.
94    fn render_icon(&self, params: &IconRenderParams, dest: &mut String);
95
96    /// Construct a reference or data URI to an icon image for the specified
97    /// icon name.
98    ///
99    /// If the `icon` attribute is set on this block, the name is ignored and
100    /// the value of this attribute is used as the target image path. Otherwise,
101    /// construct a target image path by concatenating the value of the
102    /// `iconsdir` attribute, the icon name, and the value of the `icontype`
103    /// attribute (defaulting to `png`).
104    ///
105    /// The target image path is then passed through the `image_uri()` method.
106    /// If the `data-uri` attribute is set on the document, the image will be
107    /// safely converted to a data URI.
108    ///
109    /// The return value of this method can be safely used in an image tag.
110    fn icon_uri(&self, name: &str, _attrlist: &Attrlist, parser: &Parser) -> String {
111        let icontype = parser
112            .attribute_value("icontype")
113            .as_maybe_str()
114            .unwrap_or("png")
115            .to_owned();
116
117        if false {
118            todo!(
119                "Enable this when doing block-related icon attributes: {}",
120                r#"
121                let icon = if let Some(icon) = attrlist.named_attribute("icon") {
122                    let icon_str = icon.value();
123                    if has_extname(icon_str) {
124                        icon_str.to_string()
125                    } else {
126                        format!("{icon_str}.{icontype}")
127                    }
128                } else {
129                    // This part is defaulted for now.
130                    format!("{name}.{icontype}")
131                };
132            "#
133            );
134        }
135
136        let icon = format!("{name}.{icontype}");
137
138        self.image_uri(&icon, parser, Some("iconsdir"))
139    }
140
141    /// Renders a link.
142    ///
143    /// The renderer should write an appropriate rendering of the specified
144    /// link, to `dest`.
145    fn render_link(&self, params: &LinkRenderParams, dest: &mut String);
146
147    /// Renders an anchor.
148    ///
149    /// The rendered should write an appropriate rendering of the specified
150    /// anchor with ID and possible ref text (only used by some renderers).
151    fn render_anchor(&self, id: &str, reftext: Option<String>, dest: &mut String);
152
153    /// Renders a cross-reference.
154    ///
155    /// When [`XrefRenderParams::resolved`] is `Some`, the reference resolved to
156    /// a destination; the renderer should link to it. When it is `None`, the
157    /// reference could not be resolved and the renderer should emit a sensible
158    /// fallback (e.g. a link to the raw target with bracketed text).
159    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String);
160}
161
162/// Specifies which special character is being replaced in a call to
163/// [`InlineSubstitutionRenderer::render_special_character`].
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum SpecialCharacter {
166    /// Replace `<` character.
167    Lt,
168
169    /// Replace `>` character.
170    Gt,
171
172    /// Replace `&` character.
173    Ampersand,
174}
175
176/// Specifies which [quote type] is being rendered.
177///
178/// [quote type]: https://docs.asciidoctor.org/asciidoc/latest/subs/quotes/
179#[derive(Clone, Copy, Debug, Eq, PartialEq)]
180pub enum QuoteType {
181    /// Strong (often bold) formatting.
182    Strong,
183
184    /// Word(s) surrounded by smart double quotes.
185    DoubleQuote,
186
187    /// Word(s) surrounded by smart single quotes.
188    SingleQuote,
189
190    /// Monospace (code) formatting.
191    Monospaced,
192
193    /// Emphasis (often italic) formatting.
194    Emphasis,
195
196    /// Text range (span) formatted with zero or more styles.
197    Mark,
198
199    /// Superscript formatting.
200    Superscript,
201
202    /// Subscript formatting.
203    Subscript,
204
205    /// Surrounds a block of text that may need a `<span>` or similar tag.
206    Unquoted,
207}
208
209/// Specifies whether the block is aligned to word boundaries or not.
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum QuoteScope {
212    /// The quoted section was aligned to word boundaries.
213    Constrained,
214
215    /// The quoted section may not have been aligned to word boundaries.
216    Unconstrained,
217}
218
219/// Specifies which [character replacement] is being rendered.
220///
221/// [character replacement]: https://docs.asciidoctor.org/asciidoc/latest/subs/replacements/
222#[derive(Clone, Debug, Eq, PartialEq)]
223pub enum CharacterReplacementType {
224    /// Copyright `(C)`.
225    Copyright,
226
227    /// Registered `(R)`.
228    Registered,
229
230    /// Trademark `(TM)`.
231    Trademark,
232
233    /// Em-dash surrounded by spaces ` -- `.
234    EmDashSurroundedBySpaces,
235
236    /// Em-dash without space `--`.
237    EmDashWithoutSpace,
238
239    /// Ellipsis `...`.
240    Ellipsis,
241
242    /// Single right arrow `->`.
243    SingleRightArrow,
244
245    /// Double right arrow `=>`.
246    DoubleRightArrow,
247
248    /// Single left arrow `<-`.
249    SingleLeftArrow,
250
251    /// Double left arrow `<=`.
252    DoubleLeftArrow,
253
254    /// Typographic apostrophe `'` within a word.
255    TypographicApostrophe,
256
257    /// Character reference `&___;`.
258    CharacterReference(String),
259}
260
261/// Provides parsed parameters for an image to be rendered.
262#[derive(Clone, Debug)]
263pub struct ImageRenderParams<'a> {
264    /// Target (the reference to the image).
265    pub target: &'a str,
266
267    /// Alt text (either explicitly set or defaulted).
268    pub alt: String,
269
270    /// Width. The data type is not checked; this may be any string.
271    pub width: Option<&'a str>,
272
273    /// Height. The data type is not checked; this may be any string.
274    pub height: Option<&'a str>,
275
276    /// Attribute list.
277    pub attrlist: &'a Attrlist<'a>,
278
279    /// Parser. The rendered may find document settings (such as an image
280    /// directory) in the parser's document attributes.
281    pub parser: &'a Parser,
282}
283
284/// Provides parsed parameters for an icon to be rendered.
285#[derive(Clone, Debug)]
286pub struct IconRenderParams<'a> {
287    /// Target (the reference to the image).
288    pub target: &'a str,
289
290    /// Alt text (either explicitly set or defaulted).
291    pub alt: String,
292
293    /// Size. The data type is not checked; this may be any string.
294    pub size: Option<&'a str>,
295
296    /// Attribute list.
297    pub attrlist: &'a Attrlist<'a>,
298
299    /// Parser. The rendered may find document settings (such as an image
300    /// directory) in the parser's document attributes.
301    pub parser: &'a Parser,
302}
303
304/// Provides parsed parameters for an icon to be rendered.
305#[derive(Clone, Debug)]
306pub struct LinkRenderParams<'a> {
307    /// Target (the target of this link).
308    pub target: String,
309
310    /// Link text.
311    pub link_text: String,
312
313    /// Roles (CSS classes) for this link not specified in the attrlist.
314    pub extra_roles: Vec<&'a str>,
315
316    /// Target window selection (passed through to `window` function in HTML).
317    pub window: Option<&'static str>,
318
319    /// What type of link is being rendered?
320    pub type_: LinkRenderType,
321
322    /// Attribute list.
323    pub attrlist: &'a Attrlist<'a>,
324
325    /// Parser. The rendered may find document settings (such as an image
326    /// directory) in the parser's document attributes.
327    pub parser: &'a Parser,
328}
329
330/// What type of link is being rendered?
331#[derive(Clone, Debug)]
332pub enum LinkRenderType {
333    /// TEMPORARY: I don't know the different types of links yet.
334    Link,
335}
336
337/// Provides parameters for rendering a cross-reference.
338#[derive(Clone, Debug)]
339pub struct XrefRenderParams<'a> {
340    /// The raw, uninterpreted cross-reference target as written in the source.
341    pub target: &'a str,
342
343    /// Explicit link text supplied in the cross-reference, if any.
344    pub provided_text: Option<&'a str>,
345
346    /// The resolved destination, or `None` if the reference is unresolved.
347    pub resolved: Option<&'a ResolvedReference>,
348}
349
350/// Implementation of [`InlineSubstitutionRenderer`] that renders substitutions
351/// for common HTML-based applications.
352#[derive(Debug)]
353pub struct HtmlSubstitutionRenderer {}
354
355impl InlineSubstitutionRenderer for HtmlSubstitutionRenderer {
356    fn render_special_character(&self, type_: SpecialCharacter, dest: &mut String) {
357        match type_ {
358            SpecialCharacter::Lt => {
359                dest.push_str("&lt;");
360            }
361            SpecialCharacter::Gt => {
362                dest.push_str("&gt;");
363            }
364            SpecialCharacter::Ampersand => {
365                dest.push_str("&amp;");
366            }
367        }
368    }
369
370    fn render_quoted_substitition(
371        &self,
372        type_: QuoteType,
373        _scope: QuoteScope,
374        attrlist: Option<Attrlist<'_>>,
375        mut id: Option<String>,
376        body: &str,
377        dest: &mut String,
378    ) {
379        let mut roles: Vec<&str> = attrlist.as_ref().map(|a| a.roles()).unwrap_or_default();
380
381        if let Some(block_style) = attrlist
382            .as_ref()
383            .and_then(|a| a.nth_attribute(1))
384            .and_then(|attr1| attr1.block_style())
385        {
386            roles.insert(0, block_style);
387        }
388
389        if id.is_none() {
390            id = attrlist
391                .as_ref()
392                .and_then(|a| a.nth_attribute(1))
393                .and_then(|attr1| attr1.id())
394                .map(|id| id.to_owned())
395        }
396
397        match type_ {
398            QuoteType::Strong => {
399                wrap_body_in_html_tag(attrlist.as_ref(), "strong", id, roles, body, dest);
400            }
401
402            QuoteType::DoubleQuote => {
403                dest.push_str("&#8220;");
404                dest.push_str(body);
405                dest.push_str("&#8221;");
406            }
407
408            QuoteType::SingleQuote => {
409                dest.push_str("&#8216;");
410                dest.push_str(body);
411                dest.push_str("&#8217;");
412            }
413
414            QuoteType::Monospaced => {
415                wrap_body_in_html_tag(attrlist.as_ref(), "code", id, roles, body, dest);
416            }
417
418            QuoteType::Emphasis => {
419                wrap_body_in_html_tag(attrlist.as_ref(), "em", id, roles, body, dest);
420            }
421
422            QuoteType::Mark => {
423                if roles.is_empty() && id.is_none() {
424                    wrap_body_in_html_tag(attrlist.as_ref(), "mark", id, roles, body, dest);
425                } else {
426                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
427                }
428            }
429
430            QuoteType::Superscript => {
431                wrap_body_in_html_tag(attrlist.as_ref(), "sup", id, roles, body, dest);
432            }
433
434            QuoteType::Subscript => {
435                wrap_body_in_html_tag(attrlist.as_ref(), "sub", id, roles, body, dest);
436            }
437
438            QuoteType::Unquoted => {
439                if roles.is_empty() && id.is_none() {
440                    dest.push_str(body);
441                } else {
442                    wrap_body_in_html_tag(attrlist.as_ref(), "span", id, roles, body, dest);
443                }
444            }
445        }
446    }
447
448    fn render_character_replacement(&self, type_: CharacterReplacementType, dest: &mut String) {
449        match type_ {
450            CharacterReplacementType::Copyright => {
451                dest.push_str("&#169;");
452            }
453
454            CharacterReplacementType::Registered => {
455                dest.push_str("&#174;");
456            }
457
458            CharacterReplacementType::Trademark => {
459                dest.push_str("&#8482;");
460            }
461
462            CharacterReplacementType::EmDashSurroundedBySpaces => {
463                dest.push_str("&#8201;&#8212;&#8201;");
464            }
465
466            CharacterReplacementType::EmDashWithoutSpace => {
467                dest.push_str("&#8212;&#8203;");
468            }
469
470            CharacterReplacementType::Ellipsis => {
471                dest.push_str("&#8230;&#8203;");
472            }
473
474            CharacterReplacementType::SingleLeftArrow => {
475                dest.push_str("&#8592;");
476            }
477
478            CharacterReplacementType::DoubleLeftArrow => {
479                dest.push_str("&#8656;");
480            }
481
482            CharacterReplacementType::SingleRightArrow => {
483                dest.push_str("&#8594;");
484            }
485
486            CharacterReplacementType::DoubleRightArrow => {
487                dest.push_str("&#8658;");
488            }
489
490            CharacterReplacementType::TypographicApostrophe => {
491                dest.push_str("&#8217;");
492            }
493
494            CharacterReplacementType::CharacterReference(name) => {
495                dest.push('&');
496                dest.push_str(&name);
497                dest.push(';');
498            }
499        }
500    }
501
502    fn render_line_break(&self, dest: &mut String) {
503        dest.push_str("<br>");
504    }
505
506    fn render_image(&self, params: &ImageRenderParams, dest: &mut String) {
507        let src = self.image_uri(params.target, params.parser, None);
508
509        let mut attrs: Vec<String> = vec![
510            format!(r#"src="{src}""#),
511            format!(
512                r#"alt="{alt}""#,
513                alt = encode_attribute_value(params.alt.to_string())
514            ),
515        ];
516
517        if let Some(width) = params.width {
518            attrs.push(format!(r#"width="{width}""#));
519        }
520
521        if let Some(height) = params.height {
522            attrs.push(format!(r#"height="{height}""#));
523        }
524
525        if let Some(title) = params.attrlist.named_attribute("title") {
526            attrs.push(format!(
527                r#"title="{title}""#,
528                title = encode_attribute_value(title.value().to_owned())
529            ));
530        }
531
532        let format = params
533            .attrlist
534            .named_attribute("format")
535            .map(|format| format.value());
536
537        // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
538        // Enforce non-safe mode. Add this contraint to following `if` clause:
539        // `&& node.document.safe < SafeMode::SECURE`
540
541        let img = if format == Some("svg") || params.target.contains(".svg") {
542            // NOTE: In the SVG case we may have to ignore the attrs list.
543            if params.attrlist.has_option("inline") {
544                todo!(
545                    "Port this: {}",
546                    r#"img = (read_svg_contents node, target) || %(<span class="alt">#{node.alt}</span>)
547                    NOTE: The attrs list calculated above may not be usable.
548                    "#
549                );
550            } else if params.attrlist.has_option("interactive") {
551                todo!(
552                    "Port this: {}",
553                    r##"
554                        fallback = (node.attr? 'fallback') ? %(<img src="#{node.image_uri node.attr 'fallback'}" alt="#{encode_attribute_value node.alt}"#{attrs}#{@void_element_slash}>) : %(<span class="alt">#{node.alt}</span>)
555                        img = %(<object type="image/svg+xml" data="#{src = node.image_uri target}"#{attrs}>#{fallback}</object>)
556                        NOTE: The attrs list calculated above may not be usable.
557                    "##
558                );
559            } else {
560                format!(
561                    r#"<img {attrs}{void_element_slash}>"#,
562                    attrs = attrs.join(" "),
563                    void_element_slash = "",
564                )
565            }
566        } else {
567            format!(
568                r#"<img {attrs}{void_element_slash}>"#,
569                attrs = attrs.join(" "),
570                void_element_slash = "",
571                // img = %(<img src="#{src = node.image_uri target}"
572                // alt="#{encode_attribute_value node.alt}"#{attrs}#{@
573                // void_element_slash}>)
574            )
575        };
576
577        render_icon_or_image(params.attrlist, &img, &src, "image", dest);
578    }
579
580    fn image_uri(
581        &self,
582        target_image_path: &str,
583        parser: &Parser,
584        asset_dir_key: Option<&str>,
585    ) -> String {
586        let asset_dir_key = asset_dir_key.unwrap_or("imagesdir");
587
588        if false {
589            todo!(
590                // TO DO (https://github.com/asciidoc-rs/asciidoc-parser/issues/277):
591                "Port this when implementing safe modes: {}",
592                r#"
593				if (doc = @document).safe < SafeMode::SECURE && (doc.attr? 'data-uri')
594				  if ((Helpers.uriish? target_image) && (target_image = Helpers.encode_spaces_in_uri target_image)) ||
595					  (asset_dir_key && (images_base = doc.attr asset_dir_key) && (Helpers.uriish? images_base) &&
596					  (target_image = normalize_web_path target_image, images_base, false))
597					(doc.attr? 'allow-uri-read') ? (generate_data_uri_from_uri target_image, (doc.attr? 'cache-uri')) : target_image
598				  else
599					generate_data_uri target_image, asset_dir_key
600				  end
601				else
602				  normalize_web_path target_image, (asset_dir_key ? (doc.attr asset_dir_key) : nil)
603				end
604            "#
605            );
606        } else {
607            let asset_dir = parser
608                .attribute_value(asset_dir_key)
609                .as_maybe_str()
610                .map(|s| s.to_string());
611
612            normalize_web_path(target_image_path, parser, asset_dir.as_deref(), true)
613        }
614    }
615
616    fn render_icon(&self, params: &IconRenderParams, dest: &mut String) {
617        let src = self.icon_uri(params.target, params.attrlist, params.parser);
618
619        let img = if params.parser.has_attribute("icons") {
620            let icons = params.parser.attribute_value("icons");
621            if let Some(icons) = icons.as_maybe_str()
622                && icons == "font"
623            {
624                let mut i_class_attrs: Vec<String> = vec![
625                    "fa".to_owned(),
626                    format!("fa-{target}", target = params.target),
627                ];
628
629                if let Some(size) = params.attrlist.named_or_positional_attribute("size", 1) {
630                    i_class_attrs.push(format!("fa-{size}", size = size.value()));
631                }
632
633                if let Some(flip) = params.attrlist.named_attribute("flip") {
634                    i_class_attrs.push(format!("fa-flip-{flip}", flip = flip.value()));
635                } else if let Some(rotate) = params.attrlist.named_attribute("rotate") {
636                    i_class_attrs.push(format!("fa-rotate-{rotate}", rotate = rotate.value()));
637                }
638
639                format!(
640                    r##"<i class="{i_class_attr_val}"{title_attr}></i>"##,
641                    i_class_attr_val = i_class_attrs.join(" "),
642                    title_attr = if let Some(title) = params.attrlist.named_attribute("title") {
643                        format!(r#" title="{title}""#, title = title.value())
644                    } else {
645                        "".to_owned()
646                    }
647                )
648            } else {
649                let mut attrs: Vec<String> = vec![
650                    format!(r#"src="{src}""#),
651                    format!(
652                        r#"alt="{alt}""#,
653                        alt = encode_attribute_value(params.alt.to_string())
654                    ),
655                ];
656
657                if let Some(width) = params.attrlist.named_attribute("width") {
658                    attrs.push(format!(r#"width="{width}""#, width = width.value()));
659                }
660
661                if let Some(height) = params.attrlist.named_attribute("height") {
662                    attrs.push(format!(r#"height="{height}""#, height = height.value()));
663                }
664
665                if let Some(title) = params.attrlist.named_attribute("title") {
666                    attrs.push(format!(r#"title="{title}""#, title = title.value()));
667                }
668
669                format!(
670                    "<img {attrs}{void_element_slash}>",
671                    attrs = attrs.join(" "),
672                    void_element_slash = "",
673                )
674            }
675        } else {
676            format!("[{alt}&#93;", alt = params.alt)
677        };
678
679        render_icon_or_image(params.attrlist, &img, &src, "icon", dest);
680    }
681
682    fn render_link(&self, params: &LinkRenderParams, dest: &mut String) {
683        let id = params.attrlist.id();
684
685        let mut roles = params.extra_roles.clone();
686        let mut attrlist_roles = params.attrlist.roles().clone();
687        roles.append(&mut attrlist_roles);
688
689        let link = format!(
690            r##"<a href="{target}"{id}{class}{link_constraint_attrs}>{link_text}</a>"##,
691            target = params.target,
692            id = if let Some(id) = id {
693                format!(r#" id="{id}""#)
694            } else {
695                "".to_owned()
696            },
697            class = if roles.is_empty() {
698                "".to_owned()
699            } else {
700                format!(r#" class="{roles}""#, roles = roles.join(" "))
701            },
702            // title = %( title="#{node.attr 'title'}") if node.attr? 'title'
703            // Haven't seen this in the wild yet.
704            link_constraint_attrs = link_constraint_attrs(params.attrlist, params.window),
705            link_text = params.link_text,
706        );
707
708        dest.push_str(&link);
709    }
710
711    fn render_anchor(&self, id: &str, _reftext: Option<String>, dest: &mut String) {
712        dest.push_str(&format!("<a id=\"{id}\"></a>"));
713    }
714
715    fn render_xref(&self, params: &XrefRenderParams, dest: &mut String) {
716        match params.resolved {
717            Some(resolved) => {
718                let text = params
719                    .provided_text
720                    .map(str::to_string)
721                    .or_else(|| resolved.text.clone())
722                    .unwrap_or_else(|| format!("[{target}]", target = params.target));
723
724                dest.push_str(&format!(
725                    r#"<a href="{href}">{text}</a>"#,
726                    href = resolved.href
727                ));
728            }
729
730            None => {
731                // Unresolved: link to the raw target and show bracketed text,
732                // mirroring Asciidoctor's behavior for a missing reference.
733                let text = params
734                    .provided_text
735                    .map(str::to_string)
736                    .unwrap_or_else(|| format!("[{target}]", target = params.target));
737
738                dest.push_str(&format!(
739                    r##"<a href="#{target}">{text}</a>"##,
740                    target = params.target
741                ));
742            }
743        }
744    }
745}
746
747fn wrap_body_in_html_tag(
748    _attrlist: Option<&Attrlist<'_>>,
749    tag: &'static str,
750    id: Option<String>,
751    roles: Vec<&str>,
752    body: &str,
753    dest: &mut String,
754) {
755    dest.push('<');
756    dest.push_str(tag);
757
758    if let Some(id) = id.as_ref() {
759        dest.push_str(" id=\"");
760        dest.push_str(id);
761        dest.push('"');
762    }
763
764    if !roles.is_empty() {
765        let roles = roles.join(" ");
766        dest.push_str(" class=\"");
767        dest.push_str(&roles);
768        dest.push('"');
769    }
770
771    dest.push('>');
772    dest.push_str(body);
773    dest.push_str("</");
774    dest.push_str(tag);
775    dest.push('>');
776}
777
778fn render_icon_or_image(
779    attrlist: &Attrlist,
780    img: &str,
781    src: &str,
782    type_: &'static str,
783    dest: &mut String,
784) {
785    let mut img = img.to_string();
786
787    if let Some(link) = attrlist.named_attribute("link") {
788        let mut link = link.value();
789        if link == "self" {
790            link = src;
791        }
792
793        img = format!(
794            r#"<a class="image" href="{link}"{link_constraint_attrs}>{img}</a>"#,
795            link_constraint_attrs = link_constraint_attrs(attrlist, None)
796        );
797    }
798
799    let mut roles: Vec<&str> = attrlist.roles();
800
801    if let Some(float) = attrlist.named_attribute("float") {
802        roles.insert(0, float.value());
803    }
804
805    roles.insert(0, type_);
806
807    dest.push_str(r#"<span class=""#);
808    dest.push_str(&roles.join(" "));
809    dest.push_str(r#"">"#);
810    dest.push_str(&img);
811    dest.push_str("</span>");
812}
813
814fn encode_attribute_value(value: String) -> String {
815    value.replace('"', "&quot;")
816}
817
818fn normalize_web_path(
819    target: &str,
820    parser: &Parser,
821    start: Option<&str>,
822    preserve_uri_target: bool,
823) -> String {
824    if preserve_uri_target && is_uri_ish(target) {
825        encode_spaces_in_uri(target)
826    } else {
827        parser.path_resolver.web_path(target, start)
828    }
829}
830
831fn is_uri_ish(path: &str) -> bool {
832    path.contains(':') && URI_SNIFF.is_match(path)
833}
834
835fn encode_spaces_in_uri(s: &str) -> String {
836    s.replace(' ', "%20")
837}
838
839/// Detects strings that resemble URIs.
840///
841/// ## Examples
842///
843/// * `http://domain`
844/// * `https://domain`
845/// * `file:///path`
846/// * `data:info`
847///
848/// ## Counter-examples (do not match)
849///
850/// * `c:/sample.adoc`
851/// * `c:\sample.adoc`
852static URI_SNIFF: LazyLock<Regex> = LazyLock::new(|| {
853    #[allow(clippy::unwrap_used)]
854    Regex::new(
855        r#"(?x)
856        \A                             # Anchor to start of string
857        \p{Alphabetic}                 # First character must be a letter
858        [\p{Alphabetic}\p{Nd}.+-]+     # Followed by one or more alphanum or . + -
859        :                              # Literal colon
860        /{0,2}                         # Zero to two slashes
861    "#,
862    )
863    .unwrap()
864});
865
866fn link_constraint_attrs(attrlist: &Attrlist<'_>, window: Option<&'static str>) -> String {
867    let rel = if attrlist.has_option("nofollow") {
868        Some("nofollow")
869    } else {
870        None
871    };
872
873    if let Some(window) = attrlist
874        .named_attribute("window")
875        .map(|a| a.value())
876        .or(window)
877    {
878        let rel_noopener = if window == "_blank" || attrlist.has_option("noopener") {
879            if let Some(rel) = rel {
880                format!(r#" rel="{rel}" noopener"#)
881            } else {
882                r#" rel="noopener""#.to_owned()
883            }
884        } else {
885            "".to_string()
886        };
887
888        format!(r#" target="{window}"{rel_noopener}"#)
889    } else if let Some(rel) = rel {
890        format!(r#" rel="{rel}""#)
891    } else {
892        "".to_string()
893    }
894}