1use crate::page::html_escape;
44
45#[derive(Debug, Clone, Default)]
50pub struct ShellSlots {
51 pub lang: String,
53 pub document_title: String,
56 pub site_title: String,
58 pub body_class: String,
61 pub root_prefix: String,
67 pub head: String,
71 pub site_nav: String,
75 pub breadcrumbs: String,
77 pub toc: String,
81 pub site_header: String,
84 pub content: String,
86 pub pager: String,
90 pub site_footer: String,
93 pub footer: String,
95 pub scripts: String,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum Kind {
103 Text,
104 Raw,
105}
106
107const SLOTS: &[(&str, Kind)] = &[
110 ("lang", Kind::Text),
111 ("document_title", Kind::Text),
112 ("site_title", Kind::Text),
113 ("body_class", Kind::Text),
114 ("root_prefix", Kind::Text),
115 ("head", Kind::Raw),
116 ("site_nav", Kind::Raw),
117 ("breadcrumbs", Kind::Raw),
118 ("toc", Kind::Raw),
119 ("site_header", Kind::Raw),
120 ("content", Kind::Raw),
121 ("pager", Kind::Raw),
122 ("site_footer", Kind::Raw),
123 ("footer", Kind::Raw),
124 ("scripts", Kind::Raw),
125];
126
127#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ShellError(String);
131
132impl ShellError {
133 pub fn message(&self) -> &str {
135 &self.0
136 }
137}
138
139impl std::fmt::Display for ShellError {
140 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141 f.write_str(&self.0)
142 }
143}
144
145impl std::error::Error for ShellError {}
146
147#[derive(Debug)]
149enum Segment {
150 Literal(String),
151 Slot(usize),
154}
155
156#[derive(Debug)]
161pub struct ShellTemplate {
162 segments: Vec<Segment>,
163}
164
165impl ShellTemplate {
166 pub fn parse(source: &str) -> Result<Self, ShellError> {
169 let bytes = source.as_bytes();
170 let mut segments = Vec::new();
171 let mut literal = String::new();
172 let mut i = 0;
173
174 while i < bytes.len() {
175 if bytes[i] == b'{'
176 && i + 1 < bytes.len()
177 && bytes[i + 1] == b'{'
178 && let Some((name, raw, consumed)) = scan_slot(&source[i..])
179 {
180 let index = slot_index(name, raw)?;
181 if !literal.is_empty() {
182 segments.push(Segment::Literal(std::mem::take(&mut literal)));
183 }
184 segments.push(Segment::Slot(index));
185 i += consumed;
186 continue;
187 }
188
189 let ch = source[i..].chars().next().unwrap_or('\u{fffd}');
190 literal.push(ch);
191 i += ch.len_utf8();
192 }
193
194 if !literal.is_empty() {
195 segments.push(Segment::Literal(literal));
196 }
197 Ok(Self { segments })
198 }
199
200 pub fn render(&self, slots: &ShellSlots) -> String {
202 let mut out = String::new();
203 for segment in &self.segments {
204 match segment {
205 Segment::Literal(text) => out.push_str(text),
206 Segment::Slot(index) => {
207 let (name, kind) = SLOTS[*index];
208 let value = slot_value(slots, name);
209 match kind {
210 Kind::Text => out.push_str(&html_escape(value)),
211 Kind::Raw => out.push_str(value),
212 }
213 }
214 }
215 }
216 out
217 }
218}
219
220fn slot_index(name: &str, raw: bool) -> Result<usize, ShellError> {
222 let Some(index) = SLOTS.iter().position(|(n, _)| *n == name) else {
223 let known: Vec<&str> = SLOTS.iter().map(|(n, _)| *n).collect();
224 return Err(ShellError(format!(
225 "unknown shell slot `{name}`. Known slots: {}",
226 known.join(", ")
227 )));
228 };
229 let (_, kind) = SLOTS[index];
230 match (kind, raw) {
231 (Kind::Text, false) | (Kind::Raw, true) => Ok(index),
232 (Kind::Text, true) => Err(ShellError(format!(
233 "shell slot `{name}` is text and is HTML-escaped; write it as {{{{{name}}}}}"
234 ))),
235 (Kind::Raw, false) => Err(ShellError(format!(
236 "shell slot `{name}` is raw HTML; write it as {{{{{{{name}}}}}}}"
237 ))),
238 }
239}
240
241fn slot_value<'a>(slots: &'a ShellSlots, name: &str) -> &'a str {
242 match name {
243 "lang" => &slots.lang,
244 "document_title" => &slots.document_title,
245 "site_title" => &slots.site_title,
246 "body_class" => &slots.body_class,
247 "root_prefix" => &slots.root_prefix,
248 "head" => &slots.head,
249 "site_nav" => &slots.site_nav,
250 "breadcrumbs" => &slots.breadcrumbs,
251 "toc" => &slots.toc,
252 "site_header" => &slots.site_header,
253 "content" => &slots.content,
254 "pager" => &slots.pager,
255 "site_footer" => &slots.site_footer,
256 "footer" => &slots.footer,
257 "scripts" => &slots.scripts,
258 _ => "",
260 }
261}
262
263fn scan_slot(s: &str) -> Option<(&str, bool, usize)> {
270 let raw = s.as_bytes().get(2) == Some(&b'{');
271 let open = if raw { 3 } else { 2 };
272 let close = if raw { "}}}" } else { "}}" };
273
274 let after_open = s.get(open..)?;
275 let name_start = after_open.len() - after_open.trim_start_matches([' ', '\t']).len();
276 let name_region = &after_open[name_start..];
277 let name_len = name_region
278 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
279 .unwrap_or(name_region.len());
280 if name_len == 0 {
281 return None;
282 }
283 let name = &name_region[..name_len];
284
285 let after_name = &name_region[name_len..];
286 let pad = after_name.len() - after_name.trim_start_matches([' ', '\t']).len();
287 if !after_name[pad..].starts_with(close) {
288 return None;
289 }
290
291 Some((name, raw, open + name_start + name_len + pad + close.len()))
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn slots() -> ShellSlots {
299 ShellSlots {
300 lang: "en".into(),
301 document_title: "A & B".into(),
302 site_title: "<Site>".into(),
303 body_class: "has-site-nav".into(),
304 root_prefix: "../".into(),
305 head: r#"<link rel="stylesheet" href="style.css">"#.into(),
306 site_nav: "<nav>n</nav>".into(),
307 breadcrumbs: "<p>b</p>".into(),
308 toc: "<nav>t</nav>".into(),
309 site_header: "<p>h</p>".into(),
310 content: "<p>Hello</p>".into(),
311 pager: "<nav>p</nav>".into(),
312 site_footer: "<p>sf</p>".into(),
313 footer: "<footer>f</footer>".into(),
314 scripts: "<script>s</script>".into(),
315 }
316 }
317
318 #[test]
319 fn text_slots_are_escaped_and_raw_slots_are_not() {
320 let t = ShellTemplate::parse("<title>{{document_title}}</title>{{{content}}}").unwrap();
321 assert_eq!(
322 t.render(&slots()),
323 "<title>A & B</title><p>Hello</p>",
324 "text escaped, HTML passed through"
325 );
326 }
327
328 #[test]
329 fn whitespace_inside_the_braces_is_allowed() {
330 let t = ShellTemplate::parse("{{ site_title }}|{{{ site_nav }}}").unwrap();
331 assert_eq!(t.render(&slots()), "<Site>|<nav>n</nav>");
332 }
333
334 #[test]
335 fn every_slot_is_reachable() {
336 let source: String = SLOTS
337 .iter()
338 .map(|(name, kind)| match kind {
339 Kind::Text => format!("[{{{{{name}}}}}]"),
340 Kind::Raw => format!("[{{{{{{{name}}}}}}}]"),
341 })
342 .collect();
343 let out = ShellTemplate::parse(&source).unwrap().render(&slots());
344 assert!(out.contains("[en]"));
345 assert!(out.contains("[has-site-nav]"));
346 assert!(out.contains("[../]"));
347 assert!(out.contains("[<nav>t</nav>]"));
348 assert!(out.contains("[<p>h</p>]"));
349 assert!(out.contains("[<nav>p</nav>]"));
350 assert!(out.contains("[<p>sf</p>]"));
351 assert!(out.contains("[<footer>f</footer>]"));
352 assert!(!out.contains("{{"), "nothing was left unfilled: {out}");
353 }
354
355 #[test]
356 fn an_unknown_slot_is_an_error_naming_the_known_ones() {
357 let err = ShellTemplate::parse("{{titel}}").unwrap_err();
358 assert!(err.message().contains("unknown shell slot `titel`"));
359 assert!(err.message().contains("document_title"));
360 }
361
362 #[test]
363 fn a_raw_slot_written_as_text_is_an_error_rather_than_escaped_html() {
364 let err = ShellTemplate::parse("{{content}}").unwrap_err();
365 assert!(err.message().contains("raw HTML"), "{err}");
366 assert!(err.message().contains("{{{content}}}"), "{err}");
367 }
368
369 #[test]
370 fn a_text_slot_written_as_raw_is_an_error_rather_than_unescaped_text() {
371 let err = ShellTemplate::parse("{{{site_title}}}").unwrap_err();
372 assert!(err.message().contains("HTML-escaped"), "{err}");
373 assert!(err.message().contains("{{site_title}}"), "{err}");
374 }
375
376 #[test]
379 fn braces_that_are_not_slots_pass_through() {
380 let source = "<style>a{b:c}</style><script>if(x){{y()}}</script>{{}}{ {a} }";
381 let t = ShellTemplate::parse(source).unwrap();
382 assert_eq!(t.render(&slots()), source);
383 }
384
385 #[test]
386 fn a_template_with_no_slots_is_itself() {
387 let t = ShellTemplate::parse("<p>plain</p>").unwrap();
388 assert_eq!(t.render(&ShellSlots::default()), "<p>plain</p>");
389 }
390}