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,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