theme-check 0.1.0 → 0.3.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CONTRIBUTING.md +2 -0
  4. data/README.md +62 -1
  5. data/RELEASING.md +41 -0
  6. data/Rakefile +38 -4
  7. data/config/default.yml +18 -0
  8. data/data/shopify_liquid/deprecated_filters.yml +10 -0
  9. data/data/shopify_liquid/plus_objects.yml +15 -0
  10. data/dev.yml +2 -0
  11. data/lib/theme_check.rb +5 -0
  12. data/lib/theme_check/analyzer.rb +15 -5
  13. data/lib/theme_check/check.rb +11 -0
  14. data/lib/theme_check/checks.rb +10 -0
  15. data/lib/theme_check/checks/deprecated_filter.rb +22 -0
  16. data/lib/theme_check/checks/missing_enable_comment.rb +31 -0
  17. data/lib/theme_check/checks/missing_required_template_files.rb +12 -12
  18. data/lib/theme_check/checks/parser_blocking_javascript.rb +55 -0
  19. data/lib/theme_check/checks/space_inside_braces.rb +19 -7
  20. data/lib/theme_check/checks/template_length.rb +11 -3
  21. data/lib/theme_check/checks/undefined_object.rb +107 -34
  22. data/lib/theme_check/checks/valid_html_translation.rb +2 -2
  23. data/lib/theme_check/cli.rb +18 -4
  24. data/lib/theme_check/config.rb +97 -44
  25. data/lib/theme_check/corrector.rb +31 -0
  26. data/lib/theme_check/disabled_checks.rb +77 -0
  27. data/lib/theme_check/file_system_storage.rb +51 -0
  28. data/lib/theme_check/in_memory_storage.rb +37 -0
  29. data/lib/theme_check/json_file.rb +12 -10
  30. data/lib/theme_check/language_server/handler.rb +38 -13
  31. data/lib/theme_check/language_server/server.rb +2 -2
  32. data/lib/theme_check/liquid_check.rb +2 -2
  33. data/lib/theme_check/node.rb +13 -0
  34. data/lib/theme_check/offense.rb +25 -9
  35. data/lib/theme_check/packager.rb +51 -0
  36. data/lib/theme_check/printer.rb +13 -4
  37. data/lib/theme_check/shopify_liquid.rb +1 -0
  38. data/lib/theme_check/shopify_liquid/deprecated_filter.rb +28 -0
  39. data/lib/theme_check/shopify_liquid/object.rb +6 -0
  40. data/lib/theme_check/storage.rb +25 -0
  41. data/lib/theme_check/template.rb +32 -10
  42. data/lib/theme_check/theme.rb +14 -9
  43. data/lib/theme_check/version.rb +1 -1
  44. data/lib/theme_check/visitor.rb +14 -3
  45. data/packaging/homebrew/theme_check.base.rb +98 -0
  46. metadata +21 -7
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Reports errors when trying to use parser-blocking script tags
4
+ class ParserBlockingJavaScript < LiquidCheck
5
+ severity :error
6
+ category :liquid
7
+
8
+ PARSER_BLOCKING_SCRIPT_TAG = %r{
9
+ <script # Find the start of a script tag
10
+ (?=(?:[^>]|\n|\r)+?src=)+? # Make sure src= is in the script with a lookahead
11
+ (?:(?!defer|async|type=["']module['"]).)*? # Find tags that don't have defer|async|type="module"
12
+ >
13
+ }xim
14
+ SCRIPT_TAG_FILTER = /\{\{[^}]+script_tag\s+\}\}/
15
+
16
+ def on_document(node)
17
+ @source = node.template.source
18
+ @node = node
19
+ record_offenses
20
+ end
21
+
22
+ private
23
+
24
+ def record_offenses
25
+ record_offenses_from_regex(
26
+ message: "Missing async or defer attribute on script tag",
27
+ regex: PARSER_BLOCKING_SCRIPT_TAG,
28
+ )
29
+ record_offenses_from_regex(
30
+ message: "The script_tag filter is parser-blocking. Use a script tag with the async or defer attribute for better performance",
31
+ regex: SCRIPT_TAG_FILTER,
32
+ )
33
+ end
34
+
35
+ # The trickiness here is matching on scripts that are defined on
36
+ # multiple lines (or repeat matches). This makes the line_number
37
+ # calculation a bit weird. So instead, we traverse the string in
38
+ # a very imperative way.
39
+ def record_offenses_from_regex(regex: nil, message: nil)
40
+ i = 0
41
+ while (i = @source.index(regex, i))
42
+ script = @source.match(regex, i)[0]
43
+
44
+ add_offense(
45
+ message,
46
+ node: @node,
47
+ markup: script,
48
+ line_number: @source[0...i].count("\n") + 1
49
+ )
50
+
51
+ i += script.size
52
+ end
53
+ end
54
+ end
55
+ end
@@ -11,6 +11,7 @@ module ThemeCheck
11
11
 
12
12
  def on_node(node)
13
13
  return unless node.markup
14
+ return if :assign == node.type_name
14
15
 
15
16
  outside_of_strings(node.markup) do |chunk|
16
17
  chunk.scan(/([,:]) +/) do |_match|
@@ -45,13 +46,24 @@ module ThemeCheck
45
46
  def on_variable(node)
46
47
  return if @ignore
47
48
  if node.markup[0] != " "
48
- add_offense("Space missing after '{{'", node: node)
49
- elsif node.markup[-1] != " "
50
- add_offense("Space missing before '}}'", node: node)
51
- elsif node.markup[1] == " "
52
- add_offense("Too many spaces after '{{'", node: node)
53
- elsif node.markup[-2] == " "
54
- add_offense("Too many spaces before '}}'", node: node)
49
+ add_offense("Space missing after '{{'", node: node) do |corrector|
50
+ corrector.insert_before(node, " ")
51
+ end
52
+ end
53
+ if node.markup[-1] != " "
54
+ add_offense("Space missing before '}}'", node: node) do |corrector|
55
+ corrector.insert_after(node, " ")
56
+ end
57
+ end
58
+ if node.markup[0] == " " && node.markup[1] == " "
59
+ add_offense("Too many spaces after '{{'", node: node) do |corrector|
60
+ corrector.replace(node, " #{node.markup.lstrip}")
61
+ end
62
+ end
63
+ if node.markup[-1] == " " && node.markup[-2] == " "
64
+ add_offense("Too many spaces before '}}'", node: node) do |corrector|
65
+ corrector.replace(node, "#{node.markup.rstrip} ")
66
+ end
55
67
  end
56
68
  end
57
69
  end
@@ -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 on_document(node)
12
- lines = node.template.source.count("\n")
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
@@ -11,76 +11,149 @@ module ThemeCheck
11
11
  @all_assigns = {}
12
12
  @all_captures = {}
13
13
  @all_forloops = {}
14
+ @all_renders = {}
14
15
  end
15
16
 
16
- attr_reader :all_variable_lookups, :all_assigns, :all_captures, :all_forloops
17
- def all
17
+ attr_reader :all_assigns, :all_captures, :all_forloops
18
+
19
+ def add_render(name:, node:)
20
+ @all_renders[name] = node
21
+ end
22
+
23
+ def add_variable_lookup(name:, node:)
24
+ line_number = node.parent.line_number
25
+ key = [name, line_number]
26
+ @all_variable_lookups[key] = node
27
+ end
28
+
29
+ def all_variables
18
30
  all_assigns.keys + all_captures.keys + all_forloops.keys
19
31
  end
32
+
33
+ def each_snippet
34
+ @all_renders.each do |(name, info)|
35
+ yield [name, info]
36
+ end
37
+ end
38
+
39
+ def each_variable_lookup(unique_keys = false)
40
+ seen = Set.new
41
+ @all_variable_lookups.each do |(key, info)|
42
+ name, _line_number = key
43
+
44
+ next if unique_keys && seen.include?(name)
45
+ seen << name
46
+
47
+ yield [key, info]
48
+ end
49
+ end
20
50
  end
21
51
 
22
52
  def initialize
23
- @templates = {}
24
- @used_snippets = {}
53
+ @files = {}
25
54
  end
26
55
 
27
56
  def on_document(node)
28
- @templates[node.template.name] = TemplateInfo.new
57
+ @files[node.template.name] = TemplateInfo.new
29
58
  end
30
59
 
31
60
  def on_assign(node)
32
- @templates[node.template.name].all_assigns[node.value.to] = node
61
+ @files[node.template.name].all_assigns[node.value.to] = node
33
62
  end
34
63
 
35
64
  def on_capture(node)
36
- @templates[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
65
+ @files[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
37
66
  end
38
67
 
39
68
  def on_for(node)
40
- @templates[node.template.name].all_forloops[node.value.variable_name] = node
69
+ @files[node.template.name].all_forloops[node.value.variable_name] = node
70
+ end
71
+
72
+ def on_include(_node)
73
+ # NOOP: we purposely do nothing on `include` since it is deprecated
74
+ # https://shopify.dev/docs/themes/liquid/reference/tags/deprecated-tags#include
41
75
  end
42
76
 
43
- def on_include(node)
77
+ def on_render(node)
44
78
  return unless node.value.template_name_expr.is_a?(String)
45
- name = "snippets/#{node.value.template_name_expr}"
46
- @used_snippets[name] ||= Set.new
47
- @used_snippets[name] << node.template.name
79
+
80
+ snippet_name = "snippets/#{node.value.template_name_expr}"
81
+ @files[node.template.name].add_render(
82
+ name: snippet_name,
83
+ node: node,
84
+ )
48
85
  end
49
86
 
50
87
  def on_variable_lookup(node)
51
- @templates[node.template.name].all_variable_lookups[node.value.name] = node
88
+ @files[node.template.name].add_variable_lookup(
89
+ name: node.value.name,
90
+ node: node,
91
+ )
52
92
  end
53
93
 
54
94
  def on_end
55
- foster_snippets = theme.snippets
56
- .reject { |t| @used_snippets.include?(t.name) }
57
- .map(&:name)
58
-
59
- @templates.each do |(template_name, info)|
60
- next if foster_snippets.include?(template_name)
61
- if (all_including_templates = @used_snippets[template_name])
62
- all_including_templates.each do |including_template|
63
- including_template_info = @templates[including_template]
64
- check_object(info.all_variable_lookups, including_template_info.all)
65
- end
95
+ all_global_objects = ThemeCheck::ShopifyLiquid::Object.labels
96
+ all_global_objects.freeze
97
+
98
+ shopify_plus_objects = ThemeCheck::ShopifyLiquid::Object.plus_labels
99
+ shopify_plus_objects.freeze
100
+
101
+ each_template do |(name, info)|
102
+ if 'templates/customers/reset_password' == name
103
+ # NOTE: `email` is exceptionally exposed as a theme object in
104
+ # the customers' reset password template
105
+ check_object(info, all_global_objects + ['email'])
106
+ elsif 'layout/checkout' == name
107
+ # NOTE: Shopify Plus has exceptionally exposed objects in
108
+ # the checkout template
109
+ # https://shopify.dev/docs/themes/theme-templates/checkout-liquid#optional-objects
110
+ check_object(info, all_global_objects + shopify_plus_objects)
66
111
  else
67
- all = info.all
68
- all += ['email'] if 'templates/customers/reset_password' == template_name
69
- check_object(info.all_variable_lookups, all)
112
+ check_object(info, all_global_objects)
70
113
  end
71
114
  end
72
115
  end
73
116
 
74
- def check_object(variable_lookups, all)
75
- variable_lookups.each do |(name, node)|
76
- next if all.include?(name)
77
- next if ThemeCheck::ShopifyLiquid::Object.labels.include?(name)
117
+ def each_template
118
+ @files.each do |(name, info)|
119
+ next if name.starts_with?('snippets/')
120
+ yield [name, info]
121
+ end
122
+ end
123
+ private :each_template
124
+
125
+ def check_object(info, all_global_objects, render_node = nil)
126
+ check_undefined(info, all_global_objects, render_node)
78
127
 
79
- parent = node.parent
80
- parent = parent.parent if :variable_lookup == parent.type_name
81
- add_offense("Undefined object `#{name}`", node: parent)
128
+ info.each_snippet do |(snippet_name, node)|
129
+ snippet_info = @files[snippet_name]
130
+ next unless snippet_info # NOTE: undefined snippet
131
+
132
+ snippet_variables = node.value.attributes.keys +
133
+ Array[node.value.instance_variable_get("@alias_name")]
134
+ check_object(snippet_info, all_global_objects + snippet_variables, node)
82
135
  end
83
136
  end
84
137
  private :check_object
138
+
139
+ def check_undefined(info, all_global_objects, render_node)
140
+ all_variables = info.all_variables
141
+
142
+ info.each_variable_lookup(!!render_node) do |(key, node)|
143
+ name, _line_number = key
144
+ next if all_variables.include?(name)
145
+ next if all_global_objects.include?(name)
146
+
147
+ node = node.parent
148
+ node = node.parent if %i(condition variable_lookup).include?(node.type_name)
149
+
150
+ if render_node
151
+ add_offense("Missing argument `#{name}`", node: render_node)
152
+ else
153
+ add_offense("Undefined object `#{name}`", node: node)
154
+ end
155
+ end
156
+ end
157
+ private :check_undefined
85
158
  end
86
159
  end
@@ -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 parse_and_add_offence(key, value)
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
- parse_and_add_offence(keys.join('.'), value)
39
+ parse_and_add_offense(keys.join('.'), value)
40
40
  end
41
41
  end
42
42
  end
@@ -10,7 +10,9 @@ module ThemeCheck
10
10
  -c, [--category] # Only run this category of checks
11
11
  -x, [--exclude-category] # Exclude this category of checks
12
12
  -l, [--list] # List enabled checks
13
+ -a, [--auto-correct] # Automatically fix offenses
13
14
  -h, [--help] # Show this. Hi!
15
+ -v, [--version] # Print Theme Check version
14
16
 
15
17
  Description:
16
18
  Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
@@ -25,18 +27,23 @@ module ThemeCheck
25
27
  command = :check
26
28
  only_categories = []
27
29
  exclude_categories = []
30
+ auto_correct = false
28
31
 
29
32
  args = argv.dup
30
33
  while (arg = args.shift)
31
34
  case arg
32
35
  when "--help", "-h"
33
36
  raise Abort, USAGE
37
+ when "--version", "-v"
38
+ command = :version
34
39
  when "--category", "-c"
35
40
  only_categories << args.shift.to_sym
36
41
  when "--exclude-category", "-x"
37
42
  exclude_categories << args.shift.to_sym
38
43
  when "--list", "-l"
39
44
  command = :list
45
+ when "--auto-correct", "-a"
46
+ auto_correct = true
40
47
  else
41
48
  path = arg
42
49
  end
@@ -45,6 +52,7 @@ module ThemeCheck
45
52
  @config = ThemeCheck::Config.from_path(path)
46
53
  @config.only_categories = only_categories
47
54
  @config.exclude_categories = exclude_categories
55
+ @config.auto_correct = auto_correct
48
56
 
49
57
  send(command)
50
58
  end
@@ -63,16 +71,22 @@ module ThemeCheck
63
71
  puts @config.enabled_checks
64
72
  end
65
73
 
74
+ def version
75
+ puts ThemeCheck::VERSION
76
+ end
77
+
66
78
  def check
67
79
  puts "Checking #{@config.root} ..."
68
- theme = ThemeCheck::Theme.new(@config.root)
80
+ storage = ThemeCheck::FileSystemStorage.new(@config.root, ignored_patterns: @config.ignored_patterns)
81
+ theme = ThemeCheck::Theme.new(storage)
69
82
  if theme.all.empty?
70
83
  raise Abort, "No templates found.\n#{USAGE}"
71
84
  end
72
- analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks)
85
+ analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks, @config.auto_correct)
73
86
  analyzer.analyze_theme
74
- ThemeCheck::Printer.new.print(theme, analyzer.offenses)
75
- raise Abort, "" if analyzer.offenses.any?
87
+ analyzer.correct_offenses
88
+ ThemeCheck::Printer.new.print(theme, analyzer.offenses, @config.auto_correct)
89
+ raise Abort, "" if analyzer.uncorrectable_offenses.any?
76
90
  end
77
91
  end
78
92
  end
@@ -4,20 +4,29 @@ 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
- attr_accessor :only_categories, :exclude_categories
10
+ attr_accessor :only_categories, :exclude_categories, :auto_correct
10
11
 
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,80 +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 = nil)
35
- @configuration = configuration || {}
36
- @checks = @configuration.dup
37
- @root = Pathname.new(root)
38
- if @checks.key?("root")
39
- @root = @root.join(@checks.delete("root"))
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
- resolve_requires
63
+ @auto_correct = false
64
+
65
+ resolve_requires if @root && should_resolve_requires
66
+ end
67
+
68
+ def [](name)
69
+ @configuration[name]
44
70
  end
45
71
 
46
72
  def to_h
47
73
  @configuration
48
74
  end
49
75
 
50
- def enabled_checks
51
- checks = []
52
-
53
- default_configuration.merge(@checks).each do |check_name, properties|
54
- if @checks[check_name] && !default_configuration[check_name].nil?
55
- valid_properties = valid_check_configuration(check_name)
56
- properties = properties.merge(valid_properties)
57
- end
76
+ def check_configurations
77
+ @check_configurations ||= @configuration.select { |name, _| check_name?(name) }
78
+ end
58
79
 
59
- next if properties.delete('enabled') == false
80
+ def enabled_checks
81
+ @enabled_checks ||= check_configurations.map do |check_name, options|
82
+ next unless options["enabled"]
60
83
 
61
- options = properties.transform_keys(&:to_sym)
62
84
  check_class = ThemeCheck.const_get(check_name)
85
+
63
86
  next if exclude_categories.include?(check_class.category)
64
87
  next if only_categories.any? && !only_categories.include?(check_class.category)
65
88
 
66
- check = check_class.new(**options)
67
- check.options = options
68
- checks << check
69
- end
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
70
96
 
71
- checks
97
+ def ignored_patterns
98
+ self["ignore"] || []
72
99
  end
73
100
 
74
101
  private
75
102
 
76
- def default_configuration
77
- @default_configuration ||= Config.load_file(DEFAULT_CONFIG)
103
+ def check_name?(name)
104
+ name.start_with?(/[A-Z]/)
78
105
  end
79
106
 
80
- def resolve_requires
81
- if @checks.key?("require")
82
- @checks.delete("require").tap do |paths|
83
- paths.each do |path|
84
- if path.start_with?('.')
85
- require(File.join(@root, path))
86
- end
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}")
87
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
88
135
  end
89
136
  end
90
- end
91
137
 
92
- def valid_check_configuration(check_name)
93
- default_properties = default_configuration[check_name]
138
+ valid_configuration
139
+ end
94
140
 
95
- valid = {}
141
+ def merge_with_default_configuration!(configuration, default_configuration = self.class.default)
142
+ default_configuration.each do |key, default|
143
+ value = configuration[key]
96
144
 
97
- @checks[check_name].each do |property, value|
98
- if !default_properties.key?(property)
99
- warn("#{check_name} does not support #{property} parameter.")
100
- else
101
- valid[property] = value
145
+ case value
146
+ when Hash
147
+ merge_with_default_configuration!(value, default)
148
+ when nil
149
+ configuration[key] = default
102
150
  end
103
151
  end
152
+ configuration
153
+ end
104
154
 
105
- valid
155
+ def resolve_requires
156
+ self["require"]&.each do |path|
157
+ require(File.join(@root, path))
158
+ end
106
159
  end
107
160
  end
108
161
  end