-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Use template to generate TOC #467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I really like what you're doing in this PR! It definitely should help to make the rendering side of things easier to reason about in the long run 👍
I've raised a couple points where we can improve readability. It's mainly small things like giving a function a more descriptive name or pulling things out into temporary variables so the next guy knows how things what the reasoning is behind a chunk of code.
Also, would adding in a test or two be in the scope of this PR? If we can check that the output generated using a template for the TOC is similar to the current format then that'll help give us confidence that the code works as intended. It's also really useful for detecting accidental regressions in the future.
let mut chapter = BTreeMap::new(); | ||
|
||
match *item { | ||
BookItem::Affix(ref ch) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you think it's worth pulling the contents for each of these match arms out into their own functions? I know it's more the way rustfmt
has formatted your code than anything else, but that rightward drift is getting a tad extreme.
Also, is it just me or are the branches for BookItem::Affix
and BookItem::Chapter
almost identical?
-> Result<Option<&'a serde_json::Map<String, serde_json::Value>>, RenderError> { | ||
let chapter = | ||
chapter.as_object() | ||
.ok_or_else(|| RenderError::new("Chapter is not an object"))?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You could probably collapse this all into a single statement. Something along the lines of:
if let Some(link) = chapter.as_object().and_then(|ch| ch.get("link")) {
...
}
That would also help simplify the return signature considerably because you're only returning an Option
. Returning an error and an option in this case probably won't give give us anything extra because the error branch will be ignored (we couldn't find the thing we were looking for).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mean find_chapter
should silently ignore if chapter
is not an object or if chapter.children
is not an array?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My thinking was that you're trying to find a chapter called needle
in some JSON value. If the value isn't an object then as far as the function is concerned it can't find our needle.
What would we be able to do if chapter
wasn't an object (or chapter.children
isn't an array) besides halting with an error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we should halt if find_chapter
discovers a type error at runtime. Just returning None
might make an issue with node creating harder to discover and debug.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like that reasoning 👍
I guess you raise the point that it'd be better if we could work with something a bit more strongly typed than a miscellaneous JSON value so those sorts of type errors are impossible. That's something for a later PR though.
|
||
/// Link to the current page. | ||
fn active_link(rc: &RenderContext) -> Result<String, RenderError> { | ||
Ok(Path::new(rc.evaluate_absolute("path")? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know this is picky, but would you be able to break this out into multiple lines with a couple well-named temporary variables?
// write to the handlebars template | ||
rc.writer.write_all(markdown_parsed_name.as_bytes())?; | ||
} | ||
if active_link(rc)? == link { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any chance you can pull this active_link(rc)?
out into a temporary variable? Conditions both having side effects and the possibility of failing feels kinda odd.
// render markdown to html | ||
let mut markdown_parsed_name = String::with_capacity(name.len() * 3 / 2); | ||
html::push_html(&mut markdown_parsed_name, parser); | ||
let has_link = if let Some(link) = link.as_str() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a simpler way to represent this? I feel like a let x = if let Some(y) = z { /* massive block */ true } else { false }
would make the control flow a little hard to follow.
What about something like this (rough pseudo-code):
let link = rc.evaluate("link")?.clone();
if link.is_some() {
// open anchor tag
if active_link(rc)? == link {
// add the "active" class
}
}
// write contents
if link.is_some() {
// close anchor tag
}
map.insert("section".to_owned(), json!(section)); | ||
} | ||
|
||
if let Some(name) = item.get("name") { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It may be a good idea to pull the body of this if let
block out into its own little helper function so you can give it a descriptive name. That way the next guy to work on this section of the codebase can figure out what's happening and the reasoning behind it at a glance.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be honest, I'm not 100% on what it does, this is all copied from the old code. Seems like it strips formatting from the name for some reason.
.replace("\\", "/") | ||
} | ||
|
||
/// Set name etc for a chapter. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should probably get a more detailed doc-comment. By reading through the code it looks like we're extracting specific things from item
and inserting them into map
, but a fairly generic name like set_props()
doesn't really do much to tell you that.
} | ||
} | ||
|
||
pub fn from_chapters(chapters: &[BTreeMap<String, String>]) -> Result<serde_json::Value> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What exactly are we constructing "from chapters" here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking toc::from_chapters
, but that can be amended.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah that's smart. It would make perfect sense from a caller's perspective because it's convention to call functions qualified by their module. The name makes sense, but do you reckon it's worth adding in a short doc-comment to briefly summarize what it's doing?
} | ||
|
||
/// Extend or collapse levels to reach a certain depth. | ||
fn set_level(level: usize, levels: &mut Vec<serde_json::Value>) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm... Is this function just turning a list of JSON objects into a tree-like structure, stubbing it out with empty objects if the list of levels passed in isn't big enough? If that's the case then we may want to give it a more descriptive name.
Would it be better to rewrite this in a more functional style? So you'd take in a &[Value]
then return a single Value
which points to the tree's root.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I hope the updated code is more clear in what it does. I tried finding better names and adding comments where relevant.
current.insert("spacer".to_owned(), json!(true)); | ||
} else { | ||
let level = if let Some(s) = item.get("section") { | ||
::std::cmp::max(s.matches('.').count(), 1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you reckon it's worth importing std::cmp
up the top so we don't need to specify the full function path?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Overall I really like what you're doing in this PR! It definitely should help to make the rendering side of things easier to reason about in the long run 👍
I've raised a couple points where we can improve readability. It's mainly small things like giving a function a more descriptive name or pulling things out into temporary variables so the next guy knows how things what the reasoning is behind a chunk of code.
Also, would adding in a test or two be in the scope of this PR? If we can check that the output generated using a template for the TOC is similar to the current format then that'll help give us confidence that the code works as intended. It's also really useful for detecting accidental regressions in the future.
Sorry for taking a while to get back to you on this @jacwah! I've added a couple small points on adjusting control flow or naming (names are hard!) to hopefully make things a bit more readable. The HTML renderer has typically been pretty messy, so if we can clean it up a bit while we're working in the same earlier then that's a bonus. We may also want to throw in a test or two to double check the TOC actually does what we intend it to do. As far as code organisation goes you'll probably want to keep all TOC-related code in the same file. What is the difference meant to be between |
@Michael-F-Bryan I appreciate the feedback! I'll get back to you when I've fixed the issues you pointed out. |
@Michael-F-Bryan I've added some more commits trying to adress your concerns. There are now some tests where I thought they would make sense. The coverage is not complete, but shows the main functionality. If you have any more ideas I'm all ears! What do you think about the generated html? I was thinking about stripping whitespace from the toc template when it's loaded, or perhaps adding a pretty printing or minimizing build step. Maybe that's another PR though. |
|
||
/// Turn a flat chapters array into a tree json structure. Each node represents | ||
/// a section and its subsections. | ||
pub fn make_toc_tree(chapters: &[BTreeMap<String, String>]) -> Result<serde_json::Value> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the more descriptive name and the doc-comment you've added. Now we can tell at a glance what this function does :)
Thanks for adding in the extra tests @jacwah. Don't worry too much about not having 100% coverage, it's more a case of incrementally cleaning up the code and decreasing technical debt. I don't think the generated output being non-optimal is an issue at this point, you don't generally look at the raw HTML anyway. Either way we can push that off until a later PR. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It all looks pretty good to me. There's one point where we could probably use error-chain
instead of returning an io::Error
, but other than that this is pretty much ready to merge.
src/book/mod.rs
Outdated
@@ -262,6 +262,10 @@ impl MDBook { | |||
let mut index = File::create(&themedir.join("index.hbs"))?; | |||
index.write_all(theme::INDEX)?; | |||
|
|||
// toc.hbs | |||
let mut toc = File::create(&themedir.join("toc.hbs"))?; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
From memory File::create()
takes anything which is AsRef<Path>
, so you should be able to drop the &
's from File::create(&themedir.join("toc.hbs"))
and friends.
chapter.insert("name".to_owned(), item.name.to_owned()); | ||
|
||
let path = item.path.to_str().ok_or_else(|| { | ||
io::Error::new(io::ErrorKind::Other, "Could not convert path to str") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe we're using error-chain
here, so you should be able to do chain_err(|| "Could not convert path to str")
instead of using an io::Error
.
I feel like we're about ready to merge this PR. @jacwah, it looks like An issue that was raised is it may be a good idea to use something more strongly typed than a |
@jacwah any updates on this? |
@Dylan-DPC I did all this a long time ago. At the moment, I don't really have the time to refamiliarize myself with the code and resolve accumulated merge conflicts. If someone else finds my work here useful, they should feel free to finish it. Else, I am ok with closing this PR. |
@jacwah this PR was created a while back and it isn't clear what we should do next this, so closing it would be a good idea at this point. Thanks for contributing. If you want to take another go at it sometime in future, feel free to submit a new PR :) |
Instead of directly writing html from Rust, use a Handlebars template to generate the table of contents. This requires build a separate json object from
chapters
. To remove the duplicated data, the second commit adapts navigation to use the newtoc
json object instead, and is an alternative to #465.Pros:
Cons:
I'm not quite sure how to organise the code. Perhaps
toc_json.rs
could be merged withhelpers/toc.rs
. I'm also not sure when to trust the code and useunwrap
and when passing along errors is preferred.#458