primer_view_components 0.0.43 → 0.0.47

Sign up to get free protection for your applications and to get access to all the features.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +220 -3
  3. data/app/assets/javascripts/primer_view_components.js +1 -1
  4. data/app/assets/javascripts/primer_view_components.js.map +1 -1
  5. data/app/components/primer/alpha/button_marketing.rb +70 -0
  6. data/app/components/primer/avatar_stack_component.rb +9 -3
  7. data/app/components/primer/base_component.rb +52 -23
  8. data/app/components/primer/beta/auto_complete.rb +159 -0
  9. data/app/components/primer/beta/auto_complete/auto_complete.d.ts +1 -0
  10. data/app/components/primer/{auto_complete → beta/auto_complete}/auto_complete.html.erb +1 -0
  11. data/app/components/primer/beta/auto_complete/auto_complete.js +1 -0
  12. data/app/components/primer/{auto_complete → beta/auto_complete}/auto_complete.ts +0 -0
  13. data/app/components/primer/beta/auto_complete/item.rb +44 -0
  14. data/app/components/primer/beta/avatar.rb +77 -0
  15. data/app/components/primer/beta/text.rb +27 -0
  16. data/app/components/primer/blankslate_component.rb +2 -1
  17. data/app/components/primer/border_box_component.rb +3 -0
  18. data/app/components/primer/button_component.rb +3 -2
  19. data/app/components/primer/clipboard_copy.rb +25 -7
  20. data/app/components/primer/component.rb +4 -0
  21. data/app/components/primer/details_component.rb +18 -3
  22. data/app/components/primer/dropdown.d.ts +1 -0
  23. data/app/components/primer/{dropdown_component.html.erb → dropdown.html.erb} +2 -1
  24. data/app/components/primer/dropdown.js +1 -0
  25. data/app/components/primer/dropdown.rb +149 -0
  26. data/app/components/primer/dropdown.ts +1 -0
  27. data/app/components/primer/dropdown/menu.d.ts +1 -0
  28. data/app/components/primer/dropdown/menu.html.erb +25 -0
  29. data/app/components/primer/dropdown/menu.js +1 -0
  30. data/app/components/primer/dropdown/menu.rb +99 -0
  31. data/app/components/primer/dropdown/menu.ts +1 -0
  32. data/app/components/primer/heading_component.rb +1 -1
  33. data/app/components/primer/icon_button.rb +1 -1
  34. data/app/components/primer/image_crop.rb +1 -1
  35. data/app/components/primer/markdown.rb +9 -9
  36. data/app/components/primer/menu_component.rb +7 -3
  37. data/app/components/primer/navigation/tab_component.rb +6 -6
  38. data/app/components/primer/octicon_component.rb +3 -2
  39. data/app/components/primer/popover_component.rb +6 -3
  40. data/app/components/primer/primer.d.ts +2 -1
  41. data/app/components/primer/primer.js +2 -1
  42. data/app/components/primer/primer.ts +2 -1
  43. data/app/components/primer/spinner_component.rb +2 -0
  44. data/app/components/primer/tab_nav_component.rb +5 -3
  45. data/app/components/primer/timeline_item_component.rb +2 -2
  46. data/app/components/primer/tooltip.rb +1 -1
  47. data/app/components/primer/truncate.rb +5 -0
  48. data/app/components/primer/underline_nav_component.rb +10 -4
  49. data/{app/lib → lib}/primer/classify.rb +16 -33
  50. data/{app/lib → lib}/primer/classify/cache.rb +6 -40
  51. data/{app/lib → lib}/primer/classify/flex.rb +0 -0
  52. data/{app/lib → lib}/primer/classify/functional_background_colors.rb +2 -0
  53. data/{app/lib → lib}/primer/classify/functional_border_colors.rb +2 -0
  54. data/{app/lib → lib}/primer/classify/functional_colors.rb +0 -0
  55. data/{app/lib → lib}/primer/classify/functional_text_colors.rb +2 -0
  56. data/{app/lib → lib}/primer/classify/grid.rb +0 -0
  57. data/lib/primer/classify/utilities.rb +148 -0
  58. data/lib/primer/classify/utilities.yml +1271 -0
  59. data/lib/primer/view_components.rb +1 -0
  60. data/lib/primer/view_components/linters/argument_mappers/button.rb +82 -0
  61. data/lib/primer/view_components/linters/argument_mappers/conversion_error.rb +10 -0
  62. data/lib/primer/view_components/linters/argument_mappers/system_arguments.rb +47 -0
  63. data/lib/primer/view_components/linters/button_component_migration_counter.rb +24 -1
  64. data/lib/primer/view_components/linters/flash_component_migration_counter.rb +1 -1
  65. data/lib/primer/view_components/linters/helpers.rb +137 -18
  66. data/lib/primer/view_components/statuses.rb +14 -0
  67. data/lib/primer/view_components/version.rb +1 -1
  68. data/lib/tasks/docs.rake +179 -110
  69. data/lib/tasks/utilities.rake +105 -0
  70. data/lib/yard/docs_helper.rb +13 -3
  71. data/static/statuses.json +9 -7
  72. metadata +41 -27
  73. data/app/components/primer/auto_complete.rb +0 -100
  74. data/app/components/primer/auto_complete/item.rb +0 -42
  75. data/app/components/primer/avatar_component.rb +0 -75
  76. data/app/components/primer/button_marketing_component.rb +0 -68
  77. data/app/components/primer/dropdown/menu_component.html.erb +0 -12
  78. data/app/components/primer/dropdown/menu_component.rb +0 -46
  79. data/app/components/primer/dropdown_component.rb +0 -73
  80. data/app/components/primer/text_component.rb +0 -25
  81. data/app/lib/primer/classify/spacing.rb +0 -63
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "primer/classify"
3
4
  require "primer/view_components/version"
4
5
  require "primer/view_components/engine"
5
6
 
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_error"
4
+ require_relative "system_arguments"
5
+
6
+ module ERBLint
7
+ module Linters
8
+ module ArgumentMappers
9
+ # Maps classes in a button element to arguments for the Button component.
10
+ class Button
11
+ SCHEME_MAPPINGS = {
12
+ "btn-primary" => ":primary",
13
+ "btn-danger" => ":danger",
14
+ "btn-outline" => ":outline",
15
+ "btn-invisible" => ":invisible",
16
+ "btn-link" => ":link"
17
+ }.freeze
18
+
19
+ VARIANT_MAPPINGS = {
20
+ "btn-sm" => ":small",
21
+ "btn-large" => ":large"
22
+ }.freeze
23
+
24
+ TYPE_OPTIONS = %w[button reset submit].freeze
25
+
26
+ def initialize(tag)
27
+ @tag = tag
28
+ end
29
+
30
+ def to_s
31
+ to_args.map { |k, v| "#{k}: #{v}" }.join(", ")
32
+ end
33
+
34
+ def to_args
35
+ args = {}
36
+
37
+ args[:tag] = ":#{@tag.name}" unless @tag.name == "button"
38
+
39
+ @tag.attributes.each do |attribute|
40
+ attr_name = attribute.name
41
+
42
+ if attr_name == "class"
43
+ args = args.merge(classes_to_args(attribute))
44
+ elsif attr_name == "disabled"
45
+ args[:disabled] = true
46
+ elsif attr_name == "type"
47
+ # button is the default type, so we don't need to do anything.
48
+ next if attribute.value == "button"
49
+
50
+ raise ConversionError, "Button component does not support type \"#{attribute.value}\"" unless TYPE_OPTIONS.include?(attribute.value)
51
+
52
+ args[:type] = ":#{attribute.value}"
53
+ else
54
+ # Assume the attribute is a system argument.
55
+ args.merge!(SystemArguments.new(attribute).to_args)
56
+ end
57
+ end
58
+
59
+ args
60
+ end
61
+
62
+ def classes_to_args(classes)
63
+ classes.value.split(" ").each_with_object({}) do |class_name, acc|
64
+ next if class_name == "btn"
65
+
66
+ if SCHEME_MAPPINGS[class_name] && acc[:scheme].nil?
67
+ acc[:scheme] = SCHEME_MAPPINGS[class_name]
68
+ elsif VARIANT_MAPPINGS[class_name] && acc[:variant].nil?
69
+ acc[:variant] = VARIANT_MAPPINGS[class_name]
70
+ elsif class_name == "btn-block"
71
+ acc[:block] = true
72
+ elsif class_name == "BtnGroup-item"
73
+ acc[:group_item] = true
74
+ else
75
+ raise ConversionError, "Cannot convert class \"#{class_name}\""
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ERBLint
4
+ module Linters
5
+ module ArgumentMappers
6
+ # Error when converting arguments.
7
+ class ConversionError < StandardError; end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conversion_error"
4
+
5
+ module ERBLint
6
+ module Linters
7
+ module ArgumentMappers
8
+ # Maps element attributes to system arguments.
9
+ class SystemArguments
10
+ STRING_PARAMETERS = %w[aria- data-].freeze
11
+ TEST_SELECTOR_REGEX = /test_selector\((?<selector>.+)\)$/.freeze
12
+
13
+ attr_reader :attribute
14
+ def initialize(attribute)
15
+ @attribute = attribute
16
+ end
17
+
18
+ def to_args
19
+ if attribute.erb?
20
+ _, _, code_node = *attribute.node
21
+
22
+ raise ConversionError, "Cannot convert erb block" if code_node.nil?
23
+
24
+ code = code_node.loc.source.strip
25
+ m = code.match(TEST_SELECTOR_REGEX)
26
+
27
+ raise ConversionError, "Cannot convert erb block" if m.blank?
28
+
29
+ { test_selector: m[:selector].tr("'", '"') }
30
+ elsif attr_name == "data-test-selector"
31
+ { test_selector: attribute.value.to_json }
32
+ elsif attr_name.start_with?(*STRING_PARAMETERS)
33
+ raise ConversionError, "Cannot convert attribute \"#{attr_name}\" because its value contains an erb block" if attribute.value_node&.children&.any? { |n| n.try(:type) == :erb }
34
+
35
+ { "\"#{attr_name}\"" => attribute.value.to_json }
36
+ else
37
+ raise ConversionError, "Cannot convert attribute \"#{attr_name}\""
38
+ end
39
+ end
40
+
41
+ def attr_name
42
+ attribute.name
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "helpers"
4
+ require_relative "argument_mappers/button"
4
5
 
5
6
  module ERBLint
6
7
  module Linters
@@ -9,8 +10,30 @@ module ERBLint
9
10
  include Helpers
10
11
 
11
12
  TAGS = %w[button summary a].freeze
12
- CLASS = "btn"
13
+ CLASSES = %w[btn btn-link].freeze
13
14
  MESSAGE = "We are migrating buttons to use [Primer::ButtonComponent](https://primer.style/view-components/components/button), please try to use that instead of raw HTML."
15
+
16
+ private
17
+
18
+ def map_arguments(tag)
19
+ ArgumentMappers::Button.new(tag).to_s
20
+ rescue ArgumentMappers::ConversionError
21
+ nil
22
+ end
23
+
24
+ def correction(args)
25
+ return nil if args.nil?
26
+
27
+ correction = "<%= render Primer::ButtonComponent.new"
28
+ correction += "(#{args})" if args.present?
29
+ "#{correction} do %>"
30
+ end
31
+
32
+ def message(args)
33
+ return MESSAGE if args.nil?
34
+
35
+ "#{MESSAGE}\n\nTry using:\n\n#{correction(args)}\n\nInstead of:\n"
36
+ end
14
37
  end
15
38
  end
16
39
  end
@@ -9,7 +9,7 @@ module ERBLint
9
9
  include Helpers
10
10
 
11
11
  TAGS = %w[div].freeze
12
- CLASS = "flash"
12
+ CLASSES = %w[flash].freeze
13
13
  MESSAGE = "We are migrating flashes to use [Primer::FlashComponent](https://primer.style/view-components/components/flash), please try to use that instead of raw HTML."
14
14
  end
15
15
  end
@@ -7,34 +7,69 @@ module ERBLint
7
7
  module Linters
8
8
  # Helper methods for linting ERB.
9
9
  module Helpers
10
+ # from https://github.com/Shopify/erb-lint/blob/6179ee2d9d681a6ec4dd02351a1e30eefa748d3d/lib/erb_lint/linters/self_closing_tag.rb
11
+ SELF_CLOSING_TAGS = %w[
12
+ area base br col command embed hr input keygen
13
+ link menuitem meta param source track wbr img
14
+ ].freeze
15
+
16
+ DUMP_FILE = ".erblint-counter-ignore.json"
17
+
10
18
  def self.included(base)
11
- base.include(LinterRegistry)
19
+ base.include(ERBLint::LinterRegistry)
12
20
 
13
21
  define_method "run" do |processed_source|
14
- tags(processed_source).each do |tag|
22
+ @total_offenses = 0
23
+ @offenses_not_corrected = 0
24
+ tags = tags(processed_source)
25
+ tag_tree = build_tag_tree(tags)
26
+
27
+ tags.each do |tag|
15
28
  next if tag.closing?
16
29
  next unless self.class::TAGS&.include?(tag.name)
17
30
 
18
- classes = tag.attributes["class"]&.value&.split(" ")
31
+ classes = tag.attributes["class"]&.value&.split(" ") || []
19
32
 
20
- next unless !self.class::CLASS || classes&.include?(self.class::CLASS)
33
+ tag_tree[tag][:offense] = false
21
34
 
22
- generate_offense(self.class, processed_source, tag, self.class::MESSAGE)
35
+ next unless self.class::CLASSES.blank? || (classes & self.class::CLASSES).any?
36
+
37
+ args = map_arguments(tag)
38
+ correction = correction(args)
39
+
40
+ tag_tree[tag][:offense] = true
41
+ tag_tree[tag][:correctable] = !correction.nil?
42
+ tag_tree[tag][:message] = message(args)
43
+ tag_tree[tag][:correction] = correction
44
+ end
45
+
46
+ tag_tree.each do |tag, h|
47
+ next unless h[:offense]
48
+
49
+ @total_offenses += 1
50
+ # We always fix the offenses using blocks. The closing tag corresponds to `<% end %>`.
51
+ if h[:correctable]
52
+ add_offense(tag.loc, h[:message], h[:correction])
53
+ add_offense(h[:closing].loc, h[:message], "<% end %>")
54
+ else
55
+ @offenses_not_corrected += 1
56
+ generate_offense(self.class, processed_source, tag, h[:message])
57
+ end
23
58
  end
24
59
 
25
60
  counter_correct?(processed_source)
61
+
62
+ dump_data(processed_source) if ENV["DUMP_LINT_DATA"] == "1"
26
63
  end
27
64
 
28
65
  define_method "autocorrect" do |processed_source, offense|
29
66
  return unless offense.context
30
67
 
31
68
  lambda do |corrector|
32
- if processed_source.file_content.include?("erblint:counter #{self.class.name.demodulize}")
33
- # update the counter if exists
34
- corrector.replace(offense.source_range, offense.context)
69
+ if offense.context.include?(counter_disable)
70
+ correct_counter(corrector, processed_source, offense)
35
71
  else
36
- # add comment with counter if none
37
- corrector.insert_before(processed_source.source_buffer.source_range, "#{offense.context}\n")
72
+ corrector.replace(offense.source_range, offense.context)
38
73
  end
39
74
  end
40
75
  end
@@ -42,6 +77,77 @@ module ERBLint
42
77
 
43
78
  private
44
79
 
80
+ # Override this function to convert the HTML element attributes to argument for a component.
81
+ #
82
+ # @return [Hash] if possible to map all attributes to arguments.
83
+ # @return [Nil] if cannot map to arguments.
84
+ def map_arguments(_tag)
85
+ nil
86
+ end
87
+
88
+ # Override this function to define how to autocorrect an element to a component.
89
+ #
90
+ # @return [String] with the text to replace the HTML element if possible to correct.
91
+ # @return [Nil] if cannot correct element.
92
+ def correction(_tag)
93
+ nil
94
+ end
95
+
96
+ # Override this function to customize the linter message.
97
+ #
98
+ # @return [String] message to show on linter error.
99
+ def message(_tag)
100
+ self.class::MESSAGE
101
+ end
102
+
103
+ def counter_disable
104
+ "erblint:counter #{self.class.name.demodulize}"
105
+ end
106
+
107
+ def correct_counter(corrector, processed_source, offense)
108
+ if processed_source.file_content.include?(counter_disable)
109
+ # update the counter if exists
110
+ corrector.replace(offense.source_range, offense.context)
111
+ else
112
+ # add comment with counter if none
113
+ corrector.insert_before(processed_source.source_buffer.source_range, "#{offense.context}\n")
114
+ end
115
+ end
116
+
117
+ # This assumes that the AST provided represents valid HTML, where each tag has a corresponding closing tag.
118
+ # From the tags, we build a structured tree which represents the tag hierarchy.
119
+ # With this, we are able to know where the tags start and end.
120
+ def build_tag_tree(tags)
121
+ tag_tree = {}
122
+ current_opened_tag = nil
123
+
124
+ tags.each do |tag|
125
+ if tag.closing?
126
+ if current_opened_tag && tag.name == current_opened_tag.name
127
+ tag_tree[current_opened_tag][:closing] = tag
128
+ current_opened_tag = tag_tree[current_opened_tag][:parent]
129
+ end
130
+
131
+ next
132
+ end
133
+
134
+ self_closing = self_closing?(tag)
135
+
136
+ tag_tree[tag] = {
137
+ closing: self_closing ? tag : nil,
138
+ parent: current_opened_tag
139
+ }
140
+
141
+ current_opened_tag = tag unless self_closing
142
+ end
143
+
144
+ tag_tree
145
+ end
146
+
147
+ def self_closing?(tag)
148
+ tag.self_closing? || SELF_CLOSING_TAGS.include?(tag.name)
149
+ end
150
+
45
151
  def tags(processed_source)
46
152
  processed_source.parser.nodes_with_type(:tag).map { |tag_node| BetterHtml::Tree::Tag.from_node(tag_node) }
47
153
  end
@@ -50,7 +156,6 @@ module ERBLint
50
156
  comment_node = nil
51
157
  expected_count = 0
52
158
  rule_name = self.class.name.match(/:?:?(\w+)\Z/)[1]
53
- offenses_count = @offenses.length
54
159
 
55
160
  processed_source.parser.ast.descendants(:erb).each do |node|
56
161
  indicator_node, _, code_node, = *node
@@ -58,32 +163,46 @@ module ERBLint
58
163
  comment = code_node&.loc&.source&.strip
59
164
 
60
165
  if indicator == "#" && comment.start_with?("erblint:count") && comment.match(rule_name)
61
- comment_node = code_node
166
+ comment_node = node
62
167
  expected_count = comment.match(/\s(\d+)\s?$/)[1].to_i
63
168
  end
64
169
  end
65
170
 
66
- if offenses_count.zero?
67
- add_offense(processed_source.to_source_range(comment_node.loc), "Unused erblint:count comment for #{rule_name}") if comment_node
171
+ if @offenses_not_corrected.zero?
172
+ # have to adjust to get `\n` so we delete the whole line
173
+ add_offense(processed_source.to_source_range(comment_node.loc.adjust(end_pos: 1)), "Unused erblint:count comment for #{rule_name}", "") if comment_node
68
174
  return
69
175
  end
70
176
 
71
177
  first_offense = @offenses[0]
72
178
 
73
179
  if comment_node.nil?
74
- add_offense(processed_source.to_source_range(first_offense.source_range), "#{rule_name}: If you must, add <%# erblint:counter #{rule_name} #{offenses_count} %> to bypass this check.", "<%# erblint:counter #{rule_name} #{offenses_count} %>")
75
- else
180
+ add_offense(processed_source.to_source_range(first_offense.source_range), "#{rule_name}: If you must, add <%# erblint:counter #{rule_name} #{@offenses_not_corrected} %> to bypass this check.", "<%# erblint:counter #{rule_name} #{@offenses_not_corrected} %>")
181
+ elsif expected_count != @offenses_not_corrected
182
+ add_offense(processed_source.to_source_range(comment_node.loc), "Incorrect erblint:counter number for #{rule_name}. Expected: #{expected_count}, actual: #{@offenses_not_corrected}.", "<%# erblint:counter #{rule_name} #{@offenses_not_corrected} %>")
183
+ # the only offenses remaining are not autocorrectable, so we can ignore them
184
+ elsif expected_count == @offenses_not_corrected && @offenses.size == @offenses_not_corrected
76
185
  clear_offenses
77
- add_offense(processed_source.to_source_range(comment_node.loc), "Incorrect erblint:counter number for #{rule_name}. Expected: #{expected_count}, actual: #{offenses_count}.", " erblint:counter #{rule_name} #{offenses_count} ") if expected_count != offenses_count
78
186
  end
79
187
  end
80
188
 
81
189
  def generate_offense(klass, processed_source, tag, message = nil, replacement = nil)
82
190
  message ||= klass::MESSAGE
83
- klass_name = klass.name.split("::")[-1]
191
+ klass_name = klass.name.demodulize
84
192
  offense = ["#{klass_name}:#{message}", tag.node.loc.source].join("\n")
85
193
  add_offense(processed_source.to_source_range(tag.loc), offense, replacement)
86
194
  end
195
+
196
+ def dump_data(processed_source)
197
+ return if @total_offenses.zero?
198
+
199
+ data = File.exist?(DUMP_FILE) ? JSON.parse(File.read(DUMP_FILE)) : {}
200
+
201
+ data[processed_source.filename] ||= {}
202
+ data[processed_source.filename][self.class.name.demodulize] = @total_offenses
203
+
204
+ File.write(DUMP_FILE, JSON.pretty_generate(data))
205
+ end
87
206
  end
88
207
  end
89
208
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Primer
6
+ # :nodoc:
7
+ module ViewComponents
8
+ STATUSES = JSON.parse(
9
+ File.read(
10
+ File.join(File.dirname(__FILE__), "../../../static/statuses.json")
11
+ )
12
+ ).freeze
13
+ end
14
+ end
@@ -5,7 +5,7 @@ module Primer
5
5
  module VERSION
6
6
  MAJOR = 0
7
7
  MINOR = 0
8
- PATCH = 43
8
+ PATCH = 47
9
9
 
10
10
  STRING = [MAJOR, MINOR, PATCH].join(".")
11
11
  end
data/lib/tasks/docs.rake CHANGED
@@ -32,9 +32,9 @@ namespace :docs do
32
32
  Primer::OcticonSymbolsComponent,
33
33
  Primer::ImageCrop,
34
34
  Primer::IconButton,
35
- Primer::AutoComplete,
36
- Primer::AutoComplete::Item,
37
- Primer::AvatarComponent,
35
+ Primer::Beta::AutoComplete,
36
+ Primer::Beta::AutoComplete::Item,
37
+ Primer::Beta::Avatar,
38
38
  Primer::AvatarStackComponent,
39
39
  Primer::BaseButton,
40
40
  Primer::BlankslateComponent,
@@ -43,12 +43,12 @@ namespace :docs do
43
43
  Primer::BreadcrumbComponent,
44
44
  Primer::ButtonComponent,
45
45
  Primer::ButtonGroup,
46
- Primer::ButtonMarketingComponent,
46
+ Primer::Alpha::ButtonMarketing,
47
47
  Primer::ClipboardCopy,
48
48
  Primer::CloseButton,
49
49
  Primer::CounterComponent,
50
50
  Primer::DetailsComponent,
51
- Primer::DropdownComponent,
51
+ Primer::Dropdown,
52
52
  Primer::DropdownMenuComponent,
53
53
  Primer::FlashComponent,
54
54
  Primer::FlexComponent,
@@ -69,7 +69,7 @@ namespace :docs do
69
69
  Primer::SubheadComponent,
70
70
  Primer::TabContainerComponent,
71
71
  Primer::TabNavComponent,
72
- Primer::TextComponent,
72
+ Primer::Beta::Text,
73
73
  Primer::TimeAgoComponent,
74
74
  Primer::TimelineItemComponent,
75
75
  Primer::Tooltip,
@@ -78,9 +78,10 @@ namespace :docs do
78
78
  ]
79
79
 
80
80
  js_components = [
81
+ Primer::Dropdown,
81
82
  Primer::LocalTime,
82
83
  Primer::ImageCrop,
83
- Primer::AutoComplete,
84
+ Primer::Beta::AutoComplete,
84
85
  Primer::ClipboardCopy,
85
86
  Primer::TabContainerComponent,
86
87
  Primer::TabNavComponent,
@@ -91,30 +92,35 @@ namespace :docs do
91
92
  all_components = Primer::Component.descendants - [Primer::BaseComponent]
92
93
  components_needing_docs = all_components - components
93
94
 
94
- components_without_examples = []
95
95
  args_for_components = []
96
96
  classes_found_in_examples = []
97
97
 
98
- components.each do |component|
98
+ errors = []
99
+
100
+ # Deletes docs before regenerating them, guaranteeing that we don't keep stale docs.
101
+ FileUtils.rm_rf(Dir.glob("docs/content/components/**/*.md"))
102
+
103
+ components.sort_by(&:name).each do |component|
99
104
  documentation = registry.get(component.name)
100
105
 
101
- # Primer::AvatarComponent => Avatar
102
- short_name = component.name.gsub(/Primer|::|Component/, "")
106
+ data = docs_metadata(component)
103
107
 
104
- path = Pathname.new("docs/content/components/#{short_name.downcase}.md")
108
+ path = Pathname.new(data[:path])
105
109
  path.dirname.mkdir unless path.dirname.exist?
106
110
  File.open(path, "w") do |f|
107
111
  f.puts("---")
108
- f.puts("title: #{short_name}")
109
- f.puts("status: #{component.status.to_s.capitalize}")
110
- f.puts("source: https://github.com/primer/view_components/tree/main/app/components/primer/#{component.to_s.demodulize.underscore}.rb")
111
- f.puts("storybook: https://primer.style/view-components/stories/?path=/story/primer-#{short_name.underscore.dasherize}-component")
112
+ f.puts("title: #{data[:title]}")
113
+ f.puts("status: #{data[:status]}")
114
+ f.puts("source: #{data[:source]}")
115
+ f.puts("storybook: #{data[:storybook]}")
112
116
  f.puts("---")
113
117
  f.puts
114
- f.puts("import Example from '../../src/@primer/gatsby-theme-doctocat/components/example'")
118
+ f.puts("import Example from '#{data[:example_path]}'")
119
+
120
+ initialize_method = documentation.meths.find(&:constructor?)
115
121
 
116
122
  if js_components.include?(component)
117
- f.puts("import RequiresJSFlash from '../../src/@primer/gatsby-theme-doctocat/components/requires-js-flash'")
123
+ f.puts("import RequiresJSFlash from '#{data[:require_js_path]}'")
118
124
  f.puts
119
125
  f.puts("<RequiresJSFlash />")
120
126
  end
@@ -124,108 +130,68 @@ namespace :docs do
124
130
  f.puts
125
131
  f.puts(view_context.render(inline: documentation.base_docstring))
126
132
 
127
- if documentation.tags(:accessibility).any?
133
+ if documentation.tags(:deprecated).any?
128
134
  f.puts
129
- f.puts("## Accessibility")
130
- documentation.tags(:accessibility).each do |tag|
135
+ f.puts("## Deprecation")
136
+ documentation.tags(:deprecated).each do |tag|
131
137
  f.puts
132
138
  f.puts view_context.render(inline: tag.text)
133
139
  end
134
140
  end
135
141
 
136
- if documentation.tags(:deprecated).any?
142
+ if documentation.tags(:accessibility).any?
137
143
  f.puts
138
- f.puts("## Deprecation")
139
- documentation.tags(:deprecated).each do |tag|
144
+ f.puts("## Accessibility")
145
+ documentation.tags(:accessibility).each do |tag|
140
146
  f.puts
141
147
  f.puts view_context.render(inline: tag.text)
142
148
  end
143
149
  end
144
150
 
145
- initialize_method = documentation.meths.find(&:constructor?)
151
+ params = initialize_method.tags(:param)
146
152
 
147
- if initialize_method.tags(:example).any?
148
- f.puts
149
- f.puts("## Examples")
150
- else
151
- components_without_examples << component
152
- end
153
+ errors << { component.name => { arguments: "No argument documentation found" } } unless params.any?
153
154
 
154
- initialize_method.tags(:example).each do |tag|
155
- name = tag.name
156
- description = nil
157
- code = nil
155
+ f.puts
156
+ f.puts("## Arguments")
157
+ f.puts
158
+ f.puts("| Name | Type | Default | Description |")
159
+ f.puts("| :- | :- | :- | :- |")
158
160
 
159
- if tag.text.include?("@description")
160
- splitted = tag.text.split(/@description|@code/)
161
- description = splitted.second.gsub(/^[ \t]{2}/, "").strip
162
- code = splitted.last.gsub(/^[ \t]{2}/, "").strip
163
- else
164
- code = tag.text
165
- end
161
+ docummented_params = params.map(&:name)
162
+ component_params = component.instance_method(:initialize).parameters.map { |p| p.last.to_s }
166
163
 
167
- f.puts
168
- f.puts("### #{name}")
169
- if description
170
- f.puts
171
- f.puts(description)
164
+ if (docummented_params & component_params).size != component_params.size
165
+ err = { arguments: {} }
166
+ (component_params - docummented_params).each do |arg|
167
+ err[:arguments][arg] = "Not documented"
172
168
  end
173
- f.puts
174
- html = view_context.render(inline: code)
175
- html.scan(/class="([^"]*)"/) do |classnames|
176
- classes_found_in_examples.concat(classnames[0].split(" ").reject { |c| c.starts_with?("octicon", "js", "my-") }.map { ".#{_1}"})
177
- end
178
- f.puts("<Example src=\"#{html.tr('"', "\'").delete("\n")}\" />")
179
- f.puts
180
- f.puts("```erb")
181
- f.puts(code.to_s)
182
- f.puts("```")
169
+
170
+ errors << { component.name => err }
183
171
  end
184
172
 
185
- params = initialize_method.tags(:param)
186
- if params.any?
187
- f.puts
188
- f.puts("## Arguments")
189
- f.puts
190
- f.puts("| Name | Type | Default | Description |")
191
- f.puts("| :- | :- | :- | :- |")
192
-
193
- args = []
194
- params.each do |tag|
195
- params = tag.object.parameters.find { |param| [tag.name.to_s, tag.name.to_s + ":"].include?(param[0]) }
196
-
197
- default =
198
- if params && params[1]
199
- constant_name = "#{component.name}::#{params[1]}"
200
- constant_value = constant_name.safe_constantize
201
- if constant_value.nil?
202
- pretty_value(params[1])
203
- else
204
- pretty_value(constant_value)
205
- end
206
- else
207
- "N/A"
208
- end
209
-
210
- args << {
211
- "name" => tag.name,
212
- "type" => tag.types.join(", "),
213
- "default" => default,
214
- "description" => view_context.render(inline: tag.text)
215
- }
216
-
217
- f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{default} | #{view_context.render(inline: tag.text)} |")
218
- end
173
+ args = []
174
+ params.each do |tag|
175
+ default_value = pretty_default_value(tag, component)
219
176
 
220
- component_args = {
221
- "component" => short_name,
222
- "source" => "https://github.com/primer/view_components/tree/main/app/components/primer/#{component.to_s.demodulize.underscore}.rb",
223
- "parameters" => args
177
+ args << {
178
+ "name" => tag.name,
179
+ "type" => tag.types.join(", "),
180
+ "default" => default_value,
181
+ "description" => view_context.render(inline: tag.text.squish)
224
182
  }
225
183
 
226
- args_for_components << component_args
184
+ f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{default_value} | #{view_context.render(inline: tag.text.squish)} |")
227
185
  end
228
186
 
187
+ component_args = {
188
+ "component" => data[:title],
189
+ "source" => data[:source],
190
+ "parameters" => args
191
+ }
192
+
193
+ args_for_components << component_args
194
+
229
195
  # Slots V2 docs
230
196
  slot_v2_methods = documentation.meths.select { |x| x[:renders_one] || x[:renders_many] }
231
197
 
@@ -250,22 +216,61 @@ namespace :docs do
250
216
  end
251
217
 
252
218
  param_tags.each do |tag|
253
- params = tag.object.parameters.find { |param| [tag.name.to_s, tag.name.to_s + ":"].include?(param[0]) }
219
+ f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{pretty_default_value(tag, component)} | #{view_context.render(inline: tag.text)} |")
220
+ end
221
+ end
222
+ end
254
223
 
255
- default =
256
- if params && params[1]
257
- "`#{params[1]}`"
258
- else
259
- "N/A"
260
- end
224
+ errors << { component.name => { example: "No examples found" } } unless initialize_method.tags(:example).any?
261
225
 
262
- f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{default} | #{view_context.render(inline: tag.text)} |")
263
- end
226
+ f.puts
227
+ f.puts("## Examples")
228
+
229
+ initialize_method.tags(:example).each do |tag|
230
+ name = tag.name
231
+ description = nil
232
+ code = nil
233
+
234
+ if tag.text.include?("@description")
235
+ splitted = tag.text.split(/@description|@code/)
236
+ description = splitted.second.gsub(/^[ \t]{2}/, "").strip
237
+ code = splitted.last.gsub(/^[ \t]{2}/, "").strip
238
+ else
239
+ code = tag.text
240
+ end
241
+
242
+ f.puts
243
+ f.puts("### #{name}")
244
+ if description
245
+ f.puts
246
+ f.puts(description)
264
247
  end
248
+ f.puts
249
+ html = view_context.render(inline: code)
250
+ html.scan(/class="([^"]*)"/) do |classnames|
251
+ classes_found_in_examples.concat(classnames[0].split(" ").reject { |c| c.starts_with?("octicon", "js", "my-") }.map { ".#{_1}"})
252
+ end
253
+ f.puts("<Example src=\"#{html.tr('"', "\'").delete("\n")}\" />")
254
+ f.puts
255
+ f.puts("```erb")
256
+ f.puts(code.to_s)
257
+ f.puts("```")
265
258
  end
266
259
  end
267
260
  end
268
261
 
262
+ unless errors.empty?
263
+ puts "==============================================="
264
+ puts "===================== ERRORS =================="
265
+ puts "===============================================\n\n"
266
+ puts JSON.pretty_generate(errors)
267
+ puts "\n\n==============================================="
268
+ puts "==============================================="
269
+ puts "==============================================="
270
+
271
+ raise
272
+ end
273
+
269
274
  File.open("static/classes.yml", "w") do |f|
270
275
  f.puts YAML.dump(classes_found_in_examples.sort.uniq)
271
276
  end
@@ -293,11 +298,6 @@ namespace :docs do
293
298
 
294
299
  puts "Markdown compiled."
295
300
 
296
- if components_without_examples.any?
297
- puts
298
- puts "The following components have no examples defined: #{components_without_examples.map(&:name).join(', ')}. Consider adding an example?"
299
- end
300
-
301
301
  if components_needing_docs.any?
302
302
  puts
303
303
  puts "The following components needs docs. Care to contribute them? #{components_needing_docs.map(&:name).join(', ')}"
@@ -307,6 +307,8 @@ namespace :docs do
307
307
  task :preview do
308
308
  registry = generate_yard_registry
309
309
 
310
+ FileUtils.rm_rf("demo/test/components/previews/primer/docs/")
311
+
310
312
  components = Primer::Component.descendants
311
313
 
312
314
  # Generate previews from documentation examples
@@ -346,6 +348,7 @@ namespace :docs do
346
348
  end
347
349
 
348
350
  def generate_yard_registry
351
+ ENV["SKIP_STORYBOOK_PRELOAD"] = "1"
349
352
  require File.expand_path("./../../demo/config/environment.rb", __dir__)
350
353
  require "primer/view_components"
351
354
  require "yard/docs_helper"
@@ -361,6 +364,7 @@ namespace :docs do
361
364
  # Custom tags for yard
362
365
  YARD::Tags::Library.define_tag("Accessibility", :accessibility)
363
366
  YARD::Tags::Library.define_tag("Deprecation", :deprecation)
367
+ YARD::Tags::Library.define_tag("Parameter", :param, :with_types_name_and_default)
364
368
 
365
369
  puts "Building YARD documentation."
366
370
  Rake::Task["yard"].execute
@@ -369,4 +373,69 @@ namespace :docs do
369
373
  registry.load!(".yardoc")
370
374
  registry
371
375
  end
376
+
377
+ def pretty_default_value(tag, component)
378
+ params = tag.object.parameters.find { |param| [tag.name.to_s, tag.name.to_s + ":"].include?(param[0]) }
379
+ default = tag.defaults&.first || params&.second
380
+
381
+ return "N/A" unless default
382
+
383
+ constant_name = "#{component.name}::#{default}"
384
+ constant_value = default.safe_constantize || constant_name.safe_constantize
385
+
386
+ return pretty_value(default) if constant_value.nil?
387
+
388
+ pretty_value(constant_value)
389
+ end
390
+
391
+ def status_module_and_short_name(component)
392
+ name_with_status = component.name.gsub(/Primer::|Component/, "")
393
+
394
+ m = name_with_status.match(/(?<status>Beta|Alpha|Deprecated)?(::)?(?<name>.*)/)
395
+ [m[:status]&.downcase, m[:name].gsub("::", "")]
396
+ end
397
+
398
+ def docs_metadata(component)
399
+ (status_module, short_name) = status_module_and_short_name(component)
400
+ status_path = status_module.nil? ? "" : "#{status_module}/"
401
+ status = component.status.to_s
402
+
403
+ {
404
+ title: short_name,
405
+ status: status.capitalize,
406
+ source: source_url(component),
407
+ storybook: storybook_url(component),
408
+ path: "docs/content/components/#{status_path}#{short_name.downcase}.md",
409
+ example_path: example_path(component),
410
+ require_js_path: require_js_path(component)
411
+ }
412
+ end
413
+
414
+ def source_url(component)
415
+ path = component.name.split("::").map(&:underscore).join("/")
416
+
417
+ "https://github.com/primer/view_components/tree/main/app/components/#{path}.rb"
418
+ end
419
+
420
+ def storybook_url(component)
421
+ path = component.name.split("::").map { |n| n.underscore.dasherize }.join("-")
422
+
423
+ "https://primer.style/view-components/stories/?path=/story/#{path}"
424
+ end
425
+
426
+ def example_path(component)
427
+ example_path = "../../src/@primer/gatsby-theme-doctocat/components/example"
428
+ example_path = "../#{example_path}" if status_module?(component)
429
+ example_path
430
+ end
431
+
432
+ def require_js_path(component)
433
+ require_js_path = "../../src/@primer/gatsby-theme-doctocat/components/requires-js-flash"
434
+ require_js_path = "../#{require_js_path}" if status_module?(component)
435
+ require_js_path
436
+ end
437
+
438
+ def status_module?(component)
439
+ (%w[Alpha Beta] & component.name.split("::")).any?
440
+ end
372
441
  end