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