raindown 0.11.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 +4 -4
- data/lib/raindown/actions.rb +1 -1
- data/lib/raindown/bare_directives.rb +10 -16
- data/lib/raindown/blueprints.rb +7 -6
- data/lib/raindown/cli.rb +22 -16
- data/lib/raindown/code_blocks.rb +26 -16
- data/lib/raindown/document.rb +9 -9
- data/lib/raindown/editing.rb +2 -2
- data/lib/raindown/endpoint/endpoint_runner.rb +15 -29
- data/lib/raindown/markdown_nodes.rb +1 -1
- data/lib/raindown/node_blocks.rb +1 -6
- data/lib/raindown/node_injector.rb +7 -1
- data/lib/raindown/nodes.rb +5 -3
- data/lib/raindown/pages.rb +61 -12
- data/lib/raindown/rewriter.rb +13 -12
- data/lib/raindown/table/markdown_table.rb +33 -0
- data/lib/raindown/table/table.rb +73 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +1 -1
- data/lib/raindown/tui/cloud.rb +154 -0
- data/lib/raindown/tui/feelings/celebration/celebration_renderer.rb +22 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +4 -4
- data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -16
- data/lib/raindown/tui/layout.rb +8 -1
- data/lib/raindown/tui/project.rb +23 -11
- data/lib/raindown/tui/renderers/endpoint_renderer.rb +4 -3
- data/lib/raindown/tui/terminal/loading.rb +34 -0
- data/lib/raindown/tui/terminal/screen.rb +6 -5
- data/lib/raindown/tui/terminal/styles.rb +13 -1
- data/lib/raindown/tui/views/endpoint_view.rb +5 -4
- data/lib/raindown/tui.rb +10 -6
- data/lib/raindown/watcher/file_watcher.rb +3 -3
- data/lib/raindown/watcher/project.rb +3 -4
- data/lib/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f795c35070ab8ae4f6b40e5a32595ab9ba6099b99f42aada8d0faafe2f56121d
|
|
4
|
+
data.tar.gz: a01426b3aab916e21b0e68f66fddd7f6b55d7e9c34ceb9665118a60b3ceae4b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 052231532f48cc3a575388ebe95fa62b70bc3eba2121742142071c2ab256ced437de1f4c80f2e439599d579c2b5eea2d793b7cb05439601523bc72ff9c252dd6
|
|
7
|
+
data.tar.gz: 98ff7b646f8e380817bbf69efc06a0bf67b4945a233349c9702d3140458bb27e387ecd010498c80dbfa4a9d2e64872b4f7af75bcd12b28b992e8b82ac66f56c9
|
data/lib/raindown/actions.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Raindown
|
|
|
24
24
|
# Writes back the whole file, like saving it while it's watched, with bare directives allowed, and endpoints with no
|
|
25
25
|
# response run, when asked, like with --bare and -x.
|
|
26
26
|
def write_back(path, bare: false, execute: false)
|
|
27
|
-
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
27
|
+
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path)).changed?
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
# Runs one endpoint's request and writes its response, replacing any response it had, then raises when the response
|
|
@@ -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
|
|
17
|
-
#
|
|
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
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
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]
|
|
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
|
|
92
|
-
last =
|
|
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
|
-
|
|
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
|
data/lib/raindown/blueprints.rb
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'fileutils'
|
|
3
4
|
require 'json'
|
|
4
5
|
require 'time'
|
|
5
6
|
require 'unicode/display_width'
|
|
6
7
|
require_relative 'projects'
|
|
7
8
|
|
|
8
9
|
module Raindown
|
|
9
|
-
# Blueprints from Claude Code's replies, kept in a project's .raindown/blueprints.md, which the terminal UI's Blueprints page
|
|
10
|
+
# Blueprints from Claude Code's replies, kept in a project's .raindown/data/blueprints.md, which the terminal UI's Blueprints page
|
|
10
11
|
# shows, newest first. Each is a code block under a heading of when it was saved and its kind:
|
|
11
12
|
# ## 2026-10-30T03:14:07Z · mockup
|
|
12
13
|
#
|
|
@@ -32,7 +33,7 @@ module Raindown
|
|
|
32
33
|
"#{time.sub('T', ' ').sub(/:\d\dZ\z/, ' UTC')}#{SEPARATOR}#{kind}"
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
# As it's saved in .raindown/blueprints.md, under when it was saved and its kind, then its description.
|
|
36
|
+
# As it's saved in .raindown/data/blueprints.md, under when it was saved and its kind, then its description.
|
|
36
37
|
def markdown(saved_at)
|
|
37
38
|
about = "#{description}\n\n" if description
|
|
38
39
|
"## #{saved_at}#{SEPARATOR}#{kind}\n\n#{about}```#{kind}\n#{lines.join("\n")}\n```\n\n"
|
|
@@ -49,7 +50,7 @@ module Raindown
|
|
|
49
50
|
def distance(other) = [(top - other.top).abs, (left + right - other.left - other.right).abs]
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
FILE = File.join('.raindown', 'blueprints.md')
|
|
53
|
+
FILE = File.join('.raindown', 'data', 'blueprints.md')
|
|
53
54
|
# The kinds a code block can be marked with to be a blueprint.
|
|
54
55
|
KINDS = %w[mockup diagram chart table other].freeze
|
|
55
56
|
# What a blueprint's drawn with: lines, like boxes' and arrows', and blocks, like charts' bars.
|
|
@@ -79,8 +80,7 @@ module Raindown
|
|
|
79
80
|
return [] unless File.exist?(path)
|
|
80
81
|
|
|
81
82
|
lines = File.readlines(path, chomp: true)
|
|
82
|
-
|
|
83
|
-
starts.filter_map { |start| blueprint(lines, start) }.reverse
|
|
83
|
+
lines.each_index.select { lines[it].start_with?('## ') }.filter_map { blueprint(lines, it) }.reverse
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
# Saves the blueprints in Claude Code's last reply, from its Stop hook's JSON, to the project it's working in, if any,
|
|
@@ -91,7 +91,8 @@ module Raindown
|
|
|
91
91
|
return if drawn.empty?
|
|
92
92
|
|
|
93
93
|
time = Time.now.utc.iso8601
|
|
94
|
-
File.
|
|
94
|
+
path = File.join(root, FILE).tap { FileUtils.mkdir_p(File.dirname(it)) }
|
|
95
|
+
File.open(path, 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
# The text's code blocks that are blueprints, each with its kind and the paragraph above it.
|
data/lib/raindown/cli.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'tmpdir'
|
|
4
6
|
require 'trees'
|
|
5
7
|
require_relative '../raindown'
|
|
6
8
|
require_relative '../version'
|
|
@@ -32,7 +34,7 @@ module Raindown
|
|
|
32
34
|
init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
|
|
33
35
|
server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
|
|
34
36
|
or its Markdown as it would be written back, like --stdin and --stdin --rewrite
|
|
35
|
-
blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
|
|
37
|
+
blueprints Save the blueprints in Claude Code's last reply to .raindown/data/blueprints.md, for the terminal
|
|
36
38
|
UI's Blueprints page, as a Stop hook that's given the hook's JSON
|
|
37
39
|
|
|
38
40
|
Options:
|
|
@@ -52,12 +54,8 @@ module Raindown
|
|
|
52
54
|
Set RAINDOWN_DEBUG=1 to show where an error happened.
|
|
53
55
|
TEXT
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
#
|
|
58
|
-
# Leave code blocks unhighlighted, so the browser can highlight them instead:
|
|
59
|
-
# highlight_code: false
|
|
60
|
-
YAML
|
|
57
|
+
# The files "raindown init" puts in a project's .raindown folder, like "rain new" clones raindeer-template.
|
|
58
|
+
TEMPLATE_URL = 'https://github.com/rainpour/raindown-init'
|
|
61
59
|
|
|
62
60
|
# A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
|
|
63
61
|
# are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
|
|
@@ -111,16 +109,20 @@ module Raindown
|
|
|
111
109
|
abort("raindown: #{path}: #{error.message}")
|
|
112
110
|
end
|
|
113
111
|
|
|
114
|
-
#
|
|
112
|
+
# Clones raindown-init into the .raindown folder that marks a project's root and holds its config.yml and nodes,
|
|
113
|
+
# keeping any files already there.
|
|
115
114
|
def self.init(directory = nil)
|
|
116
115
|
folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
|
|
117
116
|
config = File.join(folder, Config::FILE)
|
|
118
117
|
return puts("#{config} already exists") if File.exist?(config)
|
|
119
118
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
Dir.mktmpdir do |clone|
|
|
120
|
+
clone_template(clone)
|
|
121
|
+
FileUtils.mkdir_p(folder)
|
|
122
|
+
(Dir.children(clone) - ['.git']).each do |name|
|
|
123
|
+
FileUtils.cp_r(File.join(clone, name), folder) unless File.exist?(File.join(folder, name))
|
|
124
|
+
end
|
|
125
|
+
end
|
|
124
126
|
puts "Created #{config}"
|
|
125
127
|
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/components'
|
|
126
128
|
end
|
|
@@ -136,6 +138,11 @@ module Raindown
|
|
|
136
138
|
class << self
|
|
137
139
|
private
|
|
138
140
|
|
|
141
|
+
def clone_template(directory)
|
|
142
|
+
output, status = Open3.capture2e('git', 'clone', '--quiet', '--depth', '1', TEMPLATE_URL, directory)
|
|
143
|
+
abort("Couldn't clone #{TEMPLATE_URL}:\n#{output}") unless status.success?
|
|
144
|
+
end
|
|
145
|
+
|
|
139
146
|
# Endpoints run while formatting, which --stdin skips, so -x can't go with it. --html - prints, like --html.
|
|
140
147
|
def open_path(path, html:, stdin:, bare:, execute:)
|
|
141
148
|
output = html unless [true, '-'].include?(html)
|
|
@@ -152,14 +159,13 @@ module Raindown
|
|
|
152
159
|
Nodes.load(path)
|
|
153
160
|
|
|
154
161
|
metadata = Pages.for(path)
|
|
155
|
-
Rewriter.update(path:, bare:, execute:, metadata:)
|
|
162
|
+
failures = Rewriter.update(path:, bare:, execute:, metadata:).failures
|
|
156
163
|
write(output, Raindown.render_file(path:, bare:, metadata:))
|
|
157
|
-
check_endpoints(
|
|
164
|
+
check_endpoints(failures)
|
|
158
165
|
end
|
|
159
166
|
|
|
160
167
|
# Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
|
|
161
|
-
def check_endpoints(
|
|
162
|
-
failures = EndpointRunner.failures(content: Utils.read_markdown(path), path:, bare:)
|
|
168
|
+
def check_endpoints(failures)
|
|
163
169
|
return if failures.empty?
|
|
164
170
|
|
|
165
171
|
warn(*failures.map { "Endpoint check failed: #{it}" })
|
data/lib/raindown/code_blocks.rb
CHANGED
|
@@ -5,6 +5,11 @@ 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]*$/
|
|
11
|
+
# The line closing a fence, like ``` or ~~~~, compiled once per fence.
|
|
12
|
+
CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
|
|
8
13
|
|
|
9
14
|
module_function
|
|
10
15
|
|
|
@@ -13,31 +18,36 @@ module Raindown
|
|
|
13
18
|
fence, language = lines[index]&.match(FENCE)&.captures
|
|
14
19
|
return unless fence
|
|
15
20
|
|
|
16
|
-
close = (index + 1...lines.length).find { |i| lines[i].match?(
|
|
21
|
+
close = (index + 1...lines.length).find { |i| lines[i].match?(CLOSINGS[fence]) }
|
|
17
22
|
{ open: index, close:, language:, body: lines[index + 1...close].join } if close
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
# An endpoint's curl snippet and its response, if it has one yet
|
|
21
|
-
# A second snippet straight after is another example rather than the
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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:)]
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
def snippet?(block)
|
|
28
42
|
SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
|
|
29
43
|
end
|
|
30
44
|
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
index = block[:close] + 1
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
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
|
|
41
51
|
end
|
|
42
52
|
|
|
43
53
|
def skip_blank_lines(lines, index)
|
data/lib/raindown/document.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
-
|
|
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
|
|
125
|
-
last = end_tag(after: last) || last
|
|
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)
|
data/lib/raindown/editing.rb
CHANGED
|
@@ -17,8 +17,8 @@ module Raindown
|
|
|
17
17
|
|
|
18
18
|
def rewrite_markdown(markdown, path:, bare: false, execute: false)
|
|
19
19
|
Nodes.load(path)
|
|
20
|
-
|
|
21
|
-
{ output:
|
|
20
|
+
rewrite = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
|
|
21
|
+
{ output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } }
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
end
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
|
@@ -42,11 +40,6 @@ module Raindown
|
|
|
42
40
|
new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
|
|
43
41
|
end
|
|
44
42
|
|
|
45
|
-
# The file's failed checks, like "api.md:3: expected status 200, received 404", from the responses in it.
|
|
46
|
-
def self.failures(content:, path: nil, bare: false)
|
|
47
|
-
new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:)).tap(&:rewrite).failures
|
|
48
|
-
end
|
|
49
|
-
|
|
50
43
|
def initialize(content:, path: nil, bare: false, execute: false, only: nil)
|
|
51
44
|
@lines = content.lines
|
|
52
45
|
@path = path
|
|
@@ -88,19 +81,19 @@ module Raindown
|
|
|
88
81
|
|
|
89
82
|
# Returns the index of the last line it handled.
|
|
90
83
|
def fill(index, args, directive:)
|
|
91
|
-
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
84
|
+
curl, output = CodeBlocks.endpoint(@lines, index, bare: !@lines[directive].match?(COMMENT))
|
|
92
85
|
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
93
86
|
return write_output(curl:, output:, args:, directive:) if run?(output, directive)
|
|
94
87
|
return curl[:close] unless output
|
|
95
88
|
|
|
96
89
|
@variables.remember(args.name, output[:body])
|
|
97
|
-
check(args, directive:, body: output[:body], status:
|
|
90
|
+
check(args, directive:, body: output[:body], status: response_status(output))
|
|
98
91
|
curl[:close]
|
|
99
92
|
end
|
|
100
93
|
|
|
101
|
-
# The status in the
|
|
102
|
-
def
|
|
103
|
-
@lines[output[:
|
|
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]
|
|
104
97
|
end
|
|
105
98
|
|
|
106
99
|
# Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
|
|
@@ -118,14 +111,15 @@ module Raindown
|
|
|
118
111
|
@execute && (output.nil? || output[:body].strip.empty?)
|
|
119
112
|
end
|
|
120
113
|
|
|
121
|
-
# Replaces
|
|
114
|
+
# Replaces everything from just after the curl block to the end of the output block, if there is one, like its
|
|
115
|
+
# comment.
|
|
122
116
|
# Secrets that a response echoes are written as their placeholders.
|
|
123
117
|
def write_output(curl:, output:, args:, directive:)
|
|
124
118
|
response = Curl.run(curl[:body], @variables)
|
|
125
119
|
body = @variables.redact(response.body)
|
|
126
120
|
@variables.remember(args.name, body)
|
|
127
121
|
check(args, directive:, body:, status: response.status)
|
|
128
|
-
replace_output(curl:, output:, block: [*output_block(body, response.language)
|
|
122
|
+
replace_output(curl:, output:, block: [response_comment(response), *output_block(body, response.language)])
|
|
129
123
|
rescue Curl::Error => e
|
|
130
124
|
message = @variables.redact(e.message)
|
|
131
125
|
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
@@ -137,21 +131,13 @@ module Raindown
|
|
|
137
131
|
|
|
138
132
|
# Returns the index of the block's last line.
|
|
139
133
|
def replace_output(curl:, output:, block:)
|
|
140
|
-
|
|
141
|
-
start
|
|
142
|
-
|
|
143
|
-
@lines[start, output ? replaced_length(output) : 0] = block
|
|
134
|
+
start = curl[:close] + 1
|
|
135
|
+
@lines[start, output ? output[:close] - start + 1 : 0] = block
|
|
144
136
|
start + block.length - 1
|
|
145
137
|
end
|
|
146
138
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
length = output[:close] - output[:open] + 1
|
|
150
|
-
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def fetched(response)
|
|
154
|
-
"<!-- 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"
|
|
155
141
|
end
|
|
156
142
|
|
|
157
143
|
# The fence is longer than any run of backticks in the body, so the body can't close it.
|
|
@@ -26,7 +26,7 @@ module Raindown
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
# Whether a file is a Markdown Component, by its extension and name, so .raindown/blueprints.md isn't one.
|
|
29
|
+
# Whether a file is a Markdown Component, by its extension and name, so .raindown/data/blueprints.md isn't one.
|
|
30
30
|
def file?(path)
|
|
31
31
|
EXTENSIONS.include?(File.extname(path)) && File.basename(path, '.*').match?(NAME)
|
|
32
32
|
end
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -13,36 +13,60 @@ module Raindown
|
|
|
13
13
|
# folder: basics, order: 1, path: docs/_basics/_1-getting-started.md
|
|
14
14
|
# Folder tags can share a folder name with "&", like docs/_1&_basics/getting-started.md.
|
|
15
15
|
# An app can add its own metadata by subclassing and overriding implicit_metadata, like Raindeer's URL paths.
|
|
16
|
+
# Each file's front matter is only read once something lists the files. The listing and the front matter are kept for
|
|
17
|
+
# as long as the process runs, like "raindown server", until they change.
|
|
16
18
|
class Pages
|
|
17
19
|
EXTENSIONS = %w[md rd markdown raindown].freeze
|
|
18
20
|
SEGMENTS = Regexp.union(['/', '&', '-'])
|
|
19
21
|
|
|
20
22
|
class << self
|
|
23
|
+
# Each file's front matter, by its path, with the time it was modified when it was read.
|
|
24
|
+
def front_matters
|
|
25
|
+
@front_matters ||= {}
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
|
|
22
29
|
def for(path)
|
|
23
30
|
file_directory = File.dirname(File.expand_path(path))
|
|
24
31
|
load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
|
-
# Lists the files under the root
|
|
34
|
+
# Lists the files under the root, without loading them, for Dirload.
|
|
28
35
|
def load(root:)
|
|
29
36
|
require 'dirload'
|
|
30
|
-
file_types =
|
|
37
|
+
file_types = file_paths(root).group_by { Dirload.extension(file_path: it) }
|
|
31
38
|
new(metadata: Dirload::Metadata.new(loaded_paths: {}, missed_paths: [], file_types:), root:)
|
|
32
39
|
end
|
|
40
|
+
|
|
41
|
+
# The files under the root, outside hidden folders, like Dirload lists them. They're listed again once a folder
|
|
42
|
+
# among them has changed, which adding, removing or renaming a file or folder in it does.
|
|
43
|
+
def file_paths(root)
|
|
44
|
+
listing = listings[root]
|
|
45
|
+
return listing[:file_paths] if listing&.dig(:folders)&.all? { |folder, modified| modified_at(folder) == modified }
|
|
46
|
+
|
|
47
|
+
folders, file_paths = Dir["#{root}/**/*"].partition { File.directory?(it) }
|
|
48
|
+
listings[root] = { file_paths:, folders: [root, *folders].to_h { [it, modified_at(it)] } }
|
|
49
|
+
file_paths
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def listings
|
|
53
|
+
@listings ||= {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def modified_at(path)
|
|
57
|
+
File.mtime(path) if File.exist?(path)
|
|
58
|
+
end
|
|
33
59
|
end
|
|
34
60
|
|
|
35
61
|
attr_reader :root
|
|
36
62
|
|
|
37
63
|
# Render options, like highlight_code: false, are for rendering each listed file's content.
|
|
38
|
-
|
|
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)
|
|
39
66
|
@root = root
|
|
40
67
|
@render_options = render_options
|
|
41
68
|
@file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
|
|
42
|
-
|
|
43
|
-
@tags = {}
|
|
44
|
-
|
|
45
|
-
@file_paths.each { |file_path| process(file_path:) }
|
|
69
|
+
load_metadata if eager
|
|
46
70
|
end
|
|
47
71
|
|
|
48
72
|
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
@@ -53,21 +77,46 @@ module Raindown
|
|
|
53
77
|
end
|
|
54
78
|
|
|
55
79
|
def tagged(**tags)
|
|
56
|
-
tags.flat_map { |type, tag|
|
|
80
|
+
tags.flat_map { |type, tag| self.tags.dig(type.to_sym, tag) || [] }.uniq
|
|
57
81
|
end
|
|
58
82
|
|
|
59
83
|
private
|
|
60
84
|
|
|
85
|
+
def metadata
|
|
86
|
+
load_metadata unless @metadata
|
|
87
|
+
@metadata
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tags
|
|
91
|
+
load_metadata unless @tags
|
|
92
|
+
@tags
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def load_metadata
|
|
96
|
+
@metadata = {}
|
|
97
|
+
@tags = {}
|
|
98
|
+
@file_paths.each { |file_path| process(file_path:) }
|
|
99
|
+
end
|
|
100
|
+
|
|
61
101
|
def listed_page(file_path)
|
|
62
|
-
Page.new(file_path:, metadata:
|
|
102
|
+
Page.new(file_path:, metadata: metadata[file_path], render_options: @render_options)
|
|
63
103
|
end
|
|
64
104
|
|
|
65
105
|
def process(file_path:)
|
|
66
|
-
metadata = implicit_metadata(file_path:).merge(
|
|
106
|
+
metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
|
|
67
107
|
@metadata[file_path] = metadata
|
|
68
108
|
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
69
109
|
end
|
|
70
110
|
|
|
111
|
+
# The file's front matter, read again only when the file's changed since it was last read.
|
|
112
|
+
def front_matter(file_path)
|
|
113
|
+
modified = File.mtime(file_path)
|
|
114
|
+
modified_then, front_matter = self.class.front_matters[file_path]
|
|
115
|
+
return front_matter if modified_then == modified
|
|
116
|
+
|
|
117
|
+
FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
|
|
118
|
+
end
|
|
119
|
+
|
|
71
120
|
# The path is relative to the root, like docs/_basics/_1-getting-started.md.
|
|
72
121
|
# A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
|
|
73
122
|
def implicit_metadata(file_path:)
|
|
@@ -104,8 +153,8 @@ module Raindown
|
|
|
104
153
|
|
|
105
154
|
# Files with an order come first, by order, then the rest by path.
|
|
106
155
|
def sort_key(file_path)
|
|
107
|
-
order =
|
|
108
|
-
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0,
|
|
156
|
+
order = metadata[file_path][:order]
|
|
157
|
+
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, metadata[file_path][:path].to_s]
|
|
109
158
|
end
|
|
110
159
|
end
|
|
111
160
|
end
|