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,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
class JsonCheck < Check
|
5
|
+
extend ChecksTracking
|
6
|
+
|
7
|
+
def add_offense(message, markup: nil, line_number: nil, template: nil)
|
8
|
+
offenses << Offense.new(check: self, message: message, markup: markup, line_number: line_number, template: template)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require "json"
|
3
|
+
require "pathname"
|
4
|
+
|
5
|
+
module ThemeCheck
|
6
|
+
class JsonFile
|
7
|
+
attr_reader :path
|
8
|
+
|
9
|
+
def initialize(path, root)
|
10
|
+
@path = Pathname(path)
|
11
|
+
@root = Pathname(root)
|
12
|
+
@loaded = false
|
13
|
+
@content = nil
|
14
|
+
@parser_error = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def relative_path
|
18
|
+
@path.relative_path_from(@root)
|
19
|
+
end
|
20
|
+
|
21
|
+
def name
|
22
|
+
relative_path.sub_ext('').to_s
|
23
|
+
end
|
24
|
+
|
25
|
+
def content
|
26
|
+
load!
|
27
|
+
@content
|
28
|
+
end
|
29
|
+
|
30
|
+
def parse_error
|
31
|
+
load!
|
32
|
+
@parser_error
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def load!
|
38
|
+
return if @loaded
|
39
|
+
|
40
|
+
@content = JSON.parse(File.read(@path))
|
41
|
+
rescue JSON::ParserError => e
|
42
|
+
@parser_error = e
|
43
|
+
ensure
|
44
|
+
@loaded = true
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
module LanguageServer
|
5
|
+
class Handler
|
6
|
+
CAPABILITIES = {
|
7
|
+
textDocumentSync: {
|
8
|
+
openClose: true,
|
9
|
+
change: false,
|
10
|
+
willSave: false,
|
11
|
+
save: true,
|
12
|
+
},
|
13
|
+
}
|
14
|
+
|
15
|
+
def initialize(server)
|
16
|
+
@server = server
|
17
|
+
end
|
18
|
+
|
19
|
+
def on_initialize(id, params)
|
20
|
+
@root_path = params["rootPath"]
|
21
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#responseMessage
|
22
|
+
send_response(
|
23
|
+
id: id,
|
24
|
+
result: {
|
25
|
+
capabilities: CAPABILITIES,
|
26
|
+
}
|
27
|
+
)
|
28
|
+
end
|
29
|
+
|
30
|
+
def on_exit(_id, _params)
|
31
|
+
close!
|
32
|
+
end
|
33
|
+
|
34
|
+
def on_text_document_did_open(_id, params)
|
35
|
+
analyze_and_send_offenses(params.dig('textDocument', 'uri').sub('file://', ''))
|
36
|
+
end
|
37
|
+
alias_method :on_text_document_did_save, :on_text_document_did_open
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def analyze_and_send_offenses(file_path)
|
42
|
+
root = ThemeCheck::Config.find(file_path) || @root_path
|
43
|
+
config = ThemeCheck::Config.from_path(root)
|
44
|
+
theme = ThemeCheck::Theme.new(config.root)
|
45
|
+
analyzer = ThemeCheck::Analyzer.new(theme, config.enabled_checks)
|
46
|
+
|
47
|
+
log("Checking #{config.root}")
|
48
|
+
analyzer.analyze_theme
|
49
|
+
log("Found #{theme.all.size} templates, and #{analyzer.offenses.size} offenses")
|
50
|
+
send_offenses(analyzer.offenses)
|
51
|
+
end
|
52
|
+
|
53
|
+
def send_offenses(offenses)
|
54
|
+
offenses.group_by(&:template).each do |template, template_offenses|
|
55
|
+
next unless template
|
56
|
+
# https://microsoft.github.io/language-server-protocol/specifications/specification-current/#notificationMessage
|
57
|
+
send_response(
|
58
|
+
method: 'textDocument/publishDiagnostics',
|
59
|
+
params: {
|
60
|
+
uri: "file:#{template.path}",
|
61
|
+
diagnostics: template_offenses.map { |offense| offense_to_diagnostic(offense) },
|
62
|
+
},
|
63
|
+
)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def offense_to_diagnostic(offense)
|
68
|
+
{
|
69
|
+
range: range(offense),
|
70
|
+
severity: severity(offense),
|
71
|
+
code: offense.code_name,
|
72
|
+
source: "theme-check",
|
73
|
+
message: offense.message,
|
74
|
+
}
|
75
|
+
end
|
76
|
+
|
77
|
+
def severity(offense)
|
78
|
+
case offense.severity
|
79
|
+
when :error
|
80
|
+
1
|
81
|
+
when :suggestion
|
82
|
+
2
|
83
|
+
when :style
|
84
|
+
3
|
85
|
+
else
|
86
|
+
4
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def range(offense)
|
91
|
+
{
|
92
|
+
start: {
|
93
|
+
line: offense.start_line,
|
94
|
+
character: offense.start_column,
|
95
|
+
},
|
96
|
+
end: {
|
97
|
+
line: offense.end_line,
|
98
|
+
character: offense.end_column,
|
99
|
+
},
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
def send_response(message)
|
104
|
+
message[:jsonrpc] = '2.0'
|
105
|
+
@server.send_response(message)
|
106
|
+
end
|
107
|
+
|
108
|
+
def log(message)
|
109
|
+
@server.log(message)
|
110
|
+
end
|
111
|
+
|
112
|
+
def close!
|
113
|
+
raise DoneStreaming
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'json'
|
3
|
+
require 'stringio'
|
4
|
+
require 'active_support/core_ext/string/inflections'
|
5
|
+
|
6
|
+
module ThemeCheck
|
7
|
+
module LanguageServer
|
8
|
+
class DoneStreaming < StandardError; end
|
9
|
+
class IncompatibleStream < StandardError; end
|
10
|
+
|
11
|
+
class Server
|
12
|
+
def initialize(
|
13
|
+
in_stream: STDIN,
|
14
|
+
out_stream: STDOUT,
|
15
|
+
err_stream: $DEBUG ? File.open('/tmp/lsp.log', 'a') : STDERR
|
16
|
+
)
|
17
|
+
validate!([in_stream, out_stream, err_stream])
|
18
|
+
|
19
|
+
@handler = Handler.new(self)
|
20
|
+
@in = in_stream
|
21
|
+
@out = out_stream
|
22
|
+
@err = err_stream
|
23
|
+
|
24
|
+
@out.sync = true # do not buffer
|
25
|
+
@err.sync = true # do not buffer
|
26
|
+
end
|
27
|
+
|
28
|
+
def listen
|
29
|
+
loop do
|
30
|
+
process_request
|
31
|
+
|
32
|
+
# support ctrl+c and stuff
|
33
|
+
rescue SignalException, DoneStreaming
|
34
|
+
cleanup
|
35
|
+
return 0
|
36
|
+
|
37
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
38
|
+
log(e)
|
39
|
+
log(e.backtrace)
|
40
|
+
return 1
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def send_response(response)
|
45
|
+
response_body = JSON.dump(response)
|
46
|
+
log(response_body) if $DEBUG
|
47
|
+
|
48
|
+
@out.write("Content-Length: #{response_body.size}\r\n")
|
49
|
+
@out.write("\r\n")
|
50
|
+
@out.write(response_body)
|
51
|
+
@out.flush
|
52
|
+
end
|
53
|
+
|
54
|
+
def log(message)
|
55
|
+
@err.puts(message)
|
56
|
+
@err.flush
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def supported_io_classes
|
62
|
+
[IO, StringIO]
|
63
|
+
end
|
64
|
+
|
65
|
+
def validate!(streams = [])
|
66
|
+
streams.each do |stream|
|
67
|
+
unless supported_io_classes.find { |klass| stream.is_a?(klass) }
|
68
|
+
raise IncompatibleStream, incompatible_stream_message
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def incompatible_stream_message
|
74
|
+
'if provided, in_stream, out_stream, and err_stream must be a kind of '\
|
75
|
+
"one of the following: #{supported_io_classes.join(', ')}"
|
76
|
+
end
|
77
|
+
|
78
|
+
def process_request
|
79
|
+
request_body = read_new_content
|
80
|
+
request_json = JSON.parse(request_body)
|
81
|
+
log(JSON.pretty_generate(request_json)) if $DEBUG
|
82
|
+
|
83
|
+
id = request_json['id']
|
84
|
+
method_name = request_json['method']
|
85
|
+
params = request_json['params']
|
86
|
+
method_name = "on_#{to_snake_case(method_name)}"
|
87
|
+
|
88
|
+
if @handler.respond_to?(method_name)
|
89
|
+
@handler.send(method_name, id, params)
|
90
|
+
else
|
91
|
+
log("Handler does not respond to #{method_name}")
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def to_snake_case(method_name)
|
96
|
+
method_name.gsub(/[^\w]/, '_').underscore
|
97
|
+
end
|
98
|
+
|
99
|
+
def initial_line
|
100
|
+
# Scanning for lines that fit the protocol.
|
101
|
+
while true
|
102
|
+
initial_line = @in.gets
|
103
|
+
# gets returning nil means the stream was closed.
|
104
|
+
raise DoneStreaming if initial_line.nil?
|
105
|
+
|
106
|
+
if initial_line.match(/Content-Length: (\d+)/)
|
107
|
+
break
|
108
|
+
end
|
109
|
+
end
|
110
|
+
initial_line
|
111
|
+
end
|
112
|
+
|
113
|
+
def read_new_content
|
114
|
+
length = initial_line.match(/Content-Length: (\d+)/)[1].to_i
|
115
|
+
content = ''
|
116
|
+
while content.length < length + 2
|
117
|
+
begin
|
118
|
+
# Why + 2? Because \r\n
|
119
|
+
content += @in.read(length + 2)
|
120
|
+
rescue => e
|
121
|
+
log(e)
|
122
|
+
log(e.backtrace)
|
123
|
+
# We have almost certainly been disconnected from the server
|
124
|
+
cleanup
|
125
|
+
raise DoneStreaming
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
content
|
130
|
+
end
|
131
|
+
|
132
|
+
def cleanup
|
133
|
+
@err.close
|
134
|
+
@out.close
|
135
|
+
rescue
|
136
|
+
# I did my best
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require_relative "parsing_helpers"
|
3
|
+
|
4
|
+
module ThemeCheck
|
5
|
+
class LiquidCheck < Check
|
6
|
+
extend ChecksTracking
|
7
|
+
include ParsingHelpers
|
8
|
+
|
9
|
+
def add_offense(message, node: nil, template: node&.template, markup: nil, line_number: nil)
|
10
|
+
offenses << Offense.new(check: self, message: message, template: template, node: node, markup: markup, line_number: line_number)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
class LocaleDiff
|
4
|
+
PLURALIZATION_KEYS = Set.new(["zero", "one", "two", "few", "many", "other"])
|
5
|
+
|
6
|
+
attr_reader :extra_keys, :missing_keys
|
7
|
+
|
8
|
+
def initialize(default, other)
|
9
|
+
@default = default
|
10
|
+
@other = other
|
11
|
+
@extra_keys = []
|
12
|
+
@missing_keys = []
|
13
|
+
|
14
|
+
visit_object(@default, @other, [])
|
15
|
+
end
|
16
|
+
|
17
|
+
def add_as_offenses(check, key_prefix: [], node: nil, template: nil)
|
18
|
+
if extra_keys.any?
|
19
|
+
add_keys_offense(check, "Extra translation keys", extra_keys,
|
20
|
+
key_prefix: key_prefix, node: node, template: template)
|
21
|
+
end
|
22
|
+
|
23
|
+
if missing_keys.any?
|
24
|
+
add_keys_offense(check, "Missing translation keys", missing_keys,
|
25
|
+
key_prefix: key_prefix, node: node, template: template)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def add_keys_offense(check, cause, keys, key_prefix:, node: nil, template: nil)
|
32
|
+
message = "#{cause}: #{format_keys(key_prefix, keys)}"
|
33
|
+
if node
|
34
|
+
check.add_offense(message, node: node)
|
35
|
+
else
|
36
|
+
check.add_offense(message, template: template)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def format_keys(key_prefix, keys)
|
41
|
+
keys.map { |path| (key_prefix + path).join(".") }.join(", ")
|
42
|
+
end
|
43
|
+
|
44
|
+
def visit_object(default, other, path)
|
45
|
+
default = {} unless default.is_a?(Hash)
|
46
|
+
other = {} unless other.is_a?(Hash)
|
47
|
+
return if pluralization?(default) && pluralization?(other)
|
48
|
+
|
49
|
+
@extra_keys += (other.keys - default.keys).map { |key| path + [key] }
|
50
|
+
|
51
|
+
default.each do |key, default_value|
|
52
|
+
translated_value = other[key]
|
53
|
+
new_path = path + [key]
|
54
|
+
|
55
|
+
if translated_value.nil?
|
56
|
+
@missing_keys << new_path
|
57
|
+
else
|
58
|
+
visit_object(default_value, translated_value, new_path)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def pluralization?(hash)
|
64
|
+
hash.all? do |key, value|
|
65
|
+
PLURALIZATION_KEYS.include?(key) && !value.is_a?(Hash)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'active_support/core_ext/string/inflections'
|
3
|
+
|
4
|
+
module ThemeCheck
|
5
|
+
# A node from the Liquid AST, the result of parsing a template.
|
6
|
+
class Node
|
7
|
+
attr_reader :value, :parent, :template
|
8
|
+
|
9
|
+
def initialize(value, parent, template)
|
10
|
+
raise ArgumentError, "Expected a Liquid AST Node" if value.is_a?(Node)
|
11
|
+
@value = value
|
12
|
+
@parent = parent
|
13
|
+
@template = template
|
14
|
+
end
|
15
|
+
|
16
|
+
# The original source code of the node. Doesn't contain wrapping braces.
|
17
|
+
def markup
|
18
|
+
if tag?
|
19
|
+
@value.raw
|
20
|
+
elsif @value.instance_variable_defined?(:@markup)
|
21
|
+
@value.instance_variable_get(:@markup)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Array of children nodes.
|
26
|
+
def children
|
27
|
+
@children ||= begin
|
28
|
+
nodes =
|
29
|
+
if comment?
|
30
|
+
[]
|
31
|
+
elsif defined?(@value.class::ParseTreeVisitor)
|
32
|
+
@value.class::ParseTreeVisitor.new(@value, {}).children
|
33
|
+
elsif @value.respond_to?(:nodelist)
|
34
|
+
Array(@value.nodelist)
|
35
|
+
else
|
36
|
+
[]
|
37
|
+
end
|
38
|
+
# Work around a bug in Liquid::Variable::ParseTreeVisitor that doesn't return
|
39
|
+
# the args in a hash as children nodes.
|
40
|
+
nodes = nodes.flat_map do |node|
|
41
|
+
case node
|
42
|
+
when Hash
|
43
|
+
node.values
|
44
|
+
else
|
45
|
+
node
|
46
|
+
end
|
47
|
+
end
|
48
|
+
nodes.map { |node| Node.new(node, self, @template) }
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Literals are hard-coded values in the template.
|
53
|
+
def literal?
|
54
|
+
@value.is_a?(String) || @value.is_a?(Integer)
|
55
|
+
end
|
56
|
+
|
57
|
+
# A {% tag %} node?
|
58
|
+
def tag?
|
59
|
+
@value.is_a?(Liquid::Tag)
|
60
|
+
end
|
61
|
+
|
62
|
+
# A {% comment %} block node?
|
63
|
+
def comment?
|
64
|
+
@value.is_a?(Liquid::Comment)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Top level node of every template.
|
68
|
+
def document?
|
69
|
+
@value.is_a?(Liquid::Document)
|
70
|
+
end
|
71
|
+
alias_method :root?, :document?
|
72
|
+
|
73
|
+
# A {% tag %}...{% endtag %} node?
|
74
|
+
def block_tag?
|
75
|
+
@value.is_a?(Liquid::Block)
|
76
|
+
end
|
77
|
+
|
78
|
+
# The body of blocks
|
79
|
+
def block_body?
|
80
|
+
@value.is_a?(Liquid::BlockBody)
|
81
|
+
end
|
82
|
+
|
83
|
+
# A block of type of node?
|
84
|
+
def block?
|
85
|
+
block_tag? || block_body? || document?
|
86
|
+
end
|
87
|
+
|
88
|
+
# Most nodes have a line number, but it's not guaranteed.
|
89
|
+
def line_number
|
90
|
+
@value.line_number if @value.respond_to?(:line_number)
|
91
|
+
end
|
92
|
+
|
93
|
+
# The `:under_score_name` of this type of node. Used to dispatch to the `on_<type_name>`
|
94
|
+
# and `after_<type_name>` check methods.
|
95
|
+
def type_name
|
96
|
+
@type_name ||= @value.class.name.demodulize.underscore.to_sym
|
97
|
+
end
|
98
|
+
|
99
|
+
# Is this node inside a `{% liquid ... %}` block?
|
100
|
+
def inside_liquid_tag?
|
101
|
+
if line_number
|
102
|
+
template.excerpt(line_number).start_with?("{%")
|
103
|
+
else
|
104
|
+
false
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# Is this node inside a `{%- ... -%}`
|
109
|
+
def whitespace_trimmed?
|
110
|
+
if line_number
|
111
|
+
template.excerpt(line_number).start_with?("{%-")
|
112
|
+
else
|
113
|
+
false
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|