Skip to content
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

Feature proposal: Add support for TOC in markdown pages #501

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ Steps:

* Fork and clone the repository.
* Configure and install the dependencies: `bundle install`.
* Make sure the tests pass: `rake test`.
* Setup the appraisal gemsets: `bundle exec appraisal install`.
* Make sure the tests pass: `bundle exec appraisal rake spec`.
* Create a new branch: `git checkout -b my-branch-name`.
* Add tests, make the change, and make sure the tests still pass.
* Push to the fork and submit a pull request.
Expand Down
12 changes: 11 additions & 1 deletion app/controllers/lookbook/page_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,17 @@ def render_page(page, locals = {})
}
end

@page.markdown? ? MarkdownRenderer.call(content) : content
@page.markdown? ? markdown_render(content) : content
end

private

def markdown_render(content)
if @page.respond_to?(:toc) && @page.toc
MarkdownWithTocRenderer.call(content)
else
MarkdownRenderer.call(content)
end
end
end
end
6 changes: 4 additions & 2 deletions app/views/layouts/lookbook/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
id="app"
x-data="app"
x-cloak
x-on:alpine:initialized.window="loaded"
x-on:navigation:complete.debounce.100ms="navigationCompleted"
x-on:popstate.window="handleNavigation"
x-on:click.document="hijax"
x-on:navigation:start="closeMobileSidebar"
Expand All @@ -18,7 +20,7 @@
project_logo: @config.project_logo %>

<% if @previews.any? || @pages.any? %>

<%= lookbook_render :split_layout,
alpine_data: "$store.layout.main",
":class": "$store.layout.mobile && '!block'" do |layout| %>
Expand Down Expand Up @@ -92,7 +94,7 @@
<%= content_for?(:main) ? yield(:main) : yield %>
</div>
<% end %>

<div class="absolute opacity-0 bg-black inset-0 top-[39px] z-[-1] transition-opacity" x-bind:class="($store.layout.mobile && !sidebarHidden) && '!opacity-30 !z-[1000]'" data-cloak></div>

<% if content_for? :dropdowns %>
Expand Down
67 changes: 59 additions & 8 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export default function app() {
version: Alpine.$persist("").as("lookbook-version"),

location: window.location,
previousPathname: null,

get sidebarHidden() {
return this.$store.layout.sidebar.hidden;
Expand All @@ -27,33 +28,83 @@ export default function app() {
}
},

navigateTo(path) {
navigateTo(path, isFragment = false) {
this.debug(`Navigating to ${path}`);
history.pushState({}, null, path);
this.$dispatch("popstate");
this.$dispatch("popstate", { isFragment });
},

async handleNavigation() {
this.debug("Navigating to ", window.location.pathname);
this.$dispatch("navigation:start");
async handleNavigation(evt) {
// On a navigation event (popstate), see if the path has changed
const pathSameOnEvent =
this.previousPathname &&
evt.target.location &&
this.previousPathname === evt.target.location.pathname;
// On a click event that was hijacked, see if the path changed
const pathSameOnHijackedNavigate =
this.previousPathname &&
this.previousPathname === window.location.pathname;
// On a click event that was hijacked, see if it was a 'fragment only' anchor that was clicked
const eventIsFragmentClick = evt.detail && evt.detail.isFragment;
const targetFragment = pathSameOnEvent
? evt.target.location.hash
: window.location.hash;

this.previousPathname = window.location.pathname;
this.location = window.location;
await this.updateDOM();
this.$dispatch("navigation:complete");
if (
targetFragment &&
(pathSameOnEvent || pathSameOnHijackedNavigate || eventIsFragmentClick)
) {
this.scrollToFragment(targetFragment);
} else {
this.$dispatch("navigation:start");
await this.updateDOM();
// TODO: going back to the path without a fragment, from a url on same page but with a fragment, we need to seemingly wait before dispatching the event to allow the scroll to happen
setTimeout(() => {
this.$dispatch("navigation:complete");
}, 10);
}
},

scrollToFragment(fragment) {
if (!fragment) {
window.scrollTo(0, 0);
return;
}
this.debug(`Scroll to ${fragment}`);
const el = document.querySelector(fragment);
if (el) {
el.scrollIntoView({ block: "center", behavior: "smooth" });
}
},

// TODO: When page loads check if there is a fragment in the URL and scroll to it
loaded() {
// TODO: alpine:initialized happens before content is necessarily loaded...
setTimeout(() => {
this.scrollToFragment(window.location.hash);
}, 100);
},

navigationCompleted() {
this.scrollToFragment(window.location.hash);
},

hijax(evt) {
const link = evt.target.closest("a[href]");
if (link) {
const external = isExternalLink(link);
const embedded = this.isEmbedded();
const isFragment = link.getAttribute("href").startsWith("#");

if (embedded && (!link.hasAttribute("target") || external)) {
evt.preventDefault();
window.top.location = link.href;
return;
} else if (!embedded && !external && !link.hasAttribute("target")) {
evt.preventDefault();
this.navigateTo(link.href);
this.navigateTo(link.href, isFragment);
return;
}
}
Expand Down
5 changes: 5 additions & 0 deletions docs/src/_guide/pages/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ title: Frontmatter
types: "Hash",
text: "Optional hash of custom data to make available for use in the page - see info on <a href=\"#{guide_url :pages_variables}\">page variables & data</a>."
},
{
name: "toc",
types: "Boolean",
text: "Set to `true` to render a Table of Contents at the top of a Markdown page, just above the content."
},
] %>

<% end %>
Expand Down
25 changes: 25 additions & 0 deletions lib/lookbook/services/markdown/markdown_with_toc_renderer.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
require "redcarpet"

module Lookbook
class MarkdownWithTocRenderer < MarkdownRenderer
def call
render_toc + "<hr>".html_safe + render_content
end

private

def render_content
md_renderer = Redcarpet::Markdown.new(LookbookMarkdownRenderer.new(with_toc_data: true), opts)
md_renderer.render(clean_text).html_safe
end

def render_toc
toc_renderer = Redcarpet::Markdown.new(Redcarpet::Render::HTML_TOC.new)
toc_renderer.render(clean_text).html_safe
end

def clean_text
@_clean_text ||= ActionViewAnnotationsStripper.call(text)
end
end
end
30 changes: 30 additions & 0 deletions spec/dummy/test/components/docs/10_with_toc.md.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
id: toc-test
title: Page With Table of Contents
toc: true
---

# Introduction

Table of Contents test page

## Section 1

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pharetra vel turpis nunc eget lorem dolor sed viverra. Etiam erat velit scelerisque in dictum non. Turpis massa tincidunt dui ut ornare lectus sit amet. Dignissim convallis aenean et tortor at risus viverra adipiscing at. Blandit massa enim nec dui nunc mattis. Risus quis varius quam quisque id diam vel quam. Consectetur adipiscing elit ut aliquam purus. Tellus molestie nunc non blandit massa enim nec. Dignissim suspendisse in est ante in. Id cursus metus aliquam eleifend.

Sit amet consectetur adipiscing elit pellentesque habitant morbi tristique. Tempus egestas sed sed risus pretium quam vulputate. Augue mauris augue neque gravida in fermentum et sollicitudin. Nunc vel risus commodo viverra. Diam sit amet nisl suscipit adipiscing bibendum est ultricies integer. Enim blandit volutpat maecenas volutpat blandit. Viverra mauris in aliquam sem fringilla. Maecenas accumsan lacus vel facilisis volutpat est velit. Magna etiam tempor orci eu. Libero justo laoreet sit amet cursus sit. Phasellus egestas tellus rutrum tellus pellentesque. Vitae elementum curabitur vitae nunc sed velit dignissim. Sed enim ut sem viverra aliquet eget sit amet. Et netus et malesuada fames ac turpis. Turpis massa tincidunt dui ut ornare lectus sit amet. Tempor nec feugiat nisl pretium fusce id velit. Enim neque volutpat ac tincidunt vitae semper.

### Subsection 1

In pellentesque massa placerat duis ultricies. Laoreet id donec ultrices tincidunt arcu. Sed sed risus pretium quam. Ultricies lacus sed turpis tincidunt id aliquet. Pharetra magna ac placerat vestibulum lectus mauris ultrices eros. Placerat orci nulla pellentesque dignissim enim sit. Sit amet consectetur adipiscing elit ut aliquam. Donec enim diam vulputate ut pharetra. Posuere ac ut consequat semper viverra nam libero justo laoreet. Odio facilisis mauris sit amet. Neque volutpat ac tincidunt vitae semper. Vitae auctor eu augue ut lectus arcu bibendum. Purus non enim praesent elementum facilisis leo vel fringilla est. Adipiscing at in tellus integer. Pharetra vel turpis nunc eget. Libero volutpat sed cras ornare arcu dui vivamus arcu felis. Erat nam at lectus urna. Ultricies integer quis auctor elit sed vulputate. Id semper risus in hendrerit gravida rutrum quisque non tellus.

In egestas erat imperdiet sed. Mollis nunc sed id semper risus in. Pellentesque habitant morbi tristique senectus et netus et malesuada fames. Mattis aliquam faucibus purus in massa tempor nec feugiat nisl. Sit amet mauris commodo quis. Ridiculus mus mauris vitae ultricies. Aenean pharetra magna ac placerat vestibulum lectus mauris ultrices eros. Auctor urna nunc id cursus metus aliquam eleifend. Platea dictumst vestibulum rhoncus est pellentesque elit ullamcorper dignissim. Quisque egestas diam in arcu cursus euismod. Sed adipiscing diam donec adipiscing tristique risus nec feugiat in. Purus semper eget duis at. Tortor pretium viverra suspendisse potenti. Mauris in aliquam sem fringilla ut morbi tincidunt. Non enim praesent elementum facilisis. Interdum consectetur libero id faucibus nisl tincidunt eget. Nisl condimentum id venenatis a condimentum vitae. Iaculis urna id volutpat lacus laoreet non curabitur gravida arcu.


### Subsection 2

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pharetra vel turpis nunc eget lorem dolor sed viverra. Etiam erat velit scelerisque in dictum non. Turpis massa tincidunt dui ut ornare lectus sit amet. Dignissim convallis aenean et tortor at risus viverra adipiscing at. Blandit massa enim nec dui nunc mattis. Risus quis varius quam quisque id diam vel quam. Consectetur adipiscing elit ut aliquam purus. Tellus molestie nunc non blandit massa enim nec. Dignissim suspendisse in est ante in. Id cursus metus aliquam eleifend.

## Section 2

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Pharetra vel turpis nunc eget lorem dolor sed viverra. Etiam erat velit scelerisque in dictum non. Turpis massa tincidunt dui ut ornare lectus sit amet. Dignissim convallis aenean et tortor at risus viverra adipiscing at. Blandit massa enim nec dui nunc mattis. Risus quis varius quam quisque id diam vel quam. Consectetur adipiscing elit ut aliquam purus. Tellus molestie nunc non blandit massa enim nec. Dignissim suspendisse in est ante in. Id cursus metus aliquam eleifend.
10 changes: 10 additions & 0 deletions spec/dummy/test/components/docs/11_no_toc_on_html.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
id: toc-test
title: Page With Table of Contents
toc: true
---

# Introduction

<p>Table of Contents test page</p>

14 changes: 14 additions & 0 deletions spec/requests/pages_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,18 @@
expect(html).to have_css("[data-component=code] [data-lang=ruby]")
end
end

context "with a table of contents" do
it "should render the table of contents if `toc: true` in frontmatter" do
get Lookbook::Engine.pages.find_by_path("with_toc").url_path
expect(html).to have_css("ul li a[href='#introduction']")
expect(html).to have_css("#introduction")
expect(html).to have_css("ul li ul li ul li a[href='#subsection-2']")
end

it "should not attempt to render table of contents if `toc: true` in frontmatter of HTML pages" do
get Lookbook::Engine.pages.find_by_path("no_toc_on_html").url_path
expect(html).to_not have_css("ul li a[href='#introduction']")
end
end
end