trmnl_preview 0.14.2 → 0.16.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +31 -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/cli.rb +2 -0
  37. data/lib/trmnlp/commands/lint.rb +18 -3
  38. data/lib/trmnlp/commands/serve.rb +3 -0
  39. data/lib/trmnlp/config/plugin.rb +52 -12
  40. data/lib/trmnlp/config/project.rb +14 -4
  41. data/lib/trmnlp/context.rb +17 -2
  42. data/lib/trmnlp/firefox_driver.rb +0 -3
  43. data/lib/trmnlp/framework_version.rb +9 -0
  44. data/lib/trmnlp/image_quantizer.rb +28 -15
  45. data/lib/trmnlp/lint/checks/image_links_reachable.rb +6 -1
  46. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +12 -5
  47. data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
  48. data/lib/trmnlp/lint/diagnostic.rb +97 -0
  49. data/lib/trmnlp/lint/source.rb +53 -0
  50. data/lib/trmnlp/lint.rb +3 -1
  51. data/lib/trmnlp/merged_plugin_locals.rb +57 -0
  52. data/lib/trmnlp/outbound_request.rb +99 -0
  53. data/lib/trmnlp/paths.rb +11 -6
  54. data/lib/trmnlp/poller.rb +83 -44
  55. data/lib/trmnlp/rails_helpers.rb +41 -0
  56. data/lib/trmnlp/renderer.rb +37 -11
  57. data/lib/trmnlp/screen_generator.rb +14 -2
  58. data/lib/trmnlp/screenshot.rb +29 -4
  59. data/lib/trmnlp/select_field_choices.rb +25 -0
  60. data/lib/trmnlp/transform_pipeline.rb +47 -6
  61. data/lib/trmnlp/transform_state.rb +48 -0
  62. data/lib/trmnlp/user_data_assembler.rb +63 -25
  63. data/lib/trmnlp/version.rb +1 -1
  64. data/lib/trmnlp/watcher.rb +2 -1
  65. data/lib/trmnlp/webhook_payload.rb +74 -0
  66. data/lib/trmnlp/webhook_receiver.rb +78 -0
  67. data/lib/trmnlp.rb +1 -1
  68. data/trmnl_preview.gemspec +3 -0
  69. data/web/public/index.js +18 -6
  70. data/web/views/index.erb +7 -0
  71. data/web/views/render_html.erb +36 -0
  72. metadata +83 -1
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'reporter'
6
+ require_relative 'webhook_payload'
7
+
8
+ module TRMNLP
9
+ # TRMNL's async_polling handshake: each poll gives the API a callback url with a new version,
10
+ # and only a post to the latest version, within 15 minutes, is stored. Answers TRMNL's status and body.
11
+ class AsyncCallback
12
+ EXPIRY_SECONDS = 15 * 60
13
+
14
+ attr_accessor :server_url
15
+
16
+ def initialize(config:, paths:, reporter: Reporter.new)
17
+ @config = config
18
+ @paths = paths
19
+ @reporter = reporter
20
+ @version = 0
21
+ end
22
+
23
+ def start
24
+ @version += 1
25
+ @expires_at = Time.now + EXPIRY_SECONDS
26
+ "#{server_url}/callback?v=#{@version}"
27
+ end
28
+
29
+ def cancel = @expires_at = nil
30
+
31
+ def awaiting? = !@expires_at.nil? && !expired?
32
+
33
+ def expired? = !@expires_at.nil? && Time.now > @expires_at
34
+
35
+ def call(body, version)
36
+ refusal = refusal_for(version)
37
+ return refuse(410, refusal) if refusal
38
+
39
+ parsed = JSON.parse(body)
40
+ payload = WebhookPayload.new(parsed.is_a?(Hash) ? parsed.slice('merge_variables') : parsed)
41
+ return refuse(422, payload.rejection) if payload.rejection
42
+
43
+ store(payload)
44
+ [200, { status: 'ok' }]
45
+ rescue JSON::ParserError => e
46
+ refuse(400, e.message)
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :config, :paths, :reporter
52
+
53
+ def refusal_for(version)
54
+ return 'Strategy is not async_polling' unless config.plugin.async_polling?
55
+ return 'No pending async request' unless @expires_at
56
+ return 'Version mismatch' unless version.to_i == @version
57
+
58
+ 'Async request expired' if expired?
59
+ end
60
+
61
+ def store(payload)
62
+ cancel
63
+ payload.size_warning(payload.merge_variables)&.then { report_warning(it) }
64
+ paths.user_data.dirname.mkpath
65
+ paths.user_data.write(JSON.generate(payload.merge_variables))
66
+ end
67
+
68
+ def refuse(status, message)
69
+ report_warning(message)
70
+ [status, { message: }]
71
+ end
72
+
73
+ def report_warning(message) = reporter.info(reporter.yellow("callback warning: #{message}"))
74
+ end
75
+ end
data/lib/trmnlp/cli.rb CHANGED
@@ -72,6 +72,8 @@ module TRMNLP
72
72
  end
73
73
 
74
74
  desc 'lint', 'Check plugin code against TRMNL best practices'
75
+ method_option :format, type: :string, default: 'text', enum: %w[text json],
76
+ desc: 'Report format (text or json)'
75
77
  def lint
76
78
  # Exit non-zero when issues are found so CI pipelines can gate on it.
77
79
  exit(1) unless Commands::Lint.run(options)
@@ -2,12 +2,18 @@
2
2
 
3
3
  require_relative 'base'
4
4
  require_relative '../lint'
5
+ require_relative '../lint/diagnostic'
6
+ require 'json'
5
7
 
6
8
  module TRMNLP
7
9
  module Commands
8
10
  # Runs the markup best-practice checks and reports their findings.
9
11
  class Lint < Base
10
- Options = Data.define(:dir, :quiet)
12
+ Options = Data.define(:dir, :quiet, :format) do
13
+ def initialize(dir:, quiet:, format: 'text')
14
+ super(dir:, quiet:, format: format || 'text')
15
+ end
16
+ end
11
17
 
12
18
  def call
13
19
  context.validate!
@@ -18,7 +24,10 @@ module TRMNLP
18
24
  private
19
25
 
20
26
  def issues
21
- @issues ||= TRMNLP::Lint::CHECKS.flat_map { |check| check.new(source).issues }.uniq
27
+ @issues ||= TRMNLP::Lint::CHECKS.flat_map do |type|
28
+ check = type.new(source)
29
+ check.issues.map { |finding| TRMNLP::Lint::Diagnostic.new(check, source, finding).to_h }
30
+ end.uniq
22
31
  end
23
32
 
24
33
  def source
@@ -26,6 +35,8 @@ module TRMNLP
26
35
  end
27
36
 
28
37
  def report
38
+ return reporter.info(JSON.generate(version: 1, passed: issues.empty?, issues:)) if options.format == 'json'
39
+
29
40
  return reporter.info(reporter.green('✓ All checks passed!')) if issues.empty?
30
41
 
31
42
  reporter.info(reporter.yellow("#{issues.size} issue#{'s' if issues.size > 1} found:\n"))
@@ -34,7 +45,11 @@ module TRMNLP
34
45
  end
35
46
 
36
47
  def report_issue(issue, index)
37
- reporter.info(" #{index + 1}. #{issue[:message]}")
48
+ reporter.info(" #{index + 1}. [#{issue[:rule_id]}] #{issue[:message]}")
49
+ issue[:locations].each do |location|
50
+ reporter.info(" #{location[:path]}:#{location[:line]}:#{location[:column]}")
51
+ reporter.info(" #{location[:snippet]}") unless location[:snippet].to_s.empty?
52
+ end
38
53
  reporter.info(" Learn more: #{issue[:learn_more]}") if issue[:learn_more]
39
54
  end
40
55
  end
@@ -23,6 +23,7 @@ module TRMNLP
23
23
  App.set(:browser_pool, BrowserPool.new(driver_factory: FirefoxDriver.method(:build)))
24
24
  App.set(:bind, options.bind)
25
25
  App.set(:port, options.port)
26
+ context.async_callback.server_url = "http://#{callback_host}:#{options.port}"
26
27
  # Each live-reload SSE connection holds a Puma thread for the tab's
27
28
  # lifetime; the default 0:5 pool runs out fast with multiple tabs open.
28
29
  App.set(:server_settings, Threads: '1:16')
@@ -34,6 +35,8 @@ module TRMNLP
34
35
 
35
36
  private
36
37
 
38
+ def callback_host = options.bind == '0.0.0.0' ? 'localhost' : options.bind
39
+
37
40
  # Codespaces forwards the dev port through a proxy whose Host header Sinatra rejects.
38
41
  def codespaces? = ENV['CODESPACES'] == 'true'
39
42
 
@@ -6,6 +6,7 @@ require 'yaml'
6
6
 
7
7
  require_relative '../errors'
8
8
  require_relative '../framework_version'
9
+ require_relative '../select_field_choices'
9
10
 
10
11
  module TRMNLP
11
12
  class Config
@@ -31,19 +32,24 @@ module TRMNLP
31
32
  def polling? = strategy == 'polling'
32
33
  def webhook? = strategy == 'webhook'
33
34
  def static? = strategy == 'static'
35
+ def async_polling? = strategy == 'async_polling'
36
+ def plugin_merge? = strategy == 'plugin_merge'
34
37
 
35
- def polling_urls(extra_variables: {})
36
- # allow project-level config to override
37
- urls = project_config.user_data_overrides.dig('trmnl', 'plugin_settings',
38
- 'polling_url') || @config['polling_url']
38
+ # TRMNL transforms a webhook post as it arrives, and never transforms async_polling or plugin_merge data.
39
+ def transforms_on_render? = !(webhook? || async_polling? || plugin_merge?)
39
40
 
40
- return [] if urls.nil?
41
+ # One url per line, as TRMNL reads them: CR or LF, squished, blank lines dropped.
42
+ def polling_urls(extra_variables: {})
43
+ return [] if polling_url_text.nil?
41
44
 
42
- with_custom_fields(urls, extra_variables:).strip.split("\n")
45
+ with_custom_fields(polling_url_text, extra_variables:).tr("\r", "\n").split("\n")
46
+ .map { it.gsub(/\s+/, ' ').strip }.reject(&:empty?)
43
47
  end
44
48
 
45
- # for {{ trmnl }}
46
- def polling_url_text = polling_urls.join("\r\n")
49
+ # Unrendered, as TRMNL exposes it to markup. A project-level override wins.
50
+ def polling_url_text
51
+ project_config.user_data_overrides.dig('trmnl', 'plugin_settings', 'polling_url') || @config['polling_url']
52
+ end
47
53
 
48
54
  def polling_verb = @config['polling_verb'] || 'GET'
49
55
 
@@ -55,9 +61,6 @@ module TRMNLP
55
61
  string_to_hash(rendered)
56
62
  end
57
63
 
58
- # for {{ trmnl }}
59
- def polling_headers_encoded = polling_headers.map { |k, v| "#{k}=#{v}" }.join('&')
60
-
61
64
  def polling_body(extra_variables: {}) = with_custom_fields(@config['polling_body'] || '', extra_variables:)
62
65
 
63
66
  def dark_mode = @config['dark_mode'] || 'no'
@@ -101,6 +104,23 @@ module TRMNLP
101
104
  # Config::Project#custom_fields, which holds the field *values*.
102
105
  def custom_field_definitions = @config['custom_fields'] || []
103
106
 
107
+ # What TRMNL renders with: a select's label saved as its value, a blank value as its default.
108
+ def custom_fields_values
109
+ values = project_config.custom_fields_with_env.to_h { |keyname, value| [keyname, stored_value(keyname, value)] }
110
+ custom_field_defaults.merge(values) { |_, default, value| value.to_s.strip.empty? ? default : value }
111
+ end
112
+
113
+ # Fields a polling url needs that are still blank. TRMNL skips polling until they are filled in.
114
+ def missing_required_fields
115
+ url = polling_url_text.to_s
116
+ return [] if url.strip.empty?
117
+
118
+ values = custom_fields_values
119
+ custom_field_definitions.reject { it['optional'] || blank_is_a_valid_answer?(it) }
120
+ .map { it['keyname'] }
121
+ .select { interpolated_without_default?(url, it) && values[it].to_s.strip.empty? }
122
+ end
123
+
104
124
  # The raw parsed settings.yml hash. Most callers want the semantic
105
125
  # readers above; `trmnlp lint` needs the uninterpreted values because
106
126
  # it searches the raw {{ }} templates the semantic readers render away.
@@ -111,7 +131,27 @@ module TRMNLP
111
131
  attr_reader :paths, :project_config
112
132
 
113
133
  def with_custom_fields(value, extra_variables: {})
114
- project_config.with_custom_fields(value, extra_variables:)
134
+ project_config.with_custom_fields(value, values: custom_fields_values, extra_variables:)
135
+ end
136
+
137
+ def custom_field_defaults
138
+ custom_field_definitions.to_h { [it['keyname'], it['default']] }.reject { |_, default| default.to_s.empty? }
139
+ end
140
+
141
+ def stored_value(keyname, value)
142
+ field = custom_field_definitions.find { it['keyname'] == keyname && it['field_type'] == 'select' }
143
+ field ? SelectFieldChoices.new(field).stored_value(value) : value
144
+ end
145
+
146
+ def interpolated_without_default?(url, keyname)
147
+ url.scan(/\{\{-?\s*(?:trmnl\.plugin_settings\.custom_fields_values\.)?#{Regexp.escape(keyname)}\b([^}]*)/)
148
+ .any? { |(filters)| !filters.match?(/\|\s*default\s*:/) }
149
+ end
150
+
151
+ # A select's empty option is an answer, not a gap.
152
+ def blank_is_a_valid_answer?(field)
153
+ field['field_type'] == 'boolean' || !field['default'].to_s.strip.empty? || field['conditional_validation'] ||
154
+ Array(field['options']).any? { it.is_a?(Hash) ? it.values.first.to_s.strip.empty? : it.to_s.strip.empty? }
115
155
  end
116
156
 
117
157
  # Mirrors the hosted service's Plugins::Helpers::Transformer#string_to_hash.
@@ -26,7 +26,12 @@ module TRMNLP
26
26
  raise InvalidConfig, ".trmnlp.yml is not valid YAML: #{e.message}"
27
27
  end
28
28
 
29
- def user_filters = @config['custom_filters'] || []
29
+ def user_filter_modules
30
+ (@config['custom_filters'] || []).map do |module_name, relative_path|
31
+ require paths.root_dir.join(relative_path)
32
+ Object.const_get(module_name)
33
+ end
34
+ end
30
35
 
31
36
  def live_render? = !watch_paths.empty?
32
37
 
@@ -36,13 +41,18 @@ module TRMNLP
36
41
 
37
42
  def custom_fields = @config.fetch('custom_fields', {}).transform_values { |v| stringify_field_value(v) }
38
43
 
44
+ # Values with {{ env.X }} filled in, standing in for what a user types into TRMNL's form.
45
+ def custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
46
+
39
47
  def user_data_overrides = @config['variables'] || {}
40
48
 
49
+ # plugin_merge references ("<keyname>_<id>") mapped to the trmnlp project directories that stand in for them.
50
+ def merged_plugins = @config['merged_plugins'] || {}
51
+
41
52
  # extra_variables lets the poller inject live values (e.g.
42
53
  # oauth_access_token) into the same custom-field render.
43
- def with_custom_fields(value, extra_variables: {})
44
- custom_fields_with_env = custom_fields.transform_values { |v| with_env(v) }
45
- parse_liquid(value).render(custom_fields_with_env.merge(extra_variables))
54
+ def with_custom_fields(value, values:, extra_variables: {})
55
+ parse_liquid(value).render(values.merge(extra_variables))
46
56
  end
47
57
 
48
58
  def time_zone = @config['time_zone'] || 'UTC'
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'async_callback'
3
4
  require_relative 'config'
4
5
  require_relative 'paths'
5
6
  require_relative 'poller'
@@ -8,6 +9,7 @@ require_relative 'reporter'
8
9
  require_relative 'transform_pipeline'
9
10
  require_relative 'user_data_assembler'
10
11
  require_relative 'watcher'
12
+ require_relative 'webhook_receiver'
11
13
 
12
14
  module TRMNLP
13
15
  class Context
@@ -22,7 +24,12 @@ module TRMNLP
22
24
  # Context is the composition root: it wires and memoizes the runtime
23
25
  # object graph. Callers take the collaborator they need and talk to it
24
26
  # directly — Context does not forward methods on their behalf.
25
- def poller = @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:)
27
+ def poller
28
+ @poller ||= Poller.new(config:, paths:, oauth_session:, reporter:, async_callback:,
29
+ trmnl_variables: -> { user_data_assembler.polling_variables })
30
+ end
31
+
32
+ def async_callback = @async_callback ||= AsyncCallback.new(config:, paths:, reporter:)
26
33
 
27
34
  def oauth_session
28
35
  @oauth_session ||= begin
@@ -34,10 +41,18 @@ module TRMNLP
34
41
  end
35
42
 
36
43
  def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
37
- def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
44
+
45
+ def user_data_assembler
46
+ @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:, oauth_session:)
47
+ end
48
+
38
49
  def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
39
50
  def watcher = @watcher ||= Watcher.new(config:, user_data_assembler:, transform_pipeline:, reporter:)
40
51
 
52
+ def webhook_receiver
53
+ @webhook_receiver ||= WebhookReceiver.new(paths:, transform_pipeline:, user_data_assembler:, reporter:)
54
+ end
55
+
41
56
  def validate!
42
57
  raise NotAPlugin, "not a plugin directory (did not find #{paths.trmnlp_config})" unless paths.valid?
43
58
  end
@@ -18,9 +18,6 @@ module TRMNLP
18
18
  Selenium::WebDriver::Firefox::Options.new.tap do |opts|
19
19
  opts.add_argument('--headless')
20
20
  opts.add_argument('--disable-web-security')
21
- # Subpixel antialiasing colour-fringes badly when quantized to 1-bit e-ink.
22
- opts.add_preference('gfx.text.disable-aa', true)
23
- opts.add_preference('gfx.text.subpixel-position.force-disabled', true)
24
21
  end
25
22
  end
26
23
  end
@@ -12,6 +12,8 @@ module TRMNLP
12
12
  include Comparable
13
13
 
14
14
  DEFAULT_ASSET_HOST = 'https://trmnl.com'
15
+ # Framework::Themes::NAMES_BY_ID in trmnl-framework.
16
+ THEMES = { 'black-and-yellow' => 'Black and Yellow', 'dark' => 'Dark', 'white-and-red' => 'White and Red' }.freeze
15
17
  DATA_PATH = File.expand_path('../../db/data/framework_versions.yml', __dir__)
16
18
  # DATA_PATH holds a copy of this file, refreshed by `rake framework:sync`.
17
19
  MANIFEST_URL = 'https://raw.githubusercontent.com/usetrmnl/trmnl-framework/main/db/data/framework_versions.yml'
@@ -80,6 +82,13 @@ module TRMNLP
80
82
 
81
83
  def js_url = "#{@asset_host}/js/#{number}/plugins.js"
82
84
 
85
+ # Versions before 3.2.0 publish no themes, so the theme class stays inert there, as on TRMNL.
86
+ def theme_css_url(theme)
87
+ return unless THEMES.include?(theme) && Gem::Version.new(number) >= Gem::Version.new('3.2.0')
88
+
89
+ "#{@asset_host}/css/#{number}/themes/#{theme}-theme.css"
90
+ end
91
+
83
92
  def ==(other) = other.is_a?(self.class) && number == other.number
84
93
 
85
94
  def <=>(other)
@@ -5,12 +5,14 @@ require 'tempfile'
5
5
  require 'fileutils'
6
6
 
7
7
  module TRMNLP
8
+ # TRMNL's Converter::MagickWrapper steps: a page dithers only when it asks for `image-dither`.
8
9
  class ImageQuantizer
9
10
  MIN_DEPTH = 1
10
11
  MAX_DEPTH = 8
11
12
 
12
- def initialize(depth:)
13
+ def initialize(depth:, dither:)
13
14
  @depth = clamp(depth)
15
+ @dither = dither
14
16
  end
15
17
 
16
18
  def call(path)
@@ -25,32 +27,43 @@ module TRMNLP
25
27
  def quantize(src, dst)
26
28
  MiniMagick.convert do |m|
27
29
  m << src
28
- m.colorspace 'Gray'
29
- m.dither 'FloydSteinberg'
30
- apply_depth(m)
30
+ @dither ? quantize_with_dither(m) : quantize_without_dither(m)
31
+ m.alpha 'off'
31
32
  m.depth @depth
32
- m.define "png:bit-depth=#{@depth}"
33
+ m.type 'Palette'
34
+ m.define 'png:compression-level=9'
33
35
  m.strip
34
36
  m << dst
35
37
  end
36
38
  end
37
39
 
38
- def apply_depth(m)
40
+ def quantize_without_dither(m)
39
41
  if @depth == 1
40
- # NOTE: 1-bit only has black/white, so a halftone remap simulates gray
41
- # via dithering patterns. Skipping this collapses photos to pure
42
- # silhouettes.
43
- m.remap 'pattern:gray50'
44
- m.posterize 2
42
+ m.monochrome
45
43
  m.colors 2
46
- m.type 'Bilevel'
47
44
  else
48
- levels = 2**@depth
49
- m.posterize levels
50
- m.colors levels
45
+ m.colorspace 'Gray'
46
+ m.dither 'None'
47
+ m.posterize gray_level_count
51
48
  end
52
49
  end
53
50
 
51
+ def quantize_with_dither(m)
52
+ case @depth
53
+ when 1
54
+ m.dither 'FloydSteinberg'
55
+ m.remap 'pattern:gray50'
56
+ when 8
57
+ m.type 'Grayscale'
58
+ else
59
+ m.colorspace 'Gray'
60
+ m.dither 'FloydSteinberg'
61
+ m.posterize gray_level_count
62
+ end
63
+ end
64
+
65
+ def gray_level_count = 2**@depth
66
+
54
67
  def clamp(depth)
55
68
  [[depth.to_i, MIN_DEPTH].max, MAX_DEPTH].min
56
69
  end
@@ -19,9 +19,14 @@ module TRMNLP
19
19
  UNREACHABLE = [SocketError, Net::OpenTimeout, Net::ReadTimeout,
20
20
  Errno::ECONNREFUSED, Errno::EHOSTUNREACH, OpenSSL::SSL::SSLError].freeze
21
21
 
22
+ attr_reader :unreachable_urls
23
+
22
24
  private
23
25
 
24
- def pass? = static_image_urls.all? { |url| reachable?(url) }
26
+ def pass?
27
+ @unreachable_urls = static_image_urls.reject { |url| reachable?(url) }
28
+ unreachable_urls.empty?
29
+ end
25
30
 
26
31
  def static_image_urls
27
32
  source.all_markup
@@ -1,25 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../check'
4
+ require 'crass'
5
+ require 'nokogiri'
4
6
 
5
7
  module TRMNLP
6
8
  module Lint
7
9
  module Checks
8
10
  class LimitedInlineStyles < Check
9
11
  MAX_INLINE_STYLES = 6
10
- PROPERTIES = %w[
11
- justify-content padding margin background-color
12
- border-radius text-align object-fit font-size
13
- ].freeze
14
12
  MESSAGE = 'Markup uses too many inline styles, add more native Framework classes.'
15
13
  LEARN_MORE = 'https://help.trmnl.com/en/articles/11395668-recipe-best-practices#h_3a3eab0712'
16
14
 
17
15
  private
18
16
 
19
17
  def pass?
20
- count = PROPERTIES.sum { |property| source.all_markup.scan(property).size }
18
+ count = Nokogiri::HTML.fragment(markup_without_liquid_comments).css('[style]').sum do |element|
19
+ # Count Liquid output as a CSS value and drop Liquid tags, so every
20
+ # branch's declarations count without evaluating the template.
21
+ css = element['style'].gsub(/\{\{.*?\}\}/m, 'var(--trmnlp-liquid)').gsub(/\{%.*?%\}/m, '')
22
+ Crass.parse_properties(css).count { |token| token[:node] == :property }
23
+ end
21
24
  count <= MAX_INLINE_STYLES
22
25
  end
26
+
27
+ def markup_without_liquid_comments
28
+ source.all_markup.gsub(/\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}/m, '')
29
+ end
23
30
  end
24
31
  end
25
32
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports markup that uses a filter only .trmnlp.yml custom_filters defines.
9
+ class NoCustomFilters < Check
10
+ def self.usage_pattern(filter_name) = /\{[{%](?:(?![}%]\}).)*?\|\s*#{Regexp.escape(filter_name)}\b/m
11
+
12
+ def issues
13
+ used_filter_names.map do |name|
14
+ { message: "Filter '#{name}' comes from custom_filters in .trmnlp.yml, which TRMNL does not load. " \
15
+ 'TRMNL outputs the value unfiltered; use a built-in filter or the transform instead.' }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def used_filter_names
22
+ source.local_only_filter_names.select { |name| source.all_markup.match?(self.class.usage_pattern(name)) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module Lint
5
+ # Adds reporting metadata without changing a check's existing #issues API.
6
+ # Rule IDs are the snake_case check names and are part of the report contract.
7
+ class Diagnostic
8
+ SETTINGS_KEYS = {
9
+ 'title_casing' => 'name', 'title_length' => 'name',
10
+ 'description_length' => 'description', 'recipe_overview_length' => 'recipe_overview'
11
+ }.freeze
12
+ MARKUP_PATTERNS = {
13
+ 'no_async_functions' => /async function/i,
14
+ 'no_opacity' => Checks::NoOpacity::PATTERN,
15
+ 'no_size_classes' => Checks::NoSizeClasses::PATTERN,
16
+ 'waits_for_dom_load' => Regexp.new(Regexp.union(Checks::WaitsForDomLoad::FORBIDDEN).source, Regexp::IGNORECASE),
17
+ 'limited_inline_styles' => /\sstyle\s*=/i,
18
+ 'highcharts_animations_disabled' => /highcharts/i,
19
+ 'highcharts_elements_unique' => /highcharts/i
20
+ }.freeze
21
+
22
+ def initialize(check, source, finding)
23
+ @check = check
24
+ @source = source
25
+ @finding = finding
26
+ end
27
+
28
+ def to_h
29
+ finding.merge(rule_id:, severity: 'error', locations: locations.uniq)
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :check, :source, :finding
35
+
36
+ def rule_id
37
+ check.class.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
38
+ end
39
+
40
+ def locations
41
+ return source.yaml_location('src/settings.yml', SETTINGS_KEYS[rule_id]) if SETTINGS_KEYS.key?(rule_id)
42
+ return source.locations(MARKUP_PATTERNS[rule_id]) if MARKUP_PATTERNS.key?(rule_id)
43
+
44
+ case rule_id
45
+ when 'arbitrary_values_in_range' then class_locations
46
+ when 'layouts_have_content' then empty_view_locations
47
+ when 'form_fields_valid' then form_field_locations
48
+ when 'custom_fields_used' then project_field_locations
49
+ when 'no_custom_filters' then custom_filter_locations
50
+ when 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
51
+ else []
52
+ end
53
+ end
54
+
55
+ def class_locations
56
+ css_class = finding[:message][/\A'([^']+)'/, 1]
57
+ css_class ? source.locations(Regexp.new(Regexp.escape(css_class))) : []
58
+ end
59
+
60
+ def custom_filter_locations
61
+ source.locations(Checks::NoCustomFilters.usage_pattern(finding[:message][/Filter '([^']+)'/, 1]))
62
+ end
63
+
64
+ def empty_view_locations
65
+ return [] if source.shared_markup.length >= Checks::LayoutsHaveContent::MIN_CONTENT_LENGTH
66
+
67
+ source.view_markup.filter_map do |view, markup|
68
+ if markup.length < Checks::LayoutsHaveContent::MIN_CONTENT_LENGTH
69
+ { path: "src/#{view}.liquid", line: 1, column: 1, snippet: markup }
70
+ end
71
+ end
72
+ end
73
+
74
+ def form_field_locations
75
+ warning = finding[:message].split(' — ', 2).last
76
+ source.custom_field_definitions.each_with_index.flat_map do |field, index|
77
+ if FormField.validate(field).include?(warning)
78
+ source.yaml_location('src/settings.yml', 'custom_fields', index)
79
+ else
80
+ []
81
+ end
82
+ end
83
+ end
84
+
85
+ def project_field_locations
86
+ key = finding[:message][/Custom field '([^']+)'/, 1]
87
+ return [] unless key
88
+
89
+ # Project custom-field values can contain credentials. Show the key,
90
+ # but never echo its value into terminal output or CI artifacts.
91
+ source.yaml_location('.trmnlp.yml', 'custom_fields', key).map do |location|
92
+ location.merge(snippet: "#{key}: [value omitted]")
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end