Merge pull request #346 from mainmatter/externalize-helpers
move mdbook plugins to their own repos
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -42,9 +42,9 @@ jobs:
|
|||||||
sed -i 's/CoreSansA45.ttf/Open Sans:style=Regular/g' book/book.toml
|
sed -i 's/CoreSansA45.ttf/Open Sans:style=Regular/g' book/book.toml
|
||||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
- name: Install exercise plugin
|
- 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
|
- 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
|
- name: Install mdbook-pandoc, calibre, pdftk and related dependencies
|
||||||
run: |
|
run: |
|
||||||
cargo install mdbook-pandoc --locked --version 0.11.0
|
cargo install mdbook-pandoc --locked --version 0.11.0
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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<Book, Error> {
|
|
||||||
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<bool> {
|
|
||||||
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(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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::<String>("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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
@@ -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, String>) -> 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<Book, Error> {
|
|
||||||
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::<BTreeMap<_, _>>();
|
|
||||||
serde_json::to_writer_pretty(&mut file, &ordered)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(book)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn supports_renderer(&self, _renderer: &str) -> mdbook_preprocessor::errors::Result<bool> {
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn replace_anchors(
|
|
||||||
chapter: &mut Chapter,
|
|
||||||
root_url: &str,
|
|
||||||
alias_gen: &mut AliasGenerator,
|
|
||||||
link2alias: &mut BiHashMap<String, String>,
|
|
||||||
verify: bool,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
|
||||||
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}"))
|
|
||||||
}
|
|
||||||
@@ -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<SubCommand>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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(())
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user