File: parser.rb

package info (click to toggle)
ruby-jekyll-toc 0.18.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, forky, sid, trixie
  • size: 244 kB
  • sloc: ruby: 912; makefile: 9
file content (130 lines) | stat: -rw-r--r-- 4,572 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# frozen_string_literal: true

require 'table_of_contents/helper'

module Jekyll
  module TableOfContents
    # Parse html contents and generate table of contents
    class Parser
      include ::Jekyll::TableOfContents::Helper

      def initialize(html, options = {})
        @doc = Nokogiri::HTML::DocumentFragment.parse(html)
        @configuration = Configuration.new(options)
        @entries = parse_content
      end

      def toc
        build_toc + inject_anchors_into_html
      end

      def build_toc
        %(<#{list_tag} id="#{@configuration.list_id}" class="#{@configuration.list_class}">\n#{build_toc_list(@entries)}</#{list_tag}>)
      end

      def inject_anchors_into_html
        @entries.each do |entry|
          # NOTE: `entry[:id]` is automatically URL encoded by Nokogiri
          entry[:header_content].add_previous_sibling(
            %(<a class="anchor" href="##{entry[:id]}" aria-hidden="true"><span class="octicon octicon-link"></span></a>)
          )
        end

        @doc.inner_html
      end

      private

      # parse logic is from html-pipeline toc_filter
      # https://github.com/jch/html-pipeline/blob/v1.1.0/lib/html/pipeline/toc_filter.rb
      def parse_content
        headers = Hash.new(0)

        (@doc.css(toc_headings) - @doc.css(toc_headings_in_no_toc_section))
          .reject { |n| n.classes.include?(@configuration.no_toc_class) }
          .inject([]) do |entries, node|
          text = node.text
          id = node.attribute('id') || generate_toc_id(text)

          suffix_num = headers[id]
          headers[id] += 1

          entries << {
            id: suffix_num.zero? ? id : "#{id}-#{suffix_num}",
            text: CGI.escapeHTML(text),
            node_name: node.name,
            header_content: node.children.first,
            h_num: node.name.delete('h').to_i
          }
        end
      end

      # Returns the list items for entries
      def build_toc_list(entries)
        i = 0
        toc_list = +''
        min_h_num = entries.map { |e| e[:h_num] }.min

        while i < entries.count
          entry = entries[i]
          if entry[:h_num] == min_h_num
            # If the current entry should not be indented in the list, add the entry to the list
            toc_list << %(<li class="#{@configuration.item_class} #{@configuration.item_prefix}#{entry[:node_name]}"><a href="##{entry[:id]}">#{entry[:text]}</a>)
            # If the next entry should be indented in the list, generate a sublist
            next_i = i + 1
            if next_i < entries.count && entries[next_i][:h_num] > min_h_num
              nest_entries = get_nest_entries(entries[next_i, entries.count], min_h_num)
              toc_list << %(\n<#{list_tag}#{ul_attributes}>\n#{build_toc_list(nest_entries)}</#{list_tag}>\n)
              i += nest_entries.count
            end
            # Add the closing tag for the current entry in the list
            toc_list << %(</li>\n)
          elsif entry[:h_num] > min_h_num
            # If the current entry should be indented in the list, generate a sublist
            nest_entries = get_nest_entries(entries[i, entries.count], min_h_num)
            toc_list << build_toc_list(nest_entries)
            i += nest_entries.count - 1
          end
          i += 1
        end

        toc_list
      end

      # Returns the entries in a nested list
      # The nested list starts at the first entry in entries (inclusive)
      # The nested list ends at the first entry in entries with depth min_h_num or greater (exclusive)
      def get_nest_entries(entries, min_h_num)
        entries.inject([]) do |nest_entries, entry|
          break nest_entries if entry[:h_num] == min_h_num

          nest_entries << entry
        end
      end

      def toc_headings
        @configuration.toc_levels.map { |level| "h#{level}" }.join(',')
      end

      def toc_headings_in_no_toc_section
        if @configuration.no_toc_section_class.is_a?(Array)
          @configuration.no_toc_section_class.map { |cls| toc_headings_within(cls) }.join(',')
        else
          toc_headings_within(@configuration.no_toc_section_class)
        end
      end

      def toc_headings_within(class_name)
        @configuration.toc_levels.map { |level| ".#{class_name} h#{level}" }.join(',')
      end

      def ul_attributes
        @ul_attributes ||= @configuration.sublist_class.empty? ? '' : %( class="#{@configuration.sublist_class}")
      end

      def list_tag
        @list_tag ||= @configuration.ordered_list ? 'ol' : 'ul'
      end
    end
  end
end