summaryrefslogtreecommitdiff
path: root/src/generate_html.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/generate_html.rs')
-rw-r--r--src/generate_html.rs617
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} &mdash; {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} &mdash; {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 &#0000
- if let Some('#') = chars.get(i+1) { output.push(*c) }
- else { output.push_str("&amp;") }
- },
- '<' => output.push_str("&lt;"),
- '>' => output.push_str("&gt;"),
- '"' => match fancy {
- true => match prev.is_whitespace() {
- true => output.push('“'),
- false => output.push('”'),
- }
- false => output.push_str("&#34;"),
- },
- '\'' => match fancy {
- true => match prev.is_whitespace() {
- true => output.push('‘'),
- false => output.push('’'),
- }
- false => output.push_str("&#39;"),
- },
- '-' 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;
-}