primer_view_components 0.0.60 → 0.0.61
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 +4 -4
- data/CHANGELOG.md +71 -1
- data/app/components/primer/alpha/layout.html.erb +5 -0
- data/app/components/primer/alpha/layout.rb +276 -0
- data/app/components/primer/base_button.rb +1 -5
- data/app/components/primer/base_component.rb +7 -2
- data/app/components/primer/beta/blankslate.html.erb +15 -0
- data/app/components/primer/beta/blankslate.rb +240 -0
- data/app/components/primer/blankslate_component.rb +1 -1
- data/app/components/primer/component.rb +2 -2
- data/app/components/primer/hellip_button.rb +39 -0
- data/app/components/primer/hidden_text_expander.rb +18 -6
- data/app/components/primer/subhead_component.rb +1 -1
- data/lib/primer/classify.rb +3 -3
- data/lib/primer/view_components/engine.rb +1 -1
- data/lib/primer/view_components/linters/base_linter.rb +3 -52
- data/lib/primer/view_components/linters/blankslate_api_migration.rb +146 -0
- data/lib/primer/view_components/linters/blankslate_component_migration_counter.rb +1 -1
- data/lib/primer/view_components/linters/close_button_component_migration_counter.rb +2 -4
- data/lib/primer/view_components/linters/helpers/rubocop_helpers.rb +14 -0
- data/lib/primer/view_components/linters/tag_tree_helpers.rb +61 -0
- data/lib/primer/view_components/linters/two_column_layout_migration_counter.rb +158 -0
- data/lib/primer/view_components/version.rb +1 -1
- data/lib/tasks/docs.rake +50 -1
- data/static/arguments.yml +52 -67
- data/static/audited_at.json +5 -0
- data/static/classes.yml +20 -2
- data/static/constants.json +103 -0
- data/static/statuses.json +6 -1
- metadata +11 -2
@@ -6,7 +6,7 @@ module Primer
|
|
6
6
|
# `Blankslate` renders an `<h3>` element for the title by default. Update the heading level based on what is appropriate for your page hierarchy by setting `title_tag`.
|
7
7
|
# <%= link_to_heading_practices %>
|
8
8
|
class BlankslateComponent < Primer::Component
|
9
|
-
status :
|
9
|
+
status :deprecated
|
10
10
|
|
11
11
|
# Optional Spinner.
|
12
12
|
#
|
@@ -16,8 +16,8 @@ module Primer
|
|
16
16
|
|
17
17
|
private
|
18
18
|
|
19
|
-
def
|
20
|
-
Rails.application.config.primer_view_components.
|
19
|
+
def raise_on_invalid_options?
|
20
|
+
Rails.application.config.primer_view_components.raise_on_invalid_options
|
21
21
|
end
|
22
22
|
|
23
23
|
def deprecated_component_warning(new_class: nil, version: nil)
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Primer
|
4
|
+
# Use `HellipButton` to render a button with a hellip. Often used for hidden text expanders.
|
5
|
+
# @accessibility
|
6
|
+
# Always set an accessible label to help the user interact with the component.
|
7
|
+
#
|
8
|
+
# * This button is displaying a hellip as its content (The three dots character). Therefore a label is needed for screen readers.
|
9
|
+
# * Set the attribute `aria-label` on the system arguments. E.g. `Primer::HellipButton.new("aria-label": "Expand next part")`
|
10
|
+
class HellipButton < Primer::Component
|
11
|
+
# @example Default
|
12
|
+
# <%= render(Primer::HellipButton.new("aria-label": "No effect")) %>
|
13
|
+
#
|
14
|
+
# @example Inline
|
15
|
+
# <%= render(Primer::HellipButton.new(inline: true, "aria-label": "No effect")) %>
|
16
|
+
#
|
17
|
+
# @example Styling the button
|
18
|
+
# <%= render(Primer::HellipButton.new(p: 1, classes: "custom-class", "aria-label": "No effect")) %>
|
19
|
+
#
|
20
|
+
# @param inline [Boolean] Whether or not the button is inline.
|
21
|
+
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
22
|
+
def initialize(inline: false, **system_arguments)
|
23
|
+
@system_arguments = system_arguments
|
24
|
+
|
25
|
+
validate_aria_label
|
26
|
+
|
27
|
+
@system_arguments[:tag] = :button
|
28
|
+
@system_arguments[:"aria-expanded"] = false
|
29
|
+
@system_arguments[:classes] = class_names(
|
30
|
+
@system_arguments[:classes],
|
31
|
+
"inline" => inline
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
def call
|
36
|
+
render(Primer::BaseButton.new(**@system_arguments)) { "…".html_safe }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -2,21 +2,29 @@
|
|
2
2
|
|
3
3
|
module Primer
|
4
4
|
# Use `HiddenTextExpander` to indicate and toggle hidden text.
|
5
|
+
#
|
6
|
+
# @accessibility
|
7
|
+
# `HiddenTextExpander` requires an `aria-label`, which will provide assistive technologies with an accessible label.
|
8
|
+
# The `aria-label` should describe the action to be invoked by the `HiddenTextExpander`. For instance,
|
9
|
+
# if your `HiddenTextExpander` expands a list of 5 comments, the `aria-label` should be
|
10
|
+
# `"Expand 5 more comments"` instead of `"More"`.
|
5
11
|
class HiddenTextExpander < Primer::Component
|
6
12
|
# @example Default
|
7
|
-
# <%= render(Primer::HiddenTextExpander.new) %>
|
13
|
+
# <%= render(Primer::HiddenTextExpander.new("aria-label": "No effect")) %>
|
8
14
|
#
|
9
15
|
# @example Inline
|
10
|
-
# <%= render(Primer::HiddenTextExpander.new(inline: true)) %>
|
16
|
+
# <%= render(Primer::HiddenTextExpander.new(inline: true, "aria-label": "No effect")) %>
|
11
17
|
#
|
12
18
|
# @example Styling the button
|
13
|
-
# <%= render(Primer::HiddenTextExpander.new(button_arguments: { p: 1, classes: "custom-class" })) %>
|
19
|
+
# <%= render(Primer::HiddenTextExpander.new("aria-label": "No effect", button_arguments: { p: 1, classes: "custom-class" })) %>
|
14
20
|
#
|
15
21
|
# @param inline [Boolean] Whether or not the expander is inline.
|
16
22
|
# @param button_arguments [Hash] <%= link_to_system_arguments_docs %> for the button element.
|
17
23
|
# @param system_arguments [Hash] <%= link_to_system_arguments_docs %>
|
18
24
|
def initialize(inline: false, button_arguments: {}, **system_arguments)
|
19
25
|
@system_arguments = system_arguments
|
26
|
+
@button_arguments = button_arguments
|
27
|
+
|
20
28
|
@system_arguments[:tag] = :span
|
21
29
|
@system_arguments[:classes] = class_names(
|
22
30
|
"hidden-text-expander",
|
@@ -24,8 +32,12 @@ module Primer
|
|
24
32
|
"inline" => inline
|
25
33
|
)
|
26
34
|
|
27
|
-
|
28
|
-
|
35
|
+
aria_label = system_arguments[:"aria-label"] || system_arguments.dig(:aria, :label) || @aria_label
|
36
|
+
if aria_label.present?
|
37
|
+
@button_arguments[:"aria-label"] = aria_label
|
38
|
+
@system_arguments[:aria]&.delete(:label)
|
39
|
+
end
|
40
|
+
|
29
41
|
@button_arguments[:classes] = class_names(
|
30
42
|
"ellipsis-expander",
|
31
43
|
button_arguments[:classes]
|
@@ -34,7 +46,7 @@ module Primer
|
|
34
46
|
|
35
47
|
def call
|
36
48
|
render(Primer::BaseComponent.new(**@system_arguments)) do
|
37
|
-
render(Primer::
|
49
|
+
render(Primer::HellipButton.new(**@button_arguments))
|
38
50
|
end
|
39
51
|
end
|
40
52
|
end
|
data/lib/primer/classify.rb
CHANGED
@@ -105,7 +105,7 @@ module Primer
|
|
105
105
|
def validated_class_names(classes)
|
106
106
|
return if classes.blank?
|
107
107
|
|
108
|
-
if
|
108
|
+
if raise_on_invalid_options? && !ENV["PRIMER_WARNINGS_DISABLED"]
|
109
109
|
invalid_class_names =
|
110
110
|
classes.split.each_with_object([]) do |class_name, memo|
|
111
111
|
memo << class_name if Primer::Classify::Validation.invalid?(class_name)
|
@@ -211,8 +211,8 @@ module Primer
|
|
211
211
|
end
|
212
212
|
end
|
213
213
|
|
214
|
-
def
|
215
|
-
Rails.application.config.primer_view_components.
|
214
|
+
def raise_on_invalid_options?
|
215
|
+
Rails.application.config.primer_view_components.raise_on_invalid_options
|
216
216
|
end
|
217
217
|
end
|
218
218
|
|
@@ -15,7 +15,7 @@ module Primer
|
|
15
15
|
|
16
16
|
config.primer_view_components = ActiveSupport::OrderedOptions.new
|
17
17
|
|
18
|
-
config.primer_view_components.
|
18
|
+
config.primer_view_components.raise_on_invalid_options = false
|
19
19
|
config.primer_view_components.silence_deprecations = false
|
20
20
|
config.primer_view_components.validate_class_names = !Rails.env.production?
|
21
21
|
|
@@ -4,6 +4,8 @@ require "json"
|
|
4
4
|
require "openssl"
|
5
5
|
require "primer/view_components/constants"
|
6
6
|
|
7
|
+
require_relative "tag_tree_helpers"
|
8
|
+
|
7
9
|
# :nocov:
|
8
10
|
|
9
11
|
module ERBLint
|
@@ -14,11 +16,7 @@ module ERBLint
|
|
14
16
|
# * `CLASSES` - optional - The CSS classes that the component needs. The linter will only match elements with one of those classes.
|
15
17
|
# * `REQUIRED_ARGUMENTS` - optional - A list of HTML attributes that are required by the component.
|
16
18
|
class BaseLinter < Linter
|
17
|
-
|
18
|
-
SELF_CLOSING_TAGS = %w[
|
19
|
-
area base br col command embed hr input keygen
|
20
|
-
link menuitem meta param source track wbr img
|
21
|
-
].freeze
|
19
|
+
include TagTreeHelpers
|
22
20
|
|
23
21
|
DUMP_FILE = ".erblint-counter-ignore.json"
|
24
22
|
DISALLOWED_CLASSES = [].freeze
|
@@ -136,53 +134,6 @@ module ERBLint
|
|
136
134
|
end
|
137
135
|
end
|
138
136
|
|
139
|
-
# This assumes that the AST provided represents valid HTML, where each tag has a corresponding closing tag.
|
140
|
-
# From the tags, we build a structured tree which represents the tag hierarchy.
|
141
|
-
# With this, we are able to know where the tags start and end.
|
142
|
-
def build_tag_tree(processed_source)
|
143
|
-
nodes = processed_source.ast.children
|
144
|
-
tag_tree = {}
|
145
|
-
tags = []
|
146
|
-
current_opened_tag = nil
|
147
|
-
|
148
|
-
nodes.each do |node|
|
149
|
-
if node.type == :tag
|
150
|
-
# get the tag from previously calculated list so the references are the same
|
151
|
-
tag = BetterHtml::Tree::Tag.from_node(node)
|
152
|
-
tags << tag
|
153
|
-
|
154
|
-
if tag.closing?
|
155
|
-
if current_opened_tag && tag.name == current_opened_tag.name
|
156
|
-
tag_tree[current_opened_tag][:closing] = tag
|
157
|
-
current_opened_tag = tag_tree[current_opened_tag][:parent]
|
158
|
-
end
|
159
|
-
|
160
|
-
next
|
161
|
-
end
|
162
|
-
|
163
|
-
self_closing = self_closing?(tag)
|
164
|
-
|
165
|
-
tag_tree[tag] = {
|
166
|
-
tag: tag,
|
167
|
-
closing: self_closing ? tag : nil,
|
168
|
-
parent: current_opened_tag,
|
169
|
-
children: []
|
170
|
-
}
|
171
|
-
|
172
|
-
tag_tree[current_opened_tag][:children] << tag_tree[tag] if current_opened_tag
|
173
|
-
current_opened_tag = tag unless self_closing
|
174
|
-
elsif current_opened_tag
|
175
|
-
tag_tree[current_opened_tag][:children] << node
|
176
|
-
end
|
177
|
-
end
|
178
|
-
|
179
|
-
[tags, tag_tree]
|
180
|
-
end
|
181
|
-
|
182
|
-
def self_closing?(tag)
|
183
|
-
tag.self_closing? || SELF_CLOSING_TAGS.include?(tag.name)
|
184
|
-
end
|
185
|
-
|
186
137
|
def tags(processed_source)
|
187
138
|
processed_source.parser.nodes_with_type(:tag).map { |tag_node| BetterHtml::Tree::Tag.from_node(tag_node) }
|
188
139
|
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/string/indent"
|
4
|
+
require_relative "helpers/rubocop_helpers"
|
5
|
+
|
6
|
+
module ERBLint
|
7
|
+
module Linters
|
8
|
+
# Migrates from `Primer::BlankslateComponent` to `Primer::Beta::Blankslate`.
|
9
|
+
class BlankslateApiMigration < Linter
|
10
|
+
include ERBLint::LinterRegistry
|
11
|
+
include Helpers::RubocopHelpers
|
12
|
+
|
13
|
+
def run(processed_source)
|
14
|
+
processed_source.ast.descendants(:erb).each do |erb_node|
|
15
|
+
_, _, code_node = *erb_node
|
16
|
+
code = code_node.children.first.strip
|
17
|
+
|
18
|
+
next unless code.include?("Primer::BlankslateComponent")
|
19
|
+
# Don't fix custom blankslates
|
20
|
+
next if code.end_with?("do")
|
21
|
+
|
22
|
+
line = erb_node.loc.source_line
|
23
|
+
indent = line.split("<%=").first.size
|
24
|
+
|
25
|
+
ast = erb_ast(code)
|
26
|
+
kwargs = ast.arguments.first.arguments.last
|
27
|
+
|
28
|
+
replacement = build_replacement_blankslate(kwargs, indent)
|
29
|
+
|
30
|
+
add_offense(processed_source.to_source_range(erb_node.loc), "`Primer::BlankslateComponent` is deprecated. `Primer::Beta::Blankslate` should be used instead", replacement)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def autocorrect(_, offense)
|
35
|
+
return unless offense.context
|
36
|
+
|
37
|
+
lambda do |corrector|
|
38
|
+
corrector.replace(offense.source_range, offense.context)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def build_blankslate_arguments(kwargs)
|
45
|
+
new_blankslate = {
|
46
|
+
arguments: {},
|
47
|
+
slots: {
|
48
|
+
visual_icon: {},
|
49
|
+
visual_image: {},
|
50
|
+
heading: {
|
51
|
+
tag: ":h2"
|
52
|
+
},
|
53
|
+
description: {},
|
54
|
+
primary_action: {},
|
55
|
+
secondary_action: {}
|
56
|
+
}
|
57
|
+
}
|
58
|
+
|
59
|
+
kwargs&.pairs&.each do |pair|
|
60
|
+
source_value = pair.value.source
|
61
|
+
|
62
|
+
case pair.key.value.to_sym
|
63
|
+
when :title
|
64
|
+
new_blankslate[:slots][:heading][:content] = pair.value.value
|
65
|
+
when :title_tag
|
66
|
+
new_blankslate[:slots][:heading][:tag] = source_value
|
67
|
+
when :icon
|
68
|
+
new_blankslate[:slots][:visual_icon][:icon] = source_value
|
69
|
+
when :icon_size
|
70
|
+
new_blankslate[:slots][:visual_icon][:size] = source_value
|
71
|
+
when :image_src
|
72
|
+
new_blankslate[:slots][:visual_image][:src] = source_value
|
73
|
+
when :image_alt
|
74
|
+
new_blankslate[:slots][:visual_image][:alt] = source_value
|
75
|
+
when :description
|
76
|
+
new_blankslate[:slots][:description][:content] = pair.value.value
|
77
|
+
when :button_text
|
78
|
+
new_blankslate[:slots][:primary_action][:content] = pair.value.value
|
79
|
+
when :button_url
|
80
|
+
new_blankslate[:slots][:primary_action][:href] = source_value
|
81
|
+
when :button_classes
|
82
|
+
new_blankslate[:slots][:primary_action][:classes] = source_value
|
83
|
+
when :link_text
|
84
|
+
new_blankslate[:slots][:secondary_action][:content] = pair.value.value
|
85
|
+
when :link_url
|
86
|
+
new_blankslate[:slots][:secondary_action][:href] = source_value
|
87
|
+
when :large
|
88
|
+
next # Large does not exist anymore
|
89
|
+
else
|
90
|
+
new_blankslate[:arguments][pair.key.source] = source_value
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
new_blankslate
|
95
|
+
end
|
96
|
+
|
97
|
+
def build_replacement_blankslate(kwargs, indent)
|
98
|
+
data = build_blankslate_arguments(kwargs)
|
99
|
+
component_args = args_to_s(data[:arguments])
|
100
|
+
|
101
|
+
# If Blankslate has no heading, we don't update it.
|
102
|
+
return if data[:slots][:heading][:content].nil?
|
103
|
+
# If Blankslate sets both image and icon. don't update it.
|
104
|
+
return if data[:slots][:visual_icon].present? && data[:slots][:visual_image].present?
|
105
|
+
|
106
|
+
slots = data[:slots].map do |slot, slot_data|
|
107
|
+
next if slot_data.empty?
|
108
|
+
|
109
|
+
slot_args = args_to_s(slot_data.except(:content))
|
110
|
+
content = slot_data[:content]
|
111
|
+
|
112
|
+
if content
|
113
|
+
<<~HTML.indent(2)
|
114
|
+
<% c.#{slot}#{slot_args} do %>
|
115
|
+
#{content}
|
116
|
+
<% end %>
|
117
|
+
HTML
|
118
|
+
else
|
119
|
+
<<~HTML.indent(2)
|
120
|
+
<% c.#{slot}#{slot_args} %>
|
121
|
+
HTML
|
122
|
+
end
|
123
|
+
end.compact.join("\n").chomp
|
124
|
+
|
125
|
+
# Body needs to match the file indentation.
|
126
|
+
body = <<~HTML.indent(indent).chomp
|
127
|
+
#{slots}
|
128
|
+
<% end %>
|
129
|
+
HTML
|
130
|
+
|
131
|
+
# The render call will always be aligned.
|
132
|
+
"<%= render Primer::Beta::Blankslate.new#{component_args} do |c| %>\n#{body}"
|
133
|
+
end
|
134
|
+
|
135
|
+
def args_to_s(args)
|
136
|
+
string_args = args.except(:__polymorphic_type).map { |k, v| "#{k}: #{v}" }.join(", ")
|
137
|
+
|
138
|
+
string_args = ":#{args[:__polymorphic_type]}, #{string_args}" if args[:__polymorphic_type]
|
139
|
+
|
140
|
+
return string_args if string_args.blank?
|
141
|
+
|
142
|
+
"(#{string_args})"
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
end
|
@@ -6,7 +6,7 @@ module ERBLint
|
|
6
6
|
module Linters
|
7
7
|
# Counts the number of times a HTML Blankslate is used instead of the component.
|
8
8
|
class BlankslateComponentMigrationCounter < BaseLinter
|
9
|
-
MESSAGE = "We are migrating Blankslate to use [Primer::
|
9
|
+
MESSAGE = "We are migrating Blankslate to use [Primer::Beta::Blankslate](https://primer.style/view-components/components/beta/blankslate), please try to use that instead of raw HTML."
|
10
10
|
CLASSES = %w[blankslate].freeze
|
11
11
|
TAGS = %w[div].freeze
|
12
12
|
end
|
@@ -3,12 +3,14 @@
|
|
3
3
|
require_relative "base_linter"
|
4
4
|
require_relative "autocorrectable"
|
5
5
|
require_relative "argument_mappers/close_button"
|
6
|
+
require_relative "helpers/rubocop_helpers"
|
6
7
|
|
7
8
|
module ERBLint
|
8
9
|
module Linters
|
9
10
|
# Counts the number of times a HTML clipboard-copy is used instead of the component.
|
10
11
|
class CloseButtonComponentMigrationCounter < BaseLinter
|
11
12
|
include Autocorrectable
|
13
|
+
include Helpers::RubocopHelpers
|
12
14
|
|
13
15
|
TAGS = %w[button].freeze
|
14
16
|
CLASSES = %w[close-button].freeze
|
@@ -109,10 +111,6 @@ module ERBLint
|
|
109
111
|
(kwargs.keys.map { |key| key.value.to_s } - ALLOWED_OCTICON_ARGS).present?
|
110
112
|
end
|
111
113
|
|
112
|
-
def erb_ast(code)
|
113
|
-
RuboCop::AST::ProcessedSource.new(code, RUBY_VERSION.to_f).ast
|
114
|
-
end
|
115
|
-
|
116
114
|
def icon(args)
|
117
115
|
return args.first.value.to_sym if args.first.type == :sym || args.first.type == :str
|
118
116
|
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ERBLint
|
4
|
+
module Linters
|
5
|
+
module Helpers
|
6
|
+
# Provides helpers related to RuboCop.
|
7
|
+
module RubocopHelpers
|
8
|
+
def erb_ast(code)
|
9
|
+
RuboCop::AST::ProcessedSource.new(code, RUBY_VERSION.to_f).ast
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ERBLint
|
4
|
+
module Linters
|
5
|
+
# Helpers used by linters to organize HTML tags into abstract syntax trees.
|
6
|
+
module TagTreeHelpers
|
7
|
+
# from https://github.com/Shopify/erb-lint/blob/6179ee2d9d681a6ec4dd02351a1e30eefa748d3d/lib/erb_lint/linters/self_closing_tag.rb
|
8
|
+
SELF_CLOSING_TAGS = %w[
|
9
|
+
area base br col command embed hr input keygen
|
10
|
+
link menuitem meta param source track wbr img
|
11
|
+
].freeze
|
12
|
+
|
13
|
+
# This assumes that the AST provided represents valid HTML, where each tag has a corresponding closing tag.
|
14
|
+
# From the tags, we build a structured tree which represents the tag hierarchy.
|
15
|
+
# With this, we are able to know where the tags start and end.
|
16
|
+
def build_tag_tree(processed_source)
|
17
|
+
nodes = processed_source.ast.children
|
18
|
+
tag_tree = {}
|
19
|
+
tags = []
|
20
|
+
current_opened_tag = nil
|
21
|
+
|
22
|
+
nodes.each do |node|
|
23
|
+
if node.type == :tag
|
24
|
+
# get the tag from previously calculated list so the references are the same
|
25
|
+
tag = BetterHtml::Tree::Tag.from_node(node)
|
26
|
+
tags << tag
|
27
|
+
|
28
|
+
if tag.closing?
|
29
|
+
if current_opened_tag && tag.name == current_opened_tag.name
|
30
|
+
tag_tree[current_opened_tag][:closing] = tag
|
31
|
+
current_opened_tag = tag_tree[current_opened_tag][:parent]
|
32
|
+
end
|
33
|
+
|
34
|
+
next
|
35
|
+
end
|
36
|
+
|
37
|
+
self_closing = self_closing?(tag)
|
38
|
+
|
39
|
+
tag_tree[tag] = {
|
40
|
+
tag: tag,
|
41
|
+
closing: self_closing ? tag : nil,
|
42
|
+
parent: current_opened_tag,
|
43
|
+
children: []
|
44
|
+
}
|
45
|
+
|
46
|
+
tag_tree[current_opened_tag][:children] << tag_tree[tag] if current_opened_tag
|
47
|
+
current_opened_tag = tag unless self_closing
|
48
|
+
elsif current_opened_tag
|
49
|
+
tag_tree[current_opened_tag][:children] << node
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
[tags, tag_tree]
|
54
|
+
end
|
55
|
+
|
56
|
+
def self_closing?(tag)
|
57
|
+
tag.self_closing? || SELF_CLOSING_TAGS.include?(tag.name)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,158 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "base_linter"
|
4
|
+
require_relative "tag_tree_helpers"
|
5
|
+
|
6
|
+
module ERBLint
|
7
|
+
module Linters
|
8
|
+
# Counts the number of times a two column layout using col-* CSS classes is used instead of the layout component.
|
9
|
+
class TwoColumnLayoutMigrationCounter < BaseLinter
|
10
|
+
include LinterRegistry
|
11
|
+
include TagTreeHelpers
|
12
|
+
|
13
|
+
WIDTH_RANGE = (8..10).freeze
|
14
|
+
SIDEBAR_WIDTH_RANGE = (2..4).freeze
|
15
|
+
|
16
|
+
CONTAINER_CLASSES = %w[container-xl container-lg container-md container-sm].freeze
|
17
|
+
MESSAGE = "We are migrating two-column layouts to use "\
|
18
|
+
"[Primer::Alpha::Layout](https://primer.style/view-components/components/layout), "\
|
19
|
+
"please use that instead of raw HTML."
|
20
|
+
|
21
|
+
# :nodoc:
|
22
|
+
class Breakpoints
|
23
|
+
LABELS = %i[all sm md lg xl].freeze
|
24
|
+
|
25
|
+
def initialize
|
26
|
+
@map = {}
|
27
|
+
end
|
28
|
+
|
29
|
+
def set(breakpoint, value)
|
30
|
+
@map[breakpoint] = value
|
31
|
+
end
|
32
|
+
|
33
|
+
def min
|
34
|
+
LABELS.find { |label| @map[label] } || :all
|
35
|
+
end
|
36
|
+
|
37
|
+
def min_value
|
38
|
+
@map[min]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# :nodoc:
|
43
|
+
class Column
|
44
|
+
attr_reader :widths, :tag_tree
|
45
|
+
|
46
|
+
def initialize(widths, tag_tree)
|
47
|
+
@widths = widths
|
48
|
+
@tag_tree = tag_tree
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# :nodoc:
|
53
|
+
class Container
|
54
|
+
attr_reader :columns
|
55
|
+
|
56
|
+
def initialize(columns)
|
57
|
+
@columns = columns
|
58
|
+
end
|
59
|
+
|
60
|
+
def sidebar
|
61
|
+
sorted_columns.first
|
62
|
+
end
|
63
|
+
|
64
|
+
def main
|
65
|
+
sorted_columns.last
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def sorted_columns
|
71
|
+
@sorted_columns ||= columns.sort_by do |col|
|
72
|
+
col.widths.min_value || 0
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def run(processed_source)
|
78
|
+
@total_offenses = 0
|
79
|
+
@offenses_not_corrected = 0
|
80
|
+
|
81
|
+
tags, tag_tree = build_tag_tree(processed_source)
|
82
|
+
|
83
|
+
tags.each do |tag|
|
84
|
+
next if tag.closing?
|
85
|
+
next unless tag.name == "div"
|
86
|
+
|
87
|
+
classes = classes_from(tag)
|
88
|
+
next if (CONTAINER_CLASSES & classes).empty?
|
89
|
+
|
90
|
+
next unless metadata_from(tag_tree[tag])
|
91
|
+
|
92
|
+
@total_offenses += 1
|
93
|
+
@offenses_not_corrected += 1
|
94
|
+
|
95
|
+
generate_offense(self.class, processed_source, tag, MESSAGE)
|
96
|
+
end
|
97
|
+
|
98
|
+
counter_correct?(processed_source)
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def metadata_from(tag_tree)
|
104
|
+
tags = tag_tree[:children].select { |c| c.is_a?(Hash) }
|
105
|
+
|
106
|
+
if d_flex?(tags)
|
107
|
+
container_from(tags.first)
|
108
|
+
else
|
109
|
+
container_from(tag_tree)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def d_flex?(tags)
|
114
|
+
tags.size == 1 && classes_from(tags.first[:tag]).include?("d-flex")
|
115
|
+
end
|
116
|
+
|
117
|
+
def container_from(columns_tag_tree)
|
118
|
+
columns = columns_from(columns_tag_tree)
|
119
|
+
return unless columns.size == 2
|
120
|
+
|
121
|
+
container = Container.new(columns)
|
122
|
+
|
123
|
+
main_min = container.main.widths.min_value
|
124
|
+
sidebar_min = container.sidebar.widths.min_value
|
125
|
+
return unless sidebar_min && main_min
|
126
|
+
return unless WIDTH_RANGE.include?(main_min)
|
127
|
+
return unless SIDEBAR_WIDTH_RANGE.include?(sidebar_min)
|
128
|
+
|
129
|
+
container
|
130
|
+
end
|
131
|
+
|
132
|
+
def columns_from(tag_tree)
|
133
|
+
tag_tree[:children].each_with_object([]) do |tag_data, tags_memo|
|
134
|
+
next unless tag_data.is_a?(Hash)
|
135
|
+
next unless tag_data[:tag].name == "div"
|
136
|
+
|
137
|
+
classes = classes_from(tag_data[:tag])
|
138
|
+
widths = Breakpoints.new
|
139
|
+
|
140
|
+
classes.each do |cls|
|
141
|
+
match = cls.match(/\Acol(?:-(xl|lg|md|sm))?-(\d{1,2})(?:-max)?\z/)
|
142
|
+
next unless match
|
143
|
+
|
144
|
+
breakpoint, width = match.captures
|
145
|
+
breakpoint ||= :all
|
146
|
+
widths.set(breakpoint.to_sym, width.to_i)
|
147
|
+
end
|
148
|
+
|
149
|
+
tags_memo << Column.new(widths, tag_data)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def classes_from(tag)
|
154
|
+
tag.attributes["class"]&.value&.split(" ") || []
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|