1use crate::page::html_escape;
41
42#[derive(Debug, Clone, Default)]
47pub struct ShellSlots {
48 pub lang: String,
50 pub document_title: String,
53 pub site_title: String,
55 pub body_class: String,
58 pub head: String,
62 pub site_nav: String,
65 pub breadcrumbs: String,
67 pub content: String,
69 pub footer: String,
71 pub scripts: String,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum Kind {
79 Text,
80 Raw,
81}
82
83const SLOTS: &[(&str, Kind)] = &[
86 ("lang", Kind::Text),
87 ("document_title", Kind::Text),
88 ("site_title", Kind::Text),
89 ("body_class", Kind::Text),
90 ("head", Kind::Raw),
91 ("site_nav", Kind::Raw),
92 ("breadcrumbs", Kind::Raw),
93 ("content", Kind::Raw),
94 ("footer", Kind::Raw),
95 ("scripts", Kind::Raw),
96];
97
98#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct ShellError(String);
102
103impl ShellError {
104 pub fn message(&self) -> &str {
106 &self.0
107 }
108}
109
110impl std::fmt::Display for ShellError {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 f.write_str(&self.0)
113 }
114}
115
116impl std::error::Error for ShellError {}
117
118#[derive(Debug)]
120enum Segment {
121 Literal(String),
122 Slot(usize),
125}
126
127#[derive(Debug)]
132pub struct ShellTemplate {
133 segments: Vec<Segment>,
134}
135
136impl ShellTemplate {
137 pub fn parse(source: &str) -> Result<Self, ShellError> {
140 let bytes = source.as_bytes();
141 let mut segments = Vec::new();
142 let mut literal = String::new();
143 let mut i = 0;
144
145 while i < bytes.len() {
146 if bytes[i] == b'{'
147 && i + 1 < bytes.len()
148 && bytes[i + 1] == b'{'
149 && let Some((name, raw, consumed)) = scan_slot(&source[i..])
150 {
151 let index = slot_index(name, raw)?;
152 if !literal.is_empty() {
153 segments.push(Segment::Literal(std::mem::take(&mut literal)));
154 }
155 segments.push(Segment::Slot(index));
156 i += consumed;
157 continue;
158 }
159
160 let ch = source[i..].chars().next().unwrap_or('\u{fffd}');
161 literal.push(ch);
162 i += ch.len_utf8();
163 }
164
165 if !literal.is_empty() {
166 segments.push(Segment::Literal(literal));
167 }
168 Ok(Self { segments })
169 }
170
171 pub fn render(&self, slots: &ShellSlots) -> String {
173 let mut out = String::new();
174 for segment in &self.segments {
175 match segment {
176 Segment::Literal(text) => out.push_str(text),
177 Segment::Slot(index) => {
178 let (name, kind) = SLOTS[*index];
179 let value = slot_value(slots, name);
180 match kind {
181 Kind::Text => out.push_str(&html_escape(value)),
182 Kind::Raw => out.push_str(value),
183 }
184 }
185 }
186 }
187 out
188 }
189}
190
191fn slot_index(name: &str, raw: bool) -> Result<usize, ShellError> {
193 let Some(index) = SLOTS.iter().position(|(n, _)| *n == name) else {
194 let known: Vec<&str> = SLOTS.iter().map(|(n, _)| *n).collect();
195 return Err(ShellError(format!(
196 "unknown shell slot `{name}`. Known slots: {}",
197 known.join(", ")
198 )));
199 };
200 let (_, kind) = SLOTS[index];
201 match (kind, raw) {
202 (Kind::Text, false) | (Kind::Raw, true) => Ok(index),
203 (Kind::Text, true) => Err(ShellError(format!(
204 "shell slot `{name}` is text and is HTML-escaped; write it as {{{{{name}}}}}"
205 ))),
206 (Kind::Raw, false) => Err(ShellError(format!(
207 "shell slot `{name}` is raw HTML; write it as {{{{{{{name}}}}}}}"
208 ))),
209 }
210}
211
212fn slot_value<'a>(slots: &'a ShellSlots, name: &str) -> &'a str {
213 match name {
214 "lang" => &slots.lang,
215 "document_title" => &slots.document_title,
216 "site_title" => &slots.site_title,
217 "body_class" => &slots.body_class,
218 "head" => &slots.head,
219 "site_nav" => &slots.site_nav,
220 "breadcrumbs" => &slots.breadcrumbs,
221 "content" => &slots.content,
222 "footer" => &slots.footer,
223 "scripts" => &slots.scripts,
224 _ => "",
226 }
227}
228
229fn scan_slot(s: &str) -> Option<(&str, bool, usize)> {
236 let raw = s.as_bytes().get(2) == Some(&b'{');
237 let open = if raw { 3 } else { 2 };
238 let close = if raw { "}}}" } else { "}}" };
239
240 let after_open = s.get(open..)?;
241 let name_start = after_open.len() - after_open.trim_start_matches([' ', '\t']).len();
242 let name_region = &after_open[name_start..];
243 let name_len = name_region
244 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
245 .unwrap_or(name_region.len());
246 if name_len == 0 {
247 return None;
248 }
249 let name = &name_region[..name_len];
250
251 let after_name = &name_region[name_len..];
252 let pad = after_name.len() - after_name.trim_start_matches([' ', '\t']).len();
253 if !after_name[pad..].starts_with(close) {
254 return None;
255 }
256
257 Some((name, raw, open + name_start + name_len + pad + close.len()))
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn slots() -> ShellSlots {
265 ShellSlots {
266 lang: "en".into(),
267 document_title: "A & B".into(),
268 site_title: "<Site>".into(),
269 body_class: "has-site-nav".into(),
270 head: r#"<link rel="stylesheet" href="style.css">"#.into(),
271 site_nav: "<nav>n</nav>".into(),
272 breadcrumbs: "<p>b</p>".into(),
273 content: "<p>Hello</p>".into(),
274 footer: "<footer>f</footer>".into(),
275 scripts: "<script>s</script>".into(),
276 }
277 }
278
279 #[test]
280 fn text_slots_are_escaped_and_raw_slots_are_not() {
281 let t = ShellTemplate::parse("<title>{{document_title}}</title>{{{content}}}").unwrap();
282 assert_eq!(
283 t.render(&slots()),
284 "<title>A & B</title><p>Hello</p>",
285 "text escaped, HTML passed through"
286 );
287 }
288
289 #[test]
290 fn whitespace_inside_the_braces_is_allowed() {
291 let t = ShellTemplate::parse("{{ site_title }}|{{{ site_nav }}}").unwrap();
292 assert_eq!(t.render(&slots()), "<Site>|<nav>n</nav>");
293 }
294
295 #[test]
296 fn every_slot_is_reachable() {
297 let source: String = SLOTS
298 .iter()
299 .map(|(name, kind)| match kind {
300 Kind::Text => format!("[{{{{{name}}}}}]"),
301 Kind::Raw => format!("[{{{{{{{name}}}}}}}]"),
302 })
303 .collect();
304 let out = ShellTemplate::parse(&source).unwrap().render(&slots());
305 assert!(out.contains("[en]"));
306 assert!(out.contains("[has-site-nav]"));
307 assert!(out.contains("[<footer>f</footer>]"));
308 assert!(!out.contains("{{"), "nothing was left unfilled: {out}");
309 }
310
311 #[test]
312 fn an_unknown_slot_is_an_error_naming_the_known_ones() {
313 let err = ShellTemplate::parse("{{titel}}").unwrap_err();
314 assert!(err.message().contains("unknown shell slot `titel`"));
315 assert!(err.message().contains("document_title"));
316 }
317
318 #[test]
319 fn a_raw_slot_written_as_text_is_an_error_rather_than_escaped_html() {
320 let err = ShellTemplate::parse("{{content}}").unwrap_err();
321 assert!(err.message().contains("raw HTML"), "{err}");
322 assert!(err.message().contains("{{{content}}}"), "{err}");
323 }
324
325 #[test]
326 fn a_text_slot_written_as_raw_is_an_error_rather_than_unescaped_text() {
327 let err = ShellTemplate::parse("{{{site_title}}}").unwrap_err();
328 assert!(err.message().contains("HTML-escaped"), "{err}");
329 assert!(err.message().contains("{{site_title}}"), "{err}");
330 }
331
332 #[test]
335 fn braces_that_are_not_slots_pass_through() {
336 let source = "<style>a{b:c}</style><script>if(x){{y()}}</script>{{}}{ {a} }";
337 let t = ShellTemplate::parse(source).unwrap();
338 assert_eq!(t.render(&slots()), source);
339 }
340
341 #[test]
342 fn a_template_with_no_slots_is_itself() {
343 let t = ShellTemplate::parse("<p>plain</p>").unwrap();
344 assert_eq!(t.render(&ShellSlots::default()), "<p>plain</p>");
345 }
346}