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}