# frozen_string_literal: true
require "active_support/inflector"
namespace :docs do
task :livereload do
require "listen"
Rake::Task["docs:build"].execute
puts "Listening for changes to documentation..."
listener = Listen.to("app") do |modified, added, removed|
puts "modified absolute path: #{modified}"
puts "added absolute path: #{added}"
puts "removed absolute path: #{removed}"
Rake::Task["docs:build"].execute
end
listener.start # not blocking
sleep
end
task :build do
registry = generate_yard_registry
puts "Converting YARD documentation to Markdown files."
# Rails controller for rendering arbitrary ERB
view_context = ApplicationController.new.tap { |c| c.request = ActionDispatch::TestRequest.create }.view_context
components = [
Yattho::Beta::RelativeTime,
Yattho::Beta::IconButton,
Yattho::Beta::Button,
Yattho::Alpha::SegmentedControl,
Yattho::Alpha::Layout,
Yattho::Alpha::HellipButton,
Yattho::Alpha::Image,
Yattho::LocalTime,
Yattho::Alpha::OcticonSymbols,
Yattho::Alpha::ImageCrop,
Yattho::IconButton,
Yattho::Beta::AutoComplete,
Yattho::Beta::AutoComplete::Item,
Yattho::Beta::Avatar,
Yattho::Beta::AvatarStack,
Yattho::Beta::BaseButton,
Yattho::Alpha::Banner,
Yattho::Beta::Blankslate,
Yattho::Beta::BorderBox,
Yattho::Beta::BorderBox::Header,
Yattho::Box,
Yattho::Beta::Breadcrumbs,
Yattho::ButtonComponent,
Yattho::Beta::ButtonGroup,
Yattho::Alpha::ButtonMarketing,
Yattho::Beta::ClipboardCopy,
Yattho::Beta::CloseButton,
Yattho::Beta::Counter,
Yattho::Beta::Details,
Yattho::Alpha::Dialog,
Yattho::Alpha::Dropdown,
Yattho::Beta::Flash,
Yattho::Beta::Heading,
Yattho::Alpha::HiddenTextExpander,
Yattho::Beta::Label,
Yattho::LayoutComponent,
Yattho::Beta::Link,
Yattho::Beta::Markdown,
Yattho::Alpha::Menu,
Yattho::Navigation::TabComponent,
Yattho::Beta::Octicon,
Yattho::Beta::Popover,
Yattho::Beta::ProgressBar,
Yattho::Beta::State,
Yattho::Beta::Spinner,
Yattho::Beta::Subhead,
Yattho::Alpha::TabContainer,
Yattho::Beta::Text,
Yattho::Alpha::TextField,
Yattho::TimeAgoComponent,
Yattho::Beta::TimelineItem,
Yattho::Tooltip,
Yattho::Truncate,
Yattho::Beta::Truncate,
Yattho::Alpha::UnderlineNav,
Yattho::Alpha::UnderlinePanels,
Yattho::Alpha::TabNav,
Yattho::Alpha::TabPanels,
Yattho::Alpha::Tooltip,
Yattho::Alpha::ToggleSwitch,
Yattho::Alpha::ActionList,
Yattho::Alpha::NavList,
Yattho::Alpha::NavList::Item,
Yattho::Alpha::NavList::Section,
Yattho::Alpha::ActionList::Divider,
Yattho::Alpha::ActionList::Heading,
Yattho::Alpha::ActionList::Item
]
js_components = [
Yattho::Alpha::Dropdown,
Yattho::LocalTime,
Yattho::Alpha::ImageCrop,
Yattho::Beta::AutoComplete,
Yattho::Alpha::Banner,
Yattho::Beta::ClipboardCopy,
Yattho::Alpha::TabContainer,
Yattho::TimeAgoComponent,
Yattho::Alpha::UnderlinePanels,
Yattho::Alpha::TabPanels,
Yattho::Alpha::Tooltip,
Yattho::ButtonComponent,
Yattho::IconButton,
Yattho::Beta::Link,
Yattho::Alpha::ToggleSwitch,
Yattho::Alpha::ActionList,
Yattho::Alpha::NavList,
Yattho::Alpha::NavList::Section
]
components_without_examples = [
# ActionList is a base component that should not be used by itself
Yattho::Alpha::ActionList,
Yattho::Alpha::ActionList::Divider,
Yattho::Alpha::ActionList::Heading,
Yattho::Alpha::ActionList::Item,
# Examples can be seen in the NavList docs
Yattho::Alpha::NavList::Item,
Yattho::Alpha::NavList::Section
]
all_components = Yattho::Component.descendants - [Yattho::BaseComponent]
components_needing_docs = all_components - components
args_for_components = []
errors = []
# Deletes docs before regenerating them, guaranteeing that we don't keep stale docs.
components_content_glob = File.join(*%w[docs content components ** *.md])
FileUtils.rm_rf(components_content_glob)
components.sort_by(&:name).each do |component|
documentation = registry.get(component.name)
data = docs_metadata(component)
path = Pathname.new(data[:path])
path.dirname.mkpath unless path.dirname.exist?
File.open(path, "w") do |f|
f.puts("---")
f.puts("title: #{data[:title]}")
f.puts("componentId: #{data[:component_id]}")
f.puts("status: #{data[:status]}")
f.puts("source: #{data[:source]}")
f.puts("a11yReviewed: #{data[:a11y_reviewed]}")
f.puts("lookbook: #{data[:lookbook]}") if preview_exists?(component)
f.puts("---")
f.puts
f.puts("import Example from '#{data[:example_path]}'")
initialize_method = documentation.meths.find(&:constructor?)
if js_components.include?(component)
f.puts("import RequiresJSFlash from '#{data[:require_js_path]}'")
f.puts
f.puts("")
end
f.puts
f.puts("")
f.puts
f.puts(view_context.render(inline: documentation.base_docstring))
if documentation.tags(:deprecated).any?
f.puts
f.puts("## Deprecation")
documentation.tags(:deprecated).each do |tag|
f.puts
f.puts view_context.render(inline: tag.text)
end
end
if documentation.tags(:accessibility).any?
f.puts
f.puts("## Accessibility")
documentation.tags(:accessibility).each do |tag|
f.puts
f.puts view_context.render(inline: tag.text)
end
end
params = initialize_method.tags(:param)
errors << { component.name => { arguments: "No argument documentation found" } } unless params.any?
f.puts
f.puts("## Arguments")
f.puts
f.puts("| Name | Type | Default | Description |")
f.puts("| :- | :- | :- | :- |")
documented_params = params.map(&:name)
component_params = component.instance_method(:initialize).parameters.map { |p| p.last.to_s }
if (documented_params & component_params).size != component_params.size
err = { arguments: {} }
(component_params - documented_params).each do |arg|
err[:arguments][arg] = "Not documented"
end
errors << { component.name => err }
end
args = []
params.each do |tag|
default_value = pretty_default_value(tag, component)
args << {
"name" => tag.name,
"type" => tag.types.join(", "),
"default" => default_value,
"description" => view_context.render(inline: tag.text.squish)
}
f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{default_value} | #{view_context.render(inline: tag.text.squish)} |")
end
component_args = {
"component" => data[:title],
"status" => component.status.to_s,
"source" => data[:source],
"lookbook" => data[:lookbook],
"parameters" => args
}
args_for_components << component_args
# Slots V2 docs
slot_v2_methods = documentation.meths.select do |mtd|
(mtd[:renders_one] || mtd[:renders_many]) && mtd.tag(:private).nil?
end
if slot_v2_methods.any?
f.puts
f.puts("## Slots")
slot_v2_methods.each do |slot_documentation|
f.puts
f.puts("### `#{slot_documentation.name}`")
if slot_documentation.base_docstring.to_s.present?
f.puts
f.puts(view_context.render(inline: slot_documentation.base_docstring))
end
param_tags = slot_documentation.tags(:param)
if param_tags.any?
f.puts
f.puts("| Name | Type | Default | Description |")
f.puts("| :- | :- | :- | :- |")
end
param_tags.each do |tag|
f.puts("| `#{tag.name}` | `#{tag.types.join(', ')}` | #{pretty_default_value(tag, component)} | #{view_context.render(inline: tag.text)} |")
end
end
end
example_tags = initialize_method.tags(:example)
if example_tags.any?
f.puts
f.puts("## Examples")
example_tags.each do |tag|
name, description, code = parse_example_tag(tag)
f.puts
f.puts("### #{name}")
if description
f.puts
f.puts(view_context.render(inline: description.squish))
end
f.puts
html = view_context.render(inline: code)
f.puts("")
f.puts
f.puts("```erb")
f.puts(code.to_s)
f.puts("```")
end
else
errors << { component.name => { example: "No examples found" } } unless components_without_examples.include?(component)
end
end
end
unless errors.empty?
puts "==============================================="
puts "===================== ERRORS =================="
puts "===============================================\n\n"
puts JSON.pretty_generate(errors)
puts "\n\n==============================================="
puts "==============================================="
puts "==============================================="
raise
end
File.open("static/arguments.json", "w") do |f|
f.puts JSON.pretty_generate(args_for_components)
end
# Build system arguments docs from BaseComponent
documentation = registry.get(Yattho::BaseComponent.name)
File.open("docs/content/system-arguments.md", "w") do |f|
f.puts("---")
f.puts("title: System arguments")
f.puts("---")
f.puts
f.puts("")
f.puts
f.puts(documentation.base_docstring)
f.puts
initialize_method = documentation.meths.find(&:constructor?)
f.puts(view_context.render(inline: initialize_method.base_docstring))
end
# Copy over ADR docs and insert them into the nav
puts "Copying ADRs..."
Rake::Task["docs:build_adrs"].invoke
puts "Markdown compiled."
if components_needing_docs.any?
puts
puts "The following components needs docs. Care to contribute them? #{components_needing_docs.map(&:name).join(', ')}"
end
end
task :build_adrs do
adr_content_dir = File.join(*%w[docs content adr])
FileUtils.rm_rf(File.join(adr_content_dir))
FileUtils.mkdir(adr_content_dir)
nav_entries = Dir[File.join(*%w[docs adr *.md])].sort.map do |orig_path|
orig_file_name = File.basename(orig_path)
url_name = orig_file_name.chomp(".md")
file_contents = File.read(orig_path)
file_contents = <<~CONTENTS.sub(/\n+\z/, "\n")
#{file_contents}
CONTENTS
title_match = /^# (.+)/.match(file_contents)
title = title_match[1]
# Don't include initial ADR for recording ADRs
next nil if title == "Record architecture decisions"
File.write(File.join(adr_content_dir, orig_file_name), file_contents)
puts "Copied #{orig_path}"
{ "title" => title, "url" => "/adr/#{url_name}" }
end
nav_yaml_file = File.join(*%w[docs src @yattho gatsby-theme-doctocat nav.yml])
nav_yaml = YAML.load_file(nav_yaml_file)
adr_entry = {
"title" => "Architecture decisions",
"children" => nav_entries.compact
}
existing_index = nav_yaml.index { |entry| entry["title"] == "Architecture decisions" }
if existing_index
nav_yaml[existing_index] = adr_entry
else
nav_yaml << adr_entry
end
File.write(nav_yaml_file, YAML.dump(nav_yaml))
end
task :preview do
registry = generate_yard_registry
FileUtils.rm_rf("previews/yattho/docs/")
components = Yattho::Component.descendants
# Generate previews from documentation examples
components.each do |component|
documentation = registry.get(component.name)
short_name = component.name.gsub(/Yattho|::/, "")
initialize_method = documentation.meths.find(&:constructor?)
next unless initialize_method&.tags(:example)&.any?
yard_example_tags = initialize_method.tags(:example)
path = Pathname.new("previews/docs/#{short_name.underscore}_preview.rb")
path.dirname.mkdir unless path.dirname.exist?
File.open(path, "w") do |f|
f.puts("module Docs")
f.puts(" class #{short_name}Preview < ViewComponent::Preview")
yard_example_tags.each_with_index do |tag, index|
name, _, code = parse_example_tag(tag)
method_name = name.split("|").first.downcase.parameterize.underscore
f.puts(" def #{method_name}; end")
f.puts unless index == yard_example_tags.size - 1
path = Pathname.new("previews/docs/#{short_name.underscore}_preview/#{method_name}.html.erb")
path.dirname.mkdir unless path.dirname.exist?
File.open(path, "w") do |view_file|
view_file.puts(code.to_s)
end
end
f.puts(" end")
f.puts("end")
end
end
end
def generate_yard_registry
ENV["RAILS_ENV"] = "test"
require File.expand_path("./../../demo/config/environment.rb", __dir__)
require "yattho/view_components"
require "yattho/yard/docs_helper"
require "view_component/test_helpers"
include ViewComponent::TestHelpers
include Yattho::ViewHelper
include Yattho::YARD::DocsHelper
Dir["./app/components/yattho/**/*.rb"].sort.each { |file| require file }
YARD::Rake::YardocTask.new
# Custom tags for yard
YARD::Tags::Library.define_tag("Accessibility", :accessibility)
YARD::Tags::Library.define_tag("Deprecation", :deprecation)
YARD::Tags::Library.define_tag("Parameter", :param, :with_types_name_and_default)
puts "Building YARD documentation."
Rake::Task["yard"].execute
registry = YARD::RegistryStore.new
registry.load!(".yardoc")
registry
end
def parse_example_tag(tag)
name = tag.name
description = nil
code = nil
if tag.text.include?("@description")
splitted = tag.text.split(/@description|@code/)
description = splitted.second.gsub(/^[ \t]{2}/, "").strip
code = splitted.last.gsub(/^[ \t]{2}/, "").strip
else
code = tag.text
end
[name, description, code]
end
def pretty_default_value(tag, component)
params = tag.object.parameters.find { |param| [tag.name.to_s, "#{tag.name}:"].include?(param[0]) }
default = tag.defaults&.first || params&.second
return "N/A" unless default
constant_name = "#{component.name}::#{default}"
constant_value = default.safe_constantize || constant_name.safe_constantize
return pretty_value(default) if constant_value.nil?
pretty_value(constant_value)
end
def docs_metadata(component)
status_module, short_name, class_name = status_module_and_short_name(component)
status_path = status_module.nil? ? "" : "#{status_module}/"
status = component.status.to_s
a11y_reviewed = component.audited_at.nil? ? "false" : "true"
{
title: class_name,
component_id: short_name.underscore,
status: status.capitalize,
a11y_reviewed: a11y_reviewed,
source: source_url(component),
lookbook: lookbook_url(component),
path: "docs/content/components/#{status_path}#{short_name.downcase}.md",
example_path: example_path(component),
require_js_path: require_js_path(component)
}
end
def source_url(component)
path = component.name.split("::").map(&:underscore).join("/")
"https://github.com/yattho/view_components/tree/main/app/components/#{path}.rb"
end
def lookbook_url(component)
path = component.name.underscore.gsub("_component", "")
"https://yattho.com/view-components/lookbook/inspect/#{path}/default/"
end
def preview_exists?(component)
path = component.name.underscore
File.exist?("previews/#{path}_preview.rb")
end
def example_path(component)
example_path = "../../src/@yattho/gatsby-theme-doctocat/components/example"
example_path = "../#{example_path}" if status_module?(component)
example_path
end
def require_js_path(component)
require_js_path = "../../src/@yattho/gatsby-theme-doctocat/components/requires-js-flash"
require_js_path = "../#{require_js_path}" if status_module?(component)
require_js_path
end
def status_module?(component)
(%w[Alpha Beta] & component.name.split("::")).any?
end
end