tpnote_lib/context.rs
1//! Extends the built-in Tera filters.
2use crate::config::Assertion;
3use crate::config::FILENAME_ROOT_PATH_MARKER;
4use crate::config::LIB_CFG;
5#[cfg(feature = "viewer")]
6use crate::config::TMPL_HTML_VAR_DOC_ERROR;
7#[cfg(feature = "viewer")]
8use crate::config::TMPL_HTML_VAR_DOC_TEXT;
9use crate::config::TMPL_HTML_VAR_EXPORTER_DOC_CSS;
10use crate::config::TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS;
11use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH;
12use crate::config::TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE;
13use crate::config::TMPL_HTML_VAR_VIEWER_DOC_JS;
14use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH;
15use crate::config::TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE;
16use crate::config::TMPL_VAR_BODY;
17use crate::config::TMPL_VAR_CURRENT_SCHEME;
18use crate::config::TMPL_VAR_DIR_PATH;
19use crate::config::TMPL_VAR_DOC_FILE_DATE;
20use crate::config::TMPL_VAR_EXTENSION_DEFAULT;
21use crate::config::TMPL_VAR_FM_;
22use crate::config::TMPL_VAR_FM_ALL;
23use crate::config::TMPL_VAR_FM_SCHEME;
24use crate::config::TMPL_VAR_FORCE_LANG;
25use crate::config::TMPL_VAR_HEADER;
26use crate::config::TMPL_VAR_LANG;
27use crate::config::TMPL_VAR_PATH;
28use crate::config::TMPL_VAR_ROOT_PATH;
29use crate::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
30use crate::config::TMPL_VAR_USERNAME;
31use crate::content::Content;
32use crate::error::FileError;
33use crate::error::LibCfgError;
34use crate::error::NoteError;
35use crate::filename::Extension;
36use crate::filename::NotePath;
37use crate::filename::NotePathStr;
38use crate::filter::name;
39use crate::front_matter::FrontMatter;
40use crate::settings::SETTINGS;
41use std::borrow::Cow;
42use std::fs::File;
43use std::marker::PhantomData;
44use std::ops::Deref;
45use std::path::Path;
46use std::path::PathBuf;
47use std::time::SystemTime;
48
49/// At trait setting up a state machine as described below.
50/// Its implementors represent one specific state defining the amount and the
51/// type of data the `Context` type holds at that moment.
52pub trait ContextState {}
53
54#[derive(Debug, PartialEq, Clone)]
55/// See description in the `ContextState` implementor list.
56pub struct Invalid;
57
58#[derive(Debug, PartialEq, Clone)]
59/// See description in the `ContextState` implementor list.
60pub struct HasSettings;
61
62#[derive(Debug, PartialEq, Clone)]
63/// See description in the `ContextState` implementor list.
64pub(crate) struct ReadyForFilenameTemplate;
65
66#[derive(Debug, PartialEq, Clone)]
67/// See description in the `ContextState` implementor list.
68pub(crate) struct HasExistingContent;
69
70#[derive(Debug, PartialEq, Clone)]
71/// See description in the `ContextState` implementor list.
72pub(crate) struct ReadyForContentTemplate;
73
74#[derive(Debug, PartialEq, Clone)]
75/// See description in the `ContextState` implementor list.
76pub(crate) struct ReadyForHtmlTemplate;
77
78#[cfg(feature = "viewer")]
79#[derive(Debug, PartialEq, Clone)]
80/// See description in the `ContextState` implementor list.
81pub(crate) struct ReadyForHtmlErrorTemplate;
82
83/// The `Context` object is in an invalid state. Either it was not initialized
84/// or its data does not correspond any more to the `Content` it represents.
85///
86/// | State order | |
87/// |----------------|---------------------------------------|
88/// | Previous state | none |
89/// | Current state | `Invalid` |
90/// | Next state | `HasSettings` |
91///
92impl ContextState for Invalid {}
93
94/// The `Context` has the following initialized and valid fields: `path`,
95/// `dir_path`, `root_path` and `ct`. The context `ct` contains data from
96/// `insert_config_vars()` and `insert_settings()`.
97/// `Context<HasSettings>` has the following variables set:
98///
99/// * `TMPL_VAR_CURRENT_SCHEME`
100/// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
101/// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
102/// available).
103/// * `TMPL_VAR_EXTENSION_DEFAULT`
104/// * `TMPL_VAR_LANG`
105/// * `TMPL_VAR_PATH` in sync with `self.path`,
106/// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
107/// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
108/// * `TMPL_VAR_USERNAME`
109///
110/// The variables are inserted by the following methods: `self.from()`,
111/// `self.insert_config_vars()` and `self.insert_settings()`.
112/// Once this state is achieved, `Context` is constant and write protected until
113/// the next state transition.
114///
115/// | State order | |
116/// |----------------|---------------------------------------|
117/// | Previous state | `Invalid` |
118/// | Current state | `HasSettings` |
119/// | Next state | `ReadyForFilenameTemplate` or `HasExistingContent` |
120///
121impl ContextState for HasSettings {}
122
123/// In addition to `HasSettings`, the `context.ct` contains template variables
124/// deserialized from some note's front matter. E.g. a field named `title:`
125/// appears in the context as `fm.fm_title` template variable.
126/// In `Note` objects the `Content` is always associated with a
127/// `Context<ReadyForFilenameTemplate>`.
128/// Once this state is achieved, `Context` is constant and write protected until
129/// the next state transition.
130///
131/// | State order | |
132/// |----------------|---------------------------------------|
133/// | Previous state | `HasSettings` |
134/// | Current state | `ReadyForFilenameTemplate ` |
135/// | Next state | none or `ReadyForHtmlTemplate` |
136///
137impl ContextState for ReadyForFilenameTemplate {}
138
139/// In addition to the `HasSettings` the YAML headers of all clipboard
140/// `Content` objects are registered as front matter variables `fm.fm*` in the
141/// `Context`.
142/// This stage is also used for the `TemplateKind::FromTextFile` template.
143/// In this case the last inserted `Content` comes from the text file
144/// the command line parameter `<path>` points to. This adds the following key:
145///
146/// * `TMPL_VAR_DOC`
147///
148/// This state can evolve as the
149/// `insert_front_matter_and_raw_text_from_existing_content()` function can be
150/// called several times.
151///
152/// | State order | |
153/// |----------------|---------------------------------------|
154/// | Previous state | `HasSettings` or `HasExistingContent` |
155/// | Current state | `HasExistingContent` |
156/// | Next state | `ReadyForContentTemplate` |
157///
158impl ContextState for HasExistingContent {}
159
160/// This marker state means that enough information have been collected
161/// in the `HasExistingContent` state to be passed to a
162/// content template renderer.
163/// Once this state is achieved, `Context` is constant and write protected until
164/// the next state transition.
165///
166/// | State order | |
167/// |----------------|---------------------------------------|
168/// | Previous state | `HasExistingContent` |
169/// | Current state | `ReadyForContentTemplate` |
170/// | Next state | none |
171///
172impl ContextState for ReadyForContentTemplate {}
173
174/// In addition to the `ReadyForFilenameTemplate` state this state has the
175/// following variables set:
176///
177/// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
178/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
179/// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
180/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
181/// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
182/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
183/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
184/// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
185/// * `TMPL_VAR_DOC`
186///
187/// Once this state is achieved, `Context` is constant and write protected until
188/// the next state transition.
189///
190/// | State order | |
191/// |----------------|---------------------------------------|
192/// | Previous state | `ReadyForFilenameTemplate` |
193/// | Current state | `ReadyForHtmlTemplate` |
194/// | Next state | none |
195///
196impl ContextState for ReadyForHtmlTemplate {}
197
198/// The `Context` has all data for the intended template.
199///
200/// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
201/// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
202/// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
203///
204/// Once this state is achieved, `Context` is constant and write protected until
205/// the next state transition.
206///
207/// | State order | |
208/// |----------------|---------------------------------------|
209/// | Previous state | `HasSettings` |
210/// | Current state | `ReadyForHtmlErrorTemplate` |
211/// | Next state | none |
212///
213#[cfg(feature = "viewer")]
214impl ContextState for ReadyForHtmlErrorTemplate {}
215
216/// Recursively checks that all leaf values of a `tera::Value` tree satisfy `f`.
217/// Arrays and maps are traversed; other values are passed to `f`.
218fn tera_all_leaves(val: &tera::Value, f: &dyn Fn(&tera::Value) -> bool) -> bool {
219 if let Some(a) = val.as_array() {
220 a.iter().all(|i| tera_all_leaves(i, f))
221 } else if let Some(map) = val.as_map() {
222 map.values().all(|v| tera_all_leaves(v, f))
223 } else {
224 f(val)
225 }
226}
227
228/// Tiny wrapper around "Tera context" with some additional information.
229#[derive(Clone, Debug, PartialEq)]
230pub struct Context<S: ContextState + ?Sized> {
231 /// Collection of substitution variables.
232 ct: tera::Context,
233 /// First positional command line argument.
234 path: PathBuf,
235 /// The directory (only) path corresponding to the first positional
236 /// command line argument. The is our working directory and
237 /// the directory where the note file is (will be) located.
238 dir_path: PathBuf,
239 /// `dir_path` is a subdirectory of `root_path`. `root_path` is the
240 /// first directory, that upwards from `dir_path`, contains a file named
241 /// `FILENAME_ROOT_PATH_MARKER` (or `/` if no marker file can be found).
242 /// The root directory is interpreted by Tp-Note's viewer as its base
243 /// directory: only files within this directory are served.
244 root_path: PathBuf,
245 /// If `path` points to a file, we store its creation date here.
246 doc_file_date: Option<SystemTime>,
247 /// Rust requires usage of generic parameters, here `S`.
248 _marker: PhantomData<S>,
249}
250
251/// The methods below are available in all `ContentState` states.
252impl<S: ContextState> Context<S> {
253 /// Getter for `self.path`.
254 /// See `from()` method for details.
255 pub fn get_path(&self) -> &Path {
256 self.path.as_path()
257 }
258
259 /// Getter for `self.dir_path`.
260 /// See `from()` method for details.
261 pub fn get_dir_path(&self) -> &Path {
262 self.dir_path.as_path()
263 }
264
265 /// Getter for `self.root_path`.
266 /// See `from()` method for details.
267 pub fn get_root_path(&self) -> &Path {
268 self.root_path.as_path()
269 }
270
271 /// Getter for `self.doc_file_date`.
272 /// See `from()` method for details.
273 pub fn get_doc_file_date(&self) -> Option<SystemTime> {
274 self.doc_file_date
275 }
276
277 /// Constructor. Unlike `from()` this constructor does not access
278 /// the filesystem in order to detect `dir_path`, `root_path` and
279 /// `doc_file_date`. It copies these values from the passed `context`.
280 /// Use this constructor when you are sure that the above date has
281 /// not changed since you instantiated `context`. In this case you
282 /// can avoid repeated file access.
283 pub fn from_context_path(context: &Context<S>) -> Context<HasSettings> {
284 let mut new_context = Context {
285 ct: tera::Context::new(),
286 path: context.path.clone(),
287 dir_path: context.dir_path.clone(),
288 root_path: context.root_path.clone(),
289 doc_file_date: context.doc_file_date,
290 _marker: PhantomData,
291 };
292
293 new_context.sync_paths_to_map();
294 new_context.insert_config_vars();
295 new_context.insert_settings();
296 new_context
297 }
298
299 /// Helper function that keeps the values with the `self.ct` key
300 ///
301 /// * `TMPL_VAR_PATH` in sync with `self.path`,
302 /// * `TMPL_VAR_DIR_PATH` in sync with `self.dir_path` and
303 /// * `TMPL_VAR_ROOT_PATH` in sync with `self.root_path`.
304 /// * `TMPL_VAR_DOC_FILE_DATE` in sync with `self.doc_file_date` (only if
305 ///
306 /// available).
307 /// Synchronization is performed by copying the latter to the former.
308 fn sync_paths_to_map(&mut self) {
309 self.ct.insert(TMPL_VAR_PATH, &self.path);
310 self.ct.insert(TMPL_VAR_DIR_PATH, &self.dir_path);
311 self.ct.insert(TMPL_VAR_ROOT_PATH, &self.root_path);
312 if let Some(time) = self.doc_file_date {
313 self.ct.insert(
314 TMPL_VAR_DOC_FILE_DATE,
315 &time
316 .duration_since(SystemTime::UNIX_EPOCH)
317 .unwrap_or_default()
318 .as_secs(),
319 )
320 } else {
321 self.ct.remove(TMPL_VAR_DOC_FILE_DATE);
322 };
323 }
324
325 /// Insert some configuration variables into the context so that they
326 /// can be used in the templates.
327 ///
328 /// This function adds the key:
329 ///
330 /// * `TMPL_VAR_SCHEME_SYNC_DEFAULT`.
331 ///
332 /// ```
333 /// use std::path::Path;
334 /// use tpnote_lib::config::TMPL_VAR_SCHEME_SYNC_DEFAULT;
335 /// use tpnote_lib::settings::set_test_default_settings;
336 /// use tpnote_lib::context::Context;
337 /// set_test_default_settings().unwrap();
338 ///
339 /// // The constructor calls `context.insert_settings()` before returning.
340 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
341 ///
342 /// // When the note's YAML header does not contain a `scheme:` field,
343 /// // the `default` scheme is used.
344 /// assert_eq!(&context.get(TMPL_VAR_SCHEME_SYNC_DEFAULT).unwrap().to_string(),
345 /// "default");
346 /// ```
347 fn insert_config_vars(&mut self) {
348 let lib_cfg = LIB_CFG.read_recursive();
349
350 // Default extension for new notes as defined in the configuration file.
351 self.ct.insert(
352 TMPL_VAR_SCHEME_SYNC_DEFAULT,
353 lib_cfg.scheme_sync_default.as_str(),
354 );
355 }
356
357 /// Captures Tp-Note's environment and stores it as variables in a
358 /// `context` collection. The variables are needed later to populate
359 /// a context template and a filename template.
360 ///
361 /// This function adds the keys:
362 ///
363 /// * `TMPL_VAR_EXTENSION_DEFAULT`
364 /// * `TMPL_VAR_USERNAME`
365 /// * `TMPL_VAR_LANG`
366 /// * `TMPL_VAR_CURRENT_SCHEME`
367 ///
368 /// ```
369 /// use std::path::Path;
370 /// use tpnote_lib::config::TMPL_VAR_EXTENSION_DEFAULT;
371 /// use tpnote_lib::config::TMPL_VAR_CURRENT_SCHEME;
372 /// use tpnote_lib::settings::set_test_default_settings;
373 /// use tpnote_lib::context::Context;
374 /// set_test_default_settings().unwrap();
375 ///
376 /// // The constructor calls `context.insert_settings()` before returning.
377 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
378 ///
379 /// // For most platforms `context.get("extension_default")` is `md`
380 /// assert_eq!(&context.get(TMPL_VAR_EXTENSION_DEFAULT).unwrap().to_string(),
381 /// "md");
382 /// // `Settings.current_scheme` is by default the `default` scheme.
383 /// assert_eq!(&context.get(TMPL_VAR_CURRENT_SCHEME).unwrap().to_string(),
384 /// "default");
385 /// ```
386 fn insert_settings(&mut self) {
387 let settings = SETTINGS.read_recursive();
388
389 // Default extension for new notes as defined in the configuration file.
390 self.ct.insert(
391 TMPL_VAR_EXTENSION_DEFAULT,
392 settings.extension_default.as_str(),
393 );
394
395 {
396 let lib_cfg = LIB_CFG.read_recursive();
397 self.ct.insert(
398 TMPL_VAR_CURRENT_SCHEME,
399 &lib_cfg.scheme[settings.current_scheme].name,
400 );
401 } // Release `lib_cfg` here.
402
403 // Search for UNIX, Windows, and MacOS user-names.
404 self.ct.insert(TMPL_VAR_USERNAME, &settings.author);
405
406 // Get the user's language tag.
407 self.ct.insert(TMPL_VAR_LANG, &settings.lang);
408
409 // Store `force_lang`.
410 self.ct.insert(TMPL_VAR_FORCE_LANG, &settings.force_lang);
411 }
412
413 /// Inserts the YAML front header variables into the context for later use
414 /// with templates.
415 ///
416 fn insert_front_matter2(&mut self, fm: &FrontMatter) {
417 let mut fm_all_map: serde_json::Map<String, serde_json::Value> = self
418 .ct
419 .remove(TMPL_VAR_FM_ALL)
420 .and_then(|v| {
421 serde_json::to_value(&v).ok().and_then(|sj| match sj {
422 serde_json::Value::Object(m) => Some(m),
423 _ => None,
424 })
425 })
426 .unwrap_or_default();
427
428 // Collect all localized scheme field names.
429 // Example: `["scheme", "scheme", "Schema"]`
430 let localized_scheme_names: Vec<String> = LIB_CFG
431 .read_recursive()
432 .scheme
433 .iter()
434 .map(|s| {
435 s.tmpl
436 .fm_var
437 .localization
438 .iter()
439 .find_map(|(k, v)| (k == TMPL_VAR_FM_SCHEME).then_some(v.to_owned()))
440 })
441 .collect::<Option<Vec<String>>>()
442 .unwrap_or_default();
443
444 // Search for localized scheme names in front matter.
445 // `(scheme_idx, field_value)`. Example: `(2, "Deutsch")`
446 let localized_scheme: Option<(usize, &str)> = localized_scheme_names
447 .iter()
448 .enumerate()
449 .find_map(|(i, k)| fm.0.get(k).and_then(|s| s.as_str()).map(|s| (i, s)));
450
451 let scheme = if let Some((scheme_idx, scheme_name)) = localized_scheme {
452 {
453 log::trace!(
454 "Found `scheme: {}` with index=={} in front matter",
455 scheme_name,
456 scheme_idx,
457 );
458 scheme_idx
459 }
460 } else {
461 SETTINGS.read_recursive().current_scheme
462 };
463 let scheme = &LIB_CFG.read_recursive().scheme[scheme];
464
465 let vars = &scheme.tmpl.fm_var.localization;
466 for (key, value) in fm.iter() {
467 // This delocalizes the variable name and prepends `fm_` to its name.
468 // NB: We also insert `Value::Array` and `Value::Object`
469 // variants, No flattening occurs here.
470 let fm_key = vars.iter().find(|&l| &l.1 == key).map_or_else(
471 || {
472 let mut s = TMPL_VAR_FM_.to_string();
473 s.push_str(key);
474 Cow::Owned(s)
475 },
476 |l| Cow::Borrowed(&l.0),
477 );
478
479 // Store a copy in `fm`.
480 fm_all_map.insert(fm_key.to_string(), value.clone());
481 }
482 // Register the collection as `Object(Map<String, Value>)`.
483 self.ct.insert(TMPL_VAR_FM_ALL, &fm_all_map);
484 }
485
486 /// Insert a key/val pair directly. Only available in tests.
487 #[cfg(test)]
488 pub(crate) fn insert(&mut self, key: &str, val: &impl serde::Serialize) {
489 self.ct.insert(key.to_owned(), val);
490 }
491
492 /// Inserts a `Content` in `Context`. The content appears as key in
493 /// `context.ct` with its name taken from `content.name()`.
494 /// Its value is a `tera::Map` with two keys `TMPL_VAR_HEADER` and
495 /// `TMPL_VAR_BODY`. The corresponding values are copied from
496 /// `conten.header()` and `content.body()`.
497 fn insert_raw_text_from_existing_content(&mut self, content: &impl Content) {
498 //
499 // Register input.
500 let mut map = serde_json::Map::new();
501 map.insert(
502 TMPL_VAR_HEADER.to_string(),
503 serde_json::Value::String(content.header().to_string()),
504 );
505 map.insert(
506 TMPL_VAR_BODY.to_string(),
507 serde_json::Value::String(content.body().to_string()),
508 );
509
510 self.ct
511 .insert(content.name().to_owned(), &serde_json::Value::Object(map));
512 }
513
514 /// See function of the same name in `impl Context<HasSettings>`.
515 fn insert_front_matter_and_raw_text_from_existing_content2(
516 &mut self,
517 clipboards: &Vec<&impl Content>,
518 ) -> Result<(), NoteError> {
519 //
520 for &clip in clipboards {
521 // Register input.
522 self.insert_raw_text_from_existing_content(clip);
523
524 // Can we find a front matter in the input stream? If yes, the
525 // unmodified input stream is our new note content.
526 if !clip.header().is_empty() {
527 let input_fm = FrontMatter::try_from(clip.header());
528 match input_fm {
529 Ok(ref fm) => {
530 log::trace!(
531 "Input stream \"{}\" generates the front matter variables:\n{:#?}",
532 clip.name(),
533 &fm
534 )
535 }
536 Err(ref e) => {
537 if !clip.header().is_empty() {
538 return Err(NoteError::InvalidInputYaml {
539 tmpl_var: clip.name().to_string(),
540 source_str: e.to_string(),
541 });
542 }
543 }
544 };
545
546 // Register front matter.
547 // The variables registered here can be overwrite the ones from the clipboard.
548 if let Ok(fm) = input_fm {
549 self.insert_front_matter2(&fm);
550 }
551 }
552 }
553
554 // Tera v2 raises an error when a template accesses an undefined variable
555 // (e.g. `fm.fm_title`). Ensure `fm` is always present so templates using
556 // `fm.x | default(value='')` work even when no front matter was found.
557 if self.ct.get(TMPL_VAR_FM_ALL).is_none() {
558 let empty: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
559 self.ct.insert(TMPL_VAR_FM_ALL.to_owned(), &empty);
560 }
561
562 Ok(())
563 }
564}
565
566/// The start state of all `Context` objects.
567///
568impl Context<Invalid> {
569 /// Constructor: `path` is Tp-Notes first positional command line parameter
570 /// `<path>` (see man page). `path` must point to a directory or
571 /// a file.
572 ///
573 /// A copy of `path` is stored in `self.ct` as key `TMPL_VAR_PATH`. It
574 /// directory path as key `TMPL_VAR_DIR_PATH`. The root directory, where
575 /// the marker file `tpnote.toml` was found, is stored with the key
576 /// `TMPL_VAR_ROOT_PATH`. If `path` points to a file, its file creation
577 /// date is stored with the key `TMPL_VAR_DOC_FILE_DATE`.
578 ///
579 /// ```rust
580 /// use std::path::Path;
581 /// use tpnote_lib::settings::set_test_default_settings;
582 /// use tpnote_lib::config::TMPL_VAR_DIR_PATH;
583 /// use tpnote_lib::config::TMPL_VAR_PATH;
584 /// use tpnote_lib::context::Context;
585 /// set_test_default_settings().unwrap();
586 ///
587 /// let mut context = Context::from(&Path::new("/path/to/mynote.md")).unwrap();
588 ///
589 /// assert_eq!(context.get_path(), Path::new("/path/to/mynote.md"));
590 /// assert_eq!(context.get_dir_path(), Path::new("/path/to/"));
591 /// assert_eq!(&context.get(TMPL_VAR_PATH).unwrap().to_string(),
592 /// "/path/to/mynote.md");
593 /// assert_eq!(&context.get(TMPL_VAR_DIR_PATH).unwrap().to_string(),
594 /// "/path/to");
595 /// ```
596 pub fn from(path: &Path) -> Result<Context<HasSettings>, FileError> {
597 let path = path.to_path_buf();
598
599 // `dir_path` is a directory as fully qualified path, ending
600 // by a separator.
601 let dir_path = if path.is_dir() {
602 path.clone()
603 } else {
604 path.parent()
605 .unwrap_or_else(|| Path::new("./"))
606 .to_path_buf()
607 };
608
609 // Get the root directory.
610 let mut root_path = Path::new("");
611
612 for anc in dir_path.ancestors() {
613 root_path = anc;
614 let mut p = anc.to_owned();
615 p.push(Path::new(FILENAME_ROOT_PATH_MARKER));
616 if p.is_file() {
617 break;
618 }
619 }
620 let root_path = root_path.to_owned();
621 debug_assert!(dir_path.starts_with(&root_path));
622
623 // Get the file's creation date. Fail silently.
624 let file_creation_date = if let Ok(file) = File::open(&path) {
625 let metadata = file.metadata()?;
626 metadata.created().or_else(|_| metadata.modified()).ok()
627 } else {
628 None
629 };
630
631 // Insert environment.
632 let mut context = Context {
633 ct: tera::Context::new(),
634 path,
635 dir_path,
636 root_path,
637 doc_file_date: file_creation_date,
638 _marker: PhantomData,
639 };
640
641 context.sync_paths_to_map();
642 context.insert_config_vars();
643 context.insert_settings();
644 Ok(context)
645 }
646}
647
648impl Context<HasSettings> {
649 /// Merges `fm` into `self.ct`.
650 pub(crate) fn insert_front_matter(
651 mut self,
652 fm: &FrontMatter,
653 ) -> Context<ReadyForFilenameTemplate> {
654 Context::insert_front_matter2(&mut self, fm);
655 Context {
656 ct: self.ct,
657 path: self.path,
658 dir_path: self.dir_path,
659 root_path: self.root_path,
660 doc_file_date: self.doc_file_date,
661 _marker: PhantomData,
662 }
663 }
664
665 /// Inserts clipboard data, standard input data and/or existing note file
666 /// content into the context. The data may contain some copied text with
667 /// or without a YAML header. The latter usually carries front matter
668 /// variables. The `input` data below is registered with the key name given
669 /// by `tmpl_var_body_name`. Typical names are `"clipboard"` or `"stdin"`.
670 /// If the below `input` contains a valid YAML header, it will be registered
671 /// in the context with the key name given by `tmpl_var_header_name`. The
672 /// templates expect the key names `clipboard_header` or `std_header`. The
673 /// raw header text will be inserted with this key name.
674 ///
675 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
676 mut self,
677 clipboards: &Vec<&impl Content>,
678 ) -> Result<Context<HasExistingContent>, NoteError> {
679 //
680 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
681
682 Ok(Context {
683 ct: self.ct,
684 path: self.path,
685 dir_path: self.dir_path,
686 root_path: self.root_path,
687 doc_file_date: self.doc_file_date,
688 _marker: PhantomData,
689 })
690 }
691
692 /// This adds the following variables to `self`:
693 ///
694 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
695 /// * `TMPL_HTML_VAR_DOC_ERROR` from `error_message`
696 /// * `TMPL_HTML_VAR_DOC_TEXT` from `note_erroneous_content`
697 ///
698 #[cfg(feature = "viewer")]
699 pub(crate) fn insert_error_content(
700 mut self,
701 note_erroneous_content: &impl Content,
702 error_message: &str,
703 // Java Script live updater inject code. Will be inserted into
704 // `tmpl_html.viewer`.
705 viewer_doc_js: &str,
706 ) -> Context<ReadyForHtmlErrorTemplate> {
707 //
708 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
709
710 self.ct.insert(TMPL_HTML_VAR_DOC_ERROR, error_message);
711 self.ct
712 .insert(TMPL_HTML_VAR_DOC_TEXT, ¬e_erroneous_content.as_str());
713
714 Context {
715 ct: self.ct,
716 path: self.path,
717 dir_path: self.dir_path,
718 root_path: self.root_path,
719 doc_file_date: self.doc_file_date,
720 _marker: PhantomData,
721 }
722 }
723}
724
725impl Context<HasExistingContent> {
726 /// See same method in `Context<HasSettings>`.
727 pub(crate) fn insert_front_matter_and_raw_text_from_existing_content(
728 mut self,
729 clipboards: &Vec<&impl Content>,
730 ) -> Result<Context<HasExistingContent>, NoteError> {
731 //
732 self.insert_front_matter_and_raw_text_from_existing_content2(clipboards)?;
733
734 Ok(Context {
735 ct: self.ct,
736 path: self.path,
737 dir_path: self.dir_path,
738 root_path: self.root_path,
739 doc_file_date: self.doc_file_date,
740 _marker: PhantomData,
741 })
742 }
743
744 /// Mark this as ready for a content template.
745 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
746 Context {
747 ct: self.ct,
748 path: self.path,
749 dir_path: self.dir_path,
750 root_path: self.root_path,
751 doc_file_date: self.doc_file_date,
752 _marker: PhantomData,
753 }
754 }
755}
756
757impl Context<ReadyForFilenameTemplate> {
758 /// Checks if the front matter variables satisfy preconditions.
759 /// `self.path` is the path to the current document.
760 #[inline]
761 pub(crate) fn assert_precoditions(&self) -> Result<(), NoteError> {
762 let path = &self.path;
763 let lib_cfg = &LIB_CFG.read_recursive();
764
765 // Get front matter scheme if there is any.
766 let fm_all = self.get(TMPL_VAR_FM_ALL);
767 if fm_all.is_none() {
768 return Ok(());
769 }
770 let fm_all = fm_all.unwrap();
771 let fm_scheme = fm_all
772 .get_from_path(TMPL_VAR_FM_SCHEME)
773 .and_then(|v| v.as_str());
774 let scheme_idx = fm_scheme.and_then(|scheme_name| {
775 lib_cfg
776 .scheme
777 .iter()
778 .enumerate()
779 .find_map(|(i, s)| (s.name == scheme_name).then_some(i))
780 });
781 // If not use `current_scheme` from `SETTINGS`
782 let scheme_idx = scheme_idx.unwrap_or_else(|| SETTINGS.read_recursive().current_scheme);
783 let scheme = &lib_cfg.scheme[scheme_idx];
784
785 for (key, conditions) in scheme.tmpl.fm_var.assertions.iter() {
786 if let Some(value) = fm_all.get_from_path(key) {
787 for cond in conditions {
788 match cond {
789 Assertion::IsDefined => {}
790
791 Assertion::IsString => {
792 if !tera_all_leaves(value, &|v| v.is_string()) {
793 return Err(NoteError::FrontMatterFieldIsNotString {
794 field_name: name(scheme, key).to_string(),
795 });
796 }
797 }
798
799 Assertion::IsNotEmptyString => {
800 if !tera_all_leaves(value, &|v| v.is_string() && v.as_str() != Some(""))
801 {
802 return Err(NoteError::FrontMatterFieldIsEmptyString {
803 field_name: name(scheme, key).to_string(),
804 });
805 }
806 }
807
808 Assertion::IsNumber => {
809 if !tera_all_leaves(value, &|v| v.is_number()) {
810 return Err(NoteError::FrontMatterFieldIsNotNumber {
811 field_name: name(scheme, key).to_string(),
812 });
813 }
814 }
815
816 Assertion::IsBool => {
817 if !tera_all_leaves(value, &|v| v.is_bool()) {
818 return Err(NoteError::FrontMatterFieldIsNotBool {
819 field_name: name(scheme, key).to_string(),
820 });
821 }
822 }
823
824 Assertion::IsNotCompound => {
825 if value.is_array() || value.is_map() {
826 return Err(NoteError::FrontMatterFieldIsCompound {
827 field_name: name(scheme, key).to_string(),
828 });
829 }
830 }
831
832 Assertion::IsValidSortTag => {
833 let fm_sort_tag = value.as_str().unwrap_or_default();
834 if !fm_sort_tag.is_empty() {
835 // Check for forbidden characters.
836 let (_, rest, is_sequential) = fm_sort_tag.split_sort_tag(true);
837 if !rest.is_empty() {
838 return Err(NoteError::FrontMatterFieldIsInvalidSortTag {
839 sort_tag: fm_sort_tag.to_owned(),
840 sort_tag_extra_chars: scheme
841 .filename
842 .sort_tag
843 .extra_chars
844 .escape_default()
845 .to_string(),
846 filename_sort_tag_letters_in_succession_max: scheme
847 .filename
848 .sort_tag
849 .letters_in_succession_max,
850 });
851 }
852
853 // Check for duplicate sequential sort-tags.
854 if !is_sequential {
855 // No further checks.
856 return Ok(());
857 }
858 let docpath = path.to_str().unwrap_or_default();
859
860 let (dirpath, filename) =
861 docpath.rsplit_once(['/', '\\']).unwrap_or(("", docpath));
862 let sort_tag = filename.split_sort_tag(false).0;
863 // No further check if filename(path) has no sort-tag
864 // or if sort-tags are identical.
865 if sort_tag.is_empty() || sort_tag == fm_sort_tag {
866 return Ok(());
867 }
868 let dirpath = Path::new(dirpath);
869
870 if let Some(other_file) =
871 dirpath.has_file_with_sort_tag(fm_sort_tag)
872 {
873 return Err(NoteError::FrontMatterFieldIsDuplicateSortTag {
874 sort_tag: fm_sort_tag.to_string(),
875 existing_file: other_file,
876 });
877 }
878 }
879 }
880
881 Assertion::IsTpnoteExtension => {
882 let file_ext = value.as_str().unwrap_or_default();
883
884 if !file_ext.is_empty() && !(*file_ext).is_tpnote_ext() {
885 return Err(NoteError::FrontMatterFieldIsNotTpnoteExtension {
886 extension: file_ext.to_string(),
887 extensions: {
888 use std::fmt::Write;
889 let mut errstr = scheme.filename.extensions.iter().fold(
890 String::new(),
891 |mut output, (k, _v1, _v2)| {
892 let _ = write!(output, "{k}, ");
893 output
894 },
895 );
896 errstr.truncate(errstr.len().saturating_sub(2));
897 errstr
898 },
899 });
900 }
901 }
902
903 Assertion::IsConfiguredScheme => {
904 let fm_scheme = value.as_str().unwrap_or_default();
905 match lib_cfg.scheme_idx(fm_scheme) {
906 Ok(_) => {}
907 Err(LibCfgError::SchemeNotFound {
908 scheme_name,
909 schemes,
910 }) => {
911 return Err(NoteError::SchemeNotFound {
912 scheme_val: scheme_name,
913 scheme_key: key.to_string(),
914 schemes,
915 });
916 }
917 Err(e) => return Err(e.into()),
918 };
919 }
920
921 Assertion::NoOperation => {}
922 } //
923 }
924 //
925 } else if conditions.contains(&Assertion::IsDefined) {
926 return Err(NoteError::FrontMatterFieldMissing {
927 field_name: name(scheme, key).to_string(),
928 });
929 }
930 }
931 Ok(())
932 }
933
934 /// Indicates that this context contains all we need for the content
935 /// template.
936 #[cfg(test)]
937 pub(crate) fn set_state_ready_for_content_template(self) -> Context<ReadyForContentTemplate> {
938 //
939 Context {
940 ct: self.ct,
941 path: self.path,
942 dir_path: self.dir_path,
943 root_path: self.root_path,
944 doc_file_date: self.doc_file_date,
945 _marker: PhantomData,
946 }
947 }
948
949 /// Inserts the following variables into `self`:
950 ///
951 /// * `TMPL_HTML_VAR_VIEWER_DOC_JS` from `viewer_doc_js`
952 /// * `TMPL_VAR_DOC` from `content.header()` and `content.body()`
953 /// * `TMPL_HTML_VAR_EXPORTER_DOC_CSS`
954 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
955 /// * `TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS`
956 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH`
957 /// * `TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE`
958 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH`
959 /// * `TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE`
960 ///
961 pub(crate) fn insert_raw_content_and_css(
962 mut self,
963 content: &impl Content,
964 viewer_doc_js: &str,
965 ) -> Context<ReadyForHtmlTemplate> {
966 //
967 self.ct.insert(TMPL_HTML_VAR_VIEWER_DOC_JS, viewer_doc_js);
968
969 self.insert_raw_text_from_existing_content(content);
970
971 {
972 let lib_cfg = &LIB_CFG.read_recursive();
973
974 // Insert the raw CSS
975 self.ct.insert(
976 TMPL_HTML_VAR_EXPORTER_DOC_CSS,
977 &(lib_cfg.tmpl_html.exporter_doc_css),
978 );
979
980 // Insert the raw CSS
981 #[cfg(feature = "renderer")]
982 self.ct.insert(
983 TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS,
984 &(lib_cfg.tmpl_html.exporter_highlighting_css),
985 );
986 } // Drop `lib_cfg`.
987
988 // Insert the raw CSS
989 #[cfg(not(feature = "renderer"))]
990 self.ct.insert(TMPL_HTML_VAR_EXPORTER_HIGHLIGHTING_CSS, "");
991
992 // Insert the web server path to get the Tp-Note's CSS loaded.
993 self.ct.insert(
994 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH,
995 TMPL_HTML_VAR_VIEWER_DOC_CSS_PATH_VALUE,
996 );
997
998 // Insert the web server path to get the highlighting CSS loaded.
999 self.ct.insert(
1000 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH,
1001 TMPL_HTML_VAR_VIEWER_HIGHLIGHTING_CSS_PATH_VALUE,
1002 );
1003 Context {
1004 ct: self.ct,
1005 path: self.path,
1006 dir_path: self.dir_path,
1007 root_path: self.root_path,
1008 doc_file_date: self.doc_file_date,
1009 _marker: PhantomData,
1010 }
1011 }
1012}
1013
1014/// Auto dereferences for convenient access to `tera::Context`.
1015impl<S: ContextState> Deref for Context<S> {
1016 type Target = tera::Context;
1017
1018 fn deref(&self) -> &Self::Target {
1019 &self.ct
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025
1026 use crate::{config::TMPL_VAR_FM_ALL, error::NoteError};
1027
1028 use std::path::Path;
1029
1030 #[test]
1031 fn test_insert_front_matter() {
1032 use crate::context::Context;
1033 use crate::front_matter::FrontMatter;
1034 use std::path::Path;
1035 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1036 let context = context
1037 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1038
1039 assert_eq!(
1040 &context
1041 .get(TMPL_VAR_FM_ALL)
1042 .unwrap()
1043 .get_from_path("fm_title")
1044 .unwrap()
1045 .to_string(),
1046 "My Stdin."
1047 );
1048 assert_eq!(
1049 &context
1050 .get(TMPL_VAR_FM_ALL)
1051 .unwrap()
1052 .get_from_path("fm_some")
1053 .unwrap()
1054 .to_string(),
1055 "text"
1056 );
1057 }
1058
1059 #[test]
1060 fn test_insert_front_matter2() {
1061 use crate::context::Context;
1062 use crate::front_matter::FrontMatter;
1063 use std::path::Path;
1064 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1065 let context = context
1066 .insert_front_matter(&FrontMatter::try_from("title: My Stdin.\nsome: text").unwrap());
1067 let context = context.set_state_ready_for_content_template();
1068
1069 assert_eq!(
1070 &context
1071 .get(TMPL_VAR_FM_ALL)
1072 .unwrap()
1073 .get_from_path("fm_title")
1074 .unwrap()
1075 .to_string(),
1076 "My Stdin."
1077 );
1078 assert_eq!(
1079 &context
1080 .get(TMPL_VAR_FM_ALL)
1081 .unwrap()
1082 .get_from_path("fm_some")
1083 .unwrap()
1084 .to_string(),
1085 "text"
1086 );
1087 }
1088
1089 #[test]
1090 fn test_insert_front_matter_and_raw_text_from_existing_content() {
1091 use crate::content::Content;
1092 use crate::content::ContentString;
1093 use crate::context::Context;
1094 use crate::settings::set_test_default_settings;
1095 use std::path::Path;
1096 set_test_default_settings().unwrap();
1097 let context = Context::from(Path::new("/path/to/mynote.md")).unwrap();
1098 let c1 = ContentString::from_string(
1099 String::from("Data from clipboard."),
1100 "txt_clipboard".to_string(),
1101 );
1102 let c2 = ContentString::from_string(
1103 "---\ntitle: My Stdin.\n---\nbody".to_string(),
1104 "stdin".to_string(),
1105 );
1106 let c = vec![&c1, &c2];
1107 let context = context
1108 .insert_front_matter_and_raw_text_from_existing_content(&c)
1109 .unwrap();
1110 assert_eq!(
1111 &context
1112 .get("txt_clipboard")
1113 .unwrap()
1114 .get_from_path("body")
1115 .unwrap()
1116 .to_string(),
1117 "Data from clipboard."
1118 );
1119 assert_eq!(
1120 &context
1121 .get("stdin")
1122 .unwrap()
1123 .get_from_path("body")
1124 .unwrap()
1125 .to_string(),
1126 "body"
1127 );
1128 assert_eq!(
1129 &context
1130 .get("stdin")
1131 .unwrap()
1132 .get_from_path("header")
1133 .unwrap()
1134 .to_string(),
1135 "title: My Stdin."
1136 );
1137 // "fm_title" is dynamically generated from the header variable "title".
1138 assert_eq!(
1139 &context
1140 .get("fm")
1141 .unwrap()
1142 .get_from_path("fm_title")
1143 .unwrap()
1144 .to_string(),
1145 "My Stdin."
1146 );
1147 }
1148
1149 #[test]
1150 fn test_assert_preconditions() {
1151 // Check `tmpl.filter.assert_preconditions` in
1152 // `tpnote_lib/src/config_default.toml` to understand these tests.
1153 use crate::context::Context;
1154 use crate::front_matter::FrontMatter;
1155 use serde_json::json;
1156 //
1157 // Is empty.
1158 let input = "";
1159 let fm = FrontMatter::try_from(input).unwrap();
1160 let cx = Context::from(Path::new("does not matter")).unwrap();
1161 let cx = cx.insert_front_matter(&fm);
1162
1163 assert!(matches!(
1164 cx.assert_precoditions().unwrap_err(),
1165 NoteError::FrontMatterFieldMissing { .. }
1166 ));
1167
1168 //
1169 // Ok as long as no other file with that sort-tag exists.
1170 let input = "# document start
1171 title: The book
1172 sort_tag: 123b";
1173 let fm = FrontMatter::try_from(input).unwrap();
1174 let cx = Context::from(Path::new("./03b-test.md")).unwrap();
1175 let cx = cx.insert_front_matter(&fm);
1176
1177 assert!(matches!(cx.assert_precoditions(), Ok(())));
1178
1179 //
1180 // Should not be a compound type.
1181 let input = "# document start
1182 title: The book
1183 sort_tag:
1184 - 1234
1185 - 456";
1186 let fm = FrontMatter::try_from(input).unwrap();
1187 let cx = Context::from(Path::new("does not matter")).unwrap();
1188 let cx = cx.insert_front_matter(&fm);
1189
1190 assert!(matches!(
1191 cx.assert_precoditions().unwrap_err(),
1192 NoteError::FrontMatterFieldIsCompound { .. }
1193 ));
1194
1195 //
1196 // Should not be a compound type.
1197 let input = "# document start
1198 title: The book
1199 sort_tag:
1200 first: 1234
1201 second: 456";
1202 let fm = FrontMatter::try_from(input).unwrap();
1203 let cx = Context::from(Path::new("does not matter")).unwrap();
1204 let cx = cx.insert_front_matter(&fm);
1205
1206 assert!(matches!(
1207 cx.assert_precoditions().unwrap_err(),
1208 NoteError::FrontMatterFieldIsCompound { .. }
1209 ));
1210
1211 //
1212 // Not registered file extension.
1213 let input = "# document start
1214 title: The book
1215 file_ext: xyz";
1216 let fm = FrontMatter::try_from(input).unwrap();
1217 let cx = Context::from(Path::new("does not matter")).unwrap();
1218 let cx = cx.insert_front_matter(&fm);
1219
1220 assert!(matches!(
1221 cx.assert_precoditions().unwrap_err(),
1222 NoteError::FrontMatterFieldIsNotTpnoteExtension { .. }
1223 ));
1224
1225 //
1226 // Check `bool`
1227 let input = "# document start
1228 title: The book
1229 filename_sync: error, here should be a bool";
1230 let fm = FrontMatter::try_from(input).unwrap();
1231 let cx = Context::from(Path::new("does not matter")).unwrap();
1232 let cx = cx.insert_front_matter(&fm);
1233
1234 assert!(matches!(
1235 cx.assert_precoditions().unwrap_err(),
1236 NoteError::FrontMatterFieldIsNotBool { .. }
1237 ));
1238
1239 //
1240 let input = "# document start
1241 title: my title
1242 subtitle: my subtitle
1243 ";
1244 let expected = json!({"fm_title": "my title", "fm_subtitle": "my subtitle"});
1245
1246 let fm = FrontMatter::try_from(input).unwrap();
1247 let cx = Context::from(Path::new("does not matter")).unwrap();
1248 let cx = cx.insert_front_matter(&fm);
1249 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1250 assert_eq!(fm_all_sj, expected);
1251
1252 //
1253 let input = "# document start
1254 title: my title
1255 file_ext: ''
1256 ";
1257 let expected = json!({"fm_title": "my title", "fm_file_ext": ""});
1258
1259 let fm = FrontMatter::try_from(input).unwrap();
1260 let cx = Context::from(Path::new("does not matter")).unwrap();
1261 let cx = cx.insert_front_matter(&fm);
1262 let fm_all_sj = serde_json::to_value(cx.get(TMPL_VAR_FM_ALL).unwrap()).unwrap();
1263 assert_eq!(fm_all_sj, expected);
1264
1265 //
1266 let input = "# document start
1267 title: ''
1268 subtitle: my subtitle
1269 ";
1270 let fm = FrontMatter::try_from(input).unwrap();
1271 let cx = Context::from(Path::new("does not matter")).unwrap();
1272 let cx = cx.insert_front_matter(&fm);
1273
1274 assert!(matches!(
1275 cx.assert_precoditions().unwrap_err(),
1276 NoteError::FrontMatterFieldIsEmptyString { .. }
1277 ));
1278
1279 //
1280 let input = "# document start
1281 title: My doc
1282 author:
1283 - First author
1284 - Second author
1285 ";
1286 let fm = FrontMatter::try_from(input).unwrap();
1287 let cx = Context::from(Path::new("does not matter")).unwrap();
1288 let cx = cx.insert_front_matter(&fm);
1289
1290 assert!(cx.assert_precoditions().is_ok());
1291
1292 //
1293 let input = "# document start
1294 title: My doc
1295 subtitle: my subtitle
1296 author:
1297 - First title
1298 - 1234
1299 ";
1300 let fm = FrontMatter::try_from(input).unwrap();
1301 let cx = Context::from(Path::new("does not matter")).unwrap();
1302 let cx = cx.insert_front_matter(&fm);
1303
1304 assert!(matches!(
1305 cx.assert_precoditions().unwrap_err(),
1306 NoteError::FrontMatterFieldIsNotString { .. }
1307 ));
1308 }
1309}