raindown 0.1.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/bin/raindown +6 -0
- data/lib/elements.rb +31 -6
- data/lib/lexemes/endpoint_lexeme.rb +23 -0
- data/lib/lexemes/list_lexeme.rb +1 -1
- data/lib/nodes/component_nodes.rb +38 -0
- data/lib/nodes/endpoint_node.rb +35 -0
- data/lib/nodes/list_node.rb +1 -1
- data/lib/raindown/bare_directives.rb +91 -0
- data/lib/raindown/cli.rb +94 -0
- data/lib/raindown/code.rb +36 -0
- data/lib/raindown/code_blocks.rb +48 -0
- data/lib/raindown/config.rb +23 -0
- data/lib/raindown/directives.rb +29 -0
- data/lib/raindown/endpoint/curl.rb +154 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +104 -0
- data/lib/raindown/files.rb +22 -0
- data/lib/raindown/fragments.rb +35 -0
- data/lib/raindown/front_matter.rb +27 -0
- data/lib/raindown/heading_ids.rb +25 -0
- data/lib/raindown/node.rb +7 -0
- data/lib/raindown/node_blocks.rb +133 -0
- data/lib/raindown/node_injector.rb +64 -0
- data/lib/raindown/nodes.rb +120 -0
- data/lib/raindown/page.rb +42 -0
- data/lib/raindown/pages.rb +108 -0
- data/lib/raindown/pre_renderer.rb +45 -0
- data/lib/raindown/rewriter.rb +32 -0
- data/lib/raindown/table_formatter.rb +134 -0
- data/lib/raindown/toc.rb +50 -0
- data/lib/raindown/variables.rb +48 -0
- data/lib/raindown/watcher.rb +59 -0
- data/lib/raindown.rb +81 -16
- data/lib/version.rb +1 -1
- metadata +120 -9
- data/lib/lexemes/toc_lexeme.rb +0 -21
- data/lib/nodes/toc_node.rb +0 -41
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'front_matter'
|
|
4
|
+
require_relative 'page'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# The metadata of a project's Markdown files, which list: and files: list by tag.
|
|
8
|
+
# Ported from Raindeer's Rain::Pages, and takes the same Dirload metadata.
|
|
9
|
+
#
|
|
10
|
+
# Each file is tagged with its front matter, like "tags: [ruby, docs]" or "date: 2099", and by its path,
|
|
11
|
+
# where underscored folders are metadata: docs/_basics/_1-getting-started.md has
|
|
12
|
+
# folder: basics, order: 1, path: docs/_basics/_1-getting-started.md
|
|
13
|
+
# Folder tags can share a folder name with "&", like docs/_1&_basics/getting-started.md.
|
|
14
|
+
# An app can add its own metadata by subclassing and overriding implicit_metadata, like Raindeer's URL paths.
|
|
15
|
+
class Pages
|
|
16
|
+
EXTENSIONS = %w[md rd markdown raindown].freeze
|
|
17
|
+
SEGMENTS = Regexp.union(['/', '&', '-'])
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
|
|
21
|
+
def for(path)
|
|
22
|
+
file_directory = File.dirname(File.expand_path(path))
|
|
23
|
+
load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Lists the files under the root with Dirload, without loading them.
|
|
27
|
+
def load(root:)
|
|
28
|
+
require 'dirload'
|
|
29
|
+
file_types = Dirload.file_paths(absolute_path: root).group_by { Dirload.extension(file_path: it) }
|
|
30
|
+
new(metadata: Dirload::Metadata.new(loaded_paths: {}, missed_paths: [], file_types:), root:)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Render options, like hide_directives: true, are for rendering each listed file's content.
|
|
35
|
+
def initialize(metadata:, root:, render_options: {})
|
|
36
|
+
@root = root
|
|
37
|
+
@render_options = render_options
|
|
38
|
+
@file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
|
|
39
|
+
@metadata = {}
|
|
40
|
+
@tags = {}
|
|
41
|
+
|
|
42
|
+
@file_paths.each { |file_path| process(file_path:) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
46
|
+
# Without tags, every file.
|
|
47
|
+
def list(**tags)
|
|
48
|
+
file_paths = tags.empty? ? @file_paths : tagged(**tags)
|
|
49
|
+
file_paths.sort_by { |file_path| sort_key(file_path) }.map { |file_path| listed_page(file_path) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tagged(**tags)
|
|
53
|
+
tags.flat_map { |type, tag| @tags.dig(type.to_sym, tag) || [] }.uniq
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def listed_page(file_path)
|
|
59
|
+
Page.new(file_path:, metadata: @metadata[file_path], render_options: @render_options)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def process(file_path:)
|
|
63
|
+
metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path)).first)
|
|
64
|
+
@metadata[file_path] = metadata
|
|
65
|
+
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The path is relative to the root, like docs/_basics/_1-getting-started.md.
|
|
69
|
+
# A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
|
|
70
|
+
def implicit_metadata(file_path:)
|
|
71
|
+
{ folder: folders(file_path:).last, order: order(file_path:), path: relative(file_path).delete_prefix('/') }.compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tag(type:, tag:, file_path:)
|
|
75
|
+
@tags[type] ||= {}
|
|
76
|
+
@tags[type][tag] ||= []
|
|
77
|
+
@tags[type][tag] << file_path
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The underscored folders, like basics in docs/_basics/.
|
|
81
|
+
def folders(file_path:)
|
|
82
|
+
underscored(File.dirname(relative(file_path))).reject { number?(it) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The last underscored number in the path, like 1 in docs/_basics/_1-getting-started.md.
|
|
86
|
+
def order(file_path:)
|
|
87
|
+
underscored(relative(file_path)).select { number?(it) }.last&.then { Integer(it, exception: false) || Float(it) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def underscored(path)
|
|
91
|
+
path.split(SEGMENTS).select { it.start_with?('_') }.map { it.delete_prefix('_') }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def relative(file_path)
|
|
95
|
+
File.expand_path(file_path).delete_prefix(File.expand_path(@root))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def number?(string)
|
|
99
|
+
!Float(string, exception: false).nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Files with an order come first, by order, then the rest by path.
|
|
103
|
+
def sort_key(file_path)
|
|
104
|
+
order = @metadata[file_path][:order]
|
|
105
|
+
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, @metadata[file_path][:path].to_s]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'antlers'
|
|
4
|
+
require_relative '../elements'
|
|
5
|
+
require_relative 'bare_directives'
|
|
6
|
+
require_relative 'code'
|
|
7
|
+
require_relative 'front_matter'
|
|
8
|
+
|
|
9
|
+
module Raindown
|
|
10
|
+
# Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
|
|
11
|
+
# So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
|
|
12
|
+
# come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
|
|
13
|
+
# Nodes like toc: and files: render before this. Components and endpoints render here too, as fragments of HTML
|
|
14
|
+
# that Raindown puts back once the document is HTML.
|
|
15
|
+
module PreRenderer
|
|
16
|
+
BLOCK_DIRECTIVES = %w[for if list].freeze
|
|
17
|
+
# A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
|
|
18
|
+
BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def render(markdown, variables:, metadata: {}, bare: false)
|
|
23
|
+
markdown = BareDirectives.expand(markdown) if bare
|
|
24
|
+
return markdown unless markdown.include?('{')
|
|
25
|
+
|
|
26
|
+
markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
|
|
27
|
+
body = FrontMatter.parse(markdown).last
|
|
28
|
+
"#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def render_body(body, variables, metadata)
|
|
32
|
+
template = body.gsub(BLOCK_LINE, '\1')
|
|
33
|
+
return body unless template.include?('{')
|
|
34
|
+
|
|
35
|
+
# Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
|
|
36
|
+
leading = template[/\A\s*/]
|
|
37
|
+
trailing = template[/\s*\z/]
|
|
38
|
+
# Components and endpoints render here too, where loop variables are in scope, and keep their HTML as fragments.
|
|
39
|
+
elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:list, :endpoint, :prop_component, :slot_component]
|
|
40
|
+
rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, metadata:)
|
|
41
|
+
|
|
42
|
+
"#{leading}#{rendered}#{trailing}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -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
|
data/lib/raindown/toc.rb
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'erb'
|
|
4
|
+
require 'nokogiri'
|
|
5
|
+
|
|
6
|
+
require_relative 'node'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# The node behind toc: and :toc, a table of contents of the document's headings.
|
|
10
|
+
# A project can replace it with its own TOC node in .raindown, like "class TOC < Raindown::TOC".
|
|
11
|
+
class TOC < Node
|
|
12
|
+
def replace(document:, metadata: nil, bare: false)
|
|
13
|
+
headings = headings(markdown: document, metadata:, bare:)
|
|
14
|
+
return '' if headings.empty?
|
|
15
|
+
|
|
16
|
+
min_level = headings.map { |heading| heading[:level] }.min
|
|
17
|
+
|
|
18
|
+
headings.map do |heading|
|
|
19
|
+
"#{' ' * (heading[:level] - min_level)}- [#{heading[:text]}](##{heading[:id]})"
|
|
20
|
+
end.join("\n")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# With the document's final HTML, the headings include ones its components render.
|
|
24
|
+
def render(document:, html: nil, metadata: nil, bare: false)
|
|
25
|
+
items = headings(markdown: document, html:, metadata:, bare:).map do |heading|
|
|
26
|
+
"<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{ERB::Util.html_escape(heading[:text])}</a></li>"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
<<~HTML
|
|
30
|
+
<div class="floating">
|
|
31
|
+
<details id="toc" open>
|
|
32
|
+
<summary>Table of contents</summary>
|
|
33
|
+
<ul>
|
|
34
|
+
#{items.join("\n")}
|
|
35
|
+
</ul>
|
|
36
|
+
</details>
|
|
37
|
+
</div>
|
|
38
|
+
HTML
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The document's headings, from its final HTML, or else by rendering it with its components.
|
|
42
|
+
def headings(markdown:, html: nil, metadata: nil, bare: false)
|
|
43
|
+
html ||= Fragments.collect { Raindown.assembled(markdown:, metadata: metadata || NoMetadata, bare:) }
|
|
44
|
+
|
|
45
|
+
Nokogiri::HTML(html).css(HEADINGS).map do |heading|
|
|
46
|
+
{ level: heading.name[1].to_i, text: heading.text.strip, id: heading['id'] }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
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,96 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'commonmarker'
|
|
4
|
+
require 'antlers'
|
|
4
5
|
require 'antlers/api'
|
|
6
|
+
|
|
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/fragments'
|
|
13
|
+
require_relative 'raindown/front_matter'
|
|
14
|
+
require_relative 'raindown/heading_ids'
|
|
15
|
+
require_relative 'raindown/node_blocks'
|
|
16
|
+
require_relative 'raindown/pages'
|
|
17
|
+
require_relative 'raindown/pre_renderer'
|
|
18
|
+
require_relative 'raindown/variables'
|
|
19
|
+
|
|
20
|
+
module Raindown
|
|
21
|
+
# Antlers evaluates any braces in the HTML, so braces in code are swapped for placeholders
|
|
22
|
+
# then HTML entities, which browsers still display as braces.
|
|
23
|
+
CODE_BRACES = { '{' => "\u{E000}", '}' => "\u{E001}" }.freeze
|
|
24
|
+
CODE_BRACE_ENTITIES = { "\u{E000}" => '{', "\u{E001}" => '}' }.freeze
|
|
25
|
+
|
|
26
|
+
# Front matter is metadata, so it's left out of the HTML and the headings.
|
|
27
|
+
MARKDOWN_OPTIONS = { extension: { alerts: true, front_matter_delimiter: '---' } }.freeze
|
|
28
|
+
# Directives are HTML comments until Antlers renders them, and escaped characters like "\#" stay plain text.
|
|
29
|
+
HTML_OPTIONS = { render: { unsafe: true, escaped_char_spans: false } }.freeze
|
|
30
|
+
|
|
31
|
+
# Stands in for an app's metadata when there isn't any, so list: and files: list nothing.
|
|
32
|
+
module NoMetadata
|
|
33
|
+
def self.list(**) = []
|
|
34
|
+
end
|
|
6
35
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
module_function
|
|
36
|
+
# Headings with an id, which are the ones a table of contents links to.
|
|
37
|
+
HEADINGS = 'h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]'
|
|
10
38
|
|
|
11
|
-
|
|
12
|
-
template = markdown.gsub('<{', '<!-- ANTLERS').gsub('}>', 'ANTLERS -->')
|
|
39
|
+
module_function
|
|
13
40
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
end
|
|
19
|
-
end
|
|
41
|
+
# Directives show in bold, unless the file's project hides them in .raindown/config.yml.
|
|
42
|
+
def render_file(path:, metadata: NoMetadata, bare: false, hide_directives: Config.for(path)[:hide_directives])
|
|
43
|
+
render(markdown: File.read(path), metadata:, bare: bare || BareDirectives.file?(path), hide_directives:)
|
|
44
|
+
end
|
|
20
45
|
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
# Front matter keys are variables, like {title} or <{ title }>.
|
|
47
|
+
# Directives like "toc:" export to HTML as bold or comments depending on hide_directives config.
|
|
48
|
+
def render(markdown:, metadata: NoMetadata, bare: false, hide_directives: false)
|
|
49
|
+
Fragments.collect do
|
|
50
|
+
# Components' HTML goes back in, then their headings get unique ids, before nodes like the TOC see it.
|
|
51
|
+
html = assembled(markdown:, metadata:, bare:)
|
|
52
|
+
html = Nodes.render_placeholders(html, document: markdown, metadata:, bare:, html:)
|
|
23
53
|
|
|
24
|
-
|
|
54
|
+
Directives.render("#{html.chomp}\n", hide: hide_directives)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The document's HTML with its components' HTML put back, and unique heading ids.
|
|
59
|
+
def assembled(markdown:, metadata: NoMetadata, bare: false)
|
|
60
|
+
HeadingIds.unique(Fragments.fill(html(markdown:, variables: variables(markdown:), metadata:, bare:)))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The document's HTML from Commonmarker, after nodes and the template pass, with components as fragments.
|
|
64
|
+
def html(markdown:, variables:, metadata: NoMetadata, bare: false)
|
|
65
|
+
markdown = NodeBlocks.to_rendered(markdown.dup.force_encoding('UTF-8'), metadata:, bare:)
|
|
66
|
+
commonmark(PreRenderer.render(markdown, variables:, metadata:, bare:))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Markdown to HTML, like a document's or a component's slot.
|
|
70
|
+
def commonmark(markdown)
|
|
71
|
+
doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
|
|
72
|
+
preserve_code(doc:)
|
|
73
|
+
|
|
74
|
+
doc.to_html(options: HTML_OPTIONS).gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
|
|
75
|
+
.gsub(/[\u{E000}\u{E001}]/, CODE_BRACE_ENTITIES)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def variables(markdown:)
|
|
79
|
+
Variables.new(FrontMatter.parse(markdown).first)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Commonmarker escapes directives, so they're hidden in HTML comments until it's done.
|
|
83
|
+
def hide_directives(markdown:)
|
|
84
|
+
markdown.gsub('<{', '<!-- ANTLERS').gsub('}>', 'ANTLERS -->').force_encoding('UTF-8')
|
|
85
|
+
end
|
|
25
86
|
|
|
26
|
-
|
|
87
|
+
# Restore directives in code and hide its braces from Antlers.
|
|
88
|
+
def preserve_code(doc:)
|
|
89
|
+
doc.walk do |node|
|
|
90
|
+
next unless %i[code code_block].include?(node.type)
|
|
27
91
|
|
|
28
|
-
|
|
92
|
+
node.string_content = node.string_content.gsub('<!-- ANTLERS', '<{').gsub('ANTLERS -->', '}>')
|
|
93
|
+
.gsub(/[{}]/, CODE_BRACES)
|
|
29
94
|
end
|
|
30
95
|
end
|
|
31
96
|
end
|
data/lib/version.rb
CHANGED