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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d8b4a4baae078b6b14f7ae6fd66dde441f97776e7107a07864ca4c12beebb74
4
- data.tar.gz: 582a5012c02638f89991497cc789ef9513e7c13262445784c40ddb026b246574
3
+ metadata.gz: 1dffe3184c451794454149de18701ac6bd1afc2674b6a41385773d0f052bb2e5
4
+ data.tar.gz: 4e49463244dffc8e9b39873e2137f072f8632daec02c314d928fe09bbb115fd5
5
5
  SHA512:
6
- metadata.gz: 1e8fc0825e5462616ce435e35188f5d41b45b23fdbbb59ff840c140cf46279de4a0e696def346a92f168dc6e04e97d1bcac9e309b1db3d9219fe59eeaadad52a
7
- data.tar.gz: 5025d3381c0e0e2049a6b5fe462102438226feaa2d1a563c89b34dde74b1679163071d4df43e61d3a261e489ed71a107ae925db7f4cf08f1b9e83e773b45b46c
6
+ metadata.gz: 9502a0acb5cca634c884f74b8f2fc3e3a83c3fbe5bc635df7fccb779147718563a0a44679b3b3e6a1cf9da9e691026ff55953fc9a6b0aafad9cc48444eec12c6
7
+ data.tar.gz: dc928865b0da23c1b3dd67d859e94a3e718fe370b3d21d9344060cd5019e82fd5cce029b56d6008632690f9f1cc6f782cd41a8c38ca6d233bd06336f29fb7077
@@ -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
- raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
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
- rewritten = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
- return false if rewritten == content
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
- File.write(path, rewritten)
39
- true
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 else printing to stdout
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/nodes'
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 == true
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
- Nodes.load(path)
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
@@ -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) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
45
+ html.gsub(HIDDEN, '').gsub(MARKER) do
46
+ "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s).gsub(/&amp;(#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 response body and the code block language for its content type.
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
- body, _, content_type = stdout.rpartition(TYPE_MARKER)
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&.split
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:, name: response_name(args)) if run?(output, directive)
93
+ return write_output(curl:, output:, args:, directive:) if run?(output, directive)
94
+ return curl[:close] unless output
78
95
 
79
- @variables.remember(response_name(args), output[:body]) if output
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 name that as: gives the endpoint, for later snippets to use its response.
84
- def response_name(args)
85
- index = args.index('as:')
86
- args[index + 1] if index
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
- def write_output(curl:, output:, name:)
97
- body, language = Curl.run(curl[:body], @variables)
98
- @variables.remember(name, body)
99
- block = [*output_block(body, language), fetched]
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
- value = value(Regexp.last_match(1))
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
- file = folder && File.join(folder, FILE)
63
- return {} unless file && File.exist?(file)
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
- environments = YAML.safe_load_file(file) || {}
66
- name = ENV.fetch('RAINDOWN_ENV') { environments.keys.first }
67
- environments.fetch(name) { raise Error, "There's no #{name} environment in #{file}" }
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('.').reduce(values) do |value, key|
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
@@ -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]+)("[^"]*"|'[^']*'|\S+)/
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"']\S*)(?:\s+|\z)/
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
- span.except(:name, :class, :args).merge(node: Nodes.instance(span[:class], *args(span[:args], variables)))
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
- def props(text, variables)
152
- text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables)] }
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 author.name.
156
- def value(value, variables)
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
@@ -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 Components, if any.
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,
@@ -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, '')) && NODE_EXTENSIONS.include?(File.extname(path))
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
@@ -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
- request = Curl.request(snippet.to_s, EndpointVariables.new(path:, content: lines.join))
94
- Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.10.0'
4
+ VERSION = '0.11.0'
5
5
  end
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.10.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