raindown 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node_injector'
4
+ require_relative 'endpoint/endpoint_runner'
5
+ require_relative 'table_formatter'
6
+
7
+ module Raindown
8
+ # Rewrites the generated parts of a Markdown file in place before it's rendered:
9
+ # node output, like the TOC, endpoint responses and table alignment.
10
+ # Each rewriter responds to rewrite(content:, path:, bare:, execute:, metadata:) and returns the new content.
11
+ module Rewriter
12
+ REWRITERS = [NodeInjector, EndpointRunner, TableFormatter].freeze
13
+
14
+ module_function
15
+
16
+ def rewrite(content:, path:, bare: false, execute: false, metadata: NoMetadata)
17
+ REWRITERS.reduce(content) do |rewritten, rewriter|
18
+ rewriter.rewrite(content: rewritten, path:, bare:, execute:, metadata:)
19
+ end
20
+ end
21
+
22
+ # Returns true when the file changed.
23
+ def update(path:, bare: false, execute: false, metadata: NoMetadata)
24
+ content = File.read(path)
25
+ rewritten = rewrite(content:, path:, bare:, execute:, metadata:)
26
+ return false if rewritten == content
27
+
28
+ File.write(path, rewritten)
29
+ true
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ module Raindown
6
+ # Pads every column of each Markdown table to the width of its widest cell.
7
+ class TableFormatter
8
+ DELIMITER_CELL = /\A:?-+:?\z/
9
+ FENCE = /\A\s*(```|~~~)/
10
+
11
+ def self.rewrite(content:, **)
12
+ new(content:).format
13
+ end
14
+
15
+ def initialize(content:)
16
+ @lines = content.lines
17
+ end
18
+
19
+ def format
20
+ output = []
21
+ index = 0
22
+ in_fence = false
23
+
24
+ while index < @lines.length
25
+ line = @lines[index]
26
+ in_fence = !in_fence if line.match?(FENCE)
27
+
28
+ if !in_fence && table_start?(index)
29
+ table_end = find_table_end(index)
30
+ output.concat(align(@lines[index...table_end]))
31
+ index = table_end
32
+ else
33
+ output << line
34
+ index += 1
35
+ end
36
+ end
37
+
38
+ output.join
39
+ end
40
+
41
+ private
42
+
43
+ def table_start?(index)
44
+ header = @lines[index]
45
+ delimiter = @lines[index + 1]
46
+ return false unless header&.include?('|') && delimiter
47
+
48
+ delimiter_cells = cells(delimiter)
49
+ delimiter_cells.any? && delimiter_cells.all? { |cell| cell.match?(DELIMITER_CELL) } &&
50
+ delimiter_cells.length == cells(header).length
51
+ end
52
+
53
+ def find_table_end(index)
54
+ table_end = index + 2
55
+ table_end += 1 while @lines[table_end]&.include?('|') && !@lines[table_end].strip.empty?
56
+ table_end
57
+ end
58
+
59
+ def align(table)
60
+ indent = table.first[/\A\s*/]
61
+ rows = table.map { |line| cells(line) }
62
+ header, delimiter, *body = rows
63
+ return table if body.any? { |row| row.length > header.length }
64
+
65
+ table_lines(indent:, header:, body:, alignments: delimiter.map { |cell| alignment(cell) })
66
+ end
67
+
68
+ def table_lines(indent:, header:, body:, alignments:)
69
+ body = body.map { |row| row + ([''] * (header.length - row.length)) }
70
+ widths = column_widths(rows: [header, *body])
71
+
72
+ [
73
+ row_line(indent:, cells: header, widths:, alignments:),
74
+ delimiter_line(indent:, widths:, alignments:),
75
+ *body.map { |row| row_line(indent:, cells: row, widths:, alignments:) }
76
+ ].map { |line| "#{line}\n" }
77
+ end
78
+
79
+ # Split on pipes that aren't escaped, dropping the optional outer pipes.
80
+ def cells(line)
81
+ content = line.strip
82
+ content = content.delete_prefix('|')
83
+ content = content.delete_suffix('|') unless content.end_with?('\|')
84
+
85
+ content.split(/(?<!\\)\|/, -1).map(&:strip)
86
+ end
87
+
88
+ def alignment(cell)
89
+ if cell.start_with?(':') && cell.end_with?(':') then :center
90
+ elsif cell.end_with?(':') then :right
91
+ elsif cell.start_with?(':') then :left
92
+ end
93
+ end
94
+
95
+ # Three is the shortest delimiter cell, which also fits ':-:'.
96
+ def column_widths(rows:)
97
+ rows.transpose.map { |column| [column.map { |cell| display_width(cell) }.max, 3].max }
98
+ end
99
+
100
+ # Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
101
+ # instead of guessing from the terminal, so a file formats the same on every machine.
102
+ def display_width(cell)
103
+ Unicode::DisplayWidth.of(cell, emoji: :all)
104
+ end
105
+
106
+ def row_line(indent:, cells:, widths:, alignments:)
107
+ padded = cells.each_with_index.map { |cell, i| pad(cell, widths[i], alignments[i]) }
108
+ "#{indent}| #{padded.join(' | ')} |"
109
+ end
110
+
111
+ def delimiter_line(indent:, widths:, alignments:)
112
+ dashes = widths.each_with_index.map do |width, i|
113
+ case alignments[i]
114
+ when :center then ":#{'-' * (width - 2)}:"
115
+ when :right then "#{'-' * (width - 1)}:"
116
+ when :left then ":#{'-' * (width - 1)}"
117
+ else '-' * width
118
+ end
119
+ end
120
+
121
+ "#{indent}| #{dashes.join(' | ')} |"
122
+ end
123
+
124
+ def pad(cell, width, alignment)
125
+ padding = width - display_width(cell)
126
+
127
+ case alignment
128
+ when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
129
+ when :right then "#{' ' * padding}#{cell}"
130
+ else "#{cell}#{' ' * padding}"
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require_relative 'node'
5
+
6
+ module Raindown
7
+ # The node behind toc: and :toc, a table of contents of the document's headings.
8
+ # A project can replace it with its own TOC node in .raindown, like "class TOC < Raindown::TOC".
9
+ class TOC < Node
10
+ def replace(document:, metadata: NoMetadata, bare: false)
11
+ headings = Raindown.headings(markdown: document, metadata:, bare:)
12
+ return '' if headings.empty?
13
+
14
+ min_level = headings.map { |heading| heading[:level] }.min
15
+
16
+ headings.map do |heading|
17
+ "#{' ' * (heading[:level] - min_level)}- [#{heading[:text]}](##{heading[:id]})"
18
+ end.join("\n")
19
+ end
20
+
21
+ def render(document:, metadata: NoMetadata, bare: false)
22
+ items = Raindown.headings(markdown: document, metadata:, bare:).map do |heading|
23
+ "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{ERB::Util.html_escape(heading[:text])}</a></li>"
24
+ end
25
+
26
+ <<~HTML
27
+ <div class="floating">
28
+ <details id="toc" open>
29
+ <summary>Table of contents</summary>
30
+ <ul>
31
+ #{items.join("\n")}
32
+ </ul>
33
+ </details>
34
+ </div>
35
+ HTML
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The front matter values that Antlers variables like {title} and <{ title }> render.
5
+ # Antlers reads a variable from the binding's locals, then by sending it to the binding's receiver, which is this.
6
+ # It's a BasicObject so a key like "hash" or "display" isn't shadowed by a method, and unknown names render nothing.
7
+ # Nested values work too, like {author.name} and <{ for: tag in: tags }>.
8
+ class Variables < BasicObject
9
+ # Braces in a value become the placeholders that render as braces, so Antlers doesn't read them as a variable.
10
+ def self.wrap(value)
11
+ case value
12
+ when ::Hash then new(value)
13
+ when ::Array then value.map { |item| wrap(item) }
14
+ when ::String then value.gsub(/[{}]/, CODE_BRACES)
15
+ else value
16
+ end
17
+ end
18
+
19
+ def initialize(values)
20
+ @values = values.to_h { |key, value| [key.to_sym, Variables.wrap(value)] }
21
+ end
22
+
23
+ def respond_to?(name, _include_all = false) # rubocop:disable Style/OptionalBooleanParameter -- Ruby's signature
24
+ @values.key?(name.to_sym)
25
+ end
26
+
27
+ def send(name, *)
28
+ @values[name.to_sym]
29
+ end
30
+
31
+ def binding
32
+ ::Kernel.binding
33
+ end
34
+
35
+ def to_s
36
+ @values.to_s
37
+ end
38
+ alias inspect to_s
39
+
40
+ def method_missing(*)
41
+ nil
42
+ end
43
+
44
+ def respond_to_missing?(*)
45
+ false
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../raindown'
4
+ require_relative 'rewriter'
5
+
6
+ module Raindown
7
+ class Watcher
8
+ attr_reader :path, :output, :bare, :execute
9
+
10
+ def initialize(path:, output: nil, bare: false, execute: false)
11
+ @path = path
12
+ @output = output || default_output
13
+ @bare = bare
14
+ @execute = execute
15
+ end
16
+
17
+ def start
18
+ require 'listen'
19
+
20
+ $stdout.sync = true
21
+
22
+ rewrite
23
+ render
24
+ puts "Watching #{path} for changes (writing to #{output})..."
25
+
26
+ listener.start
27
+ sleep
28
+ end
29
+
30
+ private
31
+
32
+ def listener
33
+ directory = File.dirname(File.expand_path(path))
34
+ filename = File.basename(path)
35
+
36
+ Listen.to(directory) do |modified, added, _removed|
37
+ next unless (modified + added).any? { |f| File.basename(f) == filename }
38
+ # Formatting saves the file again, and that change triggers the render.
39
+ next if rewrite
40
+
41
+ render
42
+ puts "Re-rendered #{path} -> #{output}"
43
+ end
44
+ end
45
+
46
+ # The files' metadata is loaded again each time, so new and renamed files are listed.
47
+ def rewrite
48
+ Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
49
+ end
50
+
51
+ def render
52
+ File.write(output, Raindown.render_file(path:, bare:, metadata: Pages.for(path)))
53
+ end
54
+
55
+ def default_output
56
+ "#{File.dirname(path)}/#{File.basename(path, '.*')}.html"
57
+ end
58
+ end
59
+ end
data/lib/raindown.rb CHANGED
@@ -1,31 +1,98 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'commonmarker'
4
+ require 'antlers'
4
5
  require 'antlers/api'
6
+ require 'nokogiri'
5
7
  require_relative 'elements'
8
+ require_relative 'raindown/bare_directives'
9
+ require_relative 'raindown/code'
10
+ require_relative 'raindown/config'
11
+ require_relative 'raindown/directives'
12
+ require_relative 'raindown/front_matter'
13
+ require_relative 'raindown/node_blocks'
14
+ require_relative 'raindown/pages'
15
+ require_relative 'raindown/pre_renderer'
16
+ require_relative 'raindown/variables'
6
17
 
7
- module Rain
8
- module Raindown
9
- module_function
18
+ module Raindown
19
+ # Antlers evaluates any braces in the HTML, so braces in code are swapped for placeholders
20
+ # then HTML entities, which browsers still display as braces.
21
+ CODE_BRACES = { '{' => "\u{E000}", '}' => "\u{E001}" }.freeze
22
+ CODE_BRACE_ENTITIES = { "\u{E000}" => '&#123;', "\u{E001}" => '&#125;' }.freeze
10
23
 
11
- def render(markdown:, metadata: {})
12
- template = markdown.gsub('<{', '<!-- ANTLERS').gsub('}>', 'ANTLERS -->')
24
+ # Front matter is metadata, so it's left out of the HTML and the headings.
25
+ MARKDOWN_OPTIONS = { extension: { alerts: true, front_matter_delimiter: '---' } }.freeze
26
+ # Directives are HTML comments until Antlers renders them, and escaped characters like "\#" stay plain text.
27
+ HTML_OPTIONS = { render: { unsafe: true, escaped_char_spans: false } }.freeze
13
28
 
14
- doc = Commonmarker.parse(template.force_encoding('UTF-8'), options: { extension: { alerts: true } })
15
- doc.walk do |node|
16
- if %i[code code_block].include?(node.type)
17
- node.string_content = node.string_content.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
18
- end
19
- end
29
+ # Stands in for an app's metadata when there isn't any, so list: and files: list nothing.
30
+ module NoMetadata
31
+ def self.list(**) = []
32
+ end
33
+
34
+ # Headings with an id, which are the ones a table of contents links to.
35
+ HEADINGS = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
36
+
37
+ module_function
38
+
39
+ def headings(markdown:, metadata: NoMetadata, bare: false)
40
+ html = html(markdown:, variables: variables(markdown:), metadata:, bare:)
41
+
42
+ Nokogiri::HTML(html).css(HEADINGS).map do |heading|
43
+ { level: heading.name[1].to_i, text: heading.text.strip, id: heading['id'] }
44
+ end
45
+ end
20
46
 
21
- template = doc.to_html(options: { render: { unsafe: true } })
22
- template = template.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
47
+ # Directives show in bold, unless the file's project hides them in .raindown/config.yml.
48
+ def render_file(path:, metadata: NoMetadata, bare: false, hide_directives: Config.for(path)[:hide_directives])
49
+ render(markdown: File.read(path), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
50
+ end
23
51
 
24
- return template unless template.include?('<{') || template.include?('{')
52
+ # Front matter keys are variables, like {title} or <{ title }>.
53
+ # Directives like a node's show in bold, or as HTML comments with hide_directives.
54
+ def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false)
55
+ variables = variables(markdown:)
56
+ template = html(markdown:, variables:, metadata:, bare:)
57
+ context = { document: markdown, metadata:, bare: }
58
+
59
+ if template.include?('<{')
60
+ # Directives whose output is HTML. The rest rendered before Commonmarker, in PreRenderer.
61
+ ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :prop, :slot] + Elements[:endpoint])
62
+ template = "#{Antlers.render(ast:, current_binding: variables.binding, metadata:).chomp}\n"
63
+ end
64
+
65
+ # Nodes and directives render last, so their HTML isn't read as Markdown or directives.
66
+ Directives.render(Nodes.render_placeholders(template, **context), hide: hide_directives)
67
+ end
68
+
69
+ # The HTML from Commonmarker, after PreRenderer and before the directives whose output is HTML.
70
+ def html(markdown:, variables:, metadata:, bare:)
71
+ markdown = NodeBlocks.to_rendered(markdown.dup.force_encoding('UTF-8'), metadata:, bare:)
72
+ markdown = PreRenderer.render(markdown, variables:, metadata:, bare:)
73
+ doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
74
+ preserve_code(doc:)
75
+
76
+ doc.to_html(options: HTML_OPTIONS).gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
77
+ .gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
78
+ end
79
+
80
+ def variables(markdown:)
81
+ Variables.new(FrontMatter.parse(markdown).first)
82
+ end
83
+
84
+ # Commonmarker escapes directives, so they're hidden in HTML comments until it's done.
85
+ def hide_directives(markdown:)
86
+ markdown.gsub('<{', '<!-- ANTLERS').gsub('}>', 'ANTLERS -->').force_encoding('UTF-8')
87
+ end
25
88
 
26
- ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :var, :prop, :slot, :if, :for] + Rain::Elements[:toc, :list])
89
+ # Restore directives in code and hide its braces from Antlers.
90
+ def preserve_code(doc:)
91
+ doc.walk do |node|
92
+ next unless %i[code code_block].include?(node.type)
27
93
 
28
- Antlers.render(ast:, current_binding: binding, metadata:)
94
+ node.string_content = node.string_content.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
95
+ .gsub(/[{}]/, CODE_BRACES)
29
96
  end
30
97
  end
31
98
  end
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
metadata CHANGED
@@ -1,16 +1,30 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
- bindir: exe
8
+ bindir: bin
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 1.2.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 1.2.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: commonmarker
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - ">="
@@ -24,7 +38,49 @@ dependencies:
24
38
  - !ruby/object:Gem::Version
25
39
  version: '0'
26
40
  - !ruby/object:Gem::Dependency
27
- name: commonmarker
41
+ name: dirload
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: listen
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: lownode
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: nokogiri
28
84
  requirement: !ruby/object:Gem::Requirement
29
85
  requirements:
30
86
  - - ">="
@@ -51,21 +107,73 @@ dependencies:
51
107
  - - ">="
52
108
  - !ruby/object:Gem::Version
53
109
  version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: trees
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 0.3.5
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 0.3.5
124
+ - !ruby/object:Gem::Dependency
125
+ name: unicode-display_width
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '3.1'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '3.1'
54
138
  description: |
55
139
  Raindown is a templating layer for static site generation
56
140
  and an annotation framework for Markdown files.
57
141
  email:
58
142
  - maediprichard@gmail.com
59
- executables: []
143
+ executables:
144
+ - raindown
60
145
  extensions: []
61
146
  extra_rdoc_files: []
62
147
  files:
148
+ - bin/raindown
63
149
  - lib/elements.rb
150
+ - lib/lexemes/endpoint_lexeme.rb
64
151
  - lib/lexemes/list_lexeme.rb
65
- - lib/lexemes/toc_lexeme.rb
152
+ - lib/nodes/endpoint_node.rb
66
153
  - lib/nodes/list_node.rb
67
- - lib/nodes/toc_node.rb
68
154
  - lib/raindown.rb
155
+ - lib/raindown/bare_directives.rb
156
+ - lib/raindown/cli.rb
157
+ - lib/raindown/code.rb
158
+ - lib/raindown/code_blocks.rb
159
+ - lib/raindown/config.rb
160
+ - lib/raindown/directives.rb
161
+ - lib/raindown/endpoint/curl.rb
162
+ - lib/raindown/endpoint/endpoint_runner.rb
163
+ - lib/raindown/files.rb
164
+ - lib/raindown/front_matter.rb
165
+ - lib/raindown/node.rb
166
+ - lib/raindown/node_blocks.rb
167
+ - lib/raindown/node_injector.rb
168
+ - lib/raindown/nodes.rb
169
+ - lib/raindown/page.rb
170
+ - lib/raindown/pages.rb
171
+ - lib/raindown/pre_renderer.rb
172
+ - lib/raindown/rewriter.rb
173
+ - lib/raindown/table_formatter.rb
174
+ - lib/raindown/toc.rb
175
+ - lib/raindown/variables.rb
176
+ - lib/raindown/watcher.rb
69
177
  - lib/version.rb
70
178
  homepage: https://github.com/raindeer-rb/raindown
71
179
  licenses: []
@@ -79,14 +187,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
79
187
  requirements:
80
188
  - - ">="
81
189
  - !ruby/object:Gem::Version
82
- version: 3.3.0
190
+ version: 3.4.2
83
191
  required_rubygems_version: !ruby/object:Gem::Requirement
84
192
  requirements:
85
193
  - - ">="
86
194
  - !ruby/object:Gem::Version
87
195
  version: '0'
88
196
  requirements: []
89
- rubygems_version: 4.0.6
197
+ rubygems_version: 3.6.2
90
198
  specification_version: 4
91
199
  summary: Markup your markdown with a template and annotation layer
92
200
  test_files: []
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'antlers/api'
4
-
5
- module Rain
6
- module TOCLexeme
7
- include Antlers::Lexeme
8
-
9
- module_function
10
-
11
- KEYWORDS = [':toc'].freeze
12
-
13
- def match?(keywords:, **)
14
- keywords.keys.include?(':toc')
15
- end
16
-
17
- def lexeme(**)
18
- { toc: :default }
19
- end
20
- end
21
- end
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'antlers/api'
4
- require 'nokogiri'
5
-
6
- module Rain
7
- class TOCNode < Antlers::LeafNode
8
- def initialize(name:, template:)
9
- super(name:)
10
-
11
- @template = template
12
- end
13
-
14
- def render(current_binding: nil, parent_binding: nil, slot_node: nil, metadata: {})
15
- doc = Nokogiri::HTML(@template)
16
-
17
- <<~HTML
18
- <div class="floating">
19
- <details id="toc" open>
20
- <summary>Table of contents</summary>
21
- <ul>
22
- #{doc.css('h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]').map do |h|
23
- "<li class='#{h.name}'><a href='\##{h['id']}'>#{h.text.strip}</a></li>"
24
- end.join("\n")}
25
- </ul>
26
- </details>
27
- </div>
28
- HTML
29
- end
30
-
31
- class << self
32
- def match?(segment:)
33
- segment[:toc]
34
- end
35
-
36
- def build(template:, **)
37
- new(name: :toc, template:)
38
- end
39
- end
40
- end
41
- end