theme-check 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/.github/probots.yml +3 -0
  3. data/.github/workflows/theme-check.yml +28 -0
  4. data/.gitignore +13 -0
  5. data/.rubocop.yml +18 -0
  6. data/CODE_OF_CONDUCT.md +74 -0
  7. data/CONTRIBUTING.md +132 -0
  8. data/Gemfile +26 -0
  9. data/Guardfile +7 -0
  10. data/LICENSE.md +8 -0
  11. data/README.md +71 -0
  12. data/Rakefile +14 -0
  13. data/bin/liquid-server +4 -0
  14. data/config/default.yml +63 -0
  15. data/data/shopify_liquid/filters.yml +174 -0
  16. data/data/shopify_liquid/objects.yml +81 -0
  17. data/dev.yml +23 -0
  18. data/docs/preview.png +0 -0
  19. data/exe/theme-check +6 -0
  20. data/exe/theme-check-language-server +12 -0
  21. data/lib/theme_check.rb +25 -0
  22. data/lib/theme_check/analyzer.rb +43 -0
  23. data/lib/theme_check/check.rb +92 -0
  24. data/lib/theme_check/checks.rb +12 -0
  25. data/lib/theme_check/checks/convert_include_to_render.rb +13 -0
  26. data/lib/theme_check/checks/default_locale.rb +12 -0
  27. data/lib/theme_check/checks/liquid_tag.rb +48 -0
  28. data/lib/theme_check/checks/matching_schema_translations.rb +73 -0
  29. data/lib/theme_check/checks/matching_translations.rb +29 -0
  30. data/lib/theme_check/checks/missing_required_template_files.rb +29 -0
  31. data/lib/theme_check/checks/missing_template.rb +25 -0
  32. data/lib/theme_check/checks/nested_snippet.rb +46 -0
  33. data/lib/theme_check/checks/required_directories.rb +24 -0
  34. data/lib/theme_check/checks/required_layout_theme_object.rb +40 -0
  35. data/lib/theme_check/checks/space_inside_braces.rb +58 -0
  36. data/lib/theme_check/checks/syntax_error.rb +29 -0
  37. data/lib/theme_check/checks/template_length.rb +18 -0
  38. data/lib/theme_check/checks/translation_key_exists.rb +35 -0
  39. data/lib/theme_check/checks/undefined_object.rb +86 -0
  40. data/lib/theme_check/checks/unknown_filter.rb +25 -0
  41. data/lib/theme_check/checks/unused_assign.rb +54 -0
  42. data/lib/theme_check/checks/unused_snippet.rb +34 -0
  43. data/lib/theme_check/checks/valid_html_translation.rb +43 -0
  44. data/lib/theme_check/checks/valid_json.rb +14 -0
  45. data/lib/theme_check/checks/valid_schema.rb +13 -0
  46. data/lib/theme_check/checks_tracking.rb +8 -0
  47. data/lib/theme_check/cli.rb +78 -0
  48. data/lib/theme_check/config.rb +108 -0
  49. data/lib/theme_check/json_check.rb +11 -0
  50. data/lib/theme_check/json_file.rb +47 -0
  51. data/lib/theme_check/json_helpers.rb +9 -0
  52. data/lib/theme_check/language_server.rb +11 -0
  53. data/lib/theme_check/language_server/handler.rb +117 -0
  54. data/lib/theme_check/language_server/server.rb +140 -0
  55. data/lib/theme_check/liquid_check.rb +13 -0
  56. data/lib/theme_check/locale_diff.rb +69 -0
  57. data/lib/theme_check/node.rb +117 -0
  58. data/lib/theme_check/offense.rb +104 -0
  59. data/lib/theme_check/parsing_helpers.rb +17 -0
  60. data/lib/theme_check/printer.rb +74 -0
  61. data/lib/theme_check/shopify_liquid.rb +3 -0
  62. data/lib/theme_check/shopify_liquid/filter.rb +18 -0
  63. data/lib/theme_check/shopify_liquid/object.rb +16 -0
  64. data/lib/theme_check/tags.rb +146 -0
  65. data/lib/theme_check/template.rb +73 -0
  66. data/lib/theme_check/theme.rb +60 -0
  67. data/lib/theme_check/version.rb +4 -0
  68. data/lib/theme_check/visitor.rb +37 -0
  69. data/theme-check.gemspec +28 -0
  70. metadata +156 -0
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class TemplateLength < LiquidCheck
4
+ severity :suggestion
5
+ category :liquid
6
+
7
+ def initialize(max_length: 200)
8
+ @max_length = max_length
9
+ end
10
+
11
+ def on_document(node)
12
+ lines = node.template.source.count("\n")
13
+ if lines > @max_length
14
+ add_offense("Template has too many lines [#{lines}/#{@max_length}]", template: node.template)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class TranslationKeyExists < LiquidCheck
4
+ severity :error
5
+ category :translation
6
+
7
+ def on_variable(node)
8
+ return unless @theme.default_locale_json&.content&.is_a?(Hash)
9
+
10
+ return unless node.value.filters.any? { |name, _| name == "t" || name == "translate" }
11
+ return unless (key_node = node.children.first)
12
+ return unless key_node.value.is_a?(String)
13
+
14
+ unless key_exists?(key_node.value)
15
+ add_offense(
16
+ "'#{key_node.value}' does not have a matching entry in '#{@theme.default_locale_json.relative_path}'",
17
+ node: node,
18
+ markup: key_node.value,
19
+ )
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def key_exists?(key)
26
+ pointer = @theme.default_locale_json.content
27
+ key.split(".").each do |token|
28
+ return false unless pointer.key?(token)
29
+ pointer = pointer[token]
30
+ end
31
+
32
+ true
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class UndefinedObject < LiquidCheck
4
+ category :liquid
5
+ doc "https://shopify.dev/docs/themes/liquid/reference/objects"
6
+ severity :error
7
+
8
+ class TemplateInfo
9
+ def initialize
10
+ @all_variable_lookups = {}
11
+ @all_assigns = {}
12
+ @all_captures = {}
13
+ @all_forloops = {}
14
+ end
15
+
16
+ attr_reader :all_variable_lookups, :all_assigns, :all_captures, :all_forloops
17
+ def all
18
+ all_assigns.keys + all_captures.keys + all_forloops.keys
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @templates = {}
24
+ @used_snippets = {}
25
+ end
26
+
27
+ def on_document(node)
28
+ @templates[node.template.name] = TemplateInfo.new
29
+ end
30
+
31
+ def on_assign(node)
32
+ @templates[node.template.name].all_assigns[node.value.to] = node
33
+ end
34
+
35
+ def on_capture(node)
36
+ @templates[node.template.name].all_captures[node.value.instance_variable_get('@to')] = node
37
+ end
38
+
39
+ def on_for(node)
40
+ @templates[node.template.name].all_forloops[node.value.variable_name] = node
41
+ end
42
+
43
+ def on_include(node)
44
+ 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
48
+ end
49
+
50
+ def on_variable_lookup(node)
51
+ @templates[node.template.name].all_variable_lookups[node.value.name] = node
52
+ end
53
+
54
+ 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
66
+ else
67
+ all = info.all
68
+ all += ['email'] if 'templates/customers/reset_password' == template_name
69
+ check_object(info.all_variable_lookups, all)
70
+ end
71
+ end
72
+ end
73
+
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)
78
+
79
+ parent = node.parent
80
+ parent = parent.parent if :variable_lookup == parent.type_name
81
+ add_offense("Undefined object `#{name}`", node: parent)
82
+ end
83
+ end
84
+ private :check_object
85
+ end
86
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ #
4
+ # Unwanted:
5
+ #
6
+ # {{ x | some_unknown_filter }}
7
+ #
8
+ # Wanted:
9
+ #
10
+ # {{ x | upcase }}
11
+ #
12
+ class UnknownFilter < LiquidCheck
13
+ severity :error
14
+ category :liquid
15
+
16
+ def on_variable(node)
17
+ used_filters = node.value.filters.map { |name, *_rest| name }
18
+ undefined_filters = used_filters - ShopifyLiquid::Filter.labels
19
+
20
+ undefined_filters.each do |undefined_filter|
21
+ add_offense("Undefined filter `#{undefined_filter}`", node: node)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ # Checks unused {% assign x = ... %}
4
+ class UnusedAssign < LiquidCheck
5
+ severity :suggestion
6
+ category :liquid
7
+
8
+ class TemplateInfo < Struct.new(:used_assigns, :assign_nodes, :includes)
9
+ def collect_used_assigns(templates)
10
+ collected = used_assigns
11
+ # Check recursively inside included snippets for use
12
+ includes.each do |name|
13
+ if templates[name]
14
+ collected += templates[name].collect_used_assigns(templates)
15
+ end
16
+ end
17
+ collected
18
+ end
19
+ end
20
+
21
+ def initialize
22
+ @templates = {}
23
+ end
24
+
25
+ def on_document(node)
26
+ @templates[node.template.name] = TemplateInfo.new(Set.new, {}, Set.new)
27
+ end
28
+
29
+ def on_assign(node)
30
+ @templates[node.template.name].assign_nodes[node.value.to] = node
31
+ end
32
+
33
+ def on_include(node)
34
+ if node.value.template_name_expr.is_a?(String)
35
+ @templates[node.template.name].includes << "snippets/#{node.value.template_name_expr}"
36
+ end
37
+ end
38
+
39
+ def on_variable_lookup(node)
40
+ @templates[node.template.name].used_assigns << node.value.name
41
+ end
42
+
43
+ def on_end
44
+ @templates.each_pair do |_, info|
45
+ used = info.collect_used_assigns(@templates)
46
+ info.assign_nodes.each_pair do |name, node|
47
+ unless used.include?(name)
48
+ add_offense("`#{name}` is never used", node: node)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ require "set"
3
+
4
+ module ThemeCheck
5
+ class UnusedSnippet < LiquidCheck
6
+ severity :suggestion
7
+ category :liquid
8
+
9
+ def initialize
10
+ @used_templates = Set.new
11
+ end
12
+
13
+ def on_include(node)
14
+ if node.value.template_name_expr.is_a?(String)
15
+ @used_templates << "snippets/#{node.value.template_name_expr}"
16
+ else
17
+ # Can't reliably track unused snippets if an expression is used, ignore this check
18
+ @used_templates.clear
19
+ ignore!
20
+ end
21
+ end
22
+ alias_method :on_render, :on_include
23
+
24
+ def on_end
25
+ missing_snippets.each do |template|
26
+ add_offense("This template is not used", template: template)
27
+ end
28
+ end
29
+
30
+ def missing_snippets
31
+ theme.snippets.reject { |t| @used_templates.include?(t.name) }
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogumbo'
4
+
5
+ module ThemeCheck
6
+ class ValidHTMLTranslation < JsonCheck
7
+ severity :suggestion
8
+
9
+ def on_file(file)
10
+ return unless file.name.starts_with?("locales/")
11
+ return unless file.content.is_a?(Hash)
12
+
13
+ visit_nested(file.content)
14
+ end
15
+
16
+ private
17
+
18
+ def html_key?(keys)
19
+ pluralized_key = keys[-2] if keys.length > 1
20
+ keys[-1].end_with?('_html') || pluralized_key.end_with?('_html')
21
+ end
22
+
23
+ def parse_and_add_offence(key, value)
24
+ return unless value.is_a?(String)
25
+
26
+ html = Nokogiri::HTML5.fragment(value, max_errors: -1)
27
+ unless html.errors.empty?
28
+ err_msg = html.errors.join("\n")
29
+ add_offense("'#{key}' contains invalid HTML:\n#{err_msg}")
30
+ end
31
+ end
32
+
33
+ def visit_nested(value, keys = [])
34
+ if value.is_a?(Hash)
35
+ value.each do |k, v|
36
+ visit_nested(v, keys + [k])
37
+ end
38
+ elsif html_key?(keys)
39
+ parse_and_add_offence(keys.join('.'), value)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class ValidJson < JsonCheck
4
+ severity :error
5
+ category :json
6
+
7
+ def on_file(file)
8
+ if file.parse_error
9
+ message = format_json_parse_error(file.parse_error)
10
+ add_offense(message, template: file)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class ValidSchema < LiquidCheck
4
+ severity :suggestion
5
+ category :json
6
+
7
+ def on_schema(node)
8
+ JSON.parse(node.value.nodelist.join)
9
+ rescue JSON::ParserError => e
10
+ add_offense(format_json_parse_error(e), node: node)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ module ChecksTracking
4
+ def inherited(klass)
5
+ Check.all << klass
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class Cli
4
+ class Abort < StandardError; end
5
+
6
+ USAGE = <<~END
7
+ Usage: theme-check [options] /path/to/your/theme
8
+
9
+ Options:
10
+ -c, [--category] # Only run this category of checks
11
+ -x, [--exclude-category] # Exclude this category of checks
12
+ -l, [--list] # List enabled checks
13
+ -h, [--help] # Show this. Hi!
14
+
15
+ Description:
16
+ Theme Check helps you follow Shopify Themes & Liquid best practices by analyzing the
17
+ Liquid & JSON inside your theme.
18
+
19
+ You can configure checks in the .theme-check.yml file of your theme root directory.
20
+ END
21
+
22
+ def run(argv)
23
+ path = "."
24
+
25
+ command = :check
26
+ only_categories = []
27
+ exclude_categories = []
28
+
29
+ args = argv.dup
30
+ while (arg = args.shift)
31
+ case arg
32
+ when "--help", "-h"
33
+ raise Abort, USAGE
34
+ when "--category", "-c"
35
+ only_categories << args.shift.to_sym
36
+ when "--exclude-category", "-x"
37
+ exclude_categories << args.shift.to_sym
38
+ when "--list", "-l"
39
+ command = :list
40
+ else
41
+ path = arg
42
+ end
43
+ end
44
+
45
+ @config = ThemeCheck::Config.from_path(path)
46
+ @config.only_categories = only_categories
47
+ @config.exclude_categories = exclude_categories
48
+
49
+ send(command)
50
+ end
51
+
52
+ def run!(argv)
53
+ run(argv)
54
+ rescue Abort => e
55
+ if e.message.empty?
56
+ exit(1)
57
+ else
58
+ abort(e.message)
59
+ end
60
+ end
61
+
62
+ def list
63
+ puts @config.enabled_checks
64
+ end
65
+
66
+ def check
67
+ puts "Checking #{@config.root} ..."
68
+ theme = ThemeCheck::Theme.new(@config.root)
69
+ if theme.all.empty?
70
+ raise Abort, "No templates found.\n#{USAGE}"
71
+ end
72
+ analyzer = ThemeCheck::Analyzer.new(theme, @config.enabled_checks)
73
+ analyzer.analyze_theme
74
+ ThemeCheck::Printer.new.print(theme, analyzer.offenses)
75
+ raise Abort, "" if analyzer.offenses.any?
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class Config
5
+ DOTFILE = '.theme-check.yml'
6
+ DEFAULT_CONFIG = "#{__dir__}/../../config/default.yml"
7
+
8
+ attr_reader :root
9
+ attr_accessor :only_categories, :exclude_categories
10
+
11
+ class << self
12
+ def from_path(path)
13
+ if (filename = find(path))
14
+ new(filename.dirname, load_file(filename))
15
+ else
16
+ # No configuration file
17
+ new(path)
18
+ end
19
+ end
20
+
21
+ def find(root, needle = DOTFILE)
22
+ Pathname.new(root).descend.reverse_each do |path|
23
+ pathname = path.join(needle)
24
+ return pathname if pathname.exist?
25
+ end
26
+ nil
27
+ end
28
+
29
+ def load_file(absolute_path)
30
+ YAML.load_file(absolute_path)
31
+ end
32
+ end
33
+
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"))
40
+ end
41
+ @only_categories = []
42
+ @exclude_categories = []
43
+ resolve_requires
44
+ end
45
+
46
+ def to_h
47
+ @configuration
48
+ end
49
+
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
58
+
59
+ next if properties.delete('enabled') == false
60
+
61
+ options = properties.transform_keys(&:to_sym)
62
+ check_class = ThemeCheck.const_get(check_name)
63
+ next if exclude_categories.include?(check_class.category)
64
+ next if only_categories.any? && !only_categories.include?(check_class.category)
65
+
66
+ check = check_class.new(**options)
67
+ check.options = options
68
+ checks << check
69
+ end
70
+
71
+ checks
72
+ end
73
+
74
+ private
75
+
76
+ def default_configuration
77
+ @default_configuration ||= Config.load_file(DEFAULT_CONFIG)
78
+ end
79
+
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
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ def valid_check_configuration(check_name)
93
+ default_properties = default_configuration[check_name]
94
+
95
+ valid = {}
96
+
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
102
+ end
103
+ end
104
+
105
+ valid
106
+ end
107
+ end
108
+ end