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.
- checksums.yaml +4 -4
- data/bin/raindown +6 -0
- data/lib/elements.rb +6 -6
- data/lib/lexemes/endpoint_lexeme.rb +23 -0
- data/lib/lexemes/list_lexeme.rb +1 -1
- data/lib/nodes/endpoint_node.rb +33 -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/front_matter.rb +27 -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 +37 -0
- data/lib/raindown/pages.rb +102 -0
- data/lib/raindown/pre_renderer.rb +54 -0
- data/lib/raindown/rewriter.rb +32 -0
- data/lib/raindown/table_formatter.rb +134 -0
- data/lib/raindown/toc.rb +38 -0
- data/lib/raindown/variables.rb +48 -0
- data/lib/raindown/watcher.rb +59 -0
- data/lib/raindown.rb +83 -16
- data/lib/version.rb +1 -1
- metadata +117 -9
- data/lib/lexemes/toc_lexeme.rb +0 -21
- data/lib/nodes/toc_node.rb +0 -41
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require_relative '../bare_directives'
|
|
5
|
+
require_relative '../code_blocks'
|
|
6
|
+
require_relative 'curl'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# Runs the curl block under an endpoint directive and writes the response in the code block below it,
|
|
10
|
+
# followed by the date it was fetched.
|
|
11
|
+
# Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
|
|
12
|
+
# <{ endpoint: :x }>
|
|
13
|
+
# ```sh
|
|
14
|
+
# curl https://api.example.com
|
|
15
|
+
# ```
|
|
16
|
+
# ```json
|
|
17
|
+
# { "id": 1 }
|
|
18
|
+
# ```
|
|
19
|
+
# <!-- raindown:fetched 2026-10-14 -->
|
|
20
|
+
# <{ :endpoint }>
|
|
21
|
+
class EndpointRunner
|
|
22
|
+
DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
|
|
23
|
+
EXECUTE_ARGS = %w[:x :execute].freeze
|
|
24
|
+
# The date a response was fetched, on the line after it.
|
|
25
|
+
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
26
|
+
|
|
27
|
+
def self.rewrite(content:, path: nil, bare: false, execute: false, **)
|
|
28
|
+
new(content:, bare: BareDirectives.enabled?(path:, bare:), execute:).rewrite
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(content:, bare: false, execute: false)
|
|
32
|
+
@lines = content.lines
|
|
33
|
+
@bare = bare
|
|
34
|
+
@execute = execute
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rewrite
|
|
38
|
+
index = 0
|
|
39
|
+
|
|
40
|
+
while index < @lines.length
|
|
41
|
+
if (block = CodeBlocks.at(@lines, index))
|
|
42
|
+
index = block[:close]
|
|
43
|
+
elsif (args = directive_args(@lines[index]))
|
|
44
|
+
index = fill(index + 1, args)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
index += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@lines.join
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def directive_args(line)
|
|
56
|
+
line = BareDirectives.wrap(line) || line if @bare
|
|
57
|
+
line[DIRECTIVE, 1]&.split
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns the index of the last line it handled.
|
|
61
|
+
def fill(index, args)
|
|
62
|
+
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
63
|
+
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
64
|
+
|
|
65
|
+
return curl[:close] if (output && !output[:body].strip.empty?) || !execute?(args)
|
|
66
|
+
|
|
67
|
+
write_output(curl:, output:)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def execute?(args)
|
|
71
|
+
@execute || args.intersect?(EXECUTE_ARGS)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
|
|
75
|
+
def write_output(curl:, output:)
|
|
76
|
+
block = [*output_block(*Curl.run(curl[:body])), fetched]
|
|
77
|
+
block.unshift("\n") unless output
|
|
78
|
+
start = output ? output[:open] : curl[:close] + 1
|
|
79
|
+
|
|
80
|
+
@lines[start, output ? replaced_length(output) : 0] = block
|
|
81
|
+
start + block.length - 1
|
|
82
|
+
rescue Curl::Error => e
|
|
83
|
+
warn "Endpoint failed: #{e.message}"
|
|
84
|
+
curl[:close]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The output block, and the line after it when that's the date it was last fetched.
|
|
88
|
+
def replaced_length(output)
|
|
89
|
+
length = output[:close] - output[:open] + 1
|
|
90
|
+
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fetched
|
|
94
|
+
"<!-- raindown:fetched #{Date.today.iso8601} -->\n"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The fence is longer than any run of backticks in the body, so the body can't close it.
|
|
98
|
+
def output_block(body, language)
|
|
99
|
+
fence = '`' * [3, (body.scan(/`+/).map(&:length).max || 0) + 1].max
|
|
100
|
+
|
|
101
|
+
["#{fence}#{language}\n", *"#{body.chomp}\n".lines, "#{fence}\n"]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# The node behind files:, a list of links to the files the metadata has for its props, like files: folder: 'notes'.
|
|
7
|
+
# A project can change how files are listed with its own Files node in .raindown, like "class Files < Raindown::Files".
|
|
8
|
+
class Files < Node
|
|
9
|
+
def initialize(**tags)
|
|
10
|
+
@tags = tags
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Each file has its front matter, its path and its content.
|
|
14
|
+
def files(metadata: NoMetadata)
|
|
15
|
+
metadata.list(**@tags)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def replace(metadata: NoMetadata)
|
|
19
|
+
files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# YAML between two --- lines at the very start of a file.
|
|
8
|
+
module FrontMatter
|
|
9
|
+
PATTERN = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns the metadata and the Markdown after it.
|
|
14
|
+
def parse(content)
|
|
15
|
+
match = content.match(PATTERN)
|
|
16
|
+
return [{}, content] if match.nil?
|
|
17
|
+
|
|
18
|
+
metadata = YAML.safe_load(match[1], permitted_classes: [Date, Time], symbolize_names: true)
|
|
19
|
+
[metadata.is_a?(Hash) ? metadata : {}, match.post_match]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# How many of the lines are front matter, so directives in its YAML are left alone.
|
|
23
|
+
def line_count(lines)
|
|
24
|
+
lines.join.match(PATTERN)&.then { |match| match[0].lines.length } || 0
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'code_blocks'
|
|
4
|
+
require_relative 'directives'
|
|
5
|
+
require_relative 'front_matter'
|
|
6
|
+
require_relative 'nodes'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
|
|
10
|
+
# With delimiters the block ends with the node's end tag, and without them at the next blank line:
|
|
11
|
+
# <{ Changelog: limit=3 }> Changelog: limit=3
|
|
12
|
+
# - **1.2.0** Bare directives
|
|
13
|
+
# - **1.2.0** Bare directives
|
|
14
|
+
#
|
|
15
|
+
# <{ :Changelog }>
|
|
16
|
+
# A self-closing <{ :Changelog }> or bare :Changelog that doesn't end a block renders the node without writing it.
|
|
17
|
+
# toc: and files: are Raindown's own nodes. A name only starts a block when it's a loaded node,
|
|
18
|
+
# so other text like "Note:" is left alone.
|
|
19
|
+
module NodeBlocks
|
|
20
|
+
class MissingEndTag < StandardError; end
|
|
21
|
+
|
|
22
|
+
NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
|
|
23
|
+
OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
24
|
+
BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
|
|
25
|
+
SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
26
|
+
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
27
|
+
# A prop is name=value or name: value, where the value is quoted text, a number, true, false
|
|
28
|
+
# or a front matter key like author.name.
|
|
29
|
+
PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|\S+)/
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a bare block.
|
|
34
|
+
# A self-closing node is a block of one line with self_closing: true.
|
|
35
|
+
def find(lines, front_matter: {}, bare: false)
|
|
36
|
+
blocks = []
|
|
37
|
+
index = FrontMatter.line_count(lines)
|
|
38
|
+
|
|
39
|
+
while index < lines.length
|
|
40
|
+
if (code = CodeBlocks.at(lines, index))
|
|
41
|
+
index = code[:close]
|
|
42
|
+
elsif (block = block_at(lines, index, front_matter:, bare:))
|
|
43
|
+
blocks << block
|
|
44
|
+
index = block[:last]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
index += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
blocks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Swaps each block for its node's HTML, through a placeholder, or for its Markdown when it has no HTML of its own.
|
|
54
|
+
# The directive and end tag become Directives markers, which also keep the node's output from joining
|
|
55
|
+
# the Markdown around it, like two lists becoming one.
|
|
56
|
+
def to_rendered(markdown, metadata: NoMetadata, bare: false)
|
|
57
|
+
lines = markdown.lines
|
|
58
|
+
find(lines, front_matter: FrontMatter.parse(markdown).first, bare:).reverse_each do |block|
|
|
59
|
+
lines[block[:open]..block[:last]] = rendered_lines(lines, block, markdown:, metadata:, bare:)
|
|
60
|
+
end
|
|
61
|
+
lines.join
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def rendered_lines(lines, block, markdown:, metadata:, bare:)
|
|
65
|
+
node = block[:node]
|
|
66
|
+
output = if Nodes.html?(node) then ["#{Nodes.placeholder(node)}\n"]
|
|
67
|
+
else "#{Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s.chomp}\n".lines
|
|
68
|
+
end
|
|
69
|
+
output = [] if output == ["\n"]
|
|
70
|
+
|
|
71
|
+
[marker(lines[block[:open]]), *output, *(marker(lines[block[:close]]) if block[:close])]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def marker(line)
|
|
75
|
+
"#{Directives.marker(line)}\n"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def block_at(lines, index, front_matter:, bare:)
|
|
79
|
+
found, pattern = directive(lines[index], bare:)
|
|
80
|
+
return unless found && (klass = Nodes.node_class(found[2]))
|
|
81
|
+
|
|
82
|
+
node = Nodes.instance(klass, props(found[3].to_s, front_matter))
|
|
83
|
+
case pattern
|
|
84
|
+
when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
|
|
85
|
+
when :bare_open then bare_block(lines, index, node)
|
|
86
|
+
else { open: index, close: nil, last: index, node:, self_closing: true }
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The directive on the line and which kind it is, or nil.
|
|
91
|
+
def directive(line, bare:)
|
|
92
|
+
patterns = { open: OPEN, self_closing: SELF_CLOSING }
|
|
93
|
+
patterns.merge!(bare_open: BARE_OPEN, bare_self_closing: BARE_SELF_CLOSING) if bare
|
|
94
|
+
|
|
95
|
+
patterns.each do |pattern, regexp|
|
|
96
|
+
match = line.match(regexp)
|
|
97
|
+
return [match, pattern] if match
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
|
|
104
|
+
def bare_block(lines, open, node)
|
|
105
|
+
last = open
|
|
106
|
+
last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
|
|
107
|
+
|
|
108
|
+
{ open:, close: nil, last:, node: }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def end_tag(lines, open, name)
|
|
112
|
+
tag = /\A {0,3}<\{\s*:#{Regexp.escape(name)}\s*\}>[ \t]*$/
|
|
113
|
+
close = (open + 1...lines.length).find { |index| lines[index].match?(tag) }
|
|
114
|
+
raise MissingEndTag, "<{ #{name}: }> on line #{open + 1} needs <{ :#{name} }>" unless close
|
|
115
|
+
|
|
116
|
+
close
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def props(text, front_matter)
|
|
120
|
+
text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, front_matter)] }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def value(value, front_matter)
|
|
124
|
+
case value
|
|
125
|
+
when /\A(["']).*\1\z/ then value[1..-2]
|
|
126
|
+
when /\A-?\d+\z/ then value.to_i
|
|
127
|
+
when /\A-?\d+\.\d+\z/ then value.to_f
|
|
128
|
+
when 'true', 'false' then value == 'true'
|
|
129
|
+
else value.split('.').reduce(front_matter) { |data, key| data.is_a?(Hash) ? data[key.to_sym] : nil }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../raindown'
|
|
4
|
+
require_relative 'bare_directives'
|
|
5
|
+
require_relative 'front_matter'
|
|
6
|
+
require_relative 'node_blocks'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# Writes each node block's Markdown below its directive, replacing what it wrote before.
|
|
10
|
+
# Self-closing nodes only render, so they're left alone.
|
|
11
|
+
class NodeInjector
|
|
12
|
+
class BlankLine < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Rewriter interface. Content without a node block is returned unchanged.
|
|
15
|
+
def self.rewrite(content:, path: nil, bare: false, metadata: NoMetadata, **)
|
|
16
|
+
new(content:, path:, bare:, metadata:).call
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
|
|
20
|
+
@content = content
|
|
21
|
+
@bare = BareDirectives.enabled?(path:, bare:)
|
|
22
|
+
@metadata = metadata
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call
|
|
26
|
+
lines = @content.lines
|
|
27
|
+
blocks = NodeBlocks.find(lines, front_matter: FrontMatter.parse(@content).first, bare: @bare)
|
|
28
|
+
.reject { |block| block[:self_closing] }
|
|
29
|
+
return @content if blocks.empty?
|
|
30
|
+
|
|
31
|
+
blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
|
|
32
|
+
lines.join
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def block_lines(lines, block)
|
|
38
|
+
markdown = Nodes.call(block[:node], :replace, document: @content, metadata: @metadata, bare: @bare).to_s.chomp
|
|
39
|
+
written = markdown.empty? ? [] : "#{markdown}\n".lines
|
|
40
|
+
return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
|
|
41
|
+
|
|
42
|
+
delimited(lines, block, written)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The directive, what the node wrote and the end tag, each separated by a blank line.
|
|
46
|
+
def delimited(lines, block, written)
|
|
47
|
+
result = [line(lines[block[:open]])]
|
|
48
|
+
result.push("\n", *written) unless written.empty?
|
|
49
|
+
result.push("\n", line(lines[block[:close]]))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A bare block ends at the next blank line, so what it writes can't have one.
|
|
53
|
+
def bare(written, block)
|
|
54
|
+
return written unless written.any? { |line| line.strip.empty? }
|
|
55
|
+
|
|
56
|
+
name = block[:node].class.name
|
|
57
|
+
raise BlankLine, "#{name} writes blank lines, so it needs <{ #{name}: }> and <{ :#{name} }> around it"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def line(line)
|
|
61
|
+
line.end_with?("\n") ? line : "#{line}\n"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'files'
|
|
4
|
+
require_relative 'node'
|
|
5
|
+
require_relative 'toc'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
# Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
|
|
9
|
+
# A project keeps them in a .raindown folder, which is loaded with Dirload.
|
|
10
|
+
# A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
|
|
11
|
+
# document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
|
|
12
|
+
module Nodes
|
|
13
|
+
FOLDER = '.raindown'
|
|
14
|
+
# Lowercase directives for Raindown's own nodes. A project's own TOC or Files node replaces Raindown's.
|
|
15
|
+
BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files' }.freeze
|
|
16
|
+
# A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
|
|
17
|
+
PLACEHOLDER = /<!-- raindown:node (\d+) -->/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Loads the .raindown folder beside the file or in the nearest directory above it, once per process.
|
|
22
|
+
def load(path)
|
|
23
|
+
folder = find_folder(File.dirname(File.expand_path(path)))
|
|
24
|
+
return if folder.nil? || loaded.include?(folder)
|
|
25
|
+
|
|
26
|
+
require 'lownode'
|
|
27
|
+
require 'dirload'
|
|
28
|
+
loaded << folder
|
|
29
|
+
dirload(folder)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def find_folder(directory)
|
|
33
|
+
loop do
|
|
34
|
+
folder = File.join(directory, FOLDER)
|
|
35
|
+
return folder if File.directory?(folder)
|
|
36
|
+
|
|
37
|
+
parent = File.dirname(directory)
|
|
38
|
+
return if parent == directory
|
|
39
|
+
|
|
40
|
+
directory = parent
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def loaded
|
|
45
|
+
@loaded ||= []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The node class a block names, when it's loaded.
|
|
49
|
+
def node_class(name)
|
|
50
|
+
return loaded_class(name) unless BUILT_INS.key?(name)
|
|
51
|
+
|
|
52
|
+
loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def loaded_class(name)
|
|
56
|
+
klass = Object.const_get(name)
|
|
57
|
+
klass if node?(klass)
|
|
58
|
+
rescue NameError
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
|
|
63
|
+
# which don't have replace, so text like "Note:" only starts a block when the Note node writes something.
|
|
64
|
+
def node?(klass)
|
|
65
|
+
return false unless klass.is_a?(Class) && klass.method_defined?(:replace)
|
|
66
|
+
|
|
67
|
+
klass < Node || (defined?(::LowNode) && klass < ::LowNode) || false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Calls a node's replace or render with the context keywords it takes.
|
|
71
|
+
def call(node, name, **context)
|
|
72
|
+
parameters = node.method(name).parameters
|
|
73
|
+
return node.public_send(name, **context) if parameters.any? { |type, _| type == :keyrest }
|
|
74
|
+
|
|
75
|
+
keys = parameters.filter_map { |type, key| key if %i[key keyreq].include?(type) }
|
|
76
|
+
node.public_send(name, **context.slice(*keys))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# One instance for each node and its props, kept for the whole process.
|
|
80
|
+
# So a node can keep what its Markdown and HTML share, like a date, and both stay the same between renders.
|
|
81
|
+
def instance(klass, props)
|
|
82
|
+
key = [klass, props]
|
|
83
|
+
instances[key] ||= begin
|
|
84
|
+
id = list.length
|
|
85
|
+
list << klass.new(**props)
|
|
86
|
+
id
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
list[instances[key]]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def placeholder(node)
|
|
93
|
+
"<!-- raindown:node #{list.index(node)} -->"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Swaps each placeholder for its node's HTML.
|
|
97
|
+
def render_placeholders(html, **context)
|
|
98
|
+
html.gsub(PLACEHOLDER) { html(list[::Regexp.last_match(1).to_i], **context) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def html(node, **context)
|
|
102
|
+
return node.render_template(props: {}).to_s if node.class.respond_to?(:template) && node.class.template
|
|
103
|
+
|
|
104
|
+
call(node, :render, **context).to_s
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether the node renders its own HTML rather than the HTML of its Markdown.
|
|
108
|
+
def html?(node)
|
|
109
|
+
node.respond_to?(:render) || (node.class.respond_to?(:template) && !node.class.template.nil?)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def instances
|
|
113
|
+
@instances ||= {}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def list
|
|
117
|
+
@list ||= []
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# A file that Pages lists: its front matter and implicit metadata, like page.title, page.folder,
|
|
5
|
+
# page.order and page.path, and its content rendered to HTML when it's asked for.
|
|
6
|
+
class Page
|
|
7
|
+
attr_reader :file_path, :metadata
|
|
8
|
+
|
|
9
|
+
def initialize(file_path:, metadata:)
|
|
10
|
+
@file_path = file_path
|
|
11
|
+
@metadata = metadata
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Rendered when asked for, so a file that lists itself doesn't render forever.
|
|
15
|
+
def content
|
|
16
|
+
@content ||= begin
|
|
17
|
+
markdown = FrontMatter.parse(File.read(file_path)).last.strip
|
|
18
|
+
markdown.empty? ? '' : Raindown.render(markdown:)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def [](key)
|
|
23
|
+
key.to_sym == :content ? content : metadata[key.to_sym]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def respond_to_missing?(name, include_all = false)
|
|
27
|
+
metadata.key?(name) || super
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Unknown keys are nil, like front matter a file doesn't have.
|
|
31
|
+
def method_missing(name, *args)
|
|
32
|
+
return super unless args.empty? && !name.end_with?('=', '?', '!')
|
|
33
|
+
|
|
34
|
+
metadata[name]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
def initialize(metadata:, root:)
|
|
35
|
+
@root = root
|
|
36
|
+
@file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
|
|
37
|
+
@metadata = {}
|
|
38
|
+
@tags = {}
|
|
39
|
+
|
|
40
|
+
@file_paths.each { |file_path| process(file_path:) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
44
|
+
# Without tags, every file.
|
|
45
|
+
def list(**tags)
|
|
46
|
+
file_paths = tags.empty? ? @file_paths : tagged(**tags)
|
|
47
|
+
file_paths.sort_by { |file_path| sort_key(file_path) }.map { |file_path| Page.new(file_path:, metadata: @metadata[file_path]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def tagged(**tags)
|
|
51
|
+
tags.flat_map { |type, tag| @tags.dig(type.to_sym, tag) || [] }.uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def process(file_path:)
|
|
57
|
+
metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path)).first)
|
|
58
|
+
@metadata[file_path] = metadata
|
|
59
|
+
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The path is relative to the root, like docs/_basics/_1-getting-started.md.
|
|
63
|
+
# A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
|
|
64
|
+
def implicit_metadata(file_path:)
|
|
65
|
+
{ folder: folders(file_path:).last, order: order(file_path:), path: relative(file_path).delete_prefix('/') }.compact
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def tag(type:, tag:, file_path:)
|
|
69
|
+
@tags[type] ||= {}
|
|
70
|
+
@tags[type][tag] ||= []
|
|
71
|
+
@tags[type][tag] << file_path
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The underscored folders, like basics in docs/_basics/.
|
|
75
|
+
def folders(file_path:)
|
|
76
|
+
underscored(File.dirname(relative(file_path))).reject { number?(it) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The last underscored number in the path, like 1 in docs/_basics/_1-getting-started.md.
|
|
80
|
+
def order(file_path:)
|
|
81
|
+
underscored(relative(file_path)).select { number?(it) }.last&.then { Integer(it, exception: false) || Float(it) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def underscored(path)
|
|
85
|
+
path.split(SEGMENTS).select { it.start_with?('_') }.map { it.delete_prefix('_') }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def relative(file_path)
|
|
89
|
+
File.expand_path(file_path).delete_prefix(File.expand_path(@root))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def number?(string)
|
|
93
|
+
!Float(string, exception: false).nil?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Files with an order come first, by order, then the rest by path.
|
|
97
|
+
def sort_key(file_path)
|
|
98
|
+
order = @metadata[file_path][:order]
|
|
99
|
+
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, @metadata[file_path][:path].to_s]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
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, and directives whose output is HTML render in the HTML afterwards.
|
|
14
|
+
module PreRenderer
|
|
15
|
+
BLOCK_DIRECTIVES = %w[for if list].freeze
|
|
16
|
+
# A variable like <{ title }> or a for, if or list directive, which render here.
|
|
17
|
+
DIRECTIVE = /<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})|[@\w.]+)\s*\}>/
|
|
18
|
+
# A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
|
|
19
|
+
BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
|
|
20
|
+
HIDDEN = /\u{E002}(\d+)\u{E003}/
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
def render(markdown, variables:, metadata: {}, bare: false)
|
|
25
|
+
markdown = BareDirectives.expand(markdown) if bare
|
|
26
|
+
return markdown unless markdown.include?('{')
|
|
27
|
+
|
|
28
|
+
markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
|
|
29
|
+
body = FrontMatter.parse(markdown).last
|
|
30
|
+
"#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def render_body(body, variables, metadata)
|
|
34
|
+
hidden = []
|
|
35
|
+
# Other directives are hidden, but not the Markdown between their tags.
|
|
36
|
+
template = body.gsub(/<\{.*?\}>|<\{/) { |directive| directive.match?(/\A#{DIRECTIVE}\z/) ? directive : hide(directive, hidden) }
|
|
37
|
+
template = template.gsub(BLOCK_LINE, '\1')
|
|
38
|
+
return body unless template.include?('{')
|
|
39
|
+
|
|
40
|
+
# Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
|
|
41
|
+
leading = template[/\A\s*/]
|
|
42
|
+
trailing = template[/\s*\z/]
|
|
43
|
+
ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :var, :if, :for] + Elements[:list])
|
|
44
|
+
rendered = Antlers.render(ast:, current_binding: variables.binding, metadata:)
|
|
45
|
+
|
|
46
|
+
"#{leading}#{rendered}#{trailing}".gsub(HIDDEN) { hidden[::Regexp.last_match(1).to_i] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hide(directive, hidden)
|
|
50
|
+
hidden << directive
|
|
51
|
+
"\u{E002}#{hidden.length - 1}\u{E003}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|