trmnl_preview 0.15.0 → 0.17.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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +48 -0
  4. data/db/data/and_x_more.yml +86 -0
  5. data/db/data/locales/cs.yml +339 -0
  6. data/db/data/locales/da.yml +339 -0
  7. data/db/data/locales/de-AT.yml +341 -0
  8. data/db/data/locales/de.yml +341 -0
  9. data/db/data/locales/en-AU.yml +79 -0
  10. data/db/data/locales/en-GB.yml +79 -0
  11. data/db/data/locales/en.yml +341 -0
  12. data/db/data/locales/es-ES.yml +341 -0
  13. data/db/data/locales/fr.yml +339 -0
  14. data/db/data/locales/he.yml +339 -0
  15. data/db/data/locales/hu.yml +339 -0
  16. data/db/data/locales/id.yml +339 -0
  17. data/db/data/locales/is.yml +339 -0
  18. data/db/data/locales/it.yml +341 -0
  19. data/db/data/locales/ja.yml +342 -0
  20. data/db/data/locales/ko.yml +337 -0
  21. data/db/data/locales/lt.yml +340 -0
  22. data/db/data/locales/nl.yml +339 -0
  23. data/db/data/locales/no.yml +4 -0
  24. data/db/data/locales/pl.yml +342 -0
  25. data/db/data/locales/pt-BR.yml +340 -0
  26. data/db/data/locales/raw.yml +256 -0
  27. data/db/data/locales/ro.yml +339 -0
  28. data/db/data/locales/ru.yml +342 -0
  29. data/db/data/locales/sk.yml +339 -0
  30. data/db/data/locales/sv.yml +340 -0
  31. data/db/data/locales/uk.yml +339 -0
  32. data/db/data/locales/zh-CN.yml +338 -0
  33. data/db/data/locales/zh-HK.yml +338 -0
  34. data/lib/trmnlp/app.rb +5 -4
  35. data/lib/trmnlp/async_callback.rb +75 -0
  36. data/lib/trmnlp/browser_pool.rb +8 -8
  37. data/lib/trmnlp/cli.rb +7 -0
  38. data/lib/trmnlp/commands/serve.rb +3 -0
  39. data/lib/trmnlp/commands/test.rb +25 -0
  40. data/lib/trmnlp/config/plugin.rb +52 -12
  41. data/lib/trmnlp/config/project.rb +23 -10
  42. data/lib/trmnlp/config.rb +2 -2
  43. data/lib/trmnlp/context.rb +29 -6
  44. data/lib/trmnlp/errors.rb +1 -0
  45. data/lib/trmnlp/firefox_driver.rb +0 -3
  46. data/lib/trmnlp/framework_version.rb +9 -0
  47. data/lib/trmnlp/image_quantizer.rb +28 -15
  48. data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
  49. data/lib/trmnlp/lint/diagnostic.rb +5 -0
  50. data/lib/trmnlp/lint/source.rb +6 -0
  51. data/lib/trmnlp/lint.rb +3 -1
  52. data/lib/trmnlp/merged_plugin_locals.rb +57 -0
  53. data/lib/trmnlp/outbound_request.rb +99 -0
  54. data/lib/trmnlp/paths.rb +15 -8
  55. data/lib/trmnlp/poller.rb +100 -47
  56. data/lib/trmnlp/qr_code_intrinsic_size.rb +22 -0
  57. data/lib/trmnlp/rails_helpers.rb +41 -0
  58. data/lib/trmnlp/renderer.rb +40 -11
  59. data/lib/trmnlp/screen_generator.rb +14 -2
  60. data/lib/trmnlp/screenshot.rb +50 -11
  61. data/lib/trmnlp/select_field_choices.rb +25 -0
  62. data/lib/trmnlp/testing/browser.rb +87 -0
  63. data/lib/trmnlp/testing/certificate_authority.rb +84 -0
  64. data/lib/trmnlp/testing/device_models.rb +80 -0
  65. data/lib/trmnlp/testing/frozen_clock.rb +86 -0
  66. data/lib/trmnlp/testing/inline_stylesheets.rb +41 -0
  67. data/lib/trmnlp/testing/memory_sampler.rb +44 -0
  68. data/lib/trmnlp/testing/mock_proxy.rb +124 -0
  69. data/lib/trmnlp/testing/mock_table.rb +102 -0
  70. data/lib/trmnlp/testing/node_version.rb +27 -0
  71. data/lib/trmnlp/testing/plugin.rb +91 -0
  72. data/lib/trmnlp/testing/report.html.erb +79 -0
  73. data/lib/trmnlp/testing/report.rb +91 -0
  74. data/lib/trmnlp/testing/rspec.rb +89 -0
  75. data/lib/trmnlp/testing/run.rb +150 -0
  76. data/lib/trmnlp/testing/screen.rb +83 -0
  77. data/lib/trmnlp/testing/snapshot.rb +55 -0
  78. data/lib/trmnlp/transform_backend/subprocess.rb +8 -1
  79. data/lib/trmnlp/transform_pipeline.rb +51 -9
  80. data/lib/trmnlp/transform_state.rb +51 -0
  81. data/lib/trmnlp/user_data_assembler.rb +63 -25
  82. data/lib/trmnlp/version.rb +1 -1
  83. data/lib/trmnlp/watcher.rb +2 -1
  84. data/lib/trmnlp/webhook_payload.rb +74 -0
  85. data/lib/trmnlp/webhook_receiver.rb +78 -0
  86. data/lib/trmnlp.rb +2 -1
  87. data/templates/init/.github/workflows/trmnl.yml +33 -2
  88. data/templates/init/.gitignore +3 -0
  89. data/templates/init/tests/plugin_spec.rb +32 -0
  90. data/trmnl_preview.gemspec +6 -0
  91. data/web/public/index.js +18 -6
  92. data/web/views/index.erb +7 -0
  93. data/web/views/render_html.erb +37 -1
  94. metadata +115 -1
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ # A plugin_merge plugin's data as TRMNL builds it: each referenced plugin's locals under "<keyname>_<id>".
5
+ # Locally each reference names another trmnlp project, listed under merged_plugins in .trmnlp.yml.
6
+ class MergedPluginLocals
7
+ SENSITIVE_KEYNAMES = %w[polling_headers headers polling_body oauth_token_headers oauth_token_params].freeze
8
+
9
+ def initialize(config:, paths:)
10
+ @config = config
11
+ @paths = paths
12
+ end
13
+
14
+ def call
15
+ # A project met again is a cycle and merges as empty.
16
+ root_dirs_being_merged = (Thread.current[:trmnlp_merged_plugin_dirs] ||= [])
17
+ return {} if root_dirs_being_merged.include?(paths.root_dir)
18
+
19
+ root_dirs_being_merged.push(paths.root_dir)
20
+ begin
21
+ merged_locals
22
+ ensure
23
+ root_dirs_being_merged.pop
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :config, :paths
30
+
31
+ def merged_locals
32
+ locals = config.project.merged_plugins.to_h { |reference, dir| [reference, plugin_locals(paths.expand(dir))] }
33
+ instance_selections.each { |keyname, selected| locals[keyname] = locals[selected] if locals.key?(selected) }
34
+ locals
35
+ end
36
+
37
+ def plugin_locals(dir)
38
+ context = Context.new(dir)
39
+ context.validate!
40
+ locals = { 'merge_variables' => context.user_data_assembler.call.except('trmnl') }
41
+ values = shareable_custom_fields_values(context.config.plugin)
42
+ values.empty? ? locals : locals.merge('custom_fields_values' => values)
43
+ end
44
+
45
+ def shareable_custom_fields_values(plugin)
46
+ secret = plugin.custom_field_definitions.select do |field|
47
+ field['field_type'] == 'password' || SENSITIVE_KEYNAMES.include?(field['keyname'])
48
+ end
49
+ plugin.custom_fields_values.except(*secret.map { it['keyname'] })
50
+ end
51
+
52
+ def instance_selections
53
+ fields = config.plugin.custom_field_definitions.select { it['field_type'] == 'plugin_instance_select' }
54
+ config.plugin.custom_fields_values.slice(*fields.map { it['keyname'] }).reject { |_, selected| selected.empty? }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'faraday'
5
+ require 'ipaddr'
6
+ require 'socket'
7
+ require 'timeout'
8
+
9
+ module TRMNLP
10
+ # One polling request as TRMNL makes it; answers [response, nil] or [nil, failure reason].
11
+ class OutboundRequest
12
+ TIMEOUT_SECONDS = 10
13
+ MAX_REDIRECTS = 5
14
+ REDIRECT_STATUSES = (301..308)
15
+ METHOD_PRESERVING_REDIRECTS = [307, 308].freeze
16
+ PRIVATE_ADDRESS_RANGES = [
17
+ '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16',
18
+ '172.16.0.0/12', '192.0.0.0/24', '192.168.0.0/16', '198.18.0.0/15',
19
+ '::1/128', 'fc00::/7', 'fe80::/10'
20
+ ].map { IPAddr.new(it) }.freeze
21
+
22
+ # TRMNL refuses these hosts; a self-hosted server may not, so trmnlp only warns.
23
+ def self.private_address?(url)
24
+ host = URI.parse(url).hostname
25
+ addresses = begin
26
+ [IPAddr.new(host).to_s]
27
+ rescue IPAddr::InvalidAddressError
28
+ Socket.getaddrinfo(host, nil).map { it[3] }.uniq
29
+ end
30
+ addresses.any? { |ip| PRIVATE_ADDRESS_RANGES.any? { it.include?(IPAddr.new(ip)) } }
31
+ rescue URI::Error, SocketError, ArgumentError
32
+ false
33
+ end
34
+
35
+ def initialize(on_private_address: ->(_url) {})
36
+ @on_private_address = on_private_address
37
+ end
38
+
39
+ def call(verb, url, headers:, body:)
40
+ attempts = 0
41
+ begin
42
+ attempts += 1
43
+ response = Timeout.timeout(TIMEOUT_SECONDS * 2, Faraday::TimeoutError) do
44
+ follow_redirects(verb, encoded(url), headers, body)
45
+ end
46
+ reason = failure_reason(response)
47
+ reason ? [nil, reason] : [response, nil]
48
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Faraday::SSLError => e
49
+ timed_out = e.is_a?(Faraday::TimeoutError) || e.wrapped_exception.is_a?(Net::OpenTimeout)
50
+ retry if timed_out && attempts < 2
51
+ [nil, timed_out ? "no response within #{TIMEOUT_SECONDS}s" : "could not connect (#{e.message})"]
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def follow_redirects(verb, url, headers, body)
58
+ hops = 0
59
+ loop do
60
+ @on_private_address.call(url) if self.class.private_address?(url)
61
+ response = perform(verb, url, headers, body)
62
+ location = response.headers['location']
63
+ return response unless REDIRECT_STATUSES.cover?(response.status) && location && hops < MAX_REDIRECTS
64
+
65
+ hops += 1
66
+ next_url = URI.join(url, location).to_s
67
+ unless METHOD_PRESERVING_REDIRECTS.include?(response.status)
68
+ verb = 'GET'
69
+ body = nil
70
+ end
71
+ headers = headers.reject { |name, _| name.to_s.casecmp?('authorization') } if host(url) != host(next_url)
72
+ url = next_url
73
+ end
74
+ end
75
+
76
+ # A GET sends the polling body as its query string, as TRMNL does.
77
+ def perform(verb, url, headers, body)
78
+ connection = Faraday.new(url:, headers:, request: { timeout: TIMEOUT_SECONDS, open_timeout: TIMEOUT_SECONDS })
79
+ return connection.post { |request| request.body = body } if verb == 'POST'
80
+ return connection.get if body.to_s.empty?
81
+
82
+ connection.get("#{url}#{url.include?('?') ? '&' : '?'}#{body}")
83
+ end
84
+
85
+ def failure_reason(response)
86
+ return 'the host replied 429 Too Many Requests' if response.status == 429
87
+
88
+ "the host replied #{response.status}" if response.status >= 500
89
+ end
90
+
91
+ def host(url) = URI.parse(url).host&.downcase
92
+
93
+ # The host stays as typed; only what follows it is percent-encoded.
94
+ def encoded(url)
95
+ scheme_and_host, rest = url.match(%r{\A([a-z][a-z0-9+.-]*://[^/?#]*)(.*)\z}im)&.captures
96
+ scheme_and_host ? scheme_and_host + rest.gsub(/[^\x00-\x7F]/) { CGI.escape(it) } : url
97
+ end
98
+ end
99
+ end
data/lib/trmnlp/paths.rb CHANGED
@@ -7,8 +7,9 @@ module TRMNLP
7
7
  class Paths
8
8
  attr_reader :root_dir
9
9
 
10
- def initialize(root_dir)
11
- @root_dir = Pathname.new(root_dir)
10
+ def initialize(root_dir, cache_dir: nil)
11
+ @root_dir = Pathname.new(root_dir).expand_path
12
+ @cache_dir = cache_dir && Pathname.new(cache_dir)
12
13
  @xdg = XDG.new
13
14
  end
14
15
 
@@ -27,8 +28,7 @@ module TRMNLP
27
28
 
28
29
  def app_config_dir = xdg.config_home.join('trmnlp')
29
30
 
30
- def cache_dir = xdg.cache_home.join('trmnl')
31
- def create_cache_dir = cache_dir.mkpath
31
+ def cache_dir = @cache_dir || xdg.cache_home.join('trmnl')
32
32
 
33
33
  def valid? = trmnlp_config.exist?
34
34
 
@@ -44,11 +44,16 @@ module TRMNLP
44
44
 
45
45
  def app_config = app_config_dir.join('config.yml')
46
46
 
47
- def user_data = cache_dir.join('data.json')
47
+ # Cached files are keyed per project root so working on two plugins does not
48
+ # clobber a shared file.
49
+ def user_data = cache_dir.join('data', "#{project_key}.json")
48
50
 
49
- # OAuth tokens are keyed per project root so working on two OAuth plugins
50
- # does not clobber a shared token file.
51
- def oauth_tokens = cache_dir.join('oauth', "#{Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]}.json")
51
+ def oauth_tokens = cache_dir.join('oauth', "#{project_key}.json")
52
+
53
+ def transform_state = cache_dir.join('state', "#{project_key}.json")
54
+ def fetch_failed_marker = cache_dir.join('state', "#{project_key}.fetch_failed")
55
+
56
+ def transform_output = cache_dir.join('transform_output', "#{project_key}.json")
52
57
 
53
58
  def render_template = Pathname.new(__dir__).join('..', '..', 'web', 'views', 'render_html.erb')
54
59
 
@@ -79,5 +84,7 @@ module TRMNLP
79
84
  private
80
85
 
81
86
  attr_reader :xdg
87
+
88
+ def project_key = Digest::SHA256.hexdigest(root_dir.to_s)[0, 16]
82
89
  end
83
90
  end
data/lib/trmnlp/poller.rb CHANGED
@@ -1,52 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'active_support/core_ext/hash/conversions'
4
- require 'faraday'
4
+ require 'csv'
5
5
  require 'json'
6
6
 
7
+ require_relative 'outbound_request'
7
8
  require_relative 'reporter'
8
9
 
9
10
  module TRMNLP
10
11
  class Poller
11
- def initialize(config:, paths:, oauth_session:, reporter: Reporter.new)
12
+ BYTE_ORDER_MARK = "\xEF\xBB\xBF".b
13
+
14
+ # trmnl_variables answers the trmnl namespace TRMNL renders polling urls, headers and bodies with.
15
+ # rubocop:disable-next Metrics/ParameterLists -- keyword collaborators Context wires once
16
+ def initialize(config:, paths:, oauth_session:, trmnl_variables: -> { {} }, reporter: Reporter.new,
17
+ async_callback: nil, outbound_request: nil)
12
18
  @config = config
19
+ @given_outbound_request = outbound_request
13
20
  @paths = paths
14
21
  @oauth_session = oauth_session
22
+ @trmnl_variables = trmnl_variables
15
23
  @reporter = reporter
24
+ @async_callback = async_callback
16
25
  end
17
26
 
18
27
  def poll_data
28
+ return request_async_data if config.plugin.async_polling?
19
29
  return unless config.plugin.polling?
20
- raise InvalidConfig, 'config must specify polling_url or polling_urls' if config.plugin.polling_urls.empty?
21
30
 
22
- data = aggregate_responses
31
+ missing = config.plugin.missing_required_fields
32
+ return report_warning("Plugin is not configured — fill in: #{missing.join(', ')}") if missing.any?
33
+
34
+ @fetch_failed = false
35
+ # Like TRMNL: no url is nothing to fetch, and the transform runs on {}.
36
+ data = config.plugin.polling_urls.empty? ? {} : aggregate_responses
23
37
  write_user_data(data)
38
+ record_fetch_outcome
24
39
  data
25
40
  # NOTE: trmnlp is a dev tool — a flaky upstream API should surface a warning
26
41
  # and keep the preview server alive, not crash the user's session. We
27
42
  # deliberately swallow here and return {} so the renderer keeps rendering.
28
43
  rescue StandardError => e
29
- reporter.info(reporter.yellow("warning: #{e.message}"))
44
+ report_warning(e.message)
45
+ record_fetch_outcome
30
46
  {}
31
47
  end
32
48
 
33
- def put_webhook(payload)
34
- write_user_data(wrap_array(JSON.parse(payload)))
35
- # NOTE: Same rationale as #poll_data — a bad webhook payload shouldn't take
36
- # down the dev server. Report a warning and keep serving.
37
- rescue StandardError => e
38
- reporter.info(reporter.yellow("webhook warning: #{e.message}"))
39
- end
40
-
41
49
  private
42
50
 
43
- attr_reader :config, :paths, :oauth_session, :reporter
51
+ attr_reader :config, :paths, :oauth_session, :trmnl_variables, :reporter, :async_callback
52
+
53
+ # Like TRMNL: the API answers 202 and posts the data to callback_url later; the last post renders meanwhile.
54
+ def request_async_data
55
+ unless async_callback&.server_url
56
+ return report_warning('async_polling needs `trmnlp serve` to receive the callback')
57
+ end
58
+ return report_warning('Waiting for the async callback; the last data posted renders') if async_callback.awaiting?
59
+ return if config.plugin.polling_urls.empty?
60
+
61
+ report_warning('Async callback not received within 15 minutes') if async_callback.expired?
62
+ response, url = send_async_request(async_callback.start)
63
+ return if response&.status == 202
64
+
65
+ async_callback.cancel
66
+ report_warning("Async polling failed: HTTP #{response.status} from #{without_query(url)}") if response
67
+ end
68
+
69
+ def send_async_request(callback_url)
70
+ variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
71
+ url = config.plugin.polling_urls(extra_variables: variables.merge('callback_url' => callback_url)).first
72
+ headers = config.plugin.polling_headers(extra_variables: variables)
73
+ [fetch_one(url, headers, config.plugin.polling_body(extra_variables: variables)), url]
74
+ end
44
75
 
45
76
  def aggregate_responses
46
77
  fetched = fetch_all
47
78
  # Like the hosted service: a token rejected before its expiry gets one refresh and retry.
48
- fetched = fetch_all if fetched.any? { |_url, response| response.status == 401 } && oauth_session.force_refresh!
49
- responses = fetched.map { |url, response| parse_response(response, url) }
79
+ fetched = fetch_all if fetched.any? { |_url, response| response&.status == 401 } && oauth_session.force_refresh!
80
+ responses = fetched.map { |url, response| response ? parse_response(response, url) : {} }
50
81
  return responses.first if responses.size == 1
51
82
 
52
83
  responses.each_with_index.with_object({}) { |(r, i), h| h["IDX_#{i}"] = r }
@@ -55,62 +86,84 @@ module TRMNLP
55
86
  def fetch_all
56
87
  # Resolve once per attempt (it refreshes as a side effect), then share across
57
88
  # every URL, header, and body render.
58
- oauth_variables = oauth_session.liquid_variables
59
- urls = config.plugin.polling_urls(extra_variables: oauth_variables)
60
- urls.map { |url| [url, fetch_one(url, oauth_variables)] }
89
+ variables = trmnl_variables.call.merge(oauth_session.liquid_variables)
90
+ headers = config.plugin.polling_headers(extra_variables: variables)
91
+ body = config.plugin.polling_body(extra_variables: variables)
92
+ config.plugin.polling_urls(extra_variables: variables).map { |url| [url, fetch_one(url, headers, body)] }
61
93
  end
62
94
 
63
- def fetch_one(url, oauth_variables)
95
+ def fetch_one(url, headers, body)
64
96
  verb = config.plugin.polling_verb.upcase
65
- response = perform_request(url, verb, oauth_variables)
66
- reporter.info("#{verb} #{url} — received #{response.body.length} bytes (#{response.status} status)")
97
+ response, failure = outbound_request.call(verb, url, headers:, body:)
98
+ return report_warning("Unable to fetch data from url: #{without_query(url)} — #{failure}") if failure
99
+
100
+ size = response.body.length
101
+ reporter.info("#{verb} #{without_query(url)} — received #{size} bytes (#{response.status} status)")
67
102
  response
68
103
  end
69
104
 
70
- def perform_request(url, verb, oauth_variables)
71
- conn = Faraday.new(url:, headers: config.plugin.polling_headers(extra_variables: oauth_variables))
72
- if verb == 'POST'
73
- conn.post { |req| req.body = config.plugin.polling_body(extra_variables: oauth_variables) }
74
- else
75
- conn.get
76
- end
105
+ def outbound_request
106
+ @given_outbound_request || OutboundRequest.new(on_private_address: lambda { |url|
107
+ report_warning("TRMNL refuses #{without_query(url)}: the url resolves to a private address")
108
+ })
77
109
  end
78
110
 
79
- # Like the hosted service: an error status is reported, but its body still reaches the data.
111
+ # Like the hosted service: a client error is reported, but its body still reaches the data.
80
112
  def parse_response(response, url)
81
113
  success = (200..299).cover?(response.status)
82
- reporter.info(reporter.yellow("warning: HTTP #{response.status} from #{url}")) unless success
83
- parse_body(response.body, response.headers['content-type'])
114
+ report_warning("HTTP #{response.status} from #{url}") unless success
115
+ parse_body(without_byte_order_mark(response.body.to_s), response.headers['content-type'])
116
+ rescue JSON::ParserError, CSV::MalformedCSVError => e
117
+ report_warning("Malformed #{e.is_a?(CSV::MalformedCSVError) ? 'CSV' : 'JSON'} from url: #{url}")
118
+ {}
84
119
  end
85
120
 
121
+ # Unlisted and missing content types are read as JSON, as TRMNL does.
86
122
  def parse_body(body, content_type_header)
87
- content_type = content_type_header&.split(';')&.first&.strip
88
- case content_type
89
- when 'application/json', %r{^application/.+\+json} then wrap_array(JSON.parse(body))
123
+ case content_type_header&.split(';')&.first&.strip
90
124
  when 'text/xml', 'application/xml', %r{^application/.+\+xml} then wrap_array(Hash.from_xml(body))
91
- when 'text/html', 'text/plain' then sniff_json(body) || { 'data' => body }
92
- else log_unknown_type(content_type_header)
125
+ when 'text/csv' then wrap_array(CSV.parse(body))
126
+ when 'text/plain', 'text/html', 'text/markdown', 'text/x-markdown' then parse_textual_body(body)
127
+ else wrap_array(JSON.parse(body))
93
128
  end
94
129
  end
95
130
 
96
- def log_unknown_type(header)
97
- reporter.info("unknown content type received: #{header}")
98
- {}
131
+ # Some endpoints serve JSON under a textual content type; anything else passes through under `data`.
132
+ def parse_textual_body(body)
133
+ parsed = JSON.parse(body)
134
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? wrap_array(parsed) : { 'data' => body }
135
+ rescue JSON::ParserError
136
+ { 'data' => body }
99
137
  end
100
138
 
101
- def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
139
+ def without_byte_order_mark(body)
140
+ return body unless body.byteslice(0, BYTE_ORDER_MARK.bytesize)&.b == BYTE_ORDER_MARK
102
141
 
103
- def sniff_json(body)
104
- trimmed = body.to_s.strip
105
- return nil unless trimmed.start_with?('{', '[')
142
+ body.byteslice(BYTE_ORDER_MARK.bytesize..)
143
+ end
106
144
 
107
- wrap_array(JSON.parse(trimmed))
108
- rescue JSON::ParserError
145
+ # A polling url often carries its api key in the query string.
146
+ def without_query(url) = url.split('?').first
147
+
148
+ def wrap_array(json) = json.is_a?(Array) ? { data: json } : json
149
+
150
+ def report_warning(message)
151
+ @fetch_failed = true
152
+ reporter.info(reporter.yellow("warning: #{message}"))
109
153
  nil
110
154
  end
111
155
 
156
+ # The transform runs later, from the cached data, so whether this poll failed is kept beside it.
157
+ def record_fetch_outcome
158
+ marker = paths.fetch_failed_marker
159
+ return marker.delete if !@fetch_failed && marker.exist?
160
+
161
+ marker.dirname.mkpath
162
+ marker.write('') if @fetch_failed
163
+ end
164
+
112
165
  def write_user_data(data)
113
- paths.create_cache_dir
166
+ paths.user_data.dirname.mkpath
114
167
  paths.user_data.write(JSON.generate(data))
115
168
  end
116
169
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'trmnl/liquid'
4
+
5
+ module TRMNLP
6
+ # Mirrors core's config/initializers/trmnl_liquid_qr_code_intrinsic_size.rb: a view box alone
7
+ # gives the code no size of its own, so it renders at zero width in a shrink-to-fit container.
8
+ module QrCodeIntrinsicSize
9
+ def qr_code(data, size = 11, level = '', view = 'responsive')
10
+ svg = super
11
+ root_element = svg[/<svg[^>]*>/].to_s
12
+ return svg if root_element.include?('width=')
13
+
14
+ side_length = root_element[/viewBox="0 0 (\d+)/, 1]
15
+ return svg unless side_length
16
+
17
+ svg.sub('<svg ', %(<svg width="#{side_length}" height="#{side_length}" style="max-width:100%;height:auto" ))
18
+ end
19
+ end
20
+ end
21
+
22
+ TRMNL::Liquid::Filters.prepend(TRMNLP::QrCodeIntrinsicSize)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'i18n'
5
+ require 'active_support'
6
+ require 'active_support/number_helper'
7
+ require 'active_support/core_ext/string/inflections'
8
+
9
+ module TRMNL
10
+ module Liquid
11
+ # The ActionView helpers TRMNL::Liquid.load(:rails) gives the filters, without Rails.
12
+ module RailsHelpers
13
+ HTML_ESCAPED_OPTIONS = %i[unit separator delimiter].freeze
14
+
15
+ module_function
16
+
17
+ def number_to_currency(number, **options)
18
+ number && ActiveSupport::NumberHelper.number_to_currency(number, html_escape_options(options))
19
+ end
20
+
21
+ def number_with_delimiter(number, **options)
22
+ number && ActiveSupport::NumberHelper.number_to_delimited(number, html_escape_options(options))
23
+ end
24
+
25
+ def pluralize(count, singular, plural: nil, locale: ::I18n.locale)
26
+ word = count == 1 || count.to_s.match?(/^1(\.0+)?$/) ? singular : plural || singular.pluralize(locale)
27
+ "#{count || 0} #{word}"
28
+ end
29
+
30
+ def html_escape_options(options)
31
+ options.to_h { |key, value| [key, HTML_ESCAPED_OPTIONS.include?(key) && value ? ERB::Util.html_escape(value) : value] }
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ locale_files = Dir[File.expand_path('../../db/data/locales/*.yml', __dir__)]
38
+ I18n.load_path += locale_files
39
+ I18n.available_locales = locale_files.map { |path| File.basename(path, '.yml') }
40
+ I18n.backend.class.include I18n::Backend::Fallbacks
41
+ I18n.fallbacks = I18n::Locale::Fallbacks.new(:en)
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'erb'
4
+ require 'json'
5
+ require 'yaml'
4
6
  require 'trmnl/liquid'
5
7
 
6
8
  require_relative 'screen'
7
9
 
8
10
  module TRMNLP
9
11
  class Renderer
12
+ # TRMNL refuses to render merge variables past this size.
13
+ MAX_MERGE_VARIABLES_KB = 100
14
+ AND_X_MORE = YAML.load_file(File.expand_path('../../db/data/and_x_more.yml', __dir__)).freeze
15
+
10
16
  def initialize(config:, paths:, user_data_assembler:)
11
17
  @config = config
12
18
  @paths = paths
@@ -23,10 +29,27 @@ module TRMNLP
23
29
 
24
30
  def framework = config.plugin.framework_version
25
31
 
26
- def screen_classes(classes = 'screen')
27
- classes ||= 'screen' # an explicit nil (omitted screen_classes param) still needs a base
28
- classes += ' screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
29
- classes
32
+ # The data the last page was rendered from, for a test to read back.
33
+ attr_reader :last_data
34
+
35
+ def screen_classes(classes = 'screen', theme: nil)
36
+ classes = (classes || 'screen').split # an explicit nil (omitted screen_classes param) still needs a base
37
+ # The picker marks every render 1x, which sets the dither ratio; TRMNL's device render has no such class.
38
+ classes.delete('screen--1x')
39
+ classes << 'screen--no-bleed' if config.plugin.no_screen_padding == 'yes'
40
+ classes << 'screen--dark-mode' if config.plugin.dark_mode == 'yes'
41
+ # Framework 1.x inverts only on the bare dark-mode class.
42
+ classes << 'dark-mode' if classes.include?('screen--dark-mode')
43
+ # A new device renders in the TRMNL font unless its owner picks another.
44
+ classes << 'screen--fonts-trmnl' if classes.none? { it.start_with?('screen--fonts-') }
45
+ classes << "screen--theme-#{theme}" if FrameworkVersion::THEMES.key?(theme)
46
+ classes.uniq.join(' ')
47
+ end
48
+
49
+ # TRMNL's window.I18n.andXMore phrase for the user's locale, as [prefix, suffix].
50
+ def and_x_more
51
+ locale = config.project.user_data_overrides.dig('trmnl', 'user', 'locale').to_s
52
+ AND_X_MORE[locale] || AND_X_MORE[locale.split('-').first] || AND_X_MORE['en']
30
53
  end
31
54
 
32
55
  private
@@ -46,8 +69,15 @@ module TRMNLP
46
69
  end
47
70
 
48
71
  def parse_and_render(template_path, device:)
49
- Liquid::Template.parse(full_markup(template_path), environment: liquid_environment)
50
- .render(user_data_assembler.call(device:))
72
+ data = @last_data = user_data_assembler.call(device:)
73
+ size = JSON.generate(data.except('trmnl')).bytesize
74
+ if size > MAX_MERGE_VARIABLES_KB * 1024
75
+ raise RenderError, "Large payload received (#{size} bytes), should be less than #{MAX_MERGE_VARIABLES_KB}kb."
76
+ end
77
+
78
+ Liquid::Template.parse(full_markup(template_path), environment: liquid_environment).render(data)
79
+ rescue RenderError
80
+ raise
51
81
  rescue StandardError => e
52
82
  raise RenderError, e.message
53
83
  end
@@ -65,10 +95,7 @@ module TRMNLP
65
95
 
66
96
  def liquid_environment
67
97
  @liquid_environment ||= TRMNL::Liquid.new do |env|
68
- config.project.user_filters.each do |module_name, relative_path|
69
- require paths.root_dir.join(relative_path)
70
- env.register_filter(Object.const_get(module_name))
71
- end
98
+ config.project.user_filter_modules.each { |filter_module| env.register_filter(filter_module) }
72
99
  end
73
100
  end
74
101
 
@@ -76,8 +103,10 @@ module TRMNLP
76
103
  class TemplateBinding
77
104
  def initialize(renderer, view, params)
78
105
  @view = view
79
- @screen_classes = renderer.screen_classes(params[:screen_classes])
106
+ @screen_classes = renderer.screen_classes(params[:screen_classes], theme: params[:theme])
80
107
  @framework = renderer.framework
108
+ @theme_css_url = @framework.theme_css_url(params[:theme])
109
+ @and_x_more = renderer.and_x_more
81
110
  @mashup_classes = Screen.find(view)&.mashup_classes
82
111
  end
83
112
 
@@ -5,6 +5,17 @@ require_relative 'image_quantizer'
5
5
 
6
6
  module TRMNLP
7
7
  class ScreenGenerator
8
+ # TRMNL's own copies (Converter::Preprocessor); the CDN refuses a page with no Referer.
9
+ CHART_LIBRARIES = {
10
+ 'https://code.highcharts.com/highcharts.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts.js',
11
+ 'https://code.highcharts.com/12.3.0/highcharts.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts.js',
12
+ 'https://cdn.jsdelivr.net/npm/chartkick@5.0.1/dist/chartkick.min.js' => 'https://trmnl.com/js/chartkick/5.0.1/chartkick.min.js',
13
+ 'https://code.highcharts.com/highcharts-more.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts-more.js',
14
+ 'https://code.highcharts.com/12.3.0/highcharts-more.js' => 'https://trmnl.com/js/highcharts/12.3.0/highcharts-more.js',
15
+ 'https://code.highcharts.com/modules/pattern-fill.js' => 'https://trmnl.com/js/highcharts/12.3.0/pattern-fill.js',
16
+ 'https://code.highcharts.com/12.3.0/modules/pattern-fill.js' => 'https://trmnl.com/js/highcharts/12.3.0/pattern-fill.js'
17
+ }.freeze
18
+
8
19
  def initialize(html, opts = {})
9
20
  @input = html
10
21
  @screenshot = opts[:screenshot]
@@ -14,8 +25,9 @@ module TRMNLP
14
25
  end
15
26
 
16
27
  def process
17
- output = @screenshot.call(html: @input, width:, height:)
18
- ImageQuantizer.new(depth: color_depth).call(output.path)
28
+ html = CHART_LIBRARIES.reduce(@input) { |page, (from, to)| page.gsub(from, to) }
29
+ output = @screenshot.call(html:, width:, height:)
30
+ ImageQuantizer.new(depth: color_depth, dither: @input.include?('image-dither')).call(output.path)
19
31
  output
20
32
  end
21
33