raindown 0.12.0 → 0.14.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/lib/elements.rb +12 -25
  3. data/lib/lexemes/node_lexeme.rb +37 -0
  4. data/lib/nodes/list_node.rb +22 -25
  5. data/lib/nodes/write_in_node.rb +95 -0
  6. data/lib/raindown/bare_directives.rb +11 -17
  7. data/lib/raindown/chart/chart.rb +1 -1
  8. data/lib/raindown/code_blocks.rb +23 -15
  9. data/lib/raindown/context.rb +19 -0
  10. data/lib/raindown/directives.rb +1 -1
  11. data/lib/raindown/document.rb +9 -9
  12. data/lib/raindown/endpoint/endpoint_runner.rb +15 -24
  13. data/lib/raindown/fragments.rb +6 -1
  14. data/lib/raindown/js/host.mjs +2 -2
  15. data/lib/raindown/js/js_nodes.rb +4 -4
  16. data/lib/raindown/list.rb +31 -0
  17. data/lib/raindown/markdown_nodes.rb +3 -2
  18. data/lib/raindown/node.rb +10 -2
  19. data/lib/raindown/node_blocks.rb +28 -27
  20. data/lib/raindown/node_injector.rb +7 -1
  21. data/lib/raindown/nodes.rb +61 -21
  22. data/lib/raindown/pages.rb +14 -8
  23. data/lib/raindown/pre_renderer.rb +13 -10
  24. data/lib/raindown/stat.rb +1 -1
  25. data/lib/raindown/table/markdown_table.rb +35 -0
  26. data/lib/raindown/table/table.rb +87 -0
  27. data/lib/raindown/toc.rb +2 -2
  28. data/lib/raindown/tui/cloud.rb +154 -0
  29. data/lib/raindown/tui/feelings/celebration/celebration_renderer.rb +22 -0
  30. data/lib/raindown/tui/feelings/feelings_renderer.rb +3 -16
  31. data/lib/raindown/tui/layout.rb +8 -1
  32. data/lib/raindown/tui/project.rb +23 -11
  33. data/lib/raindown/tui/renderers/endpoint_renderer.rb +4 -3
  34. data/lib/raindown/tui/terminal/loading.rb +34 -0
  35. data/lib/raindown/tui/terminal/screen.rb +6 -5
  36. data/lib/raindown/tui/terminal/styles.rb +13 -1
  37. data/lib/raindown/tui/views/endpoint_view.rb +5 -4
  38. data/lib/raindown/tui.rb +10 -6
  39. data/lib/raindown/wiki_links.rb +1 -1
  40. data/lib/raindown.rb +9 -14
  41. data/lib/version.rb +1 -1
  42. metadata +11 -5
  43. data/lib/lexemes/list_lexeme.rb +0 -32
  44. data/lib/nodes/component_nodes.rb +0 -38
  45. data/lib/raindown/files.rb +0 -22
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ea9df31d83ebabf0649ad353b75e2f2546aeb10c31aa97038bdd85f5e45fd2c
4
- data.tar.gz: cc736f0d57e576c476629bfa7fe37405139cb1b880e86908e71e7e8d8b22926c
3
+ metadata.gz: be9fc3af39c4d6f20b00b691a14582626448ac903a01aa129a2f33911553a76f
4
+ data.tar.gz: d0bf1eea7f8996adb29d97eeeaeda1c38ad16739df7724704ab1402b1bd2c723
5
5
  SHA512:
6
- metadata.gz: 851438f9079a944aca1dc2f97d0c5081822d0ce31df12e536ca10dce8d471233ee0879fc91b7004fcf7949159554dec7cd0d1e4aa3d50b8cee4009dbd3180766
7
- data.tar.gz: 27863d35bb59084303144bdc0b1ea3852028a55c854e8142456c3e1d8f9ee495a3d5489dc00c91ce474bcd9eda833deb42d4f3696118bb24c25e913dabb54372
6
+ metadata.gz: b6b4305e373de95235142d2e030849aac517d724232194bd68eb2c16cef7c81dd9594ccc8a80b2cd7d8042af045818773d1059c8d30caef634f0722ed35cd6d4
7
+ data.tar.gz: 593a219070643268ddd2c52a25aa34d0fe76d62f1074c2a3a263dddf72407d4b03c3a13113100e684ee19c6168a15525e059bf26c118ea6bca680c7112c3ee62
data/lib/elements.rb CHANGED
@@ -6,28 +6,27 @@ module Raindown
6
6
  class Elements
7
7
  include Plugs
8
8
 
9
- # Antlers' prop and slot directives, rendered as components in the template pass.
10
- plug(:prop_component) do
9
+ # Write-in nodes, in <{ }>, like <{ Card }>, <{ :toc }> or <{ Card: }>...<{ :Card }>, rendered in the template pass.
10
+ plug(:write_in) do
11
11
  plug(:lexeme) do
12
- require 'lexemes/prop_lexeme'
13
- Antlers::PropLexeme
12
+ require_relative 'lexemes/node_lexeme'
13
+ NodeLexeme
14
14
  end
15
15
 
16
+ # A list: block loops over its files, so it comes before other nodes' blocks.
16
17
  plug(:node) do
17
- require_relative 'nodes/component_nodes'
18
- PropComponentNode
18
+ require_relative 'nodes/list_node'
19
+ ListNode
19
20
  end
20
- end
21
21
 
22
- plug(:slot_component) do
23
- plug(:lexeme) do
24
- require 'lexemes/slot_lexeme'
25
- Antlers::SlotLexeme
22
+ plug(:node) do
23
+ require_relative 'nodes/write_in_node'
24
+ WriteInNode
26
25
  end
27
26
 
28
27
  plug(:node) do
29
- require_relative 'nodes/component_nodes'
30
- SlotComponentNode
28
+ require_relative 'nodes/write_in_node'
29
+ SoloWriteInNode
31
30
  end
32
31
  end
33
32
 
@@ -42,17 +41,5 @@ module Raindown
42
41
  EndpointNode
43
42
  end
44
43
  end
45
-
46
- plug(:list) do
47
- plug(:lexeme) do
48
- require_relative 'lexemes/list_lexeme'
49
- ListLexeme
50
- end
51
-
52
- plug(:node) do
53
- require_relative 'nodes/list_node'
54
- ListNode
55
- end
56
- end
57
44
  end
58
45
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers/api'
4
+ require_relative '../raindown/node_blocks'
5
+
6
+ module Raindown
7
+ # A write-in node, in Antlers' delimiters, read with its props as Raindown reads them, like
8
+ # <{ :list folder: 'notes' }>. It opens a block with <{ Name: }>, and <{ :Name }> ends one, or else it's self-closing,
9
+ # as is <{ Card title='Hi' }>. Template directives, like if: and for:, aren't nodes, so they're left to their lexemes.
10
+ module NodeLexeme
11
+ include Antlers::Lexeme
12
+
13
+ module_function
14
+
15
+ DIRECTIVE = /\A(:)?([A-Za-z]\w*(?:::[A-Z]\w*)*)(:)?(?:\s+(.*))?\z/m
16
+
17
+ # A name with a colon is a node, like <{ :toc }> or <{ :app_name }>, while {:app_name} is a translation. One without
18
+ # is a node when it's capitalized, so <{ title }> is still a variable. The lexer gives no name to a directive that
19
+ # starts with another lexeme's keyword, like <{ if: draft }> or <{ :list }>, which it leaves to that lexeme.
20
+ def match?(segment:, name: nil, **)
21
+ _, close, node_name, open = segment.to_s.match(DIRECTIVE).to_a
22
+ return false if name.nil? || node_name.nil? || (close && open)
23
+ return true if close || open
24
+
25
+ node_name.match?(/\A[A-Z]/)
26
+ end
27
+
28
+ # <{ :Name }> ends a block of the name, or else is self-closing, while <{ Name }> is always self-closing.
29
+ def lexeme(segment:, **)
30
+ _, close, name, open, args = segment.match(DIRECTIVE).to_a
31
+ return { node_def: name, args: args.to_s } if open
32
+ return { node_end: name, args: args.to_s } if close
33
+
34
+ { node_solo: name, args: args.to_s }
35
+ end
36
+ end
37
+ end
@@ -1,46 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'antlers/api'
4
+ require_relative '../raindown/context'
5
+ require_relative '../raindown/node_blocks'
6
+ require_relative '../raindown/nodes'
7
+ require_relative 'write_in_node'
4
8
 
5
9
  module Raindown
10
+ # <{ list: page folder: 'notes' }> ... <{ :list }>, which renders its template for each file the list: node lists,
11
+ # with the file as page. Without a block, <{ :list folder: 'notes' }> renders the node's links, as a WriteInNode.
6
12
  class ListNode < Antlers::BranchNode
7
- include Antlers::Props
8
- include Antlers::Variables
13
+ DEF_KEY = :node_def
14
+ END_KEY = :node_end
9
15
 
10
- DEF_KEY = :list_def
11
- END_KEY = :list_end
16
+ def initialize(name:, args:)
17
+ super(name:)
12
18
 
13
- def initialize(value:, folder:, props: [], children: [])
14
- super(name: :toc, props:, children:)
15
-
16
- @value = value.delete("'").delete('"')
17
- @folder = folder.delete("'").delete('"')
19
+ @variable, @tags = args.split(/\s+/, 2)
18
20
  end
19
21
 
20
- def render(current_binding: nil, parent_binding: nil, slot_node: nil, metadata: {})
21
- output = ''
22
-
23
- list = metadata.list(folder: @folder)
24
-
25
- list.each do |value|
26
- # TODO: Parallelize by creating new bindings and ensuring children have any args they need via RenderEvent.
27
- current_binding.local_variable_set(@value, value)
28
-
29
- @children.each do |child|
30
- output += child.render(current_binding:, parent_binding:, slot_node:, metadata:) || ''
31
- end
32
- end
22
+ def render(current_binding: nil, context: Context.new, **)
23
+ tags = NodeBlocks.props(@tags.to_s, BoundVariables.new(current_binding))
24
+ Nodes.instance(Nodes.node_class('list'), tags).files(metadata: context.metadata).map do |file|
25
+ current_binding.local_variable_set(@variable, file)
26
+ super
27
+ end.join
28
+ end
33
29
 
34
- output
30
+ def end_name
31
+ 'list'
35
32
  end
36
33
 
37
34
  class << self
38
35
  def match?(segment:)
39
- segment[DEF_KEY]
36
+ segment[DEF_KEY] == 'list'
40
37
  end
41
38
 
42
39
  def build(segment:, **)
43
- new(value: segment[:list_def], folder: segment[:folder])
40
+ new(name: :list, args: segment[:args])
44
41
  end
45
42
  end
46
43
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers/api'
4
+ require 'nodes/html_node'
5
+ require_relative '../raindown/context'
6
+ require_relative '../raindown/fragments'
7
+ require_relative '../raindown/node_blocks'
8
+ require_relative '../raindown/nodes'
9
+
10
+ module Raindown
11
+ # A write-in node, in <{ }>, which Antlers renders where it is in the template, so its props see loop variables, like
12
+ # <{ Card name=p.name }> in a for: loop. It only renders, with its render, never its rewrite, and the Markdown
13
+ # between its tags, <{ Card: }> to <{ :Card }>, is its slot, rendered to HTML in its scope first.
14
+ # One with a class template, like Raindeer's .rbx components, renders its template, with that HTML as its slot.
15
+ class WriteInNode < Antlers::BranchNode
16
+ DEF_KEY = :node_def
17
+ END_KEY = :node_end
18
+
19
+ def initialize(name:, args: '', **)
20
+ super(name:)
21
+
22
+ @args = args
23
+ end
24
+
25
+ def render(current_binding: nil, context: Context.new, **)
26
+ slot = Raindown.commonmark(super) unless @children.empty?
27
+ props, args = NodeBlocks.args(@args, BoundVariables.new(current_binding))
28
+ node = Nodes.instance(node_class, props, args || [])
29
+ return Fragments.add(template_html(node, props, slot.to_s, current_binding)) if Nodes.template?(node.class)
30
+
31
+ Nodes.rendered(node, context, slot: slot.to_s)
32
+ end
33
+
34
+ def end_name
35
+ @name
36
+ end
37
+
38
+ private
39
+
40
+ # A render-only component, like Card, isn't a node Nodes finds, as it has no rewrite, so it's any class of the name.
41
+ def node_class
42
+ klass = Nodes.node_class(@name)
43
+ return klass if klass
44
+ raise NameError, "No node is named #{@name}, for <{ :#{@name} }>" unless @name.match?(/\A[A-Z]/)
45
+
46
+ Object.const_get(@name)
47
+ end
48
+
49
+ # The node's class template, whose slot is the HTML in place of its children.
50
+ def template_html(node, props, slot, parent_binding)
51
+ children = @children
52
+ @children = [Antlers::HTMLNode.new(html: slot)]
53
+ node.render_template(parent_binding:, slot_node: self, props:)
54
+ ensure
55
+ @children = children
56
+ end
57
+
58
+ class << self
59
+ def match?(segment:)
60
+ segment[DEF_KEY]
61
+ end
62
+
63
+ def build(segment:, **)
64
+ new(name: segment[DEF_KEY], args: segment[:args])
65
+ end
66
+ end
67
+ end
68
+
69
+ # <{ Card }>, or <{ :Card }> when it doesn't end a block, which a lowercase name needs, like <{ :toc }>.
70
+ class SoloWriteInNode < WriteInNode
71
+ END_KEY = nil
72
+
73
+ def self.match?(segment:)
74
+ segment[:node_solo] || segment[:node_end]
75
+ end
76
+
77
+ def self.build(segment:, **)
78
+ new(name: segment[:node_solo] || segment[:node_end], args: segment[:args])
79
+ end
80
+ end
81
+
82
+ # The variables where Antlers renders a node, like a loop's, and then the front matter's, as its props read them.
83
+ class BoundVariables < BasicObject
84
+ def initialize(binding)
85
+ @binding = binding
86
+ end
87
+
88
+ # A name that can't be a local, like a-b, is only looked for in the front matter.
89
+ def send(name)
90
+ return @binding.local_variable_get(name) if name.match?(/\A[a-z_]\w*\z/) && @binding.local_variable_defined?(name)
91
+
92
+ @binding.receiver.send(name)
93
+ end
94
+ end
95
+ end
@@ -8,22 +8,22 @@ require_relative 'front_matter'
8
8
  module Raindown
9
9
  # Raindown files (.rd/.raindown) can write a content directive, which writes its output back, bare on its own line,
10
10
  # like endpoint: or toc:, which shows in bold in the HTML. Markdown files opt in with "raindown: true" in their front
11
- # matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
11
+ # matter, or with the --bare flag. Nodes like toc: and list: are handled by NodeBlocks, before the rest.
12
12
  # Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
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
@@ -32,7 +32,7 @@ module Raindown
32
32
  end
33
33
 
34
34
  # The data is read every time, so the chart follows changes to its file.
35
- def replace(metadata: NoMetadata)
35
+ def rewrite(metadata: NoMetadata)
36
36
  points = points(metadata:)
37
37
  return '' if points.empty?
38
38
 
@@ -5,6 +5,9 @@ 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]*$/
8
11
  # The line closing a fence, like ``` or ~~~~, compiled once per fence.
9
12
  CLOSINGS = Hash.new { |closings, fence| closings[fence] = /\A {0,3}#{fence[0]}{#{fence.length},}[ \t]*$/ }
10
13
 
@@ -19,27 +22,32 @@ module Raindown
19
22
  { open: index, close:, language:, body: lines[index + 1...close].join } if close
20
23
  end
21
24
 
22
- # An endpoint's curl snippet and its response, if it has one yet.
23
- # A second snippet straight after is another example rather than the response.
24
- def endpoint(lines, index)
25
- snippet, response = following(lines, index, limit: 2)
26
- 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:)]
27
39
  end
28
40
 
29
41
  def snippet?(block)
30
42
  SNIPPET_LANGUAGES.include?(block[:language].downcase) || block[:body].lstrip.start_with?('curl')
31
43
  end
32
44
 
33
- # Up to limit blocks from index on, with only blank lines before and between them.
34
- def following(lines, index, limit:)
35
- blocks = []
36
-
37
- while blocks.length < limit && (block = at(lines, skip_blank_lines(lines, index)))
38
- blocks << block
39
- index = block[:close] + 1
40
- end
41
-
42
- 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
43
51
  end
44
52
 
45
53
  def skip_blank_lines(lines, index)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'front_matter'
4
+ require_relative 'variables'
5
+
6
+ module Raindown
7
+ # The document being rendered, its metadata, like a site's pages, and whether its directives can be bare: the keywords
8
+ # a node's rewrite and render can take. Antlers passes it down to Raindown's own Antlers nodes.
9
+ Context = Data.define(:document, :metadata, :bare) do
10
+ def initialize(document: '', metadata: NoMetadata, bare: false)
11
+ super
12
+ end
13
+
14
+ # The document's front matter, as variables, like {title}.
15
+ def variables
16
+ Variables.new(FrontMatter.parse(document).first)
17
+ end
18
+ end
19
+ end
@@ -19,7 +19,7 @@ module Raindown
19
19
 
20
20
  module_function
21
21
 
22
- # A directive's line without its delimiters, like "files: folder: 'notes'" for <!-- files: folder: 'notes' -->.
22
+ # A directive's line without its delimiters, like "list: folder: 'notes'" for <!-- list: folder: 'notes' -->.
23
23
  def text(line)
24
24
  line = line.strip
25
25
  [COMMENT, DELIMITERS].each do |open, close|
@@ -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)
@@ -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
 
@@ -83,19 +81,19 @@ module Raindown
83
81
 
84
82
  # Returns the index of the last line it handled.
85
83
  def fill(index, args, directive:)
86
- curl, output = CodeBlocks.endpoint(@lines, index)
84
+ curl, output = CodeBlocks.endpoint(@lines, index, bare: !@lines[directive].match?(COMMENT))
87
85
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
88
86
  return write_output(curl:, output:, args:, directive:) if run?(output, directive)
89
87
  return curl[:close] unless output
90
88
 
91
89
  @variables.remember(args.name, output[:body])
92
- check(args, directive:, body: output[:body], status: fetched_status(output))
90
+ check(args, directive:, body: output[:body], status: response_status(output))
93
91
  curl[:close]
94
92
  end
95
93
 
96
- # The status in the fetched comment after a response, which responses fetched before statuses were kept don't have.
97
- def fetched_status(output)
98
- @lines[output[:close] + 1]&.[](FETCHED_STATUS, 1)&.to_i
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]
99
97
  end
100
98
 
101
99
  # Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
@@ -113,14 +111,15 @@ module Raindown
113
111
  @execute && (output.nil? || output[:body].strip.empty?)
114
112
  end
115
113
 
116
- # 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.
117
116
  # Secrets that a response echoes are written as their placeholders.
118
117
  def write_output(curl:, output:, args:, directive:)
119
118
  response = Curl.run(curl[:body], @variables)
120
119
  body = @variables.redact(response.body)
121
120
  @variables.remember(args.name, body)
122
121
  check(args, directive:, body:, status: response.status)
123
- 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)])
124
123
  rescue Curl::Error => e
125
124
  message = @variables.redact(e.message)
126
125
  # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
@@ -132,21 +131,13 @@ module Raindown
132
131
 
133
132
  # Returns the index of the block's last line.
134
133
  def replace_output(curl:, output:, block:)
135
- block.unshift("\n") unless output
136
- start = output ? output[:open] : curl[:close] + 1
137
-
138
- @lines[start, output ? replaced_length(output) : 0] = block
134
+ start = curl[:close] + 1
135
+ @lines[start, output ? output[:close] - start + 1 : 0] = block
139
136
  start + block.length - 1
140
137
  end
141
138
 
142
- # The output block, and the line after it when that's the date it was last fetched.
143
- def replaced_length(output)
144
- length = output[:close] - output[:open] + 1
145
- @lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
146
- end
147
-
148
- def fetched(response)
149
- "<!-- 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"
150
141
  end
151
142
 
152
143
  # The fence is longer than any run of backticks in the body, so the body can't close it.
@@ -18,8 +18,13 @@ module Raindown
18
18
  end
19
19
 
20
20
  def add(html)
21
+ "<!-- raindown:fragment #{store(html)} -->"
22
+ end
23
+
24
+ # Keeps the HTML for this render, returning its index in the list.
25
+ def store(html)
21
26
  list << html
22
- "<!-- raindown:fragment #{list.length - 1} -->"
27
+ list.length - 1
23
28
  end
24
29
 
25
30
  # Fills each placeholder, again and again, since a fragment can hold others, like a component in a slot.
@@ -16,13 +16,13 @@ const classes = {}
16
16
  const nodes = {}
17
17
 
18
18
  const requests = {
19
- // Every exported class with replace is a node, named by its export, or by its class for a default export.
19
+ // Every exported class with rewrite is a node, named by its export, or by its class for a default export.
20
20
  async load({ folder }) {
21
21
  for (const file of readdirSync(folder).filter((file) => /\.m?js$/.test(file))) {
22
22
  const exports = await import(pathToFileURL(join(folder, file)).href)
23
23
 
24
24
  for (const [name, value] of Object.entries(exports)) {
25
- if (typeof value?.prototype?.replace === 'function') classes[name === 'default' ? value.name : name] = value
25
+ if (typeof value?.prototype?.rewrite === 'function') classes[name === 'default' ? value.name : name] = value
26
26
  }
27
27
  }
28
28
 
@@ -6,7 +6,7 @@ require 'open3'
6
6
  require_relative '../node'
7
7
 
8
8
  module Raindown
9
- # JavaScript Components, the .js and .mjs files in a project's .raindown folder, which export classes with replace and
9
+ # JavaScript Components, the .js and .mjs files in a project's .raindown folder, which export classes with rewrite and
10
10
  # optionally render, like Ruby nodes. They run in a NodeJS process of Raindown's own, started when a folder has them,
11
11
  # so only projects with JavaScript Components need NodeJS.
12
12
  module JSNodes
@@ -57,7 +57,7 @@ module Raindown
57
57
  end
58
58
  end
59
59
 
60
- # A JavaScript Component, made in the NodeJS process with its props, where its replace and render are sent.
60
+ # A JavaScript Component, made in the NodeJS process with its props, where its rewrite and render are sent.
61
61
  class JSNode < Node
62
62
  # A class for each JavaScript Component, which renders its own HTML when the component has render.
63
63
  def self.for(name:, render:)
@@ -72,8 +72,8 @@ module Raindown
72
72
  JSNodes.host.request(op: 'new', id: object_id, name: self.class.name, args:, props:)
73
73
  end
74
74
 
75
- def replace(**context)
76
- js_output(:replace, context)
75
+ def rewrite(**context)
76
+ js_output(:rewrite, context)
77
77
  end
78
78
 
79
79
  private