From 46eee7dd1df7aa38e2fbe4a49255705451eeba76 Mon Sep 17 00:00:00 2001 From: argjiramala-tomtom <106680087+argjiramala-tomtom@users.noreply.github.com> Date: Tue, 10 Oct 2023 16:44:39 +0200 Subject: [PATCH 01/14] MM: Prototype Document multiple modules which are Xcode project on their own or part of a workspace Update the build configuration * rename the command line attribute to modules * use config file for module to build Xcode project build the project with configuration from config file chore: remove tomtomsdk from jazzy feat: Add extensions to proper module. renaming of config variables for multiple modules Co-Authored-By: Pedro Alcobia <102952330+PedroAlcobia-TomTom@users.noreply.github.com> --- lib/jazzy/config.rb | 16 +++++++++-- lib/jazzy/doc_builder.rb | 26 +++++++++++++---- lib/jazzy/sourcekitten.rb | 60 +++++++++++++++++++++++++++++++++++---- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index e5f387df6..ea45eb198 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -51,6 +51,7 @@ def configured?(config) end def attach_to_option_parser(config, opt) + return if command_line.empty? opt.on(*command_line, *description) do |val| @@ -177,6 +178,11 @@ def hide_objc? 'sourcekitten.', default: [] + config_attr :modules, + description: 'Array of modules that are going to be documented ' \ + 'It will contain arguments: - name, build-tool-arguments arg1,arg2,…argN, source_directory.', + default: false + alias_config_attr :xcodebuild_arguments, :build_tool_arguments, command_line: ['-x', '--xcodebuild-arguments arg1,arg2,…argN', Array], description: 'Back-compatibility alias for build_tool_arguments.' @@ -513,7 +519,7 @@ def self.parse! "docsets/#{config.module_name}.xml", ) end - + config.validate config @@ -573,6 +579,7 @@ def parse_config_file self.class.all_config_attrs.group_by(&prop) end + config_file.each do |key, value| unless attr = attrs_by_conf_key[key] message = "Unknown config file attribute #{key.inspect}" @@ -583,7 +590,6 @@ def parse_config_file warning message next end - attr.first.set_if_unconfigured(self, value) end @@ -604,6 +610,11 @@ def validate warning 'Option `build_tool_arguments` is set: values passed to ' \ '`framework_root` or `umbrella_header` may be ignored.' end + + if modules_configured && module_name_configured + raise 'Jazzy only allows the use of a single command for generating documentation.' \ + 'Using both module configuration and modules configuration together is not supported.' + end end # rubocop:enable Metrics/MethodLength @@ -615,7 +626,6 @@ def locate_config_file candidate = dir.join('.jazzy.yaml') return candidate if candidate.exist? end - nil end diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 88aef44b4..81903a7a5 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -69,7 +69,9 @@ def self.children_for_doc(doc) # @param [Config] options # @return [SourceModule] the documented source module def self.build(options) - if options.sourcekitten_sourcefile_configured + if options.modules_configured + stdout = multiple_modules(options) + elsif options.sourcekitten_sourcefile_configured stdout = "[#{options.sourcekitten_sourcefile.map(&:read).join(',')}]" elsif options.podspec_configured pod_documenter = PodspecDocumenter.new(options.podspec) @@ -86,6 +88,21 @@ def self.build(options) build_docs_for_sourcekitten_output(stdout, options) end + # Build Xcode project for multiple modules and parse the api documentation into a string + # @param [Config] options + # @return String the documented source module + def self.multiple_modules(options) + modules_parsed = Array[] + options.modules.each do |arguments| + module_parsed_string = Dir.chdir(arguments['source_directory']) do + arguments = SourceKitten.arguments_from_options(options) + (arguments['build_tool_arguments']||[]) + SourceKitten.run_sourcekitten(arguments) + end + modules_parsed.push(module_parsed_string) + end + stdout = "[#{modules_parsed.join(',')}]" + 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 @@ -154,11 +171,10 @@ def self.build_site(docs, coverage, options) def self.build_docs_for_sourcekitten_output(sourcekitten_output, options) (docs, stats) = SourceKitten.parse( sourcekitten_output, - options.min_acl, - options.skip_undocumented, + options, DocumentationGenerator.source_docs, ) - + prepare_output_dir(options.output, options.clean) stats.report @@ -448,4 +464,4 @@ def self.document(source_module, doc_model, path_to_root) end # rubocop:enable Metrics/MethodLength end -end +end \ No newline at end of file diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index d0a43f8c2..5c51e4d87 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -70,6 +70,16 @@ def self.group_docs(docs) custom_categories + merge_categories(type_categories) + uncategorized end + # Group root-level docs by module name + def self.group_docs_per_module(docs, modules) + modules = modules.map { |mod| mod['name']} + categories, extra = navigation_module_section( + docs, modules + ) + + merge_categories(categories) + self.group_docs(extra) + end + def self.group_custom_categories(docs) group = Config.instance.custom_categories.map do |category| children = category['children'].flat_map do |name| @@ -100,6 +110,33 @@ def self.group_type_categories(docs, type_category_prefix) [group.compact, docs] end + def self.navigation_module_section(docs, modules) + group = modules.map do |modulename| + children, docs = docs.partition { |doc| doc.modulename == modulename } + make_group( + children, + modulename, + "", + ) + end + + # Get from the remaining docs if there are extensions that should also be part of this module. + group = group.compact.map { |group| + newDocs = docs + .select { |doc| doc.children.map { |doc| doc.modulename }.include?(group.name) } + .map { |doc| + newdoc = doc.clone + newdoc.children, doc.children = doc.children.partition { |doc| doc.modulename == group.name } + newdoc.name = group.name + "+" + newdoc.name + newdoc + } + group.children = group.children + newDocs + group + } + + [group, docs.select { |doc| !doc.children.empty?()}] + end + # Join categories with the same name (eg. ObjC and Swift classes) def self.merge_categories(categories) merged = [] @@ -230,10 +267,16 @@ def self.arguments_from_options(options) end arguments += ['--'] end - - arguments + options.build_tool_arguments + + if options.modules_configured + arguments + else + arguments + options.build_tool_arguments + end end + + def self.objc_arguments_from_options(options) arguments = [] if options.build_tool_arguments.empty? @@ -1085,9 +1128,9 @@ def self.reject_objc_types(docs) # Parse sourcekitten STDOUT output as JSON # @return [Hash] structured docs - def self.parse(sourcekitten_output, min_acl, skip_undocumented, inject_docs) - @min_acl = min_acl - @skip_undocumented = skip_undocumented + def self.parse(sourcekitten_output, options, inject_docs) + @min_acl = options.min_acl + @skip_undocumented = options.skip_undocumented @stats = Stats.new @inaccessible_protocols = [] sourcekitten_json = filter_files(JSON.parse(sourcekitten_output).flatten) @@ -1099,7 +1142,12 @@ def self.parse(sourcekitten_output, min_acl, skip_undocumented, inject_docs) # than min_acl docs = docs.reject { |doc| doc.type.swift_enum_element? } ungrouped_docs = docs - docs = group_docs(docs) + if options.modules_configured + docs = group_docs_per_module(docs, options.modules) + else + docs = group_docs(docs) + end + merge_consecutive_marks(docs) make_doc_urls(docs) autolink(docs, ungrouped_docs) From db4b4135b9d65252928c3b2be74cda73174d71e8 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Wed, 7 Feb 2024 12:50:35 +0000 Subject: [PATCH 02/14] MM: Config model and current-module awareness Nested config file objects with selected attributes for modules. Add 'doc module name' sourcedeclaration attribute saying where the declaration was found, vs. 'module name' saying where the decl's nominal is from. Quick and dirty updates to prototype code to make it pass rubocop. --- .rubocop.yml | 3 + lib/jazzy/config.rb | 152 ++++++++++++++++++++++++++------ lib/jazzy/doc.rb | 4 +- lib/jazzy/doc_builder.rb | 52 ++++------- lib/jazzy/podspec_documenter.rb | 2 +- lib/jazzy/source_declaration.rb | 21 ++++- lib/jazzy/source_module.rb | 2 +- lib/jazzy/sourcekitten.rb | 140 +++++++++++++++-------------- lib/jazzy/symbol_graph.rb | 28 +++--- 9 files changed, 254 insertions(+), 150 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 8d72959f8..de26cbba0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -221,6 +221,9 @@ Metrics/ModuleLength: Metrics/BlockLength: Enabled: false +Metrics/ParameterLists: + CountKeywordArgs: false + Style/NumericPredicate: Enabled: false diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index ea45eb198..324bf6233 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -13,16 +13,17 @@ class Config # rubocop:disable Naming/AccessorMethodName class Attribute attr_reader :name, :description, :command_line, :config_file_key, - :default, :parse + :default, :parse, :per_module def initialize(name, description: nil, command_line: nil, - default: nil, parse: ->(x) { x }) + default: nil, parse: ->(x) { x }, per_module: false) @name = name.to_s @description = Array(description) @command_line = Array(command_line) @default = default @parse = parse @config_file_key = full_command_line_name || @name + @per_module = per_module end def get(config) @@ -51,7 +52,6 @@ def configured?(config) end def attach_to_option_parser(config, opt) - return if command_line.empty? opt.on(*command_line, *description) do |val| @@ -135,22 +135,26 @@ def hide_objc? config_attr :objc_mode, command_line: '--[no-]objc', description: 'Generate docs for Objective-C.', - default: false + default: false, + per_module: true config_attr :umbrella_header, command_line: '--umbrella-header PATH', description: 'Umbrella header for your Objective-C framework.', - parse: ->(uh) { expand_path(uh) } + parse: ->(uh) { expand_path(uh) }, + per_module: true config_attr :framework_root, command_line: '--framework-root PATH', description: 'The root path to your Objective-C framework.', - parse: ->(fr) { expand_path(fr) } + parse: ->(fr) { expand_path(fr) }, + per_module: true config_attr :sdk, command_line: '--sdk [iphone|watch|appletv][os|simulator]|macosx', description: 'The SDK for which your code should be built.', - default: 'macosx' + default: 'macosx', + per_module: true config_attr :hide_declarations, command_line: '--hide-declarations [objc|swift] ', @@ -176,12 +180,14 @@ def hide_objc? command_line: ['-b', '--build-tool-arguments arg1,arg2,…argN', Array], description: 'Arguments to forward to xcodebuild, swift build, or ' \ 'sourcekitten.', - default: [] + default: [], + per_module: true config_attr :modules, - description: 'Array of modules that are going to be documented ' \ - 'It will contain arguments: - name, build-tool-arguments arg1,arg2,…argN, source_directory.', - default: false + command_line: ['--modules Mod1,Mod2,…ModN', Array], + description: 'List of modules to document. Use the config file to set per-module ' \ + 'build flags, see xxxREADMExxx.', + default: [] alias_config_attr :xcodebuild_arguments, :build_tool_arguments, command_line: ['-x', '--xcodebuild-arguments arg1,arg2,…argN', Array], @@ -191,19 +197,23 @@ def hide_objc? command_line: ['-s', '--sourcekitten-sourcefile filepath1,…filepathN', Array], description: 'File(s) generated from sourcekitten output to parse', - parse: ->(paths) { [paths].flatten.map { |path| expand_path(path) } } + parse: ->(paths) { [paths].flatten.map { |path| expand_path(path) } }, + default: [], + per_module: true config_attr :source_directory, command_line: '--source-directory DIRPATH', description: 'The directory that contains the source to be documented', default: Pathname.pwd, - parse: ->(sd) { expand_path(sd) } + parse: ->(sd) { expand_path(sd) }, + per_module: true config_attr :symbolgraph_directory, command_line: '--symbolgraph-directory DIRPATH', description: 'A directory containing a set of Swift Symbolgraph files ' \ 'representing the module to be documented', - parse: ->(sd) { expand_path(sd) } + parse: ->(sd) { expand_path(sd) }, + per_module: true config_attr :excluded_files, command_line: ['-e', '--exclude filepath1,filepath2,…filepathN', Array], @@ -267,7 +277,8 @@ def hide_objc? config_attr :module_name, command_line: ['-m', '--module MODULE_NAME'], description: 'Name of module being documented. (e.g. RealmSwift)', - default: '' + default: '', + per_module: true config_attr :version, command_line: '--module-version VERSION', @@ -516,10 +527,12 @@ def self.parse! if config.root_url config.dash_url ||= URI.join( config.root_url, - "docsets/#{config.module_name}.xml", + "docsets/#{config.module_name}.xml", # XXX help ) end - + + config.set_module_configs + config.validate config @@ -575,12 +588,13 @@ def parse_config_file puts "Using config file #{config_path}" config_file = read_config_file(config_path) - attrs_by_conf_key, attrs_by_name = %i[config_file_key name].map do |prop| - self.class.all_config_attrs.group_by(&prop) - end + attrs_by_conf_key, attrs_by_name = grouped_attributes + parse_config_hash(config_file, attrs_by_conf_key, attrs_by_name) + end - config_file.each do |key, value| + def parse_config_hash(hash, attrs_by_conf_key, attrs_by_name, override: false) + hash.each do |key, value| unless attr = attrs_by_conf_key[key] message = "Unknown config file attribute #{key.inspect}" if matching_name = attrs_by_name[key] @@ -590,10 +604,19 @@ def parse_config_file warning message next end - attr.first.set_if_unconfigured(self, value) + setter = override ? :set : :set_if_unconfigured + attr.first.method(setter).call(self, value) end + end - self.base_path = nil + # Find keyed versions of the attributes, by config file key and then name-in-code + # Optional block allows filtering/overriding of attribute list. + def grouped_attributes + attrs = self.class.all_config_attrs + attrs = yield attrs if block_given? + %i[config_file_key name].map do |property| + attrs.group_by(&property) + end end def validate @@ -604,20 +627,95 @@ def validate '`source_host_url` or `source_host_files_url`.' end + if modules_configured && module_name_configured + raise 'Options `modules` and `module` are both set. See ' \ + 'XXX readme URL XXX.' + end + + if modules_configured && podspec_configured + raise 'Options `modules` and `podspec` are both set. See ' \ + 'XXX readme URL XXX.' + end + + module_configs.each(&:validate_module) + end + + def validate_module if objc_mode && build_tool_arguments_configured && (framework_root_configured || umbrella_header_configured) warning 'Option `build_tool_arguments` is set: values passed to ' \ '`framework_root` or `umbrella_header` may be ignored.' end + end - if modules_configured && module_name_configured - raise 'Jazzy only allows the use of a single command for generating documentation.' \ - 'Using both module configuration and modules configuration together is not supported.' + # rubocop:enable Metrics/MethodLength + + # Module Configs + # + # The user can enter module information in three different ways. This + # consolidates them into one view for the rest of the code. + # + # 1) Single module, back-compatible + # --module Foo etc etc (or not given at all) + # + # 2) Multiple modules, simple, sharing build params + # --modules Foo,Bar,Baz --source-directory Xyz + # + # 3) Multiple modules, custom, different build params but + # inheriting others from the top level. + # This is config-file only. + # - modules + # - module: Foo + # source_directory: Xyz + # build_tool_arguments: [a, b, c] + # + # After this we're left with `config.module_configs` that is an + # array of `Config` objects. + + attr_reader :module_configs + attr_reader :module_names + + def set_module_configs + @module_configs = parse_module_configs + @module_names = module_configs.map(&:module_name) + @module_names_set = Set.new(module_names) + end + + def module_name?(name) + @module_names_set.include?(name) + end + + def parse_module_configs + return [self] unless modules_configured + + raise 'Config file key `modules` must be an array' unless modules.is_a?(Array) + + if modules.first.is_a?(String) + # Massage format (2) into (3) + self.modules = modules.map { { 'module' => _1 } } + end + + # Allow per-module overrides of only some config options + attrs_by_conf_key, attrs_by_name = + grouped_attributes { _1.select(&:per_module) } + + modules.map do |module_hash| + mod_name = module_hash['module'] || '' + raise 'Missing `modules.module` config key' if mod_name.empty? + + dup.tap do |module_config| + module_config.parse_config_hash( + module_hash, attrs_by_conf_key, attrs_by_name, override: true + ) + end end end - # rubocop:enable Metrics/MethodLength + # For podspec query + def module_name_known? + module_name_configured || modules_configured + end def locate_config_file return config_file if config_file diff --git a/lib/jazzy/doc.rb b/lib/jazzy/doc.rb index 69b18842b..d5c499290 100644 --- a/lib/jazzy/doc.rb +++ b/lib/jazzy/doc.rb @@ -48,9 +48,9 @@ def docs_title elsif config.version_configured # Fake version for integration tests version = ENV['JAZZY_FAKE_MODULE_VERSION'] || config.version - "#{config.module_name} #{version} Docs" + "#{config.module_configs.first.module_name} #{version} Docs" # XXX help else - "#{config.module_name} Docs" + "#{config.module_configs.first.module_name} Docs" # XXX end end diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 81903a7a5..e396c4531 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -67,40 +67,25 @@ def self.children_for_doc(doc) # Build documentation from the given options # @param [Config] options - # @return [SourceModule] the documented source module def self.build(options) - if options.modules_configured - stdout = multiple_modules(options) - elsif 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) - elsif options.swift_build_tool == :symbolgraph - stdout = SymbolGraph.build(options) - else - stdout = Dir.chdir(options.source_directory) do - arguments = SourceKitten.arguments_from_options(options) - SourceKitten.run_sourcekitten(arguments) + module_jsons = options.module_configs.map do |module_config| + if module_config.podspec_configured + # Config#validate guarantees not multi-module here + pod_documenter = PodspecDocumenter.new(options.podspec) + pod_documenter.sourcekitten_output(options) + elsif !module_config.sourcekitten_sourcefile.empty? + "[#{module_config.sourcekitten_sourcefile.map(&:read).join(',')}]" + elsif module_config.swift_build_tool == :symbolgraph + SymbolGraph.build(module_config) + else + Dir.chdir(module_config.source_directory) do + arguments = SourceKitten.arguments_from_options(module_config) + SourceKitten.run_sourcekitten(arguments) + end end end - build_docs_for_sourcekitten_output(stdout, options) - end - - # Build Xcode project for multiple modules and parse the api documentation into a string - # @param [Config] options - # @return String the documented source module - def self.multiple_modules(options) - modules_parsed = Array[] - options.modules.each do |arguments| - module_parsed_string = Dir.chdir(arguments['source_directory']) do - arguments = SourceKitten.arguments_from_options(options) + (arguments['build_tool_arguments']||[]) - SourceKitten.run_sourcekitten(arguments) - end - modules_parsed.push(module_parsed_string) - end - stdout = "[#{modules_parsed.join(',')}]" + build_docs_for_sourcekitten_output(module_jsons, options) end # Build & write HTML docs to disk from structured docs array @@ -165,16 +150,15 @@ def self.build_site(docs, coverage, options) end # Build docs given sourcekitten output - # @param [String] sourcekitten_output Output of sourcekitten command + # @param [Array] sourcekitten_output Output of sourcekitten command for each module # @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, DocumentationGenerator.source_docs, ) - + prepare_output_dir(options.output, options.clean) stats.report @@ -464,4 +448,4 @@ def self.document(source_module, doc_model, path_to_root) end # rubocop:enable Metrics/MethodLength end -end \ No newline at end of file +end diff --git a/lib/jazzy/podspec_documenter.rb b/lib/jazzy/podspec_documenter.rb index c6a3587ec..382d40e33 100644 --- a/lib/jazzy/podspec_documenter.rb +++ b/lib/jazzy/podspec_documenter.rb @@ -52,7 +52,7 @@ def self.apply_config_defaults(podspec, config) config.author_name = author_name(podspec) config.author_name_configured = true end - unless config.module_name_configured + unless config.module_name_known? config.module_name = podspec.module_name config.module_name_configured = true end diff --git a/lib/jazzy/source_declaration.rb b/lib/jazzy/source_declaration.rb index 7643b3351..7578bbb52 100644 --- a/lib/jazzy/source_declaration.rb +++ b/lib/jazzy/source_declaration.rb @@ -114,7 +114,7 @@ def display_other_language_declaration attr_accessor :column attr_accessor :usr attr_accessor :type_usr - attr_accessor :modulename + attr_accessor :module_name attr_accessor :name attr_accessor :objc_name attr_accessor :declaration @@ -140,6 +140,11 @@ def display_other_language_declaration attr_accessor :inherited_types attr_accessor :async + # The name of the module being documented that contains this + # declaration. Only different from module_name when this is + # an extension of a type from another module. Nil for guides. + attr_accessor :doc_module_name + def usage_discouraged? unavailable || deprecated end @@ -183,12 +188,20 @@ def other_inherited_types?(unwanted) inherited_types.any? { |t| !unwanted.include?(t) } end - # Pre-Swift 5.6: SourceKit only sets modulename for imported modules - # Swift 5.6+: modulename is always set + # Pre-Swift 5.6: SourceKit only sets module_name for imported modules + # Swift 5.6+: module_name is always set def type_from_doc_module? !type.extension? || (swift? && usr && - (modulename.nil? || modulename == Config.instance.module_name)) + (module_name.nil? || module_name == doc_module_name)) + end + + # Don't ask the user to write documentation for types being extended + # from other modules. Compile errors leave no docs and a `nil` USR. + def mark_undocumented? + !swift? || (usr && + (module_name.nil? || + Config.instance.module_name?(module_name))) end # Info text for contents page by collapsed item name diff --git a/lib/jazzy/source_module.rb b/lib/jazzy/source_module.rb index c0cc63376..c9d4991ab 100644 --- a/lib/jazzy/source_module.rb +++ b/lib/jazzy/source_module.rb @@ -21,7 +21,7 @@ def initialize(options, docs, doc_structure, doc_coverage) self.docs = docs self.doc_structure = doc_structure self.doc_coverage = doc_coverage - self.name = options.module_name + self.name = options.module_configs.first.module_name # XXX what actually is this type for self.author_name = options.author_name self.author_url = options.author_url self.host = SourceHost.create(options) diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index 5c51e4d87..f2417dfda 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -71,13 +71,12 @@ def self.group_docs(docs) end # Group root-level docs by module name - def self.group_docs_per_module(docs, modules) - modules = modules.map { |mod| mod['name']} + def self.group_docs_per_module(docs, config) categories, extra = navigation_module_section( - docs, modules + docs, config.module_names ) - merge_categories(categories) + self.group_docs(extra) + merge_categories(categories) + group_docs(extra) end def self.group_custom_categories(docs) @@ -110,32 +109,36 @@ def self.group_type_categories(docs, type_category_prefix) [group.compact, docs] end + # rubocop:disable Metrics/MethodLength XXX tmp def self.navigation_module_section(docs, modules) - group = modules.map do |modulename| - children, docs = docs.partition { |doc| doc.modulename == modulename } + group = modules.map do |module_name| + children, docs = docs.partition { |doc| doc.module_name == module_name } make_group( children, - modulename, - "", + module_name, + '', ) end # Get from the remaining docs if there are extensions that should also be part of this module. - group = group.compact.map { |group| - newDocs = docs - .select { |doc| doc.children.map { |doc| doc.modulename }.include?(group.name) } - .map { |doc| + group = group.compact.map do |group2| + new_docs = docs + .select { |doc| doc.children.map(&:module_name).include?(group2.name) } + .map do |doc| newdoc = doc.clone - newdoc.children, doc.children = doc.children.partition { |doc| doc.modulename == group.name } - newdoc.name = group.name + "+" + newdoc.name + newdoc.children, doc.children = + doc.children.partition { _1.module_name == group2.name } + newdoc.name = group2.name + '+' + newdoc.name + newdoc.doc_module_name = group2.name # XXX not really but for now newdoc - } - group.children = group.children + newDocs - group - } + end + group2.children = group2.children + new_docs + group2 + end - [group, docs.select { |doc| !doc.children.empty?()}] + [group, docs.reject { |doc| doc.children.empty? }] end + # rubocop:enable Metrics/MethodLength XXX tmp # Join categories with the same name (eg. ObjC and Swift classes) def self.merge_categories(categories) @@ -247,48 +250,42 @@ def self.rec_path(path) end.select { |x| x }.flatten(1) end - def self.use_spm?(options) - options.swift_build_tool == :spm || - (!options.swift_build_tool_configured && + def self.use_spm?(module_config) + module_config.swift_build_tool == :spm || + (!module_config.swift_build_tool_configured && Dir['*.xcodeproj', '*.xcworkspace'].empty? && - !options.build_tool_arguments.include?('-project') && - !options.build_tool_arguments.include?('-workspace')) + !module_config.build_tool_arguments.include?('-project') && + !module_config.build_tool_arguments.include?('-workspace')) end # Builds SourceKitten arguments based on Jazzy options - def self.arguments_from_options(options) + def self.arguments_from_options(module_config) arguments = ['doc'] - if options.objc_mode - arguments += objc_arguments_from_options(options) + if module_config.objc_mode + arguments += objc_arguments_from_options(module_config) else - arguments += ['--spm'] if use_spm?(options) - unless options.module_name.empty? - arguments += ['--module-name', options.module_name] + arguments += ['--spm'] if use_spm?(module_config) + unless module_config.module_name.empty? + arguments += ['--module-name', module_config.module_name] end arguments += ['--'] end - - if options.modules_configured - arguments - else - arguments + options.build_tool_arguments - end - end - + arguments + module_config.build_tool_arguments + end - def self.objc_arguments_from_options(options) + def self.objc_arguments_from_options(module_config) arguments = [] - if options.build_tool_arguments.empty? - arguments += ['--objc', options.umbrella_header.to_s, '--', '-x', + if module_config.build_tool_arguments.empty? + arguments += ['--objc', module_config.umbrella_header.to_s, '--', '-x', 'objective-c', '-isysroot', - `xcrun --show-sdk-path --sdk #{options.sdk}`.chomp, - '-I', options.framework_root.to_s, + `xcrun --show-sdk-path --sdk #{module_config.sdk}`.chomp, + '-I', module_config.framework_root.to_s, '-fmodules'] end # add additional -I arguments for each subdirectory of framework_root - unless options.framework_root.nil? - rec_path(Pathname.new(options.framework_root.to_s)).collect do |child| + unless module_config.framework_root.nil? + rec_path(Pathname.new(module_config.framework_root.to_s)).collect do |child| if child.directory? arguments += ['-I', child.to_s] end @@ -395,18 +392,10 @@ def self.should_document_swift_extension?(doc) end end - # Call things undocumented if they were compiled properly - # and came from our module. - def self.should_mark_undocumented(declaration) - declaration.usr && - (declaration.modulename.nil? || - declaration.modulename == Config.instance.module_name) - end - def self.process_undocumented_token(doc, declaration) make_default_doc_info(declaration) - if !declaration.swift? || should_mark_undocumented(declaration) + if declaration.mark_undocumented? @stats.add_undocumented(declaration) return nil if @skip_undocumented @@ -664,7 +653,14 @@ def self.make_source_declarations(docs, parent = nil, mark = SourceMark.new) declaration.file = Pathname(doc['key.filepath']) if doc['key.filepath'] declaration.usr = doc['key.usr'] declaration.type_usr = doc['key.typeusr'] - declaration.modulename = doc['key.modulename'] + declaration.module_name = + if declaration.swift? + doc['key.modulename'] + else + # ObjC best effort, category original module is unavailable + @current_module_name + end + declaration.doc_module_name = @current_module_name declaration.name = documented_name declaration.mark = current_mark declaration.access_control_level = @@ -732,7 +728,8 @@ def self.expand_extension(extension, name_parts, decls) SourceDeclaration.new.tap do |decl| make_default_doc_info(decl) decl.name = name - decl.modulename = extension.modulename + decl.module_name = extension.module_name + decl.doc_module_name = extension.doc_module_name decl.type = extension.type decl.mark = extension.mark decl.usr = candidates.first.usr unless candidates.empty? @@ -763,9 +760,9 @@ def self.deduplicate_declarations(declarations) # Returns true if an Objective-C declaration is mergeable. def self.mergeable_objc?(decl, root_decls) - decl.type.objc_class? \ - || (decl.type.objc_category? \ - && name_match(decl.objc_category_name[0], root_decls)) + decl.type.objc_class? || + (decl.type.objc_category? && + name_match(decl.objc_category_name[0], root_decls)) end # Returns if a Swift declaration is mergeable. @@ -1128,13 +1125,20 @@ def self.reject_objc_types(docs) # Parse sourcekitten STDOUT output as JSON # @return [Hash] structured docs + # rubocop:disable Metrics/MethodLength XXX tmp def self.parse(sourcekitten_output, options, inject_docs) @min_acl = options.min_acl @skip_undocumented = options.skip_undocumented @stats = Stats.new @inaccessible_protocols = [] - sourcekitten_json = filter_files(JSON.parse(sourcekitten_output).flatten) - docs = make_source_declarations(sourcekitten_json).concat inject_docs + + # Process each module separately to inject the source module name + docs = sourcekitten_output.zip(options.module_names).map do |json, name| + @current_module_name = name + sourcekitten_dicts = filter_files(JSON.parse(json).flatten) + make_source_declarations(sourcekitten_dicts) + end.flatten + inject_docs + docs = expand_extensions(docs) docs = deduplicate_declarations(docs) docs = reject_objc_types(docs) @@ -1142,16 +1146,18 @@ def self.parse(sourcekitten_output, options, inject_docs) # than min_acl docs = docs.reject { |doc| doc.type.swift_enum_element? } ungrouped_docs = docs - if options.modules_configured - docs = group_docs_per_module(docs, options.modules) - else - docs = group_docs(docs) - end - + docs = + if options.module_configs.count > 1 # XXX need a --merge-modules or something + group_docs_per_module(docs, options) + else + group_docs(docs) + end + merge_consecutive_marks(docs) make_doc_urls(docs) autolink(docs, ungrouped_docs) [docs, @stats] end + # rubocop:enable Metrics/MethodLength XXX tmp end end diff --git a/lib/jazzy/symbol_graph.rb b/lib/jazzy/symbol_graph.rb index abe228ed0..38e5e0bfa 100644 --- a/lib/jazzy/symbol_graph.rb +++ b/lib/jazzy/symbol_graph.rb @@ -20,10 +20,10 @@ module SymbolGraph # with configured args. # Then parse the results, and return as JSON in SourceKit[ten] # format. - def self.build(config) - if config.symbolgraph_directory.nil? + def self.build(module_config) + if module_config.symbolgraph_directory.nil? Dir.mktmpdir do |tmp_dir| - args = arguments(config, tmp_dir) + args = arguments(module_config, tmp_dir) Executable.execute_command('swift', args.unshift('symbolgraph-extract'), @@ -32,17 +32,17 @@ def self.build(config) parse_symbols(tmp_dir) end else - parse_symbols(config.symbolgraph_directory.to_s) + parse_symbols(module_config.symbolgraph_directory.to_s) end end # Figure out the args to pass to symbolgraph-extract - def self.arguments(config, output_path) - if config.module_name.empty? + def self.arguments(module_config, output_path) + if module_config.module_name.empty? raise 'error: `--swift-build-tool symbolgraph` requires `--module`.' end - user_args = config.build_tool_arguments.join + user_args = module_config.build_tool_arguments.join if user_args =~ /-(?:module-name|minimum-access-level|output-dir)/ raise 'error: `--build-tool-arguments` for ' \ @@ -52,19 +52,19 @@ def self.arguments(config, output_path) # Default set args = [ - '-module-name', config.module_name, + '-module-name', module_config.module_name, '-minimum-access-level', 'private', '-output-dir', output_path, '-skip-synthesized-members' ] # Things user can override - args += ['-sdk', sdk(config)] unless user_args =~ /-sdk/ + args += ['-sdk', sdk(module_config)] unless user_args =~ /-sdk/ args += ['-target', target] unless user_args =~ /-target/ - args += ['-F', config.source_directory.to_s] unless user_args =~ /-F(?!s)/ - args += ['-I', config.source_directory.to_s] unless user_args =~ /-I/ + args += ['-F', module_config.source_directory.to_s] unless user_args =~ /-F(?!s)/ + args += ['-I', module_config.source_directory.to_s] unless user_args =~ /-I/ - args + config.build_tool_arguments + args + module_config.build_tool_arguments end # Parse the symbol files in the given directory @@ -84,8 +84,8 @@ def self.parse_symbols(directory) end # Get the SDK path. On !darwin this just isn't needed. - def self.sdk(config) - `xcrun --show-sdk-path --sdk #{config.sdk}`.chomp + def self.sdk(module_config) + `xcrun --show-sdk-path --sdk #{module_config.sdk}`.chomp end # Guess a default LLVM target. Feels like the tool should figure this From f8c9e517f99398329a9f849f5a862f74b2c8f1e1 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Thu, 8 Feb 2024 18:16:25 +0000 Subject: [PATCH 03/14] MM: Spec testcase --- spec/integration_spec.rb | 4 ++++ spec/integration_specs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/spec/integration_spec.rb b/spec/integration_spec.rb index 71e5f3c26..c1abc5695 100644 --- a/spec/integration_spec.rb +++ b/spec/integration_spec.rb @@ -249,6 +249,10 @@ def configure_cocoapods '--build-tool-arguments ' \ "-emit-extension-block-symbols,-I,#{module_path}" end + + describe 'Creates docs for a multiple-module project' do + behaves_like cli_spec 'jazzy_multi_modules' + end end if !spec_subset || spec_subset == 'swift' describe 'jazzy cocoapods' do diff --git a/spec/integration_specs b/spec/integration_specs index eec146448..98a6d9e10 160000 --- a/spec/integration_specs +++ b/spec/integration_specs @@ -1 +1 @@ -Subproject commit eec146448d04deef52f4a53de3fb03f8464a1f8f +Subproject commit 98a6d9e1076b5b1c30f8914f3b5f1e6cd78cda2a From 536703160c17a8f0b21244ac8500a40056165c25 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Fri, 9 Feb 2024 11:29:25 +0000 Subject: [PATCH 04/14] MM: Docset title independent of module name Provide a separate option to set the docset title instead of using the module name. Refactor to pull all the docset stuff into the docset code. --- CHANGELOG.md | 4 ++ README.md | 19 +++++++ lib/jazzy/config.rb | 14 ++--- lib/jazzy/doc_builder.rb | 21 ++++---- lib/jazzy/docset_builder.rb | 57 +++++++++++++++----- lib/jazzy/docset_builder/info_plist.mustache | 2 +- lib/jazzy/source_module.rb | 21 ++++---- 7 files changed, 97 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d41b6d65f..70e9b22f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ##### Enhancements +* Add `--docset-title` to set the title of a docset separately + to the module name. + [John Fairhurst](https://github.com/johnfairh) + * Support Swift 5.9 symbolgraph extension symbols. [John Fairhurst](https://github.com/johnfairh) [#1368](https://github.com/realm/jazzy/issues/1368) diff --git a/README.md b/README.md index 72880a414..07501391d 100644 --- a/README.md +++ b/README.md @@ -423,6 +423,24 @@ jazzy --swift-version 5.7 DEVELOPER_DIR=/Applications/Xcode_14.app/Contents/Developer jazzy ``` +### Dash Docset Support + +As well as the browsable HTML documentation, Jazzy creates a _docset_ for use +with the [Dash][dash] app. + +By default the docset is created at `docs/docsets/ModuleName.tgz`. Use +`--docset-path` to create it somewhere else; use `--docset-title` to change +the docset's title. + +Use `--docset-playground-url` and `--docset-icon` to further customize the +docset. + +If you set both `--root-url` to be the (https://) URL where you plan to deploy +your documentation and `--version` to give your documentation a version number +then Jazzy also creates a docset feed XML file and includes an "Install in Dash" +button on the site. This lets users who are browsing your documentation on the +web install and start using the docs in Dash locally. + ## Linux Jazzy uses [SourceKitten][sourcekitten] to communicate with the Swift build @@ -576,3 +594,4 @@ read [our blog](https://realm.io/news) or say hi on twitter [bundler]: https://rubygems.org/gems/bundler [mustache]: https://mustache.github.io "Mustache" [spm]: https://swift.org/package-manager/ "Swift Package Manager" +[dash]: https://kapeli.com/dash/ "Dash" diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index 324bf6233..9e6139850 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -334,6 +334,13 @@ def hide_objc? command_line: '--docset-path DIRPATH', description: 'The relative path for the generated docset' + config_attr :docset_title, + command_line: '--docset-title TITLE', + description: 'The title of the generated docset. A simplified version ' \ + 'is used for the filenames associated with the docset. If the ' \ + 'option is not set then the name of the module being documented is ' \ + 'used as the docset title.' + # ──────── URLs ──────── config_attr :root_url, @@ -524,13 +531,6 @@ def self.parse! 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", # XXX help - ) - end - config.set_module_configs config.validate diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index e396c4531..7c07e2ab0 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -90,11 +90,9 @@ def self.build(options) # 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| + # @param [SourceModule] source_module All info to generate docs + def self.build_docs(output_dir, source_module) + each_doc(output_dir, source_module.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 @@ -126,10 +124,13 @@ def self.build_site(docs, coverage, options) 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) + + docset_builder = DocsetBuilder.new(output_dir) + + source_module = SourceModule.new(docs, structure, coverage, docset_builder) + + build_docs(output_dir, source_module) unless options.disable_search warn 'building search index' @@ -139,7 +140,7 @@ def self.build_site(docs, coverage, options) copy_extensions(source_module, output_dir) copy_theme_assets(output_dir) - DocsetBuilder.new(output_dir, source_module).build! + docset_builder.build!(source_module.all_declarations) generate_badge(source_module.doc_coverage, options) @@ -259,7 +260,7 @@ def self.new_document(source_module, doc_model) doc[:github_url] = doc[:source_host_url] doc[:github_token_url] = doc[:source_host_item_url] end - doc[:dash_url] = source_module.dash_url + doc[:dash_url] = source_module.dash_feed_url end end diff --git a/lib/jazzy/docset_builder.rb b/lib/jazzy/docset_builder.rb index 6c7be9abe..7970e4505 100644 --- a/lib/jazzy/docset_builder.rb +++ b/lib/jazzy/docset_builder.rb @@ -14,37 +14,43 @@ class DocsetBuilder attr_reader :source_module attr_reader :docset_dir attr_reader :documents_dir + attr_reader :name - def initialize(generated_docs_dir, source_module) - @source_module = source_module + def initialize(generated_docs_dir) + @name = config.docset_title || config.module_names.first docset_path = config.docset_path || - "docsets/#{source_module.name}.docset" + "docsets/#{safe_name}.docset" @docset_dir = generated_docs_dir + docset_path @generated_docs_dir = generated_docs_dir @output_dir = docset_dir.parent @documents_dir = docset_dir + 'Contents/Resources/Documents/' end - def build! + def build!(all_declarations) docset_dir.rmtree if docset_dir.exist? copy_docs copy_icon if config.docset_icon write_plist - create_index + create_index(all_declarations) create_archive create_xml if config.version && config.root_url end private + def safe_name + name.gsub(/[^a-z0-9_\-]+/i, '_') + end + def write_plist info_plist_path = docset_dir + 'Contents/Info.plist' info_plist_path.open('w') do |plist| template = Pathname(__dir__) + 'docset_builder/info_plist.mustache' plist << Mustache.render( template.read, - lowercase_name: source_module.name.downcase, - name: source_module.name, + lowercase_name: name.downcase, + lowercase_safe_name: safe_name.downcase, + name: name, root_url: config.root_url, playground_url: config.docset_playground_url, ) @@ -52,7 +58,7 @@ def write_plist end def create_archive - target = "#{source_module.name}.tgz" + target = "#{safe_name}.tgz" source = docset_dir.basename.to_s options = { chdir: output_dir.to_s, @@ -70,17 +76,17 @@ def copy_docs end def copy_icon - FileUtils.cp config.docset_icon, @docset_dir + 'icon.png' + FileUtils.cp config.docset_icon, docset_dir + 'icon.png' end - def create_index + def create_index(all_declarations) search_index_path = docset_dir + 'Contents/Resources/docSet.dsidx' SQLite3::Database.new(search_index_path.to_s) do |db| db.execute('CREATE TABLE searchIndex(' \ 'id INTEGER PRIMARY KEY, name TEXT, type TEXT, path TEXT);') db.execute('CREATE UNIQUE INDEX anchor ON ' \ 'searchIndex (name, type, path);') - source_module.all_declarations.select(&:type).each do |doc| + all_declarations.select(&:type).each do |doc| db.execute('INSERT OR IGNORE INTO searchIndex(name, type, path) ' \ 'VALUES (?, ?, ?);', [doc.name, doc.type.dash_type, doc.filepath]) end @@ -88,12 +94,37 @@ def create_index end def create_xml - (output_dir + "#{source_module.name}.xml").open('w') do |xml| - url = URI.join(config.root_url, "docsets/#{source_module.name}.tgz") + (output_dir + "#{safe_name}.xml").open('w') do |xml| + url = URI.join(config.root_url, "docsets/#{safe_name}.tgz") xml << "#{config.version}#{url}" \ "\n" end end + + # The web URL where the user intends to place the docset XML file. + def dash_url + return nil unless config.dash_url || config.root_url + + config.dash_url || + URI.join( + config.root_url, + "docsets/#{safe_name}.xml", + ) + end + + public + + # The dash-feed:// URL that links from the Dash icon in generated + # docs. This is passed to the Dash app and encodes the actual web + # `dash_url` where the user has placed the XML file. + # + # Unfortunately for historical reasons this is *also* called the + # 'dash_url' where it appears in mustache templates and so on. + def dash_feed_url + dash_url&.then do |url| + "dash-feed://#{ERB::Util.url_encode(url.to_s)}" + end + end end end end diff --git a/lib/jazzy/docset_builder/info_plist.mustache b/lib/jazzy/docset_builder/info_plist.mustache index dca719618..adc1712f2 100644 --- a/lib/jazzy/docset_builder/info_plist.mustache +++ b/lib/jazzy/docset_builder/info_plist.mustache @@ -3,7 +3,7 @@ CFBundleIdentifier - com.jazzy.{{lowercase_name}} + com.jazzy.{{lowercase_safe_name}} CFBundleName {{name}} DocSetPlatformFamily diff --git a/lib/jazzy/source_module.rb b/lib/jazzy/source_module.rb index c9d4991ab..7d3040c77 100644 --- a/lib/jazzy/source_module.rb +++ b/lib/jazzy/source_module.rb @@ -7,28 +7,29 @@ require 'jazzy/source_host' module Jazzy + # A cache of info that is common across all page templating, gathered + # from other parts of the program. class SourceModule + include Config::Mixin + attr_accessor :name attr_accessor :docs attr_accessor :doc_coverage attr_accessor :doc_structure attr_accessor :author_name attr_accessor :author_url - attr_accessor :dash_url + attr_accessor :dash_feed_url attr_accessor :host - def initialize(options, docs, doc_structure, doc_coverage) + def initialize(docs, doc_structure, doc_coverage, docset_builder) self.docs = docs self.doc_structure = doc_structure self.doc_coverage = doc_coverage - self.name = options.module_configs.first.module_name # XXX what actually is this type for - self.author_name = options.author_name - self.author_url = options.author_url - self.host = SourceHost.create(options) - return unless options.dash_url - - self.dash_url = - "dash-feed://#{ERB::Util.url_encode(options.dash_url.to_s)}" + self.name = config.module_names.first # XXX what actually is this type for + self.author_name = config.author_name + self.author_url = config.author_url + self.host = SourceHost.create(config) + self.dash_feed_url = docset_builder.dash_feed_url end def all_declarations From 80da8d0cb0c7e88971608be880435fa25a902432 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Fri, 9 Feb 2024 12:40:40 +0000 Subject: [PATCH 05/14] MM: Readme title independent of module name Add a new flag to set this thing manually. --- CHANGELOG.md | 4 ++-- lib/jazzy/config.rb | 4 ++++ lib/jazzy/doc_builder.rb | 4 ++-- lib/jazzy/source_document.rb | 2 +- lib/jazzy/source_module.rb | 5 +++-- 5 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70e9b22f5..92bfec61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,8 @@ ##### Enhancements -* Add `--docset-title` to set the title of a docset separately - to the module name. +* Add `--readme-title` and `--docset-title` to set the titles of the readme + docs page and the Dash docset independently of the module name. [John Fairhurst](https://github.com/johnfairh) * Support Swift 5.9 symbolgraph extension symbols. diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index 9e6139850..54a0deca9 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -301,6 +301,10 @@ def hide_objc? description: 'The path to a markdown README file', parse: ->(rp) { expand_path(rp) } + config_attr :readme_title, + command_line: '--readme-title TITLE', + description: 'The title for the README in the generated documentation' + config_attr :documentation_glob, command_line: '--documentation GLOB', description: 'Glob that matches available documentation', diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 7c07e2ab0..7bdd0927a 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -250,7 +250,7 @@ def self.new_document(source_module, doc_model) 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[:module_name] = source_module.readme_title # historical name doc[:author_name] = source_module.author_name if source_host = source_module.host doc[:source_host_name] = source_host.name @@ -270,7 +270,7 @@ def self.new_document(source_module, doc_model) # @param [String] path_to_root def self.document_markdown(source_module, doc_model, path_to_root) doc = new_document(source_module, doc_model) - name = doc_model.name == 'index' ? source_module.name : doc_model.name + name = doc_model.name == 'index' ? source_module.readme_title : doc_model.name doc[:name] = name doc[:overview] = render(doc_model, doc_model.content(source_module)) doc[:path_to_root] = path_to_root diff --git a/lib/jazzy/source_document.rb b/lib/jazzy/source_document.rb index 06667a890..1cb3b4bc1 100644 --- a/lib/jazzy/source_document.rb +++ b/lib/jazzy/source_document.rb @@ -89,7 +89,7 @@ def generated_readme(source_module) README else <<-README -# #{source_module.name} +# #{source_module.readme_title} ### Authors diff --git a/lib/jazzy/source_module.rb b/lib/jazzy/source_module.rb index 7d3040c77..18f2f9963 100644 --- a/lib/jazzy/source_module.rb +++ b/lib/jazzy/source_module.rb @@ -12,7 +12,7 @@ module Jazzy class SourceModule include Config::Mixin - attr_accessor :name + attr_accessor :readme_title attr_accessor :docs attr_accessor :doc_coverage attr_accessor :doc_structure @@ -25,7 +25,8 @@ def initialize(docs, doc_structure, doc_coverage, docset_builder) self.docs = docs self.doc_structure = doc_structure self.doc_coverage = doc_coverage - self.name = config.module_names.first # XXX what actually is this type for + self.readme_title = + config.readme_title || config.module_names.first self.author_name = config.author_name self.author_url = config.author_url self.host = SourceHost.create(config) From 2ccdce5bade0e785c9a0c1792c377322b06d32b8 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Mon, 12 Feb 2024 12:29:29 +0000 Subject: [PATCH 06/14] MM: Improve breadcrumbs Make the page breadcrumbs actually reflect the navigation hierarchy. Include a type's module name when it's from an external module or not clear from the breadcrumb list. Affects all specs. --- CHANGELOG.md | 4 +++ lib/jazzy/config.rb | 4 +++ lib/jazzy/doc_builder.rb | 29 ++++++++++++++-- lib/jazzy/source_declaration.rb | 33 +++++++++++++++++-- lib/jazzy/source_document.rb | 4 +++ lib/jazzy/source_module.rb | 4 +-- lib/jazzy/sourcekitten.rb | 3 +- .../themes/apple/assets/css/jazzy.css.scss | 6 ++-- lib/jazzy/themes/apple/templates/doc.mustache | 9 ++++- .../themes/fullwidth/templates/doc.mustache | 9 ++++- lib/jazzy/themes/jony/templates/doc.mustache | 11 +++++-- 11 files changed, 102 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92bfec61c..d64ce4943 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ ##### Enhancements +* Improve page breadcrumbs to include parent pages and indicate source module + of extensions from other modules. + [John Fairhurst](https://github.com/johnfairh) + * Add `--readme-title` and `--docset-title` to set the titles of the readme docs page and the Dash docset independently of the module name. [John Fairhurst](https://github.com/johnfairh) diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index 54a0deca9..d128b9d75 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -690,6 +690,10 @@ def module_name?(name) @module_names_set.include?(name) end + def multiple_modules? + @module_names.count > 1 + end + def parse_module_configs return [self] unless modules_configured diff --git a/lib/jazzy/doc_builder.rb b/lib/jazzy/doc_builder.rb index 7bdd0927a..b7e4b7eca 100644 --- a/lib/jazzy/doc_builder.rb +++ b/lib/jazzy/doc_builder.rb @@ -250,7 +250,8 @@ def self.new_document(source_module, doc_model) doc[:doc_coverage] = source_module.doc_coverage unless Config.instance.hide_documentation_coverage doc[:structure] = source_module.doc_structure - doc[:module_name] = source_module.readme_title # historical name + doc[:readme_title] = source_module.readme_title + doc[:module_name] = doc[:readme_title] doc[:author_name] = source_module.author_name if source_host = source_module.host doc[:source_host_name] = source_host.name @@ -261,6 +262,7 @@ def self.new_document(source_module, doc_model) doc[:github_token_url] = doc[:source_host_item_url] end doc[:dash_url] = source_module.dash_feed_url + doc[:breadcrumbs] = make_breadcrumbs(doc_model) end end @@ -270,7 +272,7 @@ def self.new_document(source_module, doc_model) # @param [String] path_to_root def self.document_markdown(source_module, doc_model, path_to_root) doc = new_document(source_module, doc_model) - name = doc_model.name == 'index' ? source_module.readme_title : doc_model.name + name = doc_model.readme? ? source_module.readme_title : doc_model.name doc[:name] = name doc[:overview] = render(doc_model, doc_model.content(source_module)) doc[:path_to_root] = path_to_root @@ -448,5 +450,28 @@ def self.document(source_module, doc_model, path_to_root) doc.render.gsub(ELIDED_AUTOLINK_TOKEN, path_to_root) end # rubocop:enable Metrics/MethodLength + + # Breadcrumbs for a page - doesn't include the top 'readme' crumb + def self.make_breadcrumbs(doc_model) + return [] if doc_model.readme? + + docs_path = doc_model.docs_path + breadcrumbs = docs_path.map do |doc| + { + name: doc.name, + url: doc.url, + last: doc == doc_model, + } + end + + return breadcrumbs if breadcrumbs.count == 1 + + # Add the module name to the outer type if not clear from context + if docs_path[1].ambiguous_module_name?(docs_path[0].name) + breadcrumbs[1][:name] = docs_path[1].fully_qualified_module_name + end + + breadcrumbs + end end end diff --git a/lib/jazzy/source_declaration.rb b/lib/jazzy/source_declaration.rb index 7578bbb52..c07fef27b 100644 --- a/lib/jazzy/source_declaration.rb +++ b/lib/jazzy/source_declaration.rb @@ -62,6 +62,7 @@ def namespace_ancestors end end + # 'OuterType.NestedType.method(arg:)' def fully_qualified_name namespace_path.map(&:name).join('.') end @@ -74,6 +75,17 @@ def fully_qualified_name_regexp .join('(?:<.*?>)?\.')) end + # 'MyModule.OuterType.NestedType.method(arg:)' + def fully_qualified_module_name + prefix = module_name&.then { _1 + '.' } || '' + prefix + fully_qualified_name + end + + # List of doc_parent decls, .last is self + def docs_path + (parent_in_docs&.docs_path || []) + [self] + end + # If this declaration is an objc category, returns an array with the name # of the extended objc class and the category name itself, i.e. # ["NSString", "MyMethods"], nil otherwise. @@ -199,9 +211,20 @@ def type_from_doc_module? # Don't ask the user to write documentation for types being extended # from other modules. Compile errors leave no docs and a `nil` USR. def mark_undocumented? - !swift? || (usr && - (module_name.nil? || - Config.instance.module_name?(module_name))) + !swift? || (usr && !extension_of_external_type?) + end + + def extension_of_external_type? + !module_name.nil? && + !Config.instance.module_name?(module_name) + end + + # Is it unclear from context what module the (top-level) decl is from? + def ambiguous_module_name?(group_name) + extension_of_external_type? || + (Config.instance.multiple_modules? && + !module_name.nil? && + group_name != module_name) end # Info text for contents page by collapsed item name @@ -212,6 +235,10 @@ def declaration_note notes.join(', ').humanize unless notes.empty? end + def readme? + false + end + def alternative_abstract if file = alternative_abstract_file Pathname(file).read diff --git a/lib/jazzy/source_document.rb b/lib/jazzy/source_document.rb index 1cb3b4bc1..f2bc4e113 100644 --- a/lib/jazzy/source_document.rb +++ b/lib/jazzy/source_document.rb @@ -27,6 +27,10 @@ def self.make_index(readme_path) end end + def readme? + url == 'index.html' + end + def render_as_page? true end diff --git a/lib/jazzy/source_module.rb b/lib/jazzy/source_module.rb index 18f2f9963..f4c3fe69c 100644 --- a/lib/jazzy/source_module.rb +++ b/lib/jazzy/source_module.rb @@ -25,8 +25,8 @@ def initialize(docs, doc_structure, doc_coverage, docset_builder) self.docs = docs self.doc_structure = doc_structure self.doc_coverage = doc_coverage - self.readme_title = - config.readme_title || config.module_names.first + title = config.readme_title || config.module_names.first + self.readme_title = title.empty? ? 'Index' : title self.author_name = config.author_name self.author_url = config.author_url self.host = SourceHost.create(config) diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index f2417dfda..8c99158e5 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -655,7 +655,8 @@ def self.make_source_declarations(docs, parent = nil, mark = SourceMark.new) declaration.type_usr = doc['key.typeusr'] declaration.module_name = if declaration.swift? - doc['key.modulename'] + # Filter out Apple sub-framework implementation names + doc['key.modulename']&.sub(/\..*$/, '') else # ObjC best effort, category original module is unavailable @current_module_name diff --git a/lib/jazzy/themes/apple/assets/css/jazzy.css.scss b/lib/jazzy/themes/apple/assets/css/jazzy.css.scss index 6f5fa0d70..d83b102e4 100644 --- a/lib/jazzy/themes/apple/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/apple/assets/css/jazzy.css.scss @@ -23,7 +23,7 @@ $content_top_offset: 70px; $content_body_margin: 16px; $content_body_left_offset: $sidebar_width + $content_body_margin; $header_height: 32px; -$breadcrumb_padding_top: 17px; +$breadcrumb_padding_top: 12px; $code_font: 0.95em Menlo, monospace; @@ -206,9 +206,11 @@ header { height: $content_top_offset - $header_height - $breadcrumb_padding_top; padding-top: $breadcrumb_padding_top; position: fixed; - width: 100%; + width: inherit; z-index: 2; margin-top: $header_height; + white-space: nowrap; + overflow-x: scroll; #carat { height: 10px; margin: 0 5px; diff --git a/lib/jazzy/themes/apple/templates/doc.mustache b/lib/jazzy/themes/apple/templates/doc.mustache index 41337ce96..534fbc115 100644 --- a/lib/jazzy/themes/apple/templates/doc.mustache +++ b/lib/jazzy/themes/apple/templates/doc.mustache @@ -28,9 +28,16 @@ {{> header}}
diff --git a/lib/jazzy/themes/fullwidth/templates/doc.mustache b/lib/jazzy/themes/fullwidth/templates/doc.mustache index 4d7955c47..ca657acae 100644 --- a/lib/jazzy/themes/fullwidth/templates/doc.mustache +++ b/lib/jazzy/themes/fullwidth/templates/doc.mustache @@ -31,9 +31,16 @@ {{> header}}
diff --git a/lib/jazzy/themes/jony/templates/doc.mustache b/lib/jazzy/themes/jony/templates/doc.mustache index b5a4d8a3d..195a58978 100644 --- a/lib/jazzy/themes/jony/templates/doc.mustache +++ b/lib/jazzy/themes/jony/templates/doc.mustache @@ -26,10 +26,17 @@
From 857cca39652a58980a46df9bd6b9236b8f275b9d Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Mon, 12 Feb 2024 13:19:54 +0000 Subject: [PATCH 07/14] MM: File layout Each module into its own directory. Each module that has extensions in its own subdir No need to separate classes/enums etc even in ObjC --- lib/jazzy/source_declaration/type.rb | 11 +++++-- lib/jazzy/sourcekitten.rb | 46 ++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/lib/jazzy/source_declaration/type.rb b/lib/jazzy/source_declaration/type.rb index 5f2b4ddd9..809917664 100644 --- a/lib/jazzy/source_declaration/type.rb +++ b/lib/jazzy/source_declaration/type.rb @@ -164,8 +164,14 @@ def objc_unexposed? kind == 'sourcekitten.source.lang.objc.decl.unexposed' end + OVERVIEW_KIND = 'Overview' + def self.overview - Type.new('Overview') + Type.new(OVERVIEW_KIND) + end + + def overview? + kind == OVERVIEW_KIND end MARKDOWN_KIND = 'document.markdown' @@ -194,7 +200,8 @@ def ==(other) dash: 'Guide', }.freeze, - 'Overview' => { + # Group/Overview + OVERVIEW_KIND => { jazzy: nil, dash: 'Section', }.freeze, diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index 8c99158e5..adf8fb8eb 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -189,7 +189,7 @@ def self.sanitize_filename(doc) end # rubocop:disable Metrics/MethodLength - # Generate doc URL by prepending its parents URLs + # Generate doc URL by prepending its parents' URLs # @return [Hash] input docs with URLs def self.make_doc_urls(docs) docs.each do |doc| @@ -229,18 +229,46 @@ def self.make_doc_urls(docs) # Guides in the root for back-compatibility. # Declarations under outer namespace type (Structures, Classes, etc.) def self.subdir_for_doc(doc) - return [] if doc.type.markdown? - - top_level_decl = doc.namespace_path.first - if top_level_decl.type.name - [top_level_decl.type.plural_url_name] + - doc.namespace_ancestors.map(&:name) + if Config.instance.multiple_modules? + subdir_for_doc_multi_module(doc) else - # Category - in the root - [] + # Back-compatibility layout version + subdir_for_doc_single_module(doc) end end + # Pre-multi-module site layout, does not allow for + # types with the same name. + def self.subdir_for_doc_single_module(doc) + # Guides + Groups in the root + return [] if doc.type.markdown? || doc.type.overview? + + [doc.namespace_path.first.type.plural_url_name] + + doc.namespace_ancestors.map(&:name) + end + + # Multi-module site layout, separate each module that + # is being documented. + def self.subdir_for_doc_multi_module(doc) + # Guides + Groups in the root + return [] if doc.type.markdown? || doc.type.overview? + + root_decl = doc.namespace_path.first + + # Extensions need an extra dir to allow for extending + # ExternalModule1.TypeName and ExternalModule2.TypeName + namespace_subdir = + if root_decl.type.swift_extension? + ['Extensions', root_decl.module_name] + else + ['Types'] + end + + [root_decl.doc_module_name] + + namespace_subdir + + doc.namespace_ancestors.map(&:name) + end + # returns all subdirectories of specified path def self.rec_path(path) path.children.collect do |child| From 0ac8e940e422dedb60a49588528ba1d8613b7dec Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Tue, 13 Feb 2024 12:57:17 +0000 Subject: [PATCH 08/14] MM: Grouping and merging control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add —merge-modules all/extensions/none and implement merging and unmerging with custom categories and guides. User docs for this later when happy. Extend test case to demonstrate. --- lib/jazzy/config.rb | 17 +++++ lib/jazzy/sourcekitten.rb | 132 ++++++++++++++++++++------------------ 2 files changed, 87 insertions(+), 62 deletions(-) diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index d128b9d75..74754bba8 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -516,6 +516,23 @@ def hide_objc? '--min-acl is set to `public` or `open`.', default: false + MERGE_MODULES = %w[all extensions none].freeze + + config_attr :merge_modules, + command_line: "--merge-modules #{MERGE_MODULES.join(' | ')}", + description: 'Control how to display declarations from multiple ' \ + 'modules. `all`, the default, places all declarations of the ' \ + "same kind together. `none` keeps each module's declarations " \ + 'separate. `extensions` is like `none` but merges ' \ + 'cross-module extensions into their extended type.', + default: 'all', + parse: ->(merge) do + return merge.to_sym if MERGE_MODULES.include?(merge) + + raise "Unsupported merge_modules #{merge}, " \ + "supported values: #{MERGE_MODULES.join(', ')}" + end + # rubocop:enable Layout/ArgumentAlignment def initialize diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index adf8fb8eb..6fd44b723 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -60,23 +60,43 @@ def self.undocumented_abstract ).freeze end - # Group root-level docs by custom categories (if any) and type + # Group root-level docs by custom categories (if any) and type or module def self.group_docs(docs) custom_categories, docs = group_custom_categories(docs) unlisted_prefix = Config.instance.custom_categories_unlisted_prefix - type_categories, uncategorized = group_type_categories( - docs, custom_categories.any? ? unlisted_prefix : '' - ) - custom_categories + merge_categories(type_categories) + uncategorized + type_category_prefix = custom_categories.any? ? unlisted_prefix : '' + custom_categories + + if Config.instance.merge_modules == :all + group_docs_by_type(docs, type_category_prefix) + else + group_docs_by_module(docs, type_category_prefix) + end + end + + # Group root-level docs by type + def self.group_docs_by_type(docs, type_category_prefix) + type_groups = SourceDeclaration::Type.all.map do |type| + children, docs = docs.partition { |doc| doc.type == type } + make_type_group(children, type, type_category_prefix) + end + merge_categories(type_groups.compact) + docs end # Group root-level docs by module name - def self.group_docs_per_module(docs, config) - categories, extra = navigation_module_section( - docs, config.module_names - ) + def self.group_docs_by_module(docs, type_category_prefix) + guide_categories, docs = group_guides(docs, type_category_prefix) + + module_categories = docs + .group_by(&:doc_module_name) + .map do |name, module_docs| + make_group( + module_docs, + name, + "The following declarations are provided by module #{name}.", + ) + end - merge_categories(categories) + group_docs(extra) + guide_categories + module_categories end def self.group_custom_categories(docs) @@ -96,49 +116,21 @@ def self.group_custom_categories(docs) [group.compact, docs] end - def self.group_type_categories(docs, type_category_prefix) - group = SourceDeclaration::Type.all.map do |type| - children, docs = docs.partition { |doc| doc.type == type } - make_group( - children, - type_category_prefix + type.plural_name, - "The following #{type.plural_name.downcase} are available globally.", - type_category_prefix + type.plural_url_name, - ) - end - [group.compact, docs] - end - - # rubocop:disable Metrics/MethodLength XXX tmp - def self.navigation_module_section(docs, modules) - group = modules.map do |module_name| - children, docs = docs.partition { |doc| doc.module_name == module_name } - make_group( - children, - module_name, - '', - ) - end + def self.group_guides(docs, prefix) + guides, others = docs.partition { |doc| doc.type.markdown? } + return [[], others] unless guides.any? - # Get from the remaining docs if there are extensions that should also be part of this module. - group = group.compact.map do |group2| - new_docs = docs - .select { |doc| doc.children.map(&:module_name).include?(group2.name) } - .map do |doc| - newdoc = doc.clone - newdoc.children, doc.children = - doc.children.partition { _1.module_name == group2.name } - newdoc.name = group2.name + '+' + newdoc.name - newdoc.doc_module_name = group2.name # XXX not really but for now - newdoc - end - group2.children = group2.children + new_docs - group2 - end + [[make_type_group(guides, guides.first.type, prefix)], others] + end - [group, docs.reject { |doc| doc.children.empty? }] + def self.make_type_group(docs, type, type_category_prefix) + make_group( + docs, + type_category_prefix + type.plural_name, + "The following #{type.plural_name.downcase} are available globally.", + type_category_prefix + type.plural_url_name, + ) end - # rubocop:enable Metrics/MethodLength XXX tmp # Join categories with the same name (eg. ObjC and Swift classes) def self.merge_categories(categories) @@ -802,22 +794,45 @@ def self.mergeable_swift?(decl) decl.type.swift_typealias? end + # Normally merge all extensions into their types and each other. + # + # :none means only merge within a module -- so two extensions to + # some type get merged, but an extension to a type from + # another documented module does not get merged into that type + # :extensions means extensions of documented modules get merged, + # but if we're documenting ModA and ModB, and they both provide + # extensions to Swift.String, then those two extensions still + # appear separately. + # + # (The USR part of the dedup key means ModA.Foo and ModB.Foo do not + # get merged.) + def self.module_deduplication_key(decl) + if (Config.instance.merge_modules == :none) || + (Config.instance.merge_modules == :extensions && + decl.extension_of_external_type?) + decl.doc_module_name + else + '' + end + end + # Two declarations get merged if they have the same deduplication key. def self.deduplication_key(decl, root_decls) + mod_key = module_deduplication_key(decl) # Swift extension of objc class if decl.swift_objc_extension? - [decl.swift_extension_objc_name, :objc_class_and_categories] + [decl.swift_extension_objc_name, :objc_class_and_categories, mod_key] # Swift type or Swift extension of Swift type elsif mergeable_swift?(decl) - [decl.usr, decl.name] + [decl.usr, decl.name, mod_key] # Objc categories and classes elsif mergeable_objc?(decl, root_decls) # Using the ObjC name to match swift_objc_extension. name, _ = decl.objc_category_name || decl.objc_name - [name, :objc_class_and_categories] + [name, :objc_class_and_categories, mod_key] # Non-mergable declarations (funcs, typedefs etc...) else - [decl.usr, decl.name, decl.type.kind] + [decl.usr, decl.name, decl.type.kind, ''] end end @@ -1154,7 +1169,6 @@ def self.reject_objc_types(docs) # Parse sourcekitten STDOUT output as JSON # @return [Hash] structured docs - # rubocop:disable Metrics/MethodLength XXX tmp def self.parse(sourcekitten_output, options, inject_docs) @min_acl = options.min_acl @skip_undocumented = options.skip_undocumented @@ -1175,18 +1189,12 @@ def self.parse(sourcekitten_output, options, inject_docs) # than min_acl docs = docs.reject { |doc| doc.type.swift_enum_element? } ungrouped_docs = docs - docs = - if options.module_configs.count > 1 # XXX need a --merge-modules or something - group_docs_per_module(docs, options) - else - group_docs(docs) - end + docs = group_docs(ungrouped_docs) merge_consecutive_marks(docs) make_doc_urls(docs) autolink(docs, ungrouped_docs) [docs, @stats] end - # rubocop:enable Metrics/MethodLength XXX tmp end end From 624914255bb35ffaa2b8078879948aa83f4565e6 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Tue, 13 Feb 2024 13:32:23 +0000 Subject: [PATCH 09/14] MM: Refactor grouping code from sourcekitten Pure refactor. --- lib/jazzy/grouper.rb | 122 +++++++++++++++++++++++ lib/jazzy/sourcekitten.rb | 198 +++++++++++--------------------------- 2 files changed, 178 insertions(+), 142 deletions(-) create mode 100644 lib/jazzy/grouper.rb diff --git a/lib/jazzy/grouper.rb b/lib/jazzy/grouper.rb new file mode 100644 index 000000000..8c54d756f --- /dev/null +++ b/lib/jazzy/grouper.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Jazzy + # This module deals with arranging top-level declarations and guides into + # groups automatically and/or using a custom list. + module Grouper + extend Config::Mixin + + # Group root-level docs by custom categories (if any) and type or module + def self.group_docs(docs) + custom_categories, docs = group_custom_categories(docs) + unlisted_prefix = config.custom_categories_unlisted_prefix + type_category_prefix = custom_categories.any? ? unlisted_prefix : '' + all_categories = + custom_categories + + if config.merge_modules == :all + group_docs_by_type(docs, type_category_prefix) + else + group_docs_by_module(docs, type_category_prefix) + end + merge_consecutive_marks(all_categories) + end + + # Group root-level docs by type + def self.group_docs_by_type(docs, type_category_prefix) + type_groups = SourceDeclaration::Type.all.map do |type| + children, docs = docs.partition { _1.type == type } + make_type_group(children, type, type_category_prefix) + end + merge_categories(type_groups.compact) + docs + end + + # Group root-level docs by module name + def self.group_docs_by_module(docs, type_category_prefix) + guide_categories, docs = group_guides(docs, type_category_prefix) + + module_categories = docs + .group_by(&:doc_module_name) + .map do |name, module_docs| + make_group( + module_docs, + name, + "The following declarations are provided by module #{name}.", + ) + end + + guide_categories + module_categories + end + + def self.group_custom_categories(docs) + group = config.custom_categories.map do |category| + children = category['children'].flat_map do |name| + docs_with_name, docs = docs.partition { _1.name == name } + if docs_with_name.empty? + warn 'WARNING: No documented top-level declarations match ' \ + "name \"#{name}\" specified in categories file" + end + docs_with_name + end + # Category config overrides alphabetization + children.each.with_index { |child, i| child.nav_order = i } + make_group(children, category['name'], '') + end + [group.compact, docs] + end + + def self.group_guides(docs, prefix) + guides, others = docs.partition { _1.type.markdown? } + return [[], others] unless guides.any? + + [[make_type_group(guides, guides.first.type, prefix)], others] + end + + def self.make_type_group(docs, type, type_category_prefix) + make_group( + docs, + type_category_prefix + type.plural_name, + "The following #{type.plural_name.downcase} are available globally.", + type_category_prefix + type.plural_url_name, + ) + end + + # Join categories with the same name (eg. ObjC and Swift classes) + def self.merge_categories(categories) + merged = [] + categories.each do |new_category| + if existing = merged.find { _1.name == new_category.name } + existing.children += new_category.children + else + merged.append(new_category) + end + end + merged + end + + def self.make_group(group, name, abstract, url_name = nil) + group.reject! { _1.name.empty? } + unless group.empty? + SourceDeclaration.new.tap do |sd| + sd.type = SourceDeclaration::Type.overview + sd.name = name + sd.url_name = url_name + sd.abstract = Markdown.render(abstract) + sd.children = group + end + end + end + + # Merge consecutive sections with the same mark into one section + # Needed because of pulling various decls into groups + def self.merge_consecutive_marks(docs) + prev_mark = nil + docs.each do |doc| + if prev_mark&.can_merge?(doc.mark) + doc.mark = prev_mark + end + prev_mark = doc.mark + merge_consecutive_marks(doc.children) + end + end + end +end diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index 6fd44b723..550ab400b 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -13,6 +13,7 @@ require 'jazzy/source_declaration' require 'jazzy/source_mark' require 'jazzy/stats' +require 'jazzy/grouper' ELIDED_AUTOLINK_TOKEN = '36f8f5912051ae747ef441d6511ca4cb' @@ -60,115 +61,9 @@ def self.undocumented_abstract ).freeze end - # Group root-level docs by custom categories (if any) and type or module - def self.group_docs(docs) - custom_categories, docs = group_custom_categories(docs) - unlisted_prefix = Config.instance.custom_categories_unlisted_prefix - type_category_prefix = custom_categories.any? ? unlisted_prefix : '' - custom_categories + - if Config.instance.merge_modules == :all - group_docs_by_type(docs, type_category_prefix) - else - group_docs_by_module(docs, type_category_prefix) - end - end - - # Group root-level docs by type - def self.group_docs_by_type(docs, type_category_prefix) - type_groups = SourceDeclaration::Type.all.map do |type| - children, docs = docs.partition { |doc| doc.type == type } - make_type_group(children, type, type_category_prefix) - end - merge_categories(type_groups.compact) + docs - end - - # Group root-level docs by module name - def self.group_docs_by_module(docs, type_category_prefix) - guide_categories, docs = group_guides(docs, type_category_prefix) - - module_categories = docs - .group_by(&:doc_module_name) - .map do |name, module_docs| - make_group( - module_docs, - name, - "The following declarations are provided by module #{name}.", - ) - end - - guide_categories + module_categories - end - - def self.group_custom_categories(docs) - group = Config.instance.custom_categories.map do |category| - children = category['children'].flat_map do |name| - docs_with_name, docs = docs.partition { |doc| doc.name == name } - if docs_with_name.empty? - warn 'WARNING: No documented top-level declarations match ' \ - "name \"#{name}\" specified in categories file" - end - docs_with_name - end - # Category config overrides alphabetization - children.each.with_index { |child, i| child.nav_order = i } - make_group(children, category['name'], '') - end - [group.compact, docs] - end - - def self.group_guides(docs, prefix) - guides, others = docs.partition { |doc| doc.type.markdown? } - return [[], others] unless guides.any? - - [[make_type_group(guides, guides.first.type, prefix)], others] - end - - def self.make_type_group(docs, type, type_category_prefix) - make_group( - docs, - type_category_prefix + type.plural_name, - "The following #{type.plural_name.downcase} are available globally.", - type_category_prefix + type.plural_url_name, - ) - end - - # Join categories with the same name (eg. ObjC and Swift classes) - def self.merge_categories(categories) - merged = [] - categories.each do |new_category| - if existing = merged.find { |c| c.name == new_category.name } - existing.children += new_category.children - else - merged.append(new_category) - end - end - merged - end - - def self.make_group(group, name, abstract, url_name = nil) - group.reject! { |doc| doc.name.empty? } - unless group.empty? - SourceDeclaration.new.tap do |sd| - sd.type = SourceDeclaration::Type.overview - sd.name = name - sd.url_name = url_name - sd.abstract = Markdown.render(abstract) - sd.children = group - end - end - end - - # Merge consecutive sections with the same mark into one section - def self.merge_consecutive_marks(docs) - prev_mark = nil - docs.each do |doc| - if prev_mark&.can_merge?(doc.mark) - doc.mark = prev_mark - end - prev_mark = doc.mark - merge_consecutive_marks(doc.children) - end - end + # + # URL assignment + # def self.sanitize_filename(doc) unsafe_filename = doc.docs_filename @@ -261,6 +156,10 @@ def self.subdir_for_doc_multi_module(doc) doc.namespace_ancestors.map(&:name) end + # + # CLI argument calculation + # + # returns all subdirectories of specified path def self.rec_path(path) path.children.collect do |child| @@ -330,6 +229,10 @@ def self.run_sourcekitten(arguments) output end + # + # SourceDeclaration generation + # + def self.make_default_doc_info(declaration) # @todo: Fix these declaration.abstract = '' @@ -725,6 +628,10 @@ def self.find_generic_requirements(parsed_declaration) Regexp.last_match[1].gsub(/\s+/, ' ') end + # + # SourceDeclaration generation - extension management + # + # Expands extensions of nested types declared at the top level into # a tree so they can be deduplicated properly def self.expand_extensions(decls) @@ -1001,37 +908,9 @@ def self.merge_code_declaration(decls) end end - # Apply filtering based on the "included" and "excluded" flags. - def self.filter_files(json) - json = filter_included_files(json) if Config.instance.included_files.any? - json = filter_excluded_files(json) if Config.instance.excluded_files.any? - json.map do |doc| - key = doc.keys.first - doc[key] - end.compact - end - - # Filter based on the "included" flag. - def self.filter_included_files(json) - included_files = Config.instance.included_files - json.map do |doc| - key = doc.keys.first - doc if included_files.detect do |include| - File.fnmatch?(include, key) - end - end.compact - end - - # Filter based on the "excluded" flag. - def self.filter_excluded_files(json) - excluded_files = Config.instance.excluded_files - json.map do |doc| - key = doc.keys.first - doc unless excluded_files.detect do |exclude| - File.fnmatch?(exclude, key) - end - end.compact - end + # + # Autolinking + # def self.name_match(name_part, docs) return nil unless name_part @@ -1152,6 +1031,42 @@ def self.autolink_document(html, doc) autolink_text(html, doc, @autolink_root_decls || []) end + # + # Entrypoint and misc filtering + # + + # Apply filtering based on the "included" and "excluded" flags. + def self.filter_files(json) + json = filter_included_files(json) if Config.instance.included_files.any? + json = filter_excluded_files(json) if Config.instance.excluded_files.any? + json.map do |doc| + key = doc.keys.first + doc[key] + end.compact + end + + # Filter based on the "included" flag. + def self.filter_included_files(json) + included_files = Config.instance.included_files + json.map do |doc| + key = doc.keys.first + doc if included_files.detect do |include| + File.fnmatch?(include, key) + end + end.compact + end + + # Filter based on the "excluded" flag. + def self.filter_excluded_files(json) + excluded_files = Config.instance.excluded_files + json.map do |doc| + key = doc.keys.first + doc unless excluded_files.detect do |exclude| + File.fnmatch?(exclude, key) + end + end.compact + end + def self.reject_objc_types(docs) enums = docs.map do |doc| [doc, doc.children] @@ -1189,9 +1104,8 @@ def self.parse(sourcekitten_output, options, inject_docs) # than min_acl docs = docs.reject { |doc| doc.type.swift_enum_element? } ungrouped_docs = docs - docs = group_docs(ungrouped_docs) + docs = Grouper.group_docs(ungrouped_docs) - merge_consecutive_marks(docs) make_doc_urls(docs) autolink(docs, ungrouped_docs) [docs, @stats] From 30df77e444a900173e86769993c8bac4e2e097f9 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Wed, 14 Feb 2024 13:08:17 +0000 Subject: [PATCH 10/14] MM: Identify module source for extension methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include a note where necessary to show what the user has to import to get access to the declaration. Three mechanisms for showing module source, increasingly fine granularity: 1. The outer group name in the left nav/first breadcrumb. 
This is set to the module name for merge=none/extns. 2. The top-level decl name in the breadcrumbs. 
This includes the module name only when mech#1 fails. 3. A decl note in the parent page.
For when mech#1,2 fail. This commit. For example a decl added by an extension or a top-level decl with no children (eg. free function, marker protocol). The idea is that jazzy use the widest-scope mechanism possible, falling back to the more visually noisy ones only when necessary. --- lib/jazzy/source_declaration.rb | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/lib/jazzy/source_declaration.rb b/lib/jazzy/source_declaration.rb index c07fef27b..d5c8d2eb3 100644 --- a/lib/jazzy/source_declaration.rb +++ b/lib/jazzy/source_declaration.rb @@ -227,12 +227,29 @@ def ambiguous_module_name?(group_name) group_name != module_name) end + # Does the user need help understanding how to get this declaration? + def need_doc_module_note? + return false unless Config.instance.multiple_modules? + return false if docs_path.first.name == doc_module_name + + if parent_in_code.nil? + # Top-level decls with no page of their own + !render_as_page? + else + # Members added by extension + parent_in_code.module_name != doc_module_name + end + end + # Info text for contents page by collapsed item name def declaration_note - notes = [default_impl_abstract ? 'default implementation' : nil, - from_protocol_extension ? 'extension method' : nil, - async ? 'asynchronous' : nil].compact - notes.join(', ').humanize unless notes.empty? + notes = [ + default_impl_abstract ? 'default implementation' : nil, + from_protocol_extension ? 'extension method' : nil, + async ? 'asynchronous' : nil, + need_doc_module_note? ? "from #{doc_module_name}" : nil, + ].compact + notes.join(', ').upcase_first unless notes.empty? end def readme? From 50c96f89ea40e1ccf2988486b87fc1421d312422 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Fri, 16 Feb 2024 11:07:14 +0000 Subject: [PATCH 11/14] MM: Identify module source for extensions This is to resolve the issue of merging extensions that provide protocol compliance -- in this case there may be no members to annotate and show the source module. A bit messy and not entirely complete because of the way the code is structured -- really we should rework 'declaration' to be a list of stuff for the templating layer but it is already tangled up with html because of Rouge. Good enough though, covers everything, and in reality these are unusual cases. --- lib/jazzy/sourcekitten.rb | 37 +++++++++++++++---- .../themes/apple/assets/css/jazzy.css.scss | 11 +++--- .../fullwidth/assets/css/jazzy.css.scss | 10 ++--- .../themes/jony/assets/css/jazzy.css.scss | 11 +++--- 4 files changed, 47 insertions(+), 22 deletions(-) diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index 550ab400b..e7ba10483 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -895,17 +895,40 @@ def self.move_merged_extension_marks(decls) # declaration: public protocol conformances and, for top-level extensions, # further conditional extensions of the same type. def self.merge_code_declaration(decls) - first = decls.first - declarations = decls[1..].select do |decl| decl.type.swift_extension? && (decl.other_inherited_types?(@inaccessible_protocols) || - (first.type.swift_extension? && decl.constrained_extension?)) - end.map(&:declaration) - - unless declarations.empty? - first.declaration = declarations.prepend(first.declaration).uniq.join + (decls.first.type.swift_extension? && decl.constrained_extension?)) + end.prepend(decls.first) + + html_declaration = '' + until declarations.empty? + module_decls, declarations = next_doc_module_group(declarations) + first = module_decls.first + if need_doc_module_note?(first, html_declaration) + html_declaration += "From #{first.doc_module_name}:" + end + html_declaration += module_decls.map(&:declaration).uniq.join end + + # Must preserve `nil` for edge cases + decls.first.declaration = html_declaration unless html_declaration.empty? + end + + # Grab all the extensions from the same doc module + def self.next_doc_module_group(decls) + decls.partition { _1.doc_module_name == decls.first.doc_module_name } + end + + # Does this extension/type need a note explaining which doc module it is from? + # Only for extensions, if there actually are multiple modules. + # Last condition avoids it for simple 'extension Array'. + def self.need_doc_module_note?(decl, html_declaration) + Config.instance.multiple_modules? && + decl.type.swift_extension? && + !(html_declaration.empty? && + !decl.constrained_extension? && + !decl.inherited_types?) end # diff --git a/lib/jazzy/themes/apple/assets/css/jazzy.css.scss b/lib/jazzy/themes/apple/assets/css/jazzy.css.scss index d83b102e4..b12aa9f64 100644 --- a/lib/jazzy/themes/apple/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/apple/assets/css/jazzy.css.scss @@ -414,11 +414,12 @@ header { .discouraged { text-decoration: line-through; } - .declaration-note { - font-size: .85em; - color: rgba(128,128,128,1); - font-style: italic; - } +} + +.declaration-note { + font-size: .85em; + color: rgba(128,128,128,1); + font-style: italic; } .pointer-container { diff --git a/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss b/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss index c6bed2de5..ded61cec0 100644 --- a/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/fullwidth/assets/css/jazzy.css.scss @@ -454,12 +454,12 @@ pre code { margin-left: 20px; font-size: 1rem; } +} - .declaration-note { - font-size: .85em; - color: #808080; - font-style: italic; - } +.declaration-note { + font-size: .85em; + color: #808080; + font-style: italic; } .pointer-container { diff --git a/lib/jazzy/themes/jony/assets/css/jazzy.css.scss b/lib/jazzy/themes/jony/assets/css/jazzy.css.scss index 692990f19..8140d29fc 100644 --- a/lib/jazzy/themes/jony/assets/css/jazzy.css.scss +++ b/lib/jazzy/themes/jony/assets/css/jazzy.css.scss @@ -450,11 +450,12 @@ header { .token-open { margin-left: 45px; } - .declaration-note { - font-size: .85em; - color: rgba(128,128,128,1); - font-style: italic; - } +} + +.declaration-note { + font-size: .85em; + color: rgba(128,128,128,1); + font-style: italic; } .pointer-container { From 4d8dbd31f8848f75ca4cfbd764140eaf90e90454 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Mon, 19 Feb 2024 21:42:46 +0000 Subject: [PATCH 12/14] MM: Index and autolinking Add module-aware doc index tree. Reimplement autolinking to use the tree. This makes linking module-aware and deals with the ambiguities. --- lib/jazzy/doc_index.rb | 185 ++++++++++++++++++++++++++++++++ lib/jazzy/source_declaration.rb | 8 +- lib/jazzy/sourcekitten.rb | 95 ++++------------ 3 files changed, 213 insertions(+), 75 deletions(-) create mode 100644 lib/jazzy/doc_index.rb diff --git a/lib/jazzy/doc_index.rb b/lib/jazzy/doc_index.rb new file mode 100644 index 000000000..844a578e1 --- /dev/null +++ b/lib/jazzy/doc_index.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Jazzy + # This class stores an index of symbol names for doing name lookup + # when resolving custom categories and autolinks. + class DocIndex + # A node in the index tree. The root has no decl; its children are + # per-module indexed by module names. The second level, where each + # scope is a module, also has no decl; its children are scopes, one + # for each top-level decl in the module. From the third level onwards + # the decl is valid. + class Scope + attr_reader :decl # SourceDeclaration + attr_reader :children # String:Scope + + def initialize(decl, children) + @decl = decl + @children = children + end + + def self.new_root(module_decls) + new(nil, + module_decls.transform_values do |decls| + Scope.new_decl(nil, decls) + end) + end + + # Decl names in a scope are usually unique. The exceptions + # are (1) methods and (2) typealias+extension, which historically + # jazzy does not merge. The logic here and in `merge()` below + # preserves the historical ambiguity-resolution of (1) and tries + # to do the best for (2). + def self.new_decl(decl, child_decls) + child_scopes = {} + child_decls.flat_map do |child_decl| + child_scope = Scope.new_decl(child_decl, child_decl.children) + child_decl.index_names.map do |name| + if curr = child_scopes[name] + curr.merge(child_scope) + else + child_scopes[name] = child_scope + end + end + end + new(decl, child_scopes) + end + + def merge(new_scope) + return unless type = decl&.type + return unless new_type = new_scope.decl&.type + + if type.swift_typealias? && new_type.swift_extension? + @children = new_scope.children + elsif type.swift_extension? && new_type.swift_typealias? + @decl = new_scope.decl + end + end + + # Lookup of a name like `Mod.Type.method(arg:)` requires passing + # an array of name 'parts' eg. ['Mod', 'Type', 'method(arg:)']. + def lookup(parts) + return decl if parts.empty? + + children[parts.first]&.lookup(parts[1...]) + end + + # Get an array of scopes matching the name parts. + def lookup_path(parts) + [self] + + (children[parts.first]&.lookup_path(parts[1...]) || []) + end + end + + attr_reader :root_scope + + def initialize(all_decls) + @root_scope = Scope.new_root(all_decls.group_by(&:module_name)) + end + + # Look up a name and return the matching SourceDeclaration or nil. + # + # `context` is an optional SourceDeclaration indicating where the text + # was found, affects name resolution - see `lookup_context()` below. + def lookup(name, context = nil) + lookup_name = LookupName.new(name) + + return lookup_fully_qualified(lookup_name) if lookup_name.fully_qualified? + return lookup_guess(lookup_name) if context.nil? + + lookup_context(lookup_name, context) + end + + private + + # Look up a fully-qualified name, ie. it starts with the module name. + def lookup_fully_qualified(lookup_name) + root_scope.lookup(lookup_name.parts) + end + + # Look up a top-level name best-effort, searching for a module that + # has it before trying the first name-part as a module name. + def lookup_guess(lookup_name) + root_scope.children.each_value do |module_scope| + if result = module_scope.lookup(lookup_name.parts) + return result + end + end + + lookup_fully_qualified(lookup_name) + end + + # Look up a name from a declaration context, approximately how + # Swift resolves names. + # + # 1 - try and resolve with a common prefix, eg. 'B' from 'T.A' + # can match 'T.B', or 'R' from 'S.T.A' can match 'S.R'. + # 2 - try and resolve as a top-level symbol from a different module + # 3 - (affordance for docs writers) resolve as a child of the context, + # eg. 'B' from 'T.A' can match 'T.A.B' *only if* (1,2) fail. + # Currently disabled for Swift for back-compatibility. + def lookup_context(lookup_name, context) + context_scope_path = + root_scope.lookup_path(context.fully_qualified_module_name_parts) + + context_scope = context_scope_path.pop + context_scope_path.reverse.each do |scope| + if decl = scope.lookup(lookup_name.parts) + return decl + end + end + + lookup_guess(lookup_name) || + (lookup_name.objc? && context_scope.lookup(lookup_name.parts)) + end + + # Helper for name lookup, really a cache for information as we + # try various strategies. + class LookupName + attr_reader :name + + def initialize(name) + @name = name + end + + def fully_qualified? + name.start_with?('/') + end + + def objc? + name.start_with?('-', '+') + end + + def parts + @parts ||= find_parts + end + + private + + # Turn a name as written into a list of components to + # be matched. + # Swift: Strip out odd characters and split + # ObjC: Compound names look like '+[Class(Category) method:]' + # and need to become ['Class(Category)', '+method:'] + def find_parts + if name =~ /([+-])\[(\w+(?: ?\(\w+\))?) ([\w:]+)\]/ + [Regexp.last_match[2], + Regexp.last_match[1] + Regexp.last_match[3]] + else + name + .sub(%r{^[@\/]}, '') # ignore custom attribute reference, fully-qualified + .gsub(/<.*?>/, '') # remove generic parameters + .split(%r{(?)?\.')) end + def fully_qualified_module_name_parts + path = namespace_path + path.map(&:name).prepend(path.first.module_name).compact + end + # 'MyModule.OuterType.NestedType.method(arg:)' def fully_qualified_module_name - prefix = module_name&.then { _1 + '.' } || '' - prefix + fully_qualified_name + fully_qualified_module_name_parts.join('.') end # List of doc_parent decls, .last is self diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index e7ba10483..f4cd6f112 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -14,6 +14,7 @@ require 'jazzy/source_mark' require 'jazzy/stats' require 'jazzy/grouper' +require 'jazzy/doc_index' ELIDED_AUTOLINK_TOKEN = '36f8f5912051ae747ef441d6511ca4cb' @@ -690,7 +691,8 @@ def self.deduplicate_declarations(declarations) def self.mergeable_objc?(decl, root_decls) decl.type.objc_class? || (decl.type.objc_category? && - name_match(decl.objc_category_name[0], root_decls)) + (category_classname = decl.objc_category_name[0]) && + root_decls.any? { _1.name == category_classname }) end # Returns if a Swift declaration is mergeable. @@ -935,37 +937,6 @@ def self.need_doc_module_note?(decl, html_declaration) # Autolinking # - def self.name_match(name_part, docs) - return nil unless name_part - - wildcard_expansion = Regexp.escape(name_part) - .gsub('\.\.\.', '[^)]*') - .gsub(/<.*>/, '') - - whole_name_pat = /\A#{wildcard_expansion}\Z/ - docs.find do |doc| - whole_name_pat =~ doc.name - end - end - - # Find the first ancestor of doc whose name matches name_part. - def self.ancestor_name_match(name_part, doc) - doc.namespace_ancestors.reverse_each do |ancestor| - if match = name_match(name_part, ancestor.children) - return match - end - end - nil - end - - def self.name_traversal(name_parts, doc) - while doc && !name_parts.empty? - next_part = name_parts.shift - doc = name_match(next_part, doc.children) - end - doc - end - # Links recognized top-level declarations within # - inlined code within docs # - method signatures after they've been processed by the highlighter @@ -973,85 +944,61 @@ def self.name_traversal(name_parts, doc) # The `after_highlight` flag is used to differentiate between the two modes. # # DocC link format - follow Xcode and don't display slash-separated parts. - # rubocop:disable Metrics/MethodLength - def self.autolink_text(text, doc, root_decls, after_highlight: false) + def self.autolink_text(text, doc, after_highlight: false) text.autolink_block(doc.url, '[^\s]+', after_highlight) do |raw_name| sym_name = (raw_name[/^$/, 1] || raw_name).sub(/(? Date: Fri, 23 Feb 2024 12:37:21 +0000 Subject: [PATCH 13/14] MM: Module-aware custom-categories --- lib/jazzy/grouper.rb | 22 +++++++++++++++------- lib/jazzy/sourcekitten.rb | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/lib/jazzy/grouper.rb b/lib/jazzy/grouper.rb index 8c54d756f..a2c96e251 100644 --- a/lib/jazzy/grouper.rb +++ b/lib/jazzy/grouper.rb @@ -7,8 +7,8 @@ module Grouper extend Config::Mixin # Group root-level docs by custom categories (if any) and type or module - def self.group_docs(docs) - custom_categories, docs = group_custom_categories(docs) + def self.group_docs(docs, doc_index) + custom_categories, docs = group_custom_categories(docs, doc_index) unlisted_prefix = config.custom_categories_unlisted_prefix type_category_prefix = custom_categories.any? ? unlisted_prefix : '' all_categories = @@ -47,15 +47,23 @@ def self.group_docs_by_module(docs, type_category_prefix) guide_categories + module_categories end - def self.group_custom_categories(docs) + def self.group_custom_categories(docs, doc_index) group = config.custom_categories.map do |category| - children = category['children'].flat_map do |name| - docs_with_name, docs = docs.partition { _1.name == name } - if docs_with_name.empty? + children = category['children'].filter_map do |name| + unless doc = doc_index.lookup(name) warn 'WARNING: No documented top-level declarations match ' \ "name \"#{name}\" specified in categories file" + next nil end - docs_with_name + + unless doc.parent_in_code.nil? + warn "WARNING: Declaration \"#{doc.fully_qualified_module_name}\" " \ + 'specified in categories file exists but is not top-level and ' \ + 'cannot be included here' + next nil + end + + docs.delete(doc) end # Category config overrides alphabetization children.each.with_index { |child, i| child.nav_order = i } diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index f4cd6f112..43e909bda 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -1076,7 +1076,7 @@ def self.parse(sourcekitten_output, options, inject_docs) @doc_index = DocIndex.new(docs) - docs = Grouper.group_docs(docs) + docs = Grouper.group_docs(docs, @doc_index) make_doc_urls(docs) autolink(docs) From 8e7b9a97335df376da7b1dc796dc2c0d72205b74 Mon Sep 17 00:00:00 2001 From: John Fairhurst Date: Tue, 27 Feb 2024 17:49:32 +0000 Subject: [PATCH 14/14] MM: Final tidies and docs Rebase, docs, last-minute objc fixes ObjC namespaces are confused by the 'tag' and typedef rules from ye olde C so have to revert to the fuller directory structure. To make things simpler and less risky, just keep that for the Swift version too. --- CHANGELOG.md | 6 +++ README.md | 94 ++++++++++++++++++++++++++++++++++++--- lib/jazzy/config.rb | 9 ++-- lib/jazzy/doc.rb | 4 +- lib/jazzy/sourcekitten.rb | 25 +++++++++-- spec/integration_specs | 2 +- 6 files changed, 123 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64ce4943..6ad395320 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ ##### Enhancements +* Support documentation of multiple modules in a single website. Use + `--modules` or the config-file `modules` for more control. See the + README 'Documenting multiple modules' for more details. + [John Fairhurst](https://github.com/johnfairh) + [#564](https://github.com/realm/jazzy/issues/564) + * Improve page breadcrumbs to include parent pages and indicate source module of extensions from other modules. [John Fairhurst](https://github.com/johnfairh) diff --git a/README.md b/README.md index 07501391d..7444f749e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,9 @@ one you'd prefer. If this doesn't help, and you're using Xcode, then try passin extra arguments to `xcodebuild`, for example `jazzy --build-tool-arguments -scheme,MyScheme,-target,MyTarget`. +If you want to generate docs for several modules at once then see [Documenting multiple +modules](#documenting-multiple-modules). + 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`. @@ -85,6 +88,13 @@ Jazzy understands Apple's DocC-style links too, for example: * \`\`\\`\` - a link to a specific overload of `method(_:)`. Jazzy can't tell which overload you intend and links to the first one. +If your documentation is for multiple modules then symbol name resolution works +approximately as though all the modules have been imported: you can use \`TypeName\` +to refer to a top-level type in any of the modules, or \`ModuleName.TypeName\` to +be specific. If there is an ambiguity then you can use a leading slash to +indicate that the first part of the name should be read as a module name: +\`/ModuleName.TypeName\`. + ### Math Jazzy can render math equations written in LaTeX embedded in your markdown: @@ -192,7 +202,7 @@ on troubleshooting. ### Mixed Objective-C / Swift -*This feature is new and has some rough edges.* +*This feature has some rough edges.* To generate documentation for a mixed Swift and Objective-C project you must first generate two [SourceKitten][sourcekitten] files: one for Swift and one for Objective-C. @@ -219,8 +229,6 @@ jazzy --module MyProject --sourcekitten-sourcefile swiftDoc.json,objcDoc.json ### Docs from `.swiftmodule`s or frameworks -*This feature is new: there may be crashes and mistakes. Reports welcome.* - Swift 5.3 added support for symbol graph generation from `.swiftmodule` files. Jazzy can use this to generate API documentation. This is faster than using @@ -274,6 +282,78 @@ See `swift symbolgraph-extract -help` for all the things you can pass via `--build-tool-arguments`: if your module has dependencies then you may need to add various search path options to let Swift load it. +### Documenting multiple modules + +*This feature is new, bugs and feedback welcome* + +Sometimes it's useful to document multiple modules together in the same site, +for example an app and its extensions, or an SDK that happens to be implemented +as several modules. + +Jazzy can build docs for all these together and create a single site with +search, cross-module linking, and navigation. + +#### Build configuration + +If all the modules share the same build flags then the easiest way to do this +is with `--modules`, for example `jazzy --modules ModuleA,ModuleB,ModuleC`. + +If your modules have different build flags then you have to use the config file. +For example: +```yaml +modules: + - module: ModuleA + - module: ModuleB + build_tool_arguments: + - -scheme + - SpecialScheme + - -target + - ModuleB + source_directory: ModuleBProject + - module: ModuleC + objc: true + umbrella_header: ModuleC/ModuleC.h + framework_root: ModuleC + sdk: appletvsimulator + - module: ModuleD + sourcekitten_sourcefile: [ModuleD1.json, ModuleD2.json] +``` +This describes a four-module project of which one is 'normal', one requires +special Xcode treatment, one is Objective-C, and one has prebuilt SourceKitten +JSON. + +Per-module options set at the top level are inherited by each module unless +also set locally -- but you can't set both `--module` and `--modules`. + +Jazzy doesn't support `--podspec` mode in conjunction with the multiple +modules feature. + +#### Presentation + +The `--merge-modules` flag controls how declarations from multiple modules +are arranged into categories. + +The default of `all` has Jazzy combine declarations from the modules so there +is one category of classes, one of structures, and so on. To the user this means +they do not worry about which module exports a particular type, although that +information remains available in the type's page. + +Setting `--merge-modules none` changes this so each module is a top-level +category, with the module's symbols listed under it. + +Setting `--merge-modules extensions` is like `none` except cross-module +extensions are shown as part of their extended type. For example if `ModuleA` +extends `ModuleB.SomeType` then those extension members from `ModuleA` are shown +on the `ModuleB.SomeType` page along with the rest of `SomeType`. + +You can use `--documentation` to include guides, `custom_categories` to customize +the layout with types from whichever modules you want, and `--abstract` to add +additional markdown content to the per-module category pages. + +Use the `--title`, `--readme-title`, and `--docset-title` flags to control the +top-level names of your documentation. Without these, Jazzy uses the name of one +of the modules being documented. + ### Themes Three themes are provided with jazzy: `apple` (default), `fullwidth` and `jony`. @@ -303,6 +383,10 @@ There are a few limitations: - File names must be unique from source files. - Readme should be specified separately using the `readme` option. +You can link to a guide from other guides or doc comments using the name of the page +as it appears in the site. For example, to link to the guide generated from a file +called `My Guide.md` you would write \`My Guide\`. + ### Section description abstracts | Description | Command | @@ -399,7 +483,7 @@ alphabetical by symbol name and USR; the order is stable for the same input. Jazzy does not normally create separate web pages for declarations that do not have any members -- instead they are entirely nested into their parent page. Use -the `--separate-global-declarations` flag to change this and create pages for +the `--separate-global-declarations` flag to change this and create pages for these empty types. ### Choosing the Swift language version @@ -487,7 +571,7 @@ See [this document](ObjectiveC.md). **Missing docset** -Jazzy only builds a docset when you set the `--module` flag. +Jazzy only builds a docset when you set the `--module` or `--modules` flag. **Unable to pass --build-tool-arguments containing commas** diff --git a/lib/jazzy/config.rb b/lib/jazzy/config.rb index 74754bba8..42c7cf3fd 100644 --- a/lib/jazzy/config.rb +++ b/lib/jazzy/config.rb @@ -186,7 +186,7 @@ def hide_objc? config_attr :modules, command_line: ['--modules Mod1,Mod2,…ModN', Array], description: 'List of modules to document. Use the config file to set per-module ' \ - 'build flags, see xxxREADMExxx.', + "build flags, see 'Documenting multiple modules' in the README.", default: [] alias_config_attr :xcodebuild_arguments, :build_tool_arguments, @@ -649,13 +649,12 @@ def validate end if modules_configured && module_name_configured - raise 'Options `modules` and `module` are both set. See ' \ - 'XXX readme URL XXX.' + raise 'Options `modules` and `module` are both set which is not supported. ' \ + 'To document multiple modules, use just `modules`.' end if modules_configured && podspec_configured - raise 'Options `modules` and `podspec` are both set. See ' \ - 'XXX readme URL XXX.' + raise 'Options `modules` and `podspec` are both set which is not supported.' end module_configs.each(&:validate_module) diff --git a/lib/jazzy/doc.rb b/lib/jazzy/doc.rb index d5c499290..3add20c82 100644 --- a/lib/jazzy/doc.rb +++ b/lib/jazzy/doc.rb @@ -48,9 +48,9 @@ def docs_title elsif config.version_configured # Fake version for integration tests version = ENV['JAZZY_FAKE_MODULE_VERSION'] || config.version - "#{config.module_configs.first.module_name} #{version} Docs" # XXX help + "#{config.module_configs.first.module_name} #{version} Docs" else - "#{config.module_configs.first.module_name} Docs" # XXX + "#{config.module_configs.first.module_name} Docs" end end diff --git a/lib/jazzy/sourcekitten.rb b/lib/jazzy/sourcekitten.rb index 43e909bda..24e2c98b7 100644 --- a/lib/jazzy/sourcekitten.rb +++ b/lib/jazzy/sourcekitten.rb @@ -149,7 +149,7 @@ def self.subdir_for_doc_multi_module(doc) if root_decl.type.swift_extension? ['Extensions', root_decl.module_name] else - ['Types'] + [doc.namespace_path.first.type.plural_url_name] end [root_decl.doc_module_name] + @@ -1052,6 +1052,24 @@ def self.reject_objc_types(docs) end end + # Remove top-level enum cases because it means they have an ACL lower + # than min_acl + def self.reject_swift_types(docs) + docs.reject { _1.type.swift_enum_element? } + end + + # Spot and mark any categories on classes not declared in these docs + def self.mark_objc_external_categories(docs) + class_names = docs.select { _1.type.objc_class? }.to_set(&:name) + + docs.map do |doc| + if (names = doc.objc_category_name) && !class_names.include?(names.first) + doc.module_name = '(Imported)' + end + doc + end + end + # Parse sourcekitten STDOUT output as JSON # @return [Hash] structured docs def self.parse(sourcekitten_output, options, inject_docs) @@ -1070,9 +1088,8 @@ def self.parse(sourcekitten_output, options, inject_docs) docs = expand_extensions(docs) docs = deduplicate_declarations(docs) docs = reject_objc_types(docs) - # Remove top-level enum cases because it means they have an ACL lower - # than min_acl - docs = docs.reject { |doc| doc.type.swift_enum_element? } + docs = reject_swift_types(docs) + docs = mark_objc_external_categories(docs) @doc_index = DocIndex.new(docs) diff --git a/spec/integration_specs b/spec/integration_specs index 98a6d9e10..d1e725975 160000 --- a/spec/integration_specs +++ b/spec/integration_specs @@ -1 +1 @@ -Subproject commit 98a6d9e1076b5b1c30f8914f3b5f1e6cd78cda2a +Subproject commit d1e725975cf03a2fd33b60f73fadc7a89f3a7f8a