raindown 0.1.0 → 0.2.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.
@@ -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,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,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,37 @@
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:)
10
+ @file_path = file_path
11
+ @metadata = metadata
12
+ end
13
+
14
+ # Rendered when asked for, so a file that lists itself doesn't render forever.
15
+ def content
16
+ @content ||= begin
17
+ markdown = FrontMatter.parse(File.read(file_path)).last.strip
18
+ markdown.empty? ? '' : Raindown.render(markdown:)
19
+ end
20
+ end
21
+
22
+ def [](key)
23
+ key.to_sym == :content ? content : metadata[key.to_sym]
24
+ end
25
+
26
+ def respond_to_missing?(name, include_all = false)
27
+ metadata.key?(name) || super
28
+ end
29
+
30
+ # Unknown keys are nil, like front matter a file doesn't have.
31
+ def method_missing(name, *args)
32
+ return super unless args.empty? && !name.end_with?('=', '?', '!')
33
+
34
+ metadata[name]
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'front_matter'
4
+ require_relative 'page'
5
+
6
+ module Raindown
7
+ # The metadata of a project's Markdown files, which list: and files: list by tag.
8
+ # Ported from Raindeer's Rain::Pages, and takes the same Dirload metadata.
9
+ #
10
+ # Each file is tagged with its front matter, like "tags: [ruby, docs]" or "date: 2099", and by its path,
11
+ # where underscored folders are metadata: docs/_basics/_1-getting-started.md has
12
+ # folder: basics, order: 1, path: docs/_basics/_1-getting-started.md
13
+ # Folder tags can share a folder name with "&", like docs/_1&_basics/getting-started.md.
14
+ # An app can add its own metadata by subclassing and overriding implicit_metadata, like Raindeer's URL paths.
15
+ class Pages
16
+ EXTENSIONS = %w[md rd markdown raindown].freeze
17
+ SEGMENTS = Regexp.union(['/', '&', '-'])
18
+
19
+ class << self
20
+ # The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
21
+ def for(path)
22
+ file_directory = File.dirname(File.expand_path(path))
23
+ load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
24
+ end
25
+
26
+ # Lists the files under the root with Dirload, without loading them.
27
+ def load(root:)
28
+ require 'dirload'
29
+ file_types = Dirload.file_paths(absolute_path: root).group_by { Dirload.extension(file_path: it) }
30
+ new(metadata: Dirload::Metadata.new(loaded_paths: {}, missed_paths: [], file_types:), root:)
31
+ end
32
+ end
33
+
34
+ def initialize(metadata:, root:)
35
+ @root = root
36
+ @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
37
+ @metadata = {}
38
+ @tags = {}
39
+
40
+ @file_paths.each { |file_path| process(file_path:) }
41
+ end
42
+
43
+ # The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
44
+ # Without tags, every file.
45
+ def list(**tags)
46
+ file_paths = tags.empty? ? @file_paths : tagged(**tags)
47
+ file_paths.sort_by { |file_path| sort_key(file_path) }.map { |file_path| Page.new(file_path:, metadata: @metadata[file_path]) }
48
+ end
49
+
50
+ def tagged(**tags)
51
+ tags.flat_map { |type, tag| @tags.dig(type.to_sym, tag) || [] }.uniq
52
+ end
53
+
54
+ private
55
+
56
+ def process(file_path:)
57
+ metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path)).first)
58
+ @metadata[file_path] = metadata
59
+ metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
60
+ end
61
+
62
+ # The path is relative to the root, like docs/_basics/_1-getting-started.md.
63
+ # A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
64
+ def implicit_metadata(file_path:)
65
+ { folder: folders(file_path:).last, order: order(file_path:), path: relative(file_path).delete_prefix('/') }.compact
66
+ end
67
+
68
+ def tag(type:, tag:, file_path:)
69
+ @tags[type] ||= {}
70
+ @tags[type][tag] ||= []
71
+ @tags[type][tag] << file_path
72
+ end
73
+
74
+ # The underscored folders, like basics in docs/_basics/.
75
+ def folders(file_path:)
76
+ underscored(File.dirname(relative(file_path))).reject { number?(it) }
77
+ end
78
+
79
+ # The last underscored number in the path, like 1 in docs/_basics/_1-getting-started.md.
80
+ def order(file_path:)
81
+ underscored(relative(file_path)).select { number?(it) }.last&.then { Integer(it, exception: false) || Float(it) }
82
+ end
83
+
84
+ def underscored(path)
85
+ path.split(SEGMENTS).select { it.start_with?('_') }.map { it.delete_prefix('_') }
86
+ end
87
+
88
+ def relative(file_path)
89
+ File.expand_path(file_path).delete_prefix(File.expand_path(@root))
90
+ end
91
+
92
+ def number?(string)
93
+ !Float(string, exception: false).nil?
94
+ end
95
+
96
+ # Files with an order come first, by order, then the rest by path.
97
+ def sort_key(file_path)
98
+ order = @metadata[file_path][:order]
99
+ [order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, @metadata[file_path][:path].to_s]
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'antlers'
4
+ require_relative '../elements'
5
+ require_relative 'bare_directives'
6
+ require_relative 'code'
7
+ require_relative 'front_matter'
8
+
9
+ module Raindown
10
+ # Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
11
+ # So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
12
+ # come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
13
+ # Nodes like toc: and files: render before this, and directives whose output is HTML render in the HTML afterwards.
14
+ module PreRenderer
15
+ BLOCK_DIRECTIVES = %w[for if list].freeze
16
+ # A variable like <{ title }> or a for, if or list directive, which render here.
17
+ DIRECTIVE = /<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})|[@\w.]+)\s*\}>/
18
+ # A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
19
+ BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
20
+ HIDDEN = /\u{E002}(\d+)\u{E003}/
21
+
22
+ module_function
23
+
24
+ def render(markdown, variables:, metadata: {}, bare: false)
25
+ markdown = BareDirectives.expand(markdown) if bare
26
+ return markdown unless markdown.include?('{')
27
+
28
+ markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
29
+ body = FrontMatter.parse(markdown).last
30
+ "#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
31
+ end
32
+
33
+ def render_body(body, variables, metadata)
34
+ hidden = []
35
+ # Other directives are hidden, but not the Markdown between their tags.
36
+ template = body.gsub(/<\{.*?\}>|<\{/) { |directive| directive.match?(/\A#{DIRECTIVE}\z/) ? directive : hide(directive, hidden) }
37
+ template = template.gsub(BLOCK_LINE, '\1')
38
+ return body unless template.include?('{')
39
+
40
+ # Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
41
+ leading = template[/\A\s*/]
42
+ trailing = template[/\s*\z/]
43
+ ast = Antlers.ast(template:, elements: Antlers::Elements[:html, :var, :if, :for] + Elements[:list])
44
+ rendered = Antlers.render(ast:, current_binding: variables.binding, metadata:)
45
+
46
+ "#{leading}#{rendered}#{trailing}".gsub(HIDDEN) { hidden[::Regexp.last_match(1).to_i] }
47
+ end
48
+
49
+ def hide(directive, hidden)
50
+ hidden << directive
51
+ "\u{E002}#{hidden.length - 1}\u{E003}"
52
+ end
53
+ end
54
+ end