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