raindown 0.7.0 → 0.8.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 +4 -4
- data/lib/nodes/endpoint_node.rb +3 -2
- data/lib/raindown/actions.rb +5 -3
- data/lib/raindown/bare_directives.rb +14 -27
- data/lib/raindown/blueprints.rb +255 -0
- data/lib/raindown/cli.rb +39 -54
- data/lib/raindown/config.rb +2 -3
- data/lib/raindown/directives.rb +30 -13
- data/lib/raindown/document.rb +21 -15
- data/lib/raindown/endpoint/endpoint_runner.rb +13 -5
- data/lib/raindown/node_blocks.rb +22 -16
- data/lib/raindown/node_injector.rb +7 -10
- data/lib/raindown/pages.rb +1 -1
- data/lib/raindown/pre_renderer.rb +18 -0
- data/lib/raindown/tui/blueprints/blueprints_renderer.rb +93 -0
- data/lib/raindown/tui/blueprints/blueprints_view.rb +146 -0
- data/lib/raindown/tui/blueprints/canvas.rb +58 -0
- data/lib/raindown/tui/blueprints/code_comments.rb +71 -0
- data/lib/raindown/tui/blueprints/filter.rb +77 -0
- data/lib/raindown/tui/blueprints/traced_code.rb +56 -0
- data/lib/raindown/tui/blueprints/tracing.rb +129 -0
- data/lib/raindown/tui/{feelings/code_box.rb → code_box.rb} +44 -30
- data/lib/raindown/tui/endpoints.rb +2 -2
- data/lib/raindown/tui/events.rb +3 -1
- data/lib/raindown/tui/feelings/bins.rb +5 -5
- data/lib/raindown/tui/feelings/{celebration.rb → celebration/celebration.rb} +5 -2
- data/lib/raindown/tui/feelings/{scene.rb → celebration/scene.rb} +4 -1
- data/lib/raindown/tui/feelings/cell_moves.rb +34 -0
- data/lib/raindown/tui/feelings/feelings_code_box.rb +40 -0
- data/lib/raindown/tui/feelings/feelings_log.rb +22 -19
- data/lib/raindown/tui/feelings/feelings_renderer.rb +94 -95
- data/lib/raindown/tui/feelings/feelings_view.rb +28 -32
- data/lib/raindown/tui/feelings/magnifier.rb +68 -0
- data/lib/raindown/tui/feelings/motion.rb +93 -0
- data/lib/raindown/tui/feelings/waffle/baked_waffle.rb +88 -0
- data/lib/raindown/tui/feelings/waffle/balloons.rb +81 -0
- data/lib/raindown/tui/feelings/waffle/confetti.rb +55 -0
- data/lib/raindown/tui/feelings/waffle/sparkles.rb +61 -0
- data/lib/raindown/tui/feelings/waffle/waffle.rb +62 -0
- data/lib/raindown/tui/feelings/weekly_batch.rb +24 -5
- data/lib/raindown/tui/layout.rb +25 -18
- data/lib/raindown/tui/navigator/commands.rb +32 -1
- data/lib/raindown/tui/navigator/mouse.rb +43 -0
- data/lib/raindown/tui/navigator/navigator.rb +40 -44
- data/lib/raindown/tui/navigator/{search.rb → teleport.rb} +14 -12
- data/lib/raindown/tui/navigator/views.rb +2 -3
- data/lib/raindown/tui/{workers/project.rb → project.rb} +33 -22
- data/lib/raindown/tui/{menu_overlay.rb → project_box.rb} +1 -1
- data/lib/raindown/tui/renderers/renderer.rb +1 -0
- data/lib/raindown/tui/saves.rb +22 -0
- data/lib/raindown/tui/{search_overlay.rb → search_box.rb} +27 -14
- data/lib/raindown/tui/terminal/input.rb +58 -7
- data/lib/raindown/tui/terminal/screen.rb +31 -26
- data/lib/raindown/tui/terminal/styles.rb +55 -28
- data/lib/raindown/tui/terminal/terminal_title.rb +31 -0
- data/lib/raindown/tui/text.rb +35 -12
- data/lib/raindown/tui/views/dashboard_view.rb +2 -2
- data/lib/raindown/tui/views/endpoint_view.rb +3 -3
- data/lib/raindown/tui/views/list_view.rb +1 -1
- data/lib/raindown/tui/views/outline_view.rb +5 -4
- data/lib/raindown/tui.rb +17 -7
- data/lib/raindown/watcher/changes.rb +33 -0
- data/lib/raindown/watcher/file_watcher.rb +58 -0
- data/lib/raindown/watcher/project.rb +47 -0
- data/lib/raindown/watcher/supervisor.rb +111 -0
- data/lib/raindown/{tui/workers → workers}/worker.rb +37 -29
- data/lib/raindown/workers/workers.rb +63 -0
- data/lib/raindown.rb +4 -4
- data/lib/version.rb +1 -1
- metadata +37 -17
- data/lib/raindown/project_worker.rb +0 -48
- data/lib/raindown/supervisor.rb +0 -160
- data/lib/raindown/tui/feelings/waffle.rb +0 -43
- data/lib/raindown/tui/workers/workers.rb +0 -53
- data/lib/raindown/watcher.rb +0 -57
- /data/lib/raindown/tui/feelings/{fireworks.rb → celebration/fireworks.rb} +0 -0
- /data/lib/raindown/tui/feelings/{rain.rb → celebration/rain.rb} +0 -0
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7683978b726d755dec718ef81848047c736e081504baeec609b5bbfadb1cb72c
|
|
4
|
+
data.tar.gz: 00e4387dd70604a382a7ad02944df5ac48718c5c90d1994c384011821153404c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e411970cde88fcf026aeea5c2eb1a7a2566ca99ba32c5920e6e53013f3e6b39f42a5449d8ecb36061a4dac5518a82516e21c54595c6d54c9906830f64f0c929
|
|
7
|
+
data.tar.gz: 1a517a2cc89afa59c9deca7b662a415712eff5a81ad1a6caa339c411fb7c4f154c1b95e0456d86c07473519a00624e94f4eeeb2548c9cad3df5690852860dc0c
|
data/lib/nodes/endpoint_node.rb
CHANGED
|
@@ -6,7 +6,8 @@ require_relative '../raindown/fragments'
|
|
|
6
6
|
|
|
7
7
|
module Raindown
|
|
8
8
|
# Holds an endpoint's curl snippet and response, which render as the code blocks they are,
|
|
9
|
-
# between Directives markers for its directive and end tag, kept as a fragment.
|
|
9
|
+
# between hidden Directives markers for its directive and end tag, kept as a fragment. A bare endpoint has markers
|
|
10
|
+
# in bold around it too, from BareDirectives.
|
|
10
11
|
class EndpointNode < Antlers::BranchNode
|
|
11
12
|
DEF_KEY = :endpoint_def
|
|
12
13
|
END_KEY = :endpoint_end
|
|
@@ -19,7 +20,7 @@ module Raindown
|
|
|
19
20
|
|
|
20
21
|
def render(**)
|
|
21
22
|
html = Raindown.commonmark(super)
|
|
22
|
-
Fragments.add("#{Directives.marker(@directive)}\n#{html}#{Directives.marker(':endpoint')}")
|
|
23
|
+
Fragments.add("#{Directives.marker(@directive, shown: false)}\n#{html}#{Directives.marker(':endpoint', shown: false)}")
|
|
23
24
|
end
|
|
24
25
|
|
|
25
26
|
class << self
|
data/lib/raindown/actions.rb
CHANGED
|
@@ -20,14 +20,16 @@ module Raindown
|
|
|
20
20
|
|
|
21
21
|
module_function
|
|
22
22
|
|
|
23
|
-
# Writes back the whole file, like saving it while it's watched
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
# Writes back the whole file, like saving it while it's watched, with bare directives allowed, and endpoints with no
|
|
24
|
+
# response run, when asked, like with --bare and -x.
|
|
25
|
+
def write_back(path, bare: false, execute: false)
|
|
26
|
+
Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# Runs one endpoint's request and writes its response, replacing any response it had.
|
|
29
30
|
def execute(path, directive)
|
|
30
31
|
raise NotAnEndpoint, "#{directive.name} on line #{directive.line} isn't an endpoint" unless directive.endpoint?
|
|
32
|
+
raise NotAnEndpoint, "The endpoint on line #{directive.line} only renders, so put it in comments to run it" unless directive.writes
|
|
31
33
|
|
|
32
34
|
content = File.read(path, encoding: Encoding::UTF_8)
|
|
33
35
|
rewritten = EndpointRunner.new(content:, bare: BareDirectives.enabled?(path:, content:), only: directive.line - 1).rewrite
|
|
@@ -1,27 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'code_blocks'
|
|
4
|
+
require_relative 'directives'
|
|
4
5
|
require_relative 'front_matter'
|
|
5
6
|
|
|
6
7
|
module Raindown
|
|
7
|
-
# Raindown files (.rd/.raindown) can write a directive
|
|
8
|
-
# Markdown files opt in with "raindown: true" in their front
|
|
9
|
-
# Nodes like toc: and files: are handled by NodeBlocks, before the rest.
|
|
8
|
+
# Raindown files (.rd/.raindown) can write a content directive, which writes its output back, bare on its own line,
|
|
9
|
+
# like endpoint: or toc:, which shows in bold in the HTML. Markdown files opt in with "raindown: true" in their front
|
|
10
|
+
# matter, or with the --bare flag. Nodes like toc: and files: are handled by NodeBlocks, before the rest.
|
|
11
|
+
# Template directives, like if: and for:, are always in Antlers' delimiters, so they're never bare.
|
|
10
12
|
#
|
|
11
|
-
# Bare directives have no end tag. A bare
|
|
12
|
-
#
|
|
13
|
-
#
|
|
13
|
+
# Bare directives have no end tag. A bare endpoint closes itself after its curl snippet and response, so where it
|
|
14
|
+
# needs more than that, write it in comments with an end tag instead:
|
|
15
|
+
# <!-- endpoint: -->
|
|
14
16
|
# ...
|
|
15
|
-
#
|
|
17
|
+
# <!-- :endpoint -->
|
|
16
18
|
module BareDirectives
|
|
17
19
|
EXTENSIONS = %w[.rd .raindown].freeze
|
|
18
20
|
# The block directives, and the method that finds the line their end tag goes after.
|
|
19
|
-
BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint
|
|
20
|
-
'list:' => :after_block }.freeze
|
|
21
|
+
BLOCK_KEYWORDS = { 'endpoint:' => :after_endpoint }.freeze
|
|
21
22
|
# Four spaces of indent would make the line a Markdown code block.
|
|
22
23
|
LINE = /\A( {0,3})((#{BLOCK_KEYWORDS.keys.join('|')})(?:[ \t]+\S.*?)?)[ \t]*$/
|
|
23
|
-
# An end tag on its own line, like one a bare block directive above added, ends a block.
|
|
24
|
-
END_TAG = /\A {0,3}<\{\s*:[a-z][\w-]*\s*\}>[ \t]*$/
|
|
25
24
|
|
|
26
25
|
module_function
|
|
27
26
|
|
|
@@ -69,34 +68,22 @@ module Raindown
|
|
|
69
68
|
match = lines[index].match(LINE)
|
|
70
69
|
return index unless match
|
|
71
70
|
|
|
72
|
-
lines[index] = wrap(lines[index])
|
|
71
|
+
lines[index] = "#{Directives.marker(match[2], shown: true)}\n#{wrap(lines[index])}"
|
|
73
72
|
close_block(lines, index, indent: match[1], keyword: match[3])
|
|
74
73
|
index
|
|
75
74
|
end
|
|
76
75
|
|
|
76
|
+
# The end tag after the block, then the end tag in bold, as a bare directive's shown.
|
|
77
77
|
def close_block(lines, index, indent:, keyword:)
|
|
78
78
|
last = public_send(BLOCK_KEYWORDS[keyword], lines, index + 1) || index
|
|
79
79
|
lines[last] = "#{lines[last]}\n" unless lines[last].end_with?("\n")
|
|
80
|
-
|
|
80
|
+
name = keyword.delete_suffix(':')
|
|
81
|
+
lines.insert(last + 1, "#{indent}<{ :#{name} }>\n#{Directives.marker(":#{name}", shown: true)}\n")
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
# The last line of the curl snippet and response below an endpoint.
|
|
84
85
|
def after_endpoint(lines, index)
|
|
85
86
|
CodeBlocks.endpoint(lines, index).last&.fetch(:close)
|
|
86
87
|
end
|
|
87
|
-
|
|
88
|
-
# The last line of the Markdown block below, after any blank lines: up to the next blank line or end tag,
|
|
89
|
-
# with fenced code kept whole. Nil when there's nothing below.
|
|
90
|
-
def after_block(lines, index)
|
|
91
|
-
index = CodeBlocks.skip_blank_lines(lines, index)
|
|
92
|
-
last = nil
|
|
93
|
-
|
|
94
|
-
while index < lines.length && !lines[index].strip.empty? && !lines[index].match?(END_TAG)
|
|
95
|
-
last = CodeBlocks.at(lines, index)&.fetch(:close) || index
|
|
96
|
-
index = last + 1
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
last
|
|
100
|
-
end
|
|
101
88
|
end
|
|
102
89
|
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
require 'unicode/display_width'
|
|
6
|
+
require_relative 'projects'
|
|
7
|
+
|
|
8
|
+
module Raindown
|
|
9
|
+
# Blueprints from Claude Code's replies, kept in a project's .raindown/blueprints.md, which the terminal UI's Blueprints page
|
|
10
|
+
# shows, newest first. Each is a code block under a heading of when it was saved and its kind:
|
|
11
|
+
# ## 2026-10-30T03:14:07Z · mockup
|
|
12
|
+
#
|
|
13
|
+
# ```mockup
|
|
14
|
+
# ╭──────╮
|
|
15
|
+
# │ Idea │
|
|
16
|
+
# ╰──────╯
|
|
17
|
+
# ```
|
|
18
|
+
# A blueprint is a code block marked with its kind, like "mockup", or one with no language that's drawn, with box-drawing
|
|
19
|
+
# lines or blocks, whose kind is worked out from what it's drawn with when it's saved (see Blueprints.kind).
|
|
20
|
+
module Blueprints
|
|
21
|
+
# A blueprint, saved at a time, or drawn in a comment in the code, in the file at its path, from the project's root,
|
|
22
|
+
# from its line, with the paragraph above it, if there was one, as its description.
|
|
23
|
+
Blueprint = Data.define(:time, :kind, :lines, :path, :line, :description) do
|
|
24
|
+
# Its description and words, without the lines drawn among them, which its filter matches and lists it by.
|
|
25
|
+
def words = [description, *lines].join(' ').gsub(DRAWN, ' ').squeeze(' ').strip
|
|
26
|
+
alias_method :text, :words
|
|
27
|
+
|
|
28
|
+
# When it was saved, to the minute, or where it's drawn in the code, and its kind.
|
|
29
|
+
def where
|
|
30
|
+
return "#{path}:#{line}#{SEPARATOR}#{kind}" unless time
|
|
31
|
+
|
|
32
|
+
"#{time.sub('T', ' ').sub(/:\d\dZ\z/, ' UTC')}#{SEPARATOR}#{kind}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# As it's saved in .raindown/blueprints.md, under when it was saved and its kind, then its description.
|
|
36
|
+
def markdown(saved_at)
|
|
37
|
+
about = "#{description}\n\n" if description
|
|
38
|
+
"## #{saved_at}#{SEPARATOR}#{kind}\n\n#{about}```#{kind}\n#{lines.join("\n")}\n```\n\n"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
# A box drawn in a blueprint, by the rows and columns of its corners, as a terminal shows them, and the text inside it and in
|
|
42
|
+
# its top border, like a card's title.
|
|
43
|
+
Box = Data.define(:top, :left, :bottom, :right, :text) do
|
|
44
|
+
def cover?(row, column) = row.between?(top, bottom) && column.between?(left, right)
|
|
45
|
+
def above?(other) = bottom < other.top
|
|
46
|
+
def below?(other) = top > other.bottom
|
|
47
|
+
|
|
48
|
+
# How far it is from the other box, in rows, then in columns between their middles.
|
|
49
|
+
def distance(other) = [(top - other.top).abs, (left + right - other.left - other.right).abs]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
FILE = File.join('.raindown', 'blueprints.md')
|
|
53
|
+
# The kinds a code block can be marked with to be a blueprint.
|
|
54
|
+
KINDS = %w[mockup diagram chart table other].freeze
|
|
55
|
+
# What a blueprint's drawn with: lines, like boxes' and arrows', and blocks, like charts' bars.
|
|
56
|
+
LINES = /[─━│┃┌┐└┘├┤┬┴┼╭╮╯╰═║╔╗╚╝╱╲]/
|
|
57
|
+
BLOCKS = /[▁▂▃▄▅▆▇█▀▏▔▕░▒▓]/
|
|
58
|
+
DRAWN = Regexp.union(LINES, BLOCKS)
|
|
59
|
+
BLOCK = /^```(\w*)\n(.*?)^```$/m
|
|
60
|
+
# Arrows that join things, across, which make a blueprint a diagram, like a flow chart's or a sequence diagram's.
|
|
61
|
+
ACROSS = /─▶|◀─|─>|<─|->|<-|[→←↔]/
|
|
62
|
+
# Arrows that join things up or down, when there's a line above or below them.
|
|
63
|
+
UP_DOWN = %w[▼ ▲].freeze
|
|
64
|
+
# The share of a blueprint's characters that are blocks, from which it's a chart.
|
|
65
|
+
CHART = 0.25
|
|
66
|
+
SEPARATOR = ' · '
|
|
67
|
+
# A box's corners, and the lines its left side is drawn with.
|
|
68
|
+
TOP_LEFT = %w[╭ ┌ ╔].freeze
|
|
69
|
+
TOP_RIGHT = %w[╮ ┐ ╗].freeze
|
|
70
|
+
BOTTOM_LEFT = %w[╰ └ ╚].freeze
|
|
71
|
+
BOTTOM_RIGHT = %w[╯ ┘ ╝].freeze
|
|
72
|
+
SIDE = %w[│ ├ ┤ ┼ ║ ╟ ╢].freeze
|
|
73
|
+
|
|
74
|
+
module_function
|
|
75
|
+
|
|
76
|
+
# The project's blueprints, newest first, each with the line its drawing starts on, counting from 1, like an editor.
|
|
77
|
+
def read(root)
|
|
78
|
+
path = File.join(root, FILE)
|
|
79
|
+
return [] unless File.exist?(path)
|
|
80
|
+
|
|
81
|
+
lines = File.readlines(path, chomp: true)
|
|
82
|
+
starts = lines.each_index.select { lines[it].start_with?('## ') }
|
|
83
|
+
starts.filter_map { |start| blueprint(lines, start) }.reverse
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Saves the blueprints in Claude Code's last reply, from its Stop hook's JSON, to the project it's working in, if any,
|
|
87
|
+
# each with its kind.
|
|
88
|
+
def save(hook)
|
|
89
|
+
root = Projects.root(hook['cwd'].to_s) || return
|
|
90
|
+
drawn = drawn(last_reply(hook['transcript_path'].to_s))
|
|
91
|
+
return if drawn.empty?
|
|
92
|
+
|
|
93
|
+
time = Time.now.utc.iso8601
|
|
94
|
+
File.open(File.join(root, FILE), 'a') { |file| drawn.each { file.write(it.markdown(time)) } }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The text's code blocks that are blueprints, each with its kind and the paragraph above it.
|
|
98
|
+
def drawn(text)
|
|
99
|
+
text.to_enum(:scan, BLOCK).filter_map do
|
|
100
|
+
language, body = Regexp.last_match.captures
|
|
101
|
+
lines = body.lines(chomp: true)
|
|
102
|
+
next unless whole?(lines)
|
|
103
|
+
|
|
104
|
+
kind = drawn_kind(language, lines) || next
|
|
105
|
+
Blueprint.new(time: nil, kind:, lines:, path: FILE, line: nil, description: description(text[0...Regexp.last_match.begin(0)]))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The kind the block's marked with, or, without a language, the kind it's drawn as, or nil when it isn't drawn.
|
|
110
|
+
def drawn_kind(language, lines)
|
|
111
|
+
return language if KINDS.include?(language)
|
|
112
|
+
|
|
113
|
+
kind(lines) if language.empty? && lines.join.match?(DRAWN)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The paragraph at the end of the text before a block, joined into a line, unless it's another block, or a list.
|
|
117
|
+
def description(before)
|
|
118
|
+
paragraph = before.split(/\n[ \t]*\n/).last.to_s.strip
|
|
119
|
+
return if paragraph.empty? || paragraph.include?('```') || paragraph.match?(/\A(?:[-*+] |\d+\. |#)/)
|
|
120
|
+
|
|
121
|
+
paragraph.split("\n").map(&:strip).join(' ')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Whether a drawing's more than a line, and closes each box its first line opens, unlike a piece of one quoted, and
|
|
125
|
+
# isn't art.
|
|
126
|
+
def whole?(lines)
|
|
127
|
+
return false if lines.length < 2 || art?(lines)
|
|
128
|
+
|
|
129
|
+
boxes(lines).count { it.top.zero? } == columns(lines.first(1)).first.count { TOP_LEFT.include?(it) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# What a blueprint is, from what it's drawn with: a diagram when arrows join its parts, a chart when it's mostly blocks,
|
|
133
|
+
# a table when its lines cross, a mockup when it's otherwise boxes and lines, and other when it's none of those.
|
|
134
|
+
def kind(lines)
|
|
135
|
+
text = lines.join("\n")
|
|
136
|
+
return 'diagram' if text.match?(ACROSS) || joined_up_or_down?(lines)
|
|
137
|
+
return 'chart' if text.scan(BLOCKS).length >= CHART * text.delete(" \n").length
|
|
138
|
+
return 'table' if text.include?('┼')
|
|
139
|
+
return 'mockup' if text.match?(DRAWN)
|
|
140
|
+
|
|
141
|
+
'other'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether an up or down arrow has a line above or below it, joining it to what it points from.
|
|
145
|
+
def joined_up_or_down?(lines)
|
|
146
|
+
lines.each_with_index.any? do |line, index|
|
|
147
|
+
line.each_char.with_index.any? do |char, column|
|
|
148
|
+
UP_DOWN.include?(char) && [lines[index - 1], lines[index + 1]].any? { it&.[](column) == '│' }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Whether it's art: drawn in blocks and words but no lines, like a stat's number in block digits, or a waffle, or
|
|
154
|
+
# with no words or numbers at all, like rain or balloons.
|
|
155
|
+
def art?(lines)
|
|
156
|
+
text = lines.join
|
|
157
|
+
!text.match?(/[[:alnum:]]/) || (text.match?(BLOCKS) && !text.match?(LINES))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The boxes drawn in the blueprint's lines, in reading order, each a top-left corner, the next top-right corner along its
|
|
161
|
+
# row, and the bottom corners below them, its left side drawn down to the bottom.
|
|
162
|
+
def boxes(lines)
|
|
163
|
+
grid = columns(lines)
|
|
164
|
+
grid.each_with_index.flat_map do |row, top|
|
|
165
|
+
row.each_index.filter_map { |left| box(grid, top, left) if TOP_LEFT.include?(row[left]) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def box(grid, top, left)
|
|
170
|
+
corner = (left + 1...grid[top].length).find { TOP_RIGHT.include?(grid[top][it]) } || return
|
|
171
|
+
bottom = (top + 1...grid.length).find { !SIDE.include?(grid[it][left]) } || return
|
|
172
|
+
right = bottom_right(grid[bottom], left, corner) || return
|
|
173
|
+
|
|
174
|
+
Box.new(top:, left:, bottom:, right:, text: box_text(grid[top...bottom], left, right))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# The lines' characters by the columns a terminal shows them in, with an empty string in the second column of a wide
|
|
178
|
+
# one, like an emoji, so what's drawn below it lines up.
|
|
179
|
+
def columns(lines)
|
|
180
|
+
lines.map { |line| line.grapheme_clusters.flat_map { [it, *Array.new([Unicode::DisplayWidth.of(it) - 1, 0].max, '')] } }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The column of the box's bottom right corner, below its top's, or a column either side, as Claude Code sometimes draws
|
|
184
|
+
# a box's top a column short or long, or nil when the row isn't its bottom.
|
|
185
|
+
def bottom_right(row, left, corner) = [corner, corner + 1, corner - 1].find { closed?(row, left, it) }
|
|
186
|
+
|
|
187
|
+
# The grid with each box's right side straight down from its bottom right corner, as Claude Code sometimes draws a
|
|
188
|
+
# box's top, or a row of it, a column short or long, leaving the text inside it as it is.
|
|
189
|
+
def tidied(grid, boxes)
|
|
190
|
+
grid = grid.map(&:dup)
|
|
191
|
+
boxes.each do |box|
|
|
192
|
+
straighten(grid[box.top], box.right, TOP_RIGHT, '─')
|
|
193
|
+
(box.top + 1...box.bottom).each { straighten(grid[it], box.right, SIDE, ' ') }
|
|
194
|
+
end
|
|
195
|
+
grid
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Moves the row's edge, one of the edges, from a column either side of the right one, filling where it was, when
|
|
199
|
+
# what's at the right column is blank.
|
|
200
|
+
def straighten(row, right, edges, fill)
|
|
201
|
+
return if edges.include?(row[right]) || !row[right].to_s.strip.empty?
|
|
202
|
+
|
|
203
|
+
off = [right - 1, right + 1].find { edges.include?(row[it]) } || return
|
|
204
|
+
row[right] = row[off]
|
|
205
|
+
row[off] = fill
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Whether the row is a box's bottom, from its left corner to its right.
|
|
209
|
+
def closed?(row, left, right) = BOTTOM_LEFT.include?(row[left]) && BOTTOM_RIGHT.include?(row[right])
|
|
210
|
+
|
|
211
|
+
# The text across the rows, between the box's sides, without the lines drawn among it.
|
|
212
|
+
def box_text(rows, left, right) = rows.map { it[(left + 1)...right].to_a.join }.join(' ').gsub(DRAWN, ' ').squeeze(' ').strip
|
|
213
|
+
|
|
214
|
+
# The text of the assistant's messages since the last thing the user typed, in a transcript of JSON lines.
|
|
215
|
+
def last_reply(path)
|
|
216
|
+
return '' unless File.exist?(path)
|
|
217
|
+
|
|
218
|
+
entries = File.readlines(path).filter_map { entry(it) }
|
|
219
|
+
asked = entries.rindex { it['type'] == 'user' && typed?(it.dig('message', 'content')) } || -1
|
|
220
|
+
entries[(asked + 1)..].select { it['type'] == 'assistant' }.flat_map { texts(it.dig('message', 'content')) }.join("\n")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A line of the transcript, or nil for one that isn't JSON, like one still being written.
|
|
224
|
+
def entry(line)
|
|
225
|
+
JSON.parse(line)
|
|
226
|
+
rescue JSON::ParserError
|
|
227
|
+
nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# A user's message is typed when it's text, rather than a tool's result.
|
|
231
|
+
def typed?(content) = content.is_a?(String) || Array(content).any? { it['type'] == 'text' }
|
|
232
|
+
|
|
233
|
+
def texts(content) = Array(content).filter_map { it['text'] if it.is_a?(Hash) && it['type'] == 'text' }
|
|
234
|
+
|
|
235
|
+
def blueprint(lines, start)
|
|
236
|
+
opening = (start + 1...lines.length).find { lines[it].start_with?('```') } || return
|
|
237
|
+
closing = (opening + 1...lines.length).find { lines[it] == '```' } || return
|
|
238
|
+
Blueprint.new(**heading(lines[start]), lines: lines[(opening + 1)...closing], path: FILE, line: opening + 2,
|
|
239
|
+
description: saved_description(lines[(start + 1)...opening]))
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# When a blueprint was saved and its kind, from its heading, which is other when it's none of the kinds.
|
|
243
|
+
def heading(line)
|
|
244
|
+
time, kind = line.delete_prefix('## ').split(SEPARATOR, 2)
|
|
245
|
+
kind = 'other' unless KINDS.include?(kind)
|
|
246
|
+
{ time:, kind: }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# The lines between a blueprint's heading and its drawing, joined, or nil when there are none.
|
|
250
|
+
def saved_description(lines)
|
|
251
|
+
description = lines.reject(&:empty?).join(' ')
|
|
252
|
+
description unless description.empty?
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/raindown/cli.rb
CHANGED
|
@@ -4,9 +4,10 @@ require 'fileutils'
|
|
|
4
4
|
require 'trees'
|
|
5
5
|
require_relative '../raindown'
|
|
6
6
|
require_relative '../version'
|
|
7
|
+
require_relative 'blueprints'
|
|
7
8
|
require_relative 'rewriter'
|
|
8
|
-
require_relative '
|
|
9
|
-
require_relative 'watcher'
|
|
9
|
+
require_relative 'watcher/file_watcher'
|
|
10
|
+
require_relative 'watcher/supervisor'
|
|
10
11
|
|
|
11
12
|
module Raindown
|
|
12
13
|
module CLI
|
|
@@ -14,24 +15,28 @@ module Raindown
|
|
|
14
15
|
|
|
15
16
|
USAGE = <<~TEXT
|
|
16
17
|
Usage: raindown [PATH] [options]
|
|
17
|
-
raindown
|
|
18
|
+
raindown watch [PATH] [options]
|
|
19
|
+
raindown FILE --html [OUTPUT] [options]
|
|
18
20
|
raindown init [DIRECTORY]
|
|
19
|
-
raindown tui [PATH]
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
22
|
+
Opens PATH (default: current directory) in the terminal UI: browse a project's files and outlines, fold
|
|
23
|
+
directives, run endpoints, open the editor at a line and see a dashboard of the project's charts. While it's open,
|
|
24
|
+
it watches the folder and writes back to its Markdown files as they're saved, like their tables of contents.
|
|
23
25
|
|
|
24
26
|
Commands:
|
|
27
|
+
watch [PATH] Watch PATH without the terminal UI, writing back to its Markdown files as they're saved.
|
|
28
|
+
PATH is a file, or a directory (default: current directory) whose projects with a
|
|
29
|
+
.raindown folder are watched
|
|
25
30
|
init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
|
|
26
|
-
|
|
27
|
-
|
|
31
|
+
blueprints Save the blueprints in Claude Code's last reply to .raindown/blueprints.md, for the terminal
|
|
32
|
+
UI's Blueprints page, as a Stop hook that's given the hook's JSON
|
|
28
33
|
|
|
29
34
|
Options:
|
|
30
35
|
--html [OUTPUT] Render FILE to HTML once, writing to OUTPUT or else printing to stdout
|
|
31
36
|
--stdin Render Markdown from stdin to HTML once, without formatting FILE, which only needs
|
|
32
37
|
its extension (to allow bare directives) and can be unsaved. Add --html OUTPUT to write a file
|
|
33
38
|
--bare Allow bare directives like :toc in Markdown files
|
|
34
|
-
-x, --execute Run endpoint curl snippets that have no output yet
|
|
39
|
+
-x, --execute Run endpoint curl snippets that have no output yet, as files are written back
|
|
35
40
|
-h, --help Show this message
|
|
36
41
|
-v, --version Show the version
|
|
37
42
|
|
|
@@ -41,61 +46,40 @@ module Raindown
|
|
|
41
46
|
CONFIG = <<~YAML
|
|
42
47
|
# Raindown project settings. See https://raindown.dev/docs/config
|
|
43
48
|
#
|
|
44
|
-
# Show directives as HTML comments rather than in bold:
|
|
45
|
-
# hide_directives: true
|
|
46
|
-
#
|
|
47
49
|
# Leave code blocks unhighlighted, so the browser can highlight them instead:
|
|
48
50
|
# highlight_code: false
|
|
49
51
|
YAML
|
|
50
52
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
# Trees matches tokens in order, so there's a line for every order and spelling of the options.
|
|
56
|
-
OPTION_LINES = (0..OPTIONS.size).flat_map { |count| OPTIONS.keys.permutation(count).to_a }.flat_map do |names|
|
|
57
|
-
[[]].product(*names.map { |name| OPTIONS[name] }).map { |flags| [names, flags.flatten] }
|
|
53
|
+
# A path opens the terminal UI, or after "watch" it's watched, and with --html or --stdin it's rendered. The flags
|
|
54
|
+
# are keyword arguments, in any order, like -x for x:, and --html is the OUTPUT when it's given one.
|
|
55
|
+
line(':path') do |path, html: nil, stdin: false, bare: false, execute: false, x: false| # rubocop:disable Metrics/ParameterLists -- each keyword is a flag
|
|
56
|
+
execute { open_path(path, html:, stdin:, bare:, execute: execute || x) }
|
|
58
57
|
end
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
next if names.include?(:stdin) && names.include?(:execute)
|
|
63
|
-
next if names.include?(:html) && names.include?(:html_to)
|
|
64
|
-
|
|
65
|
-
command = if names.include?(:stdin) then :render_stdin
|
|
66
|
-
elsif names.intersect?(%i[html html_to]) then :render
|
|
67
|
-
else :watch
|
|
68
|
-
end
|
|
69
|
-
options = { bare: names.include?(:bare), execute: names.include?(:execute) }
|
|
70
|
-
pattern = [':path', *flags].join(' ')
|
|
71
|
-
|
|
72
|
-
if names.include?(:html_to)
|
|
73
|
-
line(pattern) { |path, output| execute { send(command, path, output, **options) } }
|
|
74
|
-
else
|
|
75
|
-
line(pattern) { |path| execute { send(command, path, **options) } }
|
|
76
|
-
end
|
|
59
|
+
line('watch :path') do |path, bare: false, execute: false, x: false|
|
|
60
|
+
execute { watch(path, bare:, execute: execute || x) }
|
|
77
61
|
end
|
|
78
62
|
|
|
79
63
|
def self.run(argv)
|
|
80
64
|
return if command(argv)
|
|
81
65
|
|
|
82
|
-
# Without a path, like "raindown"
|
|
66
|
+
# Without a path, like "raindown", "raindown -x" or "raindown watch", it's the current directory.
|
|
67
|
+
argv = ['watch', '.', *argv.drop(1)] if argv.first == 'watch' && (argv.size == 1 || argv[1].start_with?('-'))
|
|
83
68
|
argv = ['.', *argv] if argv.empty? || argv.first.start_with?('-')
|
|
84
|
-
argv = html_output(argv)
|
|
85
69
|
|
|
86
70
|
super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
|
|
87
71
|
rescue StandardError => e
|
|
88
72
|
report(e, path: argv.first)
|
|
89
73
|
end
|
|
90
74
|
|
|
91
|
-
# The :path line would match help, version
|
|
75
|
+
# The :path line would match help, version and init as the file, so they're checked first.
|
|
92
76
|
# Returns true when argv was one of them.
|
|
93
77
|
def self.command(argv)
|
|
94
78
|
abort(USAGE) if argv.intersect?(%w[-h --help])
|
|
95
79
|
|
|
96
80
|
if argv.intersect?(%w[-v --version]) then puts(VERSION)
|
|
97
81
|
elsif argv.first == 'init' && argv.size <= 2 then init(argv[1])
|
|
98
|
-
elsif argv
|
|
82
|
+
elsif argv == ['blueprints'] then Blueprints.save(JSON.parse($stdin.read))
|
|
99
83
|
else return false
|
|
100
84
|
end
|
|
101
85
|
|
|
@@ -109,15 +93,6 @@ module Raindown
|
|
|
109
93
|
abort("raindown: #{path}: #{error.message}")
|
|
110
94
|
end
|
|
111
95
|
|
|
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
96
|
# Creates the .raindown folder that marks a project's root and holds its config.yml and nodes.
|
|
122
97
|
def self.init(directory = nil)
|
|
123
98
|
folder = File.join(directory || Dir.pwd, Nodes::FOLDER)
|
|
@@ -130,17 +105,27 @@ module Raindown
|
|
|
130
105
|
puts 'Put your own nodes in the same folder. See https://raindown.dev/docs/nodes'
|
|
131
106
|
end
|
|
132
107
|
|
|
133
|
-
# The terminal UI loads curses, so it's only required when it starts.
|
|
134
|
-
def self.tui(path)
|
|
108
|
+
# The terminal UI loads curses, so it's only required when it starts. Without a terminal, the path's watched instead.
|
|
109
|
+
def self.tui(path, bare: false, execute: false)
|
|
135
110
|
abort("File not found: #{path}") unless File.exist?(path)
|
|
136
111
|
|
|
137
112
|
require_relative 'tui'
|
|
138
|
-
TUI.start(path)
|
|
113
|
+
TUI.start(path, bare:, execute:) || watch(path, bare:, execute:)
|
|
139
114
|
end
|
|
140
115
|
|
|
141
116
|
class << self
|
|
142
117
|
private
|
|
143
118
|
|
|
119
|
+
# Endpoints run while formatting, which --stdin skips, so -x can't go with it.
|
|
120
|
+
def open_path(path, html:, stdin:, bare:, execute:)
|
|
121
|
+
output = html unless html == true
|
|
122
|
+
abort("-x runs endpoints as the file's formatted, which --stdin doesn't do\n\n#{USAGE}") if stdin && execute
|
|
123
|
+
return render_stdin(path, output, bare:) if stdin
|
|
124
|
+
return render(path, output, bare:, execute:) if html
|
|
125
|
+
|
|
126
|
+
tui(path, bare:, execute:)
|
|
127
|
+
end
|
|
128
|
+
|
|
144
129
|
def render(path, output = nil, bare: false, execute: false)
|
|
145
130
|
ensure_exists(path)
|
|
146
131
|
abort('--html renders a file, not a directory') if File.directory?(path)
|
|
@@ -160,10 +145,10 @@ module Raindown
|
|
|
160
145
|
# Watching only writes back to the files, while --html and --stdin render HTML.
|
|
161
146
|
def watch(path, bare: false, execute: false)
|
|
162
147
|
ensure_exists(path)
|
|
163
|
-
return Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
|
|
148
|
+
return Watcher::Supervisor.new(directory: path, bare:, execute:).start if File.directory?(path)
|
|
164
149
|
|
|
165
150
|
Nodes.load(path)
|
|
166
|
-
Watcher.new(path:, bare:, execute:).start
|
|
151
|
+
Watcher::FileWatcher.new(path:, bare:, execute:).start
|
|
167
152
|
end
|
|
168
153
|
|
|
169
154
|
def write(output, html)
|
data/lib/raindown/config.rb
CHANGED
|
@@ -4,11 +4,10 @@ require 'yaml'
|
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
6
|
# A project's settings, from config.yml in its .raindown folder:
|
|
7
|
-
# hide_directives: true # Directives become HTML comments rather than bold text in the HTML.
|
|
8
7
|
# highlight_code: false # Code blocks aren't highlighted, so the browser can highlight them instead.
|
|
9
8
|
module Config
|
|
10
9
|
FILE = 'config.yml'
|
|
11
|
-
DEFAULTS = {
|
|
10
|
+
DEFAULTS = { highlight_code: true }.freeze
|
|
12
11
|
|
|
13
12
|
module_function
|
|
14
13
|
|
|
@@ -23,7 +22,7 @@ module Raindown
|
|
|
23
22
|
|
|
24
23
|
# The settings for rendering a file, like Raindown.render(**Config.render_options(path)).
|
|
25
24
|
def render_options(path)
|
|
26
|
-
self.for(path).slice(:
|
|
25
|
+
self.for(path).slice(:highlight_code)
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
end
|
data/lib/raindown/directives.rb
CHANGED
|
@@ -3,27 +3,44 @@
|
|
|
3
3
|
require 'cgi/escape'
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# Template directives, like for: and if:, are always
|
|
6
|
+
# How a content directive is written says what it does, and how it shows in the HTML:
|
|
7
|
+
# toc: bare, it writes its output into the file, and shows in bold
|
|
8
|
+
# <!-- toc: --> <!-- :toc --> as comments, it writes its output into the file, and is left out of the HTML
|
|
9
|
+
# <{ toc: }> <{ :toc }> in Antlers' delimiters, it only renders, and is left out of the HTML
|
|
10
|
+
# Template directives, like for: and if:, are always in Antlers' delimiters, and always hidden.
|
|
11
|
+
# Until the HTML's done each is a comment marker, which ends the Markdown block before it and starts nothing itself,
|
|
12
|
+
# so a node's list stays its own list. A hidden one is left out of the HTML altogether.
|
|
11
13
|
module Directives
|
|
12
14
|
MARKER = /<!-- raindown:directive (.*?) -->/
|
|
15
|
+
HIDDEN = /<!-- raindown:hidden .*? -->\n?/
|
|
16
|
+
# A directive in an HTML comment, which writes back, or in Antlers' delimiters, which only renders.
|
|
17
|
+
COMMENT = ['<!--', '-->'].freeze
|
|
18
|
+
DELIMITERS = ['<{', '}>'].freeze
|
|
13
19
|
|
|
14
20
|
module_function
|
|
15
21
|
|
|
16
|
-
# A directive like "files: folder: 'notes'"
|
|
17
|
-
def
|
|
18
|
-
|
|
22
|
+
# A directive's line without its delimiters, like "files: folder: 'notes'" for <!-- files: folder: 'notes' -->.
|
|
23
|
+
def text(line)
|
|
24
|
+
line = line.strip
|
|
25
|
+
[COMMENT, DELIMITERS].each do |open, close|
|
|
26
|
+
return line.delete_prefix(open).delete_suffix(close).strip if line.start_with?(open) && line.end_with?(close)
|
|
27
|
+
end
|
|
28
|
+
line
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Whether the directive's line is bare, without delimiters, so it shows in bold.
|
|
32
|
+
def bare?(line) = text(line) == line.strip
|
|
33
|
+
|
|
34
|
+
# A directive's line as a marker: in bold when it's shown, like a bare directive, or else hidden.
|
|
35
|
+
def marker(line, shown: bare?(line))
|
|
36
|
+
text = text(line).gsub('--', '- -')
|
|
37
|
+
return "<!-- raindown:hidden #{text} -->" unless shown
|
|
38
|
+
|
|
19
39
|
"<!-- raindown:directive #{text} -->"
|
|
20
40
|
end
|
|
21
41
|
|
|
22
|
-
def render(html
|
|
23
|
-
html.gsub(MARKER)
|
|
24
|
-
text = ::Regexp.last_match(1)
|
|
25
|
-
hide ? "<!-- #{text} -->" : "<p><strong>#{CGI.escapeHTML(text.to_s)}</strong></p>"
|
|
26
|
-
end
|
|
42
|
+
def render(html)
|
|
43
|
+
html.gsub(HIDDEN, '').gsub(MARKER) { "<p><strong>#{CGI.escapeHTML(::Regexp.last_match(1).to_s)}</strong></p>" }
|
|
27
44
|
end
|
|
28
45
|
end
|
|
29
46
|
end
|