primer_view_components 0.0.43 → 0.0.47

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