shellfie 1.0.0 → 1.1.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/CHANGELOG.md +56 -2
- data/README.md +258 -97
- data/lib/shellfie/animation_frame_builder.rb +31 -7
- data/lib/shellfie/animation_timeline.rb +2 -1
- data/lib/shellfie/ansi_line_buffer.rb +20 -4
- data/lib/shellfie/ansi_normalizer.rb +18 -8
- data/lib/shellfie/ansi_parser.rb +120 -7
- data/lib/shellfie/cassette.rb +76 -0
- data/lib/shellfie/cli.rb +65 -2
- data/lib/shellfie/cli_authoring.rb +233 -0
- data/lib/shellfie/cli_generate.rb +244 -40
- data/lib/shellfie/cli_info.rb +106 -6
- data/lib/shellfie/cli_run.rb +167 -0
- data/lib/shellfie/config.rb +5 -1
- data/lib/shellfie/config_defaults.rb +21 -2
- data/lib/shellfie/config_validation.rb +93 -4
- data/lib/shellfie/dependency_checker.rb +74 -3
- data/lib/shellfie/errors.rb +1 -0
- data/lib/shellfie/ffmpeg_encoder.rb +46 -0
- data/lib/shellfie/font_resolver.rb +11 -1
- data/lib/shellfie/gif_generator.rb +157 -11
- data/lib/shellfie/gif_palette.rb +8 -4
- data/lib/shellfie/html_renderer.rb +54 -0
- data/lib/shellfie/line_layout.rb +19 -10
- data/lib/shellfie/output_writer.rb +7 -2
- data/lib/shellfie/parser.rb +82 -19
- data/lib/shellfie/parser_validation.rb +48 -15
- data/lib/shellfie/render_geometry.rb +2 -1
- data/lib/shellfie/render_segment.rb +17 -5
- data/lib/shellfie/renderer.rb +28 -11
- data/lib/shellfie/rendering/text_painter.rb +23 -15
- data/lib/shellfie/rendering/window_chrome.rb +2 -2
- data/lib/shellfie/reproducibility_manifest.rb +41 -0
- data/lib/shellfie/session.rb +111 -0
- data/lib/shellfie/session_config.rb +562 -0
- data/lib/shellfie/session_runner.rb +689 -0
- data/lib/shellfie/svg_renderer.rb +222 -0
- data/lib/shellfie/terminal_screen.rb +389 -0
- data/lib/shellfie/text_metrics.rb +74 -15
- data/lib/shellfie/transcript_renderer.rb +92 -0
- data/lib/shellfie/version.rb +1 -1
- data/lib/shellfie/yaml_safety.rb +147 -0
- data/lib/shellfie.rb +8 -1
- data/schema/shellfie-v1.schema.json +153 -0
- data/schema/shellfie-v2.schema.json +262 -0
- metadata +27 -24
- data/.rspec +0 -3
- data/Rakefile +0 -8
- data/docs/.nojekyll +0 -0
- data/docs/index.html +0 -205
- data/docs/scripts.js +0 -85
- data/docs/styles.css +0 -507
- data/examples/animation.yml +0 -33
- data/examples/colored.yml +0 -20
- data/examples/demo.gif +0 -0
- data/examples/demo.png +0 -0
- data/examples/demo_animation.yml +0 -31
- data/examples/headless.png +0 -0
- data/examples/headless.yml +0 -16
- data/examples/scrolling.yml +0 -48
- data/examples/simple.yml +0 -21
- data/examples/theme_macos.png +0 -0
- data/examples/theme_ubuntu.png +0 -0
- data/examples/theme_windows.png +0 -0
- data/shellfie.gemspec +0 -32
|
@@ -1,23 +1,64 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
3
5
|
module Shellfie
|
|
4
6
|
module TextMetrics
|
|
7
|
+
UNICODE_VERSION = RbConfig::CONFIG["UNICODE_VERSION"] || "unknown"
|
|
8
|
+
WIDTH_TABLE_VERSION = "1"
|
|
9
|
+
AMBIGUOUS_RANGES = [
|
|
10
|
+
0x00a1..0x00a1, 0x00a4..0x00a4, 0x00a7..0x00a8, 0x00aa..0x00aa, 0x00ad..0x00ae,
|
|
11
|
+
0x00b0..0x00b4, 0x00b6..0x00ba, 0x00bc..0x00bf, 0x00c6..0x00c6, 0x00d0..0x00d0,
|
|
12
|
+
0x00d7..0x00d8, 0x00de..0x00e1, 0x00e6..0x00e6, 0x00e8..0x00ea, 0x00ec..0x00ed,
|
|
13
|
+
0x00f0..0x00f0, 0x00f2..0x00f3, 0x00f7..0x00fa, 0x00fc..0x00fc, 0x00fe..0x00fe,
|
|
14
|
+
0x0101..0x0101, 0x0111..0x0113, 0x011b..0x011b, 0x0126..0x0127, 0x012b..0x012b,
|
|
15
|
+
0x0131..0x0133, 0x0138..0x0138, 0x013f..0x0142, 0x0144..0x0144, 0x0148..0x014b,
|
|
16
|
+
0x014d..0x014d, 0x0152..0x0153, 0x0166..0x0167, 0x016b..0x016b, 0x01ce..0x01dc,
|
|
17
|
+
0x0251..0x0251, 0x0261..0x0261, 0x02c4..0x02c4, 0x02c7..0x02c7, 0x02c9..0x02cb,
|
|
18
|
+
0x02cd..0x02d0, 0x02d8..0x02db, 0x02dd..0x02df, 0x0391..0x03a1, 0x03a3..0x03a9,
|
|
19
|
+
0x03b1..0x03c1, 0x03c3..0x03c9, 0x0401..0x0401, 0x0410..0x044f, 0x0451..0x0451,
|
|
20
|
+
0x2010..0x2010, 0x2013..0x2016, 0x2018..0x2019, 0x201c..0x201d, 0x2020..0x2022,
|
|
21
|
+
0x2024..0x2026, 0x2030..0x2030, 0x2032..0x2033, 0x2035..0x2035, 0x203b..0x203e,
|
|
22
|
+
0x2074..0x2074, 0x207f..0x207f, 0x2081..0x2084, 0x20ac..0x20ac, 0x2103..0x2103,
|
|
23
|
+
0x2105..0x2105, 0x2109..0x2109, 0x2113..0x2113, 0x2116..0x2116, 0x2121..0x2122,
|
|
24
|
+
0x2126..0x2126, 0x212b..0x212b, 0x2153..0x2154, 0x215b..0x215e, 0x2160..0x216b,
|
|
25
|
+
0x2170..0x2179, 0x2189..0x2189, 0x2190..0x2199, 0x21b8..0x21b9, 0x21d2..0x21d4,
|
|
26
|
+
0x21e7..0x21e7, 0x2200..0x2200, 0x2202..0x2203, 0x2207..0x2208, 0x220b..0x220b,
|
|
27
|
+
0x220f..0x2211, 0x2215..0x2215, 0x221a..0x221a, 0x221d..0x2220, 0x2223..0x2223,
|
|
28
|
+
0x2225..0x2225, 0x2227..0x222c, 0x222e..0x222e, 0x2234..0x2237, 0x223c..0x223d,
|
|
29
|
+
0x2248..0x2248, 0x224c..0x224c, 0x2252..0x2252, 0x2260..0x2261, 0x2264..0x2267,
|
|
30
|
+
0x226a..0x226b, 0x226e..0x226f, 0x2282..0x2283, 0x2286..0x2287, 0x2295..0x2295,
|
|
31
|
+
0x2299..0x2299, 0x22a5..0x22a5, 0x22bf..0x22bf, 0x2312..0x2312, 0x2460..0x254b,
|
|
32
|
+
0x2550..0x2573, 0x2580..0x258f, 0x2592..0x2595, 0x25a0..0x25a1, 0x25a3..0x25a9,
|
|
33
|
+
0x25b2..0x25b3, 0x25b6..0x25b7, 0x25bc..0x25bd, 0x25c0..0x25c1, 0x25c6..0x25c8,
|
|
34
|
+
0x25cb..0x25cb, 0x25ce..0x25d1, 0x25e2..0x25e5, 0x25ef..0x25ef, 0x2605..0x2606,
|
|
35
|
+
0x2609..0x2609, 0x260e..0x260f, 0x261c..0x261c, 0x261e..0x261e, 0x2640..0x2640,
|
|
36
|
+
0x2642..0x2642, 0x2660..0x2661, 0x2663..0x2665, 0x2667..0x266a, 0x266c..0x266d,
|
|
37
|
+
0x266f..0x266f, 0xe000..0xf8ff, 0xfffd..0xfffd
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
5
40
|
module_function
|
|
6
41
|
|
|
7
|
-
def
|
|
8
|
-
text.to_s.
|
|
42
|
+
def graphemes(text)
|
|
43
|
+
string = text.to_s.dup
|
|
44
|
+
string.force_encoding(Encoding::UTF_8)
|
|
45
|
+
string.scrub.scan(/\X/)
|
|
9
46
|
end
|
|
10
47
|
|
|
11
|
-
def
|
|
12
|
-
(
|
|
48
|
+
def cell_width(text, ambiguous_width: 1)
|
|
49
|
+
graphemes(text).sum { |grapheme| grapheme_width(grapheme, ambiguous_width: ambiguous_width) }
|
|
13
50
|
end
|
|
14
51
|
|
|
15
|
-
def
|
|
52
|
+
def pixel_width(text, font_size, ambiguous_width: 1)
|
|
53
|
+
(cell_width(text, ambiguous_width: ambiguous_width) * font_size * 0.6).ceil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def take_cells(text, max_cells, ambiguous_width: 1)
|
|
16
57
|
result = +""
|
|
17
58
|
used_cells = 0
|
|
18
59
|
|
|
19
|
-
text.
|
|
20
|
-
width =
|
|
60
|
+
graphemes(text).each do |char|
|
|
61
|
+
width = grapheme_width(char, ambiguous_width: ambiguous_width)
|
|
21
62
|
break if used_cells + width > max_cells
|
|
22
63
|
|
|
23
64
|
result << char
|
|
@@ -27,10 +68,10 @@ module Shellfie
|
|
|
27
68
|
result
|
|
28
69
|
end
|
|
29
70
|
|
|
30
|
-
def drop_cells(text, cells_to_drop)
|
|
71
|
+
def drop_cells(text, cells_to_drop, ambiguous_width: 1)
|
|
31
72
|
used_cells = 0
|
|
32
|
-
text.
|
|
33
|
-
width =
|
|
73
|
+
graphemes(text).each_with_object(+"") do |char, result|
|
|
74
|
+
width = grapheme_width(char, ambiguous_width: ambiguous_width)
|
|
34
75
|
if used_cells < cells_to_drop
|
|
35
76
|
used_cells += width
|
|
36
77
|
next
|
|
@@ -40,15 +81,15 @@ module Shellfie
|
|
|
40
81
|
end
|
|
41
82
|
end
|
|
42
83
|
|
|
43
|
-
def split_cells(text, max_cells)
|
|
84
|
+
def split_cells(text, max_cells, ambiguous_width: 1)
|
|
44
85
|
return [text.to_s] if max_cells <= 0
|
|
45
86
|
|
|
46
87
|
chunks = []
|
|
47
88
|
current = +""
|
|
48
89
|
used_cells = 0
|
|
49
90
|
|
|
50
|
-
text.
|
|
51
|
-
width =
|
|
91
|
+
graphemes(text).each do |char|
|
|
92
|
+
width = grapheme_width(char, ambiguous_width: ambiguous_width)
|
|
52
93
|
if used_cells.positive? && used_cells + width > max_cells
|
|
53
94
|
chunks << current
|
|
54
95
|
current = +""
|
|
@@ -63,21 +104,33 @@ module Shellfie
|
|
|
63
104
|
chunks
|
|
64
105
|
end
|
|
65
106
|
|
|
66
|
-
def char_width(char)
|
|
107
|
+
def char_width(char, ambiguous_width: 1)
|
|
67
108
|
codepoint = char.ord
|
|
68
109
|
return 0 if combining?(codepoint)
|
|
69
110
|
return 0 if codepoint < 32 || codepoint == 0x7f
|
|
70
111
|
return 2 if wide?(codepoint)
|
|
112
|
+
return ambiguous_width if ambiguous?(codepoint)
|
|
71
113
|
|
|
72
114
|
1
|
|
73
115
|
end
|
|
74
116
|
|
|
117
|
+
def grapheme_width(grapheme, ambiguous_width: 1)
|
|
118
|
+
codepoints = grapheme.codepoints
|
|
119
|
+
return 0 if codepoints.empty? || codepoints.all? { |codepoint| combining?(codepoint) }
|
|
120
|
+
return 2 if grapheme.include?("\u200d") || grapheme.include?("\ufe0f") || grapheme.include?("\u20e3") ||
|
|
121
|
+
grapheme.match?(/[\u{1f1e6}-\u{1f1ff}]{2}/)
|
|
122
|
+
|
|
123
|
+
codepoints.map { |codepoint| char_width(codepoint.chr(Encoding::UTF_8), ambiguous_width: ambiguous_width) }.max || 0
|
|
124
|
+
end
|
|
125
|
+
|
|
75
126
|
def combining?(codepoint)
|
|
76
127
|
(0x0300..0x036f).cover?(codepoint) ||
|
|
77
128
|
(0x1ab0..0x1aff).cover?(codepoint) ||
|
|
78
129
|
(0x1dc0..0x1dff).cover?(codepoint) ||
|
|
79
130
|
(0x20d0..0x20ff).cover?(codepoint) ||
|
|
80
|
-
(
|
|
131
|
+
(0xfe00..0xfe0f).cover?(codepoint) ||
|
|
132
|
+
(0xfe20..0xfe2f).cover?(codepoint) ||
|
|
133
|
+
(0xe0100..0xe01ef).cover?(codepoint)
|
|
81
134
|
end
|
|
82
135
|
|
|
83
136
|
def wide?(codepoint)
|
|
@@ -92,5 +145,11 @@ module Shellfie
|
|
|
92
145
|
(0xffe0..0xffe6).cover?(codepoint) ||
|
|
93
146
|
(0x1f300..0x1faff).cover?(codepoint)
|
|
94
147
|
end
|
|
148
|
+
|
|
149
|
+
def ambiguous?(codepoint)
|
|
150
|
+
return false if codepoint < 0x00a1
|
|
151
|
+
|
|
152
|
+
AMBIGUOUS_RANGES.any? { |range| range.cover?(codepoint) }
|
|
153
|
+
end
|
|
95
154
|
end
|
|
96
155
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "animation_frame_builder"
|
|
5
|
+
require_relative "ansi_parser"
|
|
6
|
+
require_relative "output_writer"
|
|
7
|
+
|
|
8
|
+
module Shellfie
|
|
9
|
+
class TranscriptRenderer
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def render(output_path, format:, io: nil)
|
|
15
|
+
OutputWriter.write(output_path, extension: format, io: io) do |temporary_path|
|
|
16
|
+
value = case format
|
|
17
|
+
when "json" then JSON.pretty_generate(document)
|
|
18
|
+
when "ansi" then ansi_text
|
|
19
|
+
when "asciicast", "cast" then asciicast
|
|
20
|
+
else text
|
|
21
|
+
end
|
|
22
|
+
File.write(temporary_path, value)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :config
|
|
29
|
+
|
|
30
|
+
def final_lines
|
|
31
|
+
return config.lines if config.frames.empty?
|
|
32
|
+
|
|
33
|
+
AnimationFrameBuilder.new(config).build.last[:lines]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def text
|
|
37
|
+
final_lines.flat_map do |line|
|
|
38
|
+
command = "#{line.prompt}#{line.command}" if line.prompt || line.command
|
|
39
|
+
[command, line.output].compact.map { |value| plain_text(value) }
|
|
40
|
+
end.join("\n") + "\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ansi_text
|
|
44
|
+
final_lines.flat_map do |line|
|
|
45
|
+
command = "#{line.prompt}#{line.command}" if line.prompt || line.command
|
|
46
|
+
[command, line.output].compact
|
|
47
|
+
end.join("\n") + "\n"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def asciicast
|
|
51
|
+
width = [config.window[:width].to_i / 8, 1].max
|
|
52
|
+
header = { version: 2, width: width, height: [final_lines.size, 1].max, env: { "TERM" => "xterm-256color" } }
|
|
53
|
+
elapsed = 0.0
|
|
54
|
+
source = config.frames.empty? ? [{ text: ansi_text, delay: 0 }] : config.frames.map do |frame|
|
|
55
|
+
{ text: frame_to_ansi(frame), delay: frame.delay }
|
|
56
|
+
end
|
|
57
|
+
events = source.map do |event|
|
|
58
|
+
value = [elapsed.round(6), "o", event[:text]]
|
|
59
|
+
elapsed += event[:delay].to_f / 1_000
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
([JSON.generate(header)] + events.map { |event| JSON.generate(event) }).join("\n") + "\n"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def frame_to_ansi(frame)
|
|
66
|
+
command = "#{frame.prompt}#{frame.command}" if frame.prompt || frame.command
|
|
67
|
+
[command, frame.output].compact.join("\r\n") + "\r\n"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def document
|
|
71
|
+
{
|
|
72
|
+
version: 1,
|
|
73
|
+
title: config.title,
|
|
74
|
+
lines: final_lines.map { |line| plain_value(line.to_h) },
|
|
75
|
+
events: config.frames.map { |frame| plain_value(frame.to_h) }
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def plain_value(value)
|
|
80
|
+
case value
|
|
81
|
+
when String then plain_text(value)
|
|
82
|
+
when Array then value.map { |item| plain_value(item) }
|
|
83
|
+
when Hash then value.transform_values { |item| plain_value(item) }
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def plain_text(value)
|
|
89
|
+
AnsiParser.new(state_mode: :line).parse(value.to_s).map(&:text).join
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/shellfie/version.rb
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Shellfie
|
|
7
|
+
module YamlSafety
|
|
8
|
+
MAX_DEPTH = 100
|
|
9
|
+
MAX_NODES = 100_000
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def annotate_validation_error(error, documents, provenance: {})
|
|
14
|
+
if (target = validation_path(error.message)) && (origin = provenance[target])
|
|
15
|
+
path, local_path = origin
|
|
16
|
+
content = documents.assoc(path)&.last
|
|
17
|
+
if content && (location = location_for_path(content, local_path))
|
|
18
|
+
return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.message}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
documents.each do |path, content|
|
|
23
|
+
next unless path && content
|
|
24
|
+
|
|
25
|
+
location = validation_location(content, error.message)
|
|
26
|
+
return error.class.new("#{path}:#{location[0]}:#{location[1]}: #{error.message}") if location
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
path = documents.first&.first
|
|
30
|
+
error.class.new(path ? "#{path}: #{error.message}" : error.message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def read_file(path, max_bytes:, label: "Configuration")
|
|
34
|
+
raise ParseError, "#{label} file not found: #{path}" unless File.file?(path)
|
|
35
|
+
|
|
36
|
+
content = File.open(path, "rb") { |file| file.read(max_bytes + 1) }
|
|
37
|
+
raise ParseError, "#{label} file is too large: #{path} (max #{max_bytes} bytes)" if content.bytesize > max_bytes
|
|
38
|
+
|
|
39
|
+
content
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def load_file(path, max_bytes:, label: "Configuration", symbolize_names: true)
|
|
43
|
+
value = YAML.safe_load(
|
|
44
|
+
read_file(path, max_bytes: max_bytes, label: label), symbolize_names: symbolize_names, aliases: true
|
|
45
|
+
)
|
|
46
|
+
validate_tree!(value)
|
|
47
|
+
rescue Psych::Exception => e
|
|
48
|
+
raise ParseError, "Invalid #{label.downcase} YAML syntax: #{e.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_tree!(value)
|
|
52
|
+
active = {}
|
|
53
|
+
nodes = 0
|
|
54
|
+
stack = [[value, 0, false]]
|
|
55
|
+
|
|
56
|
+
until stack.empty?
|
|
57
|
+
node, depth, leaving = stack.pop
|
|
58
|
+
next unless node.is_a?(Hash) || node.is_a?(Array)
|
|
59
|
+
if leaving
|
|
60
|
+
active.delete(node.object_id)
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
raise ParseError, "YAML aliases must not contain cycles" if active[node.object_id]
|
|
65
|
+
raise ParseError, "YAML nesting is too deep (max #{MAX_DEPTH})" if depth > MAX_DEPTH
|
|
66
|
+
nodes += 1
|
|
67
|
+
raise ParseError, "YAML structure is too large (max #{MAX_NODES} collections)" if nodes > MAX_NODES
|
|
68
|
+
|
|
69
|
+
active[node.object_id] = true
|
|
70
|
+
stack << [node, depth, true]
|
|
71
|
+
children = node.is_a?(Hash) ? node.flat_map { |key, nested| [key, nested] } : node
|
|
72
|
+
children.reverse_each { |child| stack << [child, depth + 1, false] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validation_location(content, message)
|
|
79
|
+
locations = {}
|
|
80
|
+
collect_locations(Psych.parse(content).root, [], locations)
|
|
81
|
+
target = validation_path(message)
|
|
82
|
+
return locations[target] if target && locations[target]
|
|
83
|
+
|
|
84
|
+
locations.sort_by { |path, _location| -path.size }.each do |path, location|
|
|
85
|
+
return location if message.include?(format_path(path))
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
rescue Psych::Exception
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def location_for_path(content, path)
|
|
93
|
+
locations = {}
|
|
94
|
+
collect_locations(Psych.parse(content).root, [], locations)
|
|
95
|
+
locations[path] || locations[path[0...-1]]
|
|
96
|
+
rescue Psych::Exception
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collect_locations(node, path, locations)
|
|
101
|
+
case node
|
|
102
|
+
when Psych::Nodes::Mapping
|
|
103
|
+
node.children.each_slice(2) do |key, value|
|
|
104
|
+
next unless key.respond_to?(:value)
|
|
105
|
+
|
|
106
|
+
child_path = path + [key.value.to_sym]
|
|
107
|
+
locations[child_path] = [key.start_line + 1, key.start_column + 1]
|
|
108
|
+
collect_locations(value, child_path, locations)
|
|
109
|
+
end
|
|
110
|
+
when Psych::Nodes::Sequence
|
|
111
|
+
node.children.each_with_index { |child, index| collect_locations(child, path + [index], locations) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validation_path(message)
|
|
116
|
+
if (match = /Unknown (.+?) key\(s\):\s*([^,\s]+)/.match(message))
|
|
117
|
+
prefix = match[1] == "configuration" ? [] : parse_path(match[1])
|
|
118
|
+
return prefix + [match[2].to_sym]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
explicit = message[/\b(?:steps|outputs|frames|lines)\[\d+\](?:\.[a-z_]+)*|\b(?:terminal|render|window|font|animation|cursor|limits)\.[a-z_]+/]
|
|
122
|
+
return parse_path(explicit) if explicit
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
"Session config version" => [:version], "mode must" => [:mode], "Invalid theme" => [:theme],
|
|
126
|
+
"redaction" => [:redact], "requires" => [:requires], "terminal.env" => %i[terminal env],
|
|
127
|
+
"title must" => [:title], "headless must" => [:headless]
|
|
128
|
+
}.each { |fragment, path| return path if message.include?(fragment) }
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_path(value)
|
|
133
|
+
value.to_s.scan(/[A-Za-z_][A-Za-z0-9_]*|\d+/).map { |part| part.match?(/\A\d+\z/) ? part.to_i : part.to_sym }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def format_path(path)
|
|
137
|
+
path.each_with_object(+"") do |part, result|
|
|
138
|
+
if part.is_a?(Integer)
|
|
139
|
+
result << "[#{part}]"
|
|
140
|
+
else
|
|
141
|
+
result << "." unless result.empty?
|
|
142
|
+
result << part.to_s
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
data/lib/shellfie.rb
CHANGED
|
@@ -16,6 +16,13 @@ module Shellfie
|
|
|
16
16
|
autoload :ImageMagickCommandBuilder, File.expand_path("shellfie/image_magick_command_builder", __dir__)
|
|
17
17
|
autoload :RenderChromeCache, File.expand_path("shellfie/render_chrome_cache", __dir__)
|
|
18
18
|
autoload :Renderer, File.expand_path("shellfie/renderer", __dir__)
|
|
19
|
+
autoload :TerminalScreen, File.expand_path("shellfie/terminal_screen", __dir__)
|
|
20
|
+
autoload :TranscriptRenderer, File.expand_path("shellfie/transcript_renderer", __dir__)
|
|
21
|
+
autoload :Cassette, File.expand_path("shellfie/cassette", __dir__)
|
|
22
|
+
autoload :Session, File.expand_path("shellfie/session", __dir__)
|
|
23
|
+
autoload :SessionConfig, File.expand_path("shellfie/session_config", __dir__)
|
|
24
|
+
autoload :SessionRunner, File.expand_path("shellfie/session_runner", __dir__)
|
|
25
|
+
autoload :ReproducibilityManifest, File.expand_path("shellfie/reproducibility_manifest", __dir__)
|
|
19
26
|
|
|
20
27
|
class << self
|
|
21
28
|
def parse(source)
|
|
@@ -41,7 +48,7 @@ module Shellfie
|
|
|
41
48
|
def inspect_config(source, scale: 1, shadow: true)
|
|
42
49
|
config = parse(source)
|
|
43
50
|
geometry = Renderer.new(config).estimate(scale: scale, shadow: shadow)
|
|
44
|
-
{ config: config.to_h, theme: config.theme, geometry: geometry }
|
|
51
|
+
{ config: config.to_h, theme: config.theme, geometry: geometry, fonts: Renderer.new(config).font_info }
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/ydah/shellfie/main/schema/shellfie-v1.schema.json",
|
|
4
|
+
"title": "Shellfie compose configuration",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"properties": {
|
|
8
|
+
"version": { "const": 1 },
|
|
9
|
+
"include": { "oneOf": [{ "type": "string" }, { "type": "array", "items": { "type": "string" } }] },
|
|
10
|
+
"include_policy": { "enum": ["allow", "root"] },
|
|
11
|
+
"theme": { "enum": ["macos", "ubuntu", "windows", "custom"] },
|
|
12
|
+
"window_theme": { "enum": ["macos", "ubuntu", "windows"] },
|
|
13
|
+
"color_scheme": { "enum": ["dracula", "one_dark", "solarized_dark", "catppuccin_mocha", null] },
|
|
14
|
+
"colors": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"propertyNames": { "enum": ["background", "foreground", "title_bar", "title_text", "title_bar_border", "border", "selection", "black", "red", "green", "yellow", "blue", "magenta", "cyan", "white", "bright_black", "bright_red", "bright_green", "bright_yellow", "bright_blue", "bright_magenta", "bright_cyan", "bright_white"] },
|
|
17
|
+
"additionalProperties": { "type": "string" }
|
|
18
|
+
},
|
|
19
|
+
"window_decoration": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"title_bar_height": { "type": "number", "minimum": 0 },
|
|
24
|
+
"button_size": { "type": "number", "minimum": 0 },
|
|
25
|
+
"button_spacing": { "type": "number", "minimum": 0 },
|
|
26
|
+
"button_width": { "type": "number", "minimum": 0 },
|
|
27
|
+
"corner_radius": { "type": "number", "minimum": 0 },
|
|
28
|
+
"shadow": {
|
|
29
|
+
"type": "object", "additionalProperties": false,
|
|
30
|
+
"properties": {
|
|
31
|
+
"blur": { "type": "number", "minimum": 0 }, "offset_x": { "type": "number" },
|
|
32
|
+
"offset_y": { "type": "number" }, "color": { "type": "string" }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"title": { "type": "string" },
|
|
38
|
+
"headless": { "type": "boolean" },
|
|
39
|
+
"window": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"properties": {
|
|
43
|
+
"width": { "type": "integer", "minimum": 120 },
|
|
44
|
+
"height": { "type": ["integer", "null"], "minimum": 1 },
|
|
45
|
+
"padding": { "type": "integer", "minimum": 0, "maximum": 40 },
|
|
46
|
+
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
47
|
+
"visible_lines": { "type": ["integer", "null"], "minimum": 1 },
|
|
48
|
+
"max_lines": { "type": ["integer", "null"], "minimum": 1 },
|
|
49
|
+
"max_height": { "type": ["integer", "null"], "minimum": 1 },
|
|
50
|
+
"wrap": { "type": "boolean" },
|
|
51
|
+
"overflow": { "enum": ["clip", "wrap", "scroll"] },
|
|
52
|
+
"margin": { "type": ["integer", "null"], "minimum": 0 },
|
|
53
|
+
"exact_size": { "type": "boolean" },
|
|
54
|
+
"trim": { "type": "boolean" },
|
|
55
|
+
"tab_width": { "type": "integer", "minimum": 1 },
|
|
56
|
+
"ambiguous_width": { "enum": [1, 2] },
|
|
57
|
+
"osc_policy": { "enum": ["ignore", "preserve", "apply"] },
|
|
58
|
+
"graphics_policy": { "enum": ["ignore", "error"] },
|
|
59
|
+
"ansi_state": { "enum": ["persistent", "line"] },
|
|
60
|
+
"background_gradient": { "type": ["array", "null"], "minItems": 2, "maxItems": 2, "items": { "type": "string" } },
|
|
61
|
+
"scroll_offset": { "type": "number", "minimum": 0, "maximum": 1 }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"font": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"properties": {
|
|
68
|
+
"family": { "type": ["string", "null"] },
|
|
69
|
+
"size": { "type": "number", "exclusiveMinimum": 0 },
|
|
70
|
+
"line_height": { "type": "number", "exclusiveMinimum": 0 },
|
|
71
|
+
"fallback_family": { "type": ["string", "null"] },
|
|
72
|
+
"italic_family": { "type": ["string", "null"] },
|
|
73
|
+
"emoji_family": { "type": ["string", "null"] }
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"animation": {
|
|
77
|
+
"type": "object",
|
|
78
|
+
"additionalProperties": false,
|
|
79
|
+
"properties": {
|
|
80
|
+
"typing_speed": { "type": "integer", "minimum": 0, "maximum": 86400000 },
|
|
81
|
+
"command_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
|
|
82
|
+
"cursor_blink": { "type": "boolean" },
|
|
83
|
+
"loop": { "type": "boolean" },
|
|
84
|
+
"typing_jitter": { "type": "number", "minimum": 0, "maximum": 1 },
|
|
85
|
+
"seed": { "type": "integer", "minimum": 0, "maximum": 2147483647 },
|
|
86
|
+
"typing_chunk_size": { "type": "integer", "minimum": 1 },
|
|
87
|
+
"output_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
|
|
88
|
+
"final_delay": { "type": "integer", "minimum": 0, "maximum": 86400000 },
|
|
89
|
+
"max_frames": { "type": ["integer", "null"], "minimum": 1 },
|
|
90
|
+
"dither": { "type": "boolean" },
|
|
91
|
+
"palette": { "enum": ["global", "adaptive", "theme"] },
|
|
92
|
+
"gif_colors": { "type": "integer", "minimum": 2, "maximum": 256 },
|
|
93
|
+
"gif_optimize": { "type": "boolean" },
|
|
94
|
+
"webp_lossless": { "type": "boolean" },
|
|
95
|
+
"webp_quality": { "type": "integer", "minimum": 0, "maximum": 100 },
|
|
96
|
+
"webp_method": { "type": "integer", "minimum": 0, "maximum": 6 },
|
|
97
|
+
"webp_near_lossless": { "type": "integer", "minimum": 0, "maximum": 100 },
|
|
98
|
+
"apng_prediction": { "enum": ["none", "sub", "up", "avg", "paeth", "mixed"] },
|
|
99
|
+
"loop_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 65535 },
|
|
100
|
+
"direction": { "enum": ["forward", "reverse", "ping_pong"] },
|
|
101
|
+
"loop_offset": { "type": "integer", "minimum": 0 },
|
|
102
|
+
"scroll_easing": { "enum": ["linear", "ease_in", "ease_out", "ease_in_out"] },
|
|
103
|
+
"framerate": { "type": "integer", "minimum": 1, "maximum": 120 },
|
|
104
|
+
"playback_speed": { "type": "number", "exclusiveMinimum": 0, "maximum": 100 }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
"cursor": {
|
|
108
|
+
"type": "object", "additionalProperties": false,
|
|
109
|
+
"properties": { "style": { "enum": ["block", "bar", "underline"] }, "color": { "type": ["string", "null"] } }
|
|
110
|
+
},
|
|
111
|
+
"limits": {
|
|
112
|
+
"type": "object", "additionalProperties": false,
|
|
113
|
+
"properties": {
|
|
114
|
+
"max_lines": { "type": "integer", "minimum": 1, "maximum": 10000 },
|
|
115
|
+
"max_frames": { "type": "integer", "minimum": 1, "maximum": 500 },
|
|
116
|
+
"max_render_frames": { "type": "integer", "minimum": 1, "maximum": 2000 },
|
|
117
|
+
"max_characters": { "type": "integer", "minimum": 1, "maximum": 200000 },
|
|
118
|
+
"max_pixels": { "type": "integer", "minimum": 1, "maximum": 50000000 },
|
|
119
|
+
"max_total_pixels": { "type": "integer", "minimum": 1, "maximum": 2000000000 },
|
|
120
|
+
"max_temp_bytes": { "type": "integer", "minimum": 1, "maximum": 8000000000 }
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
"lines": { "type": "array", "maxItems": 10000, "items": { "$ref": "#/$defs/line" } },
|
|
124
|
+
"frames": { "type": "array", "maxItems": 500, "items": { "$ref": "#/$defs/frame" } }
|
|
125
|
+
},
|
|
126
|
+
"anyOf": [{ "required": ["lines"] }, { "required": ["frames"] }],
|
|
127
|
+
"$defs": {
|
|
128
|
+
"line": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"additionalProperties": false,
|
|
131
|
+
"properties": {
|
|
132
|
+
"prompt": { "type": "string" }, "command": { "type": "string" }, "output": { "type": "string" },
|
|
133
|
+
"prompt_color": { "type": "string" }, "command_color": { "type": "string" }, "output_color": { "type": "string" },
|
|
134
|
+
"selected": { "type": "boolean" }
|
|
135
|
+
},
|
|
136
|
+
"anyOf": [{ "required": ["prompt"] }, { "required": ["command"] }, { "required": ["output"] }]
|
|
137
|
+
},
|
|
138
|
+
"frame": {
|
|
139
|
+
"type": "object",
|
|
140
|
+
"additionalProperties": false,
|
|
141
|
+
"properties": {
|
|
142
|
+
"prompt": { "type": "string" }, "type": { "type": "string" }, "output": { "type": "string" },
|
|
143
|
+
"screen": { "type": "array", "items": { "type": "string" } },
|
|
144
|
+
"delay": { "type": "integer", "minimum": 0, "maximum": 86400000 }, "prompt_color": { "type": "string" },
|
|
145
|
+
"command_color": { "type": "string" }, "output_color": { "type": "string" }
|
|
146
|
+
},
|
|
147
|
+
"allOf": [
|
|
148
|
+
{ "anyOf": [{ "required": ["type"] }, { "required": ["output"] }, { "required": ["screen"] }, { "required": ["delay"] }] },
|
|
149
|
+
{ "if": { "required": ["prompt"] }, "then": { "required": ["type"] } }
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|