Automatically add exercise links to sections. (#52)
We use an mdbook preprocessor to automatically generate links to the relevant exercise for each section. We remove all existing manual links and refactor the deploy process to push the rendered book to a branch.
This commit is contained in:
11
helpers/mdbook-exercise-linker/Cargo.toml
Normal file
11
helpers/mdbook-exercise-linker/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "mdbook-exercise-linker"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.86"
|
||||
clap = "4.5.4"
|
||||
mdbook = "0.4.40"
|
||||
semver = "1.0.23"
|
||||
serde_json = "1.0.117"
|
||||
72
helpers/mdbook-exercise-linker/src/lib.rs
Normal file
72
helpers/mdbook-exercise-linker/src/lib.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use anyhow::{Context, Error};
|
||||
use mdbook::book::Book;
|
||||
use mdbook::preprocess::{Preprocessor, PreprocessorContext};
|
||||
use mdbook::BookItem;
|
||||
|
||||
/// A no-op preprocessor.
|
||||
pub struct ExerciseLinker;
|
||||
|
||||
impl ExerciseLinker {
|
||||
pub fn new() -> ExerciseLinker {
|
||||
ExerciseLinker
|
||||
}
|
||||
}
|
||||
|
||||
impl Preprocessor for ExerciseLinker {
|
||||
fn name(&self) -> &str {
|
||||
"exercise-linker"
|
||||
}
|
||||
|
||||
fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
|
||||
let config = ctx
|
||||
.config
|
||||
.get_preprocessor(self.name())
|
||||
.context("Failed to get preprocessor configuration")?;
|
||||
let key = String::from("exercise_root_url");
|
||||
let root_url = config
|
||||
.get(&key)
|
||||
.context("Failed to get `exercise_root_url`")?;
|
||||
let root_url = root_url
|
||||
.as_str()
|
||||
.context("`exercise_root_url` is not a string")?
|
||||
.to_owned();
|
||||
|
||||
book.sections
|
||||
.iter_mut()
|
||||
.for_each(|i| process_book_item(i, &root_url));
|
||||
Ok(book)
|
||||
}
|
||||
|
||||
fn supports_renderer(&self, _renderer: &str) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn process_book_item(item: &mut BookItem, root_url: &str) {
|
||||
match item {
|
||||
BookItem::Chapter(chapter) => {
|
||||
chapter.sub_items.iter_mut().for_each(|item| {
|
||||
process_book_item(item, root_url);
|
||||
});
|
||||
|
||||
let Some(source_path) = &chapter.source_path else {
|
||||
return;
|
||||
};
|
||||
let source_path = source_path.display().to_string();
|
||||
|
||||
// Ignore non-exercise chapters
|
||||
if !source_path.chars().take(2).all(|c| c.is_digit(10)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let exercise_path = source_path.strip_suffix(".md").unwrap();
|
||||
let link_section = format!(
|
||||
"\n## Exercise\n\nThe exercise for this section is located in [`{exercise_path}`]({})",
|
||||
format!("{}/{}", root_url, exercise_path)
|
||||
);
|
||||
chapter.content.push_str(&link_section);
|
||||
}
|
||||
BookItem::Separator => {}
|
||||
BookItem::PartTitle(_) => {}
|
||||
}
|
||||
}
|
||||
67
helpers/mdbook-exercise-linker/src/main.rs
Normal file
67
helpers/mdbook-exercise-linker/src/main.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use std::io;
|
||||
use std::process;
|
||||
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
use mdbook::errors::Error;
|
||||
use mdbook::preprocess::{CmdPreprocessor, Preprocessor};
|
||||
use semver::{Version, VersionReq};
|
||||
|
||||
use mdbook_exercise_linker::ExerciseLinker;
|
||||
|
||||
pub fn make_app() -> Command {
|
||||
Command::new("exercise-linker").subcommand(
|
||||
Command::new("supports")
|
||||
.arg(Arg::new("renderer").required(true))
|
||||
.about("Check whether a renderer is supported by this preprocessor"),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let matches = make_app().get_matches();
|
||||
|
||||
// Users will want to construct their own preprocessor here
|
||||
let preprocessor = ExerciseLinker::new();
|
||||
|
||||
if let Some(sub_args) = matches.subcommand_matches("supports") {
|
||||
handle_supports(&preprocessor, sub_args);
|
||||
} else if let Err(e) = handle_preprocessing(&preprocessor) {
|
||||
eprintln!("{}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<(), Error> {
|
||||
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
|
||||
|
||||
let book_version = Version::parse(&ctx.mdbook_version)?;
|
||||
let version_req = VersionReq::parse(mdbook::MDBOOK_VERSION)?;
|
||||
|
||||
if !version_req.matches(&book_version) {
|
||||
eprintln!(
|
||||
"Warning: The {} plugin was built against version {} of mdbook, \
|
||||
but we're being called from version {}",
|
||||
pre.name(),
|
||||
mdbook::MDBOOK_VERSION,
|
||||
ctx.mdbook_version
|
||||
);
|
||||
}
|
||||
|
||||
let processed_book = pre.run(&ctx, book)?;
|
||||
serde_json::to_writer(io::stdout(), &processed_book)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_supports(pre: &dyn Preprocessor, sub_args: &ArgMatches) -> ! {
|
||||
let renderer = sub_args
|
||||
.get_one::<String>("renderer")
|
||||
.expect("Required argument");
|
||||
let supported = pre.supports_renderer(renderer);
|
||||
|
||||
// Signal whether the renderer is supported by exiting with 1 or 0.
|
||||
if supported {
|
||||
process::exit(0);
|
||||
} else {
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user