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.
@@ -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
- # followed by the date it was fetched.
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: :x -->
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&.split
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[:close] unless run?(args, output, directive)
93
+ return write_output(curl:, output:, args:, directive:) if run?(output, directive)
94
+ return curl[:close] unless output
75
95
 
76
- write_output(curl:, output:)
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
- def run?(args, output, directive)
80
- return directive == @only if @only
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
- (output.nil? || output[:body].strip.empty?) && execute?(args)
111
+ location = [@path, directive + 1].compact.join(':')
112
+ @failures.concat(args.failures(body:, status:).map { "#{location}: #{it}" })
83
113
  end
84
114
 
85
- def execute?(args)
86
- @execute || args.intersect?(EXECUTE_ARGS)
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
- def write_output(curl:, output:)
91
- block = [*output_block(*Curl.run(curl[:body])), 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:)
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
@@ -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
@@ -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
- request = Curl.request(snippet.to_s)
93
- 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
94
96
  end
95
97
 
96
98
  def charts_in(path)