trmnl_preview 0.7.1 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -1
- data/README.md +180 -5
- data/bin/rake +6 -6
- data/bin/trmnlp +1 -0
- data/db/data/form_fields.yml +24 -0
- data/db/data/framework_versions.yml +72 -0
- data/lib/trmnlp/api_client.rb +41 -28
- data/lib/trmnlp/app.rb +73 -44
- data/lib/trmnlp/browser_pool.rb +82 -0
- data/lib/trmnlp/cli.rb +24 -11
- data/lib/trmnlp/commands/base.rb +33 -10
- data/lib/trmnlp/commands/build.rb +13 -8
- data/lib/trmnlp/commands/clone.rb +12 -7
- data/lib/trmnlp/commands/init.rb +17 -13
- data/lib/trmnlp/commands/lint.rb +42 -0
- data/lib/trmnlp/commands/list.rb +40 -0
- data/lib/trmnlp/commands/login.rb +28 -13
- data/lib/trmnlp/commands/pull.rb +14 -6
- data/lib/trmnlp/commands/push.rb +29 -19
- data/lib/trmnlp/commands/serve.rb +32 -3
- data/lib/trmnlp/commands.rb +3 -1
- data/lib/trmnlp/config/app.rb +6 -3
- data/lib/trmnlp/config/plugin.rb +56 -14
- data/lib/trmnlp/config/project.rb +59 -7
- data/lib/trmnlp/config.rb +3 -1
- data/lib/trmnlp/context.rb +21 -224
- data/lib/trmnlp/errors.rb +15 -0
- data/lib/trmnlp/form_field.rb +42 -0
- data/lib/trmnlp/framework_version.rb +69 -0
- data/lib/trmnlp/image_quantizer.rb +58 -0
- data/lib/trmnlp/lint/check.rb +31 -0
- data/lib/trmnlp/lint/checks/custom_fields_used.rb +32 -0
- data/lib/trmnlp/lint/checks/form_fields_valid.rb +20 -0
- data/lib/trmnlp/lint/checks/highcharts_animations_disabled.rb +23 -0
- data/lib/trmnlp/lint/checks/highcharts_elements_unique.rb +24 -0
- data/lib/trmnlp/lint/checks/image_links_reachable.rb +53 -0
- data/lib/trmnlp/lint/checks/layouts_have_content.rb +24 -0
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +26 -0
- data/lib/trmnlp/lint/checks/no_async_functions.rb +18 -0
- data/lib/trmnlp/lint/checks/no_opacity.rb +19 -0
- data/lib/trmnlp/lint/checks/no_size_classes.rb +19 -0
- data/lib/trmnlp/lint/checks/title_casing.rb +20 -0
- data/lib/trmnlp/lint/checks/title_length.rb +18 -0
- data/lib/trmnlp/lint/checks/waits_for_dom_load.rb +23 -0
- data/lib/trmnlp/lint/source.rb +42 -0
- data/lib/trmnlp/lint.rb +39 -0
- data/lib/trmnlp/paths.rb +28 -8
- data/lib/trmnlp/poller.rb +105 -0
- data/lib/trmnlp/renderer.rb +87 -0
- data/lib/trmnlp/reporter.rb +28 -0
- data/lib/trmnlp/screen.rb +16 -0
- data/lib/trmnlp/screen_generator.rb +11 -217
- data/lib/trmnlp/screenshot.rb +96 -0
- data/lib/trmnlp/transform_backend/http.rb +107 -0
- data/lib/trmnlp/transform_backend/subprocess.rb +130 -0
- data/lib/trmnlp/transform_backend/wrapper.rb +113 -0
- data/lib/trmnlp/transform_client.rb +47 -0
- data/lib/trmnlp/transform_pipeline.rb +65 -0
- data/lib/trmnlp/user_data_assembler.rb +96 -0
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +60 -0
- data/lib/trmnlp.rb +6 -10
- data/templates/init/bin/trmnlp +1 -1
- data/templates/init/src/settings.yml +1 -0
- data/templates/init/src/transform.py.example +14 -0
- data/trmnl_preview.gemspec +34 -34
- data/web/public/index.css +6 -0
- data/web/public/index.js +31 -18
- data/web/views/index.erb +6 -1
- data/web/views/render_html.erb +4 -2
- metadata +81 -56
data/lib/trmnlp/context.rb
CHANGED
|
@@ -1,238 +1,35 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|