diff options
Diffstat (limited to 'src/generate_html.rs')
| -rw-r--r-- | src/generate_html.rs | 617 |
1 files changed, 327 insertions, 290 deletions
diff --git a/src/generate_html.rs b/src/generate_html.rs index af48d2e..3b6ee62 100644 --- a/src/generate_html.rs +++ b/src/generate_html.rs @@ -4,61 +4,98 @@ use markdown::*; use recipe::*; -pub fn generate_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { +const DEFAULT_TEMPLATE: &str = "\ +<!DOCTYPE html> + <head> + <title>{page_name} — {site_name}</title> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1'> + {head} + </head> + <body> + <header> + <nav id='up'> + {home_link} + {parent_link} + </nav> + <h1 id='title'>{page_name_html}</h1> + </header> + <main> + {main} + </main> + </body> +</html>"; + + +pub fn generate_html(page: &Page, website: &Website) -> String { let root = page.root(); - let page_name = sanitize_text(&page.name, true); - let site_name = sanitize_text(&website.name, true); + + // Get page name as a plain string and as an HTML fragment. + let mut page_name = page.name.plain(); + let mut page_name_html = sanitize_text(&page_name, true); + // Find any override-title fragments. + for block in &page.document.blocks { + if let Block::Fragment { language, content } = block { + if language == "override-title" { + let line = Line::from_str(content); + page_name = line.to_string(); + page_name_html = line_to_html(&line, page, website, &None); + } + } + } + page_name = sanitize_text(&page_name, true); + + // Get the URL of the parent page. + let site_name = sanitize_text(&website.name.plain(), true); let mut parent_url = String::new(); for segment in &page.parents { - parent_url.push_str(&make_url_safe(segment)); parent_url.push('/'); + parent_url.push_str(&segment.slug()); parent_url.push('/'); } parent_url.pop(); let head = get_html_head(page, website); let head = head.trim(); let home_link = format!("<a id='home' href='{root}index.html'>{site_name}</a>"); let parent_link = match page.parents.last() { - Some(name) => format!("<a id='parent' href='../{}.html'>{name}</a>", make_url_safe(name)), + Some(name) => format!("<a id='parent' href='../{}.html'>{name}</a>", name.slug()), None => String::new(), }; - let table_of_contents = get_table_of_contents(page); - let main = document_to_html(document, page, website); let main = main.trim(); - format!("\ -<!DOCTYPE html> -<head> -<title>{page_name} — {site_name}</title> -<meta charset='UTF-8'> -<meta name='viewport' content='width=device-width, initial-scale=1'> -{head} -</head> -<body> -<header> -<nav id='up'> -{home_link} -{parent_link} -</nav> -<h1 id='title'>{page_name}</h1> -<nav id='toc'> -{table_of_contents} -</nav> -</header> -<main> -{main} -</main> -</body> -</html>") + // Format tables of contents and the main page. + let toc = get_table_of_contents(page); + let toc_compact = if page.headings.len() >= 3 { + format!("<details><summary></summary>\n{toc}</details>\n") + } else { String::new() }; + let main = document_to_html(page, website); let main = main.trim(); + + let mut template = website.config.get("html.template"); + if template.trim().is_empty() { + template = DEFAULT_TEMPLATE.to_string(); + } + + template + .replace("{site_name}", &site_name ) + .replace("{page_name}", &page_name ) + .replace("{page_name_html}", &page_name_html) + .replace("{home_link}", &home_link ) + .replace("{parent_link}", &parent_link ) + .replace("{head}", &head ) + .replace("{toc_compact}", &toc_compact ) + .replace("{toc}", &toc ) + .replace("{main}", &main ) } -pub fn generate_html_redirect(path: &str) -> String { +pub fn generate_html_redirect(path: &str, website: &Website) -> String { + let head = website.config.get("html.redirect.head"); let head = head.trim(); let path = sanitize_text(path, false); format!("\ <!DOCTYPE html> <head> -<title>Redirect</title> +<title>Redirecting...</title> <meta http-equiv='refresh' content='0; url={path}'> +{head} </head> -<html>") +</html>") } @@ -77,7 +114,7 @@ pub fn get_html_head(page: &Page, website: &Website) -> String { } } if include_default_head { - html_head.insert_str(0, &website.get_config("html.head")); + html_head.insert_str(0, &website.config.get("html.head")); } let root = page.root(); html_head @@ -87,16 +124,13 @@ pub fn get_html_head(page: &Page, website: &Website) -> String { pub fn get_table_of_contents(page: &Page) -> String { - if page.headings.len() < 3 { - return String::new(); - } - let mut toc = String::from("<details><summary></summary><ul>\n"); - let site_name = sanitize_text(&page.name, true); - toc.push_str(&format!("<li class='l1'><a href='#title'>{site_name}</a></li>\n")); + let mut toc = String::from("<ul>\n"); + let page_name = sanitize_text(&page.name.plain(), true); + toc.push_str(&format!("<li class='l1'><a href='#title'>{page_name}</a></li>\n")); for heading in &page.headings { - let name = &heading.name; - let url = &heading.url; + let name = sanitize_text(&heading.name.plain(), true); + let url = &heading.slug(); let class = match heading.level { Level::Heading1 => "l1", Level::Heading2 => "l2", @@ -104,17 +138,19 @@ pub fn get_table_of_contents(page: &Page) -> String { }; toc.push_str(&format!("<li class='{class}'><a href='#{url}'>{name}</a></li>\n")); } - toc.push_str("</ul></details>\n"); + toc.push_str("</ul>\n"); return toc; } -pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String { +pub fn document_to_html(page: &Page, website: &Website) -> String { let from = &page; + let root = page.root(); let mut html = String::new(); + let mut prefix = None; macro_rules! line_to_html { - ($l:expr) => {{ line_to_html(&$l, page, website) }}; } + ($l:expr) => {{ line_to_html(&$l, page, website, &prefix) }}; } macro_rules! html { ($($arg:tt)*) => {{ html.push_str(&format!($($arg)*)); html.push('\n'); }}; } macro_rules! tag { @@ -124,201 +160,236 @@ pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Webs ($t:expr,$c:expr,$f:expr) => {{ html!("<{} {}>", $t, $c); $f; html!("</{}>", $t); }}; ($t:expr,$f:expr) => {{ html!("<{}>", $t); $f; html!("</{}>", $t); }}; } - let root = page.root(); - for block in &document.blocks { - match block { - Block::Heading { level, line } => { - let id = make_url_safe(strip_appendix(&line.to_string())); - match level { - Level::Heading1 => tag!("h1", line, format!("id='{id}'")), - Level::Heading2 => tag!("h2", line, format!("id='{id}'")), - Level::Heading3 => tag!("h3", line, format!("id='{id}'")), - } - } - Block::Paragraph(line) => tag!("p", line), - Block::Math(content) => html!("<div class='math'>{}</div>", sanitize_text(content, false)), - Block::List(lines) => wrap!("ul", for line in lines { - // Insert a <br> tag directly after the first untagged colon. - let mut depth = 0; - let mut prev = '\0'; - let mut output = String::new(); - let mut class = String::new(); - for c in line_to_html!(line).chars() { - output.push(c); - if c == '<' { - depth += 1; - } else if c == '/' && prev == '<' { - depth -= 2; // 2 because prev was a '<' as well. - } else if c == ':' && depth == 0 { - output.pop(); output.push_str("<br>"); - class.push_str("extended"); depth += 99; + wrap!("article", + for (i, block) in page.document.blocks.iter().enumerate() { + match block { + Block::Heading { level, line } => { + if let Level::Heading1 = level { + html!("</article>"); + html!("<article>"); + prefix = Some(to_slug(&line.to_string())); + }; + // Find namespaced heading ID from headings list. + let url = match page.headings.iter().find(|h| h.block_id == i) { + Some(heading) => heading.slug(), + None => unreachable!("Couldn't find heading in headings list"), + }; + let heading_tag = match level { + Level::Heading1 => "h1", + Level::Heading2 => "h2", + Level::Heading3 => "h3", + }; + // Find out whether line contains a link. + let mut contains_link = false; + for token in &line.tokens { + if let Token::InternalLink { .. } | Token::ExternalLink { .. } = token { + contains_link = true; + break; + } } - prev = c; - } - // Replace a leading checkbox with a real checkbox. - if let Some(stripped) = output.strip_prefix("<code>[ ]</code>") { - output = format!("<input type='checkbox' disabled>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("<code>[x]</code>") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("<code>[X]</code>") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - }else if let Some(stripped) = output.strip_prefix("[ ]") { - output = format!("<input type='checkbox' disabled>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("[x]") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - } else if let Some(stripped) = output.strip_prefix("[X]") { - output = format!("<input type='checkbox' disabled checked>{stripped}"); - class.push_str(" checkbox"); - } - let class = class.trim(); - match class.is_empty() { - true => html!("<li>{output}</li>"), - false => html!("<li class='{class}'>{output}</li>"), + if !contains_link { + wrap!(heading_tag, format!("id='{url}'"), { + tag!("a", line, format!("href='#{url}'")); + }); + } else { + // Leave off the heading <a> tag if the heading already contains + // a link (<a> tags cannot be nested). + tag!(heading_tag, line, format!("id='{url}'")); + } + } - }), - Block::Note(lines) => wrap!("aside", for line in lines { tag!("p", line) }), - Block::Embed { label, path } => match path.rsplit_once('.') { - Some((_, extension)) => { - let mut path = path.to_string(); - if !path.contains("://") { - match website.has_static(page, &path) { - Some(resolved) => path = resolved, - None => warn!("Page {from:?} embeds nonexistent static file {path:?}"), + Block::Paragraph(line) => tag!("p", line), + Block::Math(content) => html!("<div class='math'>{}</div>", sanitize_text(content, false)), + Block::List(lines) => wrap!("ul", for line in lines { + // Insert a <br> tag directly after the first untagged colon. + let mut depth = 0; + let mut prev = '\0'; + let mut output = String::new(); + let mut class = String::new(); + for c in line_to_html!(line).chars() { + output.push(c); + if c == '<' { + depth += 1; + } else if c == '/' && prev == '<' { + depth -= 2; // 2 because prev was a '<' as well. + } else if c == ' ' && prev == ':' && depth == 0 { + output.pop(); output.pop(); output.push_str("<br>"); + class.push_str("extended"); depth += 99; } + prev = c; } - let label = sanitize_text(label, true); - let path = sanitize_text(&path, false); - match extension.to_lowercase().as_str() { - "jpg"|"jpeg"|"png"|"webp"|"gif"|"tiff" => html!( - "<figure><a href='{path}'><img src='{path}' alt='{label}' title='{label}' /></a></figure>"), - "mp3"|"wav"|"m4a" => html!("<audio controls src='{path}'>{label}</audio>"), - "mp4"|"avi" => html!("<video controls src='{path}'>{label}</video>"), - ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {from:?}"), + // Replace a leading checkbox with a real checkbox. + if let Some(stripped) = output.strip_prefix("<code>[ ]</code>") { + output = format!("<input type='checkbox' disabled>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("<code>[x]</code>") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("<code>[X]</code>") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); + }else if let Some(stripped) = output.strip_prefix("[ ]") { + output = format!("<input type='checkbox' disabled>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("[x]") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); + } else if let Some(stripped) = output.strip_prefix("[X]") { + output = format!("<input type='checkbox' disabled checked>{stripped}"); + class.push_str(" checkbox"); } - } - _ => warn!("Cannot embed file {path:?} with no file extension in page {from:?}"), - } - Block::Fragment { language, content } => { - match language.as_str() { - "math" => html!("<div class='math'>{}</div>", content.replace("\n", " \\\\\n")), - "embed-html" => html!("{content}"), - "embed-css" => wrap!("style", html!("{content}")), - "embed-javascript"|"embed-js" => wrap!("script", html!("{content}")), - "embed-html-head"|"override-html-head" => (), - "hidden"|"todo" => (), - "poem" => wrap!("div", "class='poem'", for line in content.lines() { - let line = line.trim_end(); - match line.is_empty() { - true => html!("<br>"), - false => html!("<p>{}</p>", sanitize_text(line, true)), + let class = class.trim(); + match class.is_empty() { + true => html!("<li>{output}</li>"), + false => html!("<li class='{class}'>{output}</li>"), + } + }), + Block::Note(lines) => wrap!("aside", for line in lines { tag!("p", line) }), + Block::Embed { label, path } => match path.rsplit_once('.') { + Some((_, extension)) => { + let mut path = path.to_string(); + if !path.contains("://") { + match website.has_static(page, &path) { + Some(resolved) => path = resolved, + None => warn!("Page {from:?} embeds nonexistent static file {path:?}"), + } } - }), - "recipe" => { - let recipe = Recipe::parse(content); - html!("<div class='recipe'><ul>"); - for ingredient in recipe.ingredients { html!("<li>{ingredient}</li>") } - html!("</ul><hr>"); - for paragraph in recipe.process { html!("<p>{paragraph}</p>") } - html!("</div>"); - }, - "gallery" => wrap!("div", "class='gallery'", for line in content.lines() { - let file = line.trim(); - if !website.has_image(file) { - warn!("Gallery on page {from:?} references nonexistent image {file:?}"); - continue; + let label = sanitize_text(label, true); + let path = sanitize_text(&path, false); + match extension.to_lowercase().as_str() { + "jpg"|"jpeg"|"png"|"webp"|"gif"|"tiff" => html!( + "<figure><a href='{path}'><img src='{path}' alt='{label}' title='{label}' /></a></figure>"), + "mp3"|"wav"|"m4a" => html!("<audio controls src='{path}'>{label}</audio>"), + "mp4"|"avi" => html!("<figure><video controls src='{path}'>{label}</video></figure>"), + ext @ _ => warn!("Unrecognised extension for embedded file {path:?} with extension {ext:?} in page {from:?}"), } - let large = sanitize_text(&format!("{root}images/large/{file}"), false); - // let small = sanitize_text(&format!("{root}images/small/{file}"), false); - let thumb = sanitize_text(&format!("{root}images/thumb/{file}"), false); - html!("<a href='{large}'><img src='{thumb}' /></a>"); - }), - "gallery-nav" => wrap!("div", "class='gallery-nav'", for line in content.lines() { - let line = line.trim(); - if let Some((name, image)) = line.split_once("::") { - let name = name.trim(); - let image = image.trim(); - let ParsedLink { path, class, label } = parse_internal_link(name, page, website); - if website.has_image(image) { - let thumb = sanitize_text(&format!("{root}images/thumb/{image}"), false); - html!("<a href='{path}' class='{class}'><img src='{thumb}'/><p>{label}</p></a>") + } + _ => warn!("Cannot embed file {path:?} with no file extension in page {from:?}"), + } + Block::Fragment { language, content } => { + match language.as_str() { + "math" => { + let mut content = content.trim().to_string(); + if !content.contains(r"\begin") { + content = content.replace("\n", " \\\\\n").to_string(); + } + html!("<div class='math'>{content}</div>") + }, + "embed-html" => html!("{content}"), + "embed-css" => wrap!("style", html!("{content}")), + "embed-javascript"|"embed-js" => wrap!("script", html!("{content}")), + "embed-html-head"|"override-html-head"|"override-title" => (), + "hidden"|"todo" => (), + "poem" => wrap!("div", "class='poem'", for line in content.lines() { + let line = line.trim_end(); + match line.is_empty() { + true => html!("<br>"), + false => html!("<p>{}</p>", sanitize_text(line, true)), + } + }), + "recipe" => { + let recipe = Recipe::parse(content); + html!("<div class='recipe'><ul>"); + for ingredient in recipe.ingredients { html!("<li>{ingredient}</li>") } + html!("</ul><hr>"); + for paragraph in recipe.process { html!("<p>{paragraph}</p>") } + html!("</div>"); + }, + "gallery" => wrap!("div", "class='gallery'", for line in content.lines() { + let file_name = line.trim(); + if let Some(image_paths) = website.has_image(file_name, &root) { + let large = sanitize_text(&image_paths.large, false); + let thumb = sanitize_text(&image_paths.thumb, false); + html!("<a href='{large}'><img src='{thumb}' /></a>"); } else { - warn!("Gallery-nav on page {from:?} references nonexistent image {image:?}"); + warn!("Gallery on page {from:?} references nonexistent image {file_name:?}"); } - } else { - warn!("Gallery-nav on page {from:?} has line without a '::' separator"); - } - }), - _ => wrap!("pre", format!("class='{language}'"), { - if let Some(i) = website.highlighters.languages.get(language) { - let mut source = String::new(); - let highlighter = &website.highlighters.highlighters[*i]; - for span in highlighter.highlight(content) { - if span.tag.is_empty() { - source.push_str(&sanitize_text(&span.text, false)); + }), + "gallery-nav" => wrap!("div", "class='gallery-nav'", for line in content.lines() { + let line = line.trim(); + if let Some((name, file_name)) = line.split_once("::") { + let name = name.trim(); + let file_name = file_name.trim(); + let ParsedLink { path, class, label } = parse_internal_link(name, page, website, &prefix); + if let Some(image_paths) = website.has_image(file_name, &root) { + let thumb = sanitize_text(&image_paths.thumb, false); + html!("<a href='{path}' class='{class}'><img src='{thumb}'/><p>{label}</p></a>") } else { - source.push_str(&format!("<span class='{}'>", span.tag.to_lowercase())); - source.push_str(&sanitize_text(&span.text, false)); - source.push_str("</span>"); + warn!("Gallery on page {from:?} references nonexistent image {file_name:?}"); + warn!("Gallery-nav on page {from:?} references nonexistent image {file_name:?}"); } + } else { + warn!("Gallery-nav on page {from:?} has line without a '::' separator"); } - html!("{source}"); - } else { - html!("{}", sanitize_text(content, false)) - } - }) + }), + _ => wrap!("pre", format!("class='{language}'"), { + if let Some(i) = website.config.languages.get(language) { + let mut source = String::new(); + let highlighter = &website.config.highlighters[*i]; + for span in highlighter.highlight(content) { + if span.tag.is_empty() { + source.push_str(&sanitize_text(&span.text, false)); + } else { + source.push_str(&format!("<span class='{}'>", span.tag.to_lowercase())); + source.push_str(&sanitize_text(&span.text, false)); + source.push_str("</span>"); + } + } + html!("{source}"); + } else { + html!("{}", sanitize_text(content, false)) + } + }) + } } - } - Block::Break => html!("<hr>"), - Block::Table(table) => wrap!("div", "class='table'", wrap!("table", { - wrap!("thead", - wrap!("tr", for column in &table.columns { - match column.border_right { - true => tag!("th", column.name, "class='border'"), - false => tag!("th", column.name), - } - }) - ); - for section in &table.sections { - wrap!("tbody", for row in section { - wrap!("tr", for (column, cell) in std::iter::zip(&table.columns, row) { - let text_raw = line_to_html!(cell); - let text = match text_raw.as_str() { - "Yes" => "✓", - "No" => "✗", - other => other, - }; - let mut class = match text { - "--" => "c", - _ => match column.alignment { - Alignment::Left => "l", - Alignment::Center => "c", - Alignment::Right => "r", - }, - }.to_string(); - if ["No", "--", "0"].contains(&text_raw.as_str()) { - class.push_str(" dim"); - }; - if column.border_right { - class.push_str(" border"); + Block::Break => html!("<hr>"), + Block::Table(table) => wrap!("div", "class='table'", wrap!("table", { + wrap!("thead", + wrap!("tr", for column in &table.columns { + match column.border_right { + true => tag!("th", column.name, "class='border'"), + false => tag!("th", column.name), } - html!("<td class='{class}'>{text}</td>"); }) - }) - }; - })) + ); + for section in &table.sections { + wrap!("tbody", for row in section { + wrap!("tr", for (column, cell) in std::iter::zip(&table.columns, row) { + let text_raw = line_to_html!(cell).trim().to_string(); + let text_cmp = text_raw.to_lowercase().to_string(); + let text = match text_cmp.as_str() { + "yes" => "✓", + "no" => "✗", + _ => &text_raw, + }; + let mut class = match text { + "--" => "c", + _ => match column.alignment { + Alignment::Left => "l", + Alignment::Center => "c", + Alignment::Right => "r", + }, + }.to_string(); + if ["no", "--", "0"].contains(&text_cmp.as_str()) { + class.push_str(" dim"); + }; + if column.border_right { + class.push_str(" border"); + } + html!("<td class='{class}'>{text}</td>"); + }) + }) + }; + })) + } } - } + ); return html; } -fn line_to_html(line: &Line, page: &Page, website: &Website) -> String { +fn line_to_html(line: &Line, page: &Page, website: &Website, prefix: &Option<String>) -> String { let mut html = String::new(); for line_element in &line.tokens { match line_element { @@ -333,7 +404,7 @@ fn line_to_html(line: &Line, page: &Page, website: &Website) -> String { Token::Math(text) => { let text = &sanitize_text(text, false); html.push_str(&format!("<span class='math'>{text}</span>")) } Token::InternalLink{ label: link_label, path } => { - let ParsedLink { path, class, mut label } = parse_internal_link(path, page, website); + let ParsedLink { path, class, mut label } = parse_internal_link(path, page, website, prefix); if !link_label.is_empty() { label = link_label.to_string(); } @@ -356,30 +427,56 @@ struct ParsedLink { pub class: &'static str, } -fn parse_internal_link(name: &str, page: &Page, website: &Website) -> ParsedLink { +fn parse_internal_link(name: &str, page: &Page, website: &Website, prefix: &Option<String>) -> ParsedLink { let from = &page; let (class, label, path) = match name.split_once('#') { - Some(("", heading)) => ("heading", heading, format!("#{}", strip_appendix(heading))), - Some((page, heading)) => ("page", heading, format!("{page}.html#{}", strip_appendix(heading))), + Some(("", heading)) => ("heading", heading, format!("#{heading}")), + Some((page, heading)) => ("page", heading, format!("{page}.html#{heading}")), _ => ("page", name, format!("{name}.html")), }; - let mut path = make_url_safe(&path); + let mut path = to_slug(&path); let label = match label.rsplit_once('/') { Some((_, label)) => sanitize_text(label.trim(), true), None => sanitize_text(label.trim(), true), }; - // Check that the linked internal page exists. + // Check that the linked internal page with heading exists. if class == "page" { match website.has_page(page, &path, "html") { Some(resolved) => path = resolved, None => warn!("Page {from:?} contains link to nonexistent page {path:?}"), } } - // Check that the heading exists. + // Check that the heading exists on this page. if class == "heading" { - let heading = path.strip_prefix('#').unwrap(); - if !page.headings.iter().any(|h| h.url == heading) { - warn!("Page {from:?} contains link to nonexistent internal heading {heading:?}"); + let plain_heading = path.strip_prefix('#').unwrap().to_string(); + let prefixed_heading = match prefix { + Some(prefix) => format!("{prefix}-{plain_heading}"), + None => plain_heading.to_string(), + }; + let mut matched = false; + for heading in &page.headings { + if heading.name.slug() == plain_heading { + if heading.prefix.is_some() { + // The matched heading has a prefix, so is one of many. + // The prefix must match, we must disambiguate the path. + if heading.slug() == prefixed_heading { + matched = true; + path = format!("#{prefixed_heading}"); + break; + } + } else { + // The matched heading has no prefix, so is unique on the page. + matched = true; + break + } + } + } + if !matched { + let prefix_note = match prefix { + Some(prefix) => format!(" (under {prefix:?})"), + None => format!(""), + }; + warn!("Page {from:?} contains link to nonexistent internal heading {plain_heading:?}{prefix_note}"); } } let path = url_encode(&path); @@ -418,63 +515,3 @@ fn parse_external_link(label: &str, path: &str, page: &Page, website: &Website) let label = sanitize_text(&label, true); ParsedLink { path, class: "external", label } } - - -/// Replace each HTML-reserved character with an HTML-escaped character. -fn sanitize_text(text: &str, fancy: bool) -> String { - let mut output = String::new(); - let chars: Vec<char> = text.chars().collect(); - for (i, c) in chars.iter().enumerate() { - let prev = match i > 0 { - true => chars[i - 1], - false => ' ', - }; - let next = match i + 1 < chars.len() { - true => chars[i + 1], - false => ' ', - }; - match c { - '&' => { - // The HTML syntax for unicode characters is � - if let Some('#') = chars.get(i+1) { output.push(*c) } - else { output.push_str("&") } - }, - '<' => output.push_str("<"), - '>' => output.push_str(">"), - '"' => match fancy { - true => match prev.is_whitespace() { - true => output.push('“'), - false => output.push('”'), - } - false => output.push_str("""), - }, - '\'' => match fancy { - true => match prev.is_whitespace() { - true => output.push('‘'), - false => output.push('’'), - } - false => output.push_str("'"), - }, - '-' if fancy => match prev.is_whitespace() && next.is_whitespace() { - true => match i > 0 { - true => output.push('—'), // em-dash, for mid-sentence - false => output.push('–'), // en-dash, for start of line - } - false => output.push('-'), // regular dash, for mid-word - } - _ => output.push(*c), - } - } - return output; -} - - -/// Remove a 'Appendix #: ' prefix from a string. -pub fn strip_appendix(text: &str) -> &str { - if let Some((prefix, name)) = text.split_once(": ") { - if prefix.starts_with("Appendix") { - return name; - } - } - return text; -} |
