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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/lib/nodes/endpoint_node.rb +3 -2
  3. data/lib/raindown/actions.rb +5 -3
  4. data/lib/raindown/bare_directives.rb +14 -27
  5. data/lib/raindown/blueprints.rb +255 -0
  6. data/lib/raindown/chart/chart.rb +12 -1
  7. data/lib/raindown/cli.rb +39 -54
  8. data/lib/raindown/config.rb +2 -3
  9. data/lib/raindown/directives.rb +30 -13
  10. data/lib/raindown/document.rb +22 -20
  11. data/lib/raindown/endpoint/curl.rb +11 -7
  12. data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
  13. data/lib/raindown/node_blocks.rb +31 -22
  14. data/lib/raindown/node_injector.rb +7 -10
  15. data/lib/raindown/nodes.rb +1 -0
  16. data/lib/raindown/pages.rb +1 -1
  17. data/lib/raindown/pre_renderer.rb +18 -0
  18. data/lib/raindown/stat.rb +53 -0
  19. data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
  20. data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
  21. data/lib/raindown/tui/blueprints/canvas.rb +58 -0
  22. data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
  23. data/lib/raindown/tui/blueprints/filter.rb +77 -0
  24. data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
  25. data/lib/raindown/tui/blueprints/tracing.rb +129 -0
  26. data/lib/raindown/tui/code_box.rb +143 -0
  27. data/lib/raindown/tui/endpoints.rb +2 -2
  28. data/lib/raindown/tui/events.rb +18 -1
  29. data/lib/raindown/tui/feelings/batch.rb +47 -0
  30. data/lib/raindown/tui/feelings/bins.rb +67 -0
  31. data/lib/raindown/tui/feelings/celebration/celebration.rb +28 -0
  32. data/lib/raindown/tui/feelings/celebration/fireworks.rb +75 -0
  33. data/lib/raindown/tui/feelings/celebration/rain.rb +66 -0
  34. data/lib/raindown/tui/feelings/celebration/scene.rb +40 -0
  35. data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
  36. data/lib/raindown/tui/feelings/code_methods.rb +134 -0
  37. data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
  38. data/lib/raindown/tui/feelings/feelings_log.rb +136 -0
  39. data/lib/raindown/tui/feelings/feelings_renderer.rb +183 -0
  40. data/lib/raindown/tui/feelings/feelings_view.rb +157 -0
  41. data/lib/raindown/tui/feelings/magnifier.rb +68 -0
  42. data/lib/raindown/tui/feelings/motion.rb +93 -0
  43. data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
  44. data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
  45. data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
  46. data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
  47. data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
  48. data/lib/raindown/tui/feelings/weekly_batch.rb +51 -0
  49. data/lib/raindown/tui/file_tree.rb +6 -5
  50. data/lib/raindown/tui/highlighter.rb +45 -0
  51. data/lib/raindown/tui/layout.rb +32 -18
  52. data/lib/raindown/tui/navigator/commands.rb +46 -2
  53. data/lib/raindown/tui/navigator/mouse.rb +43 -0
  54. data/lib/raindown/tui/navigator/navigator.rb +54 -25
  55. data/lib/raindown/tui/navigator/teleport.rb +128 -0
  56. data/lib/raindown/tui/navigator/views.rb +5 -3
  57. data/lib/raindown/tui/{workers/project.rb → project.rb} +38 -27
  58. data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +2 -2
  59. data/lib/raindown/tui/renderers/dashboard_renderer.rb +4 -4
  60. data/lib/raindown/tui/renderers/endpoint_renderer.rb +14 -10
  61. data/lib/raindown/tui/renderers/outline_renderer.rb +4 -4
  62. data/lib/raindown/tui/renderers/renderer.rb +4 -0
  63. data/lib/raindown/tui/row.rb +4 -2
  64. data/lib/raindown/tui/rows.rb +2 -1
  65. data/lib/raindown/tui/saves.rb +22 -0
  66. data/lib/raindown/tui/search_box.rb +86 -0
  67. data/lib/raindown/tui/terminal/input.rb +95 -7
  68. data/lib/raindown/tui/terminal/screen.rb +37 -29
  69. data/lib/raindown/tui/terminal/styles.rb +111 -26
  70. data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
  71. data/lib/raindown/tui/text.rb +62 -12
  72. data/lib/raindown/tui/title.rb +1 -1
  73. data/lib/raindown/tui/views/dashboard_view.rb +7 -5
  74. data/lib/raindown/tui/views/endpoint_view.rb +4 -2
  75. data/lib/raindown/tui/views/list_view.rb +2 -0
  76. data/lib/raindown/tui/views/outline_view.rb +16 -6
  77. data/lib/raindown/tui.rb +19 -7
  78. data/lib/raindown/watcher/changes.rb +33 -0
  79. data/lib/raindown/watcher/file_watcher.rb +58 -0
  80. data/lib/raindown/watcher/project.rb +47 -0
  81. data/lib/raindown/watcher/supervisor.rb +111 -0
  82. data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
  83. data/lib/raindown/workers/workers.rb +63 -0
  84. data/lib/raindown.rb +4 -4
  85. data/lib/version.rb +1 -1
  86. metadata +60 -9
  87. data/lib/raindown/project_worker.rb +0 -48
  88. data/lib/raindown/supervisor.rb +0 -160
  89. data/lib/raindown/tui/workers/workers.rb +0 -53
  90. data/lib/raindown/watcher.rb +0 -57
@@ -3,27 +3,44 @@
3
3
  require 'cgi/escape'
4
4
 
5
5
  module Raindown
6
- # Content directives, like a node's directive and end tag or an endpoint's, are kept in the HTML:
7
- # in bold by default, or as HTML comments with the hide_directives setting.
8
- # Until then each is a comment marker, which ends the Markdown block before it and starts nothing itself,
9
- # so a node's list stays its own list.
10
- # Template directives, like for: and if:, are always invisible, since they often sit inside tables and lists.
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'", or a whole line like <{ :files }>, as a marker.
17
- def marker(directive)
18
- text = directive.strip.delete_prefix('<{').delete_suffix('}>').strip.gsub('--', '- -')
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, hide: false)
23
- html.gsub(MARKER) do
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
@@ -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. A self-closing directive like <{ :toc }> only renders, so it never writes.
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
- ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
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 ||= nodes(:heading).map do |node|
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: !span[:self_closing])
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 (args = endpoint_args(@lines[index]))
102
- directives << endpoint(index, args)
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 = BareDirectives.wrap(line) || line if @bare
114
- line[EndpointRunner::DIRECTIVE, 1]&.strip
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 if @lines[open].match?(EndpointRunner::DIRECTIVE)
125
+ last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
124
126
 
125
- directive('endpoint', args, open..last, output:, writes: true)
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?(ENDPOINT_END_TAG)
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 = args.find { it.match?(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 block below it,
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
- # <{ endpoint: :x }>
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
- # <{ :endpoint }>
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
- line = BareDirectives.wrap(line) || line if @bare
59
- line[DIRECTIVE, 1]&.split
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.
@@ -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
- # With delimiters the block ends with the node's end tag, and without them at the next blank line:
14
- # <{ Changelog: limit=3 }> Changelog: limit=3
15
- # - **1.2.0** Bare directives
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
- # <{ :Changelog }>
19
- # A self-closing <{ :Changelog }> or bare :Changelog that doesn't end a block renders the node without writing it.
20
- # toc: and files: are Raindown's own nodes. A name only starts a block when it's a loaded node,
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 bare block.
39
- # A self-closing node is a block of one line with self_closing: true.
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
- node = block[:node]
81
- output = if Nodes.html?(node) then ["#{Nodes.placeholder(node)}\n"]
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, *(marker(lines[block[:close]]) if block[:close])]
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 then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, **span } }
100
- when :bare_open then { **bare_block(lines, index), **span }
101
- else { open: index, close: nil, last: index, self_closing: true, **span }
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
- def end_tag(lines, open, name)
127
- tag = /\A {0,3}<\{\s*:#{Regexp.escape(name)}\s*\}>[ \t]*$/
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, "<{ #{name}: }> on line #{open + 1} needs <{ :#{name} }>" unless close
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
- # Self-closing nodes only render, so they're left alone.
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
- .reject { |block| block[:self_closing] }
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 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
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 <{ #{name}: }> and <{ :#{name} }> around it"
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)
@@ -3,6 +3,7 @@
3
3
  require_relative 'chart/chart'
4
4
  require_relative 'files'
5
5
  require_relative 'node'
6
+ require_relative 'stat'
6
7
  require_relative 'toc'
7
8
 
8
9
  module Raindown
@@ -33,7 +33,7 @@ module Raindown
33
33
 
34
34
  attr_reader :root
35
35
 
36
- # Render options, like hide_directives: true, are for rendering each listed file's content.
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