raindown 0.10.0 → 0.12.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 +15 -9
- data/lib/raindown/bare_directives.rb +14 -0
- data/lib/raindown/blueprints.rb +7 -6
- data/lib/raindown/cli.rb +62 -20
- data/lib/raindown/code_blocks.rb +3 -1
- 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 +52 -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/pages.rb +58 -11
- data/lib/raindown/projects.rb +2 -1
- data/lib/raindown/rewriter.rb +13 -12
- data/lib/raindown/server.rb +73 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +1 -1
- data/lib/raindown/tui/feelings/feelings_log.rb +4 -4
- data/lib/raindown/tui/project.rb +3 -2
- data/lib/raindown/watcher/file_watcher.rb +3 -1
- data/lib/raindown/watcher/project.rb +3 -2
- data/lib/raindown/wiki_links.rb +66 -0
- data/lib/raindown.rb +2 -0
- data/lib/version.rb +1 -1
- metadata +22 -1
|
@@ -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,6 +34,9 @@ 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
|
|
@@ -39,10 +44,13 @@ module Raindown
|
|
|
39
44
|
|
|
40
45
|
def initialize(content:, path: nil, bare: false, execute: false, only: nil)
|
|
41
46
|
@lines = content.lines
|
|
47
|
+
@path = path
|
|
42
48
|
@variables = EndpointVariables.new(path:, content:)
|
|
43
49
|
@bare = bare
|
|
44
50
|
@execute = execute
|
|
45
51
|
@only = only
|
|
52
|
+
@failures = []
|
|
53
|
+
@schemas = SchemaBlock.shared(path)
|
|
46
54
|
end
|
|
47
55
|
|
|
48
56
|
def rewrite
|
|
@@ -51,6 +59,9 @@ module Raindown
|
|
|
51
59
|
while index < @lines.length
|
|
52
60
|
if (block = CodeBlocks.at(@lines, index))
|
|
53
61
|
index = block[:close]
|
|
62
|
+
elsif (schema = SchemaBlock.at(@lines, index, bare: @bare))
|
|
63
|
+
@schemas[schema.name] = SchemaBlock.resolve(schema.type, @schemas)
|
|
64
|
+
index = schema.last
|
|
54
65
|
elsif (args = directive_args(@lines[index]))
|
|
55
66
|
index = fill(index + 1, args, directive: index)
|
|
56
67
|
end
|
|
@@ -67,23 +78,33 @@ module Raindown
|
|
|
67
78
|
def directive_args(line)
|
|
68
79
|
args = line[COMMENT, 1]
|
|
69
80
|
args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
|
|
70
|
-
args
|
|
81
|
+
EndpointArgs.parse(args, schemas: @schemas) if args
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
# Returns the index of the last line it handled.
|
|
74
85
|
def fill(index, args, directive:)
|
|
75
86
|
curl, output = CodeBlocks.endpoint(@lines, index)
|
|
76
87
|
return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
|
|
77
|
-
return write_output(curl:, output:,
|
|
88
|
+
return write_output(curl:, output:, args:, directive:) if run?(output, directive)
|
|
89
|
+
return curl[:close] unless output
|
|
78
90
|
|
|
79
|
-
@variables.remember(
|
|
91
|
+
@variables.remember(args.name, output[:body])
|
|
92
|
+
check(args, directive:, body: output[:body], status: fetched_status(output))
|
|
80
93
|
curl[:close]
|
|
81
94
|
end
|
|
82
95
|
|
|
83
|
-
# The
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
96
|
+
# The status in the fetched comment after a response, which responses fetched before statuses were kept don't have.
|
|
97
|
+
def fetched_status(output)
|
|
98
|
+
@lines[output[:close] + 1]&.[](FETCHED_STATUS, 1)&.to_i
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
|
|
102
|
+
# to check, while one that ran has a status even when its body is empty.
|
|
103
|
+
def check(args, directive:, body:, status:)
|
|
104
|
+
return if (@only && directive != @only) || (body.strip.empty? && status.nil?)
|
|
105
|
+
|
|
106
|
+
location = [@path, directive + 1].compact.join(':')
|
|
107
|
+
@failures.concat(args.failures(body:, status:).map { "#{location}: #{it}" })
|
|
87
108
|
end
|
|
88
109
|
|
|
89
110
|
def run?(output, directive)
|
|
@@ -93,21 +114,29 @@ module Raindown
|
|
|
93
114
|
end
|
|
94
115
|
|
|
95
116
|
# 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
|
-
|
|
117
|
+
# Secrets that a response echoes are written as their placeholders.
|
|
118
|
+
def write_output(curl:, output:, args:, directive:)
|
|
119
|
+
response = Curl.run(curl[:body], @variables)
|
|
120
|
+
body = @variables.redact(response.body)
|
|
121
|
+
@variables.remember(args.name, body)
|
|
122
|
+
check(args, directive:, body:, status: response.status)
|
|
123
|
+
replace_output(curl:, output:, block: [*output_block(body, response.language), fetched(response)])
|
|
124
|
+
rescue Curl::Error => e
|
|
125
|
+
message = @variables.redact(e.message)
|
|
126
|
+
# The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
|
|
127
|
+
raise Curl::Error, message if @only
|
|
128
|
+
|
|
129
|
+
warn "Endpoint failed: #{message}"
|
|
130
|
+
curl[:close]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Returns the index of the block's last line.
|
|
134
|
+
def replace_output(curl:, output:, block:)
|
|
100
135
|
block.unshift("\n") unless output
|
|
101
136
|
start = output ? output[:open] : curl[:close] + 1
|
|
102
137
|
|
|
103
138
|
@lines[start, output ? replaced_length(output) : 0] = block
|
|
104
139
|
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
140
|
end
|
|
112
141
|
|
|
113
142
|
# The output block, and the line after it when that's the date it was last fetched.
|
|
@@ -116,8 +145,8 @@ module Raindown
|
|
|
116
145
|
@lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
|
|
117
146
|
end
|
|
118
147
|
|
|
119
|
-
def fetched
|
|
120
|
-
"<!-- raindown:fetched #{Date.today.iso8601} -->\n"
|
|
148
|
+
def fetched(response)
|
|
149
|
+
"<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
|
|
121
150
|
end
|
|
122
151
|
|
|
123
152
|
# 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/data/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/pages.rb
CHANGED
|
@@ -13,23 +13,49 @@ module Raindown
|
|
|
13
13
|
# folder: basics, order: 1, path: docs/_basics/_1-getting-started.md
|
|
14
14
|
# Folder tags can share a folder name with "&", like docs/_1&_basics/getting-started.md.
|
|
15
15
|
# An app can add its own metadata by subclassing and overriding implicit_metadata, like Raindeer's URL paths.
|
|
16
|
+
# Each file's front matter is only read once something lists the files. The listing and the front matter are kept for
|
|
17
|
+
# as long as the process runs, like "raindown server", until they change.
|
|
16
18
|
class Pages
|
|
17
19
|
EXTENSIONS = %w[md rd markdown raindown].freeze
|
|
18
20
|
SEGMENTS = Regexp.union(['/', '&', '-'])
|
|
19
21
|
|
|
20
22
|
class << self
|
|
23
|
+
# Each file's front matter, by its path, with the time it was modified when it was read.
|
|
24
|
+
def front_matters
|
|
25
|
+
@front_matters ||= {}
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
# The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
|
|
22
29
|
def for(path)
|
|
23
30
|
file_directory = File.dirname(File.expand_path(path))
|
|
24
31
|
load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
|
-
# Lists the files under the root
|
|
34
|
+
# Lists the files under the root, without loading them, for Dirload.
|
|
28
35
|
def load(root:)
|
|
29
36
|
require 'dirload'
|
|
30
|
-
file_types =
|
|
37
|
+
file_types = file_paths(root).group_by { Dirload.extension(file_path: it) }
|
|
31
38
|
new(metadata: Dirload::Metadata.new(loaded_paths: {}, missed_paths: [], file_types:), root:)
|
|
32
39
|
end
|
|
40
|
+
|
|
41
|
+
# The files under the root, outside hidden folders, like Dirload lists them. They're listed again once a folder
|
|
42
|
+
# among them has changed, which adding, removing or renaming a file or folder in it does.
|
|
43
|
+
def file_paths(root)
|
|
44
|
+
listing = listings[root]
|
|
45
|
+
return listing[:file_paths] if listing&.dig(:folders)&.all? { |folder, modified| modified_at(folder) == modified }
|
|
46
|
+
|
|
47
|
+
folders, file_paths = Dir["#{root}/**/*"].partition { File.directory?(it) }
|
|
48
|
+
listings[root] = { file_paths:, folders: [root, *folders].to_h { [it, modified_at(it)] } }
|
|
49
|
+
file_paths
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def listings
|
|
53
|
+
@listings ||= {}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def modified_at(path)
|
|
57
|
+
File.mtime(path) if File.exist?(path)
|
|
58
|
+
end
|
|
33
59
|
end
|
|
34
60
|
|
|
35
61
|
attr_reader :root
|
|
@@ -39,10 +65,6 @@ module Raindown
|
|
|
39
65
|
@root = root
|
|
40
66
|
@render_options = render_options
|
|
41
67
|
@file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
|
|
42
|
-
@metadata = {}
|
|
43
|
-
@tags = {}
|
|
44
|
-
|
|
45
|
-
@file_paths.each { |file_path| process(file_path:) }
|
|
46
68
|
end
|
|
47
69
|
|
|
48
70
|
# The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
|
|
@@ -53,21 +75,46 @@ module Raindown
|
|
|
53
75
|
end
|
|
54
76
|
|
|
55
77
|
def tagged(**tags)
|
|
56
|
-
tags.flat_map { |type, tag|
|
|
78
|
+
tags.flat_map { |type, tag| self.tags.dig(type.to_sym, tag) || [] }.uniq
|
|
57
79
|
end
|
|
58
80
|
|
|
59
81
|
private
|
|
60
82
|
|
|
83
|
+
def metadata
|
|
84
|
+
process_files unless @metadata
|
|
85
|
+
@metadata
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tags
|
|
89
|
+
process_files unless @tags
|
|
90
|
+
@tags
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def process_files
|
|
94
|
+
@metadata = {}
|
|
95
|
+
@tags = {}
|
|
96
|
+
@file_paths.each { |file_path| process(file_path:) }
|
|
97
|
+
end
|
|
98
|
+
|
|
61
99
|
def listed_page(file_path)
|
|
62
|
-
Page.new(file_path:, metadata:
|
|
100
|
+
Page.new(file_path:, metadata: metadata[file_path], render_options: @render_options)
|
|
63
101
|
end
|
|
64
102
|
|
|
65
103
|
def process(file_path:)
|
|
66
|
-
metadata = implicit_metadata(file_path:).merge(
|
|
104
|
+
metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
|
|
67
105
|
@metadata[file_path] = metadata
|
|
68
106
|
metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
|
|
69
107
|
end
|
|
70
108
|
|
|
109
|
+
# The file's front matter, read again only when the file's changed since it was last read.
|
|
110
|
+
def front_matter(file_path)
|
|
111
|
+
modified = File.mtime(file_path)
|
|
112
|
+
modified_then, front_matter = self.class.front_matters[file_path]
|
|
113
|
+
return front_matter if modified_then == modified
|
|
114
|
+
|
|
115
|
+
FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
|
|
116
|
+
end
|
|
117
|
+
|
|
71
118
|
# The path is relative to the root, like docs/_basics/_1-getting-started.md.
|
|
72
119
|
# A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
|
|
73
120
|
def implicit_metadata(file_path:)
|
|
@@ -104,8 +151,8 @@ module Raindown
|
|
|
104
151
|
|
|
105
152
|
# Files with an order come first, by order, then the rest by path.
|
|
106
153
|
def sort_key(file_path)
|
|
107
|
-
order =
|
|
108
|
-
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0,
|
|
154
|
+
order = metadata[file_path][:order]
|
|
155
|
+
[order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, metadata[file_path][:path].to_s]
|
|
109
156
|
end
|
|
110
157
|
end
|
|
111
158
|
end
|
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.
|