use std::collections::{BTreeMap, BTreeSet};
use crate::ast::Expr;
use crate::eval::EvalCtx;
use crate::filter;
use crate::interactive::{self, RowView};
use crate::model::{BaseFile, GroupBy, View};
use crate::note::{Corpus, Note};
use crate::parser::parse;
use crate::semver;
use crate::value::Value;
#[derive(Debug, Clone, Default)]
pub struct RenderOptions {
pub base: String,
pub default_view_name: String,
pub interactive: bool,
pub block_index: usize,
}
impl RenderOptions {
fn note_url(&self, slug: &str) -> String {
format!("{}/{}", self.base.trim_end_matches('/'), slug)
}
}
pub fn render_base(base: &BaseFile, corpus: &Corpus, opts: &RenderOptions) -> String {
let formulas = parse_formulas(&base.formulas);
let custom_summaries = parse_formulas(&base.summaries);
let mut out = String::from("<div class=\"docgen-base\">");
if base.views.is_empty() {
let default = View {
view_type: "table".into(),
..Default::default()
};
out.push_str(&render_view(
&default,
0,
base,
corpus,
opts,
&formulas,
&custom_summaries,
));
}
for (idx, view) in base.views.iter().enumerate() {
out.push_str(&render_view(
view,
idx,
base,
corpus,
opts,
&formulas,
&custom_summaries,
));
}
out.push_str("</div>");
out
}
pub fn render_base_source(yaml: &str, corpus: &Corpus, opts: &RenderOptions) -> String {
match crate::model::parse_base(yaml) {
Ok(base) => render_base(&base, corpus, opts),
Err(e) => error_block(&format!("could not parse .base YAML: {e}")),
}
}
fn parse_formulas(src: &BTreeMap<String, String>) -> BTreeMap<String, Expr> {
src.iter()
.map(|(k, v)| (k.clone(), parse(v).unwrap_or(Expr::Null)))
.collect()
}
struct Row<'a> {
id: usize,
note: &'a Note,
cells: BTreeMap<String, Value>,
}
#[allow(clippy::too_many_arguments)]
fn render_view(
view: &View,
view_index: usize,
base: &BaseFile,
corpus: &Corpus,
opts: &RenderOptions,
formulas: &BTreeMap<String, Expr>,
custom_summaries: &BTreeMap<String, Expr>,
) -> String {
let interactive = opts.interactive && interactive::view_interactive_enabled(base, view);
let eff_opts_owned;
let opts: &RenderOptions = if interactive == opts.interactive {
opts
} else {
eff_opts_owned = RenderOptions {
interactive: false,
..opts.clone()
};
&eff_opts_owned
};
let predicate = filter::combine(&base.filters, &view.filters);
let matching: Vec<&Note> = corpus
.notes
.iter()
.filter(|n| {
let ctx = EvalCtx::new(n, corpus, formulas);
predicate.matches(&ctx)
})
.collect();
let columns = resolve_columns(view, &matching, base);
let column_exprs: Vec<(String, Expr)> = columns
.iter()
.map(|c| (c.clone(), parse(c).unwrap_or(Expr::Null)))
.collect();
let mut rows: Vec<Row> = matching
.iter()
.map(|n| {
let ctx = EvalCtx::new(n, corpus, formulas);
let cells = column_exprs
.iter()
.map(|(key, expr)| (key.clone(), ctx.eval(expr)))
.collect();
Row {
id: 0,
note: n,
cells,
}
})
.collect();
apply_sort(&mut rows, view, corpus, formulas);
for (i, row) in rows.iter_mut().enumerate() {
row.id = i;
}
if let Some(limit) = view.limit {
rows.truncate(limit);
}
let name = view
.name
.clone()
.unwrap_or_else(|| opts.default_view_name.clone());
let body = match view.view_type.as_str() {
"cards" => render_cards(&rows, base, &columns, opts, corpus),
"list" => render_list(&rows, &columns, opts, corpus, formulas),
_ => render_table(
&rows,
view,
base,
&columns,
opts,
corpus,
formulas,
custom_summaries,
),
};
let mut section = String::new();
if opts.interactive {
section.push_str(&format!(
"<section class=\"docgen-base-view\" data-base-view=\"{}-{view_index}\">",
opts.block_index
));
} else {
section.push_str("<section class=\"docgen-base-view\">");
}
if !name.is_empty() {
section.push_str(&format!(
"<div class=\"docgen-base-view__title\">{}</div>",
escape(&name)
));
}
let mut errs = Vec::new();
predicate.errors(&mut errs);
if !errs.is_empty() {
section.push_str(&format!(
"<div class=\"docgen-base-warning\">Filter parse error: {}</div>",
escape(&errs.join("; "))
));
}
if let Some(warning) = view
.interactive
.as_ref()
.and_then(|iv| iv.unknown_key_warning())
{
section.push_str(&format!(
"<div class=\"docgen-base-warning\">{}</div>",
escape(&warning)
));
}
if opts.interactive {
let row_views: Vec<RowView> = rows
.iter()
.map(|r| RowView {
id: r.id,
cells: &r.cells,
})
.collect();
let json = interactive::build_payload(view, base, &columns, &row_views, opts);
section.push_str(&format!(
"<script type=\"application/json\" class=\"docgen-base-data\">{json}</script>"
));
}
section.push_str(&body);
section.push_str("</section>");
section
}
fn resolve_columns(view: &View, matching: &[&Note], _base: &BaseFile) -> Vec<String> {
if !view.order.is_empty() {
return view.order.clone();
}
let mut props: BTreeSet<String> = BTreeSet::new();
for n in matching {
for k in n.properties.keys() {
props.insert(k.clone());
}
}
let mut cols = vec!["file.name".to_string()];
cols.extend(props.into_iter().map(|p| format!("note.{p}")));
cols
}
fn apply_sort(
rows: &mut Vec<Row>,
view: &View,
corpus: &Corpus,
formulas: &BTreeMap<String, Expr>,
) {
if view.sort.is_empty() {
return;
}
let keys: Vec<(Expr, bool)> = view
.sort
.iter()
.map(|k| (parse(k.property()).unwrap_or(Expr::Null), k.descending()))
.collect();
let mut decorated: Vec<(Vec<Value>, Row)> = rows
.drain(..)
.map(|row| {
let ctx = EvalCtx::new(row.note, corpus, formulas);
let vals = keys.iter().map(|(e, _)| ctx.eval(e)).collect();
(vals, row)
})
.collect();
let as_semver: Vec<bool> = view
.sort
.iter()
.enumerate()
.map(|(i, k)| sorts_as_semver(view, k.property(), decorated.iter().map(|d| &d.0[i])))
.collect();
decorated.sort_by(|a, b| {
for (i, (_, desc)) in keys.iter().enumerate() {
let ord = if as_semver[i] {
semver_cmp(&a.0[i], &b.0[i])
} else {
a.0[i].loose_cmp(&b.0[i])
};
let ord = if *desc { ord.reverse() } else { ord };
if ord != std::cmp::Ordering::Equal {
return ord;
}
}
std::cmp::Ordering::Equal
});
*rows = decorated.into_iter().map(|(_, row)| row).collect();
}
pub(crate) fn sorts_as_semver<'a>(
view: &View,
prop: &str,
values: impl Iterator<Item = &'a Value>,
) -> bool {
match view
.interactive
.as_ref()
.and_then(|i| i.sort_as.get(prop))
.map(|s| s.as_str())
{
Some("semver") => true,
Some("text") => false,
_ => semver::column_is_semver(values),
}
}
pub(crate) fn semver_cmp(a: &Value, b: &Value) -> std::cmp::Ordering {
semver::column_sort_key(a).cmp(&semver::column_sort_key(b))
}
pub(crate) fn column_header(col: &str, base: &BaseFile) -> String {
if let Some(cfg) = base.properties.get(col) {
if let Some(dn) = &cfg.display_name {
return dn.clone();
}
}
let leaf = col.rsplit('.').next().unwrap_or(col);
humanize(leaf)
}
fn humanize(s: &str) -> String {
let spaced = s.replace(['_', '-'], " ");
let mut chars = spaced.chars();
match chars.next() {
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
None => String::new(),
}
}
#[allow(clippy::too_many_arguments)]
fn render_table(
rows: &[Row],
view: &View,
base: &BaseFile,
columns: &[String],
opts: &RenderOptions,
corpus: &Corpus,
formulas: &BTreeMap<String, Expr>,
custom_summaries: &BTreeMap<String, Expr>,
) -> String {
let group_by = view.group_by.as_ref();
let limit: Option<usize> = None;
let mut html = String::new();
html.push_str("<div class=\"docgen-table-scroll\"><table class=\"docgen-base-table\">");
html.push_str("<thead><tr>");
for col in columns {
let width = view
.column_size
.get(col)
.map(|w| format!(" style=\"width:{w}px\""))
.unwrap_or_default();
let data_col = if opts.interactive {
format!(" data-col=\"{}\"", escape(col))
} else {
String::new()
};
html.push_str(&format!(
"<th{width}{data_col}>{}</th>",
escape(&column_header(col, base))
));
}
html.push_str("</tr></thead><tbody>");
let mut shown = 0usize;
let grouped = group_rows(rows, group_by, corpus, formulas);
'groups: for (group_label, group_rows) in &grouped {
if let Some(lim) = limit {
if shown >= lim {
break;
}
}
if let Some(label) = group_label {
let data_group = if opts.interactive {
format!(" data-group=\"{}\"", escape(label))
} else {
String::new()
};
html.push_str(&format!(
"<tr class=\"docgen-base-group\"{data_group}><td colspan=\"{}\">{}</td></tr>",
columns.len().max(1),
escape(label)
));
}
for row in group_rows {
if let Some(lim) = limit {
if shown >= lim {
break 'groups;
}
}
if opts.interactive {
html.push_str(&format!("<tr data-row=\"{}\">", row.id));
} else {
html.push_str("<tr>");
}
for col in columns {
let val = row.cells.get(col).cloned().unwrap_or(Value::Null);
html.push_str(&format!(
"<td>{}</td>",
render_cell(&val, col, row.note, opts, corpus)
));
}
html.push_str("</tr>");
shown += 1;
}
}
if rows.is_empty() {
html.push_str(&format!(
"<tr><td colspan=\"{}\" class=\"docgen-base-empty\">No results</td></tr>",
columns.len().max(1)
));
}
html.push_str("</tbody>");
if !view.summaries.is_empty() {
html.push_str("<tfoot><tr>");
for col in columns {
let cell = match view.summaries.get(col) {
Some(fname) => {
let values: Vec<Value> = rows
.iter()
.map(|r| r.cells.get(col).cloned().unwrap_or(Value::Null))
.collect();
escape(&crate::summary::summarize(
fname,
&values,
custom_summaries,
corpus,
formulas,
))
}
None => String::new(),
};
html.push_str(&format!("<td>{cell}</td>"));
}
html.push_str("</tr></tfoot>");
}
html.push_str("</table></div>");
html
}
fn group_rows<'a, 'b>(
rows: &'b [Row<'a>],
group_by: Option<&GroupBy>,
corpus: &Corpus,
formulas: &BTreeMap<String, Expr>,
) -> Vec<(Option<String>, Vec<&'b Row<'a>>)> {
let Some(gb) = group_by else {
return vec![(None, rows.iter().collect())];
};
let expr = parse(gb.property()).unwrap_or(Expr::Null);
let mut groups: Vec<(Value, String, Vec<&Row>)> = Vec::new();
for row in rows {
let val = EvalCtx::new(row.note, corpus, formulas).eval(&expr);
let label = val.display();
match groups.iter_mut().find(|(_, l, _)| *l == label) {
Some((_, _, bucket)) => bucket.push(row),
None => groups.push((val, label, vec![row])),
}
}
groups.sort_by(|a, b| a.0.loose_cmp(&b.0));
if gb.descending() {
groups.reverse();
}
groups
.into_iter()
.map(|(_, label, rows)| (Some(label), rows))
.collect()
}
fn is_body_col(col: &str) -> bool {
col == "note.body" || col == "file.body"
}
fn render_cards(
rows: &[Row],
base: &BaseFile,
columns: &[String],
opts: &RenderOptions,
corpus: &Corpus,
) -> String {
let has_body = columns.iter().any(|c| is_body_col(c));
let container = if has_body {
"docgen-base-cards docgen-base-cards--list"
} else {
"docgen-base-cards"
};
let mut html = format!("<div class=\"{container}\">");
for row in rows.iter() {
if opts.interactive {
html.push_str(&format!(
"<div class=\"docgen-base-card\" data-row=\"{}\">",
row.id
));
} else {
html.push_str("<div class=\"docgen-base-card\">");
}
html.push_str(&format!(
"<div class=\"docgen-base-card__title\">{}</div>",
render_cell(
&Value::Str(row.note.basename.clone()),
"file.name",
row.note,
opts,
corpus
)
));
html.push_str("<dl class=\"docgen-base-card__fields\">");
for col in columns {
if col == "file.name" || col == "file.basename" || is_body_col(col) {
continue;
}
let val = row.cells.get(col).cloned().unwrap_or(Value::Null);
if val.is_empty() {
continue;
}
html.push_str(&format!(
"<dt>{}</dt><dd>{}</dd>",
escape(&column_header(col, base)),
render_cell(&val, col, row.note, opts, corpus)
));
}
html.push_str("</dl>");
if has_body && !row.note.body.is_empty() {
html.push_str(&format!(
"<div class=\"docgen-base-card__body\">{}</div>",
row.note.body
));
}
html.push_str("</div>");
}
if rows.is_empty() {
html.push_str("<div class=\"docgen-base-empty\">No results</div>");
}
html.push_str("</div>");
html
}
fn render_list(
rows: &[Row],
columns: &[String],
opts: &RenderOptions,
corpus: &Corpus,
formulas: &BTreeMap<String, Expr>,
) -> String {
let _ = (columns, formulas);
let mut html = String::from("<ul class=\"docgen-base-list\">");
for row in rows {
let li_open = if opts.interactive {
format!("<li data-row=\"{}\">", row.id)
} else {
"<li>".to_string()
};
html.push_str(&format!(
"{li_open}{}</li>",
render_cell(
&Value::Str(row.note.basename.clone()),
"file.name",
row.note,
opts,
corpus
)
));
}
if rows.is_empty() {
html.push_str("<li class=\"docgen-base-empty\">No results</li>");
}
html.push_str("</ul>");
html
}
fn render_cell(
val: &Value,
col: &str,
note: &Note,
opts: &RenderOptions,
corpus: &Corpus,
) -> String {
if matches!(col, "file.name" | "file.basename" | "file.file") && !note.slug.is_empty() {
return link_html(&opts.note_url(¬e.slug), ¬e.basename);
}
if col == "file.path" && !note.slug.is_empty() {
return link_html(&opts.note_url(¬e.slug), ¬e.path);
}
render_value(val, opts, corpus)
}
fn render_value(val: &Value, opts: &RenderOptions, corpus: &Corpus) -> String {
match val {
Value::Link(l) => {
let text = l
.display
.clone()
.unwrap_or_else(|| l.basename().to_string());
match resolve_link_slug(l, corpus) {
Some(slug) => link_html(&opts.note_url(&slug), &text),
None => format!(
"<span class=\"docgen-base-link--unresolved\">{}</span>",
escape(&text)
),
}
}
Value::List(items) => items
.iter()
.map(|v| render_value(v, opts, corpus))
.collect::<Vec<_>>()
.join("<span class=\"docgen-base-sep\">, </span>"),
Value::Bool(b) => {
if *b {
"<span class=\"docgen-base-check\">✓</span>".to_string()
} else {
"<span class=\"docgen-base-check docgen-base-check--off\">✗</span>".to_string()
}
}
Value::Null => String::new(),
other => escape(&other.display()),
}
}
fn resolve_link_slug(link: &crate::value::BaseLink, corpus: &Corpus) -> Option<String> {
let want = link.basename().to_lowercase();
corpus
.notes
.iter()
.find(|n| {
n.basename.to_lowercase() == want
|| n.path.to_lowercase() == format!("{}.md", link.path.to_lowercase())
})
.map(|n| n.slug.clone())
}
fn link_html(url: &str, text: &str) -> String {
format!("<a href=\"{}\">{}</a>", escape(url), escape(text))
}
pub fn error_block(message: &str) -> String {
format!(
"<div class=\"docgen-base-error\"><strong>Base error:</strong> {}</div>",
escape(message)
)
}
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::parse_base;
use crate::value::{BaseDate, BaseLink, Value};
fn date(y: i64, mo: u32, d: u32) -> Value {
Value::Date(BaseDate {
year: y,
month: mo,
day: d,
hour: 0,
minute: 0,
second: 0,
millisecond: 0,
has_time: false,
})
}
fn extract_payload(html: &str) -> String {
let marker = "class=\"docgen-base-data\">";
let start = html.find(marker).expect("payload script present") + marker.len();
let rest = &html[start..];
let end = rest.find("</script>").expect("payload script closes");
rest[..end].to_string()
}
fn note(slug: &str, name: &str, props: &[(&str, Value)]) -> Note {
let mut n = Note::default();
n.slug = slug.to_string();
n.basename = name.to_string();
n.name = format!("{name}.md");
n.path = format!("{name}.md");
n.ext = "md".into();
for (k, v) in props {
n.properties.insert(k.to_string(), v.clone());
}
n
}
fn opts() -> RenderOptions {
RenderOptions {
base: String::new(),
default_view_name: "Base".into(),
interactive: false,
block_index: 0,
}
}
fn interactive_opts() -> RenderOptions {
RenderOptions {
base: String::new(),
default_view_name: "Base".into(),
interactive: true,
block_index: 0,
}
}
#[test]
fn renders_table_with_filter_and_columns() {
let base = parse_base(
"filters:\n and:\n - file.hasTag(\"book\")\nviews:\n - type: table\n name: Books\n order:\n - file.name\n - note.rating\n",
)
.unwrap();
let mut a = note("a", "Dune", &[("rating", Value::Number(5.0))]);
a.tags = vec!["book".into()];
let mut b = note("b", "NotABook", &[("rating", Value::Number(1.0))]);
b.tags = vec!["film".into()];
let corpus = Corpus::new(vec![a, b]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("docgen-base-table"));
assert!(html.contains("Books")); assert!(html.contains(">Dune<")); assert!(html.contains("href=\"/a\"")); assert!(!html.contains("NotABook")); assert!(html.contains("<th>Rating</th>")); }
#[test]
fn sort_descending_and_limit() {
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n sort:\n - property: file.name\n direction: DESC\n limit: 2\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "Apple", &[]),
note("b", "Banana", &[]),
note("c", "Cherry", &[]),
]);
let html = render_base(&base, &corpus, &opts());
let cherry = html.find("Cherry").unwrap();
let banana = html.find("Banana").unwrap();
assert!(cherry < banana);
assert!(!html.contains("Apple"));
}
#[test]
fn resolves_wikilink_cells_to_pages() {
let base =
parse_base("views:\n - type: table\n order: [file.name, note.author]\n").unwrap();
let corpus = Corpus::new(vec![
note(
"books/dune",
"Dune",
&[("author", Value::Link(BaseLink::new("Herbert")))],
),
note("people/herbert", "Herbert", &[]),
]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("href=\"/people/herbert\""));
}
#[test]
fn group_by_renders_group_rows() {
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n groupBy: note.status\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("done".into()))]),
note("b", "B", &[("status", Value::Str("todo".into()))]),
]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("docgen-base-group"));
assert!(html.contains(">done<"));
assert!(html.contains(">todo<"));
}
#[test]
fn summary_footer() {
let base = parse_base(
"views:\n - type: table\n order: [file.name, note.price]\n summaries:\n note.price: Sum\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("price", Value::Number(2.0))]),
note("b", "B", &[("price", Value::Number(3.0))]),
]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("<tfoot>"));
assert!(html.contains(">5<")); }
#[test]
fn empty_results_message() {
let base =
parse_base("filters:\n and:\n - file.hasTag(\"nope\")\nviews:\n - type: table\n")
.unwrap();
let corpus = Corpus::new(vec![note("a", "A", &[])]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("No results"));
}
fn version_order(extra_view_yaml: &str, versions: &[&str]) -> Vec<String> {
let base = parse_base(&format!(
"views:\n - type: table\n order: [file.name, note.version]\n sort:\n - property: note.version\n direction: ASC\n{extra_view_yaml}"
))
.unwrap();
let corpus = Corpus::new(
versions
.iter()
.enumerate()
.map(|(i, v)| {
let name = format!("n{i}");
note(
Box::leak(name.clone().into_boxed_str()),
Box::leak(name.into_boxed_str()),
&[("version", Value::Str((*v).into()))],
)
})
.collect(),
);
let html = render_base(&base, &corpus, &opts());
versions
.iter()
.map(|v| (html.find(&format!(">{v}<")).unwrap_or(usize::MAX), *v))
.fold(Vec::new(), |mut acc, (pos, v)| {
acc.push((pos, v.to_string()));
acc
})
.into_iter()
.filter(|(p, _)| *p != usize::MAX)
.collect::<std::collections::BTreeMap<_, _>>()
.into_values()
.collect()
}
#[test]
fn version_column_sorts_as_semver_not_text() {
assert_eq!(
version_order("", &["1.2.12", "1.19.20", "1.0.23"]),
["1.0.23", "1.2.12", "1.19.20"]
);
}
#[test]
fn sort_as_text_opts_out_of_semver() {
assert_eq!(
version_order(
" docgenInteractive:\n sortAs:\n note.version: text\n",
&["1.2.12", "1.19.20", "1.0.23"]
),
["1.0.23", "1.19.20", "1.2.12"]
);
}
#[test]
fn mixed_column_falls_back_to_text() {
assert_eq!(
version_order("", &["1.2.12", "nightly", "1.19.20"]),
["1.19.20", "1.2.12", "nightly"]
);
}
#[test]
fn sort_as_semver_forces_detection_and_sorts_junk_last() {
assert_eq!(
version_order(
" docgenInteractive:\n sortAs:\n note.version: semver\n",
&["1.2.12", "nightly", "1.19.20"]
),
["1.2.12", "1.19.20", "nightly"]
);
}
#[test]
fn cards_view() {
let base =
parse_base("views:\n - type: cards\n order: [file.name, note.rating]\n").unwrap();
let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("docgen-base-cards"));
assert!(html.contains("docgen-base-card__title"));
}
#[test]
fn grouped_limit_emits_no_orphan_group_header() {
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n groupBy: note.status\n limit: 2\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("done".into()))]),
note("b", "B", &[("status", Value::Str("done".into()))]),
note("c", "C", &[("status", Value::Str("todo".into()))]),
note("d", "D", &[("status", Value::Str("todo".into()))]),
]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains(">done<"));
assert!(
!html.contains(">todo<"),
"orphan group header must not appear"
);
}
#[test]
fn group_by_orders_numerically_not_lexically() {
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n groupBy: note.priority\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("priority", Value::Number(2.0))]),
note("b", "B", &[("priority", Value::Number(10.0))]),
note("c", "C", &[("priority", Value::Number(1.0))]),
]);
let html = render_base(&base, &corpus, &opts());
let p1 = html.find(">1<").unwrap();
let p2 = html.find(">2<").unwrap();
let p10 = html.find(">10<").unwrap();
assert!(p1 < p2 && p2 < p10, "expected 1 < 2 < 10 group order");
}
#[test]
fn cards_view_renders_note_body_as_block_and_single_column() {
let base =
parse_base("views:\n - type: cards\n order: [file.name, note.kind, note.body]\n")
.unwrap();
let mut n = note("v0-8-1", "v0-8-1", &[("kind", Value::Str("patch".into()))]);
n.body = "<p>The <strong>S3</strong> TLS fix.</p>".into();
let corpus = Corpus::new(vec![n]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("docgen-base-cards--list"));
assert!(html.contains(
"<div class=\"docgen-base-card__body\"><p>The <strong>S3</strong> TLS fix.</p></div>"
));
assert!(!html.contains("<dt>Body</dt>"));
assert!(html.contains("patch"));
}
#[test]
fn cards_view_without_body_stays_a_grid() {
let base =
parse_base("views:\n - type: cards\n order: [file.name, note.kind]\n").unwrap();
let corpus = Corpus::new(vec![note("a", "A", &[("kind", Value::Str("x".into()))])]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("docgen-base-cards"));
assert!(!html.contains("docgen-base-cards--list"));
}
#[test]
fn cards_view_honors_display_name() {
let base = parse_base(
"properties:\n note.status:\n displayName: Current Status\nviews:\n - type: cards\n order: [file.name, note.status]\n",
)
.unwrap();
let corpus = Corpus::new(vec![note(
"a",
"A",
&[("status", Value::Str("open".into()))],
)]);
let html = render_base(&base, &corpus, &opts());
assert!(html.contains("Current Status"));
assert!(!html.contains("<dt>Status</dt>"));
}
#[test]
fn malformed_yaml_yields_error_block() {
let html = render_base_source("filters: [unclosed\n", &Corpus::default(), &opts());
assert!(html.contains("docgen-base-error"));
assert!(html.contains("could not parse"));
}
#[test]
fn base_url_prefix_applied() {
let base = parse_base("views:\n - type: table\n order: [file.name]\n").unwrap();
let corpus = Corpus::new(vec![note("guide/a", "A", &[])]);
let o = RenderOptions {
base: "/docs".into(),
default_view_name: "Base".into(),
interactive: false,
block_index: 0,
};
let html = render_base(&base, &corpus, &o);
assert!(html.contains("href=\"/docs/guide/a\""));
}
#[test]
fn interactive_false_is_pure_static() {
let base =
parse_base("views:\n - type: table\n order: [file.name, note.rating]\n").unwrap();
let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
let html = render_base(&base, &corpus, &opts());
assert!(!html.contains("data-"));
assert!(!html.contains("docgen-base-data"));
}
#[test]
fn interactive_true_adds_hooks() {
let base =
parse_base("views:\n - type: table\n order: [file.name, note.rating]\n").unwrap();
let corpus = Corpus::new(vec![note("a", "A", &[("rating", Value::Number(5.0))])]);
let html = render_base(&base, &corpus, &interactive_opts());
assert!(html.contains("data-base-view=\"0-0\""));
assert!(html.contains("<script type=\"application/json\" class=\"docgen-base-data\">"));
assert!(html.contains("<tr data-row=\"0\""));
assert!(html.contains("<th data-col=\"file.name\""));
}
#[test]
fn payload_parses_and_limit_is_applied() {
let base =
parse_base("views:\n - type: table\n order: [file.name]\n limit: 1\n").unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[]),
note("b", "B", &[]),
note("c", "C", &[]),
]);
let html = render_base(&base, &corpus, &interactive_opts());
assert_eq!(html.matches("data-row=").count(), 1);
let payload = extract_payload(&html);
let v: serde_json::Value = serde_json::from_str(&payload).expect("valid JSON");
assert_eq!(v["v"], 1);
assert_eq!(v["columns"].as_array().unwrap().len(), 1);
assert_eq!(v["rows"].as_array().unwrap().len(), 1);
assert_eq!(v["view"]["limit"], serde_json::Value::Null);
assert_eq!(v["controls"]["pageSize"], 0);
}
#[test]
fn unknown_docgen_interactive_key_warns_but_still_renders() {
let corpus = Corpus::new(vec![note("a", "A", &[])]);
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n docgenInteractive:\n pagesize: 25\n",
)
.unwrap();
let html = render_base(&base, &corpus, &interactive_opts());
assert!(
html.contains("docgen-base-warning"),
"warning must be shown"
);
assert!(html.contains("pagesize"), "must name the offending key");
assert!(
html.contains("did you mean `pageSize`?"),
"a case slip is the likely typo; name the intended key"
);
assert!(html.contains("docgen-base-table"));
assert!(html.contains(">A<"));
let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
assert_eq!(v["controls"]["pageSize"], 0);
}
#[test]
fn known_docgen_interactive_keys_do_not_warn() {
let corpus = Corpus::new(vec![note("a", "A", &[])]);
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n docgenInteractive:\n pageSize: 25\n search: false\n maxEnum: 10\n",
)
.unwrap();
let html = render_base(&base, &corpus, &interactive_opts());
assert!(!html.contains("docgen-base-warning"));
let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
assert_eq!(v["controls"]["pageSize"], 25);
assert_eq!(v["controls"]["search"], false);
}
#[test]
fn limit_truncates_every_view_type() {
let corpus = Corpus::new(vec![
note("a", "A", &[]),
note("b", "B", &[]),
note("c", "C", &[]),
]);
for (ty, marker) in [
("table", "<tr data-row="),
("cards", "docgen-base-card\" data-row="),
("list", "<li data-row="),
] {
let base = parse_base(&format!(
"views:\n - type: {ty}\n order: [file.name]\n limit: 2\n"
))
.unwrap();
let html = render_base(&base, &corpus, &interactive_opts());
assert_eq!(
html.matches(marker).count(),
2,
"{ty} view must honor limit: 2"
);
assert!(!html.contains(">C<"), "{ty}: the cut row must be absent");
}
}
#[test]
fn cell_projection_shapes() {
let base = parse_base(
"views:\n - type: table\n order: [file.name, note.due, note.tags, note.n, note.e]\n",
)
.unwrap();
let corpus = Corpus::new(vec![note(
"a",
"A",
&[
("due", date(2026, 7, 15)),
(
"tags",
Value::List(vec![Value::Str("api".into()), Value::Str("db".into())]),
),
("n", Value::Number(42.0)),
("e", Value::Null),
],
)]);
let html = render_base(&base, &corpus, &interactive_opts());
let payload = extract_payload(&html);
let v: serde_json::Value = serde_json::from_str(&payload).unwrap();
let cells = &v["rows"][0]["cells"];
assert_eq!(cells["note.due"]["t"], "date");
assert!(cells["note.due"]["epoch"].is_number());
assert_eq!(cells["note.tags"]["t"], "list");
assert_eq!(cells["note.tags"]["f"], serde_json::json!(["api", "db"]));
assert_eq!(cells["note.tags"]["d"], "api, db");
assert_eq!(cells["note.n"]["t"], "num");
assert_eq!(cells["note.n"]["num"], 42.0);
assert_eq!(cells["note.e"]["t"], "null");
assert_eq!(cells["note.e"]["empty"], true);
}
#[test]
fn type_inference_dates_enum_text() {
let base =
parse_base("views:\n - type: table\n order: [file.name, note.due]\n").unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("due", date(2026, 1, 1))]),
note("b", "B", &[("due", date(2026, 2, 2))]),
]);
let html = render_base(&base, &corpus, &interactive_opts());
let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
let due = v["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.due")
.unwrap();
assert_eq!(due["type"], "date");
assert_eq!(due["filter"], "date");
let corpus2 = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("open".into()))]),
note("b", "B", &[("status", Value::Str("open".into()))]),
note("c", "C", &[("status", Value::Str("done".into()))]),
]);
let base2 =
parse_base("views:\n - type: table\n order: [file.name, note.status]\n").unwrap();
let html2 = render_base(&base2, &corpus2, &interactive_opts());
let v2: serde_json::Value = serde_json::from_str(&extract_payload(&html2)).unwrap();
let st = v2["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.status")
.unwrap();
assert_eq!(st["type"], "str");
assert_eq!(st["filter"], "enum");
let base3 = parse_base(
"views:\n - type: table\n order: [file.name, note.status]\n docgenInteractive:\n maxEnum: 2\n",
)
.unwrap();
let corpus3 = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("one".into()))]),
note("b", "B", &[("status", Value::Str("two".into()))]),
note("c", "C", &[("status", Value::Str("three".into()))]),
]);
let html3 = render_base(&base3, &corpus3, &interactive_opts());
let v3: serde_json::Value = serde_json::from_str(&extract_payload(&html3)).unwrap();
let st3 = v3["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.status")
.unwrap();
assert_eq!(st3["filter"], "text");
}
#[test]
fn override_precedence_and_enabled_helper() {
let base = parse_base(
"views:\n - type: table\n order: [file.name, note.status]\n docgenInteractive:\n filters:\n note.status: text\n",
)
.unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("open".into()))]),
note("b", "B", &[("status", Value::Str("open".into()))]),
]);
let html = render_base(&base, &corpus, &interactive_opts());
let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
let st = v["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.status")
.unwrap();
assert_eq!(st["filter"], "text");
let disabled =
parse_base("views:\n - type: table\n docgenInteractive:\n enabled: false\n")
.unwrap();
assert!(!crate::view_interactive_enabled(
&disabled,
&disabled.views[0]
));
let plain = parse_base("views:\n - type: table\n").unwrap();
assert!(crate::view_interactive_enabled(&plain, &plain.views[0]));
let base_off = parse_base("docgenInteractive: false\nviews:\n - type: table\n").unwrap();
assert!(!crate::view_interactive_enabled(
&base_off,
&base_off.views[0]
));
}
#[test]
fn docgen_interactive_false_opts_out_end_to_end() {
let corpus = Corpus::new(vec![
note("a", "A", &[("status", Value::Str("open".into()))]),
note("b", "B", &[("status", Value::Str("done".into()))]),
]);
let disabled =
parse_base("docgenInteractive: false\nviews:\n - type: table\n order: [file.name, note.status]\n")
.unwrap();
let off = render_base(&disabled, &corpus, &interactive_opts());
assert!(!off.contains("data-base-view"));
assert!(!off.contains("docgen-base-data"));
assert!(off.contains("docgen-base-table"));
let plain =
parse_base("views:\n - type: table\n order: [file.name, note.status]\n").unwrap();
let on = render_base(&plain, &corpus, &interactive_opts());
assert!(on.contains("data-base-view"));
assert!(on.contains("docgen-base-data"));
}
#[test]
fn list_column_facet_cardinality_capped() {
fn list(items: &[&str]) -> Value {
Value::List(items.iter().map(|s| Value::Str((*s).into())).collect())
}
let base =
parse_base("views:\n - type: table\n order: [file.name, note.tags]\n").unwrap();
let corpus = Corpus::new(vec![
note("a", "A", &[("tags", list(&["x", "y"]))]),
note("b", "B", &[("tags", list(&["y"]))]),
]);
let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base,
&corpus,
&interactive_opts(),
)))
.unwrap();
let col = v["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.tags")
.unwrap();
assert_eq!(col["type"], "list");
assert_eq!(col["filter"], "enum");
let base2 = parse_base(
"views:\n - type: table\n order: [file.name, note.tags]\n docgenInteractive:\n maxEnum: 2\n",
)
.unwrap();
let corpus2 = Corpus::new(vec![
note("a", "A", &[("tags", list(&["a", "b"]))]),
note("b", "B", &[("tags", list(&["c", "d"]))]),
]);
let v2: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base2,
&corpus2,
&interactive_opts(),
)))
.unwrap();
let col2 = v2["columns"]
.as_array()
.unwrap()
.iter()
.find(|c| c["key"] == "note.tags")
.unwrap();
assert_eq!(col2["type"], "list");
assert_eq!(
col2["filter"], "text",
"4 distinct tokens > maxEnum 2 → text"
);
}
#[test]
fn payload_escapes_all_angle_brackets() {
let base = parse_base("views:\n - type: table\n order: [file.name, note.x]\n").unwrap();
let corpus = Corpus::new(vec![note(
"a",
"A",
&[("x", Value::Str("</script><!--<script><b>hi".into()))],
)]);
let html = render_base(&base, &corpus, &interactive_opts());
let region = extract_payload(&html);
assert!(
!region.contains('<'),
"payload must contain no raw '<': {region}"
);
assert!(region.contains("\\u003c"), "escaped form present");
let v: serde_json::Value = serde_json::from_str(®ion).unwrap();
assert_eq!(
v["rows"][0]["cells"]["note.x"]["d"],
"</script><!--<script><b>hi"
);
}
#[test]
fn group_by_is_not_emitted_for_non_table_views() {
let corpus = Corpus::new(vec![note("a", "A", &[("status", Value::Str("x".into()))])]);
for ty in ["cards", "list"] {
let base = parse_base(&format!(
"views:\n - type: {ty}\n order: [file.name, note.status]\n groupBy: note.status\n"
))
.unwrap();
let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base,
&corpus,
&interactive_opts(),
)))
.unwrap();
assert_eq!(
v["view"]["groupBy"],
serde_json::Value::Null,
"{ty} renders ungrouped, so its payload must not claim a groupBy"
);
}
let base = parse_base(
"views:\n - type: table\n order: [file.name, note.status]\n groupBy: note.status\n",
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base,
&corpus,
&interactive_opts(),
)))
.unwrap();
assert_eq!(v["view"]["groupBy"]["col"], "note.status");
}
#[test]
fn payload_reports_the_ssr_sort_not_the_default_sort_override() {
let corpus = Corpus::new(vec![note("a", "A", &[("date", Value::Number(1.0))])]);
let base = parse_base(
"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",
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base,
&corpus,
&interactive_opts(),
)))
.unwrap();
assert_eq!(v["view"]["sort"][0]["col"], "note.date");
assert_eq!(v["view"]["sort"][0]["desc"], true);
assert_eq!(v["controls"]["sort"][0]["col"], "file.name");
assert_eq!(v["controls"]["sort"][0]["desc"], false);
}
#[test]
fn payload_ssr_sort_is_empty_when_the_view_does_not_sort() {
let corpus = Corpus::new(vec![note("a", "A", &[])]);
let base = parse_base(
"views:\n - type: table\n order: [file.name]\n docgenInteractive:\n defaultSort:\n - property: file.name\n direction: ASC\n",
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&extract_payload(&render_base(
&base,
&corpus,
&interactive_opts(),
)))
.unwrap();
assert_eq!(v["view"]["sort"].as_array().unwrap().len(), 0);
assert_eq!(v["controls"]["sort"][0]["col"], "file.name");
}
#[test]
fn view_ids_are_namespaced_by_block() {
let corpus = Corpus::new(vec![note("a", "A", &[])]);
let base = parse_base("views:\n - type: table\n order: [file.name]\n").unwrap();
let first = render_base(&base, &corpus, &interactive_opts());
let second = render_base(
&base,
&corpus,
&RenderOptions {
block_index: 1,
..interactive_opts()
},
);
assert!(first.contains("data-base-view=\"0-0\""));
assert!(second.contains("data-base-view=\"1-0\""));
}
}