theme-check 0.2.2 → 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.
@@ -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
@@ -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
- theme = ThemeCheck::Theme.new(@config.root)
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
@@ -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 = 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
63
  @auto_correct = false
44
- resolve_requires
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 enabled_checks
52
- checks = []
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
- 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"]
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
- check = check_class.new(**options)
68
- check.options = options
69
- checks << check
70
- 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
71
96
 
72
- checks
97
+ def ignored_patterns
98
+ self["ignore"] || []
73
99
  end
74
100
 
75
101
  private
76
102
 
77
- def default_configuration
78
- @default_configuration ||= Config.load_file(DEFAULT_CONFIG)
103
+ def check_name?(name)
104
+ name.start_with?(/[A-Z]/)
79
105
  end
80
106
 
81
- def resolve_requires
82
- if @checks.key?("require")
83
- @checks.delete("require").tap do |paths|
84
- paths.each do |path|
85
- if path.start_with?('.')
86
- require(File.join(@root, path))
87
- 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}")
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
- def valid_check_configuration(check_name)
94
- default_properties = default_configuration[check_name]
138
+ valid_configuration
139
+ end
95
140
 
96
- valid = {}
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
- @checks[check_name].each do |property, value|
99
- if !default_properties.key?(property)
100
- warn("#{check_name} does not support #{property} parameter.")
101
- else
102
- valid[property] = value
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
- valid
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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ require "pathname"
3
+
4
+ module ThemeCheck
5
+ class FileSystemStorage < Storage
6
+ attr_reader :root
7
+
8
+ def initialize(root, ignored_patterns: [])
9
+ @root = Pathname.new(root)
10
+ @ignored_patterns = ignored_patterns
11
+ @files = {}
12
+ end
13
+
14
+ def path(relative_path)
15
+ @root.join(relative_path)
16
+ end
17
+
18
+ def read(relative_path)
19
+ file(relative_path).read
20
+ end
21
+
22
+ def write(relative_path, content)
23
+ file(relative_path).write(content)
24
+ end
25
+
26
+ def files
27
+ @file_array ||= glob("**/*")
28
+ .map { |path| path.relative_path_from(@root).to_s }
29
+ end
30
+
31
+ def directories
32
+ @directories ||= glob('*')
33
+ .select { |f| File.directory?(f) }
34
+ .map { |f| f.relative_path_from(@root).to_s }
35
+ end
36
+
37
+ private
38
+
39
+ def glob(pattern)
40
+ @root.glob(pattern).reject do |path|
41
+ relative_path = path.relative_path_from(@root)
42
+ @ignored_patterns.any? { |ignored| relative_path.fnmatch?(ignored) }
43
+ end
44
+ end
45
+
46
+ def file(name)
47
+ return @files[name] if @files[name]
48
+ @files[name] = root.join(name)
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An in-memory storage is not written to disk. The reasons why you'd
4
+ # want to do that are your own. The idea is to not write to disk
5
+ # something that doesn't need to be there. If you have your template
6
+ # as a big hash already, leave it like that and save yourself some IO.
7
+ module ThemeCheck
8
+ class InMemoryStorage < Storage
9
+ def initialize(files)
10
+ @files = files
11
+ end
12
+
13
+ def path(name)
14
+ name
15
+ end
16
+
17
+ def read(name)
18
+ @files[name]
19
+ end
20
+
21
+ def write(name, content)
22
+ @files[name] = content
23
+ end
24
+
25
+ def files
26
+ @values ||= @files.keys
27
+ end
28
+
29
+ def directories
30
+ @directories ||= @files
31
+ .keys
32
+ .flat_map { |relative_path| Pathname.new(relative_path).ascend.to_a }
33
+ .map(&:to_s)
34
+ .uniq
35
+ end
36
+ end
37
+ end
@@ -4,22 +4,20 @@ require "pathname"
4
4
 
5
5
  module ThemeCheck
6
6
  class JsonFile
7
- attr_reader :path
8
-
9
- def initialize(path, root)
10
- @path = Pathname(path)
11
- @root = Pathname(root)
7
+ def initialize(relative_path, storage)
8
+ @relative_path = relative_path
9
+ @storage = storage
12
10
  @loaded = false
13
11
  @content = nil
14
12
  @parser_error = nil
15
13
  end
16
14
 
17
- def relative_path
18
- @path.relative_path_from(@root)
15
+ def path
16
+ @storage.path(@relative_path)
19
17
  end
20
18
 
21
- def name
22
- relative_path.sub_ext('').to_s
19
+ def relative_path
20
+ @relative_pathname ||= Pathname.new(@relative_path)
23
21
  end
24
22
 
25
23
  def content
@@ -32,12 +30,16 @@ module ThemeCheck
32
30
  @parser_error
33
31
  end
34
32
 
33
+ def name
34
+ relative_path.sub_ext('').to_s
35
+ end
36
+
35
37
  private
36
38
 
37
39
  def load!
38
40
  return if @loaded
39
41
 
40
- @content = JSON.parse(File.read(@path))
42
+ @content = JSON.parse(@storage.read(@relative_path))
41
43
  rescue JSON::ParserError => e
42
44
  @parser_error = e
43
45
  ensure