Skip to main content

docgen_bases/
render.rs

1//! Static HTML rendering of a base's views. Produces a self-contained
2//! `<div class="docgen-base">` styled by the shared `docgen.css`. Each view
3//! (table/cards/list) is filtered, sorted, grouped, and limited exactly as
4//! configured, then rendered to server-side HTML.
5//!
6//! When [`RenderOptions::interactive`] is set the same HTML additionally carries
7//! `data-*` hooks and a per-view JSON payload (see [`crate::interactive`]) that the
8//! client island progressively enhances into an interactive view; the payload is
9//! keys-only, so the server HTML stays the single source of what each cell looks
10//! like. With `interactive` false the output is exactly the pure static HTML.
11
12use 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/// Rendering configuration supplied by the host (docgen).
25#[derive(Debug, Clone, Default)]
26pub struct RenderOptions {
27    /// Site base path (e.g. `/docs` or empty) prefixed onto internal note URLs.
28    pub base: String,
29    /// Fallback title used for a view with no `name`.
30    pub default_view_name: String,
31    /// When true, emit the interactive DOM hooks + JSON payload the client-side
32    /// island hydrates against. When false, output is byte-for-byte the pure
33    /// static HTML (no `data-*` hooks, no payload script).
34    pub interactive: bool,
35    /// Which base on the page this is: 0 for a standalone `.base`, else the index
36    /// of the ` ```base ` block. Views number from 0 within their own base, so the
37    /// emitted `data-base-view` is `{block_index}-{view_index}` — the island keys
38    /// URL-hash segments and facet-panel DOM ids off that string, and two blocks
39    /// on one page would otherwise both claim `0` and clobber each other.
40    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
49/// Render every view in a base to HTML. Compiles formulas + the global filter
50/// once and reuses them across views.
51pub fn render_base(base: &BaseFile, corpus: &Corpus, opts: &RenderOptions) -> String {
52    // Pre-parse formulas (name → expr); an unparsable formula becomes an
53    // always-null expression so cells render empty rather than failing.
54    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        // A base with no views still shows an (empty) default table.
60        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
89/// Parse a base's malformed YAML gracefully: on a parse error, return a styled
90/// error block (never a panic), mirroring the PlantUML error-component ethos.
91pub 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
104/// A row: the note plus its pre-evaluated column values (keyed by column ref).
105/// `id` is the stable index into the post-sort row slice (assigned before
106/// grouping), joining SSR DOM nodes to their payload entry in interactive mode.
107struct 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    // Honor the per-base/per-view `docgenInteractive:false` opt-out end to end:
124    // a view the author disabled renders as pure static HTML even when the build
125    // requested interactive output. Shadow `opts` so every downstream renderer
126    // (section hooks, body `data-*`, payload) sees the effective flag.
127    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    // Collect matching notes.
142    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    // Determine the columns to display.
152    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    // Evaluate each row's cells.
159    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    // Sort by the view's sort keys (evaluated on the fly), stable, in order.
176    apply_sort(&mut rows, view, corpus, formulas);
177
178    // Assign each row a stable id = its index in the post-sort slice, BEFORE
179    // grouping, so the payload and the DOM `data-row` attributes agree.
180    for (i, row) in rows.iter_mut().enumerate() {
181        row.id = i;
182    }
183
184    // `limit` truncates the row set, in interactive mode too — it means the same
185    // thing Obsidian means by it, and the same thing this renderer's static mode
186    // has always meant. Pagination is `docgenInteractive.pageSize`'s job and only
187    // its job; conflating the two made `limit: 10` ship every matched row to the
188    // client and page them 10 at a time, so a cap the author wrote was not a cap
189    // at all. Truncating HERE (once, before both the payload and the body) is what
190    // keeps them agreeing: a row the DOM does not contain must not appear in the
191    // payload, or the island would hold ids with no node. Applies across the whole
192    // view, not per group.
193    if let Some(limit) = view.limit {
194        rows.truncate(limit);
195    }
196
197    // Group (optional).
198    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        // "table" and any unknown type fall back to a table.
207        _ => 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    // Surface filter parse errors as a small diagnostic (non-fatal).
235    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    // A typo'd key in docgen's own `docgenInteractive` block would otherwise do
244    // nothing, forever, while looking like it worked. Warn visibly but keep
245    // rendering: the build always succeeds (the graceful-degradation contract),
246    // and only the mistyped knob is lost, not the view.
247    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    // Interactive payload: after the optional title/warning, before the body.
258    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
276/// Choose the display columns: the view's `order` if given, else `file.name` plus
277/// every note property key seen (deterministic, sorted).
278fn 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    // Evaluate each row's sort keys ONCE (a decorate-sort), rather than
308    // re-evaluating inside every pairwise comparison — comparisons are O(n log n),
309    // and a `formula.*` sort key can be expensive.
310    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    // Decide once per key whether it orders as versions — detection reads the
319    // whole column, so it cannot be done inside a pairwise comparison.
320    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
343/// Whether `prop` orders as versions: `sortAs` decides if it names the column,
344/// otherwise the column auto-detects.
345pub(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        // Unknown value: fall back to detection rather than failing the build,
359        // matching how the rest of the `.base` surface tolerates bad input.
360        _ => semver::column_is_semver(values),
361    }
362}
363
364/// Version ordering over raw values. Compares the same per-cell key the island
365/// receives in the payload, so the static order and the client order are the
366/// same order by construction (see `semver::column_sort_key`).
367pub(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
371/// Human-readable header for a column ref: `properties.<ref>.displayName` if set,
372/// else the last segment humanized (`file.name` → `Name`, `note.due_date` →
373/// `Due date`).
374pub(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    // `rows` is already truncated to the view's `limit` by render_view.
406    let limit: Option<usize> = None;
407
408    let mut html = String::new();
409    // Reuse the same horizontal-scroll wrapper docgen uses for wide markdown tables.
410    html.push_str("<div class=\"docgen-table-scroll\"><table class=\"docgen-base-table\">");
411
412    // Header row.
413    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    // Body, optionally grouped. A limit caps the total number of rows shown; once
433    // it's reached we stop entirely, so a later group never emits an orphaned
434    // header with no rows beneath it.
435    let mut shown = 0usize;
436    let grouped = group_rows(rows, group_by, corpus, formulas);
437    'groups: for (group_label, group_rows) in &grouped {
438        // Skip the whole group (header included) if the limit is already spent.
439        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    // Summary footer row, if any column has a summary configured.
488    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
516/// Partition rows by the group-by property (rendered label). Returns groups in
517/// group-label order (respecting the group direction); `None` label = ungrouped.
518fn 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    // Group by rendered label (for the header text + dedup) but keep a
529    // representative typed value per group so ordering is by the value's natural
530    // order (numeric priorities 1 < 2 < 10), not lexicographic string order.
531    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
550/// Whether a column names the note's rendered body (`note.body` / `file.body`) —
551/// a docgen extension that cards render as a full-width block.
552fn 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    // `rows` is already truncated to the view's `limit` by render_view.
564    // A `note.body`/`file.body` column turns the cards into a single-column list,
565    // each card carrying the note's rendered body beneath its fields — a readable
566    // layout for long-form entries (e.g. release notes) rather than a tile grid.
567    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        // Card title: the note's file name, linked to its page.
584        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            // The name columns become the card title; the body column becomes a
597            // full-width block below — neither belongs in the field list.
598            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        // Body: the note's already-rendered HTML, emitted verbatim (not escaped).
613        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
661/// Render a single cell value to HTML, hyperlinking note references. The
662/// `file.name`/`file.basename`/`file.path`/`file.file` columns link to the row
663/// note's own page; `Link` values resolve to their target note's page.
664fn render_cell(
665    val: &Value,
666    col: &str,
667    note: &Note,
668    opts: &RenderOptions,
669    corpus: &Corpus,
670) -> String {
671    // Self-referential file columns → a link to this note's page. Obsidian shows
672    // the note's title (basename, no extension) as the link text for the name/
673    // basename/file columns, and the full path for the path column — regardless of
674    // the raw metadata value (which keeps its extension for filters).
675    if matches!(col, "file.name" | "file.basename" | "file.file") && !note.slug.is_empty() {
676        return link_html(&opts.note_url(&note.slug), &note.basename);
677    }
678    if col == "file.path" && !note.slug.is_empty() {
679        return link_html(&opts.note_url(&note.slug), &note.path);
680    }
681    render_value(val, opts, corpus)
682}
683
684/// Render a value to HTML (recursively for lists), turning links into anchors.
685fn 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            // Render booleans as a checkbox glyph, matching Obsidian's checkmark cells.
707            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
718/// Resolve a link to a corpus note's slug (by basename, case-insensitive).
719fn 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
735/// A styled, inert error block for a malformed base (parse failure). Detailed
736/// message, never a panic — the analogue of the PlantUML error component.
737pub 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
744/// Minimal HTML-escaping (the crate is standalone; no docgen-core dependency).
745fn 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("&amp;"),
750            '<' => out.push_str("&lt;"),
751            '>' => out.push_str("&gt;"),
752            '"' => out.push_str("&quot;"),
753            '\'' => out.push_str("&#39;"),
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    /// Extract the JSON text inside the `docgen-base-data` script element.
780    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")); // view title
833        assert!(html.contains(">Dune<")); // linked file name
834        assert!(html.contains("href=\"/a\"")); // note URL
835        assert!(!html.contains("NotABook")); // filtered out
836        assert!(html.contains("<th>Rating</th>")); // humanized header
837    }
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        // Descending: Cherry, Banana appear; Apple cut by the limit of 2.
852        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        // The author link resolves to the Herbert note's page.
872        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<")); // sum of prices
904    }
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    /// Row order for a `sort:` on `note.version`, top to bottom.
917    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        // Recover order from the rendered cells rather than re-sorting here, so
938        // the test observes what a reader actually sees.
939        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        // Lexically this would be 1.0.23, 1.19.20, 1.2.12.
956        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        // One non-version disqualifies the column, so ordering stays lexical
976        // ("1.19.20" before "1.2.12") rather than half-numeric.
977        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        // limit 2, groups done(A,B) and todo(C,D): the 'todo' header must NOT appear
1007        // because the limit is consumed by the 'done' group.
1008        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        // Groups appear in numeric order 1, 2, 10 — not lexical "1","10","2".
1039        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        // Single-column list layout, plus the body emitted verbatim in its block.
1055        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        // The body column is NOT also emitted as a <dt>/<dd> field pair.
1060        assert!(!html.contains("<dt>Body</dt>"));
1061        // A normal field still renders.
1062        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    // ---- Interactive mode (M1) ----
1113
1114    #[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        // `{block}-{view}`: block 0 (a standalone base), first view.
1131        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        // `limit` is a row cap, not a page size: it truncates in interactive mode
1148        // exactly as it does statically, so the rows it cut never reach the client.
1149        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        // The payload must never carry a row the DOM lacks — the island would hold
1155        // an id with no node to move or hide.
1156        assert_eq!(v["rows"].as_array().unwrap().len(), 1);
1157        // `limit` is not shipped: already applied, and re-applying would double-cap.
1158        assert_eq!(v["view"]["limit"], serde_json::Value::Null);
1159        // A row cap is not pagination. 1 row => no pager.
1160        assert_eq!(v["controls"]["pageSize"], 0);
1161    }
1162
1163    /// A key docgen does not know in ITS OWN namespace is always an author error
1164    /// (Obsidian never reads this block), so it must not vanish silently. The view
1165    /// still renders — only the mistyped knob is lost.
1166    #[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        // The view still renders: degradation, not failure.
1184        assert!(html.contains("docgen-base-table"));
1185        assert!(html.contains(">A<"));
1186        // ...and the typo really did NOT take effect (default paging, not 25).
1187        let v: serde_json::Value = serde_json::from_str(&extract_payload(&html)).unwrap();
1188        assert_eq!(v["controls"]["pageSize"], 0);
1189    }
1190
1191    /// The warning must not fire on a correct block, or it is noise.
1192    #[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    /// `limit` must cap every view type. `render_list` never applied it at all —
1207    /// not even in static mode — because it was the one renderer that did not
1208    /// receive `view`. Capping centrally in `render_view` covers all three.
1209    #[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        // Date cell → epoch present.
1259        assert_eq!(cells["note.due"]["t"], "date");
1260        assert!(cells["note.due"]["epoch"].is_number());
1261        // List cell → f tokens.
1262        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        // Numeric cell → num present.
1266        assert_eq!(cells["note.n"]["t"], "num");
1267        assert_eq!(cells["note.n"]["num"], 42.0);
1268        // Null/empty cell → t null + empty true.
1269        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        // All-dates column → type date, filter date.
1276        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        // Low-cardinality string → enum.
1294        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        // High-cardinality (> maxEnum) string → text (maxEnum lowered to 2).
1313        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        // filters override forces text even though auto would pick enum.
1336        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        // enabled:false → view_interactive_enabled reports false.
1355        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        // A plain view is enabled.
1363        let plain = parse_base("views:\n  - type: table\n").unwrap();
1364        assert!(crate::view_interactive_enabled(&plain, &plain.views[0]));
1365        // Base-level docgenInteractive:false disables all views.
1366        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        // Same base + corpus, rendered with interactive:true opts. The disabled
1376        // base must emit NO interactive hooks/payload (pure static); the plain
1377        // base must emit them — proving the renderer honors the opt-out.
1378        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        // Still rendered the static table content.
1390        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        // A low-cardinality list column → enum.
1405        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        // A high-cardinality list column (distinct tokens > maxEnum) → text.
1427        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        // Every `<` must be escaped — not just `</` — so no `</script`, `<!--`, or
1457        // `<script` sequence can steer the HTML tokenizer out of the data script
1458        // (a `<!--<script>` value contains no `</` yet still breaks naive escaping).
1459        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        // No literal `<` at all leaks into the embedded JSON.
1468        assert!(
1469            !region.contains('<'),
1470            "payload must contain no raw '<': {region}"
1471        );
1472        assert!(region.contains("\\u003c"), "escaped form present");
1473        // And the payload still parses back to the exact original string.
1474        let v: serde_json::Value = serde_json::from_str(&region).unwrap();
1475        assert_eq!(
1476            v["rows"][0]["cells"]["note.x"]["d"],
1477            "</script><!--<script><b>hi"
1478        );
1479    }
1480
1481    /// `groupBy` is table-only (see the graceful-degradation contract in
1482    /// website/docs/features/bases.md): cards/list parse it and render ungrouped.
1483    /// The payload has to say so too — the island derives `V.grouped` from this
1484    /// field, and a truthy `grouped` suppresses the sort dropdown that IS the only
1485    /// sort affordance a cards/list view has. Emitting it for a view that renders
1486    /// ungrouped left the reader with no way to sort at all.
1487    #[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        // The table case is the control: it really does group, so it keeps the field.
1509        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    /// The island skips its corrective reorder when the requested sort already
1523    /// matches the order the SSR DOM is in. It therefore needs to know that real
1524    /// order — which is `view.sort`, since `apply_sort` never consults
1525    /// `defaultSort`. Reporting `controls.sort` (= defaultSort) as the DOM order
1526    /// made the island think the rows were already arranged that way.
1527    #[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        // What the rows are actually arranged by on the server.
1542        assert_eq!(v["view"]["sort"][0]["col"], "note.date");
1543        assert_eq!(v["view"]["sort"][0]["desc"], true);
1544        // What the controls should open on — deliberately different.
1545        assert_eq!(v["controls"]["sort"][0]["col"], "file.name");
1546        assert_eq!(v["controls"]["sort"][0]["desc"], false);
1547    }
1548
1549    /// With no `sort:`, `apply_sort` early-returns and the rows keep corpus order.
1550    /// An empty `view.sort` is what tells the island that, so a `defaultSort` will
1551    /// correctly be seen as a change and applied on first render.
1552    #[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    /// `data-base-view` has to be unique per PAGE, not per base: the island uses it
1570    /// to namespace URL-hash segments and facet-panel DOM ids. Two embedded blocks
1571    /// both starting their enumeration at 0 made each block strip the other's URL
1572    /// state and emit colliding element ids.
1573    #[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}