template_fragments/
lib.rs

1//! Pre-process Jinja-like templates with fragment tags
2//!
3//! `template_fragments` offers a way to split a template that is annotated with
4//! fragment tags (`{% fragment NAME %}`, `{% endfragment %}`) into fragments.
5//! For example:
6//!
7//! ```html
8//! <body>
9//! # Header
10//! {% fragment items %}
11//! {% for item in items %}
12//!     {% fragment item %}
13//!         <div>{{ item }}</div>
14//!     {% endfragment %}
15//! {% endfor %}
16//! {% endfragment %}
17//! <body>
18//! ```
19//!
20//! This template defines three fragments:
21//!
22//! - `""`: the whole template without any fragment markers
23//! - `"items"`: the template looping over all items
24//! - `"item"`: the innermost div
25//!
26//! `template_fragments` offers two ways to pre-process such a template:
27//!
28//! - [filter_template]: given a fragment name, only return those parts of the
29//!   template that belong to the fragment. This function is designed to be used
30//!   when templates are requested dynamically
31//! - [split_templates]: split a template into all its fragments. This function
32//!   is designed to be used when to extract all templates once at application
33//!   startup
34//!
35//! # Syntax
36//!
37//! - Fragments start with `{% fragment NAMES... %}` or `{% fragment-block NAMES
38//!   %}`
39//! - `{% fragment-block NAME %}` and `{% endfragment-block %}` define fragment
40//!   blocks: they are rendered as a block, if the fragment is included. This is
41//!   equivalent to wrapping a block with a fragment of the same name.
42//! - Fragments end with `{% endfragment %}` or `{% endfragment-block %}`
43//! - Fragments can occur multiple times in the document
44//! - Multiple fragments can be started in a single tag by using multiple
45//!   whitespace separated names in the start tag
46//! - Fragment tags must be contained in a single line and there must not be any
47//!   other non-whitespace content on the same line
48//! - Fragment names can contain any alphanumeric character and `'-'`, `'_'`.
49//!
50//! # Example using `minijinja`
51//!
52//! One way to use fragment tags with  `minijinja` is to build a template source
53//! with the split templates at application start up like this
54//!
55//! ```
56//! # mod minijinja {
57//! #   pub struct Source;
58//! #   impl Source {
59//! #     pub fn new() -> Self { Source }
60//! #     pub fn add_template(
61//! #       &mut self,
62//! #       path: String,
63//! #       fragment: &str,
64//! #     ) -> Result<(), template_fragments::ErrorWithLine> {
65//! #       Ok(())
66//! #     }
67//! #   }
68//! # }
69//! # fn main() -> Result<(), template_fragments::ErrorWithLine> {
70//! use template_fragments::{split_templates, join_path};
71//!
72//! let mut source = minijinja::Source::new();
73//!
74//! for (path, template) in [("index.html", include_str!("../examples/templates/index.html"))] {
75//!     for (fragment_name, template_fragment) in split_templates(template)? {
76//!         source.add_template(join_path(path, &fragment_name), &template_fragment)?;
77//!     }
78//! }
79//! # Ok(())
80//! # }
81//! ```
82//!
83//! Note the different fragments can be rendered by requesting the relevant
84//! template, e.g., `env.get_template("index.html")` or
85//! `env.get_template("index.html#fragment")`.
86//!
87use std::collections::{HashMap, HashSet};
88
89#[cfg(test)]
90mod test;
91
92const DEFAULT_TAG_MARKERS: (&str, &str) = ("{%", "%}");
93
94/// Split a template path with optional fragment into the path and fragment
95///
96/// If no fragment is found, the fragment will be a empty string
97///
98/// ```rust
99/// # use template_fragments::split_path;
100/// #
101/// assert_eq!(split_path("index.html"), ("index.html", ""));
102/// assert_eq!(split_path("index.html#child"), ("index.html", "child"));
103///
104/// // whitespace is normalized
105/// assert_eq!(split_path("  index.html  "), ("index.html", ""));
106/// assert_eq!(split_path("  index.html  #  child  "), ("index.html", "child"));
107/// ```
108pub fn split_path(path: &str) -> (&str, &str) {
109    if let Some((path, fragment)) = path.rsplit_once('#') {
110        (path.trim(), fragment.trim())
111    } else {
112        (path.trim(), "")
113    }
114}
115
116/// Join a path with a fragment (omitting empty fragments)
117///
118/// ```rust
119/// # use template_fragments::join_path;
120/// #
121/// assert_eq!(join_path("index.html", ""), "index.html");
122/// assert_eq!(join_path("index.html", "child"), "index.html#child");
123///
124/// // whitespace is normalized
125/// assert_eq!(join_path("  index.html  ", "  "), "index.html");
126/// assert_eq!(join_path("  index.html  ", "  child  "), "index.html#child");
127/// ```
128pub fn join_path(path: &str, fragment: &str) -> String {
129    let path = path.trim();
130    let fragment = fragment.trim();
131
132    if fragment.is_empty() {
133        path.to_string()
134    } else {
135        format!("{path}#{fragment}")
136    }
137}
138
139/// Process the template and return all parts for the given fragment
140///
141/// To obtain the base template use an empty string for the fragment.
142///
143/// ```rust
144/// # use template_fragments::filter_template;
145/// let source = concat!(
146///     "<body>\n",
147///     "  {% fragment item %}\n",
148///     "    <div>{{ item }}</div>\n",
149///     "  {% endfragment %}\n",
150///     "<body>\n",
151/// );
152///
153/// assert_eq!(
154///     filter_template(source, "").unwrap(),
155///     concat!(
156///         "<body>\n",
157///         "    <div>{{ item }}</div>\n",
158///         "<body>\n",
159///     ),
160/// );
161///
162/// assert_eq!(
163///     filter_template(source, "item").unwrap(),
164///     "    <div>{{ item }}</div>\n",
165/// );
166/// ```
167///
168pub fn filter_template(src: &str, fragment: &str) -> Result<String, ErrorWithLine> {
169    let mut stack: FragmentStack<'_> = Default::default();
170    let mut res = String::new();
171    let mut last_line_idx = 0;
172
173    for (line_idx, line) in iterate_with_endings(src).enumerate() {
174        last_line_idx = line_idx;
175
176        match parse_fragment_tag(line, DEFAULT_TAG_MARKERS).map_err(|err| err.at(line_idx))? {
177            Some(Tag::Start(tag)) => stack.push(tag.fragments).map_err(|err| err.at(line_idx))?,
178            Some(Tag::End(_)) => {
179                stack.pop().map_err(|err| err.at(line_idx))?;
180            }
181            Some(Tag::StartBlock(tag)) => {
182                stack
183                    .push(HashSet::from([tag.fragment]))
184                    .map_err(|err| err.at(line_idx))?;
185                let line = format!(
186                    "{}{{% block {} %}}{}",
187                    tag.prefix,
188                    tag.fragment,
189                    get_ending(line)
190                );
191                if stack.is_active(fragment) {
192                    res.push_str(&line);
193                }
194            }
195            Some(Tag::EndBlock(tag)) => {
196                let active = stack.pop().map_err(|err| err.at(line_idx))?;
197                let line = format!("{}{{% endblock %}}{}", tag.prefix, get_ending(line));
198                if active.contains(fragment) {
199                    res.push_str(&line);
200                }
201            }
202            None => {
203                if stack.is_active(fragment) {
204                    res.push_str(line);
205                }
206            }
207        }
208    }
209    stack.done().map_err(|err| err.at(last_line_idx))?;
210
211    Ok(res)
212}
213
214/// Split the template into all fragments available
215///
216/// The base template is included as the fragment `""`.
217///
218/// ```rust
219/// # use template_fragments::split_templates;
220/// let source = concat!(
221///     "<body>\n",
222///     "  {% fragment item %}\n",
223///     "    <div>{{ item }}</div>\n",
224///     "  {% endfragment %}\n",
225///     "<body>\n",
226/// );
227/// let templates = split_templates(source).unwrap();
228///
229/// assert_eq!(
230///     templates[""],
231///     concat!(
232///         "<body>\n",
233///         "    <div>{{ item }}</div>\n",
234///         "<body>\n",
235///     ),
236/// );
237///
238/// assert_eq!(
239///     templates["item"],
240///     "    <div>{{ item }}</div>\n",
241/// );
242/// ```
243pub fn split_templates(src: &str) -> Result<HashMap<String, String>, ErrorWithLine> {
244    let mut stack: FragmentStack<'_> = Default::default();
245    let mut res: HashMap<String, String> = Default::default();
246    let mut last_line_idx = 0;
247
248    for (line_idx, line) in iterate_with_endings(src).enumerate() {
249        last_line_idx = line_idx;
250
251        match parse_fragment_tag(line, DEFAULT_TAG_MARKERS).map_err(|err| err.at(line_idx))? {
252            Some(Tag::Start(tag)) => stack.push(tag.fragments).map_err(|err| err.at(line_idx))?,
253            Some(Tag::End(_)) => {
254                stack.pop().map_err(|err| err.at(line_idx))?;
255            }
256            Some(Tag::StartBlock(tag)) => {
257                stack
258                    .push(HashSet::from([tag.fragment]))
259                    .map_err(|err| err.at(line_idx))?;
260                let line = format!(
261                    "{}{{% block {} %}}{}",
262                    tag.prefix,
263                    tag.fragment,
264                    get_ending(line)
265                );
266                for fragment in &stack.active_fragments {
267                    push_line(&mut res, fragment, &line);
268                }
269            }
270            Some(Tag::EndBlock(tag)) => {
271                let fragments = stack.pop().map_err(|err| err.at(line_idx))?;
272                let line = format!("{}{{% endblock %}}{}", tag.prefix, get_ending(line));
273
274                for fragment in fragments {
275                    push_line(&mut res, fragment, &line);
276                }
277            }
278            None => {
279                for fragment in &stack.active_fragments {
280                    push_line(&mut res, fragment, line);
281                }
282            }
283        }
284    }
285    stack.done().map_err(|err| err.at(last_line_idx))?;
286
287    Ok(res)
288}
289
290fn push_line(res: &mut HashMap<String, String>, fragment: &str, line: &str) {
291    if let Some(target) = res.get_mut(fragment) {
292        target.push_str(line);
293    } else {
294        res.insert(fragment.to_owned(), line.to_owned());
295    }
296}
297
298fn get_ending(line: &str) -> &str {
299    if line.ends_with("\r\n") {
300        "\r\n"
301    } else if line.ends_with('\n') {
302        "\n"
303    } else {
304        ""
305    }
306}
307
308#[derive(Debug)]
309struct FragmentStack<'a> {
310    stack: Vec<HashSet<&'a str>>,
311    active_fragments: HashSet<&'a str>,
312}
313
314impl<'a> std::default::Default for FragmentStack<'a> {
315    fn default() -> Self {
316        Self {
317            stack: Vec::new(),
318            active_fragments: HashSet::from([""]),
319        }
320    }
321}
322
323impl<'a> FragmentStack<'a> {
324    /// Add new fragments to the currently active fragments
325    fn push(&mut self, fragments: HashSet<&'a str>) -> Result<(), Error> {
326        let mut reentrant_fragments = Vec::new();
327
328        for &fragment in &fragments {
329            let not_seen = self.active_fragments.insert(fragment);
330            if !not_seen {
331                reentrant_fragments.push(fragment);
332            }
333        }
334        if !reentrant_fragments.is_empty() {
335            return Err(Error::ReentrantFragment(sorted_fragments(
336                reentrant_fragments,
337            )));
338        }
339
340        self.stack.push(fragments);
341        Ok(())
342    }
343
344    /// Pop the last addeed fragments and return the active fragments before
345    /// this op
346    fn pop(&mut self) -> Result<HashSet<&'a str>, Error> {
347        let fragments = self.active_fragments.clone();
348        for fragment in self.stack.pop().ok_or(Error::UnbalancedEndTag)? {
349            self.active_fragments.remove(fragment);
350        }
351
352        Ok(fragments)
353    }
354
355    fn done(&self) -> Result<(), Error> {
356        if !self.stack.is_empty() {
357            let fragments: HashSet<&str> = self.stack.iter().flatten().copied().collect();
358            Err(Error::UnclosedTag(sorted_fragments(fragments)))
359        } else {
360            Ok(())
361        }
362    }
363
364    fn is_active(&self, fragment: &str) -> bool {
365        self.active_fragments.contains(fragment)
366    }
367}
368
369fn iterate_with_endings(mut s: &str) -> impl Iterator<Item = &str> {
370    std::iter::from_fn(move || {
371        let res;
372        match s.find('\n') {
373            Some(new_line_idx) => {
374                let split_idx = new_line_idx + '\n'.len_utf8();
375                res = Some(&s[..split_idx]);
376                s = &s[split_idx..];
377            }
378            None if !s.is_empty() => {
379                res = Some(s);
380                s = "";
381            }
382            None => {
383                res = None;
384            }
385        }
386        res
387    })
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391enum Tag<'a> {
392    Start(StartTag<'a>),
393    End(EndTag),
394    StartBlock(StartBlockTag<'a>),
395    EndBlock(EndBlockTag<'a>),
396}
397
398#[derive(Debug, Clone, PartialEq, Eq)]
399struct StartTag<'a> {
400    fragments: HashSet<&'a str>,
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404struct StartBlockTag<'a> {
405    prefix: &'a str,
406    fragment: &'a str,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq)]
410struct EndBlockTag<'a> {
411    prefix: &'a str,
412}
413
414#[derive(Debug, Clone, PartialEq, Eq)]
415struct EndTag;
416
417fn parse_fragment_tag<'l>(
418    line: &'l str,
419    tag_markers: (&str, &str),
420) -> Result<Option<Tag<'l>>, Error> {
421    let parts = match parse_base(line, tag_markers) {
422        Some(parts) => parts,
423        None => return Ok(None),
424    };
425
426    if !parts.head.trim().is_empty() {
427        return Err(Error::LeadingContent(parts.head.to_owned()));
428    }
429
430    if !parts.tail.trim().is_empty() {
431        return Err(Error::TrailingContent(parts.tail.to_owned()));
432    }
433
434    match parts.fragment_type {
435        FragmentType::Start | FragmentType::BlockStart => {
436            let data = parts.data.trim();
437            if data.is_empty() {
438                return Err(Error::StartTagWithoutData);
439            }
440
441            let block = matches!(parts.fragment_type, FragmentType::BlockStart);
442
443            let fragments: HashSet<&str> = data.split_whitespace().collect();
444
445            let mut invalid_fragments = Vec::new();
446            for &fragment in &fragments {
447                if !is_valid_fragment_name(fragment) {
448                    invalid_fragments.push(fragment);
449                }
450            }
451            if !invalid_fragments.is_empty() {
452                return Err(Error::InvalidFragmentName(sorted_fragments(
453                    invalid_fragments,
454                )));
455            }
456
457            if !block {
458                Ok(Some(Tag::Start(StartTag { fragments })))
459            } else {
460                if fragments.len() > 1 {
461                    return Err(Error::MultipleNamesBlock(sorted_fragments(fragments)));
462                } else if fragments.is_empty() {
463                    return Err(Error::UnnamedBlock);
464                }
465
466                let fragment = fragments.into_iter().next().unwrap();
467                Ok(Some(Tag::StartBlock(StartBlockTag {
468                    prefix: parts.head,
469                    fragment,
470                })))
471            }
472        }
473        FragmentType::End => {
474            if !parts.data.trim().is_empty() {
475                return Err(Error::EndTagWithData(parts.data.to_owned()));
476            }
477            Ok(Some(Tag::End(EndTag)))
478        }
479        FragmentType::BlockEnd => {
480            if !parts.data.trim().is_empty() {
481                return Err(Error::EndTagWithData(parts.data.to_owned()));
482            }
483            Ok(Some(Tag::EndBlock(EndBlockTag { prefix: parts.head })))
484        }
485    }
486}
487
488fn parse_base<'l>(line: &'l str, tag_markers: (&str, &str)) -> Option<LineParts<'l>> {
489    // "(?P<head>[^\{]*)\{%\s+(?P<tag>fragment|endfragment)(?P<data>[^%]+)%\}(?P<tail>.*)
490    let (head, line) = line.split_once(tag_markers.0)?;
491    let line = line.strip_prefix(char::is_whitespace)?;
492
493    use FragmentType as T;
494
495    // NOTE: the order is important: the -block suffixes must come first
496    let (fragment_type, line) = None
497        .or_else(|| {
498            line.strip_prefix("fragment-block")
499                .map(|l| (T::BlockStart, l))
500        })
501        .or_else(|| {
502            line.strip_prefix("endfragment-block")
503                .map(|l| (T::BlockEnd, l))
504        })
505        .or_else(|| line.strip_prefix("fragment").map(|l| (T::Start, l)))
506        .or_else(|| line.strip_prefix("endfragment").map(|l| (T::End, l)))?;
507
508    let line = line.strip_prefix(char::is_whitespace)?;
509    let (data, line) = line.split_once(tag_markers.1)?;
510    let tail = line;
511
512    Some(LineParts {
513        head,
514        fragment_type,
515        data,
516        tail,
517    })
518}
519
520fn is_valid_fragment_name(name: &str) -> bool {
521    let is_reserved = matches!(name, "block");
522    let only_valid_chars = name
523        .chars()
524        .all(|c| c.is_alphanumeric() || matches!(c, '-' | '_'));
525
526    !is_reserved && only_valid_chars
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530struct LineParts<'a> {
531    head: &'a str,
532    fragment_type: FragmentType,
533    data: &'a str,
534    tail: &'a str,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq)]
538pub(crate) enum FragmentType {
539    Start,
540    End,
541    BlockStart,
542    BlockEnd,
543}
544
545fn sorted_fragments<'a, I: IntoIterator<Item = &'a str>>(fragments: I) -> String {
546    let mut fragments = fragments.into_iter().collect::<Vec<_>>();
547    fragments.sort();
548
549    let mut res = String::new();
550    for fragment in fragments {
551        push_join(&mut res, fragment);
552    }
553    res
554}
555
556/// Errors that can occurs during processing
557///
558#[derive(Debug, Clone, PartialEq, Eq)]
559pub enum Error {
560    /// Non-whitespace content before the fragment tag
561    LeadingContent(String),
562    /// None-whitespace content after the fragment tag
563    TrailingContent(String),
564    /// Endfragment tag with fragment names
565    EndTagWithData(String),
566    /// Fragment tag without names
567    StartTagWithoutData,
568    /// Fragment tag with a fragment that is already active
569    ReentrantFragment(String),
570    /// Tag without end tag
571    UnclosedTag(String),
572    /// End tag without corresponding start
573    UnbalancedEndTag,
574    /// Reserved fragment names (at the moment only `block`) or invalid characters
575    InvalidFragmentName(String),
576    /// A block fragment without a name
577    UnnamedBlock,
578    /// A block fragmen with too many names
579    MultipleNamesBlock(String),
580}
581
582impl Error {
583    pub fn at(self, line: usize) -> ErrorWithLine {
584        ErrorWithLine(line, self)
585    }
586}
587
588impl std::fmt::Display for Error {
589    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
590        match self {
591            Self::LeadingContent(content) => write!(f, "Error::LeadingContent({content:?})"),
592            Self::TrailingContent(content) => write!(f, "Error::TrailingContent({content:?})"),
593            Self::EndTagWithData(data) => write!(f, "Error::EndTagWithData({data:?})"),
594            Self::StartTagWithoutData => write!(f, "Error::StartTagWithoutData"),
595            Self::ReentrantFragment(fragments) => write!(f, "Error::ReentrantFragment({fragments}"),
596            Self::UnbalancedEndTag => write!(f, "Error::UnbalancedTags"),
597            Self::UnclosedTag(fragments) => write!(f, "Error::UnclosedTag({fragments})"),
598            Self::InvalidFragmentName(fragments) => {
599                write!(f, "Error::InvalidFragmentName({fragments}")
600            }
601            Self::UnnamedBlock => write!(f, "Error::UnnamedBlock"),
602            Self::MultipleNamesBlock(fragments) => {
603                write!(f, "Error::MultipleNamesBlock({fragments}")
604            }
605        }
606    }
607}
608
609impl std::error::Error for Error {}
610
611/// An error with the line of the template it occurred on
612///
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct ErrorWithLine(pub usize, pub Error);
615
616impl std::fmt::Display for ErrorWithLine {
617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        write!(f, "{} at line {}", self.1, self.0 + 1)
619    }
620}
621
622impl std::error::Error for ErrorWithLine {}
623
624fn push_join(s: &mut String, t: &str) {
625    if !s.is_empty() {
626        s.push_str(", ");
627    }
628    s.push_str(t);
629}