raindown 0.4.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: 17711b42b7bfd45766f0ad743b58035648b99394031bde5a420e92f01918f8a3
4
- data.tar.gz: 94753448785a4825b3e2745d0690db75d69b052d57c3ca2e185793b427fedbef
3
+ metadata.gz: ae1eb87d8271ddcfcc3fb956311e11229421188a33f5bd7e0af70cffd2f49719
4
+ data.tar.gz: 380a98d835cfa9de899c5870828ba157e89aa0d3a4b8621a495f1a0ecd1e9bf6
5
5
  SHA512:
6
- metadata.gz: be61212919255c9018253757f33d02e6cae3ec7811276791fe06bfbdaad748ffe4f47abeac8aad807d5862a7b2bcb493a2d53bebfd30f9abcb820c7faffaf438
7
- data.tar.gz: 50488661cf504f7fa1fc9963a9aff7a6ebefb755ba46e2b0e63413133ee4e5781e9d9b94f0ba7c3965d7759b453d95ea7254713cf5e5dce73df2e6299de89b71
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
@@ -5,6 +5,7 @@ require 'trees'
5
5
  require_relative '../raindown'
6
6
  require_relative '../version'
7
7
  require_relative 'rewriter'
8
+ require_relative 'supervisor'
8
9
  require_relative 'watcher'
9
10
 
10
11
  module Raindown
@@ -12,19 +13,23 @@ module Raindown
12
13
  extend Trees
13
14
 
14
15
  USAGE = <<~TEXT
15
- Usage: raindown FILE [options]
16
+ Usage: raindown [PATH] [options]
17
+ raindown FILE --html [OUTPUT]
16
18
  raindown init [DIRECTORY]
19
+ raindown tui [PATH]
17
20
 
18
- 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.
19
23
 
20
24
  Commands:
21
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
22
28
 
23
29
  Options:
24
- -o, --output PATH Write output to PATH instead of FILE.html beside FILE
25
- --once Render once without watching, printing to stdout unless -o is given
26
- --stdin Render Markdown from stdin once, without formatting FILE, which only needs
27
- 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
28
33
  --bare Allow bare directives like :toc in Markdown files
29
34
  -x, --execute Run endpoint curl snippets that have no output yet
30
35
  -h, --help Show this message
@@ -43,8 +48,9 @@ module Raindown
43
48
  # highlight_code: false
44
49
  YAML
45
50
 
46
- OPTIONS = { output: ['-o :output', '--output :output'], bare: ['--bare'], execute: ['-x', '--execute'],
47
- once: ['--once'], stdin: ['--stdin'] }.freeze
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
48
54
 
49
55
  # Trees matches tokens in order, so there's a line for every order and spelling of the options.
50
56
  OPTION_LINES = (0..OPTIONS.size).flat_map { |count| OPTIONS.keys.permutation(count).to_a }.flat_map do |names|
@@ -54,32 +60,48 @@ module Raindown
54
60
  OPTION_LINES.each do |names, flags|
55
61
  # Endpoints run while formatting, which --stdin skips.
56
62
  next if names.include?(:stdin) && names.include?(:execute)
63
+ next if names.include?(:html) && names.include?(:html_to)
57
64
 
58
65
  command = if names.include?(:stdin) then :render_stdin
59
- elsif names.include?(:once) then :render
66
+ elsif names.intersect?(%i[html html_to]) then :render
60
67
  else :watch
61
68
  end
62
69
  options = { bare: names.include?(:bare), execute: names.include?(:execute) }
63
70
  pattern = [':path', *flags].join(' ')
64
71
 
65
- if names.include?(:output)
72
+ if names.include?(:html_to)
66
73
  line(pattern) { |path, output| execute { send(command, path, output, **options) } }
67
74
  else
68
75
  line(pattern) { |path| execute { send(command, path, **options) } }
69
76
  end
70
77
  end
71
78
 
72
- # The :path line would match help, version and init as the file, so they're checked first.
73
79
  def self.run(argv)
74
- abort(USAGE) if argv.empty? || argv.intersect?(%w[-h --help])
75
- return puts(VERSION) if argv.intersect?(%w[-v --version])
76
- return init(argv[1]) if argv.first == 'init' && argv.size <= 2
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)
77
85
 
78
86
  super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
79
87
  rescue StandardError => e
80
88
  report(e, path: argv.first)
81
89
  end
82
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
+
83
105
  # Shows an error in a file as one line, rather than a backtrace, unless RAINDOWN_DEBUG is set.
84
106
  def self.report(error, path:)
85
107
  raise error if ENV['RAINDOWN_DEBUG']
@@ -87,6 +109,15 @@ module Raindown
87
109
  abort("raindown: #{path}: #{error.message}")
88
110
  end
89
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
+
90
121
  # Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
91
122
  def self.init(directory = nil)
92
123
  folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
@@ -99,11 +130,20 @@ module Raindown
99
130
  puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
100
131
  end
101
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)
139
+ end
140
+
102
141
  class << self
103
142
  private
104
143
 
105
144
  def render(path, output = nil, bare: false, execute: false)
106
145
  ensure_exists(path)
146
+ abort('--html renders a file, not a directory') if File.directory?(path)
107
147
  Nodes.load(path)
108
148
 
109
149
  metadata = Pages.for(path)
@@ -117,11 +157,13 @@ module Raindown
117
157
  **Config.render_options(path)))
118
158
  end
119
159
 
120
- 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)
121
162
  ensure_exists(path)
122
- Nodes.load(path)
163
+ return Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
123
164
 
124
- Watcher.new(path:, output:, bare:, execute:).start
165
+ Nodes.load(path)
166
+ Watcher.new(path:, bare:, execute:).start
125
167
  end
126
168
 
127
169
  def write(output, html)
@@ -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
@@ -38,21 +38,31 @@ module Raindown
38
38
  # Each block outside code and front matter as { open:, close:, last:, node: }, where close is nil for a bare block.
39
39
  # A self-closing node is a block of one line with self_closing: true.
40
40
  def find(lines, front_matter: {}, bare: false)
41
- 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 = []
42
52
  index = FrontMatter.line_count(lines)
43
53
 
44
54
  while index < lines.length
45
55
  if (code = CodeBlocks.at(lines, index))
46
56
  index = code[:close]
47
- elsif (block = block_at(lines, index, front_matter:, bare:))
48
- blocks << block
49
- index = block[:last]
57
+ elsif (span = span_at(lines, index, bare:))
58
+ spans << span
59
+ index = span[:last]
50
60
  end
51
61
 
52
62
  index += 1
53
63
  end
54
64
 
55
- blocks
65
+ spans
56
66
  end
57
67
 
58
68
  # Swaps each block for its node's HTML, through a placeholder, or for its Markdown when it has no HTML of its own.
@@ -80,15 +90,15 @@ module Raindown
80
90
  "#{Directives.marker(line)}\n"
81
91
  end
82
92
 
83
- def block_at(lines, index, front_matter:, bare:)
93
+ def span_at(lines, index, bare:)
84
94
  found, pattern = directive(lines[index], bare:)
85
95
  return unless found && (klass = Nodes.node_class(found[2]))
86
96
 
87
- 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 }
88
98
  case pattern
89
- when :open then end_tag(lines, index, found[2]).then { |close| { open: index, close:, last: close, node: } }
90
- when :bare_open then bare_block(lines, index, node)
91
- 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 }
92
102
  end
93
103
  end
94
104
 
@@ -106,11 +116,11 @@ module Raindown
106
116
  end
107
117
 
108
118
  # A bare block holds the lines straight below it, up to the next blank line, with fenced code kept whole.
109
- def bare_block(lines, open, node)
119
+ def bare_block(lines, open)
110
120
  last = open
111
121
  last = CodeBlocks.at(lines, last + 1)&.fetch(:close) || (last + 1) while lines[last + 1] && !lines[last + 1].strip.empty?
112
122
 
113
- { open:, close: nil, last:, node: }
123
+ { open:, close: nil, last: }
114
124
  end
115
125
 
116
126
  def end_tag(lines, open, name)
@@ -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
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+ require_relative 'nodes'
5
+ require_relative 'pages'
6
+
7
+ module Raindown
8
+ # Raindown projects are folders with a .raindown folder, and their Markdown files are watched for changes.
9
+ # A file belongs to the project of the nearest .raindown folder above it, so projects can sit inside each other.
10
+ module Projects
11
+ # Folders of dependencies, builds and tooling, which aren't searched for projects or watched, nor are hidden folders.
12
+ IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
13
+
14
+ module_function
15
+
16
+ # The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
17
+ def roots(directory)
18
+ directory = File.expand_path(directory)
19
+ roots = [root(directory)].compact
20
+
21
+ Find.find(directory) do |path|
22
+ next unless File.directory?(path)
23
+
24
+ Find.prune if path != directory && ignored?(File.basename(path))
25
+ roots << path if File.directory?(File.join(path, Nodes::FOLDER))
26
+ end
27
+
28
+ roots.uniq
29
+ end
30
+
31
+ # The root of the project a file or directory is in, or nil when it isn't in one.
32
+ def root(path)
33
+ path = File.expand_path(path)
34
+ folder = Nodes.find_folder(File.directory?(path) ? path : File.dirname(path))
35
+ folder && File.dirname(folder)
36
+ end
37
+
38
+ # The project's Markdown files, sorted. Ignored folders and the projects inside it are left out,
39
+ # since those projects have nodes of their own.
40
+ def files(root)
41
+ files = []
42
+
43
+ Find.find(root) do |path|
44
+ if File.directory?(path)
45
+ Find.prune if path != root && (ignored?(File.basename(path)) || File.directory?(File.join(path, Nodes::FOLDER)))
46
+ elsif markdown?(path, root:)
47
+ files << path
48
+ end
49
+ end
50
+
51
+ files.sort
52
+ end
53
+
54
+ # Whether a file is Markdown in its project, rather than in an ignored or hidden folder like node_modules.
55
+ def markdown?(path, root:)
56
+ return false unless Pages::EXTENSIONS.include?(File.extname(path).delete_prefix('.').downcase)
57
+
58
+ folders = File.dirname(File.expand_path(path)).delete_prefix(root).split('/').reject(&:empty?)
59
+ folders.none? { |folder| ignored?(folder) }
60
+ end
61
+
62
+ # Whether a file is one of a project's own nodes, which load when its watcher starts.
63
+ def node?(path, root:)
64
+ File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
65
+ end
66
+
67
+ def ignored?(folder)
68
+ folder.start_with?('.') || IGNORED.include?(folder)
69
+ end
70
+ end
71
+ end