mdBook parity A–E: pipeline, SUMMARY, CLI, output, themes - #27
mdBook parity A–E: pipeline, SUMMARY, CLI, output, themes#27AlexMikhalev wants to merge 20 commits into
Conversation
Phase 1 (disciplined-research) and Phase 2 (disciplined-design) for bringing md-book to contract parity with mdBook. Key finding: md-book has no SUMMARY.md parser. Structure is inferred from a path-sorted walkdir with sections named after parent directories, which accounts for 11 of 13 structural gaps and makes previous/next incorrect. Two output defects rank above missing features: absolute asset paths (breaks sub-path deployment) and CDN-loaded Shoelace (breaks offline viewing). Parity is scoped as the authored book contract, not mdBook internals; local decisions (Pagefind, Tera, Web Components, markdown crate, twelf, jiff, WASM) are retained and recorded as N/A rather than gaps. Five sequenced increments tracked as terraphim/md-book #1-#5. Refs #1, Refs #2, Refs #3, Refs #4, Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rounds of specification interview on the SUMMARY.md book model. Two decisions went against the drafted recommendation and change the API: - create-missing is honoured with mdBook's default of true, so the build writes stub files into src/; created paths are returned so the watcher can suppress the resulting events and avoid a rebuild loop. - Summary errors are collected in one pass and reported together rather than failing fast, so migrating a large book takes one build round-trip. parse_summary now returns SummaryErrors. The security dimension surfaced a requirement absent from the plan: path containment. SUMMARY.md names files to read and republish, which in CI can be untrusted PR input, so any path canonicalising outside src/ -- symlinks included -- is refused. The accessibility decision (nested <ul>, aria-current, disabled draft entries) is incompatible with a flat depth-tagged list because Tera macros cannot recurse; NavEntry gains open_lists/close_lists deltas so a single template loop emits correct hierarchy. Refs #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Extract the monolithic build_sync_impl_sync into named stages so later mdBook-parity increments target one stage each. Zero behaviour change: output is byte-identical to the pre-A baseline (72/72 assets). - src/pipeline: collect, run_sync, async index, identity preprocess seam - src/render: markdown (mdast+syntect) and html (Tera/assets) modules - src/core: Args + build orchestration only - Tests relocated with their code; preprocess identity covered Refs #1
Increment B of mdBook parity: parse SUMMARY.md into an explicit Book
tree, assign section numbers, map README.md to index.html, and drive
sidebar/prev/next from authored order. Directory walk remains the
fallback when no SUMMARY.md exists.
- src/book/{mod,summary,directory}.rs: Book/Chapter/NavEntry, parser,
path containment, create-missing stubs, to_nav list deltas
- pipeline selects load_book; orphan .md warnings; asset pass-through
- nested sidebar with part titles, drafts (aria-disabled), section nums
- structural fixture + integration tests; mdbook tests updated for
README→index and SUMMARY non-publication
Refs #2
- Validate deepest existing ancestor before create-missing writes (P1 symlink escape) - Normalise relative paths for duplicate detection (./a.md) - Preserve fragments on external SUMMARY URLs Refs #2
Increment C: - src/paths.rs path resolution (CLI > book.toml > default) - build|serve|watch|init|clean subcommands; -d/--dest-dir; -n/--hostname - book.src, build.build-dir, build.extra-watch-dirs, create_missing - book.toml resolved from book directory Increment D: - path_to_root on every template asset URL - server-side heading IDs (GitHub-compatible slugs) - 404.html; wire code-copy.js Increment E: - themes.css + theme-switch (localStorage); keyboard shortcuts - print.html with all chapters; [output.html.redirect] stubs - config keys for fold/print/additional-css/js/syntax-theme (wired fields) Refs #3 Refs #4 Refs #5
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
1 similar comment
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
…errors Addresses review findings 1, 2 and 5, and adds the missing output-contract regression guards. Finding 1/2 — a bare `md-book` outside a book directory exited 0 having built an empty book, and the e2e test asserting the old "required args" failure was red. Path resolution stays pure; BookPaths::validate_for_build is the I/O boundary check and names both the expected path and the recovery. The e2e test now asserts the real contract, plus its counterpart (inside a book directory, no flags needed). Finding 5 — SummaryError::PrefixAfterNumbered was never constructed. A bare link after numbered chapters is a suffix chapter by definition, so the case the variant was meant to catch is a list item *after* a suffix chapter, which was being silently absorbed into the suffix list and losing its section number. Renamed to NumberedAfterSuffix and made reachable. The new guards caught a real defect: increment D1 made static asset URLs relative but left every navigation link root-absolute, so output still could not deploy under a sub-path or open over file://. Fixed at all four sources — sidebar hrefs (to_nav now takes path_to_root), prev/next, the header home link and logo, and the index card grid. Legacy `sections` paths become root-relative with templates prefixing path_to_root. doc-toc.js reuses the document's resolved stylesheet URL, since a copied component cannot see Tera's path_to_root. Six tests added: no absolute asset paths (scanning all emitted HTML and JS), no unexpected external URLs (Shoelace allowlisted pending D2), stable heading IDs across rebuilds, sidebar nesting and ARIA, asset pass-through with orphan exclusion, and create-missing idempotence. Refs #2, Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n CI Finding 4 — `build.create-missing` writes stubs into the source tree, and the watcher saw its own writes. The created paths were already returned by book_from_summary but discarded at the pipeline boundary (`let (book, _created) = ...`), so the mechanism the specification called for did not exist. Builds now return a BuildReport carrying the created paths, and SelfWriteFilter (src/watch.rs) drops the next watcher event naming each one. The record is consumed on match, so a genuine edit to the same file immediately afterwards still rebuilds; a batch mixing our stub with a real edit rebuilds too, since suppressing it would silently lose the user's change. Verified end to end: a SUMMARY entry for a missing file yields the stub and zero rebuilds, while editing a chapter yields exactly one. test_watch_suppresses_created_stub_event now exists as specified, alongside guards for unrelated edits, mixed batches and empty batches. Testing the decision rather than the loop keeps it deterministic — no sleeps, no process spawning. Finding 8 — the "__trail__" magic title marking the structural terminator in the sidebar is replaced by NavKind::ListClose. CI ran only the unit, integration and e2e targets, so the structural conformance suite that increment B was built around never ran there. All six targets now run. Refs #1, Refs #2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes increment D's offline gate. Shoelace was loaded from jsDelivr, so generated books needed the network to render. Vendoring the full distribution was not an option: it is 14MB over 2,920 files (8.4MB of that the icon set, which sl-icon fetches at runtime) and copy_static_assets writes templates into *every* built book. Instead the transitive import closure of the five components actually used — button, icon, input, spinner, card — is vendored with the ten referenced icons and the light/dark themes: 43 JS files, 356KB. shoelace-local.js replaces the CDN autoloader and derives setBasePath from its own module URL, so it resolves at a domain root, under a sub-path, or over file://. Shadow-DOM components cannot see Tera variables, so doc-toc, doc-sidebar and simple-block now resolve their stylesheets from import.meta.url. The dead <script> tag injected via innerHTML in doc-sidebar (innerHTML never executes scripts) is removed. Verified against the 30-page corpus served under /docs/: no external URLs, every referenced asset resolves, and all 44 files in the Shoelace module graph load — a missing chunk would have broken component upgrade silently. Separately, this exposed a pre-existing defect that the sub-path check made visible: css/, js/ and img/ were only emitted when a templates directory happened to exist on disk, so an installed md-book produced books with no styles.css, no search JS, no mermaid and no logo. The repo's own builds masked it (book.toml points at src/templates) and TestBook has no templates dir either, so nothing caught it. Default css/js/img/components are now embedded with include_dir and a templates directory overrides them per file. copy_static_assets loses its pile of include_str! calls in the process. Adds test_default_assets_emitted_without_templates_dir, which asserts every local URL a page references was actually emitted, and tightens test_output_has_no_external_urls by deleting the CDN allowlist. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Vendoring the full Shoelace distribution was rejected once measured (14MB, 2,920 files, copied into every book); the plan now records what shipped instead. include_dir was not anticipated by the plan and is documented with its justification. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verifying the feature matrix after the include_dir change surfaced three latent breaks, all pre-existing and none covered by CI's matrix, which tested only default, the full set, and wasm-core: - `search` alone failed: pagefind_service spawns the pagefind CLI via tokio::process, which only resolved because `server` pulls in tokio/full. - `watcher` alone failed: main.rs awaits the watch task with futures::future::join_all, and only `server` supplied `futures`. - `core` alone failed: it drives #[tokio::main] without tokio/macros or a runtime. Fixing the first revealed a fourth, in code this branch touched: the async/sync build call site keyed its cfg off server/watcher, while core::build keys off `tokio`. With `search` (which enables tokio/rt but neither server nor watcher) the sync branch called an async fn. Now both sides key off the same feature. CI's feature matrix gains the three single-feature combinations so each stays honest about its own dependencies. Refs #1 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
js/mermaid.min.js is 2.9MB and page.html.tera loads it on every page regardless of content: 2.9MB of the corpus's 4.2MB output, plus a parse-and-execute cost on every page load for the majority of books that have no diagrams at all. Detection is specified at fence level, where render::markdown already special-cases mermaid, rather than by substring-searching the finished HTML for "language-mermaid" — the latter would also fire on a page that merely documents the class in a code sample, which the test table guards against. print.html sets the flag if any included chapter has a diagram. The asset itself keeps being emitted unconditionally, so adding a diagram later cannot silently fail. Also fixes a flaky test that blocked this commit: two config tests call load_config, which resolves a relative "book.toml" against the process CWD that sibling tests mutate, but did not take CWD_MUTEX. They failed with an io error whenever the scheduler interleaved them badly. Verified with five consecutive clean runs. Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
js/mermaid.min.js is 2.9MB and every page loaded it regardless of content.
render_markdown now returns RenderedMarkdown { html, has_mermaid }, with the
flag set by walking the parsed AST for a mermaid fence. Detection is
deliberately not a substring search for "language-mermaid" over the
rendered HTML: a page documenting mermaid contains that string as escaped
text and would pull in 2.9MB to render nothing. test_mermaid_class_in_
code_sample_does_not_trigger_load guards that. The predicate lives in one
place, shared by both rendering paths, which costs one extra mdast parse per
page and keeps increment F's second parser honest.
The flag reaches page, index and print contexts; the two script tags are
gated on it. The asset is still emitted unconditionally, so adding a
diagram later cannot reference a missing file.
Two pre-existing gaps fixed in passing: index.html.tera and print.html.tera
never loaded mermaid at all, so a diagram in index.md or in the print view
silently failed to render.
Measured on the corpus: pages loading the bundle went from all 38 to zero,
so a reader pulls ~440KB instead of ~3.3MB. Not browser-verified — the
Chrome extension is disconnected and Playwright has no browser installed —
but the scripts, their order and mermaid-init.js are unchanged, only
conditional, so pages that rendered diagrams before still do.
Refs #5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
Browser verification of E7 (agent-browser against a served build) confirmed
the mermaid gating and turned up three defects a DOM check would not have
found from the HTML alone.
E7 verified: the diagram page fetches mermaid.min.js (2867KB) and renders
svg#mermaid-… with nodes Start/Middle/End, the SVG replacing the raw fence
inside its <code> element; the plain page fetches nothing mermaid-related
and window.mermaid is undefined. No console errors on either.
Defects found and fixed:
- Config defaults were never applied. `#[serde(default = "…")]` does not
survive twelf's layering (absent keys become empty strings), and
`#[serde(default)]` on a container field constructs it with
`Default::default()`, bypassing the per-field defaults inside. A book with
no book.toml rendered an empty <title>, and a logo with src="" that the
browser resolved to the page itself. Book, Rust, Paths and SearchConfig
now have hand-written Default impls using the same default_* functions,
and load_config fills unset scalars from them.
- The page and index templates emitted a bare <html> with no lang, while
404 and print hard-coded lang="en". All four now use
config.book.language, which the defaults fix makes non-empty.
- Three config tests asserted `x.is_empty() || !x.is_empty()` — true for any
value — which is why every default being empty went unnoticed. They now
assert the actual documented defaults.
Also corrects the research document: canonical URLs, meta descriptions and
the skip link were recorded as "Have", but were read from a dirty working
tree and live only in stash@{0}. main has none of them. Merging that stash
will now conflict with increments C-E, which rewrote the same templates.
Refs #4, Refs #5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
Completes E1 and implements plan item C3, which the review had assumed was already done. Theme picker: themes.css defined all five themes and theme-switch.js listened for [data-theme-set] clicks, but no template rendered such a control, so only automatic prefers-color-scheme switching worked. The header now carries a <details> picker — keyboard accessible with no script, closing on select — and the root element exposes data-default-theme and data-preferred-dark-theme, so `default-theme` and `preferred-dark-theme` finally take effect. theme-switch.js marks the active entry with aria-current. Browser-verified with agent-browser: selecting Coal sets data-theme=coal, computed background becomes rgb(20,22,23), localStorage persists it, and it survives navigation. Unsupported-key warnings: nine keys parsed and did nothing in silence. config::unsupported_keys_in inspects the parsed document rather than the loaded BookConfig, because a filled default is indistinguishable from a value the author typed — warning on defaults would fire for every book. Messages distinguish "not implemented yet" (syntax-theme, additional-css/js, fold, mathjax-support) from "no Pagefind equivalent" (the elasticlunr scoring knobs) and "out of scope" (playground). Three further defects found while wiring it up: - main.rs had its own copy of the config loader, so the CLI got neither the defaults fill nor the new warnings, and the two copies had already drifted (only main's resolved book.toml from the book directory). Collapsed into config::load_config_from; main.rs delegates. - index.html and print.html never linked themes.css, so they applied data-theme while showing unthemed colours. index.html also loaded neither theme-switch.js nor keyboard.js. - The header GitHub link and the footer link rendered unconditionally, emitting href="" — a link to the page itself — when the URLs were unset. The header's edit link was guarded by the wrong key. Icon links also regained their aria-labels. Refs #3, Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
stash@{0} (canonical URL, meta description, skip link) cannot be popped: its
src/core.rs hunks patch functions increment A dissolved, and all three
templates it touches were rewritten by C-E. It is a source to salvage from,
not a change to replay.
Scoped as increment G with four steps, since two of its items (the lang
attribute and the header aria-labels) have already landed independently:
- G1 per-page description, deriving text from the parsed mdast rather than
the stash's `if c.is_ascii()` filter, which turns "café" into "caf "
- G2 canonical URLs, reconciling the two competing keys: mdBook's
output.html.site-url wins, book.base_url becomes a deprecated alias, and
disagreement between them warns
- G3 skip link plus the article id and the visually-hidden style
- G4 search UI gated on a Pagefind index actually existing — currently a
search box ships even when indexing was skipped
Eight tests specified, including one guarding the non-ASCII defect. The gate
records that stash@{0} can be dropped once G lands.
Refs #4
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
There was a problem hiding this comment.
Summary
Brings md-book to contract parity with mdBook across increments A–E, plus E7 (conditional mermaid) and a large body of defect fixes found by reviewing and then actually running the output in a browser.
Key changes:
src/book/— a realBook/BookItem/Chaptermodel parsed fromSUMMARY.md, replacing structure inferred from a path-sortedwalkdir. Prefix/suffix chapters, part titles, nesting, drafts, separators, section numbering and summary-driven exclusion all derive from one source. Directory-walk fallback retained, selected by the presence ofSUMMARY.md, so no existing book breaks.src/pipeline/— the former 250-linebuild_sync_impl_syncsplit into collect → preprocess → render → index, withpreprocess.rsleft as an identity seam for the deferred P2 directive work.src/paths.rs+src/main.rs—build|serve|watch|init|clean [dir]subcommands honouringbook.src/build.build-dir, with-i/-oretained as overrides.- Output correctness —
path_to_rooton every URL, server-side heading IDs, 404 page, vendored Shoelace (356KB subset, not the 14MB distribution), and default css/js/img/components embedded viainclude_dirso an installed binary emits a complete book. src/watch.rs—SelfWriteFilter, socreate-missingstubs no longer trigger their own rebuild.
Done well: the book model is the right abstraction and everything else reads from it; the NavEntry open/close deltas are a neat answer to Tera's lack of recursion; path containment in book_from_summary covers symlinked ancestors, which is more than the specification asked for; and the test suite grew from substring assertions to structural fixtures plus browser-verified behaviour (134 tests across seven targets).
Issues from prior rounds that are now resolved:
- B round 1 (3/5): symlink escape in summary path resolution — fixed and covered by
test_create_missing_rejects_symlink_escape. - C/D/E round 1 (2/5): redirect
frompath traversal and unescapedto— both fixed; the writer now rejects absolute/parent paths, canonicalises the parent against the build root, and escapes withencode_double_quoted_attribute. - Session review (8 findings): red e2e test, silent empty-book builds, half-applied
path_to_root, unstyled books from an installed binary, missing config defaults, unreachable error variant, duplicate config loader, missing regression guards — all fixed, each with a test.
What remains problematic clusters around internationalisation and the 404 page: heading slugs discard all non-ASCII, so every heading in a non-Latin book collapses to section-N; and the 404 page uses relative asset paths, which breaks in the only scenario where a 404 page is served. Two config keys (input-404, site-url) still parse and do nothing, and are missing from the new unsupported-key warning list.
Confidence Score: 3/5
- Safe to merge with caution — two P1 findings should be addressed before or shortly after merging.
- Zero P0. Neither P1 risks data loss or security: they are correctness gaps that make two features (fragment links in non-English books; the 404 page outside the site root) not work as intended. The four P2 findings are hygiene and observability. Cumulative risk is well below where it started this round — every P0/P1 from prior rounds is resolved and covered by tests.
- Files needing attention:
src/render/slug.rs,src/templates/404.html.teraand its context insrc/pipeline/mod.rs,src/config.rs(warning list).
Important Files Changed
| Filename | Overview |
|---|---|
src/book/summary.rs |
SUMMARY.md parser: collects all errors in one pass, rejects duplicates, non-.md targets and paths escaping src/ (symlinks included). Clean; no findings. |
src/book/mod.rs |
Book model, section numbering, to_nav with open/close deltas. to_nav runs per page — O(pages × chapters); fine at corpus size, see P2-4. |
src/pipeline/mod.rs |
Stage sequencing, orphan warnings, asset pass-through, 404, redirects, print. Redirect hardening looks correct now. 404 context passes path_to_root: "" — see P1-2. |
src/render/slug.rs |
Heading slugs and ID injection. Discards non-ASCII — see P1-1. Per-call collision counter causes duplicate IDs in print.html — see P2-2. |
src/render/html.rs |
Tera context assembly, path_to_root, embedded-asset writing, copy_tree. Sound. |
src/render/markdown.rs |
mdast splice + syntect, plus has_mermaid detection at fence level (not by scanning HTML — correct choice). Extra mdast parse per page, see P2-4. |
src/config.rs |
Hand-written Default impls (the serde defaults never fired), fill_unset_with_defaults, unsupported-key warnings. Two keys missing from the list — see P2-1. |
src/watch.rs |
Self-write suppression; consumed on match so real edits still rebuild. Good. |
src/main.rs |
Subcommand dispatch; now delegates config loading to the library. serve -n silently falls back — see P2-3. |
src/server.rs |
Adds serve_book_on with a bind address. See P2-3. |
src/templates/*.tera |
Relative URLs throughout, theme picker, gated mermaid, guarded optional links. 404 template — see P1-2. |
src/templates/vendor/shoelace/** |
356KB vendored subset (transitive import closure of five components + 12 icons). Verified: all 44 modules resolve under a sub-path. |
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["CLI: build|serve|watch|init|clean [dir]"] --> B["paths::resolve + validate_for_build"]
B --> C["config::load_config_from<br/>defaults fill + unsupported-key warnings"]
C --> D{"src/SUMMARY.md exists?"}
D -- yes --> E["book::summary::parse_summary<br/>collect all errors"]
D -- no --> F["book::directory::book_from_directory<br/>legacy walk"]
E --> G["book_from_summary<br/>path containment + create-missing"]
G --> H["Book { items }"]
F --> H
H --> I["pipeline::preprocess<br/>(identity seam; P2 directives land here)"]
I --> J["render::markdown<br/>mdast splice + syntect + has_mermaid"]
J --> K["render::slug::inject_heading_ids"]
K --> L["render::html<br/>nav tree + path_to_root + theme attrs"]
L --> M["index / print / 404 / redirects"]
M --> N["copy_tree + embedded assets"]
N --> O["pagefind index (optional)"]
G -.->|created stub paths| P["watch::SelfWriteFilter<br/>drops self-inflicted events"]
style K fill:#f8d7da,stroke:#dc3545
style M fill:#f8d7da,stroke:#dc3545
style H fill:#d4edda,stroke:#28a745
style P fill:#d4edda,stroke:#28a745
Red: components carrying this round's P1 findings. Green: the new abstractions this PR is built on.
Inline Findings
P1 src/render/slug.rs, line 10: Heading slugs discard all non-ASCII, so non-Latin books lose every anchor
slugify keeps only c.is_ascii_alphanumeric(). Any heading without ASCII letters produces an empty slug and falls back to the literal "section", then section-1, section-2, … Verified on a real build:
# Café -> id="caf"
## Обзор -> id="section"
## 日本語の見出し -> id="section-1"
Consequences: cross-page fragment links ([see](page.md#обзор)) can never resolve; the on-page TOC produces meaningless anchors; Pagefind result anchors point at section-N. Every heading in a Cyrillic, Greek, Hebrew, Arabic or CJK book is affected — and book.language exists precisely to support those books. GitHub's slugger, which the doc comment claims compatibility with, preserves Unicode letters and only strips punctuation.
Relatedly, test_slugify_matches_github asserts only "Hello World", "Hello, World!" and "Foo_bar" — all ASCII — so it does not establish the compatibility the name and doc comment claim.
Suggested fix: keep alphanumerics by Unicode category rather than ASCII, lowercase via to_lowercase(), and strip only punctuation/symbols:
if c.is_alphanumeric() {
slug.extend(c.to_lowercase());
prev_dash = false;
} else if (c.is_whitespace() || c == '-' || c == '_') && !prev_dash && !slug.is_empty() {
slug.push('-');
prev_dash = true;
}and extend the test with a non-ASCII case.
P1 src/templates/404.html.tera, line 9 (context: src/pipeline/mod.rs, ctx.insert("path_to_root", &"")): The 404 page only works at the site root, which is the one place it is not needed
404.html is written at the build root with path_to_root = "", so its assets and home link are relative: css/styles.css, index.html. A server returns that single file for any unmatched path, and the browser resolves relative URLs against the request path. A request for /docs/guide/missing.html therefore fetches /docs/guide/css/styles.css (404) and the "Back to home" link points at /docs/guide/index.html.
Net effect: the 404 page renders unstyled with a broken escape route in exactly the nested-path case where it is served. output.html.site-url exists for this — it is mdBook's documented answer to the same problem — but is currently parsed and unused (see P2-1).
Suggested fix: when site-url is set, emit absolute asset paths in the 404 template only; otherwise keep the relative form and note the limitation in the README.
P2 src/config.rs, line ~330 (UNSUPPORTED list): input-404 and site-url parse, do nothing, and do not warn
The new unsupported-key warnings cover nine keys, but output.html.input-404 and output.html.site-url are used nowhere outside config.rs and are absent from the list. They are the exact failure mode the warning system was added to eliminate: an author sets input-404 = "not-found.md", no custom 404 is produced, and nothing says so.
Either implement them (they pair naturally with P1-2) or add both to UNSUPPORTED with "not implemented yet".
P2 src/render/slug.rs, line 37 (inject_heading_ids): print.html contains duplicate heading IDs
inject_heading_ids allocates a fresh seen map per call, and the print page renders each chapter with a separate call, so identical headings across chapters produce identical IDs in one document. Verified:
$ rg -o 'id="[^"]*"' print.html | sort | uniq -c
2 id="overview"
Duplicate IDs are invalid HTML and make in-page anchors ambiguous. Fix by threading one collision map through the print assembly (an inject_heading_ids_with(&mut seen, html) variant), leaving the per-page path unchanged.
P2 src/server.rs, line 39: serve --hostname silently falls back to localhost while claiming otherwise
hostname.parse::<IpAddr>() fails for anything that is not an IP literal, and the error is swallowed in favour of 127.0.0.1 — yet line 43 prints the string the user supplied. md-book serve -n myhost.local reports "Serving book at http://myhost.local:3000" while listening only on loopback.
0.0.0.0 and IP literals work, so the common container case is fine; the failure is confined to DNS names. Suggested fix: report the parse failure and either exit or print the address actually bound.
P2 src/book/mod.rs (to_nav) and src/render/markdown.rs (has_mermaid_fence): Per-page costs added without the benchmark the plan gated on
to_nav is called once per page and walks the whole chapter tree, so navigation rendering is O(pages × chapters); has_mermaid_fence adds a second mdast parse per page. Both are defensible (correctness and single-definition respectively) and both are documented in the code. The plan's increment A recorded a benches/pagefind_bench.rs baseline specifically so these could be compared, but no post-change measurement is recorded anywhere in the PR.
Not a blocker at corpus size (30 pages), but the gate should either be met or explicitly waived. Note also that benches/pagefind_bench.rs:324 panics on main and is not run by CI, so the benchmark harness itself needs a fix before it can answer this.
Comments Outside Diff (2)
-
benches/pagefind_bench.rs, line 324 — panics with "Unexpected error" when theMultipleConfigscase does not trigger. Pre-existing onmainand not covered by CI (which runscargo test, and the bench declaresharness = false). It blocks the performance verification this PR's plan calls for. -
.github/workflows/ci.yml— the feature matrix gained the single-feature combinations in this PR, which is what exposed three latent build breaks (search,watcherandcoreeach relied on another feature to supply a dependency). Worth keeping in mind that the matrix still omitsserver-only and no-feature builds, both of which do currently compile.
Last reviewed commit: 20e20e4 | Reviews (4)
P1 — heading slugs discarded every non-ASCII character, so "Café" became "caf" and "Обзор" / "日本語の見出し" both collapsed to "section", "section-1". Every heading in a non-Latin book shared the same anchor namespace, making cross-page fragment links and Pagefind anchors useless. slugify now keeps Unicode alphanumerics and lowercases via to_lowercase(). The doc comment claimed GitHub compatibility while test_slugify_matches_github only exercised ASCII; both are corrected. P1 — the 404 page emitted relative asset and home-link URLs, which the browser resolves against the *request* path. A 404 served for /docs/guide/missing.html fetched /docs/guide/css/styles.css and offered a home link into the missing directory: unstyled with a broken escape route, in the only case where a 404 is served. It now uses output.html.site-url (falling back to book.base_url) for absolute paths, and keeps the relative form when neither is set. P2 — output.html.input-404 and site-url parsed and did nothing, and were missing from the unsupported-key list. Both are now implemented rather than warned about: input-404 supplies the 404 body, and its source is excluded from the orphan-markdown warning. P2 — print.html contained duplicate heading ids because inject_heading_ids allocated a fresh collision namespace per chapter. Chapters now share one namespace via inject_heading_ids_with. P2 — `serve -n <dns-name>` silently bound loopback while printing the name the user asked for. Hostnames now resolve through to_socket_addrs, an unresolvable name is an error, the bound address is printed, and a server that cannot bind exits non-zero instead of leaving a dead process. P2 — the performance gate was never evidenced, and the bench target that was supposed to answer it panicked: it asserted PagefindError::MultipleConfigs, which PagefindBuilder::new has never produced. Bench fixed to measure what exists; measurements recorded in the plan. The corpus build is 127 ms against 100 ms on main (+27%, gate not met, attributed: the branch writes 4.2 MB of assets main never wrote). Measuring found one avoidable cost and removed it — has_mermaid re-parsed each page's mdast, which the highlighting path already walks, worth 12 ms of the 30-page build. Scaling data (50/200/500 pages) is recorded: the O(pages × chapters) term is real but inherent to embedding a full sidebar in every page, as mdBook does. Refs #3, Refs #4, Refs #5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AlexMikhalev
left a comment
There was a problem hiding this comment.
Summary
Re-review after 4a83c34, which addresses every finding from round 4.
All six findings are resolved, each verified on a real build rather than by reading the diff:
- P1 Unicode slugs —
slugifynow keeps Unicode alphanumerics and lowercases viato_lowercase(). Verified:# Café → id="café",## Обзор → id="обзор",## 日本語の見出し → id="日本語の見出し", where all three previously collapsed tocaf/section/section-1. The misleading doc comment and the ASCII-onlytest_slugify_matches_githubare corrected, andtest_slugify_keeps_unicode_letterspins the behaviour. - P1 404 page — now uses
output.html.site-url(falling back tobook.base_url) for absolute asset and home-link URLs, keeping the relative form when neither is set. Verified both ways: withsite-url = "/docs/"the page emitshref="/docs/css/styles.css"; without it,href="css/styles.css". - P2
input-404/site-url— implemented rather than added to the warning list.input-404supplies the 404 body, and its source file is excluded from the orphan-markdown warning (it was producing a false positive). - P2 duplicate print IDs — chapters share one collision namespace via the new
inject_heading_ids_with. Verified: no repeatedid="overview"inprint.html. - P2 silent hostname fallback — hostnames resolve through
to_socket_addrs, an unresolvable name is a hard error, the bound address is printed alongside the requested one, and a server that cannot bind now exits non-zero rather than leaving a live process with a dead listener. - P2 unevidenced performance gate — the bench target that was meant to answer it panicked because it asserted
PagefindError::MultipleConfigs, a variantPagefindBuilder::newhas never constructed. Bench fixed; measurements recorded in the plan.
The performance work deserves note because it changed a decision. Measuring showed the corpus build at 139 ms against 100 ms on main, missing the plan's 10% gate. Profiling the gap found one avoidable cost — has_mermaid re-parsed each page's mdast, which the syntax-highlighting path already walks — worth 12 ms, bringing it to 127 ms. The residual +27% is attributed rather than hidden: the branch writes 4.2 MB of assets per build that main never wrote at all, since emitting no css/, js/ or img/ was the defect increment D fixed. Scaling data at 50/200/500 pages is now in the plan; per-page cost doubles by 500 pages, but that term is inherent to embedding a full sidebar in every page, which mdBook does too.
Remaining suggestions (none blocking, none new):
- The dead
PagefindError::MultipleConfigsandInvalidConfigFormatvariants are still declared and never constructed. Left in place deliberately rather than removed as part of a review fix — they are public API. - Increment G (page metadata, canonical URL, skip link, search gating) is planned and tracked as #8; the search modal still ships even when no Pagefind index exists.
Confidence Score: 5/5
- Safe to merge with minimal risk.
- All P0/P1 findings from prior rounds are resolved, each with a regression test, and the remaining items are tracked follow-ups rather than defects in this diff. The test suite is now 138 tests across seven targets, and this round's fixes were verified against built output and a headless browser rather than by inspection.
- No files require special attention.
Important Files Changed
| Filename | Overview |
|---|---|
src/render/slug.rs |
Unicode-preserving slugs; inject_heading_ids_with added for shared collision namespaces. Two new tests, one covering punctuation and symbols. |
src/pipeline/mod.rs |
404 now receives a site_url_prefix and optional input-404 body; print chapters share an id namespace; orphan warning excludes the 404 source. |
src/templates/404.html.tera |
Renders the custom body when configured, absolute URLs when site-url is set. |
src/render/markdown.rs |
Mermaid detection folded into the existing splice walk; the standalone predicate is retained for the non-highlighting path. |
src/server.rs |
Hostname resolution with a real error instead of a silent loopback fallback; prints the bound address. |
src/main.rs |
A failed bind exits non-zero. |
benches/pagefind_bench.rs |
The case asserting an unimplemented error variant now measures construction; the target runs again. |
docs/plans/mdbook-parity-implementation-plan.md |
Performance gate recorded as not met with attribution, plus the 1/30/50/200/500-page measurements. |
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["render_markdown(content)"] --> B{"syntax-highlighting?"}
B -- yes --> C["process_markdown_with_highlighting<br/>one mdast walk"]
C --> D["code node: highlight<br/>+ set saw_mermaid"]
B -- no --> E["process_markdown_basic"]
E --> F["has_mermaid_fence<br/>separate parse (no walk to reuse)"]
D --> G["RenderedMarkdown { html, has_mermaid }"]
F --> G
G --> H{"page kind"}
H -- chapter --> I["inject_heading_ids<br/>fresh namespace"]
H -- print --> J["inject_heading_ids_with<br/>shared namespace"]
I --> K["page.html: mermaid gated on has_mermaid"]
J --> L["print.html: unique ids across chapters"]
style C fill:#d4edda,stroke:#28a745
style J fill:#d4edda,stroke:#28a745
Green: this round's changes — detection folded into the existing walk (removing a parse per page), and the shared id namespace for the print page.
Inline Findings
None. All findings from round 4 are resolved and covered by tests:
| Round 4 finding | Resolution | Test |
|---|---|---|
| P1 Unicode slugs | is_alphanumeric + to_lowercase |
test_slugify_keeps_unicode_letters, test_non_latin_headings_keep_addressable_ids |
| P1 404 relative URLs | site_url_prefix |
test_404_uses_site_url_when_configured, test_404_falls_back_to_relative_without_site_url |
P2 input-404 / site-url ignored |
implemented | test_404_uses_site_url_when_configured |
| P2 duplicate print ids | shared namespace | test_print_page_heading_ids_are_unique |
| P2 silent hostname fallback | resolve or error | manual: serve -n nope.invalid exits 1 with a clear message |
| P2 unevidenced perf gate | bench fixed, measured, recorded | docs/plans/mdbook-parity-implementation-plan.md |
Last reviewed commit: 4a83c34 | Reviews (5)
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
Five sites turned a PathBuf into a URL via Path::display(), which uses the platform separator. On Windows every nested chapter would emit href="individual\heading.html" — broken links throughout any book built there, plus a broken edit-link in the header, which derives from current_path. Added render::to_url_path, which joins components with '/' regardless of platform, and used it for chapter hrefs, prev/next paths, the legacy sections paths, current_path and the print-page anchors. Human-facing messages keep display(). Found while investigating why PR checks could not report: the CI workflow is disabled_inactivity on GitHub, and all five required status checks come from it — including the Windows job that would have caught this. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🚀 Deployment PreviewYour changes have been deployed to Cloudflare Pages! 🔗 Preview URL: https://preview-27.md-book.pages.dev The deployment will be updated automatically when you push new changes to this PR. |
See Gitea PR. Increments A–E of mdBook parity. Refs issues 1–5.