Skip to content

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

Closed
wants to merge 12 commits into from
Closed

Use template to generate TOC #467

wants to merge 12 commits into from

Conversation

jacwah
Copy link
Contributor

@jacwah jacwah commented Oct 8, 2017

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 new toc json object instead, and is an alternative to #465.

Pros:

  • toc is easy to edit and customisable
  • code is more maintainable

Cons:

  • the template generates funky html with lots of whitespace (will look for a solution)
  • complicated template -- some handlebars knowledge required
  • book chapters are processed in two steps (can probably join them to cut down code)

I'm not quite sure how to organise the code. Perhaps toc_json.rs could be merged with helpers/toc.rs. I'm also not sure when to trust the code and use unwrap and when passing along errors is preferred.

#458

@Michael-F-Bryan Michael-F-Bryan self-requested a review November 11, 2017 08:38
Copy link
Contributor

@Michael-F-Bryan Michael-F-Bryan left a 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) => {
Copy link
Contributor

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"))?;
Copy link
Contributor

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).

Copy link
Contributor Author

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?

Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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")?
Copy link
Contributor

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 {
Copy link
Contributor

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() {
Copy link
Contributor

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") {
Copy link
Contributor

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.

Copy link
Contributor Author

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.
Copy link
Contributor

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> {
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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>) {
Copy link
Contributor

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.

Copy link
Contributor Author

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)
Copy link
Contributor

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?

Copy link
Contributor

@Michael-F-Bryan Michael-F-Bryan left a 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.

@Michael-F-Bryan
Copy link
Contributor

Michael-F-Bryan commented Nov 14, 2017

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 helpers/toc.rs and toc_json.rs?

@jacwah
Copy link
Contributor Author

jacwah commented Nov 14, 2017

@Michael-F-Bryan I appreciate the feedback! I'll get back to you when I've fixed the issues you pointed out.

@jacwah
Copy link
Contributor Author

jacwah commented Nov 14, 2017

@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> {
Copy link
Contributor

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 :)

@Michael-F-Bryan
Copy link
Contributor

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.

Copy link
Contributor

@Michael-F-Bryan Michael-F-Bryan left a 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"))?;
Copy link
Contributor

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")
Copy link
Contributor

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.

@Michael-F-Bryan
Copy link
Contributor

I feel like we're about ready to merge this PR. @jacwah, it looks like master has moved since you started this PR so you'll need to do a rebase (git rebase master && git push --force) and fix any conflicts along the way.

An issue that was raised is it may be a good idea to use something more strongly typed than a serde_json::Value. That way a lot of the error checking becomes redundant and can be replaced by a single deserialization from a Value at the start of the handlebars handler, then a conversion back to a Value at the end. We'll leave that for a later PR though.

@Michael-F-Bryan
Copy link
Contributor

Michael-F-Bryan commented Nov 18, 2017

@jacwah, you mentioned that bits of this are an alternative to #465, so merging #465 means we've now got a conflict because this PR and that one propose different refactorings to src/renderer/html_handlebars/helpers/navigation.rs.

What do you think the easiest way to resolve this would be?

@suhr suhr mentioned this pull request Apr 4, 2018
8 tasks
@Dylan-DPC-zz
Copy link

@jacwah any updates on this?

@jacwah
Copy link
Contributor Author

jacwah commented May 7, 2019

@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.

@Dylan-DPC-zz
Copy link

@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 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants