Skip to main content

plates_render/
links.rs

1//! Site layout helpers: root-relative prefixes, percent decoding, rewriting
2//! internal `.md` links to their published `.html` targets, and absolutizing a
3//! rendered body for consumers that read it away from the site.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8/// Compute the relative prefix to get from a page back to the site root.
9///
10/// `index.html` → `""`, `a/b.html` → `"../"`, `a/b/c.html` → `"../../"`.
11pub fn root_prefix(dest_filename: &str) -> String {
12    let depth = dest_filename.matches('/').count();
13    if depth == 0 {
14        String::new()
15    } else {
16        "../".repeat(depth)
17    }
18}
19
20/// Rewrite internal `.md` hyperlinks in rendered HTML to their published
21/// `.html` destinations, resolving relative/workspace-root paths via the
22/// `path_to_filename` map. External links, anchors, and non-`.md` hrefs are
23/// left untouched.
24///
25/// The lookup key is sanitized the same way `path_to_filename`'s keys are (see
26/// `sanitize_rel_path`), so a link like `First post!.md` resolves to the
27/// stored `First post.html` instead of a fabricated `First post!.html`.
28///
29/// A link whose target is **not** in this render set (excluded by audience
30/// visibility, or simply missing) is stripped: the `<a>` becomes a
31/// `<span class="unpublished-link">` that keeps the link text but isn't
32/// clickable, so the page never points at something that 404s.
33///
34/// Everything that is *not* a document link — an image, a PDF, an HTML
35/// attachment's island `<iframe>` — is then put through
36/// `rebase_root_absolute`, because a vault writes those paths from the vault
37/// root (`/img/photo.png`, prov's `path_style: root`) and a site is not always
38/// served from a domain root.
39pub fn transform_links(
40    html: &str,
41    current_path: &Path,
42    path_to_filename: &HashMap<PathBuf, String>,
43    workspace_dir: &Path,
44    dest_filename: &str,
45) -> String {
46    let prefix = root_prefix(dest_filename);
47    let html = &rewrite_document_links(
48        html,
49        current_path,
50        path_to_filename,
51        workspace_dir,
52        dest_filename,
53    );
54    rebase_root_absolute(html, &prefix)
55}
56
57/// The document-link half of [`transform_links`]: `.md`/`.dj`/`.html` hrefs to
58/// their published destinations, and unpublished targets to marked spans.
59///
60/// Runs *before* [`rebase_root_absolute`] on purpose. A document link is
61/// resolved against the page holding it, so `/post.md` has to still be
62/// recognizable as vault-root-absolute when it gets here; rebasing first would
63/// hand it over as the page-relative `post.md` and resolve it one directory too
64/// deep.
65fn rewrite_document_links(
66    html: &str,
67    current_path: &Path,
68    path_to_filename: &HashMap<PathBuf, String>,
69    workspace_dir: &Path,
70    dest_filename: &str,
71) -> String {
72    let prefix = root_prefix(dest_filename);
73    // to_canonical expects workspace-relative paths
74    let current_relative = current_path
75        .strip_prefix(workspace_dir)
76        .unwrap_or(current_path);
77
78    let mut result = String::with_capacity(html.len());
79    let mut remaining = html;
80
81    while let Some(tag_start) = remaining.find("<a ") {
82        // Emit everything before the anchor verbatim.
83        result.push_str(&remaining[..tag_start]);
84        let after = &remaining[tag_start..];
85
86        // Find the end of the opening tag. comrak escapes `>` inside attribute
87        // values, so the first `>` reliably closes the tag.
88        let Some(gt) = after.find('>') else {
89            result.push_str(after);
90            remaining = "";
91            break;
92        };
93        let open_tag = &after[..=gt];
94        let tail = &after[gt + 1..];
95
96        // Only internal `.md` links are candidates for rewrite/strip.
97        let canonical =
98            extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));
99
100        match canonical {
101            None => {
102                // External link, anchor, or non-`.md` target — leave untouched.
103                result.push_str(open_tag);
104                remaining = tail;
105            }
106            Some((canonical, suffix)) => {
107                // Anchors can't nest, so the next `</a>` closes this one.
108                let Some(close) = tail.find("</a>") else {
109                    result.push_str(open_tag);
110                    remaining = tail;
111                    continue;
112                };
113                let inner = &tail[..close];
114                let after_close = &tail[close + "</a>".len()..];
115
116                let key = workspace_dir.join(sanitize_rel_path(&canonical));
117                match path_to_filename.get(&key) {
118                    Some(html_path) => {
119                        // Published target — rewrite the href, keep the anchor.
120                        result.push_str(&replace_href(
121                            open_tag,
122                            &format!("{prefix}{html_path}{suffix}"),
123                        ));
124                        result.push_str(inner);
125                        result.push_str("</a>");
126                    }
127                    None => {
128                        // Not in this render set — strip to a marked span.
129                        result.push_str(
130                            r#"<span class="unpublished-link" title="This page isn’t published">"#,
131                        );
132                        result.push_str(inner);
133                        result.push_str("</span>");
134                    }
135                }
136                remaining = after_close;
137            }
138        }
139    }
140    result.push_str(remaining);
141
142    result
143}
144
145/// Extract the raw (still percent-encoded) `href="…"` value from an opening tag.
146fn extract_href(open_tag: &str) -> Option<&str> {
147    let start = open_tag.find("href=\"")? + 6;
148    let rest = &open_tag[start..];
149    let end = rest.find('"')?;
150    Some(&rest[..end])
151}
152
153/// If `raw_href` is an internal link to another *document*, return its
154/// workspace-relative canonical path and the `?query#fragment` that rode along
155/// with it; otherwise `None` (external, anchor and attachment links are
156/// skipped).
157///
158/// "Document" is [`prov::ContentFormat`]'s judgement, not a `.md` test: a vault
159/// links `.dj` and `.html` pages the same way it links `.md` ones, and each of
160/// them is rewritten to its published `.html` destination.
161///
162/// The suffix is split off **before** the extension test, and returned so it can
163/// be put back on the rewritten href. Testing the whole value meant a link to a
164/// heading — `about/index.md#projects`, which is how one page points at a
165/// section of another — had the extension `md#projects`, matched no content
166/// format, and was left as a `.md` href pointing at a file the site does not
167/// publish. It rides along unchanged rather than being re-resolved: a fragment
168/// names something inside the target document, which is the same fragment
169/// whatever the target's published filename turns out to be.
170fn document_link_canonical<'h>(
171    raw_href: &'h str,
172    current_relative: &Path,
173) -> Option<(String, &'h str)> {
174    if raw_href.starts_with("http://")
175        || raw_href.starts_with("https://")
176        || raw_href.starts_with('#')
177    {
178        return None;
179    }
180    let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
181    let decoded = percent_decode(path);
182    // The extension test runs on the decoded href: a link written
183    // `My%20Note.md` is a document link, and `.md` is not what it ends with.
184    prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
185    let target = prov::Link::parse_path_only(decoded.trim()).target;
186    Some((
187        prov::link::resolve(current_relative, &target)
188            .to_string_lossy()
189            .into_owned(),
190        suffix,
191    ))
192}
193
194/// Replace the `href="…"` value in an opening tag, preserving other attributes.
195fn replace_href(open_tag: &str, new_value: &str) -> String {
196    let Some(start) = open_tag.find("href=\"") else {
197        return open_tag.to_string();
198    };
199    let value_start = start + 6;
200    let rest = &open_tag[value_start..];
201    let Some(end) = rest.find('"') else {
202        return open_tag.to_string();
203    };
204    format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
205}
206
207/// Rewrite vault-root-absolute `href`/`src` values into page-relative ones.
208///
209/// A vault names its own files from its root — `![photo](/img/photo.png)`, the
210/// `path_style: root` prov writes links in — and a published site is *not*
211/// always served from a domain root: the namespace serves each site under
212/// `…/sites/<ns>/<site>/`, and a local preview server mounts every declared
213/// site under its own name. A `/img/photo.png` left as written escapes the site and 404s
214/// in both, while the attachment it means sits at `img/photo.png` below the
215/// site root. Rebasing through [`root_prefix`] is what the document links
216/// beside it already get.
217///
218/// Left alone: anything with a scheme, protocol-relative `//host/x` (absolute
219/// despite the leading slash), and a bare `/` (no path to rebase, and a site
220/// root is what it already means).
221///
222/// So a leading slash always means the *vault* root here, never the domain
223/// root. That is what a vault writes — this repo's own `config.yaml` sets
224/// `references: path_style: root`, so prov generates vault-root-absolute paths
225/// as the ordinary spelling of a link — and the costs are not symmetric: **an
226/// author who means the domain root can write a full URL**, which
227/// [`absolutize_html`] and this function both leave alone, while an author who
228/// means a vault path would have no way to say so. That escape hatch is the
229/// documented way to point outside the site.
230///
231/// This runs on rendered HTML, so by the time it sees a tag the document links
232/// in it are relative already — [`rewrite_document_links`] resolved them — and
233/// what is left holding a leading slash is the attachment case this is for.
234fn rebase_root_absolute(html: &str, prefix: &str) -> String {
235    let mut result = String::with_capacity(html.len());
236    let mut remaining = html;
237
238    while let Some(lt) = remaining.find('<') {
239        result.push_str(&remaining[..lt]);
240        let after = &remaining[lt..];
241
242        // The first `>` closes the tag — the same assumption the rest of this
243        // module makes about the renderer's attribute escaping.
244        let Some(gt) = after.find('>') else {
245            result.push_str(after);
246            return result;
247        };
248
249        let mut tag = after[..=gt].to_string();
250        for name in ["href", "src"] {
251            let Some((start, end)) = find_attr_value(&tag, name) else {
252                continue;
253            };
254            let value = &tag[start..end];
255            if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
256                continue;
257            }
258            let rebased = format!("{prefix}{}", &value[1..]);
259            tag.replace_range(start..end, &rebased);
260        }
261        result.push_str(&tag);
262        remaining = &after[gt + 1..];
263    }
264    result.push_str(remaining);
265
266    result
267}
268
269/// Rewrite a page's relative `href`/`src` values into absolute URLs under
270/// `base_url` — the rendition a reader gets *away from* the site.
271///
272/// A rendered body carries links relative to the page holding them
273/// (`../notes/target.html`, `_attachments/scan.jpg`), which is right for the
274/// published HTML and wrong everywhere the body travels without its page: a
275/// feed reader resolves them against the feed's own URL, and an email client
276/// against nothing at all. Syndicating a body unchanged turns every internal
277/// link and every image in it into a dead one.
278///
279/// Resolution is against the page's own directory, not the site root, because
280/// that is what the body's `../` prefixes were written relative to (see
281/// [`root_prefix`]).
282///
283/// Left alone, deliberately:
284///
285/// - anything carrying a scheme (`https:`, `mailto:`) or protocol-relative
286///   (`//host/x`) — already absolute;
287/// - fragment-only links (`#section`), which still resolve within the entry;
288/// - root-relative links (`/about`). `base_url` may itself carry path segments
289///   (a site is served at `…/sites/<ns>/<site>/`), so rebasing one would
290///   silently move it somewhere else. A body that came through
291///   [`transform_links`] has none left to worry about — the vault's own
292///   root-absolute paths were made page-relative by [`rebase_root_absolute`]
293///   before the feed ever saw them, and what reaches here with a leading slash
294///   is something this function did not write and should not move.
295///
296/// A path that climbs above the site root (`../../../etc`) is left alone too:
297/// it has no correct absolute form, and inventing one is worse than passing
298/// through a link that was already broken.
299///
300/// An empty `base_url` returns the html untouched — the callers that have no
301/// base skip syndication entirely.
302pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
303    let base = base_url.trim_end_matches('/');
304    if base.is_empty() {
305        return html.to_string();
306    }
307    let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);
308
309    let mut result = String::with_capacity(html.len());
310    let mut remaining = html;
311
312    while let Some(lt) = remaining.find('<') {
313        result.push_str(&remaining[..lt]);
314        let after = &remaining[lt..];
315
316        // comrak escapes `>` inside attribute values, so the first `>` closes
317        // the tag — the same assumption `transform_links` makes above.
318        let Some(gt) = after.find('>') else {
319            result.push_str(after);
320            return result;
321        };
322
323        result.push_str(&absolutize_tag(&after[..=gt], dir, base));
324        remaining = &after[gt + 1..];
325    }
326    result.push_str(remaining);
327
328    result
329}
330
331/// Rewrite the `href` and `src` values of a single tag.
332fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
333    let mut out = tag.to_string();
334    for name in ["href", "src"] {
335        let Some((start, end)) = find_attr_value(&out, name) else {
336            continue;
337        };
338        let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
339            continue;
340        };
341        out.replace_range(start..end, &absolute);
342    }
343    out
344}
345
346/// Byte range of the value in `name="…"`, requiring the name to begin at a
347/// word boundary so `src` does not also match `data-src`.
348fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
349    let pattern = format!("{name}=\"");
350    let mut from = 0;
351    while let Some(offset) = tag[from..].find(&pattern) {
352        let at = from + offset;
353        let start = at + pattern.len();
354        let end = start + tag[start..].find('"')?;
355        if at == 0
356            || tag[..at]
357                .chars()
358                .next_back()
359                .is_some_and(char::is_whitespace)
360        {
361            return Some((start, end));
362        }
363        from = end + 1;
364    }
365    None
366}
367
368/// Resolve one attribute value against the page's directory and the site base,
369/// or `None` to leave it as written. See [`absolutize_html`] for the cases.
370fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
371    if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
372        return None;
373    }
374
375    // Only the path resolves; any `?query` / `#fragment` rides along unchanged.
376    let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
377    if path.is_empty() {
378        return None;
379    }
380
381    let joined = if dir.is_empty() {
382        path.to_string()
383    } else {
384        format!("{dir}/{path}")
385    };
386    Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
387}
388
389/// Collapse `.` and `..` segments. `None` when the path climbs above its root.
390fn normalize_rel_path(path: &str) -> Option<String> {
391    let mut segments: Vec<&str> = Vec::new();
392    for segment in path.split('/') {
393        match segment {
394            "" | "." => {}
395            ".." => {
396                segments.pop()?;
397            }
398            other => segments.push(other),
399        }
400    }
401    if segments.is_empty() {
402        return None;
403    }
404    let mut joined = segments.join("/");
405    if path.ends_with('/') {
406        joined.push('/');
407    }
408    Some(joined)
409}
410
411/// Whether `value` opens with a URL scheme. A relative path that merely
412/// contains a colon (`notes/9:15.html`) is not one: a scheme is letters,
413/// digits, `+`, `-` and `.`, starting with a letter.
414fn has_scheme(value: &str) -> bool {
415    let Some(colon) = value.find(':') else {
416        return false;
417    };
418    let scheme = &value[..colon];
419    scheme.starts_with(|c: char| c.is_ascii_alphabetic())
420        && scheme
421            .chars()
422            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
423}
424
425/// Sanitize a single path component for safe use in URLs. Keeps alphanumerics,
426/// spaces, dots, hyphens, and underscores; strips URL-unsafe characters. Mirrors
427/// the publish client's dest-name sanitization so links resolve to the stored
428/// filenames.
429pub fn sanitize_path_component(s: &str) -> String {
430    s.chars()
431        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
432        .collect()
433}
434
435/// Sanitize each component of a relative path, preserving its extension. Used to
436/// normalize both stored source paths and resolved frontmatter/body links to a
437/// common key form.
438pub fn sanitize_rel_path(path: &str) -> String {
439    let sanitized: PathBuf = Path::new(path)
440        .components()
441        .map(|c| match c {
442            std::path::Component::Normal(s) => {
443                std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
444            }
445            other => other.as_os_str().to_owned(),
446        })
447        .collect();
448    sanitized.to_string_lossy().into_owned()
449}
450
451/// Decode percent-encoded characters in a URL string (e.g. `%20` → ` `).
452pub fn percent_decode(input: &str) -> String {
453    let mut result = Vec::with_capacity(input.len());
454    let bytes = input.as_bytes();
455    let mut i = 0;
456    while i < bytes.len() {
457        if bytes[i] == b'%'
458            && i + 2 < bytes.len()
459            && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
460        {
461            result.push(hi << 4 | lo);
462            i += 3;
463            continue;
464        }
465        result.push(bytes[i]);
466        i += 1;
467    }
468    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
469}
470
471fn hex_val(b: u8) -> Option<u8> {
472    match b {
473        b'0'..=b'9' => Some(b - b'0'),
474        b'a'..=b'f' => Some(b - b'a' + 10),
475        b'A'..=b'F' => Some(b - b'A' + 10),
476        _ => None,
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn root_prefix_depth() {
486        assert_eq!(root_prefix("index.html"), "");
487        assert_eq!(root_prefix("a/b.html"), "../");
488        assert_eq!(root_prefix("a/b/c.html"), "../../");
489    }
490
491    #[test]
492    fn percent_decode_cases() {
493        assert_eq!(percent_decode("hello"), "hello");
494        assert_eq!(percent_decode("hello%20world"), "hello world");
495        assert_eq!(
496            percent_decode("Message%20for%20my%20family.md"),
497            "Message for my family.md"
498        );
499        assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
500        // Incomplete sequences are left as-is
501        assert_eq!(percent_decode("hello%2"), "hello%2");
502        assert_eq!(percent_decode("hello%"), "hello%");
503        // Invalid hex chars left as-is
504        assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
505    }
506
507    #[test]
508    fn transform_links_rewrites_known_md_target() {
509        let workspace = Path::new("/ws");
510        let mut map = HashMap::new();
511        map.insert(
512            PathBuf::from("/ws/notes/target.md"),
513            "notes/target.html".to_string(),
514        );
515
516        let html = r#"<a href="target.md">x</a>"#;
517        let current = Path::new("/ws/notes/source.md");
518        let out = transform_links(html, current, &map, workspace, "notes/source.html");
519        // depth 1 → prefix "../"
520        assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
521    }
522
523    #[test]
524    fn transform_links_unknown_md_is_stripped_and_marked() {
525        // A link to a page that isn't in the render set (excluded/missing) must
526        // not become a dead .html link — it's stripped to a marked span that
527        // keeps the text but isn't clickable.
528        let workspace = Path::new("/ws");
529        let map = HashMap::new();
530        let html = r#"<a href="missing.md">link text</a>"#;
531        let current = Path::new("/ws/source.md");
532        let out = transform_links(html, current, &map, workspace, "source.html");
533        assert_eq!(
534            out,
535            r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
536        );
537    }
538
539    /// A link to a heading of another page. The extension test used to run on
540    /// the whole href, so `about/index.md#projects` had the "extension"
541    /// `md#projects`, matched no content format, and was published as a `.md`
542    /// link to a file the site does not serve.
543    #[test]
544    fn transform_links_rewrites_a_link_carrying_a_fragment() {
545        let workspace = Path::new("");
546        let mut map = HashMap::new();
547        map.insert(
548            PathBuf::from("about/index.md"),
549            "about/index.html".to_string(),
550        );
551
552        let html = r##"<a href="about/index.md#projects">p</a>"##;
553        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
554        assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);
555
556        // A query rides along too, and the fragment is not re-resolved against
557        // anything — it names a heading inside the target either way.
558        let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
559        let out = transform_links(
560            html,
561            Path::new("notes/deep.md"),
562            &map,
563            workspace,
564            "notes/deep.html",
565        );
566        assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
567    }
568
569    /// …and one whose target is not in the render set is still stripped, rather
570    /// than the fragment making it look like an anchor link.
571    #[test]
572    fn transform_links_strips_an_unpublished_target_with_a_fragment() {
573        let workspace = Path::new("");
574        let map = HashMap::new();
575        let html = r##"<a href="gone.md#sec">text</a>"##;
576        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
577        assert_eq!(
578            out,
579            r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
580        );
581    }
582
583    #[test]
584    fn transform_links_resolves_sanitized_target() {
585        // The link text references "First post!.md" but the stored/published key
586        // is the sanitized "First post.md" → "First post.html". The '!' must not
587        // leak into the href (regression for the sanitization-mismatch bug).
588        let workspace = Path::new("");
589        let mut map = HashMap::new();
590        map.insert(
591            PathBuf::from("First post.md"),
592            "First post.html".to_string(),
593        );
594        let html = r#"<a href="First%20post!.md">x</a>"#;
595        let current = Path::new("source.md");
596        let out = transform_links(html, current, &map, workspace, "source.html");
597        assert_eq!(out, r#"<a href="First post.html">x</a>"#);
598    }
599
600    #[test]
601    fn transform_links_preserves_inner_markup_when_stripping() {
602        let workspace = Path::new("");
603        let map = HashMap::new();
604        let html = r#"<a href="gone.md">see <em>this</em></a>"#;
605        let current = Path::new("source.md");
606        let out = transform_links(html, current, &map, workspace, "source.html");
607        assert!(out.contains(r#"<span class="unpublished-link""#));
608        assert!(out.contains("see <em>this</em></span>"));
609        assert!(!out.contains("<a "));
610    }
611
612    #[test]
613    fn absolutize_rewrites_href_and_src_from_the_root() {
614        let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
615        let out = absolutize_html(html, "index.html", "https://ex.com");
616        assert_eq!(
617            out,
618            r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
619        );
620    }
621
622    #[test]
623    fn absolutize_resolves_against_the_pages_own_directory() {
624        // The body of `a/b/c.html` writes `../` prefixes relative to itself, so
625        // resolving against the site root instead would land a segment too high.
626        let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
627        let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
628        assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
629        assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
630    }
631
632    #[test]
633    fn absolutize_rebases_under_a_base_url_that_has_a_path() {
634        // A site is served at `…/sites/<ns>/<site>/`, so the base is not an origin.
635        let html = r#"<img src="../_attachments/scan.jpg">"#;
636        let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
637        assert_eq!(
638            out,
639            r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
640        );
641    }
642
643    #[test]
644    fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
645        let html = r##"<a href="https://x.com/a">e</a><a href="//cdn/x.png">p</a><a href="/about">r</a><a href="#sec">f</a><a href="mailto:a@b.c">m</a>"##;
646        assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
647    }
648
649    #[test]
650    fn absolutize_keeps_query_and_fragment_suffixes() {
651        let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
652        let out = absolutize_html(html, "index.html", "https://ex.com");
653        assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
654        assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
655    }
656
657    #[test]
658    fn absolutize_leaves_a_path_that_climbs_above_the_root() {
659        // No correct absolute form exists; passing it through beats inventing one.
660        let html = r#"<a href="../../nope.html">x</a>"#;
661        assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
662    }
663
664    #[test]
665    fn absolutize_does_not_match_a_suffixed_attribute_name() {
666        let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
667        let out = absolutize_html(html, "index.html", "https://ex.com");
668        assert!(out.contains(r#"data-src="a.jpg""#));
669        assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
670    }
671
672    #[test]
673    fn absolutize_leaves_a_colon_in_a_filename_alone() {
674        let html = r#"<a href="notes/9:15.html">t</a>"#;
675        let out = absolutize_html(html, "index.html", "https://ex.com");
676        assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
677    }
678
679    #[test]
680    fn absolutize_without_a_base_is_a_no_op() {
681        let html = r#"<a href="post.html">x</a>"#;
682        assert_eq!(absolutize_html(html, "index.html", ""), html);
683    }
684
685    #[test]
686    fn absolutize_leaves_text_between_tags_untouched() {
687        let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
688        let out = absolutize_html(html, "index.html", "https://ex.com");
689        assert!(out.contains(r#"see href="post.html" below"#));
690        assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
691    }
692
693    /// The vault writes attachment paths from its own root, and a site is not
694    /// always served from a domain root — so a root-absolute `src` has to come
695    /// down to the page it sits on, exactly like the document links beside it.
696    #[test]
697    fn transform_links_rebases_root_absolute_attachments() {
698        let workspace = Path::new("");
699        let map = HashMap::new();
700        let html = r#"<img src="/img/photo.png" alt="a">"#;
701
702        // At the site root the prefix is empty, so the slash simply goes.
703        let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
704        assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);
705
706        // A page one directory down has to climb back out first.
707        let out = transform_links(
708            html,
709            Path::new("notes/deep.md"),
710            &map,
711            workspace,
712            "notes/deep.html",
713        );
714        assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
715    }
716
717    /// An island `<iframe>` and a plain link to a non-document attachment are
718    /// the same case — this is not an `<img>` rule.
719    #[test]
720    fn transform_links_rebases_every_root_absolute_src_and_href() {
721        let workspace = Path::new("");
722        let map = HashMap::new();
723        let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
724        let out = transform_links(
725            html,
726            Path::new("notes/deep.md"),
727            &map,
728            workspace,
729            "notes/deep.html",
730        );
731        assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
732        assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
733    }
734
735    /// A root-absolute link to a *document* is resolved through the render set
736    /// first, so it lands on the target's published page rather than being
737    /// rebased into a path that only looks right.
738    #[test]
739    fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
740        let workspace = Path::new("");
741        let mut map = HashMap::new();
742        map.insert(PathBuf::from("post.md"), "post.html".to_string());
743        let html = r#"<a href="/post.md">x</a>"#;
744        let out = transform_links(
745            html,
746            Path::new("notes/deep.md"),
747            &map,
748            workspace,
749            "notes/deep.html",
750        );
751        assert_eq!(out, r#"<a href="../post.html">x</a>"#);
752    }
753
754    /// A leading slash does not always mean a vault path: `//host/x` is
755    /// absolute, and a bare `/` is the site root already.
756    #[test]
757    fn transform_links_leaves_protocol_relative_and_bare_slash() {
758        let workspace = Path::new("");
759        let map = HashMap::new();
760        let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
761        let out = transform_links(
762            html,
763            Path::new("notes/deep.md"),
764            &map,
765            workspace,
766            "notes/deep.html",
767        );
768        assert_eq!(out, html);
769    }
770
771    #[test]
772    fn transform_links_leaves_external_and_anchors() {
773        let workspace = Path::new("/ws");
774        let map = HashMap::new();
775        let current = Path::new("/ws/source.md");
776        let html =
777            r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
778        let out = transform_links(html, current, &map, workspace, "source.html");
779        assert_eq!(out, html);
780    }
781}