require 'fileutils'
require 'mustache'
require 'pathname'
require 'sassc'
require 'jazzy/config'
require 'jazzy/doc'
require 'jazzy/docset_builder'
require 'jazzy/documentation_generator'
require 'jazzy/search_builder'
require 'jazzy/jazzy_markdown'
require 'jazzy/podspec_documenter'
require 'jazzy/source_declaration'
require 'jazzy/source_document'
require 'jazzy/source_module'
require 'jazzy/sourcekitten'
module Jazzy
# This module handles HTML generation, file writing, asset copying,
# and generally building docs given sourcekitten output
module DocBuilder
# mkdir -p output directory and clean if option is set
def self.prepare_output_dir(output_dir, clean)
FileUtils.rm_r output_dir if clean && output_dir.directory?
FileUtils.mkdir_p output_dir
end
# Generate doc structure to be used in sidebar navigation
# @return [Array] doc structure comprised of
# section names & child names & URLs
def self.doc_structure_for_docs(docs)
docs
.map do |doc|
children = children_for_doc(doc)
{
section: doc.name,
url: doc.url,
children: children,
}
end
.select do |structure|
if Config.instance.hide_unlisted_documentation
unlisted_prefix = Config.instance.custom_categories_unlisted_prefix
structure[:section] != "#{unlisted_prefix}Guides"
else
true
end
end
end
def self.children_for_doc(doc)
doc.children
.sort_by { |c| [c.nav_order, c.name, c.usr || ''] }
.flat_map do |child|
# FIXME: include arbitrarily nested extensible types
[{ name: child.name, url: child.url }] +
Array(child.children.select do |sub_child|
sub_child.type.swift_extensible? || sub_child.type.extension?
end).map do |sub_child|
{ name: "– #{sub_child.name}", url: sub_child.url }
end
end
end
# Build documentation from the given options
# @param [Config] options
# @return [SourceModule] the documented source module
def self.build(options)
if options.sourcekitten_sourcefile_configured
stdout = '[' + options.sourcekitten_sourcefile.map(&:read)
.join(',') + ']'
elsif options.podspec_configured
pod_documenter = PodspecDocumenter.new(options.podspec)
stdout = pod_documenter.sourcekitten_output(options)
else
stdout = Dir.chdir(options.source_directory) do
arguments = SourceKitten.arguments_from_options(options)
SourceKitten.run_sourcekitten(arguments)
end
end
build_docs_for_sourcekitten_output(stdout, options)
end
# Build & write HTML docs to disk from structured docs array
# @param [String] output_dir Root directory to write docs
# @param [Array] docs Array of structured docs
# @param [Config] options Build options
# @param [Array] doc_structure @see #doc_structure_for_docs
def self.build_docs(output_dir, docs, source_module)
each_doc(output_dir, docs) do |doc, path|
prepare_output_dir(path.parent, false)
depth = path.relative_path_from(output_dir).each_filename.count - 1
path_to_root = '../' * depth
path.open('w') do |file|
file.write(document(source_module, doc, path_to_root))
end
end
end
def self.each_doc(output_dir, docs, &block)
docs.each do |doc|
next unless doc.render_as_page?
# Filepath is relative to documentation root:
path = output_dir + doc.filepath
block.call(doc, path)
each_doc(
output_dir,
doc.children,
&block
)
end
end
def self.build_site(docs, coverage, options)
warn 'building site'
structure = doc_structure_for_docs(docs)
docs << SourceDocument.make_index(options.readme_path)
source_module = SourceModule.new(options, docs, structure, coverage)
output_dir = options.output
build_docs(output_dir, source_module.docs, source_module)
unless options.disable_search
warn 'building search index'
SearchBuilder.build(source_module, output_dir)
end
copy_assets(output_dir)
copy_extensions(output_dir)
DocsetBuilder.new(output_dir, source_module).build!
generate_badge(source_module.doc_coverage, options)
friendly_path = relative_path_if_inside(output_dir, Pathname.pwd)
puts "jam out ♪♫ to your fresh new docs in `#{friendly_path}`"
source_module
end
# Build docs given sourcekitten output
# @param [String] sourcekitten_output Output of sourcekitten command
# @param [Config] options Build options
# @return [SourceModule] the documented source module
def self.build_docs_for_sourcekitten_output(sourcekitten_output, options)
(docs, stats) = SourceKitten.parse(
sourcekitten_output,
options.min_acl,
options.skip_undocumented,
DocumentationGenerator.source_docs,
)
prepare_output_dir(options.output, options.clean)
stats.report
unless options.skip_documentation
build_site(docs, stats.doc_coverage, options)
end
write_lint_report(stats.undocumented_decls, options)
end
def self.relative_path_if_inside(path, base_path)
relative = path.relative_path_from(base_path)
if relative.to_path =~ %r{/^..(\/|$)/}
path
else
relative
end
end
def self.undocumented_warnings(decls)
decls.map do |decl|
{
file: decl.file,
line: decl.line || decl.start_line,
symbol: decl.fully_qualified_name,
symbol_kind: decl.type.kind,
warning: 'undocumented',
}
end
end
def self.write_lint_report(undocumented, options)
(options.output + 'undocumented.json').open('w') do |f|
warnings = undocumented_warnings(undocumented)
lint_report = {
warnings: warnings.sort_by do |w|
[w[:file], w[:line] || 0, w[:symbol], w[:symbol_kind]]
end,
source_directory: options.source_directory,
}
f.write(JSON.pretty_generate(lint_report))
end
end
def self.copy_assets(destination)
assets_directory = Config.instance.theme_directory + 'assets'
FileUtils.cp_r(assets_directory.children, destination)
Pathname.glob(destination + 'css/**/*.scss').each do |scss|
css = SassC::Engine.new(scss.read).render
css_filename = scss.sub(/\.scss$/, '')
css_filename.open('w') { |f| f.write(css) }
FileUtils.rm scss
end
end
def self.copy_extensions(destination)
copy_extension('katex', destination) if Markdown.has_math
end
def self.copy_extension(name, destination)
ext_directory = Pathname(__FILE__).parent + 'extensions/' + name
FileUtils.cp_r(ext_directory.children, destination)
end
def self.render(doc_model, markdown)
html = Markdown.render(markdown)
SourceKitten.autolink_document(html, doc_model)
end
# Build Mustache document from a markdown source file
# @param [Config] options Build options
# @param [Hash] doc_model Parsed doc. @see SourceKitten.parse
# @param [String] path_to_root
# @param [Array] doc_structure doc structure comprised of section names and
# child names and URLs. @see doc_structure_for_docs
def self.document_markdown(source_module, doc_model, path_to_root)
doc = Doc.new # Mustache model instance
name = doc_model.name == 'index' ? source_module.name : doc_model.name
doc[:name] = name
doc[:overview] = render(doc_model, doc_model.content(source_module))
doc[:custom_head] = Config.instance.custom_head
doc[:disable_search] = Config.instance.disable_search
doc[:doc_coverage] = source_module.doc_coverage unless
Config.instance.hide_documentation_coverage
doc[:structure] = source_module.doc_structure
doc[:module_name] = source_module.name
doc[:author_name] = source_module.author_name
doc[:github_url] = source_module.github_url
doc[:dash_url] = source_module.dash_url
doc[:path_to_root] = path_to_root
doc[:hide_name] = true
doc.render.gsub(ELIDED_AUTOLINK_TOKEN, path_to_root)
end
# Returns the appropriate color for the provided percentage,
# used for generating a badge on shields.io
# @param [Number] coverage The documentation coverage percentage
def self.color_for_coverage(coverage)
if coverage < 10
'e05d44' # red
elsif coverage < 30
'fe7d37' # orange
elsif coverage < 60
'dfb317' # yellow
elsif coverage < 85
'a4a61d' # yellowgreen
elsif coverage < 90
'97CA00' # green
else
'4c1' # brightgreen
end
end
# rubocop:disable Metrics/MethodLength
# Generates an SVG similar to those from shields.io displaying the
# documentation percentage
# @param [Number] coverage The documentation coverage percentage
# @param [Config] options Build options
def self.generate_badge(coverage, options)
return if options.hide_documentation_coverage
coverage_length = coverage.to_s.size.succ
percent_string_length = coverage_length * 80 + 10
percent_string_offset = coverage_length * 40 + 975
width = coverage_length * 8 + 104
svg = <<-SVG.gsub(/^ {8}/, '')
SVG
badge_output = options.output + 'badge.svg'
File.open(badge_output, 'w') { |f| f << svg }
end
# rubocop:enable Metrics/MethodLength
def self.should_link_to_github(file)
return unless file
file = file.realpath.to_path
source_directory = Config.instance.source_directory.to_path
file.start_with?(source_directory)
end
# Construct Github token URL
# @param [Hash] item Parsed doc child item
# @param [Config] options Build options
def self.gh_token_url(item, source_module)
return unless github_prefix = source_module.github_file_prefix
return unless should_link_to_github(item.file)
gh_line = if item.start_line && (item.start_line != item.end_line)
"#L#{item.start_line}-L#{item.end_line}"
else
"#L#{item.line}"
end
relative_file_path = item.file.realpath.relative_path_from(
source_module.root_path,
)
"#{github_prefix}/#{relative_file_path}#{gh_line}"
end
# Build mustache item for a top-level doc
# @param [Hash] item Parsed doc child item
# @param [Config] options Build options
# rubocop:disable Metrics/MethodLength
def self.render_item(item, source_module)
# Combine abstract and discussion into abstract
abstract = (item.abstract || '') + (item.discussion || '')
{
name: item.name,
name_html: item.name.gsub(':', ':'),
abstract: abstract,
declaration: item.display_declaration,
language: item.display_language,
other_language_declaration: item.display_other_language_declaration,
usr: item.usr,
dash_type: item.type.dash_type,
github_token_url: gh_token_url(item, source_module),
default_impl_abstract: item.default_impl_abstract,
from_protocol_extension: item.from_protocol_extension,
return: item.return,
parameters: (item.parameters if item.parameters.any?),
url: (item.url if item.render_as_page?),
start_line: item.start_line,
end_line: item.end_line,
direct_link: item.omit_content_from_parent?,
deprecation_message: item.deprecation_message,
unavailable_message: item.unavailable_message,
usage_discouraged: item.usage_discouraged?,
}
end
# rubocop:enable Metrics/MethodLength
def self.make_task(mark, uid, items, doc_model)
{
name: mark.name,
name_html: (render(doc_model, mark.name) if mark.name),
uid: URI.encode(uid),
items: items,
pre_separator: mark.has_start_dash,
post_separator: mark.has_end_dash,
}
end
# Render tasks for Mustache document
# @param [Config] options Build options
# @param [Hash] doc_model Parsed doc. @see SourceKitten.parse
def self.render_tasks(source_module, children)
marks = children.map(&:mark).uniq.compact
mark_names_counts = {}
marks.map do |mark|
mark_children = children.select { |child| child.mark == mark }
items = mark_children.map { |child| render_item(child, source_module) }
uid = (mark.name || 'Unnamed').to_s
if mark_names_counts.key?(uid)
mark_names_counts[uid] += 1
uid += (mark_names_counts[uid]).to_s
else
mark_names_counts[uid] = 1
end
make_task(mark, uid, items, mark_children.first)
end
end
# rubocop:disable Metrics/MethodLength
# Build Mustache document from single parsed doc
# @param [Config] options Build options
# @param [Hash] doc_model Parsed doc. @see SourceKitten.parse
# @param [String] path_to_root
# @param [Array] doc_structure doc structure comprised of section names and
# child names and URLs. @see doc_structure_for_docs
def self.document(source_module, doc_model, path_to_root)
if doc_model.type.markdown?
return document_markdown(source_module, doc_model, path_to_root)
end
overview = (doc_model.abstract || '') + (doc_model.discussion || '')
alternative_abstract = doc_model.alternative_abstract
if alternative_abstract
overview = render(doc_model, alternative_abstract) + overview
end
doc = Doc.new # Mustache model instance
doc[:custom_head] = Config.instance.custom_head
doc[:disable_search] = Config.instance.disable_search
doc[:doc_coverage] = source_module.doc_coverage unless
Config.instance.hide_documentation_coverage
doc[:name] = doc_model.name
doc[:kind] = doc_model.type.name
doc[:dash_type] = doc_model.type.dash_type
doc[:declaration] = doc_model.display_declaration
doc[:overview] = overview
doc[:structure] = source_module.doc_structure
doc[:tasks] = render_tasks(source_module, doc_model.children)
doc[:module_name] = source_module.name
doc[:author_name] = source_module.author_name
doc[:github_url] = source_module.github_url
doc[:dash_url] = source_module.dash_url
doc[:path_to_root] = path_to_root
doc[:deprecation_message] = doc_model.deprecation_message
doc[:unavailable_message] = doc_model.unavailable_message
doc[:usage_discouraged] = doc_model.usage_discouraged?
doc.render.gsub(ELIDED_AUTOLINK_TOKEN, path_to_root)
end
# rubocop:enable Metrics/MethodLength
end
end