raindown 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fab4cc96057c97d8a6c5ef07b11e12fda146e25855ac2015f667e838b1c805d
4
- data.tar.gz: 1b9044d112a1d05773bb22bfdc4767264ad190c24bceb49b82733ecd1989a026
3
+ metadata.gz: 3288235ed874ddbeae5b31b5ef6707e59864f5cca305bb9bc74fcf74a9c36366
4
+ data.tar.gz: c7bfc9dcdec92857c433d4abacbc80d221bb6029617972d362af43163c235c39
5
5
  SHA512:
6
- metadata.gz: 1cf8132950f24d3005113635835426471514a7c79312cac8379e66d93c3345b32e9788e2f86c73eb80e4050d757eab4bcb7c08ef8ad6a3c41aad2808493a6c5b
7
- data.tar.gz: f1c47f511df4d7d9725c60f30fb0e3a0af3e762e197d510ec55eafe9ad01db51f0cefa6b6b2a684e1e5a37fb2654fbe1c8fc91c07e8b5a815e576df51b617682
6
+ metadata.gz: 1cb1aaaf0f325d154393656cad4040aa8a2205464e577ce69f23931bd2f2727d28222aba9a6d3a059bd2f7531e59050f0056b413a1a72cb72fe66e5e785b300a
7
+ data.tar.gz: f89b1e87119328272a1df69a6c2f9c04159f98db03c749774bac1ae533307eff32bc833b111c41ef569456ed9e0f2ec421afc938744b31102e4f6c8c848fcace
@@ -31,7 +31,7 @@ module Raindown
31
31
  raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
32
32
  raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
33
33
 
34
- content = File.read(path, encoding: Encoding::UTF_8)
34
+ content = Utils.read_markdown(path)
35
35
  rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
36
36
  return false if rewritten == content
37
37
 
@@ -39,7 +39,7 @@ module Raindown
39
39
  # they're allowed, like with --bare.
40
40
  # A project's own nodes are only directives once they're loaded, like with Nodes.load(path), the same as when the file renders.
41
41
  def self.read(path, bare: false)
42
- new(markdown: File.read(path, encoding: Encoding::UTF_8), bare: bare || BareDirectives.file?(path))
42
+ new(markdown: Utils.read_markdown(path), bare: bare || BareDirectives.file?(path))
43
43
  end
44
44
 
45
45
  def initialize(markdown:, bare: false)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # The folders .gitignore files leave out, like a Drupal site's web/core, so finding the projects in a folder of
5
+ # them, like ~/dev, skips them. Only lines naming a folder are read, like /vendor/, node_modules or
6
+ # /web/modules/contrib/*, as a wildcard could leave out a project, and a ! takes a folder back out.
7
+ class Gitignore
8
+ WILDCARD = /[*?\[]/
9
+
10
+ # The rules for the folders inside a folder: its .gitignore's and those of the folders above it, up to its
11
+ # repository, as a repository's rules don't reach the repositories inside it.
12
+ def initialize(folder, above = nil)
13
+ above = nil if File.exist?(File.join(folder, '.git'))
14
+ @paths = Set.new(above&.paths)
15
+ @names = Set.new(above&.names)
16
+ file = File.join(folder, '.gitignore')
17
+ File.foreach(file, chomp: true) { read(it.strip, folder) } if File.file?(file)
18
+ end
19
+
20
+ def ignores?(path)
21
+ @paths.include?(path) || @names.include?(File.basename(path))
22
+ end
23
+
24
+ protected
25
+
26
+ attr_reader :paths, :names
27
+
28
+ private
29
+
30
+ # A folder with a slash, like /vendor or web/files, is from the .gitignore's folder, and one without, like
31
+ # node_modules, is any folder of that name below it.
32
+ def read(line, folder)
33
+ pattern = line.delete_suffix('/*').delete_suffix('/')
34
+ negated = pattern.delete_prefix!('!')
35
+ return if pattern.empty? || pattern.start_with?('#') || pattern.match?(WILDCARD)
36
+
37
+ if pattern.include?('/')
38
+ update(@paths, File.join(folder, pattern.delete_prefix('/')), negated:)
39
+ else
40
+ update(@names, pattern, negated:)
41
+ end
42
+ end
43
+
44
+ def update(rules, rule, negated:)
45
+ return rules.delete(rule) if negated
46
+
47
+ rules.add(rule)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ // Runs a project's JavaScript Components for Raindown, which starts it with node -e, since inside Raindown's package
2
+ // the script is where NodeJS can't read it. Each line on stdin is a request, answered by one line of JSON on stdout:
3
+ // { op: 'load', folder } → { nodes: [{ name, render }] }
4
+ // { op: 'new', id, name, args, props } → {}
5
+ // { op: 'call', id, method, context } → { value }
6
+ // A request that throws is answered with { error }.
7
+ import { readdirSync } from 'node:fs'
8
+ import { join } from 'node:path'
9
+ import { createInterface } from 'node:readline'
10
+ import { pathToFileURL } from 'node:url'
11
+
12
+ // stdout carries the replies, so what nodes log goes to stderr.
13
+ console.log = console.info = console.error
14
+
15
+ const classes = {}
16
+ const nodes = {}
17
+
18
+ const requests = {
19
+ // Every exported class with replace is a node, named by its export, or by its class for a default export.
20
+ async load({ folder }) {
21
+ for (const file of readdirSync(folder).filter((file) => /\.m?js$/.test(file))) {
22
+ const exports = await import(pathToFileURL(join(folder, file)).href)
23
+
24
+ for (const [name, value] of Object.entries(exports)) {
25
+ if (typeof value?.prototype?.replace === 'function') classes[name === 'default' ? value.name : name] = value
26
+ }
27
+ }
28
+
29
+ const nodes = Object.entries(classes).map(([name, node]) => ({ name, render: typeof node.prototype.render === 'function' }))
30
+ return { nodes }
31
+ },
32
+
33
+ new({ id, name, args, props }) {
34
+ nodes[id] = new classes[name](props, ...args)
35
+ return {}
36
+ },
37
+
38
+ async call({ id, method, context }) {
39
+ return { value: await nodes[id][method](context) }
40
+ },
41
+ }
42
+
43
+ for await (const line of createInterface({ input: process.stdin })) {
44
+ const request = JSON.parse(line)
45
+
46
+ try {
47
+ process.stdout.write(`${JSON.stringify(await requests[request.op](request))}\n`)
48
+ } catch (error) {
49
+ process.stdout.write(`${JSON.stringify({ error: String(error?.message ?? error) })}\n`)
50
+ }
51
+ }
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ require_relative '../node'
7
+
8
+ module Raindown
9
+ # JavaScript Components, the .js and .mjs files in a project's .raindown folder, which export classes with replace and
10
+ # optionally render, like Ruby nodes. They run in a NodeJS process of Raindown's own, started when a folder has them,
11
+ # so only projects with JavaScript Components need NodeJS.
12
+ module JSNodes
13
+ EXTENSIONS = %w[.js .mjs].freeze
14
+ SCRIPT = File.join(__dir__, 'host.mjs')
15
+
16
+ class Error < StandardError; end
17
+
18
+ # The NodeJS process running the nodes, which answers each request with a line of JSON. NodeJS can't read the script
19
+ # inside Raindown's package, so it's passed to node -e.
20
+ class Host
21
+ def initialize
22
+ @input, @output, = Open3.popen2('node', '--input-type=module', '-e', File.read(SCRIPT))
23
+ rescue Errno::ENOENT
24
+ raise Error, 'JavaScript Components need NodeJS 22 or later on your PATH'
25
+ end
26
+
27
+ def request(**message)
28
+ @input.puts(JSON.generate(message))
29
+ reply = JSON.parse(@output.gets || raise(Error, 'NodeJS stopped running the JavaScript Components'))
30
+ raise Error, reply['error'] if reply['error']
31
+
32
+ reply
33
+ end
34
+ end
35
+
36
+ module_function
37
+
38
+ # Loads the JavaScript Components in a .raindown folder, starting NodeJS the first time a folder has any.
39
+ def load(folder)
40
+ return if Dir.glob(EXTENSIONS.map { "*#{it}" }, base: folder).empty?
41
+
42
+ host.request(op: 'load', folder:).fetch('nodes').each do |node|
43
+ node_classes[node['name']] ||= JSNode.for(name: node['name'], render: node['render'])
44
+ end
45
+ end
46
+
47
+ def node_class(name)
48
+ node_classes[name]
49
+ end
50
+
51
+ def host
52
+ @host ||= Host.new
53
+ end
54
+
55
+ def node_classes
56
+ @node_classes ||= {}
57
+ end
58
+ end
59
+
60
+ # A JavaScript Component, made in the NodeJS process with its props, where its replace and render are sent.
61
+ class JSNode < Node
62
+ # A class for each JavaScript Component, which renders its own HTML when the component has render.
63
+ def self.for(name:, render:)
64
+ Class.new(self) do
65
+ define_singleton_method(:name) { name }
66
+ define_method(:render) { |**context| js_output(:render, context) } if render
67
+ end
68
+ end
69
+
70
+ def initialize(*args, **props)
71
+ super()
72
+ JSNodes.host.request(op: 'new', id: object_id, name: self.class.name, args:, props:)
73
+ end
74
+
75
+ def replace(**context)
76
+ js_output(:replace, context)
77
+ end
78
+
79
+ private
80
+
81
+ def js_output(method, context)
82
+ JSNodes.host.request(op: 'call', id: object_id, method:, context: json_context(**context)).fetch('value').to_s
83
+ end
84
+
85
+ # JSON can't hold Pages, so the metadata is its root and each file's front matter and path.
86
+ def json_context(metadata: NoMetadata, **context)
87
+ root = metadata.root if metadata.respond_to?(:root)
88
+ { **context, metadata: { root:, files: metadata.list.map(&:metadata) } }
89
+ end
90
+ end
91
+ end
@@ -2,13 +2,14 @@
2
2
 
3
3
  require_relative 'chart/chart'
4
4
  require_relative 'files'
5
+ require_relative 'js/js_nodes'
5
6
  require_relative 'node'
6
7
  require_relative 'stat'
7
8
  require_relative 'toc'
8
9
 
9
10
  module Raindown
10
11
  # Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
11
- # A project keeps them in a .raindown folder, which is loaded with Dirload.
12
+ # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript Components, if any.
12
13
  # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
13
14
  # document: (the Markdown being rendered), metadata: and bare:, and gets the ones it asks for.
14
15
  module Nodes
@@ -29,6 +30,7 @@ module Raindown
29
30
  require 'dirload'
30
31
  loaded << folder
31
32
  dirload(folder)
33
+ JSNodes.load(folder)
32
34
  end
33
35
 
34
36
  def find_folder(directory)
@@ -54,11 +56,12 @@ module Raindown
54
56
  loaded_class(BUILT_INS[name]) || Raindown.const_get(BUILT_INS[name])
55
57
  end
56
58
 
59
+ # A Ruby node of the name, or else a JavaScript one.
57
60
  def loaded_class(name)
58
- klass = Object.const_get(name)
59
- klass if node?(klass)
60
- rescue NameError
61
- nil
61
+ klass = Object.const_get(name) if Object.const_defined?(name)
62
+ return klass if node?(klass)
63
+
64
+ JSNodes.node_class(name)
62
65
  end
63
66
 
64
67
  # A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
data/lib/raindown/page.rb CHANGED
@@ -17,7 +17,7 @@ module Raindown
17
17
  # It follows the file's project config, like render_file, unless the render options say otherwise.
18
18
  def content
19
19
  @content ||= begin
20
- markdown = File.read(file_path, encoding: Encoding::UTF_8)
20
+ markdown = Utils.read_markdown(file_path)
21
21
 
22
22
  if FrontMatter.parse(markdown).last.strip.empty? then ''
23
23
  else Raindown.render(markdown:, bare: BareDirectives.file?(file_path), **Config.render_options(file_path).merge(@render_options))
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'front_matter'
4
4
  require_relative 'page'
5
+ require_relative 'utils'
5
6
 
6
7
  module Raindown
7
8
  # The metadata of a project's Markdown files, which list: and files: list by tag.
@@ -62,7 +63,7 @@ module Raindown
62
63
  end
63
64
 
64
65
  def process(file_path:)
65
- metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(File.read(file_path, encoding: Encoding::UTF_8)).first)
66
+ metadata = implicit_metadata(file_path:).merge(FrontMatter.parse(Utils.read_markdown(file_path)).first)
66
67
  @metadata[file_path] = metadata
67
68
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
68
69
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'find'
4
+ require_relative 'gitignore'
4
5
  require_relative 'nodes'
5
6
  require_relative 'pages'
6
7
 
@@ -10,22 +11,29 @@ module Raindown
10
11
  module Projects
11
12
  # Folders of dependencies, builds and tooling, which aren't searched for projects or watched, nor are hidden folders.
12
13
  IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
14
+ # Ruby nodes and JavaScript Components.
15
+ NODE_EXTENSIONS = ['.rb', '.rbx', *JSNodes::EXTENSIONS].freeze
13
16
 
14
17
  module_function
15
18
 
16
19
  # The roots of the projects in a directory: the project the directory is in, if any, and every project below it.
17
20
  def roots(directory)
18
21
  directory = File.expand_path(directory)
19
- roots = [root(directory)].compact
22
+ [root(directory), *project_folders(directory)].compact.uniq
23
+ end
20
24
 
21
- Find.find(directory) do |path|
22
- next unless File.directory?(path)
25
+ # The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
26
+ # gitignored ones, so it stays quick in a big folder like ~/dev.
27
+ def project_folders(directory, gitignore = Gitignore.new(directory))
28
+ Dir.glob('*/', base: directory).flat_map do |folder|
29
+ path = File.join(directory, folder.chomp('/'))
30
+ next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
23
31
 
24
- Find.prune if path != directory && ignored?(File.basename(path))
25
- roots << path if File.directory?(File.join(path, Nodes::FOLDER))
26
- end
32
+ inside = project_folders(path, Gitignore.new(path, gitignore))
33
+ next inside unless project?(path)
27
34
 
28
- roots.uniq
35
+ [path, *inside]
36
+ end
29
37
  end
30
38
 
31
39
  # The root of the project a file or directory is in, or nil when it isn't in one.
@@ -36,13 +44,13 @@ module Raindown
36
44
  end
37
45
 
38
46
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
39
- # since they have nodes of their own, unless they're asked for, like by the terminal UI, which lists them all.
40
- def files(root, nested: false)
47
+ # since they have nodes of their own.
48
+ def files(root)
41
49
  files = []
42
50
 
43
51
  Find.find(root) do |path|
44
52
  if File.directory?(path)
45
- Find.prune if path != root && (ignored?(File.basename(path)) || (!nested && project?(path)))
53
+ Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
46
54
  elsif markdown?(path, root:)
47
55
  files << path
48
56
  end
@@ -61,7 +69,12 @@ module Raindown
61
69
 
62
70
  # Whether a file is one of a project's own nodes, which load when its watcher starts.
63
71
  def node?(path, root:)
64
- File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
72
+ File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
73
+ end
74
+
75
+ # The roots that aren't inside another of them, as a project's code includes the projects inside it.
76
+ def outer_roots(roots)
77
+ roots.reject { |root| roots.any? { root != it && root.start_with?(File.join(it, '')) } }
65
78
  end
66
79
 
67
80
  def project?(folder)
@@ -3,6 +3,7 @@
3
3
  require_relative 'node_injector'
4
4
  require_relative 'endpoint/endpoint_runner'
5
5
  require_relative 'table_formatter'
6
+ require_relative 'utils'
6
7
 
7
8
  module Raindown
8
9
  # Rewrites the generated parts of a Markdown file in place before it's rendered:
@@ -21,7 +22,7 @@ module Raindown
21
22
 
22
23
  # Returns true when the file changed.
23
24
  def update(path:, bare: false, execute: false, metadata: NoMetadata)
24
- content = File.read(path, encoding: Encoding::UTF_8)
25
+ content = Utils.read_markdown(path)
25
26
  rewritten = rewrite(content:, path:, bare:, execute:, metadata:)
26
27
  return false if rewritten == content
27
28
 
@@ -3,6 +3,7 @@
3
3
  require_relative '../code_box'
4
4
  require_relative '../events'
5
5
  require_relative '../../blueprints'
6
+ require_relative '../../projects'
6
7
  require_relative 'traced_code'
7
8
  require_relative 'canvas'
8
9
  require_relative 'code_comments'
@@ -32,9 +33,10 @@ module Raindown
32
33
 
33
34
  attr_reader :cursor
34
35
 
35
- def initialize(root:)
36
+ def initialize(root:, projects: [root])
36
37
  @root = root
37
- @path = File.join(root, Blueprints::FILE)
38
+ @projects = projects
39
+ @paths = projects.map { File.join(it, Blueprints::FILE) }
38
40
  @all = []
39
41
  @cursor = 0
40
42
  select_box(0)
@@ -51,7 +53,7 @@ module Raindown
51
53
  end
52
54
 
53
55
  # Whether there are any blueprints yet, saved or in the code's comments, which are looked for once until it's loaded.
54
- def any? = File.exist?(@path) || commented.any?
56
+ def any? = @paths.any? { File.exist?(it) } || commented.any?
55
57
 
56
58
  # Whether the file's changed since it was read, so it's read again on the next tick.
57
59
  def changed? = mtime != @read_at
@@ -60,13 +62,26 @@ module Raindown
60
62
  @read_at = mtime
61
63
  newest = @all.first&.time
62
64
  @commented = nil
63
- @all = Blueprints.read(@root) + commented
65
+ @all = saved + commented
64
66
  self.cursor = 0 unless @all.first&.time == newest
65
67
  self.cursor = @cursor
66
68
  end
67
69
 
68
70
  def selected = blueprints[@cursor]
69
71
 
72
+ # Narrows the blueprints to a project's, or with nil, shows every project's.
73
+ def project=(project)
74
+ @project = project
75
+ self.cursor = 0
76
+ end
77
+
78
+ # The blueprints in the project they're narrowed to, if any, which the filter's keywords narrow further.
79
+ def in_project
80
+ return @all unless @project
81
+
82
+ @all.select { File.expand_path(it.path, @root).start_with?(File.join(@project, '')) }
83
+ end
84
+
70
85
  # Another blueprint's shown from its first box, while the same one keeps its box and canvas, like when it's clicked.
71
86
  def cursor=(index)
72
87
  shown = selected
@@ -115,7 +130,7 @@ module Raindown
115
130
  method = code
116
131
  return Edit.new(path: method.path, line: method.line) if method
117
132
 
118
- Edit.new(path: File.join(@root, selected.path), line: selected.line)
133
+ Edit.new(path: File.expand_path(selected.path, @root), line: selected.line)
119
134
  end
120
135
 
121
136
  # A click on a box drawn in the blueprint, which moves on to the next code it names when it's already selected.
@@ -138,9 +153,20 @@ module Raindown
138
153
 
139
154
  def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
140
155
 
141
- def mtime = File.exist?(@path) ? File.mtime(@path) : nil
156
+ def mtime = @paths.map { File.exist?(it) ? File.mtime(it) : nil }
157
+
158
+ # Every project's saved blueprints, newest first, each by its file from the root.
159
+ def saved
160
+ @projects.flat_map { |project| Blueprints.read(project).map { it.with(path: from_root(File.join(project, it.path))) } }
161
+ .sort_by(&:time).reverse
162
+ end
163
+
164
+ def from_root(path) = path.delete_prefix(File.join(@root, ''))
165
+
166
+ # The projects whose code is read, as a project's code includes the projects inside it.
167
+ def code_projects = @code_projects ||= Projects.outer_roots(@projects)
142
168
 
143
- def commented = @commented ||= CodeComments.blueprints(@root)
169
+ def commented = @commented ||= CodeComments.blueprints(@root, projects: code_projects)
144
170
  end
145
171
  end
146
172
  end
@@ -16,9 +16,10 @@ module Raindown
16
16
 
17
17
  module_function
18
18
 
19
- # The blueprints in the comments of the project's code, by file, from the top of each.
20
- def blueprints(root)
21
- CodeMethods.files(root).flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
19
+ # The blueprints in the comments of the projects' code, by file from the root, from the top of each. In a folder of
20
+ # projects, like ~/dev, only the projects' code is read.
21
+ def blueprints(root, projects: [root])
22
+ projects.flat_map { CodeMethods.files(it) }.flat_map { |path| in_file(path, path.delete_prefix(File.join(root, ''))) }
22
23
  end
23
24
 
24
25
  def in_file(path, relative)
@@ -15,10 +15,11 @@ module Raindown
15
15
 
16
16
  def typing? = @filtering == true
17
17
 
18
- # The blueprints with every keyword, or all of them, filtered again only when the keywords or the blueprints change,
18
+ # The blueprints with every keyword, or all of them, in the project they're narrowed to, if any, filtered again only when
19
+ # the keywords, the project or the blueprints change,
19
20
  # as what's drawn asks for the selected one many times a frame.
20
21
  def blueprints
21
- @filtered = [query, @all, filtered(query.to_s.downcase.split)] unless filtered_now?
22
+ @filtered = [query, @all, @project, filtered(query.to_s.downcase.split)] unless filtered_now?
22
23
  @filtered.last
23
24
  end
24
25
 
@@ -38,12 +39,12 @@ module Raindown
38
39
 
39
40
  private
40
41
 
41
- def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all)
42
+ def filtered_now? = @filtered && @filtered[0] == query && @filtered[1].equal?(@all) && @filtered[2] == @project
42
43
 
43
44
  def filtered(keywords)
44
- return @all if keywords.empty?
45
+ return in_project if keywords.empty?
45
46
 
46
- @all.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
+ in_project.select { |blueprint| keywords.all? { blueprint.words.downcase.include?(it) || blueprint.kind.include?(it) } }
47
48
  end
48
49
 
49
50
  def open_filter
@@ -50,7 +50,8 @@ module Raindown
50
50
 
51
51
  def first_method(owner) = methods.select { it.owner == owner }.min_by { [it.path, it.line] }
52
52
 
53
- def methods = @methods ||= CodeMethods.list(@root)
53
+ # The projects' methods. In a folder of projects, like ~/dev, only the projects' code is read.
54
+ def methods = @methods ||= code_projects.flat_map { CodeMethods.list(it) }
54
55
  end
55
56
  end
56
57
  end
@@ -18,7 +18,7 @@ module Raindown
18
18
 
19
19
  # The five bins, each a box with its number, its temper, and a bar of how many feelings it holds.
20
20
  def bins
21
- counts = view.log.counts
21
+ counts = view.counts
22
22
  most = [counts.values.max, 1].max
23
23
  chosen = chosen_temper
24
24
  boxes = counts.each_with_index.map { |(temper, count), index| bin(index + 1, temper, count, most, chosen) }
@@ -41,9 +41,24 @@ module Raindown
41
41
 
42
42
  module_function
43
43
 
44
+ # Git log runs in a thread of its own while the methods are read, as it's as slow as reading them. Waiting on git's
45
+ # output frees the main thread to read, and the two meet when each method gets its file's changes, for its drift:
46
+ # Thread main thread
47
+ # ┌──────────────────────────┐ ┌─────────────────────────┐
48
+ # │ CodeMethods.file_changes │ │ CodeMethods.tagged │
49
+ # │ git log, in a subprocess │ │ or CodeMethods.indented │
50
+ # └────────────┬─────────────┘ └────────────┬────────────┘
51
+ # │ changes.value waits for it │
52
+ # └───────────────────┬───────────────────┘
53
+ # │
54
+ # ▼
55
+ # ┌──────────────────────┐
56
+ # │ CodeMethods.drifting │
57
+ # └──────────────────────┘
44
58
  def list(root)
45
- changes = file_changes(root)
46
- drifting((tagged(root) || indented(root)).map { it.with(changes: changes.fetch(it.path, 0)) })
59
+ changes = Thread.new { file_changes(root) }
60
+ methods = tagged(root) || indented(root)
61
+ drifting(methods.map { it.with(changes: changes.value.fetch(it.path, 0)) })
47
62
  end
48
63
 
49
64
  # Each method with its drift, from 0 for the lightest to 9 for the heaviest, where methods that weigh the same
@@ -81,9 +81,9 @@ module Raindown
81
81
  # How the method last felt, while its code's the same as when it was felt, or nil.
82
82
  def temper(method) = rows.reverse.find { felt_row?(it, method) }&.fetch(:temper)
83
83
 
84
- # How many feelings there are of each temper, in the bins' order.
85
- def counts
86
- tally = rows.map { it[:temper] }.tally
84
+ # How many feelings there are of each temper, in the bins' order, of those within a folder, like a project, if one's given.
85
+ def counts(within: nil)
86
+ tally = rows.select { within.nil? || File.join(@root, path(it)).start_with?(File.join(within, '')) }.map { it[:temper] }.tally
87
87
  TEMPERS.to_h { [it, tally.fetch(it, 0)] }
88
88
  end
89
89
 
@@ -132,5 +132,33 @@ module Raindown
132
132
  nil
133
133
  end
134
134
  end
135
+
136
+ # The feelings about each project's code, in its own FeelingsLog, so in a folder of projects, like ~/dev, each project
137
+ # keeps its own feelings, and the folder isn't made a project.
138
+ class FeelingsLogs
139
+ def initialize(projects:)
140
+ @logs = projects.to_h { [it, FeelingsLog.new(root: it)] }
141
+ end
142
+
143
+ def add(feeling) = log_of(feeling.method).add(feeling)
144
+ def felt?(method) = log_of(method).felt?(method)
145
+ def temper(method) = log_of(method).temper(method)
146
+ def rows = @logs.values.flat_map(&:rows)
147
+
148
+ # Each project's feelings about methods that have changed, or gone, from its methods there are now, to its memories.
149
+ def move_stale_to_memories(methods)
150
+ by_log = methods.group_by { log_of(it) }
151
+ @logs.each_value { it.move_stale_to_memories(by_log.fetch(it, [])) }
152
+ end
153
+
154
+ def counts(within: nil)
155
+ empty = FeelingsLog::TEMPERS.to_h { [it, 0] }
156
+ @logs.values.map { it.counts(within:) }.reduce(empty) { |sum, counts| sum.merge(counts) { |_, felt, more| felt + more } }
157
+ end
158
+
159
+ private
160
+
161
+ def log_of(method) = @logs.find { |root, _| method.path.start_with?(File.join(root, '')) }&.last
162
+ end
135
163
  end
136
164
  end
@@ -76,9 +76,10 @@ module Raindown
76
76
 
77
77
  def view = navigator.view
78
78
 
79
- # Instead of the grid, that there's nothing to feel, or the celebration.
79
+ # Instead of the grid, that there's nothing to feel, as there are no projects or no methods, or the celebration.
80
80
  def instead
81
- return blank(" No functions or methods in #{File.basename(navigator.root)} to feel") if view.code_methods.empty?
81
+ return alert(NO_PROJECTS) if navigator.projects.empty?
82
+ return alert(["No functions or methods in #{File.basename(navigator.root)} to feel."]) if view.code_methods.empty?
82
83
 
83
84
  celebration(view.celebration)
84
85
  end
@@ -174,10 +175,6 @@ module Raindown
174
175
  lines[(height - shown.length) / 2, shown.length] = shown
175
176
  lines.map { [segment(it.center(width), :too_small)] }
176
177
  end
177
-
178
- def blank(message)
179
- [[segment(message, :feelings)], *Array.new([height - 1, 0].max) { [segment('', :blank)] }].first(height)
180
- end
181
178
  end
182
179
  end
183
180
  end
@@ -29,12 +29,13 @@ module Raindown
29
29
  KEYS = %i[up down left right edit execute enter toggle expand celebrate].freeze
30
30
 
31
31
  # The temper being noted, and the note typed so far, while it's asking why, whether the selected method's code
32
- # shows all that fits, until another's selected, and the rain then the waffle, once the batch is refined.
33
- attr_reader :noting, :note, :cursor, :log, :celebration
32
+ # shows all that fits, until another's selected, the rain then the waffle, once the batch is refined, and the project
33
+ # its methods and counts are narrowed to, if any.
34
+ attr_reader :noting, :note, :cursor, :log, :celebration, :project
34
35
 
35
- def initialize(root:)
36
- @root = root
37
- @log = FeelingsLog.new(root:)
36
+ def initialize(projects:)
37
+ @projects = projects
38
+ @log = FeelingsLogs.new(projects:)
38
39
  @last_feelings = {}
39
40
  @cursor = 0
40
41
  @week = WeeklyBatch.new(log: @log)
@@ -63,12 +64,20 @@ module Raindown
63
64
  # the memories.
64
65
  def load
65
66
  was = selected
66
- every = CodeMethods.list(@root)
67
- @log.move_stale_to_memories(every)
68
- @week.read(every)
67
+ @every = @projects.flat_map { CodeMethods.list(it) }
68
+ @log.move_stale_to_memories(@every)
69
+ @week.read(in_project)
69
70
  reselect(was)
70
71
  end
71
72
 
73
+ # Narrows the methods to a project's, or with nil, shows every project's, picking the batch again from them.
74
+ def project=(project)
75
+ @project = project
76
+ @week.read(in_project)
77
+ @week.pick_again
78
+ self.cursor = 0
79
+ end
80
+
72
81
  # Selects the method again, by its file and name, wherever it is now, or keeps the cursor where it was.
73
82
  def reselect(was)
74
83
  self.cursor = code_methods.index { [it.path, it.label] == [was&.path, was&.label] } || @cursor
@@ -78,6 +87,9 @@ module Raindown
78
87
  def batch = @week.batch
79
88
  def code_methods = batch.code_methods
80
89
 
90
+ # How many feelings there are of each temper, in the project the methods are narrowed to, if any.
91
+ def counts = log.counts(within: project)
92
+
81
93
  def selected = code_methods[@cursor]
82
94
 
83
95
  def cursor=(index)
@@ -106,6 +118,11 @@ module Raindown
106
118
 
107
119
  private
108
120
 
121
+ # The methods of the project they're narrowed to, if any, which are none until they're read.
122
+ def in_project
123
+ @every.to_a.select { @project.nil? || it.path.start_with?(File.join(@project, '')) }
124
+ end
125
+
109
126
  # Moves between the methods, or acts on the selected one.
110
127
  def method_step(key)
111
128
  case key
@@ -38,6 +38,11 @@ module Raindown
38
38
  refill(grid_cells)
39
39
  end
40
40
 
41
+ # Picks the batch again on the next fill, like when the methods are narrowed to a project's.
42
+ def pick_again
43
+ @filled_for = nil
44
+ end
45
+
41
46
  private
42
47
 
43
48
  def refill(grid_cells)
@@ -28,8 +28,8 @@ module Raindown
28
28
  PROJECT = '🌧'
29
29
 
30
30
  def load
31
- @files = Projects.files(root, nested: true)
32
- @projects = Projects.roots(root).to_set
31
+ @projects = @workers.roots
32
+ @files = @projects.flat_map { Projects.files(it) }.sort
33
33
  end
34
34
 
35
35
  # The folder the tree starts from: the scope's, or the root's.
@@ -45,10 +45,10 @@ module Raindown
45
45
  @files = FileTree.new(root:, workers:)
46
46
  @endpoints = Endpoints.new(workers:)
47
47
  @dashboard = DashboardView.new(root:, workers:)
48
- @feelings = FeelingsView.new(root:)
49
- @blueprints = BlueprintsView.new(root:)
48
+ @feelings = FeelingsView.new(projects: Projects.outer_roots(workers.roots))
49
+ @blueprints = BlueprintsView.new(root:, projects: workers.roots)
50
50
  @nav = ListView.new { nav_rows }
51
- first = Projects.files(root, nested: true).first
51
+ first = @files.files.first
52
52
  # The dashboard is the first page, unless a file is asked for, and "d" goes from it to the first file.
53
53
  @view = OutlineView.new(first, workers:) if first
54
54
  path ? open_at(path, nil) : open_view(dashboard)
@@ -3,8 +3,8 @@
3
3
  module Raindown
4
4
  module TUI
5
5
  # The list of projects that drops down from the header's path, with "p" or a click, when there's more than one.
6
- # Choosing one narrows the files and the dashboard to its own, and "All projects" shows every project's again.
7
- # It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
6
+ # Choosing one narrows the files, the dashboard, Code feelings and Blueprints to its own, and "All projects" shows every
7
+ # project's again. It stays open until a project's chosen, with Enter or a click, while up and down move its cursor.
8
8
  # Esc or "p" close it without choosing, and other keys and clicks do nothing while it's open.
9
9
  module ProjectMenu
10
10
  # The menu's cursor, while it's open.
@@ -54,6 +54,8 @@ module Raindown
54
54
  @files.scope = project
55
55
  @endpoints.project = project
56
56
  dashboard.project = project
57
+ feelings.project = project
58
+ blueprints.project = project
57
59
  open_view(dashboard)
58
60
  end
59
61
  end
@@ -65,7 +65,7 @@ module Raindown
65
65
  # the ones whose snippet can't be read.
66
66
  def endpoints
67
67
  Projects.files(root).flat_map do |path|
68
- lines = File.readlines(path, encoding: Encoding::UTF_8)
68
+ lines = Utils.read_markdown(path).lines
69
69
  runnable = Document.read(path, bare: @bare).directives.select { it.endpoint? && it.writes }
70
70
  runnable.filter_map { |directive| endpoint(path, lines, directive) }
71
71
  rescue StandardError
@@ -94,7 +94,7 @@ module Raindown
94
94
  end
95
95
 
96
96
  def charts_in(path)
97
- content = File.read(path, encoding: Encoding::UTF_8)
97
+ content = Utils.read_markdown(path)
98
98
  bare = @bare || BareDirectives.enabled?(path:, content:)
99
99
  lines = content.lines
100
100
  headings = Document.parse(content, bare:).headings
@@ -28,8 +28,10 @@ module Raindown
28
28
  def page = 'Dashboard'
29
29
 
30
30
  def lines
31
+ return alert(NO_PROJECTS) if navigator.projects.empty?
32
+
31
33
  cards = dashboard.cards
32
- return blank(height, " No charts in #{File.basename(navigator.root)} yet, like <{ chart: '3 5 2 8' }>") if cards.empty?
34
+ return alert(["No charts in #{File.basename(navigator.root)} yet.", "Add one like <{ chart: '3 5 2 8' }>"]) if cards.empty?
33
35
 
34
36
  rows = dashboard.rows(width)
35
37
  @headings = headings(rows, cards)
@@ -94,9 +96,7 @@ module Raindown
94
96
  [segment(" #{from_home(project)}", :project)]
95
97
  end
96
98
 
97
- def blank(count, message = '')
98
- [[segment(message, :info)], *Array.new([count - 1, 0].max) { [segment('', :blank)] }].first(count)
99
- end
99
+ def blank(count) = Array.new(count) { [segment('', :blank)] }
100
100
  end
101
101
  end
102
102
  end
@@ -6,6 +6,9 @@ require_relative '../text'
6
6
 
7
7
  module Raindown
8
8
  module TUI
9
+ # Said in place of a page, like the charts or the code to feel, when the folder has no projects, with how to make one.
10
+ NO_PROJECTS = ['No projects found in this directory.', %(Run "raindown init" in a project's folder.)].freeze
11
+
9
12
  # Draws what's in a pane, :nav or :main, as lines of the pane's width and height, which the Layout puts in it.
10
13
  # Like the Layout, it's made again for each draw, so it draws what the Navigator and its view hold then.
11
14
  Renderer = Struct.new(:navigator, :pane, :width, :height) do
@@ -14,6 +17,23 @@ module Raindown
14
17
 
15
18
  # Its side of the line above the panes, which is blank unless it has something to say there, like a progress.
16
19
  def spacer = [segment('', :blank)]
20
+
21
+ # The lines in a purple box, like an alert, at the top left, with blank below it, like when there's nothing to show.
22
+ # It's no wider than the pane, cutting the lines to fit.
23
+ def alert(messages)
24
+ inner = [messages.map { display_width(it) }.max, width - 5].min.clamp(0..)
25
+ box = [alert_edge('╔', '╗', inner), *messages.map { alert_line(it, inner) }, alert_edge('╚', '╝', inner)]
26
+ (box + Array.new(height) { [segment('', :blank)] }).first(height)
27
+ end
28
+
29
+ def alert_edge(left, right, inner)
30
+ padded([segment('', :blank, width: 1), segment("#{left}#{'═' * (inner + 2)}#{right}", :alert, width: inner + 4)])
31
+ end
32
+
33
+ def alert_line(message, inner)
34
+ padded([segment('', :blank, width: 1), segment('║ ', :alert, width: 2), segment(message, :alert, width: inner),
35
+ segment(' ║', :alert, width: 2)])
36
+ end
17
37
  end
18
38
  end
19
39
  end
@@ -34,7 +34,7 @@ module Raindown
34
34
  Curses.stdscr.timeout = timeout
35
35
  key = Curses.stdscr.getch
36
36
  return :tick if key.nil?
37
- return mouse(zones) if key == Curses::KEY_MOUSE
37
+ return mouse(zones) if MOUSE && key == Curses::KEY_MOUSE
38
38
 
39
39
  Curses.stdscr.clear if key == Curses::KEY_RESIZE
40
40
  key_event(key, typing:)
@@ -60,22 +60,27 @@ module Raindown
60
60
  TYPING_KEYS.fetch(key) { Type.new(text: key) if key.is_a?(String) && key.match?(/\A[[:print:]]\z/) }
61
61
  end
62
62
 
63
+ # Windows' curses has no mouse, so the curses gem leaves out its constants and the mouse does nothing there.
64
+ MOUSE = Curses.respond_to?(:mousemask)
65
+
63
66
  # The mouse buttons that click, which are the left button's. Scrolling does nothing, and since older ncurses,
64
67
  # like macOS's, has no button for scrolling down, it can't be mistaken for a click.
65
- CLICKS = Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
68
+ CLICKS = MOUSE && Curses::BUTTON1_PRESSED | Curses::BUTTON1_CLICKED
66
69
 
67
70
  # Letting go of the button, which older ncurses, like macOS's, also reports for each move while it's held, as it
68
71
  # doesn't know the moves terminals send then.
69
- RELEASE = Curses::BUTTON1_RELEASED
72
+ RELEASE = MOUSE && Curses::BUTTON1_RELEASED
70
73
 
71
74
  # Moving the mouse, which terminals report once they're asked to, with any motion tracking.
72
- MOTION = Curses::REPORT_MOUSE_POSITION
75
+ MOTION = MOUSE && Curses::REPORT_MOUSE_POSITION
73
76
  ANY_MOTION = "\e[?1003h"
74
77
  NO_MOTION = "\e[?1003l"
75
78
 
76
79
  # The terminal sends the app its mouse events, clicks and moves, so scrolling doesn't scroll the terminal either.
77
80
  # ncurses only asks some terminals to report every move, so it's asked directly too, until untrack_mouse.
78
81
  def track_mouse
82
+ return unless MOUSE
83
+
79
84
  Curses.mousemask(CLICKS | RELEASE | MOTION)
80
85
  Curses.mouseinterval(0)
81
86
  $stdout.write(ANY_MOTION)
@@ -83,6 +88,8 @@ module Raindown
83
88
  end
84
89
 
85
90
  def untrack_mouse
91
+ return unless MOUSE
92
+
86
93
  $stdout.write(NO_MOTION)
87
94
  $stdout.flush
88
95
  end
@@ -85,7 +85,7 @@ module Raindown
85
85
  cursor: Curses::A_UNDERLINE, cursor_button: Curses::A_UNDERLINE | Curses::A_BOLD, heading: Curses::A_BOLD,
86
86
  body: Curses::A_DIM, error: Curses::A_BOLD, button: Curses::A_BOLD, folder: Curses::A_BOLD, project: Curses::A_BOLD, page: Curses::A_BOLD,
87
87
  key: Curses::A_REVERSE, border: Curses::A_DIM, divider: Curses::A_BOLD,
88
- code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM
88
+ code_keyword: Curses::A_BOLD, code_comment: Curses::A_DIM, alert: Curses::A_BOLD
89
89
  )
90
90
  end
91
91
 
@@ -112,11 +112,12 @@ module Raindown
112
112
  Hash.new(text).merge(plain.transform_values { it | text })
113
113
  end
114
114
 
115
- # Headings, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
115
+ # Headings, alerts, the waffle's butter's shine and its syrup, and Code feelings' text and lines, bold around the method the
116
116
  # mouse is over, and its purple.
117
117
  def emphasis
118
118
  { heading: pair(:heading) | Curses::A_BOLD, feelings: pair(:feelings), nearby: pair(:feelings) | Curses::A_BOLD,
119
- feelings_purple: pair(:feelings_purple), shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
119
+ feelings_purple: pair(:feelings_purple), alert: pair(:feelings_purple) | Curses::A_BOLD,
120
+ shine: pair(:shine) | Curses::A_BOLD, syrup: pair(:syrup) }
120
121
  end
121
122
 
122
123
  # Highlighted code's tokens, like keywords and comments, with comments dim in 8 colours, where there's no grey.
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ module Utils
5
+ module_function
6
+
7
+ # A Markdown file's text, with its line endings as LF, "\n", whatever they are in the file.
8
+ #
9
+ # Windows ends lines with CRLF, "\r\n", and Git for Windows checks files out that way, so a file written or cloned on
10
+ # Windows can reach Raindown with them on any platform. Raindown finds directives, like a bare toc:, front matter
11
+ # and code fences line by line, with patterns that end at "\n", so a "\r" left at the end of a line stops them
12
+ # matching: toc: would be left as text instead of writing its table of contents.
13
+ #
14
+ # Text mode ("rt") reads CRLF as LF on every platform, as Ruby does on Windows by default, so the rest of Raindown
15
+ # only ever sees "\n". Writing a file back then gives it LF line endings, except on Windows, where text mode writes
16
+ # CRLF again.
17
+ def read_markdown(path)
18
+ File.read(path, mode: 'rt', encoding: Encoding::UTF_8)
19
+ end
20
+ end
21
+ end
@@ -25,7 +25,7 @@ module Raindown
25
25
  end
26
26
 
27
27
  # The folder's project and those inside it, found when they're first asked for, and again after a restart.
28
- def roots = @roots ||= [root, *Projects.roots(root)].uniq.sort
28
+ def roots = @roots ||= Projects.roots(root).sort
29
29
 
30
30
  # A worker for each project, as they were found when the workers last started.
31
31
  def each(&)
data/lib/raindown.rb CHANGED
@@ -15,6 +15,7 @@ require_relative 'raindown/heading_ids'
15
15
  require_relative 'raindown/node_blocks'
16
16
  require_relative 'raindown/pages'
17
17
  require_relative 'raindown/pre_renderer'
18
+ require_relative 'raindown/utils'
18
19
  require_relative 'raindown/variables'
19
20
 
20
21
  module Raindown
@@ -40,7 +41,7 @@ module Raindown
40
41
  # Code is highlighted, unless the file's project changes it in .raindown/config.yml.
41
42
  def render_file(path:, metadata: NoMetadata, bare: false, **options)
42
43
  options = Config.render_options(path).merge(options)
43
- render(markdown: File.read(path, encoding: Encoding::UTF_8), metadata:, bare: bare || BareDirectives.file?(path), **options)
44
+ render(markdown: Utils.read_markdown(path), metadata:, bare: bare || BareDirectives.file?(path), **options)
44
45
  end
45
46
 
46
47
  # Front matter keys are variables, like {title} or <{ title }>.
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.8.1'
4
+ VERSION = '0.9.1'
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-02 00:00:00.000000000 Z
10
+ date: 2026-11-03 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -216,7 +216,10 @@ files:
216
216
  - lib/raindown/files.rb
217
217
  - lib/raindown/fragments.rb
218
218
  - lib/raindown/front_matter.rb
219
+ - lib/raindown/gitignore.rb
219
220
  - lib/raindown/heading_ids.rb
221
+ - lib/raindown/js/host.mjs
222
+ - lib/raindown/js/js_nodes.rb
220
223
  - lib/raindown/node.rb
221
224
  - lib/raindown/node_blocks.rb
222
225
  - lib/raindown/node_injector.rb
@@ -295,6 +298,7 @@ files:
295
298
  - lib/raindown/tui/views/endpoint_view.rb
296
299
  - lib/raindown/tui/views/list_view.rb
297
300
  - lib/raindown/tui/views/outline_view.rb
301
+ - lib/raindown/utils.rb
298
302
  - lib/raindown/variables.rb
299
303
  - lib/raindown/watcher/changes.rb
300
304
  - lib/raindown/watcher/file_watcher.rb