trmnl_preview 0.23.1 → 0.24.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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Recipe review asks for a contact that is not Discord, because not every
9
+ # user has access to Discord.
10
+ class AuthorContactMethod < Check
11
+ MESSAGE = 'Add an email address, web link or GitHub link to the author_bio custom field, ' \
12
+ 'so users can reach you with questions or issues. A Discord link alone is not enough.'
13
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder#h_02dd8f84a9'
14
+ CONTACT_KEYS = %w[email_address github_url learn_more_url description].freeze
15
+ CONTACT_PATTERN = %r{[^\s@]+@[^\s@]+\.\w+|https?://\S+}
16
+
17
+ private
18
+
19
+ def pass?
20
+ author_bio = source.custom_field_definitions.find { it['field_type'] == 'author_bio' }
21
+ return false unless author_bio
22
+
23
+ author_bio.values_at(*CONTACT_KEYS).join(' ').scan(CONTACT_PATTERN).any? { !it.match?(/discord/i) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports separate latitude and longitude fields in settings.yml. One
9
+ # lat_lon field asks for both, and lets the user pick a place on a map.
10
+ class CoordinatesUseLatLon < Check
11
+ LATITUDE_WORDS = %w[lat latitude].freeze
12
+ LONGITUDE_WORDS = %w[lon lng long longitude].freeze
13
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder#h_07a70ff721'
14
+
15
+ def issues
16
+ # A keyname with both words (lat_lng) is one field, so it never pairs with itself.
17
+ latitude = keynames_with(LATITUDE_WORDS) - keynames_with(LONGITUDE_WORDS)
18
+ longitude = keynames_with(LONGITUDE_WORDS) - keynames_with(LATITUDE_WORDS)
19
+ return [] if latitude.empty? || longitude.empty?
20
+
21
+ fields = (latitude + longitude).map { "'#{it}'" }.join(' and ')
22
+ [{ message: "Form fields #{fields} ask for one location. Use one field with field_type 'lat_lon'.",
23
+ learn_more: LEARN_MORE }]
24
+ end
25
+
26
+ private
27
+
28
+ def keynames_with(words) = keynames.select { |keyname| words.intersect?(words_in(keyname)) }
29
+
30
+ def keynames
31
+ source.custom_field_definitions.reject { |field| field['field_type'] == 'lat_lon' }
32
+ .map { |field| field['keyname'].to_s }
33
+ end
34
+
35
+ # home_lat, homeLat and home-lat all give %w[home lat].
36
+ def words_in(keyname) = keyname.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.split(/[^a-z\d]+/)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ require_relative '../check'
6
+
7
+ module TRMNLP
8
+ module Lint
9
+ module Checks
10
+ # Reports classes in the Framework's own families that the plugin's release does
11
+ # not define, such as `value--medium`. They match no rule, so they do nothing.
12
+ class FrameworkClassesExist < Check
13
+ FAMILY_PATTERN = /\A(?:value|label|title|description|text)--/
14
+ DATA_PATH = File.expand_path('../../../../db/data/framework_classes.yml', __dir__)
15
+
16
+ # Release number => the family classes its plugins.css defines, from that release until the next one listed.
17
+ CLASSES_BY_RELEASE = YAML.load_file(DATA_PATH).transform_keys { Gem::Version.new(it) }.sort.freeze
18
+
19
+ def issues
20
+ (used_classes - release_classes).map do |name|
21
+ { message: "'#{name}' is not a class in Framework v#{source.framework_version}, so it has no effect. " \
22
+ 'Check the name in the Framework docs.' }
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def used_classes
29
+ source.html_fragments.values.flat_map { it.css('[class]').flat_map { it['class'].split } }
30
+ .map { it.split(':').last.to_s }
31
+ .select { it.match?(FAMILY_PATTERN) && !it.include?('{{') }
32
+ .uniq
33
+ end
34
+
35
+ def release_classes
36
+ version = Gem::Version.new(source.framework_version.number)
37
+ CLASSES_BY_RELEASE.select { |release, _| release <= version }.last.last
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../check'
4
+ require_relative '../source'
4
5
  require 'crass'
5
6
  require 'nokogiri'
6
7
 
@@ -25,7 +26,7 @@ module TRMNLP
25
26
  end
26
27
 
27
28
  def markup_without_liquid_comments
28
- source.all_markup.gsub(/\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}/m, '')
29
+ source.all_markup.gsub(Source::LIQUID_COMMENT, '')
29
30
  end
30
31
  end
31
32
  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 NoExternalQrCodes < Check
9
+ MESSAGE = 'Instead of an external service, please use our built-in Liquid filter for QR codes "qr_code".'
10
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10347358-custom-plugin-filters#h_1c2c240f9e'
11
+ QR_GENERATORS = %r{
12
+ (?:qrserver\.com|goqr\.me|qrcode\.tec-it\.com|qrcode-monkey\.com
13
+ |quickchart\.io/qr\b|chart\.googleapis\.com/chart\?[^"')]*\bcht=qr\b)
14
+ }xi
15
+ PATTERN = %r{(?:\bsrc\s*=\s*|\burl\(\s*)["']?(?:https?:)?//(?:[\w-]+\.)*#{QR_GENERATORS}}i
16
+
17
+ private
18
+
19
+ def pass? = !source.all_markup.match?(PATTERN)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class NoNestedLayouts < Check
9
+ MESSAGE = "A 'layout' is inside another 'layout', which breaks the render. " \
10
+ "Use one 'layout' per view, with 'columns' or 'flex' inside it."
11
+ LEARN_MORE = 'https://trmnl.com/framework/docs/layout'
12
+
13
+ def misplaced_elements
14
+ @misplaced_elements ||= source.html_fragments.flat_map do |path, fragment|
15
+ fragment.css('.layout .layout').map { [path, it] }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def pass? = misplaced_elements.empty?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ class TitleBarOutsideLayout < Check
9
+ MESSAGE = "A 'title_bar' is inside a 'layout', so it does not render in its place. " \
10
+ "Move it out of the 'layout', after it."
11
+ LEARN_MORE = 'https://trmnl.com/framework/docs/title_bar'
12
+
13
+ def misplaced_elements
14
+ @misplaced_elements ||= source.html_fragments.flat_map do |path, fragment|
15
+ fragment.css('.layout .title_bar').map { [path, it] }
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def pass? = misplaced_elements.empty?
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../check'
4
+
5
+ module TRMNLP
6
+ module Lint
7
+ module Checks
8
+ # Reports a settings.yml select whose only options are yes and no — one
9
+ # finding per field. A boolean field shows the same choice as a switch.
10
+ class YesNoSelectsAreBoolean < Check
11
+ YES_NO_VALUE_SETS = [%w[no yes], %w[false true]].freeze
12
+ LEARN_MORE = 'https://help.trmnl.com/en/articles/10513740-custom-plugin-form-builder'
13
+
14
+ def issues
15
+ source.custom_field_definitions.select { |field| yes_no_select?(field) }.map do |field|
16
+ { message: "Form field '#{field['keyname']}' is a select with only Yes and No options. " \
17
+ "Use field_type 'boolean' to show a switch.", learn_more: LEARN_MORE }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def yes_no_select?(field)
24
+ options = field['options']
25
+ field['field_type'] == 'select' && options.is_a?(Array) && YES_NO_VALUE_SETS.include?(option_values(options))
26
+ end
27
+
28
+ # Same value TRMNL submits: a Label: value pair's value, otherwise the option itself.
29
+ def option_values(options)
30
+ options.map { |option| (option.is_a?(Hash) ? option.values.first : option).to_s.downcase }.uniq.sort
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -13,6 +13,7 @@ module TRMNLP
13
13
  'no_async_functions' => /async function/i,
14
14
  'no_opacity' => Checks::NoOpacity::PATTERN,
15
15
  'no_size_classes' => Checks::NoSizeClasses::PATTERN,
16
+ 'no_external_qr_codes' => Checks::NoExternalQrCodes::PATTERN,
16
17
  'waits_for_dom_load' => Regexp.new(Regexp.union(Checks::WaitsForDomLoad::FORBIDDEN).source, Regexp::IGNORECASE),
17
18
  'limited_inline_styles' => /\sstyle\s*=/i,
18
19
  'highcharts_animations_disabled' => /highcharts/i,
@@ -40,11 +41,13 @@ module TRMNLP
40
41
  return source.locations(MARKUP_PATTERNS[rule_id]) if MARKUP_PATTERNS.key?(rule_id)
41
42
 
42
43
  case rule_id
43
- when 'arbitrary_values_in_range' then class_locations
44
+ when 'arbitrary_values_in_range', 'framework_classes_exist' then class_locations
44
45
  when 'layouts_have_content' then empty_view_locations
45
46
  when 'form_fields_valid' then form_field_locations
46
47
  when 'custom_fields_used' then project_field_locations
47
48
  when 'no_custom_filters', 'no_unknown_filters' then filter_locations
49
+ when 'title_bar_outside_layout', 'no_nested_layouts'
50
+ check.misplaced_elements.map { |path, element| source.element_location(path, element) }
48
51
  when 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
49
52
  else []
50
53
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'nokogiri'
3
4
  require 'trmnl/liquid'
4
5
  require 'yaml'
5
6
 
@@ -12,6 +13,7 @@ module TRMNLP
12
13
  # which Config::Plugin's semantic readers render away.
13
14
  class Source
14
15
  VIEWS = %w[full half_horizontal half_vertical quadrant].freeze
16
+ LIQUID_COMMENT = /\{%-?\s*comment\s*-?%\}.*?\{%-?\s*endcomment\s*-?%\}/m
15
17
 
16
18
  def initialize(config:, paths:)
17
19
  @config = config
@@ -60,6 +62,23 @@ module TRMNLP
60
62
  end
61
63
  end
62
64
 
65
+ # Parsed without render data: Liquid output becomes `{{}}` and tags become spaces,
66
+ # so the classes of every branch count and a quote inside Liquid cannot end an attribute.
67
+ # Each replacement keeps the newlines it replaced, so element lines stay true.
68
+ def html_fragments
69
+ @html_fragments ||= markup_files.transform_values do |markup|
70
+ html = markup.gsub(LIQUID_COMMENT) { "\n" * it.count("\n") }
71
+ .gsub(/\{\{.*?\}\}/m) { "{{}}#{"\n" * it.count("\n")}" }
72
+ .gsub(/\{%.*?%\}/m) { " #{"\n" * it.count("\n")}" }
73
+ Nokogiri::HTML.fragment(html)
74
+ end
75
+ end
76
+
77
+ def element_location(path, element)
78
+ snippet = markup_files.fetch(path).lines[element.line - 1].to_s.chomp
79
+ { path:, line: element.line, column: (snippet.index(/<#{element.name}\b/i) || 0) + 1, snippet: snippet[0, 240] }
80
+ end
81
+
63
82
  def locations(pattern, files: markup_files)
64
83
  files.flat_map do |path, contents|
65
84
  contents.to_enum(:scan, pattern).map do
data/lib/trmnlp/lint.rb CHANGED
@@ -18,8 +18,15 @@ require_relative 'lint/checks/highcharts_elements_unique'
18
18
  require_relative 'lint/checks/image_links_reachable'
19
19
  require_relative 'lint/checks/custom_fields_used'
20
20
  require_relative 'lint/checks/form_fields_valid'
21
+ require_relative 'lint/checks/yes_no_selects_are_boolean'
22
+ require_relative 'lint/checks/coordinates_use_lat_lon'
21
23
  require_relative 'lint/checks/no_custom_filters'
22
24
  require_relative 'lint/checks/no_unknown_filters'
25
+ require_relative 'lint/checks/author_contact_method'
26
+ require_relative 'lint/checks/title_bar_outside_layout'
27
+ require_relative 'lint/checks/no_nested_layouts'
28
+ require_relative 'lint/checks/framework_classes_exist'
29
+ require_relative 'lint/checks/no_external_qr_codes'
23
30
 
24
31
  module TRMNLP
25
32
  # Markup best-practice checks behind `trmnlp lint`.
@@ -42,8 +49,15 @@ module TRMNLP
42
49
  Checks::ImageLinksReachable,
43
50
  Checks::CustomFieldsUsed,
44
51
  Checks::FormFieldsValid,
52
+ Checks::YesNoSelectsAreBoolean,
53
+ Checks::CoordinatesUseLatLon,
45
54
  Checks::NoCustomFilters,
46
- Checks::NoUnknownFilters
55
+ Checks::NoUnknownFilters,
56
+ Checks::AuthorContactMethod,
57
+ Checks::TitleBarOutsideLayout,
58
+ Checks::NoNestedLayouts,
59
+ Checks::FrameworkClassesExist,
60
+ Checks::NoExternalQrCodes
47
61
  ].freeze
48
62
 
49
63
  def self.rule_id(check_type) = check_type.name.split('::').last.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
@@ -43,7 +43,8 @@ RSpec.shared_examples 'a publishable recipe' do |screens: TRMNLP::Testing::PUBLI
43
43
 
44
44
  TRMNLP::Testing::PUBLISHABLE_RECIPE_VIEWS.product(screens).each do |view, screen|
45
45
  device = screen[:device].is_a?(Hash) ? screen[:device][:model] : screen[:device]
46
- screen_name = [device, screen[:orientation]].compact.join(' ')
46
+ dark_mode = 'dark mode' if screen[:dark_mode]
47
+ screen_name = [device, screen[:orientation], screen[:palette], dark_mode].compact.join(' ')
47
48
 
48
49
  it "draws the #{view} view on #{screen_name} without page errors" do
49
50
  expect(trmnl.render(view:, **screen, **recipe_inputs)).to draw_cleanly
@@ -20,6 +20,7 @@
20
20
  pre { background: #f6f8fa; padding: 8px; overflow-x: auto; white-space: pre-wrap; }
21
21
  .screens { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
22
22
  figure { margin: 0; max-width: 100%; }
23
+ .og-vs-x { display: block; max-width: 100%; height: auto; border: 1px solid var(--line); }
23
24
  .frame { position: relative; border: 1px solid var(--line); max-width: min(100%, 640px); }
24
25
  .frame img, .frame svg { display: block; width: 100%; height: auto; }
25
26
  .frame svg { position: absolute; inset: 0; display: none; }
@@ -47,6 +48,12 @@
47
48
  <label for="failures">failures only</label>
48
49
  </header>
49
50
  <main>
51
+ <% if og_vs_x %>
52
+ <section>
53
+ <h2>Full view on TRMNL OG and TRMNL X, at the same height</h2>
54
+ <figure><img src="<%= og_vs_x %>" alt="TRMNL OG and TRMNL X side by side" class="og-vs-x"></figure>
55
+ </section>
56
+ <% end %>
50
57
  <% examples.each do |example| %>
51
58
  <section class="<%= 'failed-example' if example[:status] == 'failed' %>">
52
59
  <h2 class="<%= example[:status] %>"><%= h(example[:description]) %></h2>
@@ -3,6 +3,7 @@
3
3
  require 'erb'
4
4
  require 'fileutils'
5
5
  require 'json'
6
+ require 'mini_magick'
6
7
  require 'rspec/core'
7
8
 
8
9
  module TRMNLP
@@ -12,6 +13,7 @@ module TRMNLP
12
13
  # DIR/report.json. Under GitHub Actions the counts and failures go to the step summary too.
13
14
  class Report
14
15
  TEMPLATE = File.expand_path('report.html.erb', __dir__)
16
+ OG_VS_X = 'og-vs-x.png'
15
17
  OUTLINES_SCRIPT = <<~JS
16
18
  (() => [...document.querySelectorAll('.view *')].slice(0, 3000).map((el) => {
17
19
  const box = el.getBoundingClientRect();
@@ -28,12 +30,17 @@ module TRMNLP
28
30
  @dir = "#{dir}#{ENV.fetch('TEST_ENV_NUMBER', '')}" # parallel_tests: report, report2, report3...
29
31
  @examples = {}
30
32
  @images = 0
33
+ @full_landscape_images = {}
31
34
  end
32
35
 
33
36
  def record_screen(screen)
34
- entry&.fetch(:screens)&.push(
37
+ return unless entry
38
+
39
+ image = copy_image(screen.png_path)
40
+ remember_full_landscape(screen, image)
41
+ entry[:screens].push(
35
42
  label: "#{screen.device.name} · #{screen.view}", width: screen.device.width, height: screen.device.height,
36
- image: copy_image(screen.png_path), outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems
43
+ image:, outlines: screen.evaluate(OUTLINES_SCRIPT), problems: screen.problems
37
44
  )
38
45
  end
39
46
 
@@ -50,7 +57,8 @@ module TRMNLP
50
57
 
51
58
  def close(_notification)
52
59
  FileUtils.mkdir_p(@dir)
53
- File.write(File.join(@dir, 'report.json'), JSON.pretty_generate(examples: examples))
60
+ write_og_vs_x
61
+ File.write(File.join(@dir, 'report.json'), JSON.pretty_generate(og_vs_x:, examples:))
54
62
  File.write(File.join(@dir, 'index.html'), ERB.new(File.read(TEMPLATE)).result(binding))
55
63
  write_step_summary if ENV['GITHUB_STEP_SUMMARY']
56
64
  end
@@ -75,6 +83,24 @@ module TRMNLP
75
83
  name
76
84
  end
77
85
 
86
+ def remember_full_landscape(screen, image)
87
+ return unless screen.view == 'full' && screen.device.width > screen.device.height
88
+
89
+ @full_landscape_images[screen.device.name] ||= image
90
+ end
91
+
92
+ def og_vs_x = (OG_VS_X if @full_landscape_images.values_at('og_plus', 'v2').all?)
93
+
94
+ # Both at the OG's height, so content that fills the OG but is small on the X shows at a glance.
95
+ def write_og_vs_x
96
+ return unless og_vs_x
97
+
98
+ MiniMagick.convert do |convert|
99
+ @full_landscape_images.values_at('og_plus', 'v2').each { convert << File.join(@dir, it) }
100
+ convert << '-resize' << 'x480' << '+append' << '+repage' << File.join(@dir, OG_VS_X)
101
+ end
102
+ end
103
+
78
104
  def count(status) = examples.count { it[:status] == status }
79
105
 
80
106
  def write_step_summary
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.23.1'
4
+ VERSION = '0.24.0'
5
5
  end
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.23.1
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -360,6 +360,7 @@ files:
360
360
  - bin/trmnlp
361
361
  - db/data/and_x_more.yml
362
362
  - db/data/form_fields.yml
363
+ - db/data/framework_classes.yml
363
364
  - db/data/framework_versions.yml
364
365
  - db/data/locales/cs.yml
365
366
  - db/data/locales/da.yml
@@ -421,9 +422,12 @@ files:
421
422
  - lib/trmnlp/lint.rb
422
423
  - lib/trmnlp/lint/check.rb
423
424
  - lib/trmnlp/lint/checks/arbitrary_values_in_range.rb
425
+ - lib/trmnlp/lint/checks/author_contact_method.rb
426
+ - lib/trmnlp/lint/checks/coordinates_use_lat_lon.rb
424
427
  - lib/trmnlp/lint/checks/custom_fields_used.rb
425
428
  - lib/trmnlp/lint/checks/description_length.rb
426
429
  - lib/trmnlp/lint/checks/form_fields_valid.rb
430
+ - lib/trmnlp/lint/checks/framework_classes_exist.rb
427
431
  - lib/trmnlp/lint/checks/highcharts_animations_disabled.rb
428
432
  - lib/trmnlp/lint/checks/highcharts_elements_unique.rb
429
433
  - lib/trmnlp/lint/checks/image_links_reachable.rb
@@ -431,13 +435,17 @@ files:
431
435
  - lib/trmnlp/lint/checks/limited_inline_styles.rb
432
436
  - lib/trmnlp/lint/checks/no_async_functions.rb
433
437
  - lib/trmnlp/lint/checks/no_custom_filters.rb
438
+ - lib/trmnlp/lint/checks/no_external_qr_codes.rb
439
+ - lib/trmnlp/lint/checks/no_nested_layouts.rb
434
440
  - lib/trmnlp/lint/checks/no_opacity.rb
435
441
  - lib/trmnlp/lint/checks/no_size_classes.rb
436
442
  - lib/trmnlp/lint/checks/no_unknown_filters.rb
437
443
  - lib/trmnlp/lint/checks/recipe_overview_length.rb
444
+ - lib/trmnlp/lint/checks/title_bar_outside_layout.rb
438
445
  - lib/trmnlp/lint/checks/title_casing.rb
439
446
  - lib/trmnlp/lint/checks/title_length.rb
440
447
  - lib/trmnlp/lint/checks/waits_for_dom_load.rb
448
+ - lib/trmnlp/lint/checks/yes_no_selects_are_boolean.rb
441
449
  - lib/trmnlp/lint/diagnostic.rb
442
450
  - lib/trmnlp/lint/source.rb
443
451
  - lib/trmnlp/merged_plugin_locals.rb