summaryrefslogtreecommitdiff
path: root/crates/typst-docs/src/link.rs
blob: 64fb47f9f1a17dd1d15ac9fb25340ffe6bc4b682 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
use typst::diag::{bail, StrResult};
use typst::eval::Func;

use super::{get_module, GROUPS, LIBRARY};

/// Resolve an intra-doc link.
pub fn resolve(link: &str) -> StrResult<String> {
    if link.starts_with('#') || link.starts_with("http") {
        return Ok(link.to_string());
    }

    let (head, tail) = split_link(link)?;
    let mut route = match resolve_known(head) {
        Some(route) => route.into(),
        None => resolve_definition(head)?,
    };

    if !tail.is_empty() {
        route.push('/');
        route.push_str(tail);
    }

    if !route.contains('#') && !route.ends_with('/') {
        route.push('/');
    }

    Ok(route)
}

/// Split a link at the first slash.
fn split_link(link: &str) -> StrResult<(&str, &str)> {
    let first = link.split('/').next().unwrap_or(link);
    let rest = link[first.len()..].trim_start_matches('/');
    Ok((first, rest))
}

/// Resolve a `$` link head to a known destination.
fn resolve_known(head: &str) -> Option<&'static str> {
    Some(match head {
        "$tutorial" => "/docs/tutorial",
        "$reference" => "/docs/reference",
        "$category" => "/docs/reference",
        "$syntax" => "/docs/reference/syntax",
        "$styling" => "/docs/reference/styling",
        "$scripting" => "/docs/reference/scripting",
        "$guides" => "/docs/guides",
        "$packages" => "/docs/packages",
        "$changelog" => "/docs/changelog",
        "$community" => "/docs/community",
        _ => return None,
    })
}

/// Resolve a `$` link to a global definition.
fn resolve_definition(head: &str) -> StrResult<String> {
    let mut parts = head.trim_start_matches('$').split('.').peekable();
    let mut focus = &LIBRARY.global;
    while let Some(m) = parts.peek().and_then(|&name| get_module(focus, name).ok()) {
        focus = m;
        parts.next();
    }

    let name = parts.next().ok_or("link is missing first part")?;
    let value = focus.field(name)?;
    let Some(category) = focus.scope().get_category(name) else {
        bail!("{name} has no category");
    };

    // Handle grouped functions.
    if let Some(group) = GROUPS
        .iter()
        .filter(|_| category == "math")
        .find(|group| group.functions.iter().any(|func| func == name))
    {
        let mut route =
            format!("/docs/reference/math/{}/#functions-{}", group.name, name);
        if let Some(param) = parts.next() {
            route.push('-');
            route.push_str(param);
        }
        return Ok(route);
    }

    let mut route = format!("/docs/reference/{category}/{name}/");
    if let Some(next) = parts.next() {
        if value.field(next).is_ok() {
            route.push_str("#definitions-");
            route.push_str(next);
        } else if value
            .clone()
            .cast::<Func>()
            .map_or(false, |func| func.param(next).is_some())
        {
            route.push_str("#parameters-");
            route.push_str(next);
        } else {
            bail!("field {next} not found");
        }
    }

    Ok(route)
}