trmnl_preview 0.7.1 → 0.7.2

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/bin/trmnlp +15 -13
  3. metadata +17 -301
  4. data/CHANGELOG.md +0 -139
  5. data/LICENSE.txt +0 -21
  6. data/README.md +0 -144
  7. data/bin/rake +0 -27
  8. data/lib/trmnlp/api_client.rb +0 -86
  9. data/lib/trmnlp/app.rb +0 -103
  10. data/lib/trmnlp/cli.rb +0 -68
  11. data/lib/trmnlp/commands/base.rb +0 -40
  12. data/lib/trmnlp/commands/build.rb +0 -21
  13. data/lib/trmnlp/commands/clone.rb +0 -30
  14. data/lib/trmnlp/commands/init.rb +0 -55
  15. data/lib/trmnlp/commands/login.rb +0 -27
  16. data/lib/trmnlp/commands/pull.rb +0 -43
  17. data/lib/trmnlp/commands/push.rb +0 -67
  18. data/lib/trmnlp/commands/serve.rb +0 -25
  19. data/lib/trmnlp/commands.rb +0 -1
  20. data/lib/trmnlp/config/app.rb +0 -47
  21. data/lib/trmnlp/config/plugin.rb +0 -75
  22. data/lib/trmnlp/config/project.rb +0 -54
  23. data/lib/trmnlp/config.rb +0 -15
  24. data/lib/trmnlp/context.rb +0 -238
  25. data/lib/trmnlp/paths.rb +0 -58
  26. data/lib/trmnlp/screen_generator.rb +0 -240
  27. data/lib/trmnlp/version.rb +0 -5
  28. data/lib/trmnlp.rb +0 -14
  29. data/templates/init/.trmnlp.yml +0 -14
  30. data/templates/init/bin/trmnlp +0 -44
  31. data/templates/init/src/full.liquid +0 -1
  32. data/templates/init/src/half_horizontal.liquid +0 -1
  33. data/templates/init/src/half_vertical.liquid +0 -1
  34. data/templates/init/src/quadrant.liquid +0 -1
  35. data/templates/init/src/settings.yml +0 -15
  36. data/templates/init/src/shared.liquid +0 -1
  37. data/trmnl_preview.gemspec +0 -68
  38. data/web/public/highlight/highlight.min.js +0 -315
  39. data/web/public/highlight/styles/atom-one-dark.min.css +0 -1
  40. data/web/public/index.css +0 -113
  41. data/web/public/index.js +0 -83
  42. data/web/public/trmnl-picker.js +0 -656
  43. data/web/views/index.erb +0 -68
  44. data/web/views/render_html.erb +0 -23
@@ -1,75 +0,0 @@
1
- require 'yaml'
2
-
3
- module TRMNLP
4
- class Config
5
- class Plugin
6
- def initialize(paths, project_config)
7
- @paths = paths
8
- @project_config = project_config
9
- reload!
10
- end
11
-
12
- def reload!
13
- if paths.plugin_config.exist?
14
- @config = YAML.load_file(paths.plugin_config)
15
- else
16
- @config = {}
17
- end
18
- end
19
-
20
- def strategy = @config['strategy']
21
- def polling? = strategy == 'polling'
22
- def webhook? = strategy == 'webhook'
23
- def static? = strategy == 'static'
24
-
25
- def polling_urls
26
- # allow project-level config to override
27
- urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings', 'polling_url') || @config['polling_url']
28
-
29
- return [] if urls.nil?
30
-
31
- with_custom_fields(urls).strip.split("\n")
32
- end
33
-
34
- def polling_url_text = polling_urls.join("\r\n") # for {{ trmnl }}
35
-
36
- def polling_verb = @config['polling_verb'] || 'GET'
37
-
38
- def polling_headers
39
- string_to_hash(@config['polling_headers'] || '').transform_values { |v| with_custom_fields(v) }
40
- end
41
-
42
- def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&') # for {{ trmnl }}
43
-
44
- def polling_body = with_custom_fields(@config['polling_body'] || '')
45
-
46
- def dark_mode = @config['dark_mode'] || 'no'
47
-
48
- def no_screen_padding = @config['no_screen_padding'] || 'no'
49
-
50
- def id = @config['id']
51
-
52
- def static_data
53
- JSON.parse(@config['static_data'] || '{}')
54
- rescue JSON::ParserError
55
- raise Error, 'invalid JSON in static_data'
56
- end
57
-
58
- private
59
-
60
- attr_reader :paths, :project_config
61
-
62
- def with_custom_fields(value) = project_config.with_custom_fields(value)
63
-
64
- # copied from TRMNL core
65
- def string_to_hash(str, delimiter: '=')
66
- str.split('&').map do |k_v|
67
- key, value = k_v.split(delimiter)
68
- next if value.nil?
69
-
70
- { key => CGI.unescape_uri_component(value) }
71
- end.compact.reduce({}, :merge)
72
- end
73
- end
74
- end
75
- end
@@ -1,54 +0,0 @@
1
- require 'trmnl/liquid'
2
- require 'yaml'
3
-
4
- module TRMNLP
5
- class Config
6
- class Project
7
- attr_reader :paths
8
-
9
- def initialize(paths)
10
- @paths = paths
11
- reload!
12
- end
13
-
14
- def reload!
15
- if paths.trmnlp_config.exist?
16
- @config = YAML.load_file(paths.trmnlp_config)
17
- else
18
- @config = {}
19
- end
20
- end
21
-
22
- def user_filters = @config['custom_filters'] || []
23
-
24
- def live_render? = !watch_paths.empty?
25
-
26
- def watch_paths
27
- (@config['watch'] || []).map { |watch_path| paths.expand(watch_path) }.uniq
28
- end
29
-
30
- def custom_fields = @config.fetch('custom_fields', {}).transform_values(&:to_s)
31
-
32
- def user_data_overrides = @config['variables'] || {}
33
-
34
- # for interpolating custom_fields into polling_* options
35
- def with_custom_fields(value)
36
- custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
37
- parse_liquid(value).render(custom_fields_with_env)
38
- end
39
-
40
- def time_zone = @config['time_zone'] || 'UTC'
41
-
42
- private
43
-
44
- # for interpolating ENV vars into custom_fields
45
- def with_env(value)
46
- parse_liquid(value).render({ 'env' => ENV.to_h })
47
- end
48
-
49
- def parse_liquid(contents)
50
- Liquid::Template.parse(contents, environment: TRMNL::Liquid.build_environment)
51
- end
52
- end
53
- end
54
- end
data/lib/trmnlp/config.rb DELETED
@@ -1,15 +0,0 @@
1
- require_relative 'config/app'
2
- require_relative 'config/plugin'
3
- require_relative 'config/project'
4
-
5
- module TRMNLP
6
- class Config
7
- attr_reader :app, :project, :plugin
8
-
9
- def initialize(path)
10
- @app = App.new(path)
11
- @project = Project.new(path)
12
- @plugin = Plugin.new(path, @project)
13
- end
14
- end
15
- end
@@ -1,238 +0,0 @@
1
- require 'active_support/time'
2
- require 'active_support/core_ext/hash/conversions'
3
- require 'erb'
4
- require 'faraday'
5
- require 'filewatcher'
6
- require 'json'
7
- require 'trmnl/liquid'
8
-
9
- require_relative 'config'
10
- require_relative 'paths'
11
-
12
- module TRMNLP
13
- class Context
14
- attr_reader :config, :paths
15
-
16
- def initialize(root_dir)
17
- @paths = Paths.new(root_dir)
18
- @config = Config.new(paths)
19
- end
20
-
21
- def validate!
22
- raise Error, "not a plugin directory (did not find #{paths.trmnlp_config})" unless paths.valid?
23
- end
24
-
25
- def start_filewatcher
26
- @filewatcher_thread ||= Thread.new do
27
- loop do
28
- begin
29
- Filewatcher.new(config.project.watch_paths).watch do |changes|
30
- config.project.reload!
31
- config.plugin.reload!
32
- new_user_data = user_data
33
-
34
- views = changes.map { |path, _change| File.basename(path, '.liquid') }
35
- views.each do |view|
36
- @view_change_callback.call(view, new_user_data) if @view_change_callback
37
- end
38
- end
39
- rescue => e
40
- puts "Error during live render: #{e}"
41
- end
42
- end
43
- end
44
- end
45
-
46
- def on_view_change(&block)
47
- @view_change_callback = block
48
- end
49
-
50
- def user_data
51
- merged_data = base_trmnl_data
52
-
53
- if config.plugin.static?
54
- merged_data.merge!(config.plugin.static_data)
55
- elsif paths.user_data.exist?
56
- merged_data.merge!(JSON.parse(paths.user_data.read))
57
- end
58
-
59
- # Praise be to ActiveSupport
60
- merged_data.deep_merge!(config.project.user_data_overrides)
61
- end
62
-
63
- def poll_data
64
- return unless config.plugin.polling?
65
-
66
- data = {}
67
-
68
- if config.plugin.polling_urls.empty?
69
- raise Error, "config must specify polling_url or polling_urls"
70
- end
71
-
72
- config.plugin.polling_urls.each.with_index do |url, i|
73
- verb = config.plugin.polling_verb.upcase
74
-
75
- print "#{verb} #{url}... "
76
-
77
- conn = Faraday.new(url:, headers: config.plugin.polling_headers)
78
-
79
- case verb
80
- when 'GET'
81
- response = conn.get
82
- when 'POST'
83
- response = conn.post do |req|
84
- req.body = config.plugin.polling_body
85
- end
86
- end
87
-
88
- puts "received #{response.body.length} bytes (#{response.status} status)"
89
- if response.status == 200
90
- content_type = response.headers['content-type'].split(';').first.strip if response.headers.include?('content-type')
91
- case content_type
92
- when 'application/json', /^application\/.+\+json/
93
- json = wrap_array(JSON.parse(response.body))
94
- when 'text/xml', 'application/xml', /^application\/.+\+xml/
95
- json = wrap_array(Hash.from_xml(response.body))
96
- else
97
- puts "unknown content type received: #{response.headers['content-type']}"
98
- json = {}
99
- end
100
- else
101
- json = {}
102
- puts response.body
103
- end
104
-
105
- if config.plugin.polling_urls.count == 1
106
- # For a single polling URL, we just return the JSON directly
107
- data = json
108
- break
109
- else
110
- # Multiple URLs are namespaced by index
111
- data["IDX_#{i}"] = json
112
- end
113
- end
114
-
115
- write_user_data(data)
116
-
117
- data
118
- rescue StandardError => e
119
- puts "error: #{e.message}"
120
- {}
121
- end
122
-
123
- def put_webhook(payload)
124
- data = wrap_array(JSON.parse(payload))
125
- write_user_data(data)
126
- rescue
127
- puts "webhook error: #{e.message}"
128
- end
129
-
130
- def render_liquid_template(view)
131
- template_path = paths.template(view)
132
- return "Missing template: #{template_path}" unless template_path.exist?
133
-
134
- shared_template_path = paths.shared_template
135
- if shared_template_path.exist?
136
- full_markup = shared_template_path.read + template_path.read
137
- else
138
- full_markup = template_path.read
139
- end
140
-
141
- user_template = Liquid::Template.parse(full_markup, environment: liquid_environment)
142
- user_template.render(user_data)
143
- rescue StandardError => e
144
- e.message
145
- end
146
-
147
- def render_full_page(view, params = {})
148
- template = paths.render_template.read
149
-
150
- ERB.new(template).result(TemplateBinding.new(self, view, params).get_binding do
151
- render_liquid_template(view)
152
- end)
153
- end
154
-
155
- def screen_classes(classes = 'screen')
156
- classes << ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
157
- classes
158
- end
159
-
160
- private
161
-
162
- # bindings must match the `GET /render/{view}.html` route in app.rb
163
- class TemplateBinding
164
- def initialize(context, view, params)
165
- @view = view
166
- @screen_classes = context.screen_classes(params[:screen_classes])
167
-
168
- case view
169
- when 'half_horizontal'
170
- @mashup_classes = 'mashup mashup--1Tx1B'
171
- when 'half_vertical'
172
- @mashup_classes = 'mashup mashup--1Lx1R'
173
- when 'quadrant'
174
- @mashup_classes = 'mashup mashup--2x2'
175
- end
176
- end
177
-
178
- def get_binding = binding
179
- end
180
-
181
- def wrap_array(json)
182
- json.is_a?(Array) ? { data: json } : json
183
- end
184
-
185
- def base_trmnl_data
186
- tz = ActiveSupport::TimeZone.find_tzinfo(config.project.time_zone)
187
- time_zone_iana = tz.name
188
- time_zone_name = ActiveSupport::TimeZone::MAPPING.invert[time_zone_iana] || time_zone_iana
189
- utc_offset = tz.utc_offset
190
-
191
- {
192
- 'trmnl' => {
193
- 'user' => {
194
- 'name' => 'name',
195
- 'first_name' => 'first_name',
196
- 'last_name' => 'last_name',
197
- 'locale' => 'en',
198
- 'time_zone' => time_zone_name,
199
- 'time_zone_iana' => time_zone_iana,
200
- 'utc_offset' => utc_offset,
201
- },
202
- 'device' => {
203
- 'friendly_id' => 'ABC123',
204
- 'percent_charged' => 85.0,
205
- 'wifi_strength' => 90,
206
- 'height' => 480,
207
- 'width' => 800
208
- },
209
- 'system' => {
210
- 'timestamp_utc' => Time.now.utc.to_i,
211
- },
212
- 'plugin_settings' => {
213
- 'instance_name' => 'instance_name',
214
- 'strategy' => config.plugin.strategy,
215
- 'dark_mode' => config.plugin.dark_mode,
216
- 'polling_headers' => config.plugin.polling_headers_encoded,
217
- 'polling_url' => config.plugin.polling_url_text,
218
- 'custom_fields_values' => config.project.custom_fields
219
- }
220
- }
221
- }
222
- end
223
-
224
- def liquid_environment
225
- @liquid_environment ||= TRMNL::Liquid.build_environment do |env|
226
- config.project.user_filters.each do |module_name, relative_path|
227
- require paths.root_dir.join(relative_path)
228
- env.register_filter(Object.const_get(module_name))
229
- end
230
- end
231
- end
232
-
233
- def write_user_data(data)
234
- paths.create_cache_dir
235
- paths.user_data.write(JSON.generate(data))
236
- end
237
- end
238
- end
data/lib/trmnlp/paths.rb DELETED
@@ -1,58 +0,0 @@
1
- require 'xdg'
2
-
3
- module TRMNLP
4
- class Paths
5
- attr_reader :root_dir
6
-
7
- def initialize(root_dir)
8
- @root_dir = Pathname.new(root_dir)
9
- @xdg = XDG.new
10
- end
11
-
12
- # --- trmnlp library ---
13
-
14
- def gem_dir = Pathname.new(__dir__).join('..', '..').expand_path
15
-
16
- def templates_dir = gem_dir.join('templates')
17
-
18
- # --- directories ---
19
-
20
- def src_dir = root_dir.join('src')
21
-
22
- def build_dir = root_dir.join('_build')
23
- def create_build_dir = build_dir.mkpath
24
-
25
- def app_config_dir = xdg.config_home.join('trmnlp')
26
-
27
- def cache_dir = xdg.cache_home.join('trmnl')
28
- def create_cache_dir = cache_dir.mkpath
29
-
30
- def valid? = trmnlp_config.exist?
31
-
32
- # --- files ---
33
-
34
- def trmnlp_config = root_dir.join('.trmnlp.yml')
35
-
36
- def plugin_config = src_dir.join('settings.yml')
37
-
38
- def template(view) = src_dir.join("#{view}.liquid")
39
-
40
- def shared_template = template('shared')
41
-
42
- def app_config = app_config_dir.join('config.yml')
43
-
44
- def user_data = cache_dir.join('data.json')
45
-
46
- def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
47
-
48
- def src_files = src_dir.glob('*').select(&:file?)
49
-
50
- # --- utilities ---
51
-
52
- def expand(path) = Pathname.new(path).expand_path(root_dir)
53
-
54
- private
55
-
56
- attr_reader :xdg
57
- end
58
- end
@@ -1,240 +0,0 @@
1
- require 'mini_magick'
2
- require 'selenium-webdriver'
3
- require 'base64'
4
- require 'thread'
5
- require 'tempfile'
6
- require 'fileutils'
7
- require 'uri'
8
-
9
- module TRMNLP
10
- class ScreenGenerator
11
- # Browser pool management for efficient resource usage
12
- class BrowserPool
13
- def initialize(max_size: 2)
14
- @drivers = []
15
- @available = Queue.new
16
- @mutex = Mutex.new
17
- @max_size = max_size
18
- @shutdown = false
19
-
20
- at_exit { shutdown }
21
- end
22
-
23
- def with_driver
24
- driver = nil
25
-
26
- begin
27
- driver = checkout_driver
28
- yield driver
29
- ensure
30
- checkin_driver(driver) if driver
31
- end
32
- end
33
-
34
- def shutdown
35
- @mutex.synchronize do
36
- return if @shutdown
37
- @shutdown = true
38
-
39
- @drivers.each do |driver|
40
- driver.quit rescue nil
41
- end
42
-
43
- @drivers.clear
44
- end
45
- end
46
-
47
- private
48
-
49
- def checkout_driver
50
- driver = @available.pop(true) rescue nil
51
-
52
- if driver.nil?
53
- @mutex.synchronize do
54
- if @drivers.size < @max_size
55
- driver = create_driver
56
- @drivers << driver
57
- end
58
- end
59
- end
60
-
61
- driver ||= @available.pop
62
-
63
- begin
64
- # Ping the driver
65
- driver.title
66
- driver
67
- rescue
68
- @mutex.synchronize do
69
- @drivers.delete(driver)
70
- driver = create_driver
71
- @drivers << driver
72
- end
73
- driver
74
- end
75
- end
76
-
77
- def checkin_driver(driver)
78
- return if @shutdown
79
- @available.push(driver)
80
- end
81
-
82
- def create_driver
83
- options = Selenium::WebDriver::Firefox::Options.new
84
- options.add_argument('--headless')
85
- options.add_argument('--disable-web-security')
86
-
87
- driver = Selenium::WebDriver.for(:firefox, options: options)
88
- # Set a default window size that will be consistent
89
- driver.manage.window.maximize
90
- driver
91
- end
92
- end
93
-
94
- @@browser_pool = BrowserPool.new
95
-
96
- def initialize(html, opts = {})
97
- self.input = html
98
- self.image = !!opts[:image]
99
-
100
- # Accept optional rendering parameters (width/height/color depth/dark mode)
101
- @requested_width = opts[:width]
102
- @requested_height = opts[:height]
103
- @requested_color_depth = opts[:color_depth]
104
- end
105
-
106
- attr_accessor :input, :output, :image
107
-
108
- def process
109
- convert_to_image
110
- image ? mono_image(output) : mono(output)
111
- output
112
- end
113
-
114
- private
115
-
116
- def convert_to_image
117
- retry_count = 0
118
-
119
- begin
120
- @@browser_pool.with_driver do |driver|
121
- # determine dimensions of toolbars, etc
122
- borders = driver.execute_script(<<~JS)
123
- return {
124
- width: window.outerWidth - window.innerWidth,
125
- height: window.outerHeight - window.innerHeight
126
- }
127
- JS
128
-
129
- window_width = width + borders['width']
130
- window_height = height + borders['height']
131
- driver.manage.window.size = Selenium::WebDriver::Dimension.new(window_width, window_height)
132
-
133
- sleep(0.1)
134
-
135
- prepare_page(driver)
136
-
137
- self.output = Tempfile.new(['screenshot', '.png'])
138
- driver.save_screenshot(output.path)
139
- output.close
140
- end
141
- rescue Selenium::WebDriver::Error::TimeoutError,
142
- Selenium::WebDriver::Error::WebDriverError => e
143
- retry_count += 1
144
- retry if retry_count <= 1
145
- raise
146
- end
147
- end
148
-
149
- def prepare_page(driver)
150
- driver.navigate.to('about:blank')
151
-
152
- driver.execute_script(<<~JS, input)
153
- document.open();
154
- document.write(arguments[0]);
155
- document.close();
156
- JS
157
-
158
- Selenium::WebDriver::Wait.new(timeout: 5).until do
159
- driver.execute_script('return document.readyState') == 'complete'
160
- end
161
-
162
- # Wait for fonts (prevents layout shifts)
163
- driver.execute_script('return document.fonts && document.fonts.ready')
164
-
165
- driver.execute_script(<<~JS)
166
- document.documentElement.style.overflow = 'hidden';
167
- document.body.style.overflow = 'hidden';
168
- JS
169
- end
170
-
171
- def convert_with_mini_magick(img, depth)
172
- tmp = Tempfile.new(['mono', '.png'])
173
- tmp.close
174
-
175
- levels = 2**depth
176
-
177
- MiniMagick::Tool::Convert.new do |m|
178
- m << img.path
179
- m.colorspace 'Gray'
180
- m.dither 'FloydSteinberg'
181
-
182
- yield(m, depth, levels)
183
-
184
- m.depth depth
185
- m.define "png:bit-depth=#{depth}"
186
- m.strip
187
- m << tmp.path
188
- end
189
-
190
- FileUtils.mv(tmp.path, img.path, force: true)
191
- end
192
-
193
- def mono(img)
194
- depth = [[color_depth.to_i, 1].max, 8].min
195
-
196
- convert_with_mini_magick(img, depth) do |m, d, levels|
197
- m.posterize levels
198
- m.colors levels
199
- m.type 'Bilevel' if d == 1
200
- end
201
- end
202
-
203
- def mono_image(img)
204
- depth = [[color_depth.to_i, 1].max, 8].min
205
-
206
- convert_with_mini_magick(img, depth) do |m, d, levels|
207
- if d == 1
208
- # For true 1-bit, use a halftone/remap and bilevel output
209
- m.remap 'pattern:gray50'
210
- m.posterize 2
211
- m.colors 2
212
- m.type 'Bilevel'
213
- else
214
- m.posterize levels
215
- m.colors levels
216
- end
217
- end
218
- end
219
-
220
-
221
- def width
222
- @requested_width || 800
223
- end
224
-
225
- def height
226
- @requested_height || 480
227
- end
228
-
229
- def color_depth
230
- return @requested_color_depth if @requested_color_depth
231
-
232
- # Try to infer color depth from the rendered HTML's screen classes
233
- if input && input.match(/screen--(\d+)bit/)
234
- return $1.to_i
235
- end
236
-
237
- 1
238
- end
239
- end
240
- end
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TRMNLP
4
- VERSION = "0.7.1".freeze
5
- end
data/lib/trmnlp.rb DELETED
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module TRMNLP; end
4
- require 'oj'
5
- Oj.mimic_JSON()
6
- require_relative "trmnlp/config"
7
- require_relative "trmnlp/context"
8
- require_relative "trmnlp/version"
9
-
10
- module TRMNLP
11
- VIEWS = %w{full half_horizontal half_vertical quadrant}
12
-
13
- class Error < StandardError; end
14
- end