raindown 0.9.1 → 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 +54 -11
- data/lib/raindown/directives.rb +7 -3
- data/lib/raindown/editing.rb +24 -0
- data/lib/raindown/endpoint/curl.rb +60 -27
- 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 +68 -26
- data/lib/raindown/endpoint/endpoint_variables.rb +111 -0
- 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 +4 -2
- data/lib/raindown/variables.rb +7 -3
- 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 +29 -7
|
@@ -4,22 +4,27 @@ 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'
|
|
9
|
+
require_relative 'endpoint_variables'
|
|
7
10
|
|
|
8
11
|
module Raindown
|
|
9
12
|
# Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
|
|
10
|
-
# block below it,
|
|
11
|
-
#
|
|
12
|
-
# Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
|
|
13
|
+
# block below it, followed by the date it was fetched, its status and how long it took.
|
|
14
|
+
# Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
|
|
13
15
|
# Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
|
|
14
|
-
# <!-- endpoint:
|
|
16
|
+
# <!-- endpoint: -->
|
|
15
17
|
# ```sh
|
|
16
18
|
# curl https://api.example.com
|
|
17
19
|
# ```
|
|
18
20
|
# ```json
|
|
19
21
|
# { "id": 1 }
|
|
20
22
|
# ```
|
|
21
|
-
# <!-- raindown:fetched 2026-10-14 -->
|
|
23
|
+
# <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
|
|
22
24
|
# <!-- :endpoint -->
|
|
25
|
+
# Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
|
|
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.
|
|
23
28
|
# One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
|
|
24
29
|
class EndpointRunner
|
|
25
30
|
# An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
|
|
@@ -27,19 +32,30 @@ module Raindown
|
|
|
27
32
|
DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
|
|
28
33
|
COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
|
|
29
34
|
END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
|
|
30
|
-
EXECUTE_ARGS = %w[:x :execute].freeze
|
|
31
35
|
# The date a response was fetched, on the line after it.
|
|
32
36
|
FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
|
|
37
|
+
FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
|
|
38
|
+
|
|
39
|
+
attr_reader :failures
|
|
33
40
|
|
|
34
41
|
def self.rewrite(content:, path: nil, bare: false, execute: false, **)
|
|
35
|
-
new(content:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
|
|
42
|
+
new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
|
|
43
|
+
end
|
|
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
|
|
36
48
|
end
|
|
37
49
|
|
|
38
|
-
def initialize(content:, bare: false, execute: false, only: nil)
|
|
50
|
+
def initialize(content:, path: nil, bare: false, execute: false, only: nil)
|
|
39
51
|
@lines = content.lines
|
|
52
|
+
@path = path
|
|
53
|
+
@variables = EndpointVariables.new(path:, content:)
|
|
40
54
|
@bare = bare
|
|
41
55
|
@execute = execute
|
|
42
56
|
@only = only
|
|
57
|
+
@failures = []
|
|
58
|
+
@schemas = SchemaBlock.shared(path)
|
|
43
59
|
end
|
|
44
60
|
|
|
45
61
|
def rewrite
|
|
@@ -48,6 +64,9 @@ module Raindown
|
|
|
48
64
|
while index < @lines.length
|
|
49
65
|
if (block = CodeBlocks.at(@lines, index))
|
|
50
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
|
|
51
70
|
elsif (args = directive_args(@lines[index]))
|
|
52
71
|
index = fill(index + 1, args, directive: index)
|
|
53
72
|
end
|
|
@@ -64,42 +83,65 @@ module Raindown
|
|
|
64
83
|
def directive_args(line)
|
|
65
84
|
args = line[COMMENT, 1]
|
|
66
85
|
args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
|
|
67
|
-
args
|
|
86
|
+
EndpointArgs.parse(args, schemas: @schemas) if args
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
# Returns the index of the last line it handled.
|
|
71
90
|
def fill(index, args, directive:)
|
|
72
91
|
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
73
92
|
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
74
|
-
return curl
|
|
93
|
+
return write_output(curl:, output:, args:, directive:) if run?(output, directive)
|
|
94
|
+
return curl[:close] unless output
|
|
75
95
|
|
|
76
|
-
|
|
96
|
+
@variables.remember(args.name, output[:body])
|
|
97
|
+
check(args, directive:, body: output[:body], status: fetched_status(output))
|
|
98
|
+
curl[:close]
|
|
77
99
|
end
|
|
78
100
|
|
|
79
|
-
|
|
80
|
-
|
|
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?)
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
location = [@path, directive + 1].compact.join(':')
|
|
112
|
+
@failures.concat(args.failures(body:, status:).map { "#{location}: #{it}" })
|
|
83
113
|
end
|
|
84
114
|
|
|
85
|
-
def
|
|
86
|
-
@
|
|
115
|
+
def run?(output, directive)
|
|
116
|
+
return directive == @only if @only
|
|
117
|
+
|
|
118
|
+
@execute && (output.nil? || output[:body].strip.empty?)
|
|
87
119
|
end
|
|
88
120
|
|
|
89
121
|
# Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
|
|
90
|
-
|
|
91
|
-
|
|
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:)
|
|
92
140
|
block.unshift("\n") unless output
|
|
93
141
|
start = output ? output[:open] : curl[:close] + 1
|
|
94
142
|
|
|
95
143
|
@lines[start, output ? replaced_length(output) : 0] = block
|
|
96
144
|
start + block.length - 1
|
|
97
|
-
rescue Curl::Error => e
|
|
98
|
-
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
99
|
-
raise if @only
|
|
100
|
-
|
|
101
|
-
warn "Endpoint failed: #{e.message}"
|
|
102
|
-
curl[:close]
|
|
103
145
|
end
|
|
104
146
|
|
|
105
147
|
# The output block, and the line after it when that's the date it was last fetched.
|
|
@@ -108,8 +150,8 @@ module Raindown
|
|
|
108
150
|
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
109
151
|
end
|
|
110
152
|
|
|
111
|
-
def fetched
|
|
112
|
-
"<!-- raindown:fetched #{Date.today.iso8601} -->\n"
|
|
153
|
+
def fetched(response)
|
|
154
|
+
"<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
|
|
113
155
|
end
|
|
114
156
|
|
|
115
157
|
# The fence is longer than any run of backticks in the body, so the body can't close it.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require 'antlers/api'
|
|
6
|
+
require_relative '../front_matter'
|
|
7
|
+
require_relative 'curl'
|
|
8
|
+
|
|
9
|
+
module Raindown
|
|
10
|
+
# The values that {placeholders} in an endpoint's curl snippet are replaced with, like {base_url} or {login.token}.
|
|
11
|
+
# From least to most specific: the file's front matter, the project's environment, and the responses of endpoints
|
|
12
|
+
# named with as:, read from the file as it goes. {env.NAME} is the environment variable NAME, for secrets, and an
|
|
13
|
+
# environment's values can use it too, like token: '{env.STAGING_TOKEN}'.
|
|
14
|
+
# The environment is one in .raindown/environments.yml, chosen with --env or RAINDOWN_ENV, or else the first:
|
|
15
|
+
# local:
|
|
16
|
+
# base_url: http://localhost:3000
|
|
17
|
+
# staging:
|
|
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.
|
|
21
|
+
class EndpointVariables
|
|
22
|
+
class Error < Curl::Error; end
|
|
23
|
+
|
|
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
|
|
28
|
+
PLACEHOLDER = /\{([A-Za-z_][\w.-]*)\}/
|
|
29
|
+
|
|
30
|
+
def initialize(path:, content:)
|
|
31
|
+
@path = path
|
|
32
|
+
@content = content
|
|
33
|
+
@local_names = []
|
|
34
|
+
@secrets = {}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Keeps a named endpoint's response, as its JSON's data when it's JSON, or else its text.
|
|
38
|
+
def remember(name, body)
|
|
39
|
+
return unless name
|
|
40
|
+
|
|
41
|
+
values[name] = begin
|
|
42
|
+
JSON.parse(body)
|
|
43
|
+
rescue JSON::ParserError
|
|
44
|
+
body.strip
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The text with each placeholder that has a value replaced by it, and the rest left as they are. A value's own
|
|
49
|
+
# placeholders are replaced once, so an environment can read secrets like {env.TOKEN}.
|
|
50
|
+
def substitute(text, depth: 1)
|
|
51
|
+
text.gsub(PLACEHOLDER) do |placeholder|
|
|
52
|
+
name = Regexp.last_match(1)
|
|
53
|
+
value = value(name)
|
|
54
|
+
next placeholder if value.nil?
|
|
55
|
+
next value.to_json if value.is_a?(Hash) || value.is_a?(Array)
|
|
56
|
+
|
|
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
|
|
60
|
+
end
|
|
61
|
+
end
|
|
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
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Read when they're first needed, so a file without endpoints doesn't need its environment.
|
|
71
|
+
def values
|
|
72
|
+
@values ||= FrontMatter.parse(@content).first.transform_keys(&:to_s).merge(environment, 'env' => ENV.to_h)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def environment
|
|
76
|
+
folder = @path && Nodes.find_folder(File.dirname(File.expand_path(@path)))
|
|
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)
|
|
91
|
+
|
|
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)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# A value by its path, like login.token or users.0.id.
|
|
107
|
+
def value(name)
|
|
108
|
+
Antlers::Queries.read_path(value: values, path: name.split('.'))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
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
|
@@ -15,6 +15,7 @@ require_relative 'card'
|
|
|
15
15
|
require_relative 'endpoint'
|
|
16
16
|
require_relative '../code_blocks'
|
|
17
17
|
require_relative '../endpoint/curl'
|
|
18
|
+
require_relative '../endpoint/endpoint_variables'
|
|
18
19
|
|
|
19
20
|
module Raindown
|
|
20
21
|
module TUI
|
|
@@ -89,8 +90,9 @@ module Raindown
|
|
|
89
90
|
|
|
90
91
|
def endpoint(path, lines, directive)
|
|
91
92
|
snippet = CodeBlocks.endpoint(lines, directive.line).first&.dig(:body)
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
def charts_in(path)
|