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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +31 -0
- data/db/data/and_x_more.yml +86 -0
- data/db/data/locales/cs.yml +339 -0
- data/db/data/locales/da.yml +339 -0
- data/db/data/locales/de-AT.yml +341 -0
- data/db/data/locales/de.yml +341 -0
- data/db/data/locales/en-AU.yml +79 -0
- data/db/data/locales/en-GB.yml +79 -0
- data/db/data/locales/en.yml +341 -0
- data/db/data/locales/es-ES.yml +341 -0
- data/db/data/locales/fr.yml +339 -0
- data/db/data/locales/he.yml +339 -0
- data/db/data/locales/hu.yml +339 -0
- data/db/data/locales/id.yml +339 -0
- data/db/data/locales/is.yml +339 -0
- data/db/data/locales/it.yml +341 -0
- data/db/data/locales/ja.yml +342 -0
- data/db/data/locales/ko.yml +337 -0
- data/db/data/locales/lt.yml +340 -0
- data/db/data/locales/nl.yml +339 -0
- data/db/data/locales/no.yml +4 -0
- data/db/data/locales/pl.yml +342 -0
- data/db/data/locales/pt-BR.yml +340 -0
- data/db/data/locales/raw.yml +256 -0
- data/db/data/locales/ro.yml +339 -0
- data/db/data/locales/ru.yml +342 -0
- data/db/data/locales/sk.yml +339 -0
- data/db/data/locales/sv.yml +340 -0
- data/db/data/locales/uk.yml +339 -0
- data/db/data/locales/zh-CN.yml +338 -0
- data/db/data/locales/zh-HK.yml +338 -0
- data/lib/trmnlp/app.rb +5 -4
- data/lib/trmnlp/async_callback.rb +75 -0
- data/lib/trmnlp/cli.rb +2 -0
- data/lib/trmnlp/commands/lint.rb +18 -3
- data/lib/trmnlp/commands/serve.rb +3 -0
- data/lib/trmnlp/config/plugin.rb +52 -12
- data/lib/trmnlp/config/project.rb +14 -4
- data/lib/trmnlp/context.rb +17 -2
- data/lib/trmnlp/firefox_driver.rb +0 -3
- data/lib/trmnlp/framework_version.rb +9 -0
- data/lib/trmnlp/image_quantizer.rb +28 -15
- data/lib/trmnlp/lint/checks/image_links_reachable.rb +6 -1
- data/lib/trmnlp/lint/checks/limited_inline_styles.rb +12 -5
- data/lib/trmnlp/lint/checks/no_custom_filters.rb +27 -0
- data/lib/trmnlp/lint/diagnostic.rb +97 -0
- data/lib/trmnlp/lint/source.rb +53 -0
- data/lib/trmnlp/lint.rb +3 -1
- data/lib/trmnlp/merged_plugin_locals.rb +57 -0
- data/lib/trmnlp/outbound_request.rb +99 -0
- data/lib/trmnlp/paths.rb +11 -6
- data/lib/trmnlp/poller.rb +83 -44
- data/lib/trmnlp/rails_helpers.rb +41 -0
- data/lib/trmnlp/renderer.rb +37 -11
- data/lib/trmnlp/screen_generator.rb +14 -2
- data/lib/trmnlp/screenshot.rb +29 -4
- data/lib/trmnlp/select_field_choices.rb +25 -0
- data/lib/trmnlp/transform_pipeline.rb +47 -6
- data/lib/trmnlp/transform_state.rb +48 -0
- data/lib/trmnlp/user_data_assembler.rb +63 -25
- data/lib/trmnlp/version.rb +1 -1
- data/lib/trmnlp/watcher.rb +2 -1
- data/lib/trmnlp/webhook_payload.rb +74 -0
- data/lib/trmnlp/webhook_receiver.rb +78 -0
- data/lib/trmnlp.rb +1 -1
- data/trmnl_preview.gemspec +3 -0
- data/web/public/index.js +18 -6
- data/web/views/index.erb +7 -0
- data/web/views/render_html.erb +36 -0
- 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)
|
data/lib/trmnlp/commands/lint.rb
CHANGED
|
@@ -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
|
|
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
|
|
data/lib/trmnlp/config/plugin.rb
CHANGED
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
46
|
-
def polling_url_text
|
|
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
|
|
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
|
-
|
|
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'
|
data/lib/trmnlp/context.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
29
|
-
m.
|
|
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.
|
|
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
|
|
40
|
+
def quantize_without_dither(m)
|
|
39
41
|
if @depth == 1
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
m.
|
|
50
|
-
m.
|
|
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?
|
|
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 =
|
|
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
|