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, HashSet};
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/// One more spelling is a link and not a stranger: an href that is already a
35/// page's **destination** (`notes/entry.html`, as site-root-relative as the
36/// map's values are). That is what a template writes when it reads `href` off
37/// an entry — `[:val[e.title]]({{e.href}})` — and it is rebased to the page's
38/// depth rather than resolved as a source path and stripped. A source path is
39/// tried first, so a hand-written link to an `.html` document the vault
40/// holds still means that document.
41///
42/// Everything that is *not* a document link — an image, a PDF, an HTML
43/// attachment's island `<iframe>` — is then put through
44/// `rebase_root_absolute`, because a vault writes those paths from the vault
45/// root (`/img/photo.png`, prov's `path_style: root`) and a site is not always
46/// served from a domain root.
47pub fn transform_links(
48    html: &str,
49    current_path: &Path,
50    path_to_filename: &HashMap<PathBuf, String>,
51    workspace_dir: &Path,
52    dest_filename: &str,
53) -> String {
54    transform_links_with_files(
55        html,
56        current_path,
57        path_to_filename,
58        workspace_dir,
59        dest_filename,
60        None,
61    )
62}
63
64/// [`transform_links`], knowing which *files* the site ships as well as
65/// which pages: a reference to a file it does not — an `<img>`, a `<video>`,
66/// an `<audio>`, an `<iframe>`, or an `<a>` pointing at one — is marked the
67/// way a link to an unpublished page is, rather than left to 404. `None`
68/// is a caller that does not know, and marks nothing.
69pub fn transform_links_with_files(
70    html: &str,
71    current_path: &Path,
72    path_to_filename: &HashMap<PathBuf, String>,
73    workspace_dir: &Path,
74    dest_filename: &str,
75    published_files: Option<&HashSet<String>>,
76) -> String {
77    let prefix = root_prefix(dest_filename);
78    let html = rewrite_document_links(
79        html,
80        current_path,
81        path_to_filename,
82        workspace_dir,
83        dest_filename,
84    );
85    let html = match published_files {
86        Some(published) => {
87            let current_relative = current_path
88                .strip_prefix(workspace_dir)
89                .unwrap_or(current_path);
90            mark_unpublished_files(&html, current_relative, published)
91        }
92        None => html,
93    };
94    rebase_root_absolute(&html, &prefix)
95}
96
97/// The tags a page reaches a file through, and the attribute each reaches
98/// it by. `<a>` is the one with a body to keep; the rest are replaced whole.
99const FILE_TAGS: &[(&str, &str, bool)] = &[
100    ("a", "href", true),
101    ("img", "src", false),
102    ("video", "src", true),
103    ("audio", "src", true),
104    ("iframe", "src", true),
105];
106
107/// What the build itself writes beside the pages, which no attachment list
108/// names and no page is wrong to link.
109fn is_generated_asset(canonical: &str) -> bool {
110    matches!(
111        canonical,
112        "style.css" | "feed.xml" | "rss.xml" | "sitemap.xml" | "robots.txt"
113    ) || canonical == crate::html::ISLAND_CHILD_SCRIPT_FILENAME
114        || (canonical.starts_with("favicon.") && !canonical.contains('/'))
115}
116
117/// The file half of [`rewrite_document_links`]: a reference to a file the
118/// site does not ship becomes the same marked span a link to an unpublished
119/// page becomes — the link's own text, an image's `alt`, or the file's name.
120///
121/// Runs before [`rebase_root_absolute`] for the reason document links do: a
122/// `/img/photo.png` has to still read as vault-root-absolute to resolve.
123/// `published` is every file the site ships, in the coordinates a page's
124/// destination is spelled in.
125fn mark_unpublished_files(
126    html: &str,
127    current_relative: &Path,
128    published: &HashSet<String>,
129) -> String {
130    let mut result = String::with_capacity(html.len());
131    let mut remaining = html;
132
133    while let Some(lt) = remaining.find('<') {
134        result.push_str(&remaining[..lt]);
135        let after = &remaining[lt..];
136        let Some(gt) = after.find('>') else {
137            result.push_str(after);
138            return result;
139        };
140        let open_tag = &after[..=gt];
141        let tail = &after[gt + 1..];
142
143        let tag_name = open_tag[1..]
144            .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
145            .next()
146            .unwrap_or("")
147            .to_ascii_lowercase();
148        let Some((_, attr, closes)) = FILE_TAGS.iter().find(|(t, _, _)| *t == tag_name) else {
149            result.push_str(open_tag);
150            remaining = tail;
151            continue;
152        };
153        let Some((start, end)) = find_attr_value(open_tag, attr) else {
154            result.push_str(open_tag);
155            remaining = tail;
156            continue;
157        };
158        let Some(canonical) = file_link_canonical(&open_tag[start..end], current_relative) else {
159            result.push_str(open_tag);
160            remaining = tail;
161            continue;
162        };
163        if published.contains(&canonical) || is_generated_asset(&canonical) {
164            result.push_str(open_tag);
165            remaining = tail;
166            continue;
167        }
168
169        // Withheld. What the span says is what the reader would have seen:
170        // the link's text, the image's `alt`, else the file's own name.
171        let name = canonical
172            .rsplit('/')
173            .next()
174            .unwrap_or(&canonical)
175            .to_string();
176        let (inner, rest) = if *closes {
177            let close = format!("</{tag_name}>");
178            match tail.find(&close) {
179                Some(at) => (tail[..at].to_string(), &tail[at + close.len()..]),
180                None => (String::new(), tail),
181            }
182        } else {
183            (String::new(), tail)
184        };
185        let text = if tag_name == "a" && !inner.trim().is_empty() {
186            inner
187        } else {
188            find_attr_value(open_tag, "alt")
189                .map(|(s, e)| open_tag[s..e].to_string())
190                .filter(|alt| !alt.trim().is_empty())
191                .unwrap_or_else(|| crate::page::html_escape(&name))
192        };
193        result.push_str(r#"<span class="unpublished-link" title="This file isn’t published">"#);
194        result.push_str(&text);
195        result.push_str("</span>");
196        remaining = rest;
197    }
198    result.push_str(remaining);
199    result
200}
201
202/// If `raw` is a reference to a *file* in the vault — not a page, not
203/// external, not an anchor, not inline data — its workspace-relative canonical
204/// path; otherwise `None`.
205fn file_link_canonical(raw: &str, current_relative: &Path) -> Option<String> {
206    let trimmed = raw.trim();
207    if trimmed.is_empty()
208        || trimmed.starts_with('#')
209        || trimmed.starts_with("//")
210        || trimmed.split_once(':').is_some_and(|(scheme, _)| {
211            !scheme.is_empty()
212                && scheme
213                    .chars()
214                    .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
215        })
216    {
217        return None;
218    }
219    let path = &trimmed[..trimmed.find(['?', '#']).unwrap_or(trimmed.len())];
220    let decoded = percent_decode(path);
221    if decoded.is_empty() || prov::ContentFormat::from_extension(Path::new(&decoded)).is_some() {
222        return None;
223    }
224    let target = prov::Link::parse_path_only(&decoded).target;
225    Some(
226        prov::link::resolve(current_relative, &target)
227            .to_string_lossy()
228            .into_owned(),
229    )
230}
231
232/// The document-link half of [`transform_links`]: `.md`/`.dj`/`.html` hrefs to
233/// their published destinations, and unpublished targets to marked spans.
234///
235/// Runs *before* [`rebase_root_absolute`] on purpose. A document link is
236/// resolved against the page holding it, so `/post.md` has to still be
237/// recognizable as vault-root-absolute when it gets here; rebasing first would
238/// hand it over as the page-relative `post.md` and resolve it one directory too
239/// deep.
240fn rewrite_document_links(
241    html: &str,
242    current_path: &Path,
243    path_to_filename: &HashMap<PathBuf, String>,
244    workspace_dir: &Path,
245    dest_filename: &str,
246) -> String {
247    let prefix = root_prefix(dest_filename);
248    // to_canonical expects workspace-relative paths
249    let current_relative = current_path
250        .strip_prefix(workspace_dir)
251        .unwrap_or(current_path);
252
253    // The destinations this render writes, for the template case above.
254    let destinations: HashSet<&str> = path_to_filename.values().map(String::as_str).collect();
255
256    let mut result = String::with_capacity(html.len());
257    let mut remaining = html;
258
259    while let Some(tag_start) = remaining.find("<a ") {
260        // Emit everything before the anchor verbatim.
261        result.push_str(&remaining[..tag_start]);
262        let after = &remaining[tag_start..];
263
264        // Find the end of the opening tag. comrak escapes `>` inside attribute
265        // values, so the first `>` reliably closes the tag.
266        let Some(gt) = after.find('>') else {
267            result.push_str(after);
268            remaining = "";
269            break;
270        };
271        let open_tag = &after[..=gt];
272        let tail = &after[gt + 1..];
273
274        // Only internal `.md` links are candidates for rewrite/strip.
275        let canonical =
276            extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));
277
278        match canonical {
279            None => {
280                // External link, anchor, or non-`.md` target — leave untouched.
281                result.push_str(open_tag);
282                remaining = tail;
283            }
284            Some((canonical, written, suffix)) => {
285                // Anchors can't nest, so the next `</a>` closes this one.
286                let Some(close) = tail.find("</a>") else {
287                    result.push_str(open_tag);
288                    remaining = tail;
289                    continue;
290                };
291                let inner = &tail[..close];
292                let after_close = &tail[close + "</a>".len()..];
293
294                let key = workspace_dir.join(sanitize_rel_path(&canonical));
295                match path_to_filename.get(&key) {
296                    Some(html_path) => {
297                        // Published target — rewrite the href, keep the anchor.
298                        result.push_str(&replace_href(
299                            open_tag,
300                            &format!("{prefix}{html_path}{suffix}"),
301                        ));
302                        result.push_str(inner);
303                        result.push_str("</a>");
304                    }
305                    None if destinations.contains(written.as_str()) => {
306                        // Already a destination, as a template writes one:
307                        // rebase it to this page's depth and keep the anchor.
308                        result.push_str(&replace_href(
309                            open_tag,
310                            &format!("{prefix}{written}{suffix}"),
311                        ));
312                        result.push_str(inner);
313                        result.push_str("</a>");
314                    }
315                    None => {
316                        // Not in this render set — strip to a marked span.
317                        result.push_str(
318                            r#"<span class="unpublished-link" title="This page isn’t published">"#,
319                        );
320                        result.push_str(inner);
321                        result.push_str("</span>");
322                    }
323                }
324                remaining = after_close;
325            }
326        }
327    }
328    result.push_str(remaining);
329
330    result
331}
332
333/// Extract the raw (still percent-encoded) `href="…"` value from an opening tag.
334fn extract_href(open_tag: &str) -> Option<&str> {
335    let start = open_tag.find("href=\"")? + 6;
336    let rest = &open_tag[start..];
337    let end = rest.find('"')?;
338    Some(&rest[..end])
339}
340
341/// If `raw_href` is an internal link to another *document*, return its
342/// workspace-relative canonical path, the decoded path as it was written, and
343/// the `?query#fragment` that rode along with it; otherwise `None` (external,
344/// anchor and attachment links are skipped).
345///
346/// "Document" is [`prov::ContentFormat`]'s judgement, not a `.md` test: a vault
347/// links `.dj` and `.html` pages the same way it links `.md` ones, and each of
348/// them is rewritten to its published `.html` destination.
349///
350/// The suffix is split off **before** the extension test, and returned so it can
351/// be put back on the rewritten href. Testing the whole value meant a link to a
352/// heading — `about/index.md#projects`, which is how one page points at a
353/// section of another — had the extension `md#projects`, matched no content
354/// format, and was left as a `.md` href pointing at a file the site does not
355/// publish. It rides along unchanged rather than being re-resolved: a fragment
356/// names something inside the target document, which is the same fragment
357/// whatever the target's published filename turns out to be.
358fn document_link_canonical<'h>(
359    raw_href: &'h str,
360    current_relative: &Path,
361) -> Option<(String, String, &'h str)> {
362    if raw_href.starts_with("http://")
363        || raw_href.starts_with("https://")
364        || raw_href.starts_with('#')
365    {
366        return None;
367    }
368    let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
369    let decoded = percent_decode(path);
370    // The extension test runs on the decoded href: a link written
371    // `My%20Note.md` is a document link, and `.md` is not what it ends with.
372    prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
373    let target = prov::Link::parse_path_only(decoded.trim()).target;
374    Some((
375        prov::link::resolve(current_relative, &target)
376            .to_string_lossy()
377            .into_owned(),
378        decoded.trim().to_string(),
379        suffix,
380    ))
381}
382
383/// Replace the `href="…"` value in an opening tag, preserving other attributes.
384fn replace_href(open_tag: &str, new_value: &str) -> String {
385    let Some(start) = open_tag.find("href=\"") else {
386        return open_tag.to_string();
387    };
388    let value_start = start + 6;
389    let rest = &open_tag[value_start..];
390    let Some(end) = rest.find('"') else {
391        return open_tag.to_string();
392    };
393    format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
394}
395
396/// Rewrite vault-root-absolute `href`/`src` values into page-relative ones.
397///
398/// A vault names its own files from its root — `![photo](/img/photo.png)`, the
399/// `path_style: root` prov writes links in — and a published site is *not*
400/// always served from a domain root: the namespace serves each site under
401/// `…/sites/<ns>/<site>/`, and a local preview server mounts every declared
402/// site under its own name. A `/img/photo.png` left as written escapes the site and 404s
403/// in both, while the attachment it means sits at `img/photo.png` below the
404/// site root. Rebasing through [`root_prefix`] is what the document links
405/// beside it already get.
406///
407/// Left alone: anything with a scheme, protocol-relative `//host/x` (absolute
408/// despite the leading slash), and a bare `/` (no path to rebase, and a site
409/// root is what it already means).
410///
411/// So a leading slash always means the *vault* root here, never the domain
412/// root. That is what a vault writes — this repo's own `config.yaml` sets
413/// `references: path_style: root`, so prov generates vault-root-absolute paths
414/// as the ordinary spelling of a link — and the costs are not symmetric: **an
415/// author who means the domain root can write a full URL**, which
416/// [`absolutize_html`] and this function both leave alone, while an author who
417/// means a vault path would have no way to say so. That escape hatch is the
418/// documented way to point outside the site.
419///
420/// This runs on rendered HTML, so by the time it sees a tag the document links
421/// in it are relative already — [`rewrite_document_links`] resolved them — and
422/// what is left holding a leading slash is the attachment case this is for.
423fn rebase_root_absolute(html: &str, prefix: &str) -> String {
424    let mut result = String::with_capacity(html.len());
425    let mut remaining = html;
426
427    while let Some(lt) = remaining.find('<') {
428        result.push_str(&remaining[..lt]);
429        let after = &remaining[lt..];
430
431        // The first `>` closes the tag — the same assumption the rest of this
432        // module makes about the renderer's attribute escaping.
433        let Some(gt) = after.find('>') else {
434            result.push_str(after);
435            return result;
436        };
437
438        let mut tag = after[..=gt].to_string();
439        for name in ["href", "src"] {
440            let Some((start, end)) = find_attr_value(&tag, name) else {
441                continue;
442            };
443            let value = &tag[start..end];
444            if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
445                continue;
446            }
447            let rebased = format!("{prefix}{}", &value[1..]);
448            tag.replace_range(start..end, &rebased);
449        }
450        result.push_str(&tag);
451        remaining = &after[gt + 1..];
452    }
453    result.push_str(remaining);
454
455    result
456}
457
458/// Rewrite a page's relative `href`/`src` values into absolute URLs under
459/// `base_url` — the rendition a reader gets *away from* the site.
460///
461/// A rendered body carries links relative to the page holding them
462/// (`../notes/target.html`, `_attachments/scan.jpg`), which is right for the
463/// published HTML and wrong everywhere the body travels without its page: a
464/// feed reader resolves them against the feed's own URL, and an email client
465/// against nothing at all. Syndicating a body unchanged turns every internal
466/// link and every image in it into a dead one.
467///
468/// Resolution is against the page's own directory, not the site root, because
469/// that is what the body's `../` prefixes were written relative to (see
470/// [`root_prefix`]).
471///
472/// Left alone, deliberately:
473///
474/// - anything carrying a scheme (`https:`, `mailto:`) or protocol-relative
475///   (`//host/x`) — already absolute;
476/// - fragment-only links (`#section`), which still resolve within the entry;
477/// - root-relative links (`/about`). `base_url` may itself carry path segments
478///   (a site is served at `…/sites/<ns>/<site>/`), so rebasing one would
479///   silently move it somewhere else. A body that came through
480///   [`transform_links`] has none left to worry about — the vault's own
481///   root-absolute paths were made page-relative by [`rebase_root_absolute`]
482///   before the feed ever saw them, and what reaches here with a leading slash
483///   is something this function did not write and should not move.
484///
485/// A path that climbs above the site root (`../../../etc`) is left alone too:
486/// it has no correct absolute form, and inventing one is worse than passing
487/// through a link that was already broken.
488///
489/// An empty `base_url` returns the html untouched — the callers that have no
490/// base skip syndication entirely.
491pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
492    let base = base_url.trim_end_matches('/');
493    if base.is_empty() {
494        return html.to_string();
495    }
496    let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);
497
498    let mut result = String::with_capacity(html.len());
499    let mut remaining = html;
500
501    while let Some(lt) = remaining.find('<') {
502        result.push_str(&remaining[..lt]);
503        let after = &remaining[lt..];
504
505        // comrak escapes `>` inside attribute values, so the first `>` closes
506        // the tag — the same assumption `transform_links` makes above.
507        let Some(gt) = after.find('>') else {
508            result.push_str(after);
509            return result;
510        };
511
512        result.push_str(&absolutize_tag(&after[..=gt], dir, base));
513        remaining = &after[gt + 1..];
514    }
515    result.push_str(remaining);
516
517    result
518}
519
520/// Rewrite the `href` and `src` values of a single tag.
521fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
522    let mut out = tag.to_string();
523    for name in ["href", "src"] {
524        let Some((start, end)) = find_attr_value(&out, name) else {
525            continue;
526        };
527        let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
528            continue;
529        };
530        out.replace_range(start..end, &absolute);
531    }
532    out
533}
534
535/// Byte range of the value in `name="…"`, requiring the name to begin at a
536/// word boundary so `src` does not also match `data-src`.
537fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
538    let pattern = format!("{name}=\"");
539    let mut from = 0;
540    while let Some(offset) = tag[from..].find(&pattern) {
541        let at = from + offset;
542        let start = at + pattern.len();
543        let end = start + tag[start..].find('"')?;
544        if at == 0
545            || tag[..at]
546                .chars()
547                .next_back()
548                .is_some_and(char::is_whitespace)
549        {
550            return Some((start, end));
551        }
552        from = end + 1;
553    }
554    None
555}
556
557/// Resolve one attribute value against the page's directory and the site base,
558/// or `None` to leave it as written. See [`absolutize_html`] for the cases.
559fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
560    if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
561        return None;
562    }
563
564    // Only the path resolves; any `?query` / `#fragment` rides along unchanged.
565    let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
566    if path.is_empty() {
567        return None;
568    }
569
570    let joined = if dir.is_empty() {
571        path.to_string()
572    } else {
573        format!("{dir}/{path}")
574    };
575    Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
576}
577
578/// Collapse `.` and `..` segments. `None` when the path climbs above its root.
579fn normalize_rel_path(path: &str) -> Option<String> {
580    let mut segments: Vec<&str> = Vec::new();
581    for segment in path.split('/') {
582        match segment {
583            "" | "." => {}
584            ".." => {
585                segments.pop()?;
586            }
587            other => segments.push(other),
588        }
589    }
590    if segments.is_empty() {
591        return None;
592    }
593    let mut joined = segments.join("/");
594    if path.ends_with('/') {
595        joined.push('/');
596    }
597    Some(joined)
598}
599
600/// Whether `value` opens with a URL scheme. A relative path that merely
601/// contains a colon (`notes/9:15.html`) is not one: a scheme is letters,
602/// digits, `+`, `-` and `.`, starting with a letter.
603fn has_scheme(value: &str) -> bool {
604    let Some(colon) = value.find(':') else {
605        return false;
606    };
607    let scheme = &value[..colon];
608    scheme.starts_with(|c: char| c.is_ascii_alphabetic())
609        && scheme
610            .chars()
611            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
612}
613
614/// Sanitize a single path component for safe use in URLs. Keeps alphanumerics,
615/// spaces, dots, hyphens, and underscores; strips URL-unsafe characters. Mirrors
616/// the publish client's dest-name sanitization so links resolve to the stored
617/// filenames.
618pub fn sanitize_path_component(s: &str) -> String {
619    s.chars()
620        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
621        .collect()
622}
623
624/// Sanitize each component of a relative path, preserving its extension. Used to
625/// normalize both stored source paths and resolved frontmatter/body links to a
626/// common key form.
627pub fn sanitize_rel_path(path: &str) -> String {
628    let sanitized: PathBuf = Path::new(path)
629        .components()
630        .map(|c| match c {
631            std::path::Component::Normal(s) => {
632                std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
633            }
634            other => other.as_os_str().to_owned(),
635        })
636        .collect();
637    sanitized.to_string_lossy().into_owned()
638}
639
640/// Decode percent-encoded characters in a URL string (e.g. `%20` → ` `).
641pub fn percent_decode(input: &str) -> String {
642    let mut result = Vec::with_capacity(input.len());
643    let bytes = input.as_bytes();
644    let mut i = 0;
645    while i < bytes.len() {
646        if bytes[i] == b'%'
647            && i + 2 < bytes.len()
648            && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
649        {
650            result.push(hi << 4 | lo);
651            i += 3;
652            continue;
653        }
654        result.push(bytes[i]);
655        i += 1;
656    }
657    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
658}
659
660fn hex_val(b: u8) -> Option<u8> {
661    match b {
662        b'0'..=b'9' => Some(b - b'0'),
663        b'a'..=b'f' => Some(b - b'a' + 10),
664        b'A'..=b'F' => Some(b - b'A' + 10),
665        _ => None,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn root_prefix_depth() {
675        assert_eq!(root_prefix("index.html"), "");
676        assert_eq!(root_prefix("a/b.html"), "../");
677        assert_eq!(root_prefix("a/b/c.html"), "../../");
678    }
679
680    #[test]
681    fn percent_decode_cases() {
682        assert_eq!(percent_decode("hello"), "hello");
683        assert_eq!(percent_decode("hello%20world"), "hello world");
684        assert_eq!(
685            percent_decode("Message%20for%20my%20family.md"),
686            "Message for my family.md"
687        );
688        assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
689        // Incomplete sequences are left as-is
690        assert_eq!(percent_decode("hello%2"), "hello%2");
691        assert_eq!(percent_decode("hello%"), "hello%");
692        // Invalid hex chars left as-is
693        assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
694    }
695
696    #[test]
697    fn transform_links_rewrites_known_md_target() {
698        let workspace = Path::new("/ws");
699        let mut map = HashMap::new();
700        map.insert(
701            PathBuf::from("/ws/notes/target.md"),
702            "notes/target.html".to_string(),
703        );
704
705        let html = r#"<a href="target.md">x</a>"#;
706        let current = Path::new("/ws/notes/source.md");
707        let out = transform_links(html, current, &map, workspace, "notes/source.html");
708        // depth 1 → prefix "../"
709        assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
710    }
711
712    #[test]
713    fn transform_links_unknown_md_is_stripped_and_marked() {
714        // A link to a page that isn't in the render set (excluded/missing) must
715        // not become a dead .html link — it's stripped to a marked span that
716        // keeps the text but isn't clickable.
717        let workspace = Path::new("/ws");
718        let map = HashMap::new();
719        let html = r#"<a href="missing.md">link text</a>"#;
720        let current = Path::new("/ws/source.md");
721        let out = transform_links(html, current, &map, workspace, "source.html");
722        assert_eq!(
723            out,
724            r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
725        );
726    }
727
728    /// A link to a heading of another page. The extension test used to run on
729    /// the whole href, so `about/index.md#projects` had the "extension"
730    /// `md#projects`, matched no content format, and was published as a `.md`
731    /// link to a file the site does not serve.
732    #[test]
733    fn transform_links_rewrites_a_link_carrying_a_fragment() {
734        let workspace = Path::new("");
735        let mut map = HashMap::new();
736        map.insert(
737            PathBuf::from("about/index.md"),
738            "about/index.html".to_string(),
739        );
740
741        let html = r##"<a href="about/index.md#projects">p</a>"##;
742        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
743        assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);
744
745        // A query rides along too, and the fragment is not re-resolved against
746        // anything — it names a heading inside the target either way.
747        let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
748        let out = transform_links(
749            html,
750            Path::new("notes/deep.md"),
751            &map,
752            workspace,
753            "notes/deep.html",
754        );
755        assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
756    }
757
758    /// …and one whose target is not in the render set is still stripped, rather
759    /// than the fragment making it look like an anchor link.
760    #[test]
761    fn transform_links_strips_an_unpublished_target_with_a_fragment() {
762        let workspace = Path::new("");
763        let map = HashMap::new();
764        let html = r##"<a href="gone.md#sec">text</a>"##;
765        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
766        assert_eq!(
767            out,
768            r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
769        );
770    }
771
772    #[test]
773    fn transform_links_resolves_sanitized_target() {
774        // The link text references "First post!.md" but the stored/published key
775        // is the sanitized "First post.md" → "First post.html". The '!' must not
776        // leak into the href (regression for the sanitization-mismatch bug).
777        let workspace = Path::new("");
778        let mut map = HashMap::new();
779        map.insert(
780            PathBuf::from("First post.md"),
781            "First post.html".to_string(),
782        );
783        let html = r#"<a href="First%20post!.md">x</a>"#;
784        let current = Path::new("source.md");
785        let out = transform_links(html, current, &map, workspace, "source.html");
786        assert_eq!(out, r#"<a href="First post.html">x</a>"#);
787    }
788
789    #[test]
790    fn transform_links_preserves_inner_markup_when_stripping() {
791        let workspace = Path::new("");
792        let map = HashMap::new();
793        let html = r#"<a href="gone.md">see <em>this</em></a>"#;
794        let current = Path::new("source.md");
795        let out = transform_links(html, current, &map, workspace, "source.html");
796        assert!(out.contains(r#"<span class="unpublished-link""#));
797        assert!(out.contains("see <em>this</em></span>"));
798        assert!(!out.contains("<a "));
799    }
800
801    #[test]
802    fn absolutize_rewrites_href_and_src_from_the_root() {
803        let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
804        let out = absolutize_html(html, "index.html", "https://ex.com");
805        assert_eq!(
806            out,
807            r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
808        );
809    }
810
811    #[test]
812    fn absolutize_resolves_against_the_pages_own_directory() {
813        // The body of `a/b/c.html` writes `../` prefixes relative to itself, so
814        // resolving against the site root instead would land a segment too high.
815        let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
816        let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
817        assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
818        assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
819    }
820
821    #[test]
822    fn absolutize_rebases_under_a_base_url_that_has_a_path() {
823        // A site is served at `…/sites/<ns>/<site>/`, so the base is not an origin.
824        let html = r#"<img src="../_attachments/scan.jpg">"#;
825        let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
826        assert_eq!(
827            out,
828            r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
829        );
830    }
831
832    #[test]
833    fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
834        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>"##;
835        assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
836    }
837
838    #[test]
839    fn absolutize_keeps_query_and_fragment_suffixes() {
840        let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
841        let out = absolutize_html(html, "index.html", "https://ex.com");
842        assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
843        assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
844    }
845
846    #[test]
847    fn absolutize_leaves_a_path_that_climbs_above_the_root() {
848        // No correct absolute form exists; passing it through beats inventing one.
849        let html = r#"<a href="../../nope.html">x</a>"#;
850        assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
851    }
852
853    #[test]
854    fn absolutize_does_not_match_a_suffixed_attribute_name() {
855        let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
856        let out = absolutize_html(html, "index.html", "https://ex.com");
857        assert!(out.contains(r#"data-src="a.jpg""#));
858        assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
859    }
860
861    #[test]
862    fn absolutize_leaves_a_colon_in_a_filename_alone() {
863        let html = r#"<a href="notes/9:15.html">t</a>"#;
864        let out = absolutize_html(html, "index.html", "https://ex.com");
865        assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
866    }
867
868    #[test]
869    fn absolutize_without_a_base_is_a_no_op() {
870        let html = r#"<a href="post.html">x</a>"#;
871        assert_eq!(absolutize_html(html, "index.html", ""), html);
872    }
873
874    #[test]
875    fn absolutize_leaves_text_between_tags_untouched() {
876        let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
877        let out = absolutize_html(html, "index.html", "https://ex.com");
878        assert!(out.contains(r#"see href="post.html" below"#));
879        assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
880    }
881
882    /// The vault writes attachment paths from its own root, and a site is not
883    /// always served from a domain root — so a root-absolute `src` has to come
884    /// down to the page it sits on, exactly like the document links beside it.
885    #[test]
886    fn transform_links_rebases_root_absolute_attachments() {
887        let workspace = Path::new("");
888        let map = HashMap::new();
889        let html = r#"<img src="/img/photo.png" alt="a">"#;
890
891        // At the site root the prefix is empty, so the slash simply goes.
892        let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
893        assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);
894
895        // A page one directory down has to climb back out first.
896        let out = transform_links(
897            html,
898            Path::new("notes/deep.md"),
899            &map,
900            workspace,
901            "notes/deep.html",
902        );
903        assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
904    }
905
906    /// A reference to a file the site does not ship is marked as a link to a
907    /// page it does not publish is: the image's `alt` where its picture would
908    /// have been, the link's text, a player's title; a file the site ships,
909    /// the build's own assets, and anything external are left alone; and a
910    /// caller that cannot say marks nothing.
911    #[test]
912    fn a_reference_to_a_withheld_file_is_marked_like_an_unpublished_page() {
913        let workspace = Path::new("");
914        let map = HashMap::new();
915        let html = concat!(
916            r#"<img src="attachments/private.jpg" alt="A private picture">"#,
917            r#"<img src="/attachments/shipped.jpg" alt="ok">"#,
918            r#"<a href="attachments/private.pdf">Read the scan</a>"#,
919            r#"<video controls src="attachments/private.mp4"></video>"#,
920            r#"<a href="https://example.com/x.jpg">out</a>"#,
921            r#"<a href="/feed.xml">feed</a>"#,
922            r#"<img src="attachments/nameless.png" alt="">"#,
923        );
924        let published: HashSet<String> = ["attachments/shipped.jpg".to_string()].into();
925
926        let out = transform_links_with_files(
927            html,
928            Path::new("index.md"),
929            &map,
930            workspace,
931            "index.html",
932            Some(&published),
933        );
934        assert_eq!(
935            out,
936            concat!(
937                r#"<span class="unpublished-link" title="This file isn’t published">A private picture</span>"#,
938                r#"<img src="attachments/shipped.jpg" alt="ok">"#,
939                r#"<span class="unpublished-link" title="This file isn’t published">Read the scan</span>"#,
940                r#"<span class="unpublished-link" title="This file isn’t published">private.mp4</span>"#,
941                r#"<a href="https://example.com/x.jpg">out</a>"#,
942                r#"<a href="feed.xml">feed</a>"#,
943                r#"<span class="unpublished-link" title="This file isn’t published">nameless.png</span>"#,
944            )
945        );
946
947        // Not knowing is not the same as knowing nothing ships.
948        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
949        assert!(
950            out.contains(r#"<img src="attachments/private.jpg""#),
951            "{out}"
952        );
953    }
954
955    /// A link whose href is already a page's destination — what a template
956    /// writes when it reads `href` off an entry — is a link to that page,
957    /// rebased to the page's depth, and not a source path to be stripped.
958    #[test]
959    fn a_destination_href_is_a_link_to_the_page_it_names() {
960        let workspace = Path::new("");
961        let mut map = HashMap::new();
962        map.insert(PathBuf::from("index.md"), "index.html".to_string());
963        map.insert(
964            PathBuf::from("notes/entry.md"),
965            "notes/entry.html".to_string(),
966        );
967        let html = r##"<a href="notes/entry.html#top">E</a> <a href="notes/gone.html">G</a>"##;
968
969        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
970        assert_eq!(
971            out,
972            r##"<a href="notes/entry.html#top">E</a> <span class="unpublished-link" title="This page isn’t published">G</span>"##
973        );
974
975        // Written from a page at depth, the destination is still site-root
976        // relative — a template's `href` does not change with the page that
977        // reads it — so it climbs out first.
978        let out = transform_links(
979            html,
980            Path::new("notes/entry.md"),
981            &map,
982            workspace,
983            "notes/entry.html",
984        );
985        assert!(
986            out.starts_with(r##"<a href="../notes/entry.html#top">E</a>"##),
987            "{out}"
988        );
989    }
990
991    /// A source path wins over a destination when a spelling could be either:
992    /// a vault holding an `.html` document links it as the document it is.
993    #[test]
994    fn a_source_html_document_is_resolved_as_a_source() {
995        let workspace = Path::new("");
996        let mut map = HashMap::new();
997        map.insert(
998            PathBuf::from("notes/artifact.html"),
999            "notes/artifact.html".to_string(),
1000        );
1001        let html = r#"<a href="artifact.html">A</a>"#;
1002        let out = transform_links(
1003            html,
1004            Path::new("notes/entry.md"),
1005            &map,
1006            workspace,
1007            "notes/entry.html",
1008        );
1009        assert_eq!(out, r#"<a href="../notes/artifact.html">A</a>"#);
1010    }
1011
1012    /// An island `<iframe>` and a plain link to a non-document attachment are
1013    /// the same case — this is not an `<img>` rule.
1014    #[test]
1015    fn transform_links_rebases_every_root_absolute_src_and_href() {
1016        let workspace = Path::new("");
1017        let map = HashMap::new();
1018        let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
1019        let out = transform_links(
1020            html,
1021            Path::new("notes/deep.md"),
1022            &map,
1023            workspace,
1024            "notes/deep.html",
1025        );
1026        assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
1027        assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
1028    }
1029
1030    /// A root-absolute link to a *document* is resolved through the render set
1031    /// first, so it lands on the target's published page rather than being
1032    /// rebased into a path that only looks right.
1033    #[test]
1034    fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
1035        let workspace = Path::new("");
1036        let mut map = HashMap::new();
1037        map.insert(PathBuf::from("post.md"), "post.html".to_string());
1038        let html = r#"<a href="/post.md">x</a>"#;
1039        let out = transform_links(
1040            html,
1041            Path::new("notes/deep.md"),
1042            &map,
1043            workspace,
1044            "notes/deep.html",
1045        );
1046        assert_eq!(out, r#"<a href="../post.html">x</a>"#);
1047    }
1048
1049    /// A leading slash does not always mean a vault path: `//host/x` is
1050    /// absolute, and a bare `/` is the site root already.
1051    #[test]
1052    fn transform_links_leaves_protocol_relative_and_bare_slash() {
1053        let workspace = Path::new("");
1054        let map = HashMap::new();
1055        let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
1056        let out = transform_links(
1057            html,
1058            Path::new("notes/deep.md"),
1059            &map,
1060            workspace,
1061            "notes/deep.html",
1062        );
1063        assert_eq!(out, html);
1064    }
1065
1066    #[test]
1067    fn transform_links_leaves_external_and_anchors() {
1068        let workspace = Path::new("/ws");
1069        let map = HashMap::new();
1070        let current = Path::new("/ws/source.md");
1071        let html =
1072            r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
1073        let out = transform_links(html, current, &map, workspace, "source.html");
1074        assert_eq!(out, html);
1075    }
1076}