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,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,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# The HTML of components rendered while a document renders, kept out of its Markdown as placeholders
|
|
5
|
+
# so Commonmarker doesn't read it as Markdown, then filled in once the document is HTML.
|
|
6
|
+
module Fragments
|
|
7
|
+
PLACEHOLDER = /<!-- raindown:fragment (\d+) -->/
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Collects the fragments added while the block runs, like one document's render.
|
|
12
|
+
def collect
|
|
13
|
+
outer = Thread.current[:raindown_fragments]
|
|
14
|
+
Thread.current[:raindown_fragments] = []
|
|
15
|
+
yield
|
|
16
|
+
ensure
|
|
17
|
+
Thread.current[:raindown_fragments] = outer
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def add(html)
|
|
21
|
+
list << html
|
|
22
|
+
"<!-- raindown:fragment #{list.length - 1} -->"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Fills each placeholder, again and again, since a fragment can hold others, like a component in a slot.
|
|
26
|
+
def fill(html)
|
|
27
|
+
html = html.gsub(PLACEHOLDER) { list[::Regexp.last_match(1).to_i] } while html.match?(PLACEHOLDER)
|
|
28
|
+
html
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def list
|
|
32
|
+
Thread.current[:raindown_fragments] ||= []
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
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,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# Commonmarker gives each heading a unique id within what it renders, but a document's components render
|
|
5
|
+
# on their own, so two can have a heading with the same id. Later ones get -1, -2 and so on, like Commonmarker.
|
|
6
|
+
module HeadingIds
|
|
7
|
+
HEADING = %r{<h([1-6]) id="([^"]*)"(.*?)</h\1>}m
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def unique(html)
|
|
12
|
+
seen = Hash.new(0)
|
|
13
|
+
|
|
14
|
+
html.gsub(HEADING) do
|
|
15
|
+
level, id, rest = ::Regexp.last_match.captures
|
|
16
|
+
count = seen[id]
|
|
17
|
+
seen[id] += 1
|
|
18
|
+
next ::Regexp.last_match(0) if count.zero?
|
|
19
|
+
|
|
20
|
+
unique_id = "#{id}-#{count}"
|
|
21
|
+
"<h#{level} id=\"#{unique_id}\"#{rest.gsub("href=\"##{id}\"", "href=\"##{unique_id}\"")}</h#{level}>"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
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,42 @@
|
|
|
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:, render_options: {})
|
|
10
|
+
@file_path = file_path
|
|
11
|
+
@metadata = metadata
|
|
12
|
+
@render_options = render_options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Rendered when asked for, so a file that lists itself doesn't render forever.
|
|
16
|
+
# The front matter is rendered with it, so its variables work, and a Raindown file's bare directives too.
|
|
17
|
+
def content
|
|
18
|
+
@content ||= begin
|
|
19
|
+
markdown = File.read(file_path)
|
|
20
|
+
|
|
21
|
+
if FrontMatter.parse(markdown).last.strip.empty? then ''
|
|
22
|
+
else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **@render_options)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def [](key)
|
|
28
|
+
key.to_sym == :content ? content : metadata[key.to_sym]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def respond_to_missing?(name, include_all = false)
|
|
32
|
+
metadata.key?(name) || super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Unknown keys are nil, like front matter a file doesn't have.
|
|
36
|
+
def method_missing(name, *args)
|
|
37
|
+
return super unless args.empty? && !name.end_with?('=', '?', '!')
|
|
38
|
+
|
|
39
|
+
metadata[name]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|