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