raindown 0.8.1 → 0.9.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: 0fab4cc96057c97d8a6c5ef07b11e12fda146e25855ac2015f667e838b1c805d
4
- data.tar.gz: 1b9044d112a1d05773bb22bfdc4767264ad190c24bceb49b82733ecd1989a026
3
+ metadata.gz: ad54482b1bd78c23942aefb02bc8a8b9cce56e369ecbb67366668187ed521323
4
+ data.tar.gz: 9d8475c719c52063424a6215c762a926e67641700981b6834dd56e07d410d535
5
5
  SHA512:
6
- metadata.gz: 1cf8132950f24d3005113635835426471514a7c79312cac8379e66d93c3345b32e9788e2f86c73eb80e4050d757eab4bcb7c08ef8ad6a3c41aad2808493a6c5b
7
- data.tar.gz: f1c47f511df4d7d9725c60f30fb0e3a0af3e762e197d510ec55eafe9ad01db51f0cefa6b6b2a684e1e5a37fb2654fbe1c8fc91c07e8b5a815e576df51b617682
6
+ metadata.gz: 8789f0135781bfe782ae2af47f2bd537cafef9d55a522ba592bbcc1c8f27e0364cfbb860cb0aa106617af0bd6e82d2bd0174084ca4bd3ca7779b82e2e284ff79
7
+ data.tar.gz: 88237d0429270a0651434511d462b0051b9a752a2e8aae315e4d2bafc0ef833fa1818d8424cf5e025b12655c9df3de6a22e6eab7e23e1b40ec0063d7fe4f3396
@@ -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,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
@@ -10,6 +10,8 @@ module Raindown
10
10
  module Projects
11
11
  # Folders of dependencies, builds and tooling, which aren't searched for projects or watched, nor are hidden folders.
12
12
  IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
13
+ # Ruby nodes and JavaScript Components.
14
+ NODE_EXTENSIONS = ['.rb', '.rbx', *JSNodes::EXTENSIONS].freeze
13
15
 
14
16
  module_function
15
17
 
@@ -61,7 +63,7 @@ module Raindown
61
63
 
62
64
  # Whether a file is one of a project's own nodes, which load when its watcher starts.
63
65
  def node?(path, root:)
64
- File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && %w[.rb .rbx].include?(File.extname(path))
66
+ File.expand_path(path).start_with?(File.join(root, Nodes::FOLDER, '')) && NODE_EXTENSIONS.include?(File.extname(path))
65
67
  end
66
68
 
67
69
  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
 
@@ -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
@@ -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
@@ -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
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.0'
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.0
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
@@ -217,6 +217,8 @@ files:
217
217
  - lib/raindown/fragments.rb
218
218
  - lib/raindown/front_matter.rb
219
219
  - lib/raindown/heading_ids.rb
220
+ - lib/raindown/js/host.mjs
221
+ - lib/raindown/js/js_nodes.rb
220
222
  - lib/raindown/node.rb
221
223
  - lib/raindown/node_blocks.rb
222
224
  - lib/raindown/node_injector.rb
@@ -295,6 +297,7 @@ files:
295
297
  - lib/raindown/tui/views/endpoint_view.rb
296
298
  - lib/raindown/tui/views/list_view.rb
297
299
  - lib/raindown/tui/views/outline_view.rb
300
+ - lib/raindown/utils.rb
298
301
  - lib/raindown/variables.rb
299
302
  - lib/raindown/watcher/changes.rb
300
303
  - lib/raindown/watcher/file_watcher.rb