theme-check 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/probots.yml +3 -0
- data/.github/workflows/theme-check.yml +28 -0
- data/.gitignore +13 -0
- data/.rubocop.yml +18 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +132 -0
- data/Gemfile +26 -0
- data/Guardfile +7 -0
- data/LICENSE.md +8 -0
- data/README.md +71 -0
- data/Rakefile +14 -0
- data/bin/liquid-server +4 -0
- data/config/default.yml +63 -0
- data/data/shopify_liquid/filters.yml +174 -0
- data/data/shopify_liquid/objects.yml +81 -0
- data/dev.yml +23 -0
- data/docs/preview.png +0 -0
- data/exe/theme-check +6 -0
- data/exe/theme-check-language-server +12 -0
- data/lib/theme_check.rb +25 -0
- data/lib/theme_check/analyzer.rb +43 -0
- data/lib/theme_check/check.rb +92 -0
- data/lib/theme_check/checks.rb +12 -0
- data/lib/theme_check/checks/convert_include_to_render.rb +13 -0
- data/lib/theme_check/checks/default_locale.rb +12 -0
- data/lib/theme_check/checks/liquid_tag.rb +48 -0
- data/lib/theme_check/checks/matching_schema_translations.rb +73 -0
- data/lib/theme_check/checks/matching_translations.rb +29 -0
- data/lib/theme_check/checks/missing_required_template_files.rb +29 -0
- data/lib/theme_check/checks/missing_template.rb +25 -0
- data/lib/theme_check/checks/nested_snippet.rb +46 -0
- data/lib/theme_check/checks/required_directories.rb +24 -0
- data/lib/theme_check/checks/required_layout_theme_object.rb +40 -0
- data/lib/theme_check/checks/space_inside_braces.rb +58 -0
- data/lib/theme_check/checks/syntax_error.rb +29 -0
- data/lib/theme_check/checks/template_length.rb +18 -0
- data/lib/theme_check/checks/translation_key_exists.rb +35 -0
- data/lib/theme_check/checks/undefined_object.rb +86 -0
- data/lib/theme_check/checks/unknown_filter.rb +25 -0
- data/lib/theme_check/checks/unused_assign.rb +54 -0
- data/lib/theme_check/checks/unused_snippet.rb +34 -0
- data/lib/theme_check/checks/valid_html_translation.rb +43 -0
- data/lib/theme_check/checks/valid_json.rb +14 -0
- data/lib/theme_check/checks/valid_schema.rb +13 -0
- data/lib/theme_check/checks_tracking.rb +8 -0
- data/lib/theme_check/cli.rb +78 -0
- data/lib/theme_check/config.rb +108 -0
- data/lib/theme_check/json_check.rb +11 -0
- data/lib/theme_check/json_file.rb +47 -0
- data/lib/theme_check/json_helpers.rb +9 -0
- data/lib/theme_check/language_server.rb +11 -0
- data/lib/theme_check/language_server/handler.rb +117 -0
- data/lib/theme_check/language_server/server.rb +140 -0
- data/lib/theme_check/liquid_check.rb +13 -0
- data/lib/theme_check/locale_diff.rb +69 -0
- data/lib/theme_check/node.rb +117 -0
- data/lib/theme_check/offense.rb +104 -0
- data/lib/theme_check/parsing_helpers.rb +17 -0
- data/lib/theme_check/printer.rb +74 -0
- data/lib/theme_check/shopify_liquid.rb +3 -0
- data/lib/theme_check/shopify_liquid/filter.rb +18 -0
- data/lib/theme_check/shopify_liquid/object.rb +16 -0
- data/lib/theme_check/tags.rb +146 -0
- data/lib/theme_check/template.rb +73 -0
- data/lib/theme_check/theme.rb +60 -0
- data/lib/theme_check/version.rb +4 -0
- data/lib/theme_check/visitor.rb +37 -0
- data/theme-check.gemspec +28 -0
- 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,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
|