use crate::page::html_escape;
#[derive(Debug, Clone, Default)]
pub struct ShellSlots {
pub lang: String,
pub document_title: String,
pub site_title: String,
pub body_class: String,
pub root_prefix: String,
pub head: String,
pub site_nav: String,
pub breadcrumbs: String,
pub toc: String,
pub site_header: String,
pub content: String,
pub pager: String,
pub site_footer: String,
pub footer: String,
pub scripts: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Kind {
Text,
Raw,
}
const SLOTS: &[(&str, Kind)] = &[
("lang", Kind::Text),
("document_title", Kind::Text),
("site_title", Kind::Text),
("body_class", Kind::Text),
("root_prefix", Kind::Text),
("head", Kind::Raw),
("site_nav", Kind::Raw),
("breadcrumbs", Kind::Raw),
("toc", Kind::Raw),
("site_header", Kind::Raw),
("content", Kind::Raw),
("pager", Kind::Raw),
("site_footer", Kind::Raw),
("footer", Kind::Raw),
("scripts", Kind::Raw),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShellError(String);
impl ShellError {
pub fn message(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for ShellError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ShellError {}
#[derive(Debug)]
enum Segment {
Literal(String),
Slot(usize),
}
#[derive(Debug)]
pub struct ShellTemplate {
segments: Vec<Segment>,
}
impl ShellTemplate {
pub fn parse(source: &str) -> Result<Self, ShellError> {
let bytes = source.as_bytes();
let mut segments = Vec::new();
let mut literal = String::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'{'
&& let Some((name, raw, consumed)) = scan_slot(&source[i..])
{
let index = slot_index(name, raw)?;
if !literal.is_empty() {
segments.push(Segment::Literal(std::mem::take(&mut literal)));
}
segments.push(Segment::Slot(index));
i += consumed;
continue;
}
let ch = source[i..].chars().next().unwrap_or('\u{fffd}');
literal.push(ch);
i += ch.len_utf8();
}
if !literal.is_empty() {
segments.push(Segment::Literal(literal));
}
Ok(Self { segments })
}
pub fn render(&self, slots: &ShellSlots) -> String {
let mut out = String::new();
for segment in &self.segments {
match segment {
Segment::Literal(text) => out.push_str(text),
Segment::Slot(index) => {
let (name, kind) = SLOTS[*index];
let value = slot_value(slots, name);
match kind {
Kind::Text => out.push_str(&html_escape(value)),
Kind::Raw => out.push_str(value),
}
}
}
}
out
}
}
fn slot_index(name: &str, raw: bool) -> Result<usize, ShellError> {
let Some(index) = SLOTS.iter().position(|(n, _)| *n == name) else {
let known: Vec<&str> = SLOTS.iter().map(|(n, _)| *n).collect();
return Err(ShellError(format!(
"unknown shell slot `{name}`. Known slots: {}",
known.join(", ")
)));
};
let (_, kind) = SLOTS[index];
match (kind, raw) {
(Kind::Text, false) | (Kind::Raw, true) => Ok(index),
(Kind::Text, true) => Err(ShellError(format!(
"shell slot `{name}` is text and is HTML-escaped; write it as {{{{{name}}}}}"
))),
(Kind::Raw, false) => Err(ShellError(format!(
"shell slot `{name}` is raw HTML; write it as {{{{{{{name}}}}}}}"
))),
}
}
fn slot_value<'a>(slots: &'a ShellSlots, name: &str) -> &'a str {
match name {
"lang" => &slots.lang,
"document_title" => &slots.document_title,
"site_title" => &slots.site_title,
"body_class" => &slots.body_class,
"root_prefix" => &slots.root_prefix,
"head" => &slots.head,
"site_nav" => &slots.site_nav,
"breadcrumbs" => &slots.breadcrumbs,
"toc" => &slots.toc,
"site_header" => &slots.site_header,
"content" => &slots.content,
"pager" => &slots.pager,
"site_footer" => &slots.site_footer,
"footer" => &slots.footer,
"scripts" => &slots.scripts,
_ => "",
}
}
fn scan_slot(s: &str) -> Option<(&str, bool, usize)> {
let raw = s.as_bytes().get(2) == Some(&b'{');
let open = if raw { 3 } else { 2 };
let close = if raw { "}}}" } else { "}}" };
let after_open = s.get(open..)?;
let name_start = after_open.len() - after_open.trim_start_matches([' ', '\t']).len();
let name_region = &after_open[name_start..];
let name_len = name_region
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(name_region.len());
if name_len == 0 {
return None;
}
let name = &name_region[..name_len];
let after_name = &name_region[name_len..];
let pad = after_name.len() - after_name.trim_start_matches([' ', '\t']).len();
if !after_name[pad..].starts_with(close) {
return None;
}
Some((name, raw, open + name_start + name_len + pad + close.len()))
}
#[cfg(test)]
mod tests {
use super::*;
fn slots() -> ShellSlots {
ShellSlots {
lang: "en".into(),
document_title: "A & B".into(),
site_title: "<Site>".into(),
body_class: "has-site-nav".into(),
root_prefix: "../".into(),
head: r#"<link rel="stylesheet" href="style.css">"#.into(),
site_nav: "<nav>n</nav>".into(),
breadcrumbs: "<p>b</p>".into(),
toc: "<nav>t</nav>".into(),
site_header: "<p>h</p>".into(),
content: "<p>Hello</p>".into(),
pager: "<nav>p</nav>".into(),
site_footer: "<p>sf</p>".into(),
footer: "<footer>f</footer>".into(),
scripts: "<script>s</script>".into(),
}
}
#[test]
fn text_slots_are_escaped_and_raw_slots_are_not() {
let t = ShellTemplate::parse("<title>{{document_title}}</title>{{{content}}}").unwrap();
assert_eq!(
t.render(&slots()),
"<title>A & B</title><p>Hello</p>",
"text escaped, HTML passed through"
);
}
#[test]
fn whitespace_inside_the_braces_is_allowed() {
let t = ShellTemplate::parse("{{ site_title }}|{{{ site_nav }}}").unwrap();
assert_eq!(t.render(&slots()), "<Site>|<nav>n</nav>");
}
#[test]
fn every_slot_is_reachable() {
let source: String = SLOTS
.iter()
.map(|(name, kind)| match kind {
Kind::Text => format!("[{{{{{name}}}}}]"),
Kind::Raw => format!("[{{{{{{{name}}}}}}}]"),
})
.collect();
let out = ShellTemplate::parse(&source).unwrap().render(&slots());
assert!(out.contains("[en]"));
assert!(out.contains("[has-site-nav]"));
assert!(out.contains("[../]"));
assert!(out.contains("[<nav>t</nav>]"));
assert!(out.contains("[<p>h</p>]"));
assert!(out.contains("[<nav>p</nav>]"));
assert!(out.contains("[<p>sf</p>]"));
assert!(out.contains("[<footer>f</footer>]"));
assert!(!out.contains("{{"), "nothing was left unfilled: {out}");
}
#[test]
fn an_unknown_slot_is_an_error_naming_the_known_ones() {
let err = ShellTemplate::parse("{{titel}}").unwrap_err();
assert!(err.message().contains("unknown shell slot `titel`"));
assert!(err.message().contains("document_title"));
}
#[test]
fn a_raw_slot_written_as_text_is_an_error_rather_than_escaped_html() {
let err = ShellTemplate::parse("{{content}}").unwrap_err();
assert!(err.message().contains("raw HTML"), "{err}");
assert!(err.message().contains("{{{content}}}"), "{err}");
}
#[test]
fn a_text_slot_written_as_raw_is_an_error_rather_than_unescaped_text() {
let err = ShellTemplate::parse("{{{site_title}}}").unwrap_err();
assert!(err.message().contains("HTML-escaped"), "{err}");
assert!(err.message().contains("{{site_title}}"), "{err}");
}
#[test]
fn braces_that_are_not_slots_pass_through() {
let source = "<style>a{b:c}</style><script>if(x){{y()}}</script>{{}}{ {a} }";
let t = ShellTemplate::parse(source).unwrap();
assert_eq!(t.render(&slots()), source);
}
#[test]
fn a_template_with_no_slots_is_itself() {
let t = ShellTemplate::parse("<p>plain</p>").unwrap();
assert_eq!(t.render(&ShellSlots::default()), "<p>plain</p>");
}
}