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,13 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Recommends replacing `include` for `render`
|
4
|
+
class ConvertIncludeToRender < LiquidCheck
|
5
|
+
severity :suggestion
|
6
|
+
category :liquid
|
7
|
+
doc "https://shopify.dev/docs/themes/liquid/reference/tags/deprecated-tags#include"
|
8
|
+
|
9
|
+
def on_include(node)
|
10
|
+
add_offense("`include` is deprecated - convert it to `render`", node: node)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
class DefaultLocale < JsonCheck
|
4
|
+
severity :suggestion
|
5
|
+
category :translation
|
6
|
+
|
7
|
+
def on_end
|
8
|
+
return if @theme.default_locale_json
|
9
|
+
add_offense("Default translation file not found (for example locales/en.default.json)")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Recommends using {% liquid ... %} if 3 or more consecutive {% ... %} are found.
|
4
|
+
class LiquidTag < LiquidCheck
|
5
|
+
severity :suggestion
|
6
|
+
category :liquid
|
7
|
+
doc "https://shopify.dev/docs/themes/liquid/reference/tags/theme-tags#liquid"
|
8
|
+
|
9
|
+
def initialize(min_consecutive_statements: 10)
|
10
|
+
@first_statement = nil
|
11
|
+
@consecutive_statements = 0
|
12
|
+
@min_consecutive_statements = min_consecutive_statements
|
13
|
+
end
|
14
|
+
|
15
|
+
def on_tag(node)
|
16
|
+
if !node.inside_liquid_tag?
|
17
|
+
reset_consecutive_statements
|
18
|
+
# Ignore comments
|
19
|
+
elsif !node.comment?
|
20
|
+
increment_consecutive_statements(node)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def on_string(node)
|
25
|
+
# Only reset the counter on outputted strings, and ignore empty line-breaks
|
26
|
+
if node.parent.block? && !node.value.strip.empty?
|
27
|
+
reset_consecutive_statements
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def after_document(_node)
|
32
|
+
reset_consecutive_statements
|
33
|
+
end
|
34
|
+
|
35
|
+
def increment_consecutive_statements(node)
|
36
|
+
@first_statement ||= node
|
37
|
+
@consecutive_statements += 1
|
38
|
+
end
|
39
|
+
|
40
|
+
def reset_consecutive_statements
|
41
|
+
if @consecutive_statements >= @min_consecutive_statements
|
42
|
+
add_offense("Use {% liquid ... %} to write multiple tags", node: @first_statement)
|
43
|
+
end
|
44
|
+
@first_statement = nil
|
45
|
+
@consecutive_statements = 0
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
class MatchingSchemaTranslations < LiquidCheck
|
4
|
+
severity :suggestion
|
5
|
+
category :translation
|
6
|
+
|
7
|
+
def on_schema(node)
|
8
|
+
schema = JSON.parse(node.value.nodelist.join)
|
9
|
+
|
10
|
+
# Get all locales used in the schema
|
11
|
+
used_locales = Set.new([theme.default_locale])
|
12
|
+
visit_object(schema) do |_, locales|
|
13
|
+
used_locales += locales
|
14
|
+
end
|
15
|
+
used_locales = used_locales.to_a
|
16
|
+
|
17
|
+
# Check all used locales are defined in each localized keys
|
18
|
+
visit_object(schema) do |key, locales|
|
19
|
+
missing = used_locales - locales
|
20
|
+
if missing.any?
|
21
|
+
add_offense("#{key} missing translations for #{missing.join(', ')}", node: node)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
check_locales(schema["locales"], node: node)
|
26
|
+
|
27
|
+
rescue JSON::ParserError
|
28
|
+
# Ignored, handled in ValidSchema.
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def check_locales(locales, node:)
|
34
|
+
return unless locales.is_a?(Hash)
|
35
|
+
|
36
|
+
default_locale = locales[theme.default_locale]
|
37
|
+
if default_locale
|
38
|
+
locales.each_pair do |name, content|
|
39
|
+
diff = LocaleDiff.new(default_locale, content)
|
40
|
+
diff.add_as_offenses(self, key_prefix: ["locales", name], node: node)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
add_offense("Missing default locale in key: locales", node: node)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def visit_object(object, top_path = [], &block)
|
48
|
+
return unless object.is_a?(Hash)
|
49
|
+
top_path += [object["id"]] if object["id"].is_a?(String)
|
50
|
+
|
51
|
+
object.each_pair do |key, value|
|
52
|
+
path = top_path + [key]
|
53
|
+
|
54
|
+
case value
|
55
|
+
when Array
|
56
|
+
value.each do |item|
|
57
|
+
visit_object(item, path, &block)
|
58
|
+
end
|
59
|
+
|
60
|
+
when Hash
|
61
|
+
# Localized key
|
62
|
+
if value[theme.default_locale].is_a?(String)
|
63
|
+
block.call(path.join("."), value.keys)
|
64
|
+
# Nested keys
|
65
|
+
else
|
66
|
+
visit_object(value, path, &block)
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
class MatchingTranslations < JsonCheck
|
5
|
+
severity :suggestion
|
6
|
+
category :translation
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@files = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def on_file(file)
|
13
|
+
return unless file.name.starts_with?("locales/")
|
14
|
+
return unless file.content.is_a?(Hash)
|
15
|
+
return if file.name == @theme.default_locale_json&.name
|
16
|
+
|
17
|
+
@files << file
|
18
|
+
end
|
19
|
+
|
20
|
+
def on_end
|
21
|
+
return unless @theme.default_locale_json&.content
|
22
|
+
|
23
|
+
@files.each do |file|
|
24
|
+
diff = LocaleDiff.new(@theme.default_locale_json.content, file.content)
|
25
|
+
diff.add_as_offenses(self, template: file)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ThemeCheck
|
4
|
+
# Reports missing shopify required theme files
|
5
|
+
# required templates: https://shopify.dev/tutorials/review-theme-store-requirements-files
|
6
|
+
|
7
|
+
class MissingRequiredTemplateFiles < LiquidCheck
|
8
|
+
severity :error
|
9
|
+
category :liquid
|
10
|
+
doc "https://shopify.dev/docs/themes/theme-templates"
|
11
|
+
|
12
|
+
LAYOUT_FILENAME = "layout/theme"
|
13
|
+
REQUIRED_TEMPLATES_FILES = %w(index product collection cart blog article page list-collections search 404
|
14
|
+
gift_card customers/account customers/activate_account customers/addresses
|
15
|
+
customers/login customers/order customers/register customers/reset_password password)
|
16
|
+
.map { |file| "templates/#{file}" }
|
17
|
+
|
18
|
+
def on_end
|
19
|
+
missing_files = (REQUIRED_TEMPLATES_FILES + [LAYOUT_FILENAME]) - theme.liquid.map(&:name)
|
20
|
+
missing_files.each { |file| add_missing_file_offense(file) }
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def add_missing_file_offense(file)
|
26
|
+
add_offense("Theme is missing '#{file}.liquid' file")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports missing include/render/section template
|
4
|
+
class MissingTemplate < LiquidCheck
|
5
|
+
severity :suggestion
|
6
|
+
category :liquid
|
7
|
+
|
8
|
+
def on_include(node)
|
9
|
+
template = node.value.template_name_expr
|
10
|
+
if template.is_a?(String)
|
11
|
+
unless theme["snippets/#{template}"]
|
12
|
+
add_offense("'snippets/#{template}.liquid' is not found", node: node)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
alias_method :on_render, :on_include
|
17
|
+
|
18
|
+
def on_section(node)
|
19
|
+
template = node.value.section_name
|
20
|
+
unless theme["sections/#{template}"]
|
21
|
+
add_offense("'sections/#{template}.liquid' is not found", node: node)
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports deeply nested {% include ... %} or {% render ... %}
|
4
|
+
class NestedSnippet < LiquidCheck
|
5
|
+
severity :suggestion
|
6
|
+
category :liquid
|
7
|
+
|
8
|
+
class TemplateInfo < Struct.new(:includes)
|
9
|
+
def with_deep_nested(templates, max, current_level = 0)
|
10
|
+
includes.each do |node|
|
11
|
+
if current_level >= max
|
12
|
+
yield node
|
13
|
+
else
|
14
|
+
template_name = "snippets/#{node.value.template_name_expr}"
|
15
|
+
templates[template_name]
|
16
|
+
&.with_deep_nested(templates, max, current_level + 1) { yield node }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(max_nesting_level: 2)
|
23
|
+
@max_nesting_level = max_nesting_level
|
24
|
+
@templates = {}
|
25
|
+
end
|
26
|
+
|
27
|
+
def on_document(node)
|
28
|
+
@templates[node.template.name] = TemplateInfo.new(Set.new)
|
29
|
+
end
|
30
|
+
|
31
|
+
def on_include(node)
|
32
|
+
if node.value.template_name_expr.is_a?(String)
|
33
|
+
@templates[node.template.name].includes << node
|
34
|
+
end
|
35
|
+
end
|
36
|
+
alias_method :on_render, :on_include
|
37
|
+
|
38
|
+
def on_end
|
39
|
+
@templates.each_pair do |_, info|
|
40
|
+
info.with_deep_nested(@templates, @max_nesting_level) do |node|
|
41
|
+
add_offense("Too many nested snippets", node: node)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports missing shopify required directories
|
4
|
+
|
5
|
+
class RequiredDirectories < LiquidCheck
|
6
|
+
severity :error
|
7
|
+
category :liquid
|
8
|
+
doc "https://shopify.dev/tutorials/develop-theme-files"
|
9
|
+
|
10
|
+
REQUIRED_DIRECTORIES = %w(assets config layout locales sections snippets templates)
|
11
|
+
|
12
|
+
def on_end
|
13
|
+
directories = theme.directories.map(&:to_s)
|
14
|
+
missing_directories = REQUIRED_DIRECTORIES - directories
|
15
|
+
missing_directories.each { |d| add_missing_directories_offense(d) }
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def add_missing_directories_offense(directory)
|
21
|
+
add_offense("Theme is missing '#{directory}' directory")
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Reports missing content_for_header and content_for_layout in theme.liquid
|
4
|
+
class RequiredLayoutThemeObject < LiquidCheck
|
5
|
+
severity :error
|
6
|
+
category :liquid
|
7
|
+
doc "https://shopify.dev/docs/themes/theme-templates/theme-liquid"
|
8
|
+
|
9
|
+
LAYOUT_FILENAME = "layout/theme"
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@content_for_layout_found = false
|
13
|
+
@content_for_header_found = false
|
14
|
+
end
|
15
|
+
|
16
|
+
def on_document(node)
|
17
|
+
@layout_theme_node = node if node.template.name == LAYOUT_FILENAME
|
18
|
+
end
|
19
|
+
|
20
|
+
def on_variable(node)
|
21
|
+
return unless node.value.name.is_a?(Liquid::VariableLookup)
|
22
|
+
|
23
|
+
@content_for_header_found ||= node.value.name.name == "content_for_header"
|
24
|
+
@content_for_layout_found ||= node.value.name.name == "content_for_layout"
|
25
|
+
end
|
26
|
+
|
27
|
+
def after_document(node)
|
28
|
+
return unless node.template.name == LAYOUT_FILENAME
|
29
|
+
|
30
|
+
add_missing_object_offense("content_for_layout") unless @content_for_layout_found
|
31
|
+
add_missing_object_offense("content_for_header") unless @content_for_header_found
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def add_missing_object_offense(name)
|
37
|
+
add_offense("#{LAYOUT_FILENAME} must include {{#{name}}}", node: @layout_theme_node)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Ensure {% ... %} & {{ ... }} have consistent spaces.
|
4
|
+
class SpaceInsideBraces < LiquidCheck
|
5
|
+
severity :style
|
6
|
+
category :liquid
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@ignore = false
|
10
|
+
end
|
11
|
+
|
12
|
+
def on_node(node)
|
13
|
+
return unless node.markup
|
14
|
+
|
15
|
+
outside_of_strings(node.markup) do |chunk|
|
16
|
+
chunk.scan(/([,:]) +/) do |_match|
|
17
|
+
add_offense("Too many spaces after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
|
18
|
+
end
|
19
|
+
chunk.scan(/([,:])\S/) do |_match|
|
20
|
+
add_offense("Space missing after '#{Regexp.last_match(1)}'", node: node, markup: Regexp.last_match(0))
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def on_tag(node)
|
26
|
+
if node.inside_liquid_tag?
|
27
|
+
markup = if node.whitespace_trimmed?
|
28
|
+
"-%}"
|
29
|
+
else
|
30
|
+
"%}"
|
31
|
+
end
|
32
|
+
if node.markup[-1] != " " && node.markup[-1] != "\n"
|
33
|
+
add_offense("Space missing before '#{markup}'", node: node, markup: node.markup[-1] + markup)
|
34
|
+
elsif node.markup =~ /(\n?)( +)\z/m && Regexp.last_match(1) != "\n"
|
35
|
+
add_offense("Too many spaces before '#{markup}'", node: node, markup: Regexp.last_match(2) + markup)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
@ignore = true
|
39
|
+
end
|
40
|
+
|
41
|
+
def after_tag(_node)
|
42
|
+
@ignore = false
|
43
|
+
end
|
44
|
+
|
45
|
+
def on_variable(node)
|
46
|
+
return if @ignore
|
47
|
+
if node.markup[0] != " "
|
48
|
+
add_offense("Space missing after '{{'", node: node)
|
49
|
+
elsif node.markup[-1] != " "
|
50
|
+
add_offense("Space missing before '}}'", node: node)
|
51
|
+
elsif node.markup[1] == " "
|
52
|
+
add_offense("Too many spaces after '{{'", node: node)
|
53
|
+
elsif node.markup[-2] == " "
|
54
|
+
add_offense("Too many spaces before '}}'", node: node)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
module ThemeCheck
|
3
|
+
# Report Liquid syntax errors
|
4
|
+
class SyntaxError < LiquidCheck
|
5
|
+
severity :error
|
6
|
+
category :liquid
|
7
|
+
|
8
|
+
def on_document(node)
|
9
|
+
node.template.warnings.each do |warning|
|
10
|
+
add_exception_as_offense(warning, template: node.template)
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def on_error(exception)
|
15
|
+
add_exception_as_offense(exception, template: theme[exception.template_name])
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def add_exception_as_offense(exception, template:)
|
21
|
+
add_offense(
|
22
|
+
exception.to_s(false).sub(/ in ".*"$/, ''),
|
23
|
+
line_number: exception.line_number,
|
24
|
+
markup: exception.markup_context&.sub(/^in "(.*)"$/, '\1'),
|
25
|
+
template: template,
|
26
|
+
)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|