Skip to main content

tpnote_lib/
html.rs

1//! Helper functions dealing with HTML conversion.
2use crate::clone_ext::CloneExt;
3use crate::error::InputStreamError;
4use crate::filename::{NotePath, NotePathStr};
5use crate::{config::LocalLinkKind, error::NoteError};
6use html_escape;
7use parking_lot::RwLock;
8use parse_hyperlinks::parser::Link;
9use parse_hyperlinks_extras::iterator_html::HtmlLinkInlineImage;
10use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
11use std::path::MAIN_SEPARATOR_STR;
12use std::{
13    borrow::Cow,
14    collections::HashSet,
15    path::{Component, Path, PathBuf},
16    sync::Arc,
17};
18
19pub(crate) const HTML_EXT: &str = ".html";
20
21/// A local path can carry a format string at the end. This is the separator
22/// character.
23const FORMAT_SEPARATOR: char = '?';
24
25/// If followed directly after FORMAT_SEPARATOR, it selects the sort-tag
26/// for further matching.
27const FORMAT_ONLY_SORT_TAG: char = '#';
28
29/// If followed directly after FORMAT_SEPARATOR, it selects the whole filename
30/// for further matching.
31const FORMAT_COMPLETE_FILENAME: &str = "?";
32
33/// A format string can be separated in a _from_ and _to_ part. This
34/// optional separator is placed after `FORMAT_SEPARATOR` and separates
35/// the _from_ and _to_ pattern.
36const FORMAT_FROM_TO_SEPARATOR: char = ':';
37
38/// Bytes that must be percent-encoded when a filesystem path segment is
39/// embedded in an `href`/`src` attribute. `#` and `?` are URL syntax
40/// (fragment and query introducers): left as-is, a literal one in a
41/// directory or file name is read by the browser as the end of the path
42/// and everything after it never reaches the server. `%` must be in this
43/// set too, so `percent_encode_path()` escapes an existing `%` in a file
44/// name before anything can be mistaken for one of its own escapes. The
45/// space is encoded for consistency, even though browsers already encode
46/// a literal space themselves before sending it.
47static PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'%').add(b' ');
48
49/// Splits `dest` into a filesystem path and a trailing URL fragment the
50/// author wrote (`note.md#anchor`), mirroring the heuristic used
51/// throughout this module: the last `#` starts a fragment only if it
52/// falls in the final path segment, i.e. after the last `/` or `\`, or
53/// there is no separator at all. A `#` that is part of a directory name
54/// (`Meeting #12/notes.md`) precedes a later separator and is therefore
55/// left in the path half. The returned fragment, if any, keeps its
56/// leading `#`.
57fn split_path_and_fragment(dest: &str) -> (&str, &str) {
58    match (dest.rfind('#'), dest.rfind(['/', '\\'])) {
59        (Some(n), sep) if sep.is_some_and(|sep| n > sep) || sep.is_none() => {
60            (&dest[..n], &dest[n..])
61        }
62        _ => (dest, ""),
63    }
64}
65
66/// Percent-encodes `path` for safe embedding in an `href`/`src` attribute,
67/// segment by segment. Encoding is applied per segment, not to the joined
68/// string, so the `/` separators — including a leading one — never need to
69/// be exempted afterwards, which would risk exempting a `/` that was
70/// actually part of a name. Bytes outside ASCII are always percent-encoded
71/// by `utf8_percent_encode` as their UTF-8 octets.
72fn percent_encode_path(path: &str) -> String {
73    path.split('/')
74        .map(|segment| utf8_percent_encode(segment, PATH_SEGMENT).to_string())
75        .collect::<Vec<_>>()
76        .join("/")
77}
78
79/// If `rewrite_rel_path` and `dest` is relative, concatenate `docdir` and
80/// `dest`, then strip `root_path` from the left before returning.
81/// If not `rewrite_rel_path` and `dest` is relative, return `dest`.
82/// If `rewrite_abs_path` and `dest` is absolute, concatenate and return
83/// `root_path` and `dest`.
84/// If not `rewrite_abs_path` and `dest` is absolute, return `dest`.
85/// The `dest` portion of the output is always canonicalized.
86/// Return the assembled path, when in `root_path`, or `None` otherwise.
87/// Asserts in debug mode, that `doc_dir` is in `root_path`.
88fn assemble_link(
89    root_path: &Path,
90    docdir: &Path,
91    dest: &Path,
92    rewrite_rel_paths: bool,
93    rewrite_abs_paths: bool,
94) -> Option<PathBuf> {
95    ///
96    /// Concatenate `path` and `append`.
97    /// The `append` portion of the output is if possible canonicalized.
98    /// In case of underflow of an absolute link, the returned path is empty.
99    fn append(path: &mut PathBuf, append: &Path) {
100        // Append `dest` to `link` and canonicalize.
101        for dir in append.components() {
102            match dir {
103                Component::ParentDir => {
104                    if !path.pop() {
105                        let path_is_relative = {
106                            let mut c = path.components();
107                            !(c.next() == Some(Component::RootDir)
108                                || c.next() == Some(Component::RootDir))
109                        };
110                        if path_is_relative {
111                            path.push(Component::ParentDir.as_os_str());
112                        } else {
113                            path.clear();
114                            break;
115                        }
116                    }
117                }
118                Component::Normal(c) => path.push(c),
119                _ => {}
120            }
121        }
122    }
123
124    // Under Windows `.is_relative()` does not detect `Component::RootDir`
125    let dest_is_relative = {
126        let mut c = dest.components();
127        !(c.next() == Some(Component::RootDir) || c.next() == Some(Component::RootDir))
128    };
129
130    // Check if the link points into `root_path`, reject otherwise
131    // (strip_prefix will not work).
132    debug_assert!(docdir.starts_with(root_path));
133
134    // Calculate the output.
135    let mut link = match (rewrite_rel_paths, rewrite_abs_paths, dest_is_relative) {
136        // *** Relative links.
137        // Result: "/" + docdir.strip(root_path) + dest
138        (true, false, true) => {
139            let link = PathBuf::from(Component::RootDir.as_os_str());
140            link.join(docdir.strip_prefix(root_path).ok()?)
141        }
142        // Result: docdir + dest
143        (true, true, true) => docdir.to_path_buf(),
144        // Result: dest
145        (false, _, true) => PathBuf::new(),
146        // *** Absolute links.
147        // Result: "/" + dest
148        (_, false, false) => PathBuf::from(Component::RootDir.as_os_str()),
149        // Result: "/" + root_path
150        (_, true, false) => root_path.to_path_buf(),
151    };
152    append(&mut link, dest);
153
154    if link.as_os_str().is_empty() {
155        None
156    } else {
157        Some(link)
158    }
159}
160
161trait Hyperlink {
162    /// A helper function, that first HTML escape decodes all strings of the
163    /// link. Then it percent decodes the link destination (and the
164    /// link text in case of an autolink).
165    fn decode_ampersand_and_percent(&mut self);
166
167    /// True if the value is a local link.
168    #[allow(clippy::ptr_arg)]
169    fn is_local_fn(value: &Cow<str>) -> bool;
170
171    /// * `Link::Text2Dest`: strips a possible scheme in local `dest`.
172    /// * `Link::Image2Dest`: strip local scheme in `dest`.
173    /// * `Link::Image`: strip local scheme in `src`.
174    ///
175    ///  No action if not local.
176    fn strip_local_scheme(&mut self);
177
178    /// Helper function that strips a possible scheme in `input`.
179    fn strip_scheme_fn(input: &mut Cow<str>);
180
181    /// True if the link is:
182    /// * `Link::Text2Dest` and the link text equals the link destination, or
183    /// * `Link::Image` and the links `alt` equals the link source.
184    ///
185    /// WARNING: place this test after `decode_html_escape_and_percent()`
186    /// and before: `rebase_local_link`, `expand_shorthand_link`,
187    /// `rewrite_autolink` and `apply_format_attribute`.
188    fn is_autolink(&self) -> bool;
189
190    /// A method that converts the relative URLs (local links) in `self`.
191    /// If successful, it returns `Ok(Some(URL))`, otherwise
192    /// `Err(NoteError::InvalidLocalLink)`.
193    /// If `self` contains an absolute URL, no conversion is performed and the
194    /// return value is `Ok(())`.
195    ///
196    /// Conversion details:
197    /// The base path for this conversion (usually where the HTML file resides),
198    /// is `docdir`. If not `rewrite_rel_links`, relative local links are not
199    /// converted. Furthermore, all local links starting with `/` are prepended
200    /// with `root_path`. All absolute URLs always remain untouched.
201    ///
202    /// Algorithm:
203    /// 1. If `rewrite_abs_links==true` and `link` starts with `/`, concatenate
204    ///    and return `root_path` and `dest`.
205    /// 2. If `rewrite_abs_links==false` and `dest` does not start wit `/`,
206    ///    return `dest`.
207    /// 3. If `rewrite_ext==true` and the link points to a known Tp-Note file
208    ///    extension, then `.html` is appended to the converted link.
209    ///
210    /// Remark: The _anchor's text property_ is never changed. However, there
211    /// is one exception: when the text contains a URL starting with `http:` or
212    /// `https:`, only the file stem is kept. Example, the anchor text property:
213    /// `<a ...>http:dir/my file.md</a>` is rewritten into `<a ...>my file</a>`.
214    ///
215    /// Contracts:
216    /// 1. `link` may have a scheme.
217    /// 2. `link` is `Link::Text2Dest` or `Link::Image`
218    /// 3. `root_path` and `docdir` are absolute paths to directories.
219    /// 4. `root_path` is never empty `""`. It can be `"/"`.
220    fn rebase_local_link(
221        &mut self,
222        root_path: &Path,
223        docdir: &Path,
224        rewrite_rel_paths: bool,
225        rewrite_abs_paths: bool,
226    ) -> Result<(), NoteError>;
227
228    /// If `dest` in `Link::Text2Dest` contains only a sort
229    /// tag as filename, expand the latter to a full filename.
230    /// Otherwise, no action.
231    /// This method accesses the filesystem. Therefore sometimes `prepend_path`
232    /// is needed as parameter and prepended.
233    fn expand_shorthand_link(&mut self, prepend_path: Option<&Path>) -> Result<(), NoteError>;
234
235    /// This removes a possible scheme in `text`.
236    /// Call this method only when you sure that this
237    /// is an autolink by testing with `is_autolink()`.
238    fn rewrite_autolink(&mut self);
239
240    /// A formatting attribute is a format string starting with `?` followed
241    /// by one or two patterns. It is appended to `dest` or `src`.
242    /// Processing details:
243    /// 1. Extract some a possible formatting attribute string in `dest`
244    ///    (`Link::Text2Dest`) or `src` (`Link::Image`) after `?`.
245    /// 2. Extract the _path_ before `?` in `dest` or `src`.
246    /// 3. Apply the formatting to _path_.
247    /// 4. Store the result by overwriting `text` or `alt`.
248    fn apply_format_attribute(&mut self);
249
250    /// If the link destination `dest` is a local path, return it.
251    /// Otherwise return `None`.
252    /// Acts on `Link:Text2Dest` and `Link::Imgage2Dest` only.
253    fn get_local_link_dest_path(&self) -> Option<&Path>;
254
255    /// If `dest` or `src` is a local path, return it.
256    /// Otherwise return `None`.
257    /// Acts an `Link:Image` and `Link::Image2Dest` only.
258    fn get_local_link_src_path(&self) -> Option<&Path>;
259
260    /// If the extension of a local path in `dest` is some Tp-Note
261    /// extension, append `.html` to the path. Otherwise silently return.
262    /// Acts on `Link:Text2Dest` only.
263    fn append_html_ext(&mut self);
264
265    /// Renders `Link::Text2Dest`, `Link::Image2Dest` and `Link::Image`
266    /// to HTML. Some characters in `dest` or `src` might be HTML
267    /// escape encoded. This does not percent encode at all, because
268    /// we know, that the result will be inserted later in a UTF-8 template.
269    fn to_html(&self) -> String;
270}
271
272impl Hyperlink for Link<'_> {
273    #[inline]
274    fn decode_ampersand_and_percent(&mut self) {
275        // HTML escape decode value.
276        fn dec_amp(val: &mut Cow<str>) {
277            let decoded_text = html_escape::decode_html_entities(val);
278            if matches!(&decoded_text, Cow::Owned(..)) {
279                // Does nothing, but satisfying the borrow checker. Does not `clone()`.
280                let decoded_text = Cow::Owned(decoded_text.into_owned());
281                // Store result.
282                let _ = std::mem::replace(val, decoded_text);
283            }
284        }
285
286        // HTML escape decode and percent decode value.
287        fn dec_amp_percent(val: &mut Cow<str>) {
288            dec_amp(val);
289            let decoded_dest = percent_decode_str(val.as_ref()).decode_utf8().unwrap();
290            if matches!(&decoded_dest, Cow::Owned(..)) {
291                // Does nothing, but satisfying the borrow checker. Does not `clone()`.
292                let decoded_dest = Cow::Owned(decoded_dest.into_owned());
293                // Store result.
294                let _ = std::mem::replace(val, decoded_dest);
295            }
296        }
297
298        match self {
299            Link::Text2Dest(text1, dest, title) => {
300                dec_amp(text1);
301                dec_amp_percent(dest);
302                dec_amp(title);
303            }
304            Link::Image(alt, src) => {
305                dec_amp(alt);
306                dec_amp_percent(src);
307            }
308            Link::Image2Dest(text1, alt, src, text2, dest, title) => {
309                dec_amp(text1);
310                dec_amp(alt);
311                dec_amp_percent(src);
312                dec_amp(text2);
313                dec_amp_percent(dest);
314                dec_amp(title);
315            }
316            _ => unimplemented!(),
317        };
318    }
319
320    //
321    fn is_local_fn(dest: &Cow<str>) -> bool {
322        !((dest.contains("://") && !dest.contains(":///"))
323            || dest.starts_with("mailto:")
324            || dest.starts_with("tel:"))
325    }
326
327    //
328    fn strip_local_scheme(&mut self) {
329        fn strip(dest: &mut Cow<str>) {
330            if <Link<'_> as Hyperlink>::is_local_fn(dest) {
331                <Link<'_> as Hyperlink>::strip_scheme_fn(dest);
332            }
333        }
334
335        match self {
336            Link::Text2Dest(_, dest, _title) => strip(dest),
337            Link::Image2Dest(_, _, src, _, dest, _) => {
338                strip(src);
339                strip(dest);
340            }
341            Link::Image(_, src) => strip(src),
342            _ => {}
343        };
344    }
345
346    //
347    fn strip_scheme_fn(inout: &mut Cow<str>) {
348        let output = inout
349            .trim_start_matches("https://")
350            .trim_start_matches("https:")
351            .trim_start_matches("http://")
352            .trim_start_matches("http:")
353            .trim_start_matches("tpnote:")
354            .trim_start_matches("mailto:")
355            .trim_start_matches("tel:");
356        if output != inout.as_ref() {
357            let _ = std::mem::replace(inout, Cow::Owned(output.to_string()));
358        }
359    }
360
361    //
362    fn is_autolink(&self) -> bool {
363        let (text, dest) = match self {
364            Link::Text2Dest(text, dest, _title) => (text, dest),
365            Link::Image(alt, source) => (alt, source),
366            // `Link::Image2Dest` is never an autolink.
367            _ => return false,
368        };
369        text == dest
370    }
371
372    //
373    fn rebase_local_link(
374        &mut self,
375        root_path: &Path,
376        docdir: &Path,
377        rewrite_rel_paths: bool,
378        rewrite_abs_paths: bool,
379    ) -> Result<(), NoteError> {
380        let do_rebase = |path: &mut Cow<str>| -> Result<(), NoteError> {
381            if <Link as Hyperlink>::is_local_fn(path) {
382                let dest_out = assemble_link(
383                    root_path,
384                    docdir,
385                    Path::new(path.as_ref()),
386                    rewrite_rel_paths,
387                    rewrite_abs_paths,
388                )
389                .ok_or(NoteError::InvalidLocalPath {
390                    path: path.as_ref().to_string(),
391                })?;
392
393                // Store result.
394                let new_dest = Cow::Owned(dest_out.to_str().unwrap_or_default().to_string());
395                let _ = std::mem::replace(path, new_dest);
396            }
397            Ok(())
398        };
399
400        match self {
401            Link::Text2Dest(_, dest, _) => do_rebase(dest),
402            Link::Image2Dest(_, _, src, _, dest, _) => do_rebase(src).and_then(|_| do_rebase(dest)),
403            Link::Image(_, src) => do_rebase(src),
404            _ => unimplemented!(),
405        }
406    }
407
408    //
409    fn expand_shorthand_link(&mut self, prepend_path: Option<&Path>) -> Result<(), NoteError> {
410        let shorthand_link = match self {
411            Link::Text2Dest(_, dest, _) => dest,
412            Link::Image2Dest(_, _, _, _, dest, _) => dest,
413            _ => return Ok(()),
414        };
415
416        if !<Link as Hyperlink>::is_local_fn(shorthand_link) {
417            return Ok(());
418        }
419
420        let (shorthand_str, shorthand_format) = match shorthand_link.split_once(FORMAT_SEPARATOR) {
421            Some((path, fmt)) => (path, Some(fmt)),
422            None => (shorthand_link.as_ref(), None),
423        };
424
425        let shorthand_path = Path::new(shorthand_str);
426
427        if let Some(sort_tag) = shorthand_str.is_valid_sort_tag() {
428            let full_shorthand_path = if let Some(root_path) = prepend_path {
429                // Concatenate `root_path` and `shorthand_path`.
430                let shorthand_path = shorthand_path
431                    .strip_prefix(MAIN_SEPARATOR_STR)
432                    .unwrap_or(shorthand_path);
433                Cow::Owned(root_path.join(shorthand_path))
434            } else {
435                Cow::Borrowed(shorthand_path)
436            };
437
438            // Search for the file.
439            let found = full_shorthand_path
440                .parent()
441                .and_then(|dir| dir.find_file_with_sort_tag(sort_tag));
442
443            if let Some(path) = found {
444                // We prepended `root_path` before, we can safely strip it
445                // and unwrap.
446                let found_link = path
447                    .strip_prefix(prepend_path.unwrap_or(Path::new("")))
448                    .unwrap();
449                // Prepend `/`.
450                let mut found_link = Path::new(MAIN_SEPARATOR_STR)
451                    .join(found_link)
452                    .to_str()
453                    .unwrap_or_default()
454                    .to_string();
455
456                if let Some(fmt) = shorthand_format {
457                    found_link.push(FORMAT_SEPARATOR);
458                    found_link.push_str(fmt);
459                }
460
461                // Store result.
462                let _ = std::mem::replace(shorthand_link, Cow::Owned(found_link));
463            } else {
464                return Err(NoteError::CanNotExpandShorthandLink {
465                    path: full_shorthand_path.to_string_lossy().into_owned(),
466                });
467            }
468        }
469        Ok(())
470    }
471
472    //
473    fn rewrite_autolink(&mut self) {
474        let text = match self {
475            Link::Text2Dest(text, _, _) => text,
476            Link::Image(alt, _) => alt,
477            _ => return,
478        };
479
480        <Link as Hyperlink>::strip_scheme_fn(text);
481    }
482
483    //
484    fn apply_format_attribute(&mut self) {
485        // Is this an absolute URL?
486
487        let (text, dest) = match self {
488            Link::Text2Dest(text, dest, _) => (text, dest),
489            Link::Image(alt, source) => (alt, source),
490            _ => return,
491        };
492
493        if !<Link as Hyperlink>::is_local_fn(dest) {
494            return;
495        }
496
497        // We assume, that `dest` had been expanded already, so we can extract
498        // the full filename here.
499        // If ever it ends with a format string we apply it. Otherwise we quit
500        // the method and do nothing.
501        let (path, format) = match dest.split_once(FORMAT_SEPARATOR) {
502            Some(s) => s,
503            None => return,
504        };
505
506        let mut short_text = Path::new(path)
507            .file_name()
508            .unwrap_or_default()
509            .to_str()
510            .unwrap_or_default();
511
512        // Select what to match:
513        let format = if format.starts_with(FORMAT_COMPLETE_FILENAME) {
514            // Keep complete filename.
515            format
516                .strip_prefix(FORMAT_COMPLETE_FILENAME)
517                .unwrap_or(format)
518        } else if format.starts_with(FORMAT_ONLY_SORT_TAG) {
519            // Keep only format-tag.
520            short_text = Path::new(path).disassemble().0;
521            format.strip_prefix(FORMAT_ONLY_SORT_TAG).unwrap_or(format)
522        } else {
523            // Keep only stem.
524            short_text = Path::new(path).disassemble().2;
525            format
526        };
527
528        match format.split_once(FORMAT_FROM_TO_SEPARATOR) {
529            // No `:`
530            None => {
531                if !format.is_empty()
532                    && let Some(idx) = short_text.find(format) {
533                        short_text = &short_text[..idx];
534                    };
535            }
536            // Some `:`
537            Some((from, to)) => {
538                if !from.is_empty()
539                    && let Some(idx) = short_text.find(from) {
540                        short_text = &short_text[(idx + from.len())..];
541                    };
542                if !to.is_empty()
543                    && let Some(idx) = short_text.find(to) {
544                        short_text = &short_text[..idx];
545                    };
546            }
547        }
548        // Store the result.
549        let _ = std::mem::replace(text, Cow::Owned(short_text.to_string()));
550        let _ = std::mem::replace(dest, Cow::Owned(path.to_string()));
551    }
552
553    //
554    fn get_local_link_dest_path(&self) -> Option<&Path> {
555        let dest = match self {
556            Link::Text2Dest(_, dest, _) => dest,
557            Link::Image2Dest(_, _, _, _, dest, _) => dest,
558            _ => return None,
559        };
560        if <Link as Hyperlink>::is_local_fn(dest) {
561            Some(Path::new(split_path_and_fragment(dest.as_ref()).0))
562        } else {
563            None
564        }
565    }
566
567    //
568    fn get_local_link_src_path(&self) -> Option<&Path> {
569        let src = match self {
570            Link::Image2Dest(_, _, src, _, _, _) => src,
571            Link::Image(_, src) => src,
572            _ => return None,
573        };
574        if <Link as Hyperlink>::is_local_fn(src) {
575            Some(Path::new(src.as_ref()))
576        } else {
577            None
578        }
579    }
580
581    //
582    fn append_html_ext(&mut self) {
583        let dest = match self {
584            Link::Text2Dest(_, dest, _) => dest,
585            Link::Image2Dest(_, _, _, _, dest, _) => dest,
586            _ => return,
587        };
588        if <Link as Hyperlink>::is_local_fn(dest) {
589            let path = dest.as_ref();
590            if path.has_tpnote_ext() {
591                let mut newpath = path.to_string();
592                newpath.push_str(HTML_EXT);
593
594                let _ = std::mem::replace(dest, Cow::Owned(newpath));
595            }
596        }
597    }
598
599    //
600    fn to_html(&self) -> String {
601        // HTML escape encode double quoted attributes
602        fn enc_amp(val: Cow<str>) -> Cow<str> {
603            let s = html_escape::encode_double_quoted_attribute(val.as_ref());
604            if s == val {
605                val
606            } else {
607                // No cloning happens here, because we own `s` already.
608                Cow::Owned(s.into_owned())
609            }
610        }
611        // Replace Windows backslash, percent-encode the path (keeping a
612        // written fragment untouched), then HTML escape encode.
613        fn repl_backspace_enc_amp(val: Cow<str>) -> Cow<str> {
614            // Under Windows `\` is a path separator, not data: normalize it
615            // to `/` before `split_path_and_fragment`/`percent_encode_path`
616            // treat it as one.
617            let val = if val.as_ref().contains('\\') {
618                Cow::Owned(val.to_string().replace('\\', "/"))
619            } else {
620                val
621            };
622            let (path, fragment) = split_path_and_fragment(val.as_ref());
623            let encoded = format!("{}{}", percent_encode_path(path), fragment);
624            let s = html_escape::encode_double_quoted_attribute(&encoded);
625            Cow::Owned(s.into_owned())
626        }
627
628        match self {
629            Link::Text2Dest(text, dest, title) => {
630                // Format title.
631                let title_html = if !title.is_empty() {
632                    format!(" title=\"{}\"", enc_amp(title.shallow_clone()))
633                } else {
634                    "".to_string()
635                };
636
637                format!(
638                    "<a href=\"{}\"{}>{}</a>",
639                    repl_backspace_enc_amp(dest.shallow_clone()),
640                    title_html,
641                    text
642                )
643            }
644            Link::Image2Dest(text1, alt, src, text2, dest, title) => {
645                // Format title.
646                let title_html = if !title.is_empty() {
647                    format!(" title=\"{}\"", enc_amp(title.shallow_clone()))
648                } else {
649                    "".to_string()
650                };
651
652                format!(
653                    "<a href=\"{}\"{}>{}<img src=\"{}\" alt=\"{}\">{}</a>",
654                    repl_backspace_enc_amp(dest.shallow_clone()),
655                    title_html,
656                    text1,
657                    repl_backspace_enc_amp(src.shallow_clone()),
658                    enc_amp(alt.shallow_clone()),
659                    text2
660                )
661            }
662            Link::Image(alt, src) => {
663                format!(
664                    "<img src=\"{}\" alt=\"{}\">",
665                    repl_backspace_enc_amp(src.shallow_clone()),
666                    enc_amp(alt.shallow_clone())
667                )
668            }
669            _ => unimplemented!(),
670        }
671    }
672}
673
674#[inline]
675/// A helper function that scans the input HTML document in `html_input` for
676/// HTML hyperlinks. When it finds a relative URL (local link), it analyzes it's
677/// path. Depending on the `local_link_kind` configuration, relative local
678/// links are converted into absolute local links and eventually rebased.
679///
680/// In order to achieve this, the user must respect the following convention
681/// concerning absolute local links in Tp-Note documents:
682/// 1. When a document contains a local link with an absolute path (absolute
683///    local link), the base of this path is considered to be the directory
684///    where the marker file ‘.tpnote.toml’ resides (or ‘/’ in non exists). The
685///    marker file directory is `root_path`.
686/// 2. Furthermore, the parameter `docdir` contains the absolute path of the
687///    directory of the currently processed HTML document. The user guarantees
688///    that `docdir` is the base for all relative local links in the document.
689///    Note: `docdir` must always start with `root_path`.
690///
691/// If `LocalLinkKind::Off`, relative local links are not converted.
692/// If `LocalLinkKind::Short`, relative local links are converted into an
693/// absolute local links with `root_path` as base directory.
694/// If `LocalLinkKind::Long`, in addition to the above, the resulting absolute
695/// local link is prepended with `root_path`.
696///
697/// If `rewrite_ext` is true and a local link points to a known
698/// Tp-Note file extension, then `.html` is appended to the converted link.
699///
700/// Remark: The link's text property is never changed. However, there is
701/// one exception: when the link's text contains a string similar to URLs,
702/// starting with `http:` or `tpnote:`. In this case, the string is interpreted
703/// as URL and only the stem of the filename is displayed, e.g.
704/// `<a ...>http:dir/my file.md</a>` is replaced with `<a ...>my file</a>`.
705///
706/// Finally, before a converted local link is reinserted in the output HTML, a
707/// copy of that link is kept in `allowed_local_links` for further bookkeeping.
708///
709/// NB: All absolute URLs (starting with a domain) always remain untouched.
710///
711/// NB2: It is guaranteed, that the resulting HTML document contains only local
712/// links to other documents within `root_path`. Deviant links displayed as
713/// `INVALID LOCAL LINK` and URL is discarded.
714pub fn rewrite_links(
715    html_input: String,
716    root_path: &Path,
717    docdir: &Path,
718    local_link_kind: LocalLinkKind,
719    rewrite_ext: bool,
720    allowed_local_links: Arc<RwLock<HashSet<PathBuf>>>,
721) -> String {
722    let (rewrite_rel_paths, rewrite_abs_paths) = match local_link_kind {
723        LocalLinkKind::Off => (false, false),
724        LocalLinkKind::Short => (true, false),
725        LocalLinkKind::Long => (true, true),
726    };
727
728    // Search for hyperlinks and inline images in the HTML rendition
729    // of this note.
730    let mut rest = &*html_input;
731    let mut html_out = String::new();
732    for ((skipped, _consumed, remaining), mut link) in HtmlLinkInlineImage::new(&html_input) {
733        html_out.push_str(skipped);
734        rest = remaining;
735
736        // Check if `text` = `dest`.
737        let mut link_is_autolink = link.is_autolink();
738
739        // Percent decode link destination.
740        link.decode_ampersand_and_percent();
741
742        // Check again if `text` = `dest`.
743        link_is_autolink = link_is_autolink || link.is_autolink();
744
745        link.strip_local_scheme();
746
747        // Rewrite the local link.
748        match link
749            .rebase_local_link(root_path, docdir, rewrite_rel_paths, rewrite_abs_paths)
750            .and_then(|_| {
751                link.expand_shorthand_link(
752                    (matches!(local_link_kind, LocalLinkKind::Short)).then_some(root_path),
753                )
754            }) {
755            Ok(()) => {}
756            Err(e) => {
757                let e = e.to_string();
758                let e = html_escape::encode_text(&e);
759                html_out.push_str(&format!("<i>{}</i>", e));
760                continue;
761            }
762        };
763
764        if link_is_autolink {
765            link.rewrite_autolink();
766        }
767
768        link.apply_format_attribute();
769
770        if let Some(dest_path) = link.get_local_link_dest_path() {
771            allowed_local_links.write().insert(dest_path.to_path_buf());
772        };
773        if let Some(src_path) = link.get_local_link_src_path() {
774            allowed_local_links.write().insert(src_path.to_path_buf());
775        };
776
777        if rewrite_ext {
778            link.append_html_ext();
779        }
780        html_out.push_str(&link.to_html());
781    }
782    // Add the last `remaining`.
783    html_out.push_str(rest);
784
785    log::trace!(
786        "Viewer: referenced allowed local files: {}",
787        allowed_local_links
788            .read_recursive()
789            .iter()
790            .map(|p| {
791                let mut s = "\n    '".to_string();
792                s.push_str(&p.display().to_string());
793                s
794            })
795            .collect::<String>()
796    );
797
798    html_out
799    // The `RwLockWriteGuard` is released here.
800}
801
802/// This trait deals with tagged HTML `&str` data.
803pub trait HtmlStr {
804    /// Lowercase pattern to check if this is a Doctype tag.
805    const TAG_DOCTYPE_PAT: &'static str = "<!doctype";
806    /// Lowercase pattern to check if this Doctype is HTML.
807    const TAG_DOCTYPE_HTML_PAT: &'static str = "<!doctype html";
808    /// Doctype HTML tag. This is inserted by
809    /// `<HtmlString>.prepend_html_start_tag()`
810    const TAG_DOCTYPE_HTML: &'static str = "<!DOCTYPE html>";
811    /// Pattern to check if f this is an HTML start tag.
812    const START_TAG_HTML_PAT: &'static str = "<html";
813    /// HTML end tag.
814    const END_TAG_HTML: &'static str = "</html>";
815
816    /// We consider `self` empty, when it equals to `<!DOCTYPE html...>` or
817    /// when it is empty.
818    fn is_empty_html(&self) -> bool;
819
820    /// We consider `html` empty, when it equals to `<!DOCTYPE html...>` or
821    /// when it is empty.
822    /// This is identical to `is_empty_html()`, but does not pull in
823    /// additional trait bounds.
824    fn is_empty_html2(html: &str) -> bool {
825        html.is_empty_html()
826    }
827
828    /// True if stream starts with `<!DOCTYPE html...>`.
829    fn has_html_start_tag(&self) -> bool;
830
831    /// True if `html` starts with `<!DOCTYPE html...>`.
832    /// This is identical to `has_html_start_tag()`, but does not pull in
833    /// additional trait bounds.
834    fn has_html_start_tag2(html: &str) -> bool {
835        html.has_html_start_tag()
836    }
837
838    /// Some heuristics to guess if the input stream contains HTML.
839    /// Current implementation:
840    /// True if:
841    ///
842    /// * The stream starts with `<!DOCTYPE html ...>`, or
843    /// * the stream starts with `<html ...>`    
844    ///
845    /// This function does not check if the recognized HTML is valid.
846    fn is_html_unchecked(&self) -> bool;
847}
848
849impl HtmlStr for str {
850    fn is_empty_html(&self) -> bool {
851        if self.is_empty() {
852            return true;
853        }
854
855        let html = self
856            .trim_start()
857            .lines()
858            .next()
859            .map(|l| l.to_ascii_lowercase())
860            .unwrap_or_default();
861
862        html.as_str().starts_with(Self::TAG_DOCTYPE_HTML_PAT)
863            // The next closing bracket must be in last position.
864            && html.find('>').unwrap_or_default() == html.len()-1
865    }
866
867    fn has_html_start_tag(&self) -> bool {
868        let html = self
869            .trim_start()
870            .lines()
871            .next()
872            .map(|l| l.to_ascii_lowercase());
873        html.as_ref()
874            .is_some_and(|l| l.starts_with(Self::TAG_DOCTYPE_HTML_PAT))
875    }
876
877    fn is_html_unchecked(&self) -> bool {
878        let html = self
879            .trim_start()
880            .lines()
881            .next()
882            .map(|l| l.to_ascii_lowercase());
883        html.as_ref().is_some_and(|l| {
884            (l.starts_with(Self::TAG_DOCTYPE_HTML_PAT)
885                && l[Self::TAG_DOCTYPE_HTML_PAT.len()..].contains('>'))
886                || (l.starts_with(Self::START_TAG_HTML_PAT)
887                    && l[Self::START_TAG_HTML_PAT.len()..].contains('>'))
888        })
889    }
890}
891
892/// This trait deals with tagged HTML `String` data.
893pub trait HtmlString: Sized {
894    /// If the input does not start with `<!DOCTYPE html`
895    /// (or lowercase variants), then insert `<!DOCTYPE html>`.
896    /// Returns `InputStreamError::NonHtmlDoctype` if there is another Doctype
897    /// already.
898    fn prepend_html_start_tag(self) -> Result<Self, InputStreamError>;
899}
900
901impl HtmlString for String {
902    fn prepend_html_start_tag(self) -> Result<Self, InputStreamError> {
903        // Bring `HtmlStr` methods into scope.
904        use crate::html::HtmlStr;
905
906        let html2 = self
907            .trim_start()
908            .lines()
909            .next()
910            .map(|l| l.to_ascii_lowercase())
911            .unwrap_or_default();
912
913        if html2.starts_with(<str as HtmlStr>::TAG_DOCTYPE_HTML_PAT) {
914            // Has a start tag already.
915            Ok(self)
916        } else if !html2.starts_with(<str as HtmlStr>::TAG_DOCTYPE_PAT) {
917            // Insert HTML Doctype tag.
918            let mut html = self;
919            html.insert_str(0, <str as HtmlStr>::TAG_DOCTYPE_HTML);
920            Ok(html)
921        } else {
922            // There is a Doctype other than HTML.
923            Err(InputStreamError::NonHtmlDoctype {
924                html: self.chars().take(25).collect::<String>(),
925            })
926        }
927    }
928}
929
930#[cfg(test)]
931mod tests {
932
933    use crate::error::InputStreamError;
934    use crate::error::NoteError;
935    use crate::html::Hyperlink;
936    use crate::html::assemble_link;
937    use crate::html::rewrite_links;
938    use parking_lot::RwLock;
939    use parse_hyperlinks::parser::Link;
940    use parse_hyperlinks_extras::parser::parse_html::take_link;
941    use std::borrow::Cow;
942    use std::{
943        collections::HashSet,
944        path::{Path, PathBuf},
945        sync::Arc,
946    };
947
948    #[test]
949    fn test_assemble_link() {
950        // `rewrite_rel_links=true`
951        let output = assemble_link(
952            Path::new("/my"),
953            Path::new("/my/doc/path"),
954            Path::new("../local/link to/note.md"),
955            true,
956            false,
957        )
958        .unwrap();
959        assert_eq!(output, Path::new("/doc/local/link to/note.md"));
960
961        // `rewrite_rel_links=false`
962        let output = assemble_link(
963            Path::new("/my"),
964            Path::new("/my/doc/path"),
965            Path::new("../local/link to/note.md"),
966            false,
967            false,
968        )
969        .unwrap();
970        assert_eq!(output, Path::new("../local/link to/note.md"));
971
972        // Absolute `dest`.
973        let output = assemble_link(
974            Path::new("/my"),
975            Path::new("/my/doc/path"),
976            Path::new("/test/../abs/local/link to/note.md"),
977            false,
978            false,
979        )
980        .unwrap();
981        assert_eq!(output, Path::new("/abs/local/link to/note.md"));
982
983        // Underflow.
984        let output = assemble_link(
985            Path::new("/my"),
986            Path::new("/my/doc/path"),
987            Path::new("/../local/link to/note.md"),
988            false,
989            false,
990        );
991        assert_eq!(output, None);
992
993        // Absolute `dest`, `rewrite_abs_links=true`.
994        let output = assemble_link(
995            Path::new("/my"),
996            Path::new("/my/doc/path"),
997            Path::new("/abs/local/link to/note.md"),
998            false,
999            true,
1000        )
1001        .unwrap();
1002        assert_eq!(output, Path::new("/my/abs/local/link to/note.md"));
1003
1004        // Absolute `dest`, `rewrite_abs_links=false`.
1005        let output = assemble_link(
1006            Path::new("/my"),
1007            Path::new("/my/doc/path"),
1008            Path::new("/test/../abs/local/link to/note.md"),
1009            false,
1010            false,
1011        )
1012        .unwrap();
1013        assert_eq!(output, Path::new("/abs/local/link to/note.md"));
1014
1015        // Absolute `dest`, `rewrite` both.
1016        let output = assemble_link(
1017            Path::new("/my"),
1018            Path::new("/my/doc/path"),
1019            Path::new("abs/local/link to/note.md"),
1020            true,
1021            true,
1022        )
1023        .unwrap();
1024        assert_eq!(output, Path::new("/my/doc/path/abs/local/link to/note.md"));
1025    }
1026
1027    #[test]
1028    fn test_decode_html_escape_and_percent() {
1029        //
1030        let mut input = Link::Text2Dest(Cow::from("text"), Cow::from("dest"), Cow::from("title"));
1031        let expected = Link::Text2Dest(Cow::from("text"), Cow::from("dest"), Cow::from("title"));
1032        input.decode_ampersand_and_percent();
1033        let output = input;
1034        assert_eq!(output, expected);
1035
1036        //
1037        let mut input = Link::Text2Dest(
1038            Cow::from("te%20xt"),
1039            Cow::from("de%20st"),
1040            Cow::from("title"),
1041        );
1042        let expected =
1043            Link::Text2Dest(Cow::from("te%20xt"), Cow::from("de st"), Cow::from("title"));
1044        input.decode_ampersand_and_percent();
1045        let output = input;
1046        assert_eq!(output, expected);
1047
1048        //
1049        let mut input =
1050            Link::Text2Dest(Cow::from("text"), Cow::from("d:e%20st"), Cow::from("title"));
1051        let expected = Link::Text2Dest(Cow::from("text"), Cow::from("d:e st"), Cow::from("title"));
1052        input.decode_ampersand_and_percent();
1053        let output = input;
1054        assert_eq!(output, expected);
1055
1056        let mut input = Link::Text2Dest(
1057            Cow::from("a&amp;&quot;lt"),
1058            Cow::from("a&amp;&quot;lt"),
1059            Cow::from("a&amp;&quot;lt"),
1060        );
1061        let expected = Link::Text2Dest(
1062            Cow::from("a&\"lt"),
1063            Cow::from("a&\"lt"),
1064            Cow::from("a&\"lt"),
1065        );
1066        input.decode_ampersand_and_percent();
1067        let output = input;
1068        assert_eq!(output, expected);
1069
1070        //
1071        let mut input = Link::Image(Cow::from("al%20t"), Cow::from("de%20st"));
1072        let expected = Link::Image(Cow::from("al%20t"), Cow::from("de st"));
1073        input.decode_ampersand_and_percent();
1074        let output = input;
1075        assert_eq!(output, expected);
1076
1077        //
1078        let mut input = Link::Image(Cow::from("a\\lt"), Cow::from("d\\est"));
1079        let expected = Link::Image(Cow::from("a\\lt"), Cow::from("d\\est"));
1080        input.decode_ampersand_and_percent();
1081        let output = input;
1082        assert_eq!(output, expected);
1083
1084        //
1085        let mut input = Link::Image(Cow::from("a&amp;&quot;lt"), Cow::from("a&amp;&quot;lt"));
1086        let expected = Link::Image(Cow::from("a&\"lt"), Cow::from("a&\"lt"));
1087        input.decode_ampersand_and_percent();
1088        let output = input;
1089        assert_eq!(output, expected);
1090    }
1091
1092    #[test]
1093    fn test_is_local() {
1094        let input = Cow::from("/path/My doc.md");
1095        assert!(<Link as Hyperlink>::is_local_fn(&input));
1096
1097        let input = Cow::from("tpnote:path/My doc.md");
1098        assert!(<Link as Hyperlink>::is_local_fn(&input));
1099
1100        let input = Cow::from("tpnote:/path/My doc.md");
1101        assert!(<Link as Hyperlink>::is_local_fn(&input));
1102
1103        let input = Cow::from("https://getreu.net");
1104        assert!(!<Link as Hyperlink>::is_local_fn(&input));
1105    }
1106
1107    #[test]
1108    fn strip_local_scheme() {
1109        let mut input = Link::Text2Dest(
1110            Cow::from("xyz"),
1111            Cow::from("https://getreu.net"),
1112            Cow::from("xyz"),
1113        );
1114        let expected = input.clone();
1115        input.strip_local_scheme();
1116        assert_eq!(input, expected);
1117
1118        //
1119        let mut input = Link::Text2Dest(
1120            Cow::from("xyz"),
1121            Cow::from("tpnote:/dir/My doc.md"),
1122            Cow::from("xyz"),
1123        );
1124        let expected = Link::Text2Dest(
1125            Cow::from("xyz"),
1126            Cow::from("/dir/My doc.md"),
1127            Cow::from("xyz"),
1128        );
1129        input.strip_local_scheme();
1130        assert_eq!(input, expected);
1131    }
1132
1133    #[test]
1134    fn test_is_autolink() {
1135        let input = Link::Image(Cow::from("abc"), Cow::from("abc"));
1136        assert!(input.is_autolink());
1137
1138        //
1139        let input = Link::Text2Dest(Cow::from("abc"), Cow::from("abc"), Cow::from("xyz"));
1140        assert!(input.is_autolink());
1141
1142        //
1143        let input = Link::Image(Cow::from("abc"), Cow::from("abcd"));
1144        assert!(!input.is_autolink());
1145
1146        //
1147        let input = Link::Text2Dest(Cow::from("abc"), Cow::from("abcd"), Cow::from("xyz"));
1148        assert!(!input.is_autolink());
1149    }
1150
1151    #[test]
1152    fn test_rewrite_local_link() {
1153        let root_path = Path::new("/my/");
1154        let docdir = Path::new("/my/abs/note path/");
1155
1156        // Should panic: this is not a relative path.
1157        let mut input = take_link("<a href=\"ftp://getreu.net\">Blog</a>")
1158            .unwrap()
1159            .1
1160            .1;
1161        input
1162            .rebase_local_link(root_path, docdir, true, false)
1163            .unwrap();
1164        assert!(input.get_local_link_dest_path().is_none());
1165
1166        //
1167        let root_path = Path::new("/my/");
1168        let docdir = Path::new("/my/abs/note path/");
1169
1170        // Check relative path to image.
1171        let mut input = take_link("<img src=\"down/./down/../../t m p.jpg\" alt=\"Image\" />")
1172            .unwrap()
1173            .1
1174            .1;
1175        let expected = "<img src=\"/abs/note%20path/t%20m%20p.jpg\" \
1176            alt=\"Image\">";
1177        input
1178            .rebase_local_link(root_path, docdir, true, false)
1179            .unwrap();
1180        let outpath = input.get_local_link_src_path().unwrap();
1181        let output = input.to_html();
1182        assert_eq!(output, expected);
1183        assert_eq!(outpath, PathBuf::from("/abs/note path/t m p.jpg"));
1184
1185        // Check relative path to image. Canonicalized?
1186        let mut input = take_link("<img src=\"down/./../../t m p.jpg\" alt=\"Image\" />")
1187            .unwrap()
1188            .1
1189            .1;
1190        let expected = "<img src=\"/abs/t%20m%20p.jpg\" alt=\"Image\">";
1191        input
1192            .rebase_local_link(root_path, docdir, true, false)
1193            .unwrap();
1194        let outpath = input.get_local_link_src_path().unwrap();
1195        let output = input.to_html();
1196        assert_eq!(output, expected);
1197        assert_eq!(outpath, PathBuf::from("/abs/t m p.jpg"));
1198
1199        // Check relative path to note file.
1200        let mut input = take_link("<a href=\"./down/./../my note 1.md\">my note 1</a>")
1201            .unwrap()
1202            .1
1203            .1;
1204        let expected = "<a href=\"/abs/note%20path/my%20note%201.md\">my note 1</a>";
1205        input
1206            .rebase_local_link(root_path, docdir, true, false)
1207            .unwrap();
1208        let outpath = input.get_local_link_dest_path().unwrap();
1209        let output = input.to_html();
1210        assert_eq!(output, expected);
1211        assert_eq!(outpath, PathBuf::from("/abs/note path/my note 1.md"));
1212
1213        // Check absolute path to note file.
1214        let mut input = take_link("<a href=\"/dir/./down/../my note 1.md\">my note 1</a>")
1215            .unwrap()
1216            .1
1217            .1;
1218        let expected = "<a href=\"/dir/my%20note%201.md\">my note 1</a>";
1219        input
1220            .rebase_local_link(root_path, docdir, true, false)
1221            .unwrap();
1222        let outpath = input.get_local_link_dest_path().unwrap();
1223        let output = input.to_html();
1224        assert_eq!(output, expected);
1225        assert_eq!(outpath, PathBuf::from("/dir/my note 1.md"));
1226
1227        // Check relative path to note file. Canonicalized?
1228        let mut input = take_link("<a href=\"./down/./../dir/my note 1.md\">my note 1</a>")
1229            .unwrap()
1230            .1
1231            .1;
1232        let expected = "<a href=\"dir/my%20note%201.md\">my note 1</a>";
1233        input
1234            .rebase_local_link(root_path, docdir, false, false)
1235            .unwrap();
1236        let outpath = input.get_local_link_dest_path().unwrap();
1237        let output = input.to_html();
1238        assert_eq!(output, expected);
1239        assert_eq!(outpath, PathBuf::from("dir/my note 1.md"));
1240
1241        // Check relative link in input.
1242        let mut input = take_link("<a href=\"./down/./../dir/my note 1.md\">my note 1</a>")
1243            .unwrap()
1244            .1
1245            .1;
1246        let expected = "<a href=\"/path/dir/my%20note%201.md\">my note 1</a>";
1247        input
1248            .rebase_local_link(
1249                Path::new("/my/note/"),
1250                Path::new("/my/note/path/"),
1251                true,
1252                false,
1253            )
1254            .unwrap();
1255        let outpath = input.get_local_link_dest_path().unwrap();
1256        let output = input.to_html();
1257        assert_eq!(output, expected);
1258        assert_eq!(outpath, PathBuf::from("/path/dir/my note 1.md"));
1259
1260        // Check absolute link in input.
1261        let mut input = take_link("<a href=\"/down/./../dir/my note 1.md\">my note 1</a>")
1262            .unwrap()
1263            .1
1264            .1;
1265        let expected = "<a href=\"/dir/my%20note%201.md\">my note 1</a>";
1266        input
1267            .rebase_local_link(root_path, Path::new("/my/ignored/"), true, false)
1268            .unwrap();
1269        let outpath = input.get_local_link_dest_path().unwrap();
1270        let output = input.to_html();
1271        assert_eq!(output, expected);
1272        assert_eq!(outpath, PathBuf::from("/dir/my note 1.md"));
1273
1274        // Check absolute link in input, not in `root_path`.
1275        let mut input = take_link("<a href=\"/down/../../dir/my note 1.md\">my note 1</a>")
1276            .unwrap()
1277            .1
1278            .1;
1279        let output = input
1280            .rebase_local_link(root_path, Path::new("/my/notepath/"), true, false)
1281            .unwrap_err();
1282        assert!(matches!(output, NoteError::InvalidLocalPath { .. }));
1283
1284        // Check relative link in input, not in `root_path`.
1285        let mut input = take_link("<a href=\"../../dir/my note 1.md\">my note 1</a>")
1286            .unwrap()
1287            .1
1288            .1;
1289        let output = input
1290            .rebase_local_link(root_path, Path::new("/my/notepath/"), true, false)
1291            .unwrap_err();
1292        assert!(matches!(output, NoteError::InvalidLocalPath { .. }));
1293
1294        // Check relative link in input, with underflow.
1295        let root_path = Path::new("/");
1296        let mut input = take_link("<a href=\"../../dir/my note 1.md\">my note 1</a>")
1297            .unwrap()
1298            .1
1299            .1;
1300        let output = input
1301            .rebase_local_link(root_path, Path::new("/my/"), true, false)
1302            .unwrap_err();
1303        assert!(matches!(output, NoteError::InvalidLocalPath { .. }));
1304
1305        // Check relative link in input, not in `root_path`.
1306        let root_path = Path::new("/my");
1307        let mut input = take_link("<a href=\"../../dir/my note 1.md\">my note 1</a>")
1308            .unwrap()
1309            .1
1310            .1;
1311        let output = input
1312            .rebase_local_link(root_path, Path::new("/my/notepath"), true, false)
1313            .unwrap_err();
1314        assert!(matches!(output, NoteError::InvalidLocalPath { .. }));
1315
1316        // Test autolink.
1317        let root_path = Path::new("/my");
1318        let mut input =
1319            take_link("<a href=\"tpnote:dir/3.0-my note.md\">tpnote:dir/3.0-my note.md</a>")
1320                .unwrap()
1321                .1
1322                .1;
1323        input.strip_local_scheme();
1324        input
1325            .rebase_local_link(root_path, Path::new("/my/path"), true, false)
1326            .unwrap();
1327        input.rewrite_autolink();
1328        input.apply_format_attribute();
1329        let outpath = input.get_local_link_dest_path().unwrap();
1330        let output = input.to_html();
1331        let expected = "<a href=\"/path/dir/3.0-my%20note.md\">dir/3.0-my note.md</a>";
1332        assert_eq!(output, expected);
1333        assert_eq!(outpath, PathBuf::from("/path/dir/3.0-my note.md"));
1334
1335        // Test short autolink 1 with sort-tag only.
1336        let root_path = Path::new("/my");
1337        let mut input = take_link("<a href=\"tpnote:dir/3.0\">tpnote:dir/3.0</a>")
1338            .unwrap()
1339            .1
1340            .1;
1341        input.strip_local_scheme();
1342        input
1343            .rebase_local_link(root_path, Path::new("/my/path"), true, false)
1344            .unwrap();
1345        input.rewrite_autolink();
1346        input.apply_format_attribute();
1347        let outpath = input.get_local_link_dest_path().unwrap();
1348        let output = input.to_html();
1349        let expected = "<a href=\"/path/dir/3.0\">dir/3.0</a>";
1350        assert_eq!(output, expected);
1351        assert_eq!(outpath, PathBuf::from("/path/dir/3.0"));
1352
1353        // The link text contains inline content.
1354        let root_path = Path::new("/my");
1355        let mut input = take_link(
1356            "<a href=\
1357            \"/uri\">link <em>foo <strong>bar</strong> <code>#</code></em>\
1358            </a>",
1359        )
1360        .unwrap()
1361        .1
1362        .1;
1363        input.strip_local_scheme();
1364        input
1365            .rebase_local_link(root_path, Path::new("/my/path"), true, false)
1366            .unwrap();
1367        let outpath = input.get_local_link_dest_path().unwrap();
1368        let expected = "<a href=\"/uri\">link <em>foo <strong>bar\
1369            </strong> <code>#</code></em></a>";
1370
1371        let output = input.to_html();
1372        assert_eq!(output, expected);
1373        assert_eq!(outpath, PathBuf::from("/uri"));
1374    }
1375
1376    #[test]
1377    fn test_rewrite_autolink() {
1378        //
1379        let mut input = Link::Text2Dest(
1380            Cow::from("http://getreu.net"),
1381            Cow::from("http://getreu.net"),
1382            Cow::from("title"),
1383        );
1384        let expected = Link::Text2Dest(
1385            Cow::from("getreu.net"),
1386            Cow::from("http://getreu.net"),
1387            Cow::from("title"),
1388        );
1389        input.rewrite_autolink();
1390        let output = input;
1391        assert_eq!(output, expected);
1392
1393        //
1394        let mut input = Link::Text2Dest(
1395            Cow::from("/dir/3.0"),
1396            Cow::from("/dir/3.0-My note.md"),
1397            Cow::from("title"),
1398        );
1399        let expected = Link::Text2Dest(
1400            Cow::from("/dir/3.0"),
1401            Cow::from("/dir/3.0-My note.md"),
1402            Cow::from("title"),
1403        );
1404        input.rewrite_autolink();
1405        let output = input;
1406        assert_eq!(output, expected);
1407
1408        //
1409        let mut input = Link::Text2Dest(
1410            Cow::from("tpnote:/dir/3.0"),
1411            Cow::from("/dir/3.0-My note.md"),
1412            Cow::from("title"),
1413        );
1414        let expected = Link::Text2Dest(
1415            Cow::from("/dir/3.0"),
1416            Cow::from("/dir/3.0-My note.md"),
1417            Cow::from("title"),
1418        );
1419        input.rewrite_autolink();
1420        let output = input;
1421        assert_eq!(output, expected);
1422
1423        //
1424        let mut input = Link::Text2Dest(
1425            Cow::from("tpnote:/dir/3.0"),
1426            Cow::from("/dir/3.0-My note.md?"),
1427            Cow::from("title"),
1428        );
1429        let expected = Link::Text2Dest(
1430            Cow::from("/dir/3.0"),
1431            Cow::from("/dir/3.0-My note.md?"),
1432            Cow::from("title"),
1433        );
1434        input.rewrite_autolink();
1435        let output = input;
1436        assert_eq!(output, expected);
1437
1438        //
1439        let mut input = Link::Text2Dest(
1440            Cow::from("/dir/3.0-My note.md"),
1441            Cow::from("/dir/3.0-My note.md"),
1442            Cow::from("title"),
1443        );
1444        let expected = Link::Text2Dest(
1445            Cow::from("/dir/3.0-My note.md"),
1446            Cow::from("/dir/3.0-My note.md"),
1447            Cow::from("title"),
1448        );
1449        input.rewrite_autolink();
1450        let output = input;
1451        assert_eq!(output, expected);
1452    }
1453
1454    #[test]
1455    fn test_apply_format_attribute() {
1456        //
1457        let mut input = Link::Text2Dest(
1458            Cow::from("tpnote:/dir/3.0"),
1459            Cow::from("/dir/3.0-My note.md"),
1460            Cow::from("title"),
1461        );
1462        let expected = Link::Text2Dest(
1463            Cow::from("tpnote:/dir/3.0"),
1464            Cow::from("/dir/3.0-My note.md"),
1465            Cow::from("title"),
1466        );
1467        input.apply_format_attribute();
1468        let output = input;
1469        assert_eq!(output, expected);
1470
1471        //
1472        let mut input = Link::Text2Dest(
1473            Cow::from("does not matter"),
1474            Cow::from("/dir/3.0-My note.md?"),
1475            Cow::from("title"),
1476        );
1477        let expected = Link::Text2Dest(
1478            Cow::from("My note"),
1479            Cow::from("/dir/3.0-My note.md"),
1480            Cow::from("title"),
1481        );
1482        input.apply_format_attribute();
1483        let output = input;
1484        assert_eq!(output, expected);
1485
1486        let mut input = Link::Text2Dest(
1487            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1488            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1489            Cow::from("title"),
1490        );
1491        let expected = Link::Text2Dest(
1492            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1493            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1494            Cow::from("title"),
1495        );
1496        input.apply_format_attribute();
1497        let output = input;
1498        assert_eq!(output, expected);
1499
1500        //
1501        let mut input = Link::Text2Dest(
1502            Cow::from("does not matter"),
1503            Cow::from("/dir/3.0-My note--red_blue_green.jpg?"),
1504            Cow::from("title"),
1505        );
1506        let expected = Link::Text2Dest(
1507            Cow::from("My note--red_blue_green"),
1508            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1509            Cow::from("title"),
1510        );
1511        input.apply_format_attribute();
1512        let output = input;
1513        assert_eq!(output, expected);
1514
1515        //
1516        let mut input = Link::Text2Dest(
1517            Cow::from("does not matter"),
1518            Cow::from("/dir/3.0-My note--red_blue_green.jpg?--"),
1519            Cow::from("title"),
1520        );
1521        let expected = Link::Text2Dest(
1522            Cow::from("My note"),
1523            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1524            Cow::from("title"),
1525        );
1526        input.apply_format_attribute();
1527        let output = input;
1528        assert_eq!(output, expected);
1529
1530        //
1531        let mut input = Link::Text2Dest(
1532            Cow::from("does not matter"),
1533            Cow::from("/dir/3.0-My note--red_blue_green.jpg?_"),
1534            Cow::from("title"),
1535        );
1536        let expected = Link::Text2Dest(
1537            Cow::from("My note--red"),
1538            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1539            Cow::from("title"),
1540        );
1541        input.apply_format_attribute();
1542        let output = input;
1543        assert_eq!(output, expected);
1544
1545        //
1546        let mut input = Link::Text2Dest(
1547            Cow::from("does not matter"),
1548            Cow::from("/dir/3.0-My note--red_blue_green.jpg??"),
1549            Cow::from("title"),
1550        );
1551        let expected = Link::Text2Dest(
1552            Cow::from("3.0-My note--red_blue_green.jpg"),
1553            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1554            Cow::from("title"),
1555        );
1556        input.apply_format_attribute();
1557        let output = input;
1558        assert_eq!(output, expected);
1559
1560        //
1561        let mut input = Link::Text2Dest(
1562            Cow::from("does not matter"),
1563            Cow::from("/dir/3.0-My note--red_blue_green.jpg?#."),
1564            Cow::from("title"),
1565        );
1566        let expected = Link::Text2Dest(
1567            Cow::from("3"),
1568            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1569            Cow::from("title"),
1570        );
1571        input.apply_format_attribute();
1572        let output = input;
1573        assert_eq!(output, expected);
1574
1575        //
1576        let mut input = Link::Text2Dest(
1577            Cow::from("does not matter"),
1578            Cow::from("/dir/3.0-My note--red_blue_green.jpg??.:_"),
1579            Cow::from("title"),
1580        );
1581        let expected = Link::Text2Dest(
1582            Cow::from("0-My note--red"),
1583            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1584            Cow::from("title"),
1585        );
1586        input.apply_format_attribute();
1587        let output = input;
1588        assert_eq!(output, expected);
1589
1590        //
1591        let mut input = Link::Text2Dest(
1592            Cow::from("does not matter"),
1593            Cow::from("/dir/3.0-My note--red_blue_green.jpg?_:_"),
1594            Cow::from("title"),
1595        );
1596        let expected = Link::Text2Dest(
1597            Cow::from("blue"),
1598            Cow::from("/dir/3.0-My note--red_blue_green.jpg"),
1599            Cow::from("title"),
1600        );
1601        input.apply_format_attribute();
1602        let output = input;
1603        assert_eq!(output, expected);
1604    }
1605
1606    #[test]
1607    fn get_local_link_dest_path() {
1608        //
1609        let input = Link::Text2Dest(Cow::from("xyz"), Cow::from("/dir/3.0"), Cow::from("title"));
1610        assert_eq!(
1611            input.get_local_link_dest_path(),
1612            Some(Path::new("/dir/3.0"))
1613        );
1614
1615        //
1616        let input = Link::Text2Dest(
1617            Cow::from("xyz"),
1618            Cow::from("http://getreu.net"),
1619            Cow::from("title"),
1620        );
1621        assert_eq!(input.get_local_link_dest_path(), None);
1622
1623        //
1624        let input = Link::Text2Dest(Cow::from("xyz"), Cow::from("dir/doc.md"), Cow::from("xyz"));
1625        let expected = Path::new("dir/doc.md");
1626        let res = input.get_local_link_dest_path().unwrap();
1627        assert_eq!(res, expected);
1628
1629        //
1630        let input = Link::Text2Dest(Cow::from("xyz"), Cow::from("d#ir/doc.md"), Cow::from("xyz"));
1631        let expected = Path::new("d#ir/doc.md");
1632        let res = input.get_local_link_dest_path().unwrap();
1633        assert_eq!(res, expected);
1634
1635        //
1636        let input = Link::Text2Dest(
1637            Cow::from("xyz"),
1638            Cow::from("dir/doc.md#1"),
1639            Cow::from("xyz"),
1640        );
1641        let expected = Path::new("dir/doc.md");
1642        let res = input.get_local_link_dest_path().unwrap();
1643        assert_eq!(res, expected);
1644    }
1645
1646    #[test]
1647    fn test_split_path_and_fragment() {
1648        use crate::html::split_path_and_fragment;
1649
1650        // A `#` in a directory name is data, not a fragment: it precedes a
1651        // later separator, so it stays in the path half.
1652        assert_eq!(
1653            split_path_and_fragment("Task #7/note.md"),
1654            ("Task #7/note.md", "")
1655        );
1656
1657        // A `#` in the final segment, with no separator after it, is the
1658        // author's fragment.
1659        assert_eq!(
1660            split_path_and_fragment("note.md#anchor"),
1661            ("note.md", "#anchor")
1662        );
1663
1664        // Both at once: exactly one `#` survives as a fragment, the one
1665        // the author wrote.
1666        assert_eq!(
1667            split_path_and_fragment("Task #7/note.md#anchor"),
1668            ("Task #7/note.md", "#anchor")
1669        );
1670
1671        // No `#` at all.
1672        assert_eq!(split_path_and_fragment("dir/note.md"), ("dir/note.md", ""));
1673
1674        // A bare fragment, no path.
1675        assert_eq!(split_path_and_fragment("#1"), ("", "#1"));
1676    }
1677
1678    #[test]
1679    fn test_percent_encode_path() {
1680        use crate::html::percent_encode_path;
1681        use percent_encoding::percent_decode_str;
1682
1683        // Round-trip: decoding what we encode returns the original bytes.
1684        for segment in [
1685            "Meeting #12-x",
1686            "a?b",
1687            "100%",
1688            "report %23.md",
1689            "with space",
1690            "a+b",
1691            "a&b",
1692            "em—dash",
1693            "a↔b",
1694            "already%20encoded",
1695        ] {
1696            let path = format!("/dir/{segment}/note.md");
1697            let encoded = percent_encode_path(&path);
1698            let decoded = percent_decode_str(&encoded).decode_utf8().unwrap();
1699            assert_eq!(decoded, path, "round-trip failed for segment {segment:?}");
1700        }
1701
1702        // `#` and `?` are encoded so a browser cannot mistake them for URL
1703        // syntax.
1704        assert_eq!(
1705            percent_encode_path("/Meeting #12/note.md"),
1706            "/Meeting%20%2312/note.md"
1707        );
1708        assert_eq!(percent_encode_path("/a?b"), "/a%3Fb");
1709
1710        // `%` is encoded first (and only once): a literal `%23` in a file
1711        // name must not be reinterpreted as an encoded `#`.
1712        assert_eq!(percent_encode_path("/report %23.md"), "/report%20%2523.md");
1713        let encoded = percent_encode_path("/report %23.md");
1714        let decoded = percent_decode_str(&encoded).decode_utf8().unwrap();
1715        assert_eq!(decoded, "/report %23.md");
1716
1717        // The leading `/` and the `/` separators are never encoded.
1718        assert!(percent_encode_path("/a/b/c").starts_with('/'));
1719        assert_eq!(percent_encode_path("/a/b/c"), "/a/b/c");
1720    }
1721
1722    #[test]
1723    fn test_append_html_ext() {
1724        //
1725        let mut input = Link::Text2Dest(
1726            Cow::from("abc"),
1727            Cow::from("/dir/3.0-My note.md"),
1728            Cow::from("title"),
1729        );
1730        let expected = Link::Text2Dest(
1731            Cow::from("abc"),
1732            Cow::from("/dir/3.0-My note.md.html"),
1733            Cow::from("title"),
1734        );
1735        input.append_html_ext();
1736        let output = input;
1737        assert_eq!(output, expected);
1738    }
1739
1740    #[test]
1741    fn test_to_html() {
1742        //
1743        let input = Link::Text2Dest(
1744            Cow::from("te\\x/t"),
1745            Cow::from("de\\s/t"),
1746            Cow::from("ti\\t/le"),
1747        );
1748        let expected = "<a href=\"de/s/t\" title=\"ti\\t/le\">te\\x/t</a>";
1749        let output = input.to_html();
1750        assert_eq!(output, expected);
1751
1752        //
1753        let input = Link::Text2Dest(
1754            Cow::from("te&> xt"),
1755            Cow::from("de&> st"),
1756            Cow::from("ti&> tle"),
1757        );
1758        let expected = "<a href=\"de&amp;&gt;%20st\" title=\"ti&amp;&gt; tle\">te&> xt</a>";
1759        let output = input.to_html();
1760        assert_eq!(output, expected);
1761
1762        //
1763        let input = Link::Image(Cow::from("al&t"), Cow::from("sr&c"));
1764        let expected = "<img src=\"sr&amp;c\" alt=\"al&amp;t\">";
1765        let output = input.to_html();
1766        assert_eq!(output, expected);
1767
1768        //
1769        let input = Link::Text2Dest(Cow::from("te&> xt"), Cow::from("de&> st"), Cow::from(""));
1770        let expected = "<a href=\"de&amp;&gt;%20st\">te&> xt</a>";
1771        let output = input.to_html();
1772        assert_eq!(output, expected);
1773    }
1774
1775    #[test]
1776    fn test_rewrite_links() {
1777        use crate::config::LocalLinkKind;
1778
1779        let allowed_urls = Arc::new(RwLock::new(HashSet::new()));
1780        let input = "abc<a href=\"ftp://getreu.net\">Blog</a>\
1781            def<a href=\"https://getreu.net\">https://getreu.net</a>\
1782            ghi<img src=\"t m p.jpg\" alt=\"test 1\" />\
1783            jkl<a href=\"down/../down/my note 1.md\">my note 1</a>\
1784            mno<a href=\"http:./down/../dir/my note.md\">http:./down/../dir/my note.md</a>\
1785            pqr<a href=\"http:/down/../dir/my note.md\">\
1786            http:/down/../dir/my note.md</a>\
1787            stu<a href=\"http:/../dir/underflow/my note.md\">\
1788            not allowed dir</a>\
1789            vwx<a href=\"http:../../../not allowed dir/my note.md\">\
1790            not allowed</a>"
1791            .to_string();
1792        let expected = "abc<a href=\"ftp://getreu.net\">Blog</a>\
1793            def<a href=\"https://getreu.net\">getreu.net</a>\
1794            ghi<img src=\"/abs/note%20path/t%20m%20p.jpg\" alt=\"test 1\">\
1795            jkl<a href=\"/abs/note%20path/down/my%20note%201.md\">my note 1</a>\
1796            mno<a href=\"/abs/note%20path/dir/my%20note.md\">./down/../dir/my note.md</a>\
1797            pqr<a href=\"/dir/my%20note.md\">/down/../dir/my note.md</a>\
1798            stu<i>&lt;INVALID: /../dir/underflow/my note.md&gt;</i>\
1799            vwx<i>&lt;INVALID: ../../../not allowed dir/my note.md&gt;</i>"
1800            .to_string();
1801
1802        let root_path = Path::new("/my/");
1803        let docdir = Path::new("/my/abs/note path/");
1804        let output = rewrite_links(
1805            input,
1806            root_path,
1807            docdir,
1808            LocalLinkKind::Short,
1809            false,
1810            allowed_urls.clone(),
1811        );
1812        let url = allowed_urls.read_recursive();
1813
1814        assert!(url.contains(&PathBuf::from("/abs/note path/t m p.jpg")));
1815        assert!(url.contains(&PathBuf::from("/abs/note path/dir/my note.md")));
1816        assert!(url.contains(&PathBuf::from("/abs/note path/down/my note 1.md")));
1817        assert_eq!(output, expected);
1818    }
1819
1820    #[test]
1821    fn test_rewrite_links2() {
1822        use crate::config::LocalLinkKind;
1823
1824        let allowed_urls = Arc::new(RwLock::new(HashSet::new()));
1825        let input = "abd<a href=\"tpnote:dir/my note.md\">\
1826            <img src=\"/imagedir/favicon-32x32.png\" alt=\"logo\"></a>abd"
1827            .to_string();
1828        let expected = "abd<a href=\"/abs/note%20path/dir/my%20note.md\">\
1829            <img src=\"/imagedir/favicon-32x32.png\" alt=\"logo\"></a>abd";
1830        let root_path = Path::new("/my/");
1831        let docdir = Path::new("/my/abs/note path/");
1832        let output = rewrite_links(
1833            input,
1834            root_path,
1835            docdir,
1836            LocalLinkKind::Short,
1837            false,
1838            allowed_urls.clone(),
1839        );
1840        let url = allowed_urls.read_recursive();
1841        println!("{:?}", allowed_urls.read_recursive());
1842        assert!(url.contains(&PathBuf::from("/abs/note path/dir/my note.md")));
1843        assert_eq!(output, expected);
1844    }
1845
1846    #[test]
1847    fn test_rewrite_links3() {
1848        use crate::config::LocalLinkKind;
1849
1850        let allowed_urls = Arc::new(RwLock::new(HashSet::new()));
1851        let input = "abd<a href=\"#1\"></a>abd".to_string();
1852        let expected = "abd<a href=\"/abs/note%20path/#1\"></a>abd";
1853        let root_path = Path::new("/my/");
1854        let docdir = Path::new("/my/abs/note path/");
1855        let output = rewrite_links(
1856            input,
1857            root_path,
1858            docdir,
1859            LocalLinkKind::Short,
1860            false,
1861            allowed_urls.clone(),
1862        );
1863        let url = allowed_urls.read_recursive();
1864        println!("{:?}", allowed_urls.read_recursive());
1865        assert!(url.contains(&PathBuf::from("/abs/note path/")));
1866        assert_eq!(output, expected);
1867    }
1868
1869    /// A `#` in a directory name must not end up as a bare byte in the
1870    /// `href`: browsers read an unencoded `#` as the start of a fragment
1871    /// and never send anything after it, so the server only ever sees a
1872    /// truncated path. `rewrite_links` is the function shared by the
1873    /// viewer and `--export`, so this covers both call sites at once.
1874    #[test]
1875    fn test_rewrite_links_hash_in_dir_name() {
1876        use crate::config::LocalLinkKind;
1877
1878        let allowed_urls = Arc::new(RwLock::new(HashSet::new()));
1879        let input = "<a href=\"01-Agenda.md\">link</a>".to_string();
1880        let root_path = Path::new("/notes/");
1881        let docdir = Path::new("/notes/Meeting #12-Project kickoff/");
1882        let output = rewrite_links(
1883            input,
1884            root_path,
1885            docdir,
1886            LocalLinkKind::Short,
1887            false,
1888            allowed_urls.clone(),
1889        );
1890
1891        // The `#` that is part of the directory name is percent-encoded,
1892        // so the browser cannot mistake it for the start of a fragment.
1893        assert!(
1894            output.contains("href=\"/Meeting%20%2312-Project%20kickoff/01-Agenda.md\""),
1895            "unexpected output: {output}"
1896        );
1897        // No bare `#` remains in the href.
1898        assert!(!output.contains("Meeting #12"));
1899
1900        // Bookkeeping still holds the raw, decoded filesystem path — this
1901        // is what the viewer compares an incoming (percent-decoded)
1902        // request path against, so encoding the `href` must not encode
1903        // this side too.
1904        let url = allowed_urls.read_recursive();
1905        assert!(url.contains(&PathBuf::from(
1906            "/Meeting #12-Project kickoff/01-Agenda.md"
1907        )));
1908    }
1909
1910    #[test]
1911    fn test_is_empty_html() {
1912        // Bring new methods into scope.
1913        use crate::html::HtmlStr;
1914
1915        // Test where input is '<!DOCTYPE html>'
1916        // See: [HTML doctype declaration](https://www.w3schools.com/tags/tag_doctype.ASP)
1917        assert!(String::from("<!DOCTYPE html>").is_empty_html());
1918
1919        // This should fail:
1920        assert!(!String::from("<!DOCTYPE html>>").is_empty_html());
1921
1922        // Test where input is '<!DOCTYPE html>'
1923        // See: [HTML doctype declaration](https://www.w3schools.com/tags/tag_doctype.ASP)
1924        assert!(
1925            String::from(
1926                " <!DOCTYPE HTML PUBLIC \
1927            \"-//W3C//DTD HTML 4.01 Transitional//EN\" \
1928            \"http://www.w3.org/TR/html4/loose.dtd\">"
1929            )
1930            .is_empty_html()
1931        );
1932
1933        // Test where input is '<!DOCTYPE html>'
1934        // See: [HTML doctype declaration](https://www.w3schools.com/tags/tag_doctype.ASP)
1935        assert!(
1936            String::from(
1937                " <!DOCTYPE html PUBLIC \
1938            \"-//W3C//DTD XHTML 1.1//EN\" \
1939            \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">"
1940            )
1941            .is_empty_html()
1942        );
1943
1944        // Test where input is '<!DOCTYPE html>Some content'
1945        assert!(!String::from("<!DOCTYPE html>Some content").is_empty_html());
1946
1947        // Test where input is an empty string
1948        assert!(String::from("").is_empty_html());
1949
1950        // Test where input is not empty HTML.
1951        // Convention: we consider empty only `` or `<!DOCTYPE html>`.
1952        assert!(!String::from("<html></html>").is_empty_html());
1953
1954        // Test where input is not empty HTML with doctype
1955        // Convention: we consider empty only `` or `<!DOCTYPE html>`.
1956        assert!(!String::from("<!DOCTYPE html><html></html>").is_empty_html());
1957    }
1958
1959    #[test]
1960    fn test_has_html_start_tag() {
1961        // Bring new methods into scope.
1962        use crate::html::HtmlStr;
1963
1964        // Test where input is '<!DOCTYPE html>Some content'
1965        assert!(String::from("<!DOCTYPE html>Some content").has_html_start_tag());
1966
1967        // This fails because we require be convention `<!DOCTYPE html>` as
1968        // first tag
1969        assert!(!String::from("<html>Some content</html>").has_html_start_tag());
1970
1971        // This fails because we require be convention `<!DOCTYPE html>` as
1972        // first tag
1973        assert!(!String::from("<HTML>").has_html_start_tag());
1974
1975        // Test where input starts with spaces
1976        assert!(String::from("  <!doctype html>Some content").has_html_start_tag());
1977
1978        // Test where input is a non-HTML doctype
1979        assert!(!String::from("<!DOCTYPE other>").has_html_start_tag());
1980
1981        // Test where input is an empty string
1982        assert!(!String::from("").has_html_start_tag());
1983    }
1984
1985    #[test]
1986    fn test_is_html_unchecked() {
1987        // Bring new methods into scope.
1988        use crate::html::HtmlStr;
1989
1990        // Test with `<!DOCTYPE html>` tag
1991        let html = "<!doctype html>";
1992        assert!(html.is_html_unchecked());
1993
1994        // Test with `<!DOCTYPE html>` tag
1995        let html = "<!doctype html abc>def";
1996        assert!(html.is_html_unchecked());
1997
1998        // Test with `<!DOCTYPE html>` tag
1999        let html = "<!doctype html";
2000        assert!(!html.is_html_unchecked());
2001
2002        // Test with `<html>` tag
2003        let html = "<html><body></body></html>";
2004        assert!(html.is_html_unchecked());
2005
2006        // Test with `<html>` tag
2007        let html = "<html abc>def";
2008        assert!(html.is_html_unchecked());
2009
2010        // Test with `<html>` tag
2011        let html = "<html abc def";
2012        assert!(!html.is_html_unchecked());
2013
2014        // Test with leading whitespace
2015        let html = "   <!doctype html><html><body></body></html>";
2016        assert!(html.is_html_unchecked());
2017
2018        // Test with non-html content
2019        let html = "<!DOCTYPE xml><root></root>";
2020        assert!(!html.is_html_unchecked());
2021
2022        // Test with partial `<!DOCTYPE>` tag
2023        let html = "<!doctype>";
2024        assert!(!html.is_html_unchecked());
2025    }
2026
2027    #[test]
2028    fn test_prepend_html_start_tag() {
2029        // Bring new methods into scope.
2030        use crate::html::HtmlString;
2031
2032        // Test where input already has doctype HTML
2033        assert_eq!(
2034            String::from("<!DOCTYPE html>Some content").prepend_html_start_tag(),
2035            Ok(String::from("<!DOCTYPE html>Some content"))
2036        );
2037
2038        // Test where input already has doctype HTML
2039        assert_eq!(
2040            String::from("<!DOCTYPE html>").prepend_html_start_tag(),
2041            Ok(String::from("<!DOCTYPE html>"))
2042        );
2043
2044        // Test where input has no HTML tag
2045        assert_eq!(
2046            String::from("<html>Some content").prepend_html_start_tag(),
2047            Ok(String::from("<!DOCTYPE html><html>Some content"))
2048        );
2049
2050        // Test where input has a non-HTML doctype
2051        assert_eq!(
2052            String::from("<!DOCTYPE other>").prepend_html_start_tag(),
2053            Err(InputStreamError::NonHtmlDoctype {
2054                html: "<!DOCTYPE other>".to_string()
2055            })
2056        );
2057
2058        // Test where input has no HTML tag
2059        assert_eq!(
2060            String::from("Some content").prepend_html_start_tag(),
2061            Ok(String::from("<!DOCTYPE html>Some content"))
2062        );
2063
2064        // Test where input is an empty string
2065        assert_eq!(
2066            String::from("").prepend_html_start_tag(),
2067            Ok(String::from("<!DOCTYPE html>"))
2068        );
2069    }
2070}