1use std::collections::{BTreeMap, BTreeSet};
13
14use crate::ast::Expr;
15use crate::eval::EvalCtx;
16use crate::filter;
17use crate::interactive::{self, RowView};
18use crate::model::{BaseFile, GroupBy, View};
19use crate::note::{Corpus, Note};
20use crate::parser::parse;
21use crate::semver;
22use crate::value::Value;
23
24#[derive(Debug, Clone, Default)]
26pub struct RenderOptions {
27 pub base: String,
29 pub default_view_name: String,
31 pub interactive: bool,
35 pub block_index: usize,
41}
42
43impl RenderOptions {
44 fn note_url(&self, slug: &str) -> String {
45 format!("{}/{}", self.base.trim_end_matches('/'), slug)
46 }
47}
48
49pub fn render_base(base: &BaseFile, corpus: &Corpus, opts: &RenderOptions) -> String {
52 let formulas = parse_formulas(&base.formulas);
55 let custom_summaries = parse_formulas(&base.summaries);
56
57 let mut out = String::from("<div class=\"docgen-base\">");
58 if base.views.is_empty() {
59 let default = View {
61 view_type: "table".into(),
62 ..Default::default()
63 };
64 out.push_str(&render_view(
65 &default,
66 0,
67 base,
68 corpus,
69 opts,
70 &formulas,
71 &custom_summaries,
72 ));
73 }
74 for (idx, view) in base.views.iter().enumerate() {
75 out.push_str(&render_view(
76 view,
77 idx,
78 base,
79 corpus,
80 opts,
81 &formulas,
82 &custom_summaries,
83 ));
84 }
85 out.push_str("</div>");
86 out
87}
88
89pub fn render_base_source(yaml: &str, corpus: &Corpus, opts: &RenderOptions) -> String {
92 match crate::model::parse_base(yaml) {
93 Ok(base) => render_base(&base, corpus, opts),
94 Err(e) => error_block(&format!("could not parse .base YAML: {e}")),
95 }
96}
97
98fn parse_formulas(src: &BTreeMap<String, String>) -> BTreeMap<String, Expr> {
99 src.iter()
100 .map(|(k, v)| (k.clone(), parse(v).unwrap_or(Expr::Null)))
101 .collect()
102}
103
104struct Row<'a> {
108 id: usize,
109 note: &'a Note,
110 cells: BTreeMap<String, Value>,
111}
112
113#[allow(clippy::too_many_arguments)]
114fn render_view(
115 view: &View,
116 view_index: usize,
117 base: &BaseFile,
118 corpus: &Corpus,
119 opts: &RenderOptions,
120 formulas: &BTreeMap<String, Expr>,
121 custom_summaries: &BTreeMap<String, Expr>,
122) -> String {
123 let interactive = opts.interactive && interactive::view_interactive_enabled(base, view);
128 let eff_opts_owned;
129 let opts: &RenderOptions = if interactive == opts.interactive {
130 opts
131 } else {
132 eff_opts_owned = RenderOptions {
133 interactive: false,
134 ..opts.clone()
135 };
136 &eff_opts_owned
137 };
138
139 let predicate = filter::combine(&base.filters, &view.filters);
140
141 let matching: Vec<&Note> = corpus
143 .notes
144 .iter()
145 .filter(|n| {
146 let ctx = EvalCtx::new(n, corpus, formulas);
147 predicate.matches(&ctx)
148 })
149 .collect();
150
151 let columns = resolve_columns(view, &matching, base);
153 let column_exprs: Vec<(String, Expr)> = columns
154 .iter()
155 .map(|c| (c.clone(), parse(c).unwrap_or(Expr::Null)))
156 .collect();
157
158 let mut rows: Vec<Row> = matching
160 .iter()
161 .map(|n| {
162 let ctx = EvalCtx::new(n, corpus, formulas);
163 let cells = column_exprs
164 .iter()
165 .map(|(key, expr)| (key.clone(), ctx.eval(expr)))
166 .collect();
167 Row {
168 id: 0,
169 note: n,
170 cells,
171 }
172 })
173 .collect();
174
175 apply_sort(&mut rows, view, corpus, formulas);
177
178 for (i, row) in rows.iter_mut().enumerate() {
181 row.id = i;
182 }
183
184 if let Some(limit) = view.limit {
194 rows.truncate(limit);
195 }
196
197 let name = view
199 .name
200 .clone()
201 .unwrap_or_else(|| opts.default_view_name.clone());
202
203 let body = match view.view_type.as_str() {
204 "cards" => render_cards(&rows, base, &columns, opts, corpus),
205 "list" => render_list(&rows, &columns, opts, corpus, formulas),
206 _ => render_table(
208 &rows,
209 view,
210 base,
211 &columns,
212 opts,
213 corpus,
214 formulas,
215 custom_summaries,
216 ),
217 };
218
219 let mut section = String::new();
220 if opts.interactive {
221 section.push_str(&format!(
222 "<section class=\"docgen-base-view\" data-base-view=\"{}-{view_index}\">",
223 opts.block_index
224 ));
225 } else {
226 section.push_str("<section class=\"docgen-base-view\">");
227 }
228 if !name.is_empty() {
229 section.push_str(&format!(
230 "<div class=\"docgen-base-view__title\">{}</div>",
231 escape(&name)
232 ));
233 }
234 let mut errs = Vec::new();
236 predicate.errors(&mut errs);
237 if !errs.is_empty() {
238 section.push_str(&format!(
239 "<div class=\"docgen-base-warning\">Filter parse error: {}</div>",
240 escape(&errs.join("; "))
241 ));
242 }
243 if let Some(warning) = view
248 .interactive
249 .as_ref()
250 .and_then(|iv| iv.unknown_key_warning())
251 {
252 section.push_str(&format!(
253 "<div class=\"docgen-base-warning\">{}</div>",
254 escape(&warning)
255 ));
256 }
257 if opts.interactive {
259 let row_views: Vec<RowView> = rows
260 .iter()
261 .map(|r| RowView {
262 id: r.id,
263 cells: &r.cells,
264 })
265 .collect();
266 let json = interactive::build_payload(view, base, &columns, &row_views, opts);
267 section.push_str(&format!(
268 "<script type=\"application/json\" class=\"docgen-base-data\">{json}</script>"
269 ));
270 }
271 section.push_str(&body);
272 section.push_str("</section>");
273 section
274}
275
276fn resolve_columns(view: &View, matching: &[&Note], _base: &BaseFile) -> Vec<String> {
279 if !view.order.is_empty() {
280 return view.order.clone();
281 }
282 let mut props: BTreeSet<String> = BTreeSet::new();
283 for n in matching {
284 for k in n.properties.keys() {
285 props.insert(k.clone());
286 }
287 }
288 let mut cols = vec!["file.name".to_string()];
289 cols.extend(props.into_iter().map(|p| format!("note.{p}")));
290 cols
291}
292
293fn apply_sort(
294 rows: &mut Vec<Row>,
295 view: &View,
296 corpus: &Corpus,
297 formulas: &BTreeMap<String, Expr>,
298) {
299 if view.sort.is_empty() {
300 return;
301 }
302 let keys: Vec<(Expr, bool)> = view
303 .sort
304 .iter()
305 .map(|k| (parse(k.property()).unwrap_or(Expr::Null), k.descending()))
306 .collect();
307 let mut decorated: Vec<(Vec<Value>, Row)> = rows
311 .drain(..)
312 .map(|row| {
313 let ctx = EvalCtx::new(row.note, corpus, formulas);
314 let vals = keys.iter().map(|(e, _)| ctx.eval(e)).collect();
315 (vals, row)
316 })
317 .collect();
318 let as_semver: Vec<bool> = view
321 .sort
322 .iter()
323 .enumerate()
324 .map(|(i, k)| sorts_as_semver(view, k.property(), decorated.iter().map(|d| &d.0[i])))
325 .collect();
326 decorated.sort_by(|a, b| {
327 for (i, (_, desc)) in keys.iter().enumerate() {
328 let ord = if as_semver[i] {
329 semver_cmp(&a.0[i], &b.0[i])
330 } else {
331 a.0[i].loose_cmp(&b.0[i])
332 };
333 let ord = if *desc { ord.reverse() } else { ord };
334 if ord != std::cmp::Ordering::Equal {
335 return ord;
336 }
337 }
338 std::cmp::Ordering::Equal
339 });
340 *rows = decorated.into_iter().map(|(_, row)| row).collect();
341}
342
343pub(crate) fn sorts_as_semver<'a>(
346 view: &View,
347 prop: &str,
348 values: impl Iterator<Item = &'a Value>,
349) -> bool {
350 match view
351 .interactive
352 .as_ref()
353 .and_then(|i| i.sort_as.get(prop))
354 .map(|s| s.as_str())
355 {
356 Some("semver") => true,
357 Some("text") => false,
358 _ => semver::column_is_semver(values),
361 }
362}
363
364pub(crate) fn semver_cmp(a: &Value, b: &Value) -> std::cmp::Ordering {
368 semver::column_sort_key(a).cmp(&semver::column_sort_key(b))
369}
370
371pub(crate) fn column_header(col: &str, base: &BaseFile) -> String {
375 if let Some(cfg) = base.properties.get(col) {
376 if let Some(dn) = &cfg.display_name {
377 return dn.clone();
378 }
379 }
380 let leaf = col.rsplit('.').next().unwrap_or(col);
381 humanize(leaf)
382}
383
384fn humanize(s: &str) -> String {
385 let spaced = s.replace(['_', '-'], " ");
386 let mut chars = spaced.chars();
387 match chars.next() {
388 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
389 None => String::new(),
390 }
391}
392
393#[allow(clippy::too_many_arguments)]
394fn render_table(
395 rows: &[Row],
396 view: &View,
397 base: &BaseFile,
398 columns: &[String],
399 opts: &RenderOptions,
400 corpus: &Corpus,
401 formulas: &BTreeMap<String, Expr>,
402 custom_summaries: &BTreeMap<String, Expr>,
403) -> String {
404 let group_by = view.group_by.as_ref();
405 let limit: Option<usize> = None;
407
408 let mut html = String::new();
409 html.push_str("<div class=\"docgen-table-scroll\"><table class=\"docgen-base-table\">");
411
412 html.push_str("<thead><tr>");
414 for col in columns {
415 let width = view
416 .column_size
417 .get(col)
418 .map(|w| format!(" style=\"width:{w}px\""))
419 .unwrap_or_default();
420 let data_col = if opts.interactive {
421 format!(" data-col=\"{}\"", escape(col))
422 } else {
423 String::new()
424 };
425 html.push_str(&format!(
426 "<th{width}{data_col}>{}</th>",
427 escape(&column_header(col, base))
428 ));
429 }
430 html.push_str("</tr></thead><tbody>");
431
432 let mut shown = 0usize;
436 let grouped = group_rows(rows, group_by, corpus, formulas);
437 'groups: for (group_label, group_rows) in &grouped {
438 if let Some(lim) = limit {
440 if shown >= lim {
441 break;
442 }
443 }
444 if let Some(label) = group_label {
445 let data_group = if opts.interactive {
446 format!(" data-group=\"{}\"", escape(label))
447 } else {
448 String::new()
449 };
450 html.push_str(&format!(
451 "<tr class=\"docgen-base-group\"{data_group}><td colspan=\"{}\">{}</td></tr>",
452 columns.len().max(1),
453 escape(label)
454 ));
455 }
456 for row in group_rows {
457 if let Some(lim) = limit {
458 if shown >= lim {
459 break 'groups;
460 }
461 }
462 if opts.interactive {
463 html.push_str(&format!("<tr data-row=\"{}\">", row.id));
464 } else {
465 html.push_str("<tr>");
466 }
467 for col in columns {
468 let val = row.cells.get(col).cloned().unwrap_or(Value::Null);
469 html.push_str(&format!(
470 "<td>{}</td>",
471 render_cell(&val, col, row.note, opts, corpus)
472 ));
473 }
474 html.push_str("</tr>");
475 shown += 1;
476 }
477 }
478
479 if rows.is_empty() {
480 html.push_str(&format!(
481 "<tr><td colspan=\"{}\" class=\"docgen-base-empty\">No results</td></tr>",
482 columns.len().max(1)
483 ));
484 }
485 html.push_str("</tbody>");
486
487 if !view.summaries.is_empty() {
489 html.push_str("<tfoot><tr>");
490 for col in columns {
491 let cell = match view.summaries.get(col) {
492 Some(fname) => {
493 let values: Vec<Value> = rows
494 .iter()
495 .map(|r| r.cells.get(col).cloned().unwrap_or(Value::Null))
496 .collect();
497 escape(&crate::summary::summarize(
498 fname,
499 &values,
500 custom_summaries,
501 corpus,
502 formulas,
503 ))
504 }
505 None => String::new(),
506 };
507 html.push_str(&format!("<td>{cell}</td>"));
508 }
509 html.push_str("</tr></tfoot>");
510 }
511
512 html.push_str("</table></div>");
513 html
514}
515
516fn group_rows<'a, 'b>(
519 rows: &'b [Row<'a>],
520 group_by: Option<&GroupBy>,
521 corpus: &Corpus,
522 formulas: &BTreeMap<String, Expr>,
523) -> Vec<(Option<String>, Vec<&'b Row<'a>>)> {
524 let Some(gb) = group_by else {
525 return vec![(None, rows.iter().collect())];
526 };
527 let expr = parse(gb.property()).unwrap_or(Expr::Null);
528 let mut groups: Vec<(Value, String, Vec<&Row>)> = Vec::new();
532 for row in rows {
533 let val = EvalCtx::new(row.note, corpus, formulas).eval(&expr);
534 let label = val.display();
535 match groups.iter_mut().find(|(_, l, _)| *l == label) {
536 Some((_, _, bucket)) => bucket.push(row),
537 None => groups.push((val, label, vec![row])),
538 }
539 }
540 groups.sort_by(|a, b| a.0.loose_cmp(&b.0));
541 if gb.descending() {
542 groups.reverse();
543 }
544 groups
545 .into_iter()
546 .map(|(_, label, rows)| (Some(label), rows))
547 .collect()
548}
549
550fn is_body_col(col: &str) -> bool {
553 col == "note.body" || col == "file.body"
554}
555
556fn render_cards(
557 rows: &[Row],
558 base: &BaseFile,
559 columns: &[String],
560 opts: &RenderOptions,
561 corpus: &Corpus,
562) -> String {
563 let has_body = columns.iter().any(|c| is_body_col(c));
568 let container = if has_body {
569 "docgen-base-cards docgen-base-cards--list"
570 } else {
571 "docgen-base-cards"
572 };
573 let mut html = format!("<div class=\"{container}\">");
574 for row in rows.iter() {
575 if opts.interactive {
576 html.push_str(&format!(
577 "<div class=\"docgen-base-card\" data-row=\"{}\">",
578 row.id
579 ));
580 } else {
581 html.push_str("<div class=\"docgen-base-card\">");
582 }
583 html.push_str(&format!(
585 "<div class=\"docgen-base-card__title\">{}</div>",
586 render_cell(
587 &Value::Str(row.note.basename.clone()),
588 "file.name",
589 row.note,
590 opts,
591 corpus
592 )
593 ));
594 html.push_str("<dl class=\"docgen-base-card__fields\">");
595 for col in columns {
596 if col == "file.name" || col == "file.basename" || is_body_col(col) {
599 continue;
600 }
601 let val = row.cells.get(col).cloned().unwrap_or(Value::Null);
602 if val.is_empty() {
603 continue;
604 }
605 html.push_str(&format!(
606 "<dt>{}</dt><dd>{}</dd>",
607 escape(&column_header(col, base)),
608 render_cell(&val, col, row.note, opts, corpus)
609 ));
610 }
611 html.push_str("</dl>");
612 if has_body && !row.note.body.is_empty() {
614 html.push_str(&format!(
615 "<div class=\"docgen-base-card__body\">{}</div>",
616 row.note.body
617 ));
618 }
619 html.push_str("</div>");
620 }
621 if rows.is_empty() {
622 html.push_str("<div class=\"docgen-base-empty\">No results</div>");
623 }
624 html.push_str("</div>");
625 html
626}
627
628fn render_list(
629 rows: &[Row],
630 columns: &[String],
631 opts: &RenderOptions,
632 corpus: &Corpus,
633 formulas: &BTreeMap<String, Expr>,
634) -> String {
635 let _ = (columns, formulas);
636 let mut html = String::from("<ul class=\"docgen-base-list\">");
637 for row in rows {
638 let li_open = if opts.interactive {
639 format!("<li data-row=\"{}\">", row.id)
640 } else {
641 "<li>".to_string()
642 };
643 html.push_str(&format!(
644 "{li_open}{}</li>",
645 render_cell(
646 &Value::Str(row.note.basename.clone()),
647 "file.name",
648 row.note,
649 opts,
650 corpus
651 )
652 ));
653 }
654 if rows.is_empty() {
655 html.push_str("<li class=\"docgen-base-empty\">No results</li>");
656 }
657 html.push_str("</ul>");
658 html
659}
660
661fn render_cell(
665 val: &Value,
666 col: &str,
667 note: &Note,
668 opts: &RenderOptions,
669 corpus: &Corpus,
670) -> String {
671 if matches!(col, "file.name" | "file.basename" | "file.file") && !note.slug.is_empty() {
676 return link_html(&opts.note_url(¬e.slug), ¬e.basename);
677 }
678 if col == "file.path" && !note.slug.is_empty() {
679 return link_html(&opts.note_url(¬e.slug), ¬e.path);
680 }
681 render_value(val, opts, corpus)
682}
683
684fn render_value(val: &Value, opts: &RenderOptions, corpus: &Corpus) -> String {
686 match val {
687 Value::Link(l) => {
688 let text = l
689 .display
690 .clone()
691 .unwrap_or_else(|| l.basename().to_string());
692 match resolve_link_slug(l, corpus) {
693 Some(slug) => link_html(&opts.note_url(&slug), &text),
694 None => format!(
695 "<span class=\"docgen-base-link--unresolved\">{}</span>",
696 escape(&text)
697 ),
698 }
699 }
700 Value::List(items) => items
701 .iter()
702 .map(|v| render_value(v, opts, corpus))
703 .collect::<Vec<_>>()
704 .join("<span class=\"docgen-base-sep\">, </span>"),
705 Value::Bool(b) => {
706 if *b {
708 "<span class=\"docgen-base-check\">✓</span>".to_string()
709 } else {
710 "<span class=\"docgen-base-check docgen-base-check--off\">✗</span>".to_string()
711 }
712 }
713 Value::Null => String::new(),
714 other => escape(&other.display()),
715 }
716}
717
718fn resolve_link_slug(link: &crate::value::BaseLink, corpus: &Corpus) -> Option<String> {
720 let want = link.basename().to_lowercase();
721 corpus
722 .notes
723 .iter()
724 .find(|n| {
725 n.basename.to_lowercase() == want
726 || n.path.to_lowercase() == format!("{}.md", link.path.to_lowercase())
727 })
728 .map(|n| n.slug.clone())
729}
730
731fn link_html(url: &str, text: &str) -> String {
732 format!("<a href=\"{}\">{}</a>", escape(url), escape(text))
733}
734
735pub fn error_block(message: &str) -> String {
738 format!(
739 "<div class=\"docgen-base-error\"><strong>Base error:</strong> {}</div>",
740 escape(message)
741 )
742}
743
744fn escape(s: &str) -> String {
746 let mut out = String::with_capacity(s.len());
747 for c in s.chars() {
748 match c {
749 '&' => out.push_str("&"),
750 '<' => out.push_str("<"),
751 '>' => out.push_str(">"),
752 '"' => out.push_str("""),
753 '\'' => out.push_str("'"),
754 _ => out.push(c),
755 }
756 }
757 out
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use crate::model::parse_base;
764 use crate::value::{BaseDate, BaseLink, Value};
765
766 fn date(y: i64, mo: u32, d: u32) -> Value {
767 Value::Date(BaseDate {
768 year: y,
769 month: mo,
770 day: d,
771 hour: 0,
772 minute: 0,
773 second: 0,
774 millisecond: 0,
775 has_time: false,
776 })
777 }
778
779 fn extract_payload(html: &str) -> String {
781 let marker = "class=\"docgen-base-data\">";
782 let start = html.find(marker).expect("payload script present") + marker.len();
783 let rest = &html[start..];
784 let end = rest.find("</script>").expect("payload script closes");
785 rest[..end].to_string()
786 }
787
788 fn note(slug: &str, name: &str, props: &[(&str, Value)]) -> Note {
789 let mut n = Note::default();
790 n.slug = slug.to_string();
791 n.basename = name.to_string();
792 n.name = format!("{name}.md");
793 n.path = format!("{name}.md");
794 n.ext = "md".into();
795 for (k, v) in props {
796 n.properties.insert(k.to_string(), v.clone());
797 }
798 n
799 }
800
801 fn opts() -> RenderOptions {
802 RenderOptions {
803 base: String::new(),
804 default_view_name: "Base".into(),
805 interactive: false,
806 block_index: 0,
807 }
808 }
809
810 fn interactive_opts() -> RenderOptions {
811 RenderOptions {
812 base: String::new(),
813 default_view_name: "Base".into(),
814 interactive: true,
815 block_index: 0,
816 }
817 }
818
819 #[test]
820 fn renders_table_with_filter_and_columns() {
821 let base = parse_base(
822 "filters:\n and:\n - file.hasTag(\"book\")\nviews:\n - type: table\n name: Books\n order:\n - file.name\n - note.rating\n",
823 )
824 .unwrap();
825 let mut a = note("a", "Dune", &[("rating", Value::Number(5.0))]);
826 a.tags = vec!["book".into()];
827 let mut b = note("b", "NotABook", &[("rating", Value::Number(1.0))]);
828 b.tags = vec!["film".into()];
829 let corpus = Corpus::new(vec![a, b]);
830 let html = render_base(&base, &corpus, &opts());
831 assert!(html.contains("docgen-base-table"));
832 assert!(html.contains("Books")); assert!(html.contains(">Dune<")); assert!(html.contains("href=\"/a\"")); assert!(!html.contains("NotABook")); assert!(html.contains("<th>Rating</th>")); }
838
839 #[test]
840 fn sort_descending_and_limit() {
841 let base = parse_base(
842 "views:\n - type: table\n order: [file.name]\n sort:\n - property: file.name\n direction: DESC\n limit: 2\n",
843 )
844 .unwrap();
845 let corpus = Corpus::new(vec![
846 note("a", "Apple", &[]),
847 note("b", "Banana", &[]),
848 note("c", "Cherry", &[]),
849 ]);
850 let html = render_base(&base, &corpus, &opts());
851 let cherry = html.find("Cherry").unwrap();
853 let banana = html.find("Banana").unwrap();
854 assert!(cherry < banana);
855 assert!(!html.contains("Apple"));
856 }
857
858 #[test]
859 fn resolves_wikilink_cells_to_pages() {
860 let base =
861 parse_base("views:\n - type: table\n order: [file.name, note.author]\n").unwrap();
862 let corpus = Corpus::new(vec![
863 note(
864 "books/dune",
865 "Dune",
866 &[("author", Value::Link(BaseLink::new("Herbert")))],
867 ),
868 note("people/herbert", "Herbert", &[]),
869 ]);
870 let html = render_base(&base, &corpus, &opts());
871 assert!(html.contains("href=\"/people/herbert\""));
873 }
874
875 #[test]
876 fn group_by_renders_group_rows() {
877 let base = parse_base(
878 "views:\n - type: table\n order: [file.name]\n groupBy: note.status\n",
879 )
880 .unwrap();
881 let corpus = Corpus::new(vec![
882 note("a", "A", &[("status", Value::Str("done".into()))]),
883 note("b", "B", &[("status", Value::Str("todo".into()))]),
884 ]);
885 let html = render_base(&base, &corpus, &opts());
886 assert!(html.contains("docgen-base-group"));
887 assert!(html.contains(">done<"));
888 assert!(html.contains(">todo<"));
889 }
890
891 #[test]
892 fn summary_footer() {
893 let base = parse_base(
894 "views:\n - type: table\n order: [file.name, note.price]\n summaries:\n note.price: Sum\n",
895 )
896 .unwrap();
897 let corpus = Corpus::new(vec![
898 note("a", "A", &[("price", Value::Number(2.0))]),
899 note("b", "B", &[("price", Value::Number(3.0))]),
900 ]);
901 let html = render_base(&base, &corpus, &opts());
902 assert!(html.contains("<tfoot>"));
903 assert!(html.contains(">5<")); }
905
906 #[test]
907 fn empty_results_message() {
908 let base =
909 parse_base("filters:\n and:\n - file.hasTag(\"nope\")\nviews:\n - type: table\n")
910 .unwrap();
911 let corpus = Corpus::new(vec![note("a", "A", &[])]);
912 let html = render_base(&base, &corpus, &opts());
913 assert!(html.contains("No results"));
914 }
915
916 fn version_order(extra_view_yaml: &str, versions: &[&str]) -> Vec<String> {
918 let base = parse_base(&format!(
919 "views:\n - type: table\n order: [file.name, note.version]\n sort:\n - property: note.version\n direction: ASC\n{extra_view_yaml}"
920 ))
921 .unwrap();
922 let corpus = Corpus::new(
923 versions
924 .iter()
925 .enumerate()
926 .map(|(i, v)| {
927 let name = format!("n{i}");
928 note(
929 Box::leak(name.clone().into_boxed_str()),
930 Box::leak(name.into_boxed_str()),
931 &[("version", Value::Str((*v).into()))],
932 )
933 })
934 .collect(),
935 );
936 let html = render_base(&base, &corpus, &opts());
937 versions
940 .iter()
941 .map(|v| (html.find(&format!(">{v}<")).unwrap_or(usize::MAX), *v))
942 .fold(Vec::new(), |mut acc, (pos, v)| {
943 acc.push((pos, v.to_string()));
944 acc
945 })
946 .into_iter()
947 .filter(|(p, _)| *p != usize::MAX)
948 .collect::<std::collections::BTreeMap<_, _>>()
949 .into_values()
950 .collect()
951 }
952
953 #[test]
954 fn version_column_sorts_as_semver_not_text() {
955 assert_eq!(
957 version_order("", &["1.2.12", "1.19.20", "1.0.23"]),
958 ["1.0.23", "1.2.12", "1.19.20"]
959 );
960 }
961
962 #[test]
963 fn sort_as_text_opts_out_of_semver() {
964 assert_eq!(
965 version_order(
966 " docgenInteractive:\n sortAs:\n note.version: text\n",
967 &["1.2.12", "1.19.20", "1.0.23"]
968 ),
969 ["1.0.23", "1.19.20", "1.2.12"]
970 );
971 }
972
973 #[test]
974 fn mixed_column_falls_back_to_text() {
975 assert_eq!(
978 version_order("", &["1.2.12", "nightly", "1.19.20"]),
979 ["1.19.20", "1.2.12", "nightly"]
980 );
981 }
982
983 #[test]
984 fn sort_as_semver_forces_detection_and_sorts_junk_last() {
985 assert_eq!(
986 version_order(
987 " docgenInteractive:\n sortAs:\n note.version: semver\n",
988 &["1.2.12", "nightly", "1.19.20"]
989 ),
990 ["1.2.12", "1.19.20", "nightly"]
991 );
992 }
993
994 #[test]
995 fn cards_view() {
996 let base =
997 parse_base("views:\n - type: cards\n order: [file.name, note.rating]\n").unwrap();
998 let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
999 let html = render_base(&base, &corpus, &opts());
1000 assert!(html.contains("docgen-base-cards"));
1001 assert!(html.contains("docgen-base-card__title"));
1002 }
1003
1004 #[test]
1005 fn grouped_limit_emits_no_orphan_group_header() {
1006 let base = parse_base(
1009 "views:\n - type: table\n order: [file.name]\n groupBy: note.status\n limit: 2\n",
1010 )
1011 .unwrap();
1012 let corpus = Corpus::new(vec![
1013 note("a", "A", &[("status", Value::Str("done".into()))]),
1014 note("b", "B", &[("status", Value::Str("done".into()))]),
1015 note("c", "C", &[("status", Value::Str("todo".into()))]),
1016 note("d", "D", &[("status", Value::Str("todo".into()))]),
1017 ]);
1018 let html = render_base(&base, &corpus, &opts());
1019 assert!(html.contains(">done<"));
1020 assert!(
1021 !html.contains(">todo<"),
1022 "orphan group header must not appear"
1023 );
1024 }
1025
1026 #[test]
1027 fn group_by_orders_numerically_not_lexically() {
1028 let base = parse_base(
1029 "views:\n - type: table\n order: [file.name]\n groupBy: note.priority\n",
1030 )
1031 .unwrap();
1032 let corpus = Corpus::new(vec![
1033 note("a", "A", &[("priority", Value::Number(2.0))]),
1034 note("b", "B", &[("priority", Value::Number(10.0))]),
1035 note("c", "C", &[("priority", Value::Number(1.0))]),
1036 ]);
1037 let html = render_base(&base, &corpus, &opts());
1038 let p1 = html.find(">1<").unwrap();
1040 let p2 = html.find(">2<").unwrap();
1041 let p10 = html.find(">10<").unwrap();
1042 assert!(p1 < p2 && p2 < p10, "expected 1 < 2 < 10 group order");
1043 }
1044
1045 #[test]
1046 fn cards_view_renders_note_body_as_block_and_single_column() {
1047 let base =
1048 parse_base("views:\n - type: cards\n order: [file.name, note.kind, note.body]\n")
1049 .unwrap();
1050 let mut n = note("v0-8-1", "v0-8-1", &[("kind", Value::Str("patch".into()))]);
1051 n.body = "<p>The <strong>S3</strong> TLS fix.</p>".into();
1052 let corpus = Corpus::new(vec![n]);
1053 let html = render_base(&base, &corpus, &opts());
1054 assert!(html.contains("docgen-base-cards--list"));
1056 assert!(html.contains(
1057 "<div class=\"docgen-base-card__body\"><p>The <strong>S3</strong> TLS fix.</p></div>"
1058 ));
1059 assert!(!html.contains("<dt>Body</dt>"));
1061 assert!(html.contains("patch"));
1063 }
1064
1065 #[test]
1066 fn cards_view_without_body_stays_a_grid() {
1067 let base =
1068 parse_base("views:\n - type: cards\n order: [file.name, note.kind]\n").unwrap();
1069 let corpus = Corpus::new(vec![note("a", "A", &[("kind", Value::Str("x".into()))])]);
1070 let html = render_base(&base, &corpus, &opts());
1071 assert!(html.contains("docgen-base-cards"));
1072 assert!(!html.contains("docgen-base-cards--list"));
1073 }
1074
1075 #[test]
1076 fn cards_view_honors_display_name() {
1077 let base = parse_base(
1078 "properties:\n note.status:\n displayName: Current Status\nviews:\n - type: cards\n order: [file.name, note.status]\n",
1079 )
1080 .unwrap();
1081 let corpus = Corpus::new(vec![note(
1082 "a",
1083 "A",
1084 &[("status", Value::Str("open".into()))],
1085 )]);
1086 let html = render_base(&base, &corpus, &opts());
1087 assert!(html.contains("Current Status"));
1088 assert!(!html.contains("<dt>Status</dt>"));
1089 }
1090
1091 #[test]
1092 fn malformed_yaml_yields_error_block() {
1093 let html = render_base_source("filters: [unclosed\n", &Corpus::default(), &opts());
1094 assert!(html.contains("docgen-base-error"));
1095 assert!(html.contains("could not parse"));
1096 }
1097
1098 #[test]
1099 fn base_url_prefix_applied() {
1100 let base = parse_base("views:\n - type: table\n order: [file.name]\n").unwrap();
1101 let corpus = Corpus::new(vec![note("guide/a", "A", &[])]);
1102 let o = RenderOptions {
1103 base: "/docs".into(),
1104 default_view_name: "Base".into(),
1105 interactive: false,
1106 block_index: 0,
1107 };
1108 let html = render_base(&base, &corpus, &o);
1109 assert!(html.contains("href=\"/docs/guide/a\""));
1110 }
1111
1112 #[test]
1115 fn interactive_false_is_pure_static() {
1116 let base =
1117 parse_base("views:\n - type: table\n order: [file.name, note.rating]\n").unwrap();
1118 let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
1119 let html = render_base(&base, &corpus, &opts());
1120 assert!(!html.contains("data-"));
1121 assert!(!html.contains("docgen-base-data"));
1122 }
1123
1124 #[test]
1125 fn interactive_true_adds_hooks() {
1126 let base =
1127 parse_base("views:\n - type: table\n order: [file.name, note.rating]\n").unwrap();
1128 let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
1129 let html = render_base(&base, &corpus, &interactive_opts());
1130 assert!(html.contains("data-base-view=\"0-0\""));
1132 assert!(html.contains("<script type=\"application/json\" class=\"docgen-base-data\">"));
1133 assert!(html.contains("<tr data-row=\"0\""));
1134 assert!(html.contains("<th data-col=\"file.name\""));
1135 }
1136
1137 #[test]
1138 fn payload_parses_and_limit_is_applied() {
1139 let base =
1140 parse_base("views:\n - type: table\n order: [file.name]\n limit: 1\n").unwrap();
1141 let corpus = Corpus::new(vec![
1142 note("a", "A", &[]),
1143 note("b", "B", &[]),
1144 note("c", "C", &[]),
1145 ]);
1146 let html = render_base(&base, &corpus, &interactive_opts());
1147 assert_eq!(html.matches("data-row=").count(), 1);
1150 let payload = extract_payload(&html);
1151 let v: serde_json::Value = serde_json::from_str(&payload).expect("valid JSON");
1152 assert_eq!(v["v"], 1);
1153 assert_eq!(v["columns"].as_array().unwrap().len(), 1);
1154 assert_eq!(v["rows"].as_array().unwrap().len(), 1);
1157 assert_eq!(v["view"]["limit"], serde_json::Value::Null);
1159 assert_eq!(v["controls"]["pageSize"], 0);
1161 }
1162
1163 #[test]
1167 fn unknown_docgen_interactive_key_warns_but_still_renders() {
1168 let corpus = Corpus::new(vec![note("a", "A", &[])]);
1169 let base = parse_base(
1170 "views:\n - type: table\n order: [file.name]\n docgenInteractive:\n pagesize: 25\n",
1171 )
1172 .unwrap();
1173 let html = render_base(&base, &corpus, &interactive_opts());
1174 assert!(
1175 html.contains("docgen-base-warning"),
1176 "warning must be shown"
1177 );
1178 assert!(html.contains("pagesize"), "must name the offending key");
1179 assert!(
1180 html.contains("did you mean `pageSize`?"),
1181 "a case slip is the likely typo; name the intended key"
1182 );
1183 assert!(html.contains("docgen-base-table"));
1185 assert!(html.contains(">A<"));
1186 let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
1188 assert_eq!(v["controls"]["pageSize"], 0);
1189 }
1190
1191 #[test]
1193 fn known_docgen_interactive_keys_do_not_warn() {
1194 let corpus = Corpus::new(vec![note("a", "A", &[])]);
1195 let base = parse_base(
1196 "views:\n - type: table\n order: [file.name]\n docgenInteractive:\n pageSize: 25\n search: false\n maxEnum: 10\n",
1197 )
1198 .unwrap();
1199 let html = render_base(&base, &corpus, &interactive_opts());
1200 assert!(!html.contains("docgen-base-warning"));
1201 let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
1202 assert_eq!(v["controls"]["pageSize"], 25);
1203 assert_eq!(v["controls"]["search"], false);
1204 }
1205
1206 #[test]
1210 fn limit_truncates_every_view_type() {
1211 let corpus = Corpus::new(vec![
1212 note("a", "A", &[]),
1213 note("b", "B", &[]),
1214 note("c", "C", &[]),
1215 ]);
1216 for (ty, marker) in [
1217 ("table", "<tr data-row="),
1218 ("cards", "docgen-base-card\" data-row="),
1219 ("list", "<li data-row="),
1220 ] {
1221 let base = parse_base(&format!(
1222 "views:\n - type: {ty}\n order: [file.name]\n limit: 2\n"
1223 ))
1224 .unwrap();
1225 let html = render_base(&base, &corpus, &interactive_opts());
1226 assert_eq!(
1227 html.matches(marker).count(),
1228 2,
1229 "{ty} view must honor limit: 2"
1230 );
1231 assert!(!html.contains(">C<"), "{ty}: the cut row must be absent");
1232 }
1233 }
1234
1235 #[test]
1236 fn cell_projection_shapes() {
1237 let base = parse_base(
1238 "views:\n - type: table\n order: [file.name, note.due, note.tags, note.n, note.e]\n",
1239 )
1240 .unwrap();
1241 let corpus = Corpus::new(vec![note(
1242 "a",
1243 "A",
1244 &[
1245 ("due", date(2026, 7, 15)),
1246 (
1247 "tags",
1248 Value::List(vec![Value::Str("api".into()), Value::Str("db".into())]),
1249 ),
1250 ("n", Value::Number(42.0)),
1251 ("e", Value::Null),
1252 ],
1253 )]);
1254 let html = render_base(&base, &corpus, &interactive_opts());
1255 let payload = extract_payload(&html);
1256 let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
1257 let cells = &v["rows"][0]["cells"];
1258 assert_eq!(cells["note.due"]["t"], "date");
1260 assert!(cells["note.due"]["epoch"].is_number());
1261 assert_eq!(cells["note.tags"]["t"], "list");
1263 assert_eq!(cells["note.tags"]["f"], serde_json::json!(["api", "db"]));
1264 assert_eq!(cells["note.tags"]["d"], "api, db");
1265 assert_eq!(cells["note.n"]["t"], "num");
1267 assert_eq!(cells["note.n"]["num"], 42.0);
1268 assert_eq!(cells["note.e"]["t"], "null");
1270 assert_eq!(cells["note.e"]["empty"], true);
1271 }
1272
1273 #[test]
1274 fn type_inference_dates_enum_text() {
1275 let base =
1277 parse_base("views:\n - type: table\n order: [file.name, note.due]\n").unwrap();
1278 let corpus = Corpus::new(vec![
1279 note("a", "A", &[("due", date(2026, 1, 1))]),
1280 note("b", "B", &[("due", date(2026, 2, 2))]),
1281 ]);
1282 let html = render_base(&base, &corpus, &interactive_opts());
1283 let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
1284 let due = v["columns"]
1285 .as_array()
1286 .unwrap()
1287 .iter()
1288 .find(|c| c["key"] == "note.due")
1289 .unwrap();
1290 assert_eq!(due["type"], "date");
1291 assert_eq!(due["filter"], "date");
1292
1293 let corpus2 = Corpus::new(vec![
1295 note("a", "A", &[("status", Value::Str("open".into()))]),
1296 note("b", "B", &[("status", Value::Str("open".into()))]),
1297 note("c", "C", &[("status", Value::Str("done".into()))]),
1298 ]);
1299 let base2 =
1300 parse_base("views:\n - type: table\n order: [file.name, note.status]\n").unwrap();
1301 let html2 = render_base(&base2, &corpus2, &interactive_opts());
1302 let v2: serde_json::Value = serde_json::from_str(&extract_payload(&html2)).unwrap();
1303 let st = v2["columns"]
1304 .as_array()
1305 .unwrap()
1306 .iter()
1307 .find(|c| c["key"] == "note.status")
1308 .unwrap();
1309 assert_eq!(st["type"], "str");
1310 assert_eq!(st["filter"], "enum");
1311
1312 let base3 = parse_base(
1314 "views:\n - type: table\n order: [file.name, note.status]\n docgenInteractive:\n maxEnum: 2\n",
1315 )
1316 .unwrap();
1317 let corpus3 = Corpus::new(vec![
1318 note("a", "A", &[("status", Value::Str("one".into()))]),
1319 note("b", "B", &[("status", Value::Str("two".into()))]),
1320 note("c", "C", &[("status", Value::Str("three".into()))]),
1321 ]);
1322 let html3 = render_base(&base3, &corpus3, &interactive_opts());
1323 let v3: serde_json::Value = serde_json::from_str(&extract_payload(&html3)).unwrap();
1324 let st3 = v3["columns"]
1325 .as_array()
1326 .unwrap()
1327 .iter()
1328 .find(|c| c["key"] == "note.status")
1329 .unwrap();
1330 assert_eq!(st3["filter"], "text");
1331 }
1332
1333 #[test]
1334 fn override_precedence_and_enabled_helper() {
1335 let base = parse_base(
1337 "views:\n - type: table\n order: [file.name, note.status]\n docgenInteractive:\n filters:\n note.status: text\n",
1338 )
1339 .unwrap();
1340 let corpus = Corpus::new(vec![
1341 note("a", "A", &[("status", Value::Str("open".into()))]),
1342 note("b", "B", &[("status", Value::Str("open".into()))]),
1343 ]);
1344 let html = render_base(&base, &corpus, &interactive_opts());
1345 let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
1346 let st = v["columns"]
1347 .as_array()
1348 .unwrap()
1349 .iter()
1350 .find(|c| c["key"] == "note.status")
1351 .unwrap();
1352 assert_eq!(st["filter"], "text");
1353
1354 let disabled =
1356 parse_base("views:\n - type: table\n docgenInteractive:\n enabled: false\n")
1357 .unwrap();
1358 assert!(!crate::view_interactive_enabled(
1359 &disabled,
1360 &disabled.views[0]
1361 ));
1362 let plain = parse_base("views:\n - type: table\n").unwrap();
1364 assert!(crate::view_interactive_enabled(&plain, &plain.views[0]));
1365 let base_off = parse_base("docgenInteractive: false\nviews:\n - type: table\n").unwrap();
1367 assert!(!crate::view_interactive_enabled(
1368 &base_off,
1369 &base_off.views[0]
1370 ));
1371 }
1372
1373 #[test]
1374 fn docgen_interactive_false_opts_out_end_to_end() {
1375 let corpus = Corpus::new(vec![
1379 note("a", "A", &[("status", Value::Str("open".into()))]),
1380 note("b", "B", &[("status", Value::Str("done".into()))]),
1381 ]);
1382
1383 let disabled =
1384 parse_base("docgenInteractive: false\nviews:\n - type: table\n order: [file.name, note.status]\n")
1385 .unwrap();
1386 let off = render_base(&disabled, &corpus, &interactive_opts());
1387 assert!(!off.contains("data-base-view"));
1388 assert!(!off.contains("docgen-base-data"));
1389 assert!(off.contains("docgen-base-table"));
1391
1392 let plain =
1393 parse_base("views:\n - type: table\n order: [file.name, note.status]\n").unwrap();
1394 let on = render_base(&plain, &corpus, &interactive_opts());
1395 assert!(on.contains("data-base-view"));
1396 assert!(on.contains("docgen-base-data"));
1397 }
1398
1399 #[test]
1400 fn list_column_facet_cardinality_capped() {
1401 fn list(items: &[&str]) -> Value {
1402 Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
1403 }
1404 let base =
1406 parse_base("views:\n - type: table\n order: [file.name, note.tags]\n").unwrap();
1407 let corpus = Corpus::new(vec![
1408 note("a", "A", &[("tags", list(&["x", "y"]))]),
1409 note("b", "B", &[("tags", list(&["y"]))]),
1410 ]);
1411 let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1412 &base,
1413 &corpus,
1414 &interactive_opts(),
1415 )))
1416 .unwrap();
1417 let col = v["columns"]
1418 .as_array()
1419 .unwrap()
1420 .iter()
1421 .find(|c| c["key"] == "note.tags")
1422 .unwrap();
1423 assert_eq!(col["type"], "list");
1424 assert_eq!(col["filter"], "enum");
1425
1426 let base2 = parse_base(
1428 "views:\n - type: table\n order: [file.name, note.tags]\n docgenInteractive:\n maxEnum: 2\n",
1429 )
1430 .unwrap();
1431 let corpus2 = Corpus::new(vec![
1432 note("a", "A", &[("tags", list(&["a", "b"]))]),
1433 note("b", "B", &[("tags", list(&["c", "d"]))]),
1434 ]);
1435 let v2: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1436 &base2,
1437 &corpus2,
1438 &interactive_opts(),
1439 )))
1440 .unwrap();
1441 let col2 = v2["columns"]
1442 .as_array()
1443 .unwrap()
1444 .iter()
1445 .find(|c| c["key"] == "note.tags")
1446 .unwrap();
1447 assert_eq!(col2["type"], "list");
1448 assert_eq!(
1449 col2["filter"], "text",
1450 "4 distinct tokens > maxEnum 2 → text"
1451 );
1452 }
1453
1454 #[test]
1455 fn payload_escapes_all_angle_brackets() {
1456 let base = parse_base("views:\n - type: table\n order: [file.name, note.x]\n").unwrap();
1460 let corpus = Corpus::new(vec![note(
1461 "a",
1462 "A",
1463 &[("x", Value::Str("</script><!--<script><b>hi".into()))],
1464 )]);
1465 let html = render_base(&base, &corpus, &interactive_opts());
1466 let region = extract_payload(&html);
1467 assert!(
1469 !region.contains('<'),
1470 "payload must contain no raw '<': {region}"
1471 );
1472 assert!(region.contains("\\u003c"), "escaped form present");
1473 let v: serde_json::Value = serde_json::from_str(®ion).unwrap();
1475 assert_eq!(
1476 v["rows"][0]["cells"]["note.x"]["d"],
1477 "</script><!--<script><b>hi"
1478 );
1479 }
1480
1481 #[test]
1488 fn group_by_is_not_emitted_for_non_table_views() {
1489 let corpus = Corpus::new(vec![note("a", "A", &[("status", Value::Str("x".into()))])]);
1490 for ty in ["cards", "list"] {
1491 let base = parse_base(&format!(
1492 "views:\n - type: {ty}\n order: [file.name, note.status]\n groupBy: note.status\n"
1493 ))
1494 .unwrap();
1495 let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1496 &base,
1497 &corpus,
1498 &interactive_opts(),
1499 )))
1500 .unwrap();
1501 assert_eq!(
1502 v["view"]["groupBy"],
1503 serde_json::Value::Null,
1504 "{ty} renders ungrouped, so its payload must not claim a groupBy"
1505 );
1506 }
1507
1508 let base = parse_base(
1510 "views:\n - type: table\n order: [file.name, note.status]\n groupBy: note.status\n",
1511 )
1512 .unwrap();
1513 let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1514 &base,
1515 &corpus,
1516 &interactive_opts(),
1517 )))
1518 .unwrap();
1519 assert_eq!(v["view"]["groupBy"]["col"], "note.status");
1520 }
1521
1522 #[test]
1528 fn payload_reports_the_ssr_sort_not_the_default_sort_override() {
1529 let corpus = Corpus::new(vec![note("a", "A", &[("date", Value::Number(1.0))])]);
1530 let base = parse_base(
1531 "views:\n - type: table\n order: [file.name, note.date]\n sort:\n - property: note.date\n direction: DESC\n docgenInteractive:\n defaultSort:\n - property: file.name\n direction: ASC\n",
1532 )
1533 .unwrap();
1534 let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1535 &base,
1536 &corpus,
1537 &interactive_opts(),
1538 )))
1539 .unwrap();
1540
1541 assert_eq!(v["view"]["sort"][0]["col"], "note.date");
1543 assert_eq!(v["view"]["sort"][0]["desc"], true);
1544 assert_eq!(v["controls"]["sort"][0]["col"], "file.name");
1546 assert_eq!(v["controls"]["sort"][0]["desc"], false);
1547 }
1548
1549 #[test]
1553 fn payload_ssr_sort_is_empty_when_the_view_does_not_sort() {
1554 let corpus = Corpus::new(vec![note("a", "A", &[])]);
1555 let base = parse_base(
1556 "views:\n - type: table\n order: [file.name]\n docgenInteractive:\n defaultSort:\n - property: file.name\n direction: ASC\n",
1557 )
1558 .unwrap();
1559 let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
1560 &base,
1561 &corpus,
1562 &interactive_opts(),
1563 )))
1564 .unwrap();
1565 assert_eq!(v["view"]["sort"].as_array().unwrap().len(), 0);
1566 assert_eq!(v["controls"]["sort"][0]["col"], "file.name");
1567 }
1568
1569 #[test]
1574 fn view_ids_are_namespaced_by_block() {
1575 let corpus = Corpus::new(vec![note("a", "A", &[])]);
1576 let base = parse_base("views:\n - type: table\n order: [file.name]\n").unwrap();
1577
1578 let first = render_base(&base, &corpus, &interactive_opts());
1579 let second = render_base(
1580 &base,
1581 &corpus,
1582 &RenderOptions {
1583 block_index: 1,
1584 ..interactive_opts()
1585 },
1586 );
1587 assert!(first.contains("data-base-view=\"0-0\""));
1588 assert!(second.contains("data-base-view=\"1-0\""));
1589 }
1590}