diff --git a/.rubocop.yml b/.rubocop.yml index ef3c0812d..4ac4c7149 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -47,6 +47,23 @@ TrailingComma: Style/SpecialGlobalVars: Enabled: false +# For lambdas nested within certain expressions, this rule forces either ugly +# parens or curly braces that violate the "do/end for multiline blocks" rule. +Lambda: + Enabled: false + +# Disallowing indented "when" clauses destroys readability when using the +# single-line "when/then" style. +CaseIndentation: + Enabled: false + +# These are both subjective judgements that depend on the situation, and are +# not appropriate as absolute rules. +GuardClause: + Enabled: false +Next: + Enabled: false + #- Jazzy specs -----------------------------------------------------------# # Allow for `should.match /regexp/`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3417a56ce..fd186e1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ automatically hyperlinked to their reference. [JP Simard](https://github.com/jpsim) +* Jazzy can now read options from a configuration file. The command line + provides comprehensive help for available options via `jazzy -h config`. + [pcantrell](https://github.com/pcantrell) + [310](https://github.com/realm/jazzy/pull/310) + * Render special list items (e.g. Throws, See, etc.). See http://ericasadun.com/2015/06/14/swift-header-documentation-in-xcode-7/ for a complete list. diff --git a/README.md b/README.md index 2a2e703aa..ddc623177 100755 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ To install jazzy, run `[sudo] gem install jazzy` from your command line. Run `jazzy` from your command line. Run `jazzy -h` for a list of additional options. +You can set options for your project’s documentation in a configuration file, `.jazzy.yaml` by default. For a detailed explanation and an exhaustive list of all available options, run `jazzy --help config`. + ### Troubleshooting #### Only extensions are listed in the documentation. diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index 2d4d3b146..11be937ae 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -9,57 +9,233 @@ module Jazzy # rubocop:disable Metrics/ClassLength class Config - attr_accessor :output - attr_accessor :xcodebuild_arguments - attr_accessor :author_name - attr_accessor :module_name - attr_accessor :github_url - attr_accessor :github_file_prefix - attr_accessor :author_url - attr_accessor :dash_url - attr_accessor :sourcekitten_sourcefile - attr_accessor :clean - attr_accessor :readme_path - attr_accessor :docset_platform - attr_accessor :root_url - attr_accessor :version - attr_accessor :min_acl - attr_accessor :skip_undocumented - attr_accessor :hide_documentation_coverage - attr_accessor :podspec - attr_accessor :docset_icon - attr_accessor :docset_path - attr_accessor :source_directory - attr_accessor :excluded_files - attr_accessor :custom_categories - attr_accessor :template_directory - attr_accessor :swift_version - attr_accessor :assets_directory - attr_accessor :copyright + # rubocop:disable Style/AccessorMethodName + class Attribute + attr_reader :name, :description, :command_line, :default, :parse + + def initialize(name, description: nil, command_line: nil, + default: nil, parse: ->(x) { x }) + @name = name + @description = Array(description) + @command_line = Array(command_line) + @default = default + @parse = parse + end - def initialize - PodspecDocumenter.configure(self, Dir['*.podspec{,.json}'].first) - self.output = Pathname('docs') - self.xcodebuild_arguments = [] - self.author_name = '' - self.module_name = '' - self.author_url = URI('') - self.clean = false - self.docset_platform = 'jazzy' - self.version = '1.0' - self.min_acl = SourceDeclaration::AccessControlLevel.public - self.skip_undocumented = false - self.hide_documentation_coverage = false - self.source_directory = Pathname.pwd - self.excluded_files = [] - self.custom_categories = {} - self.template_directory = Pathname(__FILE__).parent + 'templates' - self.swift_version = '2.1' - self.assets_directory = Pathname(__FILE__).parent + 'assets' + def get(config) + config.method(name).call + end + + def set_raw(config, val) + config.method("#{name}=").call(val) + end + + def set(config, val, mark_configured: true) + set_raw(config, parse.call(val)) + config.method("#{name}_configured=").call(true) if mark_configured + end + + def set_to_default(config) + set(config, default, mark_configured: false) if default + end + + def set_if_unconfigured(config, val) + set(config, val) unless configured?(config) + end + + def configured?(config) + config.method("#{name}_configured").call + end + + def attach_to_option_parser(config, opt) + return if command_line.empty? + opt.on(*command_line, *description) do |val| + set(config, val) + end + end end + # rubocop:enable Style/AccessorMethodName - def podspec=(podspec) - @podspec = PodspecDocumenter.configure(self, podspec) + def self.config_attr(name, **opts) + attr_accessor name + attr_accessor "#{name}_configured" + @all_config_attrs ||= [] + @all_config_attrs << Attribute.new(name, **opts) + end + + class << self + attr_reader :all_config_attrs + end + + # ──────── Build ──────── + + # rubocop:disable Style/AlignParameters + + config_attr :output, + description: 'Folder to output the HTML docs to', + command_line: ['-o', '--output FOLDER'], + default: 'docs', + parse: ->(o) { Pathname(o) } + + config_attr :clean, + command_line: ['-c', '--[no-]clean'], + description: ['Delete contents of output directory before running. ', + 'WARNING: If --output is set to ~/Desktop, this will '\ + 'delete the ~/Desktop directory.'], + default: false + + config_attr :config_file, + command_line: '--config PATH', + description: ['Configuration file (.yaml or .json)', + 'Default: .jazzy.yaml in source directory or ancestor'], + parse: ->(cf) { Pathname(cf) } + + config_attr :xcodebuild_arguments, + command_line: ['-x', '--xcodebuild-arguments arg1,arg2,…argN', Array], + description: 'Arguments to forward to xcodebuild', + default: [] + + config_attr :sourcekitten_sourcefile, + command_line: ['-s', '--sourcekitten-sourcefile FILEPATH'], + description: 'File generated from sourcekitten output to parse', + parse: ->(s) { Pathname(s) } + + config_attr :source_directory, + command_line: '--source-directory DIRPATH', + description: 'The directory that contains the source to be documented', + default: Pathname.pwd, + parse: ->(sd) { Pathname(sd) } + + config_attr :excluded_files, + command_line: ['-e', '--exclude file1,file2,…fileN', Array], + description: 'Files to be excluded from documentation', + default: [], + parse: ->(files) do + files.map { |f| File.expand_path(f) } + end + + config_attr :swift_version, + command_line: '--swift-version VERSION', + default: '2.1' + + # ──────── Metadata ──────── + + config_attr :author_name, + command_line: ['-a', '--author AUTHOR_NAME'], + description: 'Name of author to attribute in docs (e.g. Realm)', + default: '' + + config_attr :author_url, + command_line: ['-u', '--author_url URL'], + description: 'Author URL of this project (e.g. http://realm.io)', + default: '', + parse: ->(u) { URI(u) } + + config_attr :module_name, + command_line: ['-m', '--module MODULE_NAME'], + description: 'Name of module being documented. (e.g. RealmSwift)', + default: '' + + config_attr :version, + command_line: '--module-version VERSION', + description: 'module version. will be used when generating docset', + default: '1.0' + + config_attr :copyright, + command_line: '--copyright COPYRIGHT_MARKDOWN', + description: 'copyright markdown rendered at the bottom of the docs pages' + + config_attr :readme_path, + command_line: '--readme FILEPATH', + description: 'The path to a markdown README file', + parse: ->(rp) { Pathname(rp) } + + config_attr :podspec, + command_line: '--podspec FILEPATH', + parse: ->(ps) { PodspecDocumenter.create_podspec(Pathname(ps)) if ps }, + default: Dir['*.podspec{,.json}'].first + + config_attr :docset_platform, default: 'jazzy' + + config_attr :docset_icon, + command_line: '--docset-icon FILEPATH', + parse: ->(di) { Pathname(di) } + + config_attr :docset_path, + command_line: '--docset-path DIRPATH', + description: 'The relative path for the generated docset' + + # ──────── URLs ──────── + + config_attr :root_url, + command_line: ['-r', '--root-url URL'], + description: 'Absolute URL root where these docs will be stored', + parse: ->(r) { URI(r) } + + config_attr :dash_url, + command_line: ['-d', '--dash_url URL'], + description: 'Location of the dash XML feed '\ + 'e.g. http://realm.io/docsets/realm.xml)', + parse: ->(d) { URI(d) } + + config_attr :github_url, + command_line: ['-g', '--github_url URL'], + description: 'GitHub URL of this project (e.g. '\ + 'https://github.com/realm/realm-cocoa)', + parse: ->(g) { URI(g) } + + config_attr :github_file_prefix, + command_line: '--github-file-prefix PREFIX', + description: 'GitHub URL file prefix of this project (e.g. '\ + 'https://github.com/realm/realm-cocoa/tree/v0.87.1)' + + # ──────── Doc generation options ──────── + + config_attr :min_acl, + command_line: '--min-acl [private | internal | public]', + description: 'minimum access control level to document', + default: 'public', + parse: ->(acl) do + SourceDeclaration::AccessControlLevel.from_human_string(acl) + end + + config_attr :skip_undocumented, + command_line: '--[no-]skip-undocumented', + description: "Don't document declarations that have no documentation '\ + 'comments.", + default: false + + config_attr :hide_documentation_coverage, + command_line: '--[no-]hide-documentation-coverage', + description: "Hide \"(X\% documented)\" from the generated documents", + default: false + + config_attr :custom_categories, + description: ['Custom navigation categories to replace the standard '\ + '“Classes, Protocols, etc.”', 'Types not explicitly named '\ + 'in a custom category appear in generic groups at the end.', + 'Example: http://git.io/vcTZm'], + default: [] + + config_attr :template_directory, + command_line: ['-t', '--template-directory DIRPATH'], + description: 'The directory that contains the mustache templates to use', + default: Pathname(__FILE__).parent + 'templates', + parse: ->(td) { Pathname(td) } + + config_attr :assets_directory, + command_line: '--assets-directory DIRPATH', + description: 'The directory that contains the assets (CSS, JS, images) '\ + 'used by the templates', + default: Pathname(__FILE__).parent + 'assets', + parse: ->(ad) { Pathname(ad) } + + # rubocop:enable Style/AlignParameters + + def initialize + self.class.all_config_attrs.each do |attr| + attr.set_to_default(self) + end end def template_directory=(template_directory) @@ -70,170 +246,142 @@ def template_directory=(template_directory) # rubocop:disable Metrics/MethodLength def self.parse! config = new + config.parse_command_line + config.parse_config_file + PodspecDocumenter.apply_config_defaults(config.podspec, config) + + if config.root_url + config.dash_url ||= URI.join( + config.root_url, + "docsets/#{config.module_name}.xml") + end + + config + end + + def parse_command_line OptionParser.new do |opt| opt.banner = 'Usage: jazzy' opt.separator '' opt.separator 'Options' - opt.on('-o', '--output FOLDER', - 'Folder to output the HTML docs to') do |output| - config.output = Pathname(output) - end - - opt.on('-c', '--[no-]clean', - 'Delete contents of output directory before running.', - 'WARNING: If --output is set to ~/Desktop, this will delete the \ - ~/Desktop directory.') do |clean| - config.clean = clean - end - - opt.on('-x', '--xcodebuild-arguments arg1,arg2,…argN', Array, - 'Arguments to forward to xcodebuild') do |args| - config.xcodebuild_arguments = args - end - - opt.on('-a', '--author AUTHOR_NAME', - 'Name of author to attribute in docs (i.e. Realm)') do |a| - config.author_name = a - end - - opt.on('-u', '--author_url URL', - 'Author URL of this project (i.e. http://realm.io)') do |u| - config.author_url = URI(u) - end - - opt.on('-m', '--module MODULE_NAME', - 'Name of module being documented. (i.e. RealmSwift)') do |m| - config.module_name = m - end - - opt.on('-d', '--dash_url URL', - 'Location of the dash XML feed \ - (i.e. http://realm.io/docsets/realm.xml') do |d| - config.dash_url = URI(d) + self.class.all_config_attrs.each do |attr| + attr.attach_to_option_parser(self, opt) end - opt.on('-g', '--github_url URL', - 'GitHub URL of this project (i.e. \ - https://github.com/realm/realm-cocoa)') do |g| - config.github_url = URI(g) - end - - opt.on('--github-file-prefix PREFIX', - 'GitHub URL file prefix of this project (i.e. \ - https://github.com/realm/realm-cocoa/tree/v0.87.1)') do |g| - config.github_file_prefix = g - end - - opt.on('-s', '--sourcekitten-sourcefile FILEPATH', - 'File generated from sourcekitten output to parse') do |s| - config.sourcekitten_sourcefile = Pathname(s) + opt.on('-v', '--version', 'Print version number') do + puts 'jazzy version: ' + Jazzy::VERSION + exit end - opt.on('-r', '--root-url URL', - 'Absolute URL root where these docs will be stored') do |r| - config.root_url = URI(r) - if !config.dash_url && config.root_url - config.dash_url = URI.join(r, "docsets/#{config.module_name}.xml") + opt.on('-h', '--help [TOPIC]', 'Available topics:', + ' usage Command line options (this help message)', + ' config Configuration file options', + '...or an option keyword, e.g. "dash"') do |topic| + case topic + when 'usage', nil + puts opt + when 'config' + print_config_file_help + else + print_option_help(topic) end + exit end + end.parse! - opt.on('--module-version VERSION', - 'module version. will be used when generating docset') do |mv| - config.version = mv - end + expand_paths(Pathname.pwd) + end - opt.on('--min-acl [private | internal | public]', - 'minimum access control level to document \ - (default is public)') do |acl| - config.min_acl = SourceDeclaration::AccessControlLevel - .from_human_string(acl) - end + def parse_config_file + config_path = locate_config_file + return unless config_path - opt.on('--[no-]skip-undocumented', - "Don't document declarations that have no documentation \ - comments.", - ) do |skip_undocumented| - config.skip_undocumented = skip_undocumented + puts "Using config file #{config_path}" + config_file = read_config_file(config_path) + self.class.all_config_attrs.each do |attr| + key = attr.name.to_s + if config_file.key?(key) + attr.set_if_unconfigured(self, config_file[key]) end + end - opt.on('--[no-]hide-documentation-coverage', - "Hide \"(X\% documented)\" from the generated documents", - ) do |hide_documentation_coverage| - config.hide_documentation_coverage = hide_documentation_coverage - end + expand_paths(config_path.parent) + end - opt.on('--podspec FILEPATH') do |podspec| - config.podspec = Pathname(podspec) - end + def locate_config_file + return config_file if config_file - opt.on('--docset-icon FILEPATH') do |docset_icon| - config.docset_icon = Pathname(docset_icon) - end + source_directory.ascend do |dir| + candidate = dir.join('.jazzy.yaml') + return candidate if candidate.exist? + end - opt.on('--docset-path DIRPATH', 'The relative path for the generated ' \ - 'docset') do |docset_path| - config.docset_path = docset_path - end + nil + end - opt.on('--source-directory DIRPATH', 'The directory that contains ' \ - 'the source to be documented') do |source_directory| - config.source_directory = Pathname(source_directory) - end + def read_config_file(file) + case File.extname(file) + when '.json' then JSON.parse(File.read(file)) + when '.yaml', '.yml' then YAML.load(File.read(file)) + else raise "Config file must be .yaml or .json, but got #{file.inspect}" + end + end - opt.on('t', '--template-directory DIRPATH', 'The directory that ' \ - 'contains the mustache templates to use') do |template_directory| - config.template_directory = Pathname(template_directory) + def expand_paths(base_path) + self.class.all_config_attrs.each do |attr| + val = attr.get(self) + if val.respond_to?(:expand_path) + attr.set_raw(self, val.expand_path(base_path)) end + end + end - opt.on('--swift-version VERSION') do |swift_version| - config.swift_version = swift_version - end + def print_config_file_help + puts <<-_EOS_ - opt.on('--assets-directory DIRPATH', 'The directory that contains ' \ - 'the assets (CSS, JS, images) to use') do |assets_directory| - config.assets_directory = Pathname(assets_directory) - end + By default, jazzy looks for a file named ".jazzy.yaml" in the source + directory and its ancestors. You can override the config file location + with --config. - opt.on('--readme FILEPATH', - 'The path to a markdown README file') do |readme| - config.readme_path = Pathname(readme) - end + (The source directory is the current working directory by default. + You can override that with --source-directory.) - opt.on('-e', '--exclude file1,file2,…fileN', Array, - 'Files to be excluded from documentation') do |files| - config.excluded_files = files.map { |f| File.expand_path(f) } - end + The config file can be in YAML or JSON format. Available options are: - opt.on('--categories file', - 'JSON or YAML file with custom groupings') do |file| - config.custom_categories = parse_config_file(file) - end + _EOS_ + .gsub(/^ +/, '') - opt.on('-v', '--version', 'Print version number') do - puts 'jazzy version: ' + Jazzy::VERSION - exit - end + print_option_help + end - opt.on('--copyright COPYRIGHT_MARKDOWN', 'copyright markdown ' \ - 'rendered at the bottom of the docs pages') do |copyright| - config.copyright = copyright + def print_option_help(topic = '') + found = false + self.class.all_config_attrs.each do |attr| + match = ([attr.name] + attr.command_line).any? do + |opt| opt.to_s.include?(topic) end - - opt.on('-h', '--help', 'Print this help message') do - puts opt - exit + if match + found = true + puts + puts attr.name.to_s.gsub('_', ' ').upcase + puts + puts " Config file: #{attr.name}" + cmd_line_forms = attr.command_line.select { |opt| opt.is_a?(String) } + if cmd_line_forms.any? + puts " Command line: #{cmd_line_forms.join(', ')}" + end + puts + print_attr_description(attr) end - end.parse! - - config + end + warn "Unknown help topic #{topic.inspect}" unless found end - def self.parse_config_file(file) - case File.extname(file) - when '.json' then JSON.parse(File.read(file)) - when '.yaml', '.yml' then YAML.load(File.read(file)) - else raise "Config file must be .yaml or .json, but got #{file.inspect}" + def print_attr_description(attr) + attr.description.each { |line| puts " #{line}" } + if attr.default && attr.default != '' + puts " Default: #{attr.default}" end end diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 78cbc8839..d65a07cb4 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -48,8 +48,8 @@ def self.build(options) if options.sourcekitten_sourcefile stdout = options.sourcekitten_sourcefile.read else - if podspec = options.podspec - stdout = PodspecDocumenter.new(podspec).sourcekitten_output + if options.podspec_configured + stdout = PodspecDocumenter.new(options.podspec).sourcekitten_output else stdout = Dir.chdir(options.source_directory) do arguments = SourceKitten.arguments_from_options(options) @@ -128,11 +128,21 @@ def self.build_docs_for_sourcekitten_output(sourcekitten_output, options) DocsetBuilder.new(output_dir, source_module).build! - puts "jam out ♪♫ to your fresh new docs in `#{output_dir}`" + friendly_path = relative_path_if_inside(output_dir, Pathname.pwd) + puts "jam out ♪♫ to your fresh new docs in `#{friendly_path}`" source_module end + def self.relative_path_if_inside(path, base_path) + relative = path.relative_path_from(base_path) + if relative.to_path =~ /^..(\/|$)/ + path + else + relative + end + end + def self.decl_for_token(token) if token['key.parsed_declaration'] token['key.parsed_declaration'] diff --git a/lib/jazzy/podspec_documenter.rb b/lib/jazzy/podspec_documenter.rb index cce039780..753a5754f 100644 --- a/lib/jazzy/podspec_documenter.rb +++ b/lib/jazzy/podspec_documenter.rb @@ -23,23 +23,39 @@ def sourcekitten_output stdout.reduce([]) { |a, s| a + JSON.load(s) }.to_json end - def self.configure(config, podspec) - case podspec + def self.create_podspec(podspec_path) + case podspec_path when Pathname, String require 'cocoapods' - podspec = Pod::Specification.from_file(podspec) + Pod::Specification.from_file(podspec_path) + else + nil end + end + # rubocop:disable Metrics/CyclomaticComplexity + # rubocop:disable Metrics/PerceivedComplexity + def self.apply_config_defaults(podspec, config) return unless podspec - config.author_name = author_name(podspec) - config.module_name = podspec.module_name - config.author_url = podspec.homepage || github_file_prefix(podspec) - config.version = podspec.version.to_s - config.github_file_prefix = github_file_prefix(podspec) - - podspec + unless config.author_name_configured + config.author_name = author_name(podspec) + end + unless config.module_name_configured + config.module_name = podspec.module_name + end + unless config.author_url_configured + config.author_url = podspec.homepage || github_file_prefix(podspec) + end + unless config.version_configured + config.version = podspec.version.to_s + end + unless config.github_file_prefix_configured + config.github_file_prefix = github_file_prefix(podspec) + end end + # rubocop:enable Metrics/CyclomaticComplexity + # rubocop:enable Metrics/PerceivedComplexity private diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb index da1e7fb90..647a56ecd 100644 --- a/spec/integration_spec.rb +++ b/spec/integration_spec.rb @@ -173,12 +173,7 @@ describe 'Creates docs for Swift project with a variety of contents' do behaves_like cli_spec 'misc_jazzy_features', - '-m MiscJazzyFeatures -a Realm ' \ - '-u https://github.com/realm/jazzy ' \ - '-g https://github.com/realm/jazzy ' \ - '-x -dry-run ' \ - '--min-acl private ' \ - '--hide-documentation-coverage' + '-x -dry-run ' end end if !travis_swift || travis_swift == '2.1' end diff --git a/spec/integration_specs b/spec/integration_specs index d1c156865..cd5d51f20 160000 --- a/spec/integration_specs +++ b/spec/integration_specs @@ -1 +1 @@ -Subproject commit d1c15686552ff9873b2f49b6bb23a14606bb9c30 +Subproject commit cd5d51f205d878b088ce8ee017abba2b0e06697e