raindown 0.9.0 → 0.10.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: ad54482b1bd78c23942aefb02bc8a8b9cce56e369ecbb67366668187ed521323
4
- data.tar.gz: 9d8475c719c52063424a6215c762a926e67641700981b6834dd56e07d410d535
3
+ metadata.gz: 9d8b4a4baae078b6b14f7ae6fd66dde441f97776e7107a07864ca4c12beebb74
4
+ data.tar.gz: 582a5012c02638f89991497cc789ef9513e7c13262445784c40ddb026b246574
5
5
  SHA512:
6
- metadata.gz: 8789f0135781bfe782ae2af47f2bd537cafef9d55a522ba592bbcc1c8f27e0364cfbb860cb0aa106617af0bd6e82d2bd0174084ca4bd3ca7779b82e2e284ff79
7
- data.tar.gz: 88237d0429270a0651434511d462b0051b9a752a2e8aae315e4d2bafc0ef833fa1818d8424cf5e025b12655c9df3de6a22e6eab7e23e1b40ec0063d7fe4f3396
6
+ metadata.gz: 1e8fc0825e5462616ce435e35188f5d41b45b23fdbbb59ff840c140cf46279de4a0e696def346a92f168dc6e04e97d1bcac9e309b1db3d9219fe59eeaadad52a
7
+ data.tar.gz: 5025d3381c0e0e2049a6b5fe462102438226feaa2d1a563c89b34dde74b1679163071d4df43e61d3a261e489ed71a107ae925db7f4cf08f1b9e83e773b45b46c
@@ -32,7 +32,7 @@ module Raindown
32
32
  raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
33
33
 
34
34
  content = Utils.read_markdown(path)
35
- rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
35
+ rewritten = EndpointRunner.new(content:, path:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
36
  return false if rewritten == content
37
37
 
38
38
  File.write(path, rewritten)
data/lib/raindown/cli.rb CHANGED
@@ -37,6 +37,7 @@ module Raindown
37
37
  its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
38
38
  --bare Allow bare directives like :toc in Markdown files
39
39
  -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
40
41
  -h, --help Show this message
41
42
  -v, --version Show the version
42
43
 
@@ -52,12 +53,18 @@ module Raindown
52
53
 
53
54
  # A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
54
55
  # are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
55
- line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
56
- execute { open_path(path, html:, stdin:, bare:, execute: execute || x) }
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
57
+ execute do
58
+ ENV['RAINDOWN_ENV'] = env if env
59
+ open_path(path, html:, stdin:, bare:, execute: execute || x)
60
+ end
57
61
  end
58
62
 
59
- line('watch :path') do |path, bare: false, execute: false, x: false|
60
- execute { watch(path, bare:, execute: execute || x) }
63
+ line('watch :path') do |path, bare: false, execute: false, x: false, env: nil|
64
+ execute do
65
+ ENV['RAINDOWN_ENV'] = env if env
66
+ watch(path, bare:, execute: execute || x)
67
+ end
61
68
  end
62
69
 
63
70
  def self.run(argv)
@@ -1,18 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'ipaddr'
3
4
  require 'json'
4
5
  require 'open3'
5
6
  require 'shellwords'
7
+ require 'socket'
8
+ require 'uri'
6
9
 
7
10
  module Raindown
8
11
  # Runs a curl snippet with the curl binary. There's no shell, so pipes, redirects and variables don't apply.
9
12
  # Snippets come from files that anyone (or any agent) may have written, so only options that make an HTTP request
10
13
  # are allowed: nothing that reads or writes local files, loads a config, or uses another protocol.
14
+ # Link-local addresses, like the cloud metadata service at 169.254.169.254, are refused, as they can give out
15
+ # credentials, and redirects aren't followed (no -L), so a URL can't redirect there either.
11
16
  module Curl
12
17
  class Error < StandardError; end
13
18
  Request = Data.define(:method, :url)
14
19
 
15
20
  TIMEOUT = '30'
21
+ # The variables curl reads a proxy from, which then looks the names up rather than curl.
22
+ PROXY_VARIABLES = %w[http_proxy https_proxy HTTPS_PROXY all_proxy ALL_PROXY].freeze
16
23
  # --disable must come first and skips ~/.curlrc. The protocol options stop redirects to file:// and the like.
17
24
  SAFE_OPTIONS = ['--disable', '--proto', '=http,https', '--proto-redir', '=http,https'].freeze
18
25
 
@@ -21,7 +28,7 @@ module Raindown
21
28
 
22
29
  # Options without a value.
23
30
  FLAGS = {
24
- '-G' => '--get', '-I' => '--head', '-L' => '--location', '-i' => '--include', '-k' => '--insecure',
31
+ '-G' => '--get', '-I' => '--head', '-i' => '--include', '-k' => '--insecure',
25
32
  '-f' => '--fail', '-g' => '--globoff', '-s' => '--silent', '-S' => '--show-error'
26
33
  }.freeze
27
34
  LONG_FLAGS = (FLAGS.values + %w[--compressed --http1.1 --http2]).freeze
@@ -52,9 +59,11 @@ module Raindown
52
59
  module_function
53
60
 
54
61
  # Returns the response body and the code block language for its content type.
55
- def run(snippet)
62
+ # Placeholders like {base_url} are replaced from the variables, word by word, so a value can't add options.
63
+ def run(snippet, variables = nil)
64
+ args = arguments(snippet, variables)
56
65
  stdout, stderr, status = Open3.capture3('curl', *SAFE_OPTIONS, '--silent', '--show-error', '--max-time', TIMEOUT,
57
- *arguments(snippet), '--write-out', WRITE_OUT)
66
+ *resolve_options(args), *args, '--write-out', WRITE_OUT)
58
67
  raise Error, stderr.strip.empty? ? "curl exited with #{status.exitstatus}" : stderr.strip unless status.success?
59
68
 
60
69
  body, _, content_type = stdout.rpartition(TYPE_MARKER)
@@ -66,8 +75,8 @@ module Raindown
66
75
  end
67
76
 
68
77
  # The snippet's method and URL, like GET https://httpbin.org/uuid, or nil when it can't be read.
69
- def request(snippet)
70
- args = arguments(snippet)
78
+ def request(snippet, variables = nil)
79
+ args = arguments(snippet, variables)
71
80
  Request.new(method: request_method(args), url: args.find { it.match?(URL) })
72
81
  rescue Error
73
82
  nil
@@ -83,8 +92,9 @@ module Raindown
83
92
  'GET'
84
93
  end
85
94
 
86
- def arguments(snippet)
95
+ def arguments(snippet, variables = nil)
87
96
  words = Shellwords.split(snippet.gsub(/\\\r?\n/, ' ').strip.delete_prefix('$ '))
97
+ words = words.map { variables.substitute(it) } if variables
88
98
  raise Error, 'Endpoint snippets must start with curl' unless words.first == 'curl'
89
99
  raise Error, 'Endpoint snippets must be a single curl command' if words.intersect?(OPERATORS)
90
100
 
@@ -120,7 +130,7 @@ module Raindown
120
130
  [option, value(option, words.shift)]
121
131
  end
122
132
 
123
- # Short flags can be grouped like -sSL, and a short option's value can be attached like -XPOST.
133
+ # Short flags can be grouped like -sS, and a short option's value can be attached like -XPOST.
124
134
  def short_options(word, words)
125
135
  letters = word.delete_prefix('-')
126
136
  args = []
@@ -160,6 +170,50 @@ module Raindown
160
170
  value
161
171
  end
162
172
 
173
+ # Resolves each URL's host and pins curl to the address it was checked at, like --resolve api.io:443:203.0.113.5,
174
+ # so the name can't point somewhere else by the time curl looks it up. A host that's already an IP isn't pinned,
175
+ # and behind a proxy, which looks names up itself, only a host that's an IP is checked.
176
+ def resolve_options(args)
177
+ args.grep(URL).flat_map { pin_option(URI.parse(it)) }
178
+ rescue URI::InvalidURIError => e
179
+ raise Error, "Can't read the URL: #{e.message}"
180
+ end
181
+
182
+ def pin_option(uri)
183
+ raise Error, "#{uri} has no host" if uri.hostname.to_s.empty?
184
+ return [] if proxy? && !ip?(uri.hostname)
185
+
186
+ address = allowed_address(uri.hostname)
187
+ return [] if ip?(uri.hostname)
188
+
189
+ pinned = address.to_s
190
+ pinned = "[#{pinned}]" if address.ipv6?
191
+ ['--resolve', "#{uri.hostname}:#{uri.port}:#{pinned}"]
192
+ end
193
+
194
+ # Any way of writing an IP, like 169.254.169.254 or 2852039166, found without a DNS lookup.
195
+ def ip?(host)
196
+ Addrinfo.getaddrinfo(host, nil, nil, :STREAM, nil, Socket::AI_NUMERICHOST)
197
+ true
198
+ rescue SocketError
199
+ false
200
+ end
201
+
202
+ def proxy?
203
+ PROXY_VARIABLES.any? { !ENV[it].to_s.empty? }
204
+ end
205
+
206
+ # The host's first address, unless any of them is link-local.
207
+ def allowed_address(host)
208
+ addresses = Addrinfo.getaddrinfo(host, nil, nil, :STREAM).map { IPAddr.new(it.ip_address.sub(/%.*/, '')).native }
209
+ blocked = addresses.find(&:link_local?)
210
+ raise Error, "Endpoint snippets can't request link-local addresses, like cloud metadata at #{blocked}" if blocked
211
+
212
+ addresses.first
213
+ rescue SocketError => e
214
+ raise Error, "Can't resolve #{host}: #{e.message}"
215
+ end
216
+
163
217
  def language(content_type)
164
218
  LANGUAGES.find { |pattern, _| content_type.match?(pattern) }&.last || ''
165
219
  end
@@ -4,14 +4,15 @@ require 'date'
4
4
  require_relative '../bare_directives'
5
5
  require_relative '../code_blocks'
6
6
  require_relative 'curl'
7
+ require_relative 'endpoint_variables'
7
8
 
8
9
  module Raindown
9
10
  # Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
10
11
  # block below it,
11
12
  # 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
+ # Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
13
14
  # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
14
- # <!-- endpoint: :x -->
15
+ # <!-- endpoint: -->
15
16
  # ```sh
16
17
  # curl https://api.example.com
17
18
  # ```
@@ -20,6 +21,8 @@ module Raindown
20
21
  # ```
21
22
  # <!-- raindown:fetched 2026-10-14 -->
22
23
  # <!-- :endpoint -->
24
+ # Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
25
+ # response, like {login.token}.
23
26
  # One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
24
27
  class EndpointRunner
25
28
  # An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
@@ -27,16 +30,16 @@ module Raindown
27
30
  DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
28
31
  COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
29
32
  END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
30
- EXECUTE_ARGS = %w[:x :execute].freeze
31
33
  # The date a response was fetched, on the line after it.
32
34
  FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
33
35
 
34
36
  def self.rewrite(content:, path: nil, bare: false, execute: false, **)
35
- new(content:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
37
+ new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
36
38
  end
37
39
 
38
- def initialize(content:, bare: false, execute: false, only: nil)
40
+ def initialize(content:, path: nil, bare: false, execute: false, only: nil)
39
41
  @lines = content.lines
42
+ @variables = EndpointVariables.new(path:, content:)
40
43
  @bare = bare
41
44
  @execute = execute
42
45
  @only = only
@@ -71,24 +74,29 @@ module Raindown
71
74
  def fill(index, args, directive:)
72
75
  curl, output = CodeBlocks.endpoint(@lines, index)
73
76
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
74
- return curl[:close] unless run?(args, output, directive)
77
+ return write_output(curl:, output:, name: response_name(args)) if run?(output, directive)
75
78
 
76
- write_output(curl:, output:)
79
+ @variables.remember(response_name(args), output[:body]) if output
80
+ curl[:close]
77
81
  end
78
82
 
79
- def run?(args, output, directive)
80
- return directive == @only if @only
81
-
82
- (output.nil? || output[:body].strip.empty?) && execute?(args)
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
83
87
  end
84
88
 
85
- def execute?(args)
86
- @execute || args.intersect?(EXECUTE_ARGS)
89
+ def run?(output, directive)
90
+ return directive == @only if @only
91
+
92
+ @execute && (output.nil? || output[:body].strip.empty?)
87
93
  end
88
94
 
89
95
  # 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]
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]
92
100
  block.unshift("\n") unless output
93
101
  start = output ? output[:open] : curl[:close] + 1
94
102
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+ require_relative '../front_matter'
6
+ require_relative 'curl'
7
+
8
+ module Raindown
9
+ # The values that {placeholders} in an endpoint's curl snippet are replaced with, like {base_url} or {login.token}.
10
+ # From least to most specific: the file's front matter, the project's environment, and the responses of endpoints
11
+ # named with as:, read from the file as it goes. {env.NAME} is the environment variable NAME, for secrets, and an
12
+ # environment's values can use it too, like token: '{env.STAGING_TOKEN}'.
13
+ # The environment is one in .raindown/environments.yml, chosen with --env or RAINDOWN_ENV, or else the first:
14
+ # local:
15
+ # base_url: http://localhost:3000
16
+ # staging:
17
+ # base_url: https://staging.example.com
18
+ class EndpointVariables
19
+ class Error < Curl::Error; end
20
+
21
+ FILE = 'environments.yml'
22
+ PLACEHOLDER = /\{([A-Za-z_][\w.-]*)\}/
23
+
24
+ def initialize(path:, content:)
25
+ @path = path
26
+ @content = content
27
+ end
28
+
29
+ # Keeps a named endpoint's response, as its JSON's data when it's JSON, or else its text.
30
+ def remember(name, body)
31
+ return unless name
32
+
33
+ values[name] = begin
34
+ JSON.parse(body)
35
+ rescue JSON::ParserError
36
+ body.strip
37
+ end
38
+ end
39
+
40
+ # The text with each placeholder that has a value replaced by it, and the rest left as they are. A value's own
41
+ # placeholders are replaced once, so an environment can read secrets like {env.TOKEN}.
42
+ def substitute(text, depth: 1)
43
+ text.gsub(PLACEHOLDER) do |placeholder|
44
+ value = value(Regexp.last_match(1))
45
+ next placeholder if value.nil?
46
+ 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
+
49
+ value.to_s
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # Read when they're first needed, so a file without endpoints doesn't need its environment.
56
+ def values
57
+ @values ||= FrontMatter.parse(@content).first.transform_keys(&:to_s).merge(environment, 'env' => ENV.to_h)
58
+ end
59
+
60
+ def environment
61
+ 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)
64
+
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}" }
68
+ end
69
+
70
+ # A value by its path, like login.token or users.0.id.
71
+ 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
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The folders .gitignore files leave out, like a Drupal site's web/core, so finding the projects in a folder of
5
+ # them, like ~/dev, skips them. Only lines naming a folder are read, like /vendor/, node_modules or
6
+ # /web/modules/contrib/*, as a wildcard could leave out a project, and a ! takes a folder back out.
7
+ class Gitignore
8
+ WILDCARD = /[*?\[]/
9
+
10
+ # The rules for the folders inside a folder: its .gitignore's and those of the folders above it, up to its
11
+ # repository, as a repository's rules don't reach the repositories inside it.
12
+ def initialize(folder, above = nil)
13
+ above = nil if File.exist?(File.join(folder, '.git'))
14
+ @paths = Set.new(above&.paths)
15
+ @names = Set.new(above&.names)
16
+ file = File.join(folder, '.gitignore')
17
+ File.foreach(file, chomp: true) { read(it.strip, folder) } if File.file?(file)
18
+ end
19
+
20
+ def ignores?(path)
21
+ @paths.include?(path) || @names.include?(File.basename(path))
22
+ end
23
+
24
+ protected
25
+
26
+ attr_reader :paths, :names
27
+
28
+ private
29
+
30
+ # A folder with a slash, like /vendor or web/files, is from the .gitignore's folder, and one without, like
31
+ # node_modules, is any folder of that name below it.
32
+ def read(line, folder)
33
+ pattern = line.delete_suffix('/*').delete_suffix('/')
34
+ negated = pattern.delete_prefix!('!')
35
+ return if pattern.empty? || pattern.start_with?('#') || pattern.match?(WILDCARD)
36
+
37
+ if pattern.include?('/')
38
+ update(@paths, File.join(folder, pattern.delete_prefix('/')), negated:)
39
+ else
40
+ update(@names, pattern, negated:)
41
+ end
42
+ end
43
+
44
+ def update(rules, rule, negated:)
45
+ return rules.delete(rule) if negated
46
+
47
+ rules.add(rule)
48
+ end
49
+ end
50
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'find'
4
+ require_relative 'gitignore'
4
5
  require_relative 'nodes'
5
6
  require_relative 'pages'
6
7
 
@@ -18,16 +19,21 @@ module Raindown
18
19
  # The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
19
20
  def roots(directory)
20
21
  directory = File.expand_path(directory)
21
- roots = [root(directory)].compact
22
+ [root(directory), *project_folders(directory)].compact.uniq
23
+ end
22
24
 
23
- Find.find(directory) do |path|
24
- next unless File.directory?(path)
25
+ # The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
26
+ # gitignored ones, so it stays quick in a big folder like ~/dev.
27
+ def project_folders(directory, gitignore = Gitignore.new(directory))
28
+ Dir.glob('*/', base: directory).flat_map do |folder|
29
+ path = File.join(directory, folder.chomp('/'))
30
+ next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
25
31
 
26
- Find.prune if path != directory && ignored?(File.basename(path))
27
- roots << path if File.directory?(File.join(path, Nodes::FOLDER))
28
- end
32
+ inside = project_folders(path, Gitignore.new(path, gitignore))
33
+ next inside unless project?(path)
29
34
 
30
- roots.uniq
35
+ [path, *inside]
36
+ end
31
37
  end
32
38
 
33
39
  # The root of the project a file or directory is in, or nil when it isn't in one.
@@ -38,13 +44,13 @@ module Raindown
38
44
  end
39
45
 
40
46
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
41
- # since they have nodes of their own, unless they're asked for, like by the terminal UI, which lists them all.
42
- def files(root, nested: false)
47
+ # since they have nodes of their own.
48
+ def files(root)
43
49
  files = []
44
50
 
45
51
  Find.find(root) do |path|
46
52
  if File.directory?(path)
47
- Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
53
+ Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
48
54
  elsif markdown?(path, root:)
49
55
  files << path
50
56
  end
@@ -66,6 +72,11 @@ module Raindown
66
72
  File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
67
73
  end
68
74
 
75
+ # The roots that aren't inside another of them, as a project's code includes the projects inside it.
76
+ def outer_roots(roots)
77
+ roots.reject { |root| roots.any? { root != it && root.start_with?(File.join(it, '')) } }
78
+ end
79
+
69
80
  def project?(folder)
70
81
  File.directory?(File.join(folder, Nodes::FOLDER))
71
82
  end
@@ -3,6 +3,7 @@
3
3
  require_relative '../code_box'
4
4
  require_relative '../events'
5
5
  require_relative '../../blueprints'
6
+ require_relative '../../projects'
6
7
  require_relative 'traced_code'
7
8
  require_relative 'canvas'
8
9
  require_relative 'code_comments'
@@ -32,9 +33,10 @@ module Raindown
32
33
 
33
34
  attr_reader :cursor
34
35
 
35
- def initialize(root:)
36
+ def initialize(root:, projects: [root])
36
37
  @root = root
37
- @path = File.join(root, Blueprints::FILE)
38
+ @projects = projects
39
+ @paths = projects.map { File.join(it, Blueprints::FILE) }
38
40
  @all = []
39
41
  @cursor = 0
40
42
  select_box(0)
@@ -51,7 +53,7 @@ module Raindown
51
53
  end
52
54
 
53
55
  # Whether there are any blueprints yet, saved or in the code's comments, which are looked for once until it's loaded.
54
- def any? = File.exist?(@path) || commented.any?
56
+ def any? = @paths.any? { File.exist?(it) } || commented.any?
55
57
 
56
58
  # Whether the file's changed since it was read, so it's read again on the next tick.
57
59
  def changed? = mtime != @read_at
@@ -60,13 +62,26 @@ module Raindown
60
62
  @read_at = mtime
61
63
  newest = @all.first&.time
62
64
  @commented = nil
63
- @all = Blueprints.read(@root) + commented
65
+ @all = saved + commented
64
66
  self.cursor = 0 unless @all.first&.time == newest
65
67
  self.cursor = @cursor
66
68
  end
67
69
 
68
70
  def selected = blueprints[@cursor]
69
71
 
72
+ # Narrows the blueprints to a project's, or with nil, shows every project's.
73
+ def project=(project)
74
+ @project = project
75
+ self.cursor = 0
76
+ end
77
+
78
+ # The blueprints in the project they're narrowed to, if any, which the filter's keywords narrow further.
79
+ def in_project
80
+ return @all unless @project
81
+
82
+ @all.select { File.expand_path(it.path, @root).start_with?(File.join(@project, '')) }
83
+ end
84
+
70
85
  # Another blueprint's shown from its first box, while the same one keeps its box and canvas, like when it's clicked.
71
86
  def cursor=(index)
72
87
  shown = selected
@@ -115,7 +130,7 @@ module Raindown
115
130
  method = code
116
131
  return Edit.new(path: method.path, line: method.line) if method
117
132
 
118
- Edit.new(path: File.join(@root, selected.path), line: selected.line)
133
+ Edit.new(path: File.expand_path(selected.path, @root), line: selected.line)
119
134
  end
120
135
 
121
136
  # A click on a box drawn in the blueprint, which moves on to the next code it names when it's already selected.
@@ -138,9 +153,20 @@ module Raindown
138
153
 
139
154
  def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
140
155
 
141
- def mtime = File.exist?(@path) ? File.mtime(@path) : nil
156
+ def mtime = @paths.map { File.exist?(it) ? File.mtime(it) : nil }
157
+
158
+ # Every project's saved blueprints, newest first, each by its file from the root.
159
+ def saved
160
+ @projects.flat_map { |project| Blueprints.read(project).map { it.with(path: from_root(File.join(project, it.path))) } }
161
+ .sort_by(&:time).reverse
162
+ end
163
+
164
+ def from_root(path) = path.delete_prefix(File.join(@root, ''))
165
+
166
+ # The projects whose code is read, as a project's code includes the projects inside it.
167
+ def code_projects = @code_projects ||= Projects.outer_roots(@projects)
142
168
 
143
- def commented = @commented ||= CodeComments.blueprints(@root)
169
+ def commented = @commented ||= CodeComments.blueprints(@root, projects: code_projects)
144
170
  end
145
171
  end
146
172
  end
@@ -16,9 +16,10 @@ module Raindown
16
16
 
17
17
  module_function
18
18
 
19
- # The blueprints in the comments of the project's code, by file, from the top of each.
20
- def blueprints(root)
21
- CodeMethods.files(root).flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
19
+ # The blueprints in the comments of the projects' code, by file from the root, from the top of each. In a folder of
20
+ # projects, like ~/dev, only the projects' code is read.
21
+ def blueprints(root, projects: [root])
22
+ projects.flat_map { CodeMethods.files(it) }.flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
22
23
  end
23
24
 
24
25
  def in_file(path, relative)
@@ -15,10 +15,11 @@ module Raindown
15
15
 
16
16
  def typing? = @filtering == true
17
17
 
18
- # The blueprints with every keyword, or all of them, filtered again only when the keywords or the blueprints change,
18
+ # The blueprints with every keyword, or all of them, in the project they're narrowed to, if any, filtered again only when
19
+ # the keywords, the project or the blueprints change,
19
20
  # as what's drawn asks for the selected one many times a frame.
20
21
  def blueprints
21
- @filtered = [query, @all, filtered(query.to_s.downcase.split)] unless filtered_now?
22
+ @filtered = [query, @all, @project, filtered(query.to_s.downcase.split)] unless filtered_now?
22
23
  @filtered.last
23
24
  end
24
25
 
@@ -38,12 +39,12 @@ module Raindown
38
39
 
39
40
  private
40
41
 
41
- def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all)
42
+ def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all) && @filtered[2] == @project
42
43
 
43
44
  def filtered(keywords)
44
- return @all if keywords.empty?
45
+ return in_project if keywords.empty?
45
46
 
46
- @all.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
+ in_project.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
48
  end
48
49
 
49
50
  def open_filter
@@ -50,7 +50,8 @@ module Raindown
50
50
 
51
51
  def first_method(owner) = methods.select { it.owner == owner }.min_by { [it.path, it.line] }
52
52
 
53
- def methods = @methods ||= CodeMethods.list(@root)
53
+ # The projects' methods. In a folder of projects, like ~/dev, only the projects' code is read.
54
+ def methods = @methods ||= code_projects.flat_map { CodeMethods.list(it) }
54
55
  end
55
56
  end
56
57
  end
@@ -18,7 +18,7 @@ module Raindown
18
18
 
19
19
  # The five bins, each a box with its number, its temper, and a bar of how many feelings it holds.
20
20
  def bins
21
- counts = view.log.counts
21
+ counts = view.counts
22
22
  most = [counts.values.max, 1].max
23
23
  chosen = chosen_temper
24
24
  boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
@@ -41,9 +41,24 @@ module Raindown
41
41
 
42
42
  module_function
43
43
 
44
+ # Git log runs in a thread of its own while the methods are read, as it's as slow as reading them. Waiting on git's
45
+ # output frees the main thread to read, and the two meet when each method gets its file's changes, for its drift:
46
+ # Thread main thread
47
+ # ┌──────────────────────────┐ ┌─────────────────────────┐
48
+ # │ CodeMethods.file_changes │ │ CodeMethods.tagged │
49
+ # │ git log, in a subprocess │ │ or CodeMethods.indented │
50
+ # └────────────┬─────────────┘ └────────────┬────────────┘
51
+ # │ changes.value waits for it │
52
+ # └───────────────────┬───────────────────┘
53
+ # │
54
+ # ▼
55
+ # ┌──────────────────────┐
56
+ # │ CodeMethods.drifting │
57
+ # └──────────────────────┘
44
58
  def list(root)
45
- changes = file_changes(root)
46
- drifting((tagged(root) || indented(root)).map { it.with(changes: changes.fetch(it.path, 0)) })
59
+ changes = Thread.new { file_changes(root) }
60
+ methods = tagged(root) || indented(root)
61
+ drifting(methods.map { it.with(changes: changes.value.fetch(it.path, 0)) })
47
62
  end
48
63
 
49
64
  # Each method with its drift, from 0 for the lightest to 9 for the heaviest, where methods that weigh the same
@@ -81,9 +81,9 @@ module Raindown
81
81
  # How the method last felt, while its code's the same as when it was felt, or nil.
82
82
  def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
83
83
 
84
- # How many feelings there are of each temper, in the bins' order.
85
- def counts
86
- tally = rows.map { it[:temper] }.tally
84
+ # How many feelings there are of each temper, in the bins' order, of those within a folder, like a project, if one's given.
85
+ def counts(within: nil)
86
+ tally = rows.select { within.nil? || File.join(@root, path(it)).start_with?(File.join(within, '')) }.map { it[:temper] }.tally
87
87
  TEMPERS.to_h { [it, tally.fetch(it, 0)] }
88
88
  end
89
89
 
@@ -132,5 +132,33 @@ module Raindown
132
132
  nil
133
133
  end
134
134
  end
135
+
136
+ # The feelings about each project's code, in its own FeelingsLog, so in a folder of projects, like ~/dev, each project
137
+ # keeps its own feelings, and the folder isn't made a project.
138
+ class FeelingsLogs
139
+ def initialize(projects:)
140
+ @logs = projects.to_h { [it, FeelingsLog.new(root: it)] }
141
+ end
142
+
143
+ def add(feeling) = log_of(feeling.method).add(feeling)
144
+ def felt?(method) = log_of(method).felt?(method)
145
+ def temper(method) = log_of(method).temper(method)
146
+ def rows = @logs.values.flat_map(&:rows)
147
+
148
+ # Each project's feelings about methods that have changed, or gone, from its methods there are now, to its memories.
149
+ def move_stale_to_memories(methods)
150
+ by_log = methods.group_by { log_of(it) }
151
+ @logs.each_value { it.move_stale_to_memories(by_log.fetch(it, [])) }
152
+ end
153
+
154
+ def counts(within: nil)
155
+ empty = FeelingsLog::TEMPERS.to_h { [it, 0] }
156
+ @logs.values.map { it.counts(within:) }.reduce(empty) { |sum, counts| sum.merge(counts) { |_, felt, more| felt + more } }
157
+ end
158
+
159
+ private
160
+
161
+ def log_of(method) = @logs.find { |root, _| method.path.start_with?(File.join(root, '')) }&.last
162
+ end
135
163
  end
136
164
  end
@@ -76,9 +76,10 @@ module Raindown
76
76
 
77
77
  def view = navigator.view
78
78
 
79
- # Instead of the grid, that there's nothing to feel, or the celebration.
79
+ # Instead of the grid, that there's nothing to feel, as there are no projects or no methods, or the celebration.
80
80
  def instead
81
- return blank(" No functions or methods in #{File.basename(navigator.root)} to feel") if view.code_methods.empty?
81
+ return alert(NO_PROJECTS) if navigator.projects.empty?
82
+ return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
82
83
 
83
84
  celebration(view.celebration)
84
85
  end
@@ -174,10 +175,6 @@ module Raindown
174
175
  lines[(height - shown.length) / 2, shown.length] = shown
175
176
  lines.map { [segment(it.center(width), :too_small)] }
176
177
  end
177
-
178
- def blank(message)
179
- [[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
180
- end
181
178
  end
182
179
  end
183
180
  end
@@ -29,12 +29,13 @@ module Raindown
29
29
  KEYS = %i[up down left right edit execute enter toggle expand celebrate].freeze
30
30
 
31
31
  # The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
32
- # shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
33
- attr_reader :noting, :note, :cursor, :log, :celebration
32
+ # shows all that fits, until another's selected, the rain then the waffle, once the batch is refined, and the project
33
+ # its methods and counts are narrowed to, if any.
34
+ attr_reader :noting, :note, :cursor, :log, :celebration, :project
34
35
 
35
- def initialize(root:)
36
- @root = root
37
- @log = FeelingsLog.new(root:)
36
+ def initialize(projects:)
37
+ @projects = projects
38
+ @log = FeelingsLogs.new(projects:)
38
39
  @last_feelings = {}
39
40
  @cursor = 0
40
41
  @week = WeeklyBatch.new(log: @log)
@@ -63,12 +64,20 @@ module Raindown
63
64
  # the memories.
64
65
  def load
65
66
  was = selected
66
- every = CodeMethods.list(@root)
67
- @log.move_stale_to_memories(every)
68
- @week.read(every)
67
+ @every = @projects.flat_map { CodeMethods.list(it) }
68
+ @log.move_stale_to_memories(@every)
69
+ @week.read(in_project)
69
70
  reselect(was)
70
71
  end
71
72
 
73
+ # Narrows the methods to a project's, or with nil, shows every project's, picking the batch again from them.
74
+ def project=(project)
75
+ @project = project
76
+ @week.read(in_project)
77
+ @week.pick_again
78
+ self.cursor = 0
79
+ end
80
+
72
81
  # Selects the method again, by its file and name, wherever it is now, or keeps the cursor where it was.
73
82
  def reselect(was)
74
83
  self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
@@ -78,6 +87,9 @@ module Raindown
78
87
  def batch = @week.batch
79
88
  def code_methods = batch.code_methods
80
89
 
90
+ # How many feelings there are of each temper, in the project the methods are narrowed to, if any.
91
+ def counts = log.counts(within: project)
92
+
81
93
  def selected = code_methods[@cursor]
82
94
 
83
95
  def cursor=(index)
@@ -106,6 +118,11 @@ module Raindown
106
118
 
107
119
  private
108
120
 
121
+ # The methods of the project they're narrowed to, if any, which are none until they're read.
122
+ def in_project
123
+ @every.to_a.select { @project.nil? || it.path.start_with?(File.join(@project, '')) }
124
+ end
125
+
109
126
  # Moves between the methods, or acts on the selected one.
110
127
  def method_step(key)
111
128
  case key
@@ -38,6 +38,11 @@ module Raindown
38
38
  refill(grid_cells)
39
39
  end
40
40
 
41
+ # Picks the batch again on the next fill, like when the methods are narrowed to a project's.
42
+ def pick_again
43
+ @filled_for = nil
44
+ end
45
+
41
46
  private
42
47
 
43
48
  def refill(grid_cells)
@@ -28,8 +28,8 @@ module Raindown
28
28
  PROJECT = '🌧'
29
29
 
30
30
  def load
31
- @files = Projects.files(root, nested: true)
32
- @projects = Projects.roots(root).to_set
31
+ @projects = @workers.roots
32
+ @files = @projects.flat_map { Projects.files(it) }.sort
33
33
  end
34
34
 
35
35
  # The folder the tree starts from: the scope's, or the root's.
@@ -45,10 +45,10 @@ module Raindown
45
45
  @files = FileTree.new(root:, workers:)
46
46
  @endpoints = Endpoints.new(workers:)
47
47
  @dashboard = DashboardView.new(root:, workers:)
48
- @feelings = FeelingsView.new(root:)
49
- @blueprints = BlueprintsView.new(root:)
48
+ @feelings = FeelingsView.new(projects: Projects.outer_roots(workers.roots))
49
+ @blueprints = BlueprintsView.new(root:, projects: workers.roots)
50
50
  @nav = ListView.new { nav_rows }
51
- first = Projects.files(root, nested: true).first
51
+ first = @files.files.first
52
52
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
53
53
  @view = OutlineView.new(first, workers:) if first
54
54
  path ? open_at(path, nil) : open_view(dashboard)
@@ -3,8 +3,8 @@
3
3
  module Raindown
4
4
  module TUI
5
5
  # The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
6
- # Choosing one narrows the files and the dashboard to its own, and "All projects" shows every project's again.
7
- # It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
6
+ # Choosing one narrows the files, the dashboard, Code feelings and Blueprints to its own, and "All projects" shows every
7
+ # project's again. It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
8
8
  # Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
9
9
  module ProjectMenu
10
10
  # The menu's cursor, while it's open.
@@ -54,6 +54,8 @@ module Raindown
54
54
  @files.scope = project
55
55
  @endpoints.project = project
56
56
  dashboard.project = project
57
+ feelings.project = project
58
+ blueprints.project = project
57
59
  open_view(dashboard)
58
60
  end
59
61
  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,7 +90,7 @@ 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
+ request = Curl.request(snippet.to_s, EndpointVariables.new(path:, content: lines.join))
93
94
  Endpoint.new(project: root, path:, line: directive.line, **request.to_h) if request
94
95
  end
95
96
 
@@ -28,8 +28,10 @@ module Raindown
28
28
  def page = 'Dashboard'
29
29
 
30
30
  def lines
31
+ return alert(NO_PROJECTS) if navigator.projects.empty?
32
+
31
33
  cards = dashboard.cards
32
- return blank(height, " No charts in #{File.basename(navigator.root)} yet, like <{ chart: '3 5 2 8' }>") if cards.empty?
34
+ return alert(["No charts in #{File.basename(navigator.root)} yet.", "Add one like <{ chart: '3 5 2 8' }>"]) if cards.empty?
33
35
 
34
36
  rows = dashboard.rows(width)
35
37
  @headings = headings(rows, cards)
@@ -94,9 +96,7 @@ module Raindown
94
96
  [segment(" #{from_home(project)}", :project)]
95
97
  end
96
98
 
97
- def blank(count, message = '')
98
- [[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
99
- end
99
+ def blank(count) = Array.new(count) { [segment('', :blank)] }
100
100
  end
101
101
  end
102
102
  end
@@ -6,6 +6,9 @@ require_relative '../text'
6
6
 
7
7
  module Raindown
8
8
  module TUI
9
+ # Said in place of a page, like the charts or the code to feel, when the folder has no projects, with how to make one.
10
+ NO_PROJECTS = ['No projects found in this directory.', %(Run "raindown init" in a project's folder.)].freeze
11
+
9
12
  # Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
10
13
  # Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
11
14
  Renderer = Struct.new(:navigator, :pane, :width, :height) do
@@ -14,6 +17,23 @@ module Raindown
14
17
 
15
18
  # Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
16
19
  def spacer = [segment('', :blank)]
20
+
21
+ # The lines in a purple box, like an alert, at the top left, with blank below it, like when there's nothing to show.
22
+ # It's no wider than the pane, cutting the lines to fit.
23
+ def alert(messages)
24
+ inner = [messages.map { display_width(it) }.max, width - 5].min.clamp(0..)
25
+ box = [alert_edge('╔', '╗', inner), *messages.map { alert_line(it, inner) }, alert_edge('╚', '╝', inner)]
26
+ (box + Array.new(height) { [segment('', :blank)] }).first(height)
27
+ end
28
+
29
+ def alert_edge(left, right, inner)
30
+ padded([segment('', :blank, width: 1), segment("#{left}#{'═' * (inner + 2)}#{right}", :alert, width: inner + 4)])
31
+ end
32
+
33
+ def alert_line(message, inner)
34
+ padded([segment('', :blank, width: 1), segment('║ ', :alert, width: 2), segment(message, :alert, width: inner),
35
+ segment(' ║', :alert, width: 2)])
36
+ end
17
37
  end
18
38
  end
19
39
  end
@@ -85,7 +85,7 @@ module Raindown
85
85
  cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
86
86
  body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
87
87
  key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
88
- code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
88
+ code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM, alert: Curses::A_BOLD
89
89
  )
90
90
  end
91
91
 
@@ -112,11 +112,12 @@ module Raindown
112
112
  Hash.new(text).merge(plain.transform_values { it | text })
113
113
  end
114
114
 
115
- # Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
115
+ # Headings, alerts, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
116
116
  # mouse is over, and its purple.
117
117
  def emphasis
118
118
  { heading: pair(:heading) | Curses::A_BOLD, feelings: pair(:feelings), nearby: pair(:feelings) | Curses::A_BOLD,
119
- feelings_purple: pair(:feelings_purple), shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
119
+ feelings_purple: pair(:feelings_purple), alert: pair(:feelings_purple) | Curses::A_BOLD,
120
+ shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
120
121
  end
121
122
 
122
123
  # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'antlers/api'
4
+
3
5
  module Raindown
4
6
  # The front matter values that Antlers variables like {title} and <{ title }> render.
5
7
  # Antlers reads a variable from the binding's locals, then by sending it to the binding's receiver, which is this.
6
8
  # It's a BasicObject so a key like "hash" or "display" isn't shadowed by a method, and unknown names render nothing.
7
- # Nested values work too, like {author.name} and <{ for: tag in: tags }>.
9
+ # Nested values are hashes and arrays like {author.name}, {tags.0} or <{ for: name, url in: links }>.
8
10
  class Variables < BasicObject
9
11
  # Braces in a value become the placeholders that render as braces, so Antlers doesn't read them as a variable.
10
12
  def self.wrap(value)
11
13
  case value
12
- when ::Hash then new(value)
14
+ when ::Hash then value.transform_values { |item| wrap(item) }
13
15
  when ::Array then value.map { |item| wrap(item) }
14
16
  when ::String then value.gsub(/[{}]/, CODE_BRACES)
15
17
  else value
@@ -20,6 +22,7 @@ module Raindown
20
22
  def self.unwrap(value)
21
23
  case value
22
24
  when Variables then value.to_h
25
+ when ::Hash then value.transform_values { |item| unwrap(item) }
23
26
  when ::Array then value.map { |item| unwrap(item) }
24
27
  when ::String then value.tr(CODE_BRACES.values.join, CODE_BRACES.keys.join)
25
28
  else value
@@ -28,7 +31,8 @@ module Raindown
28
31
 
29
32
  # A variable's value, like author.name, read the way {author.name} reads it, unwrapped. Unknown names are nil.
30
33
  def self.value(variables, name)
31
- unwrap(name.split('.').reduce(variables) { |value, key| value.respond_to?(key) ? value.send(key) : nil })
34
+ first, *path = name.split('.')
35
+ unwrap(::Antlers::Queries.read_path(value: variables.send(first), path:))
32
36
  end
33
37
 
34
38
  def initialize(values)
@@ -25,7 +25,7 @@ module Raindown
25
25
  end
26
26
 
27
27
  # The folder's project and those inside it, found when they're first asked for, and again after a restart.
28
- def roots = @roots ||= [root, *Projects.roots(root)].uniq.sort
28
+ def roots = @roots ||= Projects.roots(root).sort
29
29
 
30
30
  # A worker for each project, as they were found when the workers last started.
31
31
  def each(&)
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.9.0'
4
+ VERSION = '0.10.0'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-03 00:00:00.000000000 Z
10
+ date: 2026-11-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.2.2
18
+ version: 1.4.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.2.2
25
+ version: 1.4.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: commonmarker
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -213,9 +213,11 @@ files:
213
213
  - lib/raindown/document.rb
214
214
  - lib/raindown/endpoint/curl.rb
215
215
  - lib/raindown/endpoint/endpoint_runner.rb
216
+ - lib/raindown/endpoint/endpoint_variables.rb
216
217
  - lib/raindown/files.rb
217
218
  - lib/raindown/fragments.rb
218
219
  - lib/raindown/front_matter.rb
220
+ - lib/raindown/gitignore.rb
219
221
  - lib/raindown/heading_ids.rb
220
222
  - lib/raindown/js/host.mjs
221
223
  - lib/raindown/js/js_nodes.rb
@@ -306,11 +308,11 @@ files:
306
308
  - lib/raindown/workers/worker.rb
307
309
  - lib/raindown/workers/workers.rb
308
310
  - lib/version.rb
309
- homepage: https://github.com/raindeer-rb/raindown
311
+ homepage: https://github.com/rainpour/raindown
310
312
  licenses: []
311
313
  metadata:
312
- homepage_uri: https://github.com/raindeer-rb/raindown
313
- source_code_uri: https://github.com/raindeer-rb/raindown/src/branch/main
314
+ homepage_uri: https://github.com/rainpour/raindown
315
+ source_code_uri: https://github.com/rainpour/raindown/tree/main
314
316
  rdoc_options: []
315
317
  require_paths:
316
318
  - lib