Skip to main content

mbr_core/
templates.rs

1use std::{
2    collections::HashMap,
3    path::{Path, PathBuf},
4    sync::Arc,
5};
6
7use crate::contact::humanize_date;
8use crate::errors::TemplateError;
9use crate::markdown::SimpleMetadata;
10use itertools::Itertools;
11use parking_lot::RwLock;
12use tera::{Context, Tera};
13
14#[derive(Clone)]
15pub struct Templates {
16    tera: Arc<RwLock<Tera>>,
17    /// Path used for template loading (for hot reload)
18    template_path: PathBuf,
19}
20
21/// Returns true if `dir` contains at least one `.html` file at any depth.
22///
23/// Only called when zero templates loaded, so it costs nothing on the happy
24/// path.
25fn contains_html_file(dir: &Path) -> bool {
26    walkdir::WalkDir::new(dir)
27        .into_iter()
28        .filter_map(Result::ok)
29        .any(|entry| is_html_file(&entry))
30}
31
32/// Returns true when `entry` is a regular file with an `.html` extension.
33fn is_html_file(entry: &walkdir::DirEntry) -> bool {
34    entry.file_type().is_file()
35        && entry
36            .path()
37            .extension()
38            .is_some_and(|ext| ext.eq_ignore_ascii_case("html"))
39}
40
41/// Escapes a serialized JSON payload so it can be embedded verbatim inside an
42/// inline `<script>` block.
43///
44/// `serde_json` leaves `<`, `>` and `&` untouched, so a value containing
45/// `</script>` terminates the surrounding block: the rest of the script is
46/// re-parsed as markup and every `window.*` assignment in it silently
47/// disappears. A benign frontmatter title such as
48/// `Avoiding </script> in templates` is enough to trigger it.
49///
50/// `<` / `>` / `&` are ordinary JSON string escapes, so the
51/// decoded value is byte-for-byte unchanged — this only makes the payload
52/// inert as markup. The replacements are order-independent because none of
53/// the inserted escapes contains `<`, `>` or `&`.
54pub fn escape_json_for_script(json: &str) -> String {
55    json.replace('<', "\\u003c")
56        .replace('>', "\\u003e")
57        .replace('&', "\\u0026")
58}
59
60/// Serializes `value` as JSON escaped for an inline `<script>` block.
61///
62/// Serialization of a `serde_json::Value` cannot fail; the `null` fallback
63/// exists only so the emitted JavaScript stays parseable no matter what.
64fn json_for_script(value: &serde_json::Value) -> String {
65    escape_json_for_script(&serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()))
66}
67
68/// Serializes a `prev_page`/`next_page` context entry as a script-safe JSON
69/// object, or the literal `null` when the page has no such sibling.
70///
71/// The `{url, title}` shape is fixed here rather than passed through so the
72/// exposed `window.extendedMeta.prevPage` shape stays exactly what the
73/// frontend expects, including the empty-string (never `null`) `url` the
74/// hand-built JS literal used to produce.
75fn nav_page_json(page: Option<&serde_json::Value>) -> String {
76    match page {
77        Some(page) => json_for_script(&serde_json::json!({
78            "url": page.get("url").and_then(|v| v.as_str()).unwrap_or_default(),
79            "title": page.get("title").and_then(|v| v.as_str()).unwrap_or_default(),
80        })),
81        None => "null".to_string(),
82    }
83}
84
85/// Reads every `.html` file under `dir` as a `(template_name, contents)` pair,
86/// sorted by name so load order (and therefore warning order) is deterministic.
87///
88/// Names mirror `Tera::load_from_glob`: the path relative to `dir`, with `\`
89/// unified to `/`. Unreadable files are warned about and skipped.
90fn collect_user_templates(dir: &Path) -> Vec<(String, String)> {
91    let mut templates: Vec<(String, String)> = walkdir::WalkDir::new(dir)
92        .into_iter()
93        .filter_map(Result::ok)
94        .filter(is_html_file)
95        .filter_map(|entry| {
96            let name = entry
97                .path()
98                .strip_prefix(dir)
99                .ok()?
100                .to_string_lossy()
101                .replace('\\', "/");
102            let contents = std::fs::read_to_string(entry.path())
103                .inspect_err(|e| {
104                    tracing::warn!(
105                        "Skipping unreadable user template {}: {}",
106                        entry.path().display(),
107                        e
108                    )
109                })
110                .ok()?;
111            Some((name, contents))
112        })
113        .collect();
114    templates.sort_by(|(a, _), (b, _)| a.cmp(b));
115    templates
116}
117
118/// Loads user templates one file at a time, skipping only the ones that fail.
119///
120/// Cold path: only reached when the whole-glob load already failed, so the
121/// per-candidate `Tera` clone below costs nothing on a healthy repository.
122///
123/// Files are retried in passes because `add_raw_template` also validates
124/// `{% extends %}` chains — a child added before its parent fails the first
125/// time and succeeds once the parent is present. A failed `add_raw_template`
126/// leaves the offending template inserted (tera 1.20), which would poison every
127/// later add, so each attempt runs against a clone that is committed only on
128/// success. Templates skipped here fall back to the compiled-in defaults.
129fn load_tera_per_file(template_path: &Path) -> Tera {
130    let mut tera = Tera::default();
131    let mut pending = collect_user_templates(template_path);
132
133    while !pending.is_empty() {
134        let attempted = pending.len();
135        let mut failed: Vec<(String, String, tera::Error)> = Vec::new();
136
137        for (name, contents) in std::mem::take(&mut pending) {
138            let mut candidate = tera.clone();
139            match candidate.add_raw_template(&name, &contents) {
140                Ok(()) => tera = candidate,
141                Err(e) => failed.push((name, contents, e)),
142            }
143        }
144
145        // No progress this pass means the remainder is genuinely broken rather
146        // than merely out of dependency order.
147        if failed.len() == attempted {
148            for (name, _, e) in failed {
149                tracing::warn!(
150                    "Skipping malformed user template {}: {}. Using the built-in default instead.",
151                    name,
152                    e
153                );
154            }
155            break;
156        }
157
158        pending = failed.into_iter().map(|(n, c, _)| (n, c)).collect();
159    }
160
161    tera
162}
163
164impl Templates {
165    /// Creates a new Templates instance.
166    ///
167    /// Template loading priority:
168    /// 1. If `template_folder` is provided, load from `{template_folder}/**/*.html`
169    /// 2. Otherwise, load from `{root_path}/.mbr/**/*.html`
170    /// 3. Fall back to compiled defaults for any missing templates
171    pub fn new(root_path: &Path, template_folder: Option<&Path>) -> Result<Self, TemplateError> {
172        let template_path = if let Some(tf) = template_folder {
173            tf.to_path_buf()
174        } else {
175            root_path.join(".mbr")
176        };
177
178        let tera = Self::load_tera(&template_path)?;
179
180        Ok(Templates {
181            tera: Arc::new(RwLock::new(tera)),
182            template_path,
183        })
184    }
185
186    /// Load Tera templates from the given path, with fallback to compiled defaults.
187    fn load_tera(template_path: &Path) -> Result<Tera, TemplateError> {
188        let source_desc = format!("{}", template_path.display());
189        let template_path_str = template_path
190            .to_str()
191            .ok_or(TemplateError::InvalidPathEncoding)?;
192
193        // Build the glob as a string with `/` separators rather than via
194        // `Path::join`. On Windows the joined form is
195        // `C:\notes\.mbr\**\*.html`, and globwalk (which Tera uses) treats `\`
196        // as an escape character, not a separator — so the pattern matches
197        // zero files and every user template override is silently ignored.
198        // Forward slashes work on all platforms. The rewrite is gated to
199        // Windows because `\` is a legal filename character on Unix.
200        #[cfg(windows)]
201        let glob_base = template_path_str.replace('\\', "/");
202        #[cfg(not(windows))]
203        let glob_base = template_path_str.to_string();
204        let globs = format!("{}/**/*.html", glob_base.trim_end_matches('/'));
205
206        // `Tera::new` parses the whole glob as one unit and returns `Err` if
207        // *any* file is malformed, so an unclosed `{% if %}` in a brand new
208        // `.mbr/index.html` used to discard a perfectly good `.mbr/_nav.html`
209        // too. Keep the glob as the fast path and degrade to per-file loading,
210        // which skips only the files that actually fail.
211        let mut tera = Tera::new(&globs).unwrap_or_else(|e| {
212            tracing::warn!(
213                "Failed to load user templates from {} as a set: {}. Retrying per file so one \
214                 malformed template does not discard the others.",
215                source_desc,
216                e
217            );
218            load_tera_per_file(template_path)
219        });
220
221        // `Tera::new` *succeeds* with an empty template set when the glob
222        // matches nothing, so a malformed pattern produces no error at all —
223        // which is exactly what hid the Windows separator bug above. Only walk
224        // the folder in the already-degenerate zero-template case, and only
225        // complain when there really are `.html` files being missed (a `.mbr/`
226        // containing just `config.toml` is perfectly normal).
227        if tera.get_template_names().next().is_none() && contains_html_file(template_path) {
228            tracing::warn!(
229                "No templates matched glob {} even though {} contains .html files. \
230                 Using built-in defaults; user template overrides will be ignored.",
231                globs,
232                source_desc
233            );
234        }
235
236        // Custom filters. `humandate` humanizes ISO-ish date strings (see
237        // `humanize_date`); registered here so it survives `reload()` and the
238        // `Tera::default()` fallback above.
239        tera.register_filter("humandate", humandate_filter);
240        // `icon(name=…, size=…)`: the shared icon set (`icons.rs`), so a
241        // template draws the same artwork as the components.
242        tera.register_function("icon", crate::icons::icon_function);
243
244        for (name, tpl) in DEFAULT_TEMPLATES.iter() {
245            if tera.get_template(name).is_err() {
246                tracing::debug!("Adding default template {}", name);
247                tera.add_raw_template(name, tpl)
248                    .map_err(|e| TemplateError::RenderFailed {
249                        template_name: name.to_string(),
250                        source: e,
251                    })?;
252            }
253        }
254
255        Ok(tera)
256    }
257
258    /// Reload all templates from disk. Call this when template files change.
259    pub fn reload(&self) -> Result<(), TemplateError> {
260        tracing::info!("Reloading templates from {:?}", self.template_path);
261        let new_tera = Self::load_tera(&self.template_path)?;
262        *self.tera.write() = new_tera;
263        tracing::debug!("Templates reloaded successfully");
264        Ok(())
265    }
266
267    /// Returns a clone of the Tera engine for lock-free rendering.
268    ///
269    /// Acquires the read lock once to clone the Tera instance (~KB of template AST).
270    /// Use this before entering a rayon thread pool to avoid per-file lock contention.
271    pub fn tera_clone(&self) -> Tera {
272        self.tera.read().clone()
273    }
274
275    pub fn render_markdown(
276        &self,
277        html: &str,
278        frontmatter: SimpleMetadata,
279        extra_context: HashMap<String, serde_json::Value>,
280    ) -> Result<String, TemplateError> {
281        let tera = self.tera.read();
282        Self::render_markdown_with_tera(&tera, html, frontmatter, extra_context)
283    }
284
285    /// Lock-free variant of `render_markdown` that takes a `&Tera` directly.
286    ///
287    /// Use with `tera_clone()` to avoid `Arc<RwLock<Tera>>` contention when
288    /// rendering many files in parallel (e.g., from a rayon thread pool).
289    pub fn render_markdown_with_tera(
290        tera: &Tera,
291        html: &str,
292        frontmatter: SimpleMetadata,
293        extra_context: HashMap<String, serde_json::Value>,
294    ) -> Result<String, TemplateError> {
295        tracing::debug!("frontmatter: {:?}", &frontmatter);
296
297        // Create JSON from frontmatter BEFORE adding markdown to context
298        // This avoids including the large markdown HTML in the frontmatter JSON.
299        // Escaped for `<script>` context: see `escape_json_for_script`.
300        let frontmatter_json = escape_json_for_script(
301            &serde_json::to_string(&frontmatter).unwrap_or_else(|_| "{}".to_string()),
302        );
303
304        // The head template used to hand-build these as JavaScript object and
305        // string literals, which broke on any `"`/`\` in a heading, a file path
306        // or a sibling URL — all of which are legal there. Serializing
307        // server-side keeps the emitted script parseable whatever the input.
308        let headings_json = extra_context
309            .get("headings")
310            .map(json_for_script)
311            .unwrap_or_else(|| "[]".to_string());
312        let file_path_json = extra_context
313            .get("file_path")
314            .map(json_for_script)
315            .unwrap_or_else(|| "\"\"".to_string());
316        let prev_page_json = nav_page_json(extra_context.get("prev_page"));
317        let next_page_json = nav_page_json(extra_context.get("next_page"));
318
319        let mut context = Context::new();
320        frontmatter.iter().for_each(|(k, v)| {
321            // `style` is skipped here and inserted below from `body_class_list`,
322            // which also folds in `type` — so it has to be inserted even when the
323            // frontmatter carries no `style` key at all.
324            if k != "style" {
325                context.insert(k, v);
326            }
327        });
328        let body_class = body_class_list(&frontmatter);
329        // Left undefined when there is nothing to say, so `{% if style %}` in a
330        // custom template still distinguishes "no classes" from an empty string.
331        if !body_class.is_empty() {
332            context.insert("style", &body_class);
333        }
334        // Add extra context (breadcrumbs, current_dir_name, etc.)
335        extra_context.iter().for_each(|(k, v)| {
336            context.insert(k, v);
337        });
338        context.insert("markdown", html);
339        context.insert("frontmatter_json", &frontmatter_json);
340        context.insert("headings_json", &headings_json);
341        context.insert("file_path_json", &file_path_json);
342        context.insert("prev_page_json", &prev_page_json);
343        context.insert("next_page_json", &next_page_json);
344
345        let html_output =
346            tera.render("index.html", &context)
347                .map_err(|e| TemplateError::RenderFailed {
348                    template_name: "index.html".to_string(),
349                    source: e,
350                })?;
351        Ok(html_output)
352    }
353
354    /// Lock-free generic template render that takes a `&Tera` directly.
355    ///
356    /// Use with `tera_clone()` to avoid `Arc<RwLock<Tera>>` contention when
357    /// rendering many pages in parallel (e.g., from a rayon thread pool).
358    pub fn render_template_with_tera(
359        tera: &Tera,
360        template_name: &str,
361        context_data: HashMap<String, serde_json::Value>,
362    ) -> Result<String, TemplateError> {
363        let mut context = Context::new();
364        context_data.iter().for_each(|(k, v)| {
365            context.insert(k, v);
366        });
367        tera.render(template_name, &context)
368            .map_err(|e| TemplateError::RenderFailed {
369                template_name: template_name.to_string(),
370                source: e,
371            })
372    }
373
374    pub fn render_section(
375        &self,
376        context_data: HashMap<String, serde_json::Value>,
377    ) -> Result<String, TemplateError> {
378        let tera = self.tera.read();
379        Self::render_template_with_tera(&tera, "section.html", context_data)
380    }
381
382    /// Renders the home page (root directory) using home.html template.
383    /// This allows users to customize their home page differently from section pages.
384    pub fn render_home(
385        &self,
386        context_data: HashMap<String, serde_json::Value>,
387    ) -> Result<String, TemplateError> {
388        let tera = self.tera.read();
389        Self::render_template_with_tera(&tera, "home.html", context_data)
390    }
391
392    /// Renders an error page using error.html template.
393    ///
394    /// Context variables:
395    /// - `error_code`: HTTP status code (e.g., 404, 500)
396    /// - `error_title`: Short error title (e.g., "Not Found")
397    /// - `error_message`: Optional detailed message
398    /// - `requested_url`: The URL that was requested (useful in GUI mode without URL bar)
399    /// - `server_mode`: Boolean indicating server vs static mode
400    /// - `relative_base`: Path prefix to .mbr assets (e.g., ".mbr/", "../.mbr/")
401    /// - `relative_root`: Path prefix to root (e.g., "", "../")
402    pub fn render_error(
403        &self,
404        context_data: HashMap<String, serde_json::Value>,
405    ) -> Result<String, TemplateError> {
406        let tera = self.tera.read();
407        Self::render_template_with_tera(&tera, "error.html", context_data)
408    }
409
410    /// Renders a tag page showing all pages with a specific tag.
411    ///
412    /// Context variables:
413    /// - `tag_source`: URL identifier for the tag source (e.g., "tags", "performers")
414    /// - `tag_display_value`: Original display value of the tag (e.g., "Rust", "Joshua Jay")
415    /// - `tag_label`: Singular label for the tag source (e.g., "Tag", "Performer")
416    /// - `tag_label_plural`: Plural label for the tag source (e.g., "Tags", "Performers")
417    /// - `pages`: Array of page objects with url_path, title, description
418    /// - `page_count`: Number of pages with this tag
419    /// - `server_mode`: Boolean indicating server vs static mode
420    /// - `relative_base`: Path prefix to .mbr assets
421    pub fn render_tag(
422        &self,
423        context_data: HashMap<String, serde_json::Value>,
424    ) -> Result<String, TemplateError> {
425        let tera = self.tera.read();
426        Self::render_template_with_tera(&tera, "tag.html", context_data)
427    }
428
429    /// Renders a tag source index showing all tags from a source.
430    ///
431    /// Context variables:
432    /// - `tag_source`: URL identifier for the tag source (e.g., "tags", "performers")
433    /// - `tag_label`: Singular label for the tag source (e.g., "Tag", "Performer")
434    /// - `tag_label_plural`: Plural label for the tag source (e.g., "Tags", "Performers")
435    /// - `tags`: Array of tag objects with url_value, display_value, page_count
436    /// - `tag_count`: Total number of unique tags
437    /// - `server_mode`: Boolean indicating server vs static mode
438    /// - `relative_base`: Path prefix to .mbr assets
439    pub fn render_tag_index(
440        &self,
441        context_data: HashMap<String, serde_json::Value>,
442    ) -> Result<String, TemplateError> {
443        let tera = self.tera.read();
444        Self::render_template_with_tera(&tera, "tag_index.html", context_data)
445    }
446
447    /// Renders a media viewer page for video, PDF, or audio content.
448    ///
449    /// Context variables:
450    /// - `media_type`: Type of media ("video", "pdf", "audio")
451    /// - `title`: Page title (defaults to filename)
452    /// - `media_path`: Path to the media file
453    /// - `breadcrumbs`: Navigation breadcrumbs
454    /// - `parent_path`: URL to parent directory for back navigation
455    /// - `server_mode`: Boolean indicating server vs static mode
456    /// - `relative_base`: Path prefix to .mbr assets
457    /// - `sidebar_style`: Sidebar navigation style
458    /// - `sidebar_max_items`: Maximum items per section in sidebar
459    pub fn render_media_viewer(
460        &self,
461        context_data: HashMap<String, serde_json::Value>,
462    ) -> Result<String, TemplateError> {
463        let tera = self.tera.read();
464        Self::render_template_with_tera(&tera, "media_viewer.html", context_data)
465    }
466}
467
468/// Tera `humandate` filter: humanize a date, passing through anything else.
469///
470/// Strings go through [`humanize_date`], which understands every partial form
471/// the contact schema allows (`YYYY-MM-DD`, `YYYY-MM`, `YYYY`, `MM-DD`,
472/// `--MM-DD`) and returns unparseable text unchanged. Non-string values
473/// (numbers, bools, null, arrays, objects) are returned as-is so the filter
474/// never errors — a YAML `born: 1898` arrives as a number and reads fine.
475fn humandate_filter(
476    value: &serde_json::Value,
477    _args: &HashMap<String, serde_json::Value>,
478) -> tera::Result<serde_json::Value> {
479    match value {
480        serde_json::Value::String(s) => Ok(serde_json::Value::String(humanize_date(s))),
481        other => Ok(other.clone()),
482    }
483}
484
485/// Builds the `<body>` class list from the `type` and `style` frontmatter keys.
486///
487/// The OKF `type:` field is folded into `style` rather than surfaced as a new
488/// `body_class` variable so repositories that already ship a customized
489/// `.mbr/index.html` get the feature without touching their template: the class
490/// attribute they render today simply gains the type. The `type` *context*
491/// variable is deliberately left exactly as authored — templates gate on its
492/// value (`{% if type and type == "person" %}` in `_display_enhancements.html`
493/// and `_person_infobox.html`), so handing them a slugified copy would silently
494/// stop matching.
495///
496/// Entries are deduped first-seen, because `type: slides` with `style: slides`
497/// is the natural way to write a page that is both and must not emit the class
498/// twice. Type leads, styles follow: class order means nothing to CSS, but a
499/// fixed one keeps rendered pages byte-stable across runs.
500fn body_class_list(frontmatter: &SimpleMetadata) -> String {
501    let type_class = frontmatter
502        .get("type")
503        .and_then(serde_json::Value::as_str)
504        .map(|raw| collapse_dashes(&crate::markdown::slugify(raw)))
505        .filter(|slug| !slug.is_empty());
506
507    type_class
508        .into_iter()
509        .chain(frontmatter.get("style").into_iter().flat_map(style_classes))
510        .unique()
511        .join(" ")
512}
513
514/// Expands a `style` frontmatter value into individual class names.
515///
516/// Strings are split on whitespace rather than passed through whole so that
517/// `style: "a b"` and `style: [a, b]` reach the deduper as the same two
518/// classes. Non-string array elements are ignored; any other value keeps the
519/// historical `to_string()` fallback.
520fn style_classes(value: &serde_json::Value) -> Vec<String> {
521    match value {
522        serde_json::Value::String(s) => split_classes(s),
523        serde_json::Value::Array(arr) => arr
524            .iter()
525            .filter_map(serde_json::Value::as_str)
526            .flat_map(split_classes)
527            .collect(),
528        other => vec![other.to_string()],
529    }
530}
531
532fn split_classes(s: &str) -> Vec<String> {
533    s.split_whitespace().map(str::to_string).collect()
534}
535
536/// Squeezes runs of `-` out of a slug and trims them from both ends.
537///
538/// Applied to the type class only, never to `slugify` itself: `slugify` is
539/// shared with heading anchors, whose doubled dashes are frozen because they
540/// are the live `#anchor` targets of links already written in existing
541/// repositories. A body class carries no such history and is free to be the
542/// obvious thing, so `type: "Book Review (2024)"` yields `book-review-2024` —
543/// what anyone hand-writing the matching CSS selector would guess — rather than
544/// the anchor spelling `book-review--2024`.
545fn collapse_dashes(slug: &str) -> String {
546    slug.split('-').filter(|part| !part.is_empty()).join("-")
547}
548
549const DEFAULT_TEMPLATES: &[(&str, &str)] = &[
550    // Partials (underscore prefix indicates internal-only templates)
551    ("_head.html", include_str!("../templates/_head.html")),
552    (
553        "_head_custom.html",
554        include_str!("../templates/_head_custom.html"),
555    ),
556    (
557        "_head_markdown.html",
558        include_str!("../templates/_head_markdown.html"),
559    ),
560    ("_nav.html", include_str!("../templates/_nav.html")),
561    (
562        "_breadcrumbs.html",
563        include_str!("../templates/_breadcrumbs.html"),
564    ),
565    ("_footer.html", include_str!("../templates/_footer.html")),
566    (
567        "_footer_custom.html",
568        include_str!("../templates/_footer_custom.html"),
569    ),
570    ("_scripts.html", include_str!("../templates/_scripts.html")),
571    (
572        "_display_enhancements.html",
573        include_str!("../templates/_display_enhancements.html"),
574    ),
575    (
576        "_contact_card.html",
577        include_str!("../templates/_contact_card.html"),
578    ),
579    // Compat shim for customized `index.html` files written before the card.
580    (
581        "_person_infobox.html",
582        include_str!("../templates/_person_infobox.html"),
583    ),
584    // Main templates
585    ("index.html", include_str!("../templates/index.html")),
586    ("section.html", include_str!("../templates/section.html")),
587    ("home.html", include_str!("../templates/home.html")),
588    ("error.html", include_str!("../templates/error.html")),
589    // Tag templates
590    ("tag.html", include_str!("../templates/tag.html")),
591    (
592        "tag_index.html",
593        include_str!("../templates/tag_index.html"),
594    ),
595    // Media viewer template
596    (
597        "media_viewer.html",
598        include_str!("../templates/media_viewer.html"),
599    ),
600];
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605    use serde_json::json;
606
607    /// User templates in `.mbr/` must actually be picked up by the glob.
608    ///
609    /// This is the regression guard for the Windows separator bug: building the
610    /// pattern with `Path::join` yields `...\.mbr\**\*.html`, globwalk treats
611    /// `\` as an escape rather than a separator, and `Tera::new` then *succeeds*
612    /// with zero user templates — silently ignoring every override.
613    #[test]
614    fn test_user_templates_are_loaded_from_mbr_folder() {
615        let tmp = tempfile::tempdir().unwrap();
616        let mbr_dir = tmp.path().join(".mbr");
617        std::fs::create_dir_all(mbr_dir.join("partials")).unwrap();
618
619        // Names deliberately absent from DEFAULT_TEMPLATES, so finding them
620        // proves they came from disk rather than the compiled-in fallbacks.
621        std::fs::write(mbr_dir.join("zz_custom_page.html"), "<p>custom</p>").unwrap();
622        std::fs::write(
623            mbr_dir.join("partials").join("zz_nested_page.html"),
624            "<p>nested</p>",
625        )
626        .unwrap();
627
628        let templates = Templates::new(tmp.path(), None).expect("templates should build");
629        let tera = templates.tera.read();
630        let names: Vec<String> = tera.get_template_names().map(String::from).collect();
631
632        assert!(
633            names.iter().any(|n| n == "zz_custom_page.html"),
634            "top-level user template should be loaded, got: {names:?}"
635        );
636        // Asserted by suffix because Tera derives template names from the
637        // relative path, whose separator is platform dependent.
638        assert!(
639            names.iter().any(|n| n.ends_with("zz_nested_page.html")),
640            "nested user template should be loaded via `**`, got: {names:?}"
641        );
642    }
643
644    /// A `.mbr/` folder holding no HTML at all is normal (e.g. only
645    /// `config.toml`) and must still produce a working template set from the
646    /// compiled-in defaults.
647    #[test]
648    fn test_templates_fall_back_to_defaults_without_user_html() {
649        let tmp = tempfile::tempdir().unwrap();
650        let mbr_dir = tmp.path().join(".mbr");
651        std::fs::create_dir_all(&mbr_dir).unwrap();
652        std::fs::write(mbr_dir.join("config.toml"), "theme = \"amber\"").unwrap();
653
654        let templates = Templates::new(tmp.path(), None).expect("templates should build");
655        let tera = templates.tera.read();
656
657        assert!(
658            tera.get_template_names().any(|n| n == "index.html"),
659            "built-in defaults should still be registered"
660        );
661    }
662
663    // ------------------------------------------------------------------
664    // Body class list (`type` + `style` frontmatter)
665    // ------------------------------------------------------------------
666
667    /// Builds a `SimpleMetadata` from `(key, value)` pairs.
668    fn fm(pairs: &[(&str, serde_json::Value)]) -> SimpleMetadata {
669        pairs
670            .iter()
671            .map(|(k, v)| ((*k).to_string(), v.clone()))
672            .collect()
673    }
674
675    #[test]
676    fn test_body_class_style_string() {
677        assert_eq!(
678            body_class_list(&fm(&[("style", json!("slides"))])),
679            "slides"
680        );
681    }
682
683    #[test]
684    fn test_body_class_style_string_with_spaces() {
685        assert_eq!(
686            body_class_list(&fm(&[("style", json!("slides other"))])),
687            "slides other"
688        );
689    }
690
691    #[test]
692    fn test_body_class_style_array() {
693        assert_eq!(
694            body_class_list(&fm(&[("style", json!(["slides", "other"]))])),
695            "slides other"
696        );
697        assert_eq!(
698            body_class_list(&fm(&[("style", json!(["slides"]))])),
699            "slides"
700        );
701    }
702
703    /// Array elements are themselves split, so the two spellings of the same
704    /// class list collapse to the same result — and to the same dedupe keys.
705    #[test]
706    fn test_body_class_style_array_elements_are_split() {
707        assert_eq!(
708            body_class_list(&fm(&[("style", json!(["slides other", "third"]))])),
709            "slides other third"
710        );
711    }
712
713    /// Non-string array elements are dropped, matching the historical
714    /// `filter_map(as_str)`; other value kinds keep the `to_string()` fallback.
715    #[test]
716    fn test_body_class_style_non_string_values() {
717        assert_eq!(
718            body_class_list(&fm(&[("style", json!(["slides", 42, null]))])),
719            "slides"
720        );
721        assert_eq!(body_class_list(&fm(&[("style", json!(null))])), "null");
722        assert_eq!(body_class_list(&fm(&[("style", json!(42))])), "42");
723    }
724
725    #[test]
726    fn test_body_class_style_array_empty() {
727        assert_eq!(body_class_list(&fm(&[("style", json!([]))])), "");
728    }
729
730    /// The point of the change: `type` alone produces a body class.
731    #[test]
732    fn test_body_class_type_only() {
733        assert_eq!(body_class_list(&fm(&[("type", json!("slides"))])), "slides");
734    }
735
736    #[test]
737    fn test_body_class_type_is_slugified() {
738        assert_eq!(
739            body_class_list(&fm(&[("type", json!("Meeting Notes"))])),
740            "meeting-notes"
741        );
742        assert_eq!(
743            body_class_list(&fm(&[("type", json!("Field Note!"))])),
744            "field-note"
745        );
746    }
747
748    /// Punctuation *between* words makes `slugify` emit a doubled dash, which
749    /// heading anchors are stuck with. The body class is not: `collapse_dashes`
750    /// gives the selector the author would actually write.
751    #[test]
752    fn test_body_class_type_collapses_doubled_dashes() {
753        assert_eq!(
754            crate::markdown::slugify("Book Review (2024)"),
755            "book-review--2024",
756            "guards the premise: the shared slugifier really does double here"
757        );
758        assert_eq!(
759            body_class_list(&fm(&[("type", json!("Book Review (2024)"))])),
760            "book-review-2024"
761        );
762        assert_eq!(
763            body_class_list(&fm(&[("type", json!("Meeting, Notes"))])),
764            "meeting-notes"
765        );
766    }
767
768    /// Leading and trailing dashes are trimmed, and a type that is nothing but
769    /// separators drops out entirely rather than emitting a bare `-`.
770    #[test]
771    fn test_body_class_type_trims_edge_dashes() {
772        assert_eq!(body_class_list(&fm(&[("type", json!("-draft-"))])), "draft");
773        assert_eq!(body_class_list(&fm(&[("type", json!("---"))])), "");
774    }
775
776    /// Type leads, styles follow.
777    #[test]
778    fn test_body_class_type_and_style_combined() {
779        assert_eq!(
780            body_class_list(&fm(&[
781                ("style", json!(["wide", "dark"])),
782                ("type", json!("person")),
783            ])),
784            "person wide dark"
785        );
786    }
787
788    /// Writing a page as both `type: slides` and `style: slides` is natural and
789    /// must not emit the class twice.
790    #[test]
791    fn test_body_class_type_and_style_dedupe() {
792        assert_eq!(
793            body_class_list(&fm(&[
794                ("style", json!("slides")),
795                ("type", json!("slides"))
796            ])),
797            "slides"
798        );
799        assert_eq!(
800            body_class_list(&fm(&[
801                ("style", json!("wide slides wide")),
802                ("type", json!("slides")),
803            ])),
804            "slides wide"
805        );
806    }
807
808    /// A non-string `type` (a list, a number) is not a class name; it is ignored
809    /// rather than stringified, so `type: [a, b]` cannot inject markup-ish junk.
810    #[test]
811    fn test_body_class_non_string_type_ignored() {
812        assert_eq!(
813            body_class_list(&fm(&[("type", json!(["person", "place"]))])),
814            ""
815        );
816        assert_eq!(body_class_list(&fm(&[("type", json!(42))])), "");
817        // A type that slugifies to nothing leaves no empty class behind.
818        assert_eq!(body_class_list(&fm(&[("type", json!("!!!"))])), "");
819        assert_eq!(
820            body_class_list(&fm(&[("type", json!("!!!")), ("style", json!("wide"))])),
821            "wide"
822        );
823    }
824
825    #[test]
826    fn test_body_class_neither_field() {
827        assert_eq!(body_class_list(&fm(&[("title", json!("Hello"))])), "");
828    }
829
830    #[test]
831    fn test_humanize_date_full() {
832        assert_eq!(humanize_date("1855-10-30"), "October 30, 1855");
833        assert_eq!(humanize_date("1902-01-10"), "January 10, 1902");
834    }
835
836    #[test]
837    fn test_humanize_date_full_strips_leading_zero_day() {
838        assert_eq!(humanize_date("1855-10-05"), "October 5, 1855");
839        assert_eq!(humanize_date("2000-12-01"), "December 1, 2000");
840    }
841
842    #[test]
843    fn test_humanize_date_year_month() {
844        assert_eq!(humanize_date("1855-10"), "October 1855");
845        assert_eq!(humanize_date("1902-01"), "January 1902");
846    }
847
848    #[test]
849    fn test_humanize_date_year_only_unchanged() {
850        assert_eq!(humanize_date("1855"), "1855");
851    }
852
853    #[test]
854    fn test_humanize_date_invalid_passthrough() {
855        // Out-of-range month/day, partials, prose, and already-formatted strings
856        // all pass through unchanged.
857        assert_eq!(humanize_date("2020-13-40"), "2020-13-40");
858        assert_eq!(humanize_date("2020-00-10"), "2020-00-10");
859        assert_eq!(humanize_date("2020-02-32"), "2020-02-32");
860        assert_eq!(humanize_date("circa 1855"), "circa 1855");
861        assert_eq!(humanize_date("October 30, 1855"), "October 30, 1855");
862        assert_eq!(humanize_date("1855-1-1"), "1855-1-1"); // not zero-padded
863        assert_eq!(humanize_date("55-10-30"), "55-10-30"); // 2-digit year
864        assert_eq!(humanize_date(""), "");
865    }
866
867    #[test]
868    fn test_humandate_filter_string_and_passthrough() {
869        let args = HashMap::new();
870        assert_eq!(
871            humandate_filter(&json!("1855-10-30"), &args).unwrap(),
872            json!("October 30, 1855")
873        );
874        // Non-string values pass through unchanged.
875        assert_eq!(humandate_filter(&json!(1855), &args).unwrap(), json!(1855));
876        assert_eq!(humandate_filter(&json!(null), &args).unwrap(), json!(null));
877    }
878
879    #[test]
880    fn test_humandate_filter_registered_in_tera() {
881        let mut tera = Tera::default();
882        tera.register_filter("humandate", humandate_filter);
883        tera.add_raw_template("t", "{{ born | humandate }}")
884            .unwrap();
885        let mut ctx = Context::new();
886        ctx.insert("born", "1855-10-30");
887        assert_eq!(tera.render("t", &ctx).unwrap(), "October 30, 1855");
888    }
889
890    // ------------------------------------------------------------------
891    // Script-context escaping (`<script>` payloads in _head_markdown.html)
892    // ------------------------------------------------------------------
893
894    /// Renders a markdown page through the real Tera pipeline with the
895    /// compiled-in default templates.
896    ///
897    /// Only the page "chrome" keys the default templates require unconditionally
898    /// are defaulted in; everything the assertions care about comes from the
899    /// caller.
900    fn render_page(
901        frontmatter: SimpleMetadata,
902        extra_context: HashMap<String, serde_json::Value>,
903    ) -> String {
904        let mut context = HashMap::from([("sidebar_style".to_string(), json!("auto"))]);
905        context.extend(extra_context);
906
907        let tmp = tempfile::tempdir().unwrap();
908        let templates = Templates::new(tmp.path(), None).expect("templates should build");
909        templates
910            .render_markdown("<p>body</p>", frontmatter, context)
911            .expect("page should render")
912    }
913
914    /// Extracts the JSON/JS text assigned to `field` in the inline head
915    /// script, e.g. `js_value(html, "filePath: ")`.
916    ///
917    /// Line-based, which is sound because JSON never contains a raw newline —
918    /// and is itself part of the regression guard: the old hand-built
919    /// `window.headings` literal spanned several lines.
920    fn js_value<'a>(html: &'a str, prefix: &str) -> &'a str {
921        html.lines()
922            .find_map(|line| line.trim().strip_prefix(prefix))
923            .unwrap_or_else(|| panic!("no line starting with {prefix:?} in:\n{html}"))
924            .trim_end()
925            .trim_end_matches([',', ';'])
926    }
927
928    /// Parses a script payload, proving it is syntactically valid data rather
929    /// than something that merely happens to look right.
930    fn parse_js_value(html: &str, prefix: &str) -> serde_json::Value {
931        let raw = js_value(html, prefix);
932        serde_json::from_str(raw)
933            .unwrap_or_else(|e| panic!("{prefix:?} payload is not parseable ({e}): {raw}"))
934    }
935
936    #[test]
937    fn test_escape_json_for_script_neutralizes_markup_characters() {
938        let escaped = escape_json_for_script(r#"{"title":"a </script> & <b>"}"#);
939        assert!(
940            !escaped.contains("</script>"),
941            "script terminator must not survive: {escaped}"
942        );
943        assert!(
944            escaped.contains("\\u003c"),
945            "`<` must be escaped: {escaped}"
946        );
947        assert!(
948            escaped.contains("\\u003e"),
949            "`>` must be escaped: {escaped}"
950        );
951        assert!(
952            escaped.contains("\\u0026"),
953            "`&` must be escaped: {escaped}"
954        );
955    }
956
957    #[test]
958    fn test_escape_json_for_script_preserves_decoded_value() {
959        // `\uXXXX` is an ordinary JSON escape, so the payload is unchanged as
960        // data — only inert as markup.
961        let original = json!({"title": "a </script> & <b> \\ \" done"});
962        let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
963        let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
964        assert_eq!(round_tripped, original);
965    }
966
967    proptest::proptest! {
968        /// Two invariants that together define the helper: the output can never
969        /// carry markup-significant characters, and it always decodes back to
970        /// the exact input value.
971        #[test]
972        fn prop_escape_json_for_script_is_inert_and_lossless(
973            key in "\\PC*",
974            value in "\\PC*",
975        ) {
976            let original = json!({ key: value });
977            let escaped = escape_json_for_script(&serde_json::to_string(&original).unwrap());
978
979            proptest::prop_assert!(
980                !escaped.contains(['<', '>', '&']),
981                "escaped payload must contain no markup characters: {escaped}"
982            );
983            let round_tripped: serde_json::Value = serde_json::from_str(&escaped).unwrap();
984            proptest::prop_assert_eq!(round_tripped, original);
985        }
986    }
987
988    /// A frontmatter value containing `</script>` used to terminate the inline
989    /// script block, silently dropping `window.frontmatter`, `window.headings`
990    /// and `window.extendedMeta`.
991    #[test]
992    fn test_frontmatter_json_cannot_break_out_of_script_block() {
993        let frontmatter = SimpleMetadata::from([
994            (
995                "title".to_string(),
996                json!("Avoiding </script> in templates"),
997            ),
998            ("note".to_string(), json!("<img src=x onerror=alert(1)>")),
999        ]);
1000        let html = render_page(frontmatter, HashMap::new());
1001
1002        let payload = js_value(&html, "window.frontmatter = ");
1003        assert!(
1004            !payload.contains("</script>"),
1005            "frontmatter payload must not contain a raw script terminator: {payload}"
1006        );
1007        assert!(
1008            payload.contains("\\u003c/script\\u003e"),
1009            "expected the escaped terminator in: {payload}"
1010        );
1011        assert!(
1012            !payload.contains("<img"),
1013            "raw markup must not survive into the script: {payload}"
1014        );
1015
1016        // Still valid data with the original values intact.
1017        let parsed = parse_js_value(&html, "window.frontmatter = ");
1018        assert_eq!(parsed["title"], json!("Avoiding </script> in templates"));
1019        assert_eq!(parsed["note"], json!("<img src=x onerror=alert(1)>"));
1020    }
1021
1022    /// `| safe` on `title` let a frontmatter title emit live markup and
1023    /// mangled every benign `&`; the explicit `| escape` on the neighboring
1024    /// `<meta>` double-escaped because Tera autoescape runs after filters.
1025    #[test]
1026    fn test_title_is_html_escaped_exactly_once() {
1027        let html = render_page(
1028            SimpleMetadata::from([("title".to_string(), json!("Tips & Tricks"))]),
1029            HashMap::new(),
1030        );
1031        assert!(
1032            html.contains("<title>Tips &amp; Tricks</title>"),
1033            "title should be escaped exactly once, got:\n{html}"
1034        );
1035        assert!(
1036            !html.contains("&amp;amp;"),
1037            "no value should be double-escaped, got:\n{html}"
1038        );
1039    }
1040
1041    #[test]
1042    fn test_title_markup_is_not_emitted_live() {
1043        let html = render_page(
1044            SimpleMetadata::from([(
1045                "title".to_string(),
1046                json!("</title><img src=x onerror=alert(1)>"),
1047            )]),
1048            HashMap::new(),
1049        );
1050        assert!(
1051            !html.contains("<img src=x"),
1052            "a frontmatter title must never produce live markup, got:\n{html}"
1053        );
1054        assert!(
1055            html.contains("&lt;img src=x onerror=alert(1)&gt;"),
1056            "title should render as escaped text, got:\n{html}"
1057        );
1058    }
1059
1060    /// `"` and `\` are legal in filenames, and the head script used to splice
1061    /// the raw path into a JS string literal — a syntax error that killed the
1062    /// whole block.
1063    #[test]
1064    fn test_file_path_and_sibling_urls_are_json_serialized() {
1065        let file_path = r#"docs/He said "hi" \ again.md"#;
1066        let extra = HashMap::from([
1067            ("file_path".to_string(), json!(file_path)),
1068            (
1069                "prev_page".to_string(),
1070                json!({"url": r#"/a"b\c/"#, "title": r#"A "quoted" & <angled> title"#}),
1071            ),
1072            (
1073                "next_page".to_string(),
1074                json!({"url": "/plain/", "title": "Plain"}),
1075            ),
1076        ]);
1077        let html = render_page(SimpleMetadata::new(), extra);
1078
1079        assert_eq!(parse_js_value(&html, "filePath: "), json!(file_path));
1080
1081        let prev = parse_js_value(&html, "prevPage: ");
1082        assert_eq!(prev["url"], json!(r#"/a"b\c/"#));
1083        assert_eq!(prev["title"], json!(r#"A "quoted" & <angled> title"#));
1084
1085        let next = parse_js_value(&html, "nextPage: ");
1086        assert_eq!(next["url"], json!("/plain/"));
1087        assert_eq!(next["title"], json!("Plain"));
1088    }
1089
1090    /// Headings were emitted as a hand-built multi-line JS literal with
1091    /// `| escape`, so `&` arrived at the frontend as `&amp;` and a `"` in a
1092    /// heading broke the script.
1093    #[test]
1094    fn test_headings_are_json_serialized() {
1095        let extra = HashMap::from([(
1096            "headings".to_string(),
1097            json!([
1098                {"level": 2, "id": "q-and-a", "text": r#"Q & A: "the ' quote" </script>"#},
1099                {"level": 3, "id": "plain", "text": "Plain"},
1100            ]),
1101        )]);
1102        let html = render_page(SimpleMetadata::new(), extra);
1103
1104        let headings = parse_js_value(&html, "window.headings = ");
1105        assert_eq!(headings[0]["level"], json!(2));
1106        assert_eq!(headings[0]["id"], json!("q-and-a"));
1107        assert_eq!(
1108            headings[0]["text"],
1109            json!(r#"Q & A: "the ' quote" </script>"#),
1110            "heading text must round-trip verbatim, not HTML-entity encoded"
1111        );
1112        assert_eq!(headings[1]["text"], json!("Plain"));
1113        assert!(
1114            !js_value(&html, "window.headings = ").contains("</script>"),
1115            "heading text must not terminate the script block"
1116        );
1117    }
1118
1119    /// Callers that supply no page metadata (CLI stdout mode) must still emit
1120    /// the exact `window.*` shape the frontend expects.
1121    #[test]
1122    fn test_script_payload_defaults_without_extra_context() {
1123        let html = render_page(SimpleMetadata::new(), HashMap::new());
1124        assert_eq!(parse_js_value(&html, "window.headings = "), json!([]));
1125        assert_eq!(parse_js_value(&html, "filePath: "), json!(""));
1126        assert!(!html.contains("prevPage:"));
1127        assert!(!html.contains("nextPage:"));
1128        // Numeric fields keep their unquoted-key literal form.
1129        assert!(html.contains("fleschReadingEase: null"));
1130        assert!(html.contains("wordCount: 0"));
1131    }
1132
1133    // ------------------------------------------------------------------
1134    // Per-file user template loading
1135    // ------------------------------------------------------------------
1136
1137    /// `Tera::new` parses the whole glob as one unit, so a single malformed
1138    /// file used to discard *every* `.mbr/` override and silently swap in the
1139    /// built-in defaults.
1140    #[test]
1141    fn test_malformed_user_template_falls_back_to_defaults() {
1142        let tmp = tempfile::tempdir().unwrap();
1143        let mbr_dir = tmp.path().join(".mbr");
1144        std::fs::create_dir_all(&mbr_dir).unwrap();
1145
1146        // A valid override of a built-in partial...
1147        std::fs::write(mbr_dir.join("_nav.html"), "<nav>ZZ_CUSTOM_NAV</nav>").unwrap();
1148        // ...next to a file with an unclosed `{% if %}`.
1149        std::fs::write(
1150            mbr_dir.join("index.html"),
1151            "{% if broken %}<p>ZZ_BROKEN</p>",
1152        )
1153        .unwrap();
1154
1155        let templates = Templates::new(tmp.path(), None)
1156            .expect("a malformed user template must not fail construction");
1157        let html = templates
1158            .render_markdown(
1159                "<p>body</p>",
1160                SimpleMetadata::new(),
1161                HashMap::from([("sidebar_style".to_string(), json!("auto"))]),
1162            )
1163            .expect("index.html should render from the built-in default");
1164
1165        assert!(
1166            html.contains("ZZ_CUSTOM_NAV"),
1167            "the valid override must survive its malformed sibling, got:\n{html}"
1168        );
1169        assert!(
1170            html.contains("<mbr-nav></mbr-nav>"),
1171            "the built-in index.html should be used for the malformed override, got:\n{html}"
1172        );
1173        assert!(
1174            !html.contains("ZZ_BROKEN"),
1175            "the malformed template must not be loaded, got:\n{html}"
1176        );
1177    }
1178
1179    /// A user template that `{% extends %}` another must load regardless of
1180    /// which file the per-file loader reaches first.
1181    #[test]
1182    fn test_per_file_loading_resolves_inheritance_regardless_of_order() {
1183        let tmp = tempfile::tempdir().unwrap();
1184        let dir = tmp.path();
1185        // `child.html` sorts before `zz_base.html`, so it is attempted first.
1186        std::fs::write(dir.join("child.html"), "{% extends \"zz_base.html\" %}").unwrap();
1187        std::fs::write(dir.join("zz_base.html"), "ZZ_BASE_OUTPUT").unwrap();
1188        std::fs::write(dir.join("busted.html"), "{% if never_closed %}oops").unwrap();
1189
1190        let tera = load_tera_per_file(dir);
1191
1192        assert_eq!(
1193            tera.render("child.html", &Context::new()).unwrap(),
1194            "ZZ_BASE_OUTPUT"
1195        );
1196        assert!(
1197            tera.get_template("busted.html").is_err(),
1198            "the malformed template should be skipped"
1199        );
1200    }
1201
1202    // ------------------------------------------------------------------
1203    // Rendered `<body>` class attribute
1204    // ------------------------------------------------------------------
1205
1206    /// Extracts the rendered `<body ...>` tag.
1207    fn body_tag(html: &str) -> &str {
1208        html.lines()
1209            .map(str::trim)
1210            .find(|line| line.starts_with("<body"))
1211            .unwrap_or_else(|| panic!("no <body> tag in:\n{html}"))
1212    }
1213
1214    #[test]
1215    fn test_rendered_body_class_from_type_alone() {
1216        let html = render_page(
1217            SimpleMetadata::from([("type".to_string(), json!("slides"))]),
1218            HashMap::new(),
1219        );
1220        assert_eq!(body_tag(&html), r#"<body class="slides">"#);
1221    }
1222
1223    #[test]
1224    fn test_rendered_body_class_from_type_and_style() {
1225        let html = render_page(
1226            SimpleMetadata::from([
1227                ("type".to_string(), json!("Meeting Notes")),
1228                ("style".to_string(), json!(["wide", "dark"])),
1229            ]),
1230            HashMap::new(),
1231        );
1232        assert_eq!(body_tag(&html), r#"<body class="meeting-notes wide dark">"#);
1233    }
1234
1235    /// Arrays used to render as `[a, b]` and were patched up by a `replace`
1236    /// filter in `index.html`; the builder now hands the template a plain
1237    /// space-separated string, so no bracket may reach the attribute.
1238    #[test]
1239    fn test_rendered_body_class_from_style_array_has_no_brackets() {
1240        let html = render_page(
1241            SimpleMetadata::from([("style".to_string(), json!(["slides", "other"]))]),
1242            HashMap::new(),
1243        );
1244        assert_eq!(body_tag(&html), r#"<body class="slides other">"#);
1245    }
1246
1247    #[test]
1248    fn test_rendered_body_has_no_class_without_type_or_style() {
1249        let html = render_page(SimpleMetadata::new(), HashMap::new());
1250        assert_eq!(body_tag(&html), "<body>");
1251    }
1252
1253    /// The `type` context variable must survive untouched: templates gate on
1254    /// `{% if type and type == "person" %}`, so folding a slug into it (or
1255    /// dropping it in favor of the body class) silently disables the person
1256    /// infobox and the genealogy element.
1257    #[test]
1258    fn test_type_context_variable_is_passed_through_unmodified() {
1259        let html = render_page(
1260            SimpleMetadata::from([
1261                ("type".to_string(), json!("person")),
1262                ("title".to_string(), json!("Ada Lovelace")),
1263                ("born".to_string(), json!("1815-12-10")),
1264            ]),
1265            HashMap::new(),
1266        );
1267
1268        assert!(
1269            html.contains("<mbr-genealogy></mbr-genealogy>"),
1270            "genealogy element should render for `type: person`, got:\n{html}"
1271        );
1272        // …and the same value still reaches the body class.
1273        assert_eq!(body_tag(&html), r#"<body class="person">"#);
1274        assert_eq!(
1275            parse_js_value(&html, "window.frontmatter = ")["type"],
1276            json!("person"),
1277            "frontmatter payload should carry the authored type"
1278        );
1279    }
1280
1281    /// Renders a page from a real markdown file, so the frontmatter goes
1282    /// through the same simplifier the server and the build use.
1283    fn render_parsed(frontmatter: &str) -> String {
1284        let tmp = tempfile::tempdir().unwrap();
1285        let file = tmp.path().join("note.md");
1286        std::fs::write(&file, format!("---\n{frontmatter}---\nBody.\n")).unwrap();
1287        let parsed = crate::markdown::parse(&file).unwrap();
1288        render_page(parsed.frontmatter, HashMap::new())
1289    }
1290
1291    /// Whitespace around `type` must gate every feature the same way: the
1292    /// contact card and the implied company edge already trim, so the
1293    /// templates' exact `type == "person"` has to see the trimmed value too.
1294    #[test]
1295    fn test_type_with_surrounding_whitespace_gates_like_the_trimmed_value() {
1296        let html = render_parsed("type: \" person\"\n");
1297        assert!(html.contains("<mbr-genealogy></mbr-genealogy>"), "{html}");
1298        assert!(
1299            html.contains("components/mbr-genealogy.min.js"),
1300            "prefetch: {html}"
1301        );
1302        assert_eq!(body_tag(&html), r#"<body class="person">"#);
1303        assert_eq!(
1304            parse_js_value(&html, "window.frontmatter = ")["type"],
1305            json!("person")
1306        );
1307
1308        let html = render_parsed("type: \"Meeting Notes \"\n");
1309        assert_eq!(body_tag(&html), r#"<body class="meeting-notes">"#);
1310        assert_eq!(
1311            parse_js_value(&html, "window.frontmatter = ")["type"],
1312            json!("Meeting Notes")
1313        );
1314
1315        let html = render_parsed("type: \"flashcard \"\n");
1316        assert_eq!(body_tag(&html), r#"<body class="flashcard">"#);
1317
1318        let html = render_parsed("type: \"   \"\n");
1319        assert_eq!(body_tag(&html), "<body>");
1320        assert!(
1321            parse_js_value(&html, "window.frontmatter = ")
1322                .get("type")
1323                .is_none()
1324        );
1325    }
1326
1327    // ------------------------------------------------------------------
1328    // Contact card
1329    // ------------------------------------------------------------------
1330
1331    /// Renders a page the way the server does for a contact note: simplified
1332    /// frontmatter plus the `contact` context built from the raw YAML.
1333    fn render_contact(yaml: &str, extra: &[(&str, serde_json::Value)]) -> String {
1334        render_contact_with(None, yaml, extra)
1335    }
1336
1337    fn render_contact_with(
1338        user_index: Option<&str>,
1339        yaml: &str,
1340        extra: &[(&str, serde_json::Value)],
1341    ) -> String {
1342        let doc = yaml_rust2::YamlLoader::load_from_str(yaml)
1343            .unwrap()
1344            .remove(0);
1345        let mut frontmatter = SimpleMetadata::new();
1346        for key in ["type", "title"] {
1347            if let Some(v) = doc[key].as_str() {
1348                frontmatter.insert(key.to_string(), json!(v));
1349            }
1350        }
1351        let mut context: HashMap<String, serde_json::Value> = HashMap::from([
1352            ("sidebar_style".to_string(), json!("auto")),
1353            ("has_h1".to_string(), json!(false)),
1354        ]);
1355        context.extend(extra.iter().map(|(k, v)| (k.to_string(), v.clone())));
1356        let backlinks = context
1357            .remove("contact_backlinks")
1358            .and_then(|v| v.as_u64())
1359            .map(|n| n as usize);
1360        crate::page_context::insert_contact(
1361            &mut context,
1362            crate::contact::Contact::from_yaml(&doc),
1363            &[],
1364            backlinks,
1365            &crate::page_context::UrlMode::Absolute,
1366        );
1367
1368        let tmp = tempfile::tempdir().unwrap();
1369        if let Some(index) = user_index {
1370            std::fs::create_dir_all(tmp.path().join(".mbr")).unwrap();
1371            std::fs::write(tmp.path().join(".mbr/index.html"), index).unwrap();
1372        }
1373        Templates::new(tmp.path(), None)
1374            .unwrap()
1375            .render_markdown("<p>body</p>", frontmatter, context)
1376            .expect("page should render")
1377    }
1378
1379    const JANE: &str = concat!(
1380        "type: person\n",
1381        "title: Jane Doe\n",
1382        "job_title: VP Marketing\n",
1383        "department: Marketing\n",
1384        "company: Acme\n",
1385        "gender: Female\n",
1386        "aliases: [Mare, {maiden_name: Mary Smith}]\n",
1387        "phones:\n  - mobile: \"+1 (303) 555-0100 ext. 12\"\n",
1388        "emails: {work: jane@abc.example}\n",
1389        "urls:\n  homepage: https://jane.example\n  evil: \"javascript:alert(1)\"\n",
1390        "social: {twitter: \"@jdoe\"}\n",
1391        "addresses: {home: {street: 52 Rue Bonsergent, city: Paris, country: France}}\n",
1392        "born: 1927-03-19\n",
1393        "born_place: Boulder, CO\n",
1394        "dates: {anniversary: 06-10}\n",
1395    );
1396
1397    #[test]
1398    fn test_contact_card_is_the_page_header() {
1399        let html = render_contact(JANE, &[]);
1400        assert!(html.contains(
1401            r#"<header class="mbr-contact-card" data-kind="person" data-gender="female">"#
1402        ));
1403        assert_eq!(html.matches("<h1").count(), 1, "one headline:\n{html}");
1404        assert!(html.contains(r#"<h1 class="mbr-contact-card-name">Jane Doe</h1>"#));
1405        assert!(html.contains("<span>VP Marketing</span><span>Marketing</span><span>Acme</span>"));
1406        assert!(html.contains("<span>aka Mare</span><span>née Mary Smith</span>"));
1407        // The default avatar, since there is no image.
1408        assert!(html.contains("mbr-contact-card-avatar-default"));
1409        // Card first, then the notes.
1410        assert!(html.find("mbr-contact-card").unwrap() < html.find("<p>body</p>").unwrap());
1411    }
1412
1413    #[test]
1414    fn test_contact_card_links_are_allowlisted() {
1415        let html = render_contact(JANE, &[]);
1416        assert!(
1417            html.contains(r#"<a href="tel:+13035550100;ext=12">+1 (303) 555-0100 ext. 12</a>"#),
1418            "{html}"
1419        );
1420        assert!(html.contains(r#"<a href="mailto:jane@abc.example">jane@abc.example</a>"#));
1421        // Tera autoescapes `/` in attribute values; the browser decodes it.
1422        assert!(
1423            html.contains(r#"rel="noopener noreferrer">https:&#x2F;&#x2F;jane.example</a>"#),
1424            "{html}"
1425        );
1426        assert!(
1427            !html.contains(r#"href="javascript"#),
1428            "javascript: must never be a link"
1429        );
1430        assert!(html.contains("<span>javascript:alert(1)</span>"));
1431        assert!(html.contains("<span>@jdoe</span>"), "a handle stays text");
1432    }
1433
1434    #[test]
1435    fn test_contact_card_address_and_dates() {
1436        let html = render_contact(JANE, &[]);
1437        assert!(html.contains("<address>52 Rue Bonsergent<br />Paris<br />France</address>"));
1438        assert!(html.contains(r#"<time datetime="1927-03-19">March 19, 1927</time><span class="mbr-contact-card-place">Boulder, CO</span>"#), "{html}");
1439        assert!(html.contains(r#"<time datetime="06-10">June 10</time>"#));
1440    }
1441
1442    #[test]
1443    fn test_contact_card_yields_headline_to_an_authored_h1() {
1444        let html = render_contact(JANE, &[("has_h1", json!(true))]);
1445        assert!(!html.contains("mbr-contact-card-name"), "{html}");
1446        assert!(html.contains("mbr-contact-card"));
1447    }
1448
1449    #[test]
1450    fn test_contact_card_backlink_chip_states() {
1451        let known = render_contact(JANE, &[("contact_backlinks", json!(3))]);
1452        assert!(known.contains(r#"<mbr-contact-backlinks count="3"><button type="button" class="mbr-contact-card-chip">Linked from 3 notes</button></mbr-contact-backlinks>"#));
1453        let one = render_contact(JANE, &[("contact_backlinks", json!(1))]);
1454        assert!(one.contains("Linked from 1 note</button>"));
1455        let none = render_contact(JANE, &[("contact_backlinks", json!(0))]);
1456        assert!(!none.contains("mbr-contact-backlinks"));
1457        let unknown = render_contact(JANE, &[]);
1458        assert!(unknown.contains("<mbr-contact-backlinks></mbr-contact-backlinks>"));
1459    }
1460
1461    #[test]
1462    fn test_contact_card_escapes_frontmatter() {
1463        let html = render_contact(
1464            "type: person\ntitle: \"<script>x()</script>\"\njob_title: \"<b>boss</b>\"\n",
1465            &[],
1466        );
1467        assert!(!html.contains("<script>x()"));
1468        assert!(!html.contains("<b>boss</b>"));
1469    }
1470
1471    #[test]
1472    fn test_contact_card_organization_and_name_parts() {
1473        let org = render_contact("type: organization\ncompany: Acme Corp\n", &[]);
1474        assert!(org.contains(r#"data-kind="organization""#));
1475        assert!(org.contains(r#"<h1 class="mbr-contact-card-name">Acme Corp</h1>"#));
1476        assert!(org.contains("<mbr-genealogy></mbr-genealogy>"));
1477
1478        let parts = render_contact("type: person\nfirst_name: Ada\nlast_name: Lovelace\n", &[]);
1479        assert!(parts.contains(r#"<h1 class="mbr-contact-card-name">Ada Lovelace</h1>"#));
1480    }
1481
1482    #[test]
1483    fn test_non_contact_pages_get_no_card() {
1484        let html = render_contact("type: event\ntitle: Party\n", &[]);
1485        assert!(!html.contains("mbr-contact-card"));
1486        assert!(html.contains("<h1>Party</h1>"));
1487    }
1488
1489    /// A customized `index.html` written against the old infobox keeps its
1490    /// own generated `<h1>` and gets the card — once, without a second name.
1491    #[test]
1492    fn test_legacy_index_including_person_infobox_gets_the_card_once() {
1493        let legacy = concat!(
1494            "<html><body><main>",
1495            "{% if title and not has_h1 %}<h1>{{ title }}</h1>{% endif %}",
1496            "{% include \"_person_infobox.html\" %}{{ markdown | safe }}",
1497            "</main></body></html>",
1498        );
1499        let html = render_contact_with(Some(legacy), JANE, &[]);
1500        assert_eq!(html.matches("<h1").count(), 1, "{html}");
1501        assert!(html.contains("<h1>Jane Doe</h1>"));
1502        assert!(html.contains("mbr-contact-card"));
1503        assert!(html.contains("tel:+13035550100"));
1504    }
1505}