1use 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
36pub enum UrlMode {
38 Absolute,
40 RelativeToDepth(usize),
42}
43
44impl UrlMode {
45 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 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
68pub enum ModeFlags {
71 Server {
72 gui_mode: Option<bool>,
75 mbr_base: bool,
78 },
79 Static {
80 depth: usize,
82 },
83}
84
85pub 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 pub tasks_enabled: bool,
95 pub review_enabled: bool,
99 pub tasks_default_include: IncludeFilter,
103 pub title_affixes: Option<(&'a str, &'a str)>,
106 pub root_dir: Option<&'a Path>,
113}
114
115pub 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
152fn 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
169pub 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
194pub 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
203pub 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
218pub 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
246pub 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
271pub 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
286pub 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
322pub struct MarkdownPageParams<'a> {
327 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 pub file_path: &'a str,
335 pub modified_secs: Option<u64>,
337 pub current_url: &'a str,
339 pub siblings: &'a [Value],
341}
342
343pub 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 pub flashcards_concentric_threshold: f64,
354 pub flashcards_progress_indicators: bool,
357 pub tasks_enabled: bool,
361 pub review_enabled: bool,
363 pub tasks_default_include: IncludeFilter,
365 pub title_prefix: &'a str,
366 pub title_suffix: &'a str,
367 pub root_dir: Option<&'a Path>,
369}
370
371pub 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 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 ctx.insert("headings".to_string(), json!(params.headings));
399 ctx.insert("has_h1".to_string(), json!(params.has_h1));
400
401 ctx.insert(
404 "tag_sources".to_string(),
405 json!(tag_sources_json(opts.tag_sources)),
406 );
407
408 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 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 ctx.insert("file_path".to_string(), json!(params.file_path));
432
433 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 if let Some(secs) = params.modified_secs {
460 ctx.insert("modified_timestamp".to_string(), json!(secs));
461 }
462
463 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 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
500pub 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 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 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 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 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 #[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 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 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 ¶ms,
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 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 assert!(!ctx.contains_key("relative_base"));
981 assert!(!ctx.contains_key("relative_root"));
982 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 let ctx = markdown_extra_context(
1014 ¶ms,
1015 &markdown_opts(&[], None),
1016 &UrlMode::RelativeToDepth(2),
1017 );
1018
1019 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 assert_eq!(ctx.get("relative_base"), Some(&json!("../../.mbr/")));
1025 assert_eq!(ctx.get("relative_root"), Some(&json!("../../")));
1026 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(¶ms, &markdown_opts(&[], None), &UrlMode::Absolute);
1052 assert!(!ctx.contains_key("prev_page"));
1053 assert!(!ctx.contains_key("next_page"));
1054 }
1055}