diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28eff29..1a4e309 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,9 @@ jobs: sed -i 's/CoreSansA45.ttf/Open Sans:style=Regular/g' book/book.toml - uses: actions-rust-lang/setup-rust-toolchain@v1 - name: Install exercise plugin - run: cargo install --path helpers/mdbook-exercise-linker + run: cargo install --git https://github.com/mainmatter/mdbook-exercise-linker - name: Install link shortener plugin - run: cargo install --path helpers/mdbook-link-shortener + run: cargo install --git https://github.com/mainmatter/mdbook-link-shortener - name: Install mdbook-pandoc, calibre, pdftk and related dependencies run: | cargo install mdbook-pandoc --locked --version 0.11.0 diff --git a/helpers/mdbook-exercise-linker/Cargo.toml b/helpers/mdbook-exercise-linker/Cargo.toml deleted file mode 100644 index 50179c6..0000000 --- a/helpers/mdbook-exercise-linker/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "mdbook-exercise-linker" -version = "0.1.0" -edition = "2021" - -[dependencies] -anyhow = "1.0.100" -clap = "4.5.50" -mdbook-preprocessor = "0.5.3" -semver = "1.0.27" -serde_json = "1.0.145" diff --git a/helpers/mdbook-exercise-linker/src/lib.rs b/helpers/mdbook-exercise-linker/src/lib.rs deleted file mode 100644 index 8e37a1e..0000000 --- a/helpers/mdbook-exercise-linker/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -use anyhow::{Context, Error}; -use mdbook_preprocessor::book::{Book, BookItem}; -use mdbook_preprocessor::{Preprocessor, PreprocessorContext}; - -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 { - let root_url: String = ctx - .config - .get("preprocessor.exercise-linker.exercise_root_url") - .context("Failed to get `exercise_root_url`")? - .context("`exercise_root_url` is not set")?; - - book.items - .iter_mut() - .for_each(|i| process_book_item(i, &ctx.renderer, &root_url)); - Ok(book) - } - - fn supports_renderer(&self, _renderer: &str) -> mdbook_preprocessor::errors::Result { - Ok(true) - } -} - -fn process_book_item(item: &mut BookItem, renderer: &str, root_url: &str) { - match item { - BookItem::Chapter(chapter) => { - chapter.sub_items.iter_mut().for_each(|item| { - process_book_item(item, renderer, 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}`]({})\n", - format!("{}/{}", root_url, exercise_path) - ); - chapter.content.push_str(&link_section); - - if renderer == "pandoc" { - chapter.content.push_str("`\\newpage`{=latex}\n"); - } - } - BookItem::Separator => {} - BookItem::PartTitle(_) => {} - } -} diff --git a/helpers/mdbook-exercise-linker/src/main.rs b/helpers/mdbook-exercise-linker/src/main.rs deleted file mode 100644 index 79f5268..0000000 --- a/helpers/mdbook-exercise-linker/src/main.rs +++ /dev/null @@ -1,68 +0,0 @@ -use std::io; -use std::process; - -use clap::{Arg, ArgMatches, Command}; -use mdbook_preprocessor::{Preprocessor, MDBOOK_VERSION}; -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) -> anyhow::Result<()> { - let (ctx, book) = mdbook_preprocessor::parse_input(io::stdin())?; - - let book_version = Version::parse(&ctx.mdbook_version)?; - let version_req = VersionReq::parse(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_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::("renderer") - .expect("Required argument"); - let supported = pre - .supports_renderer(renderer) - .expect("Failed to check renderer support"); - - // Signal whether the renderer is supported by exiting with 1 or 0. - if supported { - process::exit(0); - } else { - process::exit(1); - } -} diff --git a/helpers/mdbook-link-shortener/Cargo.toml b/helpers/mdbook-link-shortener/Cargo.toml deleted file mode 100644 index aa1c931..0000000 --- a/helpers/mdbook-link-shortener/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "mdbook-link-shortener" -version = "0.1.0" -edition = "2021" - -[dependencies] -anyhow = "1.0.100" -bimap = { version = "0.6.3", features = ["serde"] } -clap = { version = "4.5.50", features = ["derive"] } -itertools = "0.13.0" -mdbook-preprocessor = "0.5.3" -pulldown-cmark = "0.11.3" -pulldown-cmark-to-cmark = "15" -semver = "1.0.27" -serde_json = "1.0.145" diff --git a/helpers/mdbook-link-shortener/src/lib.rs b/helpers/mdbook-link-shortener/src/lib.rs deleted file mode 100644 index 0cdaa39..0000000 --- a/helpers/mdbook-link-shortener/src/lib.rs +++ /dev/null @@ -1,217 +0,0 @@ -use anyhow::{Context, Error}; -use bimap::BiHashMap; -use itertools::Itertools; -use mdbook_preprocessor::book::{Book, BookItem, Chapter}; -use mdbook_preprocessor::{Preprocessor, PreprocessorContext}; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::File; -use std::path::PathBuf; -use std::str::FromStr; - -pub struct LinkShortener; - -struct AliasGenerator { - cursors: [usize; 3], -} - -impl AliasGenerator { - const ALPHABET: &'static [u8] = b"f2z4x6v8bnm3q5w7e9rtyuplkshgjdca"; - - fn new() -> AliasGenerator { - AliasGenerator { cursors: [0, 0, 0] } - } - - /// Generate a 4 alphanumeric long alias, starting from "aaaa" and incrementing by one each time - /// until "9999", using only lowercase letters and numbers. - /// We skip ambiguous characters like "0", "o", "1", "l". - fn next(&mut self) -> String { - let mut alias = String::with_capacity(4); - for cursor in &mut self.cursors { - alias.push(Self::ALPHABET[*cursor] as char); - } - - for cursor in self.cursors.iter_mut().rev() { - if *cursor == Self::ALPHABET.len() - 1 { - *cursor = 0; - } else { - *cursor += 1; - break; - } - } - - alias - } - - /// Generate a unique alias that is not already used by the `link2alias` map. - fn next_until_unique(&mut self, link2alias: &BiHashMap) -> String { - let mut alias = self.next(); - while link2alias.contains_right(&alias) { - alias = self.next(); - } - alias - } -} - -impl LinkShortener { - pub fn new() -> LinkShortener { - LinkShortener - } -} - -impl Preprocessor for LinkShortener { - fn name(&self) -> &str { - "link-shortener" - } - - fn run(&self, ctx: &PreprocessorContext, mut book: Book) -> Result { - let root_url: String = ctx - .config - .get("preprocessor.link-shortener.base_url") - .context("Failed to get `base_url`")? - .context("`base_url` is not set")?; - let mapping = { - let mapping: String = ctx - .config - .get("preprocessor.link-shortener.mapping") - .context("Failed to get `mapping`")? - .context("`mapping` is not set")?; - PathBuf::from_str(&mapping).context("Failed to parse `mapping` as a path")? - }; - let mut link2alias = { - match File::open(&mapping) { - Ok(file) => { - serde_json::from_reader(file).context("Failed to parse existing mapping")? - } - Err(e) => { - if e.kind() == std::io::ErrorKind::NotFound { - BiHashMap::new() - } else { - return Err(e).context("Failed to open existing mapping"); - } - } - } - }; - let verify: bool = ctx - .config - .get("preprocessor.link-shortener.verify") - .context("Failed to get `verify`")? - .context("`verify` is not set")?; - // Env var overrides config - let verify = std::env::var("LINK_SHORTENER_VERIFY") - .map(|v| v == "true") - .unwrap_or(verify); - - let mut alias_gen = AliasGenerator::new(); - - book.items.iter_mut().for_each(|i| { - if let BookItem::Chapter(c) = i { - c.content = replace_anchors(c, &root_url, &mut alias_gen, &mut link2alias, verify) - .expect("Error converting links for chapter"); - for i in c.sub_items.iter_mut() { - if let BookItem::Chapter(sub_chapter) = i { - sub_chapter.content = replace_anchors( - sub_chapter, - &root_url, - &mut alias_gen, - &mut link2alias, - verify, - ) - .expect("Error converting links for subchapter"); - } - } - } - }); - - if !verify { - std::fs::create_dir_all(mapping.parent().expect("Mapping file path has no parent"))?; - let mut file = File::create(&mapping).context("Failed to upsert mapping file")?; - let ordered = link2alias.iter().collect::>(); - serde_json::to_writer_pretty(&mut file, &ordered)?; - } - - Ok(book) - } - - fn supports_renderer(&self, _renderer: &str) -> mdbook_preprocessor::errors::Result { - Ok(true) - } -} - -fn replace_anchors( - chapter: &mut Chapter, - root_url: &str, - alias_gen: &mut AliasGenerator, - link2alias: &mut BiHashMap, - verify: bool, -) -> Result { - use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag}; - use pulldown_cmark_to_cmark::cmark; - - let mut buf = String::with_capacity(chapter.content.len()); - - let mut unshortened_links = BTreeSet::new(); - let events = Parser::new_ext(&chapter.content, Options::all()) - .map(|e| { - let Event::Start(Tag::Link { - link_type, - dest_url, - title, - id, - }) = &e - else { - return e; - }; - - match link_type { - LinkType::Autolink - | LinkType::Shortcut - | LinkType::Inline - | LinkType::Reference - | LinkType::Collapsed => { - if dest_url.starts_with("http") { - let alias = if let Some(alias) = link2alias.get_by_left(dest_url.as_ref()) { - alias.to_owned() - } else { - if verify { - unshortened_links.insert(dest_url.to_string()); - return e; - } - let alias = alias_gen.next_until_unique(&link2alias); - alias - }; - link2alias.insert(dest_url.to_string(), alias.clone()); - - Event::Start(Tag::Link { - link_type: link_type.to_owned(), - dest_url: CowStr::from(format!( - "{root_url}/{alias}", - root_url = root_url, - alias = alias - )), - title: title.clone(), - id: id.clone(), - }) - } else { - e - } - } - LinkType::Email - | LinkType::ReferenceUnknown - | LinkType::CollapsedUnknown - | LinkType::ShortcutUnknown => e, - } - }) - .collect_vec(); - - if verify && !unshortened_links.is_empty() { - let unshortened_links = unshortened_links.iter().join(", "); - return Err(anyhow::anyhow!( - "The following links are not shortened: {unshortened_links}\nRun again with `LINK_SHORTENER_VERIFY=false` to update the mapping \ - with the shortened links." - )); - } - - cmark(events.into_iter(), &mut buf) - .map(|_| buf) - .map_err(|err| anyhow::anyhow!("Markdown serialization failed: {err}")) -} diff --git a/helpers/mdbook-link-shortener/src/main.rs b/helpers/mdbook-link-shortener/src/main.rs deleted file mode 100644 index 71c1977..0000000 --- a/helpers/mdbook-link-shortener/src/main.rs +++ /dev/null @@ -1,68 +0,0 @@ -use clap::Parser; -use mdbook_link_shortener::LinkShortener; -use mdbook_preprocessor::{Preprocessor, MDBOOK_VERSION}; -use semver::{Version, VersionReq}; -use std::io; -use std::process; - -#[derive(clap::Parser, Debug)] -#[command(version, about)] -pub struct Cli { - #[command(subcommand)] - sub: Option, -} - -#[derive(clap::Parser, Debug)] -pub enum SubCommand { - #[clap(name = "supports")] - Supports(Supports), -} - -#[derive(clap::Parser, Debug)] -pub struct Supports { - #[arg(long)] - renderer: String, -} - -fn main() -> Result<(), anyhow::Error> { - let cli = Cli::parse(); - let preprocessor = LinkShortener::new(); - - if let Some(SubCommand::Supports(Supports { renderer })) = cli.sub { - let code = if preprocessor - .supports_renderer(&renderer) - .expect("Failed to check renderer support") - { - 0 - } else { - 1 - }; - process::exit(code); - } - - handle_preprocessing(&preprocessor)?; - - Ok(()) -} - -fn handle_preprocessing(pre: &dyn Preprocessor) -> anyhow::Result<()> { - let (ctx, book) = mdbook_preprocessor::parse_input(io::stdin())?; - - let book_version = Version::parse(&ctx.mdbook_version)?; - let version_req = VersionReq::parse(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_VERSION, - ctx.mdbook_version - ); - } - - let processed_book = pre.run(&ctx, book)?; - serde_json::to_writer(io::stdout(), &processed_book)?; - - Ok(()) -}