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 +4 -4
- data/bin/raindown +6 -0
- data/lib/elements.rb +6 -6
- data/lib/lexemes/endpoint_lexeme.rb +23 -0
- data/lib/lexemes/list_lexeme.rb +1 -1
- data/lib/nodes/endpoint_node.rb +33 -0
- data/lib/nodes/list_node.rb +1 -1
- data/lib/raindown/bare_directives.rb +91 -0
- data/lib/raindown/cli.rb +94 -0
- data/lib/raindown/code.rb +36 -0
- data/lib/raindown/code_blocks.rb +48 -0
- data/lib/raindown/config.rb +23 -0
- data/lib/raindown/directives.rb +29 -0
- data/lib/raindown/endpoint/curl.rb +154 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +104 -0
- data/lib/raindown/files.rb +22 -0
- data/lib/raindown/front_matter.rb +27 -0
- data/lib/raindown/node.rb +7 -0
- data/lib/raindown/node_blocks.rb +133 -0
- data/lib/raindown/node_injector.rb +64 -0
- data/lib/raindown/nodes.rb +120 -0
- data/lib/raindown/page.rb +37 -0
- data/lib/raindown/pages.rb +102 -0
- data/lib/raindown/pre_renderer.rb +54 -0
- data/lib/raindown/rewriter.rb +32 -0
- data/lib/raindown/table_formatter.rb +134 -0
- data/lib/raindown/toc.rb +38 -0
- data/lib/raindown/variables.rb +48 -0
- data/lib/raindown/watcher.rb +59 -0
- data/lib/raindown.rb +83 -16
- data/lib/version.rb +1 -1
- metadata +117 -9
- data/lib/lexemes/toc_lexeme.rb +0 -21
- data/lib/nodes/toc_node.rb +0 -41
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab2295aa7f9468fa67aaf9d8192de859220c37cec922ef558607730a0544a86a
|
|
4
|
+
data.tar.gz: 0ede0a653f452c0a9f12f9a73c35dce3df85a12e9ff35350d741ae9c7c0e3522
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f72d0180ff9b4b310c25efa2872e59dc5d31da9dacff67d7d047737cab25e3b6647b2d74fb2a130c9354f0c434cf3a57e5a7c0750a54d762a47121901abcbb83
|
|
7
|
+
data.tar.gz: 3bcc85d7eb3b2055f374028f2bdcc3e561530ea36739e655d96c0b26f5038d7e83104b7201ee9b2654ecd8ea6e550c96d9733412bb2ff07a7f31f5858630c003
|
data/bin/raindown
ADDED
data/lib/elements.rb
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require 'plugs'
|
|
4
4
|
|
|
5
|
-
module
|
|
5
|
+
module Raindown
|
|
6
6
|
class Elements
|
|
7
7
|
include Plugs
|
|
8
8
|
|
|
9
|
-
plug(:
|
|
9
|
+
plug(:endpoint) do
|
|
10
10
|
plug(:lexeme) do
|
|
11
|
-
require_relative 'lexemes/
|
|
12
|
-
|
|
11
|
+
require_relative 'lexemes/endpoint_lexeme'
|
|
12
|
+
EndpointLexeme
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
plug(:node) do
|
|
16
|
-
require_relative 'nodes/
|
|
17
|
-
|
|
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
|
data/lib/lexemes/list_lexeme.rb
CHANGED
|
@@ -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
|
data/lib/nodes/list_node.rb
CHANGED
|
@@ -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
|
data/lib/raindown/cli.rb
ADDED
|
@@ -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
|