ftml/tree/
link.rs

1/*
2 * tree/link.rs
3 *
4 * ftml - Library to parse Wikidot text
5 * Copyright (C) 2019-2025 Wikijump Team
6 *
7 * This program is free software: you can redistribute it and/or modify
8 * it under the terms of the GNU Affero General Public License as published by
9 * the Free Software Foundation, either version 3 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY; without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU Affero General Public License for more details.
16 *
17 * You should have received a copy of the GNU Affero General Public License
18 * along with this program. If not, see <http://www.gnu.org/licenses/>.
19 */
20
21use super::clone::string_to_owned;
22use crate::data::PageRef;
23use crate::settings::WikitextSettings;
24use crate::url::is_url;
25use std::borrow::Cow;
26use strum_macros::EnumIter;
27
28#[derive(Serialize, Deserialize, Debug, Hash, Clone, PartialEq, Eq)]
29#[serde(untagged)]
30pub enum LinkLocation<'a> {
31    /// This link points to a particular page on a wiki.
32    Page(PageRef),
33
34    /// This link is to a specific URL.
35    Url(Cow<'a, str>),
36}
37
38impl<'a> LinkLocation<'a> {
39    /// Like `parse()`, but also handles interwiki links.
40    pub fn parse_with_interwiki(
41        link: Cow<'a, str>,
42        settings: &WikitextSettings,
43    ) -> Option<(Self, LinkType)> {
44        // Handle interwiki (starts with "!", like "!wp:Apple")
45        match link.as_ref().strip_prefix('!') {
46            // Not interwiki, parse as normal
47            None => {
48                let interwiki = Self::parse(link);
49                let ltype = interwiki.link_type();
50                Some((interwiki, ltype))
51            }
52
53            // Try to interpret as interwiki
54            Some(link) => settings
55                .interwiki
56                .build(link)
57                .map(|url| (LinkLocation::Url(Cow::Owned(url)), LinkType::Interwiki)),
58        }
59    }
60
61    pub fn parse(link: Cow<'a, str>) -> Self {
62        // Check for direct URLs or anchor links
63        // TODO: parse local links into LinkLocation::Page
64        // Known bug: single "/" parsed into Url instead of Page
65        let link_str = &link;
66        if is_url(link_str) || link_str.starts_with('#') || link_str.starts_with("/") {
67            return LinkLocation::Url(link);
68        }
69
70        match PageRef::parse(link_str) {
71            Err(_) => LinkLocation::Url(link),
72            Ok(page_ref) => LinkLocation::Page(page_ref),
73        }
74    }
75
76    pub fn to_owned(&self) -> LinkLocation<'static> {
77        match self {
78            LinkLocation::Page(page) => LinkLocation::Page(page.to_owned()),
79            LinkLocation::Url(url) => LinkLocation::Url(string_to_owned(url)),
80        }
81    }
82
83    pub fn link_type(&self) -> LinkType {
84        match self {
85            LinkLocation::Page(_) => LinkType::Page,
86            LinkLocation::Url(_) => LinkType::Direct,
87        }
88    }
89}
90
91#[test]
92fn test_link_location() {
93    // Use of a helper function coerces None to be the right kind of Option<T>
94    #[inline]
95    fn convert_opt(s: Option<&str>) -> Option<String> {
96        s.map(String::from)
97    }
98
99    macro_rules! test {
100        // LinkLocation::Page
101        ($input:expr => $site:expr, $page:expr, $extra:expr $(,)?) => {{
102            let expected = LinkLocation::Page(PageRef {
103                site: convert_opt($site),
104                page: str!($page),
105                extra: convert_opt($extra),
106            });
107            test!($input; expected);
108        }};
109
110        // LinkLocation::Url
111        ($input:expr => $url:expr $(,)?) => {
112            let url = cow!($url);
113            let expected = LinkLocation::Url(url);
114            test!($input; expected);
115        };
116
117        // Specified LinkLocation
118        ($input:expr; $expected:expr $(,)?) => {{
119            let actual = LinkLocation::parse(cow!($input));
120            assert_eq!(
121                actual,
122                $expected,
123                "Actual link location result doesn't match expected",
124            );
125        }};
126    }
127
128    test!("" => "");
129    test!("#" => "#");
130    test!("#anchor" => "#anchor");
131
132    test!("page" => None, "page", None);
133    test!("page/edit" => None, "page", Some("/edit"));
134    test!("page#toc0" => None, "page", Some("#toc0"));
135    test!("page/comments#main" => None, "page", Some("/comments#main"));
136
137    test!("/page" => "/page");
138    test!("/page/edit" => "/page/edit");
139    test!("/page#toc0" => "/page#toc0");
140
141    test!("component:theme" => None, "component:theme", None);
142    test!(":scp-wiki:scp-1000" => Some("scp-wiki"), "scp-1000", None);
143    test!(
144        ":scp-wiki:scp-1000#page-options-bottom" =>
145            Some("scp-wiki"), "scp-1000", Some("#page-options-bottom"),
146    );
147    test!(
148        ":scp-wiki:component:theme" =>
149            Some("scp-wiki"), "component:theme", None,
150    );
151    test!(
152        ":scp-wiki:component:theme/edit/true" =>
153            Some("scp-wiki"), "component:theme", Some("/edit/true"),
154    );
155
156    test!("http://blog.wikidot.com/" => "http://blog.wikidot.com/");
157    test!("https://example.com" => "https://example.com");
158    test!("mailto:test@example.net" => "mailto:test@example.net");
159
160    test!("::page" => "::page");
161    test!("::component:theme" => "::component:theme");
162    test!("multiple:category:page" => None, "multiple-category:page", None);
163}
164
165#[derive(Serialize, Deserialize, Debug, Hash, Clone, PartialEq, Eq)]
166#[serde(rename_all = "kebab-case")]
167pub enum LinkLabel<'a> {
168    /// Custom text link label.
169    ///
170    /// Can be set to any arbitrary value of the input text's choosing.
171    Text(Cow<'a, str>),
172
173    /// Page slug-based link label.
174    ///
175    /// This is set when the link is also the label.
176    /// The link is pre-normalization but post-category stripping.
177    ///
178    /// For instance:
179    /// * `[[[SCP-001]]]`
180    /// * `[[[Ethics Committee Orientation]]]`
181    /// * `[[[system: Recent Pages]]]`
182    Slug(Cow<'a, str>),
183
184    /// URL-mirroring link label.
185    ///
186    /// This is where the label is just the same as the URL.
187    Url,
188
189    /// Article title-based link label.
190    ///
191    /// The label for this link is whatever the page's title is.
192    Page,
193}
194
195impl LinkLabel<'_> {
196    pub fn to_owned(&self) -> LinkLabel<'static> {
197        match self {
198            LinkLabel::Text(text) => LinkLabel::Text(string_to_owned(text)),
199            LinkLabel::Slug(text) => LinkLabel::Slug(string_to_owned(text)),
200            LinkLabel::Url => LinkLabel::Url,
201            LinkLabel::Page => LinkLabel::Page,
202        }
203    }
204}
205
206#[derive(EnumIter, Serialize, Deserialize, Debug, Hash, Copy, Clone, PartialEq, Eq)]
207#[serde(rename_all = "kebab-case")]
208pub enum LinkType {
209    /// This URL was specified directly.
210    ///
211    /// For instance, as a raw URL, or a single-bracket link.
212    Direct,
213
214    /// This URL was specified by specifying a particular Wikijump page.
215    ///
216    /// This variant comes from triple-bracket links.
217    Page,
218
219    /// This URL was generated via interwiki substitution.
220    Interwiki,
221
222    /// This URL points to an anchor elsewhere on this page.
223    Anchor,
224
225    /// This URL points to entries on a page in a table of contents.
226    TableOfContents,
227}
228
229impl LinkType {
230    pub fn name(self) -> &'static str {
231        match self {
232            LinkType::Direct => "direct",
233            LinkType::Page => "page",
234            LinkType::Interwiki => "interwiki",
235            LinkType::Anchor => "anchor",
236            LinkType::TableOfContents => "table-of-contents",
237        }
238    }
239}
240
241impl<'a> TryFrom<&'a str> for LinkType {
242    type Error = &'a str;
243
244    fn try_from(value: &'a str) -> Result<LinkType, &'a str> {
245        match value {
246            "direct" => Ok(LinkType::Direct),
247            "page" => Ok(LinkType::Page),
248            "interwiki" => Ok(LinkType::Interwiki),
249            "anchor" => Ok(LinkType::Anchor),
250            "table-of-contents" => Ok(LinkType::TableOfContents),
251            _ => Err(value),
252        }
253    }
254}
255
256/// Ensure `LinkType::name()` produces the same output as serde.
257#[test]
258fn link_type_name_serde() {
259    use strum::IntoEnumIterator;
260
261    for variant in LinkType::iter() {
262        let output = serde_json::to_string(&variant).expect("Unable to serialize JSON");
263        let serde_name: String =
264            serde_json::from_str(&output).expect("Unable to deserialize JSON");
265
266        assert_eq!(
267            &serde_name,
268            variant.name(),
269            "Serde name does not match variant name",
270        );
271
272        let converted: LinkType = serde_name
273            .as_str()
274            .try_into()
275            .expect("Could not convert item");
276
277        assert_eq!(converted, variant, "Converted item does not match variant");
278    }
279}