raindown 0.11.0 → 0.13.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: 1dffe3184c451794454149de18701ac6bd1afc2674b6a41385773d0f052bb2e5
4
- data.tar.gz: 4e49463244dffc8e9b39873e2137f072f8632daec02c314d928fe09bbb115fd5
3
+ metadata.gz: f795c35070ab8ae4f6b40e5a32595ab9ba6099b99f42aada8d0faafe2f56121d
4
+ data.tar.gz: a01426b3aab916e21b0e68f66fddd7f6b55d7e9c34ceb9665118a60b3ceae4b5
5
5
  SHA512:
6
- metadata.gz: 9502a0acb5cca634c884f74b8f2fc3e3a83c3fbe5bc635df7fccb779147718563a0a44679b3b3e6a1cf9da9e691026ff55953fc9a6b0aafad9cc48444eec12c6
7
- data.tar.gz: dc928865b0da23c1b3dd67d859e94a3e718fe370b3d21d9344060cd5019e82fd5cce029b56d6008632690f9f1cc6f782cd41a8c38ca6d233bd06336f29fb7077
6
+ metadata.gz: 052231532f48cc3a575388ebe95fa62b70bc3eba2121742142071c2ab256ced437de1f4c80f2e439599d579c2b5eea2d793b7cb05439601523bc72ff9c252dd6
7
+ data.tar.gz: 98ff7b646f8e380817bbf69efc06a0bf67b4945a233349c9702d3140458bb27e387ecd010498c80dbfa4a9d2e64872b4f7af75bcd12b28b992e8b82ac66f56c9
@@ -24,7 +24,7 @@ module Raindown
24
24
  # Writes back the whole file, like saving it while it's watched, with bare directives allowed, and endpoints with no
25
25
  # response run, when asked, like with --bare and -x.
26
26
  def write_back(path, bare: false, execute: false)
27
- Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
27
+ Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path)).changed?
28
28
  end
29
29
 
30
30
  # Runs one endpoint's request and writes its response, replacing any response it had, then raises when the response
@@ -13,17 +13,17 @@ module Raindown
13
13
  #
14
14
  # A bare schema: shows in bold, with its fields in a code block below it.
15
15
  #
16
- # Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
17
- # needs more than that, write it in comments with an end tag instead:
16
+ # Bare directives have no end tag. A bare endpoint holds the lines straight below it, its curl snippet, the comment
17
+ # with its response's status, time and date, and its response, up to the next blank line, like a bare node block, so
18
+ # where it needs blank lines, write it in comments with an end tag instead:
18
19
  # <!-- endpoint: -->
19
20
  # ...
20
21
  # <!-- :endpoint -->
21
22
  module BareDirectives
22
23
  EXTENSIONS = %w[.rd .raindown].freeze
23
- # The block directives, and the method that finds the line their end tag goes after.
24
- BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint }.freeze
25
- # Four spaces of indent would make the line a Markdown code block.
26
- LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
24
+ # A bare endpoint, the one bare directive with a block. Four spaces of indent would make the line a Markdown code
25
+ # block.
26
+ LINE = /\A( {0,3})(endpoint:(?:[ \t]+\S.*?)?)[ \t]*$/
27
27
 
28
28
  module_function
29
29
 
@@ -75,7 +75,7 @@ module Raindown
75
75
  return index unless match
76
76
 
77
77
  lines[index] = "#{Directives.marker(match[2], shown: true)}\n#{wrap(lines[index])}"
78
- close_block(lines, index, indent: match[1], keyword: match[3])
78
+ close_block(lines, index, indent: match[1])
79
79
  index
80
80
  end
81
81
 
@@ -88,16 +88,10 @@ module Raindown
88
88
  end
89
89
 
90
90
  # The end tag after the block, then the end tag in bold, as a bare directive's shown.
91
- def close_block(lines, index, indent:, keyword:)
92
- last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
91
+ def close_block(lines, index, indent:)
92
+ last = CodeBlocks.last_before_blank(lines, index)
93
93
  lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
94
- name = keyword.delete_suffix(':')
95
- lines.insert(last + 1, "#{indent}<{ :#{name} }>\n#{Directives.marker(":#{name}", shown: true)}\n")
96
- end
97
-
98
- # The last line of the curl snippet and response below an endpoint.
99
- def after_endpoint(lines, index)
100
- CodeBlocks.endpoint(lines, index).last&.fetch(:close)
94
+ lines.insert(last + 1, "#{indent}<{ :endpoint }>\n#{Directives.marker(':endpoint', shown: true)}\n")
101
95
  end
102
96
  end
103
97
  end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'json'
4
5
  require 'time'
5
6
  require 'unicode/display_width'
6
7
  require_relative 'projects'
7
8
 
8
9
  module Raindown
9
- # Blueprints from Claude Code's replies, kept in a project's .raindown/blueprints.md, which the terminal UI's Blueprints page
10
+ # Blueprints from Claude Code's replies, kept in a project's .raindown/data/blueprints.md, which the terminal UI's Blueprints page
10
11
  # shows, newest first. Each is a code block under a heading of when it was saved and its kind:
11
12
  # ## 2026-10-30T03:14:07Z · mockup
12
13
  #
@@ -32,7 +33,7 @@ module Raindown
32
33
  "#{time.sub('T', ' ').sub(/:\d\dZ\z/, ' UTC')}#{SEPARATOR}#{kind}"
33
34
  end
34
35
 
35
- # As it's saved in .raindown/blueprints.md, under when it was saved and its kind, then its description.
36
+ # As it's saved in .raindown/data/blueprints.md, under when it was saved and its kind, then its description.
36
37
  def markdown(saved_at)
37
38
  about = "#{description}\n\n" if description
38
39
  "## #{saved_at}#{SEPARATOR}#{kind}\n\n#{about}```#{kind}\n#{lines.join("\n")}\n```\n\n"
@@ -49,7 +50,7 @@ module Raindown
49
50
  def distance(other) = [(top - other.top).abs, (left + right - other.left - other.right).abs]
50
51
  end
51
52
 
52
- FILE = File.join('.raindown', 'blueprints.md')
53
+ FILE = File.join('.raindown', 'data', 'blueprints.md')
53
54
  # The kinds a code block can be marked with to be a blueprint.
54
55
  KINDS = %w[mockup diagram chart table other].freeze
55
56
  # What a blueprint's drawn with: lines, like boxes' and arrows', and blocks, like charts' bars.
@@ -79,8 +80,7 @@ module Raindown
79
80
  return [] unless File.exist?(path)
80
81
 
81
82
  lines = File.readlines(path, chomp: true)
82
- starts = lines.each_index.select { lines[it].start_with?('## ') }
83
- starts.filter_map { |start| blueprint(lines, start) }.reverse
83
+ lines.each_index.select { lines[it].start_with?('## ') }.filter_map { blueprint(lines, it) }.reverse
84
84
  end
85
85
 
86
86
  # Saves the blueprints in Claude Code's last reply, from its Stop hook's JSON, to the project it's working in, if any,
@@ -91,7 +91,8 @@ module Raindown
91
91
  return if drawn.empty?
92
92
 
93
93
  time = Time.now.utc.iso8601
94
- File.open(File.join(root, FILE), 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
94
+ path = File.join(root, FILE).tap { FileUtils.mkdir_p(File.dirname(it)) }
95
+ File.open(path, 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
95
96
  end
96
97
 
97
98
  # The text's code blocks that are blueprints, each with its kind and the paragraph above it.
data/lib/raindown/cli.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'open3'
5
+ require 'tmpdir'
4
6
  require 'trees'
5
7
  require_relative '../raindown'
6
8
  require_relative '../version'
@@ -32,7 +34,7 @@ module Raindown
32
34
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
33
35
  server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
34
36
  or its Markdown as it would be written back, like --stdin and --stdin --rewrite
35
- blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
37
+ blueprints Save the blueprints in Claude Code's last reply to .raindown/data/blueprints.md, for the terminal
36
38
  UI's Blueprints page, as a Stop hook that's given the hook's JSON
37
39
 
38
40
  Options:
@@ -52,12 +54,8 @@ module Raindown
52
54
  Set RAINDOWN_DEBUG=1 to show where an error happened.
53
55
  TEXT
54
56
 
55
- CONFIG = <<~YAML
56
- # Raindown project settings. See https://raindown.dev/docs/config
57
- #
58
- # Leave code blocks unhighlighted, so the browser can highlight them instead:
59
- # highlight_code: false
60
- YAML
57
+ # The files "raindown init" puts in a project's .raindown folder, like "rain new" clones raindeer-template.
58
+ TEMPLATE_URL = 'https://github.com/rainpour/raindown-init'
61
59
 
62
60
  # A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
63
61
  # are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
@@ -111,16 +109,20 @@ module Raindown
111
109
  abort("raindown: #{path}: #{error.message}")
112
110
  end
113
111
 
114
- # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
112
+ # Clones raindown-init into the .raindown folder that marks a project's root and holds its config.yml and nodes,
113
+ # keeping any files already there.
115
114
  def self.init(directory = nil)
116
115
  folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
117
116
  config = File.join(folder, Config::FILE)
118
117
  return puts("#{config} already exists") if File.exist?(config)
119
118
 
120
- FileUtils.mkdir_p(folder)
121
- File.write(config, CONFIG)
122
- gitignore = File.join(folder, '.gitignore')
123
- File.write(gitignore, "#{EndpointVariables::LOCAL_FILE}\n") unless File.exist?(gitignore)
119
+ Dir.mktmpdir do |clone|
120
+ clone_template(clone)
121
+ FileUtils.mkdir_p(folder)
122
+ (Dir.children(clone) - ['.git']).each do |name|
123
+ FileUtils.cp_r(File.join(clone, name), folder) unless File.exist?(File.join(folder, name))
124
+ end
125
+ end
124
126
  puts "Created #{config}"
125
127
  puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/components'
126
128
  end
@@ -136,6 +138,11 @@ module Raindown
136
138
  class << self
137
139
  private
138
140
 
141
+ def clone_template(directory)
142
+ output, status = Open3.capture2e('git', 'clone', '--quiet', '--depth', '1', TEMPLATE_URL, directory)
143
+ abort("Couldn't clone #{TEMPLATE_URL}:\n#{output}") unless status.success?
144
+ end
145
+
139
146
  # Endpoints run while formatting, which --stdin skips, so -x can't go with it. --html - prints, like --html.
140
147
  def open_path(path, html:, stdin:, bare:, execute:)
141
148
  output = html unless [true, '-'].include?(html)
@@ -152,14 +159,13 @@ module Raindown
152
159
  Nodes.load(path)
153
160
 
154
161
  metadata = Pages.for(path)
155
- Rewriter.update(path:, bare:, execute:, metadata:)
162
+ failures = Rewriter.update(path:, bare:, execute:, metadata:).failures
156
163
  write(output, Raindown.render_file(path:, bare:, metadata:))
157
- check_endpoints(path, bare:)
164
+ check_endpoints(failures)
158
165
  end
159
166
 
160
167
  # 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:)
168
+ def check_endpoints(failures)
163
169
  return if failures.empty?
164
170
 
165
171
  warn(*failures.map { "Endpoint check failed: #{it}" })
@@ -5,6 +5,11 @@ module Raindown
5
5
  module CodeBlocks
6
6
  FENCE = /\A {0,3}(`{3,}|~{3,})[ \t]*(\S*)/
7
7
  SNIPPET_LANGUAGES = %w[sh bash shell zsh console curl].freeze
8
+ # The comment Raindown writes between an endpoint's snippet and its response, with the response's status, how long
9
+ # it took and the date it was fetched, like <!-- response 200 · 142 ms · 2026-10-14 -->.
10
+ RESPONSE_COMMENT = /\A {0,3}<!-- response [^\n]*? -->[ \t]*$/
11
+ # The line closing a fence, like ``` or ~~~~, compiled once per fence.
12
+ CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
8
13
 
9
14
  module_function
10
15
 
@@ -13,31 +18,36 @@ module Raindown
13
18
  fence, language = lines[index]&.match(FENCE)&.captures
14
19
  return unless fence
15
20
 
16
- close = (index + 1...lines.length).find { |i| lines[i].match?(/\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/) }
21
+ close = (index + 1...lines.length).find { |i| lines[i].match?(CLOSINGS[fence]) }
17
22
  { open: index, close:, language:, body: lines[index + 1...close].join } if close
18
23
  end
19
24
 
20
- # An endpoint's curl snippet and its response, if it has one yet.
21
- # A second snippet straight after is another example rather than the response.
22
- def endpoint(lines, index)
23
- snippet, response = following(lines, index, limit: 2)
24
- response && snippet?(response) ? [snippet] : [snippet, response].compact
25
+ # An endpoint's curl snippet and its response, if it has one yet, with the line of the response comment between them
26
+ # as the response's :comment, if there is one. A second snippet straight after is another example rather than the
27
+ # response. A bare endpoint's are in the lines straight below it, up to the next blank line.
28
+ def endpoint(lines, index, bare: false)
29
+ lines = lines[..last_before_blank(lines, index - 1)] if bare
30
+ snippet = at(lines, skip_blank_lines(lines, index))
31
+ return [] unless snippet
32
+
33
+ index = skip_blank_lines(lines, snippet[:close] + 1)
34
+ comment = index if lines[index]&.match?(RESPONSE_COMMENT)
35
+ response = at(lines, comment ? skip_blank_lines(lines, comment + 1) : index)
36
+ return [snippet] if response.nil? || snippet?(response)
37
+
38
+ [snippet, response.merge(comment:)]
25
39
  end
26
40
 
27
41
  def snippet?(block)
28
42
  SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
29
43
  end
30
44
 
31
- # Up to limit blocks from index on, with only blank lines before and between them.
32
- def following(lines, index, limit:)
33
- blocks = []
34
-
35
- while blocks.length < limit && (block = at(lines, skip_blank_lines(lines, index)))
36
- blocks << block
37
- index = block[:close] + 1
38
- end
39
-
40
- blocks
45
+ # The last of the lines straight below open, up to the next blank line, with fenced code kept whole, or open when
46
+ # there are none, like a bare block's.
47
+ def last_before_blank(lines, open)
48
+ last = open
49
+ last = at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
50
+ last
41
51
  end
42
52
 
43
53
  def skip_blank_lines(lines, index)
@@ -16,6 +16,9 @@ module Raindown
16
16
  class Document
17
17
  Heading = Data.define(:level, :text, :line)
18
18
 
19
+ # Its Markdown, and whether it allows bare directives.
20
+ attr_reader :markdown, :bare
21
+
19
22
  # A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
20
23
  # It spans line to end_line, its end tag, or the last line of a bare block.
21
24
  # Body is the lines inside it, between it and its end tag, like an endpoint's request and response.
@@ -116,21 +119,18 @@ module Raindown
116
119
  [bare.strip, true] if bare
117
120
  end
118
121
 
119
- # The response and the date it was fetched are the output, while the curl snippet above them is the author's.
122
+ # The response and the comment above it are the output, while the curl snippet above them is the author's.
120
123
  def endpoint(open, args, writes)
121
- curl, response = CodeBlocks.endpoint(@lines, open + 1)
122
- output = response && (response[:open]..(fetched?(response[:close] + 1) ? response[:close] + 1 : response[:close]))
124
+ bare = Directives.bare?(@lines[open])
125
+ curl, response = CodeBlocks.endpoint(@lines, open + 1, bare:)
126
+ output = response && ((response[:comment] || response[:open])..response[:close])
123
127
  last = output&.end || curl&.fetch(:close) || open
124
- # A bare endpoint, which isn't delimited, ends after its blocks.
125
- last = end_tag(after: last) || last unless Directives.bare?(@lines[open])
128
+ # A bare endpoint ends at the next blank line, and one in a comment at its end tag.
129
+ last = bare ? CodeBlocks.last_before_blank(@lines, open) : end_tag(after: last) || last
126
130
 
127
131
  directive('endpoint', args, open..last, output:, writes:)
128
132
  end
129
133
 
130
- def fetched?(index)
131
- @lines[index]&.match?(EndpointRunner::FETCHED)
132
- end
133
-
134
134
  # The end tag after the endpoint's blocks, with only blank lines between.
135
135
  def end_tag(after:)
136
136
  index = CodeBlocks.skip_blank_lines(@lines, after + 1)
@@ -17,8 +17,8 @@ module Raindown
17
17
 
18
18
  def rewrite_markdown(markdown, path:, bare: false, execute: false)
19
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}" } }
20
+ rewrite = Rewriter.rewrite(content: markdown, path:, bare:, execute:, metadata: Pages.for(path))
21
+ { output: rewrite.content, warnings: rewrite.failures.map { "Endpoint check failed: #{it}" } }
22
22
  end
23
23
  end
24
24
  end
@@ -10,17 +10,17 @@ require_relative 'endpoint_variables'
10
10
 
11
11
  module Raindown
12
12
  # Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
13
- # block below it, followed by the date it was fetched, its status and how long it took.
13
+ # block below it, after a comment of its status, how long it took and the date it was fetched.
14
14
  # Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
15
15
  # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
16
16
  # <!-- endpoint: -->
17
17
  # ```sh
18
18
  # curl https://api.example.com
19
19
  # ```
20
+ # <!-- response 200 · 142 ms · 2026-10-14 -->
20
21
  # ```json
21
22
  # { "id": 1 }
22
23
  # ```
23
- # <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
24
24
  # <!-- :endpoint -->
25
25
  # Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
26
26
  # response, like {login.token}. With status: and schema:, each response in the file is checked, and failures are kept.
@@ -32,9 +32,7 @@ module Raindown
32
32
  DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
33
33
  COMMENT = /\A {0,3}<!--\s*endpoint:(.*?)-->[ \t]*$/
34
34
  END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
35
- # The date a response was fetched, on the line after it.
36
- FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
37
- FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
35
+ RESPONSE_STATUS = /<!-- response (\d{3}) /
38
36
 
39
37
  attr_reader :failures
40
38
 
@@ -42,11 +40,6 @@ module Raindown
42
40
  new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
43
41
  end
44
42
 
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
-
50
43
  def initialize(content:, path: nil, bare: false, execute: false, only: nil)
51
44
  @lines = content.lines
52
45
  @path = path
@@ -88,19 +81,19 @@ module Raindown
88
81
 
89
82
  # Returns the index of the last line it handled.
90
83
  def fill(index, args, directive:)
91
- curl, output = CodeBlocks.endpoint(@lines, index)
84
+ curl, output = CodeBlocks.endpoint(@lines, index, bare: !@lines[directive].match?(COMMENT))
92
85
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
93
86
  return write_output(curl:, output:, args:, directive:) if run?(output, directive)
94
87
  return curl[:close] unless output
95
88
 
96
89
  @variables.remember(args.name, output[:body])
97
- check(args, directive:, body: output[:body], status: fetched_status(output))
90
+ check(args, directive:, body: output[:body], status: response_status(output))
98
91
  curl[:close]
99
92
  end
100
93
 
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
94
+ # The status in the comment above a response, which a comment written by hand may not have.
95
+ def response_status(output)
96
+ @lines[output[:comment]][RESPONSE_STATUS, 1]&.to_i if output[:comment]
104
97
  end
105
98
 
106
99
  # Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
@@ -118,14 +111,15 @@ module Raindown
118
111
  @execute && (output.nil? || output[:body].strip.empty?)
119
112
  end
120
113
 
121
- # Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
114
+ # Replaces everything from just after the curl block to the end of the output block, if there is one, like its
115
+ # comment.
122
116
  # Secrets that a response echoes are written as their placeholders.
123
117
  def write_output(curl:, output:, args:, directive:)
124
118
  response = Curl.run(curl[:body], @variables)
125
119
  body = @variables.redact(response.body)
126
120
  @variables.remember(args.name, body)
127
121
  check(args, directive:, body:, status: response.status)
128
- replace_output(curl:, output:, block: [*output_block(body, response.language), fetched(response)])
122
+ replace_output(curl:, output:, block: [response_comment(response), *output_block(body, response.language)])
129
123
  rescue Curl::Error => e
130
124
  message = @variables.redact(e.message)
131
125
  # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
@@ -137,21 +131,13 @@ module Raindown
137
131
 
138
132
  # Returns the index of the block's last line.
139
133
  def replace_output(curl:, output:, block:)
140
- block.unshift("\n") unless output
141
- start = output ? output[:open] : curl[:close] + 1
142
-
143
- @lines[start, output ? replaced_length(output) : 0] = block
134
+ start = curl[:close] + 1
135
+ @lines[start, output ? output[:close] - start + 1 : 0] = block
144
136
  start + block.length - 1
145
137
  end
146
138
 
147
- # The output block, and the line after it when that's the date it was last fetched.
148
- def replaced_length(output)
149
- length = output[:close] - output[:open] + 1
150
- @lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
151
- end
152
-
153
- def fetched(response)
154
- "<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
139
+ def response_comment(response)
140
+ "<!-- response #{response.status} · #{response.milliseconds} ms · #{Date.today.iso8601} -->\n"
155
141
  end
156
142
 
157
143
  # The fence is longer than any run of backticks in the body, so the body can't close it.
@@ -26,7 +26,7 @@ module Raindown
26
26
  end
27
27
  end
28
28
 
29
- # Whether a file is a Markdown Component, by its extension and name, so .raindown/blueprints.md isn't one.
29
+ # Whether a file is a Markdown Component, by its extension and name, so .raindown/data/blueprints.md isn't one.
30
30
  def file?(path)
31
31
  EXTENSIONS.include?(File.extname(path)) && File.basename(path, '.*').match?(NAME)
32
32
  end
@@ -127,12 +127,7 @@ module Raindown
127
127
  end
128
128
 
129
129
  # A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
130
- def bare_block(lines, open)
131
- last = open
132
- last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
133
-
134
- { open:, close: nil, last: }
135
- end
130
+ def bare_block(lines, open) = { open:, close: nil, last: CodeBlocks.last_before_blank(lines, open) }
136
131
 
137
132
  # The end tag written the same way as the directive: in a comment, or in Antlers' delimiters.
138
133
  def end_tag(lines, open, name, comment:)
@@ -35,7 +35,8 @@ module Raindown
35
35
  private
36
36
 
37
37
  def block_lines(lines, block)
38
- markdown = Nodes.call(block[:node], :replace, document: @content, metadata: @metadata, bare: @bare).to_s.chomp
38
+ context = { document: @content, metadata: @metadata, bare: @bare, written: written_lines(lines, block) }
39
+ markdown = Nodes.call(block[:node], :replace, **context).to_s.chomp
39
40
  written = markdown.empty? ? [] : "#{markdown}\n".lines
40
41
  return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
41
42
 
@@ -54,6 +55,11 @@ module Raindown
54
55
  raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
55
56
  end
56
57
 
58
+ # What the block's node wrote last time, between its directive and its end tag, or the next blank line.
59
+ def written_lines(lines, block)
60
+ lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
61
+ end
62
+
57
63
  def line(line)
58
64
  line.end_with?("\n") ? line : "#{line}\n"
59
65
  end
@@ -6,6 +6,7 @@ require_relative 'js/js_nodes'
6
6
  require_relative 'markdown_nodes'
7
7
  require_relative 'node'
8
8
  require_relative 'stat'
9
+ require_relative 'table/table'
9
10
  require_relative 'toc'
10
11
 
11
12
  module Raindown
@@ -13,11 +14,12 @@ module Raindown
13
14
  # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
14
15
  # Components, if any.
15
16
  # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
16
- # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
17
+ # document: (the Markdown being rendered), metadata:, bare: and written: (the lines it wrote last time), and gets the
18
+ # ones it asks for.
17
19
  module Nodes
18
20
  FOLDER = '.raindown'
19
- # Lowercase directives for Raindown's own nodes. A project's own TOC, Files or Chart node replaces Raindown's.
20
- BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart' }.freeze
21
+ # Lowercase directives for Raindown's own nodes. A project's own TOC, Files, Chart or Table node replaces Raindown's.
22
+ BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart', 'table' => 'Table' }.freeze
21
23
  # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
22
24
  PLACEHOLDER = /<!-- raindown:node (\d+) -->/
23
25
 
@@ -13,36 +13,60 @@ module Raindown
13
13
  # folder: basics, order: 1, path: docs/_basics/_1-getting-started.md
14
14
  # Folder tags can share a folder name with "&", like docs/_1&_basics/getting-started.md.
15
15
  # An app can add its own metadata by subclassing and overriding implicit_metadata, like Raindeer's URL paths.
16
+ # Each file's front matter is only read once something lists the files. The listing and the front matter are kept for
17
+ # as long as the process runs, like "raindown server", until they change.
16
18
  class Pages
17
19
  EXTENSIONS = %w[md rd markdown raindown].freeze
18
20
  SEGMENTS = Regexp.union(['/', '&', '-'])
19
21
 
20
22
  class << self
23
+ # Each file's front matter, by its path, with the time it was modified when it was read.
24
+ def front_matters
25
+ @front_matters ||= {}
26
+ end
27
+
21
28
  # The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
22
29
  def for(path)
23
30
  file_directory = File.dirname(File.expand_path(path))
24
31
  load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
25
32
  end
26
33
 
27
- # Lists the files under the root with Dirload, without loading them.
34
+ # Lists the files under the root, without loading them, for Dirload.
28
35
  def load(root:)
29
36
  require 'dirload'
30
- file_types = Dirload.file_paths(absolute_path: root).group_by { Dirload.extension(file_path: it) }
37
+ file_types = file_paths(root).group_by { Dirload.extension(file_path: it) }
31
38
  new(metadata: Dirload::Metadata.new(loaded_paths: {}, missed_paths: [], file_types:), root:)
32
39
  end
40
+
41
+ # The files under the root, outside hidden folders, like Dirload lists them. They're listed again once a folder
42
+ # among them has changed, which adding, removing or renaming a file or folder in it does.
43
+ def file_paths(root)
44
+ listing = listings[root]
45
+ return listing[:file_paths] if listing&.dig(:folders)&.all? { |folder, modified| modified_at(folder) == modified }
46
+
47
+ folders, file_paths = Dir["#{root}/**/*"].partition { File.directory?(it) }
48
+ listings[root] = { file_paths:, folders: [root, *folders].to_h { [it, modified_at(it)] } }
49
+ file_paths
50
+ end
51
+
52
+ def listings
53
+ @listings ||= {}
54
+ end
55
+
56
+ def modified_at(path)
57
+ File.mtime(path) if File.exist?(path)
58
+ end
33
59
  end
34
60
 
35
61
  attr_reader :root
36
62
 
37
63
  # Render options, like highlight_code: false, are for rendering each listed file's content.
38
- def initialize(metadata:, root:, render_options: {})
64
+ # Eager reads every file's front matter straight away, for an app like Raindeer that needs all of it from the start.
65
+ def initialize(root:, metadata:, render_options: {}, eager: false)
39
66
  @root = root
40
67
  @render_options = render_options
41
68
  @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
42
- @metadata = {}
43
- @tags = {}
44
-
45
- @file_paths.each { |file_path| process(file_path:) }
69
+ load_metadata if eager
46
70
  end
47
71
 
48
72
  # The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
@@ -53,21 +77,46 @@ module Raindown
53
77
  end
54
78
 
55
79
  def tagged(**tags)
56
- tags.flat_map { |type, tag| @tags.dig(type.to_sym, tag) || [] }.uniq
80
+ tags.flat_map { |type, tag| self.tags.dig(type.to_sym, tag) || [] }.uniq
57
81
  end
58
82
 
59
83
  private
60
84
 
85
+ def metadata
86
+ load_metadata unless @metadata
87
+ @metadata
88
+ end
89
+
90
+ def tags
91
+ load_metadata unless @tags
92
+ @tags
93
+ end
94
+
95
+ def load_metadata
96
+ @metadata = {}
97
+ @tags = {}
98
+ @file_paths.each { |file_path| process(file_path:) }
99
+ end
100
+
61
101
  def listed_page(file_path)
62
- Page.new(file_path:, metadata: @metadata[file_path], render_options: @render_options)
102
+ Page.new(file_path:, metadata: metadata[file_path], render_options: @render_options)
63
103
  end
64
104
 
65
105
  def process(file_path:)
66
- metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(Utils.read_markdown(file_path)).first)
106
+ metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
67
107
  @metadata[file_path] = metadata
68
108
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
69
109
  end
70
110
 
111
+ # The file's front matter, read again only when the file's changed since it was last read.
112
+ def front_matter(file_path)
113
+ modified = File.mtime(file_path)
114
+ modified_then, front_matter = self.class.front_matters[file_path]
115
+ return front_matter if modified_then == modified
116
+
117
+ FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
118
+ end
119
+
71
120
  # The path is relative to the root, like docs/_basics/_1-getting-started.md.
72
121
  # A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
73
122
  def implicit_metadata(file_path:)
@@ -104,8 +153,8 @@ module Raindown
104
153
 
105
154
  # Files with an order come first, by order, then the rest by path.
106
155
  def sort_key(file_path)
107
- order = @metadata[file_path][:order]
108
- [order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, @metadata[file_path][:path].to_s]
156
+ order = metadata[file_path][:order]
157
+ [order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, metadata[file_path][:path].to_s]
109
158
  end
110
159
  end
111
160
  end