Render Inline Callouts
The story shipped in v0.1.0 outside-in. The first slice was the furthest out this book had reached: a real Chromium driven by playwright-rs asserting on the rendered DOM of a callout in this very chapter. Each slice shipped as one commit; the Outside-in narrative sub-section below grew by one sub-section per slice.
Story
As a book author, I want to attach inline annotations and named reference points to specific lines of a frozen listing so that my prose can stay keyed to the code even when the code evolves under a new tag.
Acceptance criteria
Inline form (callout markers in the source itself):
- A frozen listing whose language has a recognised inline-marker
syntax can carry callout markers. When the chapter renders that
listing to HTML — whether via
{{#include}}or as the new side of a{{#diff}}(added or context lines, but not removed lines — a deleted marker shouldn’t carry a current badge) — each marker produces a numbered badge at the marker’s position and an expandable annotation reachable from the badge. - The same listing rendered to PDF produces a styled note for each callout, ordered to match the listing.
- A callout marker may declare just a label, with no accompanying annotation. In that case a numbered badge appears at the marker’s position but no expandable annotation is rendered. This form serves purely as a stable cross-reference target.
Cross-reference and numbering:
- Chapter prose can reference a callout by its label, and the reference renders as the same numbered badge, hyperlinked back to the listing occurrence.
- Badge numbers are assigned ordinally within each listing and reset between listings. Adding or removing a callout above an existing one renumbers the badges visually but does not break label-based references.
Passthrough and robustness:
- A frozen listing whose language has no recognised inline- marker syntax is rendered unchanged for inline-form parsing.
- A comment that resembles a callout marker but does not parse cleanly is left unchanged in the rendered output (no silent misparse).
- A chapter reference to a callout label that does not exist fails the build with a diagnostic that names the missing label and the chapter.
HTML rendered shape (refining ACs 1, 3 once the splicer matures past its slice-3 placeholder dl shape):
- The
CALLOUT:marker comment line is removed from the rendered HTML listing — the surrounding code is shown verbatim, but the comment that carries the marker metadata does not appear as visible text. - The numbered badge is inline on the line that previously
held the marker comment in the rendered HTML. Hovering it
reveals the body text in a popover; there is no trailing
<dl>/list element below the listing.
Authoring ergonomics (added in slice 9 in response to the long-diff visual issue surfaced by the test-infra refactor):
- Authors can render a fragment of a frozen listing via
START:ENDline-range syntax in{{#diff}}and{{#include}}directives.{{#diff a b 1:30 1:30}}renders only lines 1-30 of each operand;{{#include listings/foo.rs:1:30}}inlines only lines 1-30 of the file. Endpoints are inclusive and 1-based; empty endpoints (200:,:100,:) mean “to end” / “to start” / “whole file”. Out-of-range endpoints clamp silently to the file’s actual line count.
The slice — outside-in narrative outline
The story ships as nine slices plus two refactor passes and a wrap-up chore. Slice 1 is the outermost layer — a browser-driving acceptance test — and the inner slices fill in the layers needed to satisfy it.
| Slice | What it adds |
|---|---|
| 1 | playwright-rs harness. A failing #[tokio::test] #[ignore] in tests/e2e_callouts.rs launches Chromium against the rendered ch. 5 HTML and asserts a [data-callout-badge] element exists. The test fails (no callouts in ch. 5 yet, no parser, no HTML emitter); ignore keeps the green-build chain passing while later slices grow the rest. |
| 2 | Comment-syntax table + generic parse_callouts parser parameterised on prefix. Pure unit tests for every prefix in the initial table; verifies body and no-body forms; ignores malformed. |
| 3 | HTML emitter — badge at line, <details> nearby — wires parser into preprocessor. Handles both {{#include}} (the source language’s comment prefix) and {{#diff}} (the splicer strips diff +/space indicators and tries every comment prefix; removed - lines are skipped). Slice 1’s #[ignore] comes off and the test goes green for AC 1. SupportedRenderer enum extracted here. |
| 4 | Label-only inline form (AC 3). Small addition to emitter; new playwright-rs test asserting the bare-anchor case. |
| 5 | Cross-reference directive {{#callout <label>}} (ACs 4, 8). New playwright-rs test asserting the prose-rendered badge is hyperlinked to the listing-rendered badge anchor. |
| 6 | typst-pdf emitter — admonish-note block after the code block (AC 2). Non-browser; assertion is visual or assert_cmd-on-PDF-bytes — decided in the slice. |
| 7 | HTML rendered-shape pivot (ACs 9, 10). The slice-3 placeholder shape (CALLOUT comment line visible + trailing <dl> of bodies) is replaced with the final shape: marker comment is stripped from the rendered listing, and an inline interactive <span class="callout-badge"> is overlaid on the line that previously held it. Hovering the badge reveals the body in a popover (CSS-only or <details>-driven). The trailing <dl> is removed for HTML. Cross-refs from slice 5 still resolve to the new badge anchor. New playwright-rs test asserting the comment is gone, the inline badge exists, and the body becomes visible on hover. |
| 8 | Screenshot-tool subcommands and include-block locator anchors. The preprocessor intercepts {{#include listings/TAG.ext}} directives before mdbook’s built-in links preprocessor runs and emits a <div data-listing-tag="TAG"> anchor after the rendered fenced block — mirroring what \{{#diff}} already does. The capture-screenshots tool is split into two subcommands matching the two listing-rendering shapes (include LISTING and diff LEFT RIGHT). No new acceptance criterion (this is tooling, not user-visible book behavior). |
| refactor (e2e migration) | Adopt playwright-rs-macros locator!() for compile-time selector validation, then migrate every JS-string evaluate_value sweep in tests/e2e_callouts.rs to playwright-rs Locator + expect(...).to_have_*() assertions. Surfaces a slice-8 dedup bug (duplicate id="callout-body-LABEL" when the same label appears in two blocks) and fixes it. No new ACs; pure test-quality + small splicer hardening. |
| refactor (test infra) | Move shared e2e setup into tests/common/e2e_harness.rs: per-test BrowserContext for storage isolation, tracing_subscriber::fmt() so playwright-rs’s #[tracing::instrument] spans surface under RUST_LOG, and per-test BrowserContext::tracing() recording with the trace dropped on success and saved to target/playwright-traces/<name>.zip on panic. The harness also dogfoods the new playwright-rs-trace crate by parsing the saved trace and printing failed actions to stderr inline. Sharing one Browser across tests via OnceCell was tried and reverted — Browser channels are bound to the #[tokio::test] runtime that created them, so subsequent tests deadlock; the per-test launch is the price of #[tokio::test] runtime isolation. Also folds in the upstream resolution of playwright-rust#89: bump the playwright-rs git pin past 401be500 and replace the lone history.replaceState JS string in the click-through-navigation test with the new typed page.clear_url_fragment().await. The e2e suite is now JS-string-free. The migration also surfaced and fixed a long-standing badge-positioning bug exposed by the slice’s 600-line v6→v7 diff: the overlay’s CSS positioning formula assumed each line rendered at 1.5em, but mdbook’s <pre> uses line-height: normal (~1.13 for monospace), so badges in long diffs drifted ~3px per line above their intended row, eventually landing in sibling pres above. Fix: a per-book init script (registered via additional-js) measures the previous pre’s actual rendered height and writes a --callout-line-px CSS custom property the formula picks up. New regression test every_badge_renders_inside_its_owning_pre guards against the drift returning. |
| 9 | Line-range support for {{#diff}} and \{{#include}} directives (AC 11). Both directives accept optional START:END arguments to render a fragment of a frozen listing — \{{#diff a b 1:30 1:30}} and \{{#include listings/foo.rs:1:30}}. Surfaced in the previous refactor: a 600-line diff with three callouts spread across it puts cross-ref prose ~600 lines below its first badge. Authors can now break long diffs and includes into multiple smaller rendered blocks interleaved with prose, without freezing snippet listings for each fragment. |
| wrap-up | Mark the callouts primitive shipped in ROADMAP.md and materialize this chapter’s “What this story does not solve” section. |
Outside-in narrative
Slice 1 — playwright-rs harness + failing E2E test
The first slice introduces the outermost-layer test that the rest
of the story races to satisfy: a Rust integration test that
launches a real Chromium via
playwright-rs, navigates
to the rendered ch05-render-inline-callouts.html on disk, and
asserts that a [data-callout-badge] element exists with non-empty
text content. The test fails today — there’s no parser, no HTML
emitter, and no callout-marked listing in this chapter yet.
#[ignore] keeps cargo test green for the green-build chain;
the author runs cargo test --test e2e_callouts -- --ignored once
locally to confirm the test really does fail at the badge
assertion, then commits.
Cargo.toml gains two [dev-dependencies]: playwright-rs (the
Rust bindings) and tokio (the async runtime the test uses).
--- cargo-toml-v3
+++ cargo-toml-v4
@@ -22,5 +22,7 @@
[dev-dependencies]
assert_cmd = "2"
+playwright-rs = "0.12"
predicates = "3"
tempfile = "3"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
The new test file is tests/e2e_callouts.rs. The naming
parallels the other story-scoped integration test files
(tests/install.rs, tests/freeze.rs, tests/diffs.rs); the
e2e_ prefix flags the harness tier so future readers don’t
expect assert_cmd-style assertions from it.
#![allow(unused)]
fn main() {
use std::path::PathBuf;
use playwright_rs::Playwright;
#[tokio::test]
#[ignore = "no rendered callouts in ch. 4 yet"]
async fn callout_badge_renders_with_data_attribute_in_ch04() {
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
let pw = Playwright::launch().await.expect("launch playwright");
let browser = pw.chromium().launch().await.expect("launch chromium");
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
let badge = page.locator("[data-callout-badge]").await;
let count = badge.count().await.expect("count badges");
assert!(
count > 0,
"expected at least one [data-callout-badge] element on rendered ch. 4; got 0",
);
let text = badge.first().text_content().await.expect("badge text");
assert!(
text.as_deref().is_some_and(|s| !s.trim().is_empty()),
"expected badge text to be non-empty; got {text:?}",
);
browser.close().await.expect("close browser");
}
fn chapter_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.join("book")
.join("build")
.join("html")
.join("ch05-render-inline-callouts.html")
}
}
The test file is frozen as e2e-callouts-v1 per the per-slice
freeze discipline. Slice 3 mints e2e-callouts-v2 when it removes
the #[ignore]; subsequent slices that add new tests mint
further versions.
Slice 2 — directive parser as a pure unit
Slice 2 adds the first piece slice 3’s HTML emitter will need: a
parser that turns a frozen listing’s source bytes into a list of
Callout { line, label, body }. Pure function, no IO; the
splicer in slice 3 wires it into the preprocessor.
A new src/callout.rs declares the Callout struct, the
parse_callouts(content, comment_prefix) -> Vec<Callout> entry
point, and a comment_prefix_for_extension(ext) -> Option<&str>
helper that maps file extensions to single-line comment syntaxes.
The initial table covers seventeen languages — # for
yaml/yml/toml/py/sh/bash/tf/hcl, // for
rs/c/h/cpp/hpp/js/ts/jsx/tsx, -- for sql. Block-comment-only
languages (CSS, plain Markdown) take callouts via the sidecar
form instead and return None from this lookup.
//! Parses inline `CALLOUT:` markers out of a frozen listing's source.
/// Position is a 1-based line number so error diagnostics and the eventual
/// rendered badge anchor can both refer to it directly.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Callout {
pub line: usize,
pub label: String,
pub body: Option<String>,
}
/// Walks `content` line by line and returns every well-formed callout
/// marker. A marker is a line whose first non-whitespace content matches
/// `<comment_prefix> CALLOUT: <label>[ <body>]`. Malformed lines are
/// silently skipped — the splicer leaves them in the rendered listing
/// unchanged.
pub fn parse_callouts(content: &str, comment_prefix: &str) -> Vec<Callout> {
let mut out = Vec::new();
for (idx, raw_line) in content.lines().enumerate() {
if let Some(callout) = parse_line(raw_line, comment_prefix, idx + 1) {
out.push(callout);
}
}
out
}
fn parse_line(raw_line: &str, comment_prefix: &str, line: usize) -> Option<Callout> {
let after_prefix = raw_line.trim_start().strip_prefix(comment_prefix)?;
let after_keyword = after_prefix.strip_prefix(' ')?.strip_prefix("CALLOUT:")?;
let payload = after_keyword.strip_prefix(' ')?;
let (label, rest) = match payload.split_once(char::is_whitespace) {
Some((l, r)) => (l, Some(r)),
None => (payload, None),
};
if label.is_empty() || !is_valid_label(label) {
return None;
}
let body = rest.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
Some(Callout {
line,
label: label.to_string(),
body,
})
}
fn is_valid_label(label: &str) -> bool {
label
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
/// Maps a listing's file extension to the language's single-line comment
/// prefix. Returns `None` for languages without a recognised inline-marker
/// syntax (block-comment-only languages take callouts via the sidecar form
/// instead).
pub fn comment_prefix_for_extension(ext: &str) -> Option<&'static str> {
match ext {
"yaml" | "yml" | "toml" | "py" | "sh" | "bash" | "tf" | "hcl" => Some("#"),
"rs" | "c" | "h" | "cpp" | "hpp" | "js" | "ts" | "jsx" | "tsx" => Some("//"),
"sql" => Some("--"),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_label_with_body_for_hash_prefix() {
let s = "key: value\n# CALLOUT: greeting Says hello to the user.\nfoo: bar\n";
let got = parse_callouts(s, "#");
assert_eq!(
got,
vec![Callout {
line: 2,
label: "greeting".into(),
body: Some("Says hello to the user.".into()),
}]
);
}
#[test]
fn parses_label_only_form_for_hash_prefix() {
let s = "# CALLOUT: anchor-only\n";
let got = parse_callouts(s, "#");
assert_eq!(
got,
vec![Callout {
line: 1,
label: "anchor-only".into(),
body: None,
}]
);
}
#[test]
fn parses_double_slash_prefix() {
let s = "fn main() {\n // CALLOUT: entry The program starts here.\n}\n";
let got = parse_callouts(s, "//");
assert_eq!(got.len(), 1);
assert_eq!(got[0].line, 2);
assert_eq!(got[0].label, "entry");
assert_eq!(got[0].body.as_deref(), Some("The program starts here."));
}
#[test]
fn parses_double_dash_prefix_for_sql() {
let s = "SELECT *\n-- CALLOUT: filter Limits to active rows.\nFROM users;\n";
let got = parse_callouts(s, "--");
assert_eq!(got.len(), 1);
assert_eq!(got[0].label, "filter");
}
#[test]
fn skips_marker_with_wrong_prefix() {
let s = "# CALLOUT: hash-marker\n";
assert!(parse_callouts(s, "//").is_empty());
}
#[test]
fn skips_missing_space_between_prefix_and_keyword() {
let s = "#CALLOUT: nope\n";
assert!(parse_callouts(s, "#").is_empty());
}
#[test]
fn skips_missing_space_after_keyword() {
let s = "# CALLOUT:nope\n";
assert!(parse_callouts(s, "#").is_empty());
}
#[test]
fn skips_empty_label() {
let s = "# CALLOUT: body-without-label\n";
assert!(parse_callouts(s, "#").is_empty());
}
#[test]
fn skips_label_with_invalid_characters() {
let s = "# CALLOUT: bad/label has body\n";
assert!(parse_callouts(s, "#").is_empty());
}
#[test]
fn returns_none_body_when_label_alone_with_trailing_whitespace() {
let s = "# CALLOUT: alone \n";
let got = parse_callouts(s, "#");
assert_eq!(got.len(), 1);
assert_eq!(got[0].body, None);
}
#[test]
fn collects_multiple_callouts_in_one_listing() {
let s = "\
first comment\n\
CALLOUT: one Body of one.\n\
key: value\n\
CALLOUT: two\n\
other: thing\n\
CALLOUT: three Body of three.\n\
";
let got = parse_callouts(s, "#");
assert_eq!(got.len(), 3);
assert_eq!((got[0].line, &got[0].label[..]), (2, "one"));
assert_eq!((got[1].line, &got[1].label[..]), (4, "two"));
assert_eq!((got[2].line, &got[2].label[..]), (6, "three"));
assert_eq!(got[1].body, None);
}
#[test]
fn tolerates_indented_marker() {
let s = " # CALLOUT: indented Body text.\n";
let got = parse_callouts(s, "#");
assert_eq!(got.len(), 1);
assert_eq!(got[0].label, "indented");
}
#[test]
fn comment_prefix_for_extension_covers_initial_table() {
for ext in ["yaml", "yml", "toml", "py", "sh", "bash", "tf", "hcl"] {
assert_eq!(comment_prefix_for_extension(ext), Some("#"), "ext: {ext}");
}
for ext in ["rs", "c", "h", "cpp", "hpp", "js", "ts", "jsx", "tsx"] {
assert_eq!(comment_prefix_for_extension(ext), Some("//"), "ext: {ext}");
}
assert_eq!(comment_prefix_for_extension("sql"), Some("--"));
}
#[test]
fn comment_prefix_for_extension_returns_none_for_unknown_languages() {
assert_eq!(comment_prefix_for_extension("css"), None);
assert_eq!(comment_prefix_for_extension(""), None);
assert_eq!(comment_prefix_for_extension("md"), None);
}
}
The marker grammar:
<leading-ws><comment_prefix> CALLOUT: <label>[ <body>]
— exactly one space after the prefix, the literal CALLOUT:,
exactly one space, then a label of [A-Za-z0-9_-]+, then either
end-of-line or one whitespace + the rest as body. Anything that
doesn’t match this exactly is silently skipped (AC 7 — no silent
misparse, the line stays in the rendered listing as-is). Fourteen
unit tests cover the happy paths for all three prefixes plus the
malformed-skip cases (wrong prefix, missing space after prefix,
missing space after CALLOUT:, empty label, invalid label
characters, trailing whitespace, indented marker, multiple
markers in one listing).
src/lib.rs gains pub mod callout;.
--- lib-v3
+++ lib-v4
@@ -1,5 +1,6 @@
//! Managed code listings for mdbook.
+pub mod callout;
pub mod diff;
pub mod freeze;
pub mod install;
The slice-1 integration test is still #[ignore]’d. The parser
is plumbing — slice 3 wires it into the preprocessor and emits
HTML badges, at which point the test goes green.
Slice 3 — HTML emitter + slice-1 test goes green
Slice 3 wires parse_callouts into the preprocessor and emits
HTML badges. The simplest emission shape that satisfies the
slice-1 acceptance test: leave the rendered code block alone, and
append a <dl class="callouts"> after the closing fence with one
<dt> per marker (carrying a numbered badge) and one <dd>
per marker that has a body. Per-listing ordinal numbering (AC 5)
falls out naturally — each fenced block walks its own marker list.
--- callout-v1
+++ callout-v2
@@ -14,6 +14,7 @@
/// `<comment_prefix> CALLOUT: <label>[ <body>]`. Malformed lines are
/// silently skipped — the splicer leaves them in the rendered listing
/// unchanged.
pub fn parse_callouts(content: &str, comment_prefix: &str) -> Vec<Callout> {
let mut out = Vec::new();
for (idx, raw_line) in content.lines().enumerate() {
@@ -43,6 +44,7 @@
})
}
fn is_valid_label(label: &str) -> bool {
label
.chars()
@@ -62,6 +64,210 @@
}
}
+/// Maps a fenced-code-block info string to the language's single-line
+/// comment prefix. Accepts the language names authors typically write
+/// after the opening fence (`rust`, `yaml`, `python`, etc.) and falls back
+/// to [`comment_prefix_for_extension`] for any input that's already an
+/// extension (`rs`, `yml`).
+pub fn comment_prefix_for_language(language: &str) -> Option<&'static str> {
+ let normalised = match language {
+ "rust" => "rs",
+ "python" => "py",
+ "javascript" => "js",
+ "typescript" => "ts",
+ "shell" | "zsh" => "sh",
+ "c++" => "cpp",
+ other => other,
+ };
+ comment_prefix_for_extension(normalised)
+}
+
+/// Replace each fenced code block in `content` with the original block plus
+/// (when the block contains callout markers) a trailing `<dl class="callouts">`
+/// listing each marker's badge and body. Bytes outside fenced blocks are
+/// copied through unchanged.
+pub fn splice_chapter(content: &str) -> String {
+ let bytes = content.as_bytes();
+ let mut out = String::with_capacity(content.len());
+ let mut cursor = 0;
+ let mut line_start = 0;
+ let mut open: Option<OpenFence> = None;
+
+ while line_start < bytes.len() {
+ let line_end = match content[line_start..].find('\n') {
+ Some(off) => line_start + off,
+ None => bytes.len(),
+ };
+ let line = &content[line_start..line_end];
+
+ match &open {
+ None => {
+ if let Some((info, opener)) = fence_open_info(line) {
+ open = Some(OpenFence {
+ info,
+ opener,
+ body_start: line_end + 1,
+ });
+ }
+ }
+ Some(o) => {
+ if line_closes_fence(line, o.opener) {
+ let block_text = &content[o.body_start..line_start];
+ let close_end = if line_end < bytes.len() {
+ line_end + 1
+ } else {
+ line_end
+ };
+ let callouts = callouts_for_block(&o.info, block_text);
+ if !callouts.is_empty() {
+ out.push_str(&content[cursor..close_end]);
+ out.push('\n');
+ out.push_str(&render_callout_list(&callouts));
+ out.push('\n');
+ cursor = close_end;
+ }
+ open = None;
+ }
+ }
+ }
+
+ if line_end == bytes.len() {
+ break;
+ }
+ line_start = line_end + 1;
+ }
+
+ out.push_str(&content[cursor..]);
+ out
+}
+
+struct OpenFence {
+ info: String,
+ opener: Fence,
+ body_start: usize,
+}
+
+#[derive(Clone, Copy)]
+struct Fence {
+ char: u8,
+ count: usize,
+}
+
+fn fence_open_info(line: &str) -> Option<(String, Fence)> {
+ let trimmed = line.trim_start();
+ let leading_spaces = line.len() - trimmed.len();
+ if leading_spaces > 3 {
+ return None;
+ }
+ let bytes = trimmed.as_bytes();
+ let fence_char = match bytes.first()? {
+ b'`' => b'`',
+ b'~' => b'~',
+ _ => return None,
+ };
+ let count = bytes.iter().take_while(|&&b| b == fence_char).count();
+ if count < 3 {
+ return None;
+ }
+ Some((
+ trimmed[count..].trim().to_string(),
+ Fence {
+ char: fence_char,
+ count,
+ },
+ ))
+}
+
+/// CommonMark closes a fenced block only with a fence of the same character
+/// at least as long as the opener and a blank info string. Same-character
+/// fences shorter than the opener stay inside the block as literal text —
+/// which is what lets included source files contain `\`\`\`yaml` inside
+/// string literals without prematurely terminating the outer fence.
+fn line_closes_fence(line: &str, opener: Fence) -> bool {
+ let trimmed = line.trim_start();
+ let leading_spaces = line.len() - trimmed.len();
+ if leading_spaces > 3 {
+ return false;
+ }
+ let bytes = trimmed.as_bytes();
+ let count = bytes.iter().take_while(|&&b| b == opener.char).count();
+ if count < opener.count {
+ return false;
+ }
+ trimmed[count..].trim().is_empty()
+}
+
+/// Produce the callout list for a fenced block. `info` is the fence's info
+/// string (`rust`, `yaml`, `diff`, …). Diff blocks are handled specially:
+/// added (`+`) and context (` `) lines are stripped of their diff indicator
+/// before being parsed against every known comment prefix; removed (`-`)
+/// lines and diff metadata (`---`, `+++`, `@@`, `\`) are skipped, since a
+/// callout that's been deleted shouldn't carry a badge in the post-diff
+/// state.
+fn callouts_for_block(info: &str, block_text: &str) -> Vec<Callout> {
+ if info == "diff" {
+ return callouts_from_diff_block(block_text);
+ }
+ if let Some(prefix) = comment_prefix_for_language(info) {
+ return parse_callouts(block_text, prefix);
+ }
+ Vec::new()
+}
+
+const ALL_COMMENT_PREFIXES: &[&str] = &["//", "#", "--"];
+
+fn callouts_from_diff_block(block_text: &str) -> Vec<Callout> {
+ let mut out = Vec::new();
+ for (idx, raw_line) in block_text.lines().enumerate() {
+ if raw_line.starts_with("---")
+ || raw_line.starts_with("+++")
+ || raw_line.starts_with("@@")
+ || raw_line.starts_with('\\')
+ {
+ continue;
+ }
+ let stripped = if let Some(rest) = raw_line.strip_prefix('+') {
+ rest
+ } else if let Some(rest) = raw_line.strip_prefix(' ') {
+ rest
+ } else {
+ continue;
+ };
+ for prefix in ALL_COMMENT_PREFIXES {
+ if let Some(callout) = parse_line(stripped, prefix, idx + 1) {
+ out.push(callout);
+ break;
+ }
+ }
+ }
+ out
+}
+
+fn render_callout_list(callouts: &[Callout]) -> String {
+ let mut s = String::new();
+ s.push_str("<dl class=\"callouts\">\n");
+ for (idx, c) in callouts.iter().enumerate() {
+ let ordinal = idx + 1;
+ s.push_str(&format!(
+ " <dt id=\"callout-{label}\"><span class=\"callout-badge\" data-callout-badge=\"{label}\" data-callout-ordinal=\"{ordinal}\">{ordinal}</span></dt>\n",
+ label = html_escape(&c.label),
+ ));
+ if let Some(body) = &c.body {
+ s.push_str(&format!(" <dd>{}</dd>\n", html_escape(body)));
+ }
+ }
+ s.push_str("</dl>");
+ s
+}
+
+fn html_escape(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -193,4 +399,164 @@
assert_eq!(comment_prefix_for_extension(""), None);
assert_eq!(comment_prefix_for_extension("md"), None);
}
+
+ #[test]
+ fn comment_prefix_for_language_normalises_common_fence_labels() {
+ assert_eq!(comment_prefix_for_language("rust"), Some("//"));
+ assert_eq!(comment_prefix_for_language("python"), Some("#"));
+ assert_eq!(comment_prefix_for_language("javascript"), Some("//"));
+ assert_eq!(comment_prefix_for_language("shell"), Some("#"));
+ assert_eq!(comment_prefix_for_language("c++"), Some("//"));
+ assert_eq!(comment_prefix_for_language("yaml"), Some("#"));
+ assert_eq!(comment_prefix_for_language("rs"), Some("//"));
+ }
+
+ #[test]
+ fn splice_chapter_appends_callout_dl_after_block_with_markers() {
+ let content = "Before paragraph.\n\n\
+ ```yaml\n\
+ service: greeting\n\
+ endpoint: /hello\n\
+ # CALLOUT: endpoint-path\n\
+ ```\n\n\
+ After paragraph.\n";
+ let out = splice_chapter(content);
+ assert!(out.contains("Before paragraph.\n"));
+ assert!(out.contains("After paragraph.\n"));
+ assert!(out.contains("<dl class=\"callouts\">"));
+ assert!(out.contains("data-callout-badge=\"greeting-name\""));
+ assert!(out.contains("data-callout-ordinal=\"1\""));
+ assert!(out.contains("data-callout-badge=\"endpoint-path\""));
+ assert!(out.contains("data-callout-ordinal=\"2\""));
+ assert!(out.contains("<dd>The service identifier.</dd>"));
+ assert!(
+ !out.contains("<dd>endpoint-path"),
+ "label-only callout should have no <dd> body",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_leaves_block_alone_when_no_markers_present() {
+ let content = "```yaml\nservice: greeting\nendpoint: /hello\n```\n";
+ assert_eq!(splice_chapter(content), content);
+ }
+
+ #[test]
+ fn splice_chapter_skips_block_with_unknown_language() {
+ let content = "```\n# CALLOUT: anchor body text\n```\n";
+ let out = splice_chapter(content);
+ assert!(!out.contains("data-callout-badge"));
+ }
+
+ #[test]
+ fn splice_chapter_handles_two_blocks_independently_for_per_listing_numbering() {
+ let content = "\
+ ```yaml\n\
+ # CALLOUT: a-one\n\
+ ```\n\n\
+ ```rust\n\
+ // CALLOUT: b-one\n\
+ // CALLOUT: b-two\n\
+ ```\n";
+ let out = splice_chapter(content);
+ assert!(out.contains("data-callout-badge=\"a-one\""));
+ assert!(out.contains("data-callout-badge=\"b-one\""));
+ assert!(out.contains("data-callout-badge=\"b-two\""));
+ let a_one_ordinal = out
+ .split("data-callout-badge=\"a-one\"")
+ .nth(1)
+ .and_then(|s| s.split("data-callout-ordinal=\"").nth(1))
+ .unwrap_or("");
+ assert!(
+ a_one_ordinal.starts_with("1\""),
+ "first listing's first marker should be ordinal 1; got prefix {}",
+ &a_one_ordinal[..a_one_ordinal.len().min(10)],
+ );
+ let b_two_ordinal = out
+ .split("data-callout-badge=\"b-two\"")
+ .nth(1)
+ .and_then(|s| s.split("data-callout-ordinal=\"").nth(1))
+ .unwrap_or("");
+ assert!(
+ b_two_ordinal.starts_with("2\""),
+ "second listing's second marker should be ordinal 2; got prefix {}",
+ &b_two_ordinal[..b_two_ordinal.len().min(10)],
+ );
+ }
+
+ #[test]
+ fn splice_chapter_picks_up_callouts_from_added_and_context_diff_lines() {
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1,3 +1,4 @@\n",
+ " fn unchanged() {}\n",
+ "-fn removed() {}\n",
+ "+// CALLOUT: added-marker Body for a freshly added marker.\n",
+ " // CALLOUT: context-marker Body for a marker that survived the diff.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content);
+ assert!(
+ out.contains("data-callout-badge=\"added-marker\""),
+ "added line marker should render; got:\n{out}",
+ );
+ assert!(
+ out.contains("data-callout-badge=\"context-marker\""),
+ "context line marker should render; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_skips_callouts_on_removed_diff_lines() {
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1 +1 @@\n",
+ "-// CALLOUT: gone-marker This callout was removed.\n",
+ "+// CALLOUT: kept-marker This one stays.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content);
+ assert!(out.contains("data-callout-badge=\"kept-marker\""));
+ assert!(
+ !out.contains("data-callout-badge=\"gone-marker\""),
+ "removed-line markers should not render; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_does_not_close_outer_fence_on_shorter_inner_fence() {
+ let content = "````rust\n\
+ let s = \"```yaml\\n# CALLOUT: not-real-marker\\n```\";\n\
+ ````\n";
+ let out = splice_chapter(content);
+ assert!(
+ out.contains("data-callout-badge=\"real-marker\""),
+ "expected the marker outside the embedded ```yaml string to render; got:\n{out}",
+ );
+ assert!(
+ !out.contains("data-callout-badge=\"not-real-marker\""),
+ "the marker inside the embedded string is YAML, not Rust — and the outer fence is rust; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_escapes_label_and_body() {
+ let content = "```yaml\n# CALLOUT: lbl Body with <script> in it.\n```\n";
+ let out = splice_chapter(content);
+ let dl = out.split("<dl class=\"callouts\">").nth(1).unwrap_or("");
+ assert!(
+ dl.contains("<script>"),
+ "dl body should escape <script>; got:\n{dl}",
+ );
+ assert!(
+ !dl.contains("<script>"),
+ "dl body must not contain raw <script>; got:\n{dl}",
+ );
+ }
}
Three things are happening in the diff above. First, the
comment_prefix_for_language helper normalises fence info strings
(rust, python, c++, shell) to extensions so the same
comment_prefix_for_extension table from slice 2 covers both
shapes. Second, the splice_chapter walker tracks fenced code
blocks line-by-line and dispatches per fence info: ```rust /
```yaml / etc. parse against the language’s comment prefix
directly, while ```diff strips the + or space indicator
from each line and tries every known comment prefix (so a diff
of any source language carries its callouts through to the
rendered HTML). Removed - lines and diff metadata (---,
+++, @@, \) are skipped — a deleted callout shouldn’t
carry a current badge. Third, three CALLOUT: markers were
added to the source as a dogfood demonstration; the <dl> you
see right above is the splicer’s output for the diff path on
those three markers.
Snapshot (slice 3) of the diff path’s dl as it looked the day slice 3 shipped:

To exercise the splicer’s {{#include}} path on a different
input shape, here is the source of the screenshot tool — a small
playwright-rs script with one CALLOUT marker:
//! Capture an element-scoped screenshot of a rendered chapter and write the
//! PNG to a known path. Used by ch. 4's slice-by-slice visual record so each
//! slice's narrative can embed a snapshot of how the chapter rendered the
//! day the slice shipped.
use std::path::PathBuf;
use clap::Parser;
use playwright_rs::Playwright;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Absolute path to the rendered chapter HTML to load.
#[arg(long)]
chapter_html: PathBuf,
/// CSS selector for the element to screenshot.
#[arg(long)]
selector: String,
/// Zero-based index when the selector matches multiple elements.
/// Negative values count from the end (`-1` is the last match).
#[arg(long, default_value_t = 0)]
nth: i32,
/// Absolute path to write the PNG to. Parent directories are created.
#[arg(long)]
out: PathBuf,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if let Some(parent) = cli.out.parent() {
std::fs::create_dir_all(parent)?;
}
let url = format!("file://{}", cli.chapter_html.display());
let pw = Playwright::launch().await?;
let browser = pw.chromium().launch().await?;
let page = browser.new_page().await?;
page.goto(&url, None).await?;
let target = page.locator(&cli.selector).await.nth(cli.nth);
let png = target.screenshot(None).await?;
std::fs::write(&cli.out, png)?;
println!("✓ wrote {}", cli.out.display());
browser.close().await?;
Ok(())
}
The <dl> directly below this listing is what the splicer
emitted for the marker on the target line above — one entry,
showing the marker doing real work in this very chapter.
Snapshot (slice 3) of the include path’s dl:

Both images are frozen-in-time snapshots. Readers viewing this chapter on a build after a later slice will see the live rendered shape above each image differ from the snapshot — slice 4 onward replaces the dl form with proper inline badges, side- margin annotations, and styled themes. The images stay as the record of what slice 3 produced.
The screenshot tool above is itself a workspace member at
tools/capture-screenshots/ — kept in the repo for slice 4
onward to reuse, but excluded from the published mdbook-listings
crate (own Cargo.toml with publish = false). Run with
cargo run -p capture-screenshots -- --chapter-html … --selector dl.callouts --nth N --out … to snapshot a particular
match in a particular chapter.
src/main.rs’s preprocess now chains the diff splicer’s output
into callout::splice_chapter, so {{#diff}} resolution and
callout rendering both apply to every chapter.
--- main-v5
+++ main-v6
@@ -3,7 +3,8 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
-use mdbook_listings::diff::splice_chapter;
+use mdbook_listings::callout::splice_chapter as splice_callouts;
+use mdbook_listings::diff::splice_chapter as splice_diffs;
use mdbook_listings::freeze::{FreezeOptions, FreezeOutcome, freeze};
use mdbook_listings::install::{InstallOutcome, install};
use mdbook_listings::manifest::Manifest;
@@ -140,14 +141,16 @@
.and_then(|p| p.parent())
.map(|d| src_dir.join(d))
.unwrap_or_else(|| src_dir.clone());
- match splice_chapter(
+ match splice_diffs(
&chapter.content,
&manifest,
&ctx.root,
chapter.source_path.as_deref(),
&chapter_dir,
) {
- Ok(new_content) => chapter.content = new_content,
+ Ok(new_content) => {
+ chapter.content = splice_callouts(&new_content);
+ }
Err(e) => {
splice_err =
Some(anyhow::Error::new(e).context("rendering {{#diff}} directive failed"));
tests/e2e_callouts.rs drops its #[ignore]. The Playwright
test now runs against the just-built ch. 5 HTML, finds the
[data-callout-badge] elements emitted by the splicer above,
and goes green — closing AC 1 end-to-end.
--- e2e-callouts-v1
+++ e2e-callouts-v2
@@ -3,7 +3,6 @@
use playwright_rs::Playwright;
#[tokio::test]
-#[ignore = "no rendered callouts in ch. 4 yet"]
async fn callout_badge_renders_with_data_attribute_in_ch04() {
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
Slice 4 — label-only inline form
Slice 4 closes AC 3: a callout marker may declare just a label
with no accompanying body, in which case a numbered badge appears
but no annotation. As it turns out, the slice-3 emitter already
handles this — when body.is_none() the emitter skips the <dd>,
so a label-only marker renders as a <dt> with badge and no
following <dd>. Slice 4’s job is therefore a small one: add a
label-only marker somewhere ch. 5 includes, and add a Playwright
test that pins the visual contract so future slices can’t
regress it.
The new marker is on the cli parse line in the screenshot
tool’s source — a label-only callout, ready for slice 5’s
{{#callout cli-parse}} directive to point at:
--- capture-screenshots-v1
+++ capture-screenshots-v2
@@ -31,6 +31,7 @@
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if let Some(parent) = cli.out.parent() {
std::fs::create_dir_all(parent)?;
Snapshot (slice 4) of the dl that the splicer now emits below the
screenshot tool’s rendered source — two entries this slice
(locator-pick from slice 3 with a body, plus cli-parse added
just now as a bare anchor):

A new e2e test queries the post-render DOM for the
callout-cli-parse <dt> and asserts its nextElementSibling
is not a <dd> — i.e., the label-only form really does
produce a bare badge:
--- e2e-callouts-v2
+++ e2e-callouts-v3
@@ -3,6 +3,39 @@
use playwright_rs::Playwright;
#[tokio::test]
+async fn label_only_callout_renders_badge_without_following_body() {
+ let chapter_html = chapter_path();
+ let url = format!("file://{}", chapter_html.display());
+
+ let pw = Playwright::launch().await.expect("launch playwright");
+ let browser = pw.chromium().launch().await.expect("launch chromium");
+ let page = browser.new_page().await.expect("new page");
+ page.goto(&url, None).await.expect("goto chapter");
+
+ let next_tag: String = page
+ .evaluate_value(
+ "(() => { \
+ const dt = document.querySelector('dt[id=\"callout-cli-parse\"]'); \
+ if (!dt) return 'NOT_FOUND'; \
+ const next = dt.nextElementSibling; \
+ return next ? next.tagName : 'NONE'; \
+ })()",
+ )
+ .await
+ .expect("evaluate");
+ assert_ne!(
+ next_tag, "NOT_FOUND",
+ "expected dt#callout-cli-parse to exist on rendered ch. 4",
+ );
+ assert_ne!(
+ next_tag, "DD",
+ "label-only callout's dt must not be followed by a <dd>; got <{next_tag}>",
+ );
+
+ browser.close().await.expect("close browser");
+}
+
+#[tokio::test]
async fn callout_badge_renders_with_data_attribute_in_ch04() {
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
Same caveat as slice 3’s snapshot: if you’re reading this on a build after a later slice, the live render above will show whatever shape that slice produced; the image stays as the slice-4 record.
Slice 5 — cross-reference directive {{#callout <label>}}
Slice 5 closes ACs 4 and 8: chapter prose can reference a callout
by label and the reference renders as the same numbered badge,
hyperlinked back to the listing-side <dt id="callout-<label>">
anchor; a reference to a label that no marker in the chapter
defines fails the build with a diagnostic that names the missing
label.
The splicer in src/callout.rs becomes two-pass. The first pass
walks every fenced block in the chapter and collects a
label → ordinal map (the ordinal is the badge number that label
got at its first occurrence). The second pass scans chapter prose
— i.e. the bytes outside any fenced block — for
{{#callout <label>}} directives and replaces each with an inline
anchor. A reference to a label that’s not in the map raises
SpliceError::UnknownLabel and the preprocessor exits non-zero.
The diff itself adds two new CALLOUT markers on slice 5’s own new
functions (replace_callout_refs and render_callout_ref), so
the dl that the splicer renders below the diff has fresh anchors
that this slice’s prose then points back at:
--- callout-v2
+++ callout-v3
@@ -1,5 +1,7 @@
//! Parses inline `CALLOUT:` markers out of a frozen listing's source.
+use std::collections::{HashMap, HashSet};
+
/// Position is a 1-based line number so error diagnostics and the eventual
/// rendered badge anchor can both refer to it directly.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -82,25 +84,90 @@
comment_prefix_for_extension(normalised)
}
+/// Errors raised by the callout splicer.
+#[derive(Debug)]
+pub enum SpliceError {
+ /// A `{{#callout <label>}}` directive named a label that no callout
+ /// marker in the chapter defines.
+ UnknownLabel { label: String },
+}
+
+impl std::fmt::Display for SpliceError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ SpliceError::UnknownLabel { label } => write!(
+ f,
+ "{{{{#callout {label}}}}} references a label that no callout marker defines \
+ in this chapter",
+ ),
+ }
+ }
+}
+
+impl std::error::Error for SpliceError {}
+
/// Replace each fenced code block in `content` with the original block plus
/// (when the block contains callout markers) a trailing `<dl class="callouts">`
-/// listing each marker's badge and body. Bytes outside fenced blocks are
-/// copied through unchanged.
+/// listing each marker's badge and body, and replace each
+/// `{{#callout <label>}}` directive in chapter prose (i.e. outside fenced
+/// code blocks) with an inline anchor that links back to the listing badge.
-pub fn splice_chapter(content: &str) -> String {
- let bytes = content.as_bytes();
+pub fn splice_chapter(content: &str) -> Result<String, SpliceError> {
+ let label_to_ordinal = collect_first_occurrence_ordinals(content);
+ let with_lists = splice_callout_lists(content, &label_to_ordinal);
+ replace_callout_refs(&with_lists, &label_to_ordinal)
+}
+
+/// Records each label's ordinal at its FIRST occurrence in the chapter.
+/// Subsequent occurrences (e.g. when the same source file is shown via
+/// `{{#diff}}` after being `{{#include}}`'d) are ignored: the first dt
+/// gets `id="callout-<label>"` and acts as the canonical anchor target;
+/// later dts render the badge but no `id` so the HTML stays valid.
+fn collect_first_occurrence_ordinals(content: &str) -> HashMap<String, usize> {
+ let mut map = HashMap::new();
+ for_each_fenced_block_with_span(content, |info, block_text, _body_start, _close_end| {
+ for (idx, c) in callouts_for_block(info, block_text).iter().enumerate() {
+ map.entry(c.label.clone()).or_insert(idx + 1);
+ }
+ });
+ map
+}
+
+fn splice_callout_lists(content: &str, label_to_ordinal: &HashMap<String, usize>) -> String {
let mut out = String::with_capacity(content.len());
let mut cursor = 0;
+ let mut emitted_anchor: HashSet<String> = HashSet::new();
+ for_each_fenced_block_with_span(content, |info, block_text, _body_start, close_end| {
+ let callouts = callouts_for_block(info, block_text);
+ if !callouts.is_empty() {
+ out.push_str(&content[cursor..close_end]);
+ out.push('\n');
+ out.push_str(&render_callout_list(
+ &callouts,
+ label_to_ordinal,
+ &mut emitted_anchor,
+ ));
+ out.push('\n');
+ cursor = close_end;
+ }
+ });
+ out.push_str(&content[cursor..]);
+ out
+}
+
+fn for_each_fenced_block_with_span<F>(content: &str, mut visit: F)
+where
+ F: FnMut(&str, &str, usize, usize),
+{
+ let bytes = content.as_bytes();
let mut line_start = 0;
let mut open: Option<OpenFence> = None;
-
while line_start < bytes.len() {
let line_end = match content[line_start..].find('\n') {
Some(off) => line_start + off,
None => bytes.len(),
};
let line = &content[line_start..line_end];
-
match &open {
None => {
if let Some((info, opener)) = fence_open_info(line) {
@@ -119,29 +186,89 @@
} else {
line_end
};
- let callouts = callouts_for_block(&o.info, block_text);
- if !callouts.is_empty() {
- out.push_str(&content[cursor..close_end]);
- out.push('\n');
- out.push_str(&render_callout_list(&callouts));
- out.push('\n');
- cursor = close_end;
- }
+ visit(&o.info, block_text, o.body_start, close_end);
open = None;
}
}
}
-
if line_end == bytes.len() {
break;
}
line_start = line_end + 1;
}
+}
+
+const CALLOUT_DIRECTIVE_OPEN: &str = "{{#callout ";
+const CALLOUT_DIRECTIVE_CLOSE: &str = "}}";
+
+/// Replace `{{#callout <label>}}` directives that sit outside fenced code
+/// blocks. Directives inside fenced blocks (e.g. literal documentation
+/// examples) pass through untouched so authors can show the syntax.
+fn replace_callout_refs(
+ content: &str,
+ label_to_ordinal: &HashMap<String, usize>,
+) -> Result<String, SpliceError> {
+ let mut fence_spans: Vec<(usize, usize)> = Vec::new();
+ for_each_fenced_block_with_span(content, |_info, _text, body_start, close_end| {
+ fence_spans.push((body_start, close_end));
+ });
+ let in_fence = |pos: usize| {
+ fence_spans
+ .iter()
+ .any(|&(start, end)| pos >= start && pos < end)
+ };
+
+ let mut out = String::with_capacity(content.len());
+ let mut cursor = 0;
+ while let Some(rel) = content[cursor..].find(CALLOUT_DIRECTIVE_OPEN) {
+ let open_at = cursor + rel;
+ if in_fence(open_at) {
+ // Step past the opener so we don't loop on it forever.
+ out.push_str(&content[cursor..open_at + CALLOUT_DIRECTIVE_OPEN.len()]);
+ cursor = open_at + CALLOUT_DIRECTIVE_OPEN.len();
+ continue;
+ }
+ let label_start = open_at + CALLOUT_DIRECTIVE_OPEN.len();
+ let close_rel = match content[label_start..].find(CALLOUT_DIRECTIVE_CLOSE) {
+ Some(off) => off,
+ None => {
+ out.push_str(&content[cursor..label_start]);
+ cursor = label_start;
+ continue;
+ }
+ };
+ let label = content[label_start..label_start + close_rel].trim();
+ if !is_valid_label(label) {
+ out.push_str(&content[cursor..label_start]);
+ cursor = label_start;
+ continue;
+ }
+ let ordinal =
+ label_to_ordinal
+ .get(label)
+ .copied()
+ .ok_or_else(|| SpliceError::UnknownLabel {
+ label: label.to_string(),
+ })?;
+ out.push_str(&content[cursor..open_at]);
+ out.push_str(&render_callout_ref(label, ordinal));
+ cursor = label_start + close_rel + CALLOUT_DIRECTIVE_CLOSE.len();
+ }
out.push_str(&content[cursor..]);
- out
+ Ok(out)
}
+fn render_callout_ref(label: &str, ordinal: usize) -> String {
+ let label_esc = html_escape(label);
+ format!(
+ "<a class=\"callout-badge callout-ref\" href=\"#callout-{label_esc}\" \
+ data-callout-ref=\"{label_esc}\" data-callout-ordinal=\"{ordinal}\">{ordinal}</a>",
+ )
+}
+
struct OpenFence {
info: String,
opener: Fence,
@@ -244,14 +371,23 @@
out
}
-fn render_callout_list(callouts: &[Callout]) -> String {
+fn render_callout_list(
+ callouts: &[Callout],
+ _label_to_ordinal: &HashMap<String, usize>,
+ emitted_anchor: &mut HashSet<String>,
+) -> String {
let mut s = String::new();
s.push_str("<dl class=\"callouts\">\n");
for (idx, c) in callouts.iter().enumerate() {
let ordinal = idx + 1;
+ let label_esc = html_escape(&c.label);
+ let id_attr = if emitted_anchor.insert(c.label.clone()) {
+ format!(" id=\"callout-{label_esc}\"")
+ } else {
+ String::new()
+ };
s.push_str(&format!(
- " <dt id=\"callout-{label}\"><span class=\"callout-badge\" data-callout-badge=\"{label}\" data-callout-ordinal=\"{ordinal}\">{ordinal}</span></dt>\n",
- label = html_escape(&c.label),
+ " <dt{id_attr}><span class=\"callout-badge\" data-callout-badge=\"{label_esc}\" data-callout-ordinal=\"{ordinal}\">{ordinal}</span></dt>\n",
));
if let Some(body) = &c.body {
s.push_str(&format!(" <dd>{}</dd>\n", html_escape(body)));
@@ -421,7 +557,7 @@
# CALLOUT: endpoint-path\n\
```\n\n\
After paragraph.\n";
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
assert!(out.contains("Before paragraph.\n"));
assert!(out.contains("After paragraph.\n"));
assert!(out.contains("<dl class=\"callouts\">"));
@@ -439,13 +575,13 @@
#[test]
fn splice_chapter_leaves_block_alone_when_no_markers_present() {
let content = "```yaml\nservice: greeting\nendpoint: /hello\n```\n";
- assert_eq!(splice_chapter(content), content);
+ assert_eq!(splice_chapter(content).expect("splice"), content);
}
#[test]
fn splice_chapter_skips_block_with_unknown_language() {
let content = "```\n# CALLOUT: anchor body text\n```\n";
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
assert!(!out.contains("data-callout-badge"));
}
@@ -459,7 +595,7 @@
// CALLOUT: b-one\n\
// CALLOUT: b-two\n\
```\n";
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
assert!(out.contains("data-callout-badge=\"a-one\""));
assert!(out.contains("data-callout-badge=\"b-one\""));
assert!(out.contains("data-callout-badge=\"b-two\""));
@@ -498,7 +634,7 @@
" // CALLOUT: context-marker Body for a marker that survived the diff.\n",
"```\n",
);
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
assert!(
out.contains("data-callout-badge=\"added-marker\""),
"added line marker should render; got:\n{out}",
@@ -520,7 +656,7 @@
"+// CALLOUT: kept-marker This one stays.\n",
"```\n",
);
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
assert!(out.contains("data-callout-badge=\"kept-marker\""));
assert!(
!out.contains("data-callout-badge=\"gone-marker\""),
@@ -530,11 +666,13 @@
#[test]
fn splice_chapter_does_not_close_outer_fence_on_shorter_inner_fence() {
- let content = "````rust\n\
- let s = \"```yaml\\n# CALLOUT: not-real-marker\\n```\";\n\
- ````\n";
- let out = splice_chapter(content);
+ let content = concat!(
+ "````rust\n",
+ "let s = \"```yaml\\n# CALLOUT: not-real-marker\\n```\";\n",
+ "// CALLOUT: real-marker This one should be picked up.\n",
+ "````\n",
+ );
+ let out = splice_chapter(content).expect("splice");
assert!(
out.contains("data-callout-badge=\"real-marker\""),
"expected the marker outside the embedded ```yaml string to render; got:\n{out}",
@@ -548,7 +686,7 @@
#[test]
fn splice_chapter_html_escapes_label_and_body() {
let content = "```yaml\n# CALLOUT: lbl Body with <script> in it.\n```\n";
- let out = splice_chapter(content);
+ let out = splice_chapter(content).expect("splice");
let dl = out.split("<dl class=\"callouts\">").nth(1).unwrap_or("");
assert!(
dl.contains("<script>"),
@@ -559,4 +697,109 @@
"dl body must not contain raw <script>; got:\n{dl}",
);
}
+
+ #[test]
+ fn splice_chapter_replaces_callout_directive_with_anchor_to_listing_badge() {
+ let content = concat!(
+ "Prose mentions {{#callout greeting}} the marker.\n\n",
+ "```yaml\n",
+ "# CALLOUT: greeting Says hello.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content).expect("splice");
+ assert!(
+ out.contains("href=\"#callout-greeting\""),
+ "expected anchor href pointing at listing badge id; got:\n{out}",
+ );
+ assert!(
+ out.contains("data-callout-ref=\"greeting\""),
+ "expected ref-side data attribute; got:\n{out}",
+ );
+ assert!(
+ !out.contains("{{#callout greeting}}"),
+ "directive should be replaced; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_resolves_forward_reference_to_callout_defined_below() {
+ let content = concat!(
+ "See {{#callout later}} below.\n\n",
+ "```rust\n",
+ "// CALLOUT: later Defined after the reference.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content).expect("splice");
+ assert!(out.contains("href=\"#callout-later\""));
+ }
+
+ #[test]
+ fn splice_chapter_callout_ref_carries_per_listing_ordinal() {
+ let content = concat!(
+ "Reference {{#callout two}} here.\n\n",
+ "```rust\n",
+ "// CALLOUT: one First.\n",
+ "// CALLOUT: two Second.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content).expect("splice");
+ let segment = out.split("data-callout-ref=\"two\"").nth(1).unwrap_or("");
+ assert!(
+ segment.contains("data-callout-ordinal=\"2\""),
+ "ref to `two` should carry ordinal 2; got segment:\n{segment}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_unknown_callout_label_returns_error() {
+ let content = "Unknown ref {{#callout missing}} here.\n";
+ let err = splice_chapter(content).expect_err("expected unknown-label error");
+ match err {
+ SpliceError::UnknownLabel { label } => assert_eq!(label, "missing"),
+ }
+ }
+
+ #[test]
+ fn splice_chapter_emits_id_only_on_first_occurrence_of_repeated_label() {
+ // Same source file shown via {{#include}} and {{#diff}} produces two
+ // dl entries for the same label; only the first carries id="callout-X"
+ // so the rendered HTML stays valid (no duplicate IDs).
+ let content = concat!(
+ "```rust\n",
+ "// CALLOUT: same Body.\n",
+ "```\n\n",
+ "```diff\n",
+ "+// CALLOUT: same Body.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content).expect("splice");
+ let id_count = out.matches("id=\"callout-same\"").count();
+ assert_eq!(
+ id_count, 1,
+ "expected exactly one id=\"callout-same\"; got {id_count} in:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_does_not_replace_callout_directive_inside_code_block() {
+ // Authors show literal directive syntax inside fenced examples; the
+ // splicer must not rewrite them into anchors.
+ let content = concat!(
+ "```text\n",
+ "See {{#callout greeting}} for details.\n",
+ "```\n\n",
+ "```yaml\n",
+ "# CALLOUT: greeting Says hello.\n",
+ "```\n",
+ );
+ let out = splice_chapter(content).expect("splice");
+ assert!(
+ out.contains("{{#callout greeting}}"),
+ "literal directive inside code block should pass through; got:\n{out}",
+ );
+ assert!(
+ !out.contains("href=\"#callout-greeting\""),
+ "should not have rendered anchor for the in-code-block reference; got:\n{out}",
+ );
+ }
}
Snapshot (slice 5) of the dl rendered below the diff above. The
v2→v3 diff’s context window picks up splice-entry (carried over
from slice 3 — the new code was added near it) and the two
brand-new markers from this slice, cross-ref-replace and
cross-ref-emit:

src/main.rs’s preprocess chain propagates the new
SpliceError out of splice_callouts, so the build stops at the
chapter that contains the offending reference instead of silently
emitting a broken anchor:
--- main-v6
+++ main-v7
@@ -147,14 +147,14 @@
&ctx.root,
chapter.source_path.as_deref(),
&chapter_dir,
- ) {
- Ok(new_content) => {
- chapter.content = splice_callouts(&new_content);
- }
- Err(e) => {
- splice_err =
- Some(anyhow::Error::new(e).context("rendering {{#diff}} directive failed"));
- }
+ )
+ .map_err(|e| anyhow::Error::new(e).context("rendering {{#diff}} directive failed"))
+ .and_then(|new_content| {
+ splice_callouts(&new_content)
+ .map_err(|e| anyhow::Error::new(e).context("rendering callouts failed"))
+ }) {
+ Ok(new_content) => chapter.content = new_content,
+ Err(e) => splice_err = Some(e),
}
}
});
The new e2e test queries the prose-side anchor by its
data-callout-ref attribute, asserts its href matches the
listing-side dt id, and confirms the target dt actually exists in
the rendered DOM:
--- e2e-callouts-v3
+++ e2e-callouts-v4
@@ -60,6 +60,44 @@
browser.close().await.expect("close browser");
}
+#[tokio::test]
+async fn callout_cross_ref_renders_as_anchor_to_listing_badge() {
+ let chapter_html = chapter_path();
+ let url = format!("file://{}", chapter_html.display());
+
+ let pw = Playwright::launch().await.expect("launch playwright");
+ let browser = pw.chromium().launch().await.expect("launch chromium");
+ let page = browser.new_page().await.expect("new page");
+ page.goto(&url, None).await.expect("goto chapter");
+
+ let href: String = page
+ .evaluate_value(
+ "(() => { \
+ const a = document.querySelector('a[data-callout-ref=\"cross-ref-emit\"]'); \
+ return a ? a.getAttribute('href') : 'NOT_FOUND'; \
+ })()",
+ )
+ .await
+ .expect("evaluate href");
+ assert_eq!(
+ href, "#callout-cross-ref-emit",
+ "expected prose-side cross-ref to point at listing badge anchor",
+ );
+
+ let target_present: String = page
+ .evaluate_value(
+ "(() => document.querySelector('dt[id=\"callout-cross-ref-emit\"]') ? 'YES' : 'NO')()",
+ )
+ .await
+ .expect("evaluate anchor presence");
+ assert_eq!(
+ target_present, "YES",
+ "expected listing-side dt#callout-cross-ref-emit to exist as the cross-ref's target",
+ );
+
+ browser.close().await.expect("close browser");
+}
+
fn chapter_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
To dogfood the directive in this very chapter: the next sentence’s
badge is a {{#callout cross-ref-emit}} directive that this
slice’s splicer resolves to point at the cross-ref-emit marker
introduced by the callout-v2→v3 diff above. Clicking it should
jump the page to that marker’s dt anchor.
See callout 3 for the rendering helper this reference resolves to.
Snapshot (slice 5) of the live cross-reference badge embedded in the prose paragraph above:

Same caveat as the earlier slices’ snapshots: the image freezes slice 5’s rendered shape, while the live badge above will track later slices’ styling changes.
Slice 6 — typst-pdf emitter
Slice 6 closes AC 2: the same listing rendered to PDF produces a
styled note for each callout, ordered to match the listing. Until
this slice the splicer always emitted raw HTML (<dl class="callouts">,
<a class="callout-ref">); typst-pdf has no <dl>/<a> support
in its markdown→typst conversion, so PDF builds rendered the
callouts as escaped raw HTML instead of styled note blocks. Slice
6 makes the splicer renderer-aware: HTML stays unchanged, but for
the typst-pdf renderer the same parser output is emitted as a
markdown blockquote — bold ordinal + label, optional em-dash
plus body — which typst-pdf converts to a quoted note block in
the PDF.
A new SupportedRenderer enum (Html / TypstPdf) is the dispatch
key. The preprocessor reads ctx.renderer from the JSON envelope
mdbook hands it, looks up the variant once at the top of
preprocess(), and threads it through splice_callouts to the
two leaf emitters (render_callout_list and render_callout_ref).
Inputs that name an unrecognised renderer (e.g. a third-party
backend that mdbook-listings hasn’t been taught about) cause the
preprocessor to error rather than silently fall back to one of
the known emitters — matching what supports() already advertises.
The slice-6 production-code change in src/callout.rs is shown
as a curated snippet rather than the full v3→v4 diff. The full
diff includes new unit-test fixtures whose embedded triple-backtick
strings overload the typst-pdf markdown→typst converter; the
snippet captures the new enum, the renderer-aware dispatcher, and
the new PDF emitter — together they’re the entire user-visible
production-code change in this slice:
#![allow(unused)]
fn main() {
/// Which renderer the splicer is producing output for. The HTML emitter
/// uses raw <dl> tags so the rendered DOM carries stable
/// data-callout-badge and dt[id] attributes for cross-refs and e2e
/// assertions; the PDF emitter falls back to a markdown blockquote so
/// the typst-pdf backend renders the callouts as a styled note block
/// without relying on raw HTML passthrough.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupportedRenderer {
Html,
TypstPdf,
}
impl SupportedRenderer {
pub fn from_renderer_name(name: &str) -> Option<Self> {
match name {
"html" => Some(Self::Html),
"typst-pdf" => Some(Self::TypstPdf),
_ => None,
}
}
}
fn render_callout_list(
callouts: &[Callout],
_label_to_ordinal: &HashMap<String, usize>,
emitted_anchor: &mut HashSet<String>,
renderer: SupportedRenderer,
) -> String {
match renderer {
SupportedRenderer::Html => render_callout_list_html(callouts, emitted_anchor),
SupportedRenderer::TypstPdf => render_callout_list_pdf(callouts),
}
}
fn render_callout_list_pdf(callouts: &[Callout]) -> String {
let mut s = String::new();
for (idx, c) in callouts.iter().enumerate() {
let ordinal = idx + 1;
if idx > 0 {
s.push_str("> \n");
}
match &c.body {
Some(body) => {
s.push_str(&format!("> **[{ordinal}] {}** — {body}\n", c.label));
}
None => {
s.push_str(&format!("> **[{ordinal}] {}**\n", c.label));
}
}
}
s
}
}
The file lives under book/src/snippets/ rather than
book/src/listings/ because it is a hand-curated excerpt rather
than a frozen tag — the mdbook-listings freeze discipline only
applies to byte-exact mirrors of an upstream source file. Snippets
are versioned by convention (-v1, -v2, …) so a later slice
that needs to extend the curated excerpt mints a new file rather
than mutating an earlier slice’s frozen-in-time reference.
The snippet itself dogfoods a CALLOUT marker on the new
render_callout_list_pdf function (pdf-emit), so the splicer
emits a <dl class="callouts"> directly below the snippet above.
Snapshot (slice 6) of that HTML dl as it looked the day slice 6
shipped:

src/main.rs’s preprocess resolves the renderer once and passes
it through:
--- main-v7
+++ main-v8
@@ -3,7 +3,7 @@
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
-use mdbook_listings::callout::splice_chapter as splice_callouts;
+use mdbook_listings::callout::{SupportedRenderer, splice_chapter as splice_callouts};
use mdbook_listings::diff::splice_chapter as splice_diffs;
use mdbook_listings::freeze::{FreezeOptions, FreezeOutcome, freeze};
use mdbook_listings::install::{InstallOutcome, install};
@@ -128,6 +128,8 @@
let (ctx, mut book) = mdbook_preprocessor::parse_input(std::io::stdin())?;
let manifest = Manifest::load(&ctx.root)?;
let src_dir = ctx.root.join(&ctx.config.book.src);
+ let renderer = SupportedRenderer::from_renderer_name(&ctx.renderer)
+ .with_context(|| format!("unsupported renderer: {}", ctx.renderer))?;
let mut splice_err: Option<anyhow::Error> = None;
book.for_each_mut(|item| {
@@ -150,7 +152,7 @@
)
.map_err(|e| anyhow::Error::new(e).context("rendering {{#diff}} directive failed"))
.and_then(|new_content| {
- splice_callouts(&new_content)
+ splice_callouts(&new_content, renderer)
.map_err(|e| anyhow::Error::new(e).context("rendering callouts failed"))
}) {
Ok(new_content) => chapter.content = new_content,
A new dev-dep, pdf-extract
(pure-Rust, no system deps), drives the PDF integration test —
robust to typst version bumps because it asserts on body-text
substrings rather than byte-exact PDF structure. The test is
gated to the Linux CI job that has the typst fonts installed and
the just-built PDF available; the cross-platform Test on … jobs
exclude both e2e_callouts and pdf_callouts since they need a
built book.
Cargo.toml gains the single pdf-extract [dev-dependencies]
entry — kept narrow because the test only needs the crate’s
extract_text_from_mem function:
--- cargo-toml-v4
+++ cargo-toml-v5
@@ -16,13 +16,19 @@
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
-similar = "2"
+similar = "3"
toml = "1.1"
-toml_edit = "0.22"
+toml_edit = "0.25"
[dev-dependencies]
assert_cmd = "2"
+pdf-extract = "0.9"
playwright-rs = "0.12"
predicates = "3"
tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+
+# Workspace tools live alongside the crate but are not part of the published
+# crate (each carries `publish = false`). Run with `cargo run -p <name>`.
+[workspace]
+members = ["tools/capture-screenshots"]
The new test file is tests/pdf_callouts.rs, mirroring the
naming convention of the other story-scoped integration test
files (tests/e2e_callouts.rs, tests/diffs.rs). It reads the
just-built PDF off disk, runs it through pdf-extract, and
asserts that two known callout body fragments — splice-entry’s
“HTML splicer entry point” and cross-ref-emit’s “Renders the
prose-side anchor” — appear in the extracted text:
#![allow(unused)]
fn main() {
//! Asserts the typst-pdf renderer emits callout bodies into the PDF.
//! Runs against the just-built `book/build/typst-pdf/*.pdf` (the same
//! artifact CI publishes with the HTML site).
use std::fs;
use std::path::PathBuf;
#[test]
fn ch04_pdf_contains_callout_bodies_emitted_by_pdf_splicer() {
let pdf_path = pdf_path();
let bytes =
fs::read(&pdf_path).unwrap_or_else(|e| panic!("read PDF at {}: {}", pdf_path.display(), e));
let text =
pdf_extract::extract_text_from_mem(&bytes).expect("extract text from typst-pdf output");
// The callout-v3 splice-entry marker has a body that's stable across
// splicer revisions; the PDF emitter should emit it as a blockquote
// line. We match on the body text fragment so we're robust to ordinal
// and label-formatting changes.
assert!(
text.contains("HTML splicer entry point") || text.contains("splicer entry point"),
"expected callout body text in extracted PDF; got first 4KB:\n{}",
&text[..text.len().min(4096)],
);
// The cross-ref-emit body (added in slice 5) should also appear since
// slice 5's diff exposes its callout marker.
assert!(
text.contains("Renders the prose-side anchor"),
"expected cross-ref-emit body text in extracted PDF; got first 4KB:\n{}",
&text[..text.len().min(4096)],
);
}
fn pdf_path() -> PathBuf {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
PathBuf::from(manifest_dir)
.join("book")
.join("build")
.join("typst-pdf")
.join("mdbook-listings.pdf")
}
}
Snapshot (slice 6) of one PDF page that renders the slice 5 callout-v2→v3 diff. The dl that the HTML emitter produces below the diff appears here as a quoted note block — three entries, bold ordinal + label, em-dash + body — directly under the diff fence:

The visual on this page is a frozen snapshot of slice 6’s PDF
output; the page number itself shifts as the book grows. CI runs
cargo test --test pdf_callouts against the just-built PDF on
every push, so any regression in the PDF emitter surfaces as a
failed assertion rather than a quietly-broken render.
Slice 7 — HTML rendered-shape pivot
Slice 7 closes ACs 9 and 10. The slice-3
placeholder shape (CALLOUT comment line visible in the listing
plus a trailing <dl class="callouts"> of bodies) is replaced
with the final shape:
- The marker comment is stripped from the rendered listing
for
{{#include}}blocks (every recognised language with an inline-marker syntax). Diff blocks pass through unchanged so the diff format stays valid; the canonical badge anchor lives on the include, not on the diff history. - The trailing
<dl>is gone. In its place an absolutely- positioned<div class="callout-overlay">sibling holds one interactive<button class="callout-badge">per marker, each carrying the post-stripdata-callout-lineso CSS positions it on the line that previously held the marker comment. - The vertical positioning is completely JavaScript-free. The Rust
HTML emitter calculates the total lines in the code block and
injects
--callout-listing-linesdirectly into the DOM, which the bundled CSS uses to perfectly align the badge to the line. - Hovering or keyboard-focusing the badge reveals its body in a
popover (a sibling
<div class="callout-body">). The entrance and exit animations are choreographed purely in CSS usingclip-path,color, andvisibilitytransitions (eliminating the need for abrupt[hidden]attribute toggles). Label-only markers emit no popover at all — just the badge.
Slice 7 mints a new version of the slice-6 snippet,
callout-pdf-emit-snippet-v2, that extends the curated excerpt
with the two cross-ref-related functions (replace_callout_refs
and render_callout_ref) so the callout markers attached to them
(cross-ref-replace, cross-ref-emit) now have rendered
<button> anchors. Slice 5’s {{#callout cross-ref-emit}}
cross-reference resolves to that anchor:
#![allow(unused)]
fn main() {
/// Which renderer the splicer is producing output for. The HTML emitter
/// uses raw <dl> tags so the rendered DOM carries stable
/// data-callout-badge and dt[id] attributes for cross-refs and e2e
/// assertions; the PDF emitter falls back to a markdown blockquote so
/// the typst-pdf backend renders the callouts as a styled note block
/// without relying on raw HTML passthrough.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SupportedRenderer {
Html,
TypstPdf,
}
impl SupportedRenderer {
pub fn from_renderer_name(name: &str) -> Option<Self> {
match name {
"html" => Some(Self::Html),
"typst-pdf" => Some(Self::TypstPdf),
_ => None,
}
}
}
fn render_callout_list(
callouts: &[Callout],
_label_to_ordinal: &HashMap<String, usize>,
emitted_anchor: &mut HashSet<String>,
renderer: SupportedRenderer,
) -> String {
match renderer {
SupportedRenderer::Html => render_callout_list_html(callouts, emitted_anchor),
SupportedRenderer::TypstPdf => render_callout_list_pdf(callouts),
}
}
fn render_callout_list_pdf(callouts: &[Callout]) -> String {
let mut s = String::new();
for (idx, c) in callouts.iter().enumerate() {
let ordinal = idx + 1;
if idx > 0 {
s.push_str("> \n");
}
match &c.body {
Some(body) => {
s.push_str(&format!("> **[{ordinal}] {}** — {body}\n", c.label));
}
None => {
s.push_str(&format!("> **[{ordinal}] {}**\n", c.label));
}
}
}
s
}
fn replace_callout_refs(
content: &str,
label_to_ordinal: &HashMap<String, usize>,
) -> Result<String, SpliceError> {
/* ... directive scan + label resolve, omitted for brevity ... */
Ok(content.to_string())
}
fn render_callout_ref(label: &str, ordinal: usize, renderer: SupportedRenderer) -> String {
match renderer {
SupportedRenderer::Html => {
let label_esc = html_escape(label);
format!(
"<a class=\"callout-badge callout-ref\" href=\"#callout-{label_esc}\" \
data-callout-ref=\"{label_esc}\" data-callout-ordinal=\"{ordinal}\">{ordinal}</a>",
)
}
SupportedRenderer::TypstPdf => format!("**[{ordinal}]**"),
}
}
}
Snippets are versioned by convention because slice 6’s narrative references v1 and that text shouldn’t silently drift when later slices extend the excerpt; minting a new file preserves the slice-6 reference verbatim.
To dogfood the label-only form (cli-parse) under the new shape,
the slice-4 frozen capture-screenshots-v2.rs is included here
as well. The // CALLOUT: cli-parse line is stripped from the
rendered listing; in its place the splicer’s overlay div holds a
bare badge button on the Cli::parse() line:
//! Capture an element-scoped screenshot of a rendered chapter and write the
//! PNG to a known path. Used by ch. 4's slice-by-slice visual record so each
//! slice's narrative can embed a snapshot of how the chapter rendered the
//! day the slice shipped.
use std::path::PathBuf;
use clap::Parser;
use playwright_rs::Playwright;
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
/// Absolute path to the rendered chapter HTML to load.
#[arg(long)]
chapter_html: PathBuf,
/// CSS selector for the element to screenshot.
#[arg(long)]
selector: String,
/// Zero-based index when the selector matches multiple elements.
/// Negative values count from the end (`-1` is the last match).
#[arg(long, default_value_t = 0)]
nth: i32,
/// Absolute path to write the PNG to. Parent directories are created.
#[arg(long)]
out: PathBuf,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
if let Some(parent) = cli.out.parent() {
std::fs::create_dir_all(parent)?;
}
let url = format!("file://{}", cli.chapter_html.display());
let pw = Playwright::launch().await?;
let browser = pw.chromium().launch().await?;
let page = browser.new_page().await?;
page.goto(&url, None).await?;
let target = page.locator(&cli.selector).await.nth(cli.nth);
let png = target.screenshot(None).await?;
std::fs::write(&cli.out, png)?;
println!("✓ wrote {}", cli.out.display());
browser.close().await?;
Ok(())
}
src/callout.rs gains the new splice_callout_lists_html
emitter that walks each fenced block, calls strip_marker_lines
to rewrite the body without marker comments, and appends
render_callout_overlay_html after the closing fence. The PDF
emitter (splice_callout_lists_pdf) is unchanged from slice 6 —
slice 9 is the PDF-side rendered-shape pivot.
The bundled CSS asset (assets/mdbook-listings.css) gains the
positioning + hover rules for the new shape. The install
command writes this file into the book’s theme directory just
like before; users who already installed mdbook-listings need
to rerun mdbook-listings install to pick up the slice-7 CSS.
Snapshot (slice 7) of the rendered HTML for the screenshot tool include — the marker comment is gone and the badge sits at the right margin of its line:

The dl is gone; the badges are interactive; the body popover shows on hover. Visual reference is from the day slice 7 shipped — later slices may restyle.
The screenshot above was produced by the workspace tool
tools/capture-screenshots/, which slice 7 also evolved: it now
takes a positional listing tag (capture-screenshots e2e-callouts-v5) and finds the listing in the rendered HTML via the
<div data-listing-tag> anchor that the diff splicer just learned to
emit, with a callout-badge fallback for {{#include}} blocks whose
source has at least one CALLOUT: marker. That fallback has a blind
spot — listings without callouts and not on the right side of any
diff aren’t addressable. Slice 8 closes that gap by re-engineering
the tool into two subcommands (include LISTING and
diff LEFT RIGHT) backed by a preprocessor pass that injects the
same kind of locator anchor after every {{#include listings/...}}
block.
Slice 8 — screenshot-tool subcommands and include-block locator anchors
Slice 8 doesn’t satisfy any chapter AC (those are 1–12 from the
section above); it’s tooling that closes a usability gap exposed by
slice 7 dogfooding. With slice 7 the screenshot tool can address
listings shown via {{#diff}} (locator: the <div data-listing-tag>
anchor the diff splicer learned to emit) and listings shown via
{{#include}} that have at least one CALLOUT: marker (fallback
locator: the button[id="callout-LABEL"] element from the rendered
overlay). Listings without callouts and not referenced by any diff
were unreachable.
The fix has two parts: a preprocessor side (a new include splicer + a richer diff anchor) and a tool side (subcommands matching the two listing-rendering shapes).
Preprocessor — new src/include.rs module. Intercepts
{{#include listings/TAG.ext}} directives BEFORE mdbook’s built-in
links preprocessor would expand them, replaces each with the
file’s bytes, and emits a <div data-listing-tag="TAG"> anchor
after the enclosing fenced block’s closing fence line. To run
before links, the book/book.toml listings-preprocessor entry
adds before = ["admonish", "links"] — without that ordering,
links expands every {{#include}} first and the include splicer
silently no-ops. The splicer’s path-prefix dispatch is at callout
2; the entry point that drives the
whole replace-and-emit walk is at callout {{#callout
include-splice-entry}}; the line that drops the locator anchor is
at callout 5:
#![allow(unused)]
fn main() {
//! Intercepts `{{#include listings/...}}` and `{{#include snippets/...}}`
//! before mdbook's built-in `links` preprocessor expands them, so the
//! callout splicer downstream can find any `CALLOUT:` markers in the
//! included source and so frozen-listing includes get a locator anchor.
use std::ops::Range;
use std::path::{Path, PathBuf};
use crate::callout::for_each_fenced_block_with_span;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeDirective {
pub tag: Option<String>,
pub rel_path: String,
pub span: Range<usize>,
pub fence_close_end: Option<usize>,
}
pub fn parse_listing_includes(content: &str) -> Vec<IncludeDirective> {
let mut fences: Vec<(usize, usize)> = Vec::new();
for_each_fenced_block_with_span(content, |_info, _text, body_start, close_end| {
fences.push((body_start, close_end));
});
const PREFIX: &[u8] = b"{{#include ";
let bytes = content.as_bytes();
let mut out = Vec::new();
let mut line_start = 0;
while line_start < bytes.len() {
let line_end = match content[line_start..].find('\n') {
Some(off) => line_start + off,
None => bytes.len(),
};
let mut i = line_start;
while i + PREFIX.len() <= line_end {
if &bytes[i..i + PREFIX.len()] != PREFIX {
i += 1;
continue;
}
if i > 0 && bytes[i - 1] == b'\\' {
i += PREFIX.len();
continue;
}
let backticks_before = bytes[line_start..i].iter().filter(|&&b| b == b'`').count();
if backticks_before % 2 == 1 {
i += PREFIX.len();
continue;
}
let inner_start = i + PREFIX.len();
let Some(end_rel) = content[inner_start..].find("}}") else {
break;
};
let directive_end = inner_start + end_rel + 2;
let path = content[inner_start..inner_start + end_rel].trim();
let intercepted = path.starts_with("listings/") || path.starts_with("snippets/");
if !intercepted || path.contains(':') {
i = directive_end;
continue;
}
let tag = if path.starts_with("listings/") {
Some(
std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string(),
)
} else {
None
};
let fence_close_end = fences
.iter()
.find(|(body_start, close_end)| i >= *body_start && i < *close_end)
.map(|(_, close_end)| *close_end);
out.push(IncludeDirective {
tag,
rel_path: path.to_string(),
span: i..directive_end,
fence_close_end,
});
i = directive_end;
}
if line_end == bytes.len() {
break;
}
line_start = line_end + 1;
}
out
}
#[derive(Debug)]
pub enum SpliceError {
ListingFileMissing {
tag: String,
path: PathBuf,
source: std::io::Error,
line: usize,
chapter_path: Option<PathBuf>,
},
ListingIncludeOutsideFence {
tag: String,
line: usize,
chapter_path: Option<PathBuf>,
},
}
impl std::fmt::Display for SpliceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SpliceError::ListingFileMissing {
tag,
path,
source,
line,
chapter_path,
} => {
write!(
f,
"{}:{line}: {{{{#include listings/{tag}.…}}}} references missing file {}: {source}",
chapter_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<chapter>".into()),
path.display(),
)
}
SpliceError::ListingIncludeOutsideFence {
tag,
line,
chapter_path,
} => {
write!(
f,
"{}:{line}: {{{{#include listings/{tag}.…}}}} appears outside any fenced code block; \
wrap it in ```<lang> ... ``` so the anchor has a <pre> sibling",
chapter_path
.as_deref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "<chapter>".into()),
)
}
}
}
}
impl std::error::Error for SpliceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
SpliceError::ListingFileMissing { source, .. } => Some(source),
SpliceError::ListingIncludeOutsideFence { .. } => None,
}
}
}
pub fn splice_chapter(
content: &str,
src_dir: &Path,
chapter_path: Option<&Path>,
) -> Result<String, SpliceError> {
let directives = parse_listing_includes(content);
if directives.is_empty() {
return Ok(content.to_string());
}
let mut out = String::with_capacity(content.len() * 2);
let mut cursor = 0;
for d in &directives {
let Some(close_end) = d.fence_close_end else {
return Err(SpliceError::ListingIncludeOutsideFence {
tag: d.tag.clone().unwrap_or_else(|| d.rel_path.clone()),
line: line_number(content, d.span.start),
chapter_path: chapter_path.map(Path::to_path_buf),
});
};
let abs_path = src_dir.join(&d.rel_path);
let mut body = std::fs::read_to_string(&abs_path).map_err(|source| {
SpliceError::ListingFileMissing {
tag: d.tag.clone().unwrap_or_else(|| d.rel_path.clone()),
path: abs_path.clone(),
source,
line: line_number(content, d.span.start),
chapter_path: chapter_path.map(Path::to_path_buf),
}
})?;
// Why: the chapter's newline-after-directive (preserved via
// `content[d.span.end..]`) terminates the last content line; keeping
// the file's own trailing newline produces a blank line before the
// closing fence.
while body.ends_with('\n') {
body.pop();
}
out.push_str(&content[cursor..d.span.start]);
out.push_str(&body);
out.push_str(&content[d.span.end..close_end]);
if let Some(tag) = &d.tag {
out.push_str(&format!(
"<div data-listing-tag=\"{tag}\" aria-hidden=\"true\"></div>\n",
));
}
cursor = close_end;
}
out.push_str(&content[cursor..]);
Ok(out)
}
fn line_number(content: &str, byte_offset: usize) -> usize {
content[..byte_offset]
.bytes()
.filter(|&b| b == b'\n')
.count()
+ 1
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn parse_listing_includes_extracts_well_formed_directive() {
let content = "Before.\n```rust\n{{#include listings/foo.rs}}\n```\nAfter.\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert_eq!(got[0].tag.as_deref(), Some("foo"));
assert_eq!(got[0].rel_path, "listings/foo.rs");
}
#[test]
fn parse_listing_includes_extracts_tag_as_file_stem() {
let content = "```rust\n{{#include listings/some-tag-v3.rs}}\n```\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert_eq!(got[0].tag.as_deref(), Some("some-tag-v3"));
}
#[test]
fn parse_listing_includes_collects_snippets_with_no_tag() {
let content = "```rust\n{{#include snippets/excerpt.rs}}\n```\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert_eq!(got[0].tag, None);
assert_eq!(got[0].rel_path, "snippets/excerpt.rs");
}
#[test]
fn parse_listing_includes_skips_escaped_form() {
let content = "Inline example: \{{#include listings/foo.rs}} should not match.\n";
assert!(parse_listing_includes(content).is_empty());
}
#[test]
fn parse_listing_includes_skips_directive_inside_inline_code_span() {
let content = "Prose discussing `{{#include listings/foo.rs}}` syntax.\n";
assert!(parse_listing_includes(content).is_empty());
}
#[test]
fn parse_listing_includes_skips_unintercepted_paths_and_line_ranges() {
let content = concat!(
"```toml\n",
"[package]
name = "mdbook-listings"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
license = "MIT"
description = "Managed code listings for mdbook: inline callouts, freezing, and verification"
repository = "https://github.com/padamson/mdbook-listings"
categories = ["command-line-utilities", "text-processing"]
keywords = ["mdbook", "preprocessor", "documentation", "code-listing"]
exclude = ["book/"]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
mdbook-preprocessor = "0.5"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
similar = "3"
toml = "1.1"
toml_edit = "0.25"
[dev-dependencies]
assert_cmd = "2"
futures = "0.3"
pdf-extract = "0.10"
playwright-rs = { workspace = true }
playwright-rs-trace = { git = "https://github.com/padamson/playwright-rust", branch = "main" }
predicates = "3"
tempfile = "3"
tokio = { workspace = true }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Workspace tools live alongside the crate but are not part of the published
crate (each carries `publish = false`). Run with `cargo run -p <name>`.
[workspace]
members = ["tools/capture-screenshots"]
Centralised cross-workspace deps. `playwright-rs` is dogfooded against the
upstream main branch so this project rides ahead of the latest crates.io
release (currently 0.12.3) and can adopt unreleased v0.13.0 features
(richer tracing instrumentation, ARIA snapshots, screencast surface, etc.)
before they ship. Cargo.lock pins the actual revision for reproducibility;
bump with `cargo update -p playwright-rs`.
[workspace.dependencies]
playwright-rs = { git = "https://github.com/padamson/playwright-rust", branch = "main" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }\n",
"```\n\n",
"```rust\n",
"{{#include listings/foo.rs:5:20}}\n",
"```\n\n",
"```rust\n",
"{{#include snippets/foo.rs:setup}}\n",
"```\n",
);
assert!(parse_listing_includes(content).is_empty());
}
#[test]
fn parse_listing_includes_handles_subdirectory_path() {
let content = "```rust\n{{#include listings/sub/foo.rs}}\n```\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert_eq!(got[0].tag.as_deref(), Some("foo"));
assert_eq!(got[0].rel_path, "listings/sub/foo.rs");
}
#[test]
fn parse_listing_includes_records_fence_close_end_for_in_fence_directive() {
let content = "```rust\n{{#include listings/foo.rs}}\n```\nafter\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert!(got[0].fence_close_end.is_some());
}
#[test]
fn parse_listing_includes_records_no_fence_close_end_for_out_of_fence_directive() {
let content = "Inline mention: {{#include listings/foo.rs}} not in fence.\n";
let got = parse_listing_includes(content);
assert_eq!(got.len(), 1);
assert_eq!(got[0].fence_close_end, None);
}
#[test]
fn splice_chapter_replaces_directive_with_file_contents_and_emits_anchor() {
let tmp = TempDir::new().unwrap();
let src = tmp.path();
std::fs::create_dir_all(src.join("listings")).unwrap();
std::fs::write(src.join("listings/foo.rs"), "fn body() {}\n").unwrap();
let content = "```rust\n{{#include listings/foo.rs}}\n```\n";
let out = splice_chapter(content, src, None).expect("splice");
assert!(out.contains("fn body() {}"), "got:\n{out}");
assert!(!out.contains("{{#include"), "got:\n{out}");
assert!(out.contains("data-listing-tag=\"foo\""), "got:\n{out}");
}
#[test]
fn splice_chapter_emits_anchor_after_closing_fence_not_inside_block() {
let tmp = TempDir::new().unwrap();
let src = tmp.path();
std::fs::create_dir_all(src.join("listings")).unwrap();
std::fs::write(src.join("listings/foo.rs"), "fn body() {}\n").unwrap();
let content = "```rust\n{{#include listings/foo.rs}}\n```\n";
let out = splice_chapter(content, src, None).expect("splice");
let anchor_pos = out.find("data-listing-tag").expect("anchor present");
let close_fence_pos = out
.find("```\n")
.map(|p| p + 4)
.expect("close fence present");
assert!(anchor_pos > close_fence_pos, "got:\n{out}");
}
#[test]
fn splice_chapter_returns_listing_file_missing_with_chapter_line_for_absent_file() {
let tmp = TempDir::new().unwrap();
let chapter = std::path::Path::new("ch99-foo.md");
let content = "intro\n\n```rust\n{{#include listings/missing-tag.rs}}\n```\n";
let err = splice_chapter(content, tmp.path(), Some(chapter)).expect_err("should fail");
match err {
SpliceError::ListingFileMissing {
tag,
line,
chapter_path,
..
} => {
assert_eq!(tag, "missing-tag");
assert_eq!(line, 4);
assert_eq!(chapter_path.as_deref(), Some(chapter));
}
SpliceError::ListingIncludeOutsideFence { .. } => panic!("wrong variant"),
}
}
#[test]
fn splice_chapter_returns_listing_include_outside_fence_when_directive_has_no_enclosing_fence()
{
let chapter = std::path::Path::new("ch99-foo.md");
let content = "Mid-paragraph: {{#include listings/foo.rs}} bare directive.\n";
let tmp = TempDir::new().unwrap();
let err = splice_chapter(content, tmp.path(), Some(chapter)).expect_err("should fail");
match err {
SpliceError::ListingIncludeOutsideFence {
tag,
line,
chapter_path,
} => {
assert_eq!(tag, "foo");
assert_eq!(line, 1);
assert_eq!(chapter_path.as_deref(), Some(chapter));
}
SpliceError::ListingFileMissing { .. } => panic!("wrong variant"),
}
}
#[test]
fn splice_chapter_passes_through_unintercepted_includes_untouched() {
let tmp = TempDir::new().unwrap();
let content = concat!(
"```toml\n",
"[package]
name = "mdbook-listings"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
license = "MIT"
description = "Managed code listings for mdbook: inline callouts, freezing, and verification"
repository = "https://github.com/padamson/mdbook-listings"
categories = ["command-line-utilities", "text-processing"]
keywords = ["mdbook", "preprocessor", "documentation", "code-listing"]
exclude = ["book/"]
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
mdbook-preprocessor = "0.5"
pulldown-cmark = { version = "0.13", default-features = false, features = ["html"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.11"
similar = "3"
toml = "1.1"
toml_edit = "0.25"
[dev-dependencies]
assert_cmd = "2"
futures = "0.3"
pdf-extract = "0.10"
playwright-rs = { workspace = true }
playwright-rs-trace = { git = "https://github.com/padamson/playwright-rust", branch = "main" }
predicates = "3"
tempfile = "3"
tokio = { workspace = true }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Workspace tools live alongside the crate but are not part of the published
crate (each carries `publish = false`). Run with `cargo run -p <name>`.
[workspace]
members = ["tools/capture-screenshots"]
Centralised cross-workspace deps. `playwright-rs` is dogfooded against the
upstream main branch so this project rides ahead of the latest crates.io
release (currently 0.12.3) and can adopt unreleased v0.13.0 features
(richer tracing instrumentation, ARIA snapshots, screencast surface, etc.)
before they ship. Cargo.lock pins the actual revision for reproducibility;
bump with `cargo update -p playwright-rs`.
[workspace.dependencies]
playwright-rs = { git = "https://github.com/padamson/playwright-rust", branch = "main" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }\n",
"```\n\n",
"```rust\n",
"{{#include listings/foo.rs:5:20}}\n",
"```\n",
);
let out = splice_chapter(content, tmp.path(), None).expect("splice");
assert_eq!(out, content, "got:\n{out}");
}
#[test]
fn splice_chapter_expands_snippet_include_without_emitting_anchor() {
let tmp = TempDir::new().unwrap();
let src = tmp.path();
std::fs::create_dir_all(src.join("snippets")).unwrap();
std::fs::write(src.join("snippets/excerpt.rs"), "fn snippet_body() {}\n").unwrap();
let content = "```rust\n{{#include snippets/excerpt.rs}}\n```\n";
let out = splice_chapter(content, src, None).expect("splice");
assert!(out.contains("fn snippet_body() {}"), "got:\n{out}");
assert!(!out.contains("data-listing-tag"), "got:\n{out}");
assert!(!out.contains("{{#include"), "got:\n{out}");
}
#[test]
fn splice_chapter_handles_two_includes_in_one_chapter_with_independent_anchors() {
let tmp = TempDir::new().unwrap();
let src = tmp.path();
std::fs::create_dir_all(src.join("listings")).unwrap();
std::fs::write(src.join("listings/foo.rs"), "fn body_one() {}\n").unwrap();
std::fs::write(src.join("listings/bar.rs"), "fn body_two() {}\n").unwrap();
let content = concat!(
"```rust\n",
"{{#include listings/foo.rs}}\n",
"```\n\n",
"```rust\n",
"{{#include listings/bar.rs}}\n",
"```\n",
);
let out = splice_chapter(content, src, None).expect("splice");
assert!(out.contains("fn body_one() {}"));
assert!(out.contains("fn body_two() {}"));
assert!(out.contains("data-listing-tag=\"foo\""));
assert!(out.contains("data-listing-tag=\"bar\""));
}
#[test]
fn splice_chapter_appends_trailing_newline_when_file_lacks_one() {
let tmp = TempDir::new().unwrap();
let src = tmp.path();
std::fs::create_dir_all(src.join("listings")).unwrap();
std::fs::write(src.join("listings/foo.rs"), "fn body() {}").unwrap();
let content = "```rust\n{{#include listings/foo.rs}}\n```\n";
let out = splice_chapter(content, src, None).expect("splice");
assert!(out.contains("fn body() {}\n```"), "got:\n{out}");
}
}
}
{{#include snippets/...}} paths are also intercepted (callout
2) — without an anchor — so the
callout splicer downstream sees their CALLOUT: markers (otherwise
mdbook’s links would expand them after our callout pass and the
markers would land in the rendered HTML with no overlay buttons).
All other includes (../some/path, line-range syntax :N:M,
anchor refs :setup) pass through untouched for links to handle.
The diff splicer’s anchor also expands from the slice-7
single-attribute data-listing-tag="RIGHT" to the dual-attribute
data-listing-diff-left="LEFT" data-listing-diff-right="RIGHT"
(callout 1), so the locator is unique
even when several diffs share a right operand. Slice 8 also evolves
the HTML splicer to process diff blocks for callouts: +-prefixed
and -prefixed marker lines are stripped and emit a badge keyed
to the line that previously held them; --prefixed marker lines
are dropped silently with no badge — the callout is gone in the
new state, so neither the comment nor a marker for it appears in
the rendered diff:
--- diff-v7
+++ diff-v8
@@ -271,7 +271,12 @@
out.push_str(&content[cursor..d.span.start]);
out.push_str("```diff\n");
out.push_str(&body);
- out.push_str("```");
+ out.push_str("```\n");
+ out.push_str(&format!(
+ "<div data-listing-diff-left=\"{}\" data-listing-diff-right=\"{}\" aria-hidden=\"true\"></div>",
+ d.left, d.right,
+ ));
cursor = d.span.end;
}
out.push_str(&content[cursor..]);
@@ -593,6 +598,14 @@
!out.contains("{{#diff"),
"directive should be consumed; got:\n{out}",
);
+ assert!(
+ out.contains("data-listing-diff-left=\"left-tag\""),
+ "expected diff-left anchor attribute; got:\n{out}",
+ );
+ assert!(
+ out.contains("data-listing-diff-right=\"right-tag\""),
+ "expected diff-right anchor attribute; got:\n{out}",
+ );
}
#[test]
The preprocessor wires the new include splicer into preprocess()
as the first stage of a three-stage chain — includes → diffs →
callouts (callout 1). Order matters:
the callout splicer needs included source bytes inline so it can
parse CALLOUT: markers from them.
--- main-v8
+++ main-v9
@@ -6,6 +6,7 @@
use mdbook_listings::callout::{SupportedRenderer, splice_chapter as splice_callouts};
use mdbook_listings::diff::splice_chapter as splice_diffs;
use mdbook_listings::freeze::{FreezeOptions, FreezeOutcome, freeze};
+use mdbook_listings::include::splice_chapter as splice_includes;
use mdbook_listings::install::{InstallOutcome, install};
use mdbook_listings::manifest::Manifest;
use mdbook_preprocessor::book::BookItem;
@@ -143,18 +144,27 @@
.and_then(|p| p.parent())
.map(|d| src_dir.join(d))
.unwrap_or_else(|| src_dir.clone());
- match splice_diffs(
- &chapter.content,
- &manifest,
- &ctx.root,
- chapter.source_path.as_deref(),
- &chapter_dir,
- )
- .map_err(|e| anyhow::Error::new(e).context("rendering {{#diff}} directive failed"))
- .and_then(|new_content| {
- splice_callouts(&new_content, renderer)
- .map_err(|e| anyhow::Error::new(e).context("rendering callouts failed"))
- }) {
+ match splice_includes(&chapter.content, &src_dir, chapter.source_path.as_deref())
+ .map_err(|e| {
+ anyhow::Error::new(e).context("expanding {{#include listings/...}} failed")
+ })
+ .and_then(|new_content| {
+ splice_diffs(
+ &new_content,
+ &manifest,
+ &ctx.root,
+ chapter.source_path.as_deref(),
+ &chapter_dir,
+ )
+ .map_err(|e| {
+ anyhow::Error::new(e).context("rendering {{#diff}} directive failed")
+ })
+ })
+ .and_then(|new_content| {
+ splice_callouts(&new_content, renderer)
+ .map_err(|e| anyhow::Error::new(e).context("rendering callouts failed"))
+ }) {
Ok(new_content) => chapter.content = new_content,
Err(e) => splice_err = Some(e),
}
Five integration tests in tests/includes.rs exercise the new
splicer end-to-end through the JSON envelope: directive replacement,
anchor emission position, snippet expansion without anchor, both
include and diff anchors emitted from one chapter, and the
missing-file error path:
#![allow(unused)]
fn main() {
//! Integration tests for slice 8: `{{#include listings/...}}` interception
//! and the `<div data-listing-tag>` locator anchor the include splicer
//! emits after each frozen-listing fenced block.
use std::fs;
use std::path::PathBuf;
use mdbook_preprocessor::PreprocessorContext;
use mdbook_preprocessor::book::{Book, BookItem, Chapter};
use mdbook_preprocessor::config::Config;
use tempfile::TempDir;
mod common;
use common::mdbook_listings;
#[test]
fn listing_include_directive_is_replaced_with_file_contents_inline() {
let book = MinimalIncludesBook::new();
let envelope = book.envelope_with_chapter(
"Before paragraph.\n\n```rust\n{{#include listings/sample.rs}}\n```\n\nAfter paragraph.\n",
);
let returned = run_preprocessor(envelope);
let content = chapter_content(&returned, "Include Test");
assert!(
content.contains("fn sample_body() {}"),
"expected file body inline; got:\n{content}",
);
assert!(
!content.contains("{{#include"),
"directive should be consumed; got:\n{content}",
);
}
#[test]
fn listing_include_emits_anchor_after_closing_fence() {
let book = MinimalIncludesBook::new();
let envelope = book
.envelope_with_chapter("```rust\n{{#include listings/sample.rs}}\n```\nAfter paragraph.\n");
let returned = run_preprocessor(envelope);
let content = chapter_content(&returned, "Include Test");
assert!(
content.contains("data-listing-tag=\"sample\""),
"expected listing-tag anchor with file-stem tag; got:\n{content}",
);
let anchor_pos = content.find("data-listing-tag").expect("anchor present");
let close_fence_pos = content
.find("```\n")
.map(|p| p + 4)
.expect("close fence present");
assert!(
anchor_pos > close_fence_pos,
"anchor must come AFTER the closing fence; anchor at {anchor_pos}, close-fence at {close_fence_pos}\ncontent:\n{content}",
);
}
#[test]
fn snippet_include_is_expanded_inline_without_listing_tag_anchor() {
let book = MinimalIncludesBook::new();
book.write_snippet("excerpt.rs", "fn snippet_body() {}\n");
let envelope = book.envelope_with_chapter("```rust\n{{#include snippets/excerpt.rs}}\n```\n");
let returned = run_preprocessor(envelope);
let content = chapter_content(&returned, "Include Test");
assert!(
content.contains("fn snippet_body() {}"),
"snippet should be expanded inline; got:\n{content}",
);
assert!(
!content.contains("data-listing-tag"),
"snippets must not produce a listing-tag anchor; got:\n{content}",
);
assert!(
!content.contains("{{#include"),
"directive should be consumed; got:\n{content}",
);
}
#[test]
fn listing_include_followed_by_diff_emits_both_anchor_kinds() {
let book = MinimalIncludesBook::new();
let envelope = book.envelope_with_chapter(concat!(
"First show as include.\n\n",
"```rust\n{{#include listings/sample.rs}}\n```\n\n",
"Then diff against new-tag.\n\n",
"{{#diff sample new-tag}}\n",
));
let returned = run_preprocessor(envelope);
let content = chapter_content(&returned, "Include Test");
assert!(
content.contains("data-listing-tag=\"sample\""),
"expected include-side listing-tag anchor; got:\n{content}",
);
assert!(
content.contains("data-listing-diff-left=\"sample\"")
&& content.contains("data-listing-diff-right=\"new-tag\""),
"expected diff-side dual-attribute anchor for the (sample, new-tag) pair; got:\n{content}",
);
}
#[test]
fn listing_include_with_missing_file_fails_with_chapter_path_in_diagnostic() {
let book = MinimalIncludesBook::new();
let envelope =
book.envelope_with_chapter("intro\n\n```rust\n{{#include listings/missing-tag.rs}}\n```\n");
let stderr = mdbook_listings()
.write_stdin(envelope)
.assert()
.failure()
.get_output()
.stderr
.clone();
let stderr = String::from_utf8_lossy(&stderr);
assert!(
stderr.contains("missing-tag"),
"diagnostic should name the missing tag; got:\n{stderr}",
);
assert!(
stderr.contains("expanding") || stderr.contains("include") || stderr.contains("missing"),
"diagnostic should mention the include-expansion failure; got:\n{stderr}",
);
}
/// Pipes the envelope through the preprocessor binary and returns the
/// transformed `Book` parsed from stdout.
fn run_preprocessor(envelope: String) -> Book {
let output = mdbook_listings()
.write_stdin(envelope)
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice(&output).expect("parse stdout as Book")
}
/// Tempdir laid out as a real mdbook book root: a frozen listing under
/// `src/listings/sample.rs` and a `listings.toml` manifest registering
/// it. `MinimalIncludesBook::write_snippet` lays a snippet down on demand
/// so individual tests opt into the snippet path explicitly.
struct MinimalIncludesBook {
_tmp: TempDir,
root: PathBuf,
}
impl MinimalIncludesBook {
fn new() -> Self {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().to_path_buf();
let listings_dir = root.join("src").join("listings");
fs::create_dir_all(&listings_dir).unwrap();
fs::write(listings_dir.join("sample.rs"), "fn sample_body() {}\n").unwrap();
fs::write(listings_dir.join("new-tag.rs"), "fn sample_body_v2() {}\n").unwrap();
fs::write(
root.join("listings.toml"),
"version = 1\n\n\
[[listing]]\n\
tag = \"sample\"\n\
source = \"../sample.rs\"\n\
frozen = \"src/listings/sample.rs\"\n\
sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n\n\
[[listing]]\n\
tag = \"new-tag\"\n\
source = \"../new.rs\"\n\
frozen = \"src/listings/new-tag.rs\"\n\
sha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n",
)
.unwrap();
Self { _tmp: tmp, root }
}
/// Write a file at `src/snippets/<rel>` so a chapter can reference it
/// via `{{#include snippets/<rel>}}`.
fn write_snippet(&self, rel: &str, content: &str) {
let snippets_dir = self.root.join("src").join("snippets");
fs::create_dir_all(&snippets_dir).unwrap();
fs::write(snippets_dir.join(rel), content).unwrap();
}
/// Build the `[PreprocessorContext, Book]` JSON tuple mdbook would send,
/// with one chapter carrying `chapter_content`.
fn envelope_with_chapter(&self, chapter_content: &str) -> String {
let ctx =
PreprocessorContext::new(self.root.clone(), Config::default(), "html".to_string());
let chapter = Chapter::new(
"Include Test",
chapter_content.to_string(),
"include-test.md",
vec![],
);
let book = Book::new_with_items(vec![BookItem::Chapter(chapter)]);
serde_json::to_string(&(&ctx, &book)).expect("serialize envelope")
}
}
fn chapter_content(book: &Book, chapter_name: &str) -> String {
book.iter()
.find_map(|item| match item {
BookItem::Chapter(ch) if ch.name == chapter_name => Some(ch.content.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("chapter `{chapter_name}` missing from returned book"))
}
}
Tool — subcommand redesign. tools/capture-screenshots/ becomes
a clap Subcommand with include LISTING and diff LEFT RIGHT
arms. Each subcommand discovers the chapter by scanning chapter
.md files for the directive substring (\{{#include listings/LISTING. or \{{#diff LEFT RIGHT), navigates Chromium to
the chapter HTML via
playwright-rs, and
locates the rendered <pre> via a single CSS selector
([data-listing-tag="LISTING"] or
[data-listing-diff-left="LEFT"][data-listing-diff-right="RIGHT"]).
The slice-7 callout-badge fallback is gone — anchors cover both
shapes now. Default output paths are
book/src/images/<LISTING>.png and
book/src/images/<LEFT>__to__<RIGHT>.png. The subcommand is
dispatched at callout 2:
--- capture-screenshots-v2
+++ capture-screenshots-v3
@@ -1,54 +1,235 @@
-//! Capture an element-scoped screenshot of a rendered chapter and write the
-//! PNG to a known path. Used by ch. 4's slice-by-slice visual record so each
-//! slice's narrative can embed a snapshot of how the chapter rendered the
-//! day the slice shipped.
+//! Screenshot a named listing from the built book.
+//!
+//! Two subcommands match the two listing-rendering shapes the
+//! mdbook-listings preprocessor produces:
+//!
+//! ```text
+//! capture-screenshots include LISTING # {{#include listings/LISTING.ext}} block
+//! capture-screenshots diff LEFT RIGHT # {{#diff LEFT RIGHT}} block
+//! ```
+//!
+//! Each subcommand:
+//!
+//! 1. Scans `book/src/*.md` for the chapter that references the
+//! target tag(s).
+//! 2. Loads `book/build/html/<chapter-slug>.html`.
+//! 3. Locates the rendered `<pre>` via the locator anchor the
+//! preprocessor emits — `[data-listing-tag]` for `include`,
+//! `[data-listing-diff-left][data-listing-diff-right]` for `diff`.
+//! 4. Writes a PNG to `book/src/images/<default-name>.png` (or
+//! `--out` if specified). Output defaults: `<LISTING>.png` for
+//! include, `<LEFT>__to__<RIGHT>.png` for diff.
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
-use clap::Parser;
-use playwright_rs::Playwright;
+use clap::{Parser, Subcommand};
+use playwright_rs::{Playwright, locator};
+use tracing_subscriber::EnvFilter;
+
+const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
#[derive(Parser)]
-#[command(version, about, long_about = None)]
+#[command(version, about = "Screenshot a named listing from the built book")]
struct Cli {
- /// Absolute path to the rendered chapter HTML to load.
- #[arg(long)]
- chapter_html: PathBuf,
+ #[command(subcommand)]
+ command: Command,
- /// CSS selector for the element to screenshot.
- #[arg(long)]
- selector: String,
+ /// Root of the book workspace (directory containing book.toml).
+ /// Defaults to `book/` in the workspace root.
+ #[arg(long, global = true)]
+ book_root: Option<PathBuf>,
- /// Zero-based index when the selector matches multiple elements.
- /// Negative values count from the end (`-1` is the last match).
- #[arg(long, default_value_t = 0)]
- nth: i32,
+ /// Output PNG path. Defaults to `<book-root>/src/images/<derived-name>.png`
+ /// where the derived name is `<LISTING>` for `include` and
+ /// `<LEFT>__to__<RIGHT>` for `diff`.
+ #[arg(long, global = true)]
+ out: Option<PathBuf>,
+}
- /// Absolute path to write the PNG to. Parent directories are created.
- #[arg(long)]
- out: PathBuf,
+#[derive(Subcommand)]
+enum Command {
+ /// Screenshot a `{{#include listings/LISTING.ext}}` block.
+ Include {
+ /// Tag (file stem) of the listing to screenshot.
+ listing: String,
+ },
+
+ /// Screenshot a `{{#diff LEFT RIGHT}}` block.
+ Diff {
+ /// Left (old) operand of the diff directive.
+ left: String,
+ /// Right (new) operand of the diff directive.
+ right: String,
+ },
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
- if let Some(parent) = cli.out.parent() {
- std::fs::create_dir_all(parent)?;
+
+ // playwright-rs (unreleased v0.13.0 from `padamson/playwright-rust` main)
+ // adds `#[tracing::instrument]` spans across its public async surface;
+ // wiring up tracing_subscriber here makes every `goto`, `evaluate_value`,
+ // `screenshot`, `browser.close`, etc. log a structured span. Default
+ // filter `info` keeps the top-level operations visible without
+ // descending into the per-RPC `debug` chatter; raise via
+ // `RUST_LOG=capture_screenshots=debug,playwright_rs=debug` when
+ // diagnosing locator or screenshot failures.
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
+ )
+ .with_target(true)
+ .compact()
+ .init();
+
+ let book_root = cli
+ .book_root
+ .unwrap_or_else(|| PathBuf::from(MANIFEST_DIR).join("../../book"))
+ .canonicalize()?;
+ let src_dir = book_root.join("src");
+ let html_dir = book_root.join("build").join("html");
+
+ let job = Job::from(&cli.command, &cli.out, &src_dir);
+
+ let chapter_slug = find_chapter_for_pattern(&src_dir, &job.discovery_pattern)
+ .ok_or_else(|| format!("no chapter contains `{}`", job.discovery_pattern))?;
+ println!("→ chapter: {chapter_slug}");
+
+ let html_path = html_dir.join(format!("{chapter_slug}.html"));
+ if !html_path.exists() {
+ return Err(format!("chapter HTML not found: {}", html_path.display()).into());
}
- let url = format!("file://{}", cli.chapter_html.display());
+ let url = format!("file://{}", html_path.display());
let pw = Playwright::launch().await?;
let browser = pw.chromium().launch().await?;
let page = browser.new_page().await?;
page.goto(&url, None).await?;
- let target = page.locator(&cli.selector).await.nth(cli.nth);
- let png = target.screenshot(None).await?;
- std::fs::write(&cli.out, png)?;
- println!("✓ wrote {}", cli.out.display());
+ // mdbook's #menu-bar is position: sticky; element-scoped screenshots
+ // capture overlapping viewport content, so the header would otherwise
+ // appear on top of the listing. Demote it to static.
+ let _: String = page
+ .evaluate_value(
+ r#"(() => {
+ document.querySelectorAll('#menu-bar, .menu-bar')
+ .forEach(el => el.style.position = 'static');
+ return 'ok';
+ })()"#,
+ )
+ .await?;
+
+ let result: String = page.evaluate_value(&job.locator_js).await?;
+ if result == "not-found" {
+ return Err(format!(
+ "could not locate `{}` in {chapter_slug}.html via selector `{}`",
+ job.discovery_pattern, job.css_selector,
+ )
+ .into());
+ }
+ println!("→ located via selector: {}", job.css_selector);
+
+ if let Some(parent) = job.out.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+
+ let png = page
+ .locator(locator!("#__capture_target__"))
+ .await
+ .screenshot(None)
+ .await?;
+ std::fs::write(&job.out, png)?;
+ println!("✓ wrote {}", job.out.display());
browser.close().await?;
Ok(())
}
+
+/// All the per-subcommand inputs the rest of `main` needs in one place:
+/// the substring used to scan chapter `.md` files for the right chapter,
+/// the CSS selector that uniquely names the locator anchor in the rendered
+/// HTML, the JavaScript that promotes the preceding `<pre>` to
+/// `id="__capture_target__"`, and the resolved output path.
+struct Job {
+ discovery_pattern: String,
+ css_selector: String,
+ locator_js: String,
+ out: PathBuf,
+}
+
+impl Job {
+ fn from(cmd: &Command, out_override: &Option<PathBuf>, src_dir: &Path) -> Self {
+ match cmd {
+ Command::Include { listing } => {
+ let css_selector = format!(r#"[data-listing-tag="{listing}"]"#);
+ Job {
+ discovery_pattern: format!("{{{{#include listings/{listing}."),
+ locator_js: locator_js_for(&css_selector),
+ css_selector,
+ out: out_override
+ .clone()
+ .unwrap_or_else(|| src_dir.join("images").join(format!("{listing}.png"))),
+ }
+ }
+ Command::Diff { left, right } => {
+ let css_selector = format!(
+ r#"[data-listing-diff-left="{left}"][data-listing-diff-right="{right}"]"#
+ );
+ Job {
+ discovery_pattern: format!("{{{{#diff {left} {right}"),
+ locator_js: locator_js_for(&css_selector),
+ css_selector,
+ out: out_override.clone().unwrap_or_else(|| {
+ src_dir
+ .join("images")
+ .join(format!("{left}__to__{right}.png"))
+ }),
+ }
+ }
+ }
+ }
+}
+
+/// Walks back from the locator anchor to the most recent `<pre>` (skipping
+/// any `<div class="callout-overlay">` sibling between them) and tags it
+/// `id="__capture_target__"` so a stable CSS selector can drive the
+/// element-scoped screenshot.
+fn locator_js_for(css_selector: &str) -> String {
+ format!(
+ r#"(() => {{
+ const anchor = document.querySelector('{css_selector}');
+ if (!anchor) return 'not-found';
+ let el = anchor.previousElementSibling;
+ while (el && el.tagName !== 'PRE') el = el.previousElementSibling;
+ if (!el) return 'not-found';
+ el.setAttribute('id', '__capture_target__');
+ return 'located';
+}})()"#
+ )
+}
+
+/// Scans `src_dir` for a `.md` file containing `pattern` (a substring like
+/// `"{{#include listings/foo."` or `"{{#diff foo bar"`). Returns the
+/// chapter's filename stem (e.g. `ch05-render-inline-callouts`).
+fn find_chapter_for_pattern(src_dir: &Path, pattern: &str) -> Option<String> {
+ let entries = std::fs::read_dir(src_dir).ok()?;
+ for entry in entries {
+ let Ok(entry) = entry else { continue };
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) != Some("md") {
+ continue;
+ }
+ let Ok(content) = std::fs::read_to_string(&path) else {
+ continue;
+ };
+ if content.contains(pattern) {
+ return path.file_stem().and_then(|s| s.to_str()).map(String::from);
+ }
+ }
+ None
+}
The tool also dogfoods the unreleased v0.13.0 work in the
padamson/playwright-rust
repo: the workspace Cargo.toml’s [workspace.dependencies] table
now sources playwright-rs as a git dep on branch = "main", and
the tool wires up tracing_subscriber so playwright-rs’s new
#[tracing::instrument] spans (every goto, evaluate_value,
screenshot, browser.close) log automatically once the upstream
instrumentation merges. Local debugging gets richer for free with
no per-callsite logging.
--- cargo-toml-v5
+++ cargo-toml-v6
@@ -8,6 +8,7 @@
repository = "https://github.com/padamson/mdbook-listings"
categories = ["command-line-utilities", "text-processing"]
keywords = ["mdbook", "preprocessor", "documentation", "code-listing"]
+exclude = ["book/"]
[dependencies]
anyhow = "1"
@@ -23,12 +24,22 @@
[dev-dependencies]
assert_cmd = "2"
pdf-extract = "0.9"
-playwright-rs = "0.12"
+playwright-rs = { workspace = true }
predicates = "3"
tempfile = "3"
-tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tokio = { workspace = true }
# Workspace tools live alongside the crate but are not part of the published
# crate (each carries `publish = false`). Run with `cargo run -p <name>`.
[workspace]
members = ["tools/capture-screenshots"]
+
+# Centralised cross-workspace deps. `playwright-rs` is dogfooded against the
+# upstream main branch so this project rides ahead of the latest crates.io
+# release (currently 0.12.3) and can adopt unreleased v0.13.0 features
+# (richer tracing instrumentation, ARIA snapshots, screencast surface, etc.)
+# before they ship. Cargo.lock pins the actual revision for reproducibility;
+# bump with `cargo update -p playwright-rs`.
+[workspace.dependencies]
+playwright-rs = { git = "https://github.com/padamson/playwright-rust", branch = "main" }
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
Refactor (e2e migration) — locator!() macro and the assertion API
This refactor doesn’t satisfy any chapter AC; it’s pure test-quality
work that takes advantage of two playwright-rs surfaces that landed
on the upstream padamson/playwright-rust main branch and that
slice 8 sourced via the workspace git dep:
- The
playwright-rs-macroscrate ships alocator!(...)proc-macro that compile-time-validates Playwright selector strings for empty input, unbalanced brackets, and unknown engine prefixes. Adopted at every directpage.locator(...)call site (3 sites total — most of our locator usage lives inside JS strings fed toevaluate_value, which the proc-macro can’t reach). Verified by deliberately introducing an unbalanced[data-callout-badgeselector and observingerror: unclosed[`` at compile time. - The
expect(locator).to_have_*()assertion API (added across the v0.12.x line) auto-retries on flake, returns precise failure messages with line numbers, and reads more like the test’s intent than the equivalent JS-blobevaluate_valuereturning a CSV.
Migrating tests/e2e_callouts.rs from the slice-7/8-era JS-blob
sweeps to locator + assertion calls is a substantial rewrite — every
DOM query, every attribute check, every visibility assertion. Only
one JS line remains (a history.replaceState(...) mutation in the
click-through test, since that’s a history-API operation with no
playwright equivalent).
The full diff against tests/e2e_callouts.rs (v5 → v6) shows
every JS-blob evaluate_value call replaced with a typed
page.locator(...).await plus an expect(...).to_have_*()
assertion (or a Locator::nth(i) iteration when the test sweeps
multiple matches):
--- e2e-callouts-v5
+++ e2e-callouts-v6
@@ -1,6 +1,6 @@
use std::path::PathBuf;
-use playwright_rs::{Playwright, locator};
+use playwright_rs::{Playwright, expect, locator};
#[tokio::test]
async fn label_only_callout_renders_badge_without_following_body() {
@@ -12,27 +12,16 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- // Slice 7+ shape: marker line stripped, badge is a <button> in the
- // sibling overlay; label-only markers emit no body popover.
- let body_present: String = page
- .evaluate_value(
- "(() => { \
- const btn = document.querySelector('button[id=\"callout-cli-parse\"]'); \
- if (!btn) return 'NOT_FOUND'; \
- const body = document.getElementById('callout-body-cli-parse'); \
- return body ? 'YES' : 'NO'; \
- })()",
- )
+ let badge = page.locator(locator!("button#callout-cli-parse")).await;
+ expect(badge)
+ .to_have_count(1)
.await
- .expect("evaluate");
- assert_ne!(
- body_present, "NOT_FOUND",
- "expected button#callout-cli-parse to exist on rendered ch. 4",
- );
- assert_eq!(
- body_present, "NO",
- "label-only callout must not have a body popover; got body presence: {body_present}",
- );
+ .expect("label-only badge button must exist");
+ let body = page.locator(locator!("#callout-body-cli-parse")).await;
+ expect(body)
+ .to_have_count(0)
+ .await
+ .expect("label-only callout must not have a body popover");
browser.close().await.expect("close browser");
}
@@ -47,16 +36,16 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- let badge = page.locator(locator!("[data-callout-badge]")).await;
- let count = badge.count().await.expect("count badges");
+ let badges = page.locator(locator!("[data-callout-badge]")).await;
+ let count = badges.count().await.expect("count badges");
assert!(
count > 0,
- "expected at least one [data-callout-badge] element on rendered ch. 4; got 0",
+ "expected at least one [data-callout-badge]; got 0"
);
- let text = badge.first().text_content().await.expect("badge text");
+ let text = badges.first().text_content().await.expect("badge text");
assert!(
text.as_deref().is_some_and(|s| !s.trim().is_empty()),
- "expected badge text to be non-empty; got {text:?}",
+ "expected first badge text to be non-empty; got {text:?}",
);
browser.close().await.expect("close browser");
@@ -72,30 +61,20 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- let href: String = page
- .evaluate_value(
- "(() => { \
- const a = document.querySelector('a[data-callout-ref=\"cross-ref-emit\"]'); \
- return a ? a.getAttribute('href') : 'NOT_FOUND'; \
- })()",
- )
+ let cross_ref = page
+ .locator(locator!(r#"a[data-callout-ref="cross-ref-emit"]"#))
+ .await;
+ expect(cross_ref)
+ .to_have_attribute("href", "#callout-cross-ref-emit")
.await
- .expect("evaluate href");
- assert_eq!(
- href, "#callout-cross-ref-emit",
- "expected prose-side cross-ref to point at listing badge anchor",
- );
-
- let target_present: String = page
- .evaluate_value(
- "(() => document.querySelector('button[id=\"callout-cross-ref-emit\"]') ? 'YES' : 'NO')()",
- )
+ .expect("cross-ref href must point at listing badge anchor");
+ let target = page
+ .locator(locator!("button#callout-cross-ref-emit"))
+ .await;
+ expect(target)
+ .to_have_count(1)
.await
- .expect("evaluate anchor presence");
- assert_eq!(
- target_present, "YES",
- "expected listing-side button#callout-cross-ref-emit to exist as the cross-ref's target",
- );
+ .expect("listing-side badge button must exist as the cross-ref's target");
browser.close().await.expect("close browser");
}
@@ -110,53 +89,32 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- // Pick a listing whose pre is known to contain a CALLOUT-bearing
- // include: the cross-ref-emit marker is in the slice 6 snippet
- // include, which the slice 7 splicer should have stripped from the
- // rendered <pre>. Verify the literal "CALLOUT: cross-ref-emit"
- // string is gone from that pre.
- let pre_text: String = page
- .evaluate_value(
- "(() => { \
- const btn = document.querySelector('button[id=\"callout-cross-ref-emit\"]'); \
- if (!btn) return 'NO_BTN'; \
- const overlay = btn.closest('.callout-overlay'); \
- const pre = overlay && overlay.previousElementSibling; \
- return pre ? pre.textContent : 'NO_PRE'; \
- })()",
- )
+ // Find the <pre> whose sibling overlay carries the cross-ref-emit
+ // badge (xpath does the sibling traversal that CSS can't). The
+ // splicer should have stripped the literal marker comment from
+ // that pre's text.
+ let pre = page
+ .locator(locator!(
+ r#"xpath=//pre[following-sibling::div[1][.//button[@id="callout-cross-ref-emit"]]]"#
+ ))
+ .await;
+ expect(pre.clone())
+ .not()
+ .to_contain_text("CALLOUT: cross-ref-emit")
.await
- .expect("evaluate pre text");
- assert!(
- !pre_text.contains("CALLOUT: cross-ref-emit"),
- "expected marker comment line to be stripped from the include's <pre>; \
- got pre.textContent containing the marker:\n{pre_text}",
- );
+ .expect("marker comment line must be stripped from the include's <pre>");
- // The body popover starts hidden and becomes visible after hovering
- // its triggering badge. Use Playwright's hover().
+ // Body popover starts hidden and becomes visible after hovering its
+ // triggering badge.
let badge = page
- .locator(locator!(
- "button[id=\"callout-body-emit-source\"], button[id=\"callout-cross-ref-emit\"]"
- ))
+ .locator(locator!("button#callout-cross-ref-emit"))
.await;
- badge.first().hover(None).await.expect("hover badge");
-
- let body_visible: String = page
- .evaluate_value(
- "(() => { \
- const body = document.getElementById('callout-body-cross-ref-emit'); \
- if (!body) return 'NO_BODY'; \
- const cs = window.getComputedStyle(body); \
- return cs.visibility === 'hidden' ? 'HIDDEN' : 'VISIBLE'; \
- })()",
- )
+ badge.hover(None).await.expect("hover badge");
+ let body = page.locator(locator!("#callout-body-cross-ref-emit")).await;
+ expect(body)
+ .to_be_visible()
.await
- .expect("evaluate body visibility");
- assert_eq!(
- body_visible, "VISIBLE",
- "expected body popover to become visible after hovering badge; got: {body_visible}",
- );
+ .expect("body popover must become visible after hovering its badge");
browser.close().await.expect("close browser");
}
@@ -166,20 +124,12 @@
// Sweep guard for prose-side cross-refs: every `{{#callout LABEL}}`
// directive renders as an `<a class="callout-badge callout-ref"
// href="#callout-LABEL" data-callout-ref="LABEL"
- // data-callout-ordinal="N">N</a>`. For each one in the chapter, we
- // verify:
- //
- // 1. `href` matches `#callout-<data-callout-ref>` (no stale
- // label-to-href drift)
- // 2. A `button[id="callout-LABEL"]` actually exists as the target
- // (catches refs to labels whose only occurrence is in a `{{#diff}}`
- // block, since the HTML splicer skips diffs for badge emission)
+ // data-callout-ordinal="N">N</a>`. For each one we verify (via
+ // playwright assertions, not JS-string sweeps) that:
+ // 1. `href` matches `#callout-<data-callout-ref>`
+ // 2. A `button[id="callout-LABEL"]` exists as the target
// 3. The ref's `data-callout-ordinal` matches the target badge's
- // `data-callout-ordinal` (catches numbering drift between the
- // label-to-ordinal map's first-occurrence pass and the per-listing
- // badge emission pass)
- // 4. The rendered text on the ref matches the target badge's rendered
- // text (the visible numeral readers see)
+ // 4. The rendered text on the ref matches the target badge's text
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
@@ -188,69 +138,76 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- let issues: String = page
- .evaluate_value(
- r#"(() => {
- const refs = Array.from(document.querySelectorAll('a[data-callout-ref]'));
- if (refs.length === 0) {
- return 'NO_REFS_FOUND_AT_ALL';
- }
- const issues = [];
- refs.forEach(a => {
- const label = a.getAttribute('data-callout-ref');
- const refOrdinal = a.getAttribute('data-callout-ordinal');
- const refText = (a.textContent || '').trim();
- const expectedHref = '#callout-' + label;
- const actualHref = a.getAttribute('href');
- if (actualHref !== expectedHref) {
- issues.push(`label "${label}": href="${actualHref}" but expected "${expectedHref}"`);
- }
- const target = document.querySelector('button[id="callout-' + label + '"]');
- if (!target) {
- issues.push(`label "${label}": no badge button#callout-${label} exists as the cross-ref target`);
- return;
- }
- const targetOrdinal = target.getAttribute('data-callout-ordinal');
- const targetText = (target.textContent || '').trim();
- if (refOrdinal !== targetOrdinal) {
- issues.push(`label "${label}": ref data-callout-ordinal="${refOrdinal}" but target badge data-callout-ordinal="${targetOrdinal}"`);
- }
- if (refText !== targetText) {
- issues.push(`label "${label}": ref text "${refText}" but target badge text "${targetText}"`);
- }
- });
- return issues.join('\n');
- })()"#,
- )
- .await
- .expect("evaluate cross-ref sweep");
-
- assert_ne!(
- issues, "NO_REFS_FOUND_AT_ALL",
- "expected at least one a[data-callout-ref] in the chapter; the chapter has no cross-refs to sweep",
- );
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
assert!(
- issues.is_empty(),
- "callout cross-ref sweep found broken refs in the chapter:\n{issues}",
+ count > 0,
+ "expected at least one a[data-callout-ref] in chapter"
);
+ for i in 0..count {
+ let r = refs.nth(i as i32);
+ let label = r
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ .unwrap_or_default();
+ assert!(!label.is_empty(), "ref #{i} has empty data-callout-ref");
+
+ let expected_href = format!("#callout-{label}");
+ expect(r.clone())
+ .to_have_attribute("href", &expected_href)
+ .await
+ .unwrap_or_else(|e| panic!("ref `{label}`: href mismatch: {e:?}"));
+
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
+ .await;
+ expect(target.clone())
+ .to_have_count(1)
+ .await
+ .unwrap_or_else(|e| panic!("ref `{label}`: target badge missing: {e:?}"));
+
+ let ref_ordinal = r
+ .get_attribute("data-callout-ordinal")
+ .await
+ .expect("ref ordinal")
+ .unwrap_or_default();
+ expect(target.clone())
+ .to_have_attribute("data-callout-ordinal", &ref_ordinal)
+ .await
+ .unwrap_or_else(|e| {
+ panic!("ref `{label}`: ordinal mismatch (ref={ref_ordinal}): {e:?}")
+ });
+
+ let ref_text = r
+ .text_content()
+ .await
+ .expect("ref text")
+ .unwrap_or_default()
+ .trim()
+ .to_string();
+ expect(target)
+ .to_have_text(&ref_text)
+ .await
+ .unwrap_or_else(|e| {
+ panic!("ref `{label}`: rendered text mismatch (ref=\"{ref_text}\"): {e:?}")
+ });
+ }
+
browser.close().await.expect("close browser");
}
#[tokio::test]
async fn every_cross_refed_label_has_a_visible_badge_in_the_chapter() {
// Regression guard, scoped to labels the author actually points at:
- // every `{{#callout LABEL}}` directive in chapter prose renders an
- // `<a data-callout-ref="LABEL">`, and the matching badge
- // `button[id="callout-LABEL"]` must exist somewhere in the rendered
- // page. Catches the most common slice-shipping mistake — a cross-ref
- // to a marker whose only chapter occurrence is in a `{{#diff}}` block
- // (which the HTML splicer skips for badge emission, by design — the
- // canonical anchor lives on a non-diff include). Test-fixture
- // marker strings inside string literals (e.g. `// CALLOUT: greeting`
- // inside Rust unit-test source) are intentionally not flagged: the
- // author isn't pointing at them, so they're not part of the chapter's
- // user-facing cross-reference contract.
+ // every `{{#callout LABEL}}` directive must have a corresponding
+ // `button[id="callout-LABEL"]` somewhere in the rendered page.
+ // Catches the most common slice-shipping mistake — a cross-ref to
+ // a marker whose only chapter occurrence is in a `{{#diff}}` block
+ // before slice 8 wired diff blocks through the badge emitter.
+ // Test-fixture marker strings inside string literals are
+ // intentionally not flagged (the author isn't pointing at them).
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
@@ -259,36 +216,38 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- // For every `a[data-callout-ref]` (the prose-side cross-ref shape),
- // collect the label and check that a matching `button[id="callout-LABEL"]`
- // exists. Returns a comma-separated list of labels that have a cross-ref
- // pointing at them but no badge target.
- let unmatched: String = page
- .evaluate_value(
- r#"(() => {
- const refs = Array.from(document.querySelectorAll('a[data-callout-ref]'));
- const missing = new Set();
- refs.forEach(a => {
- const label = a.getAttribute('data-callout-ref');
- if (!label) return;
- if (!document.querySelector('button[id="callout-' + label + '"]')) {
- missing.add(label);
- }
- });
- return Array.from(missing).sort().join(',');
- })()"#,
- )
- .await
- .expect("evaluate cross-ref-target scan");
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
+
+ let mut missing: Vec<String> = Vec::new();
+ for i in 0..count {
+ let label = refs
+ .nth(i as i32)
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ .unwrap_or_default();
+ if label.is_empty() {
+ continue;
+ }
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
+ .await;
+ if target.count().await.expect("count target") == 0 {
+ missing.push(label);
+ }
+ }
+ missing.sort();
+ missing.dedup();
assert!(
- unmatched.is_empty(),
+ missing.is_empty(),
"the following labels are cross-refed in chapter prose but have no \
- `button[id=\"callout-LABEL\"]` target in the rendered chapter — \
- most likely the cross-ref points at a marker whose only occurrence \
- is in a `{{{{#diff}}}}` block (which the HTML splicer skips for \
- badge emission). Add a non-diff `{{{{#include}}}}` of the source, \
- or extract a snippet, so the badge anchor lands. Broken labels: {unmatched}",
+ `button[id=\"callout-LABEL\"]` target — most likely the cross-ref \
+ points at a marker whose only occurrence is in a `{{{{#diff}}}}` \
+ block. Add a non-diff `{{{{#include}}}}` of the source, or extract \
+ a snippet, so the badge anchor lands. Broken labels: {}",
+ missing.join(", "),
);
browser.close().await.expect("close browser");
@@ -297,17 +256,11 @@
#[tokio::test]
async fn clicking_each_cross_ref_scrolls_target_badge_into_viewport() {
// End-to-end click-through guard: for every prose-side
- // `a[data-callout-ref]` in the chapter, click it and assert that the
- // target `button[id="callout-LABEL"]` ends up visible in the viewport
- // (the natural in-page anchor-jump behaviour). Catches the case where
- // the structural attributes line up (covered by the sweep tests
- // above) but the link doesn't actually navigate — e.g. the target
- // anchor is on an off-screen element with `display: none`, or the
- // chapter-internal hash routing was broken by a future theme change.
+ // `a[data-callout-ref]`, click it and assert the target badge ends
+ // up visible (the natural in-page anchor-jump behaviour).
//
- // The rebuild discipline for cross-chapter refs is out of scope:
- // chapter prose only references callouts in listings rendered in the
- // same chapter, by design.
+ // Cross-chapter refs are out of scope: chapter prose only references
+ // callouts in listings rendered in the same chapter, by design.
let chapter_html = chapter_path();
let url = format!("file://{}", chapter_html.display());
@@ -316,27 +269,31 @@
let page = browser.new_page().await.expect("new page");
page.goto(&url, None).await.expect("goto chapter");
- // Collect every cross-ref label first, in document order.
- let labels_csv: String = page
- .evaluate_value(
- r#"(() => Array.from(document.querySelectorAll('a[data-callout-ref]'))
- .map(a => a.getAttribute('data-callout-ref'))
- .filter(Boolean)
- .join(','))()"#,
- )
- .await
- .expect("collect cross-ref labels");
- let labels: Vec<&str> = labels_csv.split(',').filter(|s| !s.is_empty()).collect();
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
assert!(
- !labels.is_empty(),
- "expected at least one a[data-callout-ref] in the chapter for click-through coverage",
+ count > 0,
+ "expected at least one cross-ref for click-through coverage"
);
+ let mut labels: Vec<String> = Vec::with_capacity(count);
+ for i in 0..count {
+ if let Some(label) = refs
+ .nth(i as i32)
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ && !label.is_empty()
+ {
+ labels.push(label);
+ }
+ }
+
let mut failures: Vec<String> = Vec::new();
for label in &labels {
// Reset the URL hash so each navigation is a fresh jump rather
- // than a no-op when clicking a ref that already happens to point
- // at the current hash.
+ // than a no-op when the current hash already matches. This is a
+ // history-API mutation; no playwright equivalent.
let _: String = page
.evaluate_value(
"(() => { history.replaceState(null, '', location.pathname); return 'ok'; })()",
@@ -344,44 +301,37 @@
.await
.expect("reset hash");
- // Click the ref. Use evaluate to drive the click + scroll
- // synchronously so we don't need a brittle wait-for-scroll dance.
- let after_click: String = page
- .evaluate_value(&format!(
- r##"(() => {{
- const a = document.querySelector('a[data-callout-ref="{label}"]');
- if (!a) return 'NO_REF';
- a.click();
- const target = document.querySelector('button[id="callout-{label}"]');
- if (!target) return 'NO_TARGET';
- // Match the in-page anchor-jump semantics: scroll the
- // target into view (the click on an `<a href="#...">`
- // already does this, but force the layout settle).
- target.scrollIntoView({{ block: 'center' }});
- const r = target.getBoundingClientRect();
- const inViewport = r.bottom > 0
- && r.top < (window.innerHeight || document.documentElement.clientHeight)
- && r.right > 0
- && r.left < (window.innerWidth || document.documentElement.clientWidth);
- const hash = location.hash;
- return inViewport
- ? 'OK:' + hash
- : 'OFFSCREEN:hash=' + hash + ' rect=' + JSON.stringify(r);
- }})()"##
- ))
+ let r = page
+ .locator(&format!(r#"a[data-callout-ref="{label}"]"#))
+ .await
+ .first();
+ if let Err(e) = r.click(None).await {
+ failures.push(format!("label `{label}`: click failed: {e:?}"));
+ continue;
+ }
+
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
.await
- .expect("click cross-ref");
+ .first();
+ if let Err(e) = target.scroll_into_view_if_needed().await {
+ failures.push(format!("label `{label}`: scroll failed: {e:?}"));
+ continue;
+ }
+ if !target.is_visible().await.unwrap_or(false) {
+ failures.push(format!("label `{label}`: target not visible after click"));
+ continue;
+ }
- if !after_click.starts_with("OK:") {
- failures.push(format!("label `{label}`: {after_click}"));
- } else {
- let expected_hash = format!("#callout-{label}");
- let actual_hash = after_click.trim_start_matches("OK:");
- if actual_hash != expected_hash {
- failures.push(format!(
- "label `{label}`: hash after click was `{actual_hash}` but expected `{expected_hash}`"
- ));
- }
+ let actual_hash: String = page
+ .evaluate_value("location.hash")
+ .await
+ .expect("read hash");
+ let expected_hash = format!("#callout-{label}");
+ if actual_hash != expected_hash {
+ failures.push(format!(
+ "label `{label}`: hash after click was `{actual_hash}` but expected `{expected_hash}`"
+ ));
}
}
The migration surfaced a real slice-8 splicer bug. playwright-rs’s
strict-mode locator refused to resolve #callout-body-cross-ref-emit
because the rendered chapter contained TWO <div> elements with
that id — one from the snippet {{#include}} of
callout-pdf-emit-snippet-v2.rs, one from the diff +-line marker
addition slice 8 wired badge emission for. The button id was
already dedup’d via the existing emitted_anchor: HashSet<String>,
but the body div’s id was not. Fix in src/callout.rs: lockstep
dedup of the body div’s id and the button’s aria-describedby
against the same is_first_occurrence boolean — callout
2. The diff against src/callout.rs
(v5 → v6) shows the splicer change:
--- callout-v5
+++ callout-v6
@@ -179,16 +179,23 @@
let mut cursor = 0;
let mut emitted_anchor: HashSet<String> = HashSet::new();
for_each_fenced_block_with_span(content, |info, block_text, body_start, close_end| {
- if info == "diff" {
+ let callouts = callouts_for_block(info, block_text);
+ let is_diff = info == "diff";
+ // Diff blocks always go through the strip pass even when no `+`/` `
+ // callouts exist — `-`-side markers still need to be dropped from
+ // the rendered body.
+ if callouts.is_empty() && !is_diff {
return;
}
- let callouts = callouts_for_block(info, block_text);
- if callouts.is_empty() {
+ let (rewritten_body, post_strip_lines, total_lines) = if is_diff {
+ strip_marker_lines_diff(block_text)
+ } else {
+ strip_marker_lines(block_text, info)
+ };
+ if is_diff && callouts.is_empty() && rewritten_body == block_text {
+ // No-op diff: no markers of any kind to rewrite.
return;
}
- let (rewritten_body, post_strip_lines) = strip_marker_lines(block_text, info);
- // Find the opening fence line to copy verbatim, then the rewritten
- // body, then the closing fence line that follows.
let pre_fence = &content[cursor..body_start];
let close_fence_line = closing_fence_text(content, close_end);
out.push_str(pre_fence);
@@ -201,6 +208,7 @@
out.push_str(&render_callout_overlay_html(
&callouts,
&post_strip_lines,
+ total_lines,
&mut emitted_anchor,
));
out.push('\n');
@@ -214,7 +222,7 @@
/// post-strip 1-based line numbers each marker now lands on (i.e. the
/// line that took its place after the strip — typically the next non-
/// marker code line).
-fn strip_marker_lines(block_text: &str, info: &str) -> (String, Vec<usize>) {
+fn strip_marker_lines(block_text: &str, info: &str) -> (String, Vec<usize>, usize) {
let prefix = comment_prefix_for_language(info);
let lines: Vec<&str> = block_text.split_inclusive('\n').collect();
let mut out = String::with_capacity(block_text.len());
@@ -237,9 +245,56 @@
emitted_count += 1;
}
}
- (out, post_strip_lines)
+ (out, post_strip_lines, emitted_count)
}
+fn strip_marker_lines_diff(block_text: &str) -> (String, Vec<usize>, usize) {
+ let lines: Vec<&str> = block_text.split_inclusive('\n').collect();
+ let mut out = String::with_capacity(block_text.len());
+ let mut post_strip_lines: Vec<usize> = Vec::new();
+ let mut emitted_count: usize = 0;
+ for raw_line in lines {
+ let line_no_newline = raw_line.strip_suffix('\n').unwrap_or(raw_line);
+ // Diff metadata lines pass through unchanged.
+ if line_no_newline.starts_with("---")
+ || line_no_newline.starts_with("+++")
+ || line_no_newline.starts_with("@@")
+ || line_no_newline.starts_with('\\')
+ {
+ out.push_str(raw_line);
+ emitted_count += 1;
+ continue;
+ }
+ // Identify the diff-line prefix and try to parse the trailing
+ // payload as a marker against any known comment prefix.
+ let (prefix_char, payload) = if let Some(rest) = line_no_newline.strip_prefix('+') {
+ (Some('+'), rest)
+ } else if let Some(rest) = line_no_newline.strip_prefix('-') {
+ (Some('-'), rest)
+ } else if let Some(rest) = line_no_newline.strip_prefix(' ') {
+ (Some(' '), rest)
+ } else {
+ (None, line_no_newline)
+ };
+ let is_marker = ALL_COMMENT_PREFIXES
+ .iter()
+ .any(|p| parse_line(payload, p, 0).is_some());
+ if is_marker {
+ // `+` and ` ` markers: strip the line, record post-strip position
+ // for badge placement. `-` markers: drop silently.
+ if matches!(prefix_char, Some('+') | Some(' ')) {
+ let target = (emitted_count + 1).max(1);
+ post_strip_lines.push(target);
+ }
+ } else {
+ out.push_str(raw_line);
+ emitted_count += 1;
+ }
+ }
+ (out, post_strip_lines, emitted_count)
+}
+
fn closing_fence_text(content: &str, close_end: usize) -> &str {
// close_end is one past the trailing newline of the closing fence
// (or equal to bytes.len() if the file ends without a trailing newline).
@@ -276,7 +331,7 @@
out
}
-fn for_each_fenced_block_with_span<F>(content: &str, mut visit: F)
+pub(crate) fn for_each_fenced_block_with_span<F>(content: &str, mut visit: F)
where
F: FnMut(&str, &str, usize, usize),
{
@@ -523,6 +578,7 @@
fn render_callout_overlay_html(
callouts: &[Callout],
post_strip_lines: &[usize],
+ total_lines: usize,
emitted_anchor: &mut HashSet<String>,
) -> String {
let mut s = String::new();
@@ -531,24 +587,46 @@
let ordinal = idx + 1;
let label_esc = html_escape(&c.label);
let line = post_strip_lines.get(idx).copied().unwrap_or(1);
- let id_attr = if emitted_anchor.insert(c.label.clone()) {
+ let is_first_occurrence = emitted_anchor.insert(c.label.clone());
+ let id_attr = if is_first_occurrence {
format!(" id=\"callout-{label_esc}\"")
} else {
String::new()
};
+ // The body div's `id` and the button's `aria-describedby` are
+ // dedup'd identically: only the first occurrence per label gets
+ // them. Subsequent occurrences still hover-reveal (CSS uses the
+ // adjacent-sibling combinator inside .callout-entry, not the id),
+ // but cannot be cross-referenced from prose — by design, since
+ // `{{#callout LABEL}}` resolves to the canonical first-occurrence
+ // anchor.
+ let body_id_attr = if is_first_occurrence {
+ format!(" id=\"callout-body-{label_esc}\"")
+ } else {
+ String::new()
+ };
+ let aria_describedby_attr = if is_first_occurrence {
+ format!(" aria-describedby=\"callout-body-{label_esc}\"")
+ } else {
+ String::new()
+ };
s.push_str(&format!(
- " <button type=\"button\" class=\"callout-badge\"{id_attr} \
- data-callout-badge=\"{label_esc}\" data-callout-ordinal=\"{ordinal}\" \
- data-callout-line=\"{line}\" \
- aria-describedby=\"callout-body-{label_esc}\" \
- style=\"--callout-line: {line};\">{ordinal}</button>\n",
+ " <div class=\"callout-entry\" data-callout-line=\"{line}\" \
+ style=\"--callout-line: {line}; --callout-listing-lines: {total_lines};\">\n",
));
+ s.push_str(&format!(
+ " <button type=\"button\" class=\"callout-badge\"{id_attr} \
+ data-callout-badge=\"{label_esc}\" data-callout-ordinal=\"{ordinal}\"\
+ {aria_describedby_attr}>{ordinal}</button>\n",
+ ));
if let Some(body) = &c.body {
s.push_str(&format!(
- " <div class=\"callout-body\" id=\"callout-body-{label_esc}\" role=\"tooltip\" hidden>{}</div>\n",
+ " <div class=\"callout-body\"{body_id_attr} role=\"tooltip\">{}</div>\n",
html_escape(body),
));
}
+ s.push_str(" </div>\n");
}
s.push_str("</div>");
s
@@ -814,6 +892,134 @@
}
#[test]
+ fn splice_chapter_html_strips_added_marker_lines_from_diff_and_emits_badge() {
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1,1 +1,2 @@\n",
+ " fn unchanged() {}\n",
+ "+// CALLOUT: added-marker Body for an added marker.\n",
+ "+fn added() {}\n",
+ "```\n",
+ );
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ assert!(
+ !out.contains("// CALLOUT: added-marker"),
+ "added marker comment line should be stripped from rendered diff; got:\n{out}",
+ );
+ assert!(
+ out.contains("data-callout-badge=\"added-marker\""),
+ "expected badge for the added marker; got:\n{out}",
+ );
+ assert!(
+ out.contains("+fn added() {}"),
+ "non-marker `+` line should survive; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_strips_context_marker_lines_from_diff_and_emits_badge() {
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1,2 +1,2 @@\n",
+ " // CALLOUT: kept-marker A marker carried over unchanged.\n",
+ " fn carried() {}\n",
+ "```\n",
+ );
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ assert!(
+ !out.contains("// CALLOUT: kept-marker"),
+ "context marker comment line should be stripped; got:\n{out}",
+ );
+ assert!(
+ out.contains("data-callout-badge=\"kept-marker\""),
+ "expected badge for the carried-over marker; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_drops_removed_marker_lines_from_diff_with_no_badge() {
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1,2 +1,1 @@\n",
+ "-// CALLOUT: gone-marker Removed in this slice.\n",
+ " fn unchanged() {}\n",
+ "```\n",
+ );
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ assert!(
+ !out.contains("// CALLOUT: gone-marker"),
+ "removed marker comment line should be dropped, not visible; got:\n{out}",
+ );
+ assert!(
+ !out.contains("data-callout-badge=\"gone-marker\""),
+ "removed-side marker must not produce a badge; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_dedups_body_id_when_label_appears_in_two_blocks() {
+ // The button id and the body div id are dedup'd in lockstep: the
+ // first occurrence per label gets `id="callout-LABEL"` AND
+ // `id="callout-body-LABEL"`; subsequent occurrences emit neither.
+ // Otherwise the rendered HTML would have duplicate ids and the
+ // browser's strict-mode locator would refuse to resolve the body.
+ let content = concat!(
+ "```rust\n",
+ "// CALLOUT: shared-label First body.\n",
+ "fn one() {}\n",
+ "```\n\n",
+ "```rust\n",
+ "// CALLOUT: shared-label Second body.\n",
+ "fn two() {}\n",
+ "```\n",
+ );
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let id_count = out.matches("id=\"callout-shared-label\"").count();
+ let body_id_count = out.matches("id=\"callout-body-shared-label\"").count();
+ assert_eq!(
+ id_count, 1,
+ "expected exactly one id=\"callout-shared-label\"; got {id_count} in:\n{out}",
+ );
+ assert_eq!(
+ body_id_count, 1,
+ "expected exactly one id=\"callout-body-shared-label\"; got {body_id_count} in:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_dedups_id_when_label_appears_in_diff_then_include() {
+ // First non-empty fenced block to contain a label gets the
+ // `id="callout-LABEL"` anchor. Subsequent occurrences (same label
+ // in another block) emit the badge but skip the id so the HTML
+ // stays valid (no duplicate IDs).
+ let content = concat!(
+ "```diff\n",
+ "--- a-tag\n",
+ "+++ b-tag\n",
+ "@@ -1 +1,2 @@\n",
+ " fn unchanged() {}\n",
+ "+// CALLOUT: same-label First occurrence is in a diff.\n",
+ "```\n\n",
+ "```rust\n",
+ "// CALLOUT: same-label Second occurrence is in an include.\n",
+ "fn body() {}\n",
+ "```\n",
+ );
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let id_count = out.matches("id=\"callout-same-label\"").count();
+ assert_eq!(
+ id_count, 1,
+ "expected exactly one id=\"callout-same-label\" across the chapter; got {id_count} in:\n{out}",
+ );
+ }
+
+ #[test]
fn splice_chapter_pdf_picks_up_callouts_from_added_and_context_diff_lines() {
// The PDF emitter still emits per-block callouts for diff fences as
// a markdown blockquote (slice 6 shape). The HTML emitter (slice 7+)
The fix is small but the lesson is bigger: the JS-blob sweeps
silently ignored the duplicate-id violation because document. getElementById returns the first match. The locator-API migration
made the bug observable.
Refactor (test infra) — shared e2e harness, tracing, and trace-on-failure
Continues the playwright-rs adoption from the previous refactor by
moving the per-test browser setup into a shared
tests/common/e2e_harness.rs and dogfooding two more upstream
surfaces:
tracing_subscriberintegration. The harness initialisestracing_subscriber::fmt().with_test_writer()once per test process, scoped throughEnvFilter(defaults toinfo,RUST_LOGoverrides). playwright-rs’s#[tracing::instrument]spans on everygoto,evaluate_value,screenshot,browser.close, etc. now surface in test output on demand — drop inRUST_LOG=playwright_rs=info cargo test --test e2e_callouts -- --nocaptureto watch the protocol play out.playwright-rs-tracerecording on failure. Each test wraps its body inBrowserContext::tracing().start(...)and stops with a save path only on panic. Failing tests leavetarget/playwright-traces/<test>.zip— drag into https://trace.playwright.dev ornpx playwright show-tracefor step-through inspection. The harness also runs the saved trace throughplaywright_rs_trace::open()and prints any errored-action summary inline so quick triage doesn’t require leaving the terminal.- Per-test
BrowserContextfor storage isolation between tests.file://URLs don’t really need it today but the pattern is correct.
The harness wraps each test body in
std::panic::AssertUnwindSafe(...).catch_unwind() (via the
futures crate) so a panic in the test body still flows through
trace cleanup before re-raising.
The diff against tests/e2e_callouts.rs (v6 → v7) shows every
test body collapsing into a with_traced_chapter("test-name", CH05, |page| async move { ... }).await call — the per-test
Playwright::launch, pw.chromium().launch(), browser.new_page(),
and browser.close() move into the harness, and the test body
inherits a Page already navigated to the chapter HTML.
--- e2e-callouts-v6
+++ e2e-callouts-v7
@@ -1,122 +1,112 @@
-use std::path::PathBuf;
+use playwright_rs::{expect, locator};
-use playwright_rs::{Playwright, expect, locator};
+mod common;
+use common::e2e_harness::with_traced_chapter;
+
+const CH05: &str = "ch05-render-inline-callouts";
#[tokio::test]
async fn label_only_callout_renders_badge_without_following_body() {
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
-
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- let badge = page.locator(locator!("button#callout-cli-parse")).await;
- expect(badge)
- .to_have_count(1)
- .await
- .expect("label-only badge button must exist");
- let body = page.locator(locator!("#callout-body-cli-parse")).await;
- expect(body)
- .to_have_count(0)
- .await
- .expect("label-only callout must not have a body popover");
-
- browser.close().await.expect("close browser");
+ with_traced_chapter(
+ "label_only_callout_renders_badge_without_following_body",
+ CH05,
+ |page| async move {
+ let badge = page.locator(locator!("button#callout-cli-parse")).await;
+ expect(badge)
+ .to_have_count(1)
+ .await
+ .expect("label-only badge button must exist");
+ let body = page.locator(locator!("#callout-body-cli-parse")).await;
+ expect(body)
+ .to_have_count(0)
+ .await
+ .expect("label-only callout must not have a body popover");
+ },
+ )
+ .await;
}
#[tokio::test]
async fn callout_badge_renders_with_data_attribute_in_ch04() {
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
-
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- let badges = page.locator(locator!("[data-callout-badge]")).await;
- let count = badges.count().await.expect("count badges");
- assert!(
- count > 0,
- "expected at least one [data-callout-badge]; got 0"
- );
- let text = badges.first().text_content().await.expect("badge text");
- assert!(
- text.as_deref().is_some_and(|s| !s.trim().is_empty()),
- "expected first badge text to be non-empty; got {text:?}",
- );
-
- browser.close().await.expect("close browser");
+ with_traced_chapter(
+ "callout_badge_renders_with_data_attribute_in_ch04",
+ CH05,
+ |page| async move {
+ let badges = page.locator(locator!("[data-callout-badge]")).await;
+ let count = badges.count().await.expect("count badges");
+ assert!(count > 0, "expected at least one [data-callout-badge]; got 0");
+ let text = badges.first().text_content().await.expect("badge text");
+ assert!(
+ text.as_deref().is_some_and(|s| !s.trim().is_empty()),
+ "expected first badge text to be non-empty; got {text:?}",
+ );
+ },
+ )
+ .await;
}
#[tokio::test]
async fn callout_cross_ref_renders_as_anchor_to_listing_badge() {
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
-
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- let cross_ref = page
- .locator(locator!(r#"a[data-callout-ref="cross-ref-emit"]"#))
- .await;
- expect(cross_ref)
- .to_have_attribute("href", "#callout-cross-ref-emit")
- .await
- .expect("cross-ref href must point at listing badge anchor");
- let target = page
- .locator(locator!("button#callout-cross-ref-emit"))
- .await;
- expect(target)
- .to_have_count(1)
- .await
- .expect("listing-side badge button must exist as the cross-ref's target");
-
- browser.close().await.expect("close browser");
+ with_traced_chapter(
+ "callout_cross_ref_renders_as_anchor_to_listing_badge",
+ CH05,
+ |page| async move {
+ let cross_ref = page
+ .locator(locator!(r#"a[data-callout-ref="cross-ref-emit"]"#))
+ .await;
+ expect(cross_ref)
+ .to_have_attribute("href", "#callout-cross-ref-emit")
+ .await
+ .expect("cross-ref href must point at listing badge anchor");
+ let target = page
+ .locator(locator!("button#callout-cross-ref-emit"))
+ .await;
+ expect(target)
+ .to_have_count(1)
+ .await
+ .expect("listing-side badge button must exist as the cross-ref's target");
+ },
+ )
+ .await;
}
#[tokio::test]
async fn callout_marker_comment_is_stripped_and_body_reveals_on_hover() {
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
-
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- // Find the <pre> whose sibling overlay carries the cross-ref-emit
- // badge (xpath does the sibling traversal that CSS can't). The
- // splicer should have stripped the literal marker comment from
- // that pre's text.
- let pre = page
- .locator(locator!(
- r#"xpath=//pre[following-sibling::div[1][.//button[@id="callout-cross-ref-emit"]]]"#
- ))
- .await;
- expect(pre.clone())
- .not()
- .to_contain_text("CALLOUT: cross-ref-emit")
- .await
- .expect("marker comment line must be stripped from the include's <pre>");
-
- // Body popover starts hidden and becomes visible after hovering its
- // triggering badge.
- let badge = page
- .locator(locator!("button#callout-cross-ref-emit"))
- .await;
- badge.hover(None).await.expect("hover badge");
- let body = page.locator(locator!("#callout-body-cross-ref-emit")).await;
- expect(body)
- .to_be_visible()
- .await
- .expect("body popover must become visible after hovering its badge");
+ with_traced_chapter(
+ "callout_marker_comment_is_stripped_and_body_reveals_on_hover",
+ CH05,
+ |page| async move {
+ // Find the <pre> whose sibling overlay carries the cross-ref-emit
+ // badge (xpath does the sibling traversal that CSS can't). The
+ // splicer should have stripped the literal marker comment from
+ // that pre's text.
+ let pre = page
+ .locator(locator!(
+ r#"xpath=//pre[following-sibling::div[1][.//button[@id="callout-cross-ref-emit"]]]"#
+ ))
+ .await;
+ expect(pre.clone())
+ .not()
+ .to_contain_text("CALLOUT: cross-ref-emit")
+ .await
+ .expect("marker comment line must be stripped from the include's <pre>");
- browser.close().await.expect("close browser");
+ // Body popover starts hidden and becomes visible after hovering its
+ // triggering badge.
+ let badge = page
+ .locator(locator!("button#callout-cross-ref-emit"))
+ .await;
+ badge.hover(None).await.expect("hover badge");
+ let body = page.locator(locator!("#callout-body-cross-ref-emit")).await;
+ expect(body)
+ .to_be_visible()
+ .await
+ .expect("body popover must become visible after hovering its badge");
+ },
+ )
+ .await;
}
#[tokio::test]
@@ -130,72 +120,69 @@
// 2. A `button[id="callout-LABEL"]` exists as the target
// 3. The ref's `data-callout-ordinal` matches the target badge's
// 4. The rendered text on the ref matches the target badge's text
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
+ with_traced_chapter(
+ "every_callout_cross_ref_resolves_to_a_badge_with_matching_ordinal_and_text",
+ CH05,
+ |page| async move {
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
+ assert!(
+ count > 0,
+ "expected at least one a[data-callout-ref] in chapter"
+ );
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
+ for i in 0..count {
+ let r = refs.nth(i as i32);
+ let label = r
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ .unwrap_or_default();
+ assert!(!label.is_empty(), "ref #{i} has empty data-callout-ref");
- let refs = page.locator(locator!("a[data-callout-ref]")).await;
- let count = refs.count().await.expect("count refs");
- assert!(
- count > 0,
- "expected at least one a[data-callout-ref] in chapter"
- );
+ let expected_href = format!("#callout-{label}");
+ expect(r.clone())
+ .to_have_attribute("href", &expected_href)
+ .await
+ .unwrap_or_else(|e| panic!("ref `{label}`: href mismatch: {e:?}"));
- for i in 0..count {
- let r = refs.nth(i as i32);
- let label = r
- .get_attribute("data-callout-ref")
- .await
- .expect("ref label")
- .unwrap_or_default();
- assert!(!label.is_empty(), "ref #{i} has empty data-callout-ref");
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
+ .await;
+ expect(target.clone())
+ .to_have_count(1)
+ .await
+ .unwrap_or_else(|e| panic!("ref `{label}`: target badge missing: {e:?}"));
- let expected_href = format!("#callout-{label}");
- expect(r.clone())
- .to_have_attribute("href", &expected_href)
- .await
- .unwrap_or_else(|e| panic!("ref `{label}`: href mismatch: {e:?}"));
-
- let target = page
- .locator(&format!(r#"button[id="callout-{label}"]"#))
- .await;
- expect(target.clone())
- .to_have_count(1)
- .await
- .unwrap_or_else(|e| panic!("ref `{label}`: target badge missing: {e:?}"));
-
- let ref_ordinal = r
- .get_attribute("data-callout-ordinal")
- .await
- .expect("ref ordinal")
- .unwrap_or_default();
- expect(target.clone())
- .to_have_attribute("data-callout-ordinal", &ref_ordinal)
- .await
- .unwrap_or_else(|e| {
- panic!("ref `{label}`: ordinal mismatch (ref={ref_ordinal}): {e:?}")
- });
-
- let ref_text = r
- .text_content()
- .await
- .expect("ref text")
- .unwrap_or_default()
- .trim()
- .to_string();
- expect(target)
- .to_have_text(&ref_text)
- .await
- .unwrap_or_else(|e| {
- panic!("ref `{label}`: rendered text mismatch (ref=\"{ref_text}\"): {e:?}")
- });
- }
+ let ref_ordinal = r
+ .get_attribute("data-callout-ordinal")
+ .await
+ .expect("ref ordinal")
+ .unwrap_or_default();
+ expect(target.clone())
+ .to_have_attribute("data-callout-ordinal", &ref_ordinal)
+ .await
+ .unwrap_or_else(|e| {
+ panic!("ref `{label}`: ordinal mismatch (ref={ref_ordinal}): {e:?}")
+ });
- browser.close().await.expect("close browser");
+ let ref_text = r
+ .text_content()
+ .await
+ .expect("ref text")
+ .unwrap_or_default()
+ .trim()
+ .to_string();
+ expect(target)
+ .to_have_text(&ref_text)
+ .await
+ .unwrap_or_else(|e| {
+ panic!("ref `{label}`: rendered text mismatch (ref=\"{ref_text}\"): {e:?}")
+ });
+ }
+ },
+ )
+ .await;
}
#[tokio::test]
@@ -203,54 +190,43 @@
// Regression guard, scoped to labels the author actually points at:
// every `{{#callout LABEL}}` directive must have a corresponding
// `button[id="callout-LABEL"]` somewhere in the rendered page.
- // Catches the most common slice-shipping mistake — a cross-ref to
- // a marker whose only chapter occurrence is in a `{{#diff}}` block
- // before slice 8 wired diff blocks through the badge emitter.
- // Test-fixture marker strings inside string literals are
- // intentionally not flagged (the author isn't pointing at them).
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
+ with_traced_chapter(
+ "every_cross_refed_label_has_a_visible_badge_in_the_chapter",
+ CH05,
+ |page| async move {
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- let refs = page.locator(locator!("a[data-callout-ref]")).await;
- let count = refs.count().await.expect("count refs");
-
- let mut missing: Vec<String> = Vec::new();
- for i in 0..count {
- let label = refs
- .nth(i as i32)
- .get_attribute("data-callout-ref")
- .await
- .expect("ref label")
- .unwrap_or_default();
- if label.is_empty() {
- continue;
- }
- let target = page
- .locator(&format!(r#"button[id="callout-{label}"]"#))
- .await;
- if target.count().await.expect("count target") == 0 {
- missing.push(label);
- }
- }
- missing.sort();
- missing.dedup();
-
- assert!(
- missing.is_empty(),
- "the following labels are cross-refed in chapter prose but have no \
- `button[id=\"callout-LABEL\"]` target — most likely the cross-ref \
- points at a marker whose only occurrence is in a `{{{{#diff}}}}` \
- block. Add a non-diff `{{{{#include}}}}` of the source, or extract \
- a snippet, so the badge anchor lands. Broken labels: {}",
- missing.join(", "),
- );
+ let mut missing: Vec<String> = Vec::new();
+ for i in 0..count {
+ let label = refs
+ .nth(i as i32)
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ .unwrap_or_default();
+ if label.is_empty() {
+ continue;
+ }
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
+ .await;
+ if target.count().await.expect("count target") == 0 {
+ missing.push(label);
+ }
+ }
+ missing.sort();
+ missing.dedup();
- browser.close().await.expect("close browser");
+ assert!(
+ missing.is_empty(),
+ "the following labels are cross-refed in chapter prose but have no \
+ `button[id=\"callout-LABEL\"]` target. Broken labels: {}",
+ missing.join(", "),
+ );
+ },
+ )
+ .await;
}
#[tokio::test]
@@ -258,99 +234,77 @@
// End-to-end click-through guard: for every prose-side
// `a[data-callout-ref]`, click it and assert the target badge ends
// up visible (the natural in-page anchor-jump behaviour).
- //
- // Cross-chapter refs are out of scope: chapter prose only references
- // callouts in listings rendered in the same chapter, by design.
- let chapter_html = chapter_path();
- let url = format!("file://{}", chapter_html.display());
-
- let pw = Playwright::launch().await.expect("launch playwright");
- let browser = pw.chromium().launch().await.expect("launch chromium");
- let page = browser.new_page().await.expect("new page");
- page.goto(&url, None).await.expect("goto chapter");
-
- let refs = page.locator(locator!("a[data-callout-ref]")).await;
- let count = refs.count().await.expect("count refs");
- assert!(
- count > 0,
- "expected at least one cross-ref for click-through coverage"
- );
-
- let mut labels: Vec<String> = Vec::with_capacity(count);
- for i in 0..count {
- if let Some(label) = refs
- .nth(i as i32)
- .get_attribute("data-callout-ref")
- .await
- .expect("ref label")
- && !label.is_empty()
- {
- labels.push(label);
- }
- }
-
- let mut failures: Vec<String> = Vec::new();
- for label in &labels {
- // Reset the URL hash so each navigation is a fresh jump rather
- // than a no-op when the current hash already matches. This is a
- // history-API mutation; no playwright equivalent.
- let _: String = page
- .evaluate_value(
- "(() => { history.replaceState(null, '', location.pathname); return 'ok'; })()",
- )
- .await
- .expect("reset hash");
+ with_traced_chapter(
+ "clicking_each_cross_ref_scrolls_target_badge_into_viewport",
+ CH05,
+ |page| async move {
+ let refs = page.locator(locator!("a[data-callout-ref]")).await;
+ let count = refs.count().await.expect("count refs");
+ assert!(
+ count > 0,
+ "expected at least one cross-ref for click-through coverage"
+ );
- let r = page
- .locator(&format!(r#"a[data-callout-ref="{label}"]"#))
- .await
- .first();
- if let Err(e) = r.click(None).await {
- failures.push(format!("label `{label}`: click failed: {e:?}"));
- continue;
- }
+ let mut labels: Vec<String> = Vec::with_capacity(count);
+ for i in 0..count {
+ if let Some(label) = refs
+ .nth(i as i32)
+ .get_attribute("data-callout-ref")
+ .await
+ .expect("ref label")
+ && !label.is_empty()
+ {
+ labels.push(label);
+ }
+ }
- let target = page
- .locator(&format!(r#"button[id="callout-{label}"]"#))
- .await
- .first();
- if let Err(e) = target.scroll_into_view_if_needed().await {
- failures.push(format!("label `{label}`: scroll failed: {e:?}"));
- continue;
- }
- if !target.is_visible().await.unwrap_or(false) {
- failures.push(format!("label `{label}`: target not visible after click"));
- continue;
- }
+ let mut failures: Vec<String> = Vec::new();
+ for label in &labels {
+ page.clear_url_fragment().await.expect("reset hash");
- let actual_hash: String = page
- .evaluate_value("location.hash")
- .await
- .expect("read hash");
- let expected_hash = format!("#callout-{label}");
- if actual_hash != expected_hash {
- failures.push(format!(
- "label `{label}`: hash after click was `{actual_hash}` but expected `{expected_hash}`"
- ));
- }
- }
+ let r = page
+ .locator(&format!(r#"a[data-callout-ref="{label}"]"#))
+ .await
+ .first();
+ if let Err(e) = r.click(None).await {
+ failures.push(format!("label `{label}`: click failed: {e:?}"));
+ continue;
+ }
- assert!(
- failures.is_empty(),
- "click-through navigation failed for {} of {} cross-ref(s):\n - {}",
- failures.len(),
- labels.len(),
- failures.join("\n - "),
- );
+ let target = page
+ .locator(&format!(r#"button[id="callout-{label}"]"#))
+ .await
+ .first();
+ if let Err(e) = target.scroll_into_view_if_needed().await {
+ failures.push(format!("label `{label}`: scroll failed: {e:?}"));
+ continue;
+ }
+ if !target.is_visible().await.unwrap_or(false) {
+ failures.push(format!("label `{label}`: target not visible after click"));
+ continue;
+ }
- browser.close().await.expect("close browser");
-}
+ let actual_hash: String = page
+ .evaluate_value("location.hash")
+ .await
+ .expect("read hash");
+ let expected_hash = format!("#callout-{label}");
+ if actual_hash != expected_hash {
+ failures.push(format!(
+ "label `{label}`: hash after click was `{actual_hash}` but expected `{expected_hash}`"
+ ));
+ }
+ }
-fn chapter_path() -> PathBuf {
- let manifest_dir = env!("CARGO_MANIFEST_DIR");
- PathBuf::from(manifest_dir)
- .join("book")
- .join("build")
- .join("html")
- .join("ch05-render-inline-callouts.html")
+ assert!(
+ failures.is_empty(),
+ "click-through navigation failed for {} of {} cross-ref(s):\n - {}",
+ failures.len(),
+ labels.len(),
+ failures.join("\n - "),
+ );
+ },
+ )
+ .await;
}
Three strategically placed callout markers anchor the long diff above: the harness import (callout 1), the canonical call shape every test now follows (callout 2), and the line that drops the last JS string in the suite (callout 3).
The harness landed without the originally-planned shared Browser
optimisation. Each #[tokio::test] creates its own tokio runtime;
a Browser handle’s internal channels are bound to the runtime
that created them. Sharing a Browser via tokio::sync::OnceCell
across tests deadlocks once the first test’s runtime exits —
subsequent tests block forever waiting for responses on dead
channels. Per-test Playwright::launch + browser.launch is the
price of #[tokio::test] runtime isolation.
The dogfooding cycle filed this as
playwright-rust#90
and the upstream response landed within the same pass: a
debug-build assertion that captures the launching runtime’s ID at
Connection construction and panics in Connection::send_message
when the current runtime differs. Silent deadlock is now a loud
panic with a clear message. The mdbook-listings harness also
gained verbose chatter at playwright_rs=debug from the same
upstream — each protocol message dispatched dumped tens of KB per
test. Filed as
playwright-rust#91;
upstream demoted the per-message log lines from debug to trace,
making RUST_LOG=playwright_rs=debug usable for triage again.
This refactor also folds in the upstream resolution of
playwright-rust#89.
The previous refactor’s locator/assertion migration left exactly
one JS string in the suite — a history.replaceState(null, '', location.pathname) mutation in the click-through-navigation test,
since playwright-rs had no typed equivalent. Filed
#89
upstream; resolved within the dogfooding pass as commit
401be500
which adds Page::clear_url_fragment(). Bumping the workspace
playwright-rs git pin past that commit and replacing the JS
line with page.clear_url_fragment().await (visible in the
v6 → v7 diff above) makes the e2e suite JS-string-free.
The migration also surfaced a long-standing positioning bug. The
v6 → v7 diff above is ~600 lines tall — its first two callouts
(harness-import at line 9, harness-call at line 35) sit near
the top, the third (clear-url-fragment at line 529) near the
bottom. In the browser, only the third one rendered where it
should; the other two visually appeared inside the previous
diff (the callout-v5 → callout-v6 block in the previous
refactor section). The cross-references still navigated to the
right anchors — the IDs were correct — but the badges themselves
had drifted ~1800px above their intended rows.
Root cause: the overlay’s CSS positioning formula assumed each
listing line rendered at 1.5em (in the overlay’s 0.875em font =
21px). mdbook’s <pre> actually uses line-height: normal,
which Chromium computes as ~1.13 for monospace = ~18px. A 3px
per-line gap × 600 lines compounds to ~1800px of cumulative
drift — enough to push the line-9 and line-35 badges entirely
out of the v6 → v7 diff and into the previous sibling pre.
Fix: ship a tiny per-book init script (registered via
additional-js in book.toml, alongside the existing
additional-css entry) that walks every .callout-overlay on
page load, measures the previous <pre>’s actual rendered height,
divides by the listing’s line count, and writes the per-line pixel
value as a CSS custom property --callout-line-px on the overlay.
The CSS formula then uses var(--callout-line-px, 1.5em) instead
of the bare 1.5em, so the bug doesn’t reappear no matter what
font or theme an author drops in. A new e2e regression test —
every_badge_renders_inside_its_owning_pre — asserts that every
badge’s bounding box lies within its sibling pre’s, so this can’t
silently regress. The diff against tests/e2e_callouts.rs (v7 →
v8) shows the new test:
--- e2e-callouts-v7
+++ e2e-callouts-v8
@@ -36,7 +36,10 @@
|page| async move {
let badges = page.locator(locator!("[data-callout-badge]")).await;
let count = badges.count().await.expect("count badges");
- assert!(count > 0, "expected at least one [data-callout-badge]; got 0");
+ assert!(
+ count > 0,
+ "expected at least one [data-callout-badge]; got 0"
+ );
let text = badges.first().text_content().await.expect("badge text");
assert!(
text.as_deref().is_some_and(|s| !s.trim().is_empty()),
@@ -308,3 +311,57 @@
)
.await;
}
+
+#[tokio::test]
+async fn every_badge_renders_inside_its_owning_pre() {
+ // Regression guard for the long-diff badge mispositioning bug:
+ // each callout badge must visually land within the y-range of the
+ // <pre> it belongs to (the one immediately preceding its
+ // .callout-overlay parent). Pre-fix, badges in long diffs drifted
+ // ~3px per line above their intended row because the overlay's
+ // assumed line-height (1.5em at 0.875em font = 21px) didn't match
+ // the pre's rendered line-height (`normal` ~ 18px for monospace).
+ // For a 600-line diff that compounds to ~1800px, landing badges
+ // inside the wrong sibling pre.
+ with_traced_chapter(
+ "every_badge_renders_inside_its_owning_pre",
+ CH05,
+ |page| async move {
+ // For each .callout-overlay, locate its sibling <pre> and
+ // every .callout-badge inside, and verify each badge's y
+ // sits within the pre's y-range.
+ let report: String = page
+ .evaluate_value(
+ r#"(() => {
+ const failures = [];
+ const overlays = document.querySelectorAll('.callout-overlay');
+ overlays.forEach((o, i) => {
+ const pre = o.previousElementSibling;
+ if (!pre || pre.tagName !== 'PRE') return;
+ const preBox = pre.getBoundingClientRect();
+ const preTopAbs = preBox.top + window.scrollY;
+ const preBotAbs = preBox.bottom + window.scrollY;
+ o.querySelectorAll('.callout-badge').forEach(b => {
+ const bb = b.getBoundingClientRect();
+ const bAbs = bb.top + window.scrollY;
+ if (bAbs < preTopAbs - 2 || bAbs > preBotAbs + 2) {
+ failures.push(
+ `overlay#${i} badge#${b.id || b.dataset.calloutBadge}: ` +
+ `y=${bAbs.toFixed(0)} pre=[${preTopAbs.toFixed(0)}..${preBotAbs.toFixed(0)}]`
+ );
+ }
+ });
+ });
+ return failures.join('\n');
+ })()"#,
+ )
+ .await
+ .expect("evaluate badges-vs-pre");
+ assert!(
+ report.is_empty(),
+ "badges rendered outside their owning <pre>:\n{report}"
+ );
+ },
+ )
+ .await;
+}
Slice 9 — line-range support for {{#diff}} and {{#include}}
The previous refactor’s badge-positioning fix put every callout back
on the line that previously held its CALLOUT: marker. But the
visual UX problem that prompted the fix has another side: a
600-line diff is taller than the browser viewport regardless of
where badges land within it. Cross-ref prose at the bottom of a
long diff anchor-jumps the reader hundreds of em above; on the way
back down, the prior section’s overlay (with its own ordinal-1 and
ordinal-2 badges) sits in the line of sight long before the
intended diff scrolls back into view. The author can fight this by
freezing snippet listings — extract just the first 30 lines of
tests/e2e_callouts.rs v7 as e2e-callouts-v7-imports.rs,
freeze it, diff that against the corresponding slice of v6 — but
that’s a lot of boilerplate per fragment, plus a maintenance tax
every time the parent listing evolves.
This slice adds line-range support to the two directives that
render frozen-listing bytes: {{#diff}} and {{#include}}. Both
accept optional START:END arguments to render only the
corresponding fragment of the source files. Endpoints are
inclusive and 1-based; empty endpoints (200:, :100, :) mean
“to end” / “to start” / “whole file”. Out-of-range endpoints clamp
silently — authors using 200: to mean “from line 200 to whatever
the end happens to be” don’t have to know the file’s exact length.
The {{#diff}} form takes two ranges (one per operand) since line
numbers shift between versions:
{{#diff a b}} # whole files (today's behaviour)
{{#diff a b 1:50 1:60}} # left lines 1-50 vs right lines 1-60
{{#diff a b 200: 220:}} # from line N to end-of-file
{{#diff a b :100 :100}} # from start to line 100
The {{#include}} form re-uses mdBook’s existing :start:end
suffix syntax — same shape readers already learn for the built-in
include directive:
{{#include listings/foo.rs}} # whole file
{{#include listings/foo.rs:1:30}} # lines 1-30
{{#include listings/foo.rs:200:}} # from line 200 to EOF
The diff splicer slices both source files to their respective
ranges before running the diff algorithm; the include splicer
slices the file body before inlining. CALLOUT-marker stripping
and badge emission run on the post-slice content, so
--callout-line values refer to the rendered slice (line 5 in
the slice = line 5 in the rendered <pre>, regardless of where
that line was in the original file). The locator anchors emitted
for the screenshot tool gain a range data-attribute when ranges
are present — data-listing-diff-left-range="1:50",
data-listing-tag-range="1:30" — keeping the (tag, range) pair
unique even when the same listing is shown sliced more than once
in a chapter.
The new LineRange struct lives in src/diff.rs (and is re-used
by the include splicer). The diff against the slice-8 state of
src/diff.rs (v8 → v9), sliced to the prelude where the struct
and its slice() / render() helpers live, shows the new types
landing as a pure addition between the existing DiffDirective
struct and the parser:
--- diff-v8
+++ diff-v9
@@ -9,22 +9,105 @@
/// `span` covers the directive in full (`{{#diff …}}` inclusive) so callers
/// can replace the whole substring in one pass.
+///
+/// `left_range` and `right_range` are present when the directive carries
+/// optional 3rd and 4th `START:END` arguments — `{{#diff a b 1:50 1:60}}`
+/// renders only those slices of each operand. Empty endpoints mean "to
+/// start" or "to end". Two ranges (one per operand) because line numbers
+/// shift between versions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffDirective {
pub left: String,
pub right: String,
+ pub left_range: Option<LineRange>,
+ pub right_range: Option<LineRange>,
pub span: Range<usize>,
}
+/// 1-based inclusive line range. `None` endpoints mean "to start"
+/// (`start`) or "to end" (`end`). Out-of-range endpoints are clamped to
+/// the file's actual line count silently.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct LineRange {
+ pub start: Option<usize>,
+ pub end: Option<usize>,
+}
+
+impl LineRange {
+ /// Render as the `START:END` form used in directives and anchors.
+ /// Empty endpoints render as the empty string.
+ pub fn render(&self) -> String {
+ let s = self.start.map(|n| n.to_string()).unwrap_or_default();
+ let e = self.end.map(|n| n.to_string()).unwrap_or_default();
+ format!("{s}:{e}")
+ }
+
+ /// Slice `text` to the range's line span, 1-based inclusive. Returns
+ /// the substring that includes lines `[start..=end]` (clamped). The
+ /// returned substring preserves trailing newlines.
+ pub fn slice<'a>(&self, text: &'a str) -> &'a str {
+ let total = text.lines().count();
+ let start_1 = self.start.unwrap_or(1).max(1);
+ let end_1 = self.end.unwrap_or(total).min(total);
+ if start_1 > end_1 || total == 0 {
+ return "";
+ }
+ // Find byte offsets for line `start_1` and `end_1 + 1` (or EOF).
+ let mut byte_start = 0usize;
+ let mut current_line = 1usize;
+ let bytes = text.as_bytes();
+ while current_line < start_1 && byte_start < bytes.len() {
+ match text[byte_start..].find('\n') {
+ Some(off) => byte_start += off + 1,
+ None => return "",
+ }
+ current_line += 1;
+ }
+ let mut byte_end = byte_start;
+ let mut line = current_line;
+ while line <= end_1 && byte_end < bytes.len() {
+ match text[byte_end..].find('\n') {
+ Some(off) => byte_end += off + 1,
+ None => {
+ byte_end = bytes.len();
+ break;
+ }
+ }
+ line += 1;
+ }
+ &text[byte_start..byte_end]
+ }
+}
+
+/// Parses one `START:END` token. Returns `None` when the form is malformed
+/// (the directive is then skipped just like a wrong-arity `{{#diff}}`).
+/// Empty endpoints are allowed; `:` (both empty) means whole file. Numeric
+/// endpoints must be positive (zero rejected).
+pub fn parse_line_range(tok: &str) -> Option<LineRange> {
+ let (s, e) = tok.split_once(':')?;
+ let start = if s.is_empty() {
+ None
+ } else {
+ let n: usize = s.parse().ok()?;
+ if n == 0 {
+ return None;
+ }
+ Some(n)
+ };
+ let end = if e.is_empty() {
+ None
+ } else {
+ let n: usize = e.parse().ok()?;
+ if n == 0 {
+ return None;
+ }
+ Some(n)
+ };
+ Some(LineRange { start, end })
+}
+
/// Returns every well-formed `{{#diff a b}}` directive in `content`.
/// Backslash-escaped directives, wrong-arity matches, and any directive
/// whose start byte falls inside a fenced code block are skipped — the
/// fence rule lets a chapter quote literal directive examples (e.g. a
/// frozen test fixture) without the preprocessor consuming them.
-pub fn parse_directives(content: &str) -> Vec<DiffDirective> {
- const PREFIX: &[u8] = b"{{#diff";
- let bytes = content.as_bytes();
- let mut out = Vec::new();
- let mut in_fence = false;
- let mut line_start = 0;
- while line_start < bytes.len() {
The directive parser grows from a single 2-token shape to a
3-armed match that accepts 2 tokens (whole-file, today’s shape)
or 4 tokens (with two START:END ranges). Anything else —
malformed ranges, wrong arity — falls through and skips the
directive, same shape as today’s wrong-arity handling, so authors
who fat-finger a range get a literal {{#diff …}} in the
rendered chapter rather than an opaque silent failure:
--- diff-v8
+++ diff-v9
@@ -58,18 +148,28 @@
let tokens: Vec<&str> = content[inner_start..inner_start + end_rel]
.split_whitespace()
.collect();
- if tokens.len() == 2 {
+ let parsed = match tokens.as_slice() {
+ [l, r] => Some((l.to_string(), r.to_string(), None, None)),
+ [l, r, lr, rr] => match (parse_line_range(lr), parse_line_range(rr)) {
+ (Some(left_range), Some(right_range)) => Some((
+ l.to_string(),
+ r.to_string(),
+ Some(left_range),
+ Some(right_range),
+ )),
+ _ => None,
+ },
+ _ => None,
+ };
+ if let Some((left, right, left_range, right_range)) = parsed {
out.push(DiffDirective {
- left: tokens[0].to_string(),
- right: tokens[1].to_string(),
+ left,
+ right,
+ left_range,
+ right_range,
span: i..directive_end,
});
}
i = directive_end;
}
}
- line_start = line_end + 1;
- }
- out
-}
-
The splicer applies the slice between the byte-load and the diff
render, branching on Option<LineRange> so the no-range case
still loans bytes through the existing diff engine without an
intermediate copy. The locator anchor’s data attributes pick up
the new data-listing-diff-{left,right}-range keys when ranges
are present:
--- diff-v8
+++ diff-v9
@@ -263,33 +435,61 @@
+ let resolved =
resolve(&d, manifest, book_root, chapter_dir).map_err(|source| SpliceError {
chapter_path: chapter_path.map(Path::to_path_buf),
line: line_number(content, d.span.start),
source,
})?;
- let left = String::from_utf8_lossy(&resolved.left_bytes);
- let right = String::from_utf8_lossy(&resolved.right_bytes);
- let body = render(&left, &right, &resolved.left_label, &resolved.right_label);
+ let left_full = String::from_utf8_lossy(&resolved.left_bytes);
+ let right_full = String::from_utf8_lossy(&resolved.right_bytes);
+ let left_sliced: &str = match &d.left_range {
+ Some(r) => r.slice(&left_full),
+ None => &left_full,
+ };
+ let right_sliced: &str = match &d.right_range {
+ Some(r) => r.slice(&right_full),
+ None => &right_full,
+ };
+ let body = render(
+ left_sliced,
+ right_sliced,
+ &resolved.left_label,
+ &resolved.right_label,
+ );
+ // When a range is set, similar's hunk headers are relative to the
+ // slice (line 1 of the slice = line N of the original). Shift them
+ // back to absolute line numbers so readers can map a +/- line in
+ // the rendered diff to its real position in the parent listing.
+ let left_offset = d
+ .left_range
+ .and_then(|r| r.start)
+ .map(|n| n - 1)
+ .unwrap_or(0);
+ let right_offset = d
+ .right_range
+ .and_then(|r| r.start)
+ .map(|n| n - 1)
+ .unwrap_or(0);
+ let body = shift_hunk_headers(&body, left_offset, right_offset);
out.push_str(&content[cursor..d.span.start]);
out.push_str("```diff\n");
out.push_str(&body);
out.push_str("```\n");
- out.push_str(&format!(
- "<div data-listing-diff-left=\"{}\" data-listing-diff-right=\"{}\" aria-hidden=\"true\"></div>",
+ let mut anchor = format!(
+ "<div data-listing-diff-left=\"{}\" data-listing-diff-right=\"{}\"",
d.left, d.right,
- ));
+ );
+ if let Some(r) = &d.left_range {
+ anchor.push_str(&format!(" data-listing-diff-left-range=\"{}\"", r.render()));
+ }
+ if let Some(r) = &d.right_range {
+ anchor.push_str(&format!(
+ " data-listing-diff-right-range=\"{}\"",
+ r.render()
+ ));
+ }
+ anchor.push_str(" aria-hidden=\"true\"></div>");
+ out.push_str(&anchor);
cursor = d.span.end;
}
out.push_str(&content[cursor..]);
Ok(out)
-}
-
-fn line_number(content: &str, byte_offset: usize) -> usize {
- content[..byte_offset]
- .bytes()
- .filter(|&b| b == b'\n')
- .count()
- + 1
-}
-
-#[cfg(test)]
-mod tests {
The include splicer mirrors the diff splicer’s shape. Its parser
splits the directive’s path on the first : to peel off any
:start:end suffix — falling through to mdBook’s built-in
links preprocessor for unrecognised suffix forms (anchor names
like :setup) so authors who already use those keep their
expected behaviour:
--- include-v1
+++ include-v2
@@ -52,13 +57,29 @@
break;
};
let directive_end = inner_start + end_rel + 2;
- let path = content[inner_start..inner_start + end_rel].trim();
+ let raw = content[inner_start..inner_start + end_rel].trim();
- let intercepted = path.starts_with("listings/") || path.starts_with("snippets/");
- if !intercepted || path.contains(':') {
+ let intercepted = raw.starts_with("listings/") || raw.starts_with("snippets/");
+ if !intercepted {
i = directive_end;
continue;
}
+ // Split on the first `:` to separate the path from an optional
+ // `:start:end` suffix (mdBook's built-in include slicing form).
+ // We accept the suffix here so listings/snippets includes can
+ // address a fragment of the file the same way mdBook's `links`
+ // preprocessor would for any other path. Other forms (anchor
+ // names, `=anchor`) fall through to `links`.
+ let (path, range) = match raw.split_once(':') {
+ Some((p, suffix)) => match parse_line_range(suffix) {
+ Some(r) => (p, Some(r)),
+ None => {
+ i = directive_end;
+ continue;
+ }
+ },
+ None => (raw, None),
+ };
let tag = if path.starts_with("listings/") {
Some(
@@ -78,13 +99,7 @@
out.push(IncludeDirective {
tag,
rel_path: path.to_string(),
+ range,
span: i..directive_end,
fence_close_end,
});
- i = directive_end;
- }
- if line_end == bytes.len() {
- break;
- }
- line_start = line_end + 1;
- }
The splicer slices the file body before the inline expansion and
emits a data-listing-tag-range attribute on the locator anchor
when a range is set:
--- include-v1
+++ include-v2
@@ -186,30 +208,33 @@
chapter_path: chapter_path.map(Path::to_path_buf),
}
})?;
- // Why: the chapter's newline-after-directive (preserved via
- // `content[d.span.end..]`) terminates the last content line; keeping
- // the file's own trailing newline produces a blank line before the
- // closing fence.
- while body.ends_with('\n') {
- body.pop();
- }
- out.push_str(&content[cursor..d.span.start]);
- out.push_str(&body);
- out.push_str(&content[d.span.end..close_end]);
- if let Some(tag) = &d.tag {
- out.push_str(&format!(
- "<div data-listing-tag=\"{tag}\" aria-hidden=\"true\"></div>\n",
- ));
+ if let Some(range) = &d.range {
+ // Prepend a two-line header that mirrors a unified-diff's
+ // `--- left-tag\n@@ -A,B +C,D @@` shape: filename basename on
+ // line 1 (analogous to `--- TAG`), `@@ start,end @@` on
+ // line 2 (analogous to the hunk header). Both lines are
+ // comment-prefixed when the file extension maps to a known
+ // single-line syntax, so syntax highlighters render them as
+ // metadata rather than invalid code.
+ let basename = std::path::Path::new(&d.rel_path)
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or(d.rel_path.as_str());
+ let prefix = std::path::Path::new(&d.rel_path)
+ .extension()
+ .and_then(|e| e.to_str())
+ .and_then(comment_prefix_for_extension)
+ .map(|p| format!("{p} "))
+ .unwrap_or_default();
+ let header = format!(
+ "{prefix}{basename}\n{prefix}@@ {},{} @@",
+ range.start.unwrap_or(1),
+ range
+ .end
+ .map(|n| n.to_string())
+ .unwrap_or_else(|| "EOF".to_string()),
+ );
+ let sliced = range.slice(&body);
+ body = format!("{header}\n{sliced}");
}
- cursor = close_end;
- }
- out.push_str(&content[cursor..]);
- Ok(out)
-}
-
-fn line_number(content: &str, byte_offset: usize) -> usize {
- content[..byte_offset]
- .bytes()
- .filter(|&b| b == b'\n')
- .count()
+ // Why: the chapter's newline-after-directive (preserved via
Both call sites use the same slice() method and render()
formatter from src/diff.rs, so range semantics stay consistent
across the two directives.
A subtle correctness consideration: when the diff splicer hands a
slice of each operand to similar, the resulting unified-diff
hunk headers (@@ -A,B +C,D @@) are keyed to slice-relative line
numbers. A reader looking at a + line in the rendered diff would
have no way to map it back to its position in the parent listing.
The splicer post-processes similar’s output to shift every hunk
header’s start lines by (range.start - 1) per side, so
{{#diff a b 56:75 146:175}} renders @@ -58,18 +148,28 @@
rather than the raw @@ -3,18 +3,28 @@ similar emits. The
include splicer mirrors the diff splicer’s two-line header shape:
where a unified diff opens with --- left-tag\n+++ right-tag\n@@ -A,B +C,D @@,
a sliced include opens with // basename\n// @@ start,end @@
(language-aware comment prefix when the file extension has a
known one). Line 1 plays the role of the diff’s --- TAG; line
2 plays the role of the diff’s @@ -A,B +C,D @@. Readers can
tell at a glance that they’re looking at a fragment, not the
whole file, and they know which file the fragment came from.
Both behaviours are covered by integration tests in
tests/diff_line_ranges.rs and tests/include_line_ranges.rs.
The include test file is itself a useful demo: it’s about 165
lines of Rust split across five tests + a small harness, and
slice 9 shows the whole thing in chunks via the very {{#include listings/foo.rs:start:end}} syntax it tests. Each chunk gets a
brief intro paragraph; the // basename\n// @@ start,end @@
header prepended by the splicer makes each fragment
self-locating against the unsliced file.
Doc comment + imports + harness use:
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 1,17 @@
//! Integration tests for slice 9: the `{{#include path:START:END}}` line-
//! range form. Each test exercises one facet of the feature; the file is
//! frozen as `include-line-ranges-v1.rs` and shown in ch.5 slice 9 via
//! `` — the slice
//! demonstrates the include-line-range syntax by using it on this very
//! file.
use std::fs;
use std::path::PathBuf;
use mdbook_preprocessor::PreprocessorContext;
use mdbook_preprocessor::book::{Book, BookItem, Chapter};
use mdbook_preprocessor::config::Config;
use tempfile::TempDir;
mod common;
use common::mdbook_listings;
}
The first test asserts the basic slicing contract — lines outside the requested range never appear in the rendered chapter (callout 1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 19,32 @@
#[test]
fn include_with_line_range_inlines_only_the_sliced_lines() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing(
"ranged.rs",
b"line1\nline2\nline3\nline4\nline5\nline6\nline7\n",
);
let envelope =
book.envelope_with_chapter("```rust\n{{#include listings/ranged.rs:3:5}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(content.contains("line3\nline4\nline5"));
assert!(!content.contains("line1") && !content.contains("line7"));
}
}
The header-line test pins the contract that the rendered slice is
prefixed with a two-line // basename\n// @@ start,end @@ banner
(callout 1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 34,49 @@
#[test]
fn include_with_line_range_prepends_a_two_line_diff_style_header() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing(
"ranged.rs",
b"line1\nline2\nline3\nline4\nline5\nline6\nline7\n",
);
let envelope =
book.envelope_with_chapter("```rust\n{{#include listings/ranged.rs:3:5}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains("// ranged.rs\n// @@ 3,5 @@"),
"expected two-line `// basename\\n// @@ start,end @@` header; got:\n{content}",
);
}
}
The header’s comment prefix is language-aware — Rust gets //,
Python/YAML/TOML/shell get #, SQL gets --, anything else gets
a raw header with no prefix at all. Three more tests pin each
case, so the contract is explicit (callout
1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 51,93 @@
#[test]
fn include_range_header_uses_hash_comment_prefix_for_python_extension() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing("script.py", b"a\nb\nc\nd\n");
let envelope =
book.envelope_with_chapter("```python\n{{#include listings/script.py:1:2}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains("# script.py\n# @@ 1,2 @@"),
"expected `#`-prefixed header for `.py` extension; got:\n{content}",
);
}
#[test]
fn include_range_header_uses_double_dash_comment_prefix_for_sql_extension() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing("schema.sql", b"a\nb\nc\nd\n");
let envelope =
book.envelope_with_chapter("```sql\n{{#include listings/schema.sql:1:2}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains("-- schema.sql\n-- @@ 1,2 @@"),
"expected `--`-prefixed header for `.sql` extension; got:\n{content}",
);
}
#[test]
fn include_range_header_omits_comment_prefix_for_unknown_extension() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing("readme.txt", b"a\nb\nc\nd\n");
let envelope =
book.envelope_with_chapter("```text\n{{#include listings/readme.txt:1:2}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains("readme.txt\n@@ 1,2 @@"),
"expected raw header (no prefix) for unknown `.txt` extension; got:\n{content}",
);
assert!(
!content.contains("// readme.txt") && !content.contains("# readme.txt"),
"no comment prefix expected; got:\n{content}",
);
}
}
The data-attribute test pins the locator-anchor contract — the
screenshot tool can address the sliced include via
[data-listing-tag-range="..."] (callout
1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 95,105 @@
#[test]
fn include_with_line_range_emits_range_data_attribute_on_locator_anchor() {
let book = MinimalIncludeLineRangeBook::new();
book.write_listing("ranged.rs", b"a\nb\nc\nd\ne\n");
let envelope =
book.envelope_with_chapter("```rust\n{{#include listings/ranged.rs:2:4}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(content.contains(r#"data-listing-tag="ranged""#));
assert!(content.contains(r#"data-listing-tag-range="2:4""#));
}
}
The callout-composition test verifies that a // CALLOUT: marker
inside the slice window flows through the include splicer, then
the callout splicer, and emerges as a <button id="callout-LABEL">
badge — the line-range form composes with callouts the same way
whole-file includes do (callout
1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 107,131 @@
#[test]
fn include_with_line_range_renders_a_badge_for_a_callout_inside_the_window() {
let book = MinimalIncludeLineRangeBook::new();
let mut body = String::new();
for i in 1..=20 {
if i == 10 {
body.push_str("// CALLOUT: in-slice-callout Demo body for sliced-include callouts.\n");
} else {
body.push_str(&format!("// row {i}\n"));
}
}
book.write_listing("with-callouts.rs", body.as_bytes());
let envelope =
book.envelope_with_chapter("```rust\n{{#include listings/with-callouts.rs:5:15}}\n```\n");
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains(r#"id="callout-in-slice-callout""#),
"expected a badge with id=callout-in-slice-callout from the slice; got:\n{content}",
);
assert!(
!content.contains("CALLOUT: in-slice-callout"),
"the marker line itself should be stripped from the rendered listing; got:\n{content}",
);
}
}
The cross-reference test pins the half of the contract you’ve
been clicking through this slice — chapter prose can
{{#callout LABEL}} to a marker that lives inside a sliced
include, and the directive resolves to the same
id="callout-LABEL" anchor the badge gets (callout
1):
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 133,160 @@
#[test]
fn cross_ref_to_callout_inside_sliced_include_resolves_to_badge_anchor() {
let book = MinimalIncludeLineRangeBook::new();
let mut body = String::new();
for i in 1..=20 {
if i == 10 {
body.push_str("// CALLOUT: refed-from-prose Cross-ref target.\n");
} else {
body.push_str(&format!("// row {i}\n"));
}
}
book.write_listing("with-callouts.rs", body.as_bytes());
let envelope = book.envelope_with_chapter(concat!(
"Cross-ref demo: see callout {{#callout refed-from-prose}}.\n\n",
"```rust\n{{#include listings/with-callouts.rs:5:15}}\n```\n",
));
let content = chapter_content(&run_preprocessor(envelope));
assert!(
content.contains(r##"href="#callout-refed-from-prose""##),
"cross-ref must resolve to the badge anchor; got:\n{content}",
);
assert!(
content.contains(r#"id="callout-refed-from-prose""#),
"badge id must exist as the anchor target; got:\n{content}",
);
}
}
And the harness, completing the file:
#![allow(unused)]
fn main() {
// include-line-ranges-v1.rs
// @@ 162,210 @@
/// the `[ctx, book]` envelope mdbook hands a preprocessor.
struct MinimalIncludeLineRangeBook {
_tmp: TempDir,
root: PathBuf,
}
impl MinimalIncludeLineRangeBook {
fn new() -> Self {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
fs::create_dir_all(root.join("src/listings")).unwrap();
// Empty manifest — the include splicer doesn't consult it; only
// the diff splicer does.
fs::write(root.join("listings.toml"), "version = 1\n").unwrap();
Self { _tmp: tmp, root }
}
fn write_listing(&self, rel: &str, bytes: &[u8]) {
fs::write(self.root.join("src/listings").join(rel), bytes).unwrap();
}
fn envelope_with_chapter(&self, content: &str) -> String {
let ctx =
PreprocessorContext::new(self.root.clone(), Config::default(), "html".to_string());
let chapter = Chapter::new("Include Line Ranges", content.to_string(), "ilr.md", vec![]);
let book = Book::new_with_items(vec![BookItem::Chapter(chapter)]);
serde_json::to_string(&(&ctx, &book)).expect("serialize envelope")
}
}
fn run_preprocessor(envelope: String) -> Book {
let out = mdbook_listings()
.write_stdin(envelope)
.assert()
.success()
.get_output()
.stdout
.clone();
serde_json::from_slice(&out).expect("Book")
}
fn chapter_content(book: &Book) -> String {
for item in &book.items {
if let BookItem::Chapter(c) = item {
return c.content.clone();
}
}
panic!("no chapter in book");
}
}
A future refactor could extract the test-infra refactor’s
{{#diff e2e-callouts-v6 e2e-callouts-v7}} into three smaller
ranged diffs interleaved with prose — one for the imports + first
test, one for the click-through test fragment, and one tying
back to the cross-refs. We’re leaving the existing diff intact
for now since the badge-positioning fix already addressed the
correctness issue; the ergonomics improvement here is for future
authoring rather than retroactive cleanup of the current
materialization.
What this story does not solve
This chapter handles inline callouts (markers embedded in source
code as // CALLOUT: <label> <body> lines) plus prose-side
{{#callout LABEL}} cross-references and the line-range support
that breaks long diffs and includes into reader-friendly
fragments. Two related features sit outside the chapter’s scope
and are sketched in ch.9 (Future Work):
- Out-of-band callouts via sidecar TOML files. Some listings
can’t carry inline markers — third-party code the author
doesn’t own, or block-comment-only languages like CSS where a
single-line
// CALLOUT:form doesn’t apply. A sidecar<tag>.callouts.tomlfile alongside the frozen listing would let an author attach annotations without modifying the source. - PDF inline-badge rendering. In HTML, the marker comment is stripped from the rendered listing and replaced with an inline interactive badge (slice 7). In PDF, the rendering uses a complementary shape: the marker comment stays visible, and bodies render as a styled blockquote below the listing (slice 6). A future iteration could match the HTML inline-badge form in PDF too — the design lives in ch.9.
A retrospective chore — adding callouts via the sidecar form to the listings already frozen by ch.2 (Install), ch.3 (Freeze), and ch.4 (Show Diffs) — also lives in ch.9. It demonstrates how callouts replace inline-comment-style code documentation, but it needs the sidecar form available first.