trmnl_preview 0.14.2 → 0.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dabcf8ddd9b11d8ce8fc76e389820c7cd4f2d3b48db7aa7848896f9dfb6ec59
4
- data.tar.gz: 467322fc910fdfed1ed5c37bb9773a7256d3d8f1f44683e2c9e4f3882b4ab7df
3
+ metadata.gz: de41fcea181f5a6a71aba1dcd87b4faa4c3d886df1f6581c3008a0f91e597bba
4
+ data.tar.gz: e9f1c5bb500da483e282ec826f812915b17dbfbbe5704e2b2be2c36de802e4cb
5
5
  SHA512:
6
- metadata.gz: 75747b93b8aa45ac964b1ae87ef0dd50134c836bf5bc4bef19e47f166132b1dfca6edf8744f967702be6ffdc4c80c7b9b725646443053708f2d74df37a8537e9
7
- data.tar.gz: b6fa8f755ab31f3dae9a3c2773ced039f646c7363288ae27512d0e3a95e72143093ca2869cb26a402084dd1b66c6c2dfc29a022ef8fed84bbb6e18fa5ffed347
6
+ metadata.gz: 5cba8a2ebb0798770ac545866b2c6e882e857db554fc2bcfa41d7c1046e53aaf3a2d0f798b32c634e5f00c4353cbb6fdeb14a4e407e3770d6bc016c90e0717c2
7
+ data.tar.gz: d3941f34861a81f2a21f9d1907620fb7d90f2f7f5d0fcb2dda3bac43ce683fa5eaafc5825853358635025125904037cb1305a038be841522d020b1239f3b2b07
data/CHANGELOG.md CHANGED
@@ -1,6 +1,11 @@
1
1
 
2
2
  # Changelog
3
3
 
4
+ ## 0.15.0
5
+
6
+ - `trmnlp lint` names where each finding is: a rule ID such as `[no_opacity]`, then the file, line and column with the source line. Before, it printed only the message, so you had to search every view and Shared for the cause. `trmnlp lint --format json` writes the same report as one JSON object (`version`, `passed`, `issues`) for CI. Exit statuses are unchanged, and the values of unused custom fields in `.trmnlp.yml` are never printed. (#153)
7
+ - The inline-style check counts real CSS declarations in `style` attributes, still with a limit of six. It counted eight property names anywhere in the markup, so `font-size` in an HTML comment or a `<style>` block failed while seven `style="color:red"` passed. Comments, scripts, text and `<style>` blocks no longer count, and declarations inside Liquid `{% if %}` branches all count. (#152)
8
+
4
9
  ## 0.14.2
5
10
 
6
11
  - A failed transform prints its stderr once. The `transform failed:` line repeated the whole stack trace that the `transform stderr:` line had just printed; it now names only the exit code. The preview page still shows the full error. (#148)
data/README.md CHANGED
@@ -99,6 +99,26 @@ trmnlp push # upload
99
99
 
100
100
  `trmnlp lint` exits non-zero when it finds issues, so you can gate CI on it. Run `trmnlp help` for all flags.
101
101
 
102
+ Lint findings include a stable snake_case rule ID, severity and source locations.
103
+ Locations use project-relative paths and one-based line/column numbers, followed
104
+ by a source excerpt (up to 240 characters). Aggregate checks show their contributing
105
+ locations; checks for a missing chart setting show the related Highcharts usage.
106
+ Unused project custom-field values are omitted from excerpts because they can
107
+ contain credentials.
108
+
109
+ For CI integrations, use `trmnlp lint --format json`. It writes one JSON object
110
+ with `version: 1`, `passed` and an `issues` array. Each issue has `rule_id`,
111
+ `severity`, `message`, `locations` and, when available, `learn_more`. Each location
112
+ has `path`, `line`, `column` and `snippet`. A clean report has `passed: true` and
113
+ `issues: []`. All existing checks retain severity `error` and the same exit status:
114
+ zero when clean, nonzero when findings exist. `--quiet` suppresses either format.
115
+
116
+ The inline-style check counts CSS declarations in actual HTML `style` attributes,
117
+ with a limit of six across the view templates and Shared. All property names count,
118
+ including custom properties. HTML/Liquid comments, script strings, text,
119
+ `data-*` attributes and `<style>` blocks are excluded from this inline check.
120
+ Liquid in declaration values is counted without rendering the plugin, every branch included.
121
+
102
122
  ## Building Static Files
103
123
 
104
124
  `trmnlp build` renders every view to a static file under `_build/` — handy for exporting a snapshot or feeding the output into another pipeline. Run it from inside a plugin project:
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)
@@ -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 { |check| check.new(source).issues }.uniq
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
@@ -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? = static_image_urls.all? { |url| reachable?(url) }
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 = PROPERTIES.sum { |property| source.all_markup.scan(property).size }
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,92 @@
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 'image_links_reachable' then source.locations(Regexp.union(check.unreachable_urls))
50
+ else []
51
+ end
52
+ end
53
+
54
+ def class_locations
55
+ css_class = finding[:message][/\A'([^']+)'/, 1]
56
+ css_class ? source.locations(Regexp.new(Regexp.escape(css_class))) : []
57
+ end
58
+
59
+ def empty_view_locations
60
+ return [] if source.shared_markup.length >= Checks::LayoutsHaveContent::MIN_CONTENT_LENGTH
61
+
62
+ source.view_markup.filter_map do |view, markup|
63
+ if markup.length < Checks::LayoutsHaveContent::MIN_CONTENT_LENGTH
64
+ { path: "src/#{view}.liquid", line: 1, column: 1, snippet: markup }
65
+ end
66
+ end
67
+ end
68
+
69
+ def form_field_locations
70
+ warning = finding[:message].split(' — ', 2).last
71
+ source.custom_field_definitions.each_with_index.flat_map do |field, index|
72
+ if FormField.validate(field).include?(warning)
73
+ source.yaml_location('src/settings.yml', 'custom_fields', index)
74
+ else
75
+ []
76
+ end
77
+ end
78
+ end
79
+
80
+ def project_field_locations
81
+ key = finding[:message][/Custom field '([^']+)'/, 1]
82
+ return [] unless key
83
+
84
+ # Project custom-field values can contain credentials. Show the key,
85
+ # but never echo its value into terminal output or CI artifacts.
86
+ source.yaml_location('.trmnlp.yml', 'custom_fields', key).map do |location|
87
+ location.merge(snippet: "#{key}: [value omitted]")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'yaml'
4
+
3
5
  module TRMNLP
4
6
  module Lint
5
7
  # The plugin data every lint check examines: markup per view, shared
@@ -44,10 +46,55 @@ module TRMNLP
44
46
  end
45
47
  end
46
48
 
49
+ # Keep the original whitespace here: stripped templates cannot provide
50
+ # accurate line/column numbers. All paths in reports are project-relative.
51
+ def markup_files
52
+ @markup_files ||= (VIEWS + ['shared']).to_h do |view|
53
+ ["src/#{view}.liquid", raw_read(paths.template(view))]
54
+ end
55
+ end
56
+
57
+ def locations(pattern, files: markup_files)
58
+ files.flat_map do |path, contents|
59
+ contents.to_enum(:scan, pattern).map do
60
+ offset = Regexp.last_match.begin(0)
61
+ prefix = contents[0...offset]
62
+ line = prefix.count("\n") + 1
63
+ { path:, line:, column: offset - (prefix.rindex("\n") || -1),
64
+ snippet: contents.lines[line - 1].to_s.chomp[0, 240] }
65
+ end
66
+ end.uniq
67
+ end
68
+
69
+ def yaml_location(file, *keys)
70
+ path = file == '.trmnlp.yml' ? paths.trmnlp_config : paths.plugin_config
71
+ contents = raw_read(path)
72
+ node = Psych.parse_stream(contents).children.first&.root
73
+ keys.each do |key|
74
+ node = if key.is_a?(Integer) && node.is_a?(Psych::Nodes::Sequence)
75
+ node.children[key]
76
+ else
77
+ mapping_value(node, key)
78
+ end
79
+ end
80
+ return [] unless node
81
+
82
+ [{ path: file, line: node.start_line + 1, column: node.start_column + 1,
83
+ snippet: contents.lines[node.start_line].to_s.chomp[0, 240] }]
84
+ end
85
+
47
86
  private
48
87
 
49
88
  attr_reader :config, :paths
50
89
 
90
+ def raw_read(path) = path.exist? ? path.read : ''
91
+
92
+ def mapping_value(node, key)
93
+ return unless node.is_a?(Psych::Nodes::Mapping)
94
+
95
+ node.children.each_slice(2).to_a.rfind { |name, _| name.value == key.to_s }&.last
96
+ end
97
+
51
98
  def read(path) = path.exist? ? path.read.strip : ''
52
99
  end
53
100
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TRMNLP
4
- VERSION = '0.14.2'
4
+ VERSION = '0.15.0'
5
5
  end
@@ -47,6 +47,8 @@ Gem::Specification.new do |spec|
47
47
 
48
48
  # HTML rendering
49
49
  spec.add_dependency 'activesupport', '~> 8.0'
50
+ spec.add_dependency 'crass', '~> 1.0'
51
+ spec.add_dependency 'nokogiri', '~> 1.18'
50
52
  spec.add_dependency 'trmnl-liquid', '~> 0.8.2'
51
53
 
52
54
  # PNG rendering
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.14.2
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Rockwell Schrock
@@ -65,6 +65,34 @@ dependencies:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
67
  version: '8.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: crass
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '1.0'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '1.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: nokogiri
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '1.18'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '1.18'
68
96
  - !ruby/object:Gem::Dependency
69
97
  name: trmnl-liquid
70
98
  requirement: !ruby/object:Gem::Requirement
@@ -320,6 +348,7 @@ files:
320
348
  - lib/trmnlp/lint/checks/title_casing.rb
321
349
  - lib/trmnlp/lint/checks/title_length.rb
322
350
  - lib/trmnlp/lint/checks/waits_for_dom_load.rb
351
+ - lib/trmnlp/lint/diagnostic.rb
323
352
  - lib/trmnlp/lint/source.rb
324
353
  - lib/trmnlp/oauth.rb
325
354
  - lib/trmnlp/oauth/client.rb