raindown 0.6.0 → 0.8.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/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/chart/chart.rb +12 -1
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +22 -20
- data/lib/raindown/endpoint/curl.rb +11 -7
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +31 -22
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/stat.rb +53 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/code_box.rb +143 -0
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +18 -1
- data/lib/raindown/tui/feelings/batch.rb +47 -0
- data/lib/raindown/tui/feelings/bins.rb +67 -0
- data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
- data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
- data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
- data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/code_methods.rb +134 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
- data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
- data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
- data/lib/raindown/tui/file_tree.rb +6 -5
- data/lib/raindown/tui/highlighter.rb +45 -0
- data/lib/raindown/tui/layout.rb +32 -18
- data/lib/raindown/tui/navigator/commands.rb +46 -2
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +54 -25
- data/lib/raindown/tui/navigator/teleport.rb +128 -0
- data/lib/raindown/tui/navigator/views.rb +5 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
- data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
- data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
- data/lib/raindown/tui/renderers/renderer.rb +4 -0
- data/lib/raindown/tui/row.rb +4 -2
- data/lib/raindown/tui/rows.rb +2 -1
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/search_box.rb +86 -0
- data/lib/raindown/tui/terminal/input.rb +95 -7
- data/lib/raindown/tui/terminal/screen.rb +37 -29
- data/lib/raindown/tui/terminal/styles.rb +111 -26
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +62 -12
- data/lib/raindown/tui/title.rb +1 -1
- data/lib/raindown/tui/views/dashboard_view.rb +7 -5
- data/lib/raindown/tui/views/endpoint_view.rb +4 -2
- data/lib/raindown/tui/views/list_view.rb +2 -0
- data/lib/raindown/tui/views/outline_view.rb +16 -6
- data/lib/raindown/tui.rb +19 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +60 -9
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
data/lib/raindown/directives.rb
CHANGED
|
@@ -3,27 +3,44 @@
|
|
|
3
3
|
require 'cgi/escape'
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# Template directives, like for: and if:, are always
|
|
6
|
+
# How a content directive is written says what it does, and how it shows in the HTML:
|
|
7
|
+
# toc: bare, it writes its output into the file, and shows in bold
|
|
8
|
+
# <!-- toc: --> <!-- :toc --> as comments, it writes its output into the file, and is left out of the HTML
|
|
9
|
+
# <{ toc: }> <{ :toc }> in Antlers' delimiters, it only renders, and is left out of the HTML
|
|
10
|
+
# Template directives, like for: and if:, are always in Antlers' delimiters, and always hidden.
|
|
11
|
+
# Until the HTML's done each is a comment marker, which ends the Markdown block before it and starts nothing itself,
|
|
12
|
+
# so a node's list stays its own list. A hidden one is left out of the HTML altogether.
|
|
11
13
|
module Directives
|
|
12
14
|
MARKER = /<!-- raindown:directive (.*?) -->/
|
|
15
|
+
HIDDEN = /<!-- raindown:hidden .*? -->\n?/
|
|
16
|
+
# A directive in an HTML comment, which writes back, or in Antlers' delimiters, which only renders.
|
|
17
|
+
COMMENT = ['<!--', '-->'].freeze
|
|
18
|
+
DELIMITERS = ['<{', '}>'].freeze
|
|
13
19
|
|
|
14
20
|
module_function
|
|
15
21
|
|
|
16
|
-
# A directive like "files: folder: 'notes'"
|
|
17
|
-
def
|
|
18
|
-
|
|
22
|
+
# A directive's line without its delimiters, like "files: folder: 'notes'" for <!-- files: folder: 'notes' -->.
|
|
23
|
+
def text(line)
|
|
24
|
+
line = line.strip
|
|
25
|
+
[COMMENT, DELIMITERS].each do |open, close|
|
|
26
|
+
return line.delete_prefix(open).delete_suffix(close).strip if line.start_with?(open) && line.end_with?(close)
|
|
27
|
+
end
|
|
28
|
+
line
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether the directive's line is bare, without delimiters, so it shows in bold.
|
|
32
|
+
def bare?(line) = text(line) == line.strip
|
|
33
|
+
|
|
34
|
+
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
|
|
35
|
+
def marker(line, shown: bare?(line))
|
|
36
|
+
text = text(line).gsub('--', '- -')
|
|
37
|
+
return "<!-- raindown:hidden #{text} -->" unless shown
|
|
38
|
+
|
|
19
39
|
"<!-- raindown:directive #{text} -->"
|
|
20
40
|
end
|
|
21
41
|
|
|
22
|
-
def render(html
|
|
23
|
-
html.gsub(MARKER)
|
|
24
|
-
text = ::Regexp.last_match(1)
|
|
25
|
-
hide ? "<!-- #{text} -->" : "<p><strong>#{CGI.escapeHTML(text.to_s)}</strong></p>"
|
|
26
|
-
end
|
|
42
|
+
def render(html)
|
|
43
|
+
html.gsub(HIDDEN, '').gsub(MARKER) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
|
|
27
44
|
end
|
|
28
45
|
end
|
|
29
46
|
end
|
data/lib/raindown/document.rb
CHANGED
|
@@ -20,7 +20,8 @@ module Raindown
|
|
|
20
20
|
# It spans line to end_line, its end tag, or the last line of a bare block.
|
|
21
21
|
# Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
|
|
22
22
|
# Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
|
|
23
|
-
# when nothing's written yet.
|
|
23
|
+
# when nothing's written yet. One in Antlers' delimiters, like <{ toc: }> or <{ :toc }>, only renders, so it never
|
|
24
|
+
# writes.
|
|
24
25
|
Directive = Data.define(:name, :args, :line, :end_line, :body, :output, :writes) do
|
|
25
26
|
def endpoint? = name == 'endpoint'
|
|
26
27
|
end
|
|
@@ -28,17 +29,17 @@ module Raindown
|
|
|
28
29
|
# Inline nodes whose text a reader sees, like code spans, as opposed to inline HTML tags.
|
|
29
30
|
TEXT_NODES = %i[text code].freeze
|
|
30
31
|
BREAK_NODES = %i[softbreak linebreak].freeze
|
|
31
|
-
|
|
32
|
-
END_TAG = /\A {0,3}<\{\s*:[\w:]+\s*\}>[ \t]*$/
|
|
32
|
+
END_TAG = /\A {0,3}(?:<\{\s*:[\w:]+\s*\}>|<!--\s*:[\w:]+\s*-->)[ \t]*$/
|
|
33
33
|
|
|
34
34
|
def self.parse(markdown, bare: false)
|
|
35
35
|
new(markdown:, bare:)
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
# Raindown files read bare directives, as do Markdown files with "raindown: true" front matter
|
|
38
|
+
# Raindown files read bare directives, as do Markdown files with "raindown: true" front matter, or any file when
|
|
39
|
+
# they're allowed, like with --bare.
|
|
39
40
|
# A project's own nodes are only directives once they're loaded, like with Nodes.load(path), the same as when the file renders.
|
|
40
|
-
def self.read(path)
|
|
41
|
-
new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: BareDirectives.file?(path))
|
|
41
|
+
def self.read(path, bare: false)
|
|
42
|
+
new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: bare || BareDirectives.file?(path))
|
|
42
43
|
end
|
|
43
44
|
|
|
44
45
|
def initialize(markdown:, bare: false)
|
|
@@ -49,7 +50,7 @@ module Raindown
|
|
|
49
50
|
|
|
50
51
|
# The headings in the order they appear, with their source text, like "Notes for {name}" before it's rendered.
|
|
51
52
|
def headings
|
|
52
|
-
@headings ||=
|
|
53
|
+
@headings ||= root.walk.select { |node| node.type == :heading }.map do |node|
|
|
53
54
|
Heading.new(level: node.header_level, text: text(node), line: node.source_position[:start_line])
|
|
54
55
|
end
|
|
55
56
|
end
|
|
@@ -71,10 +72,6 @@ module Raindown
|
|
|
71
72
|
@root ||= Commonmarker.parse(@markdown, options: MARKDOWN_OPTIONS)
|
|
72
73
|
end
|
|
73
74
|
|
|
74
|
-
def nodes(type)
|
|
75
|
-
[].tap { |nodes| root.walk { |node| nodes << node if node.type == type } }
|
|
76
|
-
end
|
|
77
|
-
|
|
78
75
|
def text(heading)
|
|
79
76
|
parts = []
|
|
80
77
|
heading.walk do |node|
|
|
@@ -87,7 +84,7 @@ module Raindown
|
|
|
87
84
|
def node_directives
|
|
88
85
|
NodeBlocks.spans(@lines, bare: @bare).map do |span|
|
|
89
86
|
output = trimmed(span[:open] + 1, span[:close] ? span[:close] - 1 : span[:last])
|
|
90
|
-
directive(span[:name], span[:args], span[:open]..span[:last], output:, writes:
|
|
87
|
+
directive(span[:name], span[:args], span[:open]..span[:last], output:, writes: span[:writes])
|
|
91
88
|
end
|
|
92
89
|
end
|
|
93
90
|
|
|
@@ -98,8 +95,8 @@ module Raindown
|
|
|
98
95
|
while index < @lines.length
|
|
99
96
|
if (code = CodeBlocks.at(@lines, index))
|
|
100
97
|
index = code[:close]
|
|
101
|
-
elsif (
|
|
102
|
-
directives << endpoint(index,
|
|
98
|
+
elsif (found = endpoint_args(@lines[index]))
|
|
99
|
+
directives << endpoint(index, *found)
|
|
103
100
|
index = directives.last.end_line - 1
|
|
104
101
|
end
|
|
105
102
|
|
|
@@ -109,20 +106,25 @@ module Raindown
|
|
|
109
106
|
directives
|
|
110
107
|
end
|
|
111
108
|
|
|
109
|
+
# The endpoint's arguments, in any of the ways it can be written, and whether it writes its response back, which
|
|
110
|
+
# it does unless it's in Antlers' delimiters, or nil when the line isn't an endpoint.
|
|
112
111
|
def endpoint_args(line)
|
|
113
|
-
line
|
|
114
|
-
line[EndpointRunner::
|
|
112
|
+
return [line[EndpointRunner::DIRECTIVE, 1].strip, false] if line.match?(EndpointRunner::DIRECTIVE)
|
|
113
|
+
return [line[EndpointRunner::COMMENT, 1].strip, true] if line.match?(EndpointRunner::COMMENT)
|
|
114
|
+
|
|
115
|
+
bare = BareDirectives.wrap(line)&.[](EndpointRunner::DIRECTIVE, 1) if @bare
|
|
116
|
+
[bare.strip, true] if bare
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
# The response and the date it was fetched are the output, while the curl snippet above them is the author's.
|
|
118
|
-
def endpoint(open, args)
|
|
120
|
+
def endpoint(open, args, writes)
|
|
119
121
|
curl, response = CodeBlocks.endpoint(@lines, open + 1)
|
|
120
122
|
output = response && (response[:open]..(fetched?(response[:close] + 1) ? response[:close] + 1 : response[:close]))
|
|
121
123
|
last = output&.end || curl&.fetch(:close) || open
|
|
122
124
|
# A bare endpoint, which isn't delimited, ends after its blocks.
|
|
123
|
-
last = end_tag(after: last) || last
|
|
125
|
+
last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
|
|
124
126
|
|
|
125
|
-
directive('endpoint', args, open..last, output:, writes:
|
|
127
|
+
directive('endpoint', args, open..last, output:, writes:)
|
|
126
128
|
end
|
|
127
129
|
|
|
128
130
|
def fetched?(index)
|
|
@@ -132,7 +134,7 @@ module Raindown
|
|
|
132
134
|
# The end tag after the endpoint's blocks, with only blank lines between.
|
|
133
135
|
def end_tag(after:)
|
|
134
136
|
index = CodeBlocks.skip_blank_lines(@lines, after + 1)
|
|
135
|
-
index if @lines[index]&.match?(
|
|
137
|
+
index if @lines[index]&.match?(EndpointRunner::END_TAG)
|
|
136
138
|
end
|
|
137
139
|
|
|
138
140
|
# Lines from first to last with the blank lines around them left out, or nil when there are none.
|
|
@@ -66,19 +66,23 @@ module Raindown
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
# The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
|
|
69
|
-
# A snippet that sends data is a POST, unless it says otherwise, as with curl.
|
|
70
69
|
def request(snippet)
|
|
71
70
|
args = arguments(snippet)
|
|
72
|
-
url
|
|
73
|
-
method = args[args.index('--request') + 1] if args.include?('--request')
|
|
74
|
-
method ||= 'HEAD' if args.include?('--head')
|
|
75
|
-
method ||= 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
|
|
76
|
-
|
|
77
|
-
Request.new(method: (method || 'GET').upcase, url:) if url
|
|
71
|
+
Request.new(method: request_method(args), url: args.find { it.match?(URL) })
|
|
78
72
|
rescue Error
|
|
79
73
|
nil
|
|
80
74
|
end
|
|
81
75
|
|
|
76
|
+
# A snippet that sends data is a POST, unless it says otherwise, as with curl.
|
|
77
|
+
def request_method(args)
|
|
78
|
+
index = args.index('--request')
|
|
79
|
+
return args[index + 1].upcase if index
|
|
80
|
+
return 'HEAD' if args.include?('--head')
|
|
81
|
+
return 'POST' if args.intersect?(VALUE_CHECKS.keys.grep(/data|json/))
|
|
82
|
+
|
|
83
|
+
'GET'
|
|
84
|
+
end
|
|
85
|
+
|
|
82
86
|
def arguments(snippet)
|
|
83
87
|
words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
|
|
84
88
|
raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
|
|
@@ -6,11 +6,12 @@ require_relative '../code_blocks'
|
|
|
6
6
|
require_relative 'curl'
|
|
7
7
|
|
|
8
8
|
module Raindown
|
|
9
|
-
# Runs the curl block under an endpoint directive and writes the response in the code
|
|
9
|
+
# Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
|
|
10
|
+
# block below it,
|
|
10
11
|
# followed by the date it was fetched.
|
|
11
12
|
# Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
|
|
12
13
|
# Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
|
|
13
|
-
#
|
|
14
|
+
# <!-- endpoint: :x -->
|
|
14
15
|
# ```sh
|
|
15
16
|
# curl https://api.example.com
|
|
16
17
|
# ```
|
|
@@ -18,9 +19,14 @@ module Raindown
|
|
|
18
19
|
# { "id": 1 }
|
|
19
20
|
# ```
|
|
20
21
|
# <!-- raindown:fetched 2026-10-14 -->
|
|
21
|
-
#
|
|
22
|
+
# <!-- :endpoint -->
|
|
23
|
+
# One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
|
|
22
24
|
class EndpointRunner
|
|
25
|
+
# An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
|
|
26
|
+
# and the end tag of either.
|
|
23
27
|
DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
|
|
28
|
+
COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
|
|
29
|
+
END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
|
|
24
30
|
EXECUTE_ARGS = %w[:x :execute].freeze
|
|
25
31
|
# The date a response was fetched, on the line after it.
|
|
26
32
|
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
@@ -54,9 +60,11 @@ module Raindown
|
|
|
54
60
|
|
|
55
61
|
private
|
|
56
62
|
|
|
63
|
+
# The arguments of an endpoint that writes back: in a comment, or bare.
|
|
57
64
|
def directive_args(line)
|
|
58
|
-
|
|
59
|
-
line[DIRECTIVE, 1
|
|
65
|
+
args = line[COMMENT, 1]
|
|
66
|
+
args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
|
|
67
|
+
args&.split
|
|
60
68
|
end
|
|
61
69
|
|
|
62
70
|
# Returns the index of the last line it handled.
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -10,20 +10,21 @@ require_relative 'variables'
|
|
|
10
10
|
|
|
11
11
|
module Raindown
|
|
12
12
|
# Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# In comments the block ends with the node's end tag, and bare at the next blank line, and both write back:
|
|
14
|
+
# <!-- Changelog: limit=3 --> Changelog: limit=3
|
|
15
|
+
# - **1.2.0** Bare directives
|
|
16
16
|
# - **1.2.0** Bare directives
|
|
17
17
|
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# toc: and files: are Raindown's own nodes. A name only starts
|
|
21
|
-
# so other text like "Note:" is left alone.
|
|
18
|
+
# <!-- :Changelog -->
|
|
19
|
+
# In Antlers' delimiters, <{ Changelog: }> to <{ :Changelog }>, or a self-closing <{ :Changelog }> or bare
|
|
20
|
+
# :Changelog, it renders the node without writing it. toc: and files: are Raindown's own nodes. A name only starts
|
|
21
|
+
# a block when it's a loaded node, so other text like "Note:" is left alone.
|
|
22
22
|
module NodeBlocks
|
|
23
23
|
class MissingEndTag < StandardError; end
|
|
24
24
|
|
|
25
25
|
NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
|
|
26
26
|
OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
27
|
+
COMMENT_OPEN = /\A( {0,3})<!--\s*(#{NAME}):(?:\s+(.*?))?\s*-->[ \t]*$/
|
|
27
28
|
BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
|
|
28
29
|
SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
29
30
|
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
@@ -35,8 +36,9 @@ module Raindown
|
|
|
35
36
|
|
|
36
37
|
module_function
|
|
37
38
|
|
|
38
|
-
# Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a
|
|
39
|
-
# A self-closing node is a block of one line
|
|
39
|
+
# Each block outside code and front matter as { open:, close:, last:, writes:, node: }, where close is nil for a
|
|
40
|
+
# bare block, and writes is whether its output's written into the file. A self-closing node is a block of one line
|
|
41
|
+
# with self_closing: true.
|
|
40
42
|
def find(lines, front_matter: {}, bare: false)
|
|
41
43
|
variables = Variables.new(front_matter)
|
|
42
44
|
|
|
@@ -77,13 +79,16 @@ module Raindown
|
|
|
77
79
|
end
|
|
78
80
|
|
|
79
81
|
def rendered_lines(lines, block, markdown:, metadata:, bare:)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
else "#{Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s.chomp}\n".lines
|
|
83
|
-
end
|
|
84
|
-
output = [] if output == ["\n"]
|
|
82
|
+
output = node_markdown(block[:node], markdown:, metadata:, bare:).chomp
|
|
83
|
+
close = marker(lines[block[:close]]) if block[:close]
|
|
85
84
|
|
|
86
|
-
[marker(lines[block[:open]]), *output, *
|
|
85
|
+
[marker(lines[block[:open]]), *("#{output}\n".lines unless output.empty?), *close]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def node_markdown(node, markdown:, metadata:, bare:)
|
|
89
|
+
return Nodes.placeholder(node) if Nodes.html?(node)
|
|
90
|
+
|
|
91
|
+
Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s
|
|
87
92
|
end
|
|
88
93
|
|
|
89
94
|
def marker(line)
|
|
@@ -96,15 +101,17 @@ module Raindown
|
|
|
96
101
|
|
|
97
102
|
span = { name: found[2], class: klass, args: found[3].to_s }
|
|
98
103
|
case pattern
|
|
99
|
-
when :open
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
when :open, :comment_open
|
|
105
|
+
end_tag(lines, index, found[2], comment: pattern == :comment_open)
|
|
106
|
+
.then { |close| { open: index, close:, last: close, writes: pattern == :comment_open, **span } }
|
|
107
|
+
when :bare_open then { **bare_block(lines, index), writes: true, **span }
|
|
108
|
+
else { open: index, close: nil, last: index, writes: false, self_closing: true, **span }
|
|
102
109
|
end
|
|
103
110
|
end
|
|
104
111
|
|
|
105
112
|
# The directive on the line and which kind it is, or nil.
|
|
106
113
|
def directive(line, bare:)
|
|
107
|
-
patterns = { open: OPEN, self_closing: SELF_CLOSING }
|
|
114
|
+
patterns = { open: OPEN, comment_open: COMMENT_OPEN, self_closing: SELF_CLOSING }
|
|
108
115
|
patterns.merge!(bare_open: BARE_OPEN, bare_self_closing: BARE_SELF_CLOSING) if bare
|
|
109
116
|
|
|
110
117
|
patterns.each do |pattern, regexp|
|
|
@@ -123,10 +130,12 @@ module Raindown
|
|
|
123
130
|
{ open:, close: nil, last: }
|
|
124
131
|
end
|
|
125
132
|
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
# The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
|
|
134
|
+
def end_tag(lines, open, name, comment:)
|
|
135
|
+
first, last = comment ? Directives::COMMENT : Directives::DELIMITERS
|
|
136
|
+
tag = /\A {0,3}#{Regexp.escape(first)}\s*:#{Regexp.escape(name)}\s*#{Regexp.escape(last)}[ \t]*$/
|
|
128
137
|
close = (open + 1...lines.length).find { |index| lines[index].match?(tag) }
|
|
129
|
-
raise MissingEndTag, "
|
|
138
|
+
raise MissingEndTag, "#{first} #{name}: #{last} on line #{open + 1} needs #{first} :#{name} #{last}" unless close
|
|
130
139
|
|
|
131
140
|
close
|
|
132
141
|
end
|
|
@@ -6,8 +6,8 @@ require_relative 'front_matter'
|
|
|
6
6
|
require_relative 'node_blocks'
|
|
7
7
|
|
|
8
8
|
module Raindown
|
|
9
|
-
# Writes each node block's Markdown below its directive, replacing what it wrote before
|
|
10
|
-
#
|
|
9
|
+
# Writes each node block's Markdown below its directive, replacing what it wrote before, when it's in comments or
|
|
10
|
+
# bare. Ones in Antlers' delimiters, and self-closing ones, only render, so they're left alone.
|
|
11
11
|
class NodeInjector
|
|
12
12
|
class BlankLine < StandardError; end
|
|
13
13
|
|
|
@@ -25,7 +25,7 @@ module Raindown
|
|
|
25
25
|
def call
|
|
26
26
|
lines = @content.lines
|
|
27
27
|
blocks = NodeBlocks.find(lines, front_matter: FrontMatter.parse(@content).first, bare: @bare)
|
|
28
|
-
.
|
|
28
|
+
.select { |block| block[:writes] }
|
|
29
29
|
return @content if blocks.empty?
|
|
30
30
|
|
|
31
31
|
blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
|
|
@@ -42,19 +42,16 @@ module Raindown
|
|
|
42
42
|
delimited(lines, block, written)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# The directive, what the node wrote and the end tag, each
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
result.push("\n", *written) unless written.empty?
|
|
49
|
-
result.push("\n", line(lines[block[:close]]))
|
|
50
|
-
end
|
|
45
|
+
# The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
|
|
46
|
+
# at its -->, so what's written below it, like a list, is its own block.
|
|
47
|
+
def delimited(lines, block, written) = [line(lines[block[:open]]), *written, line(lines[block[:close]])]
|
|
51
48
|
|
|
52
49
|
# A bare block ends at the next blank line, so what it writes can't have one.
|
|
53
50
|
def bare(written, block)
|
|
54
51
|
return written unless written.any? { |line| line.strip.empty? }
|
|
55
52
|
|
|
56
53
|
name = block[:node].class.name
|
|
57
|
-
raise BlankLine, "#{name} writes blank lines, so it needs
|
|
54
|
+
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
58
55
|
end
|
|
59
56
|
|
|
60
57
|
def line(line)
|
data/lib/raindown/nodes.rb
CHANGED
data/lib/raindown/pages.rb
CHANGED
|
@@ -33,7 +33,7 @@ module Raindown
|
|
|
33
33
|
|
|
34
34
|
attr_reader :root
|
|
35
35
|
|
|
36
|
-
# Render options, like
|
|
36
|
+
# Render options, like highlight_code: false, are for rendering each listed file's content.
|
|
37
37
|
def initialize(metadata:, root:, render_options: {})
|
|
38
38
|
@root = root
|
|
39
39
|
@render_options = render_options
|
|
@@ -4,6 +4,7 @@ require 'antlers'
|
|
|
4
4
|
require_relative '../elements'
|
|
5
5
|
require_relative 'bare_directives'
|
|
6
6
|
require_relative 'code'
|
|
7
|
+
require_relative 'code_blocks'
|
|
7
8
|
require_relative 'front_matter'
|
|
8
9
|
|
|
9
10
|
module Raindown
|
|
@@ -16,11 +17,14 @@ module Raindown
|
|
|
16
17
|
BLOCK_DIRECTIVES = %w[for if list].freeze
|
|
17
18
|
# A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
|
|
18
19
|
BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
|
|
20
|
+
# An endpoint's directive or end tag in a comment, which renders as it would in Antlers' delimiters.
|
|
21
|
+
ENDPOINT_COMMENT = /\A( {0,3})<!--\s*(endpoint:.*?|:endpoint)\s*-->/
|
|
19
22
|
|
|
20
23
|
module_function
|
|
21
24
|
|
|
22
25
|
def render(markdown, variables:, metadata: {}, bare: false)
|
|
23
26
|
markdown = BareDirectives.expand(markdown) if bare
|
|
27
|
+
markdown = delimited_endpoints(markdown) if markdown.include?('<!--')
|
|
24
28
|
return markdown unless markdown.include?('{')
|
|
25
29
|
|
|
26
30
|
markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
|
|
@@ -28,6 +32,20 @@ module Raindown
|
|
|
28
32
|
"#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# The endpoints in comments in Antlers' delimiters, which the template pass renders, leaving code as it is.
|
|
36
|
+
def delimited_endpoints(markdown)
|
|
37
|
+
lines = markdown.lines
|
|
38
|
+
index = 0
|
|
39
|
+
|
|
40
|
+
while index < lines.length
|
|
41
|
+
code = CodeBlocks.at(lines, index)
|
|
42
|
+
lines[index] = lines[index].sub(ENDPOINT_COMMENT) { "#{::Regexp.last_match(1)}<{ #{::Regexp.last_match(2)} }>" } unless code
|
|
43
|
+
index = code ? code[:close] + 1 : index + 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
lines.join
|
|
47
|
+
end
|
|
48
|
+
|
|
31
49
|
def render_body(body, variables, metadata)
|
|
32
50
|
template = body.gsub(BLOCK_LINE, '\1')
|
|
33
51
|
return body unless template.include?('{')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'node'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# The base of a project's stats: one big number in block digits, above its label, written into the Markdown as code,
|
|
7
|
+
# and shown on the terminal UI's Dashboard as a card, like a chart:
|
|
8
|
+
# ▀█ ▀▀█
|
|
9
|
+
# █ █▀▀
|
|
10
|
+
# ▀▀▀ ▀▀▀
|
|
11
|
+
# open issues
|
|
12
|
+
# A stat's a node in .raindown that says its value, and its label:
|
|
13
|
+
# class Issues < Raindown::Stat
|
|
14
|
+
# def value(metadata:) = Issue.open.count
|
|
15
|
+
# def label = 'open issues'
|
|
16
|
+
# end
|
|
17
|
+
class Stat < Node
|
|
18
|
+
# Each character's three lines in block digits, which end on the top half of the third, where a point sits too.
|
|
19
|
+
# Others are left as they are, on the middle line.
|
|
20
|
+
DIGITS = { '0' => ['█▀█', '█ █', '▀▀▀'], '1' => ['▀█ ', ' █ ', '▀▀▀'], '2' => ['▀▀█', '█▀▀', '▀▀▀'],
|
|
21
|
+
'3' => ['▀▀█', ' ▀█', '▀▀▀'], '4' => ['█ █', '▀▀█', ' ▀'], '5' => ['█▀▀', '▀▀█', '▀▀▀'],
|
|
22
|
+
'6' => ['█▀▀', '█▀█', '▀▀▀'], '7' => ['▀▀█', ' █', ' ▀'], '8' => ['█▀█', '█▀█', '▀▀▀'],
|
|
23
|
+
'9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
|
|
24
|
+
'.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
|
|
25
|
+
|
|
26
|
+
def replace(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
|
|
27
|
+
|
|
28
|
+
# The number in block digits, then the label, as a card on the Dashboard shows them.
|
|
29
|
+
def card_lines(metadata: NoMetadata)
|
|
30
|
+
characters = number(value(metadata:)).chars.map { digit(it) }
|
|
31
|
+
[*Array.new(3) { |line| characters.map { it[line] }.join(' ').rstrip }, label]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def value(metadata: NoMetadata) = raise(NotImplementedError, "#{self.class} needs a value")
|
|
35
|
+
|
|
36
|
+
def label = ''
|
|
37
|
+
|
|
38
|
+
# How the value's written, which a stat can change, like with a sign.
|
|
39
|
+
def number(value) = value.to_s
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# The character's three lines in block digits, or itself on the middle line.
|
|
44
|
+
def digit(character) = DIGITS.fetch(character) { [' ', character, ' '] }
|
|
45
|
+
|
|
46
|
+
# The project's folder, from the page being rendered, or else the current one.
|
|
47
|
+
def root(metadata)
|
|
48
|
+
return metadata.root if metadata.respond_to?(:root)
|
|
49
|
+
|
|
50
|
+
Dir.pwd
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tracing'
|
|
4
|
+
require_relative '../renderers/renderer'
|
|
5
|
+
require_relative '../search_box'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
module TUI
|
|
9
|
+
# The blueprints from Claude Code's replies, right of the files, below the keywords they're filtered by, in the border,
|
|
10
|
+
# which opens the filter, like Teleport, over it, listing those that match: when it was saved and its kind, and which one of how
|
|
11
|
+
# many, then the blueprint, cut to fit the pane:
|
|
12
|
+
# ─────┌─────────────────── Filter ──────────────────┐───
|
|
13
|
+
# │ / weather▏ │
|
|
14
|
+
# ╭─│ Weather Report Light rain 2026-10-30 03:14 │
|
|
15
|
+
# │ └─────────────────────────────────────────────┘
|
|
16
|
+
# Each is traced to its code: it's cornflower blue, the selected box is highlighted, and the code it names is below:
|
|
17
|
+
# ╭──────────────╮ ╭──────────────╮
|
|
18
|
+
# │ Watcher.listen│ │ Workers │
|
|
19
|
+
# ╰──────────────╯ ╰──────────────╯
|
|
20
|
+
# ╭─ lib/raindown/watcher/changes.rb:17 · Watcher#listen ─────────────╮
|
|
21
|
+
# │ def self.listen(directory, &) │
|
|
22
|
+
# ╰────────────────────────────────────────────────────────────────────╯
|
|
23
|
+
class BlueprintsRenderer < Renderer
|
|
24
|
+
include Tracing
|
|
25
|
+
|
|
26
|
+
BUTTONS = [['Tab', 'Pane', :tab], ['f', 'Filter', :filter], ['↑↓←→', 'Box', nil], ['Space', 'Code', :toggle],
|
|
27
|
+
['Enter', 'More', :expand], ['e', 'Edit', :edit], ['/', 'Search', :search], ['q', 'Quit', :quit]].freeze
|
|
28
|
+
|
|
29
|
+
def buttons = BUTTONS
|
|
30
|
+
|
|
31
|
+
def page = 'Blueprints'
|
|
32
|
+
|
|
33
|
+
# The keywords the blueprints are filtered by, in the middle of the line above the panes, which opens the filter,
|
|
34
|
+
# whose box's top is drawn over it while it's open.
|
|
35
|
+
def spacer
|
|
36
|
+
return title_line unless view.typing?
|
|
37
|
+
|
|
38
|
+
with_filter.first
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The blueprint, with the rest of the filter over it while it's open.
|
|
42
|
+
def lines
|
|
43
|
+
return shown_lines unless view.typing?
|
|
44
|
+
|
|
45
|
+
with_filter.drop(1)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def view = navigator.view
|
|
51
|
+
|
|
52
|
+
def title_line
|
|
53
|
+
title = " #{filtered_by} ▾ "
|
|
54
|
+
title_width = display_width(title)
|
|
55
|
+
left = [(width - title_width) / 2, 0].max
|
|
56
|
+
padded([segment('', :blank, width: left), segment(title, :divider, width: title_width, event: filter_click),
|
|
57
|
+
segment('', :blank, width: [width - left - title_width, 0].max)])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The title line and the blueprint, with the filter drawn over them from the title line, once a frame.
|
|
61
|
+
def with_filter = @with_filter ||= SearchBox.new(view, width:, title: 'Filter', pane:, top: 0).apply([title_line, *shown_lines])
|
|
62
|
+
|
|
63
|
+
def shown_lines
|
|
64
|
+
return traced_lines if view.selected
|
|
65
|
+
return blank(" No blueprints match #{filtered_by}.") unless view.query.to_s.empty?
|
|
66
|
+
|
|
67
|
+
blank(" No blueprints yet. #{saving}")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def filtered_by
|
|
71
|
+
return 'All' if view.query.to_s.strip.empty?
|
|
72
|
+
|
|
73
|
+
"“#{view.query.strip}”"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The click on the title, which keeps the blueprint shown.
|
|
77
|
+
def filter_click = Click.new(pane:, index: view.cursor, action: :filter)
|
|
78
|
+
|
|
79
|
+
# When it was saved and its kind, then which one it is of how many, at the right.
|
|
80
|
+
def about(blueprint)
|
|
81
|
+
saved = " #{blueprint.where}"
|
|
82
|
+
place = "#{view.cursor + 1} of #{view.blueprints.length} "
|
|
83
|
+
[segment(saved, :body, width: [width - display_width(place), 0].max), segment(place, :body, width: display_width(place))]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def saving = "Claude Code's are saved to #{Blueprints::FILE} by `raindown blueprints`."
|
|
87
|
+
|
|
88
|
+
def blank(message)
|
|
89
|
+
[[segment(message, :info)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|