Skip to main content

typst_library/model/
link.rs

1use std::ops::Deref;
2use std::str::FromStr;
3
4use comemo::Tracked;
5use ecow::{EcoString, eco_format};
6use rustc_hash::FxHashMap;
7use typst_syntax::{Span, VirtualPath};
8use typst_utils::PicoStr;
9
10use crate::diag::{At, SourceDiagnostic, SourceResult, StrResult, bail, warning};
11use crate::engine::Engine;
12use crate::foundations::{
13    Args, Construct, Content, Label, NativeElement, Packed, Repr, Selector, ShowSet,
14    Smart, StyleChain, Styles, cast, elem,
15};
16use crate::introspection::{
17    Counter, CounterKey, History, Introspect, Introspector, Locatable, Location,
18    PagedPosition, PathIntrospection, QueryFirstIntrospection, QueryLabelIntrospection,
19    Tagged,
20};
21use crate::layout::PageElem;
22use crate::model::{NumberingPattern, Refable};
23use crate::text::{LocalName, TextElem};
24
25/// Links to a URL or a location in the document.
26///
27/// By default, links do not look any different from normal text. However, you
28/// can easily apply a style of your choice with a show rule.
29///
30/// = Example <example>
31/// ```example
32/// #show link: underline
33///
34/// https://example.com \
35///
36/// #link("https://example.com") \
37/// #link("https://example.com")[
38///   See example.com
39/// ]
40/// ```
41///
42/// = Syntax <syntax>
43/// This function also has dedicated syntax: Text that starts with `http://` or
44/// `https://` is automatically turned into a link.
45///
46/// = Hyphenation <hyphenation>
47/// If you enable hyphenation or justification, by default, it will not apply to
48/// links to prevent unwanted hyphenation in URLs. You can opt out of this
49/// default via `{show link: set text(hyphenate: true)}`.
50///
51/// = Accessibility <accessibility>
52/// The destination of a link should be clear from the link text itself, or at
53/// least from the text immediately surrounding it. In PDF export, Typst will
54/// automatically generate a tooltip description for links based on their
55/// destination. For links to URLs, the URL itself will be used as the tooltip.
56///
57/// = Links in HTML export <links-in-html-export>
58/// In @html[HTML export], a link to a @label[label] or @location[location] will
59/// be turned into a fragment link to a named anchor point. To support this,
60/// targets without an existing ID will automatically receive an ID in the DOM.
61/// How this works varies by which kind of HTML node(s) the link target turned
62/// into:
63///
64/// - If the link target turned into a single HTML element, that element will
65///   receive the ID. This is, for instance, typically the case when linking to
66///   a top-level heading (which turns into a single `<h2>` element).
67///
68/// - If the link target turned into a single text node, the node will be
69///   wrapped in a `<span>`, which will then receive the ID.
70///
71/// - If the link target turned into multiple nodes, the first node will receive
72///   the ID.
73///
74/// - If the link target turned into no nodes at all, an empty span will be
75///   generated to serve as a link target.
76///
77/// If you rely on a specific DOM structure, you should ensure that the link
78/// target turns into one or multiple elements, as the compiler makes no
79/// guarantees on the precise segmentation of text into text nodes.
80///
81/// If present, the automatic ID generation tries to reuse the link target's
82/// label to create a human-readable ID. A label can be reused if:
83///
84/// - All characters are alphabetic or numeric according to Unicode, or a
85///   hyphen, or an underscore.
86///
87/// - The label does not start with a digit or hyphen.
88///
89/// These rules ensure that the label is both a valid CSS identifier and a valid
90/// URL fragment for linking.
91///
92/// As IDs must be unique in the DOM, duplicate labels might need disambiguation
93/// when reusing them as IDs. The precise rules for this are as follows:
94///
95/// - If a label can be reused and is unique in the document, it will directly
96///   be used as the ID.
97///
98/// - If it's reusable, but not unique, a suffix consisting of a hyphen and an
99///   integer will be added. For instance, if the label `<mylabel>` exists
100///   twice, it would turn into `mylabel-1` and `mylabel-2`.
101///
102/// - Otherwise, a unique ID of the form `loc-` followed by an integer will be
103///   generated.
104///
105/// = Links in bundle export <links-in-bundle-export>
106/// In @reference:bundle[bundle export], linking still works as usual. For
107/// instance, if you attach a label to an element in one document, links in
108/// other documents can reference that label. In addition, documents and assets
109/// are also directly linkable. To link to a full document or asset, you can
110/// attach a label to it or @query[query] for it and extract its
111/// @location[location].
112///
113/// ```typ
114/// #document("index.html")[
115///   // Link to document.
116///   #link(<appendix>)[To appendix]
117///
118///   // Link into document.
119///   See the #link(<glossary>)[Glossary]
120///   for more information.
121/// ]
122///
123/// #document("appendix.html")[
124///   = Definitions
125///   ...
126///
127///   = Glossary <glossary>
128///   ...
129/// ] <appendix>
130/// ```
131///
132/// Cross-document links are emitted as relative paths (potentially with
133/// fragments). Typst automatically assigns anchor names per document based on
134/// the same rules as in HTML export. In HTML and SVG documents, these are
135/// emitted as `id` attributes on elements. In PDF documents, they are emitted
136/// as _named destinations._ PNG documents do not support linking.
137///
138/// Note that links always use full relative paths. In some scenarios (primarily
139/// for multi-page websites), this may not be desirable. For instance, you may
140/// want to generate a `/blog/index.html` document while wanting to link to it
141/// as just `/blog`. Furthermore, your web server might treat `/blog` and
142/// `/blog/` as interchangeable and serve `/blog/index.html` for both. If a user
143/// then navigates to `/blog`, relative links to other pages generated by Typst
144/// will no longer work. Currently, Typst does not have a way to directly hook
145/// into the built-in link handling. That said, in HTML export, depending on
146/// your use case, it may be possible to adjust the built-in link handling with
147/// a show rule on `{html.elem.where(tag: "a")}`.
148#[elem(Locatable)]
149pub struct LinkElem {
150    /// The destination the link points to.
151    ///
152    /// - To link to web pages, `dest` should be a valid URL string. If the URL
153    ///   is in the `mailto:` or `tel:` scheme and the `body` parameter is
154    ///   omitted, the email address or phone number will be the link's body,
155    ///   without the scheme.
156    ///
157    /// - To link to another part of the document, `dest` can take one of three
158    ///   forms:
159    ///   - A @label[label] attached to an element. If you also want automatic
160    ///     text for the link based on the element, consider using a
161    ///     @ref[reference] instead.
162    ///
163    ///   - A @location (typically retrieved from @here, @locate or @query).
164    ///
165    ///   - A dictionary with a `page` key of type @int[integer] and `x` and `y`
166    ///     coordinates of type @length[length]. Pages are counted from one, and
167    ///     the coordinates are relative to the page's top left corner.
168    ///
169    /// ```example
170    /// = Introduction <intro>
171    /// #link("mailto:hello@typst.app") \
172    /// #link(<intro>)[Go to intro] \
173    /// #link((page: 1, x: 0pt, y: 0pt))[
174    ///   Go to top
175    /// ]
176    /// ```
177    #[required]
178    #[parse(
179        let dest = args.expect::<LinkTarget>("destination")?;
180        dest.clone()
181    )]
182    pub dest: LinkTarget,
183
184    /// The content that should become a link.
185    ///
186    /// If `dest` is an URL string, the parameter can be omitted. In this case,
187    /// the URL will be shown as the link.
188    #[required]
189    #[parse(match &dest {
190        LinkTarget::Dest(Destination::Url(url)) => match args.eat()? {
191            Some(body) => body,
192            None => body_from_url(url),
193        },
194        _ => args.expect("body")?,
195    })]
196    pub body: Content,
197
198    /// A destination style that should be applied to elements.
199    #[internal]
200    #[ghost]
201    pub current: Option<Destination>,
202}
203
204impl LinkElem {
205    /// Create a link element from a URL with its bare text.
206    pub fn from_url(url: Url) -> Self {
207        let body = body_from_url(&url);
208        Self::new(LinkTarget::Dest(Destination::Url(url)), body)
209    }
210
211    /// Finds all linked-to locations referenced in an introspector.
212    pub fn find_destinations(
213        introspector: &dyn Introspector,
214    ) -> impl Iterator<Item = Location> {
215        introspector
216            .query(&Self::ELEM.select())
217            .into_iter()
218            .map(|elem| elem.into_packed::<Self>().unwrap())
219            .filter_map(|elem| match elem.dest.resolve_late(introspector) {
220                Ok(Destination::Location(loc)) => Some(loc),
221                _ => None,
222            })
223    }
224}
225
226impl ShowSet for Packed<LinkElem> {
227    fn show_set(&self, _: StyleChain) -> Styles {
228        let mut out = Styles::new();
229        out.set(TextElem::hyphenate, Smart::Custom(false));
230        out
231    }
232}
233
234pub(crate) fn body_from_url(url: &Url) -> Content {
235    let stripped = url.strip_contact_scheme().map(|(_, s)| s.into());
236    TextElem::packed(stripped.unwrap_or_else(|| url.clone().into_inner()))
237}
238
239/// A target where a link can go.
240#[derive(Debug, Clone, PartialEq, Hash)]
241pub enum LinkTarget {
242    Dest(Destination),
243    Label(Label),
244}
245
246impl LinkTarget {
247    /// Resolves the destination.
248    pub fn resolve_early(
249        &self,
250        engine: &mut Engine,
251        span: Span,
252    ) -> SourceResult<Destination> {
253        Ok(match self {
254            LinkTarget::Dest(dest) => dest.clone(),
255            LinkTarget::Label(label) => {
256                let elem =
257                    engine.introspect(QueryLabelIntrospection(*label, span)).at(span)?;
258                Destination::Location(elem.location().unwrap())
259            }
260        })
261    }
262
263    /// Resolves the destination without an engine.
264    pub fn resolve_late(
265        &self,
266        introspector: &dyn Introspector,
267    ) -> StrResult<Destination> {
268        Ok(match self {
269            LinkTarget::Dest(dest) => dest.clone(),
270            LinkTarget::Label(label) => {
271                let elem = introspector.query_label(*label)?;
272                Destination::Location(elem.location().unwrap())
273            }
274        })
275    }
276}
277
278cast! {
279    LinkTarget,
280    self => match self {
281        Self::Dest(v) => v.into_value(),
282        Self::Label(v) => v.into_value(),
283    },
284    v: Destination => Self::Dest(v),
285    v: Label => Self::Label(v),
286}
287
288impl From<Destination> for LinkTarget {
289    fn from(dest: Destination) -> Self {
290        Self::Dest(dest)
291    }
292}
293
294/// A link destination.
295#[derive(Debug, Clone, Eq, PartialEq, Hash)]
296pub enum Destination {
297    /// A link to a URL.
298    Url(Url),
299    /// A link to a point on a page.
300    Position(PagedPosition),
301    /// An unresolved link to a location in the document.
302    Location(Location),
303}
304
305impl Destination {
306    pub fn alt_text(
307        &self,
308        engine: &mut Engine,
309        styles: StyleChain,
310        span: Span,
311    ) -> SourceResult<EcoString> {
312        match self {
313            Destination::Url(url) => {
314                let contact = url.strip_contact_scheme().map(|(scheme, stripped)| {
315                    eco_format!("{} {stripped}", scheme.local_name_in(styles))
316                });
317                Ok(contact.unwrap_or_else(|| url.clone().into_inner()))
318            }
319            Destination::Position(pos) => {
320                let page_nr = eco_format!("{}", pos.page.get());
321                let page_str = PageElem::local_name_in(styles);
322                Ok(eco_format!("{page_str} {page_nr}"))
323            }
324            &Destination::Location(loc) => {
325                let fallback = |engine: &mut Engine| {
326                    // Fall back to a generating a page reference.
327                    let numbering =
328                        loc.page_numbering(engine, span).unwrap_or_else(|| {
329                            NumberingPattern::from_str("1").unwrap().into()
330                        });
331                    let page_nr = Counter::new(CounterKey::Page)
332                        .display_at(engine, loc, styles, &numbering, span)?
333                        .plain_text();
334                    let page_str = PageElem::local_name_in(styles);
335                    Ok(eco_format!("{page_str} {page_nr}"))
336                };
337
338                // Try to generate more meaningful alt text if the location is a
339                // refable element.
340                if let Some(elem) = engine
341                    .introspect(QueryFirstIntrospection(Selector::Location(loc), span))
342                    && let Some(refable) = elem.with::<dyn Refable>()
343                {
344                    let counter = refable.counter();
345                    let supplement = refable.supplement().plain_text();
346
347                    if let Some(numbering) = refable.numbering() {
348                        let numbers = counter.display_at(
349                            engine,
350                            loc,
351                            styles,
352                            &numbering.clone().trimmed(),
353                            span,
354                        )?;
355                        return Ok(eco_format!("{supplement} {}", numbers.plain_text()));
356                    } else {
357                        let page_ref = fallback(engine)?;
358                        return Ok(eco_format!("{supplement}, {page_ref}"));
359                    }
360                }
361
362                fallback(engine)
363            }
364        }
365    }
366}
367
368impl Repr for Destination {
369    fn repr(&self) -> EcoString {
370        eco_format!("{self:?}")
371    }
372}
373
374cast! {
375    Destination,
376    self => match self {
377        Self::Url(v) => v.into_value(),
378        Self::Position(v) => v.into_value(),
379        Self::Location(v) => v.into_value(),
380    },
381    v: Url => Self::Url(v),
382    v: PagedPosition => Self::Position(v),
383    v: Location => Self::Location(v),
384}
385
386/// A uniform resource locator with a maximum length.
387#[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
388pub struct Url(EcoString);
389
390impl Url {
391    /// Create a URL from a string, checking the maximum length.
392    pub fn new(url: impl Into<EcoString>) -> StrResult<Self> {
393        let url = url.into();
394        if url.len() > 8000 {
395            bail!("URL is too long")
396        } else if url.is_empty() {
397            bail!("URL must not be empty")
398        }
399        Ok(Self(url))
400    }
401
402    /// Extract the underlying [`EcoString`].
403    pub fn into_inner(self) -> EcoString {
404        self.0
405    }
406
407    pub fn strip_contact_scheme(&self) -> Option<(UrlContactScheme, &str)> {
408        [UrlContactScheme::Mailto, UrlContactScheme::Tel]
409            .into_iter()
410            .find_map(|scheme| {
411                let stripped = self.strip_prefix(scheme.as_str())?;
412                Some((scheme, stripped))
413            })
414    }
415}
416
417impl Deref for Url {
418    type Target = EcoString;
419
420    fn deref(&self) -> &Self::Target {
421        &self.0
422    }
423}
424
425cast! {
426    Url,
427    self => self.0.into_value(),
428    v: EcoString => Self::new(v)?,
429}
430
431/// This is a temporary hack to dispatch to
432/// - a raw link that does not go through `LinkElem` in paged
433/// - `LinkElem` in HTML (there is no equivalent to a direct link)
434///
435/// We'll want to dispatch all kinds of links to `LinkElem` in the future, but
436/// this is a visually breaking change in paged export as e.g.
437/// `show link: underline` will suddenly also affect references, bibliography
438/// back references, footnote references, etc. We'll want to do this change
439/// carefully and in a way where we provide a good way to keep styling only URL
440/// links, which is a bit too complicated to achieve right now for such a basic
441/// requirement.
442#[elem(Construct)]
443pub struct DirectLinkElem {
444    #[required]
445    #[internal]
446    pub loc: Location,
447    #[required]
448    #[internal]
449    pub body: Content,
450    #[required]
451    #[internal]
452    pub alt: Option<EcoString>,
453}
454
455impl Construct for DirectLinkElem {
456    fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
457        bail!(args.span, "cannot be constructed manually");
458    }
459}
460
461/// An element that wraps all content that is @Content::linked to a destination.
462#[elem(Tagged, Construct)]
463pub struct LinkMarker {
464    /// The content.
465    #[internal]
466    #[required]
467    pub body: Content,
468    #[internal]
469    #[required]
470    pub alt: Option<EcoString>,
471}
472
473impl Construct for LinkMarker {
474    fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
475        bail!(args.span, "cannot be constructed manually");
476    }
477}
478
479#[derive(Copy, Clone)]
480pub enum UrlContactScheme {
481    /// The `mailto:` prefix.
482    Mailto,
483    /// The `tel:` prefix.
484    Tel,
485}
486
487impl UrlContactScheme {
488    pub fn as_str(self) -> &'static str {
489        match self {
490            Self::Mailto => "mailto:",
491            Self::Tel => "tel:",
492        }
493    }
494
495    pub fn local_name_in(self, styles: StyleChain) -> &'static str {
496        match self {
497            UrlContactScheme::Mailto => Email::local_name_in(styles),
498            UrlContactScheme::Tel => Telephone::local_name_in(styles),
499        }
500    }
501}
502
503#[derive(Copy, Clone)]
504pub struct Email;
505impl LocalName for Email {
506    const KEY: &'static str = "email";
507}
508
509#[derive(Copy, Clone)]
510pub struct Telephone;
511impl LocalName for Telephone {
512    const KEY: &'static str = "telephone";
513}
514
515/// Creates unique IDs for elements.
516pub struct AnchorGenerator<'a> {
517    introspector: &'a dyn Introspector,
518    loc_counter: usize,
519    label_counter: FxHashMap<Label, usize>,
520}
521
522impl<'a> AnchorGenerator<'a> {
523    /// Creates a new identificator.
524    pub fn new(introspector: &'a dyn Introspector) -> Self {
525        Self {
526            introspector,
527            loc_counter: 0,
528            label_counter: FxHashMap::default(),
529        }
530    }
531
532    /// Returns a reference to the underlying introspector.
533    pub fn introspector(&self) -> &'a dyn Introspector {
534        self.introspector
535    }
536
537    /// Generates an ID, potentially based on a label.
538    pub fn identify(&mut self, label: Option<Label>) -> EcoString {
539        if let Some(label) = label {
540            let resolved = label.resolve();
541            let text = resolved.as_str();
542            if can_use_label_as_id(text) {
543                if self.introspector.label_count(label) == 1 {
544                    return text.into();
545                }
546
547                let counter = self.label_counter.entry(label).or_insert(0);
548                *counter += 1;
549                return disambiguate(self.introspector, text, counter);
550            }
551        }
552
553        self.loc_counter += 1;
554        disambiguate(self.introspector, "loc", &mut self.loc_counter)
555    }
556}
557
558/// Whether the label is both a valid CSS identifier and a valid URL fragment
559/// for linking.
560///
561/// This is slightly more restrictive than HTML and CSS, but easier to
562/// understand and explain.
563fn can_use_label_as_id(label: &str) -> bool {
564    !label.is_empty()
565        && label.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
566        && !label.starts_with(|c: char| c.is_numeric() || c == '-')
567}
568
569/// Disambiguates `text` with the suffix `-{counter}`, while ensuring that this
570/// does not result in a collision with an existing label.
571fn disambiguate(
572    introspector: &dyn Introspector,
573    text: &str,
574    counter: &mut usize,
575) -> EcoString {
576    loop {
577        let disambiguated = eco_format!("{text}-{counter}");
578        if PicoStr::get(&disambiguated)
579            .and_then(Label::new)
580            .is_some_and(|label| introspector.label_count(label) > 0)
581        {
582            *counter += 1;
583        } else {
584            break disambiguated;
585        }
586    }
587}
588
589/// Resolves location links during compilation.
590///
591/// This is used in HTML export as there isn't a dedicated export stage that
592/// could make use of the [`LateLinkResolver`]. There is the HTML serialization,
593/// but that's not an appropriate stage for meaningful DOM manipulation.
594pub struct EarlyLinkResolver {
595    base: Location,
596    span: Span,
597}
598
599impl EarlyLinkResolver {
600    /// Creates a resolver that resolves links relatively to the element with
601    /// the given location.
602    pub fn new(base: Location, span: Span) -> Self {
603        Self { base, span }
604    }
605
606    /// Resolves a link to the given location.
607    pub fn resolve(
608        &self,
609        engine: &mut Engine,
610        location: Location,
611    ) -> StrResult<ResolvedLink> {
612        let from = engine.introspect(PathIntrospection(self.base, self.span));
613        let to = engine.introspect(PathIntrospection(location, self.span));
614        let anchor = engine
615            .introspect(LinkAnchorIntrospection(location, self.span))
616            .ok_or("failed to determine link anchor")?;
617
618        Ok(match (from, to) {
619            // This is the normal case in single file export.
620            (None, None) => ResolvedLink::Local { anchor },
621            // This is the normal case in bundle export.
622            (Some(from), Some(to)) => {
623                if from == to {
624                    ResolvedLink::Local { anchor }
625                } else {
626                    ResolvedLink::Cross { from, to, anchor }
627                }
628            }
629            // This can, for instance, happen when trying to link to
630            // metadata that is not within a document (top-level in the
631            // bundle).
632            (Some(_), None) => {
633                bail!("link destination is not within a document")
634            }
635            // This is rather unlikely because we can't resolve a link rule
636            // in a non-file. It could happen in a non-convergent case.
637            (None, Some(_)) => bail!("failed to resolve cross-link"),
638        })
639    }
640}
641
642/// Resolves location links during export.
643///
644/// This is used in paged exports. Compared to the [`EarlyLinkResolver`], this
645/// one can save an introspection iteration as links don't need to be fully
646/// resolved during compilation. Keeping the location link unresolved will also
647/// be useful for tagging links in PDF 2.0 (linking to an element and not just a
648/// position).
649///
650/// The downside is that links could be silently broken in a non-converging
651/// scenario where HTML would instead generate an error, so it's a bit of a
652/// trade-off and not entirely clear whether this is the best way to do it.
653pub struct LateLinkResolver<'a> {
654    base: Option<&'a VirtualPath>,
655    introspector: &'a dyn Introspector,
656}
657
658impl<'a> LateLinkResolver<'a> {
659    /// Creates a resolver.
660    ///
661    /// - In single-document export, `base` should be `None`.
662    /// - In bundle export, `base` should be the path of the document relative
663    ///   to which links shall be resolved.
664    pub fn new(
665        base: Option<&'a VirtualPath>,
666        introspector: &'a dyn Introspector,
667    ) -> Self {
668        Self { base, introspector }
669    }
670}
671
672/// Resolves a link to the given location.
673#[comemo::track]
674impl<'a> LateLinkResolver<'a> {
675    pub fn resolve(&self, location: Location) -> Option<ResolvedLink> {
676        let from = self.base;
677        let to = self.introspector.path(location);
678        let anchor = self.introspector.anchor(location)?.clone();
679
680        // See `EarlyLinkResolver::resolve` for more details.
681        Some(match (from, to) {
682            (None, None) => ResolvedLink::Local { anchor },
683            (Some(from), Some(to)) => {
684                if from == to {
685                    ResolvedLink::Local { anchor }
686                } else {
687                    ResolvedLink::Cross { from: from.clone(), to: to.clone(), anchor }
688                }
689            }
690            (Some(_), None) => return None,
691            (None, Some(_)) => return None,
692        })
693    }
694}
695
696/// A resolved internal link.
697#[derive(Debug, Clone, Eq, PartialEq, Hash)]
698pub enum ResolvedLink {
699    /// Should link to an anchor in the same document.
700    Local {
701        /// The anchor to link to. If empty, should link to the full current
702        /// document.
703        anchor: EcoString,
704    },
705    /// Should link to an anchor in another document.
706    Cross {
707        /// The path of the file containing the link.
708        from: VirtualPath,
709        /// The path of the linked-to file.
710        to: VirtualPath,
711        /// The anchor to link to in the `to` file. If empty, should link to the
712        /// full document.
713        anchor: EcoString,
714    },
715}
716
717impl ResolvedLink {
718    /// Turns the link into a relative URI, potentially with an `#` anchor
719    /// fragment.
720    ///
721    /// This will percent-encode characters in relative paths if necessary.
722    /// Anchor fragments are guaranteed to be URI-compatible so they are not
723    /// encoded.
724    pub fn into_relative_uri(self) -> StrResult<EcoString> {
725        Ok(match self {
726            // Still write the empty anchor if linking to the document itself
727            // because `#` doesn't trigger a reload unlike an empty href.
728            Self::Local { anchor } => eco_format!("#{anchor}"),
729            Self::Cross { from, to, anchor } => {
730                let Some(parent) = from.parent() else {
731                    // For this to happen, `src` would have to be `/`, which
732                    // is not allowed.
733                    bail!("containing document has invalid path");
734                };
735
736                let relative_path = to.relative_from(&parent);
737                let encoded = percent_encode_path(&relative_path);
738
739                if anchor.is_empty() {
740                    // Don't write a trailing `#` if linking to a full document.
741                    encoded
742                } else {
743                    eco_format!("{encoded}#{anchor}")
744                }
745            }
746        })
747    }
748}
749
750/// Cross-bundle relative paths may contain characters that are not safe to use
751/// in URIs. This function encodes them with percent encoding.
752fn percent_encode_path(relative_path: &str) -> EcoString {
753    /// This is the complement of an allow-list of alphanumeric + the listed
754    /// chars. The double negation is necessary because `percent_encode`
755    /// requires a denylist.
756    ///
757    /// Everything not in the allow-list is percent-encoded. What characters in
758    /// a URI need to be percent-encoded depends a bit on the context (e.g. `?`
759    /// does not necessarily need to be escaped in all positions). However, we
760    /// use a very strict allow-list because overcautious encoding only results
761    /// in less pretty URIs while a missing encoding could result in a broken
762    /// link.
763    static NOT_PATH_SAFE: percent_encoding::AsciiSet = percent_encoding::NON_ALPHANUMERIC
764        // These are all unreserved.
765        .remove(b'-')
766        .remove(b'.')
767        .remove(b'_')
768        .remove(b'~')
769        // Slashes should be kept as-is, as they separate fragments in the
770        // relative path.
771        .remove(b'/');
772
773    // Encode the path with URI encoding.
774    let encoded_parts =
775        percent_encoding::percent_encode(relative_path.as_bytes(), &NOT_PATH_SAFE);
776
777    // TODO: This loop can be replaced with `collect()` after bumping to ecow 0.3.
778    let mut encoded = EcoString::new();
779    for item in encoded_parts {
780        encoded.push_str(item);
781    }
782
783    encoded
784}
785
786/// Resolves the anchor to reach the linked-to element with the given location.
787#[derive(Debug, Clone, PartialEq, Hash)]
788struct LinkAnchorIntrospection(Location, Span);
789
790impl Introspect for LinkAnchorIntrospection {
791    type Output = Option<EcoString>;
792
793    fn introspect(
794        &self,
795        _: &mut Engine,
796        introspector: Tracked<dyn Introspector + '_>,
797    ) -> Self::Output {
798        introspector.anchor(self.0).cloned()
799    }
800
801    fn diagnose(&self, history: &History<Self::Output>) -> SourceDiagnostic {
802        let introspector = history.final_introspector();
803        let what = match introspector.query_first(&Selector::Location(self.0)) {
804            Some(content) => content.elem().name(),
805            None => "element",
806        };
807        warning!(
808            self.1,
809            "link anchor assigned to the destination {what} did not stabilize",
810        )
811        .with_hint(history.hint("anchors", |id| match id {
812            Some(id) => id.clone(),
813            None => "(no anchor)".into(),
814        }))
815    }
816}