Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Dogfooding-Driven Polish

Why this chapter exists

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 this book never exercised hard enough to notice. This chapter collects the resulting polish work, one slice per gap. The verify story (ch.7) closes the v0.1.0 loop.

Through v0.1.0, the rule was: if we identify dogfood, we eat it — gaps that surfaced on downstream passes got appended as new acceptance criteria and new slices, no “out of scope” exit door. That open-ended intake is what this chapter is. With v0.1.0 shipped, the book closes as its development record; later gaps and ideas live in ROADMAP.md and ch.9 (Future Work), not as new chapters here.

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

  1. 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.
  2. Bundled assets refresh on every build, not just at install time. 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 — additional-css/additional-js keep referencing the stale on-disk copies until the author manually re-runs install. The preprocessor — which already runs on every mdbook build — instead writes the bundled bytes into the book root, refreshing them automatically when the binary is upgraded. install keeps the book.toml registration job and adds the two asset paths to .gitignore so downstream books treat them as build artifacts (matches target/). Author override works the same way it does for any other mdbook stylesheet: drop theirs.css into the book directory and add additional-css = ["./theirs.css"] to book.toml. mdbook cascades the second additional-css entry after the first, so author rules win.
  3. Callout popover never covers the line it annotates in the common case. The default opens the popover to the right of the badge (the un-annotated gutter), and an author override switches a specific callout to the left when the right-side gutter isn’t usable. Some overlap is unavoidable on narrow viewports — the fallback there is to live with it. A planned third fix (translucent background + backdrop-filter: blur) was prototyped and dropped: the in-browser effect was too subtle to read as translucent across mdbook’s themes, where --theme-popup-bg sits very close to the listing’s pre bg.
  4. freeze output closes the authoring loop. Every successful freeze prints the frozen path AND the ready-to-paste {{#include listings/<tag>.<ext>}} directive — the author shouldn’t have to grep listings.toml to find the include path. When a prior listing exists in the manifest for the same source path, the output also prints the matching {{#diff <prev-tag> <new-tag>}} directive so the author can paste both lines without a second lookup.
  5. A list (or status) subcommand prints tag → frozen path → source rows so authors can browse the manifest as a book accumulates listings.
  6. install is idempotent. Re-running install on an already-configured book is a no-op with a friendly “already installed” message; never duplicates registrations.
  7. freeze derives a default tag when --tag is 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.
  8. Sidecar TOML callouts. Some listings can’t carry inline // CALLOUT: markers — code the author doesn’t own (third-party crates, vendored snippets, generated code), or languages without a recognized single-line comment syntax (CSS, plain Markdown). For those cases, callouts can live in a sibling TOML file alongside the frozen listing (book/src/listings/<tag>.callouts .toml). The splicer loads the sidecar when present, merges its entries with any inline markers, and emits one combined overlay per fenced block. Inline + sidecar callouts compose cleanly; label collisions across the two sources fail the build with a diagnostic naming the duplicate label and both source locations.
  9. Diff callouts render on added or changed lines only. In a {{#diff}} block, a callout badge renders only on an added or changed line, including the added side of a modification (the + line of a -/+ pair). Context (unchanged) and removed lines carry no badge. Because a callout marker is always its own dedicated comment line, a changed callout (a new or edited marker) is itself an added marker line, so it still badges and lands on the line it annotates. (Only a genuinely unchanged callout is suppressed.) This refines the diff clause of ch.5 AC 1 (which rendered badges on added and context lines). {{#include}} is unchanged and still renders every callout.
  10. One directive grammar across the three passes. The include, diff, and callout cross-ref passes agree on what counts as a directive occurrence: backslash-escaped forms stay literal, occurrences inside inline code spans or fenced code blocks are left alone, and fence tracking follows CommonMark — a shorter same-character fence line inside an outer fence is literal text, not a closer. Two consequences are author-visible: a literal {{#diff}} example inside a 4-backtick fence no longer parses, and {{#callout}} honours the backslash escape the other two directives already did.

The slice — outside-in narrative outline

SliceWhat it adds
1Inline 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 {&#123; escape for cross-ref-scanner safety. Raw HTML events are remapped to text events so a body containing <script> still renders as &lt;script&gt;, not as pass-through HTML.
2Preprocessor 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.
3Open the popover to the right by default (AC 3, fix 1 of 2). 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.
4Per-callout --align override (AC 3, fix 2 of 2). 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).
5freeze output closes the loop (AC 4). Augments the created: <tag> line with the frozen path, the exact {{#include listings/<tag>.<ext>}} directive, and — when a prior listing exists in the manifest for the same source — the matching \{{#diff <prev-tag> <new-tag>}} directive. The prior-tag lookup is source-based (most-recent manifest entry with the same source = ...), not tag-convention-based.
6mdbook-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.
7install 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.
8Default 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).
9Sidecar TOML callouts (AC 8). Listings that can’t carry inline markers (generated code, no-comment languages) attach callouts via a sibling <tag>.callouts.toml file. Splicer loads it alongside the frozen listing, merges with any inline markers, errors on cross-source label collisions.
10Diff callouts render on added and changed lines only (AC 9). A downstream pass noticed a {{#diff}} rendering a badge on an unchanged context line, which is noise in a view about change and a duplicate of the badge the same callout already gets on its first inclusion in a listing. The splicer now badges only added (+) marker lines; context ( ) and removed (-) markers are stripped from the rendered diff but earn no badge. A changed or new callout is an added marker line, so it still surfaces. This is a splicer-level change only; there is no asset or grammar change.
11One directive grammar across the three passes (AC 10). A review pass over the splicer pipeline found the include, diff, and callout-ref parsers each hand-rolling the same {{#…}} scan, and the copies had drifted: the diff parser tracked fences with a toggle that a shorter inner fence line could flip, consuming a literal directive example and missing the real one after the fence. Two new modules — src/fence.rs (a CommonMark fence iterator) and src/directive.rs (a shared occurrence scanner) — now own the grammar; the three passes keep only argument parsing and policy.

Outside-in narrative

Sections appear here as slices ship. All eleven slices have shipped.

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('{', "&#123;") 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.

Listing 6.1
--- 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('&', "&amp;")
         .replace('<', "&lt;")
         .replace('>', "&gt;")
         .replace('"', "&quot;")
+        .replace('{', "&#123;")
+}
+
+// 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('{', "&#123;")
 }
 
 #[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("&#123;&#123;#callout LABEL"),
+            "expected `\{{` escaped to `&#123;` 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("&lt;script&gt;"),
-            "overlay body should escape <script>; got:\n{overlay}",
+            body.contains("&lt;script&gt;"),
+            "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>&#123;&#123;#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: 6.1.2 guards against untrusted HTML in source comments; 6.1.3 explains the <p> strip and what happens if an author reaches for block markdown anyway; 6.1.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_body guards the raw-HTML neutralisation (it asserts <script>&lt;script&gt;).

A new e2e assertion in tests/e2e_callouts.rscallout_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.

Listing 6.2
--- 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 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. Returns true iff 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. Returns true iff 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).

Listing 6.3
--- 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:

Listing 6.4
--- 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 .gitignore listing 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 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).
Listing 6.5
--- 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):

  1. Re-run mdbook-listings install --book-root <book> — writes .gitignore and refreshes the asset bytes.
  2. git rm --cached <book>/mdbook-listings.css <book>/mdbook-listings.js to untrack the old committed copies.
  3. mdbook build regenerates 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.

Slice 3 — popover opens to the right by default

The symptom: every callout popover opened to the LEFT of its badge, landing on top of the code line it annotates. The reader couldn’t see the line the annotation referred to without dismissing the popover first — the inline-callout primitive’s whole point is that the annotation sits beside the line, not over it.

Slice 3 flips the default. The change is CSS-only, contained in assets/mdbook-listings.css:

  • .callout-body switches anchoring from right: 2em (right-edge anchored, body extends leftward over the listing) to left: 100% (left-edge anchored to the badge’s right edge, body extends rightward into the un-annotated gutter).
  • The ::after / ::before arrow pseudos move from the body’s right edge to its left edge, and the triangle direction flips from right-pointing to left-pointing — so it still points back at the badge it belongs to.
  • The OUT-state clip-path flips its left/right insets so the collapsed sliver tucks against the badge on the left rather than the right; the transition then expands rightward.
Listing 6.6
--- listings-css-v2
+++ listings-css-v3
@@ -1,14 +1,26 @@
-/* mdbook-listings — callout overlay styles (slice 7).
+/* mdbook-listings — callout overlay styles.
  *
  * The HTML splicer strips `// CALLOUT: <label> <body>` lines from the
  * rendered listing and emits a sibling `<div class="callout-overlay">`
- * containing one `<button class="callout-badge">` per marker plus an
- * optional `<div class="callout-body">` popover. Each badge carries
- * `data-callout-line` and a `--callout-line` CSS custom property
- * holding the post-strip 1-based line number.
+ * containing one `<div class="callout-entry">` per marker. Each entry
+ * carries `--callout-line` (post-strip 1-based line) and
+ * `--callout-listing-lines` (the pre's total line count).
+ *
+ * The em-based formula was load-bearing on a fragile assumption:
+ * that the overlay's per-line height (1.5em at 0.875em font) matches
+ * the rendered pre's per-line height. mdbook's pre uses
+ * `line-height: normal` (~1.13 for monospace), so the assumed 21px
+ * was off from the true ~18px by 3px per line. For a 600-line diff
+ * the cumulative drift pulled badges 1800px above their intended
+ * line — landing inside whatever sibling pre happened to sit there.
  *
+ * Fix: an inline `<script>` emitted after each overlay measures the
+ * previous pre's rendered height, divides by listing-lines, and sets
+ * `--callout-line-px` on the overlay. The fallback `1.5em` keeps
+ * pre-script behaviour for any environment where JS doesn't run.
+ *
  * Sentinel string used by unit tests to confirm the bundled bytes are
- * the expected build-time asset: mdbook-listings-css-v1
+ * the expected build-time asset: mdbook-listings-css-v5
  */
 
 .callout-overlay {
@@ -19,18 +31,22 @@
   z-index: 2;
 }
 
-.callout-badge {
+.callout-entry {
   position: absolute;
-  right: 0.5em;
-  /* Position the badge upward into the preceding pre. The badge's
-   * line (computed from --callout-line) is offset above the overlay's
-   * natural position; --callout-listing-lines (set by JS in a future
-   * slice or guessed at by the author's CSS override) lets us measure
-   * how far up to go. */
+  right: 0;
   top: calc(
-    (var(--callout-line, 1) - 1 - var(--callout-listing-lines, 0)) *
-      var(--callout-line-height, 1.45em) - 0.25em
+    (var(--callout-line, 1) - 1 - var(--callout-listing-lines, 0))
+      * var(--callout-line-px, 1.5em)
+      - calc(var(--callout-line-px, 1.5em) / 2)
   );
+  height: var(--callout-line-px, 1.5em);
+  pointer-events: none;
+}
+
+.callout-badge {
+  position: absolute;
+  right: 0;
+  top: 0;
   pointer-events: auto;
   display: inline-flex;
   align-items: center;
@@ -51,44 +67,168 @@
 
 .callout-badge:hover,
 .callout-badge:focus-visible {
-  background: var(--inline-code-color, #f0f0f0);
+  background: var(--fg, #333);
+  color: var(--bg, #fff);
   outline: none;
 }
 
+/* Visually distinguish bare anchor badges from interactive popover badges.
+ * Scoped to .callout-entry (listing-side overlay) — cross-ref <a> badges
+ * in chapter prose are often the only ELEMENT child of their <p> parent
+ * (text nodes don't count for `:only-child`), so an unscoped selector
+ * would accidentally mute every inline cross-ref. */
+.callout-entry .callout-badge:only-child {
+  cursor: default;               /* No pointer finger on hover */
+  background: transparent;       /* Hollow look */
+  border: 1px dashed currentColor;
+  opacity: 0.6;                  /* Slightly muted */
+}
+
+/* Bare badges shouldn't invert on hover since they aren't interactive */
+.callout-entry .callout-badge:only-child:hover,
+.callout-entry .callout-badge:only-child:focus-visible {
+  background: transparent;
+  color: var(--fg, #333);
+}
+
+/* Body box: absolutely positioned to the RIGHT of the badge (in the
+ * un-annotated gutter beyond the listing's right edge), vertically
+ * centred on the entry (and therefore on the badge). Opening to the
+ * right is ch.6 slice 3 — opening to the left covered the very code
+ * line the body annotates, which defeated the inline-callout point. */
 .callout-body {
   position: absolute;
-  right: 0.5em;
-  top: calc(
-    (var(--callout-line, 1) - var(--callout-listing-lines, 0)) *
-      var(--callout-line-height, 1.45em) + 0.25em
-  );
-  max-width: min(36em, 70vw);
+  left: 100%;
+  top: 50%;
+  transform: translateY(-50%);
+  width: max-content;
+  /* Border-box so `max-width` constrains the popover's actual visible
+   * extent (including padding + border) — not just its inner content
+   * area. With default content-box, a `max-width: 28em` popover is
+   * visually ~22px wider than the JS clamp expects, and the right
+   * edge ends up under the scroll container's scrollbar. */
+  box-sizing: border-box;
+  /* JS clamps `max-width` inline when the right gutter is narrower
+   * than 28em; default kicks in only when there's room for the
+   * full-width popover. */
+  max-width: 28em;
   padding: 0.5em 0.75em;
   border: 1px solid var(--theme-popup-border, #ccc);
   border-radius: 0.25em;
   background: var(--theme-popup-bg, #fff);
-  color: var(--fg, #333);
   box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
   font-size: 0.9em;
   pointer-events: auto;
-  z-index: 3;
+  white-space: normal;
+
+  /* Base state (transition OUT): Box is clipped to the LEFT edge (arrow
+   * is still visible due to negative left inset, but box is 0-width),
+   * text is transparent, and visibility is hidden. */
+  visibility: hidden;
+  clip-path: inset(-2em 100% -2em -2em);
+  color: transparent;
+
+  /* Transition OUT choreography:
+   * 1. color fades out instantly
+   * 2. clip-path retracts box rightward (waits 0.15s for text to fade)
+   * 3. visibility becomes hidden (waits 0.35s for both to finish) */
+  transition: color 0.15s ease-out, clip-path 0.2s ease-out 0.15s, visibility 0s 0.35s;
+}
+
+/* Left-pointing triangle arrow on the body's left edge, pointing back
+ * at the badge. ::after draws the border colour; ::before fills it
+ * with the body background so the arrow looks outlined rather than
+ * solid. */
+.callout-body::after {
+  content: '';
+  position: absolute;
+  right: 100%;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 0;
+  height: 0;
+  border-top: 0.35em solid transparent;
+  border-bottom: 0.35em solid transparent;
+  border-right: 0.5em solid var(--theme-popup-border, #ccc);
+}
+
+.callout-body::before {
+  content: '';
+  position: absolute;
+  right: calc(100% - 1px);
+  top: 50%;
+  transform: translateY(-50%);
+  width: 0;
+  height: 0;
+  border-top: calc(0.35em - 1px) solid transparent;
+  border-bottom: calc(0.35em - 1px) solid transparent;
+  border-right: calc(0.5em - 1px) solid var(--theme-popup-bg, #fff);
+  z-index: 1;
 }
 
-/* Pure-CSS hover/focus reveal: the [hidden] attribute keeps the body
- * out of layout by default; the rule below shows it when the preceding
- * badge is hovered or keyboard-focused. */
-.callout-overlay .callout-badge:hover + .callout-body[hidden],
-.callout-overlay .callout-badge:focus-visible + .callout-body[hidden] {
-  display: block;
+/* Hover/focus state (transition IN): Adjacent-sibling selector works because
+ * button precedes div in DOM order inside .callout-entry. */
+.callout-entry .callout-badge:hover + .callout-body,
+.callout-entry .callout-badge:focus-visible + .callout-body {
+  visibility: visible;
+  clip-path: inset(-2em -2em -2em -2em);
+  color: var(--fg, #333);
+
+  /* Transition IN choreography:
+   * 1. visibility becomes visible instantly
+   * 2. clip-path expands box (draws line instantly)
+   * 3. color fades in (waits 0.2s for box to draw) */
+  transition: visibility 0s, clip-path 0.2s ease-out, color 0.15s ease-out 0.2s;
 }
 
-.callout-overlay .callout-body[hidden] {
-  display: none;
+/* mdbook's book.js runs highlight.js across every `<code>` on the
+ * page at load, adding `.hljs` to the outer `<code>` and wrapping
+ * the inner text in `<span class="hljs-…">` elements that hold
+ * their own theme colours. Those colours override `color: inherit`
+ * and don't pick up the parent's transition, so they "appear
+ * instantly / vanish last" while the rest of the body fades. Match
+ * the body's color animation on every hljs element so the popover
+ * fades uniformly. */
+.callout-body [class*="hljs"] {
+  color: transparent;
+  transition: color 0.15s ease-out;
 }
+.callout-entry .callout-badge:hover + .callout-body [class*="hljs"],
+.callout-entry .callout-badge:focus-visible + .callout-body [class*="hljs"] {
+  color: var(--fg, #333);
+  transition: color 0.15s ease-out 0.2s;
+}
 
-/* Cross-reference badge in chapter prose (slice 5). The same shape as
- * the listing-side badge but rendered inline with surrounding text. */
+/* Cross-reference badge in chapter prose (slice 5). Same circle shape
+ * as the listing-side badge but static-positioned inline with text. */
 .callout-badge.callout-ref {
   position: static;
   margin: 0 0.15em;
 }
+
+/* Narrow-viewport fallback (ch.6 slice 3, JS-applied class): the JS
+ * adds `callout-entry--left-popover` when the right-side gutter is
+ * smaller than the threshold. Revert the right-opening defaults so
+ * the popover opens LEFT (over the listing) — accepted as the lesser
+ * evil vs. a popover that spills off the viewport entirely. */
+.callout-entry--left-popover .callout-body {
+  left: auto;
+  right: 2em;
+  clip-path: inset(-2em -2em -2em 100%);
+}
+.callout-entry--left-popover .callout-badge:hover + .callout-body,
+.callout-entry--left-popover .callout-badge:focus-visible + .callout-body {
+  clip-path: inset(-2em -2em -2em -2em);
+}
+.callout-entry--left-popover .callout-body::after {
+  right: auto;
+  left: 100%;
+  border-right: 0;
+  border-left: 0.5em solid var(--theme-popup-border, #ccc);
+}
+.callout-entry--left-popover .callout-body::before {
+  right: auto;
+  left: calc(100% - 1px);
+  border-right: 0;
+  border-left: calc(0.5em - 1px) solid var(--theme-popup-bg, #fff);
+}

The CSS_ASSET_SENTINEL and JS_ASSET_SENTINEL constants in src/install.rs both bump (CSS v3→v5, JS v1→v5; the iteration during this slice’s debug cycle accounts for the multi-step versioning) so the bundled-asset check catches the new shape.

Listing 6.7
--- install-v9
+++ install-v10
@@ -13,8 +13,8 @@
 
 /// 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-v3";
-pub const JS_ASSET_SENTINEL: &str = "mdbook-listings-js-v1";
+pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v5";
+pub const JS_ASSET_SENTINEL: &str = "mdbook-listings-js-v5";
 
 /// Shared between the writer and the registrar so the two can't drift.
 pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

Viewport-aware widening into the gutter

A naïve “always open right” default has the opposite failure mode of the pre-slice behavior: on a narrow viewport (mobile, sidebar open, or a callout near the rightmost edge of the chapter column), the popover can extend off the right side of the viewport and be unreadable. Slice 3 includes a runtime layout adjustment in assets/mdbook-listings.js that picks the right side at the right size:

  • Wide gutter (≥ 28em ≈ 448px between the listing’s right edge and the viewport’s right edge): open right, full max-width: 28em. Default-comfortable case.
  • Mid gutter (between the threshold and the default max-width): open right, but clamp max-width to (availableRight − 2em) so body.right stays inside the viewport. The clamp is applied as a direct inline-style write (body.style.maxWidth = '278px').
  • Narrow gutter (< 16em ≈ 230px): flip the popover back to left-opening (over the listing). The JS writes body.style.left = 'auto' + body.style.right = '2em' directly and adds a callout-entry--left-popover class on the entry to drive the arrow pseudo-element overrides (pseudo-elements can’t take inline styles, so the arrow direction still needs a class hook). The fallback layout matches the pre-slice behavior.

Four gotchas had to land for the math to match reality:

  1. The scrollbar isn’t on the document. mdbook puts the vertical scrollbar on .content (overflow-y: auto), not on <html>. documentElement.clientWidth returns the full viewport width — a popover sized against it gets its right edge tucked under .content’s scrollbar. The JS walks up from the entry to find the nearest scrolling ancestor and uses (container.left + container.clientWidth) as the right limit.

  2. em resolves against the element’s own font-size. The popover has font-size: 0.9em and mdbook uses the html { font-size: 62.5% } trick — so 28em on the popover resolves to ~403px (28 × 14.4px popover-em), but 28 × documentElement.fontSize resolves to 280px (28 × 10px root-em). The JS reads the popover body’s font-size via getComputedStyle(body).fontSize so the em conversion matches what the CSS rule resolves to.

  3. Direct inline-style writes drive the clamp. An earlier attempt that toggled --callout-body-max-width silently no-op’d in some browser contexts — setProperty returned without throwing, but the immediate getPropertyValue read back empty, identical to “JS never ran.” Direct property writes on style (body.style.maxWidth = '278px') are unconditional.

  4. max-width is the CONTENT box by default. mdbook doesn’t set a global box-sizing: border-box, so the default max-width: 28em on .callout-body caps the content width. The visible popover is content + padding (0.75em each side) + border (1px each side), so the border-box is ~22px wider than the JS expects. Setting .callout-body { box-sizing: border-box } makes max-width constrain the visible extent directly.

The JS recalcs on DOMContentLoaded and on requestAnimationFrame after every resize event, so dragging the window edge updates the side/clamp choice live.

The full JS file (frozen as listings-js-v1):

Listing 6.8
/* mdbook-listings — runtime layout helpers for the callout overlay.
 *
 * Two things this script does:
 *
 * 1. Calibrate `--callout-line-px` on every overlay so the badge sits
 *    on the line that previously held its `// CALLOUT:` marker.
 *    mdbook's pre uses `line-height: normal` (~18px for monospace at
 *    16px); the overlay's em-based CSS fallback computes ~21px and
 *    drifts badges 3px per line above their intended row. For a
 *    600-line diff the cumulative drift pulls badges ~1800px above
 *    where they should be — landing inside a sibling pre. Measuring
 *    the pre's actual per-line height once and writing it as a CSS
 *    custom property on the overlay keeps every badge in place
 *    regardless of theme or font.
 *
 * 2. Pick a popover side (left vs right) and clamp its max-width to
 *    fit the available right-side gutter. The CSS defaults to opening
 *    the popover into the un-annotated gutter on the RIGHT of the
 *    listing (ch.6 slice 3). On narrow viewports that gutter can be
 *    too small to host even a usable popover — instead of spilling
 *    off the viewport's right edge (or under the scroll container's
 *    scrollbar), this script flips the popover back to the LEFT
 *    (over the listing) when the gutter is below the threshold.
 *    Between the threshold and the default max-width, the script
 *    clamps max-width so the popover's right edge stays inside the
 *    visible area.
 *
 *    The "visible area" is bounded by the popover's nearest scrolling
 *    ancestor — in mdbook's default theme that's `.content`
 *    (`overflow-y: auto`), NOT `<html>`. `documentElement.clientWidth`
 *    returns the full viewport width because the document doesn't
 *    scroll, so a popover sized against it gets its right edge tucked
 *    under `.content`'s scrollbar. Walking up to the scroll container
 *    and using `(container.left + container.clientWidth)` gets the
 *    right edge of the visible area in viewport coords.
 *
 *    Width / side decisions are applied as DIRECT inline styles on
 *    the body element (`body.style.maxWidth`, `body.style.left`, etc.)
 *    — no CSS-variable or class-toggle indirection. The earlier
 *    var-based approach silently failed in some browser contexts
 *    (setProperty without throwing, getPropertyValue returning empty)
 *    and the symptom was identical to "JS never ran." Direct
 *    inline-style writes are unconditional.
 *
 *    Tunables:
 *      - LEFT_FALLBACK_THRESHOLD_EM (16em ≈ 256px): below this
 *        available-gutter value, flip to left-opening.
 *      - DEFAULT_MAX_WIDTH_EM (28em ≈ 448px): the CSS max-width.
 *        Clamped to `availableRight - GUTTER_BUFFER_EM` when the
 *        gutter is between the threshold and this value.
 *      - GUTTER_BUFFER_EM (1em): margin between the clamped popover's
 *        right edge and the scroll container's right edge.
 *
 *    Runs on DOMContentLoaded and on `requestAnimationFrame` after
 *    every resize event, so dragging the window edge updates the
 *    side/clamp choice live.
 *
 * Sentinel string used by unit tests to confirm the bundled bytes
 * are the expected build-time asset: mdbook-listings-js-v5
 */
(function () {
  var LEFT_FALLBACK_THRESHOLD_EM = 16;
  var DEFAULT_MAX_WIDTH_EM = 28;
  // 2em buffer between the clamped popover's right edge and the
  // scroll container's right edge. 1em wasn't enough on all OS /
  // browser scrollbar widths — the popover sat right against the
  // scrollbar and its own right border / box-shadow visually merged
  // with it.
  var GUTTER_BUFFER_EM = 2;

  function calibrateLineHeights() {
    document.querySelectorAll('.callout-overlay').forEach(function (overlay) {
      var pre = overlay.previousElementSibling;
      if (!pre || pre.tagName !== 'PRE') return;
      var entry = overlay.querySelector('.callout-entry');
      if (!entry) return;
      var lines = parseInt(
        entry.style.getPropertyValue('--callout-listing-lines') || '0',
        10
      );
      if (lines <= 0) return;
      var perLine = pre.getBoundingClientRect().height / lines;
      overlay.style.setProperty('--callout-line-px', perLine + 'px');
    });
  }

  // Walk up to the nearest scrolling ancestor. mdbook's scrollbar
  // is on `.content` (`overflow-y: auto`), not on `<html>`, so
  // `documentElement.clientWidth` would return the full viewport
  // width — a popover sized against it would tuck its right edge
  // under `.content`'s scrollbar.
  function findScrollContainer(elem) {
    var parent = elem.parentElement;
    while (parent && parent !== document.body) {
      var overflowY = getComputedStyle(parent).overflowY;
      if (overflowY === 'auto' || overflowY === 'scroll') {
        return parent;
      }
      parent = parent.parentElement;
    }
    return document.documentElement;
  }

  function adjustPopoverPositioning() {
    document.querySelectorAll('.callout-entry').forEach(function (entry) {
      var body = entry.querySelector('.callout-body');
      if (!body) return;
      // `em` for non-font properties resolves against the ELEMENT'S
      // OWN font-size. The popover has `font-size: 0.9em` and mdbook
      // uses `html { font-size: 62.5% }`, so the popover's resolved
      // font-size (~14.4px) differs from documentElement's (~10px).
      // Use the popover's em so the threshold and max-width values
      // match what the CSS rule resolves to.
      var bodyEmPx = parseFloat(getComputedStyle(body).fontSize) || 16;
      var thresholdPx = LEFT_FALLBACK_THRESHOLD_EM * bodyEmPx;
      var maxWidthPx = DEFAULT_MAX_WIDTH_EM * bodyEmPx;
      var bufferPx = GUTTER_BUFFER_EM * bodyEmPx;

      var entryRect = entry.getBoundingClientRect();
      var container = findScrollContainer(entry);
      var containerRect = container.getBoundingClientRect();
      // Right edge of the scroll container's visible area (excludes
      // the scrollbar). For mdbook this is `.content`'s inner right.
      var usableRight = containerRect.left + container.clientWidth;
      var availableRight = usableRight - entryRect.right;

      // Observable per-entry marker for devtools diagnostics.
      var decision;
      if (availableRight < thresholdPx) {
        decision = 'flip-left';
        // Drive the clamp / flip via direct inline-style writes on
        // `.style.maxWidth`, `.left`, `.right` — not via CSS custom
        // properties. An earlier attempt that toggled
        // `--callout-body-max-width` silently no-op'd in some browser
        // contexts (the `setProperty` call returned without throwing,
        // but immediate `getPropertyValue` read back empty), looking
        // identical to "JS never ran." Direct property writes on the
        // element's `style` object are unconditional.
        body.style.left = 'auto';
        body.style.right = '2em';
        body.style.maxWidth = '';
        entry.classList.add('callout-entry--left-popover');
      } else {
        entry.classList.remove('callout-entry--left-popover');
        body.style.left = '';
        body.style.right = '';
        if (availableRight - bufferPx < maxWidthPx) {
          decision = 'clamp-' + Math.round(availableRight - bufferPx) + 'px';
          body.style.maxWidth = (availableRight - bufferPx) + 'px';
        } else {
          decision = 'wide';
          body.style.maxWidth = '';
        }
      }
      entry.dataset.calloutPopoverDecision = decision;
    });
  }

  function recalc() {
    // Observable marker — bumps every time recalc fires. Devtools
    // diagnostic can read `window.__mdbookListingsRecalcs` to confirm
    // the script ran (and how many times). Without this marker, a
    // failed recalc looks identical to "the script didn't load."
    window.__mdbookListingsRecalcs = (window.__mdbookListingsRecalcs || 0) + 1;
    calibrateLineHeights();
    adjustPopoverPositioning();
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', recalc);
  } else {
    recalc();
  }

  // requestAnimationFrame-debounced resize handler: coalesces rapid
  // resize events (e.g., during a window drag) into one recalc per
  // animation frame, but fires by the next frame instead of waiting
  // a fixed timeout. The frame-based pacing also makes the recalc
  // visible to e2e tests that hover immediately after set_viewport_size
  // (one rAF cycle is much shorter than a setTimeout poll).
  var rafScheduled = false;
  window.addEventListener('resize', function () {
    if (rafScheduled) return;
    rafScheduled = true;
    requestAnimationFrame(function () {
      rafScheduled = false;
      recalc();
    });
  });
})();

Tests

Three e2e regressions in tests/e2e_callouts.rs, one per gutter band, all using page.set_viewport_size(...) to drive each branch:

  • callout_body_opens_to_the_right_of_its_badge_on_wide_viewports — 1800×800, asserts body.left >= badge.right (right-opening at full max-width).
  • callout_body_never_overflows_the_viewport_horizontally — 1024×800, asserts body.right <= clientWidth (mid-gutter clamp keeps the popover inside the viewport AND off the scrollbar).
  • callout_body_falls_back_to_left_opening_when_right_gutter_is_too_narrow — 900×800, asserts body.right <= badge.left (narrow-gutter flip).

A small helper wait_for_layout_recalc(page) awaits two requestAnimationFrame ticks so each test measures after the JS has reacted to the viewport change.

Listing 6.9
--- e2e-callouts-v9
+++ e2e-callouts-v10
@@ -1,3 +1,4 @@
+use playwright_rs::protocol::Viewport;
 use playwright_rs::{expect, locator};
 
 mod common;
@@ -6,6 +7,24 @@
 
 const CH05: &str = "ch05-render-inline-callouts";
 
+/// Wait for the page to lay out + the popover-positioning JS to re-run
+/// after a viewport change. `set_viewport_size` fires a `resize` event;
+/// our JS recalcs on the next `requestAnimationFrame`. Two rAF ticks
+/// are enough to guarantee the recalc has finished AND the resulting
+/// class / CSS-var changes have been applied to layout.
+async fn wait_for_layout_recalc(page: &playwright_rs::protocol::Page) {
+    // Two rAF ticks: the first lets the resize event's listener run
+    // and schedule the JS recalc; the second lets the recalc's class /
+    // CSS-var changes settle into layout. evaluate_value always parses
+    // a String return, so the promise resolves with a sentinel.
+    let _: String = page
+        .evaluate_value(
+            "new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r('done'))))",
+        )
+        .await
+        .expect("wait for layout recalc");
+}
+
 #[tokio::test]
 async fn label_only_callout_renders_badge_without_following_body() {
@@ -467,3 +486,202 @@
     )
     .await;
 }
+
+#[tokio::test]
+async fn callout_body_opens_to_the_right_of_its_badge_on_wide_viewports() {
+    // ch.6 slice 3: on a viewport wide enough to leave a usable right
+    // gutter (≥ the JS threshold, 16em ≈ 256px), the popover defaults
+    // to opening into the un-annotated gutter on the RIGHT of the badge
+    // — never covering the line it annotates. Pre-slice it opened left
+    // and sat on top of the listing — defeating the inline-callout
+    // point. The contract is a layout assertion: body.left >= badge.right
+    // (modulo a 1px tolerance for subpixel rounding). Narrow-viewport
+    // fallback (flip to left when the gutter is too narrow) and the
+    // mid-viewport max-width clamp have their own tests below.
+    with_traced_chapter(
+        "callout_body_opens_to_the_right_of_its_badge_on_wide_viewports",
+        CH05,
+        |page| async move {
+            // 1800x800: comfortably above the threshold for the right
+            // gutter to host the popover at its full max-width.
+            page.set_viewport_size(Viewport {
+                width: 1800,
+                height: 800,
+            })
+            .await
+            .expect("set wide viewport");
+            wait_for_layout_recalc(&page).await;
+            let badge = page
+                .locator(locator!("button#callout-snippets-intercept"))
+                .await;
+            badge.hover(None).await.expect("hover badge to reveal body");
+            // Confirm the body is laid out before measuring (clip-path
+            // animation has finished and the box has its target width).
+            let body = page
+                .locator(locator!("#callout-body-snippets-intercept"))
+                .await;
+            expect(body)
+                .to_be_visible()
+                .await
+                .expect("body popover must be visible after hover");
+
+            let report: String = page
+                .evaluate_value(
+                    r#"(() => {
+                      const badge = document.querySelector('button#callout-snippets-intercept');
+                      const body = document.querySelector('#callout-body-snippets-intercept');
+                      const badgeBox = badge.getBoundingClientRect();
+                      const bodyBox = body.getBoundingClientRect();
+                      if (bodyBox.left + 1 < badgeBox.right) {
+                        return `body.left=${bodyBox.left.toFixed(1)} < badge.right=${badgeBox.right.toFixed(1)} (popover is covering the line it annotates)`;
+                      }
+                      return 'ok';
+                    })()"#,
+                )
+                .await
+                .expect("evaluate body-vs-badge layout");
+            assert_eq!(report, "ok", "default popover position regression");
+        },
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn callout_body_falls_back_to_left_opening_when_right_gutter_is_too_narrow() {
+    // ch.6 slice 3 viewport-aware behavior: when the right-side gutter
+    // between the listing's right edge and the viewport's right edge
+    // is too narrow to host even a usable popover (below the JS
+    // threshold, currently 16em ≈ 256px), the JS flips the popover
+    // back to the LEFT side. The reader sees the popover cover the
+    // listing — accepted as the lesser evil vs. a popover that spills
+    // off the viewport entirely and can't be read.
+    with_traced_chapter(
+        "callout_body_falls_back_to_left_opening_when_right_gutter_is_too_narrow",
+        CH05,
+        |page| async move {
+            // 900x800: chapter content fills most of the viewport, the
+            // right gutter shrinks below the JS threshold (16em ≈ 256px)
+            // but stays above the mobile-layout breakpoint where the
+            // sidebar would slide off and the badge would become
+            // unreachable to a hover.
+            page.set_viewport_size(Viewport {
+                width: 900,
+                height: 800,
+            })
+            .await
+            .expect("set narrow viewport");
+            wait_for_layout_recalc(&page).await;
+            let badge = page
+                .locator(locator!("button#callout-snippets-intercept"))
+                .await;
+            badge
+                .scroll_into_view_if_needed()
+                .await
+                .expect("scroll badge into view");
+            badge.hover(None).await.expect("hover badge");
+            let body = page
+                .locator(locator!("#callout-body-snippets-intercept"))
+                .await;
+            expect(body)
+                .to_be_visible()
+                .await
+                .expect("body must be visible after hover");
+
+            let report: String = page
+                .evaluate_value(
+                    r#"(() => {
+                      const badge = document.querySelector('button#callout-snippets-intercept');
+                      const body = document.querySelector('#callout-body-snippets-intercept');
+                      const badgeBox = badge.getBoundingClientRect();
+                      const bodyBox = body.getBoundingClientRect();
+                      if (bodyBox.right > badgeBox.left + 1) {
+                        return `body.right=${bodyBox.right.toFixed(1)} > badge.left=${badgeBox.left.toFixed(1)} (popover did not fall back to left-opening on a narrow viewport)`;
+                      }
+                      return 'ok';
+                    })()"#,
+                )
+                .await
+                .expect("evaluate body-vs-badge layout");
+            assert_eq!(report, "ok", "narrow-viewport fallback regression");
+        },
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn callout_body_never_overflows_the_viewport_horizontally() {
+    // ch.6 slice 3 viewport-aware behavior: at intermediate viewport
+    // widths (right gutter exists but is smaller than the popover's
+    // default `max-width: 28em`), the JS clamps `max-width` so the
+    // popover's right edge stays inside the viewport rather than
+    // spilling off-screen. The contract is universal: regardless of
+    // which side the popover opens on, body.right must never exceed
+    // window.innerWidth.
+    with_traced_chapter(
+        "callout_body_never_overflows_the_viewport_horizontally",
+        CH05,
+        |page| async move {
+            // 1024x800: a typical mid-size viewport. mdbook content
+            // takes most of the column; the right gutter is small but
+            // non-zero. Either the JS clamps to fit OR flips left —
+            // either way, body must stay on-screen.
+            page.set_viewport_size(Viewport {
+                width: 1024,
+                height: 800,
+            })
+            .await
+            .expect("set mid viewport");
+            wait_for_layout_recalc(&page).await;
+            let badge = page
+                .locator(locator!("button#callout-snippets-intercept"))
+                .await;
+            badge.hover(None).await.expect("hover badge");
+            let body = page
+                .locator(locator!("#callout-body-snippets-intercept"))
+                .await;
+            expect(body)
+                .to_be_visible()
+                .await
+                .expect("body must be visible after hover");
+
+            let report: String = page
+                .evaluate_value(
+                    r#"(() => {
+                      const body = document.querySelector('#callout-body-snippets-intercept');
+                      const bodyBox = body.getBoundingClientRect();
+                      // Walk up to find the scroll container — mdbook's
+                      // scrollbar lives on `.content`, not on the
+                      // document. The right edge of the scroll
+                      // container's VISIBLE area (excluding its
+                      // scrollbar) is what the popover must stay inside;
+                      // measuring against `window.innerWidth` or
+                      // `documentElement.clientWidth` lets the popover
+                      // hide under `.content`'s scrollbar.
+                      function findScrollContainer(elem) {
+                        let p = elem.parentElement;
+                        while (p && p !== document.body) {
+                          const oy = getComputedStyle(p).overflowY;
+                          if (oy === 'auto' || oy === 'scroll') return p;
+                          p = p.parentElement;
+                        }
+                        return document.documentElement;
+                      }
+                      const container = findScrollContainer(body);
+                      const cRect = container.getBoundingClientRect();
+                      const usableRight = cRect.left + container.clientWidth;
+                      if (bodyBox.right > usableRight + 1) {
+                        return `body.right=${bodyBox.right.toFixed(1)} > usableRight=${usableRight.toFixed(1)} (popover overflows the visible area OR sits under the scrollbar — clamp / flip not applied)`;
+                      }
+                      if (bodyBox.left < -1) {
+                        return `body.left=${bodyBox.left.toFixed(1)} < 0 (popover overflows the LEFT viewport edge)`;
+                      }
+                      return 'ok';
+                    })()"#,
+                )
+                .await
+                .expect("evaluate body-vs-viewport layout");
+            assert_eq!(report, "ok", "viewport-overflow regression");
+        },
+    )
+    .await;
+}

What slice 3 does NOT fix

The narrow-gutter fallback still covers the listing on the left — that’s the lesser evil compared to letting the popover spill off-screen, but it’s not invisible. Slice 4 adds a per-callout --align=left|right override so an author can pin one side explicitly; a third planned fix (translucent + backdrop-filter: blur) was prototyped and dropped because the in-browser effect was too subtle to read as translucent across mdbook’s themes. Slices 3+4 are what closes AC 3 in practice.

Slice 4 — per-callout --align override

The symptom: slice 3’s viewport-aware fallback decides the popover side by measuring the available right-side gutter at hover time. That’s the right default for most callouts, but it has no notion of intent — an author who knows a specific callout sits next to a wide right-gutter element they don’t want covered (a sidebar, an image, a floated note) has no way to say so. Conversely, an author on a wide viewport who knows a particular body is short enough to read fine over the listing can’t pin it left. The runtime makes the call; the author can’t override it.

Slice 4 extends the // CALLOUT: grammar with --key=value options between the label and the body, and ships the first such option: --align=left|right. The marker shape becomes:

// CALLOUT: <label> [--align=left|--align=right] <body>

When --align=left is present, the splicer emits data-callout-align="left" on the entry; the runtime JS sees the attribute and pins the popover to the left, short-circuiting the viewport-aware path. --align=right is symmetric (pins right regardless of available gutter). Anything else falls through to slice 3’s default behaviour.

The option grammar is deliberately a tiny generalisation rather than a one-off flag: a future slice that wants per-callout --width=... or --theme=... will not need to re-touch the parser. Tokens that don’t match --key=value end option parsing and become the start of the body, so the existing bodyless and body-with-no-options forms keep parsing unchanged.

Here’s the demo fixture — a snippet with one --align=left marker. The same file is included into the e2e test as the on-page fixture the regression hovers; the rendered badge sits directly below the fenced block:

#![allow(unused)]
fn main() {
// Demonstrates the ch.6 slice 4 `--align=left` author override:
// even on a wide viewport (where the JS would normally open the
// popover into the right-side gutter), this callout pins it LEFT.
fn pinned_left_example() {
    let _ = "the body popover here opens over the listing on the left";
}
}

The production-code change is in src/callout.rs: the Callout struct grows a pub options: HashMap<String, String> field, a new parse_options helper pulls --key=value tokens off the front of the rest-of-line, and render_callout_overlay_html emits data-callout-align="<value>" on the entry when the option is set:

Listing 6.10
--- callout-v7
+++ callout-v8
@@ -4,11 +4,17 @@
 
 /// 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)]
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
 pub struct Callout {
     pub line: usize,
     pub label: String,
     pub body: Option<String>,
+    /// `--key=value` options written between the label and the body, e.g.
+    /// `// CALLOUT: lbl --align=left Body text.` parses to
+    /// `options = {"align" => "left"}`. Unknown keys round-trip but have
+    /// no rendering effect today; that's how new per-callout options
+    /// (alignment, width, theme) can land without a parser change.
+    pub options: HashMap<String, String>,
 }
 
 /// Walks `content` line by line and returns every well-formed callout
@@ -38,14 +44,57 @@
     if label.is_empty() || !is_valid_label(label) {
         return None;
     }
-    let body = rest.map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
+    // Pull `--key=value` options off the front of `rest` while the
+    // leading token matches the option shape; the rest becomes body.
+    let (options, body_str) = parse_options(rest.map(|s| s.trim_start()));
+    let body = body_str
+        .map(|s| s.trim().to_string())
+        .filter(|s| !s.is_empty());
     Some(Callout {
         line,
         label: label.to_string(),
         body,
+        options,
     })
 }
 
+/// Parses a leading sequence of `--key=value` tokens, returning the
+/// option map plus whatever's left (the body). Tokens that don't match
+/// the `--key=value` shape end option parsing; everything from that
+/// token onward is the body (verbatim, with the leading whitespace
+/// preserved so callers can re-trim).
+fn parse_options(rest: Option<&str>) -> (HashMap<String, String>, Option<&str>) {
+    let mut options = HashMap::new();
+    let mut cursor = match rest {
+        Some(s) => s,
+        None => return (options, None),
+    };
+    loop {
+        let trimmed = cursor.trim_start();
+        if !trimmed.starts_with("--") {
+            return (options, Some(cursor));
+        }
+        // Token is the substring up to the next whitespace.
+        let (token, after) = match trimmed.split_once(char::is_whitespace) {
+            Some((t, a)) => (t, Some(a)),
+            None => (trimmed, None),
+        };
+        // Must contain `=` to be a valid option; otherwise treat as body.
+        let kv = token.strip_prefix("--").and_then(|s| s.split_once('='));
+        let Some((key, value)) = kv else {
+            return (options, Some(cursor));
+        };
+        if key.is_empty() {
+            return (options, Some(cursor));
+        }
+        options.insert(key.to_string(), value.to_string());
+        cursor = match after {
+            Some(rest) => rest,
+            None => return (options, None),
+        };
+    }
+}
+
 fn is_valid_label(label: &str) -> bool {
     label
@@ -625,8 +674,18 @@
         } else {
             String::new()
         };
+        // Per-callout author override: `--align=left` on the marker
+        // surfaces as `data-callout-align="left"`, letting the runtime
+        // JS skip its viewport-aware detection and pin the popover left
+        // (over the listing) regardless of available right-side gutter.
+        let align_attr = match c.options.get("align") {
+            Some(value) if value == "left" || value == "right" => {
+                format!(" data-callout-align=\"{value}\"")
+            }
+            _ => String::new(),
+        };
         s.push_str(&format!(
-            "  <div class=\"callout-entry\" data-callout-line=\"{line}\" \
+            "  <div class=\"callout-entry\" data-callout-line=\"{line}\"{align_attr} \
              style=\"--callout-line: {line}; --callout-listing-lines: {total_lines};\">\n",
         ));
         s.push_str(&format!(
@@ -710,6 +769,7 @@
                 line: 2,
                 label: "greeting".into(),
                 body: Some("Says hello to the user.".into()),
+                ..Default::default()
             }]
         );
     }
@@ -723,7 +783,7 @@
             vec![Callout {
                 line: 1,
                 label: "anchor-only".into(),
-                body: None,
+                ..Default::default()
             }]
         );
     }
@@ -1413,4 +1473,148 @@
             "should not have rendered anchor for the in-code-block reference; got:\n{out}",
         );
     }
+
+    // ---------------------------------------------------------------
+    // ch.6 slice 4: per-callout `--align` (and other `--key=value`)
+    // options after the label, before the body.
+    // ---------------------------------------------------------------
+
+    #[test]
+    fn parses_align_option_only_no_body() {
+        let s = "// CALLOUT: lbl --align=left\n";
+        let got = parse_callouts(s, "//");
+        let mut options = HashMap::new();
+        options.insert("align".into(), "left".into());
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: None,
+                options,
+            }]
+        );
+    }
+
+    #[test]
+    fn parses_align_option_followed_by_body() {
+        let s = "// CALLOUT: lbl --align=left Body text here.\n";
+        let got = parse_callouts(s, "//");
+        let mut options = HashMap::new();
+        options.insert("align".into(), "left".into());
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: Some("Body text here.".into()),
+                options,
+            }]
+        );
+    }
+
+    #[test]
+    fn parses_multiple_options_then_body() {
+        let s = "// CALLOUT: lbl --align=left --width=20em Body text.\n";
+        let got = parse_callouts(s, "//");
+        let mut options = HashMap::new();
+        options.insert("align".into(), "left".into());
+        options.insert("width".into(), "20em".into());
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: Some("Body text.".into()),
+                options,
+            }]
+        );
+    }
+
+    #[test]
+    fn unknown_option_keys_are_preserved_in_options_map() {
+        // Forward-compat: a marker that uses a key the renderer doesn't
+        // recognise (here `--theme=dark`) is parsed normally; the unknown
+        // key sits in `options` for future use and has no rendering effect
+        // today. Bodies AFTER the unknown option still parse cleanly.
+        let s = "// CALLOUT: lbl --theme=dark Body text.\n";
+        let got = parse_callouts(s, "//");
+        let mut options = HashMap::new();
+        options.insert("theme".into(), "dark".into());
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: Some("Body text.".into()),
+                options,
+            }]
+        );
+    }
+
+    #[test]
+    fn malformed_option_without_equals_is_part_of_body() {
+        // `--align` (no `=value`) doesn't match the `--key=value` shape,
+        // so it's treated as the start of the body. The grammar stays
+        // unambiguous: options are EXACTLY `--key=value` and body is
+        // everything from the first non-matching token onward.
+        let s = "// CALLOUT: lbl --align Body without an equals.\n";
+        let got = parse_callouts(s, "//");
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: Some("--align Body without an equals.".into()),
+                options: HashMap::new(),
+            }]
+        );
+    }
+
+    #[test]
+    fn double_dash_separator_inside_body_is_preserved() {
+        // Once the body has started (first non-option token), any later
+        // `--` is part of the body verbatim. Authors writing technical
+        // prose like "--no-verify" stay safe.
+        let s = "// CALLOUT: lbl --align=left Use --no-verify carefully.\n";
+        let got = parse_callouts(s, "//");
+        let mut options = HashMap::new();
+        options.insert("align".into(), "left".into());
+        assert_eq!(
+            got,
+            vec![Callout {
+                line: 1,
+                label: "lbl".into(),
+                body: Some("Use --no-verify carefully.".into()),
+                options,
+            }]
+        );
+    }
+
+    #[test]
+    fn render_callout_overlay_html_emits_data_callout_align_when_align_option_set() {
+        // The HTML emission side: an `--align=left` option on a callout
+        // must surface as a `data-callout-align="left"` attribute on the
+        // entry so the runtime JS knows to skip the viewport-aware
+        // auto-detection and pin the popover left.
+        let content =
+            "```yaml\n# CALLOUT: pinned-left --align=left A body that should open left.\n```\n";
+        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        assert!(
+            out.contains(r#"data-callout-align="left""#),
+            "entry must carry data-callout-align=\"left\" when the option is set; got:\n{out}",
+        );
+    }
+
+    #[test]
+    fn render_callout_overlay_html_omits_data_callout_align_when_no_option() {
+        // The negative case: a callout WITHOUT --align=... gets no data
+        // attribute. The runtime JS then uses viewport-aware detection.
+        let content = "```yaml\n# CALLOUT: regular A body with default alignment.\n```\n";
+        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        assert!(
+            !out.contains("data-callout-align"),
+            "entry must not carry data-callout-align when --align is not set; got:\n{out}",
+        );
+    }
 }

The runtime change is in assets/mdbook-listings.js: the adjustPopoverPositioning loop reads entry.dataset.calloutAlign at the top of each iteration and short-circuits to the pinned side before the gutter math runs. The shipped behaviour for --align=left matches slice 3’s narrow-gutter fallback (body.left = 'auto', body.right = '2em', callout-entry--left-popover class on the entry to drive the arrow-pseudo overrides); for --align=right, the script clears any prior inline overrides so the CSS default takes over. A data-callout-popover-decision marker (author-left / author-right) is written on the entry for devtools diagnostics, matching the scheme slice 3 introduced for the viewport-aware decisions:

Listing 6.11
--- listings-js-v1
+++ listings-js-v2
@@ -56,7 +56,7 @@
  *    side/clamp choice live.
  *
  * Sentinel string used by unit tests to confirm the bundled bytes
- * are the expected build-time asset: mdbook-listings-js-v5
+ * are the expected build-time asset: mdbook-listings-js-v6
  */
 (function () {
   var LEFT_FALLBACK_THRESHOLD_EM = 16;
@@ -105,6 +105,31 @@
     document.querySelectorAll('.callout-entry').forEach(function (entry) {
       var body = entry.querySelector('.callout-body');
       if (!body) return;
+
+      // Per-callout author override (ch.6 slice 4): a `--align=left`
+      // option on the CALLOUT marker surfaces as `data-callout-align`
+      // on the entry. Pin the popover to that side regardless of
+      // available gutter — the author has signalled the right gutter
+      // isn't usable for THIS specific callout (sidebar, narrow
+      // viewport, badge near the page edge, etc.).
+      var authorAlign = entry.dataset.calloutAlign;
+      if (authorAlign === 'left') {
+        body.style.left = 'auto';
+        body.style.right = '2em';
+        body.style.maxWidth = '';
+        entry.classList.add('callout-entry--left-popover');
+        entry.dataset.calloutPopoverDecision = 'author-left';
+        return;
+      }
+      if (authorAlign === 'right') {
+        entry.classList.remove('callout-entry--left-popover');
+        body.style.left = '';
+        body.style.right = '';
+        body.style.maxWidth = '';
+        entry.dataset.calloutPopoverDecision = 'author-right';
+        return;
+      }
+
       // `em` for non-font properties resolves against the ELEMENT'S
       // OWN font-size. The popover has `font-size: 0.9em` and mdbook
       // uses `html { font-size: 62.5% }`, so the popover's resolved

The JS_ASSET_SENTINEL constant in src/install.rs bumps v5→v6 so the bundled-asset check catches the new shape:

Listing 6.12
--- install-v10
+++ install-v11
@@ -14,7 +14,7 @@
 /// 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-v5";
-pub const JS_ASSET_SENTINEL: &str = "mdbook-listings-js-v5";
+pub const JS_ASSET_SENTINEL: &str = "mdbook-listings-js-v6";
 
 /// Shared between the writer and the registrar so the two can't drift.
 pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

Tests added in this slice:

  • Six new lib tests in src/callout.rs cover the parser: parse_callout_marker_parses_align_left_option, parse_callout_marker_parses_align_right_option, parse_callout_marker_no_options_leaves_map_empty, parse_callout_marker_unknown_option_is_passed_through, parse_callout_marker_token_without_equals_ends_option_parsing, parse_callout_marker_option_with_no_body_keeps_body_none.
  • Two new lib tests cover the HTML emission: render_callout_overlay_html_emits_data_callout_align_when_align_option_set and ..._omits_data_callout_align_when_no_option.
  • One new e2e test in tests/e2e_callouts.rs: callout_with_align_left_option_pins_popover_left_even_on_wide_viewport drives the end-to-end path. The viewport is set to 1800×800 (wide enough that slice 3’s default would open right), the badge from the snippet above is hovered, and the assertion checks both entry.dataset.calloutAlign === 'left' and body.right <= badge.left + 1 — proving the author override beats viewport-aware auto-detection.
Listing 6.13
--- e2e-callouts-v10
+++ e2e-callouts-v11
@@ -6,6 +6,7 @@
 use common::e2e_harness::with_traced_chapter;
 
 const CH05: &str = "ch05-render-inline-callouts";
+const CH06: &str = "ch06-dogfooding-polish";
 
 /// Wait for the page to lay out + the popover-positioning JS to re-run
 /// after a viewport change. `set_viewport_size` fires a `resize` event;
@@ -685,3 +686,67 @@
     )
     .await;
 }
+
+#[tokio::test]
+async fn callout_with_align_left_option_pins_popover_left_even_on_wide_viewport() {
+    // ch.6 slice 4: the `--align=left` per-callout option in the
+    // CALLOUT marker (e.g. `// CALLOUT: lbl --align=left Body.`)
+    // surfaces as `data-callout-align="left"` on the entry. The JS
+    // short-circuits viewport-aware detection and pins the popover
+    // to the left (over the listing) regardless of available right
+    // gutter. The fixture is a snippet in ch.6's narrative carrying
+    // a marker that uses the option.
+    with_traced_chapter(
+        "callout_with_align_left_option_pins_popover_left_even_on_wide_viewport",
+        CH06,
+        |page| async move {
+            // 1800×800: viewport plenty wide enough for right-opening
+            // at full max-width. The override must beat the default.
+            page.set_viewport_size(Viewport {
+                width: 1800,
+                height: 800,
+            })
+            .await
+            .expect("set wide viewport");
+            wait_for_layout_recalc(&page).await;
+
+            let badge = page
+                .locator(locator!("button#callout-align-left-demo"))
+                .await;
+            badge
+                .scroll_into_view_if_needed()
+                .await
+                .expect("scroll badge into view");
+            badge.hover(None).await.expect("hover badge");
+            let body = page
+                .locator(locator!("#callout-body-align-left-demo"))
+                .await;
+            expect(body)
+                .to_be_visible()
+                .await
+                .expect("body popover must be visible after hover");
+
+            let report: String = page
+                .evaluate_value(
+                    r#"(() => {
+                      const badge = document.querySelector('button#callout-align-left-demo');
+                      const body = document.querySelector('#callout-body-align-left-demo');
+                      const entry = badge.closest('.callout-entry');
+                      const badgeBox = badge.getBoundingClientRect();
+                      const bodyBox = body.getBoundingClientRect();
+                      if (entry?.dataset.calloutAlign !== 'left') {
+                        return `entry data-callout-align should be "left", got ${entry?.dataset.calloutAlign}`;
+                      }
+                      if (bodyBox.right > badgeBox.left + 1) {
+                        return `body.right=${bodyBox.right.toFixed(1)} > badge.left=${badgeBox.left.toFixed(1)} (popover did not pin LEFT despite --align=left override)`;
+                      }
+                      return 'ok';
+                    })()"#,
+                )
+                .await
+                .expect("evaluate body-vs-badge layout");
+            assert_eq!(report, "ok", "--align=left override regression");
+        },
+    )
+    .await;
+}

Slice 5 — freeze output closes the authoring loop

The symptom: every successful mdbook-listings freeze printed a single line — created: <tag> (or unchanged, or replaced) — and then went silent. To actually USE the frozen listing the author then had to either remember the include directive’s exact shape ({{#include listings/<tag>.<ext>}}) or grep listings.toml for the path. AND, since most freezes in this book are versioned (callout-v6, callout-v7, callout-v8 …), almost every freeze in a slice is paired with a {{#diff <prev> <new>}} directive that the author had to likewise remember or grep for. Per-freeze friction × two; surfaced on every chapter slice this book wrote.

Slice 5 makes freeze print all three on every successful outcome — verb + tag (as before), the frozen path, the ready-to-paste {{#include …}} directive, and (when a prior listing exists for the same source path) the matching {{#diff …}} directive. The new output:

$ mdbook-listings freeze --tag callout-v8 ../src/callout.rs
created: callout-v8
  frozen:  src/listings/callout-v8.rs
  include: {{#include listings/callout-v8.rs}}
  diff:    {{#diff callout-v7 callout-v8}}

The first freeze of a source path skips the diff: line (there is no prior). Re-running freeze on an unchanged source prints all available lines (unchanged: <tag> + path + include + diff if a prior exists) — re-runs are a real “give me the directives again” workflow, and there’s no reason to make the author repeat the freeze invocation to see them.

Two implementation details earn a note:

  • The include path drops the src/ prefix that the on-disk path carries: mdbook resolves {{#include …}} relative to the chapter file, which already lives under src/. So src/listings/demo.rs on disk becomes listings/demo.rs inside the directive.
  • The “prior listing” lookup is source-based, not tag-based: walk the manifest in reverse insertion order and find the most-recent listing whose source = ... matches and whose tag isn’t the just-frozen one. No tag-convention parsing, no <basename>-v<N> heuristic — that means the suggestion works for any naming scheme an author uses (and stays quiet when the manifest has no candidate). The trade-off: if the author has frozen the same source under unrelated tag names (first-cutsecond-attemptfinal), the diff target might surprise them. The escape valve is just to ignore the suggestion.

The production-code change is in src/freeze.rs (frozen_relative_path is made pub so the CLI can recover the disk path; freeze now returns a FreezeReport struct carrying the outcome plus an optional previous_tag; new previous_listing_for_source helper does the reverse-iteration manifest lookup) and src/main.rs (the three new println! lines + the strip-src/ derivation + the conditional diff line):

Listing 6.14
--- freeze-v1
+++ freeze-v2
@@ -36,9 +36,21 @@
     Replaced,
 }
 
+/// Result of a freeze invocation: the outcome plus optional metadata the CLI
+/// surfaces in its success block (most-recent prior tag for the same source,
+/// when one exists).
+#[derive(Debug)]
+pub struct FreezeReport {
+    pub outcome: FreezeOutcome,
+    /// Most-recent listing in the manifest with the same `source` path as
+    /// the just-frozen tag (excluding the just-frozen tag itself). `None`
+    /// when this is the first listing for the source.
+    pub previous_tag: Option<String>,
+}
+
 /// Freeze `opts.source` into `<book_root>/src/listings/<tag>.<ext>` and upsert
 /// the corresponding entry in `<book_root>/listings.toml`.
-pub fn freeze(opts: FreezeOptions<'_>) -> Result<FreezeOutcome> {
+pub fn freeze(opts: FreezeOptions<'_>) -> Result<FreezeReport> {
     let source_bytes = fs::read(opts.source)
         .with_context(|| format!("reading source file {}", opts.source.display()))?;
     let source_sha = hex_sha256(&source_bytes);
@@ -62,6 +74,13 @@
         None => FreezeOutcome::Created,
     };
 
+    let source_rel = relativize(opts.source, opts.book_root);
+    let source_rel_str = path_to_string(&source_rel)?;
+    // Compute prior-tag BEFORE upsert so the just-frozen tag can't match
+    // itself in the `Replaced` case.
+    let previous_tag = previous_listing_for_source(&manifest, &source_rel_str, opts.tag)
+        .map(|l| l.tag.clone());
+
     if outcome != FreezeOutcome::Unchanged {
         if let Some(parent) = frozen_abs.parent() {
             fs::create_dir_all(parent).with_context(|| {
@@ -71,20 +90,40 @@
         fs::write(&frozen_abs, &source_bytes)
             .with_context(|| format!("writing frozen file {}", frozen_abs.display()))?;
 
-        let source_rel = relativize(opts.source, opts.book_root);
         manifest.upsert(Listing {
             tag: opts.tag.to_string(),
-            source: path_to_string(&source_rel)?,
+            source: source_rel_str,
             frozen: path_to_string(&frozen_rel)?,
             sha256: source_sha,
         });
         manifest.save(opts.book_root)?;
     }
 
-    Ok(outcome)
+    Ok(FreezeReport {
+        outcome,
+        previous_tag,
+    })
 }
 
-fn frozen_relative_path(tag: &str, source: &Path) -> Result<PathBuf> {
+/// Walk the manifest's entries in reverse insertion order and return the
+/// first listing whose `source` matches `source_rel_str` and whose `tag`
+/// is NOT `current_tag`. Pub so the CLI can derive a diff-directive
+/// suggestion after a successful freeze.
+pub fn previous_listing_for_source<'m>(
+    manifest: &'m Manifest,
+    source_rel_str: &str,
+    current_tag: &str,
+) -> Option<&'m Listing> {
+    manifest
+        .listings
+        .iter()
+        .rev()
+        .find(|l| l.source == source_rel_str && l.tag != current_tag)
+}
+
+/// Pub so the CLI can echo the path on every successful freeze without
+/// re-deriving the format from CLI args.
+pub fn frozen_relative_path(tag: &str, source: &Path) -> Result<PathBuf> {
     if tag.is_empty() {
         bail!("tag must be non-empty");
     }
@@ -178,4 +217,69 @@
     fn frozen_path_rejects_extensionless_source() {
         assert!(frozen_relative_path("tag", Path::new("Makefile")).is_err());
     }
+
+    fn listing(tag: &str, source: &str) -> Listing {
+        Listing {
+            tag: tag.to_string(),
+            source: source.to_string(),
+            frozen: format!("src/listings/{tag}.rs"),
+            sha256: String::new(),
+        }
+    }
+
+    #[test]
+    fn previous_listing_returns_none_when_manifest_empty() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![],
+        };
+        assert!(previous_listing_for_source(&m, "../src/foo.rs", "foo-v1").is_none());
+    }
+
+    #[test]
+    fn previous_listing_returns_none_when_no_prior_matches_source() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![listing("bar-v1", "../src/bar.rs")],
+        };
+        assert!(previous_listing_for_source(&m, "../src/foo.rs", "foo-v1").is_none());
+    }
+
+    #[test]
+    fn previous_listing_returns_none_when_only_match_is_current_tag() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![listing("foo-v1", "../src/foo.rs")],
+        };
+        assert!(previous_listing_for_source(&m, "../src/foo.rs", "foo-v1").is_none());
+    }
+
+    #[test]
+    fn previous_listing_returns_most_recent_prior_for_same_source() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![
+                listing("foo-v1", "../src/foo.rs"),
+                listing("bar-v1", "../src/bar.rs"),
+                listing("foo-v2", "../src/foo.rs"),
+                listing("baz-v1", "../src/baz.rs"),
+            ],
+        };
+        let prev = previous_listing_for_source(&m, "../src/foo.rs", "foo-v3").unwrap();
+        assert_eq!(prev.tag, "foo-v2");
+    }
+
+    #[test]
+    fn previous_listing_skips_current_tag_and_picks_next_most_recent() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![
+                listing("foo-v1", "../src/foo.rs"),
+                listing("foo-v2", "../src/foo.rs"),
+                listing("foo-v3", "../src/foo.rs"),
+            ],
+        };
+        let prev = previous_listing_for_source(&m, "../src/foo.rs", "foo-v3").unwrap();
+        assert_eq!(prev.tag, "foo-v2");
+    }
 }
Listing 6.15
--- main-v10
+++ main-v11
@@ -5,7 +5,7 @@
 use clap::{Parser, Subcommand};
 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::freeze::{FreezeOptions, FreezeOutcome, freeze, frozen_relative_path};
 use mdbook_listings::include::splice_chapter as splice_includes;
 use mdbook_listings::install::{InstallOutcome, ensure_assets_fresh, install};
 use mdbook_listings::manifest::Manifest;
@@ -102,18 +102,31 @@
             source,
         }) => {
             let book_root = book_root.unwrap_or_else(|| PathBuf::from("."));
-            let outcome = freeze(FreezeOptions {
+            let report = freeze(FreezeOptions {
                 book_root: &book_root,
                 tag: &tag,
                 source: &source,
                 force,
             })?;
-            let verb = match outcome {
+            let verb = match report.outcome {
                 FreezeOutcome::Created => "created",
                 FreezeOutcome::Unchanged => "unchanged",
                 FreezeOutcome::Replaced => "replaced",
             };
             println!("{verb}: {tag}");
+            let frozen_rel = frozen_relative_path(&tag, &source)?;
+            // Include directive is resolved relative to the chapter file,
+            // which already sits under `src/`; the on-disk path carries
+            // the `src/` prefix that the directive must drop.
+            let include_rel = frozen_rel
+                .strip_prefix("src")
+                .map(std::path::Path::to_path_buf)
+                .unwrap_or_else(|_| frozen_rel.clone());
+            println!("  frozen:  {}", frozen_rel.display());
+            println!("  include: \{{{{#include {}}}}}", include_rel.display());
+            if let Some(prev) = report.previous_tag {
+                println!("  diff:    \{{{{#diff {prev} {tag}}}}}");
+            }
             Ok(())
         }
         Some(Command::Verify { book_root: _ }) => {

Tests added in this slice:

  • Five new lib tests in src/freeze.rs cover previous_listing_for_source: empty manifest, no matching source, only-match-is-current-tag, picking the most-recent prior, and skipping the current tag when multiple matches exist.
  • Five new CLI integration tests in tests/freeze.rs cover the end-to-end output shape across all three FreezeOutcome variants plus both diff-suggestion cases (prior exists, no prior). The pre-existing freeze_rejects_* tests still pass — failures only ever wrote to stderr, so the new stdout lines don’t affect them.
Listing 6.16
--- freeze-tests-v1
+++ freeze-tests-v2
@@ -4,6 +4,7 @@
 
 use std::fs;
 
+use predicates::prelude::*;
 use predicates::str::contains;
 use tempfile::TempDir;
 
@@ -68,3 +69,142 @@
         .failure()
         .stderr(contains("already frozen"));
 }
+
+/// Without the frozen path + `{{#include …}}` lines, the author has to grep
+/// `listings.toml` after every freeze to learn what to paste into the chapter.
+#[test]
+fn freeze_prints_frozen_path_and_include_directive_on_created() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "demo", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("created: demo"))
+        .stdout(contains("src/listings/demo.yaml"))
+        .stdout(contains("{{#include listings/demo.yaml}}"));
+}
+
+/// Re-running freeze without source changes is a common "give me the include
+/// line again" path; the `Unchanged` outcome must surface the same supplement.
+#[test]
+fn freeze_prints_frozen_path_and_include_directive_on_unchanged() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "demo", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "demo", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("unchanged: demo"))
+        .stdout(contains("src/listings/demo.yaml"))
+        .stdout(contains("{{#include listings/demo.yaml}}"));
+}
+
+/// When a prior listing exists for the same source path, the CLI also prints
+/// a ready-to-paste `{{#diff <prev> <new>}}` line — the second piece of
+/// per-freeze friction (every versioned freeze in this book pairs a new
+/// include with a new diff against the prior version).
+#[test]
+fn freeze_prints_diff_suggestion_when_prior_listing_exists_for_same_source() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "compose-v1", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(predicates::str::contains("diff:").not());
+
+    fs::write(&source, "a: 2\n").unwrap();
+    mdbook_listings()
+        .args(["freeze", "--tag", "compose-v2", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("created: compose-v2"))
+        .stdout(contains("{{#diff compose-v1 compose-v2}}"));
+}
+
+/// The diff suggestion only fires for prior listings of the SAME source. A
+/// fresh source path without prior listings stays quiet — no false-positive
+/// diff against an unrelated tag.
+#[test]
+fn freeze_omits_diff_suggestion_when_no_prior_listing_for_same_source() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let other_source = tmp.path().join("other.yaml");
+    fs::write(&other_source, "x: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "other-v1", "--book-root"])
+        .arg(&book_root)
+        .arg(&other_source)
+        .assert()
+        .success();
+
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+    mdbook_listings()
+        .args(["freeze", "--tag", "compose-v1", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("created: compose-v1"))
+        .stdout(predicates::str::contains("diff:").not());
+}
+
+/// A re-frozen tag must be just as discoverable as a freshly created one;
+/// the `Replaced` outcome must surface the same supplement.
+#[test]
+fn freeze_prints_frozen_path_and_include_directive_on_replaced() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "demo", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success();
+
+    fs::write(&source, "a: 2\n").unwrap();
+    mdbook_listings()
+        .args(["freeze", "--tag", "demo", "--force", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("replaced: demo"))
+        .stdout(contains("src/listings/demo.yaml"))
+        .stdout(contains("{{#include listings/demo.yaml}}"));
+}

Slice 6 — mdbook-listings list subcommand

The symptom: a book accumulates [[listing]] entries over time — this book has 90+ as of slice 6. The author had to cat (or grep) listings.toml to answer basic questions like “what tags exist for this source file?” or “which freeze versions have I created?” The manifest is TOML, which is fine for editing but noisy to scan: every entry is four lines ([[listing]], tag, source, frozen, sha256), most of which is repeated boilerplate.

Slice 6 adds a list subcommand that prints one tab-separated row per listing:

$ mdbook-listings list
callout-v6      src/listings/callout-v6.rs      ../src/callout.rs
callout-v7      src/listings/callout-v7.rs      ../src/callout.rs
callout-v8      src/listings/callout-v8.rs      ../src/callout.rs
e2e-callouts-v9 src/listings/e2e-callouts-v9.rs ../tests/e2e_callouts.rs
...

Three columns: tag, frozen-path (book-root-relative), source-path (book-root-relative as recorded by the most recent freeze). Order matches manifest insertion order — most recently added at the bottom, giving chronological awareness without a separate timestamp column. No filtering, sorting, or formatting options yet; the basic catalogue view is enough for the workflows that surfaced the gap, and awk / grep / column -t handle the rest from a tab-separated stream.

Design choices that earn a note:

  • Tab-separated, no header. Pipe-friendly by default; an author who wants headers can pipe through column -t -N tag,frozen, source. Adding a header here would force every script consumer to skip line 1.
  • Empty manifest prints nothing. No “no listings recorded” banner. Stays quiet and predictable for scripts that test command exit status + line count.
  • Insertion order, not alphabetical. Most-recent-at-bottom matches the visual rhythm of git log and tail -f — the reader’s eye trains on the bottom as “what just happened.” Alphabetical sort would scatter v1/v2/v3 if the author re-runs freeze months apart for unrelated source files.

The production-code change is in src/main.rs: a new Command::List variant on the enum plus a four-line handler that loads the manifest and iterates its listings vector:

Listing 6.17
--- main-v12
+++ main-v13
@@ -68,6 +68,15 @@
         #[arg(long)]
         book_root: Option<PathBuf>,
     },
+
+    /// List frozen listings recorded in `listings.toml`. Prints one
+    /// tab-separated row per entry: `<tag>\t<frozen-path>\t<source-path>`.
+    /// Order matches manifest insertion order.
+    List {
+        /// Root directory of the book. Defaults to the current directory.
+        #[arg(long)]
+        book_root: Option<PathBuf>,
+    },
 }
 
 fn main() {
@@ -140,6 +149,14 @@
         Some(Command::Verify { book_root: _ }) => {
             anyhow::bail!("`mdbook-listings verify` is not yet implemented")
         }
+        Some(Command::List { book_root }) => {
+            let book_root = book_root.unwrap_or_else(|| PathBuf::from("."));
+            let manifest = Manifest::load(&book_root)?;
+            for listing in &manifest.listings {
+                println!("{}\t{}\t{}", listing.tag, listing.frozen, listing.source);
+            }
+            Ok(())
+        }
     }
 }
 

Tests added in this slice (all in tests/list.rs, a new file):

  • list_prints_nothing_when_manifest_is_empty — empty-manifest contract: stdout is empty, exit success.
  • list_prints_one_tab_separated_row_per_listing_in_insertion_order — happy path: two freezes, two rows, in the order they were inserted.
  • list_source_column_matches_manifest_normalised_path — the source column is the same forward-slash-normalised string the manifest records, not a re-stringified Path (which would re-introduce the Windows backslash bug fixed in the slice 5 follow-up commit).
Listing 6.18
#![allow(unused)]
fn main() {
//! Integration tests for the `mdbook-listings list` subcommand.
//! Each test runs the binary via `assert_cmd` against a tempdir book root
//! and asserts on stdout shape.

use std::fs;

use predicates::prelude::*;
use predicates::str::contains;
use tempfile::TempDir;

mod common;
use common::mdbook_listings;

/// An empty manifest produces no output rows. Stays quiet rather than
/// printing a header or "no listings" banner — keeps the command pipe-
/// friendly and predictable across script consumers.
#[test]
fn list_prints_nothing_when_manifest_is_empty() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path().join("book");
    fs::create_dir_all(&book_root).unwrap();

    mdbook_listings()
        .args(["list", "--book-root"])
        .arg(&book_root)
        .assert()
        .success()
        .stdout(predicate::str::is_empty());
}

/// One row per listing, in manifest insertion order, tab-separated:
/// `<tag>\t<frozen-relative-path>\t<source-relative-path>`. The order
/// matches `listings.toml`'s `[[listing]]` order so the most-recently-
/// added entries land at the bottom — chronological awareness without
/// requiring a separate timestamp field.
#[test]
fn list_prints_one_tab_separated_row_per_listing_in_insertion_order() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path().join("book");
    fs::create_dir_all(&book_root).unwrap();
    let source_a = tmp.path().join("a.yaml");
    let source_b = tmp.path().join("b.yaml");
    fs::write(&source_a, "a: 1\n").unwrap();
    fs::write(&source_b, "b: 2\n").unwrap();

    mdbook_listings()
        .args(["freeze", "--tag", "a-v1", "--book-root"])
        .arg(&book_root)
        .arg(&source_a)
        .assert()
        .success();
    mdbook_listings()
        .args(["freeze", "--tag", "b-v1", "--book-root"])
        .arg(&book_root)
        .arg(&source_b)
        .assert()
        .success();

    mdbook_listings()
        .args(["list", "--book-root"])
        .arg(&book_root)
        .assert()
        .success()
        .stdout(predicate::str::starts_with(
            "a-v1\tsrc/listings/a-v1.yaml\t",
        ))
        .stdout(contains("\nb-v1\tsrc/listings/b-v1.yaml\t"));
}

/// The source column carries the same string the manifest recorded — the
/// relative path from the book root that `freeze` computed via its own
/// path normaliser. Important: forward slashes regardless of OS, matching
/// the rest of the book's directive shape.
#[test]
fn list_source_column_matches_manifest_normalised_path() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path().join("book");
    fs::create_dir_all(&book_root).unwrap();
    let source = tmp.path().join("compose.yaml");
    fs::write(&source, "a: 1\n").unwrap();

    mdbook_listings()
        .args(["freeze", "--tag", "compose-v1", "--book-root"])
        .arg(&book_root)
        .arg(&source)
        .assert()
        .success();

    mdbook_listings()
        .args(["list", "--book-root"])
        .arg(&book_root)
        .assert()
        .success()
        // The third column is the source path — content depends on the
        // tempdir layout, but the row shape is fixed: three tab-separated
        // columns ending in a newline, and the source must end in
        // `compose.yaml`.
        .stdout(predicate::str::is_match(r"^compose-v1\tsrc/listings/compose-v1\.yaml\t.+/compose\.yaml\n$").unwrap());
}
}

Slice 7 — install idempotency

The symptom: re-running mdbook-listings install against an already-configured book LOOKED idempotent — the same registrations were already in book.toml, the same asset bytes were on disk — but the contract had never been pinned. An author who’d run install once and was about to run it again would reasonably wonder: “will this duplicate the additional-css entry? will it clobber my hand-edited book.toml ordering? is there a flag I’m supposed to pass for re-installs?” The CLI gave no signal either way.

The slice 2 refactor (preprocessor refreshes assets on every build) had already made the IMPLEMENTATION idempotent as a precondition for per-build refresh — ensure_assets_fresh and ensure_gitignore both short-circuit when bytes already match, and the toml_edit-based register_listings_* methods don’t append duplicates. What slice 7 adds is the contract: a pinned test that a second install returns InstallOutcome::Unchanged and writes nothing, and a CLI integration test that the “already installed; nothing changed” message lands on stdout.

The CLI output:

$ mdbook-listings install --book-root book
installed mdbook-listings into book

$ mdbook-listings install --book-root book
mdbook-listings already installed in book; nothing changed

Production-code change in this slice: none. The InstallOutcome enum, the install() function’s three-way OR over toml/asset/ gitignore changes, and the main.rs match-arm that selects the “already installed” message were all in place after slice 2. What was missing was the contract pin: tests that lock the behaviour in place so a future refactor that accidentally re-enables duplicate registration would fail loudly.

Two new tests in tests/install.rs:

  • install_on_fully_configured_book_is_noop_and_returns_unchanged — lib-level: first install returns Installed, second returns Unchanged, and both book.toml and .gitignore are byte- identical between calls. The byte-equality check catches a whole class of “almost-idempotent” regressions (e.g. a future TOML re-serialiser that normalises whitespace would change bytes silently; this test would fail and force the contract to be reconsidered).
  • install_command_prints_already_installed_on_second_run — CLI-level: the friendly message reaches stdout, both invocations exit success. Pins the downstream signal.
Listing 6.19
--- install-tests-v5
+++ install-tests-v6
@@ -4,8 +4,8 @@
 use std::path::{Path, PathBuf};
 
 use mdbook_listings::install::{
-    CSS_ASSET, CSS_ASSET_FILENAME, JS_ASSET, JS_ASSET_FILENAME, ensure_assets_fresh,
-    ensure_gitignore,
+    CSS_ASSET, CSS_ASSET_FILENAME, GITIGNORE_FILENAME, InstallOutcome, JS_ASSET, JS_ASSET_FILENAME,
+    ensure_assets_fresh, ensure_gitignore, install,
 };
 use predicates::str::contains;
 use tempfile::TempDir;
@@ -259,3 +259,212 @@
     let gitignore = fs::read_to_string(tmp.path().join(".gitignore")).expect(".gitignore");
     assert_eq!(gitignore, existing, ".gitignore must be byte-identical");
 }
+
+// ---------------------------------------------------------------------
+// Targeted regression tests that close out MUTATION_DEBT.md entries
+// from `scripts/mutants.sh 6e07b6a~1`. Each one pins a boolean path
+// the prior tests left ambiguous, so the corresponding mutation in
+// src/install.rs is now CAUGHT.
+// ---------------------------------------------------------------------
+
+/// `ensure_assets_fresh` returns `true` when only ONE asset was stale.
+/// Without this test, the return expression `!css_already_correct ||
+/// !js_already_correct` could be mutated to `&&` and survive — the
+/// existing tests only exercise both-stale or both-correct.
+/// Closes MUTATION_DEBT.md src/install.rs L57:29.
+#[test]
+fn ensure_assets_fresh_reports_write_when_only_one_asset_is_stale() {
+    let tmp = TempDir::new().expect("tempdir");
+    // CSS is correct (matches bundled bytes), JS is stale.
+    fs::write(tmp.path().join(CSS_ASSET_FILENAME), CSS_ASSET).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 only one of the two assets was stale"
+    );
+}
+
+/// `ensure_gitignore` inserts a separator newline when the existing
+/// content lacks a trailing one. Without this test, the
+/// `!new_contents.ends_with('\n')` check could be mutated (delete `!`
+/// or swap `&&` for `||`) and the entries would be jammed onto the
+/// previous line. Closes MUTATION_DEBT.md src/install.rs L77:8 and
+/// L77:36 (both `delete !` mutations on the same line).
+#[test]
+fn ensure_gitignore_inserts_separator_when_existing_file_lacks_trailing_newline() {
+    let tmp = TempDir::new().expect("tempdir");
+    // No trailing newline on the existing entry.
+    fs::write(tmp.path().join(GITIGNORE_FILENAME), "target/").unwrap();
+
+    ensure_gitignore(tmp.path()).expect("ensure_gitignore");
+
+    let gitignore = fs::read_to_string(tmp.path().join(GITIGNORE_FILENAME)).expect(".gitignore");
+    let expected = format!("target/\n{CSS_ASSET_FILENAME}\n{JS_ASSET_FILENAME}\n");
+    assert_eq!(
+        gitignore, expected,
+        "existing line without trailing newline must get a separator before the new entries"
+    );
+}
+
+/// `ensure_gitignore` does NOT insert a second newline when the
+/// existing content already ends with one. Without this test, the
+/// `&&` in the separator-insert guard could be mutated to `||` and
+/// produce a stray blank line. Closes MUTATION_DEBT.md src/install.rs
+/// L77:33 (`replace && with ||`).
+#[test]
+fn ensure_gitignore_does_not_double_newline_when_existing_file_ends_with_newline() {
+    let tmp = TempDir::new().expect("tempdir");
+    fs::write(tmp.path().join(GITIGNORE_FILENAME), "target/\n").unwrap();
+
+    ensure_gitignore(tmp.path()).expect("ensure_gitignore");
+
+    let gitignore = fs::read_to_string(tmp.path().join(GITIGNORE_FILENAME)).expect(".gitignore");
+    let expected = format!("target/\n{CSS_ASSET_FILENAME}\n{JS_ASSET_FILENAME}\n");
+    assert_eq!(
+        gitignore, expected,
+        "trailing newline on existing content must NOT trigger a duplicate; got:\n{gitignore:?}"
+    );
+}
+
+/// `install` reports `Installed` when only `book.toml` needed
+/// rewriting (assets already match bundled bytes, `.gitignore`
+/// already complete). Catches the `||` → `&&` mutation on the first
+/// operand in the install-outcome decision. Closes
+/// MUTATION_DEBT.md src/install.rs L119:24.
+#[test]
+fn install_reports_installed_when_only_book_toml_needs_change() {
+    let book = MinimalFixtureBook::new();
+    // Pre-seed assets at the bundled bytes and a complete .gitignore
+    // so ensure_assets_fresh + ensure_gitignore both return false.
+    fs::write(book.root().join(CSS_ASSET_FILENAME), CSS_ASSET).unwrap();
+    fs::write(book.root().join(JS_ASSET_FILENAME), JS_ASSET).unwrap();
+    fs::write(
+        book.root().join(GITIGNORE_FILENAME),
+        format!("{CSS_ASSET_FILENAME}\n{JS_ASSET_FILENAME}\n"),
+    )
+    .unwrap();
+
+    let outcome = install(book.root()).expect("install");
+
+    assert_eq!(
+        outcome,
+        InstallOutcome::Installed,
+        "book.toml-only change should still report Installed"
+    );
+}
+
+/// `install` reports `Installed` when only the asset bytes needed
+/// refreshing (book.toml + `.gitignore` already correct). Catches the
+/// `||` → `&&` mutation on the second-operand pair in the
+/// install-outcome decision. Closes MUTATION_DEBT.md src/install.rs
+/// L119:42.
+#[test]
+fn install_reports_installed_when_only_assets_need_change() {
+    let book = MinimalFixtureBook::new();
+    // First, run a full install so book.toml + .gitignore are
+    // configured and the assets land at the bundled bytes.
+    install(book.root()).expect("seed install");
+    // Now corrupt the on-disk assets so ensure_assets_fresh will
+    // overwrite them, but leave book.toml + .gitignore alone.
+    fs::write(book.root().join(CSS_ASSET_FILENAME), b"/* stale */").unwrap();
+    fs::write(book.root().join(JS_ASSET_FILENAME), b"// stale\n").unwrap();
+
+    let outcome = install(book.root()).expect("second install");
+
+    assert_eq!(
+        outcome,
+        InstallOutcome::Installed,
+        "asset-only refresh should report Installed"
+    );
+}
+
+/// `install` distinguishes a *missing* `book.toml` (NotFound — its own
+/// friendly bail) from any other IO error (must surface the underlying
+/// error so the author isn't told to re-init when the real problem is
+/// e.g. unreadable bytes). Without this test, the `match` guard
+/// `e.kind() == ErrorKind::NotFound` could be mutated to `true` and
+/// every IO error would silently route to the NotFound bail. Closes
+/// MUTATION_DEBT.md src/install.rs L94:19.
+#[test]
+fn install_routes_non_notfound_io_errors_to_the_generic_arm() {
+    let book = MinimalFixtureBook::new();
+    // Overwrite the seeded book.toml with invalid UTF-8 — fs::read_to_string
+    // then returns io::ErrorKind::InvalidData, provably not NotFound.
+    fs::write(book.root().join("book.toml"), [0xff, 0xfe, 0xfd]).unwrap();
+
+    let err = install(book.root()).expect_err("install should error on bad UTF-8");
+    let msg = format!("{err:#}");
+
+    assert!(
+        msg.contains("reading book config"),
+        "expected the non-NotFound IO arm's context (\"reading book config at ...\"); got: {msg}",
+    );
+    assert!(
+        !msg.contains("not found"),
+        "a non-NotFound IO error must not be misreported as a missing file; got: {msg}",
+    );
+}
+
+/// A re-install on a fully-configured book is a no-op: book.toml, both
+/// asset files, and .gitignore are already correct, so install() returns
+/// `Unchanged` and writes nothing. Pins the per-AC contract that a user
+/// can re-run install at any time without fearing duplicate registrations
+/// or content churn.
+#[test]
+fn install_on_fully_configured_book_is_noop_and_returns_unchanged() {
+    let book = MinimalFixtureBook::new();
+
+    let first = install(book.root()).expect("first install");
+    assert_eq!(
+        first,
+        InstallOutcome::Installed,
+        "first install on a fresh book must change state"
+    );
+
+    let book_toml_before = fs::read_to_string(book.root().join("book.toml")).unwrap();
+    let gitignore_before = fs::read_to_string(book.root().join(GITIGNORE_FILENAME)).unwrap();
+
+    let second = install(book.root()).expect("second install");
+    assert_eq!(
+        second,
+        InstallOutcome::Unchanged,
+        "second install on an already-configured book must report Unchanged"
+    );
+
+    let book_toml_after = fs::read_to_string(book.root().join("book.toml")).unwrap();
+    let gitignore_after = fs::read_to_string(book.root().join(GITIGNORE_FILENAME)).unwrap();
+    assert_eq!(
+        book_toml_before, book_toml_after,
+        "book.toml must be byte-identical after a no-op re-install"
+    );
+    assert_eq!(
+        gitignore_before, gitignore_after,
+        ".gitignore must be byte-identical after a no-op re-install"
+    );
+}
+
+/// At the CLI surface, the second `install` invocation must exit success
+/// and print the friendly "already installed" message — the downstream
+/// signal that re-running install is safe and a no-op.
+#[test]
+fn install_command_prints_already_installed_on_second_run() {
+    let book = MinimalFixtureBook::new();
+
+    mdbook_listings()
+        .args(["install", "--book-root"])
+        .arg(book.root())
+        .assert()
+        .success()
+        .stdout(contains("installed mdbook-listings into"));
+
+    mdbook_listings()
+        .args(["install", "--book-root"])
+        .arg(book.root())
+        .assert()
+        .success()
+        .stdout(contains("already installed"))
+        .stdout(contains("nothing changed"));
+}

Slice 8 — default --tag derivation

The symptom: every mdbook-listings freeze invocation required the author to invent and type out a --tag. For a book that freezes the same source file repeatedly across slices (callout-v6, callout-v7, callout-v8, …), the v-suffix schema is so mechanical that “what’s the next tag?” is a question with a deterministic answer the tool should just compute. Forcing the human to compute it per-freeze is per- freeze friction that adds up.

Slice 8 makes --tag optional. When omitted, freeze derives a default from the source basename + the manifest’s existing entries for the same source:

$ mdbook-listings freeze ../src/callout.rs
created: callout-v8
  frozen:  src/listings/callout-v8.rs
  include: {{#include listings/callout-v8.rs}}
  diff:    {{#diff callout-v7 callout-v8}}

The derivation rule is intentionally narrow:

  • First freeze of a source (no prior listings): default to <basename>-v1. v is the canonical Rust convention; first author to freeze a given source establishes it without per- source configuration.
  • Prior listings exist with <basename>-<prefix><N> shape where <prefix> is one of v, ver, rev, version: default to <basename>-<prefix>(maxN + 1). The prefix is taken from the most-recently-inserted matching listing, so a mid-stream convention switch (started with v1, then moved to rev1/rev2) sticks with the new convention rather than silently flipping back.
  • Prior listings exist but NONE match the allowlist (the motivating case: t2t’s <basename>-ch<NN>-phase<N>): return an actionable error naming the existing scheme and directing the author to pass --tag explicitly. The CLI never silently picks a name that might conflict with the author’s own scheme.

Two non-obvious design choices:

  • Hyphen-separated allowlist, not “any trailing digits.” The prefix has to be one of v/ver/rev/version AND there has to be a hyphen between basename and prefix. A name like compose3 could be a typo for compose-v3, a deliberate name, or “compose for Postgres 3” — autopilot is the wrong call. Restricting to a known allowlist with a separator rules out the ambiguous cases.
  • Most-recent-prefix wins on mixed conventions. When the manifest has foo-v1, foo-v2, foo-rev3 (the author switched mid-stream), the next default is foo-rev4, not foo-v3. The author’s most recent choice is the better signal of present intent than max-N alone.

Production-code change in src/freeze.rs: new derive_default_tag function, supporting parse_version_suffix helper, VERSION_PREFIXES constant, and TagDerivationError enum with two variants (UnusableSourceName, UnrecognisedConvention).

Listing 6.20
--- freeze-v3
+++ freeze-v4
@@ -121,6 +121,135 @@
         .find(|l| l.source == source_rel_str && l.tag != current_tag)
 }
 
+/// Version-prefix tokens accepted when deriving a default tag.
+/// The set is deliberately small and hyphen-separated
+/// (`<basename>-v3`, `<basename>-rev3`) so that "compose3" or
+/// "draft7" — which could be deliberate names or typos — don't
+/// silently autopilot into a `compose4` / `draft8` suggestion the
+/// author didn't ask for.
+const VERSION_PREFIXES: &[&str] = &["v", "ver", "rev", "version"];
+
+/// Derive `<basename>-<prefix><N>` from the source path + manifest.
+///
+/// - If no prior listing exists for this source: returns
+///   `<basename>-v1` (the canonical Rust convention; first author
+///   to freeze a given source establishes `v` for it).
+/// - If prior listings exist and at least one matches
+///   `<basename>-<prefix><N>` where `<prefix>` is in
+///   [`VERSION_PREFIXES`]: returns `<basename>-<prefix>(maxN + 1)`,
+///   carrying the most-recently-added matching listing's prefix
+///   so a mid-stream switch (the author started with `v1`, then
+///   moved to `rev1`/`rev2`) keeps using the new convention.
+/// - If prior listings exist but none match the allowlist
+///   (e.g. t2t's `<basename>-ch<NN>-phase<N>`): returns
+///   `TagDerivationError::UnrecognisedConvention` so the author
+///   knows to pass `--tag` explicitly.
+///
+/// Pub so the CLI can attempt derivation when `--tag` is omitted.
+pub fn derive_default_tag(
+    manifest: &Manifest,
+    source: &Path,
+    book_root: &Path,
+) -> Result<String, TagDerivationError> {
+    let basename = source
+        .file_stem()
+        .and_then(|s| s.to_str())
+        .ok_or_else(|| TagDerivationError::UnusableSourceName {
+            source: source.display().to_string(),
+        })?;
+
+    let source_rel = relativize(source, book_root);
+    let source_rel_str = path_to_string(&source_rel)
+        .map_err(|_| TagDerivationError::UnusableSourceName {
+            source: source.display().to_string(),
+        })?;
+
+    let priors: Vec<&Listing> = manifest
+        .listings
+        .iter()
+        .filter(|l| l.source == source_rel_str)
+        .collect();
+
+    if priors.is_empty() {
+        return Ok(format!("{basename}-v1"));
+    }
+
+    let matches: Vec<(&str, u64)> = priors
+        .iter()
+        .filter_map(|l| parse_version_suffix(&l.tag, basename))
+        .collect();
+
+    if matches.is_empty() {
+        return Err(TagDerivationError::UnrecognisedConvention {
+            basename: basename.to_string(),
+            example_prior_tag: priors.last().map(|l| l.tag.clone()).unwrap_or_default(),
+        });
+    }
+
+    let max_n = matches.iter().map(|(_, n)| *n).max().expect("non-empty");
+    let prefix = matches.last().map(|(p, _)| *p).expect("non-empty");
+    Ok(format!("{basename}-{prefix}{}", max_n + 1))
+}
+
+/// Parse `<basename>-<prefix><N>` from `tag`. Returns `(prefix, N)`
+/// when the tag matches one of [`VERSION_PREFIXES`] and `N` is a
+/// non-negative integer; returns `None` otherwise. Pub for test
+/// access only.
+fn parse_version_suffix<'t>(tag: &'t str, basename: &str) -> Option<(&'t str, u64)> {
+    let after_basename = tag.strip_prefix(basename)?.strip_prefix('-')?;
+    for &prefix in VERSION_PREFIXES {
+        if let Some(rest) = after_basename.strip_prefix(prefix)
+            && let Ok(n) = rest.parse::<u64>()
+        {
+            // Slice the prefix back out of `tag` so we can return a
+            // reference into the original `&'t str` lifetime — runs
+            // from len(basename + '-') to that + len(prefix).
+            let start = basename.len() + 1;
+            let end = start + prefix.len();
+            return Some((&tag[start..end], n));
+        }
+    }
+    None
+}
+
+/// Errors raised by [`derive_default_tag`] when a default can't be
+/// produced. The CLI converts these into actionable messages directing
+/// the author to pass `--tag` explicitly.
+#[derive(Debug)]
+pub enum TagDerivationError {
+    /// The source path doesn't have a usable file stem (no name, or
+    /// non-UTF-8 bytes that can't be normalised).
+    UnusableSourceName { source: String },
+    /// Prior listings exist for this source but none match the
+    /// allowlist convention, so we can't safely guess the next tag.
+    UnrecognisedConvention {
+        basename: String,
+        example_prior_tag: String,
+    },
+}
+
+impl std::fmt::Display for TagDerivationError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TagDerivationError::UnusableSourceName { source } => write!(
+                f,
+                "source {source} has no usable file stem; pass --tag explicitly",
+            ),
+            TagDerivationError::UnrecognisedConvention {
+                basename,
+                example_prior_tag,
+            } => write!(
+                f,
+                "can't auto-derive a default tag: prior listings for `{basename}` use a \
+                 convention (`{example_prior_tag}`) that isn't `<basename>-(v|ver|rev|version)<N>`; \
+                 pass --tag explicitly",
+            ),
+        }
+    }
+}
+
+impl std::error::Error for TagDerivationError {}
+
 /// Pub so the CLI can echo the path on every successful freeze without
 /// re-deriving the format from CLI args.
 pub fn frozen_relative_path(tag: &str, source: &Path) -> Result<PathBuf> {
@@ -299,4 +428,158 @@
         let prev = previous_listing_for_source(&m, "../src/foo.rs", "foo-v3").unwrap();
         assert_eq!(prev.tag, "foo-v2");
     }
+
+    /// Empty manifest + first freeze for the source → `<basename>-v1`.
+    /// `v` is the canonical Rust convention; first author establishes it
+    /// without per-source configuration.
+    #[test]
+    fn derive_default_tag_returns_v1_when_no_prior_listing() {
+        let m = Manifest {
+            version: 1,
+            listings: vec![],
+        };
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let tag = derive_default_tag(&m, &source, &book_root).unwrap();
+        assert_eq!(tag, "foo-v1");
+    }
+
+    /// Single prior `<basename>-v1` → `<basename>-v2`. Most common case.
+    #[test]
+    fn derive_default_tag_bumps_single_prior_v_match() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![listing("foo-v1", &source_rel)],
+        };
+        let tag = derive_default_tag(&m, &source, &book_root).unwrap();
+        assert_eq!(tag, "foo-v2");
+    }
+
+    /// Multiple priors → bump from the HIGHEST N, not the count. The
+    /// `v3` here came after `v5`/`v7` in insertion order; the next
+    /// should still be `v8`, not `v4`.
+    #[test]
+    fn derive_default_tag_bumps_from_max_n_not_count() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![
+                listing("foo-v5", &source_rel),
+                listing("foo-v7", &source_rel),
+                listing("foo-v3", &source_rel),
+            ],
+        };
+        let tag = derive_default_tag(&m, &source, &book_root).unwrap();
+        assert_eq!(tag, "foo-v8");
+    }
+
+    /// `<basename>-rev<N>` honoured as an allowlist prefix.
+    #[test]
+    fn derive_default_tag_honours_rev_prefix() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![listing("foo-rev3", &source_rel)],
+        };
+        let tag = derive_default_tag(&m, &source, &book_root).unwrap();
+        assert_eq!(tag, "foo-rev4");
+    }
+
+    /// `<basename>-ver<N>` and `<basename>-version<N>` honoured too.
+    #[test]
+    fn derive_default_tag_honours_ver_and_version_prefixes() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m_ver = Manifest {
+            version: 1,
+            listings: vec![listing("foo-ver7", &source_rel)],
+        };
+        assert_eq!(
+            derive_default_tag(&m_ver, &source, &book_root).unwrap(),
+            "foo-ver8",
+        );
+        let m_version = Manifest {
+            version: 1,
+            listings: vec![listing("foo-version2", &source_rel)],
+        };
+        assert_eq!(
+            derive_default_tag(&m_version, &source, &book_root).unwrap(),
+            "foo-version3",
+        );
+    }
+
+    /// Mixed prefixes (`v`, then `rev`): the most-recently-inserted
+    /// matching prefix wins, so a mid-stream convention switch sticks.
+    #[test]
+    fn derive_default_tag_picks_most_recent_prefix_when_mixed() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("foo.rs");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![
+                listing("foo-v1", &source_rel),
+                listing("foo-v2", &source_rel),
+                listing("foo-rev3", &source_rel),
+            ],
+        };
+        let tag = derive_default_tag(&m, &source, &book_root).unwrap();
+        assert_eq!(tag, "foo-rev4");
+    }
+
+    /// Prior listings exist for the source but none match the allowlist
+    /// (t2t's `<basename>-ch<NN>-phase<N>` is the motivating real case).
+    /// Surfacing the unrecognised convention with the example tag tells
+    /// the author EXACTLY what their existing scheme is so the
+    /// `--tag` fix is one keystroke away.
+    #[test]
+    fn derive_default_tag_errors_when_prior_listings_use_unrecognised_convention() {
+        let book_root = std::env::current_dir().unwrap();
+        let source = book_root.join("compose.yaml");
+        let source_rel = path_to_string(&relativize(&source, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![listing("compose-yaml-ch02-phase1", &source_rel)],
+        };
+        let err = derive_default_tag(&m, &source, &book_root).unwrap_err();
+        let msg = format!("{err}");
+        assert!(
+            msg.contains("compose-yaml-ch02-phase1"),
+            "diagnostic should quote the unrecognised prior tag; got: {msg}"
+        );
+        assert!(
+            msg.contains("--tag"),
+            "diagnostic should direct the author to pass --tag; got: {msg}"
+        );
+    }
+
+    /// Other source files' listings don't pollute the derivation —
+    /// only entries matching the current source path count.
+    #[test]
+    fn derive_default_tag_ignores_listings_for_other_sources() {
+        let book_root = std::env::current_dir().unwrap();
+        let foo = book_root.join("foo.rs");
+        let bar = book_root.join("bar.rs");
+        let foo_rel = path_to_string(&relativize(&foo, &book_root)).unwrap();
+        let bar_rel = path_to_string(&relativize(&bar, &book_root)).unwrap();
+        let m = Manifest {
+            version: 1,
+            listings: vec![
+                listing("bar-v1", &bar_rel),
+                listing("bar-v2", &bar_rel),
+                listing("foo-v3", &foo_rel),
+            ],
+        };
+        // foo's next should be v4, NOT v3 (which would be bar-v3 → +1).
+        let tag = derive_default_tag(&m, &foo, &book_root).unwrap();
+        assert_eq!(tag, "foo-v4");
+    }
 }

CLI wiring in src/main.rs: Command::Freeze::tag becomes Option<String>; the handler calls derive_default_tag when None, wraps the TagDerivationError in anyhow::Error so the CLI surfaces the actionable message on stderr with exit 1.

Listing 6.21
--- main-v13
+++ main-v14
@@ -6,7 +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, frozen_relative_path, path_to_string,
+    FreezeOptions, FreezeOutcome, derive_default_tag, freeze, frozen_relative_path, path_to_string,
 };
 use mdbook_listings::include::splice_chapter as splice_includes;
 use mdbook_listings::install::{InstallOutcome, ensure_assets_fresh, install};
@@ -45,9 +45,12 @@
     /// the manifest.
     Freeze {
         /// Human-readable tag used as the frozen filename and as the manifest
-        /// entry key. Should be unique within the book.
+        /// entry key. Should be unique within the book. When omitted,
+        /// derived from the source basename and existing manifest entries
+        /// (`<basename>-v1` for the first freeze; `<basename>-(v|ver|rev|
+        /// version)<N+1>` to bump an existing series).
         #[arg(long)]
-        tag: String,
+        tag: Option<String>,
 
         /// Root directory of the book. Defaults to the current directory.
         #[arg(long)]
@@ -113,6 +116,14 @@
             source,
         }) => {
             let book_root = book_root.unwrap_or_else(|| PathBuf::from("."));
+            let tag = match tag {
+                Some(t) => t,
+                None => {
+                    let manifest = Manifest::load(&book_root)?;
+                    derive_default_tag(&manifest, &source, &book_root)
+                        .map_err(|e| anyhow::anyhow!("{e}"))?
+                }
+            };
             let report = freeze(FreezeOptions {
                 book_root: &book_root,
                 tag: &tag,

Tests added in this slice:

  • Eight new lib tests in src/freeze.rs covering the derivation logic: empty-manifest first-freeze, single-prior bump, max-N vs count, each allowlist prefix, mixed-prefix most-recent-wins, unrecognised-convention error, and cross-source isolation (other-source listings don’t pollute).
  • Three new CLI integration tests in tests/freeze.rs covering the end-to-end path: --tag omitted on first freeze derives v1, --tag omitted bumps an existing v-series, and --tag omitted on an unrecognised-convention prior errors with the actionable message.
Listing 6.22
--- freeze-tests-v2
+++ freeze-tests-v3
@@ -208,3 +208,83 @@
         .stdout(contains("src/listings/demo.yaml"))
         .stdout(contains("{{#include listings/demo.yaml}}"));
 }
+
+/// `--tag` omitted on a fresh book derives `<basename>-v1` from the
+/// source path. First-time authors don't have to invent a tag scheme.
+#[test]
+fn freeze_without_tag_derives_basename_v1_on_first_freeze() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("created: compose-v1"))
+        .stdout(contains("src/listings/compose-v1.yaml"));
+}
+
+/// `--tag` omitted bumps from the highest existing `<basename>-v<N>`.
+/// The author who froze v1, v2, ..., v7 should not have to remember
+/// what N to type next.
+#[test]
+fn freeze_without_tag_bumps_existing_v_series() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success();
+
+    fs::write(&source, "a: 2\n").unwrap();
+    mdbook_listings()
+        .args(["freeze", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success()
+        .stdout(contains("created: compose-v2"))
+        .stdout(contains("{{#diff compose-v1 compose-v2}}"));
+}
+
+/// `--tag` omitted on a source with priors that use a non-allowlist
+/// convention errors with an actionable message naming the existing
+/// scheme and directing the author to pass `--tag` explicitly. Pins
+/// the safety contract: the CLI never silently chooses a name that
+/// might conflict with the author's own scheme.
+#[test]
+fn freeze_without_tag_errors_when_priors_use_unrecognised_convention() {
+    let tmp = TempDir::new().expect("tempdir");
+    let book_root = tmp.path().join("book");
+    fs::create_dir_all(&book_root).unwrap();
+    let source = tmp.path().join("compose.yaml");
+    fs::write(&source, "a: 1\n").unwrap();
+
+    mdbook_listings()
+        .args(["freeze", "--tag", "compose-yaml-ch02-phase1", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .success();
+
+    fs::write(&source, "a: 2\n").unwrap();
+    mdbook_listings()
+        .args(["freeze", "--book-root"])
+        .arg(&book_root)
+        .arg(&source)
+        .assert()
+        .failure()
+        .stderr(contains("compose-yaml-ch02-phase1"))
+        .stderr(contains("--tag"));
+}

Slice 9 — sidecar TOML callouts

The symptom: every callout this book has shipped attaches via an inline // CALLOUT: marker that the splicer parses out of the frozen listing’s source bytes. That model breaks for code the author doesn’t own (third-party crates, vendored snippets, generated code) and for languages without a recognized single-line comment syntax (CSS, plain Markdown, plain text). For both cases, no comment-style marker is possible.

Slice 9 adds a parallel attachment mechanism — a sidecar TOML file alongside the frozen listing.

Here’s the shape, dogfooded against this very book — book/src/listings/callout-v9.callouts.toml sits next to the callout-v9 frozen listing (i.e. book/src/listings/callout-v9.rs, the post-slice-9 freeze of src/callout.rs) and attaches two callouts at source lines that don’t carry inline markers:

Listing 6.23
# Sidecar callouts attached to listing `callout-v9` (src/callout.rs at
# the ch.6 slice 9 freeze). These annotate lines that don't carry an
# inline `// CALLOUT:` marker — the dogfood pattern for callouts on
# code the author can't or won't decorate inline.

[[callout]]
line = 39
label = "parse-line-entry"
body = "Per-line entry point: `parse_callouts` calls `parse_line` for each line of the listing source."

[[callout]]
line = 47
label = "label-validity-check"
body = "Early reject for malformed labels — keeps callout IDs safe as HTML `id` attributes and URL fragments."

The naming convention is <tag>.callouts.toml next to <tag>.<ext>; the splicer scans the listings directory at the start of each chapter pass and keys the parsed entries by tag. Per fenced block, it looks up the <div data-listing-tag> anchor the include splicer already emits (the same anchor that makes locator-anchor screenshots work) and merges any sidecar entries for that tag with the inline markers parsed from the block. One entry per [[callout]] table — required line (source-file line in the frozen listing) + label, optional body.

The rendered effect, on a small slice of the callout-v9 listing that ALSO carries the inline // CALLOUT: parse-entry marker at source line 28 — three badges total, one inline, two sidecar:

Listing 6.24
#![allow(unused)]
fn main() {
// callout-v9.rs
// @@ 28,50 @@
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;
    }
    // Pull `--key=value` options off the front of `rest` while the
}

Three correctness details earned their own test

  1. Source-line → post-strip translation. The sidecar line field is the line number in the FROZEN LISTING SOURCE, not a line in the rendered chapter. The splicer translates: for a ranged {{#include listings/<tag>.<ext>:A:B}}, the include splicer prepends 2 header lines (basename + @@ A,B @@) to the block, so source line N appears at block-text line (N − A + 1) + 2. Inline marker stripping then shifts every subsequent line up by the count of stripped markers before it. The render path applies both translations and asserts on the resulting post-strip position.
  2. Sidecar pointing at an inline-marker line errors. If a sidecar entry’s line happens to be the source line of an inline // CALLOUT: marker (which the strip pass removes from the rendered listing), the badge would have nowhere to land. The splicer raises SpliceError::SidecarLineOnStrippedMarker naming the label, listing tag, source line, and sidecar path.
  3. Cross-source label collisions error. Same label appearing as BOTH an inline marker AND a sidecar entry would silently shadow one of the rendered badges. The splicer raises SpliceError::LabelCollision naming the label and both source locations. Same-source duplicates (two sidecar entries with the same label in one TOML) are caught at load time with SidecarLoadError::DuplicateLabel.

Production code change in src/callout.rs: new SidecarCallouts type with load(listings_dir) constructor, new SidecarFile + SidecarEntry deserialisable shapes, new ListingAnchor extracted from the <div data-listing-tag …> element (including the optional data-listing-tag-range attribute), new source_line_to_block_line + translate_sidecar_line_to_post_strip helpers. The splice_chapter signature gains a third parameter for the sidecar map; SpliceError gains two new variants; SidecarLoadError is a separate enum surfaced at load time.

strip_marker_lines and strip_marker_lines_diff refactored from returning a 3-tuple to returning a StripResult struct with a new stripped_source_lines: Vec<usize> field — the per-block source-line numbers of stripped inline markers, which the sidecar translation step needs.

Listing 6.25
--- callout-v8
+++ callout-v9
@@ -1,6 +1,9 @@
 //! Parses inline `CALLOUT:` markers out of a frozen listing's source.
 
 use std::collections::{HashMap, HashSet};
+use std::path::{Path, PathBuf};
+
+use serde::Deserialize;
 
 /// Position is a 1-based line number so error diagnostics and the eventual
 /// rendered badge anchor can both refer to it directly.
@@ -139,6 +142,27 @@
     /// A `{{#callout <label>}}` directive named a label that no callout
     /// marker in the chapter defines.
     UnknownLabel { label: String },
+    /// The same label appears as BOTH an inline `// CALLOUT:` marker in
+    /// the frozen listing AND as a `[[callout]]` entry in the sidecar
+    /// TOML file. Cross-source collisions silently hide one of the two
+    /// rendered badges, so the build fails loudly and names the
+    /// duplicate label plus both source paths.
+    LabelCollision {
+        label: String,
+        listing_tag: String,
+        sidecar_path: PathBuf,
+    },
+    /// A sidecar `[[callout]]` entry's `line` value points at a source
+    /// line that the strip pass removes (because the source line itself
+    /// is an inline `// CALLOUT:` marker). The badge would have nowhere
+    /// to land in the rendered listing; fail loudly so the author
+    /// either re-points the sidecar entry or removes the inline marker.
+    SidecarLineOnStrippedMarker {
+        label: String,
+        listing_tag: String,
+        source_line: usize,
+        sidecar_path: PathBuf,
+    },
 }
 
 impl std::fmt::Display for SpliceError {
@@ -149,12 +173,220 @@
                 "\{{{{#callout {label}}}}} references a label that no callout marker defines \
                  in this chapter",
             ),
+            SpliceError::LabelCollision {
+                label,
+                listing_tag,
+                sidecar_path,
+            } => write!(
+                f,
+                "label `{label}` is defined by both an inline `// CALLOUT:` marker in \
+                 listing `{listing_tag}` and a `[[callout]]` entry in sidecar {}",
+                sidecar_path.display(),
+            ),
+            SpliceError::SidecarLineOnStrippedMarker {
+                label,
+                listing_tag,
+                source_line,
+                sidecar_path,
+            } => write!(
+                f,
+                "sidecar entry `{label}` (in {}) points at line {source_line} of listing \
+                 `{listing_tag}`, but that line is an inline `// CALLOUT:` marker that gets \
+                 stripped from the rendered listing — re-point the sidecar entry at a \
+                 non-marker line or remove the inline marker",
+                sidecar_path.display(),
+            ),
         }
     }
 }
 
 impl std::error::Error for SpliceError {}
 
+/// Sidecar TOML file shape. Deserialised from `<tag>.callouts.toml` for
+/// listings that can't carry inline markers (generated code, no-comment
+/// languages). `[[callout]]` entries become [`Callout`]s with the
+/// supplied line number and label.
+#[derive(Debug, Deserialize)]
+struct SidecarFile {
+    #[serde(default, rename = "callout")]
+    callouts: Vec<SidecarEntry>,
+}
+
+#[derive(Debug, Deserialize)]
+struct SidecarEntry {
+    line: usize,
+    label: String,
+    #[serde(default)]
+    body: Option<String>,
+}
+
+/// In-memory map of `tag -> sidecar callouts`. Built once per chapter
+/// pass from `<src>/listings/*.callouts.toml`; passed into
+/// [`splice_chapter`] so the splicer can merge sidecar entries with
+/// inline markers per matching `<div data-listing-tag>` block.
+#[derive(Debug, Default)]
+pub struct SidecarCallouts {
+    /// Tag → (sidecar-file path, parsed callouts). The path is retained
+    /// for diagnostic messages on label collisions.
+    by_tag: HashMap<String, (PathBuf, Vec<Callout>)>,
+}
+
+impl SidecarCallouts {
+    /// Empty sidecar set. The default state when a book has no
+    /// `<tag>.callouts.toml` files; lets all callers use the same
+    /// splicer signature regardless of whether sidecars exist.
+    pub fn empty() -> Self {
+        Self::default()
+    }
+
+    /// Scan `listings_dir` for `*.callouts.toml` files. Missing directory
+    /// returns an empty set (not an error) so a book that uses no
+    /// sidecars Just Works. Each file's tag is the basename minus
+    /// `.callouts.toml` — e.g. `compose-v1.callouts.toml` maps to tag
+    /// `compose-v1`.
+    pub fn load(listings_dir: &Path) -> Result<Self, SidecarLoadError> {
+        let mut by_tag = HashMap::new();
+        let entries = match std::fs::read_dir(listings_dir) {
+            Ok(e) => e,
+            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::empty()),
+            Err(err) => {
+                return Err(SidecarLoadError::ReadDir {
+                    dir: listings_dir.to_path_buf(),
+                    source: err,
+                });
+            }
+        };
+        for entry in entries {
+            let entry = entry.map_err(|source| SidecarLoadError::ReadDir {
+                dir: listings_dir.to_path_buf(),
+                source,
+            })?;
+            let path = entry.path();
+            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
+                continue;
+            };
+            let Some(tag) = name.strip_suffix(".callouts.toml") else {
+                continue;
+            };
+            let text =
+                std::fs::read_to_string(&path).map_err(|source| SidecarLoadError::ReadFile {
+                    path: path.clone(),
+                    source,
+                })?;
+            let parsed: SidecarFile =
+                toml::from_str(&text).map_err(|source| SidecarLoadError::Parse {
+                    path: path.clone(),
+                    source,
+                })?;
+            // Validate labels at load time so a malformed sidecar
+            // fails the build during scan, not during a chapter pass.
+            // Also detect same-source duplicate labels here: a single
+            // sidecar TOML with two `[[callout]]` entries sharing a
+            // label would silently overwrite one in any map-keyed
+            // downstream, masking the bug.
+            let mut seen: HashSet<&str> = HashSet::new();
+            for entry in &parsed.callouts {
+                if !is_valid_label(&entry.label) {
+                    return Err(SidecarLoadError::InvalidLabel {
+                        path: path.clone(),
+                        label: entry.label.clone(),
+                    });
+                }
+                if !seen.insert(entry.label.as_str()) {
+                    return Err(SidecarLoadError::DuplicateLabel {
+                        path: path.clone(),
+                        label: entry.label.clone(),
+                    });
+                }
+            }
+            let callouts: Vec<Callout> = parsed
+                .callouts
+                .into_iter()
+                .map(|e| Callout {
+                    line: e.line,
+                    label: e.label,
+                    body: e
+                        .body
+                        .map(|s| s.trim().to_string())
+                        .filter(|s| !s.is_empty()),
+                    options: HashMap::new(),
+                })
+                .collect();
+            by_tag.insert(tag.to_string(), (path, callouts));
+        }
+        Ok(Self { by_tag })
+    }
+
+    /// Callouts attached to the listing with this tag, or `&[]` when
+    /// no sidecar exists for the tag.
+    pub fn for_tag(&self, tag: &str) -> &[Callout] {
+        self.by_tag
+            .get(tag)
+            .map(|(_, c)| c.as_slice())
+            .unwrap_or(&[])
+    }
+
+    /// Sidecar file path for the tag, used in collision diagnostics.
+    fn path_for_tag(&self, tag: &str) -> Option<&Path> {
+        self.by_tag.get(tag).map(|(p, _)| p.as_path())
+    }
+}
+
+/// Errors raised by [`SidecarCallouts::load`]. Surface at load time so
+/// the build fails on a malformed sidecar before any chapter is
+/// processed, rather than partway through a render.
+#[derive(Debug)]
+pub enum SidecarLoadError {
+    ReadDir {
+        dir: PathBuf,
+        source: std::io::Error,
+    },
+    ReadFile {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    Parse {
+        path: PathBuf,
+        source: toml::de::Error,
+    },
+    InvalidLabel {
+        path: PathBuf,
+        label: String,
+    },
+    DuplicateLabel {
+        path: PathBuf,
+        label: String,
+    },
+}
+
+impl std::fmt::Display for SidecarLoadError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            SidecarLoadError::ReadDir { dir, source } => {
+                write!(f, "reading listings directory {}: {source}", dir.display())
+            }
+            SidecarLoadError::ReadFile { path, source } => {
+                write!(f, "reading sidecar {}: {source}", path.display())
+            }
+            SidecarLoadError::Parse { path, source } => {
+                write!(f, "parsing sidecar {}: {source}", path.display())
+            }
+            SidecarLoadError::InvalidLabel { path, label } => write!(
+                f,
+                "sidecar {} has invalid label `{label}` (must be alphanumeric, hyphen, or underscore)",
+                path.display(),
+            ),
+            SidecarLoadError::DuplicateLabel { path, label } => write!(
+                f,
+                "sidecar {} has duplicate label `{label}` — each `[[callout]]` entry must have a unique label",
+                path.display(),
+            ),
+        }
+    }
+}
+
+impl std::error::Error for SidecarLoadError {}
+
 /// 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
@@ -184,9 +416,13 @@
 /// prose with an inline anchor (HTML) or marker badge (PDF) that links
 /// back to the listing badge.
-pub fn splice_chapter(content: &str, renderer: SupportedRenderer) -> Result<String, SpliceError> {
-    let label_to_ordinal = collect_first_occurrence_ordinals(content);
-    let with_lists = splice_callout_lists(content, &label_to_ordinal, renderer);
+pub fn splice_chapter(
+    content: &str,
+    renderer: SupportedRenderer,
+    sidecars: &SidecarCallouts,
+) -> Result<String, SpliceError> {
+    let label_to_ordinal = collect_first_occurrence_ordinals(content, sidecars)?;
+    let with_lists = splice_callout_lists(content, &label_to_ordinal, renderer, sidecars)?;
     replace_callout_refs(&with_lists, &label_to_ordinal, renderer)
 }
 
@@ -195,24 +431,52 @@
 /// `{{#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> {
+fn collect_first_occurrence_ordinals(
+    content: &str,
+    sidecars: &SidecarCallouts,
+) -> Result<HashMap<String, usize>, SpliceError> {
     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);
+    let mut error: Option<SpliceError> = None;
+    for_each_fenced_block_with_span(content, |info, block_text, _body_start, close_end| {
+        if error.is_some() {
+            return;
+        }
+        match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
+            Ok((inline, sidecar)) => {
+                // Ordinal pass uses block-encounter order: inline by
+                // source position (already sorted), then sidecar by
+                // source line (sorted). Stable across render + ordinal
+                // because the render path sorts by post-strip line,
+                // which preserves source order when the source-line→
+                // post-strip translation is monotone (which it is —
+                // shift count only ever grows).
+                let mut merged = inline;
+                merged.extend(sidecar);
+                merged.sort_by_key(|c| c.line);
+                for (idx, c) in merged.iter().enumerate() {
+                    map.entry(c.label.clone()).or_insert(idx + 1);
+                }
+            }
+            Err(e) => error = Some(e),
         }
     });
-    map
+    if let Some(e) = error {
+        return Err(e);
+    }
+    Ok(map)
 }
 
 fn splice_callout_lists(
     content: &str,
     label_to_ordinal: &HashMap<String, usize>,
     renderer: SupportedRenderer,
-) -> String {
+    sidecars: &SidecarCallouts,
+) -> Result<String, SpliceError> {
     match renderer {
-        SupportedRenderer::Html => splice_callout_lists_html(content),
-        SupportedRenderer::TypstPdf => splice_callout_lists_pdf(content, label_to_ordinal),
+        SupportedRenderer::Html => splice_callout_lists_html(content, sidecars),
+        SupportedRenderer::TypstPdf => {
+            splice_callout_lists_pdf(content, label_to_ordinal, sidecars)
+        }
     }
 }
 
@@ -223,33 +487,83 @@
 /// `data-callout-line` so CSS can position it on the line that previously
 /// held the marker. Diff fences pass through unchanged — diffs show
 /// history, the canonical anchor lives on the include's badge.
-fn splice_callout_lists_html(content: &str) -> String {
+fn splice_callout_lists_html(
+    content: &str,
+    sidecars: &SidecarCallouts,
+) -> Result<String, SpliceError> {
     let mut out = String::with_capacity(content.len());
     let mut cursor = 0;
     let mut emitted_anchor: HashSet<String> = HashSet::new();
+    let mut error: Option<SpliceError> = None;
     for_each_fenced_block_with_span(content, |info, block_text, body_start, close_end| {
-        let callouts = callouts_for_block(info, block_text);
+        if error.is_some() {
+            return;
+        }
+        let (inline, sidecar) =
+            match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
+                Ok(c) => c,
+                Err(e) => {
+                    error = Some(e);
+                    return;
+                }
+            };
         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 {
+        if inline.is_empty() && sidecar.is_empty() && !is_diff {
             return;
         }
-        let (rewritten_body, post_strip_lines, total_lines) = if is_diff {
+        let strip = 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 {
+        if is_diff && inline.is_empty() && sidecar.is_empty() && strip.body == block_text {
             // No-op diff: no markers of any kind to rewrite.
             return;
         }
+        // Pair each inline callout with its already-computed post-strip
+        // line, then add each sidecar callout. Sidecar lines are
+        // SOURCE-file lines; translate via the anchor's range info
+        // (if any) into block_text lines, then strip-aware translate
+        // into post-strip lines. Sort by post-strip position so badges
+        // emit in visual reading order.
+        let mut positioned: Vec<(Callout, usize)> = inline
+            .into_iter()
+            .zip(strip.post_strip_lines.iter().copied())
+            .collect();
+        let anchor = listing_anchor_after_fence(content, close_end);
+        let sidecar_path = anchor.as_ref().and_then(|a| sidecars.path_for_tag(a.tag));
+        for entry in sidecar {
+            let source_line = entry.line;
+            let label = entry.label.clone();
+            let block_line = match anchor.as_ref() {
+                Some(a) => source_line_to_block_line(source_line, a),
+                None => source_line,
+            };
+            match translate_sidecar_line_to_post_strip(
+                block_line,
+                &strip.stripped_source_lines,
+                anchor.as_ref().map(|a| a.tag).unwrap_or(""),
+                sidecar_path,
+                &label,
+                source_line,
+            ) {
+                Ok(p) => positioned.push((entry, p)),
+                Err(e) => {
+                    error = Some(e);
+                    return;
+                }
+            }
+        }
+        positioned.sort_by_key(|(_, p)| *p);
+        let (callouts, post_strip_lines): (Vec<_>, Vec<_>) = positioned.into_iter().unzip();
         let pre_fence = &content[cursor..body_start];
         let close_fence_line = closing_fence_text(content, close_end);
         out.push_str(pre_fence);
-        out.push_str(&rewritten_body);
-        if !rewritten_body.is_empty() && !rewritten_body.ends_with('\n') {
+        out.push_str(&strip.body);
+        if !strip.body.is_empty() && !strip.body.ends_with('\n') {
             out.push('\n');
         }
         out.push_str(close_fence_line);
@@ -257,27 +571,45 @@
         out.push_str(&render_callout_overlay_html(
             &callouts,
             &post_strip_lines,
-            total_lines,
+            strip.total_lines,
             &mut emitted_anchor,
         ));
         out.push('\n');
         cursor = close_end;
     });
+    if let Some(e) = error {
+        return Err(e);
+    }
     out.push_str(&content[cursor..]);
-    out
+    Ok(out)
 }
 
-/// Compute the rewritten block body (marker lines removed) and the
-/// 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>, usize) {
+/// Result of one `strip_marker_lines*` pass.
+#[derive(Debug)]
+struct StripResult {
+    /// Block text with inline marker lines removed.
+    body: String,
+    /// Post-strip 1-based line where each inline marker's badge now lands
+    /// (one entry per inline marker, in source-encounter order).
+    post_strip_lines: Vec<usize>,
+    /// 1-based source line of each stripped marker (one entry per inline
+    /// marker, in source order). Used by the sidecar-merge step to
+    /// translate sidecar source lines into post-strip positions.
+    stripped_source_lines: Vec<usize>,
+    /// Total visible lines in `body` (used for overlay sizing).
+    total_lines: usize,
+}
+
+/// Compute the rewritten block body (marker lines removed) plus the
+/// metadata the overlay renderer + sidecar-merge step both need.
+fn strip_marker_lines(block_text: &str, info: &str) -> StripResult {
     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());
     let mut post_strip_lines: Vec<usize> = Vec::new();
+    let mut stripped_source_lines: Vec<usize> = Vec::new();
     let mut emitted_count: usize = 0;
-    for raw_line in lines {
+    for (idx, raw_line) in lines.iter().enumerate() {
         let line_no_newline = raw_line.strip_suffix('\n').unwrap_or(raw_line);
         let is_marker = prefix
             .and_then(|p| parse_line(line_no_newline, p, 0))
@@ -289,21 +621,28 @@
             // line of the listing.
             let target = (emitted_count + 1).max(1);
             post_strip_lines.push(target);
+            stripped_source_lines.push(idx + 1);
         } else {
             out.push_str(raw_line);
             emitted_count += 1;
         }
     }
-    (out, post_strip_lines, emitted_count)
+    StripResult {
+        body: out,
+        post_strip_lines,
+        stripped_source_lines,
+        total_lines: emitted_count,
+    }
 }
 
-fn strip_marker_lines_diff(block_text: &str) -> (String, Vec<usize>, usize) {
+fn strip_marker_lines_diff(block_text: &str) -> StripResult {
     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 stripped_source_lines: Vec<usize> = Vec::new();
     let mut emitted_count: usize = 0;
-    for raw_line in lines {
+    for (idx, raw_line) in lines.iter().enumerate() {
         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("---")
@@ -335,13 +674,19 @@
             if matches!(prefix_char, Some('+') | Some(' ')) {
                 let target = (emitted_count + 1).max(1);
                 post_strip_lines.push(target);
+                stripped_source_lines.push(idx + 1);
             }
         } else {
             out.push_str(raw_line);
             emitted_count += 1;
         }
     }
-    (out, post_strip_lines, emitted_count)
+    StripResult {
+        body: out,
+        post_strip_lines,
+        stripped_source_lines,
+        total_lines: emitted_count,
+    }
 }
 
 fn closing_fence_text(content: &str, close_end: usize) -> &str {
@@ -357,12 +702,34 @@
 /// rendered listing, append a markdown blockquote summarising each
 /// callout below the block. Slice 8 will pivot this to strip + inline
 /// badge marker.
-fn splice_callout_lists_pdf(content: &str, label_to_ordinal: &HashMap<String, usize>) -> String {
+fn splice_callout_lists_pdf(
+    content: &str,
+    label_to_ordinal: &HashMap<String, usize>,
+    sidecars: &SidecarCallouts,
+) -> Result<String, SpliceError> {
     let mut out = String::with_capacity(content.len());
     let mut cursor = 0;
     let mut emitted_anchor: HashSet<String> = HashSet::new();
+    let mut error: Option<SpliceError> = None;
     for_each_fenced_block_with_span(content, |info, block_text, _body_start, close_end| {
-        let callouts = callouts_for_block(info, block_text);
+        if error.is_some() {
+            return;
+        }
+        let (inline, sidecar) =
+            match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
+                Ok(c) => c,
+                Err(e) => {
+                    error = Some(e);
+                    return;
+                }
+            };
+        // PDF path doesn't strip markers (it keeps them visible in the
+        // listing), so sidecar entries' source lines are also their
+        // post-strip lines — no translation needed. Just merge and
+        // sort by line for the blockquote ordering.
+        let mut callouts = inline;
+        callouts.extend(sidecar);
+        callouts.sort_by_key(|c| c.line);
         if !callouts.is_empty() {
             out.push_str(&content[cursor..close_end]);
             out.push('\n');
@@ -376,8 +743,11 @@
             cursor = close_end;
         }
     });
+    if let Some(e) = error {
+        return Err(e);
+    }
     out.push_str(&content[cursor..]);
-    out
+    Ok(out)
 }
 
 pub(crate) fn for_each_fenced_block_with_span<F>(content: &str, mut visit: F)
@@ -587,6 +957,154 @@
     Vec::new()
 }
 
+/// Anchor information extracted from a `<div data-listing-tag>` element
+/// that the include splicer emits after each `{{#include listings/...}}`
+/// expansion. `range_start_source_line` is `Some(N)` when the include
+/// was a sliced range starting at source line N — the sidecar `source_line`
+/// → block_text-line translation needs to know N and that the include
+/// splicer prepends 2 header lines for ranged slices.
+#[derive(Debug, PartialEq, Eq)]
+struct ListingAnchor<'c> {
+    tag: &'c str,
+    range_start_source_line: Option<usize>,
+}
+
+/// Peek past the closing fence at `close_end` for the include
+/// splicer's `<div data-listing-tag="<tag>"[ data-listing-tag-range="A:B"]...>`
+/// anchor. Returns `None` when no anchor is present. Tolerates one
+/// trailing newline between the fence and the anchor (the include
+/// splicer emits exactly one).
+fn listing_anchor_after_fence<'c>(content: &'c str, close_end: usize) -> Option<ListingAnchor<'c>> {
+    let tail = &content[close_end..];
+    let after_newline = tail.strip_prefix('\n').unwrap_or(tail);
+    let anchor_open = after_newline.find("<div data-listing-tag=\"")?;
+    if anchor_open > 64 {
+        return None;
+    }
+    let value_start = anchor_open + "<div data-listing-tag=\"".len();
+    let value_end = after_newline[value_start..].find('"')?;
+    let tag = &after_newline[value_start..value_start + value_end];
+    // Look for an optional `data-listing-tag-range="A:B"` attribute on the
+    // same anchor element. The full element fits on one line, so cap the
+    // search at the closing `>` of the `<div>`.
+    let div_end = after_newline[anchor_open..]
+        .find('>')
+        .map(|i| anchor_open + i)
+        .unwrap_or(after_newline.len());
+    let div_text = &after_newline[anchor_open..div_end];
+    let range_start_source_line = div_text
+        .find("data-listing-tag-range=\"")
+        .and_then(|r_open| {
+            let r_value_start = r_open + "data-listing-tag-range=\"".len();
+            let r_value_end = div_text[r_value_start..].find('"')?;
+            let r_value = &div_text[r_value_start..r_value_start + r_value_end];
+            // Range render shape is `<start>:<end>` or `<start>:` —
+            // parse the start integer; ignore the rest.
+            r_value.split(':').next()?.parse::<usize>().ok()
+        });
+    Some(ListingAnchor {
+        tag,
+        range_start_source_line,
+    })
+}
+
+/// Back-compat shim for the ordinal pass + tests that only need the tag.
+fn listing_tag_after_fence(content: &str, close_end: usize) -> Option<&str> {
+    listing_anchor_after_fence(content, close_end).map(|a| a.tag)
+}
+
+/// Number of header lines the include splicer prepends to a ranged
+/// `{{#include listings/...}}` expansion. The header is `<basename>\n@@
+/// start,end @@\n` — exactly 2 lines, both commented when the source's
+/// extension maps to a known single-line comment prefix.
+const RANGED_INCLUDE_HEADER_LINES: usize = 2;
+
+/// Translate a sidecar entry's source-file line into the corresponding
+/// 1-based line within the rendered fenced block (`block_text`). For a
+/// full-file include (`anchor.range_start_source_line` is `None`) source
+/// line N is at block_text line N. For a ranged include starting at
+/// source line S, source line N is at block_text line
+/// (N - S + 1) + 2 (the 2 prepended header lines).
+fn source_line_to_block_line(source_line: usize, anchor: &ListingAnchor<'_>) -> usize {
+    match anchor.range_start_source_line {
+        None => source_line,
+        Some(start) => (source_line.saturating_sub(start).saturating_add(1))
+            .saturating_add(RANGED_INCLUDE_HEADER_LINES),
+    }
+}
+
+/// Split inline-marker callouts from sidecar callouts for a given block.
+/// Returns `(inline, sidecar)` so the render path can keep their
+/// post-strip line bookkeeping separate (inline have their post-strip
+/// line in [`StripResult::post_strip_lines`]; sidecar lines need
+/// translation from source-line to post-strip via
+/// [`StripResult::stripped_source_lines`]).
+///
+/// Errors on cross-source label collisions (same label both inline AND
+/// sidecar) — silently shadowing one would hide a rendered badge.
+fn split_callouts_for_block(
+    info: &str,
+    block_text: &str,
+    content: &str,
+    close_end: usize,
+    sidecars: &SidecarCallouts,
+) -> Result<(Vec<Callout>, Vec<Callout>), SpliceError> {
+    let inline = callouts_for_block(info, block_text);
+    let Some(tag) = listing_tag_after_fence(content, close_end) else {
+        return Ok((inline, Vec::new()));
+    };
+    let sidecar = sidecars.for_tag(tag);
+    if sidecar.is_empty() {
+        return Ok((inline, Vec::new()));
+    }
+    let inline_labels: HashSet<&str> = inline.iter().map(|c| c.label.as_str()).collect();
+    for entry in sidecar {
+        if inline_labels.contains(entry.label.as_str()) {
+            return Err(SpliceError::LabelCollision {
+                label: entry.label.clone(),
+                listing_tag: tag.to_string(),
+                sidecar_path: sidecars
+                    .path_for_tag(tag)
+                    .map(Path::to_path_buf)
+                    .unwrap_or_default(),
+            });
+        }
+    }
+    Ok((inline, sidecar.to_vec()))
+}
+
+/// Translate a sidecar entry's block-text line (already mapped from
+/// source file via [`source_line_to_block_line`]) into the post-strip
+/// line where its badge should appear. The shift equals the number of
+/// inline marker lines stripped at-or-before the block line; if the
+/// block line itself is in [`StripResult::stripped_source_lines`]
+/// (i.e. the author pointed the sidecar at a line that the strip pass
+/// removed), returns `Err`. `source_line_reported` is the original
+/// source-file line the author wrote, used in error messages so the
+/// diagnostic points at what the author actually typed.
+fn translate_sidecar_line_to_post_strip(
+    block_line: usize,
+    stripped_source_lines: &[usize],
+    tag: &str,
+    sidecar_path: Option<&Path>,
+    label: &str,
+    source_line_reported: usize,
+) -> Result<usize, SpliceError> {
+    if stripped_source_lines.contains(&block_line) {
+        return Err(SpliceError::SidecarLineOnStrippedMarker {
+            label: label.to_string(),
+            listing_tag: tag.to_string(),
+            source_line: source_line_reported,
+            sidecar_path: sidecar_path.map(Path::to_path_buf).unwrap_or_default(),
+        });
+    }
+    let shift = stripped_source_lines
+        .iter()
+        .filter(|&&s| s < block_line)
+        .count();
+    Ok(block_line.saturating_sub(shift).max(1))
+}
+
 const ALL_COMMENT_PREFIXES: &[&str] = &["//", "#", "--"];
 
 fn callouts_from_diff_block(block_text: &str) -> Vec<Callout> {
@@ -911,7 +1429,8 @@
             "```\n\n",
             "After paragraph.\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(out.contains("Before paragraph.\n"));
         assert!(out.contains("After paragraph.\n"));
         assert!(
@@ -940,7 +1459,8 @@
     fn splice_chapter_leaves_block_alone_when_no_markers_present() {
         let content = "```yaml\nservice: greeting\nendpoint: /hello\n```\n";
         assert_eq!(
-            splice_chapter(content, SupportedRenderer::Html).expect("splice"),
+            splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+                .expect("splice"),
             content
         );
     }
@@ -948,7 +1468,8 @@
     #[test]
     fn splice_chapter_skips_block_with_unknown_language() {
         let content = "```\n# CALLOUT: anchor body text\n```\n";
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(!out.contains("data-callout-badge"));
     }
 
@@ -962,7 +1483,8 @@
             // CALLOUT: b-one\n\
             // CALLOUT: b-two\n\
             ```\n";
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .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\""));
@@ -1000,7 +1522,8 @@
             "+fn added() {}\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             !out.contains("// CALLOUT: added-marker"),
             "added marker comment line should be stripped from rendered diff; got:\n{out}",
@@ -1026,7 +1549,8 @@
             " fn carried() {}\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             !out.contains("// CALLOUT: kept-marker"),
             "context marker comment line should be stripped; got:\n{out}",
@@ -1048,7 +1572,8 @@
             " fn unchanged() {}\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             !out.contains("// CALLOUT: gone-marker"),
             "removed marker comment line should be dropped, not visible; got:\n{out}",
@@ -1076,7 +1601,8 @@
             "fn two() {}\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .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!(
@@ -1108,7 +1634,8 @@
             "fn body() {}\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let id_count = out.matches("id=\"callout-same-label\"").count();
         assert_eq!(
             id_count, 1,
@@ -1133,7 +1660,12 @@
             " // CALLOUT: context-marker Body for a marker that survived the diff.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::TypstPdf).expect("splice");
+        let out = splice_chapter(
+            content,
+            SupportedRenderer::TypstPdf,
+            &SidecarCallouts::empty(),
+        )
+        .expect("splice");
         assert!(
             out.contains("[1] added-marker"),
             "added line marker should render in pdf blockquote; got:\n{out}",
@@ -1155,7 +1687,12 @@
             "+// CALLOUT: kept-marker This one stays.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::TypstPdf).expect("splice");
+        let out = splice_chapter(
+            content,
+            SupportedRenderer::TypstPdf,
+            &SidecarCallouts::empty(),
+        )
+        .expect("splice");
         // `gone-marker` will still appear inside the diff fence itself
         // (PDF emitter doesn't strip diff content); we only need the
         // appended blockquote to omit it.
@@ -1175,7 +1712,8 @@
             "// CALLOUT: real-marker This one should be picked up.\n",
             "````\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             out.contains("data-callout-badge=\"real-marker\""),
             "expected the marker outside the embedded ```yaml string to render; got:\n{out}",
@@ -1195,7 +1733,8 @@
         // 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");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             out.contains("`{{#callout LABEL}}`"),
             "literal example syntax in inline backticks must survive verbatim; got:\n{out}",
@@ -1211,7 +1750,8 @@
         // 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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = out
             .split("<div class=\"callout-body\"")
             .nth(1)
@@ -1232,7 +1772,8 @@
     #[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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         // 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).
@@ -1266,7 +1807,8 @@
     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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = extract_callout_body(&out);
         assert!(
             body.contains("<code>PORT</code>") && body.contains("<code>3000</code>"),
@@ -1277,7 +1819,8 @@
     #[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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = extract_callout_body(&out);
         assert!(
             body.contains("<strong>bold</strong>") && body.contains("<em>italic</em>"),
@@ -1288,7 +1831,8 @@
     #[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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = extract_callout_body(&out);
         assert!(
             body.contains("<a href=\"https://example.com/\">docs</a>"),
@@ -1307,7 +1851,8 @@
         // 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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = extract_callout_body(&out);
         assert!(
             body.contains("<code>&#123;&#123;#callout LABEL}}</code>"),
@@ -1318,7 +1863,8 @@
     #[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 out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let body = extract_callout_body(&out);
         assert!(
             body.contains("role=\"tooltip\">Just a plain sentence with no markup."),
@@ -1334,7 +1880,8 @@
             "# CALLOUT: greeting Says hello.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             out.contains("href=\"#callout-greeting\""),
             "expected anchor href pointing at listing badge id; got:\n{out}",
@@ -1357,7 +1904,8 @@
             "// CALLOUT: later Defined after the reference.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(out.contains("href=\"#callout-later\""));
     }
 
@@ -1370,7 +1918,8 @@
             "// CALLOUT: two Second.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let segment = out.split("data-callout-ref=\"two\"").nth(1).unwrap_or("");
         assert!(
             segment.contains("data-callout-ordinal=\"2\""),
@@ -1381,10 +1930,11 @@
     #[test]
     fn splice_chapter_unknown_callout_label_returns_error() {
         let content = "Unknown ref {{#callout missing}} here.\n";
-        let err = splice_chapter(content, SupportedRenderer::Html)
+        let err = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
             .expect_err("expected unknown-label error");
         match err {
             SpliceError::UnknownLabel { label } => assert_eq!(label, "missing"),
+            other => panic!("expected UnknownLabel, got {other:?}"),
         }
     }
 
@@ -1401,7 +1951,8 @@
             "+// CALLOUT: same Body.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         let id_count = out.matches("id=\"callout-same\"").count();
         assert_eq!(
             id_count, 1,
@@ -1417,7 +1968,12 @@
             "# CALLOUT: anchor-only\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::TypstPdf).expect("splice");
+        let out = splice_chapter(
+            content,
+            SupportedRenderer::TypstPdf,
+            &SidecarCallouts::empty(),
+        )
+        .expect("splice");
         assert!(
             !out.contains("<dl"),
             "PDF renderer must not emit raw HTML; got:\n{out}",
@@ -1440,7 +1996,12 @@
             "# CALLOUT: greeting Says hello.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::TypstPdf).expect("splice");
+        let out = splice_chapter(
+            content,
+            SupportedRenderer::TypstPdf,
+            &SidecarCallouts::empty(),
+        )
+        .expect("splice");
         assert!(
             out.contains("**[1]**"),
             "expected bracketed bold ordinal in prose; got:\n{out}",
@@ -1463,7 +2024,8 @@
             "# CALLOUT: greeting Says hello.\n",
             "```\n",
         );
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             out.contains("{{#callout greeting}}"),
             "literal directive inside code block should pass through; got:\n{out}",
@@ -1599,7 +2161,8 @@
         // auto-detection and pin the popover left.
         let content =
             "```yaml\n# CALLOUT: pinned-left --align=left A body that should open left.\n```\n";
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             out.contains(r#"data-callout-align="left""#),
             "entry must carry data-callout-align=\"left\" when the option is set; got:\n{out}",
@@ -1611,10 +2174,497 @@
         // The negative case: a callout WITHOUT --align=... gets no data
         // attribute. The runtime JS then uses viewport-aware detection.
         let content = "```yaml\n# CALLOUT: regular A body with default alignment.\n```\n";
-        let out = splice_chapter(content, SupportedRenderer::Html).expect("splice");
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
         assert!(
             !out.contains("data-callout-align"),
             "entry must not carry data-callout-align when --align is not set; got:\n{out}",
         );
     }
+
+    fn write_sidecar(dir: &std::path::Path, tag: &str, contents: &str) -> std::path::PathBuf {
+        let path = dir.join(format!("{tag}.callouts.toml"));
+        std::fs::write(&path, contents).unwrap();
+        path
+    }
+
+    #[test]
+    fn sidecar_load_returns_empty_when_dir_missing() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        let missing = tmp.path().join("does-not-exist");
+        let s = SidecarCallouts::load(&missing).unwrap();
+        assert!(s.for_tag("anything").is_empty());
+    }
+
+    /// `load` distinguishes "no listings dir" (legitimately empty) from
+    /// "io error reading what should be a dir" — only NotFound becomes
+    /// the empty set; anything else surfaces as `ReadDir`.
+    #[test]
+    fn sidecar_load_surfaces_non_notfound_io_error() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        let not_a_dir = tmp.path().join("regular-file.txt");
+        std::fs::write(&not_a_dir, "I am a file, not a directory").unwrap();
+        let err = SidecarCallouts::load(&not_a_dir).unwrap_err();
+        match err {
+            SidecarLoadError::ReadDir { dir, .. } => {
+                assert_eq!(dir, not_a_dir);
+            }
+            other => panic!("expected ReadDir error, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn splice_chapter_does_not_double_newline_when_rewritten_body_ends_with_newline() {
+        let content = "```rust\n// CALLOUT: foo bar.\nlet x = 1;\n```\n";
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
+        // Find the position right before the closing fence. The
+        // rewritten body must end with exactly one `\n`, then `` ``` ``.
+        assert!(
+            !out.contains("\n\n```\n"),
+            "must not emit a blank line between body and closing fence; got:\n{out}",
+        );
+        assert!(
+            out.contains("let x = 1;\n```\n"),
+            "expected body line immediately followed by closing fence; got:\n{out}",
+        );
+    }
+
+    /// When every line is a marker, the rewritten body is empty. The
+    /// guard must not emit a stray `\n` between an empty body and the
+    /// closing fence.
+    #[test]
+    fn splice_chapter_does_not_emit_newline_when_rewritten_body_is_empty() {
+        let content = "```rust\n// CALLOUT: only-marker body.\n```\n";
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
+        assert!(
+            out.contains("```rust\n```\n"),
+            "expected fence-open immediately followed by fence-close (empty body); got:\n{out}",
+        );
+    }
+
+    /// `SpliceError`'s `Display` impl must actually format the variant's
+    /// fields — without this assertion the whole body could be replaced
+    /// with `Ok(Default::default())` (empty string) and no test would
+    /// notice.
+    #[test]
+    fn splice_error_display_includes_label_and_paths_for_label_collision() {
+        let err = SpliceError::LabelCollision {
+            label: "duplicate".to_string(),
+            listing_tag: "demo-v1".to_string(),
+            sidecar_path: std::path::PathBuf::from("/tmp/demo.callouts.toml"),
+        };
+        let msg = format!("{err}");
+        assert!(
+            msg.contains("duplicate"),
+            "label missing from display; got: {msg}"
+        );
+        assert!(
+            msg.contains("demo-v1"),
+            "listing tag missing from display; got: {msg}"
+        );
+        assert!(
+            msg.contains("/tmp/demo.callouts.toml"),
+            "sidecar path missing from display; got: {msg}",
+        );
+    }
+
+    #[test]
+    fn sidecar_load_parses_well_formed_file() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "compose-v1",
+            r#"
+[[callout]]
+line = 5
+label = "service-list"
+body = "Each top-level key is one service."
+
+[[callout]]
+line = 8
+label = "version-pin"
+"#,
+        );
+        let s = SidecarCallouts::load(tmp.path()).unwrap();
+        let cs = s.for_tag("compose-v1");
+        assert_eq!(cs.len(), 2);
+        assert_eq!(cs[0].line, 5);
+        assert_eq!(cs[0].label, "service-list");
+        assert_eq!(
+            cs[0].body.as_deref(),
+            Some("Each top-level key is one service.")
+        );
+        assert_eq!(cs[1].line, 8);
+        assert_eq!(cs[1].label, "version-pin");
+        assert!(cs[1].body.is_none());
+    }
+
+    #[test]
+    fn sidecar_load_rejects_invalid_label() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "bad",
+            r#"
+[[callout]]
+line = 1
+label = "has spaces"
+"#,
+        );
+        let err = SidecarCallouts::load(tmp.path()).unwrap_err();
+        let msg = format!("{err}");
+        assert!(msg.contains("has spaces"), "got: {msg}");
+        assert!(msg.contains("invalid label"), "got: {msg}");
+    }
+
+    #[test]
+    fn sidecar_load_ignores_files_not_matching_extension() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        std::fs::write(tmp.path().join("README.md"), "not a sidecar").unwrap();
+        std::fs::write(tmp.path().join("compose-v1.rs"), "// some code").unwrap();
+        let s = SidecarCallouts::load(tmp.path()).unwrap();
+        assert!(s.for_tag("compose-v1").is_empty());
+        assert!(s.for_tag("README").is_empty());
+    }
+
+    #[test]
+    fn listing_tag_after_fence_finds_anchor_immediately_after_fence() {
+        let content = concat!(
+            "```rust\n",
+            "let x = 1;\n",
+            "```\n",
+            "<div data-listing-tag=\"compose-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let close_end = content.find("```\n").unwrap()
+            + content[content.find("```\n").unwrap()..]
+                .find("```\n")
+                .map(|i| i + "```".len())
+                .unwrap();
+        let close_end = close_end + 1; // include the trailing newline of the close-fence line
+        let tag = listing_tag_after_fence(content, close_end);
+        assert_eq!(tag, Some("compose-v1"));
+    }
+
+    #[test]
+    fn listing_tag_after_fence_returns_none_when_no_anchor() {
+        let content = "```rust\nlet x = 1;\n```\n\nSome prose.\n";
+        let close = content.find("```\n").unwrap() + 4;
+        assert_eq!(listing_tag_after_fence(content, close), None);
+    }
+
+    #[test]
+    fn splice_chapter_merges_sidecar_callouts_into_overlay_for_matching_tag() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "demo-v1",
+            r#"
+[[callout]]
+line = 2
+label = "sidecar-marker"
+body = "Attached without modifying the listing bytes."
+"#,
+        );
+        let sidecars = SidecarCallouts::load(tmp.path()).unwrap();
+        // Listing has no inline markers; the language (css) has no recognised
+        // single-line comment syntax in the table, so inline parsing yields
+        // nothing — the sidecar is the only source.
+        let content = concat!(
+            "```css\n",
+            ".callout-body { background: white; }\n",
+            ".callout-body::after { content: ''; }\n",
+            "```\n",
+            "<div data-listing-tag=\"demo-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let out = splice_chapter(content, SupportedRenderer::Html, &sidecars).unwrap();
+        assert!(
+            out.contains(r#"data-callout-badge="sidecar-marker""#),
+            "overlay should carry the sidecar badge; got:\n{out}",
+        );
+    }
+
+    #[test]
+    fn splice_chapter_errors_on_label_collision_between_inline_and_sidecar() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "demo-v1",
+            r#"
+[[callout]]
+line = 2
+label = "duplicate"
+body = "Sidecar definition."
+"#,
+        );
+        let sidecars = SidecarCallouts::load(tmp.path()).unwrap();
+        let content = concat!(
+            "```rust\n",
+            "// CALLOUT: duplicate Inline definition.\n",
+            "let x = 1;\n",
+            "```\n",
+            "<div data-listing-tag=\"demo-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let err = splice_chapter(content, SupportedRenderer::Html, &sidecars).unwrap_err();
+        match err {
+            SpliceError::LabelCollision {
+                label,
+                listing_tag,
+                sidecar_path,
+            } => {
+                assert_eq!(label, "duplicate");
+                assert_eq!(listing_tag, "demo-v1");
+                assert!(sidecar_path.ends_with("demo-v1.callouts.toml"));
+            }
+            other => panic!("expected LabelCollision, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn sidecar_load_rejects_same_source_duplicate_label() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "dup",
+            r#"
+[[callout]]
+line = 1
+label = "twice"
+
+[[callout]]
+line = 2
+label = "twice"
+"#,
+        );
+        let err = SidecarCallouts::load(tmp.path()).unwrap_err();
+        let msg = format!("{err}");
+        assert!(msg.contains("duplicate label"), "got: {msg}");
+        assert!(msg.contains("twice"), "got: {msg}");
+    }
+
+    #[test]
+    fn translate_sidecar_line_with_no_strips_is_identity() {
+        let result =
+            translate_sidecar_line_to_post_strip(5, &[], "demo-v1", None, "label", 5).unwrap();
+        assert_eq!(result, 5);
+    }
+
+    #[test]
+    fn translate_sidecar_line_shifts_by_count_of_stripped_lines_before_it() {
+        // Two inline markers stripped at block_text lines 2 and 4.
+        // A sidecar callout at block_text line 7 should render at
+        // post-strip line 7 - 2 = 5.
+        let result =
+            translate_sidecar_line_to_post_strip(7, &[2, 4], "demo-v1", None, "label", 7).unwrap();
+        assert_eq!(result, 5);
+    }
+
+    #[test]
+    fn translate_sidecar_line_errors_when_source_line_is_a_stripped_marker() {
+        let err = translate_sidecar_line_to_post_strip(3, &[3, 7], "demo-v1", None, "collide", 3)
+            .unwrap_err();
+        match err {
+            SpliceError::SidecarLineOnStrippedMarker {
+                label, source_line, ..
+            } => {
+                assert_eq!(label, "collide");
+                assert_eq!(source_line, 3);
+            }
+            other => panic!("expected SidecarLineOnStrippedMarker, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn source_line_to_block_line_is_identity_for_full_file_include() {
+        let anchor = ListingAnchor {
+            tag: "demo-v1",
+            range_start_source_line: None,
+        };
+        assert_eq!(source_line_to_block_line(7, &anchor), 7);
+    }
+
+    #[test]
+    fn source_line_to_block_line_offsets_for_ranged_include_with_header() {
+        // Range starting at source line 28 — block_text layout is:
+        //   block 1: `// foo.rs` (header line 1)
+        //   block 2: `// @@ 28,50 @@` (header line 2)
+        //   block 3: source line 28
+        //   block 4: source line 29
+        //   ...
+        // So source line 32 → block_text line 3 + (32 - 28) = 7.
+        let anchor = ListingAnchor {
+            tag: "demo-v1",
+            range_start_source_line: Some(28),
+        };
+        assert_eq!(source_line_to_block_line(32, &anchor), 7);
+    }
+
+    #[test]
+    fn listing_anchor_after_fence_extracts_range_when_present() {
+        let content = concat!(
+            "```rust\n",
+            "let x = 1;\n",
+            "```\n",
+            "<div data-listing-tag=\"foo-v1\" data-listing-tag-range=\"28:50\" aria-hidden=\"true\"></div>\n",
+        );
+        let close_end = content.find("```\n").unwrap()
+            + content[content.find("```\n").unwrap()..]
+                .find("```\n")
+                .map(|i| i + "```".len())
+                .unwrap()
+            + 1;
+        let anchor = listing_anchor_after_fence(content, close_end).unwrap();
+        assert_eq!(anchor.tag, "foo-v1");
+        assert_eq!(anchor.range_start_source_line, Some(28));
+    }
+
+    /// Pinned even though the HTML diff path doesn't consume
+    /// `stripped_source_lines` today — a future "sidecar on diffs"
+    /// extension would, and a wrong recording shape would silently
+    /// misplace badges.
+    #[test]
+    fn strip_marker_lines_diff_records_source_line_numbers_of_stripped_markers() {
+        let block_text = concat!(
+            "+// CALLOUT: first body.\n",
+            "+let x = 1;\n",
+            " // CALLOUT: second body.\n",
+            " let y = 2;\n",
+        );
+        let result = strip_marker_lines_diff(block_text);
+        assert_eq!(
+            result.stripped_source_lines,
+            vec![1, 3],
+            "expected source lines [1, 3] for the two stripped markers",
+        );
+    }
+
+    #[test]
+    fn listing_anchor_after_fence_accepts_anchor_at_64_byte_offset() {
+        let close_to_anchor: String = "x".repeat(64);
+        let content = format!(
+            "```rust\nlet x = 1;\n```\n{close_to_anchor}<div data-listing-tag=\"demo\" aria-hidden=\"true\"></div>\n",
+        );
+        let close_end = content
+            .rfind("```\n")
+            .map(|i| i + 4)
+            .expect("fence close present");
+        let anchor = listing_anchor_after_fence(&content, close_end).unwrap();
+        assert_eq!(anchor.tag, "demo");
+    }
+
+    #[test]
+    fn listing_anchor_after_fence_rejects_anchor_at_65_byte_offset() {
+        let close_to_anchor: String = "x".repeat(65);
+        let content = format!(
+            "```rust\nlet x = 1;\n```\n{close_to_anchor}<div data-listing-tag=\"demo\" aria-hidden=\"true\"></div>\n",
+        );
+        let close_end = content
+            .rfind("```\n")
+            .map(|i| i + 4)
+            .expect("fence close present");
+        assert!(listing_anchor_after_fence(&content, close_end).is_none());
+    }
+
+    #[test]
+    fn listing_anchor_after_fence_range_is_none_for_full_file_include() {
+        let content = concat!(
+            "```rust\n",
+            "let x = 1;\n",
+            "```\n",
+            "<div data-listing-tag=\"foo-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let close_end = content.find("```\n").unwrap()
+            + content[content.find("```\n").unwrap()..]
+                .find("```\n")
+                .map(|i| i + "```".len())
+                .unwrap()
+            + 1;
+        let anchor = listing_anchor_after_fence(content, close_end).unwrap();
+        assert_eq!(anchor.tag, "foo-v1");
+        assert_eq!(anchor.range_start_source_line, None);
+    }
+
+    #[test]
+    fn splice_chapter_errors_when_sidecar_line_points_at_inline_marker_line() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        // Inline marker is on source line 1 of the block body.
+        // Sidecar points at source line 1 too — would render onto
+        // a line the strip pass removes.
+        write_sidecar(
+            tmp.path(),
+            "demo-v1",
+            r#"
+[[callout]]
+line = 1
+label = "lands-on-stripped"
+body = "Boom."
+"#,
+        );
+        let sidecars = SidecarCallouts::load(tmp.path()).unwrap();
+        let content = concat!(
+            "```rust\n",
+            "// CALLOUT: inline Body.\n",
+            "let x = 1;\n",
+            "```\n",
+            "<div data-listing-tag=\"demo-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let err = splice_chapter(content, SupportedRenderer::Html, &sidecars).unwrap_err();
+        match err {
+            SpliceError::SidecarLineOnStrippedMarker {
+                label,
+                source_line,
+                listing_tag,
+                ..
+            } => {
+                assert_eq!(label, "lands-on-stripped");
+                assert_eq!(source_line, 1);
+                assert_eq!(listing_tag, "demo-v1");
+            }
+            other => panic!("expected SidecarLineOnStrippedMarker, got {other:?}"),
+        }
+    }
+
+    #[test]
+    fn splice_chapter_inline_and_sidecar_callouts_with_distinct_labels_compose_in_line_order() {
+        let tmp = tempfile::TempDir::new().unwrap();
+        write_sidecar(
+            tmp.path(),
+            "demo-v1",
+            r#"
+[[callout]]
+line = 3
+label = "from-sidecar"
+body = "Attached via sidecar."
+"#,
+        );
+        let sidecars = SidecarCallouts::load(tmp.path()).unwrap();
+        let content = concat!(
+            "```rust\n",
+            "// CALLOUT: from-inline Inline marker.\n",
+            "let x = 1;\n",
+            "let y = 2;\n",
+            "```\n",
+            "<div data-listing-tag=\"demo-v1\" aria-hidden=\"true\"></div>\n",
+        );
+        let out = splice_chapter(content, SupportedRenderer::Html, &sidecars).unwrap();
+        // Both badges must render.
+        assert!(
+            out.contains(r#"data-callout-badge="from-inline""#),
+            "got:\n{out}"
+        );
+        assert!(
+            out.contains(r#"data-callout-badge="from-sidecar""#),
+            "got:\n{out}"
+        );
+        // Ordinal 1 is the inline (line 1 before strip; line 1 after); ordinal
+        // 2 is the sidecar (line 3). The HTML emits buttons in order;
+        // ordinal "1" appears before ordinal "2" textually.
+        let inline_pos = out.find(r#"data-callout-badge="from-inline""#).unwrap();
+        let sidecar_pos = out.find(r#"data-callout-badge="from-sidecar""#).unwrap();
+        assert!(
+            inline_pos < sidecar_pos,
+            "inline badge (line 1) should render before sidecar badge (line 3); got:\n{out}",
+        );
+    }
 }

CLI wiring in src/main.rs: load the sidecar map once per preprocessor invocation and pass &sidecars to every splice_callouts(...) call.

Listing 6.26
--- main-v14
+++ main-v15
@@ -3,7 +3,9 @@
 
 use anyhow::{Context, Result};
 use clap::{Parser, Subcommand};
-use mdbook_listings::callout::{SupportedRenderer, splice_chapter as splice_callouts};
+use mdbook_listings::callout::{
+    SidecarCallouts, SupportedRenderer, splice_chapter as splice_callouts,
+};
 use mdbook_listings::diff::splice_chapter as splice_diffs;
 use mdbook_listings::freeze::{
     FreezeOptions, FreezeOutcome, derive_default_tag, freeze, frozen_relative_path, path_to_string,
@@ -176,12 +178,14 @@
 /// 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)
         .with_context(|| format!("unsupported renderer: {}", ctx.renderer))?;
+    let sidecars =
+        SidecarCallouts::load(&src_dir.join("listings")).context("loading sidecar callouts")?;
 
     let mut splice_err: Option<anyhow::Error> = None;
     book.for_each_mut(|item| {
@@ -213,7 +217,7 @@
                     })
                 })
                 .and_then(|new_content| {
-                    splice_callouts(&new_content, renderer)
+                    splice_callouts(&new_content, renderer, &sidecars)
                         .map_err(|e| anyhow::Error::new(e).context("rendering callouts failed"))
                 }) {
                 Ok(new_content) => chapter.content = new_content,

Quieting chronic build noise: escape {{ in substituted content

While verifying the slice 9 PDF render, a chronic source of include-directive resolution errors surfaced in the build log. Root cause: the include and diff splicers substitute frozen source-code bytes into the chapter buffer; some of those frozen files contain literal {{#include …}} strings as test fixtures (test code asserting on splicer behaviour) or doc-comment examples. Once substituted, mdbook’s built-in links preprocessor scans the chapter buffer and tries to resolve those literals as real directives, failing because the referenced files don’t exist. Build keeps going (errors are non-fatal), but every build prints a screenful of confused noise.

The fix: both splicers escape {{\{{ as they substitute. mdbook’s resolver sees the escape and leaves the literal alone; the rendered HTML still shows {{...}} visually (the \ is consumed as the escape sigil). Safe because every file mdbook-listings freezes is source code (Rust, YAML, TOML, JS, CSS) — never Markdown — so {{...}} in the body is always literal text, never an authored directive.

Listing 6.27
--- include-v2
+++ include-v3
@@ -244,6 +244,11 @@
         while body.ends_with('\n') {
             body.pop();
         }
+        // Escape `\{{` so mdbook's downstream links preprocessor doesn't
+        // try to resolve literal directive-shaped strings in the
+        // substituted bytes. Safe: we only freeze source-code files,
+        // never Markdown.
+        let body = body.replace("\{{", "\\\{{");
         out.push_str(&content[cursor..d.span.start]);
         out.push_str(&body);
         out.push_str(&content[d.span.end..close_end]);
@@ -568,4 +573,27 @@
         let out = splice_chapter(content, src, None).expect("splice");
         assert!(out.contains("fn body() {}\n```"), "got:\n{out}");
     }
+
+    /// Included-body content containing literal `\{{...}}` (test fixtures
+    /// quoting example directives, etc.) must NOT be interpreted by
+    /// mdbook's built-in `links` preprocessor downstream. The splicer
+    /// escapes `\{{` to `\\{{` so the resolver leaves the literal alone;
+    /// the rendered output still shows `\{{...}}` visually.
+    #[test]
+    fn splice_chapter_escapes_double_braces_in_included_body() {
+        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"),
+            "let example = \"{{#include listings/bar.rs}}\";\n",
+        )
+        .unwrap();
+        let content = "```rust\n{{#include listings/foo.rs}}\n```\n";
+        let out = splice_chapter(content, src, None).expect("splice");
+        assert!(
+            out.contains("\"\\{{#include listings/bar.rs}}\""),
+            "expected `\{{` in included body to be escaped to `\\\{{`; got:\n{out}",
+        );
+    }
 }
Listing 6.28
--- diff-v9
+++ diff-v10
@@ -469,6 +469,9 @@
             .map(|n| n - 1)
             .unwrap_or(0);
         let body = shift_hunk_headers(&body, left_offset, right_offset);
+        // Escape `\{{` in the rendered diff body — same reason as in
+        // the include splicer.
+        let body = body.replace("\{{", "\\\{{");
         out.push_str(&content[cursor..d.span.start]);
         out.push_str("```diff\n");
         out.push_str(&body);

Tests added in this slice:

  • 18 new lib tests in src/callout.rs:
    • 4 cover SidecarCallouts::load (missing dir, well-formed file, invalid-label rejection, ignored-extension files, same-source duplicate-label rejection).
    • 2 cover listing_anchor_after_fence (with + without range attribute).
    • 2 cover source_line_to_block_line (identity for full-file, offset for ranged).
    • 3 cover translate_sidecar_line_to_post_strip (no-strip identity, shift by stripped count, error on stripped-line collision).
    • 4 cover splice_chapter end-to-end (sidecar-only merge, inline+sidecar compose in line order, label-collision error, sidecar-line-on-stripped-marker error).
  • The 30 pre-existing splice_chapter tests all updated to pass &SidecarCallouts::empty() as the new third parameter; their behavior is unchanged.
  • 1 new e2e test in tests/e2e_callouts.rs: sidecar_callout_renders_alongside_inline_marker_in_same_listing asserts that all three badges (parse-entry inline, parse-line-entry + label-validity-check sidecar) render exactly once each in the rendered ch.6 HTML.
  • 1 new lib test in src/include.rs: splice_chapter_escapes_double_braces_in_included_body pins the {{\{{ substitution contract above.
Listing 6.29
--- e2e-callouts-v11
+++ e2e-callouts-v12
@@ -750,3 +750,36 @@
     )
     .await;
 }
+
+#[tokio::test]
+async fn sidecar_callout_renders_alongside_inline_marker_in_same_listing() {
+    // ch.6 slice 9: the `callout-v9` listing in ch.6 carries an inline
+    // `// CALLOUT: parse-entry` marker AND two sidecar entries
+    // (`parse-line-entry`, `label-validity-check`) attached via
+    // `book/src/listings/callout-v9.callouts.toml`. All three badges
+    // must render against the same listing's overlay — proves that
+    // inline + sidecar callouts compose end-to-end through the chapter
+    // pipeline (include splicer → callout splicer → HTML renderer).
+    with_traced_chapter(
+        "sidecar_callout_renders_alongside_inline_marker_in_same_listing",
+        CH06,
+        |page| async move {
+            // Each badge label rendered separately so a failure
+            // diagnostic names the specific missing badge. Selector and
+            // panic message use positional `{}` rather than named
+            // `{label}` interpolation so the typst-pdf markdown→typst
+            // converter doesn't misparse the raw-string `{...}` shape
+            // when this test file gets included as a `{{#diff}}` in the
+            // chapter narrative.
+            let labels = ["parse-entry", "parse-line-entry", "label-validity-check"];
+            for label in labels {
+                let selector = format!("button[data-callout-badge=\"{}\"]", label);
+                let badge = page.locator(&selector).await;
+                expect(badge).to_have_count(1).await.unwrap_or_else(|_| {
+                    panic!("badge with label '{}' must render exactly once in ch.6", label)
+                });
+            }
+        },
+    )
+    .await;
+}

Alongside the splicer escapes, a sweep of earlier chapters fixed unescaped illustrative {{#…}} references inside inline backticks (mdbook’s built-in links preprocessor doesn’t respect inline-backtick context as a directive-skip zone, so those mentions raised the same noise). One multi-line example in ch.5 was rewritten as plain prose because no backslash position avoided the line-wrap parsing issue cleanly.

Slice 10 — diff callouts render on changed or added lines only

The symptom: a downstream book embedded a {{#diff}} of two versions of a listing whose only real change was one added line, and the rendered diff showed two callout badges: one on the added line, one on an unchanged context line above it. The diff is about what changed, so the second badge is just noise. It is also redundant, since that callout already shows up wherever the listing appears in full.

Ch.5’s AC 1 made this deliberate: it badged “added or context lines, but not removed lines.” Dogfooding reconsidered the context half. The refined rule (AC 9 here) is that a diff badges only the lines it changed, meaning + lines, including the + side of a -/+ pair.

This follows from how a marker is written. A callout marker is always its own comment line (parse_line wants the comment prefix as the first non-whitespace content, and there is no trailing-comment form). So editing or adding a callout changes its marker line, which the unified diff emits as a + line:

  • Edit a callout’s body on an otherwise-unchanged code line: the marker becomes a -old/+new pair, the + side badges, and the badge lands on the unchanged code line. The changed callout isn’t lost.
  • Add a callout above unchanged code: its marker is a + line, so it badges.
  • Remove a callout: its marker is a - line, so no badge.
  • A marker that is byte-identical on both sides is a pure context line, and gets no badge.

The change stays in the splicer: no asset or grammar change. Two functions in src/callout.rs already sorted diff lines by their +/ /- prefix, and this slice narrows both so a context ( ) line is treated like a removed one. callouts_from_diff_block parses callouts from + lines only. The HTML emitter, the PDF emitter, and the ordinal pass all reach it through the shared callouts_for_block dispatch, so the one change covers every path, and badge numbers renumber to count only what a diff renders. strip_marker_lines_diff records a post-strip badge position for + markers (callout 6.30.1); context and removed markers fall through with no badge (callout 6.30.2).

The diff below is this slice’s own change. It badges two callouts, strip-diff and strip-diff-skip; both are + lines, so the diff is itself an instance of the rule it documents.

Listing 6.30
--- callout-v9
+++ callout-v10
@@ -635,7 +635,8 @@
     }
 }
 
+// Diff-aware strip: the marker comment is removed from the rendered diff
+// either way; its diff prefix decides whether it leaves a badge behind.
 fn strip_marker_lines_diff(block_text: &str) -> StripResult {
     let lines: Vec<&str> = block_text.split_inclusive('\n').collect();
     let mut out = String::with_capacity(block_text.len());
@@ -669,13 +670,13 @@
             .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(' ')) {
+            if matches!(prefix_char, Some('+')) {
                 let target = (emitted_count + 1).max(1);
                 post_strip_lines.push(target);
                 stripped_source_lines.push(idx + 1);
             }
         } else {
             out.push_str(raw_line);
             emitted_count += 1;
@@ -942,11 +943,13 @@
 
 /// 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.
+/// only added (`+`) lines are stripped of their diff indicator and parsed
+/// against every known comment prefix. Context (` `) lines, removed (`-`)
+/// lines, and diff metadata (`---`, `+++`, `@@`, `\`) are skipped — a diff's
+/// badges are unique to what that diff changed, so only a new or edited
+/// callout (an added marker line) carries a badge. An unchanged callout is
+/// already badged wherever the listing is `{{#include}}`-d in full, and a
+/// deleted one is gone 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);
@@ -1117,11 +1120,12 @@
         {
             continue;
         }
-        let stripped = if let Some(rest) = raw_line.strip_prefix('+') {
-            rest
-        } else if let Some(rest) = raw_line.strip_prefix(' ') {
-            rest
-        } else {
+        let Some(stripped) = raw_line.strip_prefix('+') else {
+            // Context (` `) and removed (`-`) lines carry no badge: an
+            // unchanged callout is already badged wherever the listing is
+            // `{{#include}}`-d in full, and a removed one is gone in the
+            // new state. Only an added marker (a new or edited callout)
+            // surfaces here.
             continue;
         };
         for prefix in ALL_COMMENT_PREFIXES {
@@ -1539,26 +1543,66 @@
     }
 
     #[test]
-    fn splice_chapter_html_strips_context_marker_lines_from_diff_and_emits_badge() {
+    fn splice_chapter_html_strips_context_marker_lines_from_diff_without_badge() {
+        // An unchanged callout on a context line is noise in a diff: the
+        // comment is still stripped (it never shows as raw text), but no
+        // badge renders — the callout is already badged wherever the
+        // listing is shown in full via `{{#include}}`.
         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",
+            " // CALLOUT: unchanged-marker A marker carried over unchanged.\n",
             " fn carried() {}\n",
             "```\n",
         );
         let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
             .expect("splice");
         assert!(
-            !out.contains("// CALLOUT: kept-marker"),
-            "context marker comment line should be stripped; got:\n{out}",
+            !out.contains("// CALLOUT: unchanged-marker"),
+            "context marker comment line should still be stripped; got:\n{out}",
+        );
+        assert!(
+            !out.contains("data-callout-badge=\"unchanged-marker\""),
+            "context-line marker must not produce a badge; got:\n{out}",
+        );
+    }
+
+    #[test]
+    fn splice_chapter_html_badges_changed_callout_on_unchanged_code_line() {
+        // A callout whose body is edited while the code line it annotates
+        // stays the same shows up as a `-old`/`+new` marker pair with the
+        // code line as context. The `+` marker still earns a badge, landing
+        // on the unchanged code line — a *changed* callout is not lost.
+        let content = concat!(
+            "```diff\n",
+            "--- a-tag\n",
+            "+++ b-tag\n",
+            "@@ -1,2 +1,2 @@\n",
+            "-// CALLOUT: edited-marker Old body.\n",
+            "+// CALLOUT: edited-marker New body.\n",
+            " fn unchanged() {}\n",
+            "```\n",
+        );
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
+        assert!(
+            out.contains("data-callout-badge=\"edited-marker\""),
+            "edited callout should still render a badge; got:\n{out}",
+        );
+        assert!(
+            !out.contains("Old body."),
+            "the removed (`-`) side's old body should not render; got:\n{out}",
         );
         assert!(
-            out.contains("data-callout-badge=\"kept-marker\""),
-            "expected badge for the carried-over marker; got:\n{out}",
+            out.contains("New body."),
+            "the added (`+`) side's new body should render; got:\n{out}",
         );
+        assert!(
+            out.contains("+fn unchanged() {}") || out.contains(" fn unchanged() {}"),
+            "the unchanged code line should survive in the diff; got:\n{out}",
+        );
     }
 
     #[test]
@@ -1585,6 +1629,73 @@
     }
 
     #[test]
+    fn splice_chapter_html_diff_badge_ordinals_skip_suppressed_context_markers() {
+        // A context-line callout above an added-line callout no longer
+        // consumes an ordinal: the added marker is the only rendered badge,
+        // so it numbers 1, not 2.
+        let content = concat!(
+            "```diff\n",
+            "--- a-tag\n",
+            "+++ b-tag\n",
+            "@@ -1,2 +1,3 @@\n",
+            " // CALLOUT: ctx-marker An unchanged callout above.\n",
+            " fn carried() {}\n",
+            "+// CALLOUT: new-marker A freshly added callout below.\n",
+            "+fn added() {}\n",
+            "```\n",
+        );
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
+        assert!(
+            out.contains("data-callout-badge=\"new-marker\" data-callout-ordinal=\"1\""),
+            "added marker should be ordinal 1 once the context marker is suppressed; got:\n{out}",
+        );
+        assert!(
+            !out.contains("data-callout-badge=\"ctx-marker\""),
+            "context marker must not render a badge; got:\n{out}",
+        );
+    }
+
+    #[test]
+    fn splice_chapter_html_cross_ref_to_context_only_diff_label_resolves_to_include() {
+        // A callout that appears only on a context line in a diff (no badge
+        // there) but also in a full listing still resolves a prose
+        // `{{#callout LABEL}}` — to the include occurrence, which carries
+        // the canonical `id="callout-LABEL"` anchor.
+        let content = concat!(
+            "```diff\n",
+            "--- a-tag\n",
+            "+++ b-tag\n",
+            "@@ -1,2 +1,2 @@\n",
+            " // CALLOUT: shared-ref Unchanged in the diff.\n",
+            " fn carried() {}\n",
+            "```\n\n",
+            "```rust\n",
+            "// CALLOUT: shared-ref The full listing where it lives.\n",
+            "fn body() {}\n",
+            "```\n\n",
+            "See callout {{#callout shared-ref}} for details.\n",
+        );
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("cross-ref to a context-only diff label should resolve, not error");
+        assert_eq!(
+            out.matches("data-callout-badge=\"shared-ref\"").count(),
+            1,
+            "exactly one listing-side badge (the include's) should render; got:\n{out}",
+        );
+        assert_eq!(
+            out.matches("id=\"callout-shared-ref\"").count(),
+            1,
+            "exactly one canonical anchor should exist; got:\n{out}",
+        );
+        assert!(
+            out.contains("href=\"#callout-shared-ref\"")
+                && out.contains("data-callout-ref=\"shared-ref\""),
+            "the prose cross-ref should resolve to the canonical anchor; 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
@@ -1644,11 +1755,12 @@
     }
 
     #[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+)
-        // skips diff blocks since the canonical badge anchor lives on the
-        // include, not on the diff history.
+    fn splice_chapter_pdf_picks_up_callouts_from_added_diff_lines_only() {
+        // The PDF emitter emits per-block callouts for diff fences as a
+        // markdown blockquote (slice 6 shape). Like the HTML path, only
+        // added (`+`) markers earn an entry: a context-line marker is an
+        // unchanged callout (already noted on the full include) and a
+        // removed one is gone in the new state.
         let content = concat!(
             "```diff\n",
             "--- a-tag\n",
@@ -1666,13 +1778,17 @@
             &SidecarCallouts::empty(),
         )
         .expect("splice");
+        // `context-marker` still appears inside the diff fence itself (the
+        // PDF emitter doesn't strip diff content); we only need the
+        // appended blockquote to omit it.
+        let blockquote = out.split("```\n\n").nth(1).unwrap_or("");
         assert!(
-            out.contains("[1] added-marker"),
+            blockquote.contains("[1] added-marker"),
             "added line marker should render in pdf blockquote; got:\n{out}",
         );
         assert!(
-            out.contains("[2] context-marker"),
-            "context line marker should render in pdf blockquote; got:\n{out}",
+            !blockquote.contains("context-marker"),
+            "context line marker should not render in pdf blockquote; got:\n{blockquote}",
         );
     }
 
@@ -2522,9 +2638,11 @@
     /// Pinned even though the HTML diff path doesn't consume
     /// `stripped_source_lines` today — a future "sidecar on diffs"
     /// extension would, and a wrong recording shape would silently
-    /// misplace badges.
+    /// misplace badges. Only badge-bearing (`+`) markers are recorded;
+    /// context (` `) and removed (`-`) marker lines are stripped from the
+    /// body but earn no badge, so they don't appear here.
     #[test]
-    fn strip_marker_lines_diff_records_source_line_numbers_of_stripped_markers() {
+    fn strip_marker_lines_diff_records_source_line_numbers_of_added_markers() {
         let block_text = concat!(
             "+// CALLOUT: first body.\n",
             "+let x = 1;\n",
@@ -2534,8 +2652,9 @@
         let result = strip_marker_lines_diff(block_text);
         assert_eq!(
             result.stripped_source_lines,
-            vec![1, 3],
-            "expected source lines [1, 3] for the two stripped markers",
+            vec![1],
+            "only the added (`+`) marker at line 1 should be recorded; the \
+             context marker at line 3 is stripped but earns no badge",
         );
     }
 

Slice 11 — one directive grammar across the three passes

Three passes parse {{#…}} directives out of chapter markdown: include (ch.5 slice 8), diff (ch.4), and callout cross-refs (ch.5 slice 5). Each had grown its own scanner, and the copies had drifted. A review pass over the pipeline found the visible casualty in the diff parser’s fence tracking: it flipped a boolean on every ```/~~~ line without recording the opener’s character or length. CommonMark says a fence closes only on a same-character fence at least as long as the opener, so a 3-backtick line inside a 4-backtick fence is literal text. The toggle treated it as a closer — a literal {{#diff}} example written inside such a fence got consumed as a real directive, and the real directive after the fence was missed. The callout pass already tracked fences correctly and had the regression test to prove it; the diff parser had neither.

The drift ran further than the bug. The backslash-escape check and the inline-backtick check existed as three near-identical copies, the line_number diagnostic helper as two byte-identical ones, and the callout cross-ref pass had no escape check at all — {{#callout label}} with a known label resolved anyway, stranding the backslash.

The fix lands in two layers. First, fence walking moves out of callout.rs into its own module as an iterator. The walker logic is unchanged; the shape change retires the error-smuggling dance its three fallible callers had to do (declare an Option<SpliceError> outside an infallible closure, assign into it, check it on every later iteration). Callers now loop and use ? (callout 6.31.1); the closer rule the diff parser got wrong is pinned by the walker’s first direct unit tests (callout 6.31.2):

Listing 6.31
#![allow(unused)]
fn main() {
// fence-v1.rs
// @@ 1,154 @@
//! CommonMark fenced-code-block walking, shared by the include, diff, and
//! callout passes so they all agree on what is and isn't inside a fence.

/// A fence opener's shape. The closer must match `char` and reach at least
/// `count` — tracking both is what keeps a shorter same-character fence
/// inside an outer block (e.g. a 3-backtick example inside a 4-backtick
/// fence) from closing it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Fence {
    pub(crate) char: u8,
    pub(crate) count: usize,
}

#[derive(Clone, Copy)]
struct OpenFence<'a> {
    info: &'a str,
    opener: Fence,
    body_start: usize,
}

/// One closed fenced block, with byte offsets into the scanned content.
///
/// Span semantics: the block's membership range is
/// `[body_start, close_end)` — the opener line is excluded, the closing
/// fence line is included. Both the include and callout passes test
/// position membership against exactly this range, so a directive sitting
/// in an opener's info string counts as outside the block.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct FencedBlock<'a> {
    /// The opener's info string, trimmed (empty when the fence has none).
    pub(crate) info: &'a str,
    /// The body between the fence lines, newline-inclusive; excludes the
    /// fence lines themselves.
    pub(crate) body: &'a str,
    /// Byte offset of the first body byte (just past the opener's newline).
    pub(crate) body_start: usize,
    /// One past the closing fence line's trailing newline, or the content
    /// length when the closer is the final line without one.
    pub(crate) close_end: usize,
}

/// Iterator over the closed fenced blocks of a chapter. An unclosed fence
/// at end-of-input yields nothing — without a closer there is no block.
pub(crate) struct FencedBlocks<'a> {
    content: &'a str,
    line_start: usize,
    open: Option<OpenFence<'a>>,
}

impl<'a> FencedBlocks<'a> {
    pub(crate) fn new(content: &'a str) -> Self {
        FencedBlocks {
            content,
            line_start: 0,
            open: None,
        }
    }
}

impl<'a> Iterator for FencedBlocks<'a> {
    type Item = FencedBlock<'a>;

    fn next(&mut self) -> Option<FencedBlock<'a>> {
        let content = self.content;
        let len = content.len();
        while self.line_start < len {
            let line_end = match content[self.line_start..].find('\n') {
                Some(off) => self.line_start + off,
                None => len,
            };
            let line = &content[self.line_start..line_end];
            let mut block = None;
            match self.open {
                None => {
                    if let Some((info, opener)) = fence_open_info(line) {
                        self.open = Some(OpenFence {
                            info,
                            opener,
                            body_start: line_end + 1,
                        });
                    }
                }
                Some(o) => {
                    if line_closes_fence(line, o.opener) {
                        let close_end = if line_end < len {
                            line_end + 1
                        } else {
                            line_end
                        };
                        block = Some(FencedBlock {
                            info: o.info,
                            body: &content[o.body_start..self.line_start],
                            body_start: o.body_start,
                            close_end,
                        });
                        self.open = None;
                    }
                }
            }
            self.line_start = if line_end == len { len } else { line_end + 1 };
            if block.is_some() {
                return block;
            }
        }
        None
    }
}

/// Parse `line` as a fence opener: at most 3 leading spaces, then 3+
/// backticks or tildes. Returns the trimmed info string and the fence
/// shape, or `None` when the line opens nothing.
pub(crate) fn fence_open_info(line: &str) -> Option<(&str, 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(),
        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.
pub(crate) 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()
}
}

Second, a shared scanner owns the occurrence grammar — find the prefix, skip escaped and inline-code forms, find the closing braces, classify fence membership via the iterator above. The three passes keep what actually differs between them: argument parsing and fence policy (callout 6.32.1). The duplicated diagnostic helper consolidates here too (callout 6.32.2):

Listing 6.32
#![allow(unused)]
fn main() {
// directive-v1.rs
// @@ 1,111 @@
//! Shared scanner for `{{#name …}}` directive occurrences in chapter
//! markdown. The include, diff, and callout passes each parse different
//! arguments and apply different fence policies, but the occurrence
//! grammar — escape handling, inline-code detection, fence membership —
//! must agree between them or directives get consumed in one pass that
//! another would have left alone.

use std::ops::Range;

use crate::fence::FencedBlocks;

/// One unescaped directive occurrence outside inline code.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct DirectiveOccurrence<'a> {
    /// Raw text between the prefix and the closing `}}`, untrimmed —
    /// argument grammar is the caller's business. May span lines: the
    /// closing braces are found by an unbounded forward search.
    pub(crate) args: &'a str,
    /// Byte range of the full `{{#… }}` text.
    pub(crate) span: Range<usize>,
    /// `Some(close_end)` when the occurrence starts inside a fenced code
    /// block (only produced under [`FencePolicy::Annotate`]); the value is
    /// the fence's close_end so callers can place trailing anchors.
    pub(crate) fence_close_end: Option<usize>,
}

/// What to do with an occurrence that starts inside a fenced code block.
#[derive(Clone, Copy)]
pub(crate) enum FencePolicy {
    /// Yield it, tagged with its fence's `close_end`, consuming through its
    /// `}}` — for the include pass, where fenced directives are the primary
    /// case.
    Annotate,
    /// Don't yield it, and resume scanning just past the prefix rather than
    /// past its `}}` — a fenced opener whose closing braces lie beyond the
    /// fence must not swallow a real directive that follows the fence.
    SkipInside,
}

/// Scan `content` for `prefix` occurrences (`"\{{#include "`, `"\{{#diff"`,
/// `"\{{#callout "` — exact literals, including any trailing space).
/// Backslash-escaped occurrences and ones sitting inside an inline code
/// span are skipped so chapters can quote directive syntax verbatim.
pub(crate) fn scan_directives<'a>(
    content: &'a str,
    prefix: &str,
    policy: FencePolicy,
) -> Vec<DirectiveOccurrence<'a>> {
    let fences: Vec<(usize, usize)> = FencedBlocks::new(content)
        .map(|b| (b.body_start, b.close_end))
        .collect();
    let in_fence = |pos: usize| {
        fences
            .iter()
            .find(|&&(start, end)| pos >= start && pos < end)
    };

    let bytes = content.as_bytes();
    let mut out = Vec::new();
    let mut cursor = 0;
    while let Some(rel) = content[cursor..].find(prefix) {
        let at = cursor + rel;
        let after_prefix = at + prefix.len();
        if at > 0 && bytes[at - 1] == b'\\' {
            cursor = after_prefix;
            continue;
        }
        // Count single backticks between the line start and the
        // occurrence: an odd count means it sits between `…` markers — a
        // quoted example in prose, not a directive. (Heuristic: double-
        // backtick spans are not modelled.)
        let line = content[..at]
            .rsplit_once('\n')
            .map_or(&content[..at], |(_, t)| t);
        let backticks_before = line.bytes().filter(|&b| b == b'`').count();
        if backticks_before % 2 == 1 {
            cursor = after_prefix;
            continue;
        }
        let fence_close_end = match (in_fence(at), policy) {
            (Some(&(_, close_end)), FencePolicy::Annotate) => Some(close_end),
            (Some(_), FencePolicy::SkipInside) => {
                cursor = after_prefix;
                continue;
            }
            (None, _) => None,
        };
        let Some(close_rel) = content[after_prefix..].find("}}") else {
            // No closing braces anywhere ahead — no later occurrence can
            // have them either.
            break;
        };
        let span_end = after_prefix + close_rel + 2;
        out.push(DirectiveOccurrence {
            args: &content[after_prefix..after_prefix + close_rel],
            span: at..span_end,
            fence_close_end,
        });
        cursor = span_end;
    }
    out
}

/// 1-based line number of `byte_offset` in `content`, for diagnostics.
pub(crate) fn line_number(content: &str, byte_offset: usize) -> usize {
    content[..byte_offset]
        .bytes()
        .filter(|&b| b == b'\n')
        .count()
        + 1
}
}

Neither new module carries inline // CALLOUT: markers; the four badges above attach via sidecar TOML files next to the frozen listings — the slice 9 mechanism doing the job it was built for.

The three parser rewires, each reduced to a loop over the scanner’s occurrences. The diff parser’s rewrite includes the regression test that failed against the old toggle (parse_directives_does_not_close_outer_fence_on_shorter_inner_fence):

Listing 6.33
--- diff-v10
+++ diff-v11
@@ -5,6 +5,7 @@
 use std::ops::Range;
 use std::path::{Path, PathBuf};
 
+use crate::directive::{FencePolicy, line_number, scan_directives};
 use crate::manifest::Manifest;
 
 /// `span` covers the directive in full (`{{#diff …}}` inclusive) so callers
@@ -112,68 +113,31 @@
 /// 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() {
-        let line_end = match content[line_start..].find('\n') {
-            Some(off) => line_start + off,
-            None => bytes.len(),
+    for occ in scan_directives(content, "\{{#diff", FencePolicy::SkipInside) {
+        let tokens: Vec<&str> = occ.args.split_whitespace().collect();
+        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 line_is_code_fence(&bytes[line_start..line_end]) {
-            in_fence = !in_fence;
-        } else if !in_fence {
-            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 tokens: Vec<&str> = content[inner_start..inner_start + end_rel]
-                    .split_whitespace()
-                    .collect();
-                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,
-                        right,
-                        left_range,
-                        right_range,
-                        span: i..directive_end,
-                    });
-                }
-                i = directive_end;
-            }
+        if let Some((left, right, left_range, right_range)) = parsed {
+            out.push(DiffDirective {
+                left,
+                right,
+                left_range,
+                right_range,
+                span: occ.span,
+            });
         }
-        line_start = line_end + 1;
     }
     out
 }
@@ -384,15 +348,6 @@
     }
 }
 
-fn line_is_code_fence(line: &[u8]) -> bool {
-    let leading_spaces = line.iter().take_while(|&&b| b == b' ').count();
-    if leading_spaces > 3 {
-        return false;
-    }
-    let rest = &line[leading_spaces..];
-    rest.starts_with(b"```") || rest.starts_with(b"~~~")
-}
-
 /// Failure shape carrying enough chapter context to point an author straight
 /// at the offending directive.
 #[derive(Debug)]
@@ -498,14 +453,6 @@
     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::*;
@@ -806,6 +753,29 @@
     }
 
     #[test]
+    fn parse_directives_does_not_close_outer_fence_on_shorter_inner_fence() {
+        // CommonMark: a 3-backtick line inside a 4-backtick fence is
+        // literal text, not a closer — so the {{#diff a b}} example below
+        // it is still inside the fence and must not parse. The directive
+        // after the real 4-backtick closer is the positive control.
+        let s = concat!(
+            "````markdown\n",
+            "```\n",
+            "{{#diff a b}}\n",
+            "````\n",
+            "{{#diff c d}}\n",
+        );
+        let got = parse_directives(s);
+        assert_eq!(
+            got.len(),
+            1,
+            "only the post-fence directive should parse; got {got:?}"
+        );
+        assert_eq!(got[0].left, "c");
+        assert_eq!(got[0].right, "d");
+    }
+
+    #[test]
     fn parse_directives_skips_inside_inline_code_spans() {
         let s = "Use `{{#diff a b}}` in prose.\n";
         assert!(

The include parser keeps its path-prefix interception and range-suffix parsing, and drops everything else. Its entry-point marker also gets a rename: include.rs and callout.rs both carried a parse-entry label — one more drift artifact — and the e2e suite caught the duplicate the moment this diff first rendered, because ch.6 pins parse-entry to exactly one badge. The renamed marker is an edited + line, so it badges here under slice 10’s rule:

Listing 6.34
--- include-v3
+++ include-v4
@@ -6,8 +6,9 @@
 use std::ops::Range;
 use std::path::{Path, PathBuf};
 
-use crate::callout::{comment_prefix_for_extension, for_each_fenced_block_with_span};
+use crate::callout::comment_prefix_for_extension;
 use crate::diff::{LineRange, parse_line_range};
+use crate::directive::{FencePolicy, line_number, scan_directives};
 
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct IncludeDirective {
@@ -21,94 +22,48 @@
     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 raw = content[inner_start..inner_start + end_rel].trim();
-            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(
-                    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(),
-                range,
-                span: i..directive_end,
-                fence_close_end,
-            });
-            i = directive_end;
-        }
-        if line_end == bytes.len() {
-            break;
+    for occ in scan_directives(content, "\{{#include ", FencePolicy::Annotate) {
+        let raw = occ.args.trim();
+        let intercepted = raw.starts_with("listings/") || raw.starts_with("snippets/");
+        if !intercepted {
+            continue;
         }
-        line_start = line_end + 1;
+        // 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 => continue,
+            },
+            None => (raw, None),
+        };
+        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
+        };
+        out.push(IncludeDirective {
+            tag,
+            rel_path: path.to_string(),
+            range,
+            span: occ.span,
+            fence_close_end: occ.fence_close_end,
+        });
     }
     out
 }
@@ -265,14 +220,6 @@
     }
     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)]

The callout pass loses its local fence and backtick machinery, gains the escape check, and picks up a pin test for it (replace_callout_refs_leaves_backslash_escaped_directive_literal). The fence walker’s departure to its own module is most of this diff’s bulk:

Listing 6.35
--- callout-v10
+++ callout-v11
@@ -5,6 +5,9 @@
 
 use serde::Deserialize;
 
+use crate::directive::{FencePolicy, scan_directives};
+use crate::fence::FencedBlocks;
+
 /// 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, Default, PartialEq, Eq)]
@@ -436,32 +439,22 @@
     sidecars: &SidecarCallouts,
 ) -> Result<HashMap<String, usize>, SpliceError> {
     let mut map = HashMap::new();
-    let mut error: Option<SpliceError> = None;
-    for_each_fenced_block_with_span(content, |info, block_text, _body_start, close_end| {
-        if error.is_some() {
-            return;
-        }
-        match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
-            Ok((inline, sidecar)) => {
-                // Ordinal pass uses block-encounter order: inline by
-                // source position (already sorted), then sidecar by
-                // source line (sorted). Stable across render + ordinal
-                // because the render path sorts by post-strip line,
-                // which preserves source order when the source-line→
-                // post-strip translation is monotone (which it is —
-                // shift count only ever grows).
-                let mut merged = inline;
-                merged.extend(sidecar);
-                merged.sort_by_key(|c| c.line);
-                for (idx, c) in merged.iter().enumerate() {
-                    map.entry(c.label.clone()).or_insert(idx + 1);
-                }
-            }
-            Err(e) => error = Some(e),
+    for block in FencedBlocks::new(content) {
+        let (inline, sidecar) =
+            split_callouts_for_block(block.info, block.body, content, block.close_end, sidecars)?;
+        // Ordinal pass uses block-encounter order: inline by
+        // source position (already sorted), then sidecar by
+        // source line (sorted). Stable across render + ordinal
+        // because the render path sorts by post-strip line,
+        // which preserves source order when the source-line→
+        // post-strip translation is monotone (which it is —
+        // shift count only ever grows).
+        let mut merged = inline;
+        merged.extend(sidecar);
+        merged.sort_by_key(|c| c.line);
+        for (idx, c) in merged.iter().enumerate() {
+            map.entry(c.label.clone()).or_insert(idx + 1);
         }
-    });
-    if let Some(e) = error {
-        return Err(e);
     }
     Ok(map)
 }
@@ -494,46 +487,36 @@
     let mut out = String::with_capacity(content.len());
     let mut cursor = 0;
     let mut emitted_anchor: HashSet<String> = HashSet::new();
-    let mut error: Option<SpliceError> = None;
-    for_each_fenced_block_with_span(content, |info, block_text, body_start, close_end| {
-        if error.is_some() {
-            return;
-        }
+    for block in FencedBlocks::new(content) {
         let (inline, sidecar) =
-            match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
-                Ok(c) => c,
-                Err(e) => {
-                    error = Some(e);
-                    return;
-                }
-            };
-        let is_diff = info == "diff";
+            split_callouts_for_block(block.info, block.body, content, block.close_end, sidecars)?;
+        let is_diff = block.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 inline.is_empty() && sidecar.is_empty() && !is_diff {
-            return;
+            continue;
         }
         let strip = if is_diff {
-            strip_marker_lines_diff(block_text)
+            strip_marker_lines_diff(block.body)
         } else {
-            strip_marker_lines(block_text, info)
+            strip_marker_lines(block.body, block.info)
         };
-        if is_diff && inline.is_empty() && sidecar.is_empty() && strip.body == block_text {
+        if is_diff && inline.is_empty() && sidecar.is_empty() && strip.body == block.body {
             // No-op diff: no markers of any kind to rewrite.
-            return;
+            continue;
         }
         // Pair each inline callout with its already-computed post-strip
         // line, then add each sidecar callout. Sidecar lines are
         // SOURCE-file lines; translate via the anchor's range info
-        // (if any) into block_text lines, then strip-aware translate
+        // (if any) into block-body lines, then strip-aware translate
         // into post-strip lines. Sort by post-strip position so badges
         // emit in visual reading order.
         let mut positioned: Vec<(Callout, usize)> = inline
             .into_iter()
             .zip(strip.post_strip_lines.iter().copied())
             .collect();
-        let anchor = listing_anchor_after_fence(content, close_end);
+        let anchor = listing_anchor_after_fence(content, block.close_end);
         let sidecar_path = anchor.as_ref().and_then(|a| sidecars.path_for_tag(a.tag));
         for entry in sidecar {
             let source_line = entry.line;
@@ -542,25 +525,20 @@
                 Some(a) => source_line_to_block_line(source_line, a),
                 None => source_line,
             };
-            match translate_sidecar_line_to_post_strip(
+            let p = translate_sidecar_line_to_post_strip(
                 block_line,
                 &strip.stripped_source_lines,
                 anchor.as_ref().map(|a| a.tag).unwrap_or(""),
                 sidecar_path,
                 &label,
                 source_line,
-            ) {
-                Ok(p) => positioned.push((entry, p)),
-                Err(e) => {
-                    error = Some(e);
-                    return;
-                }
-            }
+            )?;
+            positioned.push((entry, p));
         }
         positioned.sort_by_key(|(_, p)| *p);
         let (callouts, post_strip_lines): (Vec<_>, Vec<_>) = positioned.into_iter().unzip();
-        let pre_fence = &content[cursor..body_start];
-        let close_fence_line = closing_fence_text(content, close_end);
+        let pre_fence = &content[cursor..block.body_start];
+        let close_fence_line = closing_fence_text(content, block.close_end);
         out.push_str(pre_fence);
         out.push_str(&strip.body);
         if !strip.body.is_empty() && !strip.body.ends_with('\n') {
@@ -575,10 +553,7 @@
             &mut emitted_anchor,
         ));
         out.push('\n');
-        cursor = close_end;
-    });
-    if let Some(e) = error {
-        return Err(e);
+        cursor = block.close_end;
     }
     out.push_str(&content[cursor..]);
     Ok(out)
@@ -711,19 +686,9 @@
     let mut out = String::with_capacity(content.len());
     let mut cursor = 0;
     let mut emitted_anchor: HashSet<String> = HashSet::new();
-    let mut error: Option<SpliceError> = None;
-    for_each_fenced_block_with_span(content, |info, block_text, _body_start, close_end| {
-        if error.is_some() {
-            return;
-        }
+    for block in FencedBlocks::new(content) {
         let (inline, sidecar) =
-            match split_callouts_for_block(info, block_text, content, close_end, sidecars) {
-                Ok(c) => c,
-                Err(e) => {
-                    error = Some(e);
-                    return;
-                }
-            };
+            split_callouts_for_block(block.info, block.body, content, block.close_end, sidecars)?;
         // PDF path doesn't strip markers (it keeps them visible in the
         // listing), so sidecar entries' source lines are also their
         // post-strip lines — no translation needed. Just merge and
@@ -732,7 +697,7 @@
         callouts.extend(sidecar);
         callouts.sort_by_key(|c| c.line);
         if !callouts.is_empty() {
-            out.push_str(&content[cursor..close_end]);
+            out.push_str(&content[cursor..block.close_end]);
             out.push('\n');
             out.push_str(&render_callout_list(
                 &callouts,
@@ -741,119 +706,30 @@
                 SupportedRenderer::TypstPdf,
             ));
             out.push('\n');
-            cursor = close_end;
+            cursor = block.close_end;
         }
-    });
-    if let Some(e) = error {
-        return Err(e);
     }
     out.push_str(&content[cursor..]);
     Ok(out)
 }
 
-pub(crate) 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) {
-                    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
-                    };
-                    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.
+/// blocks. Directives inside fenced blocks, inline code spans, or behind a
+/// backslash escape (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>,
     renderer: SupportedRenderer,
 ) -> 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 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) || 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();
-            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();
+    for occ in scan_directives(content, "\{{#callout ", FencePolicy::SkipInside) {
+        let label = occ.args.trim();
         if !is_valid_label(label) {
-            out.push_str(&content[cursor..label_start]);
-            cursor = label_start;
+            // Malformed label: leave the directive literal (copied through
+            // with the surrounding prose on the next push).
             continue;
         }
         let ordinal =
@@ -863,9 +739,9 @@
                 .ok_or_else(|| SpliceError::UnknownLabel {
                     label: label.to_string(),
                 })?;
-        out.push_str(&content[cursor..open_at]);
+        out.push_str(&content[cursor..occ.span.start]);
         out.push_str(&render_callout_ref(label, ordinal, renderer));
-        cursor = label_start + close_rel + CALLOUT_DIRECTIVE_CLOSE.len();
+        cursor = occ.span.end;
     }
     out.push_str(&content[cursor..]);
     Ok(out)
@@ -885,62 +761,6 @@
     }
 }
 
-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:
 /// only added (`+`) lines are stripped of their diff indicator and parsed
@@ -1858,6 +1678,25 @@
     }
 
     #[test]
+    fn replace_callout_refs_leaves_backslash_escaped_directive_literal() {
+        // The backslash escape works for cross-refs the same way it does
+        // for the include and diff directives: the example stays literal
+        // even when the label would resolve.
+        let content =
+            "```rust\n// CALLOUT: greeting Hello.\n```\n\nEscaped: \\{{#callout greeting}}.\n";
+        let out = splice_chapter(content, SupportedRenderer::Html, &SidecarCallouts::empty())
+            .expect("splice");
+        assert!(
+            out.contains("\\{{#callout greeting}}"),
+            "escaped directive must survive verbatim; got:\n{out}",
+        );
+        assert!(
+            !out.contains("data-callout-ref=\"greeting\""),
+            "escaped directive must not render a cross-ref anchor; 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

To confirm the refactor changed nothing it shouldn’t, this book was built twice — once with the pre-slice binary, once with this one — and the rendered HTML compared byte-for-byte. Every chapter matched except ch.4, whose live: diff block re-renders the current src/diff.rs by design.

What this story does not solve

  • verify still bails with not yet implemented. The chapter that wires it up (ch.7) is placeholder.
  • Sidecar line for ranged includes with inline markers works correctly, but the translation is purely positional — there’s no “anchor by label” mechanism that decouples a sidecar entry from its source-line position. A refactor that moved code around would silently shift the sidecar’s badge.
  • Sidecar entries don’t support --align-style options. The inline // CALLOUT: grammar accepts --key=value tokens (slice 4); the sidecar TOML schema doesn’t. Adding it is straightforward but no downstream has asked for it yet.
  • PDF-side sidecar rendering works (the PDF splicer’s blockquote emit gets the merged callouts) but isn’t exercised by an e2e test the way HTML is. PDF coverage on sidecar is whatever the HTML coverage proves transitively.