Skip to content

Commit 93d3d22

Browse files
authored
Improve the rendering pipeline, move admonition markup processing to preproc (#513)
* Split preproc into several files These files are shorter, and thus easier to navigate than a big monolith. * Move admonition markup processing to preproc instead of renderer This lets them work with `mdbook serve` (which hardcodes the HTML renderer), and at the same time is more robust (no more running regexes against HTML output!). The syntax was slightly adjusted to be closer to established VuePress etc.
1 parent 082dba8 commit 93d3d22

35 files changed

+504
-494
lines changed

CONTRIBUTING.md

+3-3
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,12 @@ In any case, maintainers will chime in, reviewing what you changed and if necess
3737

3838
Pan Docs uses a custom mdBook preprocessor & renderer to enable some special markup:
3939

40-
### Custom Containers
40+
### Custom Containers
4141

4242
Those mimick Vuepress' [custom containers](https://vuepress.vuejs.org/guide/markdown.html#custom-containers) functionality.
4343

4444
```markdown
45-
::: type HEADING
45+
:::type HEADING
4646

4747
Content
4848

@@ -58,7 +58,7 @@ These are rendered as "info boxes".
5858
E.g.
5959

6060
```markdown
61-
::: tip SCOPE
61+
:::tip SCOPE
6262

6363
The information here is targeted at homebrew development.
6464
Emulator developers may be also interested in the [Game Boy: Complete Technical Reference](https://gekkio.fi/files/gb-docs/gbctr.pdf) document.

preproc/src/admonitions.rs

+101
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/*
2+
* This Source Code Form is subject to the
3+
* terms of the Mozilla Public License, v.
4+
* 2.0. If a copy of the MPL was not
5+
* distributed with this file, You can
6+
* obtain one at
7+
* http://mozilla.org/MPL/2.0/.
8+
*/
9+
10+
use std::{iter::Peekable, matches};
11+
12+
use anyhow::Error;
13+
use mdbook::book::Chapter;
14+
use pulldown_cmark::{Event, Options, Parser, Tag};
15+
16+
use crate::Pandocs;
17+
18+
impl Pandocs {
19+
pub fn process_admonitions(&self, chapter: &mut Chapter) -> Result<(), Error> {
20+
let mut buf = String::with_capacity(chapter.content.len());
21+
let extensions =
22+
Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
23+
24+
let events = AdmonitionsGenerator::new(Parser::new_ext(&chapter.content, extensions));
25+
26+
pulldown_cmark_to_cmark::cmark(events, &mut buf, None)
27+
.map_err(|err| Error::from(err).context("Markdown serialization failed"))?;
28+
chapter.content = buf;
29+
30+
Ok(())
31+
}
32+
}
33+
34+
struct AdmonitionsGenerator<'a, Iter: Iterator<Item = Event<'a>>> {
35+
iter: Peekable<Iter>,
36+
nesting_level: usize,
37+
at_paragraph_start: bool,
38+
}
39+
40+
impl<'a, Iter: Iterator<Item = Event<'a>>> AdmonitionsGenerator<'a, Iter> {
41+
const KINDS: [&'static str; 3] = ["tip", "warning", "danger"];
42+
43+
fn new(iter: Iter) -> Self {
44+
Self {
45+
iter: iter.peekable(),
46+
nesting_level: 0,
47+
at_paragraph_start: false,
48+
}
49+
}
50+
}
51+
52+
impl<'a, Iter: Iterator<Item = Event<'a>>> Iterator for AdmonitionsGenerator<'a, Iter> {
53+
type Item = Event<'a>;
54+
55+
fn next(&mut self) -> Option<Self::Item> {
56+
let mut evt = self.iter.next()?;
57+
58+
match evt {
59+
Event::Text(ref text) if self.at_paragraph_start => {
60+
if let Some(params) = text.strip_prefix(":::") {
61+
// Check that there is no more text in the paragraph; if there isn't, we'll consume the entire paragraph.
62+
// Note that this intentionally rejects any formatting within the paragraph—serialisation would be too complex.
63+
if matches!(self.iter.peek(), Some(Event::End(Tag::Paragraph))) {
64+
if params.is_empty() {
65+
if self.nesting_level != 0 {
66+
// Ending an admonition.
67+
self.nesting_level -= 1;
68+
69+
evt = Event::Html("</div>".into());
70+
}
71+
} else {
72+
let (kind, title) =
73+
match params.split_once(|c: char| c.is_ascii_whitespace()) {
74+
Some((kind, title)) => (kind, title.trim()),
75+
None => (params, ""),
76+
};
77+
if Self::KINDS.contains(&kind) {
78+
// Beginning an admonition.
79+
self.nesting_level += 1;
80+
81+
evt = Event::Html(
82+
if title.is_empty() {
83+
format!("<div class=\"box {kind}\">")
84+
} else {
85+
format!("<div class=\"box {kind}\"><p class=\"box-title\">{title}</p>")
86+
}
87+
.into(),
88+
);
89+
}
90+
}
91+
}
92+
}
93+
}
94+
_ => {}
95+
}
96+
97+
self.at_paragraph_start = matches!(evt, Event::Start(Tag::Paragraph));
98+
99+
Some(evt)
100+
}
101+
}

preproc/src/anchors.rs

+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/*
2+
* This Source Code Form is subject to the
3+
* terms of the Mozilla Public License, v.
4+
* 2.0. If a copy of the MPL was not
5+
* distributed with this file, You can
6+
* obtain one at
7+
* http://mozilla.org/MPL/2.0/.
8+
*/
9+
10+
use std::collections::HashMap;
11+
use std::io::Write;
12+
13+
use mdbook::book::Chapter;
14+
use mdbook::errors::Error;
15+
use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag};
16+
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
17+
18+
use crate::Pandocs;
19+
20+
impl Pandocs {
21+
pub fn list_chapter_sections(
22+
&self,
23+
sections: &mut HashMap<String, (String, bool)>,
24+
chapter: &Chapter,
25+
) {
26+
let mut events = Parser::new(&chapter.content);
27+
while let Some(event) = events.next() {
28+
if let Event::Start(Tag::Heading(_)) = event {
29+
let mut depth = 1;
30+
let mut name = String::new();
31+
32+
while depth != 0 {
33+
match events.next().expect("Unclosed `Start` tag??") {
34+
Event::Start(_) => depth += 1,
35+
Event::End(_) => depth -= 1,
36+
Event::Text(text) | Event::Code(text) => name.push_str(&text),
37+
event => panic!("Unexpected event in header {:?}", event),
38+
}
39+
}
40+
41+
let chapter_name = name.clone();
42+
let mut page_name = chapter
43+
.path
44+
.as_ref()
45+
.expect("Chapter without a source file??")
46+
.clone()
47+
.into_os_string()
48+
.into_string()
49+
.expect("Chapter file path is not valid UTF-8");
50+
page_name.truncate(
51+
page_name
52+
.strip_suffix(".md")
53+
.expect("Source file not ending in `.md`??")
54+
.len(),
55+
);
56+
57+
if sections.insert(name, (page_name, false)).is_some() {
58+
// Mark the ambiguity
59+
sections.get_mut(&chapter_name).unwrap().1 = true;
60+
}
61+
}
62+
}
63+
}
64+
65+
pub fn process_internal_anchor_links(
66+
&self,
67+
chapter: &mut Chapter,
68+
sections: &HashMap<String, (String, bool)>,
69+
) -> Result<(), Error> {
70+
let mut buf = String::with_capacity(chapter.content.len());
71+
let extensions =
72+
Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH;
73+
74+
let events = Parser::new_ext(&chapter.content, extensions).map(|event| match event {
75+
Event::Start(Tag::Link(link_type, url, title)) if url.starts_with('#') => {
76+
let (link, ok) = translate_anchor_link(sections, link_type, url, title);
77+
if !ok {
78+
let mut stderr = StandardStream::stderr(ColorChoice::Auto);
79+
stderr
80+
.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)).set_bold(true))
81+
.unwrap();
82+
write!(&mut stderr, "warning:").unwrap();
83+
stderr.reset().unwrap();
84+
85+
if let Tag::Link(_, ref url, _) = link {
86+
eprintln!(
87+
" {}: Internal anchor link \"{}\" not found, keeping as-is",
88+
&chapter.name, url
89+
);
90+
} else {
91+
unreachable!()
92+
}
93+
}
94+
Event::Start(link)
95+
}
96+
97+
Event::End(Tag::Link(link_type, url, title)) if url.starts_with('#') => {
98+
Event::End(translate_anchor_link(sections, link_type, url, title).0)
99+
}
100+
101+
_ => event,
102+
});
103+
104+
pulldown_cmark_to_cmark::cmark(events, &mut buf, None)
105+
.map_err(|err| Error::from(err).context("Markdown serialization failed"))?;
106+
chapter.content = buf;
107+
108+
Ok(())
109+
}
110+
}
111+
112+
fn translate_anchor_link<'a>(
113+
sections: &HashMap<String, (String, bool)>,
114+
link_type: LinkType,
115+
url: CowStr<'a>,
116+
title: CowStr<'a>,
117+
) -> (Tag<'a>, bool) {
118+
let (url, ok) = if let Some((chapter, multiple)) = sections.get(&url[1..]) {
119+
if *multiple {
120+
let mut stderr = StandardStream::stderr(ColorChoice::Auto);
121+
stderr
122+
.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)).set_bold(true))
123+
.unwrap();
124+
write!(&mut stderr, "warning:").unwrap();
125+
stderr.reset().unwrap();
126+
eprintln!(
127+
" Referencing multiply-defined section \"{}\" (using chapter \"{}\")",
128+
&url[1..],
129+
&chapter
130+
);
131+
}
132+
(
133+
CowStr::Boxed(format!("{}.html#{}", chapter, id_from_name(&url[1..])).into_boxed_str()),
134+
true,
135+
)
136+
} else {
137+
(url, false)
138+
};
139+
140+
(Tag::Link(link_type, url, title), ok)
141+
}
142+
143+
fn id_from_name(name: &str) -> String {
144+
let mut content = name.to_string();
145+
146+
// Skip any tags or html-encoded stuff
147+
const REPL_SUB: &[&str] = &[
148+
"<em>",
149+
"</em>",
150+
"<code>",
151+
"</code>",
152+
"<strong>",
153+
"</strong>",
154+
"&lt;",
155+
"&gt;",
156+
"&amp;",
157+
"&#39;",
158+
"&quot;",
159+
];
160+
for sub in REPL_SUB {
161+
content = content.replace(sub, "");
162+
}
163+
164+
// Remove spaces and hashes indicating a header
165+
let trimmed = content.trim().trim_start_matches('#').trim();
166+
167+
// Normalize
168+
trimmed
169+
.chars()
170+
.filter_map(|ch| {
171+
if ch.is_alphanumeric() || ch == '_' || ch == '-' {
172+
Some(ch.to_ascii_lowercase())
173+
} else if ch.is_whitespace() {
174+
Some('-')
175+
} else {
176+
None
177+
}
178+
})
179+
.collect::<String>()
180+
}

0 commit comments

Comments
 (0)