raindown 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee322a9f4df788bddfb51b6d669a7a676574620ecd1ce30e69a5682d49b8c88e
4
- data.tar.gz: 2bb5f698d9c5e90f0c44271e4b1529512dab858dcd07beb04061ac8690a81bff
3
+ metadata.gz: ae1eb87d8271ddcfcc3fb956311e11229421188a33f5bd7e0af70cffd2f49719
4
+ data.tar.gz: 380a98d835cfa9de899c5870828ba157e89aa0d3a4b8621a495f1a0ecd1e9bf6
5
5
  SHA512:
6
- metadata.gz: e550608449c87c693edef3c249472a89c62618de92941b81e135250462bbc74a062b61b2e025ea8666ff571b5057a25b67a13ed6fb385f304e500448f09a3034
7
- data.tar.gz: a60db4e479d593ea779e93c3c683401519599ac0bf1d07f6f1e2a280634eff26b5f46167f27d5ce24fcd08f54660cb4051ed21e0c422951247a8f3d2c53938a7
6
+ metadata.gz: b3e74f64840850c572446865664e5368915df5065f09e9483d530d58392acb13f0e20ce872b9f8ee1930154af82d4c3baf8bf6fbd7c370399315e3b7a3a3b480
7
+ data.tar.gz: '09a95ee90ef2568aff8c783ecee3b2ac06590f0a71f1bde6e14e99e7e8f5c6e05776ca6ff3c7dcce22fd55a56e456273ecb0de2475fceb0785a312a022cd48fa'
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ require_relative '../raindown'
6
+ require_relative 'bare_directives'
7
+ require_relative 'endpoint/endpoint_runner'
8
+ require_relative 'rewriter'
9
+
10
+ module Raindown
11
+ # What a tool can do to a file from its Document, like the terminal UI, without knowing how it's drawn.
12
+ # Each returns true when the file changed, and raises when it can't, for the tool to show.
13
+ module Actions
14
+ class NotAnEndpoint < StandardError; end
15
+
16
+ # Editors that open a file at a line with "path:line" rather than "+line path".
17
+ PATH_LINE_EDITORS = %w[hx helix subl zed].freeze
18
+ # Editors that need -g before "path:line", and --wait so the tool waits for the file to close.
19
+ GOTO_EDITORS = %w[code codium cursor windsurf].freeze
20
+
21
+ module_function
22
+
23
+ # Writes back the whole file, like saving it while it's watched.
24
+ def write_back(path)
25
+ Rewriter.update(path:, metadata: Pages.for(path))
26
+ end
27
+
28
+ # Runs one endpoint's request and writes its response, replacing any response it had.
29
+ def execute(path, directive)
30
+ raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
31
+
32
+ content = File.read(path, encoding: Encoding::UTF_8)
33
+ rewritten = EndpointRunner.new(content:, bare: BareDirectives.file?(path), only: directive.line - 1).rewrite
34
+ return false if rewritten == content
35
+
36
+ File.write(path, rewritten)
37
+ true
38
+ end
39
+
40
+ # The command that opens the file at a line in the person's editor, like ["vim", "+12", "notes.md"].
41
+ def edit_command(path, line:, editor: ENV['VISUAL'] || ENV['EDITOR'] || 'vi')
42
+ command = Shellwords.split(editor)
43
+ name = File.basename(command.first)
44
+
45
+ if GOTO_EDITORS.include?(name)
46
+ [*command, *('--wait' unless command.include?('--wait')), '-g', "#{path}:#{line}"]
47
+ elsif PATH_LINE_EDITORS.include?(name)
48
+ [*command, "#{path}:#{line}"]
49
+ else
50
+ [*command, "+#{line}", path]
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/raindown/cli.rb CHANGED
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
3
4
  require 'trees'
4
5
  require_relative '../raindown'
6
+ require_relative '../version'
5
7
  require_relative 'rewriter'
8
+ require_relative 'supervisor'
6
9
  require_relative 'watcher'
7
10
 
8
11
  module Raindown
@@ -10,22 +13,44 @@ module Raindown
10
13
  extend Trees
11
14
 
12
15
  USAGE = <<~TEXT
13
- Usage: raindown FILE [options]
16
+ Usage: raindown [PATH] [options]
17
+ raindown FILE --html [OUTPUT]
18
+ raindown init [DIRECTORY]
19
+ raindown tui [PATH]
14
20
 
15
- Renders FILE to HTML, then re-renders it every time it is saved.
21
+ Watches PATH and writes back to its Markdown files as they're saved, like their tables of contents and tables.
22
+ PATH is a file, or a directory (default: current directory) whose projects with a .raindown folder are watched.
23
+
24
+ Commands:
25
+ init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
26
+ tui [PATH] Browse a project's files and outlines in the terminal, fold directives, run endpoints
27
+ and open the editor at a line
16
28
 
17
29
  Options:
18
- -o, --output PATH Write output to PATH instead of FILE.html beside FILE
19
- --once Render once without watching, printing to stdout unless -o is given
20
- --stdin Render Markdown from stdin once, without formatting FILE, which only needs
21
- its extension (to allow bare directives) and can be unsaved
30
+ --html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
31
+ --stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
32
+ its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
22
33
  --bare Allow bare directives like :toc in Markdown files
23
34
  -x, --execute Run endpoint curl snippets that have no output yet
24
35
  -h, --help Show this message
36
+ -v, --version Show the version
37
+
38
+ Set RAINDOWN_DEBUG=1 to show where an error happened.
25
39
  TEXT
26
40
 
27
- OPTIONS = { output: ['-o :output', '--output :output'], bare: ['--bare'], execute: ['-x', '--execute'],
28
- once: ['--once'], stdin: ['--stdin'] }.freeze
41
+ CONFIG = <<~YAML
42
+ # Raindown project settings. See https://raindown.dev/docs/config
43
+ #
44
+ # Show directives as HTML comments rather than in bold:
45
+ # hide_directives: true
46
+ #
47
+ # Leave code blocks unhighlighted, so the browser can highlight them instead:
48
+ # highlight_code: false
49
+ YAML
50
+
51
+ # "--html OUTPUT" is matched as "--html-to OUTPUT" (see run), so a flag after a bare --html isn't read as the OUTPUT.
52
+ OPTIONS = { html_to: ['--html-to :output'], html: ['--html'], bare: ['--bare'], execute: ['-x', '--execute'],
53
+ stdin: ['--stdin'] }.freeze
29
54
 
30
55
  # Trees matches tokens in order, so there's a line for every order and spelling of the options.
31
56
  OPTION_LINES = (0..OPTIONS.size).flat_map { |count| OPTIONS.keys.permutation(count).to_a }.flat_map do |names|
@@ -35,26 +60,82 @@ module Raindown
35
60
  OPTION_LINES.each do |names, flags|
36
61
  # Endpoints run while formatting, which --stdin skips.
37
62
  next if names.include?(:stdin) && names.include?(:execute)
63
+ next if names.include?(:html) && names.include?(:html_to)
38
64
 
39
65
  command = if names.include?(:stdin) then :render_stdin
40
- elsif names.include?(:once) then :render
66
+ elsif names.intersect?(%i[html html_to]) then :render
41
67
  else :watch
42
68
  end
43
69
  options = { bare: names.include?(:bare), execute: names.include?(:execute) }
44
70
  pattern = [':path', *flags].join(' ')
45
71
 
46
- if names.include?(:output)
72
+ if names.include?(:html_to)
47
73
  line(pattern) { |path, output| execute { send(command, path, output, **options) } }
48
74
  else
49
75
  line(pattern) { |path| execute { send(command, path, **options) } }
50
76
  end
51
77
  end
52
78
 
53
- # The :path line would match a help flag as the file, so help is checked first.
54
79
  def self.run(argv)
55
- abort(USAGE) if argv.empty? || argv.intersect?(%w[-h --help])
80
+ return if command(argv)
81
+
82
+ # Without a path, like "raindown" or "raindown -x", the current directory is watched.
83
+ argv = ['.', *argv] if argv.empty? || argv.first.start_with?('-')
84
+ argv = html_output(argv)
56
85
 
57
86
  super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
87
+ rescue StandardError => e
88
+ report(e, path: argv.first)
89
+ end
90
+
91
+ # The :path line would match help, version, init and tui as the file, so they're checked first.
92
+ # Returns true when argv was one of them.
93
+ def self.command(argv)
94
+ abort(USAGE) if argv.intersect?(%w[-h --help])
95
+
96
+ if argv.intersect?(%w[-v --version]) then puts(VERSION)
97
+ elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
98
+ elsif argv.first == 'tui' && argv.size <= 2 then tui(argv[1] || '.')
99
+ else return false
100
+ end
101
+
102
+ true
103
+ end
104
+
105
+ # Shows an error in a file as one line, rather than a backtrace, unless RAINDOWN_DEBUG is set.
106
+ def self.report(error, path:)
107
+ raise error if ENV['RAINDOWN_DEBUG']
108
+
109
+ abort("raindown: #{path}: #{error.message}")
110
+ end
111
+
112
+ # "--html out.html" names an OUTPUT, while "--html --bare" doesn't, as OUTPUT never starts with "-".
113
+ def self.html_output(argv)
114
+ index = argv.index('--html') || (return argv)
115
+ output = argv[index + 1]
116
+ return argv if output.nil? || output.start_with?('-')
117
+
118
+ [*argv[0...index], '--html-to', *argv[(index + 1)..]]
119
+ end
120
+
121
+ # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
122
+ def self.init(directory = nil)
123
+ folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
124
+ config = File.join(folder, Config::FILE)
125
+ return puts("#{config} already exists") if File.exist?(config)
126
+
127
+ FileUtils.mkdir_p(folder)
128
+ File.write(config, CONFIG)
129
+ puts "Created #{config}"
130
+ puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
131
+ end
132
+
133
+ # The terminal UI loads curses, so it's only required when it starts.
134
+ def self.tui(path)
135
+ abort("File not found: #{path}") unless File.exist?(path)
136
+
137
+ require_relative 'tui'
138
+ TUI.start(path)
58
139
  end
59
140
 
60
141
  class << self
@@ -62,6 +143,7 @@ module Raindown
62
143
 
63
144
  def render(path, output = nil, bare: false, execute: false)
64
145
  ensure_exists(path)
146
+ abort('--html renders a file, not a directory') if File.directory?(path)
65
147
  Nodes.load(path)
66
148
 
67
149
  metadata = Pages.for(path)
@@ -75,11 +157,13 @@ module Raindown
75
157
  **Config.render_options(path)))
76
158
  end
77
159
 
78
- def watch(path, output = nil, bare: false, execute: false)
160
+ # Watching only writes back to the files, while --html and --stdin render HTML.
161
+ def watch(path, bare: false, execute: false)
79
162
  ensure_exists(path)
80
- Nodes.load(path)
163
+ return Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
81
164
 
82
- Watcher.new(path:, output:, bare:, execute:).start
165
+ Nodes.load(path)
166
+ Watcher.new(path:, bare:, execute:).start
83
167
  end
84
168
 
85
169
  def write(output, html)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
3
+ require 'cgi/escape'
4
4
 
5
5
  module Raindown
6
6
  # Content directives, like a node's directive and end tag or an endpoint's, are kept in the HTML:
@@ -22,7 +22,7 @@ module Raindown
22
22
  def render(html, hide: false)
23
23
  html.gsub(MARKER) do
24
24
  text = ::Regexp.last_match(1)
25
- hide ? "<!-- #{text} -->" : "<p><strong>#{ERB::Util.html_escape(text)}</strong></p>"
25
+ hide ? "<!-- #{text} -->" : "<p><strong>#{CGI.escapeHTML(text.to_s)}</strong></p>"
26
26
  end
27
27
  end
28
28
  end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'commonmarker'
4
+
5
+ require_relative 'bare_directives'
6
+ require_relative 'code_blocks'
7
+ require_relative 'endpoint/endpoint_runner'
8
+ require_relative 'front_matter'
9
+ require_relative 'node_blocks'
10
+
11
+ module Raindown
12
+ # A Markdown file's structure, read from its source so each part has the line it's on, for tools like an editor.
13
+ # Headings that a directive writes back below it are in the source too, while ones a node only renders as HTML aren't.
14
+ # Lines count from 1, from the top of the file.
15
+ class Document
16
+ Heading = Data.define(:level, :text, :line)
17
+
18
+ # A content directive: a node, like toc: or a project's Changelog:, or an endpoint.
19
+ # It spans line to end_line, its end tag, or the last line of a bare block.
20
+ # Output is the lines Raindown writes back, like a node's Markdown or an endpoint's response, and is nil
21
+ # when nothing's written yet. A self-closing directive like <{ :toc }> only renders, so it never writes.
22
+ Directive = Data.define(:name, :args, :line, :end_line, :output, :writes) do
23
+ def endpoint? = name == 'endpoint'
24
+ end
25
+
26
+ # Inline nodes whose text a reader sees, like code spans, as opposed to inline HTML tags.
27
+ TEXT_NODES = %i[text code].freeze
28
+ BREAK_NODES = %i[softbreak linebreak].freeze
29
+ ENDPOINT_END_TAG = /\A {0,3}<\{\s*:endpoint\s*\}>[ \t]*$/
30
+
31
+ def self.parse(markdown, bare: false)
32
+ new(markdown:, bare:)
33
+ end
34
+
35
+ # Raindown files read bare directives. A project's own nodes are only directives once they're loaded,
36
+ # like with Nodes.load(path), the same as when the file renders.
37
+ def self.read(path)
38
+ new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: BareDirectives.file?(path))
39
+ end
40
+
41
+ def initialize(markdown:, bare: false)
42
+ @markdown = markdown
43
+ @lines = markdown.lines
44
+ @bare = bare
45
+ end
46
+
47
+ # The headings in the order they appear, with their source text, like "Notes for {name}" before it's rendered.
48
+ def headings
49
+ @headings ||= nodes(:heading).map do |node|
50
+ Heading.new(level: node.header_level, text: text(node), line: node.source_position[:start_line])
51
+ end
52
+ end
53
+
54
+ # The content directives in the order they appear. Template directives like if: and for: aren't here,
55
+ # since they only change what renders, and write nothing back.
56
+ def directives
57
+ @directives ||= (node_directives + endpoint_directives).sort_by(&:line)
58
+ end
59
+
60
+ # The headings and directives together, in the order they appear.
61
+ def outline
62
+ (headings + directives).sort_by(&:line)
63
+ end
64
+
65
+ private
66
+
67
+ def root
68
+ @root ||= Commonmarker.parse(@markdown, options: MARKDOWN_OPTIONS)
69
+ end
70
+
71
+ def nodes(type)
72
+ [].tap { |nodes| root.walk { |node| nodes << node if node.type == type } }
73
+ end
74
+
75
+ def text(heading)
76
+ parts = []
77
+ heading.walk do |node|
78
+ parts << node.string_content if TEXT_NODES.include?(node.type)
79
+ parts << ' ' if BREAK_NODES.include?(node.type)
80
+ end
81
+ parts.join.strip
82
+ end
83
+
84
+ def node_directives
85
+ NodeBlocks.spans(@lines, bare: @bare).map do |span|
86
+ output = trimmed(span[:open] + 1, span[:close] ? span[:close] - 1 : span[:last])
87
+ directive(span[:name], span[:args], span[:open]..span[:last], output:, writes: !span[:self_closing])
88
+ end
89
+ end
90
+
91
+ def endpoint_directives
92
+ directives = []
93
+ index = FrontMatter.line_count(@lines)
94
+
95
+ while index < @lines.length
96
+ if (code = CodeBlocks.at(@lines, index))
97
+ index = code[:close]
98
+ elsif (args = endpoint_args(@lines[index]))
99
+ directives << endpoint(index, args)
100
+ index = directives.last.end_line - 1
101
+ end
102
+
103
+ index += 1
104
+ end
105
+
106
+ directives
107
+ end
108
+
109
+ def endpoint_args(line)
110
+ line = BareDirectives.wrap(line) || line if @bare
111
+ line[EndpointRunner::DIRECTIVE, 1]&.strip
112
+ end
113
+
114
+ # The response and the date it was fetched are the output, while the curl snippet above them is the author's.
115
+ def endpoint(open, args)
116
+ curl, response = CodeBlocks.endpoint(@lines, open + 1)
117
+ output = response && (response[:open]..(fetched?(response[:close] + 1) ? response[:close] + 1 : response[:close]))
118
+ last = output&.end || curl&.fetch(:close) || open
119
+ # A bare endpoint, which isn't delimited, ends after its blocks.
120
+ last = end_tag(after: last) || last if @lines[open].match?(EndpointRunner::DIRECTIVE)
121
+
122
+ directive('endpoint', args, open..last, output:, writes: true)
123
+ end
124
+
125
+ def fetched?(index)
126
+ @lines[index]&.match?(EndpointRunner::FETCHED)
127
+ end
128
+
129
+ # The end tag after the endpoint's blocks, with only blank lines between.
130
+ def end_tag(after:)
131
+ index = CodeBlocks.skip_blank_lines(@lines, after + 1)
132
+ index if @lines[index]&.match?(ENDPOINT_END_TAG)
133
+ end
134
+
135
+ # Lines from first to last with the blank lines around them left out, or nil when there are none.
136
+ def trimmed(first, last)
137
+ first += 1 while first <= last && @lines[first].strip.empty?
138
+ last -= 1 while last >= first && @lines[last].strip.empty?
139
+ first..last if first <= last
140
+ end
141
+
142
+ # From a range of indexes to line numbers.
143
+ def directive(name, args, span, output:, writes:)
144
+ output = (output.begin + 1)..(output.end + 1) if output
145
+ Directive.new(name:, args:, line: span.begin + 1, end_line: span.end + 1, output:, writes:)
146
+ end
147
+ end
148
+ end
@@ -9,6 +9,7 @@ module Raindown
9
9
  # Runs the curl block under an endpoint directive and writes the response in the code block below it,
10
10
  # followed by the date it was fetched.
11
11
  # Only runs when that block is missing or empty, and the directive has :x/:execute or --execute is passed.
12
+ # Or with only: and a directive's line index, runs just that endpoint, replacing any response it has.
12
13
  # <{ endpoint: :x }>
13
14
  # ```sh
14
15
  # curl https://api.example.com
@@ -28,10 +29,11 @@ module Raindown
28
29
  new(content:, bare: BareDirectives.enabled?(path:, bare:), execute:).rewrite
29
30
  end
30
31
 
31
- def initialize(content:, bare: false, execute: false)
32
+ def initialize(content:, bare: false, execute: false, only: nil)
32
33
  @lines = content.lines
33
34
  @bare = bare
34
35
  @execute = execute
36
+ @only = only
35
37
  end
36
38
 
37
39
  def rewrite
@@ -41,7 +43,7 @@ module Raindown
41
43
  if (block = CodeBlocks.at(@lines, index))
42
44
  index = block[:close]
43
45
  elsif (args = directive_args(@lines[index]))
44
- index = fill(index + 1, args)
46
+ index = fill(index + 1, args, directive: index)
45
47
  end
46
48
 
47
49
  index += 1
@@ -58,15 +60,20 @@ module Raindown
58
60
  end
59
61
 
60
62
  # Returns the index of the last line it handled.
61
- def fill(index, args)
63
+ def fill(index, args, directive:)
62
64
  curl, output = CodeBlocks.endpoint(@lines, index)
63
65
  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
+ return curl[:close] unless run?(args, output, directive)
66
67
 
67
68
  write_output(curl:, output:)
68
69
  end
69
70
 
71
+ def run?(args, output, directive)
72
+ return directive == @only if @only
73
+
74
+ (output.nil? || output[:body].strip.empty?) && execute?(args)
75
+ end
76
+
70
77
  def execute?(args)
71
78
  @execute || args.intersect?(EXECUTE_ARGS)
72
79
  end
@@ -80,6 +87,9 @@ module Raindown
80
87
  @lines[start, output ? replaced_length(output) : 0] = block
81
88
  start + block.length - 1
82
89
  rescue Curl::Error => e
90
+ # The one endpoint that was asked for fails for the caller to show, like in the terminal UI.
91
+ raise if @only
92
+
83
93
  warn "Endpoint failed: #{e.message}"
84
94
  curl[:close]
85
95
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'lowkey'
4
+
3
5
  require_relative 'code_blocks'
4
6
  require_relative 'directives'
5
7
  require_relative 'front_matter'
@@ -36,21 +38,31 @@ module Raindown
36
38
  # Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a bare block.
37
39
  # A self-closing node is a block of one line with self_closing: true.
38
40
  def find(lines, front_matter: {}, bare: false)
39
- blocks = []
41
+ variables = Variables.new(front_matter)
42
+
43
+ spans(lines, bare:).map do |span|
44
+ span.except(:name, :class, :args).merge(node: Nodes.instance(span[:class], *args(span[:args], variables)))
45
+ end
46
+ end
47
+
48
+ # Each block like find's, with the node's name, class and props text in place of the node,
49
+ # so a block can be found without making its node, like for a document's outline.
50
+ def spans(lines, bare: false)
51
+ spans = []
40
52
  index = FrontMatter.line_count(lines)
41
53
 
42
54
  while index < lines.length
43
55
  if (code = CodeBlocks.at(lines, index))
44
56
  index = code[:close]
45
- elsif (block = block_at(lines, index, front_matter:, bare:))
46
- blocks << block
47
- index = block[:last]
57
+ elsif (span = span_at(lines, index, bare:))
58
+ spans << span
59
+ index = span[:last]
48
60
  end
49
61
 
50
62
  index += 1
51
63
  end
52
64
 
53
- blocks
65
+ spans
54
66
  end
55
67
 
56
68
  # Swaps each block for its node's HTML, through a placeholder, or for its Markdown when it has no HTML of its own.
@@ -78,15 +90,15 @@ module Raindown
78
90
  "#{Directives.marker(line)}\n"
79
91
  end
80
92
 
81
- def block_at(lines, index, front_matter:, bare:)
93
+ def span_at(lines, index, bare:)
82
94
  found, pattern = directive(lines[index], bare:)
83
95
  return unless found && (klass = Nodes.node_class(found[2]))
84
96
 
85
- node = Nodes.instance(klass, *args(found[3].to_s, Variables.new(front_matter)))
97
+ span = { name: found[2], class: klass, args: found[3].to_s }
86
98
  case pattern
87
- when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
88
- when :bare_open then bare_block(lines, index, node)
89
- else { open: index, close: nil, last: index, node:, self_closing: true }
99
+ when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, **span } }
100
+ when :bare_open then { **bare_block(lines, index), **span }
101
+ else { open: index, close: nil, last: index, self_closing: true, **span }
90
102
  end
91
103
  end
92
104
 
@@ -104,11 +116,11 @@ module Raindown
104
116
  end
105
117
 
106
118
  # A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
107
- def bare_block(lines, open, node)
119
+ def bare_block(lines, open)
108
120
  last = open
109
121
  last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
110
122
 
111
- { open:, close: nil, last:, node: }
123
+ { open:, close: nil, last: }
112
124
  end
113
125
 
114
126
  def end_tag(lines, open, name)
@@ -131,13 +143,12 @@ module Raindown
131
143
  text.scan(PROP).to_h { |name, value| [name.to_sym, value(value, variables)] }
132
144
  end
133
145
 
146
+ # A value, like 'text', 3, true, nil or :draft, or else a variable, like author.name.
134
147
  def value(value, variables)
135
- case value
136
- when /\A(["']).*\1\z/ then value[1..-2]
137
- when /\A-?\d+\z/ then value.to_i
138
- when /\A-?\d+\.\d+\z/ then value.to_f
139
- when 'true', 'false' then value == 'true'
140
- else Variables.value(variables, value)
148
+ Lowkey::ValueParser.parse(value) do
149
+ next value[1..-2] if value.match?(/\A(["']).*\1\z/)
150
+
151
+ Variables.value(variables, value)
141
152
  end
142
153
  end
143
154
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../raindown'
4
+ require_relative 'rewriter'
5
+
6
+ module Raindown
7
+ # Writes back to one project's Markdown files as they're saved, like its TOCs, tables and endpoint responses,
8
+ # with the project's own nodes. Each line it shows starts with the project's name.
9
+ class ProjectWorker
10
+ attr_reader :root, :name
11
+
12
+ def initialize(root:, name: File.basename(root), bare: false, execute: false)
13
+ @root = root
14
+ @name = name
15
+ @bare = bare
16
+ @execute = execute
17
+ end
18
+
19
+ # Loads the project's nodes from its .raindown folder.
20
+ def start
21
+ Nodes.load(File.join(root, Nodes::FOLDER))
22
+ rescue StandardError, ScriptError => e
23
+ raise if ENV['RAINDOWN_DEBUG']
24
+
25
+ warn "[#{name}] raindown: #{Nodes::FOLDER}: #{e.message}"
26
+ end
27
+
28
+ # Writing back saves the file again, and that save changes nothing, so it ends there.
29
+ def update(path)
30
+ changed = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
31
+ puts "[#{name}] Updated #{relative(path)}" if changed
32
+ rescue StandardError => e
33
+ raise if ENV['RAINDOWN_DEBUG']
34
+
35
+ # An error in the file is shown rather than stopping the watcher, so it can be fixed and saved again.
36
+ warn "[#{name}] raindown: #{relative(path)}: #{e.message}"
37
+ end
38
+
39
+ # Workers in this process have nothing to stop, unlike a ForkedWorker's process.
40
+ def stop; end
41
+
42
+ private
43
+
44
+ def relative(path)
45
+ File.expand_path(path).delete_prefix(File.join(root, ''))
46
+ end
47
+ end
48
+ end