summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Bridle <ben@derelict.engineering>2025-09-04 08:10:06 +1200
committerBen Bridle <ben@derelict.engineering>2025-09-04 08:10:06 +1200
commit3767f5cd3c2ec26872d52958e94dafbae074039f (patch)
tree6880b47385f8503f1a28960b0babd6cd6312e8a6
parent8fe9268dc12111ae771a3ba4042c74acd3610116 (diff)
downloadtoaster-3767f5cd3c2ec26872d52958e94dafbae074039f.zip
Wrap each h1-delimited block of a generated page in <article> tags
This is to make it possible when printing the document to start each h1 heading element at the top of a new page using CSS.
-rw-r--r--src/generate_html.rs354
1 files changed, 180 insertions, 174 deletions
diff --git a/src/generate_html.rs b/src/generate_html.rs
index 4064820..c0b26f6 100644
--- a/src/generate_html.rs
+++ b/src/generate_html.rs
@@ -122,6 +122,7 @@ pub fn get_table_of_contents(page: &Page) -> String {
pub fn document_to_html(document: &MarkdownDocument, page: &Page, website: &Website) -> String {
let from = &page;
+ let root = page.root();
let mut html = String::new();
macro_rules! line_to_html {
@@ -135,195 +136,200 @@ 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 block in &document.blocks {
+ match block {
+ Block::Heading { level, line } => {
+ let id = make_url_safe(strip_appendix(&line.to_string()));
+ match level {
+ Level::Heading1 => {
+ html!("</article>");
+ html!("<article>");
+ tag!("h1", line, format!("id='{id}'"))
+ }
+ Level::Heading2 => tag!("h2", line, format!("id='{id}'")),
+ Level::Heading3 => tag!("h3", line, format!("id='{id}'")),
}
- 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>"),
- }
- }),
- 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 == ':' && depth == 0 {
+ 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!("<video controls src='{path}'>{label}</video>"),
+ 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>")
- } else {
- warn!("Gallery-nav on page {from:?} references nonexistent image {image:?}");
+ }
+ _ => 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)),
}
- } 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));
+ }),
+ "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 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>")
} 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-nav on page {from:?} references nonexistent image {image:?}");
}
+ } 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.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));
+ } 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);
+ 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");
+ }
+ html!("<td class='{class}'>{text}</td>");
+ })
+ })
+ };
+ }))
+ }
}
- }
+ );
return html;
}