plates-render 0.7.3

The document-to-HTML half of plates: what a published page looks like.
Documentation
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
//! Site layout helpers: root-relative prefixes, percent decoding, rewriting
//! internal `.md` links to their published `.html` targets, and absolutizing a
//! rendered body for consumers that read it away from the site.

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

/// Compute the relative prefix to get from a page back to the site root.
///
/// `index.html` → `""`, `a/b.html` → `"../"`, `a/b/c.html` → `"../../"`.
pub fn root_prefix(dest_filename: &str) -> String {
    let depth = dest_filename.matches('/').count();
    if depth == 0 {
        String::new()
    } else {
        "../".repeat(depth)
    }
}

/// Rewrite internal `.md` hyperlinks in rendered HTML to their published
/// `.html` destinations, resolving relative/workspace-root paths via the
/// `path_to_filename` map. External links, anchors, and non-`.md` hrefs are
/// left untouched.
///
/// The lookup key is sanitized the same way `path_to_filename`'s keys are (see
/// `sanitize_rel_path`), so a link like `First post!.md` resolves to the
/// stored `First post.html` instead of a fabricated `First post!.html`.
///
/// A link whose target is **not** in this render set (excluded by audience
/// visibility, or simply missing) is stripped: the `<a>` becomes a
/// `<span class="unpublished-link">` that keeps the link text but isn't
/// clickable, so the page never points at something that 404s.
///
/// One more spelling is a link and not a stranger: an href that is already a
/// page's **destination** (`notes/entry.html`, as site-root-relative as the
/// map's values are). That is what a template writes when it reads `href` off
/// an entry — `[:val[e.title]]({{e.href}})` — and it is rebased to the page's
/// depth rather than resolved as a source path and stripped. A source path is
/// tried first, so a hand-written link to an `.html` document the vault
/// holds still means that document.
///
/// Everything that is *not* a document link — an image, a PDF, an HTML
/// attachment's island `<iframe>` — is then put through
/// `rebase_root_absolute`, because a vault writes those paths from the vault
/// root (`/img/photo.png`, prov's `path_style: root`) and a site is not always
/// served from a domain root.
pub fn transform_links(
    html: &str,
    current_path: &Path,
    path_to_filename: &HashMap<PathBuf, String>,
    workspace_dir: &Path,
    dest_filename: &str,
) -> String {
    transform_links_with_files(
        html,
        current_path,
        path_to_filename,
        workspace_dir,
        dest_filename,
        None,
    )
}

/// [`transform_links`], knowing which *files* the site ships as well as
/// which pages: a reference to a file it does not — an `<img>`, a `<video>`,
/// an `<audio>`, an `<iframe>`, or an `<a>` pointing at one — is marked the
/// way a link to an unpublished page is, rather than left to 404. `None`
/// is a caller that does not know, and marks nothing.
pub fn transform_links_with_files(
    html: &str,
    current_path: &Path,
    path_to_filename: &HashMap<PathBuf, String>,
    workspace_dir: &Path,
    dest_filename: &str,
    published_files: Option<&HashSet<String>>,
) -> String {
    let prefix = root_prefix(dest_filename);
    let html = rewrite_document_links(
        html,
        current_path,
        path_to_filename,
        workspace_dir,
        dest_filename,
    );
    let html = match published_files {
        Some(published) => {
            let current_relative = current_path
                .strip_prefix(workspace_dir)
                .unwrap_or(current_path);
            mark_unpublished_files(&html, current_relative, published)
        }
        None => html,
    };
    rebase_root_absolute(&html, &prefix)
}

/// The tags a page reaches a file through, and the attribute each reaches
/// it by. `<a>` is the one with a body to keep; the rest are replaced whole.
const FILE_TAGS: &[(&str, &str, bool)] = &[
    ("a", "href", true),
    ("img", "src", false),
    ("video", "src", true),
    ("audio", "src", true),
    ("iframe", "src", true),
];

/// What the build itself writes beside the pages, which no attachment list
/// names and no page is wrong to link.
fn is_generated_asset(canonical: &str) -> bool {
    matches!(
        canonical,
        "style.css" | "feed.xml" | "rss.xml" | "sitemap.xml" | "robots.txt"
    ) || canonical == crate::html::ISLAND_CHILD_SCRIPT_FILENAME
        || (canonical.starts_with("favicon.") && !canonical.contains('/'))
}

/// The file half of [`rewrite_document_links`]: a reference to a file the
/// site does not ship becomes the same marked span a link to an unpublished
/// page becomes — the link's own text, an image's `alt`, or the file's name.
///
/// Runs before [`rebase_root_absolute`] for the reason document links do: a
/// `/img/photo.png` has to still read as vault-root-absolute to resolve.
/// `published` is every file the site ships, in the coordinates a page's
/// destination is spelled in.
fn mark_unpublished_files(
    html: &str,
    current_relative: &Path,
    published: &HashSet<String>,
) -> String {
    let mut result = String::with_capacity(html.len());
    let mut remaining = html;

    while let Some(lt) = remaining.find('<') {
        result.push_str(&remaining[..lt]);
        let after = &remaining[lt..];
        let Some(gt) = after.find('>') else {
            result.push_str(after);
            return result;
        };
        let open_tag = &after[..=gt];
        let tail = &after[gt + 1..];

        let tag_name = open_tag[1..]
            .split(|c: char| c.is_whitespace() || c == '>' || c == '/')
            .next()
            .unwrap_or("")
            .to_ascii_lowercase();
        let Some((_, attr, closes)) = FILE_TAGS.iter().find(|(t, _, _)| *t == tag_name) else {
            result.push_str(open_tag);
            remaining = tail;
            continue;
        };
        let Some((start, end)) = find_attr_value(open_tag, attr) else {
            result.push_str(open_tag);
            remaining = tail;
            continue;
        };
        let Some(canonical) = file_link_canonical(&open_tag[start..end], current_relative) else {
            result.push_str(open_tag);
            remaining = tail;
            continue;
        };
        if published.contains(&canonical) || is_generated_asset(&canonical) {
            result.push_str(open_tag);
            remaining = tail;
            continue;
        }

        // Withheld. What the span says is what the reader would have seen:
        // the link's text, the image's `alt`, else the file's own name.
        let name = canonical
            .rsplit('/')
            .next()
            .unwrap_or(&canonical)
            .to_string();
        let (inner, rest) = if *closes {
            let close = format!("</{tag_name}>");
            match tail.find(&close) {
                Some(at) => (tail[..at].to_string(), &tail[at + close.len()..]),
                None => (String::new(), tail),
            }
        } else {
            (String::new(), tail)
        };
        let text = if tag_name == "a" && !inner.trim().is_empty() {
            inner
        } else {
            find_attr_value(open_tag, "alt")
                .map(|(s, e)| open_tag[s..e].to_string())
                .filter(|alt| !alt.trim().is_empty())
                .unwrap_or_else(|| crate::page::html_escape(&name))
        };
        result.push_str(r#"<span class="unpublished-link" title="This file isn’t published">"#);
        result.push_str(&text);
        result.push_str("</span>");
        remaining = rest;
    }
    result.push_str(remaining);
    result
}

/// If `raw` is a reference to a *file* in the vault — not a page, not
/// external, not an anchor, not inline data — its workspace-relative canonical
/// path; otherwise `None`.
fn file_link_canonical(raw: &str, current_relative: &Path) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty()
        || trimmed.starts_with('#')
        || trimmed.starts_with("//")
        || trimmed.split_once(':').is_some_and(|(scheme, _)| {
            !scheme.is_empty()
                && scheme
                    .chars()
                    .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
        })
    {
        return None;
    }
    let path = &trimmed[..trimmed.find(['?', '#']).unwrap_or(trimmed.len())];
    let decoded = percent_decode(path);
    if decoded.is_empty() || prov::ContentFormat::from_extension(Path::new(&decoded)).is_some() {
        return None;
    }
    let target = prov::Link::parse_path_only(&decoded).target;
    Some(
        prov::link::resolve(current_relative, &target)
            .to_string_lossy()
            .into_owned(),
    )
}

/// The document-link half of [`transform_links`]: `.md`/`.dj`/`.html` hrefs to
/// their published destinations, and unpublished targets to marked spans.
///
/// Runs *before* [`rebase_root_absolute`] on purpose. A document link is
/// resolved against the page holding it, so `/post.md` has to still be
/// recognizable as vault-root-absolute when it gets here; rebasing first would
/// hand it over as the page-relative `post.md` and resolve it one directory too
/// deep.
fn rewrite_document_links(
    html: &str,
    current_path: &Path,
    path_to_filename: &HashMap<PathBuf, String>,
    workspace_dir: &Path,
    dest_filename: &str,
) -> String {
    let prefix = root_prefix(dest_filename);
    // to_canonical expects workspace-relative paths
    let current_relative = current_path
        .strip_prefix(workspace_dir)
        .unwrap_or(current_path);

    // The destinations this render writes, for the template case above.
    let destinations: HashSet<&str> = path_to_filename.values().map(String::as_str).collect();

    let mut result = String::with_capacity(html.len());
    let mut remaining = html;

    while let Some(tag_start) = remaining.find("<a ") {
        // Emit everything before the anchor verbatim.
        result.push_str(&remaining[..tag_start]);
        let after = &remaining[tag_start..];

        // Find the end of the opening tag. comrak escapes `>` inside attribute
        // values, so the first `>` reliably closes the tag.
        let Some(gt) = after.find('>') else {
            result.push_str(after);
            remaining = "";
            break;
        };
        let open_tag = &after[..=gt];
        let tail = &after[gt + 1..];

        // Only internal `.md` links are candidates for rewrite/strip.
        let canonical =
            extract_href(open_tag).and_then(|href| document_link_canonical(href, current_relative));

        match canonical {
            None => {
                // External link, anchor, or non-`.md` target — leave untouched.
                result.push_str(open_tag);
                remaining = tail;
            }
            Some((canonical, written, suffix)) => {
                // Anchors can't nest, so the next `</a>` closes this one.
                let Some(close) = tail.find("</a>") else {
                    result.push_str(open_tag);
                    remaining = tail;
                    continue;
                };
                let inner = &tail[..close];
                let after_close = &tail[close + "</a>".len()..];

                let key = workspace_dir.join(sanitize_rel_path(&canonical));
                match path_to_filename.get(&key) {
                    Some(html_path) => {
                        // Published target — rewrite the href, keep the anchor.
                        result.push_str(&replace_href(
                            open_tag,
                            &format!("{prefix}{html_path}{suffix}"),
                        ));
                        result.push_str(inner);
                        result.push_str("</a>");
                    }
                    None if destinations.contains(written.as_str()) => {
                        // Already a destination, as a template writes one:
                        // rebase it to this page's depth and keep the anchor.
                        result.push_str(&replace_href(
                            open_tag,
                            &format!("{prefix}{written}{suffix}"),
                        ));
                        result.push_str(inner);
                        result.push_str("</a>");
                    }
                    None => {
                        // Not in this render set — strip to a marked span.
                        result.push_str(
                            r#"<span class="unpublished-link" title="This page isn’t published">"#,
                        );
                        result.push_str(inner);
                        result.push_str("</span>");
                    }
                }
                remaining = after_close;
            }
        }
    }
    result.push_str(remaining);

    result
}

/// Extract the raw (still percent-encoded) `href="…"` value from an opening tag.
fn extract_href(open_tag: &str) -> Option<&str> {
    let start = open_tag.find("href=\"")? + 6;
    let rest = &open_tag[start..];
    let end = rest.find('"')?;
    Some(&rest[..end])
}

/// If `raw_href` is an internal link to another *document*, return its
/// workspace-relative canonical path, the decoded path as it was written, and
/// the `?query#fragment` that rode along with it; otherwise `None` (external,
/// anchor and attachment links are skipped).
///
/// "Document" is [`prov::ContentFormat`]'s judgement, not a `.md` test: a vault
/// links `.dj` and `.html` pages the same way it links `.md` ones, and each of
/// them is rewritten to its published `.html` destination.
///
/// The suffix is split off **before** the extension test, and returned so it can
/// be put back on the rewritten href. Testing the whole value meant a link to a
/// heading — `about/index.md#projects`, which is how one page points at a
/// section of another — had the extension `md#projects`, matched no content
/// format, and was left as a `.md` href pointing at a file the site does not
/// publish. It rides along unchanged rather than being re-resolved: a fragment
/// names something inside the target document, which is the same fragment
/// whatever the target's published filename turns out to be.
fn document_link_canonical<'h>(
    raw_href: &'h str,
    current_relative: &Path,
) -> Option<(String, String, &'h str)> {
    if raw_href.starts_with("http://")
        || raw_href.starts_with("https://")
        || raw_href.starts_with('#')
    {
        return None;
    }
    let (path, suffix) = raw_href.split_at(raw_href.find(['?', '#']).unwrap_or(raw_href.len()));
    let decoded = percent_decode(path);
    // The extension test runs on the decoded href: a link written
    // `My%20Note.md` is a document link, and `.md` is not what it ends with.
    prov::ContentFormat::from_extension(Path::new(decoded.trim()))?;
    let target = prov::Link::parse_path_only(decoded.trim()).target;
    Some((
        prov::link::resolve(current_relative, &target)
            .to_string_lossy()
            .into_owned(),
        decoded.trim().to_string(),
        suffix,
    ))
}

/// Replace the `href="…"` value in an opening tag, preserving other attributes.
fn replace_href(open_tag: &str, new_value: &str) -> String {
    let Some(start) = open_tag.find("href=\"") else {
        return open_tag.to_string();
    };
    let value_start = start + 6;
    let rest = &open_tag[value_start..];
    let Some(end) = rest.find('"') else {
        return open_tag.to_string();
    };
    format!("{}{}{}", &open_tag[..value_start], new_value, &rest[end..])
}

/// Rewrite vault-root-absolute `href`/`src` values into page-relative ones.
///
/// A vault names its own files from its root — `![photo](/img/photo.png)`, the
/// `path_style: root` prov writes links in — and a published site is *not*
/// always served from a domain root: the namespace serves each site under
/// `…/sites/<ns>/<site>/`, and a local preview server mounts every declared
/// site under its own name. A `/img/photo.png` left as written escapes the site and 404s
/// in both, while the attachment it means sits at `img/photo.png` below the
/// site root. Rebasing through [`root_prefix`] is what the document links
/// beside it already get.
///
/// Left alone: anything with a scheme, protocol-relative `//host/x` (absolute
/// despite the leading slash), and a bare `/` (no path to rebase, and a site
/// root is what it already means).
///
/// So a leading slash always means the *vault* root here, never the domain
/// root. That is what a vault writes — this repo's own `config.yaml` sets
/// `references: path_style: root`, so prov generates vault-root-absolute paths
/// as the ordinary spelling of a link — and the costs are not symmetric: **an
/// author who means the domain root can write a full URL**, which
/// [`absolutize_html`] and this function both leave alone, while an author who
/// means a vault path would have no way to say so. That escape hatch is the
/// documented way to point outside the site.
///
/// This runs on rendered HTML, so by the time it sees a tag the document links
/// in it are relative already — [`rewrite_document_links`] resolved them — and
/// what is left holding a leading slash is the attachment case this is for.
fn rebase_root_absolute(html: &str, prefix: &str) -> String {
    let mut result = String::with_capacity(html.len());
    let mut remaining = html;

    while let Some(lt) = remaining.find('<') {
        result.push_str(&remaining[..lt]);
        let after = &remaining[lt..];

        // The first `>` closes the tag — the same assumption the rest of this
        // module makes about the renderer's attribute escaping.
        let Some(gt) = after.find('>') else {
            result.push_str(after);
            return result;
        };

        let mut tag = after[..=gt].to_string();
        for name in ["href", "src"] {
            let Some((start, end)) = find_attr_value(&tag, name) else {
                continue;
            };
            let value = &tag[start..end];
            if !value.starts_with('/') || value.starts_with("//") || value.len() == 1 {
                continue;
            }
            let rebased = format!("{prefix}{}", &value[1..]);
            tag.replace_range(start..end, &rebased);
        }
        result.push_str(&tag);
        remaining = &after[gt + 1..];
    }
    result.push_str(remaining);

    result
}

/// Rewrite a page's relative `href`/`src` values into absolute URLs under
/// `base_url` — the rendition a reader gets *away from* the site.
///
/// A rendered body carries links relative to the page holding them
/// (`../notes/target.html`, `_attachments/scan.jpg`), which is right for the
/// published HTML and wrong everywhere the body travels without its page: a
/// feed reader resolves them against the feed's own URL, and an email client
/// against nothing at all. Syndicating a body unchanged turns every internal
/// link and every image in it into a dead one.
///
/// Resolution is against the page's own directory, not the site root, because
/// that is what the body's `../` prefixes were written relative to (see
/// [`root_prefix`]).
///
/// Left alone, deliberately:
///
/// - anything carrying a scheme (`https:`, `mailto:`) or protocol-relative
///   (`//host/x`) — already absolute;
/// - fragment-only links (`#section`), which still resolve within the entry;
/// - root-relative links (`/about`). `base_url` may itself carry path segments
///   (a site is served at `…/sites/<ns>/<site>/`), so rebasing one would
///   silently move it somewhere else. A body that came through
///   [`transform_links`] has none left to worry about — the vault's own
///   root-absolute paths were made page-relative by [`rebase_root_absolute`]
///   before the feed ever saw them, and what reaches here with a leading slash
///   is something this function did not write and should not move.
///
/// A path that climbs above the site root (`../../../etc`) is left alone too:
/// it has no correct absolute form, and inventing one is worse than passing
/// through a link that was already broken.
///
/// An empty `base_url` returns the html untouched — the callers that have no
/// base skip syndication entirely.
pub fn absolutize_html(html: &str, dest_filename: &str, base_url: &str) -> String {
    let base = base_url.trim_end_matches('/');
    if base.is_empty() {
        return html.to_string();
    }
    let dir = dest_filename.rsplit_once('/').map_or("", |(dir, _)| dir);

    let mut result = String::with_capacity(html.len());
    let mut remaining = html;

    while let Some(lt) = remaining.find('<') {
        result.push_str(&remaining[..lt]);
        let after = &remaining[lt..];

        // comrak escapes `>` inside attribute values, so the first `>` closes
        // the tag — the same assumption `transform_links` makes above.
        let Some(gt) = after.find('>') else {
            result.push_str(after);
            return result;
        };

        result.push_str(&absolutize_tag(&after[..=gt], dir, base));
        remaining = &after[gt + 1..];
    }
    result.push_str(remaining);

    result
}

/// Rewrite the `href` and `src` values of a single tag.
fn absolutize_tag(tag: &str, dir: &str, base: &str) -> String {
    let mut out = tag.to_string();
    for name in ["href", "src"] {
        let Some((start, end)) = find_attr_value(&out, name) else {
            continue;
        };
        let Some(absolute) = absolutize_url(&out[start..end], dir, base) else {
            continue;
        };
        out.replace_range(start..end, &absolute);
    }
    out
}

/// Byte range of the value in `name="…"`, requiring the name to begin at a
/// word boundary so `src` does not also match `data-src`.
fn find_attr_value(tag: &str, name: &str) -> Option<(usize, usize)> {
    let pattern = format!("{name}=\"");
    let mut from = 0;
    while let Some(offset) = tag[from..].find(&pattern) {
        let at = from + offset;
        let start = at + pattern.len();
        let end = start + tag[start..].find('"')?;
        if at == 0
            || tag[..at]
                .chars()
                .next_back()
                .is_some_and(char::is_whitespace)
        {
            return Some((start, end));
        }
        from = end + 1;
    }
    None
}

/// Resolve one attribute value against the page's directory and the site base,
/// or `None` to leave it as written. See [`absolutize_html`] for the cases.
fn absolutize_url(value: &str, dir: &str, base: &str) -> Option<String> {
    if value.is_empty() || value.starts_with('#') || value.starts_with('/') || has_scheme(value) {
        return None;
    }

    // Only the path resolves; any `?query` / `#fragment` rides along unchanged.
    let (path, suffix) = value.split_at(value.find(['?', '#']).unwrap_or(value.len()));
    if path.is_empty() {
        return None;
    }

    let joined = if dir.is_empty() {
        path.to_string()
    } else {
        format!("{dir}/{path}")
    };
    Some(format!("{base}/{}{suffix}", normalize_rel_path(&joined)?))
}

/// Collapse `.` and `..` segments. `None` when the path climbs above its root.
fn normalize_rel_path(path: &str) -> Option<String> {
    let mut segments: Vec<&str> = Vec::new();
    for segment in path.split('/') {
        match segment {
            "" | "." => {}
            ".." => {
                segments.pop()?;
            }
            other => segments.push(other),
        }
    }
    if segments.is_empty() {
        return None;
    }
    let mut joined = segments.join("/");
    if path.ends_with('/') {
        joined.push('/');
    }
    Some(joined)
}

/// Whether `value` opens with a URL scheme. A relative path that merely
/// contains a colon (`notes/9:15.html`) is not one: a scheme is letters,
/// digits, `+`, `-` and `.`, starting with a letter.
fn has_scheme(value: &str) -> bool {
    let Some(colon) = value.find(':') else {
        return false;
    };
    let scheme = &value[..colon];
    scheme.starts_with(|c: char| c.is_ascii_alphabetic())
        && scheme
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}

/// Sanitize a single path component for safe use in URLs. Keeps alphanumerics,
/// spaces, dots, hyphens, and underscores; strips URL-unsafe characters. Mirrors
/// the publish client's dest-name sanitization so links resolve to the stored
/// filenames.
pub fn sanitize_path_component(s: &str) -> String {
    s.chars()
        .filter(|c| c.is_alphanumeric() || *c == ' ' || *c == '-' || *c == '_' || *c == '.')
        .collect()
}

/// Sanitize each component of a relative path, preserving its extension. Used to
/// normalize both stored source paths and resolved frontmatter/body links to a
/// common key form.
pub fn sanitize_rel_path(path: &str) -> String {
    let sanitized: PathBuf = Path::new(path)
        .components()
        .map(|c| match c {
            std::path::Component::Normal(s) => {
                std::ffi::OsString::from(sanitize_path_component(&s.to_string_lossy()))
            }
            other => other.as_os_str().to_owned(),
        })
        .collect();
    sanitized.to_string_lossy().into_owned()
}

/// Decode percent-encoded characters in a URL string (e.g. `%20` → ` `).
pub fn percent_decode(input: &str) -> String {
    let mut result = Vec::with_capacity(input.len());
    let bytes = input.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'%'
            && i + 2 < bytes.len()
            && let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
        {
            result.push(hi << 4 | lo);
            i += 3;
            continue;
        }
        result.push(bytes[i]);
        i += 1;
    }
    String::from_utf8(result).unwrap_or_else(|_| input.to_string())
}

fn hex_val(b: u8) -> Option<u8> {
    match b {
        b'0'..=b'9' => Some(b - b'0'),
        b'a'..=b'f' => Some(b - b'a' + 10),
        b'A'..=b'F' => Some(b - b'A' + 10),
        _ => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn root_prefix_depth() {
        assert_eq!(root_prefix("index.html"), "");
        assert_eq!(root_prefix("a/b.html"), "../");
        assert_eq!(root_prefix("a/b/c.html"), "../../");
    }

    #[test]
    fn percent_decode_cases() {
        assert_eq!(percent_decode("hello"), "hello");
        assert_eq!(percent_decode("hello%20world"), "hello world");
        assert_eq!(
            percent_decode("Message%20for%20my%20family.md"),
            "Message for my family.md"
        );
        assert_eq!(percent_decode("%2Fpath%2Fto%2Ffile"), "/path/to/file");
        // Incomplete sequences are left as-is
        assert_eq!(percent_decode("hello%2"), "hello%2");
        assert_eq!(percent_decode("hello%"), "hello%");
        // Invalid hex chars left as-is
        assert_eq!(percent_decode("hello%ZZ"), "hello%ZZ");
    }

    #[test]
    fn transform_links_rewrites_known_md_target() {
        let workspace = Path::new("/ws");
        let mut map = HashMap::new();
        map.insert(
            PathBuf::from("/ws/notes/target.md"),
            "notes/target.html".to_string(),
        );

        let html = r#"<a href="target.md">x</a>"#;
        let current = Path::new("/ws/notes/source.md");
        let out = transform_links(html, current, &map, workspace, "notes/source.html");
        // depth 1 → prefix "../"
        assert_eq!(out, r#"<a href="../notes/target.html">x</a>"#);
    }

    #[test]
    fn transform_links_unknown_md_is_stripped_and_marked() {
        // A link to a page that isn't in the render set (excluded/missing) must
        // not become a dead .html link — it's stripped to a marked span that
        // keeps the text but isn't clickable.
        let workspace = Path::new("/ws");
        let map = HashMap::new();
        let html = r#"<a href="missing.md">link text</a>"#;
        let current = Path::new("/ws/source.md");
        let out = transform_links(html, current, &map, workspace, "source.html");
        assert_eq!(
            out,
            r#"<span class="unpublished-link" title="This page isn’t published">link text</span>"#
        );
    }

    /// A link to a heading of another page. The extension test used to run on
    /// the whole href, so `about/index.md#projects` had the "extension"
    /// `md#projects`, matched no content format, and was published as a `.md`
    /// link to a file the site does not serve.
    #[test]
    fn transform_links_rewrites_a_link_carrying_a_fragment() {
        let workspace = Path::new("");
        let mut map = HashMap::new();
        map.insert(
            PathBuf::from("about/index.md"),
            "about/index.html".to_string(),
        );

        let html = r##"<a href="about/index.md#projects">p</a>"##;
        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
        assert_eq!(out, r##"<a href="about/index.html#projects">p</a>"##);

        // A query rides along too, and the fragment is not re-resolved against
        // anything — it names a heading inside the target either way.
        let html = r##"<a href="/about/index.md?v=2#sec">p</a>"##;
        let out = transform_links(
            html,
            Path::new("notes/deep.md"),
            &map,
            workspace,
            "notes/deep.html",
        );
        assert_eq!(out, r##"<a href="../about/index.html?v=2#sec">p</a>"##);
    }

    /// …and one whose target is not in the render set is still stripped, rather
    /// than the fragment making it look like an anchor link.
    #[test]
    fn transform_links_strips_an_unpublished_target_with_a_fragment() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = r##"<a href="gone.md#sec">text</a>"##;
        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
        assert_eq!(
            out,
            r#"<span class="unpublished-link" title="This page isn’t published">text</span>"#
        );
    }

    #[test]
    fn transform_links_resolves_sanitized_target() {
        // The link text references "First post!.md" but the stored/published key
        // is the sanitized "First post.md" → "First post.html". The '!' must not
        // leak into the href (regression for the sanitization-mismatch bug).
        let workspace = Path::new("");
        let mut map = HashMap::new();
        map.insert(
            PathBuf::from("First post.md"),
            "First post.html".to_string(),
        );
        let html = r#"<a href="First%20post!.md">x</a>"#;
        let current = Path::new("source.md");
        let out = transform_links(html, current, &map, workspace, "source.html");
        assert_eq!(out, r#"<a href="First post.html">x</a>"#);
    }

    #[test]
    fn transform_links_preserves_inner_markup_when_stripping() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = r#"<a href="gone.md">see <em>this</em></a>"#;
        let current = Path::new("source.md");
        let out = transform_links(html, current, &map, workspace, "source.html");
        assert!(out.contains(r#"<span class="unpublished-link""#));
        assert!(out.contains("see <em>this</em></span>"));
        assert!(!out.contains("<a "));
    }

    #[test]
    fn absolutize_rewrites_href_and_src_from_the_root() {
        let html = r#"<a href="post.html">x</a><img src="_attachments/a.jpg">"#;
        let out = absolutize_html(html, "index.html", "https://ex.com");
        assert_eq!(
            out,
            r#"<a href="https://ex.com/post.html">x</a><img src="https://ex.com/_attachments/a.jpg">"#
        );
    }

    #[test]
    fn absolutize_resolves_against_the_pages_own_directory() {
        // The body of `a/b/c.html` writes `../` prefixes relative to itself, so
        // resolving against the site root instead would land a segment too high.
        let html = r#"<a href="../sibling.html">s</a><a href="deeper/d.html">d</a>"#;
        let out = absolutize_html(html, "a/b/c.html", "https://ex.com/");
        assert!(out.contains(r#"href="https://ex.com/a/sibling.html""#));
        assert!(out.contains(r#"href="https://ex.com/a/b/deeper/d.html""#));
    }

    #[test]
    fn absolutize_rebases_under_a_base_url_that_has_a_path() {
        // A site is served at `…/sites/<ns>/<site>/`, so the base is not an origin.
        let html = r#"<img src="../_attachments/scan.jpg">"#;
        let out = absolutize_html(html, "notes/entry.html", "https://ex.com/sites/ns/letters");
        assert_eq!(
            out,
            r#"<img src="https://ex.com/sites/ns/letters/_attachments/scan.jpg">"#
        );
    }

    #[test]
    fn absolutize_leaves_absolute_root_relative_and_fragment_links() {
        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>"##;
        assert_eq!(absolutize_html(html, "index.html", "https://ex.com"), html);
    }

    #[test]
    fn absolutize_keeps_query_and_fragment_suffixes() {
        let html = r##"<a href="post.html#note-1">n</a><a href="p.html?v=2">q</a>"##;
        let out = absolutize_html(html, "index.html", "https://ex.com");
        assert!(out.contains(r#"href="https://ex.com/post.html#note-1""#));
        assert!(out.contains(r#"href="https://ex.com/p.html?v=2""#));
    }

    #[test]
    fn absolutize_leaves_a_path_that_climbs_above_the_root() {
        // No correct absolute form exists; passing it through beats inventing one.
        let html = r#"<a href="../../nope.html">x</a>"#;
        assert_eq!(absolutize_html(html, "a/b.html", "https://ex.com"), html);
    }

    #[test]
    fn absolutize_does_not_match_a_suffixed_attribute_name() {
        let html = r#"<img data-src="a.jpg" src="b.jpg">"#;
        let out = absolutize_html(html, "index.html", "https://ex.com");
        assert!(out.contains(r#"data-src="a.jpg""#));
        assert!(out.contains(r#"src="https://ex.com/b.jpg""#));
    }

    #[test]
    fn absolutize_leaves_a_colon_in_a_filename_alone() {
        let html = r#"<a href="notes/9:15.html">t</a>"#;
        let out = absolutize_html(html, "index.html", "https://ex.com");
        assert_eq!(out, r#"<a href="https://ex.com/notes/9:15.html">t</a>"#);
    }

    #[test]
    fn absolutize_without_a_base_is_a_no_op() {
        let html = r#"<a href="post.html">x</a>"#;
        assert_eq!(absolutize_html(html, "index.html", ""), html);
    }

    #[test]
    fn absolutize_leaves_text_between_tags_untouched() {
        let html = r#"<p>see href="post.html" below</p><a href="post.html">x</a>"#;
        let out = absolutize_html(html, "index.html", "https://ex.com");
        assert!(out.contains(r#"see href="post.html" below"#));
        assert!(out.contains(r#"<a href="https://ex.com/post.html">"#));
    }

    /// The vault writes attachment paths from its own root, and a site is not
    /// always served from a domain root — so a root-absolute `src` has to come
    /// down to the page it sits on, exactly like the document links beside it.
    #[test]
    fn transform_links_rebases_root_absolute_attachments() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = r#"<img src="/img/photo.png" alt="a">"#;

        // At the site root the prefix is empty, so the slash simply goes.
        let out = transform_links(html, Path::new("post.md"), &map, workspace, "post.html");
        assert_eq!(out, r#"<img src="img/photo.png" alt="a">"#);

        // A page one directory down has to climb back out first.
        let out = transform_links(
            html,
            Path::new("notes/deep.md"),
            &map,
            workspace,
            "notes/deep.html",
        );
        assert_eq!(out, r#"<img src="../img/photo.png" alt="a">"#);
    }

    /// A reference to a file the site does not ship is marked as a link to a
    /// page it does not publish is: the image's `alt` where its picture would
    /// have been, the link's text, a player's title; a file the site ships,
    /// the build's own assets, and anything external are left alone; and a
    /// caller that cannot say marks nothing.
    #[test]
    fn a_reference_to_a_withheld_file_is_marked_like_an_unpublished_page() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = concat!(
            r#"<img src="attachments/private.jpg" alt="A private picture">"#,
            r#"<img src="/attachments/shipped.jpg" alt="ok">"#,
            r#"<a href="attachments/private.pdf">Read the scan</a>"#,
            r#"<video controls src="attachments/private.mp4"></video>"#,
            r#"<a href="https://example.com/x.jpg">out</a>"#,
            r#"<a href="/feed.xml">feed</a>"#,
            r#"<img src="attachments/nameless.png" alt="">"#,
        );
        let published: HashSet<String> = ["attachments/shipped.jpg".to_string()].into();

        let out = transform_links_with_files(
            html,
            Path::new("index.md"),
            &map,
            workspace,
            "index.html",
            Some(&published),
        );
        assert_eq!(
            out,
            concat!(
                r#"<span class="unpublished-link" title="This file isn’t published">A private picture</span>"#,
                r#"<img src="attachments/shipped.jpg" alt="ok">"#,
                r#"<span class="unpublished-link" title="This file isn’t published">Read the scan</span>"#,
                r#"<span class="unpublished-link" title="This file isn’t published">private.mp4</span>"#,
                r#"<a href="https://example.com/x.jpg">out</a>"#,
                r#"<a href="feed.xml">feed</a>"#,
                r#"<span class="unpublished-link" title="This file isn’t published">nameless.png</span>"#,
            )
        );

        // Not knowing is not the same as knowing nothing ships.
        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
        assert!(
            out.contains(r#"<img src="attachments/private.jpg""#),
            "{out}"
        );
    }

    /// A link whose href is already a page's destination — what a template
    /// writes when it reads `href` off an entry — is a link to that page,
    /// rebased to the page's depth, and not a source path to be stripped.
    #[test]
    fn a_destination_href_is_a_link_to_the_page_it_names() {
        let workspace = Path::new("");
        let mut map = HashMap::new();
        map.insert(PathBuf::from("index.md"), "index.html".to_string());
        map.insert(
            PathBuf::from("notes/entry.md"),
            "notes/entry.html".to_string(),
        );
        let html = r##"<a href="notes/entry.html#top">E</a> <a href="notes/gone.html">G</a>"##;

        let out = transform_links(html, Path::new("index.md"), &map, workspace, "index.html");
        assert_eq!(
            out,
            r##"<a href="notes/entry.html#top">E</a> <span class="unpublished-link" title="This page isn’t published">G</span>"##
        );

        // Written from a page at depth, the destination is still site-root
        // relative — a template's `href` does not change with the page that
        // reads it — so it climbs out first.
        let out = transform_links(
            html,
            Path::new("notes/entry.md"),
            &map,
            workspace,
            "notes/entry.html",
        );
        assert!(
            out.starts_with(r##"<a href="../notes/entry.html#top">E</a>"##),
            "{out}"
        );
    }

    /// A source path wins over a destination when a spelling could be either:
    /// a vault holding an `.html` document links it as the document it is.
    #[test]
    fn a_source_html_document_is_resolved_as_a_source() {
        let workspace = Path::new("");
        let mut map = HashMap::new();
        map.insert(
            PathBuf::from("notes/artifact.html"),
            "notes/artifact.html".to_string(),
        );
        let html = r#"<a href="artifact.html">A</a>"#;
        let out = transform_links(
            html,
            Path::new("notes/entry.md"),
            &map,
            workspace,
            "notes/entry.html",
        );
        assert_eq!(out, r#"<a href="../notes/artifact.html">A</a>"#);
    }

    /// An island `<iframe>` and a plain link to a non-document attachment are
    /// the same case — this is not an `<img>` rule.
    #[test]
    fn transform_links_rebases_every_root_absolute_src_and_href() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = r#"<iframe class="diaryx-island" src="/att/page.html"></iframe><a href="/att/scan.pdf">s</a>"#;
        let out = transform_links(
            html,
            Path::new("notes/deep.md"),
            &map,
            workspace,
            "notes/deep.html",
        );
        assert!(out.contains(r#"src="../att/page.html""#), "got {out}");
        assert!(out.contains(r#"href="../att/scan.pdf""#), "got {out}");
    }

    /// A root-absolute link to a *document* is resolved through the render set
    /// first, so it lands on the target's published page rather than being
    /// rebased into a path that only looks right.
    #[test]
    fn transform_links_resolves_a_root_absolute_document_before_rebasing() {
        let workspace = Path::new("");
        let mut map = HashMap::new();
        map.insert(PathBuf::from("post.md"), "post.html".to_string());
        let html = r#"<a href="/post.md">x</a>"#;
        let out = transform_links(
            html,
            Path::new("notes/deep.md"),
            &map,
            workspace,
            "notes/deep.html",
        );
        assert_eq!(out, r#"<a href="../post.html">x</a>"#);
    }

    /// A leading slash does not always mean a vault path: `//host/x` is
    /// absolute, and a bare `/` is the site root already.
    #[test]
    fn transform_links_leaves_protocol_relative_and_bare_slash() {
        let workspace = Path::new("");
        let map = HashMap::new();
        let html = r#"<img src="//cdn.example/x.png"><a href="/">home</a>"#;
        let out = transform_links(
            html,
            Path::new("notes/deep.md"),
            &map,
            workspace,
            "notes/deep.html",
        );
        assert_eq!(out, html);
    }

    #[test]
    fn transform_links_leaves_external_and_anchors() {
        let workspace = Path::new("/ws");
        let map = HashMap::new();
        let current = Path::new("/ws/source.md");
        let html =
            r##"<a href="https://x.com/a.md">e</a><a href="#frag">f</a><a href="img.png">g</a>"##;
        let out = transform_links(html, current, &map, workspace, "source.html");
        assert_eq!(out, html);
    }
}