Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ jobs:
- name: Install Rust nightly
run: |
rustup set profile minimal
rustup toolchain install nightly -c rustc-dev -c llvm-tools
rustup toolchain install nightly -c rustc-dev -c llvm-tools -c clippy
rustup default nightly
- name: Report versions
run: |
Expand All @@ -117,6 +117,8 @@ jobs:
run: cargo update -p mdbook-spec --locked
- name: Test tools
run: cargo test --workspace --exclude grammar-check
- name: Check clippy (mdbook-chapter-index only)
run: cargo clippy -p mdbook-chapter-index --all-targets -- -Dwarnings

dev-guide:
name: dev-guide build check
Expand Down
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion book.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,9 @@ edition = "2024"
[preprocessor.spec]
command = "cargo run --release --manifest-path tools/mdbook-spec/Cargo.toml"

[preprocessor.chapter-index]
command = "cargo run --release --manifest-path tools/mdbook-chapter-index/Cargo.toml"
headings = { Appendices = "Appendices" }

[build]
extra-watch-dirs = ["tools/mdbook-spec/src", "tools/grammar/src"]
extra-watch-dirs = ["tools/mdbook-spec/src", "tools/grammar/src", "tools/mdbook-chapter-summary/src"]
2 changes: 2 additions & 0 deletions src/appendices.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# Appendices

<!-- mdbook-chapter-index:add-index -->
2 changes: 1 addition & 1 deletion src/lexical-structure.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Lexical structure

<!-- Editor Note: Oh, there's nothing here -->
<!-- mdbook-chapter-index:add-index -->
2 changes: 2 additions & 0 deletions src/type-system.md
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
# Type system

<!-- mdbook-chapter-index:add-index -->
17 changes: 17 additions & 0 deletions tools/mdbook-chapter-index/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[package]
name = "mdbook-chapter-index"
version = "0.1.0"
edition = "2024"
license = "MIT OR Apache-2.0"
description = "mdBook preprocessor to include chapter indices with links."
repository = "https://github.com/rust-lang/reference/"
publish = false

[dependencies]
mdbook-preprocessor = "0.5.4"
pathdiff = "0.2.3"
serde_json = "1.0.154"
toml = "1.1"

[lints.clippy]
pedantic = { level = "warn", priority = -1 }
211 changes: 211 additions & 0 deletions tools/mdbook-chapter-index/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Preprocessor to add chapter indices to chapter pages.

use std::io;
use std::path::{Path, PathBuf};

use mdbook_preprocessor::book::{Book, BookItem, Chapter};
use mdbook_preprocessor::config::Config;
use mdbook_preprocessor::errors::Result as PreprocessorResult;
use mdbook_preprocessor::{MDBOOK_VERSION, Preprocessor, PreprocessorContext, parse_input};

use pathdiff::diff_paths;

/// Marker to look for and replace in chapter pages.
const ADD_INDEX_MARKER: &str = "<!-- mdbook-chapter-index:add-index -->";

/// A page in a chapter, which chapter it is in should be tracked separately.
struct ChapterPage {
/// Chapter name, to display, including number if applicable.
name: String,
/// Chapter path, to link.
path: PathBuf,
}

/// Overall hierarchy of chapters.
enum ChapterHierarchy {
/// This chapter has no sub-chapters.
Leaf(ChapterPage),
/// This chapter has sub-chapters.
Nested(ChapterPage, Vec<ChapterHierarchy>),
}

impl ChapterHierarchy {
/// Convert an mdbook chapter into its hierarchy, ignoring draft chapters.
fn from_mdbook_chapter(chapter: &Chapter) -> Option<Self> {
let Some(ref path) = chapter.path else {
// This is a draft chapter.
return None;
};
let current_page = ChapterPage {
name: chapter.to_string(),
path: path.clone(),
};
let inner: Vec<ChapterHierarchy> = chapter
.sub_items
.iter()
.filter_map(|item| match item {
BookItem::Chapter(inner_chapter) => Some(inner_chapter),
_ => None,
})
.filter_map(ChapterHierarchy::from_mdbook_chapter)
.collect();
if inner.is_empty() {
Some(ChapterHierarchy::Leaf(current_page))
} else {
Some(ChapterHierarchy::Nested(current_page, inner))
}
}

/// Get the markdown to represent the current hierarchy.
///
/// The markdown list is nested four spaces for each level of `nesting`.
///
/// Links to chapters are created relative to the provided `root`.
fn to_markdown_links(&self, nesting: usize, root: &Path) -> String {
let current_indent = " ".repeat(nesting);
match self {
ChapterHierarchy::Leaf(current_page) => {
let Some(relative_path) = diff_paths(&current_page.path, root) else {
// Something went wrong
return String::new();
};
format!(
"{current_indent}* [{display}]({link})\n",
display = current_page.name,
link = relative_path.display()
)
}
ChapterHierarchy::Nested(current_page, inner) => {
let Some(relative_path) = diff_paths(&current_page.path, root) else {
// Something went wrong
return String::new();
};
let inner_display: String = inner
.iter()
.map(|hierarchy| hierarchy.to_markdown_links(nesting + 1, root))
.collect();
if inner_display.is_empty() {
// Something went wrong - don't add the extra colon if there
// is no nested list.
return format!(
"{current_indent}* [{display}]({link})\n",
display = current_page.name,
link = relative_path.display()
);
}
format!(
"{current_indent}* [{display}]({link}):\n{inner_display}",
display = current_page.name,
link = relative_path.display()
)
}
}
}
}

/// Process the chapter indices using the [`ChapterIndices`] preprocessor.
///
/// # Errors
///
/// Errors come from one of
///
/// - [`mdbook_preprocessor::parse_input()`][mdbook_preprocessor::parse_input]
/// - [`ChapterIndices::run()`][ChapterIndices::run]
/// - [`serde_json::to_writer()`][serde_json::to_writer]
pub fn process_chapter_indices() -> PreprocessorResult<()> {
let preprocessor = ChapterIndices;
let (context, book) = parse_input(io::stdin())?;

if context.mdbook_version != MDBOOK_VERSION {
eprintln!(
"Warning: The {} plugin was built against version {} of mdbook, \
but is being called from version {}",
preprocessor.name(),
MDBOOK_VERSION,
context.mdbook_version
);
}

let processed_book = preprocessor.run(&context, book)?;
serde_json::to_writer(io::stdout(), &processed_book)?;

Ok(())
}

/// Check the configuration for a custom header to be configured for the chapter
/// index.
///
/// The `headings` configuration can be:
/// - a string to use for all headings
/// - a table mapping chapter names to the string to use
///
/// anything else is ignored and results in the default.
fn get_configured_header(config: &Config, chapter_name: &String) -> Option<String> {
let value: toml::Value = config.get("preprocessor.chapter-index.headings").ok()??;
match value {
toml::Value::String(fixed_header) => Some(fixed_header),
toml::Value::Table(custom_headers) => match custom_headers.get(chapter_name)? {
toml::Value::String(chapter_header) => Some(chapter_header.to_owned()),
_ => None,
},
_ => None,
}
}

/// Implementation of the preprocessor.
#[derive(Default, Copy, Clone, Debug)]
#[non_exhaustive]
pub struct ChapterIndices;

impl ChapterIndices {
/// Construct a new instance of the preprocessor.
#[must_use]
pub fn new() -> Self {
ChapterIndices
}

/// Handle a single chapter in the book, updating its content if requested.
fn process_chapter(config: &Config, chapter: &mut Chapter) {
let Some(ref root_path) = chapter.path else {
// Draft chapter.
return;
};
let Some(root_directory) = root_path.parent() else {
// Something weird is going on...
return;
};
if !chapter.content.contains(ADD_INDEX_MARKER) {
return;
}
let mut index: String = chapter
.sub_items
.iter()
.filter_map(|item| match item {
BookItem::Chapter(inner_chapter) => Some(inner_chapter),
_ => None,
})
.filter_map(ChapterHierarchy::from_mdbook_chapter)
.map(|hierarchy| hierarchy.to_markdown_links(0, root_directory))
.collect();
if !index.is_empty() {
// Support for custom headings for different pages
let index_header = match get_configured_header(config, &chapter.name) {
Some(header) => header,
None => "Chapter contents".to_owned(),
};
index = format!("{index_header}:\n{index}");
}
chapter.content = chapter.content.replace(ADD_INDEX_MARKER, &index);
}
}

impl Preprocessor for ChapterIndices {
fn name(&self) -> &'static str {
"mdbook-chapter-index"
}

fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> PreprocessorResult<Book> {
book.for_each_chapter_mut(|chapter| Self::process_chapter(&ctx.config, chapter));
Ok(book)
}
}
24 changes: 24 additions & 0 deletions tools/mdbook-chapter-index/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! Preprocessor to add chapter indices to chapter pages.

use std::env;
use std::error::Error;

use mdbook_preprocessor::errors::Error as PreprocessorError;
use mdbook_preprocessor::errors::Result as PreprocessorResult;

fn main() -> PreprocessorResult<()> {
// Skip index 0 which is the path
let mut args = env::args().skip(1);
if let Some(command) = args.next() {
return match command.as_str() {
"supports" => Ok(()),
unknown => {
let error: Box<dyn Error + Send + Sync> =
format!("Unknown_command: {unknown}").into();
Err(PreprocessorError::from_boxed(error))
}
};
}

mdbook_chapter_index::process_chapter_indices()
}
Loading
Loading