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,104 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ class Offense
4
+ MAX_SOURCE_EXCERPT_SIZE = 120
5
+
6
+ attr_reader :check, :message, :template, :node, :markup, :line_number
7
+
8
+ def initialize(check:, message: nil, template: nil, node: nil, markup: nil, line_number: nil)
9
+ @check = check
10
+
11
+ if message
12
+ @message = message
13
+ elsif defined?(check.class::MESSAGE)
14
+ @message = check.class::MESSAGE
15
+ else
16
+ raise ArgumentError, "message required"
17
+ end
18
+
19
+ @node = node
20
+ if node
21
+ @template = node.template
22
+ elsif template
23
+ @template = template
24
+ end
25
+
26
+ @markup = if markup
27
+ markup
28
+ else
29
+ node&.markup
30
+ end
31
+
32
+ @line_number = if line_number
33
+ line_number
34
+ elsif @node
35
+ @node.line_number
36
+ end
37
+ end
38
+
39
+ def source_excerpt
40
+ return unless line_number
41
+ @source_excerpt ||= begin
42
+ excerpt = template.excerpt(line_number)
43
+ if excerpt.size > MAX_SOURCE_EXCERPT_SIZE
44
+ excerpt[0, MAX_SOURCE_EXCERPT_SIZE - 3] + '...'
45
+ else
46
+ excerpt
47
+ end
48
+ end
49
+ end
50
+
51
+ def start_line
52
+ return 0 unless line_number
53
+ line_number - 1
54
+ end
55
+
56
+ def end_line
57
+ return 0 unless line_number
58
+ line_number - 1
59
+ end
60
+
61
+ def start_column
62
+ return 0 unless line_number
63
+ template.full_line(line_number).index(markup)
64
+ end
65
+
66
+ def end_column
67
+ return 0 unless line_number
68
+ template.full_line(line_number).index(markup) + markup.size
69
+ end
70
+
71
+ def code_name
72
+ check.code_name
73
+ end
74
+
75
+ def markup_start_in_excerpt
76
+ source_excerpt.index(markup) if markup
77
+ end
78
+
79
+ def severity
80
+ check.severity
81
+ end
82
+
83
+ def check_name
84
+ check.class.name.demodulize
85
+ end
86
+
87
+ def doc
88
+ check.doc
89
+ end
90
+
91
+ def location
92
+ tokens = [template&.relative_path, line_number].compact
93
+ tokens.join(":") if tokens.any?
94
+ end
95
+
96
+ def to_s
97
+ if template
98
+ "#{message} at #{location}"
99
+ else
100
+ message
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+ module ThemeCheck
3
+ module ParsingHelpers
4
+ # Yield each chunk outside of "...", '...'
5
+ def outside_of_strings(markup)
6
+ scanner = StringScanner.new(markup)
7
+
8
+ while scanner.scan(/.*?("|')/)
9
+ yield scanner.matched[..-2]
10
+ # Skip to the end of the string
11
+ scanner.skip_until(scanner.matched[-1] == "'" ? /[^\\]'/ : /[^\\]"/)
12
+ end
13
+
14
+ yield scanner.rest if scanner.rest?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ThemeCheck
4
+ class Printer
5
+ def print(theme, offenses)
6
+ offenses.each do |offense|
7
+ print_offense(offense)
8
+ puts
9
+ end
10
+
11
+ puts "#{theme.all.size} files inspected, #{red(offenses.size.to_s + ' offenses')} detected"
12
+ end
13
+
14
+ def print_offense(offense)
15
+ location = if offense.location
16
+ blue(offense.location) + ": "
17
+ else
18
+ ""
19
+ end
20
+
21
+ puts location +
22
+ colorized_severity(offense.severity) + ": " +
23
+ yellow(offense.check_name) + ": " +
24
+ offense.message + "."
25
+ if offense.source_excerpt
26
+ puts "\t#{offense.source_excerpt}"
27
+ if offense.markup_start_in_excerpt
28
+ puts "\t" + (" " * offense.markup_start_in_excerpt) + ("^" * offense.markup.size)
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def colorize(str, color_code)
36
+ "\e[#{color_code}m#{str}\e[0m"
37
+ end
38
+
39
+ def colorized_severity(severity)
40
+ case severity
41
+ when :error
42
+ red(severity)
43
+ when :suggestion
44
+ pink(severity)
45
+ when :style
46
+ light_blue(severity)
47
+ end
48
+ end
49
+
50
+ def red(str)
51
+ colorize(str, 31)
52
+ end
53
+
54
+ def green(str)
55
+ colorize(str, 32)
56
+ end
57
+
58
+ def yellow(str)
59
+ colorize(str, 33)
60
+ end
61
+
62
+ def blue(str)
63
+ colorize(str, 34)
64
+ end
65
+
66
+ def pink(str)
67
+ colorize(str, 35)
68
+ end
69
+
70
+ def light_blue(str)
71
+ colorize(str, 36)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require_relative 'shopify_liquid/filter'
3
+ require_relative 'shopify_liquid/object'
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+
4
+ module ThemeCheck
5
+ module ShopifyLiquid
6
+ module Filter
7
+ extend self
8
+
9
+ def labels
10
+ @labels ||= begin
11
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/filters.yml"))
12
+ .values
13
+ .flatten
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+
4
+ module ThemeCheck
5
+ module ShopifyLiquid
6
+ module Object
7
+ extend self
8
+
9
+ def labels
10
+ @labels ||= begin
11
+ YAML.load(File.read("#{__dir__}/../../../data/shopify_liquid/objects.yml"))
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+ require "active_support/core_ext/string/starts_ends_with"
3
+
4
+ module ThemeCheck
5
+ module Tags
6
+ # Copied tags parsing code from storefront-renderer
7
+
8
+ class Section < Liquid::Tag
9
+ SYNTAX = /\A\s*(?<section_name>#{Liquid::QuotedString})\s*\z/o
10
+
11
+ attr_reader :section_name
12
+
13
+ def initialize(tag_name, markup, options)
14
+ super
15
+
16
+ match = markup.match(SYNTAX)
17
+ raise(
18
+ Liquid::SyntaxError,
19
+ "Error in tag 'section' - Valid syntax: section '[type]'",
20
+ ) unless match
21
+ @section_name = match[:section_name].tr(%('"), '')
22
+ @section_name.chomp!(".liquid") if @section_name.ends_with?(".liquid")
23
+ end
24
+ end
25
+
26
+ class Form < Liquid::Block
27
+ TAG_ATTRIBUTES = /([\w\-]+)\s*:\s*(#{Liquid::QuotedFragment})/o
28
+ # Matches forms with arguments:
29
+ # 'type', object
30
+ # 'type', object, key: value, ...
31
+ # 'type', key: value, ...
32
+ #
33
+ # old format: form product
34
+ # new format: form "product", product, id: "newID", class: "custom-class", data-example: "100"
35
+ FORM_FORMAT = %r{
36
+ (?<type>#{Liquid::QuotedFragment})
37
+ (?:\s*,\s*(?<variable_name>#{Liquid::VariableSignature}+)(?!:))?
38
+ (?<attributes>(?:\s*,\s*(?:#{TAG_ATTRIBUTES}))*)\s*\Z
39
+ }xo
40
+
41
+ attr_reader :type_expr, :variable_name_expr, :tag_attributes
42
+
43
+ def initialize(tag_name, markup, options)
44
+ super
45
+ @match = FORM_FORMAT.match(markup)
46
+ raise Liquid::SyntaxError, "in 'form' - Valid syntax: form 'type'[, object]" unless @match
47
+ @type_expr = parse_expression(@match[:type])
48
+ @variable_name_expr = parse_expression(@match[:variable_name])
49
+ tag_attributes = @match[:attributes].scan(TAG_ATTRIBUTES)
50
+ tag_attributes.each do |kv_pair|
51
+ kv_pair[1] = parse_expression(kv_pair[1])
52
+ end
53
+ @tag_attributes = tag_attributes
54
+ end
55
+
56
+ class ParseTreeVisitor < Liquid::ParseTreeVisitor
57
+ def children
58
+ super + [@node.type_expr, @node.variable_name_expr] + @node.tag_attributes
59
+ end
60
+ end
61
+ end
62
+
63
+ class Paginate < Liquid::Block
64
+ SYNTAX = /(?<liquid_variable_name>#{Liquid::QuotedFragment})\s*((?<by>by)\s*(?<page_size>#{Liquid::QuotedFragment}))?/
65
+
66
+ attr_reader :page_size
67
+
68
+ def initialize(tag_name, markup, options)
69
+ super
70
+
71
+ if (matches = markup.match(SYNTAX))
72
+ @liquid_variable_name = matches[:liquid_variable_name]
73
+ @page_size = parse_expression(matches[:page_size])
74
+ @window_size = nil # determines how many pagination links are shown
75
+
76
+ @liquid_variable_count_expr = parse_expression("#{@liquid_variable_name}_count")
77
+
78
+ var_parts = @liquid_variable_name.rpartition('.')
79
+ @source_drop_expr = parse_expression(var_parts[0].empty? ? var_parts.last : var_parts.first)
80
+ @method_name = var_parts.last.to_sym
81
+
82
+ markup.scan(Liquid::TagAttributes) do |key, value|
83
+ case key
84
+ when 'window_size'
85
+ @window_size = value.to_i
86
+ end
87
+ end
88
+ else
89
+ raise(Liquid::SyntaxError, "in tag 'paginate' - Valid syntax: paginate [collection] by number")
90
+ end
91
+ end
92
+
93
+ class ParseTreeVisitor < Liquid::ParseTreeVisitor
94
+ def children
95
+ super + [@node.page_size]
96
+ end
97
+ end
98
+ end
99
+
100
+ class Layout < Liquid::Tag
101
+ SYNTAX = /(?<layout>#{Liquid::QuotedFragment})/
102
+
103
+ NO_LAYOUT_KEYS = %w(false nil none).freeze
104
+
105
+ attr_reader :layout_expr
106
+
107
+ def initialize(tag_name, markup, tokens)
108
+ super
109
+ match = markup.match(SYNTAX)
110
+ raise(
111
+ Liquid::SyntaxError,
112
+ "in 'layout' - Valid syntax: layout (none|[layout_name])",
113
+ ) unless match
114
+ layout_markup = match[:layout]
115
+ @layout_expr = if NO_LAYOUT_KEYS.include?(layout_markup.downcase)
116
+ false
117
+ else
118
+ parse_expression(layout_markup)
119
+ end
120
+ end
121
+
122
+ class ParseTreeVisitor < Liquid::ParseTreeVisitor
123
+ def children
124
+ [@node.layout_expr]
125
+ end
126
+ end
127
+ end
128
+
129
+ class Style < Liquid::Block; end
130
+
131
+ class Schema < Liquid::Raw; end
132
+
133
+ class Javascript < Liquid::Raw; end
134
+
135
+ class Stylesheet < Liquid::Raw; end
136
+
137
+ Liquid::Template.register_tag('form', Form)
138
+ Liquid::Template.register_tag('layout', Layout)
139
+ Liquid::Template.register_tag('paginate', Paginate)
140
+ Liquid::Template.register_tag('section', Section)
141
+ Liquid::Template.register_tag('style', Style)
142
+ Liquid::Template.register_tag('schema', Schema)
143
+ Liquid::Template.register_tag('javascript', Javascript)
144
+ Liquid::Template.register_tag('stylesheet', Stylesheet)
145
+ end
146
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ require "pathname"
3
+
4
+ module ThemeCheck
5
+ class Template
6
+ attr_reader :path
7
+
8
+ def initialize(path, root)
9
+ @path = Pathname(path)
10
+ @root = Pathname(root)
11
+ end
12
+
13
+ def relative_path
14
+ @path.relative_path_from(@root)
15
+ end
16
+
17
+ def name
18
+ relative_path.sub_ext('').to_s
19
+ end
20
+
21
+ def template?
22
+ name.start_with?('templates')
23
+ end
24
+
25
+ def section?
26
+ name.start_with?('sections')
27
+ end
28
+
29
+ def snippet?
30
+ name.start_with?('snippets')
31
+ end
32
+
33
+ def source
34
+ @source ||= @path.read
35
+ end
36
+
37
+ def lines
38
+ @lines ||= source.split("\n")
39
+ end
40
+
41
+ def excerpt(line)
42
+ lines[line - 1].strip
43
+ end
44
+
45
+ def full_line(line)
46
+ lines[line - 1]
47
+ end
48
+
49
+ def parse
50
+ @ast ||= self.class.parse(source)
51
+ end
52
+
53
+ def warnings
54
+ @ast.warnings
55
+ end
56
+
57
+ def root
58
+ parse.root
59
+ end
60
+
61
+ def ==(other)
62
+ other.is_a?(Template) && @path == other.path
63
+ end
64
+
65
+ def self.parse(source)
66
+ Liquid::Template.parse(
67
+ source,
68
+ line_numbers: true,
69
+ error_mode: :warn,
70
+ )
71
+ end
72
+ end
73
+ end