raindown 0.12.0 → 0.13.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ea9df31d83ebabf0649ad353b75e2f2546aeb10c31aa97038bdd85f5e45fd2c
4
- data.tar.gz: cc736f0d57e576c476629bfa7fe37405139cb1b880e86908e71e7e8d8b22926c
3
+ metadata.gz: f795c35070ab8ae4f6b40e5a32595ab9ba6099b99f42aada8d0faafe2f56121d
4
+ data.tar.gz: a01426b3aab916e21b0e68f66fddd7f6b55d7e9c34ceb9665118a60b3ceae4b5
5
5
  SHA512:
6
- metadata.gz: 851438f9079a944aca1dc2f97d0c5081822d0ce31df12e536ca10dce8d471233ee0879fc91b7004fcf7949159554dec7cd0d1e4aa3d50b8cee4009dbd3180766
7
- data.tar.gz: 27863d35bb59084303144bdc0b1ea3852028a55c854e8142456c3e1d8f9ee495a3d5489dc00c91ce474bcd9eda833deb42d4f3696118bb24c25e913dabb54372
6
+ metadata.gz: 052231532f48cc3a575388ebe95fa62b70bc3eba2121742142071c2ab256ced437de1f4c80f2e439599d579c2b5eea2d793b7cb05439601523bc72ff9c252dd6
7
+ data.tar.gz: 98ff7b646f8e380817bbf69efc06a0bf67b4945a233349c9702d3140458bb27e387ecd010498c80dbfa4a9d2e64872b4f7af75bcd12b28b992e8b82ac66f56c9
@@ -13,17 +13,17 @@ module Raindown
13
13
  #
14
14
  # A bare schema: shows in bold, with its fields in a code block below it.
15
15
  #
16
- # Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
17
- # needs more than that, write it in comments with an end tag instead:
16
+ # Bare directives have no end tag. A bare endpoint holds the lines straight below it, its curl snippet, the comment
17
+ # with its response's status, time and date, and its response, up to the next blank line, like a bare node block, so
18
+ # where it needs blank lines, write it in comments with an end tag instead:
18
19
  # <!-- endpoint: -->
19
20
  # ...
20
21
  # <!-- :endpoint -->
21
22
  module BareDirectives
22
23
  EXTENSIONS = %w[.rd .raindown].freeze
23
- # The block directives, and the method that finds the line their end tag goes after.
24
- BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint }.freeze
25
- # Four spaces of indent would make the line a Markdown code block.
26
- LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
24
+ # A bare endpoint, the one bare directive with a block. Four spaces of indent would make the line a Markdown code
25
+ # block.
26
+ LINE = /\A( {0,3})(endpoint:(?:[ \t]+\S.*?)?)[ \t]*$/
27
27
 
28
28
  module_function
29
29
 
@@ -75,7 +75,7 @@ module Raindown
75
75
  return index unless match
76
76
 
77
77
  lines[index] = "#{Directives.marker(match[2], shown: true)}\n#{wrap(lines[index])}"
78
- close_block(lines, index, indent: match[1], keyword: match[3])
78
+ close_block(lines, index, indent: match[1])
79
79
  index
80
80
  end
81
81
 
@@ -88,16 +88,10 @@ module Raindown
88
88
  end
89
89
 
90
90
  # The end tag after the block, then the end tag in bold, as a bare directive's shown.
91
- def close_block(lines, index, indent:, keyword:)
92
- last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
91
+ def close_block(lines, index, indent:)
92
+ last = CodeBlocks.last_before_blank(lines, index)
93
93
  lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
94
- name = keyword.delete_suffix(':')
95
- lines.insert(last + 1, "#{indent}<{ :#{name} }>\n#{Directives.marker(":#{name}", shown: true)}\n")
96
- end
97
-
98
- # The last line of the curl snippet and response below an endpoint.
99
- def after_endpoint(lines, index)
100
- CodeBlocks.endpoint(lines, index).last&.fetch(:close)
94
+ lines.insert(last + 1, "#{indent}<{ :endpoint }>\n#{Directives.marker(':endpoint', shown: true)}\n")
101
95
  end
102
96
  end
103
97
  end
@@ -5,6 +5,9 @@ module Raindown
5
5
  module CodeBlocks
6
6
  FENCE = /\A {0,3}(`{3,}|~{3,})[ \t]*(\S*)/
7
7
  SNIPPET_LANGUAGES = %w[sh bash shell zsh console curl].freeze
8
+ # The comment Raindown writes between an endpoint's snippet and its response, with the response's status, how long
9
+ # it took and the date it was fetched, like <!-- response 200 · 142 ms · 2026-10-14 -->.
10
+ RESPONSE_COMMENT = /\A {0,3}<!-- response [^\n]*? -->[ \t]*$/
8
11
  # The line closing a fence, like ``` or ~~~~, compiled once per fence.
9
12
  CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
10
13
 
@@ -19,27 +22,32 @@ module Raindown
19
22
  { open: index, close:, language:, body: lines[index + 1...close].join } if close
20
23
  end
21
24
 
22
- # An endpoint's curl snippet and its response, if it has one yet.
23
- # A second snippet straight after is another example rather than the response.
24
- def endpoint(lines, index)
25
- snippet, response = following(lines, index, limit: 2)
26
- response && snippet?(response) ? [snippet] : [snippet, response].compact
25
+ # An endpoint's curl snippet and its response, if it has one yet, with the line of the response comment between them
26
+ # as the response's :comment, if there is one. A second snippet straight after is another example rather than the
27
+ # response. A bare endpoint's are in the lines straight below it, up to the next blank line.
28
+ def endpoint(lines, index, bare: false)
29
+ lines = lines[..last_before_blank(lines, index - 1)] if bare
30
+ snippet = at(lines, skip_blank_lines(lines, index))
31
+ return [] unless snippet
32
+
33
+ index = skip_blank_lines(lines, snippet[:close] + 1)
34
+ comment = index if lines[index]&.match?(RESPONSE_COMMENT)
35
+ response = at(lines, comment ? skip_blank_lines(lines, comment + 1) : index)
36
+ return [snippet] if response.nil? || snippet?(response)
37
+
38
+ [snippet, response.merge(comment:)]
27
39
  end
28
40
 
29
41
  def snippet?(block)
30
42
  SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
31
43
  end
32
44
 
33
- # Up to limit blocks from index on, with only blank lines before and between them.
34
- def following(lines, index, limit:)
35
- blocks = []
36
-
37
- while blocks.length < limit && (block = at(lines, skip_blank_lines(lines, index)))
38
- blocks << block
39
- index = block[:close] + 1
40
- end
41
-
42
- blocks
45
+ # The last of the lines straight below open, up to the next blank line, with fenced code kept whole, or open when
46
+ # there are none, like a bare block's.
47
+ def last_before_blank(lines, open)
48
+ last = open
49
+ last = at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
50
+ last
43
51
  end
44
52
 
45
53
  def skip_blank_lines(lines, index)
@@ -16,6 +16,9 @@ module Raindown
16
16
  class Document
17
17
  Heading = Data.define(:level, :text, :line)
18
18
 
19
+ # Its Markdown, and whether it allows bare directives.
20
+ attr_reader :markdown, :bare
21
+
19
22
  # A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
20
23
  # It spans line to end_line, its end tag, or the last line of a bare block.
21
24
  # Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
@@ -116,21 +119,18 @@ module Raindown
116
119
  [bare.strip, true] if bare
117
120
  end
118
121
 
119
- # The response and the date it was fetched are the output, while the curl snippet above them is the author's.
122
+ # The response and the comment above it are the output, while the curl snippet above them is the author's.
120
123
  def endpoint(open, args, writes)
121
- curl, response = CodeBlocks.endpoint(@lines, open + 1)
122
- output = response && (response[:open]..(fetched?(response[:close] + 1) ? response[:close] + 1 : response[:close]))
124
+ bare = Directives.bare?(@lines[open])
125
+ curl, response = CodeBlocks.endpoint(@lines, open + 1, bare:)
126
+ output = response && ((response[:comment] || response[:open])..response[:close])
123
127
  last = output&.end || curl&.fetch(:close) || open
124
- # A bare endpoint, which isn't delimited, ends after its blocks.
125
- last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
128
+ # A bare endpoint ends at the next blank line, and one in a comment at its end tag.
129
+ last = bare ? CodeBlocks.last_before_blank(@lines, open) : end_tag(after: last) || last
126
130
 
127
131
  directive('endpoint', args, open..last, output:, writes:)
128
132
  end
129
133
 
130
- def fetched?(index)
131
- @lines[index]&.match?(EndpointRunner::FETCHED)
132
- end
133
-
134
134
  # The end tag after the endpoint's blocks, with only blank lines between.
135
135
  def end_tag(after:)
136
136
  index = CodeBlocks.skip_blank_lines(@lines, after + 1)
@@ -10,17 +10,17 @@ require_relative 'endpoint_variables'
10
10
 
11
11
  module Raindown
12
12
  # Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
13
- # block below it, followed by the date it was fetched, its status and how long it took.
13
+ # block below it, after a comment of its status, how long it took and the date it was fetched.
14
14
  # Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
15
15
  # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
16
16
  # <!-- endpoint: -->
17
17
  # ```sh
18
18
  # curl https://api.example.com
19
19
  # ```
20
+ # <!-- response 200 · 142 ms · 2026-10-14 -->
20
21
  # ```json
21
22
  # { "id": 1 }
22
23
  # ```
23
- # <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
24
24
  # <!-- :endpoint -->
25
25
  # Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
26
26
  # response, like {login.token}. With status: and schema:, each response in the file is checked, and failures are kept.
@@ -32,9 +32,7 @@ module Raindown
32
32
  DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
33
33
  COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
34
34
  END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
35
- # The date a response was fetched, on the line after it.
36
- FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
37
- FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
35
+ RESPONSE_STATUS = /<!-- response (\d{3}) /
38
36
 
39
37
  attr_reader :failures
40
38
 
@@ -83,19 +81,19 @@ module Raindown
83
81
 
84
82
  # Returns the index of the last line it handled.
85
83
  def fill(index, args, directive:)
86
- curl, output = CodeBlocks.endpoint(@lines, index)
84
+ curl, output = CodeBlocks.endpoint(@lines, index, bare: !@lines[directive].match?(COMMENT))
87
85
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
88
86
  return write_output(curl:, output:, args:, directive:) if run?(output, directive)
89
87
  return curl[:close] unless output
90
88
 
91
89
  @variables.remember(args.name, output[:body])
92
- check(args, directive:, body: output[:body], status: fetched_status(output))
90
+ check(args, directive:, body: output[:body], status: response_status(output))
93
91
  curl[:close]
94
92
  end
95
93
 
96
- # The status in the fetched comment after a response, which responses fetched before statuses were kept don't have.
97
- def fetched_status(output)
98
- @lines[output[:close] + 1]&.[](FETCHED_STATUS, 1)&.to_i
94
+ # The status in the comment above a response, which a comment written by hand may not have.
95
+ def response_status(output)
96
+ @lines[output[:comment]][RESPONSE_STATUS, 1]&.to_i if output[:comment]
99
97
  end
100
98
 
101
99
  # Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
@@ -113,14 +111,15 @@ module Raindown
113
111
  @execute && (output.nil? || output[:body].strip.empty?)
114
112
  end
115
113
 
116
- # Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
114
+ # Replaces everything from just after the curl block to the end of the output block, if there is one, like its
115
+ # comment.
117
116
  # Secrets that a response echoes are written as their placeholders.
118
117
  def write_output(curl:, output:, args:, directive:)
119
118
  response = Curl.run(curl[:body], @variables)
120
119
  body = @variables.redact(response.body)
121
120
  @variables.remember(args.name, body)
122
121
  check(args, directive:, body:, status: response.status)
123
- replace_output(curl:, output:, block: [*output_block(body, response.language), fetched(response)])
122
+ replace_output(curl:, output:, block: [response_comment(response), *output_block(body, response.language)])
124
123
  rescue Curl::Error => e
125
124
  message = @variables.redact(e.message)
126
125
  # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
@@ -132,21 +131,13 @@ module Raindown
132
131
 
133
132
  # Returns the index of the block's last line.
134
133
  def replace_output(curl:, output:, block:)
135
- block.unshift("\n") unless output
136
- start = output ? output[:open] : curl[:close] + 1
137
-
138
- @lines[start, output ? replaced_length(output) : 0] = block
134
+ start = curl[:close] + 1
135
+ @lines[start, output ? output[:close] - start + 1 : 0] = block
139
136
  start + block.length - 1
140
137
  end
141
138
 
142
- # The output block, and the line after it when that's the date it was last fetched.
143
- def replaced_length(output)
144
- length = output[:close] - output[:open] + 1
145
- @lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
146
- end
147
-
148
- def fetched(response)
149
- "<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
139
+ def response_comment(response)
140
+ "<!-- response #{response.status} · #{response.milliseconds} ms · #{Date.today.iso8601} -->\n"
150
141
  end
151
142
 
152
143
  # The fence is longer than any run of backticks in the body, so the body can't close it.
@@ -127,12 +127,7 @@ module Raindown
127
127
  end
128
128
 
129
129
  # 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
130
+ def bare_block(lines, open) = { open:, close: nil, last: CodeBlocks.last_before_blank(lines, open) }
136
131
 
137
132
  # The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
138
133
  def end_tag(lines, open, name, comment:)
@@ -35,7 +35,8 @@ module Raindown
35
35
  private
36
36
 
37
37
  def block_lines(lines, block)
38
- markdown = Nodes.call(block[:node], :replace, document: @content, metadata: @metadata, bare: @bare).to_s.chomp
38
+ context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
39
+ markdown = Nodes.call(block[:node], :replace, **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
@@ -6,6 +6,7 @@ require_relative 'js/js_nodes'
6
6
  require_relative 'markdown_nodes'
7
7
  require_relative 'node'
8
8
  require_relative 'stat'
9
+ require_relative 'table/table'
9
10
  require_relative 'toc'
10
11
 
11
12
  module Raindown
@@ -13,11 +14,12 @@ module Raindown
13
14
  # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
14
15
  # Components, if any.
15
16
  # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
16
- # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
17
+ # document: (the Markdown being rendered), metadata:, bare: and written: (the lines it wrote last time), and gets the
18
+ # ones it asks for.
17
19
  module Nodes
18
20
  FOLDER = '.raindown'
19
- # Lowercase directives for Raindown's own nodes. A project's own TOC, Files or Chart node replaces Raindown's.
20
- BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart' }.freeze
21
+ # Lowercase directives for Raindown's own nodes. A project's own TOC, Files, Chart or Table node replaces Raindown's.
22
+ BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart', 'table' => 'Table' }.freeze
21
23
  # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
22
24
  PLACEHOLDER = /<!-- raindown:node (\d+) -->/
23
25
 
@@ -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
- def initialize(metadata:, root:, render_options: {})
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
68
  @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
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.
@@ -81,16 +83,16 @@ module Raindown
81
83
  private
82
84
 
83
85
  def metadata
84
- process_files unless @metadata
86
+ load_metadata unless @metadata
85
87
  @metadata
86
88
  end
87
89
 
88
90
  def tags
89
- process_files unless @tags
91
+ load_metadata unless @tags
90
92
  @tags
91
93
  end
92
94
 
93
- def process_files
95
+ def load_metadata
94
96
  @metadata = {}
95
97
  @tags = {}
96
98
  @file_paths.each { |file_path| process(file_path:) }
@@ -0,0 +1,33 @@
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
+ header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
15
+ [header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
16
+ end
17
+
18
+ # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
19
+ def parse(lines)
20
+ return [] unless lines[1]&.strip&.match?(SEPARATOR)
21
+
22
+ [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
23
+ end
24
+
25
+ def cells(line)
26
+ line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
27
+ end
28
+
29
+ def escape(cell)
30
+ cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,73 @@
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 replace(written: [], metadata: NoMetadata)
31
+ path = File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
32
+ return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
33
+ return "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
34
+
35
+ synced(path, written.map(&:chomp))
36
+ end
37
+
38
+ private
39
+
40
+ def synced(path, written)
41
+ file = read_rows(path)
42
+ synced_hash = written.first&.[](COMMENT, 1)
43
+ table = MarkdownTable.parse(written.drop(1))
44
+ return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
45
+ return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
46
+
47
+ warn "raindown: #{@data} and its table both changed, so neither is written until they match"
48
+ ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
49
+ end
50
+
51
+ def markdown(rows)
52
+ return '' if rows.empty?
53
+
54
+ count = rows.length - 1
55
+ "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
56
+ end
57
+
58
+ def read_rows(path)
59
+ rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
60
+ width = rows.map(&:length).max.to_i
61
+ rows.map { |row| Array.new(width) { row[it].to_s.strip } }
62
+ end
63
+
64
+ def write_rows(path, rows)
65
+ File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
66
+ rows
67
+ end
68
+
69
+ def hash_rows(rows)
70
+ Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Raindown
6
+ module TUI
7
+ # Raindown's logo, raindown.dev's rain cloud, in the middle of the screen while the terminal UI loads: bumps and a
8
+ # rounded base of blocks, each its own blue, lighter at the top, with a few corrupted, and rain falling from under it
9
+ # to the bottom of the screen, fading and thinning out as it goes:
10
+ # ███████████████▞███▄██████████
11
+ # ██████████████████████████
12
+ # ╎╎,┊ ' , ┊ ┊ ' ┊' ╎,
13
+ # ╎ │ ││ , │ │ , ' ' ┊,
14
+ # It's drawn like the site's RainCloud, with the same seed, so it's the same cloud, in the nearest of the 256 colours
15
+ # most terminals have.
16
+ class Cloud
17
+ WIDTH = 50
18
+ ROWS = 19
19
+ SEED = 5
20
+ # Bumps as [column, row, radius], and the base under them, with its corners rounded. Rows are stretched, as
21
+ # characters are taller than they're wide.
22
+ BUMPS = [[25, 8, 11], [13, 12, 7.5], [37, 11, 8.5]].freeze
23
+ BASE_ROWS = 12..18
24
+ BASE_COLUMNS = 7..43
25
+ CORNER = 5
26
+ ROW_STRETCH = 1.7
27
+ SHADES = %w[█ ▓].freeze
28
+ CORRUPT = %w[▀ ▄ ▚ ▞ ▌ ▐].freeze
29
+ CORRUPTED = 0.015
30
+ RAIN = %w[│ ╎ ┊ ' ,].freeze
31
+ # The rain's blue, as hue, saturation and lightness, which fades in steps as it falls, and thins out.
32
+ RAIN_BLUE = [212, 100, 70].freeze
33
+ RAIN_STEPS = 8
34
+ FADE = 0.11
35
+ # The levels of red, green and blue in the 256 colours' cube, from 16 to 231, leaving out the greys after it, so
36
+ # the rain stays blue as it fades.
37
+ CUBE = [0, 95, 135, 175, 215, 255].freeze
38
+ XTERM = (16..231).to_h { [it, CUBE.values_at((it - 16) / 36, ((it - 16) / 6) % 6, (it - 16) % 6)] }.freeze
39
+
40
+ class << self
41
+ # Each cell inside the cloud, by its row and column, as its block and its blue's style.
42
+ def cells = drawing.first
43
+
44
+ # The columns it rains from.
45
+ def rainy = drawing.last
46
+
47
+ # The rain's style at each step, from just under the cloud to the bottom of the screen.
48
+ def rain_styles
49
+ @rain_styles ||= Array.new(RAIN_STEPS) { |step| style(rgb(*RAIN_BLUE).map { (it * (1 - (step * FADE))).round }) }
50
+ end
51
+
52
+ # Every blue it's drawn in, for the screen's colours.
53
+ def styles = (cells.values.map(&:last) + rain_styles).uniq
54
+
55
+ private
56
+
57
+ # Rolled in the site's order, a cell at a time, then the rainy columns.
58
+ def drawing
59
+ @drawing ||= begin
60
+ random = Random.new(SEED)
61
+ cells = ROWS.times.to_a.product(WIDTH.times.to_a).select { inside?(*it) }.to_h { [it, cell(it.first, random)] }
62
+ [cells, ((BASE_COLUMNS.begin + 1)..(BASE_COLUMNS.end - 1)).select { random.rand < 0.6 }]
63
+ end
64
+ end
65
+
66
+ # A corrupted block now and then, and otherwise a shade, denser towards the bottom, with some noise.
67
+ def cell(row, random)
68
+ return [CORRUPT.sample(random:), blue(row, random)] if random.rand < CORRUPTED
69
+
70
+ depth = (row / ROWS.to_f) + random.rand(-0.25..0.25)
71
+ [SHADES[(1 - (depth * 2)).round.clamp(0, 1)], blue(row, random)]
72
+ end
73
+
74
+ # A random blue, lighter at the top of the cloud and darker at the bottom.
75
+ def blue(row, random)
76
+ light = (82 - (row * 2.4) + random.rand(-12..12)).round.clamp(25, 90)
77
+ style(rgb(random.rand(198..235), random.rand(65..100), light))
78
+ end
79
+
80
+ # The style of the nearest of the 256 colours.
81
+ def style(rgb) = :"cloud_#{XTERM.min_by { |_, color| color.zip(rgb).sum { (it[0] - it[1])**2 } }.first}"
82
+
83
+ def rgb(hue, saturation, lightness)
84
+ saturation /= 100.0
85
+ lightness /= 100.0
86
+ chroma = saturation * [lightness, 1 - lightness].min
87
+ [0, 8, 4].map do |offset|
88
+ k = (offset + (hue / 30.0)) % 12
89
+ ((lightness - (chroma * [[k - 3, 9 - k, 1].min, -1].max)) * 255).round
90
+ end
91
+ end
92
+
93
+ def inside?(row, column)
94
+ BUMPS.any? { |x, y, radius| ((column - x)**2) + (((row - y) * ROW_STRETCH)**2) <= radius**2 } || base?(row, column)
95
+ end
96
+
97
+ def base?(row, column)
98
+ return false unless BASE_ROWS.cover?(row) && BASE_COLUMNS.cover?(column)
99
+
100
+ corner_row, corner_column = corner(row, column)
101
+ ((column - corner_column)**2) + (((row - corner_row) * ROW_STRETCH)**2) <= CORNER**2
102
+ end
103
+
104
+ # The nearest point to the cell that's a corner's radius in from the base's edges, where its corners curve in
105
+ # from, reaching at most halfway up the base.
106
+ def corner(row, column)
107
+ inset = [CORNER / ROW_STRETCH, (BASE_ROWS.end - BASE_ROWS.begin) / 2.0].min
108
+ [row.clamp(BASE_ROWS.begin + inset, BASE_ROWS.end - inset), column.clamp(BASE_COLUMNS.begin + CORNER, BASE_COLUMNS.end - CORNER)]
109
+ end
110
+ end
111
+
112
+ attr_reader :width, :height
113
+
114
+ def initialize(width:, height:)
115
+ @width = width
116
+ @height = height
117
+ @left = (width - WIDTH) / 2
118
+ @top = [(height - ROWS) / 3, 0].max
119
+ end
120
+
121
+ # Draws the cloud and its rain at the tick into a Scene's cells.
122
+ def draw(cells, tick)
123
+ self.class.cells.each { |(row, column), (block, style)| put(cells, row, column, block, style) }
124
+ rain(cells, tick)
125
+ end
126
+
127
+ private
128
+
129
+ # Drops in the rainy columns, moving down a row a tick, fading and thinning out in steps below the cloud.
130
+ def rain(cells, tick)
131
+ falling = height - @top - ROWS
132
+ falling.times do |below|
133
+ step = below * RAIN_STEPS / falling
134
+ self.class.rainy.each { |column| drop(cells, column, below - tick, ROWS + below, step) }
135
+ end
136
+ end
137
+
138
+ # The drop in the column at the row, if there's one there at the tick, from a checksum of where it is in the rain.
139
+ def drop(cells, column, falling, row, step)
140
+ drop = Zlib.crc32("#{column} #{falling}")
141
+ return unless (drop % 100) < 90 - (step * 9)
142
+
143
+ put(cells, row, column, RAIN[drop % RAIN.length], self.class.rain_styles[step])
144
+ end
145
+
146
+ # The cell at the cloud's row and column, where it's on the screen.
147
+ def put(cells, row, column, char, style)
148
+ row += @top
149
+ column += @left
150
+ cells[row][column] = [char, style] if row.between?(0, height - 1) && column.between?(0, width - 1)
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../text'
4
+ require_relative '../waffle/waffle'
5
+ require_relative 'scene'
6
+
7
+ module Raindown
8
+ module TUI
9
+ # A Celebration over the whole screen, in place of the Layout: rain and fireworks, then a waffle popping out, among
10
+ # confetti, balloons and sparkles.
11
+ CelebrationRenderer = Struct.new(:celebration, :width, :height) do
12
+ include Text
13
+ include Waffle
14
+
15
+ def lines
16
+ return Scene.new(width:, height:).lines(celebration.frame) if celebration.raining?
17
+
18
+ waffle(Scene.new(width:, height:, animations: [Confetti, Balloons, Sparkles]).lines(celebration.popping), celebration.popping)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -7,14 +7,11 @@ require_relative 'bins'
7
7
  require_relative 'feelings_code_box'
8
8
  require_relative 'magnifier'
9
9
  require_relative 'motion'
10
- require_relative 'celebration/scene'
11
- require_relative 'waffle/waffle'
12
10
 
13
11
  module Raindown
14
12
  module TUI
15
13
  # Code feelings, right of the files: the methods as clusters of digits in a grid, with the selected one highlighted
16
14
  # and its code below, then why it felt that way while that's typed, then the five bins and how many each holds.
17
- # When the batch is refined, it rains over them, then there's a waffle:
18
15
  # 0 0 2 2 2 5 5 9 9 9 9 1
19
16
  # 1 3 3 7 7 7 6 4 4 0 8 8
20
17
  # ╭─ lib/text.rb:62 · Text#segments_between ──────────── changed 6× ─╮
@@ -29,7 +26,6 @@ module Raindown
29
26
  include FeelingsCodeBox
30
27
  include Magnifier
31
28
  include Motion
32
- include Waffle
33
29
 
34
30
  # The columns a cell takes, on a line of its own, its digit small in the middle, which is magnified in a stat's block
35
31
  # digits across the cell and over the cells above and below it when the mouse is over its method.
@@ -66,7 +62,7 @@ module Raindown
66
62
  return too_small if too_small?
67
63
 
68
64
  cells = view.cells(columns, filled_rows)
69
- return instead if view.code_methods.empty? || view.celebration
65
+ return instead if view.code_methods.empty?
70
66
 
71
67
  below = [*code_box, *bins]
72
68
  (grid(cells, below.length) + below).first(height)
@@ -76,20 +72,11 @@ module Raindown
76
72
 
77
73
  def view = navigator.view
78
74
 
79
- # Instead of the grid, that there's nothing to feel, as there are no projects or no methods, or the celebration.
75
+ # Instead of the grid, that there's nothing to feel, as there are no projects or no methods.
80
76
  def instead
81
77
  return alert(NO_PROJECTS) if navigator.projects.empty?
82
- return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
83
78
 
84
- celebration(view.celebration)
85
- end
86
-
87
- # Rain and fireworks over the pane when the batch is refined, then a waffle popping out, among confetti, balloons and
88
- # sparkles.
89
- def celebration(celebration)
90
- return Scene.new(width:, height:).lines(celebration.frame) if celebration.raining?
91
-
92
- waffle(Scene.new(width:, height:, animations: [Confetti, Balloons, Sparkles]).lines(celebration.popping), celebration.popping)
79
+ alert(["No functions or methods in #{File.basename(navigator.root)} to feel."])
93
80
  end
94
81
 
95
82
  # The rows of the grid around the selected method's, between blank lines that leave the first and last rows room to
@@ -5,6 +5,7 @@ require_relative 'text'
5
5
  require_relative 'title'
6
6
  require_relative 'project_box'
7
7
  require_relative 'search_box'
8
+ require_relative 'feelings/celebration/celebration_renderer'
8
9
 
9
10
  module Raindown
10
11
  module TUI
@@ -49,7 +50,7 @@ module Raindown
49
50
  def lines
50
51
  return [status].first(height) if height < CHROME_HEIGHT + 1
51
52
 
52
- overlaid([title, *panes, status, button_bar])
53
+ celebration || overlaid([title, *panes, status, button_bar])
53
54
  end
54
55
 
55
56
  # A third of the width, within NAV_WIDTH, and never more than half of a narrow screen.
@@ -59,6 +60,12 @@ module Raindown
59
60
 
60
61
  private
61
62
 
63
+ # Code feelings' celebration over the whole screen, while it's on.
64
+ def celebration
65
+ celebration = navigator.feelings.celebration
66
+ CelebrationRenderer.new(celebration, width, height).lines if celebration
67
+ end
68
+
62
69
  # The project menu or Teleport, drawn over the lines while it's open.
63
70
  def overlaid(lines)
64
71
  return ProjectBox.new(navigator, width:, column: display_width(name)).apply(lines) if navigator.menu
@@ -46,7 +46,7 @@ module Raindown
46
46
 
47
47
  # A file's lines, headings and directives.
48
48
  def outline(path)
49
- document = Document.read(path, bare: @bare)
49
+ document = document(path)
50
50
  Outline.new(lines: File.readlines(path, chomp: true), headings: document.headings, directives: document.directives)
51
51
  end
52
52
 
@@ -66,9 +66,12 @@ module Raindown
66
66
  # the ones whose snippet can't be read.
67
67
  def endpoints
68
68
  Projects.files(root).flat_map do |path|
69
- lines = Utils.read_markdown(path).lines
70
- runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
71
- runnable.filter_map { |directive| endpoint(path, lines, directive) }
69
+ document = document(path)
70
+ # Most files have no endpoint, so they aren't parsed for one.
71
+ next [] unless document.markdown.include?('endpoint')
72
+
73
+ runnable = document.directives.select { it.endpoint? && it.writes }
74
+ runnable.filter_map { |directive| endpoint(path, document.markdown.lines, directive) }
72
75
  rescue StandardError
73
76
  []
74
77
  end
@@ -96,19 +99,28 @@ module Raindown
96
99
  end
97
100
 
98
101
  def charts_in(path)
99
- content = Utils.read_markdown(path)
100
- bare = @bare || BareDirectives.enabled?(path:, content:)
101
- lines = content.lines
102
- headings = Document.parse(content, bare:).headings
103
-
104
- NodeBlocks.find(lines, front_matter: FrontMatter.parse(content).first, bare:)
102
+ document = document(path)
103
+ lines = document.markdown.lines
104
+ # Its headings are only parsed when it has a chart, as most files don't.
105
+ NodeBlocks.find(lines, front_matter: FrontMatter.parse(document.markdown).first, bare: document.bare)
105
106
  .select { it[:node].respond_to?(:card_lines) }
106
- .map { |block| chart(block, path:, lines:, headings:) }
107
+ .map { |block| chart(block, path:, lines:, headings: document.headings) }
107
108
  rescue StandardError => e
108
109
  @file_errors << "#{path.delete_prefix(File.join(root, ''))}: #{e.message}"
109
110
  []
110
111
  end
111
112
 
113
+ # The file's Document, read and parsed again only once the file's changed, so its endpoints, charts and outline
114
+ # share it, and finding them again only parses the files that changed.
115
+ def document(path)
116
+ stat = File.stat(path)
117
+ changed = [stat.mtime, stat.size]
118
+ cached_at, document = (@documents ||= {})[path]
119
+ return document if cached_at == changed
120
+
121
+ Document.read(path, bare: @bare).tap { @documents[path] = [changed, it] }
122
+ end
123
+
112
124
  def drawn
113
125
  pages = {}
114
126
  @charts.map do |chart|
@@ -12,7 +12,7 @@ module Raindown
12
12
  # │ GET https://httpbin.org/uuid [Run] │
13
13
  # ╰──────────────────────────────────────────╯
14
14
  #
15
- # Response · json · fetched 2026-10-26
15
+ # response 200 · 142 ms · 2026-10-26 · json
16
16
  # {
17
17
  # "uuid": "48024974-f1b1-431a-a0cf-5cac0099a1a5"
18
18
  # }
@@ -73,8 +73,9 @@ module Raindown
73
73
  return [segment(" #{view.error}", :error, width:)] if view.error
74
74
  return [segment(' No response yet. X runs the request.', :heading, width:)] if view.response.empty?
75
75
 
76
- details = ['Response', view.language, ("fetched #{view.fetched}" if view.fetched)].reject { it.to_s.empty? }
77
- [segment(" #{details.join(' · ')}", :heading, width:)]
76
+ # As its comment in the file reads, or just response when it has none, then its language.
77
+ heading = [view.comment || 'response', view.language].reject { it.to_s.empty? }.join(' · ')
78
+ [segment(" #{heading}", :heading, width:)]
78
79
  end
79
80
  end
80
81
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../cloud'
4
+ require_relative '../feelings/celebration/scene'
5
+
6
+ module Raindown
7
+ module TUI
8
+ # The Screen's first frames, which it's prepended to: Raindown's rain cloud over the whole screen while the Navigator's
9
+ # made in another thread, like while Rouge loads.
10
+ module Loading
11
+ private
12
+
13
+ def start
14
+ super
15
+ loading = Thread.new(&@loading)
16
+ loading.report_on_exception = false
17
+ rain_until(loading)
18
+ @navigator = loading.value
19
+ end
20
+
21
+ # The rain cloud, a frame each animation tick, until the thread's done.
22
+ def rain_until(thread)
23
+ scene = Scene.new(width: Curses.cols, height: Curses.lines, animations: [Cloud])
24
+ tick = 0
25
+ loop do
26
+ draw(scene.lines(tick))
27
+ break if thread.join(Screen::TICKS[:animation] / 1000.0)
28
+
29
+ tick += 1
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -3,6 +3,7 @@
3
3
  require 'curses'
4
4
 
5
5
  require_relative 'input'
6
+ require_relative 'loading'
6
7
  require_relative '../layout'
7
8
  require_relative 'ncurses_shim'
8
9
  require_relative 'quiet'
@@ -15,15 +16,16 @@ module Raindown
15
16
  # Only this class draws on the terminal, so another library could draw the same Layout.
16
17
  class Screen
17
18
  prepend TerminalTitle
19
+ prepend Loading
18
20
 
19
21
  # How often, in milliseconds, the screen's drawn, and the open file checked for changes, while no key is pressed, by
20
22
  # the view's pace, from when the last frame started drawing: every half second while it's active, at 12 frames a
21
23
  # second while it's reactive, just faster than Code feelings' digits tremble, and at 20 during an animation.
22
24
  TICKS = { active: 500, reactive: 83, animation: 50 }.freeze
23
25
 
24
- def initialize(navigator)
25
- @navigator = navigator
26
- @zones = []
26
+ # Loading makes the Navigator, while the rain cloud's drawn.
27
+ def initialize(&loading)
28
+ @loading = loading
27
29
  end
28
30
 
29
31
  def run
@@ -89,8 +91,7 @@ module Raindown
89
91
  Curses.close_screen
90
92
  end
91
93
 
92
- def draw
93
- lines = Layout.new(@navigator, width: Curses.cols, height: Curses.lines).lines
94
+ def draw(lines = Layout.new(@navigator, width: Curses.cols, height: Curses.lines).lines)
94
95
  @zones = Input.zones(lines)
95
96
  window = Curses.stdscr
96
97
  window.erase
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'curses'
4
4
 
5
+ require_relative '../cloud'
6
+
5
7
  module Raindown
6
8
  module TUI
7
9
  # The curses attributes of a Layout's styles. Text is periwinkle where there are 256 colours, and the terminal's own
@@ -92,7 +94,7 @@ module Raindown
92
94
  def colored
93
95
  in_text_color.merge(
94
96
  title: pair(:title) | Curses::A_BOLD, directive: pair(:directive), error: pair(:error) | Curses::A_BOLD,
95
- button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **files,
97
+ button: pair(:button) | Curses::A_BOLD, **selections, **emphasis, **dividers, **code_tokens, **bright, **cloud, **files,
96
98
  **unfocused
97
99
  )
98
100
  end
@@ -130,6 +132,8 @@ module Raindown
130
132
  # The bright colours, bold, so they're bright in 8 colours too.
131
133
  def bright = BRIGHT.keys.to_h { [it, pair(it) | Curses::A_BOLD] }
132
134
 
135
+ def cloud = Cloud.styles.to_h { [it, pair(it)] }
136
+
133
137
  # The lines between the panes and beneath the title.
134
138
  def dividers = { divider: pair(:divider) }
135
139
 
@@ -156,6 +160,7 @@ module Raindown
156
160
  *(many ? METHODS : BASIC_METHODS).map { |name, color| [:"method_#{name}", color, -1] },
157
161
  *(many ? CODE.merge(BRIGHT, FEELINGS, ACCENTS) : BASIC_CODE.merge(BASIC_BRIGHT, BASIC_FEELINGS, BASIC_ACCENTS))
158
162
  .map { |name, color| [name, color, -1] },
163
+ *cloud_pairs,
159
164
  [:text, text, -1],
160
165
  [:selected, white, blues[:selected]], [:felt_selected, white, blues[:felt]], [:too_small, white, many ? DARK_RED : Curses::COLOR_RED],
161
166
  [:selected_button, Curses::COLOR_YELLOW, blues[:selected]],
@@ -163,6 +168,13 @@ module Raindown
163
168
  [:shine, white, -1]]
164
169
  end
165
170
 
171
+ # The rain cloud's blues while the terminal UI loads, each the 256 colour its style's named after, or cyan with 8.
172
+ def cloud_pairs
173
+ return Cloud.styles.map { [it, Curses::COLOR_CYAN, -1] } if Curses.colors < 256
174
+
175
+ Cloud.styles.map { [it, it.to_s.delete_prefix('cloud_').to_i, -1] }
176
+ end
177
+
166
178
  def pair(name)
167
179
  Curses.color_pair(pairs.index { it.first == name } + 1)
168
180
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../../code_blocks'
3
4
  require_relative '../events'
4
5
  require_relative '../../workers/worker'
5
6
 
@@ -9,11 +10,11 @@ module Raindown
9
10
  # Worker when it's opened, which scrolls.
10
11
  # A key's next step, for the Navigator, is nil, an Open or Edit of its directive, or an Execute of it.
11
12
  class EndpointView
12
- # The date a response was fetched, on the line after it.
13
- FETCHED = /raindown:fetched (\S+)/
13
+ # The text of the comment above a response, like "response 200 · 142 ms · 2026-10-26".
14
+ COMMENT = /<!--\s*(.*?)\s*-->/
14
15
  FENCE = /\A\s*`{3,}(\S*)/
15
16
 
16
- attr_reader :endpoint, :response, :language, :fetched, :error
17
+ attr_reader :endpoint, :response, :language, :comment, :error
17
18
 
18
19
  def initialize(endpoint, workers:)
19
20
  @endpoint = endpoint
@@ -36,7 +37,7 @@ module Raindown
36
37
  # Reads the response again, like after the request's run.
37
38
  def load
38
39
  lines = output_lines
39
- @fetched = lines.pop[FETCHED, 1] if lines.last&.match?(FETCHED)
40
+ @comment = lines.shift[COMMENT, 1] if lines.first&.match?(CodeBlocks::RESPONSE_COMMENT)
40
41
  @language = lines.first.to_s[FENCE, 1]
41
42
  @response = @language ? lines[1...-1] : lines
42
43
  @error = nil
data/lib/raindown/tui.rb CHANGED
@@ -27,12 +27,16 @@ module Raindown
27
27
  # so two projects' nodes of the same name stay apart.
28
28
  workers = Project.workers(root:, bare:, execute:)
29
29
 
30
- # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI.
31
- require 'rouge'
32
- # Saved files are written back, like when the folder's watched.
33
- require_relative 'tui/saves'
34
- saves = Saves.new(root)
35
- Screen.new(Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)).run
30
+ saves = nil
31
+ Screen.new do
32
+ # Rouge loads here, rather than with Raindown, which doesn't highlight code outside the TUI, while the screen shows
33
+ # the rain cloud.
34
+ require 'rouge'
35
+ # Saved files are written back, like when the folder's watched.
36
+ require_relative 'tui/saves'
37
+ saves = Saves.new(root)
38
+ Navigator.new(root:, path: (path unless File.directory?(path)), workers:, saves:, runs_endpoints: execute)
39
+ end.run
36
40
  true
37
41
  ensure
38
42
  saves&.stop
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.12.0'
4
+ VERSION = '0.13.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-04 00:00:00.000000000 Z
10
+ date: 2026-11-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -251,6 +251,8 @@ files:
251
251
  - lib/raindown/rewriter.rb
252
252
  - lib/raindown/server.rb
253
253
  - lib/raindown/stat.rb
254
+ - lib/raindown/table/markdown_table.rb
255
+ - lib/raindown/table/table.rb
254
256
  - lib/raindown/table_formatter.rb
255
257
  - lib/raindown/toc.rb
256
258
  - lib/raindown/tui.rb
@@ -262,6 +264,7 @@ files:
262
264
  - lib/raindown/tui/blueprints/traced_code.rb
263
265
  - lib/raindown/tui/blueprints/tracing.rb
264
266
  - lib/raindown/tui/card.rb
267
+ - lib/raindown/tui/cloud.rb
265
268
  - lib/raindown/tui/code_box.rb
266
269
  - lib/raindown/tui/endpoint.rb
267
270
  - lib/raindown/tui/endpoints.rb
@@ -269,6 +272,7 @@ files:
269
272
  - lib/raindown/tui/feelings/batch.rb
270
273
  - lib/raindown/tui/feelings/bins.rb
271
274
  - lib/raindown/tui/feelings/celebration/celebration.rb
275
+ - lib/raindown/tui/feelings/celebration/celebration_renderer.rb
272
276
  - lib/raindown/tui/feelings/celebration/fireworks.rb
273
277
  - lib/raindown/tui/feelings/celebration/rain.rb
274
278
  - lib/raindown/tui/feelings/celebration/scene.rb
@@ -308,6 +312,7 @@ files:
308
312
  - lib/raindown/tui/saves.rb
309
313
  - lib/raindown/tui/search_box.rb
310
314
  - lib/raindown/tui/terminal/input.rb
315
+ - lib/raindown/tui/terminal/loading.rb
311
316
  - lib/raindown/tui/terminal/ncurses_shim.rb
312
317
  - lib/raindown/tui/terminal/quiet.rb
313
318
  - lib/raindown/tui/terminal/screen.rb