raindown 0.1.0 → 0.2.1

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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require_relative '../bare_directives'
5
+ require_relative '../code_blocks'
6
+ require_relative 'curl'
7
+
8
+ module Raindown
9
+ # Runs the curl block under an endpoint directive and writes the response in the code block below it,
10
+ # followed by the date it was fetched.
11
+ # Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
12
+ # <{ endpoint: :x }>
13
+ # ```sh
14
+ # curl https://api.example.com
15
+ # ```
16
+ # ```json
17
+ # { "id": 1 }
18
+ # ```
19
+ # <!-- raindown:fetched 2026-10-14 -->
20
+ # <{ :endpoint }>
21
+ class EndpointRunner
22
+ DIRECTIVE = /\A {0,3}<\{\s*endpoint:(.*?)\}>[ \t]*$/
23
+ EXECUTE_ARGS = %w[:x :execute].freeze
24
+ # The date a response was fetched, on the line after it.
25
+ FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
26
+
27
+ def self.rewrite(content:, path: nil, bare: false, execute: false, **)
28
+ new(content:, bare: BareDirectives.enabled?(path:, bare:), execute:).rewrite
29
+ end
30
+
31
+ def initialize(content:, bare: false, execute: false)
32
+ @lines = content.lines
33
+ @bare = bare
34
+ @execute = execute
35
+ end
36
+
37
+ def rewrite
38
+ index = 0
39
+
40
+ while index < @lines.length
41
+ if (block = CodeBlocks.at(@lines, index))
42
+ index = block[:close]
43
+ elsif (args = directive_args(@lines[index]))
44
+ index = fill(index + 1, args)
45
+ end
46
+
47
+ index += 1
48
+ end
49
+
50
+ @lines.join
51
+ end
52
+
53
+ private
54
+
55
+ def directive_args(line)
56
+ line = BareDirectives.wrap(line) || line if @bare
57
+ line[DIRECTIVE, 1]&.split
58
+ end
59
+
60
+ # Returns the index of the last line it handled.
61
+ def fill(index, args)
62
+ curl, output = CodeBlocks.endpoint(@lines, index)
63
+ return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
64
+
65
+ return curl[:close] if (output && !output[:body].strip.empty?) || !execute?(args)
66
+
67
+ write_output(curl:, output:)
68
+ end
69
+
70
+ def execute?(args)
71
+ @execute || args.intersect?(EXECUTE_ARGS)
72
+ end
73
+
74
+ # Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
75
+ def write_output(curl:, output:)
76
+ block = [*output_block(*Curl.run(curl[:body])), fetched]
77
+ block.unshift("\n") unless output
78
+ start = output ? output[:open] : curl[:close] + 1
79
+
80
+ @lines[start, output ? replaced_length(output) : 0] = block
81
+ start + block.length - 1
82
+ rescue Curl::Error => e
83
+ warn "Endpoint failed: #{e.message}"
84
+ curl[:close]
85
+ end
86
+
87
+ # The output block, and the line after it when that's the date it was last fetched.
88
+ def replaced_length(output)
89
+ length = output[:close] - output[:open] + 1
90
+ @lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
91
+ end
92
+
93
+ def fetched
94
+ "<!-- raindown:fetched #{Date.today.iso8601} -->\n"
95
+ end
96
+
97
+ # The fence is longer than any run of backticks in the body, so the body can't close it.
98
+ def output_block(body, language)
99
+ fence = '`' * [3, (body.scan(/`+/).map(&:length).max || 0) + 1].max
100
+
101
+ ["#{fence}#{language}\n", *"#{body.chomp}\n".lines, "#{fence}\n"]
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'node'
4
+
5
+ module Raindown
6
+ # The node behind files:, a list of links to the files the metadata has for its props, like files: folder: 'notes'.
7
+ # A project can change how files are listed with its own Files node in .raindown, like "class Files < Raindown::Files".
8
+ class Files < Node
9
+ def initialize(**tags)
10
+ @tags = tags
11
+ end
12
+
13
+ # Each file has its front matter, its path and its content.
14
+ def files(metadata: NoMetadata)
15
+ metadata.list(**@tags)
16
+ end
17
+
18
+ def replace(metadata: NoMetadata)
19
+ files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The HTML of components rendered while a document renders, kept out of its Markdown as placeholders
5
+ # so Commonmarker doesn't read it as Markdown, then filled in once the document is HTML.
6
+ module Fragments
7
+ PLACEHOLDER = /<!-- raindown:fragment (\d+) -->/
8
+
9
+ module_function
10
+
11
+ # Collects the fragments added while the block runs, like one document's render.
12
+ def collect
13
+ outer = Thread.current[:raindown_fragments]
14
+ Thread.current[:raindown_fragments] = []
15
+ yield
16
+ ensure
17
+ Thread.current[:raindown_fragments] = outer
18
+ end
19
+
20
+ def add(html)
21
+ list << html
22
+ "<!-- raindown:fragment #{list.length - 1} -->"
23
+ end
24
+
25
+ # Fills each placeholder, again and again, since a fragment can hold others, like a component in a slot.
26
+ def fill(html)
27
+ html = html.gsub(PLACEHOLDER) { list[::Regexp.last_match(1).to_i] } while html.match?(PLACEHOLDER)
28
+ html
29
+ end
30
+
31
+ def list
32
+ Thread.current[:raindown_fragments] ||= []
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'yaml'
5
+
6
+ module Raindown
7
+ # YAML between two --- lines at the very start of a file.
8
+ module FrontMatter
9
+ PATTERN = /\A---[ \t]*\r?\n(.*?)^---[ \t]*(?:\r?\n|\z)/m
10
+
11
+ module_function
12
+
13
+ # Returns the metadata and the Markdown after it.
14
+ def parse(content)
15
+ match = content.match(PATTERN)
16
+ return [{}, content] if match.nil?
17
+
18
+ metadata = YAML.safe_load(match[1], permitted_classes: [Date, Time], symbolize_names: true)
19
+ [metadata.is_a?(Hash) ? metadata : {}, match.post_match]
20
+ end
21
+
22
+ # How many of the lines are front matter, so directives in its YAML are left alone.
23
+ def line_count(lines)
24
+ lines.join.match(PATTERN)&.then { |match| match[0].lines.length } || 0
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Commonmarker gives each heading a unique id within what it renders, but a document's components render
5
+ # on their own, so two can have a heading with the same id. Later ones get -1, -2 and so on, like Commonmarker.
6
+ module HeadingIds
7
+ HEADING = %r{<h([1-6]) id="([^"]*)"(.*?)</h\1>}m
8
+
9
+ module_function
10
+
11
+ def unique(html)
12
+ seen = Hash.new(0)
13
+
14
+ html.gsub(HEADING) do
15
+ level, id, rest = ::Regexp.last_match.captures
16
+ count = seen[id]
17
+ seen[id] += 1
18
+ next ::Regexp.last_match(0) if count.zero?
19
+
20
+ unique_id = "#{id}-#{count}"
21
+ "<h#{level} id=\"#{unique_id}\"#{rest.gsub("href=\"##{id}\"", "href=\"##{unique_id}\"")}</h#{level}>"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The base class of Raindown's own nodes, like TOC and Files.
5
+ # A project's nodes are LowNodes, or subclasses of these.
6
+ class Node; end
7
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'code_blocks'
4
+ require_relative 'directives'
5
+ require_relative 'front_matter'
6
+ require_relative 'nodes'
7
+
8
+ module Raindown
9
+ # Finds node blocks in a file's lines: a node's directive and the Markdown it wrote below it.
10
+ # With delimiters the block ends with the node's end tag, and without them at the next blank line:
11
+ # <{ Changelog: limit=3 }> Changelog: limit=3
12
+ # - **1.2.0** Bare directives
13
+ # - **1.2.0** Bare directives
14
+ #
15
+ # <{ :Changelog }>
16
+ # A self-closing <{ :Changelog }> or bare :Changelog that doesn't end a block renders the node without writing it.
17
+ # toc: and files: are Raindown's own nodes. A name only starts a block when it's a loaded node,
18
+ # so other text like "Note:" is left alone.
19
+ module NodeBlocks
20
+ class MissingEndTag < StandardError; end
21
+
22
+ NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
23
+ OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
24
+ BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
25
+ SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
26
+ BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
27
+ # A prop is name=value or name: value, where the value is quoted text, a number, true, false
28
+ # or a front matter key like author.name.
29
+ PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|\S+)/
30
+
31
+ module_function
32
+
33
+ # Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a bare block.
34
+ # A self-closing node is a block of one line with self_closing: true.
35
+ def find(lines, front_matter: {}, bare: false)
36
+ blocks = []
37
+ index = FrontMatter.line_count(lines)
38
+
39
+ while index < lines.length
40
+ if (code = CodeBlocks.at(lines, index))
41
+ index = code[:close]
42
+ elsif (block = block_at(lines, index, front_matter:, bare:))
43
+ blocks << block
44
+ index = block[:last]
45
+ end
46
+
47
+ index += 1
48
+ end
49
+
50
+ blocks
51
+ end
52
+
53
+ # Swaps each block for its node's HTML, through a placeholder, or for its Markdown when it has no HTML of its own.
54
+ # The directive and end tag become Directives markers, which also keep the node's output from joining
55
+ # the Markdown around it, like two lists becoming one.
56
+ def to_rendered(markdown, metadata: NoMetadata, bare: false)
57
+ lines = markdown.lines
58
+ find(lines, front_matter: FrontMatter.parse(markdown).first, bare:).reverse_each do |block|
59
+ lines[block[:open]..block[:last]] = rendered_lines(lines, block, markdown:, metadata:, bare:)
60
+ end
61
+ lines.join
62
+ end
63
+
64
+ def rendered_lines(lines, block, markdown:, metadata:, bare:)
65
+ node = block[:node]
66
+ output = if Nodes.html?(node) then ["#{Nodes.placeholder(node)}\n"]
67
+ else "#{Nodes.call(node, :replace, document: markdown, metadata:, bare:).to_s.chomp}\n".lines
68
+ end
69
+ output = [] if output == ["\n"]
70
+
71
+ [marker(lines[block[:open]]), *output, *(marker(lines[block[:close]]) if block[:close])]
72
+ end
73
+
74
+ def marker(line)
75
+ "#{Directives.marker(line)}\n"
76
+ end
77
+
78
+ def block_at(lines, index, front_matter:, bare:)
79
+ found, pattern = directive(lines[index], bare:)
80
+ return unless found && (klass = Nodes.node_class(found[2]))
81
+
82
+ node = Nodes.instance(klass, props(found[3].to_s, front_matter))
83
+ case pattern
84
+ when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
85
+ when :bare_open then bare_block(lines, index, node)
86
+ else { open: index, close: nil, last: index, node:, self_closing: true }
87
+ end
88
+ end
89
+
90
+ # The directive on the line and which kind it is, or nil.
91
+ def directive(line, bare:)
92
+ patterns = { open: OPEN, self_closing: SELF_CLOSING }
93
+ patterns.merge!(bare_open: BARE_OPEN, bare_self_closing: BARE_SELF_CLOSING) if bare
94
+
95
+ patterns.each do |pattern, regexp|
96
+ match = line.match(regexp)
97
+ return [match, pattern] if match
98
+ end
99
+
100
+ nil
101
+ end
102
+
103
+ # A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
104
+ def bare_block(lines, open, node)
105
+ last = open
106
+ last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
107
+
108
+ { open:, close: nil, last:, node: }
109
+ end
110
+
111
+ def end_tag(lines, open, name)
112
+ tag = /\A {0,3}<\{\s*:#{Regexp.escape(name)}\s*\}>[ \t]*$/
113
+ close = (open + 1...lines.length).find { |index| lines[index].match?(tag) }
114
+ raise MissingEndTag, "<{ #{name}: }> on line #{open + 1} needs <{ :#{name} }>" unless close
115
+
116
+ close
117
+ end
118
+
119
+ def props(text, front_matter)
120
+ text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, front_matter)] }
121
+ end
122
+
123
+ def value(value, front_matter)
124
+ case value
125
+ when /\A(["']).*\1\z/ then value[1..-2]
126
+ when /\A-?\d+\z/ then value.to_i
127
+ when /\A-?\d+\.\d+\z/ then value.to_f
128
+ when 'true', 'false' then value == 'true'
129
+ else value.split('.').reduce(front_matter) { |data, key| data.is_a?(Hash) ? data[key.to_sym] : nil }
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../raindown'
4
+ require_relative 'bare_directives'
5
+ require_relative 'front_matter'
6
+ require_relative 'node_blocks'
7
+
8
+ module Raindown
9
+ # Writes each node block's Markdown below its directive, replacing what it wrote before.
10
+ # Self-closing nodes only render, so they're left alone.
11
+ class NodeInjector
12
+ class BlankLine < StandardError; end
13
+
14
+ # Rewriter interface. Content without a node block is returned unchanged.
15
+ def self.rewrite(content:, path: nil, bare: false, metadata: NoMetadata, **)
16
+ new(content:, path:, bare:, metadata:).call
17
+ end
18
+
19
+ def initialize(content:, path: nil, bare: false, metadata: NoMetadata)
20
+ @content = content
21
+ @bare = BareDirectives.enabled?(path:, bare:)
22
+ @metadata = metadata
23
+ end
24
+
25
+ def call
26
+ lines = @content.lines
27
+ blocks = NodeBlocks.find(lines, front_matter: FrontMatter.parse(@content).first, bare: @bare)
28
+ .reject { |block| block[:self_closing] }
29
+ return @content if blocks.empty?
30
+
31
+ blocks.reverse_each { |block| lines[block[:open]..block[:last]] = block_lines(lines, block) }
32
+ lines.join
33
+ end
34
+
35
+ private
36
+
37
+ def block_lines(lines, block)
38
+ markdown = Nodes.call(block[:node], :replace, document: @content, metadata: @metadata, bare: @bare).to_s.chomp
39
+ written = markdown.empty? ? [] : "#{markdown}\n".lines
40
+ return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
41
+
42
+ delimited(lines, block, written)
43
+ end
44
+
45
+ # The directive, what the node wrote and the end tag, each separated by a blank line.
46
+ def delimited(lines, block, written)
47
+ result = [line(lines[block[:open]])]
48
+ result.push("\n", *written) unless written.empty?
49
+ result.push("\n", line(lines[block[:close]]))
50
+ end
51
+
52
+ # A bare block ends at the next blank line, so what it writes can't have one.
53
+ def bare(written, block)
54
+ return written unless written.any? { |line| line.strip.empty? }
55
+
56
+ name = block[:node].class.name
57
+ raise BlankLine, "#{name} writes blank lines, so it needs <{ #{name}: }> and <{ :#{name} }> around it"
58
+ end
59
+
60
+ def line(line)
61
+ line.end_with?("\n") ? line : "#{line}\n"
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'files'
4
+ require_relative 'node'
5
+ require_relative 'toc'
6
+
7
+ module Raindown
8
+ # Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
9
+ # A project keeps them in a .raindown folder, which is loaded with Dirload.
10
+ # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
11
+ # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
12
+ module Nodes
13
+ FOLDER = '.raindown'
14
+ # Lowercase directives for Raindown's own nodes. A project's own TOC or Files node replaces Raindown's.
15
+ BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files' }.freeze
16
+ # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
17
+ PLACEHOLDER = /<!-- raindown:node (\d+) -->/
18
+
19
+ module_function
20
+
21
+ # Loads the .raindown folder beside the file or in the nearest directory above it, once per process.
22
+ def load(path)
23
+ folder = find_folder(File.dirname(File.expand_path(path)))
24
+ return if folder.nil? || loaded.include?(folder)
25
+
26
+ require 'lownode'
27
+ require 'dirload'
28
+ loaded << folder
29
+ dirload(folder)
30
+ end
31
+
32
+ def find_folder(directory)
33
+ loop do
34
+ folder = File.join(directory, FOLDER)
35
+ return folder if File.directory?(folder)
36
+
37
+ parent = File.dirname(directory)
38
+ return if parent == directory
39
+
40
+ directory = parent
41
+ end
42
+ end
43
+
44
+ def loaded
45
+ @loaded ||= []
46
+ end
47
+
48
+ # The node class a block names, when it's loaded.
49
+ def node_class(name)
50
+ return loaded_class(name) unless BUILT_INS.key?(name)
51
+
52
+ loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
53
+ end
54
+
55
+ def loaded_class(name)
56
+ klass = Object.const_get(name)
57
+ klass if node?(klass)
58
+ rescue NameError
59
+ nil
60
+ end
61
+
62
+ # A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
63
+ # which don't have replace, so text like "Note:" only starts a block when the Note node writes something.
64
+ def node?(klass)
65
+ return false unless klass.is_a?(Class) && klass.method_defined?(:replace)
66
+
67
+ klass < Node || (defined?(::LowNode) && klass < ::LowNode) || false
68
+ end
69
+
70
+ # Calls a node's replace or render with the context keywords it takes.
71
+ def call(node, name, **context)
72
+ parameters = node.method(name).parameters
73
+ return node.public_send(name, **context) if parameters.any? { |type, _| type == :keyrest }
74
+
75
+ keys = parameters.filter_map { |type, key| key if %i[key keyreq].include?(type) }
76
+ node.public_send(name, **context.slice(*keys))
77
+ end
78
+
79
+ # One instance for each node and its props, kept for the whole process.
80
+ # So a node can keep what its Markdown and HTML share, like a date, and both stay the same between renders.
81
+ def instance(klass, props)
82
+ key = [klass, props]
83
+ instances[key] ||= begin
84
+ id = list.length
85
+ list << klass.new(**props)
86
+ id
87
+ end
88
+
89
+ list[instances[key]]
90
+ end
91
+
92
+ def placeholder(node)
93
+ "<!-- raindown:node #{list.index(node)} -->"
94
+ end
95
+
96
+ # Swaps each placeholder for its node's HTML.
97
+ def render_placeholders(html, **context)
98
+ html.gsub(PLACEHOLDER) { html(list[::Regexp.last_match(1).to_i], **context) }
99
+ end
100
+
101
+ def html(node, **context)
102
+ return node.render_template(props: {}).to_s if node.class.respond_to?(:template) && node.class.template
103
+
104
+ call(node, :render, **context).to_s
105
+ end
106
+
107
+ # Whether the node renders its own HTML rather than the HTML of its Markdown.
108
+ def html?(node)
109
+ node.respond_to?(:render) || (node.class.respond_to?(:template) && !node.class.template.nil?)
110
+ end
111
+
112
+ def instances
113
+ @instances ||= {}
114
+ end
115
+
116
+ def list
117
+ @list ||= []
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # A file that Pages lists: its front matter and implicit metadata, like page.title, page.folder,
5
+ # page.order and page.path, and its content rendered to HTML when it's asked for.
6
+ class Page
7
+ attr_reader :file_path, :metadata
8
+
9
+ def initialize(file_path:, metadata:, render_options: {})
10
+ @file_path = file_path
11
+ @metadata = metadata
12
+ @render_options = render_options
13
+ end
14
+
15
+ # Rendered when asked for, so a file that lists itself doesn't render forever.
16
+ # The front matter is rendered with it, so its variables work, and a Raindown file's bare directives too.
17
+ def content
18
+ @content ||= begin
19
+ markdown = File.read(file_path)
20
+
21
+ if FrontMatter.parse(markdown).last.strip.empty? then ''
22
+ else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **@render_options)
23
+ end
24
+ end
25
+ end
26
+
27
+ def [](key)
28
+ key.to_sym == :content ? content : metadata[key.to_sym]
29
+ end
30
+
31
+ def respond_to_missing?(name, include_all = false)
32
+ metadata.key?(name) || super
33
+ end
34
+
35
+ # Unknown keys are nil, like front matter a file doesn't have.
36
+ def method_missing(name, *args)
37
+ return super unless args.empty? && !name.end_with?('=', '?', '!')
38
+
39
+ metadata[name]
40
+ end
41
+ end
42
+ end