use std::path::Path;
use lini::{Diagnostic, Level, Options};
const BLOCK: &str = "lini-figure-block";
const ICON_HEAD: &str = concat!(
r#"<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" "#,
r#"stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">"#,
);
const ICON_SOURCE: &str =
r#"<path d="M9 6.5 3.5 12 9 17.5"/><path d="M15 6.5 20.5 12 15 17.5"/></svg>"#;
const ICON_FIGURE: &str = concat!(
r#"<rect x="3" y="4.75" width="18" height="14.5" rx="2.5"/>"#,
r#"<circle cx="8.75" cy="10" r="1.5"/>"#,
r#"<path d="M20.5 17.5 14.25 11.25 5 19.25"/></svg>"#,
);
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Mode {
FigureFirst,
CodeFirst,
FigureOnly,
RawOnly,
}
pub fn render(
source: &str,
chapter: &str,
first_line: usize,
base_dir: Option<&Path>,
words: &[&str],
) -> String {
let mode = mode(words, chapter, first_line);
if mode == Mode::RawOnly {
return listing(source, "");
}
let padded = "\n".repeat(first_line - 1) + source;
let options = Options { base_dir: base_dir.map(Path::to_path_buf), ..Options::default() };
if let Some(fatal) = report(lini::lint_str(&padded).unwrap_or_default(), &padded, chapter) {
return error_box(&fatal);
}
match lini::compile_str_checked(&padded, &options) {
Ok((svg, routing)) => {
report(routing, &padded, chapter);
let id = toggle_id(chapter, first_line);
match mode {
Mode::FigureOnly => wrap(&svg),
Mode::FigureFirst => format!(
"<div class=\"{BLOCK}\">{figure}{toggle}{button}\
<div class=\"lini-alt-view\">{alt}</div></div>",
figure = wrap(&svg),
toggle = checkbox("source", &id),
button = button(ICON_SOURCE, "source", &id),
alt = listing(source, ""),
),
Mode::CodeFirst => format!(
"<div class=\"{BLOCK}\">{toggle}{code}\
<div class=\"lini-alt-view\">{alt}</div></div>",
toggle = checkbox("figure", &id),
code = listing(
source,
&format!(
"<span class=\"buttons\">{}</span>",
button(ICON_FIGURE, "figure", &id)
),
),
alt = wrap(&svg),
),
Mode::RawOnly => unreachable!("returned above"),
}
}
Err(e) => {
let text = e.display_with_source(&padded, chapter).to_string();
eprintln!("mdbook-lini: {text}");
error_box(&text)
}
}
}
fn mode(words: &[&str], chapter: &str, line: usize) -> Mode {
let mut mode = Mode::FigureFirst;
for word in words {
mode = match *word {
"figure" => Mode::FigureOnly,
"code" => Mode::CodeFirst,
"raw" => Mode::RawOnly,
other => {
eprintln!(
"mdbook-lini: {chapter}:{line}: unknown word `{other}` on a lini fence — ignoring"
);
mode
}
};
}
mode
}
fn checkbox(names: &str, id: &str) -> String {
format!(
"<input class=\"lini-view-toggle\" type=\"checkbox\" id=\"{id}\" aria-label=\"Show {names}\">"
)
}
fn button(icon: &str, names: &str, id: &str) -> String {
format!(
"<label class=\"lini-view-button\" for=\"{id}\" title=\"Show {names}\">{ICON_HEAD}{icon}</label>"
)
}
fn listing(source: &str, buttons: &str) -> String {
format!(
"<div class=\"lini-source\"><pre>{buttons}<code class=\"nohighlight\">{}</code></pre></div>",
one_line(&lini::highlight_html(source)),
)
}
fn toggle_id(chapter: &str, line: usize) -> String {
let mut slug = String::with_capacity(chapter.len());
for c in chapter.chars() {
match c {
c if c.is_ascii_alphanumeric() => slug.push(c.to_ascii_lowercase()),
_ if !slug.ends_with('-') => slug.push('-'),
_ => {}
}
}
format!("lini-src-{}-{line}", slug.trim_matches('-'))
}
fn one_line(html: &str) -> String {
html.replace('\n', " ")
}
fn report(diags: Vec<Diagnostic>, source: &str, chapter: &str) -> Option<String> {
let mut fatal = None;
for d in diags {
let text = d.display_with_source(source, chapter).to_string();
eprintln!("mdbook-lini: {text}");
if d.level == Level::Error && fatal.is_none() {
fatal = Some(text);
}
}
fatal
}
fn wrap(svg: &str) -> String {
match natural_width(svg) {
Some(w) => format!("<div class=\"lini-figure\" style=\"--lini-w: {w}px\">{svg}</div>"),
None => format!("<div class=\"lini-figure\">{svg}</div>"),
}
}
fn natural_width(svg: &str) -> Option<&str> {
let tag = &svg[..svg.find('>')?];
let (_, after) = tag.split_once(" width=\"")?;
after.split_once('"').map(|(width, _)| width)
}
fn error_box(message: &str) -> String {
format!("<pre class=\"lini-error\">{}</pre>", one_line(&escape(message)))
}
fn escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wraps_a_diagram_with_its_natural_width() {
for words in [&[][..], &["figure"][..]] {
let html = render("a -> b", "demo.md", 1, None, words);
assert!(
html.contains("<div class=\"lini-figure\" style=\"--lini-w: "),
"{words:?}: {html}"
);
assert!(html.contains("<svg") && html.ends_with("</div>"), "{words:?}: {html}");
}
}
#[test]
fn a_broken_block_becomes_an_error_box() {
let html = render("|box", "demo.md", 1, None, &[]);
assert!(html.starts_with("<pre class=\"lini-error\">"), "{html}");
}
#[test]
fn an_error_points_at_the_chapter_line_not_the_block_line() {
let html = render("a -> b\n|box", "demo.md", 41, None, &[]);
assert!(html.contains("demo.md:42:"), "{html}");
}
#[test]
fn reads_the_width_off_the_root_tag() {
assert_eq!(natural_width(r#"<svg viewBox="0 0 8 4" width="8" height="4">"#), Some("8"));
assert_eq!(natural_width("<svg>"), None);
}
const SPACED: &str = "{ layout: flow; }\n\n|box#a| \"A\"\n\na -> b \"go\"\n";
fn listing(html: &str) -> String {
let open = html.find("<code").expect("a code element");
let start = html[open..].find('>').expect("its end") + open + 1;
let end = html[start..].find("</code>").expect("its close") + start;
let mut out = String::new();
let mut rest = &html[start..end];
while let Some(lt) = rest.find('<') {
out.push_str(&rest[..lt]);
let gt = rest[lt..].find('>').expect("well-formed tag") + lt;
rest = &rest[gt + 1..];
}
out.push_str(rest);
out.replace(" ", "\n")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("&", "&")
}
fn blank_line(html: &str) -> Option<usize> {
html.split('\n').position(|line| line.trim().is_empty())
}
#[test]
fn a_figure_carries_a_source_toggle_by_default() {
let html = render("a -> b", "demo.md", 1, None, &[]);
assert!(html.starts_with("<div class=\"lini-figure-block\">"), "{html}");
assert!(html.contains("<input class=\"lini-view-toggle\""), "{html}");
assert!(html.contains("<label class=\"lini-view-button\""), "{html}");
assert!(html.contains("lini-source"), "{html}");
assert!(html.contains("<div class=\"lini-figure\""), "{html}");
}
#[test]
fn the_control_is_a_label_with_no_disclosure_marker() {
let html = render("a -> b", "demo.md", 1, None, &[]);
assert!(!html.contains("<details"), "{html}");
assert!(!html.contains("<summary"), "{html}");
}
#[test]
fn only_the_pre_is_boxable() {
let html = render("a -> b", "demo.md", 1, None, &[]);
let panel = html.split("<div class=\"lini-source").nth(1).expect("a panel");
let panel = &panel[panel.find('>').unwrap() + 1..];
assert!(panel.starts_with("<pre><code"), "{panel}");
}
#[test]
fn each_toggle_owns_its_id() {
let first = render("a -> b", "guide/figures.md", 4, None, &[]);
let second = render("a -> b", "guide/figures.md", 40, None, &[]);
let id_of = |html: &str| {
let mark = "<input class=\"lini-view-toggle\" type=\"checkbox\" id=\"";
let at = html.find(mark).expect("a toggle") + mark.len();
html[at..][..html[at..].find('"').unwrap()].to_owned()
};
let (a, b) = (id_of(&first), id_of(&second));
assert_ne!(a, b, "two blocks in one chapter share an id");
assert!(first.contains(&format!("for=\"{a}\"")), "label points elsewhere: {first}");
assert!(a.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'), "illegal id: {a}");
}
#[test]
fn the_listing_opts_out_of_mdbooks_highlighter() {
let html = render("a -> b", "demo.md", 1, None, &[]);
assert!(html.contains("<pre><code class=\"nohighlight\">"), "{html}");
}
#[test]
fn the_figure_word_emits_the_bare_figure() {
let html = render("a -> b", "demo.md", 1, None, &["figure"]);
assert!(html.starts_with("<div class=\"lini-figure\" style=\"--lini-w: "), "{html}");
assert!(!html.contains("lini-source"), "{html}");
assert!(!html.contains("lini-figure-block"), "{html}");
assert!(html.ends_with("</div>"));
}
#[test]
fn an_unrecognised_word_still_renders_the_default() {
let html = render("a -> b", "demo.md", 1, None, &["wat"]);
assert!(html.contains("lini-source"), "{html}");
}
#[test]
fn the_listing_is_the_authors_own_text_verbatim() {
let html = render(SPACED, "demo.md", 1, None, &[]);
assert_eq!(listing(&html), SPACED);
}
#[test]
fn a_source_with_blank_lines_emits_no_blank_line() {
let html = render(SPACED, "demo.md", 1, None, &[]);
assert_eq!(blank_line(&html), None, "line {:?} is blank in {html}", blank_line(&html));
}
#[test]
fn a_bare_figure_emits_no_blank_line() {
let html = render(SPACED, "demo.md", 1, None, &["figure"]);
assert_eq!(blank_line(&html), None, "{html}");
}
#[test]
fn an_error_box_emits_no_blank_line() {
let html = render("|box| { fill: ", "demo.md", 1, None, &[]);
assert!(html.starts_with("<pre class=\"lini-error\">"), "{html}");
assert_eq!(blank_line(&html), None, "{html}");
}
#[test]
fn the_code_word_puts_the_source_first() {
let html = render("a -> b", "demo.md", 1, None, &["code"]);
let source_at = html.find("lini-source").expect("a listing");
let figure_at = html.find("lini-figure\"").expect("a figure");
assert!(source_at < figure_at, "the figure still comes first: {html}");
assert!(html.contains("Show figure"), "{html}");
assert!(html.contains("lini-alt-view"), "{html}");
}
#[test]
fn code_mode_hides_the_figure_not_the_listing() {
let html = render("a -> b", "demo.md", 1, None, &["code"]);
let alt = html.split("lini-alt-view").nth(1).expect("an alt panel");
assert!(alt[..80].contains("lini-figure"), "the alt panel is not the figure: {alt}");
}
#[test]
fn the_code_mode_button_joins_mdbooks_button_row() {
let html = render("a -> b", "demo.md", 1, None, &["code"]);
assert!(
html.contains("<pre><span class=\"buttons\"><label class=\"lini-view-button\""),
"{html}"
);
}
#[test]
fn the_default_mode_button_stands_alone() {
let html = render("a -> b", "demo.md", 1, None, &[]);
assert!(!html.contains("class=\"buttons\""), "{html}");
assert!(html.contains("<label class=\"lini-view-button\""), "{html}");
}
#[test]
fn the_raw_word_emits_a_listing_and_nothing_else() {
let html = render("a -> b", "demo.md", 1, None, &["raw"]);
assert!(html.contains("lini-source"), "{html}");
assert!(!html.contains("<svg"), "raw drew a figure: {html}");
assert!(!html.contains("lini-view-toggle"), "raw carries a toggle: {html}");
assert!(!html.contains("lini-figure"), "{html}");
}
#[test]
fn a_raw_block_is_never_compiled() {
let html = render("|box| { fill:", "demo.md", 1, None, &["raw"]);
assert!(!html.contains("lini-error"), "raw reported a compile error: {html}");
assert!(html.contains("lini-tok-"), "raw lost its highlighting: {html}");
}
#[test]
fn a_raw_listing_emits_no_blank_line() {
let html = render(SPACED, "demo.md", 1, None, &["raw"]);
assert_eq!(blank_line(&html), None, "{html}");
}
#[test]
fn our_classes_are_ours_alone() {
let src = "{ layout: flow; }\n|box#a| \"A\"\n|cyl#b| \"B\"\n|note#c| \"C\"\na -> b";
let svg = lini::compile_str(src).expect("the probe compiles");
for ours in [
BLOCK,
"lini-figure",
"lini-source",
"lini-error",
"lini-view-toggle",
"lini-view-button",
"lini-alt-view",
] {
assert!(
!svg.contains(ours),
"Lini now emits `{ours}` itself — rename ours before it restyles diagrams"
);
}
}
}