trmnl_preview 0.7.0 → 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -6
  3. data/README.md +182 -7
  4. data/bin/rake +6 -6
  5. data/bin/trmnlp +2 -1
  6. data/db/data/form_fields.yml +24 -0
  7. data/db/data/framework_versions.yml +72 -0
  8. data/lib/trmnlp/api_client.rb +41 -28
  9. data/lib/trmnlp/app.rb +73 -44
  10. data/lib/trmnlp/browser_pool.rb +82 -0
  11. data/lib/trmnlp/cli.rb +24 -11
  12. data/lib/trmnlp/commands/base.rb +33 -10
  13. data/lib/trmnlp/commands/build.rb +13 -8
  14. data/lib/trmnlp/commands/clone.rb +12 -7
  15. data/lib/trmnlp/commands/init.rb +17 -13
  16. data/lib/trmnlp/commands/lint.rb +42 -0
  17. data/lib/trmnlp/commands/list.rb +40 -0
  18. data/lib/trmnlp/commands/login.rb +28 -13
  19. data/lib/trmnlp/commands/pull.rb +14 -6
  20. data/lib/trmnlp/commands/push.rb +29 -19
  21. data/lib/trmnlp/commands/serve.rb +32 -3
  22. data/lib/trmnlp/commands.rb +3 -1
  23. data/lib/trmnlp/config/app.rb +7 -4
  24. data/lib/trmnlp/config/plugin.rb +56 -14
  25. data/lib/trmnlp/config/project.rb +59 -7
  26. data/lib/trmnlp/config.rb +3 -1
  27. data/lib/trmnlp/context.rb +21 -224
  28. data/lib/trmnlp/errors.rb +15 -0
  29. data/lib/trmnlp/form_field.rb +42 -0
  30. data/lib/trmnlp/framework_version.rb +69 -0
  31. data/lib/trmnlp/image_quantizer.rb +58 -0
  32. data/lib/trmnlp/lint/check.rb +31 -0
  33. data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
  34. data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
  35. data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
  36. data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
  37. data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
  38. data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
  39. data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
  40. data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
  41. data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
  42. data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
  43. data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
  44. data/lib/trmnlp/lint/checks/title_length.rb +18 -0
  45. data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
  46. data/lib/trmnlp/lint/source.rb +42 -0
  47. data/lib/trmnlp/lint.rb +39 -0
  48. data/lib/trmnlp/paths.rb +28 -8
  49. data/lib/trmnlp/poller.rb +105 -0
  50. data/lib/trmnlp/renderer.rb +87 -0
  51. data/lib/trmnlp/reporter.rb +28 -0
  52. data/lib/trmnlp/screen.rb +16 -0
  53. data/lib/trmnlp/screen_generator.rb +11 -217
  54. data/lib/trmnlp/screenshot.rb +96 -0
  55. data/lib/trmnlp/transform_backend/http.rb +107 -0
  56. data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
  57. data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
  58. data/lib/trmnlp/transform_client.rb +47 -0
  59. data/lib/trmnlp/transform_pipeline.rb +65 -0
  60. data/lib/trmnlp/user_data_assembler.rb +96 -0
  61. data/lib/trmnlp/version.rb +1 -1
  62. data/lib/trmnlp/watcher.rb +60 -0
  63. data/lib/trmnlp.rb +6 -10
  64. data/templates/init/bin/trmnlp +1 -1
  65. data/templates/init/src/settings.yml +2 -1
  66. data/templates/init/src/transform.py.example +14 -0
  67. data/trmnl_preview.gemspec +34 -34
  68. data/web/public/index.css +6 -0
  69. data/web/public/index.js +31 -18
  70. data/web/public/trmnl-picker.js +2 -2
  71. data/web/views/index.erb +6 -1
  72. data/web/views/render_html.erb +4 -2
  73. metadata +81 -56
@@ -1,238 +1,35 @@
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'
1
+ # frozen_string_literal: true
8
2
 
9
3
  require_relative 'config'
10
4
  require_relative 'paths'
5
+ require_relative 'poller'
6
+ require_relative 'renderer'
7
+ require_relative 'reporter'
8
+ require_relative 'transform_pipeline'
9
+ require_relative 'user_data_assembler'
10
+ require_relative 'watcher'
11
11
 
12
12
  module TRMNLP
13
13
  class Context
14
- attr_reader :config, :paths
15
-
16
- def initialize(root_dir)
14
+ attr_reader :config, :paths, :reporter
15
+
16
+ def initialize(root_dir, reporter: Reporter.new)
17
17
  @paths = Paths.new(root_dir)
18
18
  @config = Config.new(paths)
19
+ @reporter = reporter
19
20
  end
20
21
 
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])
22
+ # Context is the composition root: it wires and memoizes the runtime
23
+ # object graph. Callers take the collaborator they need and talk to it
24
+ # directly — Context does not forward methods on their behalf.
25
+ def poller = @poller ||= Poller.new(config:, paths:, reporter:)
26
+ def transform_pipeline = @transform_pipeline ||= TransformPipeline.new(config:, paths:, reporter:)
27
+ def user_data_assembler = @user_data_assembler ||= UserDataAssembler.new(config:, paths:, transform_pipeline:)
28
+ def renderer = @renderer ||= Renderer.new(config:, paths:, user_data_assembler:)
29
+ def watcher = @watcher ||= Watcher.new(config:, user_data_assembler:, transform_pipeline:, reporter:)
167
30
 
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))
31
+ def validate!
32
+ raise NotAPlugin, "not a plugin directory (did not find #{paths.trmnlp_config})" unless paths.valid?
236
33
  end
237
34
  end
238
35
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ class Error < StandardError; end
5
+
6
+ class NotLoggedIn < Error; end
7
+ class NotAPlugin < Error; end
8
+ class DirectoryExists < Error; end
9
+ class Aborted < Error; end
10
+ class PluginIdRequired < Error; end
11
+ class InvalidApiKey < Error; end
12
+ class AuthenticationFailed < Error; end
13
+ class InvalidConfig < Error; end
14
+ class RenderError < Error; end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TRMNLP
6
+ # Validates entries in a plugin's settings.yml custom_fields list.
7
+ #
8
+ # The required keys and field-type allowlist are vendored from the hosted
9
+ # service into db/data/form_fields.yml — the same db/data convention
10
+ # FrameworkVersion uses. See that file's header for the refresh policy.
11
+ #
12
+ # Validation is intentionally lenient: it mirrors the hosted service's
13
+ # live form-field validation (presence checks only) and does NOT reject
14
+ # unknown keys — the hosted form views read far more keys than they
15
+ # validate.
16
+ module FormField
17
+ DATA_PATH = File.expand_path('../../db/data/form_fields.yml', __dir__)
18
+
19
+ def self.schema = @schema ||= YAML.safe_load_file(DATA_PATH).freeze
20
+ def self.required_keys = schema.fetch('required_keys')
21
+ def self.field_types = schema.fetch('field_types')
22
+
23
+ def self.multi_select?(field) = field['field_type'] == 'select' && field['multiple']
24
+
25
+ def self.validate(field)
26
+ warnings = []
27
+
28
+ required_keys.each do |key|
29
+ warnings << "missing required key: #{key}" unless field.key?(key) || field.key?(key.to_sym)
30
+ end
31
+
32
+ type = field['field_type'] || field[:field_type]
33
+ warnings << "unknown field_type: #{type}" if type && !field_types.include?(type)
34
+
35
+ warnings
36
+ end
37
+
38
+ def self.validate_all(fields)
39
+ Array(fields).flat_map { |field| validate(field) }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module TRMNLP
6
+ # Represents a TRMNL design-system framework version, so plugins can
7
+ # pin (or float to "latest") the CSS/JS bundle they render against,
8
+ # both in production and locally.
9
+ class FrameworkVersion
10
+ include Comparable
11
+
12
+ DEFAULT_ASSET_HOST = 'https://trmnl.com'
13
+ DATA_PATH = File.expand_path('../../db/data/framework_versions.yml', __dir__)
14
+
15
+ attr_reader :number
16
+
17
+ def self.config = @config ||= YAML.load_file(DATA_PATH).freeze
18
+
19
+ def self.version_numbers = config.fetch('versions').map { |v| v['number'] }.freeze
20
+
21
+ def self.latest = new(config.fetch('latest'))
22
+
23
+ # Suitable for showing in a `select` form field. Pinnable versions are
24
+ # ordered newest-first by semantic version — never by manifest order.
25
+ def self.options
26
+ newest_first = version_numbers.sort_by { |number| Gem::Version.new(number) }.reverse
27
+ [{ "Always track latest (currently v#{latest.number})" => 'latest' }] +
28
+ newest_first.map { |number| { "v#{number}" => number } }
29
+ end
30
+
31
+ def initialize(number, asset_host: DEFAULT_ASSET_HOST)
32
+ @asset_host = asset_host
33
+
34
+ if number.nil? || number == 'latest'
35
+ @number = self.class.config.fetch('latest')
36
+ @pinned = false
37
+ elsif self.class.version_numbers.include?(number)
38
+ @number = number
39
+ @pinned = true
40
+ else
41
+ raise ArgumentError, "unknown framework version: #{number}"
42
+ end
43
+ end
44
+
45
+ def pinned? = @pinned
46
+
47
+ def css_url = "#{@asset_host}/css/#{path_segment}/plugins.css"
48
+
49
+ def js_url = "#{@asset_host}/js/#{path_segment}/plugins.js"
50
+
51
+ def ==(other) = other.is_a?(self.class) && number == other.number
52
+
53
+ def <=>(other)
54
+ return nil unless other.is_a?(self.class)
55
+
56
+ Gem::Version.new(number) <=> Gem::Version.new(other.number)
57
+ end
58
+
59
+ def as_json(*) = number
60
+
61
+ def to_s = number
62
+
63
+ private
64
+
65
+ # When pinned, requests assets at /css/<version>/plugins.css to lock
66
+ # behavior; otherwise hit /css/latest/ for live updates.
67
+ def path_segment = pinned? ? @number : 'latest'
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mini_magick'
4
+ require 'tempfile'
5
+ require 'fileutils'
6
+
7
+ module TRMNLP
8
+ class ImageQuantizer
9
+ MIN_DEPTH = 1
10
+ MAX_DEPTH = 8
11
+
12
+ def initialize(depth:)
13
+ @depth = clamp(depth)
14
+ end
15
+
16
+ def call(path)
17
+ tmp = Tempfile.new(['mono', '.png'])
18
+ tmp.close
19
+ quantize(path, tmp.path)
20
+ FileUtils.mv(tmp.path, path, force: true)
21
+ end
22
+
23
+ private
24
+
25
+ def quantize(src, dst)
26
+ MiniMagick.convert do |m|
27
+ m << src
28
+ m.colorspace 'Gray'
29
+ m.dither 'FloydSteinberg'
30
+ apply_depth(m)
31
+ m.depth @depth
32
+ m.define "png:bit-depth=#{@depth}"
33
+ m.strip
34
+ m << dst
35
+ end
36
+ end
37
+
38
+ def apply_depth(m)
39
+ 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
45
+ m.colors 2
46
+ m.type 'Bilevel'
47
+ else
48
+ levels = 2**@depth
49
+ m.posterize levels
50
+ m.colors levels
51
+ end
52
+ end
53
+
54
+ def clamp(depth)
55
+ [[depth.to_i, MIN_DEPTH].max, MAX_DEPTH].min
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRMNLP
4
+ module Lint
5
+ # Base class for a single best-practice check. A subclass declares a
6
+ # MESSAGE constant (and optionally LEARN_MORE) and implements #pass?.
7
+ # Checks that surface a variable number of findings override #issues.
8
+ class Check
9
+ def initialize(source)
10
+ @source = source
11
+ end
12
+
13
+ def issues
14
+ pass? ? [] : [issue]
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :source
20
+
21
+ def pass?
22
+ raise NotImplementedError
23
+ end
24
+
25
+ def issue
26
+ learn_more = self.class.const_defined?(:LEARN_MORE) ? self.class::LEARN_MORE : nil
27
+ { message: self.class::MESSAGE, learn_more: }.compact
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports custom fields declared in .trmnlp.yml that never appear in the
9
+ # polling settings or the markup — one finding per unused field.
10
+ class CustomFieldsUsed < Check
11
+ SETTINGS_KEYS = %w[polling_url polling_headers polling_body].freeze
12
+
13
+ def issues
14
+ source.custom_field_values.keys.reject { |keyname| used?(keyname) }.map do |keyname|
15
+ { message: "Custom field '#{keyname}' is not used in form fields or markup." }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def used?(keyname)
22
+ pattern = /#{Regexp.escape(keyname)}/
23
+ searchable_settings.match?(pattern) || source.all_markup.match?(pattern)
24
+ end
25
+
26
+ def searchable_settings
27
+ @searchable_settings ||= SETTINGS_KEYS.filter_map { |key| source.settings[key] }.join(' ')
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+ require_relative '../../form_field'
5
+
6
+ module TRMNLP
7
+ module Lint
8
+ module Checks
9
+ # Validates settings.yml custom_fields against the FormField schema —
10
+ # the same source serve/build use for their startup warnings.
11
+ class FormFieldsValid < Check
12
+ def issues
13
+ FormField.validate_all(source.custom_field_definitions).map do |warning|
14
+ { message: "settings.yml custom_fields — #{warning}" }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class HighchartsAnimationsDisabled < Check
9
+ MESSAGE = 'Highcharts should have animations disabled.'
10
+ LEARN_MORE = 'https://trmnl.com/framework/chart'
11
+
12
+ private
13
+
14
+ def pass?
15
+ markup = source.all_markup
16
+ return true unless markup.downcase.include?('highcharts')
17
+
18
+ markup.match?(/animation:\s{0,6}false/)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class HighchartsElementsUnique < Check
9
+ MESSAGE = 'To avoid variable shadowing across charts in multiple layouts, ' \
10
+ 'use the append_random filter for your Highcharts elements.'
11
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10347358-custom-plugin-filters'
12
+
13
+ private
14
+
15
+ def pass?
16
+ markup = source.all_markup
17
+ return true unless markup.downcase.include?('highcharts')
18
+
19
+ markup.match?(/append_random/)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'openssl'
5
+ require 'uri'
6
+
7
+ require_relative '../check'
8
+
9
+ module TRMNLP
10
+ module Lint
11
+ module Checks
12
+ # Flags static <img> URLs that answer with a non-success status. A
13
+ # network failure (offline, DNS, timeout) is NOT a plugin defect, so
14
+ # those are skipped rather than reported as one.
15
+ class ImageLinksReachable < Check
16
+ MESSAGE = 'One or more <img> tags has a static "src" URL that does not ' \
17
+ 'respond to HTTP GET requests with a success code.'
18
+ TIMEOUT = 5
19
+ UNREACHABLE = [SocketError, Net::OpenTimeout, Net::ReadTimeout,
20
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, OpenSSL::SSL::SSLError].freeze
21
+
22
+ private
23
+
24
+ def pass? = static_image_urls.all? { |url| reachable?(url) }
25
+
26
+ def static_image_urls
27
+ source.view_markup.values
28
+ .flat_map { |html| html.scan(/<img[^>]+src\s*=\s*["']([^"']+)["']/i).flatten }
29
+ .map(&:strip)
30
+ .reject { |src| src.empty? || src.include?('{{') || src.start_with?('data:') }
31
+ end
32
+
33
+ def reachable?(url)
34
+ return false unless url.match?(%r{\Ahttps?://})
35
+
36
+ response = fetch(URI.parse(url))
37
+ response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPRedirection)
38
+ rescue *UNREACHABLE
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def fetch(uri)
45
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https',
46
+ open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
47
+ http.get(uri.request_uri)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class LayoutsHaveContent < Check
9
+ MIN_CONTENT_LENGTH = 10
10
+ MESSAGE = 'Some markup layouts are empty, please provide basic treatment.'
11
+
12
+ private
13
+
14
+ # A view passes when either its own markup or the shared markup
15
+ # carries real content — mirrors the hosted behaviour.
16
+ def pass?
17
+ source.view_markup.values.all? do |markup|
18
+ [markup.length, source.shared_markup.length].max >= MIN_CONTENT_LENGTH
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class LimitedInlineStyles < Check
9
+ 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
+ MESSAGE = 'Markup uses too many inline styles, add more native Framework classes.'
15
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/11395668-recipe-best-practices#h_3a3eab0712'
16
+
17
+ private
18
+
19
+ def pass?
20
+ count = PROPERTIES.sum { |property| source.all_markup.scan(property).size }
21
+ count <= MAX_INLINE_STYLES
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end