theme-check 0.1.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 (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