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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d8b4a4baae078b6b14f7ae6fd66dde441f97776e7107a07864ca4c12beebb74
4
- data.tar.gz: 582a5012c02638f89991497cc789ef9513e7c13262445784c40ddb026b246574
3
+ metadata.gz: 6ea9df31d83ebabf0649ad353b75e2f2546aeb10c31aa97038bdd85f5e45fd2c
4
+ data.tar.gz: cc736f0d57e576c476629bfa7fe37405139cb1b880e86908e71e7e8d8b22926c
5
5
  SHA512:
6
- metadata.gz: 1e8fc0825e5462616ce435e35188f5d41b45b23fdbbb59ff840c140cf46279de4a0e696def346a92f168dc6e04e97d1bcac9e309b1db3d9219fe59eeaadad52a
7
- data.tar.gz: 5025d3381c0e0e2049a6b5fe462102438226feaa2d1a563c89b34dde74b1679163071d4df43e61d3a261e489ed71a107ae925db7f4cf08f1b9e83e773b45b46c
6
+ metadata.gz: 851438f9079a944aca1dc2f97d0c5081822d0ce31df12e536ca10dce8d471233ee0879fc91b7004fcf7949159554dec7cd0d1e4aa3d50b8cee4009dbd3180766
7
+ data.tar.gz: 27863d35bb59084303144bdc0b1ea3852028a55c854e8142456c3e1d8f9ee495a3d5489dc00c91ce474bcd9eda833deb42d4f3696118bb24c25e913dabb54372
@@ -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
- raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
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
- rewritten = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
- return false if rewritten == content
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
- File.write(path, rewritten)
39
- true
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
@@ -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
- starts = lines.each_index.select { lines[it].start_with?('## ') }
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.open(File.join(root, FILE), 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
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
- blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
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 else printing to stdout
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
- CONFIG = <<~YAML
48
- # Raindown project settings. See https://raindown.dev/docs/config
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
- # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
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
- FileUtils.mkdir_p(folder)
110
- File.write(config, CONFIG)
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/nodes'
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
- # Endpoints run while formatting, which --stdin skips, so -x can't go with it.
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 == true
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
- Nodes.load(path)
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
@@ -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?(/\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/) }
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
 
@@ -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) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
45
+ html.gsub(HIDDEN, '').gsub(MARKER) do
46
+ "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s).gsub(/&amp;(#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 response body and the code block language for its content type.
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
- body, _, content_type = stdout.rpartition(TYPE_MARKER)
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