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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +20 -0
- data/lib/trmnlp/cli.rb +2 -0
- data/lib/trmnlp/commands/lint.rb +18 -3
- 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/diagnostic.rb +92 -0
- data/lib/trmnlp/lint/source.rb +47 -0
- data/lib/trmnlp/version.rb +1 -1
- data/trmnl_preview.gemspec +2 -0
- metadata +30 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de41fcea181f5a6a71aba1dcd87b4faa4c3d886df1f6581c3008a0f91e597bba
|
|
4
|
+
data.tar.gz: e9f1c5bb500da483e282ec826f812915b17dbfbbe5704e2b2be2c36de802e4cb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
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
|
|
@@ -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,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
|
data/lib/trmnlp/lint/source.rb
CHANGED
|
@@ -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
|
data/lib/trmnlp/version.rb
CHANGED
data/trmnl_preview.gemspec
CHANGED
|
@@ -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.
|
|
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
|