raindown 0.10.0 → 0.12.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 +15 -9
- data/lib/raindown/bare_directives.rb +14 -0
- data/lib/raindown/blueprints.rb +7 -6
- data/lib/raindown/cli.rb +62 -20
- data/lib/raindown/code_blocks.rb +3 -1
- data/lib/raindown/directives.rb +7 -3
- data/lib/raindown/editing.rb +24 -0
- data/lib/raindown/endpoint/curl.rb +4 -25
- data/lib/raindown/endpoint/curl_response.rb +35 -0
- data/lib/raindown/endpoint/endpoint_args.rb +66 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +52 -23
- data/lib/raindown/endpoint/endpoint_variables.rb +45 -14
- data/lib/raindown/endpoint/schema_block.rb +82 -0
- data/lib/raindown/markdown_nodes.rb +67 -0
- data/lib/raindown/node_blocks.rb +25 -12
- data/lib/raindown/nodes.rb +6 -3
- data/lib/raindown/pages.rb +58 -11
- data/lib/raindown/projects.rb +2 -1
- data/lib/raindown/rewriter.rb +13 -12
- data/lib/raindown/server.rb +73 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +1 -1
- data/lib/raindown/tui/feelings/feelings_log.rb +4 -4
- data/lib/raindown/tui/project.rb +3 -2
- data/lib/raindown/watcher/file_watcher.rb +3 -1
- data/lib/raindown/watcher/project.rb +3 -2
- data/lib/raindown/wiki_links.rb +66 -0
- data/lib/raindown.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ea9df31d83ebabf0649ad353b75e2f2546aeb10c31aa97038bdd85f5e45fd2c
|
|
4
|
+
data.tar.gz: cc736f0d57e576c476629bfa7fe37405139cb1b880e86908e71e7e8d8b22926c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 851438f9079a944aca1dc2f97d0c5081822d0ce31df12e536ca10dce8d471233ee0879fc91b7004fcf7949159554dec7cd0d1e4aa3d50b8cee4009dbd3180766
|
|
7
|
+
data.tar.gz: 27863d35bb59084303144bdc0b1ea3852028a55c854e8142456c3e1d8f9ee495a3d5489dc00c91ce474bcd9eda833deb42d4f3696118bb24c25e913dabb54372
|
data/lib/raindown/actions.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Raindown
|
|
|
12
12
|
# Each returns true when the file changed, and raises when it can't, for the tool to show.
|
|
13
13
|
module Actions
|
|
14
14
|
class NotAnEndpoint < StandardError; end
|
|
15
|
+
class FailedCheck < StandardError; end
|
|
15
16
|
|
|
16
17
|
# Editors that open a file at a line with "path:line" rather than "+line path".
|
|
17
18
|
PATH_LINE_EDITORS = %w[hx helix subl zed].freeze
|
|
@@ -23,20 +24,25 @@ module Raindown
|
|
|
23
24
|
# Writes back the whole file, like saving it while it's watched, with bare directives allowed, and endpoints with no
|
|
24
25
|
# response run, when asked, like with --bare and -x.
|
|
25
26
|
def write_back(path, bare: false, execute: false)
|
|
26
|
-
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
27
|
+
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path)).changed?
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
# Runs one endpoint's request and writes its response, replacing any response it had
|
|
30
|
+
# Runs one endpoint's request and writes its response, replacing any response it had, then raises when the response
|
|
31
|
+
# fails its checks, like status: 200.
|
|
30
32
|
def execute(path, directive)
|
|
31
|
-
|
|
32
|
-
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
33
|
-
|
|
33
|
+
check_runnable(directive)
|
|
34
34
|
content = Utils.read_markdown(path)
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
runner = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1)
|
|
36
|
+
rewritten = runner.rewrite
|
|
37
|
+
File.write(path, rewritten) unless rewritten == content
|
|
38
|
+
raise FailedCheck, runner.failures.join("\n") if runner.failures.any?
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
rewritten != content
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_runnable(directive)
|
|
44
|
+
raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
|
|
45
|
+
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# The command that opens the file at a line in the person's editor, like ["vim", "+12", "notes.md"].
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'code_blocks'
|
|
4
4
|
require_relative 'directives'
|
|
5
|
+
require_relative 'endpoint/schema_block'
|
|
5
6
|
require_relative 'front_matter'
|
|
6
7
|
|
|
7
8
|
module Raindown
|
|
@@ -10,6 +11,8 @@ module Raindown
|
|
|
10
11
|
# matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
|
|
11
12
|
# Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
|
|
12
13
|
#
|
|
14
|
+
# A bare schema: shows in bold, with its fields in a code block below it.
|
|
15
|
+
#
|
|
13
16
|
# Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
|
|
14
17
|
# needs more than that, write it in comments with an end tag instead:
|
|
15
18
|
# <!-- endpoint: -->
|
|
@@ -65,6 +68,9 @@ module Raindown
|
|
|
65
68
|
# Returns the index of the last line it handled. That's the directive itself, so the lines it holds
|
|
66
69
|
# are expanded too, and a bare directive inside it closes before it does.
|
|
67
70
|
def expand_line(lines, index)
|
|
71
|
+
schema = SchemaBlock.at(lines, index, bare: true)
|
|
72
|
+
return expand_schema(lines, index, schema) if schema
|
|
73
|
+
|
|
68
74
|
match = lines[index].match(LINE)
|
|
69
75
|
return index unless match
|
|
70
76
|
|
|
@@ -73,6 +79,14 @@ module Raindown
|
|
|
73
79
|
index
|
|
74
80
|
end
|
|
75
81
|
|
|
82
|
+
# Returns the index of the code block's last line.
|
|
83
|
+
def expand_schema(lines, index, schema)
|
|
84
|
+
indent = schema.fields.first.to_s[/\A[ \t]*/]
|
|
85
|
+
block = [Directives.marker(lines[index], shown: true), "\n```\n", *schema.fields.map { it.delete_prefix(indent) }, "```\n"]
|
|
86
|
+
lines[index..schema.last] = block
|
|
87
|
+
index + block.length - 1
|
|
88
|
+
end
|
|
89
|
+
|
|
76
90
|
# The end tag after the block, then the end tag in bold, as a bare directive's shown.
|
|
77
91
|
def close_block(lines, index, indent:, keyword:)
|
|
78
92
|
last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
|
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,11 +1,15 @@
|
|
|
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'
|
|
7
9
|
require_relative 'blueprints'
|
|
10
|
+
require_relative 'editing'
|
|
8
11
|
require_relative 'rewriter'
|
|
12
|
+
require_relative 'server'
|
|
9
13
|
require_relative 'watcher/file_watcher'
|
|
10
14
|
require_relative 'watcher/supervisor'
|
|
11
15
|
|
|
@@ -28,34 +32,38 @@ module Raindown
|
|
|
28
32
|
PATH is a file, or a directory (default: current directory) whose projects with a
|
|
29
33
|
.raindown folder are watched
|
|
30
34
|
init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
|
|
31
|
-
|
|
35
|
+
server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
|
|
36
|
+
or its Markdown as it would be written back, like --stdin and --stdin --rewrite
|
|
37
|
+
blueprints Save the blueprints in Claude Code's last reply to .raindown/data/blueprints.md, for the terminal
|
|
32
38
|
UI's Blueprints page, as a Stop hook that's given the hook's JSON
|
|
33
39
|
|
|
34
40
|
Options:
|
|
35
|
-
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or
|
|
41
|
+
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT, or to stdout without one or with -, then exit with 1
|
|
42
|
+
if its endpoint responses fail their status: or schema: checks
|
|
36
43
|
--stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
|
|
37
44
|
its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
|
|
45
|
+
--rewrite With --stdin, print its Markdown as it would be written back to FILE, without writing
|
|
46
|
+
FILE, for editors like Obsidian. Add -x to run its endpoints
|
|
38
47
|
--bare Allow bare directives like :toc in Markdown files
|
|
39
48
|
-x, --execute Run endpoint curl snippets that have no output yet, as files are written back
|
|
40
|
-
--env NAME Fill endpoint snippets from the NAME environment in .raindown/environments.yml
|
|
49
|
+
--env NAME Fill endpoint snippets from the NAME environment in .raindown/environments.yml and
|
|
50
|
+
environments.local.yml
|
|
41
51
|
-h, --help Show this message
|
|
42
52
|
-v, --version Show the version
|
|
43
53
|
|
|
44
54
|
Set RAINDOWN_DEBUG=1 to show where an error happened.
|
|
45
55
|
TEXT
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
#
|
|
50
|
-
# Leave code blocks unhighlighted, so the browser can highlight them instead:
|
|
51
|
-
# highlight_code: false
|
|
52
|
-
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'
|
|
53
59
|
|
|
54
60
|
# A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
|
|
55
61
|
# are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
|
|
56
|
-
line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false, env: nil| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
|
|
62
|
+
line(':path') do |path, html: nil, stdin: false, rewrite: false, bare: false, execute: false, x: false, env: nil| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
|
|
57
63
|
execute do
|
|
58
64
|
ENV['RAINDOWN_ENV'] = env if env
|
|
65
|
+
next rewrite_stdin(path, bare:, execute: execute || x) if stdin && rewrite
|
|
66
|
+
|
|
59
67
|
open_path(path, html:, stdin:, bare:, execute: execute || x)
|
|
60
68
|
end
|
|
61
69
|
end
|
|
@@ -87,6 +95,7 @@ module Raindown
|
|
|
87
95
|
if argv.intersect?(%w[-v --version]) then puts(VERSION)
|
|
88
96
|
elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
|
|
89
97
|
elsif argv == ['blueprints'] then Blueprints.save(JSON.parse($stdin.read))
|
|
98
|
+
elsif argv == ['server'] then serve
|
|
90
99
|
else return false
|
|
91
100
|
end
|
|
92
101
|
|
|
@@ -100,16 +109,22 @@ module Raindown
|
|
|
100
109
|
abort("raindown: #{path}: #{error.message}")
|
|
101
110
|
end
|
|
102
111
|
|
|
103
|
-
#
|
|
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.
|
|
104
114
|
def self.init(directory = nil)
|
|
105
115
|
folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
|
|
106
116
|
config = File.join(folder, Config::FILE)
|
|
107
117
|
return puts("#{config} already exists") if File.exist?(config)
|
|
108
118
|
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
111
126
|
puts "Created #{config}"
|
|
112
|
-
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/
|
|
127
|
+
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/components'
|
|
113
128
|
end
|
|
114
129
|
|
|
115
130
|
# The terminal UI loads curses, so it's only required when it starts. Without a terminal, the path's watched instead.
|
|
@@ -123,9 +138,14 @@ module Raindown
|
|
|
123
138
|
class << self
|
|
124
139
|
private
|
|
125
140
|
|
|
126
|
-
|
|
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
|
+
|
|
146
|
+
# Endpoints run while formatting, which --stdin skips, so -x can't go with it. --html - prints, like --html.
|
|
127
147
|
def open_path(path, html:, stdin:, bare:, execute:)
|
|
128
|
-
output = html unless html
|
|
148
|
+
output = html unless [true, '-'].include?(html)
|
|
129
149
|
abort("-x runs endpoints as the file's formatted, which --stdin doesn't do\n\n#{USAGE}") if stdin && execute
|
|
130
150
|
return render_stdin(path, output, bare:) if stdin
|
|
131
151
|
return render(path, output, bare:, execute:) if html
|
|
@@ -139,14 +159,29 @@ module Raindown
|
|
|
139
159
|
Nodes.load(path)
|
|
140
160
|
|
|
141
161
|
metadata = Pages.for(path)
|
|
142
|
-
Rewriter.update(path:, bare:, execute:, metadata:)
|
|
162
|
+
failures = Rewriter.update(path:, bare:, execute:, metadata:).failures
|
|
143
163
|
write(output, Raindown.render_file(path:, bare:, metadata:))
|
|
164
|
+
check_endpoints(failures)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
|
|
168
|
+
def check_endpoints(failures)
|
|
169
|
+
return if failures.empty?
|
|
170
|
+
|
|
171
|
+
warn(*failures.map { "Endpoint check failed: #{it}" })
|
|
172
|
+
exit 1
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The Markdown from stdin written back as it would be to the file, and its failed endpoint checks, for an editor
|
|
176
|
+
# to apply to what it's editing.
|
|
177
|
+
def rewrite_stdin(path, bare:, execute:)
|
|
178
|
+
rewritten = Editing.rewrite_markdown($stdin.read, path:, bare:, execute:)
|
|
179
|
+
$stdout.write(rewritten[:output])
|
|
180
|
+
warn(*rewritten[:warnings])
|
|
144
181
|
end
|
|
145
182
|
|
|
146
183
|
def render_stdin(path, output = nil, bare: false, **)
|
|
147
|
-
|
|
148
|
-
write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
|
|
149
|
-
**Config.render_options(path)))
|
|
184
|
+
write(output, Editing.render_markdown($stdin.read, path:, bare:))
|
|
150
185
|
end
|
|
151
186
|
|
|
152
187
|
# Watching only writes back to the files, while --html and --stdin render HTML.
|
|
@@ -158,6 +193,13 @@ module Raindown
|
|
|
158
193
|
Watcher::FileWatcher.new(path:, bare:, execute:).start
|
|
159
194
|
end
|
|
160
195
|
|
|
196
|
+
# The answers have stdout to themselves, so what nodes print goes to stderr.
|
|
197
|
+
def serve
|
|
198
|
+
output = $stdout.dup
|
|
199
|
+
$stdout.reopen($stderr)
|
|
200
|
+
Server.new(output:).start
|
|
201
|
+
end
|
|
202
|
+
|
|
161
203
|
def write(output, html)
|
|
162
204
|
output ? File.write(output, html) : puts(html)
|
|
163
205
|
end
|
data/lib/raindown/code_blocks.rb
CHANGED
|
@@ -5,6 +5,8 @@ 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 line closing a fence, like ``` or ~~~~, compiled once per fence.
|
|
9
|
+
CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
|
|
8
10
|
|
|
9
11
|
module_function
|
|
10
12
|
|
|
@@ -13,7 +15,7 @@ module Raindown
|
|
|
13
15
|
fence, language = lines[index]&.match(FENCE)&.captures
|
|
14
16
|
return unless fence
|
|
15
17
|
|
|
16
|
-
close = (index + 1...lines.length).find { |i| lines[i].match?(
|
|
18
|
+
close = (index + 1...lines.length).find { |i| lines[i].match?(CLOSINGS[fence]) }
|
|
17
19
|
{ open: index, close:, language:, body: lines[index + 1...close].join } if close
|
|
18
20
|
end
|
|
19
21
|
|
data/lib/raindown/directives.rb
CHANGED
|
@@ -31,16 +31,20 @@ module Raindown
|
|
|
31
31
|
# Whether the directive's line is bare, without delimiters, so it shows in bold.
|
|
32
32
|
def bare?(line) = text(line) == line.strip
|
|
33
33
|
|
|
34
|
-
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
|
|
34
|
+
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden. Its braces are
|
|
35
|
+
# hidden from Antlers like code's, so a shown chart: {stats} shows as it's written.
|
|
35
36
|
def marker(line, shown: bare?(line))
|
|
36
|
-
text = text(line).gsub('--', '- -')
|
|
37
|
+
text = text(line).gsub('--', '- -').gsub(/[{}]/, CODE_BRACES)
|
|
37
38
|
return "<!-- raindown:hidden #{text} -->" unless shown
|
|
38
39
|
|
|
39
40
|
"<!-- raindown:directive #{text} -->"
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# The braces' entities, like code's, are kept as they are.
|
|
42
44
|
def render(html)
|
|
43
|
-
html.gsub(HIDDEN, '').gsub(MARKER)
|
|
45
|
+
html.gsub(HIDDEN, '').gsub(MARKER) do
|
|
46
|
+
"<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s).gsub(/&(#12[35];)/, '&\1')}</strong></p>"
|
|
47
|
+
end
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../raindown'
|
|
4
|
+
require_relative 'rewriter'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# What an editor asks of Raindown about Markdown it's editing, which needn't be saved: its HTML, or the Markdown as it
|
|
8
|
+
# would be written back to the file, with its failed endpoint checks. "raindown FILE --stdin" and "raindown server"
|
|
9
|
+
# both answer with it.
|
|
10
|
+
module Editing
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render_markdown(markdown, path:, bare: false)
|
|
14
|
+
Nodes.load(path)
|
|
15
|
+
Raindown.render(markdown:, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path), **Config.render_options(path))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rewrite_markdown(markdown, path:, bare: false, execute: false)
|
|
19
|
+
Nodes.load(path)
|
|
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
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'ipaddr'
|
|
4
|
-
require 'json'
|
|
5
4
|
require 'open3'
|
|
6
5
|
require 'shellwords'
|
|
7
6
|
require 'socket'
|
|
8
7
|
require 'uri'
|
|
8
|
+
require_relative 'curl_response'
|
|
9
9
|
|
|
10
10
|
module Raindown
|
|
11
11
|
# Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
|
|
@@ -48,28 +48,17 @@ module Raindown
|
|
|
48
48
|
'--data-urlencode' => :no_urlencoded_file
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
|
-
# Separates the response body from the content type that --write-out appends.
|
|
52
|
-
TYPE_MARKER = "\n--raindown-content-type--"
|
|
53
|
-
WRITE_OUT = "#{TYPE_MARKER}%{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
|
|
54
|
-
LANGUAGES = {
|
|
55
|
-
/json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
|
|
56
|
-
/css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
|
|
57
|
-
}.freeze
|
|
58
|
-
|
|
59
51
|
module_function
|
|
60
52
|
|
|
61
|
-
# Returns the
|
|
53
|
+
# Returns the Response, with the code block language for its content type.
|
|
62
54
|
# Placeholders like {base_url} are replaced from the variables, word by word, so a value can't add options.
|
|
63
55
|
def run(snippet, variables = nil)
|
|
64
56
|
args = arguments(snippet, variables)
|
|
65
57
|
stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
|
|
66
|
-
*resolve_options(args), *args, '--write-out', WRITE_OUT)
|
|
58
|
+
*resolve_options(args), *args, '--write-out', Response::WRITE_OUT)
|
|
67
59
|
raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
language = language(content_type)
|
|
71
|
-
|
|
72
|
-
[language == 'json' ? pretty_json(body) : body, language]
|
|
61
|
+
Response.parse(stdout)
|
|
73
62
|
rescue Errno::ENOENT
|
|
74
63
|
raise Error, 'curl is not installed'
|
|
75
64
|
end
|
|
@@ -213,15 +202,5 @@ module Raindown
|
|
|
213
202
|
rescue SocketError => e
|
|
214
203
|
raise Error, "Can't resolve #{host}: #{e.message}"
|
|
215
204
|
end
|
|
216
|
-
|
|
217
|
-
def language(content_type)
|
|
218
|
-
LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def pretty_json(body)
|
|
222
|
-
JSON.pretty_generate(JSON.parse(body))
|
|
223
|
-
rescue JSON::ParserError
|
|
224
|
-
body
|
|
225
|
-
end
|
|
226
205
|
end
|
|
227
206
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module Curl
|
|
7
|
+
# A response's body, its code block language, and its status code and time, like 200 in 142 milliseconds,
|
|
8
|
+
# read from what curl prints with --write-out WRITE_OUT.
|
|
9
|
+
class Response < Data.define(:body, :language, :status, :milliseconds)
|
|
10
|
+
# Separates the response body from the status, time and content type that --write-out appends.
|
|
11
|
+
INFO_MARKER = "\n--raindown-response-info--"
|
|
12
|
+
WRITE_OUT = "#{INFO_MARKER}%{http_code} %{time_total} %{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
|
|
13
|
+
LANGUAGES = {
|
|
14
|
+
/json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
|
|
15
|
+
/css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# JSON is pretty-printed, and other bodies are kept as they are.
|
|
19
|
+
def self.parse(stdout)
|
|
20
|
+
body, _, info = stdout.rpartition(INFO_MARKER)
|
|
21
|
+
status, seconds, content_type = info.split(' ', 3)
|
|
22
|
+
language = LANGUAGES.find { |pattern, _| content_type.to_s.match?(pattern) }&.last || ''
|
|
23
|
+
|
|
24
|
+
new(body: language == 'json' ? pretty_json(body) : body, language:, status: status.to_i,
|
|
25
|
+
milliseconds: (seconds.to_f * 1000).round)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.pretty_json(body)
|
|
29
|
+
JSON.pretty_generate(JSON.parse(body))
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
body
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'schema_block'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# An endpoint directive's arguments: as: names its response for later snippets, and status: and schema: are what its
|
|
8
|
+
# response is checked against, like "as: user status: 200 schema: Schema[id: Integer, email: String | nil]".
|
|
9
|
+
# A schema is a LowType type, read without running it, and is written with Schema[] rather than {}, which Antlers reads,
|
|
10
|
+
# or names a SchemaBlock, like schema: 'User' or schema: Array[User].
|
|
11
|
+
class EndpointArgs < Data.define(:name, :status, :schema)
|
|
12
|
+
KEYWORDS = %w[as: status: schema:].freeze
|
|
13
|
+
|
|
14
|
+
def self.parse(text, schemas: {})
|
|
15
|
+
values = keyword_values(text.to_s)
|
|
16
|
+
new(name: values['as'], status: values['status']&.then { Integer(it, exception: false) },
|
|
17
|
+
schema: values['schema']&.then { SchemaBlock.resolve(it, schemas) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Each keyword's words, up to the next keyword outside brackets, so a schema's own keys, like Schema[status: Integer],
|
|
21
|
+
# stay in it.
|
|
22
|
+
def self.keyword_values(text)
|
|
23
|
+
values = {}
|
|
24
|
+
key = nil
|
|
25
|
+
depth = 0
|
|
26
|
+
|
|
27
|
+
text.split.each do |word|
|
|
28
|
+
next values[key = word.delete_suffix(':')] = [] if depth.zero? && KEYWORDS.include?(word)
|
|
29
|
+
next unless key
|
|
30
|
+
|
|
31
|
+
values[key] << word
|
|
32
|
+
depth += word.count('[(') - word.count('])')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
values.transform_values { it.join(' ') }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What's wrong with a response, like "expected status 200, received 404" or "id: expected Integer, received "1"".
|
|
39
|
+
# A response fetched before statuses were kept has no status to check.
|
|
40
|
+
def failures(body:, status:)
|
|
41
|
+
failures = []
|
|
42
|
+
failures << "expected status #{self.status}, received #{status}" if self.status && status && status != self.status
|
|
43
|
+
failures.concat(schema_failures(body)) if schema
|
|
44
|
+
failures
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# LowType is only loaded for an endpoint with a schema.
|
|
50
|
+
def schema_failures(body)
|
|
51
|
+
require 'lowtype'
|
|
52
|
+
|
|
53
|
+
Low::TypeParser.parse(schema).errors(data(body)).map do |node|
|
|
54
|
+
"#{node.path.to_s.empty? ? 'response' : node.path}: expected #{node.expected}, received #{node.received}"
|
|
55
|
+
end
|
|
56
|
+
rescue Low::ParseError => e
|
|
57
|
+
["schema: #{e.message}"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def data(body)
|
|
61
|
+
JSON.parse(body)
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
body.strip
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|