# frozen_string_literal: true require "active_support/inflector" require "fileutils" namespace :docs do desc "Rebuilds docs on change; run via the Procfile" 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}" if modified.length.nonzero? changed = modified.dup.uniq while (path = changed.shift) puts "Reloading #{path}" # reload constants (in case they changed) load(path) end end Rake::Task["docs:build"].execute end listener.start # not blocking sleep end # for classes in hashes CLASS_MAPPINGS = '(?:\\S+|:\\"[\\S-]+\\"): \\"([\\S -]+)\\"' # for classes in constants CLASS_CONSTANT = '[A-Z_]+ = \\"([\\S -]+)\\"' CLASS_REGEX = Regexp.new("(?:#{CLASS_MAPPINGS}|#{CLASS_CONSTANT})") desc "Generate the documentation." 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 = [ Ariadne::AvatarComponent, Ariadne::AvatarStackComponent, Ariadne::CommentComponent, Ariadne::BodyComponent, Ariadne::BlankslateComponent, Ariadne::BaseButton, Ariadne::ButtonComponent, Ariadne::ContainerComponent, Ariadne::ClipboardCopyComponent, Ariadne::CounterComponent, Ariadne::DetailsComponent, Ariadne::DropdownComponent, Ariadne::GridComponent, Ariadne::FlashComponent, Ariadne::FlexComponent, Ariadne::FooterComponent, Ariadne::HeaderComponent, Ariadne::HeadingComponent, Ariadne::HeroiconComponent, Ariadne::ImageComponent, Ariadne::InlineFlexComponent, Ariadne::LinkComponent, Ariadne::ListComponent, Ariadne::NarrowContainerComponent, Ariadne::PanelBarComponent, Ariadne::PillComponent, Ariadne::RichTextAreaComponent, Ariadne::SlideoverComponent, Ariadne::TabComponent, Ariadne::TabContainerComponent, Ariadne::TableNavComponent, Ariadne::TabNavComponent, Ariadne::Text, Ariadne::TimeAgoComponent, Ariadne::TimelineComponent, Ariadne::TooltipComponent, ] # TODO: Form is not in documentation js_components = [ Ariadne::ClipboardCopyComponent, Ariadne::RichTextAreaComponent, Ariadne::SlideoverComponent, Ariadne::TabContainerComponent, Ariadne::TabNavComponent, Ariadne::TimeAgoComponent, Ariadne::TooltipComponent, ] all_components = Ariadne::Component.descendants - [Ariadne::BaseComponent, Ariadne::Content] # TODO: why is `Ariadne::Content` not picked up? components_needing_docs = all_components - components args_for_components = [] classes_found_in_examples = [] errors = [] # Deletes docs before regenerating them, guaranteeing that we don't keep stale docs. components_content_glob = File.join("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("lookbook: #{data[:lookbook]}") 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" } } if params.none? 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], "source" => data[:source], "parameters" => args, } args_for_components << component_args # Slots V2 docs slot_v2_methods = documentation.meths.select { |x| x[:renders_one] || x[:renders_many] } if slot_v2_methods.any? f.puts f.puts("## Slots") slot_v2_methods.each do |slot_documentation| f.puts f.puts("### `#{slot_documentation.name.to_s.capitalize}`") 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 = begin view_context.render(inline: code) rescue StandardError => e raise StandardError, "Unexpected error with #{data[:title]}: #{e.message}" end html.scan(/class="([^"]*)"/) do |classnames| classes_found_in_examples.concat(classnames[0].split.reject { |c| c.starts_with?("heroicon", "js") }.map { ".#{_1}" }) end 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/classes.yml", "w") do |f| non_ariadne_classes = classes_found_in_examples.reject { |c| c =~ /(?:ariadne|tiptap)/ }.uniq if non_ariadne_classes.length.nonzero? puts "===============================================" puts "===================== ERRORS ==================" puts "===============================================\n\n" puts "The following non-Ariadne classes were found: #{non_ariadne_classes.join(", ")}" puts "\n\n===============================================" puts "===============================================" puts "===============================================" raise end f.puts YAML.dump(classes_found_in_examples.uniq) end File.open("static/arguments.yml", "w") do |f| f.puts YAML.dump(args_for_components) end puts "Markdown compiled." if components_needing_docs.any? puts "\nThe following components need documentation. Can you add them? #{components_needing_docs.map(&:name).join(", ")}" end end desc "Generate previews from documentation examples" task :preview do registry = generate_yard_registry FileUtils.rm_rf("lookbook/test/components/previews/ariadne/docs/") components = Ariadne::Component.descendants components.each do |component| documentation = registry.get(component.name) short_name = component.name.gsub(/Ariadne|::/, "") initialize_method = documentation.meths.find(&:constructor?) next unless initialize_method&.tags(:example)&.any? yard_example_tags = initialize_method.tags(:example) path = Pathname.new("lookbook/test/components/previews/ariadne/docs/#{short_name.underscore}_preview.rb") FileUtils.mkdir_p("lookbook/test/components/previews/ariadne/docs") unless path.dirname.exist? File.open(path, "w") do |f| f.puts("module Ariadne") 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("lookbook/test/components/previews/ariadne/docs/#{short_name.underscore}_preview/#{method_name}.html.erb") FileUtils.mkdir_p("lookbook/test/components/previews/ariadne/docs/#{short_name.underscore}_preview") 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") f.puts("end") end end end end def generate_yard_registry require "action_dispatch" require_relative "../../app/lib/ariadne/view_helper" require File.expand_path("./../../lookbook/config/environment.rb", __dir__) YARD::Registry.yardoc_file = ".yardoc" require "./app/components/ariadne/component.rb" require "ariadne/view_components" require "yard/docs_helper" require "view_component/base" require "view_component/test_helpers" include(ViewComponent::TestHelpers) include(Ariadne::ViewHelper) include(YARD::DocsHelper) Dir["./app/components/ariadne/**/*.rb"].sort.each do |file| puts file require file end 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, component_name) = status_module_and_component_name(component) status_path = status_module.nil? ? "" : "/" status = component.status.to_s { title: component_name, component_id: component_name.underscore, status: status.capitalize, source: source_url(component), lookbook: lookbook_url(component), path: "docs/content/components/#{status_path}#{component_name.underscore}.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/yettoapp/ariadne/ruby/view_components/tree/main/app/components/#{path}.rb" end def lookbook_url(component) path = component.name.split("::").map { |n| n.underscore.dasherize }.join("-") "https://ariadne.style/view-components/lookbook/?path=/component/#{path}" end def example_path(component) example_path = "../../src/@primer/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/@primer/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) ["Alpha", "Beta"].intersect?(component.name.split("::")) end