trmnl_preview 0.26.0 → 0.28.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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Small gray text dithers into dots on a 1-bit screen, so it needs a 1bit: class
9
+ # (1bit:text--black, or 1bit:text--white on a black panel) on the same element.
10
+ class GrayTextHasOneBitClass < Check
11
+ SMALL = /\A(?:label|text|description)--x?small\z/
12
+ GRAY = /\A(?:text--gray(?:-\d+)?|label--gray)\z/
13
+ LEARN_MORE = 'https://trmnl.com/framework/docs/3.4/text_color'
14
+
15
+ def issues
16
+ offending_elements.map { |_, element| self.class.class_list(element) }.uniq.map do |classes|
17
+ { message: "'#{classes}' is small gray text with no 1bit: class, so it breaks up " \
18
+ 'into dots on 1-bit screens. Add 1bit:text--black (1bit:text--white on a black background).',
19
+ learn_more: LEARN_MORE }
20
+ end
21
+ end
22
+
23
+ def offending_elements
24
+ @offending_elements ||= source.html_fragments.flat_map do |path, fragment|
25
+ fragment.css('[class]').select { small_gray_without_one_bit?(it['class'].split) }.map { [path, it] }
26
+ end
27
+ end
28
+
29
+ def self.class_list(element) = element['class'].split.join(' ')
30
+
31
+ private
32
+
33
+ def small_gray_without_one_bit?(classes)
34
+ classes.any?(SMALL) && classes.any?(GRAY) && classes.none? { it.start_with?('1bit:') }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports a custom field that sets how often the content changes. The refresh rate and the
9
+ # playlist already decide that, so the field repeats them. A field must both be named for
10
+ # it (rotation, refresh, new …) and offer intervals, so data such as "collected every 2 weeks" passes.
11
+ class NoRefreshRateField < Check
12
+ NAME = /rotat|refresh|frequen|interval|how often|\bnew\b|\bnext\b/i
13
+ INTERVAL_OPTION = /\A(?:daily|hourly|(?:once|twice|\d+\stimes)\s(?:a|per)\s(?:day|hour)|
14
+ every\s(?:\d+\s)?(?:minute|hour|day)s?|\d+\s*(?:minutes?|mins?|hours?|hrs?)\b)/ix
15
+ TIME_UNIT = /\b(?:minutes?|hours?)\b/i
16
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/11395668-recipe-best-practices'
17
+
18
+ def issues
19
+ offending_fields.map do |field|
20
+ { message: "Form field '#{field['keyname']}' sets how often the content changes. Remove it and let " \
21
+ 'the refresh rate and the playlist decide.', learn_more: LEARN_MORE }
22
+ end
23
+ end
24
+
25
+ def offending_fields = source.custom_field_definitions.select { refresh_rate_field?(it) }
26
+
27
+ private
28
+
29
+ def refresh_rate_field?(field)
30
+ return false unless "#{field['keyname'].to_s.tr('_', ' ')} #{field['name']}".match?(NAME)
31
+
32
+ case field['field_type']
33
+ when 'select' then interval_options?(field['options'])
34
+ when 'number', 'string' then [field['keyname'], field['name'],
35
+ field['description']].join(' ').match?(TIME_UNIT)
36
+ else false
37
+ end
38
+ end
39
+
40
+ def interval_options?(options)
41
+ labels = Array(options).map { it.is_a?(Hash) ? it.keys.first.to_s : it.to_s }
42
+ labels.count { it.match?(INTERVAL_OPTION) } >= 2
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports <style> rules that redo what the framework does across screens: fixed font
9
+ # sizes (no lg: scaling), hand-written line clamping and orientation reflows. CSS for
10
+ # art, charts and SVG text is left alone.
11
+ class NoStyleRestylingFramework < Check
12
+ STYLE_BLOCK = %r{<style\b[^>]*>(.*?)</style>}mi
13
+ RULE = /([^{}]*)\{([^{}]*)\}/
14
+ FIXED_FONT_SIZE = /font-size\s*:\s*[\d.]+\s*(?:px|pt)\b/i
15
+ SVG_OR_CHART = /(?:\A|[\s>+~,])(?:svg|text|tspan)\b|highcharts|chart/i
16
+ KINDS = {
17
+ font_size: ['A <style> block sets font-size in px or pt, so the text keeps one size on every screen. ' \
18
+ 'Use the element sizes (label, value, description, title) with lg: steps.',
19
+ 'https://trmnl.com/framework/docs/3.4/element_sizes'],
20
+ line_clamp: ['A <style> block clamps lines with -webkit-line-clamp. Use data-clamp="N" on the element.',
21
+ 'https://trmnl.com/framework/docs/3.4/clamp'],
22
+ orientation: ['A <style> block reflows with @media (orientation …). ' \
23
+ 'Use the portrait: and lg:portrait: prefixes.',
24
+ 'https://trmnl.com/framework/docs/3.4/responsive']
25
+ }.freeze
26
+
27
+ def issues
28
+ occurrences.keys.map { |kind| { message: KINDS[kind][0], learn_more: KINDS[kind][1] } }
29
+ end
30
+
31
+ # kind => [[path, offset], ...] for each place in a <style> block.
32
+ def occurrences
33
+ @occurrences ||= places.group_by(&:first).transform_values { it.map { |_, path, offset| [path, offset] } }
34
+ end
35
+
36
+ private
37
+
38
+ def places
39
+ source.markup_files.flat_map do |path, markup|
40
+ markup.to_enum(:scan, STYLE_BLOCK).flat_map do
41
+ start = Regexp.last_match.begin(1)
42
+ style_block_offsets(Regexp.last_match(1)).map { |kind, offset| [kind, path, start + offset] }
43
+ end
44
+ end
45
+ end
46
+
47
+ def style_block_offsets(css)
48
+ offsets = css.to_enum(:scan, RULE).filter_map do
49
+ selector, declarations = Regexp.last_match.captures
50
+ next if selector.match?(SVG_OR_CHART) || !declarations.match?(FIXED_FONT_SIZE)
51
+
52
+ [:font_size, Regexp.last_match.begin(2) + declarations.index(FIXED_FONT_SIZE)]
53
+ end
54
+ offsets + pattern_offsets(css, /-webkit-line-clamp/i, :line_clamp) +
55
+ pattern_offsets(css, /@media[^{]*orientation/i, :orientation)
56
+ end
57
+
58
+ def pattern_offsets(css, pattern, kind) = css.to_enum(:scan, pattern).map { [kind, Regexp.last_match.begin(0)] }
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports a select option written as one quoted string, 'Voyager 1: voyager1'. TRMNL reads
9
+ # a string option as a label and saves it lowercased with spaces as underscores, so the
10
+ # value the markup sees is 'voyager_1:_voyager1'. Unquoted, YAML makes it a label and a value.
11
+ class SelectOptionsSplitLabelAndValue < Check
12
+ LABEL_AND_VALUE = /\A[^:]+:\s+\S/
13
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder'
14
+
15
+ def issues
16
+ split_options.map do |field_index, option_index|
17
+ field = source.custom_field_definitions[field_index]
18
+ option = field['options'][option_index]
19
+ { message: "Form field '#{field['keyname']}' has the option '#{option}' as one string, so TRMNL saves " \
20
+ "'#{option.downcase.tr(' ', '_')}'. Write it as 'Label': value to save the value.",
21
+ learn_more: LEARN_MORE }
22
+ end
23
+ end
24
+
25
+ # [field index, option index] of the first such option in each select field.
26
+ def split_options
27
+ source.custom_field_definitions.each_with_index.filter_map do |field, field_index|
28
+ options = field['options']
29
+ next unless field['field_type'] == 'select' && options.is_a?(Array)
30
+
31
+ option_index = options.index { |option| option.is_a?(String) && option.match?(LABEL_AND_VALUE) }
32
+ [field_index, option_index] if option_index
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ 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
+ # Recipe review asks a webhook recipe to show each install its own
9
+ # webhook URL, so the user can copy it into the service that pushes data.
10
+ class WebhookUrlShown < Check
11
+ MESSAGE = 'Add a custom field with field_type copyable_webhook_url, so people who install this webhook ' \
12
+ 'recipe can copy their webhook URL. TRMNL fills it in with the URL of each install.'
13
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder#h_431c22552a'
14
+
15
+ private
16
+
17
+ def pass?
18
+ source.settings['strategy'] != 'webhook' ||
19
+ source.custom_field_definitions.any? { it['field_type'] == 'copyable_webhook_url' }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -7,7 +7,8 @@ module TRMNLP
7
7
  class Diagnostic
8
8
  SETTINGS_KEYS = {
9
9
  'title_casing' => 'name', 'title_length' => 'name',
10
- 'description_length' => 'description', 'recipe_overview_length' => 'recipe_overview'
10
+ 'description_length' => 'description', 'recipe_overview_length' => 'recipe_overview',
11
+ 'webhook_url_shown' => 'strategy'
11
12
  }.freeze
12
13
  MARKUP_PATTERNS = {
13
14
  'no_async_functions' => /async function/i,
@@ -36,6 +37,7 @@ module TRMNLP
36
37
 
37
38
  def rule_id = Lint.rule_id(check.class)
38
39
 
40
+ # rubocop:disable-next Metrics/CyclomaticComplexity -- one branch per rule that locates its own findings
39
41
  def locations
40
42
  return source.yaml_location('src/settings.yml', SETTINGS_KEYS[rule_id]) if SETTINGS_KEYS.key?(rule_id)
41
43
  return source.locations(MARKUP_PATTERNS[rule_id]) if MARKUP_PATTERNS.key?(rule_id)
@@ -46,17 +48,22 @@ module TRMNLP
46
48
  when 'form_fields_valid' then form_field_locations
47
49
  when 'custom_field_links_embedded' then custom_field_description_locations
48
50
  when 'custom_fields_used' then project_field_locations
51
+ when 'no_refresh_rate_field' then settings_field_locations
49
52
  when 'no_custom_filters', 'no_unknown_filters' then filter_locations
50
53
  when 'title_bar_outside_layout', 'no_nested_layouts'
51
54
  check.misplaced_elements.map { |path, element| source.element_location(path, element) }
55
+ when 'gray_text_has_one_bit_class' then gray_text_locations
52
56
  when 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
57
+ when 'select_options_split_label_and_value' then split_option_locations
58
+ when 'no_style_restyling_framework' then style_block_locations
53
59
  else []
54
60
  end
55
61
  end
56
62
 
57
63
  def class_locations
58
64
  css_class = finding[:message][/\A'([^']+)'/, 1]
59
- css_class ? source.locations(Regexp.new(Regexp.escape(css_class))) : []
65
+ # Up to the end of the class, so `lg:title` does not also find `lg:title--small`.
66
+ css_class ? source.locations(Regexp.new("#{Regexp.escape(css_class)}(?![\\w-])")) : []
60
67
  end
61
68
 
62
69
  def filter_locations
@@ -73,6 +80,29 @@ module TRMNLP
73
80
  end
74
81
  end
75
82
 
83
+ def split_option_locations
84
+ check.split_options.flat_map do |field, option|
85
+ source.yaml_location('src/settings.yml', 'custom_fields', field, 'options', option)
86
+ end
87
+ end
88
+
89
+ def gray_text_locations
90
+ classes = finding[:message][/\A'([^']+)'/, 1]
91
+ check.offending_elements.select { |_, element| Checks::GrayTextHasOneBitClass.class_list(element) == classes }
92
+ .map { |path, element| source.element_location(path, element) }
93
+ end
94
+
95
+ def style_block_locations
96
+ kind = Checks::NoStyleRestylingFramework::KINDS.find { |_, (message, _)| message == finding[:message] }&.first
97
+ check.occurrences.fetch(kind, []).map { |path, offset| source.offset_location(path, offset) }
98
+ end
99
+
100
+ def settings_field_locations
101
+ keyname = finding[:message][/Form field '([^']+)'/, 1]
102
+ index = source.custom_field_definitions.index { it['keyname'].to_s == keyname }
103
+ index ? source.yaml_location('src/settings.yml', 'custom_fields', index) : []
104
+ end
105
+
76
106
  def form_field_locations
77
107
  warning = finding[:message].split(' — ', 2).last
78
108
  source.custom_field_definitions.each_with_index.flat_map do |field, index|
@@ -87,16 +87,17 @@ module TRMNLP
87
87
 
88
88
  def locations(pattern, files: markup_files)
89
89
  files.flat_map do |path, contents|
90
- contents.to_enum(:scan, pattern).map do
91
- offset = Regexp.last_match.begin(0)
92
- prefix = contents[0...offset]
93
- line = prefix.count("\n") + 1
94
- { path:, line:, column: offset - (prefix.rindex("\n") || -1),
95
- snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
96
- end
90
+ contents.to_enum(:scan, pattern).map { offset_location(path, Regexp.last_match.begin(0), contents:) }
97
91
  end.uniq
98
92
  end
99
93
 
94
+ def offset_location(path, offset, contents: markup_files.fetch(path))
95
+ prefix = contents[0...offset]
96
+ line = prefix.count("\n") + 1
97
+ { path:, line:, column: offset - (prefix.rindex("\n") || -1),
98
+ snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
99
+ end
100
+
100
101
  def yaml_location(file, *keys)
101
102
  path = file == '.trmnlp.yml' ? paths.trmnlp_config : paths.plugin_config
102
103
  contents = raw_read(path)
data/lib/trmnlp/lint.rb CHANGED
@@ -10,15 +10,19 @@ require_relative 'lint/checks/layouts_have_content'
10
10
  require_relative 'lint/checks/no_async_functions'
11
11
  require_relative 'lint/checks/waits_for_dom_load'
12
12
  require_relative 'lint/checks/limited_inline_styles'
13
+ require_relative 'lint/checks/no_style_restyling_framework'
13
14
  require_relative 'lint/checks/no_size_classes'
14
15
  require_relative 'lint/checks/arbitrary_values_in_range'
15
16
  require_relative 'lint/checks/no_opacity'
17
+ require_relative 'lint/checks/gray_text_has_one_bit_class'
16
18
  require_relative 'lint/checks/highcharts_animations_disabled'
17
19
  require_relative 'lint/checks/highcharts_elements_unique'
18
20
  require_relative 'lint/checks/image_links_reachable'
19
21
  require_relative 'lint/checks/custom_fields_used'
20
22
  require_relative 'lint/checks/form_fields_valid'
21
23
  require_relative 'lint/checks/yes_no_selects_are_boolean'
24
+ require_relative 'lint/checks/select_options_split_label_and_value'
25
+ require_relative 'lint/checks/no_refresh_rate_field'
22
26
  require_relative 'lint/checks/coordinates_use_lat_lon'
23
27
  require_relative 'lint/checks/no_custom_filters'
24
28
  require_relative 'lint/checks/no_unknown_filters'
@@ -30,6 +34,7 @@ require_relative 'lint/checks/framework_prefixes_exist'
30
34
  require_relative 'lint/checks/no_external_qr_codes'
31
35
  require_relative 'lint/checks/custom_field_links_embedded'
32
36
  require_relative 'lint/checks/files_under_upload_limit'
37
+ require_relative 'lint/checks/webhook_url_shown'
33
38
 
34
39
  module TRMNLP
35
40
  # Markup best-practice checks behind `trmnlp lint`.
@@ -44,15 +49,19 @@ module TRMNLP
44
49
  Checks::NoAsyncFunctions,
45
50
  Checks::WaitsForDomLoad,
46
51
  Checks::LimitedInlineStyles,
52
+ Checks::NoStyleRestylingFramework,
47
53
  Checks::NoSizeClasses,
48
54
  Checks::ArbitraryValuesInRange,
49
55
  Checks::NoOpacity,
56
+ Checks::GrayTextHasOneBitClass,
50
57
  Checks::HighchartsAnimationsDisabled,
51
58
  Checks::HighchartsElementsUnique,
52
59
  Checks::ImageLinksReachable,
53
60
  Checks::CustomFieldsUsed,
54
61
  Checks::FormFieldsValid,
55
62
  Checks::YesNoSelectsAreBoolean,
63
+ Checks::SelectOptionsSplitLabelAndValue,
64
+ Checks::NoRefreshRateField,
56
65
  Checks::CoordinatesUseLatLon,
57
66
  Checks::NoCustomFilters,
58
67
  Checks::NoUnknownFilters,
@@ -63,7 +72,8 @@ module TRMNLP
63
72
  Checks::FrameworkPrefixesExist,
64
73
  Checks::NoExternalQrCodes,
65
74
  Checks::CustomFieldLinksEmbedded,
66
- Checks::FilesUnderUploadLimit
75
+ Checks::FilesUnderUploadLimit,
76
+ Checks::WebhookUrlShown
67
77
  ].freeze
68
78
 
69
79
  def self.rule_id(check_type) = check_type.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.26.0'
4
+ VERSION = '0.28.0'
5
5
  end
@@ -7,9 +7,10 @@
7
7
  # Add a tag to stay on one release, v0.22.0 or later: trmnl/trmnlp:v0.22.0
8
8
  IMAGE=trmnl/trmnlp
9
9
 
10
- # Saved on the PATH as `trmnlp`, this script is what `command -v trmnlp` finds: not a gem.
10
+ # Saved on the PATH as `trmnlp`, this script is what `command -v trmnlp` finds: not a gem. Nor is
11
+ # another copy of it there (it names run_in_docker): that one would run its own image, not the one above.
11
12
  GEM="$(command -v trmnlp)"
12
- if [ -n "$GEM" ] && ! [ "$GEM" -ef "$0" ]
13
+ if [ -n "$GEM" ] && ! [ "$GEM" -ef "$0" ] && ! grep -qs run_in_docker "$GEM"
13
14
  then
14
15
  exec "$GEM" "$@"
15
16
  fi
@@ -60,6 +60,7 @@ exec docker run \
60
60
  "${ARGS[@]}" \
61
61
  --env CI \
62
62
  --env TRMNL_API_KEY \
63
+ --env TRMNLP_IMAGE="$IMAGE" \
63
64
  --volume "$CONFIG_DIR:$CONTAINER_HOME/.config/trmnlp" \
64
65
  --volume "$CACHE_DIR:$CONTAINER_HOME/.cache/trmnl" \
65
66
  --volume "$(pwd):/plugin" \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trmnl_preview
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -432,6 +432,7 @@ files:
432
432
  - lib/trmnlp/lint/checks/form_fields_valid.rb
433
433
  - lib/trmnlp/lint/checks/framework_classes_exist.rb
434
434
  - lib/trmnlp/lint/checks/framework_prefixes_exist.rb
435
+ - lib/trmnlp/lint/checks/gray_text_has_one_bit_class.rb
435
436
  - lib/trmnlp/lint/checks/highcharts_animations_disabled.rb
436
437
  - lib/trmnlp/lint/checks/highcharts_elements_unique.rb
437
438
  - lib/trmnlp/lint/checks/image_links_reachable.rb
@@ -442,13 +443,17 @@ files:
442
443
  - lib/trmnlp/lint/checks/no_external_qr_codes.rb
443
444
  - lib/trmnlp/lint/checks/no_nested_layouts.rb
444
445
  - lib/trmnlp/lint/checks/no_opacity.rb
446
+ - lib/trmnlp/lint/checks/no_refresh_rate_field.rb
445
447
  - lib/trmnlp/lint/checks/no_size_classes.rb
448
+ - lib/trmnlp/lint/checks/no_style_restyling_framework.rb
446
449
  - lib/trmnlp/lint/checks/no_unknown_filters.rb
447
450
  - lib/trmnlp/lint/checks/recipe_overview_length.rb
451
+ - lib/trmnlp/lint/checks/select_options_split_label_and_value.rb
448
452
  - lib/trmnlp/lint/checks/title_bar_outside_layout.rb
449
453
  - lib/trmnlp/lint/checks/title_casing.rb
450
454
  - lib/trmnlp/lint/checks/title_length.rb
451
455
  - lib/trmnlp/lint/checks/waits_for_dom_load.rb
456
+ - lib/trmnlp/lint/checks/webhook_url_shown.rb
452
457
  - lib/trmnlp/lint/checks/yes_no_selects_are_boolean.rb
453
458
  - lib/trmnlp/lint/diagnostic.rb
454
459
  - lib/trmnlp/lint/source.rb