raindown 0.1.0 → 0.2.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: 0250c356fb715c70bb5620ebb20f3d9f92775398b1b002c71eec906f4cf3ad20
4
- data.tar.gz: a601014e5ce54762c17db6d888067cd66239a64169ab453621036d95ca82a177
3
+ metadata.gz: ab2295aa7f9468fa67aaf9d8192de859220c37cec922ef558607730a0544a86a
4
+ data.tar.gz: 0ede0a653f452c0a9f12f9a73c35dce3df85a12e9ff35350d741ae9c7c0e3522
5
5
  SHA512:
6
- metadata.gz: 975fbcedd3fc3a2e3c2b853173ac5f5eb882db921462fc4dba2d15cc35e7e8278ff0c44a89e986f8c967dcc73c1f80d59f1348850cddb06e92d409484d18f005
7
- data.tar.gz: 2042a62352c54e59801d300cd49fa5ea7d7ffc65a8f01bd1aa0f213151606d947a7ea614239e148d36e5ccff1f0340714870dc1a383f695b7266990e58632c57
6
+ metadata.gz: f72d0180ff9b4b310c25efa2872e59dc5d31da9dacff67d7d047737cab25e3b6647b2d74fb2a130c9354f0c434cf3a57e5a7c0750a54d762a47121901abcbb83
7
+ data.tar.gz: 3bcc85d7eb3b2055f374028f2bdcc3e561530ea36739e655d96c0b26f5038d7e83104b7201ee9b2654ecd8ea6e550c96d9733412bb2ff07a7f31f5858630c003
data/bin/raindown ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/raindown/cli'
5
+
6
+ Raindown::CLI.run(ARGV)
data/lib/elements.rb CHANGED
@@ -2,19 +2,19 @@
2
2
 
3
3
  require 'plugs'
4
4
 
5
- module Rain
5
+ module Raindown
6
6
  class Elements
7
7
  include Plugs
8
8
 
9
- plug(:toc) do
9
+ plug(:endpoint) do
10
10
  plug(:lexeme) do
11
- require_relative 'lexemes/toc_lexeme'
12
- TOCLexeme
11
+ require_relative 'lexemes/endpoint_lexeme'
12
+ EndpointLexeme
13
13
  end
14
14
 
15
15
  plug(:node) do
16
- require_relative 'nodes/toc_node'
17
- TOCNode
16
+ require_relative 'nodes/endpoint_node'
17
+ EndpointNode
18
18
  end
19
19
  end
20
20
 
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers/api'
4
+
5
+ module Raindown
6
+ module EndpointLexeme
7
+ include Antlers::Lexeme
8
+
9
+ module_function
10
+
11
+ KEYWORDS = ['endpoint:', ':endpoint'].freeze
12
+
13
+ def match?(keywords:, **)
14
+ KEYWORDS.include?(keywords.keys.first)
15
+ end
16
+
17
+ def lexeme(keywords:, **)
18
+ return { endpoint_def: keywords } if keywords.key?('endpoint:')
19
+
20
+ { endpoint_end: 'level_1' }
21
+ end
22
+ end
23
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'antlers/api'
4
4
 
5
- module Rain
5
+ module Raindown
6
6
  module ListLexeme
7
7
  include Antlers::Lexeme
8
8
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers/api'
4
+ require_relative '../raindown/directives'
5
+
6
+ module Raindown
7
+ # Holds an endpoint's curl snippet and response, which render as the code blocks they are,
8
+ # between Directives markers for its directive and end tag.
9
+ class EndpointNode < Antlers::BranchNode
10
+ DEF_KEY = :endpoint_def
11
+ END_KEY = :endpoint_end
12
+
13
+ def initialize(name:, directive: 'endpoint:', **)
14
+ super(name:, **)
15
+
16
+ @directive = directive
17
+ end
18
+
19
+ def render(**)
20
+ "#{Directives.marker(@directive)}\n#{super.sub(/\A\n+/, '')}#{Directives.marker(':endpoint')}"
21
+ end
22
+
23
+ class << self
24
+ def match?(segment:)
25
+ segment[DEF_KEY]
26
+ end
27
+
28
+ def build(segment:, **)
29
+ new(name: :endpoint, directive: segment[DEF_KEY].map { |keyword, value| [keyword, value].compact.join(' ') }.join(' '))
30
+ end
31
+ end
32
+ end
33
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require 'antlers/api'
4
4
 
5
- module Rain
5
+ module Raindown
6
6
  class ListNode < Antlers::BranchNode
7
7
  include Antlers::Props
8
8
  include Antlers::Variables
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'code_blocks'
4
+ require_relative 'front_matter'
5
+
6
+ module Raindown
7
+ # Raindown files (.rd/.raindown) can write a directive like <{ if: draft }> as a bare if: draft on its own line.
8
+ # Markdown files opt in with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
9
+ #
10
+ # Bare directives have no end tag. A bare block directive closes itself after the content it applies to,
11
+ # so where a block needs to wrap more than that, write it with delimiters and an end tag instead:
12
+ # <{ if: draft }>
13
+ # ...
14
+ # <{ :if }>
15
+ module BareDirectives
16
+ EXTENSIONS = %w[.rd .raindown].freeze
17
+ # The block directives, and the method that finds the line their end tag goes after.
18
+ BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint, 'for:' => :after_block, 'if:' => :after_block,
19
+ 'list:' => :after_block }.freeze
20
+ # Four spaces of indent would make the line a Markdown code block.
21
+ LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
22
+ # An end tag on its own line, like one a bare block directive above added, ends a block.
23
+ END_TAG = /\A {0,3}<\{\s*:[a-z][\w-]*\s*\}>[ \t]*$/
24
+
25
+ module_function
26
+
27
+ def enabled?(path: nil, bare: false)
28
+ bare || file?(path)
29
+ end
30
+
31
+ def file?(path)
32
+ return false if path.nil?
33
+
34
+ EXTENSIONS.include?(File.extname(path).downcase)
35
+ end
36
+
37
+ # Wrap each bare directive in Antlers delimiters and close its block, leaving code and front matter as-is.
38
+ def expand(content)
39
+ lines = content.lines
40
+ index = FrontMatter.line_count(lines)
41
+
42
+ while index < lines.length
43
+ block = CodeBlocks.at(lines, index)
44
+ index = (block ? block[:close] : expand_line(lines, index)) + 1
45
+ end
46
+
47
+ lines.join
48
+ end
49
+
50
+ # The directive in Antlers delimiters, or nil when the line isn't a bare directive.
51
+ def wrap(line)
52
+ line.sub(LINE) { "#{::Regexp.last_match(1)}<{ #{::Regexp.last_match(2)} }>" } if line.match?(LINE)
53
+ end
54
+
55
+ # Returns the index of the last line it handled. That's the directive itself, so the lines it holds
56
+ # are expanded too, and a bare directive inside it closes before it does.
57
+ def expand_line(lines, index)
58
+ match = lines[index].match(LINE)
59
+ return index unless match
60
+
61
+ lines[index] = wrap(lines[index])
62
+ close_block(lines, index, indent: match[1], keyword: match[3])
63
+ index
64
+ end
65
+
66
+ def close_block(lines, index, indent:, keyword:)
67
+ last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
68
+ lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
69
+ lines.insert(last + 1, "#{indent}<{ :#{keyword.delete_suffix(':')} }>\n")
70
+ end
71
+
72
+ # The last line of the curl snippet and response below an endpoint.
73
+ def after_endpoint(lines, index)
74
+ CodeBlocks.endpoint(lines, index).last&.fetch(:close)
75
+ end
76
+
77
+ # The last line of the Markdown block below, after any blank lines: up to the next blank line or end tag,
78
+ # with fenced code kept whole. Nil when there's nothing below.
79
+ def after_block(lines, index)
80
+ index = CodeBlocks.skip_blank_lines(lines, index)
81
+ last = nil
82
+
83
+ while index < lines.length && !lines[index].strip.empty? && !lines[index].match?(END_TAG)
84
+ last = CodeBlocks.at(lines, index)&.fetch(:close) || index
85
+ index = last + 1
86
+ end
87
+
88
+ last
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trees'
4
+ require_relative '../raindown'
5
+ require_relative 'rewriter'
6
+ require_relative 'watcher'
7
+
8
+ module Raindown
9
+ module CLI
10
+ extend Trees
11
+
12
+ USAGE = <<~TEXT
13
+ Usage: raindown FILE [options]
14
+
15
+ Renders FILE to HTML, then re-renders it every time it is saved.
16
+
17
+ Options:
18
+ -o, --output PATH Write output to PATH instead of FILE.html beside FILE
19
+ --once Render once without watching, printing to stdout unless -o is given
20
+ --stdin Render Markdown from stdin once, without formatting FILE, which only needs
21
+ its extension (to allow bare directives) and can be unsaved
22
+ --bare Allow bare directives like :toc in Markdown files
23
+ -x, --execute Run endpoint curl snippets that have no output yet
24
+ -h, --help Show this message
25
+ TEXT
26
+
27
+ OPTIONS = { output: ['-o :output', '--output :output'], bare: ['--bare'], execute: ['-x', '--execute'],
28
+ once: ['--once'], stdin: ['--stdin'] }.freeze
29
+
30
+ # Trees matches tokens in order, so there's a line for every order and spelling of the options.
31
+ OPTION_LINES = (0..OPTIONS.size).flat_map { |count| OPTIONS.keys.permutation(count).to_a }.flat_map do |names|
32
+ [[]].product(*names.map { |name| OPTIONS[name] }).map { |flags| [names, flags.flatten] }
33
+ end
34
+
35
+ OPTION_LINES.each do |names, flags|
36
+ # Endpoints run while formatting, which --stdin skips.
37
+ next if names.include?(:stdin) && names.include?(:execute)
38
+
39
+ command = if names.include?(:stdin) then :render_stdin
40
+ elsif names.include?(:once) then :render
41
+ else :watch
42
+ end
43
+ options = { bare: names.include?(:bare), execute: names.include?(:execute) }
44
+ pattern = [':path', *flags].join(' ')
45
+
46
+ if names.include?(:output)
47
+ line(pattern) { |path, output| execute { send(command, path, output, **options) } }
48
+ else
49
+ line(pattern) { |path| execute { send(command, path, **options) } }
50
+ end
51
+ end
52
+
53
+ # The :path line would match a help flag as the file, so help is checked first.
54
+ def self.run(argv)
55
+ abort(USAGE) if argv.empty? || argv.intersect?(%w[-h --help])
56
+
57
+ super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
58
+ end
59
+
60
+ class << self
61
+ private
62
+
63
+ def render(path, output = nil, bare: false, execute: false)
64
+ ensure_exists(path)
65
+ Nodes.load(path)
66
+
67
+ metadata = Pages.for(path)
68
+ Rewriter.update(path:, bare:, execute:, metadata:)
69
+ write(output, Raindown.render_file(path:, bare:, metadata:))
70
+ end
71
+
72
+ def render_stdin(path, output = nil, bare: false, **)
73
+ Nodes.load(path)
74
+ write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
75
+ hide_directives: Config.for(path)[:hide_directives]))
76
+ end
77
+
78
+ def watch(path, output = nil, bare: false, execute: false)
79
+ ensure_exists(path)
80
+ Nodes.load(path)
81
+
82
+ Watcher.new(path:, output:, bare:, execute:).start
83
+ end
84
+
85
+ def write(output, html)
86
+ output ? File.write(output, html) : puts(html)
87
+ end
88
+
89
+ def ensure_exists(path)
90
+ abort("File not found: #{path}") unless File.exist?(path)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'commonmarker'
4
+
5
+ module Raindown
6
+ # Finds code in Markdown the way Commonmarker reads it: code spans and fenced, indented or quoted code blocks.
7
+ module Code
8
+ module_function
9
+
10
+ # Byte ranges of code in the Markdown, from Commonmarker's line and column positions.
11
+ def ranges(markdown)
12
+ line_offsets = markdown.each_line.reduce([0]) { |offsets, line| offsets << (offsets.last + line.bytesize) }
13
+ ranges = []
14
+
15
+ Commonmarker.parse(markdown, options: MARKDOWN_OPTIONS).walk do |node|
16
+ ranges << range(node.source_position, line_offsets) if %i[code code_block].include?(node.type)
17
+ end
18
+
19
+ ranges
20
+ end
21
+
22
+ def range(position, line_offsets)
23
+ start = line_offsets[position[:start_line] - 1] + position[:start_column] - 1
24
+ # A block that ends with its last line ends at column 0 of the line after.
25
+ finish = line_offsets[position[:end_line] - 1] + position[:end_column]
26
+ start...finish
27
+ end
28
+
29
+ # Swaps braces in code for the placeholders that render as braces, so Antlers leaves them alone.
30
+ def hide_braces(markdown)
31
+ ranges(markdown).reverse.each_with_object(markdown.b) do |range, bytes|
32
+ bytes[range] = bytes[range].force_encoding('UTF-8').gsub(/[{}]/, CODE_BRACES).b
33
+ end.force_encoding('UTF-8')
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Finds fenced code blocks in a file's lines.
5
+ module CodeBlocks
6
+ FENCE = /\A {0,3}(`{3,}|~{3,})[ \t]*(\S*)/
7
+ SNIPPET_LANGUAGES = %w[sh bash shell zsh console curl].freeze
8
+
9
+ module_function
10
+
11
+ # The block opening at index, closed by a fence of the same character that's at least as long.
12
+ def at(lines, index)
13
+ fence, language = lines[index]&.match(FENCE)&.captures
14
+ return unless fence
15
+
16
+ close = (index + 1...lines.length).find { |i| lines[i].match?(/\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/) }
17
+ { open: index, close:, language:, body: lines[index + 1...close].join } if close
18
+ end
19
+
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 response.
22
+ def endpoint(lines, index)
23
+ snippet, response = following(lines, index, limit: 2)
24
+ response && snippet?(response) ? [snippet] : [snippet, response].compact
25
+ end
26
+
27
+ def snippet?(block)
28
+ SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
29
+ end
30
+
31
+ # Up to limit blocks from index on, with only blank lines before and between them.
32
+ def following(lines, index, limit:)
33
+ blocks = []
34
+
35
+ while blocks.length < limit && (block = at(lines, skip_blank_lines(lines, index)))
36
+ blocks << block
37
+ index = block[:close] + 1
38
+ end
39
+
40
+ blocks
41
+ end
42
+
43
+ def skip_blank_lines(lines, index)
44
+ index += 1 while lines[index]&.strip&.empty?
45
+ index
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Raindown
6
+ # A project's settings, from config.yml in its .raindown folder:
7
+ # hide_directives: true # Directives become HTML comments rather than bold text in the HTML.
8
+ module Config
9
+ FILE = 'config.yml'
10
+ DEFAULTS = { hide_directives: false }.freeze
11
+
12
+ module_function
13
+
14
+ # The settings for the project a file is in, or the defaults outside one.
15
+ def for(path)
16
+ folder = Nodes.find_folder(File.dirname(File.expand_path(path)))
17
+ file = folder && File.join(folder, FILE)
18
+ return DEFAULTS unless file && File.exist?(file)
19
+
20
+ DEFAULTS.merge(YAML.safe_load_file(file, symbolize_names: true) || {})
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module Raindown
6
+ # Content directives, like a node's directive and end tag or an endpoint's, are kept in the HTML:
7
+ # in bold by default, or as HTML comments with the hide_directives setting.
8
+ # Until then each is a comment marker, which ends the Markdown block before it and starts nothing itself,
9
+ # so a node's list stays its own list.
10
+ # Template directives, like for: and if:, are always invisible, since they often sit inside tables and lists.
11
+ module Directives
12
+ MARKER = /<!-- raindown:directive (.*?) -->/
13
+
14
+ module_function
15
+
16
+ # A directive like "files: folder: 'notes'", or a whole line like <{ :files }>, as a marker.
17
+ def marker(directive)
18
+ text = directive.strip.delete_prefix('<{').delete_suffix('}>').strip.gsub('--', '- -')
19
+ "<!-- raindown:directive #{text} -->"
20
+ end
21
+
22
+ def render(html, hide: false)
23
+ html.gsub(MARKER) do
24
+ text = ::Regexp.last_match(1)
25
+ hide ? "<!-- #{text} -->" : "<p><strong>#{ERB::Util.html_escape(text)}</strong></p>"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+ require 'shellwords'
6
+
7
+ module Raindown
8
+ # Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
9
+ # Snippets come from files that anyone (or any agent) may have written, so only options that make an HTTP request
10
+ # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
11
+ module Curl
12
+ class Error < StandardError; end
13
+
14
+ TIMEOUT = '30'
15
+ # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
16
+ SAFE_OPTIONS = ['--disable', '--proto', '=http,https', '--proto-redir', '=http,https'].freeze
17
+
18
+ OPERATORS = %w[| || & && ; > >> < 2> 2>&1].freeze
19
+ URL = %r{\Ahttps?://}i
20
+
21
+ # Options without a value.
22
+ FLAGS = {
23
+ '-G' => '--get', '-I' => '--head', '-L' => '--location', '-i' => '--include', '-k' => '--insecure',
24
+ '-f' => '--fail', '-g' => '--globoff', '-s' => '--silent', '-S' => '--show-error'
25
+ }.freeze
26
+ LONG_FLAGS = (FLAGS.values + %w[--compressed --http1.1 --http2]).freeze
27
+
28
+ # Options with a value, and how the value is checked.
29
+ VALUE_OPTIONS = {
30
+ '-X' => '--request', '-H' => '--header', '-d' => '--data', '-A' => '--user-agent', '-e' => '--referer',
31
+ '-u' => '--user'
32
+ }.freeze
33
+ VALUE_CHECKS = {
34
+ '--request' => :any, '--user-agent' => :any, '--referer' => :any, '--user' => :any, '--data-raw' => :any,
35
+ '--url' => :url,
36
+ # "@file" reads a file.
37
+ '--header' => :no_file, '--data' => :no_file, '--data-ascii' => :no_file, '--data-binary' => :no_file,
38
+ '--json' => :no_file,
39
+ # "@file" and "name@file" read a file, but "name=a@b" doesn't.
40
+ '--data-urlencode' => :no_urlencoded_file
41
+ }.freeze
42
+
43
+ # Separates the response body from the content type that --write-out appends.
44
+ TYPE_MARKER = "\n--raindown-content-type--"
45
+ WRITE_OUT = "#{TYPE_MARKER}%{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
46
+ LANGUAGES = {
47
+ /json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
48
+ /css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
49
+ }.freeze
50
+
51
+ module_function
52
+
53
+ # Returns the response body and the code block language for its content type.
54
+ def run(snippet)
55
+ stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
56
+ *arguments(snippet), '--write-out', WRITE_OUT)
57
+ raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
58
+
59
+ body, _, content_type = stdout.rpartition(TYPE_MARKER)
60
+ language = language(content_type)
61
+
62
+ [language == 'json' ? pretty_json(body) : body, language]
63
+ rescue Errno::ENOENT
64
+ raise Error, 'curl is not installed'
65
+ end
66
+
67
+ def arguments(snippet)
68
+ words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
69
+ raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
70
+ raise Error, 'Endpoint snippets must be a single curl command' if words.intersect?(OPERATORS)
71
+
72
+ check(words.drop(1))
73
+ rescue ArgumentError => e
74
+ raise Error, "Can't read the curl snippet: #{e.message}"
75
+ end
76
+
77
+ # Returns the arguments with short options spelled long, or raises on anything not allowed.
78
+ def check(words)
79
+ args = []
80
+ words = words.dup
81
+
82
+ until words.empty?
83
+ word = words.shift
84
+
85
+ if word.start_with?('--') then args.concat(long_option(word, words))
86
+ elsif word.start_with?('-') && word.length > 1 then args.concat(short_options(word, words))
87
+ else args << url(word)
88
+ end
89
+ end
90
+
91
+ raise Error, 'Endpoint snippets need a URL' unless args.any? { |arg| arg.match?(URL) }
92
+
93
+ args
94
+ end
95
+
96
+ def long_option(option, words)
97
+ return [option] if LONG_FLAGS.include?(option)
98
+ raise Error, "curl's #{option} option isn't allowed in endpoint snippets" unless VALUE_CHECKS.key?(option)
99
+ raise Error, "curl's #{option} option needs a value" if words.empty?
100
+
101
+ [option, value(option, words.shift)]
102
+ end
103
+
104
+ # Short flags can be grouped like -sSL, and a short option's value can be attached like -XPOST.
105
+ def short_options(word, words)
106
+ letters = word.delete_prefix('-')
107
+ args = []
108
+
109
+ letters.each_char.with_index do |letter, index|
110
+ option = "-#{letter}"
111
+ next args << FLAGS[option] if FLAGS.key?(option)
112
+ raise Error, "curl's #{option} option isn't allowed in endpoint snippets" unless VALUE_OPTIONS.key?(option)
113
+
114
+ # The value is the rest of the word, or else the next word.
115
+ attached = letters[(index + 1)..]
116
+ words.unshift(attached) unless attached.empty?
117
+ return args.concat(long_option(VALUE_OPTIONS[option], words))
118
+ end
119
+
120
+ args
121
+ end
122
+
123
+ def value(option, value)
124
+ case VALUE_CHECKS.fetch(option)
125
+ when :url then url(value)
126
+ when :no_file then no_file(option, value, value.start_with?('@'))
127
+ when :no_urlencoded_file then no_file(option, value, value.match?(/\A[^=]*@/))
128
+ else value
129
+ end
130
+ end
131
+
132
+ def no_file(option, value, reads_file)
133
+ raise Error, "curl's #{option} can't read a file (#{value}) in endpoint snippets" if reads_file
134
+
135
+ value
136
+ end
137
+
138
+ def url(value)
139
+ raise Error, "Endpoint snippets can only request http(s) URLs, not #{value}" unless value.match?(URL)
140
+
141
+ value
142
+ end
143
+
144
+ def language(content_type)
145
+ LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
146
+ end
147
+
148
+ def pretty_json(body)
149
+ JSON.pretty_generate(JSON.parse(body))
150
+ rescue JSON::ParserError
151
+ body
152
+ end
153
+ end
154
+ end