mdbook_repo_links/
resolver.rs1pub struct Config {
5 book_src: std::path::PathBuf,
7 repo_root: std::path::PathBuf,
8 repository: String,
9 branch: String,
10}
11
12impl Config {
13 pub fn from_context(context: &serde_json::Value) -> Result<Config, String> {
15 let root = context
16 .get("root")
17 .and_then(serde_json::Value::as_str)
18 .ok_or("mdBook's context names no root")?;
19 let root = std::path::Path::new(root);
20 let config = context.get("config");
21 let src = config
22 .and_then(|config| config.pointer("/book/src"))
23 .and_then(serde_json::Value::as_str)
24 .unwrap_or("src");
25 let table = config.and_then(|config| config.pointer("/preprocessor/repo-links"));
26 let setting = |key: &str| {
27 table
28 .and_then(|table| table.get(key))
29 .and_then(serde_json::Value::as_str)
30 };
31 let repository = setting("repository")
32 .ok_or("book.toml sets no [preprocessor.repo-links] repository")?
33 .trim_end_matches('/')
34 .to_string();
35 let repo_root = root.join(setting("repo-root").unwrap_or(".."));
36 Ok(Config {
37 book_src: canonical(&root.join(src))?,
38 repo_root: canonical(&repo_root)?,
39 repository,
40 branch: setting("branch").unwrap_or("master").to_string(),
41 })
42 }
43}
44
45fn canonical(path: &std::path::Path) -> Result<std::path::PathBuf, String> {
46 std::fs::canonicalize(path).map_err(|e| format!("{}: {e}", path.display()))
47}
48
49pub struct Resolver<'a> {
51 config: &'a Config,
52 in_book: std::path::PathBuf,
54 in_repo: std::path::PathBuf,
58}
59
60impl<'a> Resolver<'a> {
61 pub fn for_chapter(config: &'a Config, source: &std::path::Path) -> Resolver<'a> {
62 let directory = source.parent().unwrap_or(std::path::Path::new(""));
63 let in_book = directory.to_path_buf();
64 let mirrors_the_repository = config.repo_root.join(source).is_file();
65 let in_repo = if mirrors_the_repository {
66 directory.to_path_buf()
67 } else {
68 let book = config
69 .book_src
70 .strip_prefix(&config.repo_root)
71 .unwrap_or(std::path::Path::new(""));
72 book.join(directory)
73 };
74 Resolver {
75 config,
76 in_book,
77 in_repo,
78 }
79 }
80
81 pub fn resolve(&self, target: &str) -> Option<String> {
84 let external = target.contains("://") || target.starts_with("mailto:");
85 if target.is_empty() || external || target.starts_with(['#', '/']) {
86 return None;
87 }
88 let (path, anchor) = match target.split_once('#') {
89 Some((path, anchor)) => (path, format!("#{anchor}")),
90 None => (target, String::new()),
91 };
92 let in_book = normalize(&self.in_book.join(path))
95 .is_some_and(|page| self.config.book_src.join(page).exists());
96 if path.is_empty() || in_book {
97 return None;
98 }
99 let relative = normalize(&self.in_repo.join(path))?;
100 let file = self.config.repo_root.join(&relative);
101 let kind = if file.is_dir() {
102 "tree"
103 } else if file.is_file() {
104 "blob"
105 } else {
106 return None;
107 };
108 Some(format!(
109 "{}/{kind}/{}/{relative}{anchor}",
110 self.config.repository, self.config.branch
111 ))
112 }
113}
114
115fn normalize(path: &std::path::Path) -> Option<String> {
118 let mut parts: Vec<&str> = Vec::new();
119 for component in path.components() {
120 match component {
121 std::path::Component::Normal(part) => parts.push(part.to_str()?),
122 std::path::Component::ParentDir => {
123 parts.pop()?;
124 }
125 std::path::Component::CurDir => {}
126 _ => return None,
127 }
128 }
129 Some(parts.join("/"))
130}
131
132#[cfg(test)]
133mod tests {
134 struct Fixture {
136 root: std::path::PathBuf,
137 }
138
139 impl Fixture {
140 fn new(label: &str) -> Fixture {
141 let root =
142 std::env::temp_dir().join(format!("repo-links-{label}-{}", std::process::id()));
143 let _ = std::fs::remove_dir_all(&root);
144 for file in [
145 "installer/DESIGN.md",
146 "installer/src/zip.rs",
147 "LICENSE",
148 "docs/src/installer/DESIGN.md",
149 "docs/src/architecture/README.md",
150 "docs/src/architecture/diagrams/er.svg",
151 ] {
152 let path = root.join(file);
153 std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
154 std::fs::write(&path, "x").expect("write");
155 }
156 Fixture { root }
157 }
158
159 fn config(&self) -> super::Config {
160 let context = serde_json::json!({
161 "root": self.root.join("docs"),
162 "config": { "preprocessor": { "repo-links": {
163 "repository": "https://github.com/o/r/",
164 } } }
165 });
166 super::Config::from_context(&context).expect("config")
167 }
168 }
169
170 impl Drop for Fixture {
171 fn drop(&mut self) {
172 let _ = std::fs::remove_dir_all(&self.root);
173 }
174 }
175
176 #[test]
177 fn a_mirrored_chapter_resolves_against_the_original() {
178 let fixture = Fixture::new("mirror");
179 let config = fixture.config();
180 let chapter = super::Resolver::for_chapter(&config, "installer/DESIGN.md".as_ref());
181 assert_eq!(
182 chapter.resolve("src/zip.rs#L3").as_deref(),
183 Some("https://github.com/o/r/blob/master/installer/src/zip.rs#L3")
184 );
185 assert_eq!(
186 chapter.resolve("../LICENSE").as_deref(),
187 Some("https://github.com/o/r/blob/master/LICENSE")
188 );
189 assert_eq!(
190 chapter.resolve("src/").as_deref(),
191 Some("https://github.com/o/r/tree/master/installer/src")
192 );
193 assert_eq!(chapter.resolve("DESIGN.md"), None);
195 assert_eq!(chapter.resolve("https://example.test/x"), None);
196 assert_eq!(chapter.resolve("#scene-4"), None);
197 assert_eq!(chapter.resolve("missing.rs"), None);
198 assert_eq!(chapter.resolve("../../../outside"), None);
199 }
200
201 #[test]
202 fn a_chapter_written_for_the_book_resolves_from_its_own_place() {
203 let fixture = Fixture::new("authored");
204 let config = fixture.config();
205 let chapter = super::Resolver::for_chapter(&config, "architecture/README.md".as_ref());
206 assert_eq!(chapter.resolve("diagrams/er.svg"), None);
207 assert_eq!(
208 chapter.resolve("../../../installer/src/zip.rs").as_deref(),
209 Some("https://github.com/o/r/blob/master/installer/src/zip.rs")
210 );
211 }
212}