Skip to main content

mbr_core/
page_context.rs

1//! Shared template-context assembly for server mode and static site builds.
2//!
3//! Both `server.rs` (live rendering) and `build.rs` (static site generation)
4//! hand a `HashMap<String, serde_json::Value>` to the Tera templates. The two
5//! modes insert nearly identical key sets and differ only in:
6//!
7//! - **Mode flags**: server pages get `server_mode=true` (plus `gui_mode`
8//!   and, on some pages, `relative_base="/.mbr/"`); static pages get
9//!   `server_mode=false` plus `relative_base`/`relative_root` computed from
10//!   the page depth.
11//! - **URL style**: server pages use absolute URLs; static pages rewrite
12//!   URLs relative to the page depth via `make_relative_url`.
13//!
14//! The helpers here capture those deltas so each call site only supplies its
15//! genuinely page-specific data. Rendered output must remain byte-identical
16//! to the previous hand-rolled assembly, so helpers never insert keys a call
17//! site did not previously emit.
18
19use std::collections::HashMap;
20use std::path::Path;
21
22use serde_json::{Value, json};
23
24use crate::config::TagSource;
25use crate::edit_auth::content_hash;
26use crate::link_transform::make_relative_url;
27use crate::markdown::HeadingInfo;
28use crate::readability::ReadabilityScores;
29use crate::repo::MarkdownInfo;
30use crate::tag_index::{TagInfo, TaggedPage};
31use crate::task_query::IncludeFilter;
32use crate::url_helpers::{
33    Breadcrumb, generate_breadcrumbs, get_current_dir_name, relative_base, relative_root,
34};
35
36/// How URLs are emitted into a template context.
37pub enum UrlMode {
38    /// Server mode: absolute URLs, used as-is.
39    Absolute,
40    /// Static build: URLs rewritten relative to a page at the given depth.
41    RelativeToDepth(usize),
42}
43
44impl UrlMode {
45    /// Rewrites an absolute URL according to this mode.
46    pub fn rewrite(&self, url: &str) -> String {
47        match self {
48            UrlMode::Absolute => url.to_string(),
49            UrlMode::RelativeToDepth(depth) => make_relative_url(url, *depth),
50        }
51    }
52
53    /// Extracts the `url_path` of a sibling-page JSON object for prev/next
54    /// navigation, preserving each mode's historical behavior:
55    /// server inserts the raw value (JSON `null` if missing), build falls
56    /// back to `/` and relativizes.
57    fn nav_url(&self, page: &Value) -> Value {
58        match self {
59            UrlMode::Absolute => json!(page.get("url_path")),
60            UrlMode::RelativeToDepth(depth) => {
61                let url = page.get("url_path").and_then(|v| v.as_str()).unwrap_or("/");
62                json!(make_relative_url(url, *depth))
63            }
64        }
65    }
66}
67
68/// The `server_mode`/`gui_mode`/`relative_base`/`relative_root` key set,
69/// which varies per render mode (and per page type within server mode).
70pub enum ModeFlags {
71    Server {
72        /// `Some(flag)` inserts `gui_mode`; `None` omits the key entirely
73        /// (tag pages historically don't emit it).
74        gui_mode: Option<bool>,
75        /// When true, inserts `relative_base: "/.mbr/"` (tag pages and the
76        /// media viewer emit it in server mode; other pages don't).
77        mbr_base: bool,
78    },
79    Static {
80        /// Page depth used to compute `relative_base`/`relative_root`.
81        depth: usize,
82    },
83}
84
85/// Common page "chrome": mode flags, sidebar configuration, and title
86/// prefix/suffix. Every non-markdown assembly site inserts this set.
87pub struct PageChrome<'a> {
88    pub mode: ModeFlags,
89    pub sidebar_style: &'a str,
90    pub sidebar_max_items: usize,
91    pub graph_depth: usize,
92    /// Whether the task browser is available on this page. Always `false` in
93    /// static builds: the task index is built from live files.
94    pub tasks_enabled: bool,
95    /// Whether this page carries `data-mbr-line` source-line attributes.
96    /// Always `false` in static builds: an anchor is only meaningful against a
97    /// live file, so `mbr -b` never emits one.
98    pub review_enabled: bool,
99    /// Where the task panel's Show filter starts. Emitted even when
100    /// `tasks_enabled` is false (as `graph_depth` is when link tracking is off):
101    /// the key is inert without a panel to read it.
102    pub tasks_default_include: IncludeFilter,
103    /// `Some((prefix, suffix))` for content pages; `None` for error pages,
104    /// which historically omit `title_prefix`/`title_suffix`.
105    pub title_affixes: Option<(&'a str, &'a str)>,
106    /// Repository root directory, used to derive `repo_id` (a stable per-repo
107    /// id the frontend uses to scope browser storage — currently just review
108    /// notes — to the served repository, not just the origin). `None` means
109    /// there is no live repository to scope against: a static build (the
110    /// review feature is unconditionally off there) or an error page (no
111    /// markdown body, nothing to review).
112    pub root_dir: Option<&'a Path>,
113}
114
115/// Inserts the shared chrome key set into a template context.
116pub fn insert_page_chrome(ctx: &mut HashMap<String, Value>, chrome: &PageChrome<'_>) {
117    match &chrome.mode {
118        ModeFlags::Server { gui_mode, mbr_base } => {
119            ctx.insert("server_mode".to_string(), json!(true));
120            if let Some(gui) = gui_mode {
121                ctx.insert("gui_mode".to_string(), json!(gui));
122            }
123            if *mbr_base {
124                ctx.insert("relative_base".to_string(), json!("/.mbr/"));
125            }
126        }
127        ModeFlags::Static { depth } => {
128            ctx.insert("server_mode".to_string(), json!(false));
129            ctx.insert("relative_base".to_string(), json!(relative_base(*depth)));
130            ctx.insert("relative_root".to_string(), json!(relative_root(*depth)));
131        }
132    }
133    ctx.insert("sidebar_style".to_string(), json!(chrome.sidebar_style));
134    ctx.insert(
135        "sidebar_max_items".to_string(),
136        json!(chrome.sidebar_max_items),
137    );
138    ctx.insert("graph_depth".to_string(), json!(chrome.graph_depth));
139    ctx.insert("tasks_enabled".to_string(), json!(chrome.tasks_enabled));
140    ctx.insert("review_enabled".to_string(), json!(chrome.review_enabled));
141    ctx.insert(
142        "tasks_default_include".to_string(),
143        json!(chrome.tasks_default_include),
144    );
145    if let Some((prefix, suffix)) = chrome.title_affixes {
146        ctx.insert("title_prefix".to_string(), json!(prefix));
147        ctx.insert("title_suffix".to_string(), json!(suffix));
148    }
149    insert_repo_id(ctx, chrome.root_dir);
150}
151
152/// Inserts `repo_id`, a stable per-repository id derived from the server's
153/// root directory, used by the frontend to scope browser storage (currently
154/// just review notes) to the served repository as well as the browser
155/// origin — several repos served from the same host:port (GUI mode's fixed
156/// default port, or a reused dev port) must not share state. The path itself
157/// is hashed rather than passed through, so a repo's local filesystem path is
158/// never embedded in page HTML. Omits the key entirely (not just empty) when
159/// `root_dir` is `None`.
160fn insert_repo_id(ctx: &mut HashMap<String, Value>, root_dir: Option<&Path>) {
161    if let Some(root_dir) = root_dir {
162        ctx.insert(
163            "repo_id".to_string(),
164            json!(content_hash(root_dir.to_string_lossy().as_bytes())),
165        );
166    }
167}
168
169/// Serializes tag-source configuration as a JSON string for safe template
170/// rendering in a JavaScript context (used by frontend tag linking).
171///
172/// `_head.html` emits this inside an inline `<script>` with `| safe`, and the
173/// labels come from `.mbr/config.toml`, so the payload is escaped for script
174/// context (see [`crate::templates::escape_json_for_script`]) — otherwise a
175/// label containing `</script>` would terminate the block.
176pub fn tag_sources_json(tag_sources: &[TagSource]) -> String {
177    let json = serde_json::to_string(
178        &tag_sources
179            .iter()
180            .map(|ts| {
181                json!({
182                    "field": ts.field,
183                    "urlSource": ts.url_source(),
184                    "label": ts.singular_label(),
185                    "labelPlural": ts.plural_label()
186                })
187            })
188            .collect::<Vec<_>>(),
189    )
190    .unwrap_or_else(|_| "[]".to_string());
191    crate::templates::escape_json_for_script(&json)
192}
193
194/// Converts breadcrumbs to their JSON template representation, rewriting
195/// URLs per the given mode.
196pub fn breadcrumbs_to_json(breadcrumbs: &[Breadcrumb], url_mode: &UrlMode) -> Vec<Value> {
197    breadcrumbs
198        .iter()
199        .map(|b| json!({"name": b.name, "url": url_mode.rewrite(&b.url)}))
200        .collect()
201}
202
203/// Resolves singular/plural labels for a tag source. Falls back to
204/// `fallback_base` / `{fallback_base}s` when the source is not configured
205/// (server capitalizes the fallback, build uses it verbatim).
206pub fn tag_labels(
207    tag_sources: &[TagSource],
208    source: &str,
209    fallback_base: &str,
210) -> (String, String) {
211    tag_sources
212        .iter()
213        .find(|ts| ts.url_source() == source)
214        .map(|ts| (ts.singular_label(), ts.plural_label()))
215        .unwrap_or_else(|| (fallback_base.to_string(), format!("{}s", fallback_base)))
216}
217
218/// Inserts the shared key set for a tag page (`tag.html`).
219pub fn insert_tag_page_keys(
220    ctx: &mut HashMap<String, Value>,
221    source: &str,
222    display_value: &str,
223    label: &str,
224    label_plural: &str,
225    pages: &[TaggedPage],
226    url_mode: &UrlMode,
227) {
228    ctx.insert("tag_source".to_string(), json!(source));
229    ctx.insert("tag_display_value".to_string(), json!(display_value));
230    ctx.insert("tag_label".to_string(), json!(label));
231    ctx.insert("tag_label_plural".to_string(), json!(label_plural));
232    let pages_json: Vec<Value> = pages
233        .iter()
234        .map(|p| {
235            json!({
236                "url_path": url_mode.rewrite(&p.url_path),
237                "title": p.title,
238                "description": p.description,
239            })
240        })
241        .collect();
242    ctx.insert("pages".to_string(), json!(pages_json));
243    ctx.insert("page_count".to_string(), json!(pages.len()));
244}
245
246/// Inserts the shared key set for a tag source index page (`tag_index.html`).
247pub fn insert_tag_index_keys(
248    ctx: &mut HashMap<String, Value>,
249    source: &str,
250    label: &str,
251    label_plural: &str,
252    tags: &[TagInfo],
253) {
254    ctx.insert("tag_source".to_string(), json!(source));
255    ctx.insert("tag_label".to_string(), json!(label));
256    ctx.insert("tag_label_plural".to_string(), json!(label_plural));
257    let tags_json: Vec<Value> = tags
258        .iter()
259        .map(|t| {
260            json!({
261                "url_value": t.normalized,
262                "display_value": t.display,
263                "page_count": t.count,
264            })
265        })
266        .collect();
267    ctx.insert("tags".to_string(), json!(tags_json));
268    ctx.insert("tag_count".to_string(), json!(tags.len()));
269}
270
271/// Inserts the shared key set for an error page (`error.html`).
272/// `error_message` is omitted when `None` (matches historical behavior).
273pub fn insert_error_keys(
274    ctx: &mut HashMap<String, Value>,
275    error_code: u16,
276    error_title: &str,
277    error_message: Option<&str>,
278) {
279    ctx.insert("error_code".to_string(), json!(error_code));
280    ctx.insert("error_title".to_string(), json!(error_title));
281    if let Some(msg) = error_message {
282        ctx.insert("error_message".to_string(), json!(msg));
283    }
284}
285
286/// Adds the contact card's context for a `type: person` / `type: organization`
287/// page: `contact` (the [`crate::contact::Contact`] model) and, when the
288/// backlink count is known without any extra work, `contact_backlinks`.
289///
290/// Inserts nothing for other notes, so every other page renders byte-for-byte
291/// as before. `relationships` are the page's own resolved relationships, used
292/// only to link a wikilinked `company` (see
293/// [`crate::contact::Contact::resolve_company`]); the company URL is made
294/// relative in static builds like every other link on the page.
295///
296/// `backlinks` is `None` whenever counting would cost anything — before the
297/// server's inbound index is ready, in static builds — and the card then emits
298/// a placeholder that the browser fills from `links.json` on idle.
299pub fn insert_contact(
300    ctx: &mut HashMap<String, Value>,
301    contact: Option<crate::contact::Contact>,
302    relationships: &[crate::relationships::ResolvedRelationship],
303    backlinks: Option<usize>,
304    url_mode: &UrlMode,
305) {
306    let Some(mut contact) = contact else {
307        return;
308    };
309    contact.resolve_company(relationships);
310    if let (UrlMode::RelativeToDepth(depth), Some(company)) = (url_mode, contact.company.as_mut()) {
311        company.url = company
312            .url
313            .take()
314            .map(|url| make_relative_url(&url, *depth));
315    }
316    ctx.insert("contact".to_string(), json!(contact));
317    if let Some(count) = backlinks {
318        ctx.insert("contact_backlinks".to_string(), json!(count));
319    }
320}
321
322/// Page-specific inputs for the shared markdown-page context builder.
323///
324/// Sibling computation stays with the caller (server memoizes via a cache,
325/// build uses a pre-built index); this struct only carries the results.
326pub struct MarkdownPageParams<'a> {
327    /// URL-shaped path used for breadcrumbs and `current_dir_name`.
328    pub breadcrumb_path: &'a Path,
329    pub headings: &'a [HeadingInfo],
330    pub has_h1: bool,
331    pub word_count: usize,
332    pub readability: &'a ReadabilityScores,
333    /// File path relative to the repository root.
334    pub file_path: &'a str,
335    /// File mtime as seconds since the Unix epoch, if available.
336    pub modified_secs: Option<u64>,
337    /// Absolute URL of this page, used to locate it among its siblings.
338    pub current_url: &'a str,
339    /// Sorted sibling pages (JSON objects with `url_path`/`title`).
340    pub siblings: &'a [Value],
341}
342
343/// Repository-level configuration for the markdown-page context builder.
344pub struct MarkdownContextOptions<'a> {
345    pub tag_sources: &'a [TagSource],
346    pub sidebar_style: &'a str,
347    pub sidebar_max_items: usize,
348    pub graph_depth: usize,
349    /// Concentric-mode growth threshold, read by the flashcard deck as
350    /// `__MBR_CONFIG__.flashcardsConcentricThreshold`. Markdown pages only:
351    /// the deck exists only on a `type: flashcard` note, so the chrome-only
352    /// pages leave it to the template's default.
353    pub flashcards_concentric_threshold: f64,
354    /// Whether the reading view draws flashcard progress indicators
355    /// (`__MBR_CONFIG__.flashcardsProgressIndicators`). Markdown pages only.
356    pub flashcards_progress_indicators: bool,
357    /// Whether the task browser is available on this page. Markdown pages do
358    /// not go through [`insert_page_chrome`], so this is the sibling of
359    /// [`PageChrome::tasks_enabled`] for them. Always `false` in static builds.
360    pub tasks_enabled: bool,
361    /// Sibling of [`PageChrome::review_enabled`] for markdown pages.
362    pub review_enabled: bool,
363    /// Sibling of [`PageChrome::tasks_default_include`] for markdown pages.
364    pub tasks_default_include: IncludeFilter,
365    pub title_prefix: &'a str,
366    pub title_suffix: &'a str,
367    /// Sibling of [`PageChrome::root_dir`] for markdown pages.
368    pub root_dir: Option<&'a Path>,
369}
370
371/// Builds the `extra_context` map shared by server-mode and static-build
372/// markdown rendering.
373///
374/// Mode flags (`server_mode`/`gui_mode`) are *not* inserted here: markdown
375/// pages carry them in frontmatter (so they appear in `frontmatter_json`),
376/// which remains the caller's responsibility. In static mode
377/// (`UrlMode::RelativeToDepth`) this also inserts `relative_base` and
378/// `relative_root`.
379pub fn markdown_extra_context(
380    params: &MarkdownPageParams<'_>,
381    opts: &MarkdownContextOptions<'_>,
382    url_mode: &UrlMode,
383) -> HashMap<String, Value> {
384    let mut ctx = HashMap::new();
385
386    // Navigation: breadcrumbs and current directory name
387    let breadcrumbs = generate_breadcrumbs(params.breadcrumb_path);
388    ctx.insert(
389        "breadcrumbs".to_string(),
390        json!(breadcrumbs_to_json(&breadcrumbs, url_mode)),
391    );
392    ctx.insert(
393        "current_dir_name".to_string(),
394        json!(get_current_dir_name(params.breadcrumb_path)),
395    );
396
397    // Heading TOC
398    ctx.insert("headings".to_string(), json!(params.headings));
399    ctx.insert("has_h1".to_string(), json!(params.has_h1));
400
401    // Tag sources configuration for frontend tag linking (pre-serialized as
402    // a JSON string for safe template rendering in a JavaScript context)
403    ctx.insert(
404        "tag_sources".to_string(),
405        json!(tag_sources_json(opts.tag_sources)),
406    );
407
408    // Word count and reading time (200 words per minute)
409    let reading_time_minutes = params
410        .word_count
411        .div_ceil(crate::constants::WORDS_PER_MINUTE);
412    ctx.insert("word_count".to_string(), json!(params.word_count));
413    ctx.insert(
414        "reading_time_minutes".to_string(),
415        json!(reading_time_minutes),
416    );
417
418    // Readability scores (Flesch Reading Ease + Flesch-Kincaid Grade Level).
419    // `None` values serialize as JSON `null`, which the template outputs
420    // literally so the frontend can guard on `!= null`.
421    ctx.insert(
422        "flesch_reading_ease".to_string(),
423        json!(params.readability.flesch_reading_ease),
424    );
425    ctx.insert(
426        "flesch_kincaid_grade".to_string(),
427        json!(params.readability.flesch_kincaid_grade),
428    );
429
430    // File path (relative to root) for reference
431    ctx.insert("file_path".to_string(), json!(params.file_path));
432
433    // Sidebar navigation configuration and title affixes
434    ctx.insert("sidebar_style".to_string(), json!(opts.sidebar_style));
435    ctx.insert(
436        "sidebar_max_items".to_string(),
437        json!(opts.sidebar_max_items),
438    );
439    ctx.insert("graph_depth".to_string(), json!(opts.graph_depth));
440    ctx.insert(
441        "flashcards_concentric_threshold".to_string(),
442        json!(opts.flashcards_concentric_threshold),
443    );
444    ctx.insert(
445        "flashcards_progress_indicators".to_string(),
446        json!(opts.flashcards_progress_indicators),
447    );
448    ctx.insert("tasks_enabled".to_string(), json!(opts.tasks_enabled));
449    ctx.insert("review_enabled".to_string(), json!(opts.review_enabled));
450    ctx.insert(
451        "tasks_default_include".to_string(),
452        json!(opts.tasks_default_include),
453    );
454    ctx.insert("title_prefix".to_string(), json!(opts.title_prefix));
455    ctx.insert("title_suffix".to_string(), json!(opts.title_suffix));
456    insert_repo_id(&mut ctx, opts.root_dir);
457
458    // Modified date from file metadata
459    if let Some(secs) = params.modified_secs {
460        ctx.insert("modified_timestamp".to_string(), json!(secs));
461    }
462
463    // Prev/next sibling pages for navigation
464    if let Some(current_idx) = params.siblings.iter().position(|f| {
465        f.get("url_path")
466            .and_then(|v| v.as_str())
467            .is_some_and(|p| p == params.current_url)
468    }) {
469        if current_idx > 0
470            && let Some(prev) = params.siblings.get(current_idx - 1)
471        {
472            ctx.insert(
473                "prev_page".to_string(),
474                json!({
475                    "url": url_mode.nav_url(prev),
476                    "title": prev.get("title").and_then(|v| v.as_str()).unwrap_or("Previous")
477                }),
478            );
479        }
480        if let Some(next) = params.siblings.get(current_idx + 1) {
481            ctx.insert(
482                "next_page".to_string(),
483                json!({
484                    "url": url_mode.nav_url(next),
485                    "title": next.get("title").and_then(|v| v.as_str()).unwrap_or("Next")
486                }),
487            );
488        }
489    }
490
491    // Relative path variables for static builds
492    if let UrlMode::RelativeToDepth(depth) = url_mode {
493        ctx.insert("relative_base".to_string(), json!(relative_base(*depth)));
494        ctx.insert("relative_root".to_string(), json!(relative_root(*depth)));
495    }
496
497    ctx
498}
499
500/// Transforms markdown file info into a JSON value for template rendering.
501///
502/// The one shape a page listing (`files` in section/home pages, sibling
503/// navigation) hands to Tera, shared by the server and the static build.
504pub fn markdown_file_to_json(file_info: &MarkdownInfo) -> Value {
505    let title = file_info
506        .frontmatter
507        .as_ref()
508        .and_then(|fm| fm.get("title"))
509        .cloned()
510        .unwrap_or_else(|| {
511            Value::String(
512                file_info
513                    .raw_path
514                    .file_stem()
515                    .and_then(|s| s.to_str())
516                    .unwrap_or("Untitled")
517                    .to_string(),
518            )
519        });
520
521    let description = file_info
522        .frontmatter
523        .as_ref()
524        .and_then(|fm| fm.get("description"))
525        .cloned();
526
527    let tags = file_info
528        .frontmatter
529        .as_ref()
530        .and_then(|fm| fm.get("tags"))
531        .cloned();
532
533    let note_type = file_info
534        .frontmatter
535        .as_ref()
536        .and_then(|fm| fm.get("type"))
537        .cloned();
538
539    let modified_date = chrono::DateTime::from_timestamp(file_info.modified as i64, 0)
540        .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
541        .unwrap_or_else(|| "Unknown".to_string());
542
543    json!({
544        "title": title,
545        "url_path": file_info.url_path,
546        "description": description,
547        "tags": tags,
548        "type": note_type,
549        "modified_date": modified_date,
550        "modified": file_info.modified,
551        "name": file_info.raw_path.file_name().and_then(|s| s.to_str()).unwrap_or(""),
552    })
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use std::path::PathBuf;
559
560    #[test]
561    fn test_markdown_file_to_json_with_frontmatter() {
562        let mut frontmatter = crate::markdown::SimpleMetadata::new();
563        frontmatter.insert("title".to_string(), Value::String("My Title".to_string()));
564        frontmatter.insert(
565            "description".to_string(),
566            Value::String("My description".to_string()),
567        );
568        frontmatter.insert("tags".to_string(), json!(["rust", "testing"]));
569
570        let file_info = MarkdownInfo {
571            raw_path: PathBuf::from("test.md"),
572            url_path: "/test/".to_string(),
573            frontmatter: Some(frontmatter),
574            created: 1699000000,
575            modified: 1700000000,
576            relationships: Vec::new(),
577        };
578
579        let json = markdown_file_to_json(&file_info);
580
581        assert_eq!(json["title"], "My Title");
582        assert_eq!(json["url_path"], "/test/");
583        assert_eq!(json["description"], "My description");
584        assert_eq!(json["tags"], json!(["rust", "testing"]));
585        assert_eq!(json["modified"], 1700000000);
586        assert_eq!(json["name"], "test.md");
587    }
588
589    #[test]
590    fn test_markdown_file_to_json_without_frontmatter() {
591        let file_info = MarkdownInfo {
592            raw_path: PathBuf::from("my-document.md"),
593            url_path: "/my-document/".to_string(),
594            frontmatter: None,
595            created: 1699000000,
596            modified: 1700000000,
597            relationships: Vec::new(),
598        };
599
600        let json = markdown_file_to_json(&file_info);
601
602        // Should use file stem as title when no frontmatter
603        assert_eq!(json["title"], "my-document");
604        assert_eq!(json["url_path"], "/my-document/");
605        assert!(json["description"].is_null());
606        assert!(json["tags"].is_null());
607    }
608
609    #[test]
610    fn test_markdown_file_to_json_partial_frontmatter() {
611        let mut frontmatter = crate::markdown::SimpleMetadata::new();
612        frontmatter.insert("title".to_string(), Value::String("Only Title".to_string()));
613        // No description or tags
614
615        let file_info = MarkdownInfo {
616            raw_path: PathBuf::from("partial.md"),
617            url_path: "/partial/".to_string(),
618            frontmatter: Some(frontmatter),
619            created: 1699000000,
620            modified: 1700000000,
621            relationships: Vec::new(),
622        };
623
624        let json = markdown_file_to_json(&file_info);
625
626        assert_eq!(json["title"], "Only Title");
627        assert!(json["description"].is_null());
628        assert!(json["tags"].is_null());
629    }
630
631    fn tag_source(field: &str) -> TagSource {
632        TagSource {
633            field: field.to_string(),
634            label: None,
635            label_plural: None,
636        }
637    }
638
639    #[test]
640    fn test_url_mode_rewrite_absolute_is_identity() {
641        assert_eq!(UrlMode::Absolute.rewrite("/docs/guide/"), "/docs/guide/");
642    }
643
644    #[test]
645    fn test_url_mode_rewrite_relative_matches_make_relative_url() {
646        assert_eq!(
647            UrlMode::RelativeToDepth(2).rewrite("/docs/guide/"),
648            make_relative_url("/docs/guide/", 2)
649        );
650    }
651
652    #[test]
653    fn test_insert_page_chrome_server_full() {
654        let mut ctx = HashMap::new();
655        insert_page_chrome(
656            &mut ctx,
657            &PageChrome {
658                mode: ModeFlags::Server {
659                    gui_mode: Some(true),
660                    mbr_base: true,
661                },
662                sidebar_style: "auto",
663                sidebar_max_items: 10,
664                graph_depth: 3,
665                tasks_enabled: true,
666                review_enabled: false,
667                tasks_default_include: IncludeFilter::Tasks,
668                title_affixes: Some(("pre ", " suf")),
669                root_dir: Some(Path::new("/repo/root")),
670            },
671        );
672        assert_eq!(ctx.get("server_mode"), Some(&json!(true)));
673        assert_eq!(ctx.get("gui_mode"), Some(&json!(true)));
674        assert_eq!(ctx.get("relative_base"), Some(&json!("/.mbr/")));
675        assert_eq!(ctx.get("sidebar_style"), Some(&json!("auto")));
676        assert_eq!(ctx.get("sidebar_max_items"), Some(&json!(10)));
677        assert_eq!(ctx.get("graph_depth"), Some(&json!(3)));
678        assert_eq!(ctx.get("tasks_enabled"), Some(&json!(true)));
679        assert_eq!(ctx.get("title_prefix"), Some(&json!("pre ")));
680        assert_eq!(ctx.get("title_suffix"), Some(&json!(" suf")));
681        assert!(!ctx.contains_key("relative_root"));
682        assert_eq!(
683            ctx.get("repo_id"),
684            Some(&json!(content_hash(b"/repo/root")))
685        );
686    }
687
688    #[test]
689    fn test_insert_page_chrome_server_omits_optional_keys() {
690        let mut ctx = HashMap::new();
691        insert_page_chrome(
692            &mut ctx,
693            &PageChrome {
694                mode: ModeFlags::Server {
695                    gui_mode: None,
696                    mbr_base: false,
697                },
698                sidebar_style: "auto",
699                sidebar_max_items: 10,
700                graph_depth: 2,
701                tasks_enabled: false,
702                review_enabled: false,
703                tasks_default_include: IncludeFilter::Tasks,
704                title_affixes: None,
705                root_dir: None,
706            },
707        );
708        assert_eq!(ctx.get("server_mode"), Some(&json!(true)));
709        assert!(!ctx.contains_key("gui_mode"));
710        assert!(!ctx.contains_key("relative_base"));
711        assert!(!ctx.contains_key("title_prefix"));
712        assert!(!ctx.contains_key("title_suffix"));
713        assert!(!ctx.contains_key("repo_id"));
714    }
715
716    #[test]
717    fn test_insert_page_chrome_static() {
718        let mut ctx = HashMap::new();
719        insert_page_chrome(
720            &mut ctx,
721            &PageChrome {
722                mode: ModeFlags::Static { depth: 2 },
723                sidebar_style: "auto",
724                sidebar_max_items: 5,
725                graph_depth: 2,
726                tasks_enabled: false,
727                review_enabled: false,
728                tasks_default_include: IncludeFilter::Tasks,
729                title_affixes: Some(("", "")),
730                // Static builds never have a live repo to scope storage
731                // against.
732                root_dir: None,
733            },
734        );
735        assert_eq!(ctx.get("server_mode"), Some(&json!(false)));
736        assert_eq!(ctx.get("relative_base"), Some(&json!("../../.mbr/")));
737        assert_eq!(ctx.get("relative_root"), Some(&json!("../../")));
738        assert!(!ctx.contains_key("gui_mode"));
739        // Static builds never carry the task browser: its index reads live files.
740        assert_eq!(ctx.get("tasks_enabled"), Some(&json!(false)));
741        assert!(!ctx.contains_key("repo_id"));
742    }
743
744    #[test]
745    fn test_tag_sources_json_format() {
746        let sources = vec![tag_source("tags")];
747        let json_str = tag_sources_json(&sources);
748        let parsed: Vec<Value> = serde_json::from_str(&json_str).unwrap();
749        assert_eq!(parsed.len(), 1);
750        assert_eq!(parsed[0]["field"], "tags");
751        assert_eq!(parsed[0]["urlSource"], "tags");
752        assert!(parsed[0]["label"].is_string());
753        assert!(parsed[0]["labelPlural"].is_string());
754    }
755
756    #[test]
757    fn test_tag_sources_json_empty() {
758        assert_eq!(tag_sources_json(&[]), "[]");
759    }
760
761    /// `_head.html` emits this payload inside an inline `<script>` with
762    /// `| safe`, so a configured label containing `</script>` would otherwise
763    /// terminate the block and wipe out `window.__MBR_CONFIG__`.
764    #[test]
765    fn test_tag_sources_json_is_escaped_for_script_context() {
766        let sources = vec![TagSource {
767            field: "tags".to_string(),
768            label: Some("</script><img src=x>".to_string()),
769            label_plural: None,
770        }];
771        let json_str = tag_sources_json(&sources);
772
773        assert!(
774            !json_str.contains("</script>"),
775            "script terminator must not survive: {json_str}"
776        );
777        assert!(
778            json_str.contains("\\u003c/script\\u003e"),
779            "expected the escaped terminator in: {json_str}"
780        );
781        // Escaping is markup-only: the payload still decodes to the original.
782        let parsed: Vec<Value> = serde_json::from_str(&json_str).unwrap();
783        assert_eq!(parsed[0]["label"], "</script><img src=x>");
784    }
785
786    #[test]
787    fn test_breadcrumbs_to_json_absolute() {
788        let crumbs = generate_breadcrumbs(Path::new("docs/guide"));
789        let json_crumbs = breadcrumbs_to_json(&crumbs, &UrlMode::Absolute);
790        assert_eq!(json_crumbs.len(), crumbs.len());
791        for (jc, c) in json_crumbs.iter().zip(crumbs.iter()) {
792            assert_eq!(jc["name"], json!(c.name));
793            assert_eq!(jc["url"], json!(c.url));
794        }
795    }
796
797    #[test]
798    fn test_breadcrumbs_to_json_relative() {
799        let crumbs = generate_breadcrumbs(Path::new("docs/guide"));
800        let json_crumbs = breadcrumbs_to_json(&crumbs, &UrlMode::RelativeToDepth(2));
801        for (jc, c) in json_crumbs.iter().zip(crumbs.iter()) {
802            assert_eq!(jc["url"], json!(make_relative_url(&c.url, 2)));
803        }
804    }
805
806    #[test]
807    fn test_tag_labels_configured_source() {
808        let sources = vec![tag_source("tags")];
809        let (label, plural) = tag_labels(&sources, "tags", "fallback");
810        assert_eq!(label, sources[0].singular_label());
811        assert_eq!(plural, sources[0].plural_label());
812    }
813
814    #[test]
815    fn test_tag_labels_fallback() {
816        let (label, plural) = tag_labels(&[], "performers", "Performers");
817        assert_eq!(label, "Performers");
818        assert_eq!(plural, "Performerss");
819
820        let (label, plural) = tag_labels(&[], "performers", "performers");
821        assert_eq!(label, "performers");
822        assert_eq!(plural, "performerss");
823    }
824
825    #[test]
826    fn test_insert_tag_page_keys() {
827        let mut ctx = HashMap::new();
828        let pages = vec![TaggedPage::with_description(
829            "/docs/rust/",
830            "Rust Guide",
831            "A guide",
832            "Rust",
833        )];
834        insert_tag_page_keys(
835            &mut ctx,
836            "tags",
837            "Rust",
838            "Tag",
839            "Tags",
840            &pages,
841            &UrlMode::Absolute,
842        );
843        assert_eq!(ctx.get("tag_source"), Some(&json!("tags")));
844        assert_eq!(ctx.get("tag_display_value"), Some(&json!("Rust")));
845        assert_eq!(ctx.get("tag_label"), Some(&json!("Tag")));
846        assert_eq!(ctx.get("tag_label_plural"), Some(&json!("Tags")));
847        assert_eq!(ctx.get("page_count"), Some(&json!(1)));
848        let pages_val = ctx.get("pages").unwrap();
849        assert_eq!(pages_val[0]["url_path"], "/docs/rust/");
850        assert_eq!(pages_val[0]["title"], "Rust Guide");
851        assert_eq!(pages_val[0]["description"], "A guide");
852    }
853
854    #[test]
855    fn test_insert_tag_page_keys_null_description_and_relative_url() {
856        let mut ctx = HashMap::new();
857        let pages = vec![TaggedPage::new("/docs/rust/", "Rust Guide", "Rust")];
858        insert_tag_page_keys(
859            &mut ctx,
860            "tags",
861            "Rust",
862            "Tag",
863            "Tags",
864            &pages,
865            &UrlMode::RelativeToDepth(2),
866        );
867        let pages_val = ctx.get("pages").unwrap();
868        // Missing description serializes as JSON null (historical behavior)
869        assert!(pages_val[0]["description"].is_null());
870        assert_eq!(
871            pages_val[0]["url_path"],
872            json!(make_relative_url("/docs/rust/", 2))
873        );
874    }
875
876    #[test]
877    fn test_insert_tag_index_keys() {
878        let mut ctx = HashMap::new();
879        let tags = vec![TagInfo {
880            normalized: "rust".to_string(),
881            display: "Rust".to_string(),
882            count: 3,
883        }];
884        insert_tag_index_keys(&mut ctx, "tags", "Tag", "Tags", &tags);
885        assert_eq!(ctx.get("tag_source"), Some(&json!("tags")));
886        assert_eq!(ctx.get("tag_count"), Some(&json!(1)));
887        let tags_val = ctx.get("tags").unwrap();
888        assert_eq!(tags_val[0]["url_value"], "rust");
889        assert_eq!(tags_val[0]["display_value"], "Rust");
890        assert_eq!(tags_val[0]["page_count"], 3);
891    }
892
893    #[test]
894    fn test_insert_error_keys_with_and_without_message() {
895        let mut ctx = HashMap::new();
896        insert_error_keys(&mut ctx, 404, "Not Found", Some("gone"));
897        assert_eq!(ctx.get("error_code"), Some(&json!(404)));
898        assert_eq!(ctx.get("error_title"), Some(&json!("Not Found")));
899        assert_eq!(ctx.get("error_message"), Some(&json!("gone")));
900
901        let mut ctx = HashMap::new();
902        insert_error_keys(&mut ctx, 500, "Internal Server Error", None);
903        assert!(!ctx.contains_key("error_message"));
904    }
905
906    fn markdown_opts<'a>(
907        sources: &'a [TagSource],
908        root_dir: Option<&'a Path>,
909    ) -> MarkdownContextOptions<'a> {
910        MarkdownContextOptions {
911            tag_sources: sources,
912            sidebar_style: "auto",
913            sidebar_max_items: 10,
914            graph_depth: 2,
915            flashcards_concentric_threshold: 0.7,
916            flashcards_progress_indicators: true,
917            tasks_enabled: true,
918            review_enabled: false,
919            tasks_default_include: IncludeFilter::Tasks,
920            title_prefix: "",
921            title_suffix: "",
922            root_dir,
923        }
924    }
925
926    #[test]
927    fn test_markdown_extra_context_server_mode() {
928        let scores = crate::readability::ReadabilityScores {
929            flesch_reading_ease: Some(65.0),
930            flesch_kincaid_grade: Some(8.0),
931        };
932        let siblings = vec![
933            json!({"url_path": "/docs/a/", "title": "A"}),
934            json!({"url_path": "/docs/b/", "title": "B"}),
935            json!({"url_path": "/docs/c/", "title": "C"}),
936        ];
937        let headings = vec![HeadingInfo {
938            level: 2,
939            text: "Intro".to_string(),
940            id: "intro".to_string(),
941        }];
942        let params = MarkdownPageParams {
943            breadcrumb_path: Path::new("docs/b"),
944            headings: &headings,
945            has_h1: true,
946            word_count: 401,
947            readability: &scores,
948            file_path: "docs/b.md",
949            modified_secs: Some(1700000000),
950            current_url: "/docs/b/",
951            siblings: &siblings,
952        };
953        let root_dir = Path::new("/repo/root");
954        let ctx = markdown_extra_context(
955            &params,
956            &markdown_opts(&[], Some(root_dir)),
957            &UrlMode::Absolute,
958        );
959
960        assert_eq!(ctx.get("has_h1"), Some(&json!(true)));
961        assert_eq!(ctx.get("tasks_enabled"), Some(&json!(true)));
962        assert_eq!(
963            ctx.get("flashcards_concentric_threshold"),
964            Some(&json!(0.7))
965        );
966        assert_eq!(
967            ctx.get("flashcards_progress_indicators"),
968            Some(&json!(true))
969        );
970        assert_eq!(ctx.get("word_count"), Some(&json!(401)));
971        // 401 words at 200 wpm rounds up to 3 minutes
972        assert_eq!(ctx.get("reading_time_minutes"), Some(&json!(3)));
973        assert_eq!(ctx.get("flesch_reading_ease"), Some(&json!(65.0)));
974        assert_eq!(ctx.get("file_path"), Some(&json!("docs/b.md")));
975        assert_eq!(ctx.get("modified_timestamp"), Some(&json!(1700000000u64)));
976        assert_eq!(ctx.get("prev_page").unwrap()["url"], "/docs/a/");
977        assert_eq!(ctx.get("prev_page").unwrap()["title"], "A");
978        assert_eq!(ctx.get("next_page").unwrap()["url"], "/docs/c/");
979        // Server mode: no relative path variables
980        assert!(!ctx.contains_key("relative_base"));
981        assert!(!ctx.contains_key("relative_root"));
982        // Mode flags are the caller's responsibility (frontmatter)
983        assert!(!ctx.contains_key("server_mode"));
984        assert!(!ctx.contains_key("gui_mode"));
985        assert_eq!(
986            ctx.get("repo_id"),
987            Some(&json!(content_hash(root_dir.to_string_lossy().as_bytes())))
988        );
989    }
990
991    #[test]
992    fn test_markdown_extra_context_static_mode() {
993        let scores = crate::readability::ReadabilityScores {
994            flesch_reading_ease: None,
995            flesch_kincaid_grade: None,
996        };
997        let siblings = vec![
998            json!({"url_path": "/docs/a/", "title": "A"}),
999            json!({"url_path": "/docs/b/", "title": "B"}),
1000        ];
1001        let params = MarkdownPageParams {
1002            breadcrumb_path: Path::new("/docs/b/"),
1003            headings: &[],
1004            has_h1: false,
1005            word_count: 0,
1006            readability: &scores,
1007            file_path: "docs/b.md",
1008            modified_secs: None,
1009            current_url: "/docs/b/",
1010            siblings: &siblings,
1011        };
1012        // Static builds never have a live repo to scope storage against.
1013        let ctx = markdown_extra_context(
1014            &params,
1015            &markdown_opts(&[], None),
1016            &UrlMode::RelativeToDepth(2),
1017        );
1018
1019        // None scores serialize as JSON null
1020        assert_eq!(ctx.get("flesch_reading_ease"), Some(&json!(null)));
1021        assert!(!ctx.contains_key("modified_timestamp"));
1022        assert!(!ctx.contains_key("repo_id"));
1023        // Static mode: relative path variables present
1024        assert_eq!(ctx.get("relative_base"), Some(&json!("../../.mbr/")));
1025        assert_eq!(ctx.get("relative_root"), Some(&json!("../../")));
1026        // Prev URL is relativized; no next page (current is last)
1027        assert_eq!(
1028            ctx.get("prev_page").unwrap()["url"],
1029            json!(make_relative_url("/docs/a/", 2))
1030        );
1031        assert!(!ctx.contains_key("next_page"));
1032    }
1033
1034    #[test]
1035    fn test_markdown_extra_context_no_siblings_match() {
1036        let scores = crate::readability::ReadabilityScores {
1037            flesch_reading_ease: None,
1038            flesch_kincaid_grade: None,
1039        };
1040        let params = MarkdownPageParams {
1041            breadcrumb_path: Path::new("docs/b"),
1042            headings: &[],
1043            has_h1: false,
1044            word_count: 10,
1045            readability: &scores,
1046            file_path: "docs/b.md",
1047            modified_secs: None,
1048            current_url: "/not-in-list/",
1049            siblings: &[],
1050        };
1051        let ctx = markdown_extra_context(&params, &markdown_opts(&[], None), &UrlMode::Absolute);
1052        assert!(!ctx.contains_key("prev_page"));
1053        assert!(!ctx.contains_key("next_page"));
1054    }
1055}