raindown 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/raindown/actions.rb +14 -8
- data/lib/raindown/bare_directives.rb +14 -0
- data/lib/raindown/cli.rb +45 -9
- data/lib/raindown/directives.rb +7 -3
- data/lib/raindown/editing.rb +24 -0
- data/lib/raindown/endpoint/curl.rb +4 -25
- data/lib/raindown/endpoint/curl_response.rb +35 -0
- data/lib/raindown/endpoint/endpoint_args.rb +66 -0
- data/lib/raindown/endpoint/endpoint_runner.rb +57 -23
- data/lib/raindown/endpoint/endpoint_variables.rb +45 -14
- data/lib/raindown/endpoint/schema_block.rb +82 -0
- data/lib/raindown/markdown_nodes.rb +67 -0
- data/lib/raindown/node_blocks.rb +25 -12
- data/lib/raindown/nodes.rb +6 -3
- data/lib/raindown/projects.rb +2 -1
- data/lib/raindown/server.rb +73 -0
- data/lib/raindown/tui/project.rb +3 -2
- data/lib/raindown/watcher/file_watcher.rb +3 -1
- data/lib/raindown/watcher/project.rb +2 -0
- data/lib/raindown/wiki_links.rb +66 -0
- data/lib/raindown.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +22 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1dffe3184c451794454149de18701ac6bd1afc2674b6a41385773d0f052bb2e5
|
|
4
|
+
data.tar.gz: 4e49463244dffc8e9b39873e2137f072f8632daec02c314d928fe09bbb115fd5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9502a0acb5cca634c884f74b8f2fc3e3a83c3fbe5bc635df7fccb779147718563a0a44679b3b3e6a1cf9da9e691026ff55953fc9a6b0aafad9cc48444eec12c6
|
|
7
|
+
data.tar.gz: dc928865b0da23c1b3dd67d859e94a3e718fe370b3d21d9344060cd5019e82fd5cce029b56d6008632690f9f1cc6f782cd41a8c38ca6d233bd06336f29fb7077
|
data/lib/raindown/actions.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Raindown
|
|
|
12
12
|
# Each returns true when the file changed, and raises when it can't, for the tool to show.
|
|
13
13
|
module Actions
|
|
14
14
|
class NotAnEndpoint < StandardError; end
|
|
15
|
+
class FailedCheck < StandardError; end
|
|
15
16
|
|
|
16
17
|
# Editors that open a file at a line with "path:line" rather than "+line path".
|
|
17
18
|
PATH_LINE_EDITORS = %w[hx helix subl zed].freeze
|
|
@@ -26,17 +27,22 @@ module Raindown
|
|
|
26
27
|
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
27
28
|
end
|
|
28
29
|
|
|
29
|
-
# Runs one endpoint's request and writes its response, replacing any response it had
|
|
30
|
+
# Runs one endpoint's request and writes its response, replacing any response it had, then raises when the response
|
|
31
|
+
# fails its checks, like status: 200.
|
|
30
32
|
def execute(path, directive)
|
|
31
|
-
|
|
32
|
-
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
33
|
-
|
|
33
|
+
check_runnable(directive)
|
|
34
34
|
content = Utils.read_markdown(path)
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
runner = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1)
|
|
36
|
+
rewritten = runner.rewrite
|
|
37
|
+
File.write(path, rewritten) unless rewritten == content
|
|
38
|
+
raise FailedCheck, runner.failures.join("\n") if runner.failures.any?
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
rewritten != content
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_runnable(directive)
|
|
44
|
+
raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
|
|
45
|
+
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
40
46
|
end
|
|
41
47
|
|
|
42
48
|
# The command that opens the file at a line in the person's editor, like ["vim", "+12", "notes.md"].
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'code_blocks'
|
|
4
4
|
require_relative 'directives'
|
|
5
|
+
require_relative 'endpoint/schema_block'
|
|
5
6
|
require_relative 'front_matter'
|
|
6
7
|
|
|
7
8
|
module Raindown
|
|
@@ -10,6 +11,8 @@ module Raindown
|
|
|
10
11
|
# matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
|
|
11
12
|
# Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
|
|
12
13
|
#
|
|
14
|
+
# A bare schema: shows in bold, with its fields in a code block below it.
|
|
15
|
+
#
|
|
13
16
|
# Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
|
|
14
17
|
# needs more than that, write it in comments with an end tag instead:
|
|
15
18
|
# <!-- endpoint: -->
|
|
@@ -65,6 +68,9 @@ module Raindown
|
|
|
65
68
|
# Returns the index of the last line it handled. That's the directive itself, so the lines it holds
|
|
66
69
|
# are expanded too, and a bare directive inside it closes before it does.
|
|
67
70
|
def expand_line(lines, index)
|
|
71
|
+
schema = SchemaBlock.at(lines, index, bare: true)
|
|
72
|
+
return expand_schema(lines, index, schema) if schema
|
|
73
|
+
|
|
68
74
|
match = lines[index].match(LINE)
|
|
69
75
|
return index unless match
|
|
70
76
|
|
|
@@ -73,6 +79,14 @@ module Raindown
|
|
|
73
79
|
index
|
|
74
80
|
end
|
|
75
81
|
|
|
82
|
+
# Returns the index of the code block's last line.
|
|
83
|
+
def expand_schema(lines, index, schema)
|
|
84
|
+
indent = schema.fields.first.to_s[/\A[ \t]*/]
|
|
85
|
+
block = [Directives.marker(lines[index], shown: true), "\n```\n", *schema.fields.map { it.delete_prefix(indent) }, "```\n"]
|
|
86
|
+
lines[index..schema.last] = block
|
|
87
|
+
index + block.length - 1
|
|
88
|
+
end
|
|
89
|
+
|
|
76
90
|
# The end tag after the block, then the end tag in bold, as a bare directive's shown.
|
|
77
91
|
def close_block(lines, index, indent:, keyword:)
|
|
78
92
|
last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
|
data/lib/raindown/cli.rb
CHANGED
|
@@ -5,7 +5,9 @@ require 'trees'
|
|
|
5
5
|
require_relative '../raindown'
|
|
6
6
|
require_relative '../version'
|
|
7
7
|
require_relative 'blueprints'
|
|
8
|
+
require_relative 'editing'
|
|
8
9
|
require_relative 'rewriter'
|
|
10
|
+
require_relative 'server'
|
|
9
11
|
require_relative 'watcher/file_watcher'
|
|
10
12
|
require_relative 'watcher/supervisor'
|
|
11
13
|
|
|
@@ -28,16 +30,22 @@ module Raindown
|
|
|
28
30
|
PATH is a file, or a directory (default: current directory) whose projects with a
|
|
29
31
|
.raindown folder are watched
|
|
30
32
|
init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
|
|
33
|
+
server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
|
|
34
|
+
or its Markdown as it would be written back, like --stdin and --stdin --rewrite
|
|
31
35
|
blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
|
|
32
36
|
UI's Blueprints page, as a Stop hook that's given the hook's JSON
|
|
33
37
|
|
|
34
38
|
Options:
|
|
35
|
-
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or
|
|
39
|
+
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT, or to stdout without one or with -, then exit with 1
|
|
40
|
+
if its endpoint responses fail their status: or schema: checks
|
|
36
41
|
--stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
|
|
37
42
|
its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
|
|
43
|
+
--rewrite With --stdin, print its Markdown as it would be written back to FILE, without writing
|
|
44
|
+
FILE, for editors like Obsidian. Add -x to run its endpoints
|
|
38
45
|
--bare Allow bare directives like :toc in Markdown files
|
|
39
46
|
-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
|
|
47
|
+
--env NAME Fill endpoint snippets from the NAME environment in .raindown/environments.yml and
|
|
48
|
+
environments.local.yml
|
|
41
49
|
-h, --help Show this message
|
|
42
50
|
-v, --version Show the version
|
|
43
51
|
|
|
@@ -53,9 +61,11 @@ module Raindown
|
|
|
53
61
|
|
|
54
62
|
# A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
|
|
55
63
|
# 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
|
|
64
|
+
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
65
|
execute do
|
|
58
66
|
ENV['RAINDOWN_ENV'] = env if env
|
|
67
|
+
next rewrite_stdin(path, bare:, execute: execute || x) if stdin && rewrite
|
|
68
|
+
|
|
59
69
|
open_path(path, html:, stdin:, bare:, execute: execute || x)
|
|
60
70
|
end
|
|
61
71
|
end
|
|
@@ -87,6 +97,7 @@ module Raindown
|
|
|
87
97
|
if argv.intersect?(%w[-v --version]) then puts(VERSION)
|
|
88
98
|
elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
|
|
89
99
|
elsif argv == ['blueprints'] then Blueprints.save(JSON.parse($stdin.read))
|
|
100
|
+
elsif argv == ['server'] then serve
|
|
90
101
|
else return false
|
|
91
102
|
end
|
|
92
103
|
|
|
@@ -108,8 +119,10 @@ module Raindown
|
|
|
108
119
|
|
|
109
120
|
FileUtils.mkdir_p(folder)
|
|
110
121
|
File.write(config, CONFIG)
|
|
122
|
+
gitignore = File.join(folder, '.gitignore')
|
|
123
|
+
File.write(gitignore, "#{EndpointVariables::LOCAL_FILE}\n") unless File.exist?(gitignore)
|
|
111
124
|
puts "Created #{config}"
|
|
112
|
-
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/
|
|
125
|
+
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/components'
|
|
113
126
|
end
|
|
114
127
|
|
|
115
128
|
# The terminal UI loads curses, so it's only required when it starts. Without a terminal, the path's watched instead.
|
|
@@ -123,9 +136,9 @@ module Raindown
|
|
|
123
136
|
class << self
|
|
124
137
|
private
|
|
125
138
|
|
|
126
|
-
# Endpoints run while formatting, which --stdin skips, so -x can't go with it.
|
|
139
|
+
# Endpoints run while formatting, which --stdin skips, so -x can't go with it. --html - prints, like --html.
|
|
127
140
|
def open_path(path, html:, stdin:, bare:, execute:)
|
|
128
|
-
output = html unless html
|
|
141
|
+
output = html unless [true, '-'].include?(html)
|
|
129
142
|
abort("-x runs endpoints as the file's formatted, which --stdin doesn't do\n\n#{USAGE}") if stdin && execute
|
|
130
143
|
return render_stdin(path, output, bare:) if stdin
|
|
131
144
|
return render(path, output, bare:, execute:) if html
|
|
@@ -141,12 +154,28 @@ module Raindown
|
|
|
141
154
|
metadata = Pages.for(path)
|
|
142
155
|
Rewriter.update(path:, bare:, execute:, metadata:)
|
|
143
156
|
write(output, Raindown.render_file(path:, bare:, metadata:))
|
|
157
|
+
check_endpoints(path, bare:)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Endpoint responses that fail their checks, like status: 200, are listed, and fail the command, for CI.
|
|
161
|
+
def check_endpoints(path, bare:)
|
|
162
|
+
failures = EndpointRunner.failures(content: Utils.read_markdown(path), path:, bare:)
|
|
163
|
+
return if failures.empty?
|
|
164
|
+
|
|
165
|
+
warn(*failures.map { "Endpoint check failed: #{it}" })
|
|
166
|
+
exit 1
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The Markdown from stdin written back as it would be to the file, and its failed endpoint checks, for an editor
|
|
170
|
+
# to apply to what it's editing.
|
|
171
|
+
def rewrite_stdin(path, bare:, execute:)
|
|
172
|
+
rewritten = Editing.rewrite_markdown($stdin.read, path:, bare:, execute:)
|
|
173
|
+
$stdout.write(rewritten[:output])
|
|
174
|
+
warn(*rewritten[:warnings])
|
|
144
175
|
end
|
|
145
176
|
|
|
146
177
|
def render_stdin(path, output = nil, bare: false, **)
|
|
147
|
-
|
|
148
|
-
write(output, Raindown.render(markdown: $stdin.read, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path),
|
|
149
|
-
**Config.render_options(path)))
|
|
178
|
+
write(output, Editing.render_markdown($stdin.read, path:, bare:))
|
|
150
179
|
end
|
|
151
180
|
|
|
152
181
|
# Watching only writes back to the files, while --html and --stdin render HTML.
|
|
@@ -158,6 +187,13 @@ module Raindown
|
|
|
158
187
|
Watcher::FileWatcher.new(path:, bare:, execute:).start
|
|
159
188
|
end
|
|
160
189
|
|
|
190
|
+
# The answers have stdout to themselves, so what nodes print goes to stderr.
|
|
191
|
+
def serve
|
|
192
|
+
output = $stdout.dup
|
|
193
|
+
$stdout.reopen($stderr)
|
|
194
|
+
Server.new(output:).start
|
|
195
|
+
end
|
|
196
|
+
|
|
161
197
|
def write(output, html)
|
|
162
198
|
output ? File.write(output, html) : puts(html)
|
|
163
199
|
end
|
data/lib/raindown/directives.rb
CHANGED
|
@@ -31,16 +31,20 @@ module Raindown
|
|
|
31
31
|
# Whether the directive's line is bare, without delimiters, so it shows in bold.
|
|
32
32
|
def bare?(line) = text(line) == line.strip
|
|
33
33
|
|
|
34
|
-
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
|
|
34
|
+
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden. Its braces are
|
|
35
|
+
# hidden from Antlers like code's, so a shown chart: {stats} shows as it's written.
|
|
35
36
|
def marker(line, shown: bare?(line))
|
|
36
|
-
text = text(line).gsub('--', '- -')
|
|
37
|
+
text = text(line).gsub('--', '- -').gsub(/[{}]/, CODE_BRACES)
|
|
37
38
|
return "<!-- raindown:hidden #{text} -->" unless shown
|
|
38
39
|
|
|
39
40
|
"<!-- raindown:directive #{text} -->"
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# The braces' entities, like code's, are kept as they are.
|
|
42
44
|
def render(html)
|
|
43
|
-
html.gsub(HIDDEN, '').gsub(MARKER)
|
|
45
|
+
html.gsub(HIDDEN, '').gsub(MARKER) do
|
|
46
|
+
"<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s).gsub(/&(#12[35];)/, '&\1')}</strong></p>"
|
|
47
|
+
end
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../raindown'
|
|
4
|
+
require_relative 'rewriter'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# What an editor asks of Raindown about Markdown it's editing, which needn't be saved: its HTML, or the Markdown as it
|
|
8
|
+
# would be written back to the file, with its failed endpoint checks. "raindown FILE --stdin" and "raindown server"
|
|
9
|
+
# both answer with it.
|
|
10
|
+
module Editing
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def render_markdown(markdown, path:, bare: false)
|
|
14
|
+
Nodes.load(path)
|
|
15
|
+
Raindown.render(markdown:, metadata: Pages.for(path), bare: bare || BareDirectives.file?(path), **Config.render_options(path))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rewrite_markdown(markdown, path:, bare: false, execute: false)
|
|
19
|
+
Nodes.load(path)
|
|
20
|
+
rewritten = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
|
|
21
|
+
{ output: rewritten, warnings: EndpointRunner.failures(content: rewritten, path:, bare:).map { "Endpoint check failed: #{it}" } }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'ipaddr'
|
|
4
|
-
require 'json'
|
|
5
4
|
require 'open3'
|
|
6
5
|
require 'shellwords'
|
|
7
6
|
require 'socket'
|
|
8
7
|
require 'uri'
|
|
8
|
+
require_relative 'curl_response'
|
|
9
9
|
|
|
10
10
|
module Raindown
|
|
11
11
|
# Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
|
|
@@ -48,28 +48,17 @@ module Raindown
|
|
|
48
48
|
'--data-urlencode' => :no_urlencoded_file
|
|
49
49
|
}.freeze
|
|
50
50
|
|
|
51
|
-
# Separates the response body from the content type that --write-out appends.
|
|
52
|
-
TYPE_MARKER = "\n--raindown-content-type--"
|
|
53
|
-
WRITE_OUT = "#{TYPE_MARKER}%{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
|
|
54
|
-
LANGUAGES = {
|
|
55
|
-
/json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
|
|
56
|
-
/css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
|
|
57
|
-
}.freeze
|
|
58
|
-
|
|
59
51
|
module_function
|
|
60
52
|
|
|
61
|
-
# Returns the
|
|
53
|
+
# Returns the Response, with the code block language for its content type.
|
|
62
54
|
# Placeholders like {base_url} are replaced from the variables, word by word, so a value can't add options.
|
|
63
55
|
def run(snippet, variables = nil)
|
|
64
56
|
args = arguments(snippet, variables)
|
|
65
57
|
stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
|
|
66
|
-
*resolve_options(args), *args, '--write-out', WRITE_OUT)
|
|
58
|
+
*resolve_options(args), *args, '--write-out', Response::WRITE_OUT)
|
|
67
59
|
raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
|
|
68
60
|
|
|
69
|
-
|
|
70
|
-
language = language(content_type)
|
|
71
|
-
|
|
72
|
-
[language == 'json' ? pretty_json(body) : body, language]
|
|
61
|
+
Response.parse(stdout)
|
|
73
62
|
rescue Errno::ENOENT
|
|
74
63
|
raise Error, 'curl is not installed'
|
|
75
64
|
end
|
|
@@ -213,15 +202,5 @@ module Raindown
|
|
|
213
202
|
rescue SocketError => e
|
|
214
203
|
raise Error, "Can't resolve #{host}: #{e.message}"
|
|
215
204
|
end
|
|
216
|
-
|
|
217
|
-
def language(content_type)
|
|
218
|
-
LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
def pretty_json(body)
|
|
222
|
-
JSON.pretty_generate(JSON.parse(body))
|
|
223
|
-
rescue JSON::ParserError
|
|
224
|
-
body
|
|
225
|
-
end
|
|
226
205
|
end
|
|
227
206
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
module Curl
|
|
7
|
+
# A response's body, its code block language, and its status code and time, like 200 in 142 milliseconds,
|
|
8
|
+
# read from what curl prints with --write-out WRITE_OUT.
|
|
9
|
+
class Response < Data.define(:body, :language, :status, :milliseconds)
|
|
10
|
+
# Separates the response body from the status, time and content type that --write-out appends.
|
|
11
|
+
INFO_MARKER = "\n--raindown-response-info--"
|
|
12
|
+
WRITE_OUT = "#{INFO_MARKER}%{http_code} %{time_total} %{content_type}".freeze # rubocop:disable Style/FormatStringToken -- curl's syntax, not Ruby's
|
|
13
|
+
LANGUAGES = {
|
|
14
|
+
/json/ => 'json', /xml/ => 'xml', /html/ => 'html', /javascript/ => 'javascript',
|
|
15
|
+
/css/ => 'css', /yaml/ => 'yaml', %r{\Atext/plain} => 'text'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
# JSON is pretty-printed, and other bodies are kept as they are.
|
|
19
|
+
def self.parse(stdout)
|
|
20
|
+
body, _, info = stdout.rpartition(INFO_MARKER)
|
|
21
|
+
status, seconds, content_type = info.split(' ', 3)
|
|
22
|
+
language = LANGUAGES.find { |pattern, _| content_type.to_s.match?(pattern) }&.last || ''
|
|
23
|
+
|
|
24
|
+
new(body: language == 'json' ? pretty_json(body) : body, language:, status: status.to_i,
|
|
25
|
+
milliseconds: (seconds.to_f * 1000).round)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.pretty_json(body)
|
|
29
|
+
JSON.pretty_generate(JSON.parse(body))
|
|
30
|
+
rescue JSON::ParserError
|
|
31
|
+
body
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'schema_block'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# An endpoint directive's arguments: as: names its response for later snippets, and status: and schema: are what its
|
|
8
|
+
# response is checked against, like "as: user status: 200 schema: Schema[id: Integer, email: String | nil]".
|
|
9
|
+
# A schema is a LowType type, read without running it, and is written with Schema[] rather than {}, which Antlers reads,
|
|
10
|
+
# or names a SchemaBlock, like schema: 'User' or schema: Array[User].
|
|
11
|
+
class EndpointArgs < Data.define(:name, :status, :schema)
|
|
12
|
+
KEYWORDS = %w[as: status: schema:].freeze
|
|
13
|
+
|
|
14
|
+
def self.parse(text, schemas: {})
|
|
15
|
+
values = keyword_values(text.to_s)
|
|
16
|
+
new(name: values['as'], status: values['status']&.then { Integer(it, exception: false) },
|
|
17
|
+
schema: values['schema']&.then { SchemaBlock.resolve(it, schemas) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Each keyword's words, up to the next keyword outside brackets, so a schema's own keys, like Schema[status: Integer],
|
|
21
|
+
# stay in it.
|
|
22
|
+
def self.keyword_values(text)
|
|
23
|
+
values = {}
|
|
24
|
+
key = nil
|
|
25
|
+
depth = 0
|
|
26
|
+
|
|
27
|
+
text.split.each do |word|
|
|
28
|
+
next values[key = word.delete_suffix(':')] = [] if depth.zero? && KEYWORDS.include?(word)
|
|
29
|
+
next unless key
|
|
30
|
+
|
|
31
|
+
values[key] << word
|
|
32
|
+
depth += word.count('[(') - word.count('])')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
values.transform_values { it.join(' ') }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# What's wrong with a response, like "expected status 200, received 404" or "id: expected Integer, received "1"".
|
|
39
|
+
# A response fetched before statuses were kept has no status to check.
|
|
40
|
+
def failures(body:, status:)
|
|
41
|
+
failures = []
|
|
42
|
+
failures << "expected status #{self.status}, received #{status}" if self.status && status && status != self.status
|
|
43
|
+
failures.concat(schema_failures(body)) if schema
|
|
44
|
+
failures
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# LowType is only loaded for an endpoint with a schema.
|
|
50
|
+
def schema_failures(body)
|
|
51
|
+
require 'lowtype'
|
|
52
|
+
|
|
53
|
+
Low::TypeParser.parse(schema).errors(data(body)).map do |node|
|
|
54
|
+
"#{node.path.to_s.empty? ? 'response' : node.path}: expected #{node.expected}, received #{node.received}"
|
|
55
|
+
end
|
|
56
|
+
rescue Low::ParseError => e
|
|
57
|
+
["schema: #{e.message}"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def data(body)
|
|
61
|
+
JSON.parse(body)
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
body.strip
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -4,12 +4,13 @@ require 'date'
|
|
|
4
4
|
require_relative '../bare_directives'
|
|
5
5
|
require_relative '../code_blocks'
|
|
6
6
|
require_relative 'curl'
|
|
7
|
+
require_relative 'endpoint_args'
|
|
8
|
+
require_relative 'schema_block'
|
|
7
9
|
require_relative 'endpoint_variables'
|
|
8
10
|
|
|
9
11
|
module Raindown
|
|
10
12
|
# Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
|
|
11
|
-
# block below it,
|
|
12
|
-
# followed by the date it was fetched.
|
|
13
|
+
# block below it, followed by the date it was fetched, its status and how long it took.
|
|
13
14
|
# Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
|
|
14
15
|
# Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
|
|
15
16
|
# <!-- endpoint: -->
|
|
@@ -19,10 +20,11 @@ module Raindown
|
|
|
19
20
|
# ```json
|
|
20
21
|
# { "id": 1 }
|
|
21
22
|
# ```
|
|
22
|
-
# <!-- raindown:fetched 2026-10-14 -->
|
|
23
|
+
# <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
|
|
23
24
|
# <!-- :endpoint -->
|
|
24
25
|
# Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
|
|
25
|
-
# response, like {login.token}.
|
|
26
|
+
# response, like {login.token}. With status: and schema:, each response in the file is checked, and failures are kept.
|
|
27
|
+
# A schema: can name a SchemaBlock above it, like schema: 'User', or one in .raindown/schemas.rd.
|
|
26
28
|
# One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
|
|
27
29
|
class EndpointRunner
|
|
28
30
|
# An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
|
|
@@ -32,17 +34,28 @@ module Raindown
|
|
|
32
34
|
END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
|
|
33
35
|
# The date a response was fetched, on the line after it.
|
|
34
36
|
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
37
|
+
FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
|
|
38
|
+
|
|
39
|
+
attr_reader :failures
|
|
35
40
|
|
|
36
41
|
def self.rewrite(content:, path: nil, bare: false, execute: false, **)
|
|
37
42
|
new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
|
|
38
43
|
end
|
|
39
44
|
|
|
45
|
+
# The file's failed checks, like "api.md:3: expected status 200, received 404", from the responses in it.
|
|
46
|
+
def self.failures(content:, path: nil, bare: false)
|
|
47
|
+
new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:)).tap(&:rewrite).failures
|
|
48
|
+
end
|
|
49
|
+
|
|
40
50
|
def initialize(content:, path: nil, bare: false, execute: false, only: nil)
|
|
41
51
|
@lines = content.lines
|
|
52
|
+
@path = path
|
|
42
53
|
@variables = EndpointVariables.new(path:, content:)
|
|
43
54
|
@bare = bare
|
|
44
55
|
@execute = execute
|
|
45
56
|
@only = only
|
|
57
|
+
@failures = []
|
|
58
|
+
@schemas = SchemaBlock.shared(path)
|
|
46
59
|
end
|
|
47
60
|
|
|
48
61
|
def rewrite
|
|
@@ -51,6 +64,9 @@ module Raindown
|
|
|
51
64
|
while index < @lines.length
|
|
52
65
|
if (block = CodeBlocks.at(@lines, index))
|
|
53
66
|
index = block[:close]
|
|
67
|
+
elsif (schema = SchemaBlock.at(@lines, index, bare: @bare))
|
|
68
|
+
@schemas[schema.name] = SchemaBlock.resolve(schema.type, @schemas)
|
|
69
|
+
index = schema.last
|
|
54
70
|
elsif (args = directive_args(@lines[index]))
|
|
55
71
|
index = fill(index + 1, args, directive: index)
|
|
56
72
|
end
|
|
@@ -67,23 +83,33 @@ module Raindown
|
|
|
67
83
|
def directive_args(line)
|
|
68
84
|
args = line[COMMENT, 1]
|
|
69
85
|
args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
|
|
70
|
-
args
|
|
86
|
+
EndpointArgs.parse(args, schemas: @schemas) if args
|
|
71
87
|
end
|
|
72
88
|
|
|
73
89
|
# Returns the index of the last line it handled.
|
|
74
90
|
def fill(index, args, directive:)
|
|
75
91
|
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
76
92
|
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
77
|
-
return write_output(curl:, output:,
|
|
93
|
+
return write_output(curl:, output:, args:, directive:) if run?(output, directive)
|
|
94
|
+
return curl[:close] unless output
|
|
78
95
|
|
|
79
|
-
@variables.remember(
|
|
96
|
+
@variables.remember(args.name, output[:body])
|
|
97
|
+
check(args, directive:, body: output[:body], status: fetched_status(output))
|
|
80
98
|
curl[:close]
|
|
81
99
|
end
|
|
82
100
|
|
|
83
|
-
# The
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
101
|
+
# The status in the fetched comment after a response, which responses fetched before statuses were kept don't have.
|
|
102
|
+
def fetched_status(output)
|
|
103
|
+
@lines[output[:close] + 1]&.[](FETCHED_STATUS, 1)&.to_i
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
|
|
107
|
+
# to check, while one that ran has a status even when its body is empty.
|
|
108
|
+
def check(args, directive:, body:, status:)
|
|
109
|
+
return if (@only && directive != @only) || (body.strip.empty? && status.nil?)
|
|
110
|
+
|
|
111
|
+
location = [@path, directive + 1].compact.join(':')
|
|
112
|
+
@failures.concat(args.failures(body:, status:).map { "#{location}: #{it}" })
|
|
87
113
|
end
|
|
88
114
|
|
|
89
115
|
def run?(output, directive)
|
|
@@ -93,21 +119,29 @@ module Raindown
|
|
|
93
119
|
end
|
|
94
120
|
|
|
95
121
|
# Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
122
|
+
# Secrets that a response echoes are written as their placeholders.
|
|
123
|
+
def write_output(curl:, output:, args:, directive:)
|
|
124
|
+
response = Curl.run(curl[:body], @variables)
|
|
125
|
+
body = @variables.redact(response.body)
|
|
126
|
+
@variables.remember(args.name, body)
|
|
127
|
+
check(args, directive:, body:, status: response.status)
|
|
128
|
+
replace_output(curl:, output:, block: [*output_block(body, response.language), fetched(response)])
|
|
129
|
+
rescue Curl::Error => e
|
|
130
|
+
message = @variables.redact(e.message)
|
|
131
|
+
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
132
|
+
raise Curl::Error, message if @only
|
|
133
|
+
|
|
134
|
+
warn "Endpoint failed: #{message}"
|
|
135
|
+
curl[:close]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Returns the index of the block's last line.
|
|
139
|
+
def replace_output(curl:, output:, block:)
|
|
100
140
|
block.unshift("\n") unless output
|
|
101
141
|
start = output ? output[:open] : curl[:close] + 1
|
|
102
142
|
|
|
103
143
|
@lines[start, output ? replaced_length(output) : 0] = block
|
|
104
144
|
start + block.length - 1
|
|
105
|
-
rescue Curl::Error => e
|
|
106
|
-
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
107
|
-
raise if @only
|
|
108
|
-
|
|
109
|
-
warn "Endpoint failed: #{e.message}"
|
|
110
|
-
curl[:close]
|
|
111
145
|
end
|
|
112
146
|
|
|
113
147
|
# The output block, and the line after it when that's the date it was last fetched.
|
|
@@ -116,8 +150,8 @@ module Raindown
|
|
|
116
150
|
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
117
151
|
end
|
|
118
152
|
|
|
119
|
-
def fetched
|
|
120
|
-
"<!-- raindown:fetched #{Date.today.iso8601} -->\n"
|
|
153
|
+
def fetched(response)
|
|
154
|
+
"<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
|
|
121
155
|
end
|
|
122
156
|
|
|
123
157
|
# The fence is longer than any run of backticks in the body, so the body can't close it.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'yaml'
|
|
5
|
+
require 'antlers/api'
|
|
5
6
|
require_relative '../front_matter'
|
|
6
7
|
require_relative 'curl'
|
|
7
8
|
|
|
@@ -15,15 +16,22 @@ module Raindown
|
|
|
15
16
|
# base_url: http://localhost:3000
|
|
16
17
|
# staging:
|
|
17
18
|
# base_url: https://staging.example.com
|
|
19
|
+
# Secrets go in environments.local.yml beside it, which git ignores, and which adds to and overrides its environments.
|
|
20
|
+
# Its values and env's are secret, so a response or error that echoes one is written with its placeholder instead.
|
|
18
21
|
class EndpointVariables
|
|
19
22
|
class Error < Curl::Error; end
|
|
20
23
|
|
|
21
24
|
FILE = 'environments.yml'
|
|
25
|
+
LOCAL_FILE = 'environments.local.yml'
|
|
26
|
+
# Shorter values, like a port or a flag, would be replaced wherever they happen to appear in a response.
|
|
27
|
+
SECRET_LENGTH = 4
|
|
22
28
|
PLACEHOLDER = /\{([A-Za-z_][\w.-]*)\}/
|
|
23
29
|
|
|
24
30
|
def initialize(path:, content:)
|
|
25
31
|
@path = path
|
|
26
32
|
@content = content
|
|
33
|
+
@local_names = []
|
|
34
|
+
@secrets = {}
|
|
27
35
|
end
|
|
28
36
|
|
|
29
37
|
# Keeps a named endpoint's response, as its JSON's data when it's JSON, or else its text.
|
|
@@ -41,15 +49,22 @@ module Raindown
|
|
|
41
49
|
# placeholders are replaced once, so an environment can read secrets like {env.TOKEN}.
|
|
42
50
|
def substitute(text, depth: 1)
|
|
43
51
|
text.gsub(PLACEHOLDER) do |placeholder|
|
|
44
|
-
|
|
52
|
+
name = Regexp.last_match(1)
|
|
53
|
+
value = value(name)
|
|
45
54
|
next placeholder if value.nil?
|
|
46
55
|
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
56
|
|
|
49
|
-
value.to_s
|
|
57
|
+
value = depth.positive? && value.is_a?(String) ? substitute(value, depth: 0) : value.to_s
|
|
58
|
+
@secrets[value] = placeholder if secret?(name) && value.length >= SECRET_LENGTH
|
|
59
|
+
value
|
|
50
60
|
end
|
|
51
61
|
end
|
|
52
62
|
|
|
63
|
+
# The text with each secret that was filled in put back as its placeholder, like {env.TOKEN}, longest first.
|
|
64
|
+
def redact(text)
|
|
65
|
+
@secrets.keys.sort_by { -it.length }.reduce(text) { |redacted, secret| redacted.gsub(secret, @secrets[secret]) }
|
|
66
|
+
end
|
|
67
|
+
|
|
53
68
|
private
|
|
54
69
|
|
|
55
70
|
# Read when they're first needed, so a file without endpoints doesn't need its environment.
|
|
@@ -59,22 +74,38 @@ module Raindown
|
|
|
59
74
|
|
|
60
75
|
def environment
|
|
61
76
|
folder = @path && Nodes.find_folder(File.dirname(File.expand_path(@path)))
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
return {} unless folder
|
|
78
|
+
|
|
79
|
+
shared, local = [FILE, LOCAL_FILE].map { environments(File.join(folder, it)) }
|
|
80
|
+
return {} if shared.empty? && local.empty?
|
|
81
|
+
|
|
82
|
+
name = environment_name(shared, local)
|
|
83
|
+
@local_names = local.fetch(name, {}).keys
|
|
84
|
+
shared.fetch(name, {}).merge(local.fetch(name, {}))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The one --env or RAINDOWN_ENV names, or else the first.
|
|
88
|
+
def environment_name(shared, local)
|
|
89
|
+
name = ENV.fetch('RAINDOWN_ENV') { (shared.keys + local.keys).first }
|
|
90
|
+
return name if shared.key?(name) || local.key?(name)
|
|
64
91
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
raise Error, "There's no #{name} environment in #{Nodes::FOLDER}/#{FILE}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def environments(file)
|
|
96
|
+
return {} unless File.exist?(file)
|
|
97
|
+
|
|
98
|
+
YAML.safe_load_file(file) || {}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def secret?(name)
|
|
102
|
+
first = name.split('.').first
|
|
103
|
+
first == 'env' || @local_names.include?(first)
|
|
68
104
|
end
|
|
69
105
|
|
|
70
106
|
# A value by its path, like login.token or users.0.id.
|
|
71
107
|
def value(name)
|
|
72
|
-
name.split('.')
|
|
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
|
|
108
|
+
Antlers::Queries.read_path(value: values, path: name.split('.'))
|
|
78
109
|
end
|
|
79
110
|
end
|
|
80
111
|
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../nodes'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# A schema named for endpoints to check their responses against: a schema directive with its fields below it,
|
|
7
|
+
# indented, which become a LowType Schema[], where a key with no type holds the more indented fields below it:
|
|
8
|
+
# <!-- schema: 'User' schema: 'User'
|
|
9
|
+
# name: String name: String
|
|
10
|
+
# address: address:
|
|
11
|
+
# city: String city: String
|
|
12
|
+
# -->
|
|
13
|
+
# is Schema[name: String, address: Schema[city: String]]. Bare, in a Raindown file, its fields end at the first line
|
|
14
|
+
# that isn't indented. An endpoint below it names it, like schema: 'User' or schema: Array[User], as can a schema.
|
|
15
|
+
# Schemas in the project's .raindown/schemas.rd, written the same way, are named for every file's endpoints.
|
|
16
|
+
class SchemaBlock < Data.define(:name, :fields, :last)
|
|
17
|
+
# The name is quoted or not, like 'User' or User.
|
|
18
|
+
COMMENT = /\A {0,3}<!--\s*schema:\s*(['"]?)([A-Z]\w*)\1\s*$/
|
|
19
|
+
BARE = /\A {0,3}schema:\s*(['"]?)([A-Z]\w*)\1\s*$/
|
|
20
|
+
FIELD = /\A([ \t]*)(\S+:)[ \t]*(.*?)\s*\z/
|
|
21
|
+
FILE = 'schemas.rd'
|
|
22
|
+
|
|
23
|
+
# The schema starting at index, or nil when the line doesn't start one.
|
|
24
|
+
def self.at(lines, index, bare: false)
|
|
25
|
+
if (name = lines[index][COMMENT, 2])
|
|
26
|
+
close = (index + 1...lines.length).find { lines[it].strip == '-->' }
|
|
27
|
+
new(name:, fields: lines[index + 1...close], last: close) if close
|
|
28
|
+
elsif bare && (name = lines[index][BARE, 2])
|
|
29
|
+
last = index
|
|
30
|
+
last += 1 while lines[last + 1]&.match?(/\A[ \t]+\S/)
|
|
31
|
+
new(name:, fields: lines[index + 1..last], last:)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The schemas in the project's .raindown/schemas.rd, by name.
|
|
36
|
+
def self.shared(path)
|
|
37
|
+
folder = path && Nodes.find_folder(File.dirname(File.expand_path(path)))
|
|
38
|
+
file = folder && File.join(folder, FILE)
|
|
39
|
+
return {} unless file && File.exist?(file)
|
|
40
|
+
|
|
41
|
+
lines = File.readlines(file)
|
|
42
|
+
schemas = {}
|
|
43
|
+
index = 0
|
|
44
|
+
|
|
45
|
+
while index < lines.length
|
|
46
|
+
if (block = at(lines, index, bare: true))
|
|
47
|
+
schemas[block.name] = resolve(block.type, schemas)
|
|
48
|
+
index = block.last
|
|
49
|
+
end
|
|
50
|
+
index += 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
schemas
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A schema: argument with the schemas it names written out, like Array[User] as Array[Schema[name: String]].
|
|
57
|
+
def self.resolve(text, schemas)
|
|
58
|
+
text = text.strip
|
|
59
|
+
text = text[1...-1] if text.match?(/\A(['"]).*\1\z/)
|
|
60
|
+
text.gsub(/\b[A-Z]\w*\b/) { schemas.fetch(it, it) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Each field's indent, key and type, as Schema[key: Type].
|
|
64
|
+
def self.type(fields)
|
|
65
|
+
pairs = []
|
|
66
|
+
|
|
67
|
+
until fields.empty?
|
|
68
|
+
indent, key, value = fields.shift
|
|
69
|
+
nested = []
|
|
70
|
+
nested << fields.shift while fields.first && fields.first[0].length > indent.length
|
|
71
|
+
value = type(nested) if nested.any?
|
|
72
|
+
pairs << "#{key} #{value}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
"Schema[#{pairs.join(', ')}]"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def type
|
|
79
|
+
self.class.type(fields.filter_map { it.match(FIELD)&.captures })
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'front_matter'
|
|
4
|
+
require_relative 'node'
|
|
5
|
+
require_relative 'variables'
|
|
6
|
+
|
|
7
|
+
module Raindown
|
|
8
|
+
# Markdown Components, the .md and .rd files in a project's .raindown folder named like a component, like BookCard.md,
|
|
9
|
+
# which write their Markdown with their props as variables, so they need no code:
|
|
10
|
+
# --- BookCard: title: Dune rating: 5
|
|
11
|
+
# rating: 0
|
|
12
|
+
# ---
|
|
13
|
+
# **{title}** {rating}/5
|
|
14
|
+
# Its front matter is its props' defaults, and a value before its props, like Dune in BookCard: Dune, is {value}.
|
|
15
|
+
# Its template is read each time it's written, so changing it changes the next render.
|
|
16
|
+
module MarkdownNodes
|
|
17
|
+
EXTENSIONS = %w[.md .rd].freeze
|
|
18
|
+
NAME = /\A[A-Z]\w*\z/
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def load(folder)
|
|
23
|
+
Dir.glob(EXTENSIONS.map { "*#{it}" }, base: folder).each do |file|
|
|
24
|
+
name = File.basename(file, '.*')
|
|
25
|
+
node_classes[name] ||= MarkdownNode.for(name:, path: File.join(folder, file)) if name.match?(NAME)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Whether a file is a Markdown Component, by its extension and name, so .raindown/blueprints.md isn't one.
|
|
30
|
+
def file?(path)
|
|
31
|
+
EXTENSIONS.include?(File.extname(path)) && File.basename(path, '.*').match?(NAME)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def node_class(name)
|
|
35
|
+
node_classes[name]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def node_classes
|
|
39
|
+
@node_classes ||= {}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A Markdown Component, made with its props, which writes its template's Markdown.
|
|
44
|
+
class MarkdownNode < Node
|
|
45
|
+
def self.for(name:, path:)
|
|
46
|
+
Class.new(self) do
|
|
47
|
+
define_singleton_method(:name) { name }
|
|
48
|
+
define_singleton_method(:path) { path }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(value = nil, **props)
|
|
53
|
+
super()
|
|
54
|
+
@props = props
|
|
55
|
+
@props = { value:, **props } unless value.nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Braces that the template pass hid, in its code or its props' values, are written back as braces.
|
|
59
|
+
# The template pass loads here, as it loads nodes itself.
|
|
60
|
+
def replace(metadata: NoMetadata, bare: false)
|
|
61
|
+
require_relative 'pre_renderer'
|
|
62
|
+
defaults, template = FrontMatter.parse(File.read(self.class.path))
|
|
63
|
+
markdown = PreRenderer.render(template, variables: Variables.new(defaults.merge(@props)), metadata:, bare:)
|
|
64
|
+
Variables.unwrap(markdown.sub(/\A(?:[ \t]*\n)+/, '').rstrip)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/raindown/node_blocks.rb
CHANGED
|
@@ -21,6 +21,7 @@ module Raindown
|
|
|
21
21
|
# a block when it's a loaded node, so other text like "Note:" is left alone.
|
|
22
22
|
module NodeBlocks
|
|
23
23
|
class MissingEndTag < StandardError; end
|
|
24
|
+
class BrokenText < StandardError; end
|
|
24
25
|
|
|
25
26
|
NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
|
|
26
27
|
OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
@@ -28,11 +29,13 @@ module Raindown
|
|
|
28
29
|
BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
|
|
29
30
|
SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
|
|
30
31
|
BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
|
|
32
|
+
# Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
|
|
33
|
+
UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
|
|
31
34
|
# A prop is name=value or name: value, where the value is quoted text, a number, true, false
|
|
32
|
-
# or a variable like author.name.
|
|
33
|
-
PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'
|
|
35
|
+
# or a variable like author.name. After name= it's one word.
|
|
36
|
+
PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S+|#{UNQUOTED})/
|
|
34
37
|
# A value before the props is the node's argument.
|
|
35
|
-
ARG = /\A("[^"]*"|'[^']*'|[^\s"']
|
|
38
|
+
ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(?:\s+|\z)/
|
|
36
39
|
|
|
37
40
|
module_function
|
|
38
41
|
|
|
@@ -43,7 +46,8 @@ module Raindown
|
|
|
43
46
|
variables = Variables.new(front_matter)
|
|
44
47
|
|
|
45
48
|
spans(lines, bare:).map do |span|
|
|
46
|
-
|
|
49
|
+
node = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
|
|
50
|
+
span.except(:name, :class, :args, :antlers).merge(node:)
|
|
47
51
|
end
|
|
48
52
|
end
|
|
49
53
|
|
|
@@ -99,7 +103,7 @@ module Raindown
|
|
|
99
103
|
found, pattern = directive(lines[index], bare:)
|
|
100
104
|
return unless found && (klass = Nodes.node_class(found[2]))
|
|
101
105
|
|
|
102
|
-
span = { name: found[2], class: klass, args: found[3].to_s }
|
|
106
|
+
span = { name: found[2], class: klass, args: found[3].to_s, antlers: %i[open self_closing].include?(pattern) }
|
|
103
107
|
case pattern
|
|
104
108
|
when :open, :comment_open
|
|
105
109
|
end_tag(lines, index, found[2], comment: pattern == :comment_open)
|
|
@@ -141,21 +145,30 @@ module Raindown
|
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
# The props, after a value that's the node's argument when it comes first, like 'data.csv' in chart: 'data.csv'.
|
|
144
|
-
def args(text, variables)
|
|
148
|
+
def args(text, variables, antlers: true)
|
|
145
149
|
arg = text.match(ARG) unless text.match?(/\A#{PROP}/)
|
|
146
|
-
return [props(text, variables)] unless arg
|
|
150
|
+
return [props(text, variables, antlers:)] unless arg
|
|
147
151
|
|
|
148
|
-
[props(arg.post_match, variables), [value(arg[1], variables)]]
|
|
152
|
+
[props(arg.post_match, variables, antlers:), [value(arg[1], variables, antlers:)]]
|
|
149
153
|
end
|
|
150
154
|
|
|
151
|
-
|
|
152
|
-
|
|
155
|
+
# Text that isn't a prop, like world in title: 'Hello' world or tag=a b, is an error rather than left out.
|
|
156
|
+
def props(text, variables, antlers: true)
|
|
157
|
+
unread = text.gsub(PROP, '').split
|
|
158
|
+
raise BrokenText, "Can't read #{unread.join(' ')} in #{text.strip}. Text with spaces needs quotes" if unread.any?
|
|
159
|
+
|
|
160
|
+
text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables, antlers:)] }
|
|
153
161
|
end
|
|
154
162
|
|
|
155
|
-
# A value, like 'text', 3, true, nil or :draft, or else a variable, like
|
|
156
|
-
|
|
163
|
+
# A value, like 'text', 3, true, nil or :draft, or else a variable. In Antlers' delimiters a word is a variable, like
|
|
164
|
+
# author.name, while bare or in a comment, which read like Markdown, it's text, like notes in files: folder: notes,
|
|
165
|
+
# and only one in braces is a variable, like {author.name}.
|
|
166
|
+
def value(value, variables, antlers: true)
|
|
157
167
|
Lowkey::ValueParser.parse(value) do
|
|
158
168
|
next value[1..-2] if value.match?(/\A(["']).*\1\z/)
|
|
169
|
+
next Variables.value(variables, value[1..-2]) if !antlers && value.match?(/\A\{.+\}\z/)
|
|
170
|
+
next value unless antlers
|
|
171
|
+
raise BrokenText, "Can't read #{value} as a variable. Text with spaces needs quotes" if value.match?(/\s/)
|
|
159
172
|
|
|
160
173
|
Variables.value(variables, value)
|
|
161
174
|
end
|
data/lib/raindown/nodes.rb
CHANGED
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
require_relative 'chart/chart'
|
|
4
4
|
require_relative 'files'
|
|
5
5
|
require_relative 'js/js_nodes'
|
|
6
|
+
require_relative 'markdown_nodes'
|
|
6
7
|
require_relative 'node'
|
|
7
8
|
require_relative 'stat'
|
|
8
9
|
require_relative 'toc'
|
|
9
10
|
|
|
10
11
|
module Raindown
|
|
11
12
|
# Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
|
|
12
|
-
# A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript
|
|
13
|
+
# A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
|
|
14
|
+
# Components, if any.
|
|
13
15
|
# A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
|
|
14
16
|
# document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
|
|
15
17
|
module Nodes
|
|
@@ -31,6 +33,7 @@ module Raindown
|
|
|
31
33
|
loaded << folder
|
|
32
34
|
dirload(folder)
|
|
33
35
|
JSNodes.load(folder)
|
|
36
|
+
MarkdownNodes.load(folder)
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
def find_folder(directory)
|
|
@@ -56,12 +59,12 @@ module Raindown
|
|
|
56
59
|
loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
|
|
57
60
|
end
|
|
58
61
|
|
|
59
|
-
# A Ruby node of the name, or else a JavaScript one.
|
|
62
|
+
# A Ruby node of the name, or else a JavaScript one, or else a Markdown one.
|
|
60
63
|
def loaded_class(name)
|
|
61
64
|
klass = Object.const_get(name) if Object.const_defined?(name)
|
|
62
65
|
return klass if node?(klass)
|
|
63
66
|
|
|
64
|
-
JSNodes.node_class(name)
|
|
67
|
+
JSNodes.node_class(name) || MarkdownNodes.node_class(name)
|
|
65
68
|
end
|
|
66
69
|
|
|
67
70
|
# A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
|
data/lib/raindown/projects.rb
CHANGED
|
@@ -69,7 +69,8 @@ module Raindown
|
|
|
69
69
|
|
|
70
70
|
# Whether a file is one of a project's own nodes, which load when its watcher starts.
|
|
71
71
|
def node?(path, root:)
|
|
72
|
-
File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) &&
|
|
72
|
+
File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) &&
|
|
73
|
+
(NODE_EXTENSIONS.include?(File.extname(path)) || MarkdownNodes.file?(path))
|
|
73
74
|
end
|
|
74
75
|
|
|
75
76
|
# The roots that aren't inside another of them, as a project's code includes the projects inside it.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../version'
|
|
5
|
+
require_relative 'editing'
|
|
6
|
+
require_relative 'projects'
|
|
7
|
+
require_relative 'workers/workers'
|
|
8
|
+
|
|
9
|
+
module Raindown
|
|
10
|
+
# "raindown server" keeps Raindown loaded for editors, like the Obsidian plugin and the VS Code extension, so a render
|
|
11
|
+
# doesn't start Ruby again. Once it's ready, it says so, then answers a JSON request a line, in order:
|
|
12
|
+
# {"ready": true, "version": "0.10.0"}
|
|
13
|
+
# {"id": 1, "path": "/notes/a.md", "markdown": "<!-- toc: -->", "rewrite": true}
|
|
14
|
+
# {"id": 1, "output": "<!-- toc: -->\n- ...", "warnings": []}
|
|
15
|
+
# Rewriting answers like --stdin --rewrite, with "execute" like -x, and otherwise it's HTML, like --stdin. An error's
|
|
16
|
+
# answered as {"id": 1, "error": "raindown: /notes/a.md: ..."}, like the CLI writes it.
|
|
17
|
+
# Each project renders in a worker of its own, which starts again when its .raindown folder changes, so its nodes,
|
|
18
|
+
# config and schemas are never stale. Without fork, as on Windows, node changes need the server started again.
|
|
19
|
+
class Server
|
|
20
|
+
def initialize(input: $stdin, output: $stdout)
|
|
21
|
+
@input = input
|
|
22
|
+
@output = output
|
|
23
|
+
@workers = Workers.new(root: Dir.pwd) { Editing }
|
|
24
|
+
@stamps = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def start
|
|
28
|
+
write(ready: true, version: VERSION)
|
|
29
|
+
@input.each_line { |line| write(answer(line)) }
|
|
30
|
+
ensure
|
|
31
|
+
@workers.stop
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def answer(line)
|
|
37
|
+
request = JSON.parse(line)
|
|
38
|
+
{ id: request['id'], **edit(request) }
|
|
39
|
+
rescue JSON::ParserError, KeyError => e
|
|
40
|
+
{ id: request&.dig('id'), error: "raindown: #{e.message}" }
|
|
41
|
+
rescue Workers::Worker::Error => e
|
|
42
|
+
{ id: request['id'], error: "raindown: #{request['path']}: #{e.message}" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def edit(request)
|
|
46
|
+
path = File.expand_path(request.fetch('path'))
|
|
47
|
+
options = { path:, bare: request['bare'] == true }
|
|
48
|
+
worker = worker_for(path)
|
|
49
|
+
return worker.call(:rewrite_markdown, request.fetch('markdown'), **options, execute: request['execute'] == true) if request['rewrite']
|
|
50
|
+
|
|
51
|
+
{ output: worker.call(:render_markdown, request.fetch('markdown'), **options) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The project's worker, started again first when a file in its .raindown folder was added, changed or removed.
|
|
55
|
+
def worker_for(path)
|
|
56
|
+
project = Projects.root(path) || @workers.root
|
|
57
|
+
@workers.restart(project) if nodes_changed?(project)
|
|
58
|
+
@workers[project]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def nodes_changed?(project)
|
|
62
|
+
stamp = Dir.glob(File.join(project, Nodes::FOLDER, '**', '*')).to_h { [it, File.mtime(it)] }
|
|
63
|
+
changed = @stamps.key?(project) && @stamps[project] != stamp
|
|
64
|
+
@stamps[project] = stamp
|
|
65
|
+
changed
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def write(message)
|
|
69
|
+
@output.puts(JSON.generate(message))
|
|
70
|
+
@output.flush
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/raindown/tui/project.rb
CHANGED
|
@@ -90,8 +90,9 @@ module Raindown
|
|
|
90
90
|
|
|
91
91
|
def endpoint(path, lines, directive)
|
|
92
92
|
snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
variables = EndpointVariables.new(path:, content: lines.join)
|
|
94
|
+
request = Curl.request(snippet.to_s, variables)
|
|
95
|
+
Endpoint.new(project: root, path:, line: directive.line, method: request.method, url: variables.redact(request.url)) if request
|
|
95
96
|
end
|
|
96
97
|
|
|
97
98
|
def charts_in(path)
|
|
@@ -51,7 +51,9 @@ module Raindown
|
|
|
51
51
|
|
|
52
52
|
# The files' metadata is loaded again each time, so new and renamed files are listed.
|
|
53
53
|
def rewrite
|
|
54
|
-
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
54
|
+
changed = Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
55
|
+
EndpointRunner.failures(content: Utils.read_markdown(path), path:, bare:).each { warn "Endpoint check failed: #{it}" }
|
|
56
|
+
changed
|
|
55
57
|
end
|
|
56
58
|
end
|
|
57
59
|
end
|
|
@@ -30,6 +30,8 @@ module Raindown
|
|
|
30
30
|
def update(path)
|
|
31
31
|
changed = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
|
|
32
32
|
puts "[#{name}] Updated #{relative(path)}" if changed
|
|
33
|
+
EndpointRunner.failures(content: Utils.read_markdown(path), path:, bare: @bare)
|
|
34
|
+
.each { warn "[#{name}] Endpoint check failed: #{it}" }
|
|
33
35
|
rescue StandardError => e
|
|
34
36
|
raise if ENV['RAINDOWN_DEBUG']
|
|
35
37
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'code'
|
|
4
|
+
require_relative 'front_matter'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# Obsidian's links between notes, which render as Markdown links to the file they name, by its path in the project,
|
|
8
|
+
# like files: links, while the file keeps them as they are:
|
|
9
|
+
# [[Reading list]] [Reading list](<notes/Reading list.md>)
|
|
10
|
+
# [[Reading list|books]] [books](<notes/Reading list.md>)
|
|
11
|
+
# [[Reading list#To read]] [Reading list > To read](<notes/Reading list.md#to-read>)
|
|
12
|
+
# A name matches a file's name, or the end of its path, like [[notes/Reading list]], without its extension and
|
|
13
|
+
# whatever its case. One that matches no file, and ones in code or front matter, are left as they are, as are
|
|
14
|
+
# embeds, like ![[photo.png]].
|
|
15
|
+
module WikiLinks
|
|
16
|
+
LINK = /(?<!!)\[\[([^\[\]|#\n]+)(?:#([^\[\]|\n]+))?(?:\|([^\[\]\n]+))?\]\]/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def to_markdown(markdown, metadata:)
|
|
21
|
+
return markdown unless markdown.include?('[[')
|
|
22
|
+
|
|
23
|
+
pages = metadata.list
|
|
24
|
+
links(markdown).reverse_each.with_object(markdown.b) do |link, bytes|
|
|
25
|
+
page = page_for(link[1], pages)
|
|
26
|
+
bytes[link.byteoffset(0)[0]...link.byteoffset(0)[1]] = markdown_link(link, page).b if page
|
|
27
|
+
end.force_encoding('UTF-8')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The links in the Markdown, outside its code and front matter, as matches.
|
|
31
|
+
def links(markdown)
|
|
32
|
+
start = markdown.bytesize - FrontMatter.parse(markdown).last.bytesize
|
|
33
|
+
code = Code.ranges(markdown)
|
|
34
|
+
found = []
|
|
35
|
+
|
|
36
|
+
markdown.scan(LINK) do
|
|
37
|
+
link = ::Regexp.last_match
|
|
38
|
+
offset = link.byteoffset(0)[0]
|
|
39
|
+
found << link if offset >= start && code.none? { it.cover?(offset) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
found
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The page a name links to: one whose path without its extension is the name, or ends with it.
|
|
46
|
+
def page_for(name, pages)
|
|
47
|
+
name = name.strip.downcase.sub(/\.(?:md|rd|markdown|raindown)\z/, '')
|
|
48
|
+
stems = pages.to_h { [it, it.path.to_s.downcase.sub(%r{\.[^./]+\z}, '')] }
|
|
49
|
+
|
|
50
|
+
pages.find { stems[it] == name } || pages.find { stems[it].end_with?("/#{name}") }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def markdown_link(link, page)
|
|
54
|
+
name, heading, label = link.captures.map { it&.strip }
|
|
55
|
+
label ||= [name, heading].compact.join(' > ')
|
|
56
|
+
anchor = "##{anchor(heading)}" if heading
|
|
57
|
+
|
|
58
|
+
"[#{label.gsub(/[\[\]]/) { "\\#{it}" }}](<#{page.path}#{anchor}>)"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# A heading's id, the way Commonmarker makes it, like to-read for "To read".
|
|
62
|
+
def anchor(heading)
|
|
63
|
+
heading.downcase.gsub(/[^\p{Word}\- ]/, '').tr(' ', '-')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/raindown.rb
CHANGED
|
@@ -17,6 +17,7 @@ require_relative 'raindown/pages'
|
|
|
17
17
|
require_relative 'raindown/pre_renderer'
|
|
18
18
|
require_relative 'raindown/utils'
|
|
19
19
|
require_relative 'raindown/variables'
|
|
20
|
+
require_relative 'raindown/wiki_links'
|
|
20
21
|
|
|
21
22
|
module Raindown
|
|
22
23
|
# Antlers evaluates any braces in the HTML, so braces in code are swapped for placeholders
|
|
@@ -83,6 +84,7 @@ module Raindown
|
|
|
83
84
|
# The document's HTML from Commonmarker, after nodes and the template pass, with components as fragments.
|
|
84
85
|
def html(markdown:, variables:, metadata: NoMetadata, bare: false)
|
|
85
86
|
markdown = NodeBlocks.to_rendered(markdown.dup.force_encoding('UTF-8'), metadata:, bare:)
|
|
87
|
+
markdown = WikiLinks.to_markdown(markdown, metadata:)
|
|
86
88
|
commonmark(PreRenderer.render(markdown, variables:, metadata:, bare:))
|
|
87
89
|
end
|
|
88
90
|
|
data/lib/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raindown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
@@ -121,6 +121,20 @@ dependencies:
|
|
|
121
121
|
- - ">="
|
|
122
122
|
- !ruby/object:Gem::Version
|
|
123
123
|
version: '0'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: lowtype
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '1.7'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - ">="
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '1.7'
|
|
124
138
|
- !ruby/object:Gem::Dependency
|
|
125
139
|
name: plugs
|
|
126
140
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -211,9 +225,13 @@ files:
|
|
|
211
225
|
- lib/raindown/config.rb
|
|
212
226
|
- lib/raindown/directives.rb
|
|
213
227
|
- lib/raindown/document.rb
|
|
228
|
+
- lib/raindown/editing.rb
|
|
214
229
|
- lib/raindown/endpoint/curl.rb
|
|
230
|
+
- lib/raindown/endpoint/curl_response.rb
|
|
231
|
+
- lib/raindown/endpoint/endpoint_args.rb
|
|
215
232
|
- lib/raindown/endpoint/endpoint_runner.rb
|
|
216
233
|
- lib/raindown/endpoint/endpoint_variables.rb
|
|
234
|
+
- lib/raindown/endpoint/schema_block.rb
|
|
217
235
|
- lib/raindown/files.rb
|
|
218
236
|
- lib/raindown/fragments.rb
|
|
219
237
|
- lib/raindown/front_matter.rb
|
|
@@ -221,6 +239,7 @@ files:
|
|
|
221
239
|
- lib/raindown/heading_ids.rb
|
|
222
240
|
- lib/raindown/js/host.mjs
|
|
223
241
|
- lib/raindown/js/js_nodes.rb
|
|
242
|
+
- lib/raindown/markdown_nodes.rb
|
|
224
243
|
- lib/raindown/node.rb
|
|
225
244
|
- lib/raindown/node_blocks.rb
|
|
226
245
|
- lib/raindown/node_injector.rb
|
|
@@ -230,6 +249,7 @@ files:
|
|
|
230
249
|
- lib/raindown/pre_renderer.rb
|
|
231
250
|
- lib/raindown/projects.rb
|
|
232
251
|
- lib/raindown/rewriter.rb
|
|
252
|
+
- lib/raindown/server.rb
|
|
233
253
|
- lib/raindown/stat.rb
|
|
234
254
|
- lib/raindown/table_formatter.rb
|
|
235
255
|
- lib/raindown/toc.rb
|
|
@@ -305,6 +325,7 @@ files:
|
|
|
305
325
|
- lib/raindown/watcher/file_watcher.rb
|
|
306
326
|
- lib/raindown/watcher/project.rb
|
|
307
327
|
- lib/raindown/watcher/supervisor.rb
|
|
328
|
+
- lib/raindown/wiki_links.rb
|
|
308
329
|
- lib/raindown/workers/worker.rb
|
|
309
330
|
- lib/raindown/workers/workers.rb
|
|
310
331
|
- lib/version.rb
|