Dogfooding-Driven Polish
Chapters 2–5 shipped the v0.1.0 primitives (install, freeze, diff, callouts). The first real downstream project to take a dependency on those primitives — the t2t book — surfaced a handful of rendering and ergonomic gaps that the in-house book never exercised hard enough to notice. This chapter collects the resulting polish work, one slice per gap. The verify story (ch.7) is still placeholder; it’ll close the v0.1.0 loop separately.
If we identify dogfood, we eat it. New gaps that surface on later downstream passes get appended as new acceptance criteria and new slices — there is no “out of scope” exit door.
Story
As a downstream book author, I want the v0.1.0 primitives to feel finished when I write real annotated prose against them — not just “the happy path runs to completion,” but “the rendered output is the output I wrote, and the CLI tells me what I need to know to keep going.”
Acceptance criteria
- Inline markdown in callout body text. A callout body that
contains inline markdown (backticks for code spans,
*emphasis*,**strong**,[text](url)) renders as the corresponding inline HTML in the body popover — not as literal punctuation. Block-level markdown (lists, blockquotes, headings) is out of scope: callouts are inline annotations. Raw HTML in a callout body renders as escaped text, not as pass-through HTML. - Bundled assets refresh on every build, not just at install
time. Today
installwritesmdbook-listings.cssandmdbook-listings.jsinto the book source tree as a one-time snapshot, then the bytes drift as the binary version moves forward —additional-css/additional-jskeep referencing the stale on-disk copies until the author manually re-runsinstall. The preprocessor — which already runs on everymdbook build— instead writes the bundled bytes into the book root, refreshing them automatically when the binary is upgraded.installkeeps thebook.tomlregistration job and adds the two asset paths to.gitignoreso downstream books treat them as build artifacts (matchestarget/). Author override works the same way it does for any other mdbook stylesheet: droptheirs.cssinto the book directory and addadditional-css = ["./theirs.css"]tobook.toml. mdbook cascades the secondadditional-cssentry after the first, so author rules win. - Callout popover never covers the line it annotates. The
default opens the popover to the right of the badge (the
un-annotated gutter), an author override switches a specific
callout to the left for narrow viewports, and a transparent /
backdrop-filter: blurfallback keeps the underlying code legible when overlap is unavoidable. freezeoutput closes the authoring loop. Every successfulfreezeprints the frozen path AND the ready-to-paste{{#include listings/<tag>.<ext>}}directive — the author shouldn’t have to greplistings.tomlto find the include path.- A
list(orstatus) subcommand printstag → frozen path → sourcerows so authors can browse the manifest as a book accumulates listings. installis idempotent. Re-runninginstallon an already-configured book is a no-op with a friendly “already installed” message; never duplicates registrations.freezederives a default tag when--tagis omitted. The default<basename>-v<next>removes the “invent your own scheme” tax on every first-time author. Already on the v0.2.0 ROADMAP; downstream surfaced it as a real pain point, so it lives here.
The slice — outside-in narrative outline
| Slice | What it adds |
|---|---|
| 1 | Inline markdown in callout body text (AC 1). Downstream dogfooding noticed that backticks around an identifier in a callout body rendered as literal backtick characters rather than a <code> span. The fix routes the body through pulldown-cmark’s inline parser before wrapping it in the <div class="callout-body">, strips the synthetic <p> wrapper, and re-applies the { → { escape for cross-ref-scanner safety. Raw HTML events are remapped to text events so a body containing <script> still renders as <script>, not as pass-through HTML. |
| 2 | Preprocessor refreshes assets on every build (AC 2). Today install writes mdbook-listings.css and mdbook-listings.js into the book source tree as a one-time snapshot, then the bytes drift as the binary version moves forward — t2t Pass 3 hit this: bumping the locally-installed binary forward without re-running install left the rendered book mixing new HTML emission with stale CSS, producing subtle (and sometimes loud) breakage. The slice moves the asset write from install to the preprocessor’s run hook so the bytes refresh on every build (no-op when bytes already match). install keeps the book.toml registration job and now also adds the two asset paths to .gitignore so downstream books treat them as build artifacts. Migration for existing books: re-run install, then git rm --cached the two old committed copies. |
| 3 | Open the popover to the right by default (AC 3, fix 1 of 3). CSS-only positioning change on the <div class="callout-body"> so the natural reading direction (left-to-right) drops the popover into the un-annotated gutter rather than over the line it annotates. |
| 4 | Per-callout --align override (AC 3, fix 2 of 3). Tiny extension to the // CALLOUT: <label> grammar — // CALLOUT: <label> --align=left <body> flips a single callout when the right-side gutter isn’t usable (sidebar, narrow viewport, badge near the page edge). The extension is shaped to scale to other per-callout options later (width, theme). |
| 5 | Transparent / backdrop-filter: blur fallback (AC 3, fix 3 of 3). Pure CSS. When the popover must cover the listing (narrow viewport, author override, very long body), a translucent background + backdrop blur keeps the underlying code legible behind it. |
| 6 | freeze output closes the loop (AC 4). Augments the created: <tag> line with the frozen path and the exact {{#include listings/<tag>.<ext>}} directive to copy-paste into the chapter. |
| 7 | mdbook-listings list subcommand (AC 5). Prints one row per [[listing]] in listings.toml: tag, frozen path, source path. No filtering options yet — just the basic catalogue view. |
| 8 | install idempotency (AC 6). After slice 2 the only things install writes are book.toml registrations and the .gitignore entries. The first run continues to register the preprocessor + additional-css/additional-js and to add the asset paths to .gitignore. A second run detects everything already present and prints “already installed” with no writes. |
| 9 | Default tag derivation (AC 7). When --tag is omitted, derive <basename>-v<next> by reading existing [[listing]] entries for the same source path and bumping the highest vN suffix. Surfaces a clean error if any existing tag for the same source doesn’t match the <basename>-vN shape (the heuristic is opinionated; an author who’s invented their own scheme keeps using --tag explicitly). |
Outside-in narrative
Sections appear here as slices ship. Slices 1 and 2 have shipped; slices 3–9 are sketched in the table above.
Slice 1 — inline markdown in callout body text
The symptom: a callout body whose author reached for inline
backticks — say, to call out a name like PORT — rendered to the
popover with the literal backtick characters intact instead of a
<code> span around the name. Annotated technical prose leans on
inline-code formatting to distinguish identifiers from prose; a
callout body that can’t render inline code reads worse than the
surrounding chapter, which defeats the whole point of attaching
context to a specific line.
The diff is between the two frozen snapshots of src/callout.rs
that bracket this slice — callout-v6 (the last freeze, made when
ch.5 wrapped) and callout-v7 (frozen as part of this slice). It’s
the full file diff: there’s no freeze between them. Two earlier
commits modified callout.rs without refreezing, so their changes
show up here too: the e2e-harness refactor rescoped the
splice_chapter_html_escapes_label_and_body test assertion, and
ch.5’s slice 9 added the in_inline_backticks check near the top
of replace_callout_refs plus the // CALLOUT: html-escape
comment and .replace('{', "{") line on html_escape. This
slice’s contribution is the call-site swap (line 640 of v7), the
new render_inline_markdown function just below html_escape,
and the unit tests at the bottom.
--- callout-v6
+++ callout-v7
@@ -397,11 +397,25 @@
.any(|&(start, end)| pos >= start && pos < end)
};
+ let bytes = content.as_bytes();
+ // Same shape as the diff/include parsers: count single backticks on
+ // the line BEFORE the directive's opening offset; an odd count means
+ // the directive sits between `…` markers (inline code span) and is a
+ // documentation example, not a real cross-ref.
+ let in_inline_backticks = |pos: usize| {
+ let line_start = content[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
+ bytes[line_start..pos]
+ .iter()
+ .filter(|&&b| b == b'`')
+ .count()
+ % 2
+ == 1
+ };
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) {
+ if in_fence(open_at) || in_inline_backticks(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();
@@ -623,7 +637,7 @@
if let Some(body) = &c.body {
s.push_str(&format!(
" <div class=\"callout-body\"{body_id_attr} role=\"tooltip\">{}</div>\n",
- html_escape(body),
+ render_inline_markdown(body),
));
}
s.push_str(" </div>\n");
@@ -652,11 +666,34 @@
s
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
+ .replace('{', "{")
+}
+
+// Render `body` as inline markdown (backticks → <code>, *em*, **strong**,
+// [text](url)) for emission into the callout overlay popover.
+fn render_inline_markdown(body: &str) -> String {
+ use pulldown_cmark::{Event, Parser, html};
+ let parser = Parser::new(body).map(|event| match event {
+ Event::Html(s) | Event::InlineHtml(s) => Event::Text(s),
+ other => other,
+ });
+ let mut rendered = String::new();
+ html::push_html(&mut rendered, parser);
+ let trimmed = rendered.trim_end_matches('\n');
+ let stripped = trimmed
+ .strip_prefix("<p>")
+ .and_then(|s| s.strip_suffix("</p>"))
+ .unwrap_or(trimmed);
+ stripped.replace('{', "{")
}
#[cfg(test)]
@@ -1090,20 +1127,142 @@
}
#[test]
+ fn replace_callout_refs_skips_directives_inside_inline_backticks_in_prose() {
+ // A chapter that documents the cross-ref syntax in prose like
+ // "use `{{#callout LABEL}}` to ..." must not have the example
+ // text resolve as a real cross-ref — the inline backticks mark
+ // it as a documentation example, mirroring how the diff parser
+ // skips directives between `…` on the same line.
+ let content =
+ "```rust\n// CALLOUT: greeting Hello.\n```\n\nUse `{{#callout LABEL}}` to refer.\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ assert!(
+ out.contains("`{{#callout LABEL}}`"),
+ "literal example syntax in inline backticks must survive verbatim; got:\n{out}",
+ );
+ }
+
+ #[test]
+ fn splice_chapter_html_escapes_curly_braces_in_body_to_protect_cross_ref_scanner() {
+ // A callout body that documents the `{{#callout LABEL}}` syntax
+ // would, post-overlay-emit, land OUTSIDE its fenced code block
+ // — the overlay div is a sibling of the pre. Without escaping,
+ // the cross-ref scanner downstream sees the literal directive
+ // text and tries to resolve `LABEL`, failing the build.
+ let content =
+ "```rust\n// CALLOUT: lbl Authors write `{{#callout LABEL}}` to cross-ref.\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = out
+ .split("<div class=\"callout-body\"")
+ .nth(1)
+ .unwrap_or("")
+ .split("</div>")
+ .next()
+ .unwrap_or("");
+ assert!(
+ body.contains("{{#callout LABEL"),
+ "expected `{{` escaped to `{` so the cross-ref scanner can't see it; got body:\n{body}",
+ );
+ assert!(
+ !body.contains("{{#callout LABEL"),
+ "raw `{{#callout LABEL}}` must not survive into the overlay body; got body:\n{body}",
+ );
+ }
+
+ #[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, SupportedRenderer::Html).expect("splice");
- let overlay = out
- .split("<div class=\"callout-overlay\"")
+ // Scope the check to the rendered callout-body div, since the
+ // overlay is now followed by a measurement <script> emitted by
+ // the splicer itself (not user content).
+ let body = out
+ .split("<div class=\"callout-body\"")
.nth(1)
+ .unwrap_or("")
+ .split("</div>")
+ .next()
.unwrap_or("");
assert!(
- overlay.contains("<script>"),
- "overlay body should escape <script>; got:\n{overlay}",
+ body.contains("<script>"),
+ "callout body must escape user-supplied <script>; got:\n{body}",
+ );
+ assert!(
+ !body.contains("<script>"),
+ "callout body must not contain raw <script>; got:\n{body}",
+ );
+ }
+
+ fn extract_callout_body(out: &str) -> &str {
+ out.split("<div class=\"callout-body\"")
+ .nth(1)
+ .unwrap_or("")
+ .split("</div>")
+ .next()
+ .unwrap_or("")
+ }
+
+ #[test]
+ fn callout_body_renders_inline_backticks_as_code_spans() {
+ let content =
+ "```rust\n// CALLOUT: lbl Read the `PORT` env var, fall back to `3000`.\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = extract_callout_body(&out);
+ assert!(
+ body.contains("<code>PORT</code>") && body.contains("<code>3000</code>"),
+ "expected backticks rendered as <code> spans; got body:\n{body}",
);
+ }
+
+ #[test]
+ fn callout_body_renders_strong_and_emphasis() {
+ let content = "```rust\n// CALLOUT: lbl A **bold** and *italic* note.\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = extract_callout_body(&out);
assert!(
- !overlay.contains("<script>"),
- "overlay body must not contain raw <script>; got:\n{overlay}",
+ body.contains("<strong>bold</strong>") && body.contains("<em>italic</em>"),
+ "expected **/* rendered as <strong>/<em>; got body:\n{body}",
+ );
+ }
+
+ #[test]
+ fn callout_body_renders_inline_link() {
+ let content = "```rust\n// CALLOUT: lbl See [docs](https://example.com/).\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = extract_callout_body(&out);
+ assert!(
+ body.contains("<a href=\"https://example.com/\">docs</a>"),
+ "expected [text](url) rendered as anchor; got body:\n{body}",
+ );
+ }
+
+ #[test]
+ fn callout_body_curly_brace_escape_survives_inside_code_span() {
+ // Authors documenting the `{{#callout LABEL}}` directive will
+ // wrap it in backticks for clarity. The inline-markdown render
+ // must produce <code>...</code>, AND the `{` escape must still
+ // apply inside that code span so the cross-ref scanner downstream
+ // (which searches for `{{...}}`) doesn't see a real directive.
+ // Only `{` needs escaping — breaking the opening `{{` is
+ // sufficient; trailing `}}` survives, matching pre-markdown behaviour.
+ let content =
+ "```rust\n// CALLOUT: lbl Authors write `{{#callout LABEL}}` to cross-ref.\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = extract_callout_body(&out);
+ assert!(
+ body.contains("<code>{{#callout LABEL}}</code>"),
+ "expected `{{` escaped inside <code> (and `}}` left as-is, matching old behaviour); got body:\n{body}",
+ );
+ }
+
+ #[test]
+ fn callout_body_plain_text_passes_through_unchanged() {
+ let content = "```rust\n// CALLOUT: lbl Just a plain sentence with no markup.\n```\n";
+ let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+ let body = extract_callout_body(&out);
+ assert!(
+ body.contains("role=\"tooltip\">Just a plain sentence with no markup."),
+ "plain body must follow the opening tag directly (no <p> wrapper); got body:\n{body}",
);
}
Three details inside render_inline_markdown earn their own
callout: 2 guards against
untrusted HTML in source comments; 3
explains the <p> strip and what happens if an author reaches for
block markdown anyway; 4 preserves
the cross-ref-scanner safety property the original html_escape
provided.
The PDF path needs no change. render_callout_list_pdf interpolates
the body into a markdown blockquote that typst-pdf re-parses, so
markdown in the body has always rendered correctly in print — the
gap was HTML-only.
Tests added in this slice:
callout_body_renders_inline_backticks_as_code_spans— backticks →<code>.callout_body_renders_strong_and_emphasis—**bold**and*italic*→<strong>and<em>.callout_body_renders_inline_link—[docs](https://example.com/)→<a href>.callout_body_curly_brace_escape_survives_inside_code_span— the cross-ref-scanner safety property holds inside a<code>span.callout_body_plain_text_passes_through_unchanged— the synthetic<p>wrapper is stripped on plain bodies.- The pre-existing
splice_chapter_html_escapes_label_and_bodyguards the raw-HTML neutralisation (it asserts<script>→<script>).
A new e2e assertion in tests/e2e_callouts.rs —
callout_body_renders_inline_backticks_as_code_spans — closes the
loop end-to-end: it hovers the snippets-intercept badge in the
rendered ch.5 HTML and asserts that the popover contains a <code>
element with the expected text.
The diff between e2e-callouts-v8 (last freeze, made when ch.5
wrapped) and e2e-callouts-v9 (frozen as part of this slice) shows
the new test plus a couple of mechanical changes that came with
this commit’s chapter renumbering — CH04 was renamed to CH05
and its value bumped to "ch05-render-inline-callouts". Ch.5
slice 9 also modified this file without refreezing (the
callout_inside_a_sliced_include_renders_with_resolvable_cross_ref
and cross_ref_badges_in_prose_render_with_full_opacity_not_subdued
tests), so those appear in the diff too.
--- e2e-callouts-v8
+++ e2e-callouts-v9
@@ -313,6 +313,73 @@
}
#[tokio::test]
+async fn cross_ref_badges_in_prose_render_with_full_opacity_not_subdued() {
+ // Regression guard: a bare-anchor listing badge (label-only marker
+ // with no body popover) is styled muted/dashed via
+ // `.callout-entry .callout-badge:only-child`. Pre-fix that rule was
+ // unscoped (`.callout-badge:only-child`) and matched every cross-ref
+ // <a> in chapter prose — they're typically the only ELEMENT child
+ // of their <p> parent (text nodes don't count for :only-child), so
+ // every inline cross-ref ended up muted/dashed. The scoping fix
+ // requires the badge to live inside a `.callout-entry` overlay
+ // before muting kicks in.
+ with_traced_chapter(
+ "cross_ref_badges_in_prose_render_with_full_opacity_not_subdued",
+ CH05,
+ |page| async move {
+ let opacity: String = page
+ .evaluate_value(
+ r#"(() => {
+ const a = document.querySelector('a.callout-badge.callout-ref');
+ if (!a) return 'no-cross-ref-found';
+ return getComputedStyle(a).opacity;
+ })()"#,
+ )
+ .await
+ .expect("read computed opacity");
+ assert_eq!(
+ opacity, "1",
+ "cross-ref badge in prose should have full opacity; got `{opacity}` \
+ (subdued styling means the .callout-entry scope on `:only-child` regressed)",
+ );
+ },
+ )
+ .await;
+}
+
+#[tokio::test]
+async fn callout_inside_a_sliced_include_renders_with_resolvable_cross_ref() {
+ // Slice 9 demo: the chapter slices `include-line-ranges-v1.rs:73:96`
+ // and the slice carries a `// CALLOUT: include-range-cross-ref-resolves`
+ // marker. Verify the full pipeline end-to-end: the badge button has
+ // the expected id, and the prose-side `{{#callout ...}}` cross-ref
+ // resolves to that id.
+ with_traced_chapter(
+ "callout_inside_a_sliced_include_renders_with_resolvable_cross_ref",
+ CH05,
+ |page| async move {
+ let badge = page
+ .locator(locator!("button#callout-include-range-cross-ref-resolves"))
+ .await;
+ expect(badge)
+ .to_have_count(1)
+ .await
+ .expect("badge for callout inside sliced include must exist");
+ let cross_ref = page
+ .locator(locator!(
+ r#"a[data-callout-ref="include-range-cross-ref-resolves"]"#
+ ))
+ .await;
+ expect(cross_ref)
+ .to_have_attribute("href", "#callout-include-range-cross-ref-resolves")
+ .await
+ .expect("cross-ref href must point at the badge anchor");
+ },
+ )
+ .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
@@ -365,3 +432,38 @@
)
.await;
}
+
+#[tokio::test]
+async fn callout_body_renders_inline_backticks_as_code_spans() {
+ // ch.6 slice 1: a callout body that contains inline backticks must
+ // render the wrapped span as <code>, not as literal punctuation.
+ // The `snippets-intercept` callout in listings/include-v1.rs has
+ // four backtick spans (`listings/`, `snippets/`, `CALLOUT:`,
+ // `links`); asserting one <code> with the right text is enough to
+ // confirm the inline-markdown render path is wired up end-to-end.
+ // The body popover starts hidden — `to_have_text` uses innerText,
+ // which respects visibility, so we hover the badge first.
+ with_traced_chapter(
+ "callout_body_renders_inline_backticks_as_code_spans",
+ CH05,
+ |page| async move {
+ let badge = page
+ .locator(locator!("button#callout-snippets-intercept"))
+ .await;
+ badge.hover(None).await.expect("hover badge to reveal body");
+ let body = page
+ .locator(locator!("#callout-body-snippets-intercept"))
+ .await;
+ expect(body.clone())
+ .to_be_visible()
+ .await
+ .expect("body popover must be visible after hover");
+ let code = body.locator("code").first();
+ expect(code)
+ .to_have_text("listings/")
+ .await
+ .expect("first <code> in body must be the rendered `listings/` backtick span");
+ },
+ )
+ .await;
+}
Slice 2 — preprocessor refreshes assets on every build
The symptom: a downstream book installs mdbook-listings once, runs
install to drop the bundled CSS/JS into the book directory, and
ships fine. Some weeks later the author bumps the binary forward via
cargo install --force to pick up a fix. The next mdbook build
renders the chapter against the new HTML emission paired with the
old on-disk CSS/JS — silent visual breakage until the author
remembers to also re-run install. This is exactly what t2t
Pass 3 hit after we shipped slice 1’s hljs-fade CSS fix.
The fix moves the asset write from “one-time at install” to “every
build, idempotent.” Two reusable helpers land in src/install.rs:
ensure_assets_fresh(book_root)reads each asset path and compares to the binary’s bundled bytes; only writes when they differ. Returnstrueiff anything was written.ensure_gitignore(book_root)appends the two asset filenames to<book>/.gitignore(creating the file if missing); skips entries that are already present. Returnstrueiff the file was written.
install() is refactored to use both helpers — keeping its existing
idempotency contract while now also seeding .gitignore. The
preprocessor’s preprocess() calls only ensure_assets_fresh (the
gitignore is one-time setup, not per-build).
--- install-v8
+++ install-v9
@@ -9,14 +9,17 @@
/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");
+pub const JS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.js");
/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
-pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";
+pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v3";
+pub const JS_ASSET_SENTINEL: &str = "mdbook-listings-js-v1";
-/// Shared between [`write_css_asset`] and
-/// [`BookConfig::register_listings_css`] so the two can't drift.
+/// Shared between the writer and the registrar so the two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";
+pub const JS_ASSET_FILENAME: &str = "mdbook-listings.js";
+pub const GITIGNORE_FILENAME: &str = ".gitignore";
/// Always overwrites — install ships the bundled bytes, not whatever a
/// stale on-disk copy happens to contain.
@@ -25,6 +28,63 @@
fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}
+pub fn write_js_asset(book_root: &Path) -> Result<()> {
+ let path = book_root.join(JS_ASSET_FILENAME);
+ fs::write(&path, JS_ASSET).with_context(|| format!("writing JS asset to {}", path.display()))
+}
+
+/// Idempotent: writes the bundled CSS/JS to the book root only when the
+/// on-disk bytes differ from the binary's embedded asset. Called by both
+/// `install` (one-time setup) and the preprocessor (every build), so a
+/// downstream book always renders against assets matching the binary
+/// version — no manual reinstall required after `cargo install --force`.
+/// Returns `true` iff anything was written.
+pub fn ensure_assets_fresh(book_root: &Path) -> Result<bool> {
+ let css_path = book_root.join(CSS_ASSET_FILENAME);
+ let css_already_correct = fs::read(&css_path)
+ .ok()
+ .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);
+ if !css_already_correct {
+ write_css_asset(book_root)?;
+ }
+ let js_path = book_root.join(JS_ASSET_FILENAME);
+ let js_already_correct = fs::read(&js_path)
+ .ok()
+ .is_some_and(|bytes| bytes.as_slice() == JS_ASSET);
+ if !js_already_correct {
+ write_js_asset(book_root)?;
+ }
+ Ok(!css_already_correct || !js_already_correct)
+}
+
+/// Idempotent: ensures both asset filenames are present as whole-line
+/// entries in the book's `.gitignore`. Creates the file if missing.
+/// Existing entries are left untouched; missing ones are appended.
+/// Returns `true` iff `.gitignore` was written.
+pub fn ensure_gitignore(book_root: &Path) -> Result<bool> {
+ let path = book_root.join(GITIGNORE_FILENAME);
+ let original = fs::read_to_string(&path).unwrap_or_default();
+ let needed = [CSS_ASSET_FILENAME, JS_ASSET_FILENAME];
+ let missing: Vec<&str> = needed
+ .iter()
+ .copied()
+ .filter(|entry| !original.lines().any(|l| l.trim() == *entry))
+ .collect();
+ if missing.is_empty() {
+ return Ok(false);
+ }
+ let mut new_contents = original.clone();
+ if !new_contents.is_empty() && !new_contents.ends_with('\n') {
+ new_contents.push('\n');
+ }
+ for entry in missing {
+ new_contents.push_str(entry);
+ new_contents.push('\n');
+ }
+ fs::write(&path, new_contents).with_context(|| format!("writing {}", path.display()))?;
+ Ok(true)
+}
+
/// Idempotent: book.toml and the CSS asset on disk are only rewritten if
/// they differ from what install would produce.
pub fn install(book_root: &Path) -> Result<InstallOutcome> {
@@ -45,23 +105,18 @@
let mut config = BookConfig::parse(&original)?;
config.register_listings_preprocessor();
config.register_listings_css();
+ config.register_listings_js();
let new = config.render();
- let css_path = book_root.join(CSS_ASSET_FILENAME);
- let css_already_correct = fs::read(&css_path)
- .ok()
- .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);
-
let toml_changed = new != original;
if toml_changed {
fs::write(&book_toml_path, new)
.with_context(|| format!("writing book config at {}", book_toml_path.display()))?;
- }
- if !css_already_correct {
- write_css_asset(book_root)?;
}
+ let assets_written = ensure_assets_fresh(book_root)?;
+ let gitignore_changed = ensure_gitignore(book_root)?;
- Ok(if toml_changed || !css_already_correct {
+ Ok(if toml_changed || assets_written || gitignore_changed {
InstallOutcome::Installed
} else {
InstallOutcome::Unchanged
@@ -93,36 +148,49 @@
}
/// Idempotent: a second call on an already-registered config is a no-op
- /// in the rendered output. If `[preprocessor.admonish]` is registered,
- /// the listings entry gets `before = ["admonish"]` so the
- /// callout → admonish-note pipeline produces correctly styled PDF
+ /// in the rendered output. The listings entry always declares
+ /// `before = ["links"]` so the include splicer sees raw
+ /// `{{#include listings/...}}` directives before mdbook's built-in
+ /// `links` preprocessor expands them. If `[preprocessor.admonish]` is
+ /// also registered, `"admonish"` is added to the same `before` list so
+ /// the callout → admonish-note pipeline produces correctly styled PDF
/// output.
pub fn register_listings_preprocessor(&mut self) {
let preprocessor = subtable_mut(self.0.as_table_mut(), "preprocessor");
let admonish_present = preprocessor.contains_key("admonish");
let listings = subtable_mut(preprocessor, "listings");
listings["command"] = toml_edit::value("mdbook-listings");
+ let mut before = Array::new();
if admonish_present {
- let mut before = Array::new();
before.push("admonish");
- listings["before"] = toml_edit::value(before);
}
+ before.push("links");
+ listings["before"] = toml_edit::value(before);
}
/// Idempotent: duplicate entries are not appended.
pub fn register_listings_css(&mut self) {
- let entry = format!("./{CSS_ASSET_FILENAME}");
- let html = subtable_mut(subtable_mut(self.0.as_table_mut(), "output"), "html");
- let array = html
- .entry("additional-css")
- .or_insert_with(|| Item::Value(Value::Array(Array::new())))
- .as_value_mut()
- .expect("additional-css must be a value")
- .as_array_mut()
- .expect("additional-css must be an array");
- if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
- array.push(entry);
- }
+ register_html_asset(self.0.as_table_mut(), "additional-css", CSS_ASSET_FILENAME);
+ }
+
+ /// Idempotent: duplicate entries are not appended.
+ pub fn register_listings_js(&mut self) {
+ register_html_asset(self.0.as_table_mut(), "additional-js", JS_ASSET_FILENAME);
+ }
+}
+
+fn register_html_asset(root: &mut Table, key: &'static str, filename: &str) {
+ let entry = format!("./{filename}");
+ let html = subtable_mut(subtable_mut(root, "output"), "html");
+ let array = html
+ .entry(key)
+ .or_insert_with(|| Item::Value(Value::Array(Array::new())))
+ .as_value_mut()
+ .unwrap_or_else(|| panic!("{key} must be a value"))
+ .as_array_mut()
+ .unwrap_or_else(|| panic!("{key} must be an array"));
+ if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
+ array.push(entry);
}
}
@@ -156,6 +224,20 @@
}
#[test]
+ fn js_asset_is_non_empty() {
+ assert!(!JS_ASSET.is_empty(), "bundled JS asset must not be empty");
+ }
+
+ #[test]
+ fn js_asset_contains_sentinel() {
+ let contents = std::str::from_utf8(JS_ASSET).expect("JS asset must be UTF-8");
+ assert!(
+ contents.contains(JS_ASSET_SENTINEL),
+ "bundled JS asset must contain sentinel `{JS_ASSET_SENTINEL}`; got:\n{contents}",
+ );
+ }
+
+ #[test]
fn book_config_round_trip_preserves_comments_and_ordering() {
let input = "\
# top comment
@@ -227,14 +309,41 @@
}
#[test]
- fn book_config_register_listings_preprocessor_orders_before_admonish_when_present() {
+ fn book_config_register_listings_js_adds_entry() {
+ let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
+ cfg.register_listings_js();
+ let rendered = cfg.render();
+ assert!(
+ rendered.contains(r#"additional-js = ["./mdbook-listings.js"]"#),
+ "rendered config should reference the JS asset; got:\n{rendered}",
+ );
+ }
+
+ #[test]
+ fn book_config_register_listings_js_is_idempotent() {
+ let input = "[book]\ntitle = \"Test\"\n";
+ let mut cfg = BookConfig::parse(input).unwrap();
+ cfg.register_listings_js();
+ let after_first = cfg.render();
+ let mut cfg2 = BookConfig::parse(&after_first).unwrap();
+ cfg2.register_listings_js();
+ let after_second = cfg2.render();
+ assert_eq!(
+ after_first, after_second,
+ "register_listings_js must be idempotent"
+ );
+ }
+
+ #[test]
+ fn book_config_register_listings_preprocessor_orders_before_admonish_and_links_when_admonish_present()
+ {
let input = "[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n";
let mut cfg = BookConfig::parse(input).unwrap();
cfg.register_listings_preprocessor();
let rendered = cfg.render();
assert!(
- rendered.contains(r#"before = ["admonish"]"#),
- "listings should declare before = [\"admonish\"]; got:\n{rendered}",
+ rendered.contains(r#"before = ["admonish", "links"]"#),
+ "listings should declare before = [\"admonish\", \"links\"]; got:\n{rendered}",
);
assert!(
rendered.contains("[preprocessor.admonish]"),
@@ -243,13 +352,16 @@
}
#[test]
- fn book_config_register_listings_preprocessor_skips_before_when_admonish_absent() {
+ fn book_config_register_listings_preprocessor_orders_before_links_when_admonish_absent() {
+ // The include splicer requires `before = ["links"]` so it sees raw
+ // `{{#include listings/...}}` before mdbook's built-in `links`
+ // expands them. Without this, the splicer silently no-ops.
let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
cfg.register_listings_preprocessor();
let rendered = cfg.render();
assert!(
- !rendered.contains("before"),
- "listings should not declare a before field when admonish is absent; got:\n{rendered}",
+ rendered.contains(r#"before = ["links"]"#),
+ "listings should declare before = [\"links\"] when admonish is absent; got:\n{rendered}",
);
}
The new helpers carry a single // CALLOUT: marker each — the
detail that earns the WHY comment is the {{#callout
assets-on-build}} note, which lives in main.rs next to the
preprocessor call:
--- main-v9
+++ main-v10
@@ -7,7 +7,7 @@
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::install::{InstallOutcome, ensure_assets_fresh, install};
use mdbook_listings::manifest::Manifest;
use mdbook_preprocessor::book::BookItem;
@@ -127,6 +127,8 @@
/// payload on stdout.
fn preprocess() -> Result<()> {
let (ctx, mut book) = mdbook_preprocessor::parse_input(std::io::stdin())?;
+ ensure_assets_fresh(&ctx.root).context("refreshing bundled CSS/JS assets")?;
let manifest = Manifest::load(&ctx.root)?;
let src_dir = ctx.root.join(&ctx.config.book.src);
let renderer = SupportedRenderer::from_renderer_name(&ctx.renderer)
Tests added in this slice (all in tests/install.rs):
install_writes_gitignore_entries_for_both_assets— end-to-end install run produces a.gitignorelisting both assets.ensure_assets_fresh_writes_when_missing— the bundled bytes land on first call.ensure_assets_fresh_is_noop_when_bytes_match— preprocessor calls this on every build; mtime churn would force unnecessary rebuilds.ensure_assets_fresh_overwrites_stale_bytes— proves the t2t Pass 3 fix: stale on-disk copies are refreshed automatically.ensure_gitignore_creates_file_when_missing— bare-tempdir case.ensure_gitignore_appends_only_missing_entries— preserves existing author entries; never duplicates.ensure_gitignore_is_noop_when_complete— required for AC 6 idempotency (the future slice that adds the “already installed” message depends on this).
--- install-tests-v4
+++ install-tests-v5
@@ -3,6 +3,10 @@
use std::fs;
use std::path::{Path, PathBuf};
+use mdbook_listings::install::{
+ CSS_ASSET, CSS_ASSET_FILENAME, JS_ASSET, JS_ASSET_FILENAME, ensure_assets_fresh,
+ ensure_gitignore,
+};
use predicates::str::contains;
use tempfile::TempDir;
@@ -84,8 +88,8 @@
let book_toml = fs::read_to_string(book_root.join("book.toml")).unwrap();
assert!(
- book_toml.contains(r#"before = ["admonish"]"#),
- "listings should be ordered before admonish; got:\n{book_toml}",
+ book_toml.contains(r#"before = ["admonish", "links"]"#),
+ "listings should be ordered before both admonish and links; got:\n{book_toml}",
);
assert!(
book_toml.contains("[preprocessor.listings]"),
@@ -110,3 +114,148 @@
.failure()
.stderr(contains("book.toml not found"));
}
+
+/// `install` writes both asset paths into `.gitignore` (creating the file
+/// if missing) so downstream books treat them as build artifacts (ch.6
+/// slice 2 / AC 2).
+#[test]
+fn install_writes_gitignore_entries_for_both_assets() {
+ let book = MinimalFixtureBook::new();
+
+ mdbook_listings()
+ .args(["install", "--book-root"])
+ .arg(book.root())
+ .assert()
+ .success();
+
+ let gitignore = fs::read_to_string(book.root().join(".gitignore")).expect(".gitignore");
+ assert!(
+ gitignore.lines().any(|l| l.trim() == CSS_ASSET_FILENAME),
+ "`.gitignore` should list the CSS asset; got:\n{gitignore}",
+ );
+ assert!(
+ gitignore.lines().any(|l| l.trim() == JS_ASSET_FILENAME),
+ "`.gitignore` should list the JS asset; got:\n{gitignore}",
+ );
+}
+
+/// `ensure_assets_fresh` writes the bundled bytes when the on-disk copies
+/// are missing, returning `true` (something was written).
+#[test]
+fn ensure_assets_fresh_writes_when_missing() {
+ let tmp = TempDir::new().expect("tempdir");
+
+ let wrote = ensure_assets_fresh(tmp.path()).expect("ensure_assets_fresh");
+
+ assert!(wrote, "should report a write when assets were missing");
+ assert_eq!(
+ fs::read(tmp.path().join(CSS_ASSET_FILENAME)).expect("css written"),
+ CSS_ASSET,
+ );
+ assert_eq!(
+ fs::read(tmp.path().join(JS_ASSET_FILENAME)).expect("js written"),
+ JS_ASSET,
+ );
+}
+
+/// `ensure_assets_fresh` is a no-op when both files already match the
+/// bundled bytes — the preprocessor calls this on every build, so it must
+/// not churn mtimes when nothing has changed.
+#[test]
+fn ensure_assets_fresh_is_noop_when_bytes_match() {
+ let tmp = TempDir::new().expect("tempdir");
+ fs::write(tmp.path().join(CSS_ASSET_FILENAME), CSS_ASSET).unwrap();
+ fs::write(tmp.path().join(JS_ASSET_FILENAME), JS_ASSET).unwrap();
+
+ let wrote = ensure_assets_fresh(tmp.path()).expect("ensure_assets_fresh");
+
+ assert!(!wrote, "should report no-op when bytes already match");
+}
+
+/// `ensure_assets_fresh` overwrites stale on-disk bytes — this is what
+/// keeps the rendered HTML in sync with the upgraded binary even when an
+/// author skips re-running `install`.
+#[test]
+fn ensure_assets_fresh_overwrites_stale_bytes() {
+ let tmp = TempDir::new().expect("tempdir");
+ fs::write(tmp.path().join(CSS_ASSET_FILENAME), b"/* stale */").unwrap();
+ fs::write(tmp.path().join(JS_ASSET_FILENAME), b"// stale\n").unwrap();
+
+ let wrote = ensure_assets_fresh(tmp.path()).expect("ensure_assets_fresh");
+
+ assert!(wrote, "should report a write when bytes were stale");
+ assert_eq!(
+ fs::read(tmp.path().join(CSS_ASSET_FILENAME)).expect("css refreshed"),
+ CSS_ASSET,
+ );
+ assert_eq!(
+ fs::read(tmp.path().join(JS_ASSET_FILENAME)).expect("js refreshed"),
+ JS_ASSET,
+ );
+}
+
+/// `ensure_gitignore` creates `.gitignore` with both entries when no file
+/// exists.
+#[test]
+fn ensure_gitignore_creates_file_when_missing() {
+ let tmp = TempDir::new().expect("tempdir");
+
+ let wrote = ensure_gitignore(tmp.path()).expect("ensure_gitignore");
+
+ assert!(wrote, "should report a write when .gitignore was missing");
+ let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).expect(".gitignore");
+ assert!(gitignore.lines().any(|l| l.trim() == CSS_ASSET_FILENAME));
+ assert!(gitignore.lines().any(|l| l.trim() == JS_ASSET_FILENAME));
+}
+
+/// `ensure_gitignore` appends only the missing entry, leaving any existing
+/// author entries (and the entry that's already there) untouched.
+#[test]
+fn ensure_gitignore_appends_only_missing_entries() {
+ let tmp = TempDir::new().expect("tempdir");
+ let existing = "build/\nmdbook-listings.css\n";
+ fs::write(tmp.path().join(".gitignore"), existing).unwrap();
+
+ let wrote = ensure_gitignore(tmp.path()).expect("ensure_gitignore");
+
+ assert!(
+ wrote,
+ "JS entry was missing, so .gitignore should be written"
+ );
+ let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).expect(".gitignore");
+ assert!(
+ gitignore.contains("build/\n"),
+ "existing author entries must survive; got:\n{gitignore}",
+ );
+ assert_eq!(
+ gitignore
+ .lines()
+ .filter(|l| l.trim() == CSS_ASSET_FILENAME)
+ .count(),
+ 1,
+ "CSS entry must not be duplicated; got:\n{gitignore}",
+ );
+ assert!(
+ gitignore.lines().any(|l| l.trim() == JS_ASSET_FILENAME),
+ "JS entry must be appended; got:\n{gitignore}",
+ );
+}
+
+/// `ensure_gitignore` is a no-op when both entries are already present —
+/// matters because re-running `install` on a configured book must not
+/// churn the file (AC 6 idempotency).
+#[test]
+fn ensure_gitignore_is_noop_when_complete() {
+ let tmp = TempDir::new().expect("tempdir");
+ let existing = format!("target/\n{CSS_ASSET_FILENAME}\n{JS_ASSET_FILENAME}\n");
+ fs::write(tmp.path().join(".gitignore"), &existing).unwrap();
+
+ let wrote = ensure_gitignore(tmp.path()).expect("ensure_gitignore");
+
+ assert!(
+ !wrote,
+ "should report no-op when both entries already present"
+ );
+ let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).expect(".gitignore");
+ assert_eq!(gitignore, existing, ".gitignore must be byte-identical");
+}
Migration for an existing book (this book did exactly this in the slice-2 commit):
- Re-run
mdbook-listings install --book-root <book>— writes.gitignoreand refreshes the asset bytes. git rm --cached <book>/mdbook-listings.css <book>/mdbook-listings.jsto untrack the old committed copies.mdbook buildregenerates the assets via the preprocessor.
After migration, cargo install --force ... mdbook-listings is the
only step needed to upgrade — the next build picks up the new bytes
automatically.