raindown 0.12.0 → 0.14.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/lib/elements.rb +12 -25
- data/lib/lexemes/node_lexeme.rb +37 -0
- data/lib/nodes/list_node.rb +22 -25
- data/lib/nodes/write_in_node.rb +95 -0
- data/lib/raindown/bare_directives.rb +11 -17
- data/lib/raindown/chart/chart.rb +1 -1
- data/lib/raindown/code_blocks.rb +23 -15
- data/lib/raindown/context.rb +19 -0
- data/lib/raindown/directives.rb +1 -1
- data/lib/raindown/document.rb +9 -9
- data/lib/raindown/endpoint/endpoint_runner.rb +15 -24
- data/lib/raindown/fragments.rb +6 -1
- data/lib/raindown/js/host.mjs +2 -2
- data/lib/raindown/js/js_nodes.rb +4 -4
- data/lib/raindown/list.rb +31 -0
- data/lib/raindown/markdown_nodes.rb +3 -2
- data/lib/raindown/node.rb +10 -2
- data/lib/raindown/node_blocks.rb +28 -27
- data/lib/raindown/node_injector.rb +7 -1
- data/lib/raindown/nodes.rb +61 -21
- data/lib/raindown/pages.rb +14 -8
- data/lib/raindown/pre_renderer.rb +13 -10
- data/lib/raindown/stat.rb +1 -1
- data/lib/raindown/table/markdown_table.rb +35 -0
- data/lib/raindown/table/table.rb +87 -0
- data/lib/raindown/toc.rb +2 -2
- data/lib/raindown/tui/cloud.rb +154 -0
- data/lib/raindown/tui/feelings/celebration/celebration_renderer.rb +22 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -16
- data/lib/raindown/tui/layout.rb +8 -1
- data/lib/raindown/tui/project.rb +23 -11
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +4 -3
- data/lib/raindown/tui/terminal/loading.rb +34 -0
- data/lib/raindown/tui/terminal/screen.rb +6 -5
- data/lib/raindown/tui/terminal/styles.rb +13 -1
- data/lib/raindown/tui/views/endpoint_view.rb +5 -4
- data/lib/raindown/tui.rb +10 -6
- data/lib/raindown/wiki_links.rb +1 -1
- data/lib/raindown.rb +9 -14
- data/lib/version.rb +1 -1
- metadata +11 -5
- data/lib/lexemes/list_lexeme.rb +0 -32
- data/lib/nodes/component_nodes.rb +0 -38
- data/lib/raindown/files.rb +0 -22
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# The node behind list:, the files the metadata has for its props, like list: folder: 'notes'. Bare or in comments it
|
|
7
|
+
# writes a link to each, and in <{ list: page folder: 'notes' }> ... <{ :list }> it renders its template for each,
|
|
8
|
+
# with the file as page, the name before its props, which is its argument. A project can change how files are listed
|
|
9
|
+
# with its own List node in .raindown, like "class List < Raindown::List".
|
|
10
|
+
class List < Node
|
|
11
|
+
# Order is the metadata to sort by, like order: :created_at, rather than a tag to list by.
|
|
12
|
+
def initialize(_name = nil, order: nil, **tags)
|
|
13
|
+
@order = order
|
|
14
|
+
@tags = tags
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Each file has its front matter, its path and its content. Sorted by an order, files without it come last, and ones
|
|
18
|
+
# with the same value stay in the order they're listed.
|
|
19
|
+
def files(metadata: NoMetadata)
|
|
20
|
+
files = metadata.list(**@tags)
|
|
21
|
+
return files unless @order
|
|
22
|
+
|
|
23
|
+
sorted, unsorted = files.partition { it[@order] }
|
|
24
|
+
sorted.sort_by.with_index { |file, index| [file[@order], index] } + unsorted
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def rewrite(metadata: NoMetadata)
|
|
28
|
+
files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -57,10 +57,11 @@ module Raindown
|
|
|
57
57
|
|
|
58
58
|
# Braces that the template pass hid, in its code or its props' values, are written back as braces.
|
|
59
59
|
# The template pass loads here, as it loads nodes itself.
|
|
60
|
-
def
|
|
60
|
+
def rewrite(document: '', metadata: NoMetadata, bare: false)
|
|
61
61
|
require_relative 'pre_renderer'
|
|
62
62
|
defaults, template = FrontMatter.parse(File.read(self.class.path))
|
|
63
|
-
|
|
63
|
+
context = Context.new(document:, metadata:, bare:)
|
|
64
|
+
markdown = PreRenderer.render(template, variables: Variables.new(defaults.merge(@props)), context:)
|
|
64
65
|
Variables.unwrap(markdown.sub(/\A(?:[ \t]*\n)+/, '').rstrip)
|
|
65
66
|
end
|
|
66
67
|
end
|
data/lib/raindown/node.rb
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Raindown
|
|
4
|
-
# The base class of Raindown's own nodes, like TOC and
|
|
4
|
+
# The base class of Raindown's own nodes, like TOC and List.
|
|
5
5
|
# A project's nodes are LowNodes, or subclasses of these.
|
|
6
|
-
class Node
|
|
6
|
+
class Node
|
|
7
|
+
# Write-in, in <{ }>, a node renders its Markdown as HTML. One whose rewrite takes written:, what it wrote last
|
|
8
|
+
# time, can write edits back to its source, like Table to its file, so it needs its own render, which never does.
|
|
9
|
+
def render(**context)
|
|
10
|
+
raise Nodes::NoRender, "#{self.class.name} writes edits back, so it needs a render" if Nodes.takes?(self, :rewrite, :written)
|
|
11
|
+
|
|
12
|
+
Raindown.commonmark(Nodes.call(self, :rewrite, **context).to_s)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
7
15
|
end
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -16,9 +16,10 @@ module Raindown
|
|
|
16
16
|
# - **1.2.0** Bare directives
|
|
17
17
|
#
|
|
18
18
|
# <!-- :Changelog -->
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# a
|
|
19
|
+
# A bare :Changelog is write-in: it only renders, with its render, never its rewrite. In Antlers' delimiters, like
|
|
20
|
+
# <{ Changelog }>, it's write-in too, and the template pass renders it, as a WriteInNode, so it's only found here
|
|
21
|
+
# for a document's outline. toc: and list: are Raindown's own nodes. A name only starts a block when it's a loaded
|
|
22
|
+
# node, so other text like "Note:" is left alone.
|
|
22
23
|
module NodeBlocks
|
|
23
24
|
class MissingEndTag < StandardError; end
|
|
24
25
|
class BrokenText < StandardError; end
|
|
@@ -27,7 +28,8 @@ module Raindown
|
|
|
27
28
|
OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
28
29
|
COMMENT_OPEN = /\A( {0,3})<!--\s*(#{NAME}):(?:\s+(.*?))?\s*-->[ \t]*$/
|
|
29
30
|
BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
|
|
30
|
-
|
|
31
|
+
# <{ Card }>, or <{ :Card }>, which a lowercase name like toc needs, as <{ toc }> is a variable.
|
|
32
|
+
SELF_CLOSING = /\A( {0,3})<\{\s*(?::|(?=[A-Z]))(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
31
33
|
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
32
34
|
# Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
|
|
33
35
|
UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
|
|
@@ -40,12 +42,15 @@ module Raindown
|
|
|
40
42
|
module_function
|
|
41
43
|
|
|
42
44
|
# Each block outside code and front matter as { open:, close:, last:, writes:, node: }, where close is nil for a
|
|
43
|
-
# bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line
|
|
44
|
-
# with self_closing: true.
|
|
45
|
+
# bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line.
|
|
45
46
|
def find(lines, front_matter: {}, bare: false)
|
|
47
|
+
with_nodes(spans(lines, bare:), front_matter)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def with_nodes(spans, front_matter)
|
|
46
51
|
variables = Variables.new(front_matter)
|
|
47
52
|
|
|
48
|
-
spans
|
|
53
|
+
spans.map do |span|
|
|
49
54
|
node = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
|
|
50
55
|
span.except(:name, :class, :args, :antlers).merge(node:)
|
|
51
56
|
end
|
|
@@ -71,28 +76,29 @@ module Raindown
|
|
|
71
76
|
spans
|
|
72
77
|
end
|
|
73
78
|
|
|
74
|
-
# Swaps each block for its node's
|
|
75
|
-
# The directive and end tag become Directives markers, which also keep the node's
|
|
76
|
-
# the Markdown around it, like two lists becoming one.
|
|
77
|
-
def to_rendered(
|
|
78
|
-
lines =
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
# Swaps each bare block or block in comments for its node's Markdown, or its HTML when it has its own, or only renders,
|
|
80
|
+
# as a fragment or a placeholder. The directive and end tag become Directives markers, which also keep the node's
|
|
81
|
+
# output from joining the Markdown around it, like two lists becoming one. Ones in <{ }> are left to Antlers.
|
|
82
|
+
def to_rendered(context)
|
|
83
|
+
lines = context.document.dup.force_encoding('UTF-8').lines
|
|
84
|
+
spans = spans(lines, bare: context.bare).reject { it[:antlers] }
|
|
85
|
+
with_nodes(spans, FrontMatter.parse(context.document).first).reverse_each do |block|
|
|
86
|
+
lines[block[:open]..block[:last]] = rendered_lines(lines, block, context)
|
|
81
87
|
end
|
|
82
88
|
lines.join
|
|
83
89
|
end
|
|
84
90
|
|
|
85
|
-
def rendered_lines(lines, block,
|
|
86
|
-
output = node_markdown(block
|
|
91
|
+
def rendered_lines(lines, block, context)
|
|
92
|
+
output = node_markdown(block, context).chomp
|
|
87
93
|
close = marker(lines[block[:close]]) if block[:close]
|
|
88
94
|
|
|
89
95
|
[marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
|
|
90
96
|
end
|
|
91
97
|
|
|
92
|
-
def node_markdown(
|
|
93
|
-
return Nodes.
|
|
98
|
+
def node_markdown(block, context)
|
|
99
|
+
return Nodes.rendered(block[:node], context) if !block[:writes] || Nodes.html?(block[:node])
|
|
94
100
|
|
|
95
|
-
Nodes.call(node, :
|
|
101
|
+
Nodes.call(block[:node], :rewrite, **context.to_h).to_s
|
|
96
102
|
end
|
|
97
103
|
|
|
98
104
|
def marker(line)
|
|
@@ -109,7 +115,7 @@ module Raindown
|
|
|
109
115
|
end_tag(lines, index, found[2], comment: pattern == :comment_open)
|
|
110
116
|
.then { |close| { open: index, close:, last: close, writes: pattern == :comment_open, **span } }
|
|
111
117
|
when :bare_open then { **bare_block(lines, index), writes: true, **span }
|
|
112
|
-
else { open: index, close: nil, last: index, writes: false,
|
|
118
|
+
else { open: index, close: nil, last: index, writes: false, **span }
|
|
113
119
|
end
|
|
114
120
|
end
|
|
115
121
|
|
|
@@ -127,12 +133,7 @@ module Raindown
|
|
|
127
133
|
end
|
|
128
134
|
|
|
129
135
|
# A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
|
|
130
|
-
def bare_block(lines, open)
|
|
131
|
-
last = open
|
|
132
|
-
last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
|
|
133
|
-
|
|
134
|
-
{ open:, close: nil, last: }
|
|
135
|
-
end
|
|
136
|
+
def bare_block(lines, open) = { open:, close: nil, last: CodeBlocks.last_before_blank(lines, open) }
|
|
136
137
|
|
|
137
138
|
# The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
|
|
138
139
|
def end_tag(lines, open, name, comment:)
|
|
@@ -161,7 +162,7 @@ module Raindown
|
|
|
161
162
|
end
|
|
162
163
|
|
|
163
164
|
# A value, like 'text', 3, true, nil or :draft, or else a variable. In Antlers' delimiters a word is a variable, like
|
|
164
|
-
# author.name, while bare or in a comment, which read like Markdown, it's text, like notes in
|
|
165
|
+
# author.name, while bare or in a comment, which read like Markdown, it's text, like notes in list: folder: notes,
|
|
165
166
|
# and only one in braces is a variable, like {author.name}.
|
|
166
167
|
def value(value, variables, antlers: true)
|
|
167
168
|
Lowkey::ValueParser.parse(value) do
|
|
@@ -35,7 +35,8 @@ module Raindown
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
def block_lines(lines, block)
|
|
38
|
-
|
|
38
|
+
context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
|
|
39
|
+
markdown = Nodes.call(block[:node], :rewrite, **context).to_s.chomp
|
|
39
40
|
written = markdown.empty? ? [] : "#{markdown}\n".lines
|
|
40
41
|
return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
|
|
41
42
|
|
|
@@ -54,6 +55,11 @@ module Raindown
|
|
|
54
55
|
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# What the block's node wrote last time, between its directive and its end tag, or the next blank line.
|
|
59
|
+
def written_lines(lines, block)
|
|
60
|
+
lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
|
|
61
|
+
end
|
|
62
|
+
|
|
57
63
|
def line(line)
|
|
58
64
|
line.end_with?("\n") ? line : "#{line}\n"
|
|
59
65
|
end
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'chart/chart'
|
|
4
|
-
require_relative '
|
|
4
|
+
require_relative 'fragments'
|
|
5
5
|
require_relative 'js/js_nodes'
|
|
6
|
+
require_relative 'list'
|
|
6
7
|
require_relative 'markdown_nodes'
|
|
7
8
|
require_relative 'node'
|
|
8
9
|
require_relative 'stat'
|
|
10
|
+
require_relative 'table/table'
|
|
9
11
|
require_relative 'toc'
|
|
10
12
|
|
|
11
13
|
module Raindown
|
|
12
14
|
# Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
|
|
13
15
|
# A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
|
|
14
16
|
# Components, if any.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
+
# Bare or in comments, a node writes back: its Markdown, from #rewrite, is written into the file, and its HTML is from
|
|
18
|
+
# #render if it has one. In <{ }> it's write-in: it only renders, with #render, which takes the Markdown between its
|
|
19
|
+
# tags as slot:, in HTML. Either can take the keywords document: (the Markdown being rendered), metadata:, bare: and
|
|
20
|
+
# written: (the lines it wrote last time), and gets the ones it asks for.
|
|
17
21
|
module Nodes
|
|
22
|
+
# A write-in node that has no render.
|
|
23
|
+
class NoRender < StandardError; end
|
|
24
|
+
|
|
18
25
|
FOLDER = '.raindown'
|
|
19
|
-
# Lowercase directives for Raindown's own nodes. A project's own TOC,
|
|
20
|
-
BUILT_INS = { 'toc' => 'TOC', '
|
|
21
|
-
# A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers
|
|
22
|
-
|
|
26
|
+
# Lowercase directives for Raindown's own nodes. A project's own TOC, List, Chart or Table node replaces Raindown's.
|
|
27
|
+
BUILT_INS = { 'toc' => 'TOC', 'list' => 'List', 'chart' => 'Chart', 'table' => 'Table' }.freeze
|
|
28
|
+
# A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers, with its slot's HTML, as a
|
|
29
|
+
# fragment, when it has one.
|
|
30
|
+
PLACEHOLDER = /<!-- raindown:node (\d+)(?: slot (\d+))? -->/
|
|
23
31
|
|
|
24
32
|
module_function
|
|
25
33
|
|
|
@@ -61,21 +69,21 @@ module Raindown
|
|
|
61
69
|
|
|
62
70
|
# A Ruby node of the name, or else a JavaScript one, or else a Markdown one.
|
|
63
71
|
def loaded_class(name)
|
|
64
|
-
klass = Object.const_get(name) if Object.const_defined?(name)
|
|
72
|
+
klass = Object.const_get(name) if name.match?(/\A[A-Z]/) && Object.const_defined?(name)
|
|
65
73
|
return klass if node?(klass)
|
|
66
74
|
|
|
67
75
|
JSNodes.node_class(name) || MarkdownNodes.node_class(name)
|
|
68
76
|
end
|
|
69
77
|
|
|
70
78
|
# A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
|
|
71
|
-
# which don't have
|
|
79
|
+
# which don't have rewrite, so text like "Note:" only starts a block when the Note node writes something.
|
|
72
80
|
def node?(klass)
|
|
73
|
-
return false unless klass.is_a?(Class) && klass.method_defined?(:
|
|
81
|
+
return false unless klass.is_a?(Class) && klass.method_defined?(:rewrite)
|
|
74
82
|
|
|
75
83
|
klass < Node || (defined?(::LowNode) && klass < ::LowNode) || false
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
# Calls a node's
|
|
86
|
+
# Calls a node's rewrite or render with the context keywords it takes.
|
|
79
87
|
def call(node, name, **context)
|
|
80
88
|
parameters = node.method(name).parameters
|
|
81
89
|
return node.public_send(name, **context) if parameters.any? { |type, _| type == :keyrest }
|
|
@@ -97,24 +105,56 @@ module Raindown
|
|
|
97
105
|
list[instances[key]]
|
|
98
106
|
end
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
# Renders the node where it is, as a fragment, so its headings are part of the page, or through a placeholder, once
|
|
109
|
+
# the page is HTML, when its render takes html:, the page's HTML, like TOC's.
|
|
110
|
+
def rendered(node, context, slot: '')
|
|
111
|
+
raise NoRender, "#{node.class.name} has no render, so it can't be write-in: write it bare or in comments" unless renders?(node)
|
|
112
|
+
return placeholder(node, slot:) if !template?(node.class) && takes?(node, :render, :html)
|
|
113
|
+
|
|
114
|
+
Fragments.add(html(node, context, slot:))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def takes?(node, method_name, keyword)
|
|
118
|
+
node.method(method_name).parameters.any? { |_, name| name == keyword }
|
|
102
119
|
end
|
|
103
120
|
|
|
104
|
-
#
|
|
105
|
-
def
|
|
106
|
-
|
|
121
|
+
# Whether the node's class has a template, like a Raindeer .rbx component, which renders in place of render.
|
|
122
|
+
def template?(klass)
|
|
123
|
+
klass.respond_to?(:template) && !klass.template.nil?
|
|
107
124
|
end
|
|
108
125
|
|
|
109
|
-
def
|
|
110
|
-
return node.
|
|
126
|
+
def placeholder(node, slot: '')
|
|
127
|
+
return "<!-- raindown:node #{list.index(node)} -->" if slot.empty?
|
|
111
128
|
|
|
112
|
-
|
|
129
|
+
"<!-- raindown:node #{list.index(node)} slot #{Fragments.store(slot)} -->"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Swaps each placeholder for its node's HTML, given the page's HTML and its slot, the HTML between its tags in <{ }>,
|
|
133
|
+
# which can hold components' fragments.
|
|
134
|
+
def render_placeholders(page_html, context)
|
|
135
|
+
page_html.gsub(PLACEHOLDER) do
|
|
136
|
+
match = ::Regexp.last_match
|
|
137
|
+
slot = match[2] ? Fragments.list[match[2].to_i] : ''
|
|
138
|
+
Fragments.fill(html(list[match[1].to_i], context, slot:, html: page_html))
|
|
139
|
+
end
|
|
113
140
|
end
|
|
114
141
|
|
|
115
|
-
|
|
142
|
+
def html(node, context, **keywords)
|
|
143
|
+
return node.render_template(props: {}).to_s if template?(node.class)
|
|
144
|
+
|
|
145
|
+
call(node, :render, **context.to_h, **keywords).to_s
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Whether the node renders its own HTML rather than the HTML of its Markdown, which Raindown's nodes render by default.
|
|
116
149
|
def html?(node)
|
|
117
|
-
|
|
150
|
+
return true if template?(node.class)
|
|
151
|
+
|
|
152
|
+
node.respond_to?(:render) && node.method(:render).owner != Node
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Whether the node renders at all, so it can be write-in, in <{ }>.
|
|
156
|
+
def renders?(node)
|
|
157
|
+
node.respond_to?(:render) || html?(node)
|
|
118
158
|
end
|
|
119
159
|
|
|
120
160
|
def instances
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -5,7 +5,7 @@ require_relative 'page'
|
|
|
5
5
|
require_relative 'utils'
|
|
6
6
|
|
|
7
7
|
module Raindown
|
|
8
|
-
# The metadata of a project's Markdown files, which list:
|
|
8
|
+
# The metadata of a project's Markdown files, which list: lists by tag.
|
|
9
9
|
# Ported from Raindeer's Rain::Pages, and takes the same Dirload metadata.
|
|
10
10
|
#
|
|
11
11
|
# Each file is tagged with its front matter, like "tags: [ruby, docs]" or "date: 2099", and by its path,
|
|
@@ -61,10 +61,12 @@ module Raindown
|
|
|
61
61
|
attr_reader :root
|
|
62
62
|
|
|
63
63
|
# Render options, like highlight_code: false, are for rendering each listed file's content.
|
|
64
|
-
|
|
64
|
+
# Eager reads every file's front matter straight away, for an app like Raindeer that needs all of it from the start.
|
|
65
|
+
def initialize(root:, metadata:, render_options: {}, eager: false)
|
|
65
66
|
@root = root
|
|
66
67
|
@render_options = render_options
|
|
67
|
-
@file_paths = metadata
|
|
68
|
+
@file_paths = filtered_paths(metadata:)
|
|
69
|
+
load_metadata if eager
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
@@ -80,17 +82,22 @@ module Raindown
|
|
|
80
82
|
|
|
81
83
|
private
|
|
82
84
|
|
|
85
|
+
# The Markdown files in Dirload's metadata, only those under the root path.
|
|
86
|
+
def filtered_paths(metadata:)
|
|
87
|
+
metadata.file_types.values_at(*EXTENSIONS).flatten.compact.select { it.start_with?("#{@root}/") }
|
|
88
|
+
end
|
|
89
|
+
|
|
83
90
|
def metadata
|
|
84
|
-
|
|
91
|
+
load_metadata unless @metadata
|
|
85
92
|
@metadata
|
|
86
93
|
end
|
|
87
94
|
|
|
88
95
|
def tags
|
|
89
|
-
|
|
96
|
+
load_metadata unless @tags
|
|
90
97
|
@tags
|
|
91
98
|
end
|
|
92
99
|
|
|
93
|
-
def
|
|
100
|
+
def load_metadata
|
|
94
101
|
@metadata = {}
|
|
95
102
|
@tags = {}
|
|
96
103
|
@file_paths.each { |file_path| process(file_path:) }
|
|
@@ -101,8 +108,7 @@ module Raindown
|
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
def process(file_path:)
|
|
104
|
-
metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
|
|
105
|
-
@metadata[file_path] = metadata
|
|
111
|
+
metadata = @metadata[file_path] = implicit_metadata(file_path:).merge(front_matter(file_path))
|
|
106
112
|
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
107
113
|
end
|
|
108
114
|
|
|
@@ -5,31 +5,33 @@ require_relative '../elements'
|
|
|
5
5
|
require_relative 'bare_directives'
|
|
6
6
|
require_relative 'code'
|
|
7
7
|
require_relative 'code_blocks'
|
|
8
|
+
require_relative 'context'
|
|
8
9
|
require_relative 'front_matter'
|
|
9
10
|
|
|
10
11
|
module Raindown
|
|
11
12
|
# Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
|
|
12
13
|
# So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
|
|
13
14
|
# come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
|
|
14
|
-
# Nodes like toc: and
|
|
15
|
-
# that Raindown puts back once the document is HTML.
|
|
15
|
+
# Nodes bare or in comments, like toc: and list:, render before this. Nodes in <{ }> and endpoints render here, as
|
|
16
|
+
# fragments of HTML that Raindown puts back once the document is HTML.
|
|
16
17
|
module PreRenderer
|
|
17
18
|
BLOCK_DIRECTIVES = %w[for if list].freeze
|
|
18
19
|
# A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
|
|
19
|
-
|
|
20
|
+
# A self-closing <{ :list folder: 'notes' }> isn't one, as only an open tag has props after its name.
|
|
21
|
+
BLOCK_LINE = /^[ \t]*(<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})\s*)\}>)[ \t]*\n/
|
|
20
22
|
# An endpoint's directive or end tag in a comment, which renders as it would in Antlers' delimiters.
|
|
21
23
|
ENDPOINT_COMMENT = /\A( {0,3})<!--\s*(endpoint:.*?|:endpoint)\s*-->/
|
|
22
24
|
|
|
23
25
|
module_function
|
|
24
26
|
|
|
25
|
-
def render(markdown, variables:,
|
|
26
|
-
markdown = BareDirectives.expand(markdown) if bare
|
|
27
|
+
def render(markdown, variables:, context: Context.new)
|
|
28
|
+
markdown = BareDirectives.expand(markdown) if context.bare
|
|
27
29
|
markdown = delimited_endpoints(markdown) if markdown.include?('<!--')
|
|
28
30
|
return markdown unless markdown.include?('{')
|
|
29
31
|
|
|
30
32
|
markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
|
|
31
33
|
body = FrontMatter.parse(markdown).last
|
|
32
|
-
"#{markdown.delete_suffix(body)}#{render_body(body, variables,
|
|
34
|
+
"#{markdown.delete_suffix(body)}#{render_body(body, variables, context)}"
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# The endpoints in comments in Antlers' delimiters, which the template pass renders, leaving code as it is.
|
|
@@ -46,16 +48,17 @@ module Raindown
|
|
|
46
48
|
lines.join
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
def render_body(body, variables,
|
|
51
|
+
def render_body(body, variables, context)
|
|
50
52
|
template = body.gsub(BLOCK_LINE, '\1')
|
|
51
53
|
return body unless template.include?('{')
|
|
52
54
|
|
|
53
55
|
# Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
|
|
54
56
|
leading = template[/\A\s*/]
|
|
55
57
|
trailing = template[/\s*\z/]
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
# Write-in nodes and endpoints render here too, where loop variables are in scope, and keep their HTML as
|
|
59
|
+
# fragments.
|
|
60
|
+
elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:write_in, :endpoint]
|
|
61
|
+
rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, context:)
|
|
59
62
|
|
|
60
63
|
"#{leading}#{rendered}#{trailing}"
|
|
61
64
|
end
|
data/lib/raindown/stat.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Raindown
|
|
|
23
23
|
'9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
|
|
24
24
|
'.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def rewrite(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
|
|
27
27
|
|
|
28
28
|
# The number in block digits, then the label, as a card on the Dashboard shows them.
|
|
29
29
|
def card_lines(metadata: NoMetadata)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# Rows of cells as a Markdown table, its first row the header, and back again. A | in a cell is escaped as \|, and
|
|
5
|
+
# a line break is written as a space, as a table's cells are one line. The TableFormatter lines up its columns.
|
|
6
|
+
module MarkdownTable
|
|
7
|
+
SEPARATOR = /\A\|?(\s*:?-+:?\s*\|)*\s*:?-+:?\s*\|?\z/
|
|
8
|
+
# A | that isn't escaped.
|
|
9
|
+
PIPE = /(?<!\\)\|/
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def format(rows)
|
|
14
|
+
return '' if rows.empty?
|
|
15
|
+
|
|
16
|
+
header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
|
|
17
|
+
[header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
|
|
21
|
+
def parse(lines)
|
|
22
|
+
return [] unless lines[1]&.strip&.match?(SEPARATOR)
|
|
23
|
+
|
|
24
|
+
[lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cells(line)
|
|
28
|
+
line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def escape(cell)
|
|
32
|
+
cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative '../node'
|
|
8
|
+
require_relative 'markdown_table'
|
|
9
|
+
|
|
10
|
+
module Raindown
|
|
11
|
+
# The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
|
|
12
|
+
# rows it has and a hash of them, as they were when the table and the file last matched:
|
|
13
|
+
# table: 'data/team.csv'
|
|
14
|
+
# <!-- table 2 rows · 3f2a9c -->
|
|
15
|
+
# | name | role |
|
|
16
|
+
# |------|----------|
|
|
17
|
+
# | Ada | engineer |
|
|
18
|
+
# | Lin | design |
|
|
19
|
+
# Edit the table and its rows are written back to the file, or edit the file and the table's written again. When
|
|
20
|
+
# both changed, neither is, and the table's comment says so until they match again.
|
|
21
|
+
class Table < Node
|
|
22
|
+
COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
|
|
23
|
+
SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(data)
|
|
26
|
+
@data = data
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Written is what it wrote last time, so it can tell which of the table and the file changed since.
|
|
30
|
+
def rewrite(written: [], metadata: NoMetadata)
|
|
31
|
+
path = data_path(metadata)
|
|
32
|
+
data_error(path) || synced(path, written.map(&:chomp))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Write-in, in <{ }>, the table only shows its file, and never writes to it.
|
|
36
|
+
def render(metadata: NoMetadata)
|
|
37
|
+
path = data_path(metadata)
|
|
38
|
+
Raindown.commonmark(data_error(path) || MarkdownTable.format(read_rows(path)))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def data_path(metadata)
|
|
44
|
+
File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Why the file can't be a table, or nil when it can.
|
|
48
|
+
def data_error(path)
|
|
49
|
+
return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
|
|
50
|
+
|
|
51
|
+
"Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def synced(path, written)
|
|
55
|
+
file = read_rows(path)
|
|
56
|
+
synced_hash = written.first&.[](COMMENT, 1)
|
|
57
|
+
table = MarkdownTable.parse(written.drop(1))
|
|
58
|
+
return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
|
|
59
|
+
return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
|
|
60
|
+
|
|
61
|
+
warn "raindown: #{@data} and its table both changed, so neither is written until they match"
|
|
62
|
+
["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def markdown(rows)
|
|
66
|
+
return '' if rows.empty?
|
|
67
|
+
|
|
68
|
+
count = rows.length - 1
|
|
69
|
+
"<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read_rows(path)
|
|
73
|
+
rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
|
|
74
|
+
width = rows.map(&:length).max.to_i
|
|
75
|
+
rows.map { |row| Array.new(width) { row[it].to_s.strip } }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_rows(path, rows)
|
|
79
|
+
File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
|
|
80
|
+
rows
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def hash_rows(rows)
|
|
84
|
+
Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/raindown/toc.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Raindown
|
|
|
14
14
|
IGNORED = %r{<!--.*?-->|<script\b.*?</script\s*>}mi
|
|
15
15
|
TAG = /<[^>]*>/
|
|
16
16
|
|
|
17
|
-
def
|
|
17
|
+
def rewrite(document:, metadata: nil, bare: false)
|
|
18
18
|
headings = headings(markdown: document, metadata:, bare:)
|
|
19
19
|
return '' if headings.empty?
|
|
20
20
|
|
|
@@ -45,7 +45,7 @@ module Raindown
|
|
|
45
45
|
|
|
46
46
|
# The document's headings, from its final HTML, or else by rendering it with its components.
|
|
47
47
|
def headings(markdown:, html: nil, metadata: nil, bare: false)
|
|
48
|
-
html ||= Fragments.collect { Raindown.assembled(markdown
|
|
48
|
+
html ||= Fragments.collect { Raindown.assembled(Context.new(document: markdown, metadata: metadata || NoMetadata, bare:)) }
|
|
49
49
|
|
|
50
50
|
html.gsub(IGNORED, '').scan(HEADING).map do |level, *ids, content|
|
|
51
51
|
{ level: level.to_i, text: CGI.unescapeHTML(content.gsub(TAG, '')).strip, id: CGI.unescapeHTML(ids.compact.first) }
|