theme-check 0.2.0 → 0.3.3
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/.rubocop.yml +1 -0
- data/CHANGELOG.md +33 -0
- data/CONTRIBUTING.md +2 -0
- data/README.md +45 -2
- data/RELEASING.md +41 -0
- data/Rakefile +24 -4
- data/config/default.yml +16 -0
- data/data/shopify_liquid/plus_objects.yml +15 -0
- data/dev.yml +2 -0
- data/lib/theme_check.rb +5 -0
- data/lib/theme_check/analyzer.rb +0 -6
- data/lib/theme_check/check.rb +11 -0
- data/lib/theme_check/checks.rb +10 -0
- data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
- data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
- data/lib/theme_check/checks/space_inside_braces.rb +1 -0
- data/lib/theme_check/checks/template_length.rb +11 -3
- data/lib/theme_check/checks/undefined_object.rb +27 -6
- data/lib/theme_check/checks/unused_assign.rb +4 -3
- data/lib/theme_check/checks/valid_html_translation.rb +2 -2
- data/lib/theme_check/cli.rb +9 -1
- data/lib/theme_check/config.rb +95 -43
- data/lib/theme_check/corrector.rb +0 -4
- data/lib/theme_check/disabled_checks.rb +77 -0
- data/lib/theme_check/file_system_storage.rb +51 -0
- data/lib/theme_check/in_memory_storage.rb +37 -0
- data/lib/theme_check/json_file.rb +12 -10
- data/lib/theme_check/language_server/handler.rb +38 -13
- data/lib/theme_check/language_server/server.rb +2 -2
- data/lib/theme_check/offense.rb +3 -1
- data/lib/theme_check/shopify_liquid/object.rb +6 -0
- data/lib/theme_check/storage.rb +25 -0
- data/lib/theme_check/template.rb +26 -21
- data/lib/theme_check/theme.rb +14 -9
- data/lib/theme_check/version.rb +1 -1
- data/lib/theme_check/visitor.rb +14 -3
- data/packaging/homebrew/theme_check.base.rb +10 -6
- metadata +11 -2
@@ -4,12 +4,20 @@ module ThemeCheck
|
|
4
4
|
severity :suggestion
|
5
5
|
category :liquid
|
6
6
|
|
7
|
-
def initialize(max_length: 200)
|
7
|
+
def initialize(max_length: 200, exclude_schema: true)
|
8
8
|
@max_length = max_length
|
9
|
+
@exclude_schema = exclude_schema
|
10
|
+
@excluded_lines = 0
|
9
11
|
end
|
10
12
|
|
11
|
-
def
|
12
|
-
|
13
|
+
def on_schema(node)
|
14
|
+
if @exclude_schema
|
15
|
+
@excluded_lines += node.value.nodelist.join.count("\n")
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def after_document(node)
|
20
|
+
lines = node.template.source.count("\n") - @excluded_lines
|
13
21
|
if lines > @max_length
|
14
22
|
add_offense("Template has too many lines [#{lines}/#{@max_length}]", template: node.template)
|
15
23
|
end
|
@@ -49,23 +49,28 @@ module ThemeCheck
|
|
49
49
|
end
|
50
50
|
end
|
51
51
|
|
52
|
-
def initialize
|
52
|
+
def initialize(exclude_snippets: false)
|
53
|
+
@exclude_snippets = exclude_snippets
|
53
54
|
@files = {}
|
54
55
|
end
|
55
56
|
|
56
57
|
def on_document(node)
|
58
|
+
return if ignore?(node)
|
57
59
|
@files[node.template.name] = TemplateInfo.new
|
58
60
|
end
|
59
61
|
|
60
62
|
def on_assign(node)
|
63
|
+
return if ignore?(node)
|
61
64
|
@files[node.template.name].all_assigns[node.value.to] = node
|
62
65
|
end
|
63
66
|
|
64
67
|
def on_capture(node)
|
68
|
+
return if ignore?(node)
|
65
69
|
@files[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
|
66
70
|
end
|
67
71
|
|
68
72
|
def on_for(node)
|
73
|
+
return if ignore?(node)
|
69
74
|
@files[node.template.name].all_forloops[node.value.variable_name] = node
|
70
75
|
end
|
71
76
|
|
@@ -75,6 +80,7 @@ module ThemeCheck
|
|
75
80
|
end
|
76
81
|
|
77
82
|
def on_render(node)
|
83
|
+
return if ignore?(node)
|
78
84
|
return unless node.value.template_name_expr.is_a?(String)
|
79
85
|
|
80
86
|
snippet_name = "snippets/#{node.value.template_name_expr}"
|
@@ -85,6 +91,7 @@ module ThemeCheck
|
|
85
91
|
end
|
86
92
|
|
87
93
|
def on_variable_lookup(node)
|
94
|
+
return if ignore?(node)
|
88
95
|
@files[node.template.name].add_variable_lookup(
|
89
96
|
name: node.value.name,
|
90
97
|
node: node,
|
@@ -95,26 +102,39 @@ module ThemeCheck
|
|
95
102
|
all_global_objects = ThemeCheck::ShopifyLiquid::Object.labels
|
96
103
|
all_global_objects.freeze
|
97
104
|
|
105
|
+
shopify_plus_objects = ThemeCheck::ShopifyLiquid::Object.plus_labels
|
106
|
+
shopify_plus_objects.freeze
|
107
|
+
|
98
108
|
each_template do |(name, info)|
|
99
109
|
if 'templates/customers/reset_password' == name
|
100
110
|
# NOTE: `email` is exceptionally exposed as a theme object in
|
101
111
|
# the customers' reset password template
|
102
112
|
check_object(info, all_global_objects + ['email'])
|
113
|
+
elsif 'layout/checkout' == name
|
114
|
+
# NOTE: Shopify Plus has exceptionally exposed objects in
|
115
|
+
# the checkout template
|
116
|
+
# https://shopify.dev/docs/themes/theme-templates/checkout-liquid#optional-objects
|
117
|
+
check_object(info, all_global_objects + shopify_plus_objects)
|
103
118
|
else
|
104
119
|
check_object(info, all_global_objects)
|
105
120
|
end
|
106
121
|
end
|
107
122
|
end
|
108
123
|
|
124
|
+
private
|
125
|
+
|
126
|
+
def ignore?(node)
|
127
|
+
@exclude_snippets && node.template.snippet?
|
128
|
+
end
|
129
|
+
|
109
130
|
def each_template
|
110
131
|
@files.each do |(name, info)|
|
111
132
|
next if name.starts_with?('snippets/')
|
112
133
|
yield [name, info]
|
113
134
|
end
|
114
135
|
end
|
115
|
-
private :each_template
|
116
136
|
|
117
|
-
def check_object(info, all_global_objects, render_node = nil)
|
137
|
+
def check_object(info, all_global_objects, render_node = nil, visited_snippets = Set.new)
|
118
138
|
check_undefined(info, all_global_objects, render_node)
|
119
139
|
|
120
140
|
info.each_snippet do |(snippet_name, node)|
|
@@ -123,10 +143,12 @@ module ThemeCheck
|
|
123
143
|
|
124
144
|
snippet_variables = node.value.attributes.keys +
|
125
145
|
Array[node.value.instance_variable_get("@alias_name")]
|
126
|
-
|
146
|
+
unless visited_snippets.include?(snippet_name)
|
147
|
+
visited_snippets << snippet_name
|
148
|
+
check_object(snippet_info, all_global_objects + snippet_variables, node, visited_snippets)
|
149
|
+
end
|
127
150
|
end
|
128
151
|
end
|
129
|
-
private :check_object
|
130
152
|
|
131
153
|
def check_undefined(info, all_global_objects, render_node)
|
132
154
|
all_variables = info.all_variables
|
@@ -146,6 +168,5 @@ module ThemeCheck
|
|
146
168
|
end
|
147
169
|
end
|
148
170
|
end
|
149
|
-
private :check_undefined
|
150
171
|
end
|
151
172
|
end
|
@@ -6,12 +6,13 @@ module ThemeCheck
|
|
6
6
|
category :liquid
|
7
7
|
|
8
8
|
class TemplateInfo < Struct.new(:used_assigns, :assign_nodes, :includes)
|
9
|
-
def collect_used_assigns(templates)
|
9
|
+
def collect_used_assigns(templates, visited = Set.new)
|
10
10
|
collected = used_assigns
|
11
11
|
# Check recursively inside included snippets for use
|
12
12
|
includes.each do |name|
|
13
|
-
if templates[name]
|
14
|
-
|
13
|
+
if templates[name] && !visited.include?(name)
|
14
|
+
visited << name
|
15
|
+
collected += templates[name].collect_used_assigns(templates, visited)
|
15
16
|
end
|
16
17
|
end
|
17
18
|
collected
|
@@ -20,7 +20,7 @@ module ThemeCheck
|
|
20
20
|
keys[-1].end_with?('_html') || pluralized_key.end_with?('_html')
|
21
21
|
end
|
22
22
|
|
23
|
-
def
|
23
|
+
def parse_and_add_offense(key, value)
|
24
24
|
return unless value.is_a?(String)
|
25
25
|
|
26
26
|
html = Nokogiri::HTML5.fragment(value, max_errors: -1)
|
@@ -36,7 +36,7 @@ module ThemeCheck
|
|
36
36
|
visit_nested(v, keys + [k])
|
37
37
|
end
|
38
38
|
elsif html_key?(keys)
|
39
|
-
|
39
|
+
parse_and_add_offense(keys.join('.'), value)
|
40
40
|
end
|
41
41
|
end
|
42
42
|
end
|
data/lib/theme_check/cli.rb
CHANGED
@@ -12,6 +12,7 @@ module ThemeCheck
|
|
12
12
|
-l, [--list] # List enabled checks
|
13
13
|
-a, [--auto-correct] # Automatically fix offenses
|
14
14
|
-h, [--help] # Show this. Hi!
|
15
|
+
-v, [--version] # Print Theme Check version
|
15
16
|
|
16
17
|
Description:
|
17
18
|
Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
|
@@ -33,6 +34,8 @@ module ThemeCheck
|
|
33
34
|
case arg
|
34
35
|
when "--help", "-h"
|
35
36
|
raise Abort, USAGE
|
37
|
+
when "--version", "-v"
|
38
|
+
command = :version
|
36
39
|
when "--category", "-c"
|
37
40
|
only_categories << args.shift.to_sym
|
38
41
|
when "--exclude-category", "-x"
|
@@ -68,9 +71,14 @@ module ThemeCheck
|
|
68
71
|
puts @config.enabled_checks
|
69
72
|
end
|
70
73
|
|
74
|
+
def version
|
75
|
+
puts ThemeCheck::VERSION
|
76
|
+
end
|
77
|
+
|
71
78
|
def check
|
72
79
|
puts "Checking #{@config.root} ..."
|
73
|
-
|
80
|
+
storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
|
81
|
+
theme = ThemeCheck::Theme.new(storage)
|
74
82
|
if theme.all.empty?
|
75
83
|
raise Abort, "No templates found.\n#{USAGE}"
|
76
84
|
end
|
data/lib/theme_check/config.rb
CHANGED
@@ -4,6 +4,7 @@ module ThemeCheck
|
|
4
4
|
class Config
|
5
5
|
DOTFILE = '.theme-check.yml'
|
6
6
|
DEFAULT_CONFIG = "#{__dir__}/../../config/default.yml"
|
7
|
+
BOOLEAN = [true, false]
|
7
8
|
|
8
9
|
attr_reader :root
|
9
10
|
attr_accessor :only_categories, :exclude_categories, :auto_correct
|
@@ -11,13 +12,21 @@ module ThemeCheck
|
|
11
12
|
class << self
|
12
13
|
def from_path(path)
|
13
14
|
if (filename = find(path))
|
14
|
-
new(filename.dirname, load_file(filename))
|
15
|
+
new(root: filename.dirname, configuration: load_file(filename))
|
15
16
|
else
|
16
17
|
# No configuration file
|
17
|
-
new(path)
|
18
|
+
new(root: path)
|
18
19
|
end
|
19
20
|
end
|
20
21
|
|
22
|
+
def from_string(config)
|
23
|
+
new(configuration: YAML.load(config), should_resolve_requires: false)
|
24
|
+
end
|
25
|
+
|
26
|
+
def from_hash(config)
|
27
|
+
new(configuration: config, should_resolve_requires: false)
|
28
|
+
end
|
29
|
+
|
21
30
|
def find(root, needle = DOTFILE)
|
22
31
|
Pathname.new(root).descend.reverse_each do |path|
|
23
32
|
pathname = path.join(needle)
|
@@ -29,81 +38,124 @@ module ThemeCheck
|
|
29
38
|
def load_file(absolute_path)
|
30
39
|
YAML.load_file(absolute_path)
|
31
40
|
end
|
41
|
+
|
42
|
+
def default
|
43
|
+
@default ||= load_file(DEFAULT_CONFIG)
|
44
|
+
end
|
32
45
|
end
|
33
46
|
|
34
|
-
def initialize(root, configuration
|
35
|
-
@configuration = configuration
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
47
|
+
def initialize(root: nil, configuration: nil, should_resolve_requires: true)
|
48
|
+
@configuration = if configuration
|
49
|
+
validate_configuration(configuration)
|
50
|
+
else
|
51
|
+
{}
|
52
|
+
end
|
53
|
+
merge_with_default_configuration!(@configuration)
|
54
|
+
|
55
|
+
@root = if root && @configuration.key?("root")
|
56
|
+
Pathname.new(root).join(@configuration["root"])
|
57
|
+
elsif root
|
58
|
+
Pathname.new(root)
|
40
59
|
end
|
60
|
+
|
41
61
|
@only_categories = []
|
42
62
|
@exclude_categories = []
|
43
63
|
@auto_correct = false
|
44
|
-
|
64
|
+
|
65
|
+
resolve_requires if @root && should_resolve_requires
|
66
|
+
end
|
67
|
+
|
68
|
+
def [](name)
|
69
|
+
@configuration[name]
|
45
70
|
end
|
46
71
|
|
47
72
|
def to_h
|
48
73
|
@configuration
|
49
74
|
end
|
50
75
|
|
51
|
-
def
|
52
|
-
|
53
|
-
|
54
|
-
default_configuration.merge(@checks).each do |check_name, properties|
|
55
|
-
if @checks[check_name] && !default_configuration[check_name].nil?
|
56
|
-
valid_properties = valid_check_configuration(check_name)
|
57
|
-
properties = properties.merge(valid_properties)
|
58
|
-
end
|
76
|
+
def check_configurations
|
77
|
+
@check_configurations ||= @configuration.select { |name, _| check_name?(name) }
|
78
|
+
end
|
59
79
|
|
60
|
-
|
80
|
+
def enabled_checks
|
81
|
+
@enabled_checks ||= check_configurations.map do |check_name, options|
|
82
|
+
next unless options["enabled"]
|
61
83
|
|
62
|
-
options = properties.transform_keys(&:to_sym)
|
63
84
|
check_class = ThemeCheck.const_get(check_name)
|
85
|
+
|
64
86
|
next if exclude_categories.include?(check_class.category)
|
65
87
|
next if only_categories.any? && !only_categories.include?(check_class.category)
|
66
88
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
89
|
+
options_for_check = options.transform_keys(&:to_sym)
|
90
|
+
options_for_check.delete(:enabled)
|
91
|
+
check = check_class.new(**options_for_check)
|
92
|
+
check.options = options_for_check
|
93
|
+
check
|
94
|
+
end.compact
|
95
|
+
end
|
71
96
|
|
72
|
-
|
97
|
+
def ignored_patterns
|
98
|
+
self["ignore"] || []
|
73
99
|
end
|
74
100
|
|
75
101
|
private
|
76
102
|
|
77
|
-
def
|
78
|
-
|
103
|
+
def check_name?(name)
|
104
|
+
name.start_with?(/[A-Z]/)
|
79
105
|
end
|
80
106
|
|
81
|
-
def
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
107
|
+
def validate_configuration(configuration, default_configuration = self.class.default, parent_keys = [])
|
108
|
+
valid_configuration = {}
|
109
|
+
|
110
|
+
configuration.each do |key, value|
|
111
|
+
# No validation possible unless we have a default to compare to
|
112
|
+
unless default_configuration
|
113
|
+
valid_configuration[key] = value
|
114
|
+
next
|
115
|
+
end
|
116
|
+
|
117
|
+
default = default_configuration[key]
|
118
|
+
keys = parent_keys + [key]
|
119
|
+
name = keys.join(".")
|
120
|
+
|
121
|
+
if check_name?(key)
|
122
|
+
if value.is_a?(Hash)
|
123
|
+
valid_configuration[key] = validate_configuration(value, default, keys)
|
124
|
+
else
|
125
|
+
warn("bad configuration type for #{name}: expected a Hash, got #{value.inspect}")
|
88
126
|
end
|
127
|
+
elsif default.nil?
|
128
|
+
warn("unknown configuration: #{name}")
|
129
|
+
elsif BOOLEAN.include?(default) && !BOOLEAN.include?(value)
|
130
|
+
warn("bad configuration type for #{name}: expected true or false, got #{value.inspect}")
|
131
|
+
elsif !BOOLEAN.include?(default) && default.class != value.class
|
132
|
+
warn("bad configuration type for #{name}: expected a #{default.class}, got #{value.inspect}")
|
133
|
+
else
|
134
|
+
valid_configuration[key] = value
|
89
135
|
end
|
90
136
|
end
|
91
|
-
end
|
92
137
|
|
93
|
-
|
94
|
-
|
138
|
+
valid_configuration
|
139
|
+
end
|
95
140
|
|
96
|
-
|
141
|
+
def merge_with_default_configuration!(configuration, default_configuration = self.class.default)
|
142
|
+
default_configuration.each do |key, default|
|
143
|
+
value = configuration[key]
|
97
144
|
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
145
|
+
case value
|
146
|
+
when Hash
|
147
|
+
merge_with_default_configuration!(value, default)
|
148
|
+
when nil
|
149
|
+
configuration[key] = default
|
103
150
|
end
|
104
151
|
end
|
152
|
+
configuration
|
153
|
+
end
|
105
154
|
|
106
|
-
|
155
|
+
def resolve_requires
|
156
|
+
self["require"]&.each do |path|
|
157
|
+
require(File.join(@root, path))
|
158
|
+
end
|
107
159
|
end
|
108
160
|
end
|
109
161
|
end
|
@@ -9,27 +9,23 @@ module ThemeCheck
|
|
9
9
|
def insert_after(node, content)
|
10
10
|
line = @template.full_line(node.line_number)
|
11
11
|
line.insert(node.range[1] + 1, content)
|
12
|
-
@template.update!
|
13
12
|
end
|
14
13
|
|
15
14
|
def insert_before(node, content)
|
16
15
|
line = @template.full_line(node.line_number)
|
17
16
|
line.insert(node.range[0], content)
|
18
|
-
@template.update!
|
19
17
|
end
|
20
18
|
|
21
19
|
def replace(node, content)
|
22
20
|
line = @template.full_line(node.line_number)
|
23
21
|
line[node.range[0]..node.range[1]] = content
|
24
22
|
node.markup = content
|
25
|
-
@template.update!
|
26
23
|
end
|
27
24
|
|
28
25
|
def wrap(node, insert_before, insert_after)
|
29
26
|
line = @template.full_line(node.line_number)
|
30
27
|
line.insert(node.range[0], insert_before)
|
31
28
|
line.insert(node.range[1] + 1 + insert_before.length, insert_after)
|
32
|
-
@template.update!
|
33
29
|
end
|
34
30
|
end
|
35
31
|
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
class DisabledChecks
|
5
|
+
DISABLE_START = 'theme-check-disable'
|
6
|
+
DISABLE_END = 'theme-check-enable'
|
7
|
+
DISABLE_PREFIX_PATTERN = /#{DISABLE_START}|#{DISABLE_END}/
|
8
|
+
|
9
|
+
ACTION_DISABLE_CHECKS = :disable
|
10
|
+
ACTION_ENABLE_CHECKS = :enable
|
11
|
+
ACTION_UNRELATED_COMMENT = :unrelated
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@disabled = []
|
15
|
+
@all_disabled = false
|
16
|
+
@full_document_disabled = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def update(node)
|
20
|
+
text = comment_text(node)
|
21
|
+
|
22
|
+
if start_disabling?(text)
|
23
|
+
@disabled = checks_from_text(text)
|
24
|
+
@all_disabled = @disabled.empty?
|
25
|
+
|
26
|
+
if node&.line_number == 1
|
27
|
+
@full_document_disabled = true
|
28
|
+
end
|
29
|
+
elsif stop_disabling?(text)
|
30
|
+
checks = checks_from_text(text)
|
31
|
+
@disabled = checks.empty? ? [] : @disabled - checks
|
32
|
+
|
33
|
+
@all_disabled = false
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Whether any checks are currently disabled
|
38
|
+
def any?
|
39
|
+
!@disabled.empty? || @all_disabled
|
40
|
+
end
|
41
|
+
|
42
|
+
# Whether all checks should be disabled
|
43
|
+
def all_disabled?
|
44
|
+
@all_disabled
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get a list of all the individual disabled checks
|
48
|
+
def all
|
49
|
+
@disabled
|
50
|
+
end
|
51
|
+
|
52
|
+
# If the first line of the document is a theme-check-disable comment
|
53
|
+
def full_document_disabled?
|
54
|
+
@full_document_disabled
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def comment_text(node)
|
60
|
+
node.value.nodelist.join
|
61
|
+
end
|
62
|
+
|
63
|
+
def start_disabling?(text)
|
64
|
+
text.strip.starts_with?(DISABLE_START)
|
65
|
+
end
|
66
|
+
|
67
|
+
def stop_disabling?(text)
|
68
|
+
text.strip.starts_with?(DISABLE_END)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Return a list of checks from a theme-check-disable comment
|
72
|
+
# Returns [] if all checks are meant to be disabled
|
73
|
+
def checks_from_text(text)
|
74
|
+
text.gsub(DISABLE_PREFIX_PATTERN, '').strip.split(',').map(&:strip)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|