Current section

Files

Jump to
rbt_weave crates weave-content src html pages.rs
Raw

crates/weave-content/src/html/pages.rs

//! Tag, country, and sitemap page renderers.
use crate::domain::AmountEntry;
use super::countries::{country_name, extract_country_from_case_slug};
use super::escape::{escape, escape_attr};
/// A case entry associated with a tag, used for tag page rendering.
pub struct TagCaseEntry {
/// Display slug for the case link (e.g. `cases/id/corruption/2024/test-case`).
pub slug: String,
/// Case title.
pub title: String,
/// Structured amounts for display badge.
pub amounts: Vec<AmountEntry>,
}
/// Generate a tag page HTML fragment listing all cases with this tag.
///
/// # Errors
///
/// Returns an error if the rendered HTML exceeds the size limit.
pub fn render_tag_page(tag: &str, cases: &[TagCaseEntry]) -> Result<String, String> {
render_tag_page_with_path(tag, &format!("/tags/{}", escape_attr(tag)), cases)
}
pub fn render_tag_page_scoped(
tag: &str,
country: &str,
cases: &[TagCaseEntry],
) -> Result<String, String> {
let display_tag = format!("{} ({})", tag.replace('-', " "), country.to_uppercase());
render_tag_page_with_path(
&display_tag,
&format!("/tags/{}/{}", escape_attr(country), escape_attr(tag)),
cases,
)
}
fn render_tag_page_with_path(
display: &str,
og_url: &str,
cases: &[TagCaseEntry],
) -> Result<String, String> {
let mut html = String::with_capacity(2048);
let og_title = format!("Cases tagged \"{display}\"");
html.push_str(&format!(
"<article class=\"loom-tag-page\" \
data-og-title=\"{}\" \
data-og-description=\"{} cases tagged with {}\" \
data-og-type=\"website\" \
data-og-url=\"{}\">\n",
escape_attr(&og_title),
cases.len(),
escape_attr(display),
escape_attr(og_url),
));
html.push_str(&format!(
" <header class=\"loom-tag-header\">\n \
<h1>{}</h1>\n \
<p class=\"loom-tag-count\">{} cases</p>\n \
</header>\n",
escape(display),
cases.len(),
));
html.push_str(" <ul class=\"loom-case-list\">\n");
for entry in cases {
let country_badge = extract_country_from_case_slug(&entry.slug)
.map(|cc| {
format!(
" <a href=\"/countries/{}\" class=\"loom-country-badge\">{}</a>",
escape_attr(&cc),
escape(&country_name(&cc))
)
})
.unwrap_or_default();
let amount_badges = if entry.amounts.is_empty() {
String::new()
} else {
let badges: Vec<String> = entry
.amounts
.iter()
.map(|a| {
format!(
" <span class=\"loom-amount-badge\">{}</span>",
escape(&a.format_display())
)
})
.collect();
badges.join("")
};
html.push_str(&format!(
" <li><a href=\"/{}\">{}</a>{}{}</li>\n",
escape_attr(&entry.slug),
escape(&entry.title),
country_badge,
amount_badges,
));
}
html.push_str(" </ul>\n");
html.push_str("</article>\n");
super::check_size(&html)
}
/// An entry for the countries index page.
pub struct CountryIndexEntry {
/// ISO 3166-1 alpha-2 country code (lowercase).
pub code: String,
/// Display name of the country.
pub name: String,
/// Number of cases in this country.
pub case_count: usize,
}
/// An entry for the tags index page.
pub struct TagIndexEntry {
/// Tag slug (e.g. `bribery`).
pub tag: String,
/// Number of cases with this tag.
pub case_count: usize,
}
/// Generate a countries index page listing all countries with case counts.
///
/// # Errors
///
/// Returns an error if the rendered HTML exceeds the size limit.
pub fn render_countries_index(countries: &[CountryIndexEntry]) -> Result<String, String> {
let total: usize = countries.iter().map(|c| c.case_count).sum();
let mut html = String::with_capacity(4096);
html.push_str(
"<article class=\"loom-countries-index\" \
data-og-title=\"Countries\" \
data-og-description=\"Browse cases by country\" \
data-og-type=\"website\" \
data-og-url=\"/countries\">\n",
);
html.push_str(&format!(
" <header class=\"loom-countries-header\">\n \
<h1>Countries</h1>\n \
<p class=\"loom-countries-count\">{total} cases across {} countries</p>\n \
</header>\n",
countries.len(),
));
html.push_str(" <ul class=\"loom-country-list\">\n");
for entry in countries {
html.push_str(&format!(
" <li><a href=\"/countries/{}\">{}</a> \
<span class=\"loom-count-badge\">{}</span></li>\n",
escape_attr(&entry.code),
escape(&entry.name),
entry.case_count,
));
}
html.push_str(" </ul>\n");
html.push_str("</article>\n");
super::check_size(&html)
}
/// Generate a tags index page listing all tags with case counts.
///
/// # Errors
///
/// Returns an error if the rendered HTML exceeds the size limit.
pub fn render_tags_index(tags: &[TagIndexEntry]) -> Result<String, String> {
let total: usize = tags.iter().map(|t| t.case_count).sum();
let mut html = String::with_capacity(4096);
html.push_str(
"<article class=\"loom-tags-index\" \
data-og-title=\"Tags\" \
data-og-description=\"Browse cases by tag\" \
data-og-type=\"website\" \
data-og-url=\"/tags\">\n",
);
html.push_str(&format!(
" <header class=\"loom-tags-header\">\n \
<h1>Tags</h1>\n \
<p class=\"loom-tags-count\">{total} cases across {} tags</p>\n \
</header>\n",
tags.len(),
));
html.push_str(" <ul class=\"loom-tag-list\">\n");
for entry in tags {
html.push_str(&format!(
" <li><a href=\"/tags/{}\">{}</a> \
<span class=\"loom-count-badge\">{}</span></li>\n",
escape_attr(&entry.tag),
escape(&entry.tag.replace('-', " ")),
entry.case_count,
));
}
html.push_str(" </ul>\n");
html.push_str("</article>\n");
super::check_size(&html)
}
/// Generate a country page HTML fragment listing all cases for a country.
///
/// # Errors
///
/// Returns an error if the rendered HTML exceeds the size limit.
pub fn render_country_page(country_code: &str, cases: &[TagCaseEntry]) -> Result<String, String> {
let name = country_name(country_code);
let og_url = format!("/countries/{}", escape_attr(country_code));
let mut html = String::with_capacity(2048);
let og_title = format!("Cases in {name}");
html.push_str(&format!(
"<article class=\"loom-country-page\" \
data-og-title=\"{}\" \
data-og-description=\"{} cases in {}\" \
data-og-type=\"website\" \
data-og-url=\"{}\">\n",
escape_attr(&og_title),
cases.len(),
escape_attr(&name),
escape_attr(&og_url),
));
html.push_str(&format!(
" <header class=\"loom-country-header\">\n \
<h1>{}</h1>\n \
<p class=\"loom-country-count\">{} cases</p>\n \
</header>\n",
escape(&name),
cases.len(),
));
html.push_str(" <ul class=\"loom-case-list\">\n");
for entry in cases {
let amount_badges = if entry.amounts.is_empty() {
String::new()
} else {
let badges: Vec<String> = entry
.amounts
.iter()
.map(|a| {
format!(
" <span class=\"loom-amount-badge\">{}</span>",
escape(&a.format_display())
)
})
.collect();
badges.join("")
};
html.push_str(&format!(
" <li><a href=\"/{}\">{}</a>{}</li>\n",
escape_attr(&entry.slug),
escape(&entry.title),
amount_badges,
));
}
html.push_str(" </ul>\n");
html.push_str("</article>\n");
super::check_size(&html)
}
/// Generate a sitemap XML string.
///
/// All tuples are `(slug, display_name)` where slug is the full file-path slug
/// (e.g. `cases/id/corruption/2024/hambalang-case`).
pub fn render_sitemap(
cases: &[(String, String)],
people: &[(String, String)],
organizations: &[(String, String)],
country_codes: &[String],
tags: &[String],
base_url: &str,
) -> String {
let mut xml = String::with_capacity(4096);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
for (slug, _) in cases {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
for (slug, _) in people {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
for (slug, _) in organizations {
xml.push_str(&format!(
" <url><loc>{base_url}/{}</loc></url>\n",
escape(slug)
));
}
// Index pages
xml.push_str(&format!(" <url><loc>{base_url}/countries</loc></url>\n"));
xml.push_str(&format!(" <url><loc>{base_url}/tags</loc></url>\n"));
for cc in country_codes {
xml.push_str(&format!(
" <url><loc>{base_url}/countries/{}</loc></url>\n",
escape(cc)
));
}
for tag in tags {
xml.push_str(&format!(
" <url><loc>{base_url}/tags/{}</loc></url>\n",
escape(tag)
));
}
xml.push_str("</urlset>\n");
xml
}