raindown 0.10.0 → 0.12.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.
@@ -4,12 +4,13 @@ require 'date'
4
4
  require_relative '../bare_directives'
5
5
  require_relative '../code_blocks'
6
6
  require_relative 'curl'
7
+ require_relative 'endpoint_args'
8
+ require_relative 'schema_block'
7
9
  require_relative 'endpoint_variables'
8
10
 
9
11
  module Raindown
10
12
  # Runs the curl block under an endpoint directive in a comment, or a bare one, and writes the response in the code
11
- # block below it,
12
- # followed by the date it was fetched.
13
+ # block below it, followed by the date it was fetched, its status and how long it took.
13
14
  # Only runs when that block is missing or empty, and --execute is passed, so a file can't run requests by itself.
14
15
  # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
15
16
  # <!-- endpoint: -->
@@ -19,10 +20,11 @@ module Raindown
19
20
  # ```json
20
21
  # { "id": 1 }
21
22
  # ```
22
- # <!-- raindown:fetched 2026-10-14 -->
23
+ # <!-- raindown:fetched 2026-10-14 · 200 · 142 ms -->
23
24
  # <!-- :endpoint -->
24
25
  # Its snippet's {placeholders} are filled from EndpointVariables, and with as: login, later snippets can use its
25
- # response, like {login.token}.
26
+ # response, like {login.token}. With status: and schema:, each response in the file is checked, and failures are kept.
27
+ # A schema: can name a SchemaBlock above it, like schema: 'User', or one in .raindown/schemas.rd.
26
28
  # One in Antlers' delimiters, <{ endpoint: }>, only renders, so it's never run.
27
29
  class EndpointRunner
28
30
  # An endpoint's directive in Antlers' delimiters, which only renders, or in a comment, which writes its response back,
@@ -32,6 +34,9 @@ module Raindown
32
34
  END_TAG = /\A {0,3}(?:<\{\s*:endpoint\s*\}>|<!--\s*:endpoint\s*-->)[ \t]*$/
33
35
  # The date a response was fetched, on the line after it.
34
36
  FETCHED = /\A {0,3}<!-- raindown:fetched [^\n]*? -->[ \t]*$/
37
+ FETCHED_STATUS = /raindown:fetched \S+ · (\d{3}) /
38
+
39
+ attr_reader :failures
35
40
 
36
41
  def self.rewrite(content:, path: nil, bare: false, execute: false, **)
37
42
  new(content:, path:, bare: BareDirectives.enabled?(path:, content:, bare:), execute:).rewrite
@@ -39,10 +44,13 @@ module Raindown
39
44
 
40
45
  def initialize(content:, path: nil, bare: false, execute: false, only: nil)
41
46
  @lines = content.lines
47
+ @path = path
42
48
  @variables = EndpointVariables.new(path:, content:)
43
49
  @bare = bare
44
50
  @execute = execute
45
51
  @only = only
52
+ @failures = []
53
+ @schemas = SchemaBlock.shared(path)
46
54
  end
47
55
 
48
56
  def rewrite
@@ -51,6 +59,9 @@ module Raindown
51
59
  while index < @lines.length
52
60
  if (block = CodeBlocks.at(@lines, index))
53
61
  index = block[:close]
62
+ elsif (schema = SchemaBlock.at(@lines, index, bare: @bare))
63
+ @schemas[schema.name] = SchemaBlock.resolve(schema.type, @schemas)
64
+ index = schema.last
54
65
  elsif (args = directive_args(@lines[index]))
55
66
  index = fill(index + 1, args, directive: index)
56
67
  end
@@ -67,23 +78,33 @@ module Raindown
67
78
  def directive_args(line)
68
79
  args = line[COMMENT, 1]
69
80
  args ||= BareDirectives.wrap(line)&.[](DIRECTIVE, 1) if @bare
70
- args&.split
81
+ EndpointArgs.parse(args, schemas: @schemas) if args
71
82
  end
72
83
 
73
84
  # Returns the index of the last line it handled.
74
85
  def fill(index, args, directive:)
75
86
  curl, output = CodeBlocks.endpoint(@lines, index)
76
87
  return index - 1 unless curl&.dig(:body)&.lstrip&.start_with?('curl')
77
- return write_output(curl:, output:, name: response_name(args)) if run?(output, directive)
88
+ return write_output(curl:, output:, args:, directive:) if run?(output, directive)
89
+ return curl[:close] unless output
78
90
 
79
- @variables.remember(response_name(args), output[:body]) if output
91
+ @variables.remember(args.name, output[:body])
92
+ check(args, directive:, body: output[:body], status: fetched_status(output))
80
93
  curl[:close]
81
94
  end
82
95
 
83
- # The name that as: gives the endpoint, for later snippets to use its response.
84
- def response_name(args)
85
- index = args.index('as:')
86
- args[index + 1] if index
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
99
+ end
100
+
101
+ # Run alone, only the endpoint that ran is checked. One without a response or a status hasn't run, so it has nothing
102
+ # to check, while one that ran has a status even when its body is empty.
103
+ def check(args, directive:, body:, status:)
104
+ return if (@only && directive != @only) || (body.strip.empty? && status.nil?)
105
+
106
+ location = [@path, directive + 1].compact.join(':')
107
+ @failures.concat(args.failures(body:, status:).map { "#{location}: #{it}" })
87
108
  end
88
109
 
89
110
  def run?(output, directive)
@@ -93,21 +114,29 @@ module Raindown
93
114
  end
94
115
 
95
116
  # Replaces the output block and the date it was fetched, or with no block, replaces nothing just after the curl block.
96
- def write_output(curl:, output:, name:)
97
- body, language = Curl.run(curl[:body], @variables)
98
- @variables.remember(name, body)
99
- block = [*output_block(body, language), fetched]
117
+ # Secrets that a response echoes are written as their placeholders.
118
+ def write_output(curl:, output:, args:, directive:)
119
+ response = Curl.run(curl[:body], @variables)
120
+ body = @variables.redact(response.body)
121
+ @variables.remember(args.name, body)
122
+ check(args, directive:, body:, status: response.status)
123
+ replace_output(curl:, output:, block: [*output_block(body, response.language), fetched(response)])
124
+ rescue Curl::Error => e
125
+ message = @variables.redact(e.message)
126
+ # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
127
+ raise Curl::Error, message if @only
128
+
129
+ warn "Endpoint failed: #{message}"
130
+ curl[:close]
131
+ end
132
+
133
+ # Returns the index of the block's last line.
134
+ def replace_output(curl:, output:, block:)
100
135
  block.unshift("\n") unless output
101
136
  start = output ? output[:open] : curl[:close] + 1
102
137
 
103
138
  @lines[start, output ? replaced_length(output) : 0] = block
104
139
  start + block.length - 1
105
- rescue Curl::Error => e
106
- # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
107
- raise if @only
108
-
109
- warn "Endpoint failed: #{e.message}"
110
- curl[:close]
111
140
  end
112
141
 
113
142
  # The output block, and the line after it when that's the date it was last fetched.
@@ -116,8 +145,8 @@ module Raindown
116
145
  @lines[output[:close] + 1]&.match?(FETCHED) ? length + 1 : length
117
146
  end
118
147
 
119
- def fetched
120
- "<!-- raindown:fetched #{Date.today.iso8601} -->\n"
148
+ def fetched(response)
149
+ "<!-- raindown:fetched #{Date.today.iso8601} · #{response.status} · #{response.milliseconds} ms -->\n"
121
150
  end
122
151
 
123
152
  # The fence is longer than any run of backticks in the body, so the body can't close it.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'yaml'
5
+ require 'antlers/api'
5
6
  require_relative '../front_matter'
6
7
  require_relative 'curl'
7
8
 
@@ -15,15 +16,22 @@ module Raindown
15
16
  # base_url: http://localhost:3000
16
17
  # staging:
17
18
  # base_url: https://staging.example.com
19
+ # Secrets go in environments.local.yml beside it, which git ignores, and which adds to and overrides its environments.
20
+ # Its values and env's are secret, so a response or error that echoes one is written with its placeholder instead.
18
21
  class EndpointVariables
19
22
  class Error < Curl::Error; end
20
23
 
21
24
  FILE = 'environments.yml'
25
+ LOCAL_FILE = 'environments.local.yml'
26
+ # Shorter values, like a port or a flag, would be replaced wherever they happen to appear in a response.
27
+ SECRET_LENGTH = 4
22
28
  PLACEHOLDER = /\{([A-Za-z_][\w.-]*)\}/
23
29
 
24
30
  def initialize(path:, content:)
25
31
  @path = path
26
32
  @content = content
33
+ @local_names = []
34
+ @secrets = {}
27
35
  end
28
36
 
29
37
  # Keeps a named endpoint's response, as its JSON's data when it's JSON, or else its text.
@@ -41,15 +49,22 @@ module Raindown
41
49
  # placeholders are replaced once, so an environment can read secrets like {env.TOKEN}.
42
50
  def substitute(text, depth: 1)
43
51
  text.gsub(PLACEHOLDER) do |placeholder|
44
- value = value(Regexp.last_match(1))
52
+ name = Regexp.last_match(1)
53
+ value = value(name)
45
54
  next placeholder if value.nil?
46
55
  next value.to_json if value.is_a?(Hash) || value.is_a?(Array)
47
- next substitute(value, depth: 0) if depth.positive? && value.is_a?(String)
48
56
 
49
- value.to_s
57
+ value = depth.positive? && value.is_a?(String) ? substitute(value, depth: 0) : value.to_s
58
+ @secrets[value] = placeholder if secret?(name) && value.length >= SECRET_LENGTH
59
+ value
50
60
  end
51
61
  end
52
62
 
63
+ # The text with each secret that was filled in put back as its placeholder, like {env.TOKEN}, longest first.
64
+ def redact(text)
65
+ @secrets.keys.sort_by { -it.length }.reduce(text) { |redacted, secret| redacted.gsub(secret, @secrets[secret]) }
66
+ end
67
+
53
68
  private
54
69
 
55
70
  # Read when they're first needed, so a file without endpoints doesn't need its environment.
@@ -59,22 +74,38 @@ module Raindown
59
74
 
60
75
  def environment
61
76
  folder = @path && Nodes.find_folder(File.dirname(File.expand_path(@path)))
62
- file = folder && File.join(folder, FILE)
63
- return {} unless file && File.exist?(file)
77
+ return {} unless folder
78
+
79
+ shared, local = [FILE, LOCAL_FILE].map { environments(File.join(folder, it)) }
80
+ return {} if shared.empty? && local.empty?
81
+
82
+ name = environment_name(shared, local)
83
+ @local_names = local.fetch(name, {}).keys
84
+ shared.fetch(name, {}).merge(local.fetch(name, {}))
85
+ end
86
+
87
+ # The one --env or RAINDOWN_ENV names, or else the first.
88
+ def environment_name(shared, local)
89
+ name = ENV.fetch('RAINDOWN_ENV') { (shared.keys + local.keys).first }
90
+ return name if shared.key?(name) || local.key?(name)
64
91
 
65
- environments = YAML.safe_load_file(file) || {}
66
- name = ENV.fetch('RAINDOWN_ENV') { environments.keys.first }
67
- environments.fetch(name) { raise Error, "There's no #{name} environment in #{file}" }
92
+ raise Error, "There's no #{name} environment in #{Nodes::FOLDER}/#{FILE}"
93
+ end
94
+
95
+ def environments(file)
96
+ return {} unless File.exist?(file)
97
+
98
+ YAML.safe_load_file(file) || {}
99
+ end
100
+
101
+ def secret?(name)
102
+ first = name.split('.').first
103
+ first == 'env' || @local_names.include?(first)
68
104
  end
69
105
 
70
106
  # A value by its path, like login.token or users.0.id.
71
107
  def value(name)
72
- name.split('.').reduce(values) do |value, key|
73
- case value
74
- when Hash then value.fetch(key) { value[key.to_sym] }
75
- when Array then value[Integer(key, exception: false) || value.length]
76
- end
77
- end
108
+ Antlers::Queries.read_path(value: values, path: name.split('.'))
78
109
  end
79
110
  end
80
111
  end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../nodes'
4
+
5
+ module Raindown
6
+ # A schema named for endpoints to check their responses against: a schema directive with its fields below it,
7
+ # indented, which become a LowType Schema[], where a key with no type holds the more indented fields below it:
8
+ # <!-- schema: 'User' schema: 'User'
9
+ # name: String name: String
10
+ # address: address:
11
+ # city: String city: String
12
+ # -->
13
+ # is Schema[name: String, address: Schema[city: String]]. Bare, in a Raindown file, its fields end at the first line
14
+ # that isn't indented. An endpoint below it names it, like schema: 'User' or schema: Array[User], as can a schema.
15
+ # Schemas in the project's .raindown/schemas.rd, written the same way, are named for every file's endpoints.
16
+ class SchemaBlock < Data.define(:name, :fields, :last)
17
+ # The name is quoted or not, like 'User' or User.
18
+ COMMENT = /\A {0,3}<!--\s*schema:\s*(['"]?)([A-Z]\w*)\1\s*$/
19
+ BARE = /\A {0,3}schema:\s*(['"]?)([A-Z]\w*)\1\s*$/
20
+ FIELD = /\A([ \t]*)(\S+:)[ \t]*(.*?)\s*\z/
21
+ FILE = 'schemas.rd'
22
+
23
+ # The schema starting at index, or nil when the line doesn't start one.
24
+ def self.at(lines, index, bare: false)
25
+ if (name = lines[index][COMMENT, 2])
26
+ close = (index + 1...lines.length).find { lines[it].strip == '-->' }
27
+ new(name:, fields: lines[index + 1...close], last: close) if close
28
+ elsif bare && (name = lines[index][BARE, 2])
29
+ last = index
30
+ last += 1 while lines[last + 1]&.match?(/\A[ \t]+\S/)
31
+ new(name:, fields: lines[index + 1..last], last:)
32
+ end
33
+ end
34
+
35
+ # The schemas in the project's .raindown/schemas.rd, by name.
36
+ def self.shared(path)
37
+ folder = path && Nodes.find_folder(File.dirname(File.expand_path(path)))
38
+ file = folder && File.join(folder, FILE)
39
+ return {} unless file && File.exist?(file)
40
+
41
+ lines = File.readlines(file)
42
+ schemas = {}
43
+ index = 0
44
+
45
+ while index < lines.length
46
+ if (block = at(lines, index, bare: true))
47
+ schemas[block.name] = resolve(block.type, schemas)
48
+ index = block.last
49
+ end
50
+ index += 1
51
+ end
52
+
53
+ schemas
54
+ end
55
+
56
+ # A schema: argument with the schemas it names written out, like Array[User] as Array[Schema[name: String]].
57
+ def self.resolve(text, schemas)
58
+ text = text.strip
59
+ text = text[1...-1] if text.match?(/\A(['"]).*\1\z/)
60
+ text.gsub(/\b[A-Z]\w*\b/) { schemas.fetch(it, it) }
61
+ end
62
+
63
+ # Each field's indent, key and type, as Schema[key: Type].
64
+ def self.type(fields)
65
+ pairs = []
66
+
67
+ until fields.empty?
68
+ indent, key, value = fields.shift
69
+ nested = []
70
+ nested << fields.shift while fields.first && fields.first[0].length > indent.length
71
+ value = type(nested) if nested.any?
72
+ pairs << "#{key} #{value}"
73
+ end
74
+
75
+ "Schema[#{pairs.join(', ')}]"
76
+ end
77
+
78
+ def type
79
+ self.class.type(fields.filter_map { it.match(FIELD)&.captures })
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'front_matter'
4
+ require_relative 'node'
5
+ require_relative 'variables'
6
+
7
+ module Raindown
8
+ # Markdown Components, the .md and .rd files in a project's .raindown folder named like a component, like BookCard.md,
9
+ # which write their Markdown with their props as variables, so they need no code:
10
+ # --- BookCard: title: Dune rating: 5
11
+ # rating: 0
12
+ # ---
13
+ # **{title}** {rating}/5
14
+ # Its front matter is its props' defaults, and a value before its props, like Dune in BookCard: Dune, is {value}.
15
+ # Its template is read each time it's written, so changing it changes the next render.
16
+ module MarkdownNodes
17
+ EXTENSIONS = %w[.md .rd].freeze
18
+ NAME = /\A[A-Z]\w*\z/
19
+
20
+ module_function
21
+
22
+ def load(folder)
23
+ Dir.glob(EXTENSIONS.map { "*#{it}" }, base: folder).each do |file|
24
+ name = File.basename(file, '.*')
25
+ node_classes[name] ||= MarkdownNode.for(name:, path: File.join(folder, file)) if name.match?(NAME)
26
+ end
27
+ end
28
+
29
+ # Whether a file is a Markdown Component, by its extension and name, so .raindown/data/blueprints.md isn't one.
30
+ def file?(path)
31
+ EXTENSIONS.include?(File.extname(path)) && File.basename(path, '.*').match?(NAME)
32
+ end
33
+
34
+ def node_class(name)
35
+ node_classes[name]
36
+ end
37
+
38
+ def node_classes
39
+ @node_classes ||= {}
40
+ end
41
+ end
42
+
43
+ # A Markdown Component, made with its props, which writes its template's Markdown.
44
+ class MarkdownNode < Node
45
+ def self.for(name:, path:)
46
+ Class.new(self) do
47
+ define_singleton_method(:name) { name }
48
+ define_singleton_method(:path) { path }
49
+ end
50
+ end
51
+
52
+ def initialize(value = nil, **props)
53
+ super()
54
+ @props = props
55
+ @props = { value:, **props } unless value.nil?
56
+ end
57
+
58
+ # Braces that the template pass hid, in its code or its props' values, are written back as braces.
59
+ # The template pass loads here, as it loads nodes itself.
60
+ def replace(metadata: NoMetadata, bare: false)
61
+ require_relative 'pre_renderer'
62
+ defaults, template = FrontMatter.parse(File.read(self.class.path))
63
+ markdown = PreRenderer.render(template, variables: Variables.new(defaults.merge(@props)), metadata:, bare:)
64
+ Variables.unwrap(markdown.sub(/\A(?:[ \t]*\n)+/, '').rstrip)
65
+ end
66
+ end
67
+ end
@@ -21,6 +21,7 @@ module Raindown
21
21
  # a block when it's a loaded node, so other text like "Note:" is left alone.
22
22
  module NodeBlocks
23
23
  class MissingEndTag < StandardError; end
24
+ class BrokenText < StandardError; end
24
25
 
25
26
  NAME = "(?:#{Nodes::BUILT_INS.keys.join('|')}|[A-Z]\\w*(?:::[A-Z]\\w*)*)".freeze
26
27
  OPEN = /\A( {0,3})<\{\s*(#{NAME}):(?:\s+(.*?))?\s*\}>[ \t]*$/
@@ -28,11 +29,13 @@ module Raindown
28
29
  BARE_OPEN = /\A( {0,3})(#{NAME}):(?:[ \t]+(.*?))?[ \t]*$/
29
30
  SELF_CLOSING = /\A( {0,3})<\{\s*:(#{NAME})(?:\s+(.*?))?\s*\}>[ \t]*$/
30
31
  BARE_SELF_CLOSING = /\A( {0,3}):(#{NAME})(?:[ \t]+(.*?))?[ \t]*$/
32
+ # Unquoted text after name:, like YAML's, runs up to the next prop, so title: Hello world limit: 3 has two.
33
+ UNQUOTED = /.+?(?=[ \t]+\w+(?:=|:[ \t])|[ \t]*\z)/
31
34
  # A prop is name=value or name: value, where the value is quoted text, a number, true, false
32
- # or a variable like author.name.
33
- PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|\S+)/
35
+ # or a variable like author.name. After name= it's one word.
36
+ PROP = /(\w+)(?:=|:[ \t]+)("[^"]*"|'[^']*'|(?<==)\S+|#{UNQUOTED})/
34
37
  # A value before the props is the node's argument.
35
- ARG = /\A("[^"]*"|'[^']*'|[^\s"']\S*)(?:\s+|\z)/
38
+ ARG = /\A("[^"]*"|'[^']*'|[^\s"']#{UNQUOTED}|[^\s"'])(?:\s+|\z)/
36
39
 
37
40
  module_function
38
41
 
@@ -43,7 +46,8 @@ module Raindown
43
46
  variables = Variables.new(front_matter)
44
47
 
45
48
  spans(lines, bare:).map do |span|
46
- span.except(:name, :class, :args).merge(node: Nodes.instance(span[:class], *args(span[:args], variables)))
49
+ node = Nodes.instance(span[:class], *args(span[:args], variables, antlers: span[:antlers]))
50
+ span.except(:name, :class, :args, :antlers).merge(node:)
47
51
  end
48
52
  end
49
53
 
@@ -99,7 +103,7 @@ module Raindown
99
103
  found, pattern = directive(lines[index], bare:)
100
104
  return unless found && (klass = Nodes.node_class(found[2]))
101
105
 
102
- span = { name: found[2], class: klass, args: found[3].to_s }
106
+ span = { name: found[2], class: klass, args: found[3].to_s, antlers: %i[open self_closing].include?(pattern) }
103
107
  case pattern
104
108
  when :open, :comment_open
105
109
  end_tag(lines, index, found[2], comment: pattern == :comment_open)
@@ -141,21 +145,30 @@ module Raindown
141
145
  end
142
146
 
143
147
  # The props, after a value that's the node's argument when it comes first, like 'data.csv' in chart: 'data.csv'.
144
- def args(text, variables)
148
+ def args(text, variables, antlers: true)
145
149
  arg = text.match(ARG) unless text.match?(/\A#{PROP}/)
146
- return [props(text, variables)] unless arg
150
+ return [props(text, variables, antlers:)] unless arg
147
151
 
148
- [props(arg.post_match, variables), [value(arg[1], variables)]]
152
+ [props(arg.post_match, variables, antlers:), [value(arg[1], variables, antlers:)]]
149
153
  end
150
154
 
151
- def props(text, variables)
152
- text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables)] }
155
+ # Text that isn't a prop, like world in title: 'Hello' world or tag=a b, is an error rather than left out.
156
+ def props(text, variables, antlers: true)
157
+ unread = text.gsub(PROP, '').split
158
+ raise BrokenText, "Can't read #{unread.join(' ')} in #{text.strip}. Text with spaces needs quotes" if unread.any?
159
+
160
+ text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables, antlers:)] }
153
161
  end
154
162
 
155
- # A value, like 'text', 3, true, nil or :draft, or else a variable, like author.name.
156
- def value(value, variables)
163
+ # A value, like 'text', 3, true, nil or :draft, or else a variable. In Antlers' delimiters a word is a variable, like
164
+ # author.name, while bare or in a comment, which read like Markdown, it's text, like notes in files: folder: notes,
165
+ # and only one in braces is a variable, like {author.name}.
166
+ def value(value, variables, antlers: true)
157
167
  Lowkey::ValueParser.parse(value) do
158
168
  next value[1..-2] if value.match?(/\A(["']).*\1\z/)
169
+ next Variables.value(variables, value[1..-2]) if !antlers && value.match?(/\A\{.+\}\z/)
170
+ next value unless antlers
171
+ raise BrokenText, "Can't read #{value} as a variable. Text with spaces needs quotes" if value.match?(/\s/)
159
172
 
160
173
  Variables.value(variables, value)
161
174
  end
@@ -3,13 +3,15 @@
3
3
  require_relative 'chart/chart'
4
4
  require_relative 'files'
5
5
  require_relative 'js/js_nodes'
6
+ require_relative 'markdown_nodes'
6
7
  require_relative 'node'
7
8
  require_relative 'stat'
8
9
  require_relative 'toc'
9
10
 
10
11
  module Raindown
11
12
  # Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
12
- # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript Components, if any.
13
+ # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
14
+ # Components, if any.
13
15
  # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
14
16
  # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
15
17
  module Nodes
@@ -31,6 +33,7 @@ module Raindown
31
33
  loaded << folder
32
34
  dirload(folder)
33
35
  JSNodes.load(folder)
36
+ MarkdownNodes.load(folder)
34
37
  end
35
38
 
36
39
  def find_folder(directory)
@@ -56,12 +59,12 @@ module Raindown
56
59
  loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
57
60
  end
58
61
 
59
- # A Ruby node of the name, or else a JavaScript one.
62
+ # A Ruby node of the name, or else a JavaScript one, or else a Markdown one.
60
63
  def loaded_class(name)
61
64
  klass = Object.const_get(name) if Object.const_defined?(name)
62
65
  return klass if node?(klass)
63
66
 
64
- JSNodes.node_class(name)
67
+ JSNodes.node_class(name) || MarkdownNodes.node_class(name)
65
68
  end
66
69
 
67
70
  # A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
@@ -13,23 +13,49 @@ 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
@@ -39,10 +65,6 @@ module Raindown
39
65
  @root = root
40
66
  @render_options = render_options
41
67
  @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
42
- @metadata = {}
43
- @tags = {}
44
-
45
- @file_paths.each { |file_path| process(file_path:) }
46
68
  end
47
69
 
48
70
  # The files with any of the tags, like list(folder: 'basics') or list(tags: 'ruby'), sorted by order.
@@ -53,21 +75,46 @@ module Raindown
53
75
  end
54
76
 
55
77
  def tagged(**tags)
56
- tags.flat_map { |type, tag| @tags.dig(type.to_sym, tag) || [] }.uniq
78
+ tags.flat_map { |type, tag| self.tags.dig(type.to_sym, tag) || [] }.uniq
57
79
  end
58
80
 
59
81
  private
60
82
 
83
+ def metadata
84
+ process_files unless @metadata
85
+ @metadata
86
+ end
87
+
88
+ def tags
89
+ process_files unless @tags
90
+ @tags
91
+ end
92
+
93
+ def process_files
94
+ @metadata = {}
95
+ @tags = {}
96
+ @file_paths.each { |file_path| process(file_path:) }
97
+ end
98
+
61
99
  def listed_page(file_path)
62
- Page.new(file_path:, metadata: @metadata[file_path], render_options: @render_options)
100
+ Page.new(file_path:, metadata: metadata[file_path], render_options: @render_options)
63
101
  end
64
102
 
65
103
  def process(file_path:)
66
- metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(Utils.read_markdown(file_path)).first)
104
+ metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
67
105
  @metadata[file_path] = metadata
68
106
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
69
107
  end
70
108
 
109
+ # The file's front matter, read again only when the file's changed since it was last read.
110
+ def front_matter(file_path)
111
+ modified = File.mtime(file_path)
112
+ modified_then, front_matter = self.class.front_matters[file_path]
113
+ return front_matter if modified_then == modified
114
+
115
+ FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
116
+ end
117
+
71
118
  # The path is relative to the root, like docs/_basics/_1-getting-started.md.
72
119
  # A subclass can add or replace metadata here, like Raindeer's Rain::Pages replacing the path with a URL path.
73
120
  def implicit_metadata(file_path:)
@@ -104,8 +151,8 @@ module Raindown
104
151
 
105
152
  # Files with an order come first, by order, then the rest by path.
106
153
  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]
154
+ order = metadata[file_path][:order]
155
+ [order.is_a?(Numeric) ? 0 : 1, order.is_a?(Numeric) ? order : 0, metadata[file_path][:path].to_s]
109
156
  end
110
157
  end
111
158
  end
@@ -69,7 +69,8 @@ module Raindown
69
69
 
70
70
  # Whether a file is one of a project's own nodes, which load when its watcher starts.
71
71
  def node?(path, root:)
72
- File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
72
+ File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) &&
73
+ (NODE_EXTENSIONS.include?(File.extname(path)) || MarkdownNodes.file?(path))
73
74
  end
74
75
 
75
76
  # The roots that aren't inside another of them, as a project's code includes the projects inside it.