lib/render/template.rs
1//! Defines types to represent a template's content and metadata.
2
3use std::path::{Path, PathBuf};
4
5use serde::Deserialize;
6
7use crate::result::{Error, Result};
8
9use super::defaults::{CONFIG_TAG_CLOSE, CONFIG_TAG_OPEN};
10use super::names::Names;
11
12/// A struct representing a fully configured template.
13#[derive(Clone, Deserialize)]
14#[serde(rename_all = "kebab-case")]
15pub struct Template {
16 /// The template's id.
17 ///
18 /// This is the file path relative to the templates directory. It serves to identify a template
19 /// within the registry when rendering.
20 ///
21 /// ```plaintext
22 /// --> /path/to/templates/nested/template.md
23 /// --> nested/template.md
24 /// ```
25 #[serde(skip_deserializing)]
26 pub id: String,
27
28 /// The unparsed contents of the template.
29 ///
30 /// This gets parsed and validated during template registration.
31 #[serde(skip_deserializing)]
32 pub contents: String,
33
34 /// The template's group name.
35 ///
36 /// See [`StructureMode::FlatGrouped`] and [`StructureMode::NestedGrouped`] for more information.
37 #[serde(deserialize_with = "crate::utils::deserialize_and_sanitize")]
38 pub group: String,
39
40 /// The template's context mode i.e what the template intends to render.
41 ///
42 /// See [`ContextMode`] for more information.
43 #[serde(rename = "context")]
44 pub context_mode: ContextMode,
45
46 /// The template's structure mode i.e. how the output should be structured.
47 ///
48 /// See [`StructureMode`] for more information.
49 #[serde(rename = "structure")]
50 pub structure_mode: StructureMode,
51
52 /// The template's file extension.
53 pub extension: String,
54
55 /// The template strings for generating output file and directory names.
56 #[serde(default)]
57 pub names: Names,
58}
59
60impl Template {
61 /// Creates a new instance of [`Template`].
62 ///
63 /// # Arguments
64 ///
65 /// * `path` - The path to the template relative to the templates directory.
66 /// * `string` - The contents of the template file.
67 ///
68 /// # Errors
69 ///
70 /// Will return `Err` if:
71 /// * The template's opening and closing config tags have syntax errors.
72 /// * The tempalte's config has syntax errors or is missing required fields.
73 pub fn new<P>(path: P, string: &str) -> Result<Self>
74 where
75 P: AsRef<Path>,
76 {
77 let path = path.as_ref();
78
79 let (config, contents) = Self::parse(string).ok_or(Error::InvalidTemplateConfig {
80 path: path.display().to_string(),
81 })?;
82
83 let mut template: Self = serde_yaml_ng::from_str(config)?;
84
85 template.id = path.display().to_string();
86 template.contents = contents;
87
88 Ok(template)
89 }
90
91 /// Returns a tuple containing the template's configuration and its contents respectively.
92 ///
93 /// Returns `None` if the template's config block is formatted incorrectly.
94 fn parse(string: &str) -> Option<(&str, String)> {
95 // Find where the opening tag starts...
96 let mut config_start = string.find(CONFIG_TAG_OPEN)?;
97
98 // (Save the pre-config contents.)
99 let pre_config_contents = &string[0..config_start];
100
101 // ...and offset it by the length of the config opening tag.
102 config_start += CONFIG_TAG_OPEN.len();
103
104 // Starting from where we found the opening tag, search for a closing tag. If we don't offset
105 // the starting point we might find another closing tag located before the opening tag.
106 let mut config_end = string[config_start..].find(CONFIG_TAG_CLOSE)?;
107 // Remove the offset we just used.
108 config_end += config_start;
109
110 let config = &string[config_start..config_end];
111
112 // The template's post-config contents start after the closiong tag.
113 let post_config_contents = config_end + CONFIG_TAG_CLOSE.len();
114 let mut post_config_contents = &string[post_config_contents..];
115
116 // Trim a single linebreak if its present.
117 if post_config_contents.starts_with('\n') {
118 post_config_contents = &post_config_contents[1..];
119 }
120
121 let contents = format!("{pre_config_contents}{post_config_contents}",);
122
123 Some((config, contents))
124 }
125}
126
127impl std::fmt::Debug for Template {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("Template")
130 .field("id", &self.id)
131 .field("group", &self.group)
132 .field("context_mode", &self.context_mode)
133 .field("structure_mode", &self.structure_mode)
134 .finish_non_exhaustive()
135 }
136}
137
138/// A struct representing an unconfigured partial template.
139///
140/// Partial templates get their configuration from the normal templates that `include` them.
141#[derive(Clone)]
142pub struct TemplatePartial {
143 /// The template's id.
144 ///
145 /// This is the file path relative to the templates directory. It serves to identify a partial
146 /// template when called in an `include` tag from within a non-partial template.
147 ///
148 /// ```plaintext
149 /// --> /path/to/templates/nested/template.md
150 /// --> nested/template.md
151 /// --> {% include "nested/template.md" %}
152 /// ````
153 pub id: String,
154
155 /// The unparsed contents of the template.
156 ///
157 /// This gets parsed and validated *only* if its called in an `include` tag in a non-partial
158 /// template that is being registered/parsed/valiated.
159 pub contents: String,
160}
161
162impl TemplatePartial {
163 /// Creates a new instance of [`TemplatePartial`].
164 ///
165 /// # Arguments
166 ///
167 /// * `path` - The path to the template relative to the templates directory.
168 /// * `string` - The contents of the template file.
169 pub fn new<P>(path: P, string: &str) -> Self
170 where
171 P: AsRef<Path>,
172 {
173 Self {
174 id: path.as_ref().display().to_string(),
175 contents: string.to_owned(),
176 }
177 }
178}
179
180impl std::fmt::Debug for TemplatePartial {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 f.debug_struct("TemplatePartial")
183 .field("id", &self.id)
184 .finish_non_exhaustive()
185 }
186}
187
188/// A struct representing a rendered template.
189#[derive(Default)]
190pub struct Render {
191 /// The path to where the template will be written to.
192 ///
193 /// This path should be relative to the final output directory as this path is appended to it to
194 /// determine the the full output path.
195 pub path: PathBuf,
196
197 /// The final output filename.
198 pub filename: String,
199
200 /// The rendered content.
201 pub contents: String,
202}
203
204impl Render {
205 /// Creates a new instance of [`Template`].
206 #[must_use]
207 pub fn new(path: PathBuf, filename: String, contents: String) -> Self {
208 Self {
209 path,
210 filename,
211 contents,
212 }
213 }
214}
215
216impl std::fmt::Debug for Render {
217 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
218 f.debug_struct("Render")
219 .field("path", &self.path)
220 .field("filename", &self.filename)
221 .finish_non_exhaustive()
222 }
223}
224
225/// An enum representing the ways to structure a template's rendered files.
226#[derive(Debug, Clone, Copy, Deserialize)]
227#[serde(rename_all = "kebab-case")]
228pub enum StructureMode {
229 /// When selected, the template is rendered to the output directory without any structure.
230 ///
231 /// ```yaml
232 /// output-mode: flat
233 /// ```
234 ///
235 /// ```plaintext
236 /// [ouput-directory]
237 /// │
238 /// ├─ [template-name-01].[extension]
239 /// ├─ [template-name-01].[extension]
240 /// └─ ...
241 /// ```
242 Flat,
243
244 /// When selected, the template is rendered to the output directory and placed inside a
245 /// directory named after its `group`. This useful if there are multiple related and unrelated
246 /// templates being rendered to the same directory.
247 ///
248 /// ```yaml
249 /// output-mode: flat-grouped
250 /// ```
251 ///
252 /// ```plaintext
253 /// [ouput-directory]
254 /// │
255 /// ├─ [template-group-01]
256 /// │ ├─ [template-name-01].[extension]
257 /// │ ├─ [template-name-01].[extension]
258 /// │ └─ ...
259 /// │
260 /// ├─ [template-group-02]
261 /// │ └─ ...
262 /// └─ ...
263 /// ```
264 FlatGrouped,
265
266 /// When selected, the template is rendered to the output directory and placed inside a
267 /// directory named after its `nested-directory-template`. This useful if multiple templates are
268 /// used to represent a single book i.e. a book template used to render a book's information to
269 /// a single file and an annotation template used to render each annotation to a separate file.
270 ///
271 /// ```yaml
272 /// output-mode: nested
273 /// ```
274 ///
275 /// ```plaintext
276 /// [ouput-directory]
277 /// │
278 /// ├─ [author-title-01]
279 /// │ ├─ [template-name-01].[extension]
280 /// │ ├─ [template-name-01].[extension]
281 /// │ └─ ...
282 /// │
283 /// ├─ [author-title-02]
284 /// │ └─ ...
285 /// └─ ...
286 /// ```
287 Nested,
288
289 /// When selected, the template is rendered to the output directory and placed inside a
290 /// directory named after its `group` and another named after its `nested-directory-template`.
291 /// This useful if multiple templates are used to represent a single book i.e. a book template
292 /// and an annotation template and there are multiple related and unrelated templates being
293 /// rendered to the same directory.
294 ///
295 ///
296 /// ```yaml
297 /// output-mode: nested-grouped
298 /// ```
299 ///
300 /// ```plaintext
301 /// [ouput-directory]
302 /// │
303 /// ├─ [template-group-01]
304 /// │ │
305 /// │ ├─ [author-title-01]
306 /// │ │ ├─ [template-name-01].[extension]
307 /// │ │ ├─ [template-name-01].[extension]
308 /// │ │ └─ ...
309 /// │ │
310 /// │ ├─ [author-title-02]
311 /// │ │ ├─ [template-name-02].[extension]
312 /// │ │ ├─ [template-name-02].[extension]
313 /// │ │ └─ ...
314 /// │ └─ ...
315 /// │
316 /// ├─ [template-group-02]
317 /// │ ├─ [author-title-01]
318 /// │ │ └─ ...
319 /// │ └─ ...
320 /// └─ ...
321 /// ```
322 NestedGrouped,
323}
324
325/// An enum representing what a template intends to render.
326#[derive(Debug, Clone, Copy, Deserialize)]
327#[serde(rename_all = "lowercase")]
328pub enum ContextMode {
329 /// When selected, the template is rendered to a single file containing a [`Book`][book] and all
330 /// its [`Annotation`][annotation]s.
331 ///
332 /// ```yaml
333 /// render-context: book
334 /// ```
335 ///
336 /// ```plaintext
337 /// [ouput-directory]
338 /// └─ [template-name].[extension]
339 /// ```
340 ///
341 /// [book]: crate::models::book::Book
342 /// [annotation]: crate::models::annotation::Annotation
343 Book,
344
345 /// When selected, the template is rendered to multiple files containing a [`Book`][book] and
346 /// only one its [`Annotation`][annotation]s.
347 ///
348 /// ```yaml
349 /// render-context: annotation
350 /// ```
351 ///
352 /// ```plaintext
353 /// [ouput-directory]
354 /// ├─ [template-name].[extension]
355 /// ├─ [template-name].[extension]
356 /// ├─ [template-name].[extension]
357 /// └─ ...
358 /// ```
359 ///
360 /// [book]: crate::models::book::Book
361 /// [annotation]: crate::models::annotation::Annotation
362 Annotation,
363}
364
365#[cfg(test)]
366mod test {
367
368 use super::*;
369
370 use crate::defaults::test::TemplatesDirectory;
371 use crate::utils;
372
373 mod invalid_config {
374
375 use super::*;
376
377 // Tests that a missing config block returns an error.
378 #[test]
379 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
380 fn missing_config() {
381 let template = utils::testing::load_template_str(
382 TemplatesDirectory::InvalidConfig,
383 "missing-config.txt",
384 );
385 Template::parse(&template).unwrap();
386 }
387
388 // Tests that a missing closing tag returns an error.
389 #[test]
390 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
391 fn missing_closing_tag() {
392 let template = utils::testing::load_template_str(
393 TemplatesDirectory::InvalidConfig,
394 "missing-closing-tag.txt",
395 );
396 Template::parse(&template).unwrap();
397 }
398
399 // Tests that missing `readstor` in the opening tag returns an error.
400 #[test]
401 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
402 fn incomplete_opening_tag_01() {
403 let template = utils::testing::load_template_str(
404 TemplatesDirectory::InvalidConfig,
405 "incomplete-opening-tag-01.txt",
406 );
407 Template::parse(&template).unwrap();
408 }
409
410 // Tests that missing the `!` in the opening tag returns an error.
411 #[test]
412 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
413 fn incomplete_opening_tag_02() {
414 let template = utils::testing::load_template_str(
415 TemplatesDirectory::InvalidConfig,
416 "incomplete-opening-tag-02.txt",
417 );
418 Template::parse(&template).unwrap();
419 }
420
421 // Tests that no linebreak after `readstor` returns an error.
422 #[test]
423 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
424 fn missing_linebreak_01() {
425 let template = utils::testing::load_template_str(
426 TemplatesDirectory::InvalidConfig,
427 "missing-linebreak-01.txt",
428 );
429 Template::parse(&template).unwrap();
430 }
431
432 // Tests that no linebreak after the config body returns an error.
433 #[test]
434 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
435 fn missing_linebreak_02() {
436 let template = utils::testing::load_template_str(
437 TemplatesDirectory::InvalidConfig,
438 "missing-linebreak-02.txt",
439 );
440 Template::parse(&template).unwrap();
441 }
442
443 // Tests that no linebreak after the closing tag returns an error.
444 #[test]
445 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
446 fn missing_linebreak_03() {
447 let template = utils::testing::load_template_str(
448 TemplatesDirectory::InvalidConfig,
449 "missing-linebreak-03.txt",
450 );
451 Template::parse(&template).unwrap();
452 }
453
454 // Tests that no linebreak before the opening tag returns an error.
455 #[test]
456 #[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
457 fn missing_linebreak_04() {
458 let template = utils::testing::load_template_str(
459 TemplatesDirectory::InvalidConfig,
460 "missing-linebreak-04.txt",
461 );
462 Template::parse(&template).unwrap();
463 }
464 }
465
466 mod valid_config {
467
468 use super::*;
469
470 // Test the minimum required keys.
471 #[test]
472 fn minimum_required_keys() {
473 let filename = "minimum-required-keys.txt";
474 let template =
475 utils::testing::load_template_str(TemplatesDirectory::ValidConfig, filename);
476 Template::new(filename, &template).unwrap();
477 }
478
479 // Tests that a template with pre- and post-config-content returns no error.
480 #[test]
481 fn pre_and_post_config_content() {
482 let template = utils::testing::load_template_str(
483 TemplatesDirectory::ValidConfig,
484 "pre-and-post-config-content.txt",
485 );
486 Template::parse(&template).unwrap();
487 }
488
489 // Tests that a template with pre-config-content returns no error.
490 #[test]
491 fn pre_config_content() {
492 let template = utils::testing::load_template_str(
493 TemplatesDirectory::ValidConfig,
494 "pre-config-content.txt",
495 );
496 Template::parse(&template).unwrap();
497 }
498
499 // Tests that a template with post-config-content returns no error.
500 #[test]
501 fn post_config_content() {
502 let template = utils::testing::load_template_str(
503 TemplatesDirectory::ValidConfig,
504 "post-config-content.txt",
505 );
506 Template::parse(&template).unwrap();
507 }
508 }
509}