From 6da371a7d54e4df347b2af848b0b3586ffa103e4 Mon Sep 17 00:00:00 2001 From: Stephen Ierodiaconou Date: Mon, 3 Jul 2023 19:13:21 +0200 Subject: [PATCH 1/4] feat(pages): add support for TOC in markdown pages Takes advantage of Redcarpet support for generating HTML TOC. Moves markdown renders to a subdirectory of services. Add tests of TOC generation --- app/controllers/lookbook/page_controller.rb | 12 +++++++++++- docs/src/_guide/pages/frontmatter.md | 5 +++++ .../{ => markdown}/markdown_renderer.rb | 0 .../services/markdown/markdown_toc_renderer.rb | 17 +++++++++++++++++ .../markdown/markdown_with_toc_renderer.rb | 11 +++++++++++ .../test/components/docs/10_with_toc.md.erb | 17 +++++++++++++++++ .../components/docs/11_no_toc_on_html.html.erb | 17 +++++++++++++++++ spec/requests/pages_spec.rb | 13 +++++++++++++ 8 files changed, 91 insertions(+), 1 deletion(-) rename lib/lookbook/services/{ => markdown}/markdown_renderer.rb (100%) create mode 100644 lib/lookbook/services/markdown/markdown_toc_renderer.rb create mode 100644 lib/lookbook/services/markdown/markdown_with_toc_renderer.rb create mode 100644 spec/dummy/test/components/docs/10_with_toc.md.erb create mode 100644 spec/dummy/test/components/docs/11_no_toc_on_html.html.erb diff --git a/app/controllers/lookbook/page_controller.rb b/app/controllers/lookbook/page_controller.rb index f946591b1..0651f02fd 100644 --- a/app/controllers/lookbook/page_controller.rb +++ b/app/controllers/lookbook/page_controller.rb @@ -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 diff --git a/docs/src/_guide/pages/frontmatter.md b/docs/src/_guide/pages/frontmatter.md index 1ac6fca85..b230ba21b 100644 --- a/docs/src/_guide/pages/frontmatter.md +++ b/docs/src/_guide/pages/frontmatter.md @@ -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 page variables & data." }, + { + 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 %> diff --git a/lib/lookbook/services/markdown_renderer.rb b/lib/lookbook/services/markdown/markdown_renderer.rb similarity index 100% rename from lib/lookbook/services/markdown_renderer.rb rename to lib/lookbook/services/markdown/markdown_renderer.rb diff --git a/lib/lookbook/services/markdown/markdown_toc_renderer.rb b/lib/lookbook/services/markdown/markdown_toc_renderer.rb new file mode 100644 index 000000000..39978ba11 --- /dev/null +++ b/lib/lookbook/services/markdown/markdown_toc_renderer.rb @@ -0,0 +1,17 @@ +require "redcarpet" + +module Lookbook + class MarkdownTocRenderer < Service + attr_reader :text + + def initialize(text) + @text = text + end + + def call + clean_text = ActionViewAnnotationsStripper.call(text) + renderer = Redcarpet::Render::HTML_TOC.new(with_toc_data: true) + Redcarpet::Markdown.new(renderer).render(clean_text).html_safe + end + end +end diff --git a/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb b/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb new file mode 100644 index 000000000..a34baa100 --- /dev/null +++ b/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb @@ -0,0 +1,11 @@ +require "redcarpet" + +module Lookbook + class MarkdownWithTocRenderer < MarkdownRenderer + def call + html = super + toc_html = MarkdownTocRenderer.call(text) + toc_html + "
".html_safe + html + end + end +end diff --git a/spec/dummy/test/components/docs/10_with_toc.md.erb b/spec/dummy/test/components/docs/10_with_toc.md.erb new file mode 100644 index 000000000..1609540b4 --- /dev/null +++ b/spec/dummy/test/components/docs/10_with_toc.md.erb @@ -0,0 +1,17 @@ +--- +id: toc-test +title: Page With Table of Contents +toc: true +--- + +# Introduction + +Table of Contents test page + +## Section 1 + +### Subsection 1 + +### Subsection 2 + +## Section 2 diff --git a/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb b/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb new file mode 100644 index 000000000..1609540b4 --- /dev/null +++ b/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb @@ -0,0 +1,17 @@ +--- +id: toc-test +title: Page With Table of Contents +toc: true +--- + +# Introduction + +Table of Contents test page + +## Section 1 + +### Subsection 1 + +### Subsection 2 + +## Section 2 diff --git a/spec/requests/pages_spec.rb b/spec/requests/pages_spec.rb index 3aa79f891..b4349fc9d 100644 --- a/spec/requests/pages_spec.rb +++ b/spec/requests/pages_spec.rb @@ -32,4 +32,17 @@ 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("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 From 629313c10fc24ddcd50f72d97c11a5f4eac45a85 Mon Sep 17 00:00:00 2001 From: Stephen Ierodiaconou Date: Mon, 3 Jul 2023 19:14:21 +0200 Subject: [PATCH 2/4] Tweak to contributing doc about running tests with appraisals --- CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 853f108f8..d2ac49833 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. From ce863fbf40d906d38a764bee22e4ec5bdca41200 Mon Sep 17 00:00:00 2001 From: Stephen Ierodiaconou Date: Mon, 3 Jul 2023 23:08:10 +0200 Subject: [PATCH 3/4] Remove the TOC only renderer --- .../markdown/markdown_toc_renderer.rb | 17 ---------------- .../markdown/markdown_with_toc_renderer.rb | 20 ++++++++++++++++--- 2 files changed, 17 insertions(+), 20 deletions(-) delete mode 100644 lib/lookbook/services/markdown/markdown_toc_renderer.rb diff --git a/lib/lookbook/services/markdown/markdown_toc_renderer.rb b/lib/lookbook/services/markdown/markdown_toc_renderer.rb deleted file mode 100644 index 39978ba11..000000000 --- a/lib/lookbook/services/markdown/markdown_toc_renderer.rb +++ /dev/null @@ -1,17 +0,0 @@ -require "redcarpet" - -module Lookbook - class MarkdownTocRenderer < Service - attr_reader :text - - def initialize(text) - @text = text - end - - def call - clean_text = ActionViewAnnotationsStripper.call(text) - renderer = Redcarpet::Render::HTML_TOC.new(with_toc_data: true) - Redcarpet::Markdown.new(renderer).render(clean_text).html_safe - end - end -end diff --git a/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb b/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb index a34baa100..c7c89d890 100644 --- a/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb +++ b/lib/lookbook/services/markdown/markdown_with_toc_renderer.rb @@ -3,9 +3,23 @@ module Lookbook class MarkdownWithTocRenderer < MarkdownRenderer def call - html = super - toc_html = MarkdownTocRenderer.call(text) - toc_html + "
".html_safe + html + render_toc + "
".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 From 065f3bea46d368664d28b178a014025648307258 Mon Sep 17 00:00:00 2001 From: Stephen Ierodiaconou Date: Tue, 4 Jul 2023 13:23:24 +0200 Subject: [PATCH 4/4] Work in progress changes to navigation and link handling in JS to account for path fragments --- .../layouts/lookbook/application.html.erb | 6 +- assets/js/app.js | 67 ++++++++++++++++--- .../test/components/docs/10_with_toc.md.erb | 13 ++++ .../docs/11_no_toc_on_html.html.erb | 9 +-- spec/requests/pages_spec.rb | 1 + 5 files changed, 78 insertions(+), 18 deletions(-) diff --git a/app/views/layouts/lookbook/application.html.erb b/app/views/layouts/lookbook/application.html.erb index a3c6bcae8..cdff8242a 100644 --- a/app/views/layouts/lookbook/application.html.erb +++ b/app/views/layouts/lookbook/application.html.erb @@ -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" @@ -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| %> @@ -92,7 +94,7 @@ <%= content_for?(:main) ? yield(:main) : yield %> <% end %> - +
<% if content_for? :dropdowns %> diff --git a/assets/js/app.js b/assets/js/app.js index 0a3b5d064..cffbd6340 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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; @@ -27,18 +28,67 @@ 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) { @@ -46,6 +96,7 @@ export default function app() { if (link) { const external = isExternalLink(link); const embedded = this.isEmbedded(); + const isFragment = link.getAttribute("href").startsWith("#"); if (embedded && (!link.hasAttribute("target") || external)) { evt.preventDefault(); @@ -53,7 +104,7 @@ export default function app() { return; } else if (!embedded && !external && !link.hasAttribute("target")) { evt.preventDefault(); - this.navigateTo(link.href); + this.navigateTo(link.href, isFragment); return; } } diff --git a/spec/dummy/test/components/docs/10_with_toc.md.erb b/spec/dummy/test/components/docs/10_with_toc.md.erb index 1609540b4..89154141f 100644 --- a/spec/dummy/test/components/docs/10_with_toc.md.erb +++ b/spec/dummy/test/components/docs/10_with_toc.md.erb @@ -10,8 +10,21 @@ 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. diff --git a/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb b/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb index 1609540b4..afc3ea9ad 100644 --- a/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb +++ b/spec/dummy/test/components/docs/11_no_toc_on_html.html.erb @@ -6,12 +6,5 @@ toc: true # Introduction -Table of Contents test page +

Table of Contents test page

-## Section 1 - -### Subsection 1 - -### Subsection 2 - -## Section 2 diff --git a/spec/requests/pages_spec.rb b/spec/requests/pages_spec.rb index b4349fc9d..ffa276a5c 100644 --- a/spec/requests/pages_spec.rb +++ b/spec/requests/pages_spec.rb @@ -37,6 +37,7 @@ 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