shellfie 0.1.0 → 1.0.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +95 -236
  4. data/docs/.nojekyll +0 -0
  5. data/docs/index.html +205 -0
  6. data/docs/scripts.js +85 -0
  7. data/docs/styles.css +507 -0
  8. data/examples/simple.yml +3 -3
  9. data/lib/shellfie/animation_frame_builder.rb +178 -0
  10. data/lib/shellfie/animation_scroll_easing.rb +77 -0
  11. data/lib/shellfie/animation_timeline.rb +27 -0
  12. data/lib/shellfie/ansi_colors.rb +94 -0
  13. data/lib/shellfie/ansi_line_buffer.rb +87 -0
  14. data/lib/shellfie/ansi_normalizer.rb +51 -0
  15. data/lib/shellfie/ansi_parser.rb +50 -84
  16. data/lib/shellfie/cli.rb +22 -173
  17. data/lib/shellfie/cli_generate.rb +197 -0
  18. data/lib/shellfie/cli_info.rb +139 -0
  19. data/lib/shellfie/config.rb +108 -25
  20. data/lib/shellfie/config_defaults.rb +64 -0
  21. data/lib/shellfie/config_validation.rb +200 -0
  22. data/lib/shellfie/dependency_checker.rb +76 -0
  23. data/lib/shellfie/errors.rb +11 -1
  24. data/lib/shellfie/font_resolver.rb +58 -0
  25. data/lib/shellfie/format_resolver.rb +15 -0
  26. data/lib/shellfie/gif_generator.rb +83 -87
  27. data/lib/shellfie/gif_palette.rb +101 -0
  28. data/lib/shellfie/headless_theme_registry.rb +42 -0
  29. data/lib/shellfie/image_magick_command_builder.rb +75 -0
  30. data/lib/shellfie/line_layout.rb +137 -0
  31. data/lib/shellfie/output_writer.rb +41 -0
  32. data/lib/shellfie/parser.rb +113 -23
  33. data/lib/shellfie/parser_validation.rb +145 -0
  34. data/lib/shellfie/raster_painter.rb +157 -0
  35. data/lib/shellfie/render_chrome_cache.rb +40 -0
  36. data/lib/shellfie/render_geometry.rb +114 -0
  37. data/lib/shellfie/render_segment.rb +59 -0
  38. data/lib/shellfie/renderer.rb +79 -149
  39. data/lib/shellfie/rendering/shape_helpers.rb +42 -0
  40. data/lib/shellfie/rendering/text_painter.rb +187 -0
  41. data/lib/shellfie/rendering/window_chrome.rb +196 -0
  42. data/lib/shellfie/svg_raster_wrapper.rb +35 -0
  43. data/lib/shellfie/text_metrics.rb +96 -0
  44. data/lib/shellfie/theme_data.rb +80 -0
  45. data/lib/shellfie/theme_registry.rb +131 -0
  46. data/lib/shellfie/themes/base.rb +10 -1
  47. data/lib/shellfie/themes/configured.rb +61 -0
  48. data/lib/shellfie/themes/macos.rb +3 -1
  49. data/lib/shellfie/themes/ubuntu.rb +2 -1
  50. data/lib/shellfie/themes/windows_terminal.rb +7 -1
  51. data/lib/shellfie/version.rb +1 -1
  52. data/lib/shellfie.rb +37 -3
  53. metadata +37 -2
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module CLIInfo
5
+ private
6
+
7
+ def run_init
8
+ puts <<~YAML
9
+ # Shellfie configuration file
10
+ theme: macos
11
+ title: "Terminal — zsh"
12
+
13
+ window:
14
+ width: 600
15
+ padding: 20
16
+
17
+ lines:
18
+ - prompt: "$ "
19
+ command: "gem install shellfie"
20
+
21
+ - output: |
22
+ Fetching shellfie-#{VERSION}.gem
23
+ Successfully installed shellfie-#{VERSION}
24
+ 1 gem installed
25
+
26
+ - prompt: "$ "
27
+ command: "shellfie --version"
28
+
29
+ - output: "shellfie #{VERSION}"
30
+ YAML
31
+ end
32
+
33
+ def run_themes
34
+ puts "Available themes:"
35
+ puts
36
+ ThemeRegistry.available_themes.each { |theme| puts " #{theme}" }
37
+ puts
38
+ puts "Available color schemes:"
39
+ ThemeRegistry.available_color_schemes.each { |scheme| puts " #{scheme}" }
40
+ puts
41
+ puts "Use: shellfie generate config.yml -o output.png -t THEME_NAME"
42
+ end
43
+
44
+ def run_validate
45
+ input_file = @args.shift
46
+ raise ConfigError, "Input file is required" unless input_file
47
+
48
+ config = Parser.parse(input_file)
49
+ puts "✓ Configuration is valid"
50
+ puts " Theme: #{config.theme}"
51
+ puts " Title: #{config.title}"
52
+ puts " Mode: #{config.animated? ? "animated" : "static"}"
53
+ puts " Lines: #{config.lines.size}" if config.static?
54
+ puts " Source frames: #{config.frames.size}" if config.animated?
55
+ puts " Estimated render frames: #{AnimationFrameBuilder.new(config).build.size}" if config.animated?
56
+ geometry = Renderer.new(config).estimate
57
+ puts " Estimated size: #{geometry[:canvas_width]}x#{geometry[:canvas_height]}"
58
+ puts " Logical size: #{geometry[:logical_width]}x#{geometry[:logical_height]} @#{geometry[:scale]}x"
59
+ end
60
+
61
+ def run_inspect
62
+ input_file = @args.shift
63
+ raise ConfigError, "Input file is required" unless input_file
64
+
65
+ info = Shellfie.inspect_config(input_file)
66
+ puts "Config:"
67
+ puts " Version: #{info[:config][:version]}"
68
+ puts " Theme: #{info[:theme]}"
69
+ puts " Title: #{info[:config][:title]}"
70
+ puts " Mode: #{info[:config][:frames].empty? ? "static" : "animated"}"
71
+ puts " Lines: #{info[:config][:lines].size}"
72
+ puts " Frames: #{info[:config][:frames].size}"
73
+ puts " Estimated size: #{info[:geometry][:canvas_width]}x#{info[:geometry][:canvas_height]}"
74
+ puts " Logical size: #{info[:geometry][:logical_width]}x#{info[:geometry][:logical_height]} @#{info[:geometry][:scale]}x"
75
+ end
76
+
77
+ def run_doctor
78
+ failed = false
79
+ DependencyChecker.doctor.each do |check|
80
+ status = check[:ok] ? "ok" : "fail"
81
+ failed ||= !check[:ok]
82
+ puts "#{status.ljust(4)} #{check[:name]}: #{check[:detail]}"
83
+ end
84
+ exit 4 if failed
85
+ end
86
+
87
+ def run_version
88
+ puts "shellfie #{VERSION}"
89
+ end
90
+
91
+ def show_help
92
+ puts <<~HELP
93
+ Shellfie - Terminal screenshot-style image generator
94
+
95
+ Usage: shellfie <command> [options]
96
+ shf <command> [options]
97
+
98
+ Commands:
99
+ generate Generate image from configuration file
100
+ init Output sample configuration
101
+ themes List available themes
102
+ validate Validate configuration file
103
+ inspect Print resolved config and estimated image size
104
+ doctor Check dependencies and local environment
105
+ version Show version
106
+ help Show this help
107
+
108
+ Generate Options:
109
+ -o, --output PATH Output file path (required)
110
+ -t, --theme NAME Override theme (macos, ubuntu, windows)
111
+ -a, --animate Generate animated GIF
112
+ -s, --scale FACTOR Output scale (1, 2, 3)
113
+ -w, --width PIXELS Override width
114
+ --no-shadow Disable shadow effect
115
+ --no-header Disable window header (headless mode)
116
+ --transparent Transparent background
117
+ --fps FPS Override animation typing FPS
118
+ --overflow MODE Line overflow mode: clip, wrap, scroll
119
+ --wrap, --no-wrap Enable or disable long-line wrapping
120
+ --exact-size Match canvas to configured window size
121
+ --format FORMAT Output format: png, gif, svg, webp, apng
122
+ --force Overwrite existing output files
123
+ --quiet Suppress non-error output
124
+ --verbose Print progress details
125
+
126
+ Examples:
127
+ shellfie generate config.yml -o terminal.png
128
+ shellfie generate config.yml -o demo.gif --animate
129
+ shellfie generate config.yml -o retina.png --scale 2
130
+ shellfie init > my-config.yml
131
+ shellfie themes
132
+
133
+ # Short form
134
+ shf generate config.yml -o terminal.png
135
+ shf init > config.yml
136
+ HELP
137
+ end
138
+ end
139
+ end
@@ -1,40 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "config_validation"
4
+ require_relative "config_defaults"
5
+ require_relative "errors"
6
+ require_relative "theme_registry"
7
+
3
8
  module Shellfie
4
9
  class Config
5
- DEFAULTS = {
6
- theme: "macos",
7
- window: {
8
- width: 600,
9
- padding: 20,
10
- opacity: 1.0,
11
- visible_lines: nil
12
- },
13
- font: {
14
- family: "Monaco",
15
- size: 14,
16
- line_height: 1.4
17
- },
18
- animation: {
19
- typing_speed: 80,
20
- command_delay: 500,
21
- cursor_blink: true,
22
- loop: false
23
- }
24
- }.freeze
10
+ include ConfigValidation
11
+
12
+ VALID_THEMES = ThemeRegistry.available_themes.freeze
13
+ VALID_OVERFLOW_MODES = %w[clip wrap scroll].freeze
14
+ VALID_CURSOR_STYLES = %w[block bar underline].freeze
15
+ VALID_PALETTES = %w[global adaptive theme].freeze
16
+ VALID_SCROLL_EASINGS = %w[linear ease_in ease_out ease_in_out].freeze
17
+
18
+ class << self
19
+ def deep_dup(value)
20
+ case value
21
+ when Hash
22
+ value.each_with_object({}) { |(key, nested_value), result| result[key] = deep_dup(nested_value) }
23
+ when Array
24
+ value.map { |nested_value| deep_dup(nested_value) }
25
+ else
26
+ value
27
+ end
28
+ end
29
+
30
+ def deep_freeze(value)
31
+ case value
32
+ when Hash
33
+ value.each_value { |nested_value| deep_freeze(nested_value) }
34
+ when Array
35
+ value.each { |nested_value| deep_freeze(nested_value) }
36
+ end
37
+ value.freeze
38
+ end
39
+
40
+ def normalize_keys(value)
41
+ case value
42
+ when Hash
43
+ value.each_with_object({}) do |(key, nested_value), result|
44
+ normalized_key = key.is_a?(String) ? key.to_sym : key
45
+ result[normalized_key] = normalize_keys(nested_value)
46
+ end
47
+ when Array
48
+ value.map { |nested_value| normalize_keys(nested_value) }
49
+ else
50
+ value
51
+ end
52
+ end
53
+ end
54
+
55
+ DEFAULTS = deep_freeze(deep_dup(ConfigDefaults::VALUES))
25
56
 
26
- attr_reader :theme, :title, :window, :font, :lines, :animation, :frames, :headless
57
+ attr_reader :version, :theme, :window_theme, :color_scheme, :colors, :window_decoration, :title, :window, :font,
58
+ :lines, :animation, :frames, :headless, :cursor, :limits
27
59
 
28
60
  def initialize(options = {})
61
+ options = self.class.normalize_keys(options)
29
62
  merged = merge_defaults(options)
63
+ @version = merged[:version]
30
64
  @theme = merged[:theme]
65
+ @window_theme = merged[:window_theme]
66
+ @color_scheme = merged[:color_scheme]
67
+ @colors = merged[:colors]
68
+ @window_decoration = merged[:window_decoration]
31
69
  @title = merged[:title] || "Terminal"
32
70
  @window = merged[:window]
33
71
  @font = merged[:font]
34
72
  @lines = merged[:lines] || []
35
73
  @animation = merged[:animation]
36
74
  @frames = merged[:frames] || []
37
- @headless = options[:headless] || false
75
+ @cursor = merged[:cursor]
76
+ @limits = merged[:limits]
77
+ @headless = merged[:headless] || false
78
+
79
+ validate!
80
+ freeze_state!
38
81
  end
39
82
 
40
83
  def static?
@@ -45,21 +88,61 @@ module Shellfie
45
88
  !static?
46
89
  end
47
90
 
91
+ def to_h
92
+ {
93
+ version: version,
94
+ theme: theme,
95
+ window_theme: window_theme,
96
+ color_scheme: color_scheme,
97
+ colors: colors,
98
+ window_decoration: window_decoration,
99
+ title: title,
100
+ window: window,
101
+ font: font,
102
+ animation: animation,
103
+ cursor: cursor,
104
+ limits: limits,
105
+ headless: headless,
106
+ lines: lines.map(&:to_h),
107
+ frames: frames.map(&:to_h)
108
+ }
109
+ end
110
+
48
111
  private
49
112
 
50
113
  def merge_defaults(options)
51
- result = {}
114
+ result = self.class.deep_dup(DEFAULTS)
52
115
  DEFAULTS.each do |key, value|
116
+ next unless options.key?(key)
117
+
53
118
  result[key] = if value.is_a?(Hash) && options[key].is_a?(Hash)
54
- value.merge(options[key])
119
+ result[key].merge(options[key])
55
120
  else
56
- options.key?(key) ? options[key] : value
121
+ self.class.deep_dup(options[key])
57
122
  end
58
123
  end
59
124
  result[:title] = options[:title]
60
125
  result[:lines] = options[:lines]
61
126
  result[:frames] = options[:frames]
127
+ result[:headless] = options[:headless] if options.key?(:headless)
62
128
  result
63
129
  end
130
+
131
+ def freeze_state!
132
+ @colors = self.class.deep_freeze(@colors)
133
+ @window_decoration = self.class.deep_freeze(@window_decoration)
134
+ @window = self.class.deep_freeze(@window)
135
+ @font = self.class.deep_freeze(@font)
136
+ @animation = self.class.deep_freeze(@animation)
137
+ @cursor = self.class.deep_freeze(@cursor)
138
+ @limits = self.class.deep_freeze(@limits)
139
+ @lines = self.class.deep_freeze(@lines)
140
+ @frames = self.class.deep_freeze(@frames)
141
+ @title.freeze
142
+ @theme.freeze
143
+ @window_theme.freeze if @window_theme
144
+ @color_scheme.freeze if @color_scheme
145
+ freeze
146
+ end
64
147
  end
65
148
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module ConfigDefaults
5
+ VALUES = {
6
+ version: 1,
7
+ theme: "macos",
8
+ window_theme: nil,
9
+ color_scheme: nil,
10
+ colors: {},
11
+ window_decoration: {},
12
+ window: {
13
+ width: 600,
14
+ padding: 20,
15
+ opacity: 1.0,
16
+ visible_lines: nil,
17
+ max_lines: nil,
18
+ max_height: nil,
19
+ wrap: false,
20
+ overflow: "clip",
21
+ margin: nil,
22
+ exact_size: false,
23
+ trim: false,
24
+ tab_width: 8,
25
+ ansi_state: "persistent",
26
+ background_gradient: nil,
27
+ scroll_offset: 0.0
28
+ },
29
+ font: {
30
+ family: "Monaco",
31
+ size: 14,
32
+ line_height: 1.4,
33
+ fallback_family: nil,
34
+ italic_family: nil,
35
+ emoji_family: nil
36
+ },
37
+ animation: {
38
+ typing_speed: 80,
39
+ command_delay: 500,
40
+ cursor_blink: true,
41
+ loop: false,
42
+ typing_jitter: 0.0,
43
+ typing_chunk_size: 1,
44
+ output_delay: 0,
45
+ final_delay: 1_000,
46
+ max_frames: nil,
47
+ dither: true,
48
+ palette: "global",
49
+ scroll_easing: "linear"
50
+ },
51
+ cursor: {
52
+ style: "block",
53
+ color: nil
54
+ },
55
+ limits: {
56
+ max_lines: 10_000,
57
+ max_frames: 500,
58
+ max_render_frames: 2_000,
59
+ max_characters: 200_000,
60
+ max_pixels: 50_000_000
61
+ }
62
+ }.freeze
63
+ end
64
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Shellfie
4
+ module ConfigValidation
5
+ def validate!
6
+ validate_version!
7
+ validate_theme!
8
+ validate_window!
9
+ validate_font!
10
+ validate_animation!
11
+ validate_cursor!
12
+ validate_lines!
13
+ validate_limits!
14
+ validate_headless!
15
+ validate_resource_limits!
16
+ end
17
+
18
+ private
19
+
20
+ def validate_version!
21
+ return if @version == 1
22
+
23
+ raise ValidationError, "Unsupported config version '#{@version}'"
24
+ end
25
+
26
+ def validate_theme!
27
+ return if ThemeRegistry.valid_theme?(@theme)
28
+
29
+ raise ValidationError, "Invalid theme '#{@theme}'\n → Available themes: #{ThemeRegistry.available_themes.join(", ")}"
30
+ end
31
+
32
+ def validate_window_theme!
33
+ return if @window_theme.nil? || ThemeRegistry.valid_window_theme?(@window_theme)
34
+
35
+ raise ValidationError, "Invalid window_theme '#{@window_theme}'"
36
+ end
37
+
38
+ def validate_color_scheme!
39
+ return if ThemeRegistry.valid_color_scheme?(@color_scheme)
40
+
41
+ raise ValidationError, "Invalid color_scheme '#{@color_scheme}'"
42
+ end
43
+
44
+ def validate_window!
45
+ validate_window_theme!
46
+ validate_color_scheme!
47
+ validate_positive_integer!(@window[:width], "window.width")
48
+ validate_non_negative_integer!(@window[:padding], "window.padding")
49
+ %i[opacity scroll_offset].each { |key| validate_number_range!(@window[key], "window.#{key}", 0.0, 1.0) }
50
+ validate_optional_positive_integer!(@window[:visible_lines], "window.visible_lines")
51
+ validate_optional_positive_integer!(@window[:max_lines], "window.max_lines")
52
+ validate_optional_positive_integer!(@window[:max_height], "window.max_height")
53
+ validate_optional_non_negative_integer!(@window[:margin], "window.margin")
54
+ validate_positive_integer!(@window[:tab_width], "window.tab_width")
55
+ validate_boolean!(@window[:wrap], "window.wrap")
56
+ validate_boolean!(@window[:exact_size], "window.exact_size")
57
+ validate_boolean!(@window[:trim], "window.trim")
58
+ validate_overflow!
59
+ validate_ansi_state!
60
+ validate_background_gradient!
61
+ validate_minimum_width!
62
+ end
63
+
64
+ def validate_font!
65
+ validate_optional_string!(@font[:family], "font.family")
66
+ validate_optional_string!(@font[:fallback_family], "font.fallback_family")
67
+ validate_optional_string!(@font[:italic_family], "font.italic_family")
68
+ validate_optional_string!(@font[:emoji_family], "font.emoji_family")
69
+ validate_positive_number!(@font[:size], "font.size")
70
+ validate_positive_number!(@font[:line_height], "font.line_height")
71
+ end
72
+
73
+ def validate_animation!
74
+ validate_non_negative_integer!(@animation[:typing_speed], "animation.typing_speed")
75
+ validate_non_negative_integer!(@animation[:command_delay], "animation.command_delay")
76
+ validate_number_range!(@animation[:typing_jitter], "animation.typing_jitter", 0.0, 1.0)
77
+ validate_positive_integer!(@animation[:typing_chunk_size], "animation.typing_chunk_size")
78
+ validate_non_negative_integer!(@animation[:output_delay], "animation.output_delay")
79
+ validate_non_negative_integer!(@animation[:final_delay], "animation.final_delay")
80
+ validate_optional_positive_integer!(@animation[:max_frames], "animation.max_frames")
81
+ validate_boolean!(@animation[:cursor_blink], "animation.cursor_blink")
82
+ validate_boolean!(@animation[:loop], "animation.loop")
83
+ validate_boolean!(@animation[:dither], "animation.dither")
84
+ validate_inclusion!(@animation[:palette], "animation.palette", self.class::VALID_PALETTES)
85
+ validate_inclusion!(@animation[:scroll_easing], "animation.scroll_easing", self.class::VALID_SCROLL_EASINGS)
86
+ end
87
+
88
+ def validate_cursor!
89
+ return if self.class::VALID_CURSOR_STYLES.include?(@cursor[:style])
90
+
91
+ raise ValidationError, "cursor.style must be one of: #{self.class::VALID_CURSOR_STYLES.join(", ")}"
92
+ end
93
+
94
+ def validate_lines!
95
+ raise ValidationError, "lines must be an Array" unless @lines.is_a?(Array)
96
+ raise ValidationError, "frames must be an Array" unless @frames.is_a?(Array)
97
+ end
98
+
99
+ def validate_limits!
100
+ @limits.each_key do |key|
101
+ validate_positive_integer!(@limits[key], "limits.#{key}")
102
+ end
103
+ end
104
+
105
+ def validate_headless!
106
+ validate_boolean!(@headless, "headless")
107
+ end
108
+
109
+ def validate_resource_limits!
110
+ raise ResourceLimitError, "Too many lines (max #{@limits[:max_lines]})" if @lines.size > @limits[:max_lines]
111
+ raise ResourceLimitError, "Too many frames (max #{@limits[:max_frames]})" if @frames.size > @limits[:max_frames]
112
+
113
+ total_characters = (@lines.sum { |line| line.to_s.length } + @frames.sum { |frame| frame.to_s.length })
114
+ return if total_characters <= @limits[:max_characters]
115
+
116
+ raise ResourceLimitError, "Configuration text is too large (max #{@limits[:max_characters]} characters)"
117
+ end
118
+
119
+ def validate_overflow!
120
+ return if self.class::VALID_OVERFLOW_MODES.include?(@window[:overflow])
121
+
122
+ raise ValidationError, "window.overflow must be one of: #{self.class::VALID_OVERFLOW_MODES.join(", ")}"
123
+ end
124
+
125
+ def validate_ansi_state!
126
+ return if %w[persistent line].include?(@window[:ansi_state])
127
+
128
+ raise ValidationError, "window.ansi_state must be persistent or line"
129
+ end
130
+
131
+ def validate_background_gradient!
132
+ gradient = @window[:background_gradient]
133
+ return if gradient.nil?
134
+ return if gradient.is_a?(Array) && gradient.size == 2 && gradient.all? { |color| color.is_a?(String) }
135
+
136
+ raise ValidationError, "window.background_gradient must be an array of two colors"
137
+ end
138
+
139
+ def validate_minimum_width!
140
+ min_width = [120, (@window[:padding] * 2) + 40].max
141
+ return if @window[:width] >= min_width
142
+
143
+ raise ValidationError, "window.width must be at least #{min_width}px for the configured padding"
144
+ end
145
+
146
+ def validate_optional_positive_integer!(value, name)
147
+ return if value.nil?
148
+
149
+ validate_positive_integer!(value, name)
150
+ end
151
+
152
+ def validate_optional_non_negative_integer!(value, name)
153
+ return if value.nil?
154
+
155
+ validate_non_negative_integer!(value, name)
156
+ end
157
+
158
+ def validate_optional_string!(value, name)
159
+ return if value.nil? || value.is_a?(String)
160
+
161
+ raise ValidationError, "#{name} must be a string"
162
+ end
163
+
164
+ def validate_positive_integer!(value, name)
165
+ return if value.is_a?(Integer) && value.positive?
166
+
167
+ raise ValidationError, "#{name} must be a positive integer"
168
+ end
169
+
170
+ def validate_non_negative_integer!(value, name)
171
+ return if value.is_a?(Integer) && value >= 0
172
+
173
+ raise ValidationError, "#{name} must be a non-negative integer"
174
+ end
175
+
176
+ def validate_positive_number!(value, name)
177
+ return if value.is_a?(Numeric) && value.positive?
178
+
179
+ raise ValidationError, "#{name} must be a positive number"
180
+ end
181
+
182
+ def validate_number_range!(value, name, min, max)
183
+ return if value.is_a?(Numeric) && value >= min && value <= max
184
+
185
+ raise ValidationError, "#{name} must be between #{min} and #{max}"
186
+ end
187
+
188
+ def validate_inclusion!(value, name, allowed)
189
+ return if allowed.include?(value)
190
+
191
+ raise ValidationError, "#{name} must be one of: #{allowed.join(", ")}"
192
+ end
193
+
194
+ def validate_boolean!(value, name)
195
+ return if value == true || value == false
196
+
197
+ raise ValidationError, "#{name} must be true or false"
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Shellfie
6
+ class DependencyChecker
7
+ IMAGE_MAGICK_COMMANDS = %w[magick convert].freeze
8
+
9
+ class << self
10
+ def imagemagick_path
11
+ @imagemagick_path ||= find_executable(IMAGE_MAGICK_COMMANDS)
12
+ end
13
+
14
+ def imagemagick_available?
15
+ !imagemagick_path.to_s.empty?
16
+ end
17
+
18
+ def ensure_imagemagick!
19
+ return if imagemagick_available?
20
+
21
+ raise DependencyError, <<~MSG
22
+ ImageMagick not found
23
+ → Please install ImageMagick: brew install imagemagick
24
+ → Or visit: https://imagemagick.org/script/download.php
25
+ MSG
26
+ end
27
+
28
+ def configure_mini_magick!(timeout: 30)
29
+ return unless defined?(MiniMagick) && MiniMagick.respond_to?(:timeout=)
30
+
31
+ MiniMagick.timeout = timeout
32
+ end
33
+
34
+ def doctor(output_dir: Dir.pwd)
35
+ [
36
+ check("Ruby", RUBY_VERSION, Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.0.0")),
37
+ check("ImageMagick", imagemagick_path || "not found", imagemagick_available?),
38
+ check("Writable output", output_dir, File.writable?(output_dir)),
39
+ check("Encoding", Encoding.default_external.name, true)
40
+ ]
41
+ end
42
+
43
+ private
44
+
45
+ def check(name, detail, ok)
46
+ { name: name, detail: detail, ok: ok }
47
+ end
48
+
49
+ def find_executable(names)
50
+ paths = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
51
+ extensions = executable_extensions
52
+
53
+ names.each do |name|
54
+ paths.each do |path|
55
+ extensions.each do |extension|
56
+ candidate = File.join(path, "#{name}#{extension}")
57
+ return candidate if File.file?(candidate) && File.executable?(candidate)
58
+ end
59
+ end
60
+ end
61
+
62
+ nil
63
+ end
64
+
65
+ def executable_extensions
66
+ return [""] unless windows?
67
+
68
+ ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
69
+ end
70
+
71
+ def windows?
72
+ RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/i)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -1,15 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shellfie
4
- class Error < StandardError; end
4
+ class Error < StandardError
5
+ attr_reader :category, :context
6
+
7
+ def initialize(message = nil, category: nil, context: {})
8
+ @category = category
9
+ @context = context.freeze
10
+ super(message)
11
+ end
12
+ end
5
13
 
6
14
  class ConfigError < Error; end
7
15
  class ParseError < ConfigError; end
8
16
  class ValidationError < ConfigError; end
17
+ class ResourceLimitError < ConfigError; end
9
18
 
10
19
  class RenderError < Error; end
11
20
  class FontError < RenderError; end
12
21
  class ImageError < RenderError; end
13
22
 
14
23
  class DependencyError < Error; end
24
+ class FileSystemError < Error; end
15
25
  end