raindown 0.9.1 → 0.10.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: 3288235ed874ddbeae5b31b5ef6707e59864f5cca305bb9bc74fcf74a9c36366
4
- data.tar.gz: c7bfc9dcdec92857c433d4abacbc80d221bb6029617972d362af43163c235c39
3
+ metadata.gz: 9d8b4a4baae078b6b14f7ae6fd66dde441f97776e7107a07864ca4c12beebb74
4
+ data.tar.gz: 582a5012c02638f89991497cc789ef9513e7c13262445784c40ddb026b246574
5
5
  SHA512:
6
- metadata.gz: 1cb1aaaf0f325d154393656cad4040aa8a2205464e577ce69f23931bd2f2727d28222aba9a6d3a059bd2f7531e59050f0056b413a1a72cb72fe66e5e785b300a
7
- data.tar.gz: f89b1e87119328272a1df69a6c2f9c04159f98db03c749774bac1ae533307eff32bc833b111c41ef569456ed9e0f2ec421afc938744b31102e4f6c8c848fcace
6
+ metadata.gz: 1e8fc0825e5462616ce435e35188f5d41b45b23fdbbb59ff840c140cf46279de4a0e696def346a92f168dc6e04e97d1bcac9e309b1db3d9219fe59eeaadad52a
7
+ data.tar.gz: 5025d3381c0e0e2049a6b5fe462102438226feaa2d1a563c89b34dde74b1679163071d4df43e61d3a261e489ed71a107ae925db7f4cf08f1b9e83e773b45b46c
@@ -32,7 +32,7 @@ module Raindown
32
32
  raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
33
33
 
34
34
  content = Utils.read_markdown(path)
35
- rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
35
+ rewritten = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
36
  return false if rewritten == content
37
37
 
38
38
  File.write(path, rewritten)
data/lib/raindown/cli.rb CHANGED
@@ -37,6 +37,7 @@ module Raindown
37
37
  its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
38
38
  --bare Allow bare directives like :toc in Markdown files
39
39
  -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
40
41
  -h, --help Show this message
41
42
  -v, --version Show the version
42
43
 
@@ -52,12 +53,18 @@ module Raindown
52
53
 
53
54
  # A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
54
55
  # are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
55
- line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
56
- execute { open_path(path, html:, stdin:, bare:, execute: execute || x) }
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
57
+ execute do
58
+ ENV['RAINDOWN_ENV'] = env if env
59
+ open_path(path, html:, stdin:, bare:, execute: execute || x)
60
+ end
57
61
  end
58
62
 
59
- line('watch :path') do |path, bare: false, execute: false, x: false|
60
- execute { watch(path, bare:, execute: execute || x) }
63
+ line('watch :path') do |path, bare: false, execute: false, x: false, env: nil|
64
+ execute do
65
+ ENV['RAINDOWN_ENV'] = env if env
66
+ watch(path, bare:, execute: execute || x)
67
+ end
61
68
  end
62
69
 
63
70
  def self.run(argv)
@@ -1,18 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ipaddr'
3
4
  require 'json'
4
5
  require 'open3'
5
6
  require 'shellwords'
7
+ require 'socket'
8
+ require 'uri'
6
9
 
7
10
  module Raindown
8
11
  # Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
9
12
  # Snippets come from files that anyone (or any agent) may have written, so only options that make an HTTP request
10
13
  # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
14
+ # Link-local addresses, like the cloud metadata service at 169.254.169.254, are refused, as they can give out
15
+ # credentials, and redirects aren't followed (no -L), so a URL can't redirect there either.
11
16
  module Curl
12
17
  class Error < StandardError; end
13
18
  Request = Data.define(:method, :url)
14
19
 
15
20
  TIMEOUT = '30'
21
+ # The variables curl reads a proxy from, which then looks the names up rather than curl.
22
+ PROXY_VARIABLES = %w[http_proxy https_proxy HTTPS_PROXY all_proxy ALL_PROXY].freeze
16
23
  # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
17
24
  SAFE_OPTIONS = ['--disable', '--proto', '=http,https', '--proto-redir', '=http,https'].freeze
18
25
 
@@ -21,7 +28,7 @@ module Raindown
21
28
 
22
29
  # Options without a value.
23
30
  FLAGS = {
24
- '-G' => '--get', '-I' => '--head', '-L' => '--location', '-i' => '--include', '-k' => '--insecure',
31
+ '-G' => '--get', '-I' => '--head', '-i' => '--include', '-k' => '--insecure',
25
32
  '-f' => '--fail', '-g' => '--globoff', '-s' => '--silent', '-S' => '--show-error'
26
33
  }.freeze
27
34
  LONG_FLAGS = (FLAGS.values + %w[--compressed --http1.1 --http2]).freeze
@@ -52,9 +59,11 @@ module Raindown
52
59
  module_function
53
60
 
54
61
  # Returns the response body and the code block language for its content type.
55
- def run(snippet)
62
+ # Placeholders like {base_url} are replaced from the variables, word by word, so a value can't add options.
63
+ def run(snippet, variables = nil)
64
+ args = arguments(snippet, variables)
56
65
  stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
57
- *arguments(snippet), '--write-out', WRITE_OUT)
66
+ *resolve_options(args), *args, '--write-out', WRITE_OUT)
58
67
  raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
59
68
 
60
69
  body, _, content_type = stdout.rpartition(TYPE_MARKER)
@@ -66,8 +75,8 @@ module Raindown
66
75
  end
67
76
 
68
77
  # The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
69
- def request(snippet)
70
- args = arguments(snippet)
78
+ def request(snippet, variables = nil)
79
+ args = arguments(snippet, variables)
71
80
  Request.new(method: request_method(args), url: args.find { it.match?(URL) })
72
81
  rescue Error
73
82
  nil
@@ -83,8 +92,9 @@ module Raindown
83
92
  'GET'
84
93
  end
85
94
 
86
- def arguments(snippet)
95
+ def arguments(snippet, variables = nil)
87
96
  words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
97
+ words = words.map { variables.substitute(it) } if variables
88
98
  raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
89
99
  raise Error, 'Endpoint snippets must be a single curl command' if words.intersect?(OPERATORS)
90
100
 
@@ -120,7 +130,7 @@ module Raindown
120
130
  [option, value(option, words.shift)]
121
131
  end
122
132
 
123
- # Short flags can be grouped like -sSL, and a short option's value can be attached like -XPOST.
133
+ # Short flags can be grouped like -sS, and a short option's value can be attached like -XPOST.
124
134
  def short_options(word, words)
125
135
  letters = word.delete_prefix('-')
126
136
  args = []
@@ -160,6 +170,50 @@ module Raindown
160
170
  value
161
171
  end
162
172
 
173
+ # Resolves each URL's host and pins curl to the address it was checked at, like --resolve api.io:443:203.0.113.5,
174
+ # so the name can't point somewhere else by the time curl looks it up. A host that's already an IP isn't pinned,
175
+ # and behind a proxy, which looks names up itself, only a host that's an IP is checked.
176
+ def resolve_options(args)
177
+ args.grep(URL).flat_map { pin_option(URI.parse(it)) }
178
+ rescue URI::InvalidURIError => e
179
+ raise Error, "Can't read the URL: #{e.message}"
180
+ end
181
+
182
+ def pin_option(uri)
183
+ raise Error, "#{uri} has no host" if uri.hostname.to_s.empty?
184
+ return [] if proxy? && !ip?(uri.hostname)
185
+
186
+ address = allowed_address(uri.hostname)
187
+ return [] if ip?(uri.hostname)
188
+
189
+ pinned = address.to_s
190
+ pinned = "[#{pinned}]" if address.ipv6?
191
+ ['--resolve', "#{uri.hostname}:#{uri.port}:#{pinned}"]
192
+ end
193
+
194
+ # Any way of writing an IP, like 169.254.169.254 or 2852039166, found without a DNS lookup.
195
+ def ip?(host)
196
+ Addrinfo.getaddrinfo(host, nil, nil, :STREAM, nil, Socket::AI_NUMERICHOST)
197
+ true
198
+ rescue SocketError
199
+ false
200
+ end
201
+
202
+ def proxy?
203
+ PROXY_VARIABLES.any? { !ENV[it].to_s.empty? }
204
+ end
205
+
206
+ # The host's first address, unless any of them is link-local.
207
+ def allowed_address(host)
208
+ addresses = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map { IPAddr.new(it.ip_address.sub(/%.*/, '')).native }
209
+ blocked = addresses.find(&:link_local?)
210
+ raise Error, "Endpoint snippets can't request link-local addresses, like cloud metadata at #{blocked}" if blocked
211
+
212
+ addresses.first
213
+ rescue SocketError => e
214
+ raise Error, "Can't resolve #{host}: #{e.message}"
215
+ end
216
+
163
217
  def language(content_type)
164
218
  LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
165
219
  end
@@ -4,14 +4,15 @@ require 'date'
4
4
  require_relative '../bare_directives'
5
5
  require_relative '../code_blocks'
6
6
  require_relative 'curl'
7
+ require_relative 'endpoint_variables'
7
8
 
8
9
  module Raindown
9
10
  # Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
10
11
  # block below it,
11
12
  # followed by the date it was fetched.
12
- # Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
13
+ # Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
13
14
  # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
14
- # <!-- endpoint: :x -->
15
+ # <!-- endpoint: -->
15
16
  # ```sh
16
17
  # curl https://api.example.com
17
18
  # ```
@@ -20,6 +21,8 @@ module Raindown
20
21
  # ```
21
22
  # <!-- raindown:fetched 2026-10-14 -->
22
23
  # <!-- :endpoint -->
24
+ # Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
25
+ # response, like {login.token}.
23
26
  # One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
24
27
  class EndpointRunner
25
28
  # An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
@@ -27,16 +30,16 @@ module Raindown
27
30
  DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
28
31
  COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
29
32
  END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
30
- EXECUTE_ARGS = %w[:x :execute].freeze
31
33
  # The date a response was fetched, on the line after it.
32
34
  FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
33
35
 
34
36
  def self.rewrite(content:, path: nil, bare: false, execute: false, **)
35
- new(content:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
37
+ new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
36
38
  end
37
39
 
38
- def initialize(content:, bare: false, execute: false, only: nil)
40
+ def initialize(content:, path: nil, bare: false, execute: false, only: nil)
39
41
  @lines = content.lines
42
+ @variables = EndpointVariables.new(path:, content:)
40
43
  @bare = bare
41
44
  @execute = execute
42
45
  @only = only
@@ -71,24 +74,29 @@ module Raindown
71
74
  def fill(index, args, directive:)
72
75
  curl, output = CodeBlocks.endpoint(@lines, index)
73
76
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
74
- return curl[:close] unless run?(args, output, directive)
77
+ return write_output(curl:, output:, name: response_name(args)) if run?(output, directive)
75
78
 
76
- write_output(curl:, output:)
79
+ @variables.remember(response_name(args), output[:body]) if output
80
+ curl[:close]
77
81
  end
78
82
 
79
- def run?(args, output, directive)
80
- return directive == @only if @only
81
-
82
- (output.nil? || output[:body].strip.empty?) && execute?(args)
83
+ # The name that as: gives the endpoint, for later snippets to use its response.
84
+ def response_name(args)
85
+ index = args.index('as:')
86
+ args[index + 1] if index
83
87
  end
84
88
 
85
- def execute?(args)
86
- @execute || args.intersect?(EXECUTE_ARGS)
89
+ def run?(output, directive)
90
+ return directive == @only if @only
91
+
92
+ @execute && (output.nil? || output[:body].strip.empty?)
87
93
  end
88
94
 
89
95
  # Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
90
- def write_output(curl:, output:)
91
- block = [*output_block(*Curl.run(curl[:body])), fetched]
96
+ def write_output(curl:, output:, name:)
97
+ body, language = Curl.run(curl[:body], @variables)
98
+ @variables.remember(name, body)
99
+ block = [*output_block(body, language), fetched]
92
100
  block.unshift("\n") unless output
93
101
  start = output ? output[:open] : curl[:close] + 1
94
102
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative '../front_matter'
6
+ require_relative 'curl'
7
+
8
+ module Raindown
9
+ # The values that {placeholders} in an endpoint's curl snippet are replaced with, like {base_url} or {login.token}.
10
+ # From least to most specific: the file's front matter, the project's environment, and the responses of endpoints
11
+ # named with as:, read from the file as it goes. {env.NAME} is the environment variable NAME, for secrets, and an
12
+ # environment's values can use it too, like token: '{env.STAGING_TOKEN}'.
13
+ # The environment is one in .raindown/environments.yml, chosen with --env or RAINDOWN_ENV, or else the first:
14
+ # local:
15
+ # base_url: http://localhost:3000
16
+ # staging:
17
+ # base_url: https://staging.example.com
18
+ class EndpointVariables
19
+ class Error < Curl::Error; end
20
+
21
+ FILE = 'environments.yml'
22
+ PLACEHOLDER = /\{([A-Za-z_][\w.-]*)\}/
23
+
24
+ def initialize(path:, content:)
25
+ @path = path
26
+ @content = content
27
+ end
28
+
29
+ # Keeps a named endpoint's response, as its JSON's data when it's JSON, or else its text.
30
+ def remember(name, body)
31
+ return unless name
32
+
33
+ values[name] = begin
34
+ JSON.parse(body)
35
+ rescue JSON::ParserError
36
+ body.strip
37
+ end
38
+ end
39
+
40
+ # The text with each placeholder that has a value replaced by it, and the rest left as they are. A value's own
41
+ # placeholders are replaced once, so an environment can read secrets like {env.TOKEN}.
42
+ def substitute(text, depth: 1)
43
+ text.gsub(PLACEHOLDER) do |placeholder|
44
+ value = value(Regexp.last_match(1))
45
+ next placeholder if value.nil?
46
+ next value.to_json if value.is_a?(Hash) || value.is_a?(Array)
47
+ next substitute(value, depth: 0) if depth.positive? && value.is_a?(String)
48
+
49
+ value.to_s
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Read when they're first needed, so a file without endpoints doesn't need its environment.
56
+ def values
57
+ @values ||= FrontMatter.parse(@content).first.transform_keys(&:to_s).merge(environment, 'env' => ENV.to_h)
58
+ end
59
+
60
+ def environment
61
+ folder = @path && Nodes.find_folder(File.dirname(File.expand_path(@path)))
62
+ file = folder && File.join(folder, FILE)
63
+ return {} unless file && File.exist?(file)
64
+
65
+ environments = YAML.safe_load_file(file) || {}
66
+ name = ENV.fetch('RAINDOWN_ENV') { environments.keys.first }
67
+ environments.fetch(name) { raise Error, "There's no #{name} environment in #{file}" }
68
+ end
69
+
70
+ # A value by its path, like login.token or users.0.id.
71
+ def value(name)
72
+ name.split('.').reduce(values) do |value, key|
73
+ case value
74
+ when Hash then value.fetch(key) { value[key.to_sym] }
75
+ when Array then value[Integer(key, exception: false) || value.length]
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -15,6 +15,7 @@ require_relative 'card'
15
15
  require_relative 'endpoint'
16
16
  require_relative '../code_blocks'
17
17
  require_relative '../endpoint/curl'
18
+ require_relative '../endpoint/endpoint_variables'
18
19
 
19
20
  module Raindown
20
21
  module TUI
@@ -89,7 +90,7 @@ module Raindown
89
90
 
90
91
  def endpoint(path, lines, directive)
91
92
  snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
92
- request = Curl.request(snippet.to_s)
93
+ request = Curl.request(snippet.to_s, EndpointVariables.new(path:, content: lines.join))
93
94
  Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
94
95
  end
95
96
 
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'antlers/api'
4
+
3
5
  module Raindown
4
6
  # The front matter values that Antlers variables like {title} and <{ title }> render.
5
7
  # Antlers reads a variable from the binding's locals, then by sending it to the binding's receiver, which is this.
6
8
  # It's a BasicObject so a key like "hash" or "display" isn't shadowed by a method, and unknown names render nothing.
7
- # Nested values work too, like {author.name} and <{ for: tag in: tags }>.
9
+ # Nested values are hashes and arrays like {author.name}, {tags.0} or <{ for: name, url in: links }>.
8
10
  class Variables < BasicObject
9
11
  # Braces in a value become the placeholders that render as braces, so Antlers doesn't read them as a variable.
10
12
  def self.wrap(value)
11
13
  case value
12
- when ::Hash then new(value)
14
+ when ::Hash then value.transform_values { |item| wrap(item) }
13
15
  when ::Array then value.map { |item| wrap(item) }
14
16
  when ::String then value.gsub(/[{}]/, CODE_BRACES)
15
17
  else value
@@ -20,6 +22,7 @@ module Raindown
20
22
  def self.unwrap(value)
21
23
  case value
22
24
  when Variables then value.to_h
25
+ when ::Hash then value.transform_values { |item| unwrap(item) }
23
26
  when ::Array then value.map { |item| unwrap(item) }
24
27
  when ::String then value.tr(CODE_BRACES.values.join, CODE_BRACES.keys.join)
25
28
  else value
@@ -28,7 +31,8 @@ module Raindown
28
31
 
29
32
  # A variable's value, like author.name, read the way {author.name} reads it, unwrapped. Unknown names are nil.
30
33
  def self.value(variables, name)
31
- unwrap(name.split('.').reduce(variables) { |value, key| value.respond_to?(key) ? value.send(key) : nil })
34
+ first, *path = name.split('.')
35
+ unwrap(::Antlers::Queries.read_path(value: variables.send(first), path:))
32
36
  end
33
37
 
34
38
  def initialize(values)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.9.1'
4
+ VERSION = '0.10.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.9.1
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-03 00:00:00.000000000 Z
10
+ date: 2026-11-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.2.2
18
+ version: 1.4.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.2.2
25
+ version: 1.4.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: commonmarker
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -213,6 +213,7 @@ files:
213
213
  - lib/raindown/document.rb
214
214
  - lib/raindown/endpoint/curl.rb
215
215
  - lib/raindown/endpoint/endpoint_runner.rb
216
+ - lib/raindown/endpoint/endpoint_variables.rb
216
217
  - lib/raindown/files.rb
217
218
  - lib/raindown/fragments.rb
218
219
  - lib/raindown/front_matter.rb
@@ -307,11 +308,11 @@ files:
307
308
  - lib/raindown/workers/worker.rb
308
309
  - lib/raindown/workers/workers.rb
309
310
  - lib/version.rb
310
- homepage: https://github.com/raindeer-rb/raindown
311
+ homepage: https://github.com/rainpour/raindown
311
312
  licenses: []
312
313
  metadata:
313
- homepage_uri: https://github.com/raindeer-rb/raindown
314
- source_code_uri: https://github.com/raindeer-rb/raindown/src/branch/main
314
+ homepage_uri: https://github.com/rainpour/raindown
315
+ source_code_uri: https://github.com/rainpour/raindown/tree/main
315
316
  rdoc_options: []
316
317
  require_paths:
317
318
  - lib