diff options
-rw-r--r-- | lib/asciidoctor/interdoc_reftext.rb | 6 | ||||
-rw-r--r-- | lib/asciidoctor/interdoc_reftext/inline_node_mixin.rb | 38 | ||||
-rw-r--r-- | lib/asciidoctor/interdoc_reftext/processor.rb | 78 | ||||
-rw-r--r-- | lib/asciidoctor/interdoc_reftext/resolver.rb | 116 |
4 files changed, 238 insertions, 0 deletions
diff --git a/lib/asciidoctor/interdoc_reftext.rb b/lib/asciidoctor/interdoc_reftext.rb index 190a41f..430f377 100644 --- a/lib/asciidoctor/interdoc_reftext.rb +++ b/lib/asciidoctor/interdoc_reftext.rb @@ -1,2 +1,8 @@ # frozen_string_literal: true +require 'asciidoctor/extensions' require 'asciidoctor/interdoc_reftext/version' +require 'asciidoctor/interdoc_reftext/processor' + +Asciidoctor::Extensions.register do + treeprocessor Asciidoctor::InterdocReftext::Processor +end diff --git a/lib/asciidoctor/interdoc_reftext/inline_node_mixin.rb b/lib/asciidoctor/interdoc_reftext/inline_node_mixin.rb new file mode 100644 index 0000000..f82b7a3 --- /dev/null +++ b/lib/asciidoctor/interdoc_reftext/inline_node_mixin.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +require 'asciidoctor/interdoc_reftext/version' +require 'asciidoctor' + +module Asciidoctor::InterdocReftext + # Mixin intended to be prepended into `Asciidoctor::Inline`. + # + # It modifies the method `#text` to resolve the value via {Resolver} if it's + # not set, this node is an *inline_anchor* and has attribute *path* (i.e. + # represents an inter-document cross reference). + module InlineNodeMixin + + # Returns text of this inline element. + # + # @note This method will override the same name attribute reader in + # class `Asciidoctor::Inline`. + # + # @return [String, nil] + def text + if (value = super) + value + # If this node is an inter-document cross reference... + elsif @node_name == 'inline_anchor' && @attributes['path'] + resolver = interdoc_reftext_resolver + @text = resolver.call(@attributes['refid']) if resolver + end + end + + private + + # @return [#call, nil] an inter-document reftext resolver, or nil if not + # set for the document. + def interdoc_reftext_resolver + # This variable is injected into the document by {Processor}. + @document.instance_variable_get(Processor::RESOLVER_VAR_NAME) + end + end +end diff --git a/lib/asciidoctor/interdoc_reftext/processor.rb b/lib/asciidoctor/interdoc_reftext/processor.rb new file mode 100644 index 0000000..55ee1ae --- /dev/null +++ b/lib/asciidoctor/interdoc_reftext/processor.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'asciidoctor/extensions' +require 'asciidoctor/interdoc_reftext/inline_node_mixin' +require 'asciidoctor/interdoc_reftext/resolver' +require 'asciidoctor/interdoc_reftext/version' + +module Asciidoctor::InterdocReftext + # Asciidoctor processor that adds support for automatic cross-reference text + # for inter-document cross references. + # + # ### Implementation Considerations + # + # Asciidoctor does not allow to _cleanly_ change the way of resolving + # xreftext for `xref:path#[]` macro with path and without explicit xreflabel; + # it always uses path as the default xreflabel. + # + # 1. `xref:[]` macros are parsed and even converted in + # `Asciidoctor::Substitutors#sub_inline_xrefs` - a single, huge and nasty + # method that accepts a text (e.g. whole paragraph) and returns the text + # with converted `xref:[]` macros. The conversion is delegated to + # `Asciidoctor::Inline#convert` - for each macro a new instance of + # `Inline` node is created and then `#convert` is called. + # + # 2. `Inline#convert` just calls `converter.convert` with `self`, i.e. it's + # dispatched to converter's `inline_anchor` handler. + # + # 3. The built-in so called HTML5 converter looks into the catalog of + # references (`document.catalog[:refs]`) for reflabel for the xref's + # *refid*, but only if xref node does not define attribute *path* or + # *text* (explicit reflabel). If *text* is not set and *path* is set, i.e. + # it's an inter-document reference without explicit reflabel, catalog of + # references is bypassed and *path* is used as a reflabel. + # + # Eh, this is really nasty... The least evil way how to achieve the goal + # seems to be monkey-patching of the `Asciidoctor::Inline` class. This is + # done via {InlineNodeMixin} which is prepended into the `Inline` class on + # initialization of this processor. + # + # The actual logic that resolves reflabel for the given *refid* is + # implemented in class {Resolver}. The {Processor} is responsible for + # creating an instance of {Resolver} for the processed document and injecting + # it into instance variable {RESOLVER_VAR_NAME} in the document, so + # {InlineNodeMixin} can access it. + # + # Prepending {InlineNodeMixin} into the `Asciidoctor::Inline` class has + # (obviously) a global effect. However, if {RESOLVER_VAR_NAME} is not + # injected in the document object (e.g. extension is not active), `Inline` + # behaves the same as without {InlineNodeMixin}. + # + # NOTE: We use _reftext_ and _reflabel_ as interchangeable terms in this gem. + class Processor < ::Asciidoctor::Extensions::TreeProcessor + + # Name of instance variable that is dynamically defined in a document + # object; it contains an instance of the Resolver for the document. + RESOLVER_VAR_NAME = :@_interdoc_reftext_resolver + + # @param resolver_class [#new] the {Resolver} class to use. + # @param resolver_opts [Hash<Symbol, Object>] options to be passed into + # the resolver_class's initializer (see {Resolver#initialize}). + def initialize(resolver_class: Resolver, **resolver_opts) + super + @resolver_class = resolver_class + @resolver_opts = resolver_opts + + # Monkey-patch Asciidoctor::Inline unless already patched. + unless ::Asciidoctor::Inline.include? InlineNodeMixin + ::Asciidoctor::Inline.send(:prepend, InlineNodeMixin) + end + end + + # @param document [Asciidoctor::Document] the document to process. + def process(document) + resolver = @resolver_class.new(document, @resolver_opts) + document.instance_variable_set(RESOLVER_VAR_NAME, resolver) + nil + end + end +end diff --git a/lib/asciidoctor/interdoc_reftext/resolver.rb b/lib/asciidoctor/interdoc_reftext/resolver.rb new file mode 100644 index 0000000..bf0201d --- /dev/null +++ b/lib/asciidoctor/interdoc_reftext/resolver.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +require 'asciidoctor/interdoc_reftext/version' +require 'asciidoctor' +require 'logger' + +module Asciidoctor::InterdocReftext + # Resolver of inter-document cross reference texts. + class Resolver + + # @param document [Asciidoctor::Document] the document associated with this resolver. + # @param asciidoc_exts [Array<String>] AsciiDoc file extensions (e.g. `.adoc`). + # @param logger [Logger] the logger to use for logging warning and errors. + # @param raise_exceptions [Boolean] whether to raise exceptions, or just log them. + def initialize(document, + asciidoc_exts: ['.adoc', '.asciidoc', '.ad'], + logger: ::Logger.new(STDERR), + raise_exceptions: true) + + @document = document + @asciidoc_exts = asciidoc_exts.dup.freeze + @logger = logger + @raise_exceptions = raise_exceptions + @cache = {} + end + + # @param refid [String] the target without a file extension, optionally with + # a fragment (e.g. `intro`, `intro#about`). + # @return [String, nil] reference text, or `nil` if not found. + # @raise ArgumentError if the *refid* is empty or starts with `#` and + # *raise_exceptions* is true. + def resolve_reftext(refid) + if refid.empty? || refid.start_with?('#') + msg = "interdoc-reftext: refid must not be empty or start with '#', but given: '#{refid}'" + raise ArgumentError, msg if @raise_exceptions + @logger.error msg + return nil + end + + path, fragment = refid.split('#', 2) + path = resolve_target_path(path) or return nil + + @cache["#{path}##{fragment}".freeze] ||= begin + lines = read_file(path) or return nil + parse_reftext(lines, fragment) + rescue => e # rubocop: disable RescueWithoutErrorClass + raise if @raise_exceptions + @logger.error "interdoc-reftext: #{e}" + nil + end + end + + alias call resolve_reftext + + protected + + # @return [Hash<String, String>] a cache of resolved reftexts. + attr_reader :cache + + # @param target_path [String] the target path without a file extension. + # @return [String, nil] file path of the *target_path*, or `nil` if not found. + def resolve_target_path(target_path) + # Include file is resolved relative to dir of the current include, + # or base_dir if within original docfile. + path = @document.normalize_system_path(target_path, @document.reader.dir, + nil, target_name: 'xref target') + return nil unless path + + @asciidoc_exts.each do |extname| + filename = path + extname + return filename if ::File.file? filename + end + nil + end + + # @param path [String] path of the file to read. + # @return [Enumerable<String>] lines of the file. + def read_file(path) + ::IO.foreach(path) + end + + # @param input [Enumerable<String>] lines of the AsciiDoc document. + # @param fragment [String, nil] part of the target after `#`. + # @return [String, nil] + def parse_reftext(input, fragment = nil) + unless fragment + # Document title is typically defined at top of the document, + # so we try to parse just the first 10 lines to save resources. + # If document title is not here, we fallback to parsing whole document. + title = asciidoc_load(input.take(10)).doctitle + return title if title + end + + doc = asciidoc_load(input) + + if fragment + ref = doc.catalog[:refs][fragment] + ref.xreftext if ref + else + doc.doctitle + end + end + + # @param input [Enumerable<String>, String] lines of the AsciiDoc document to load. + # @return [Asciidoctor::Document] a parsed document. + def asciidoc_load(input) + # Asciidoctor is dumb. It doesn't know enumerators and when we give it + # an Array, it calls #dup on it. At least it knows #readlines, so we just + # define it as an alias for #to_a. + if input.is_a?(::Enumerable) && !input.respond_to?(:readlines) + input.singleton_class.send(:alias_method, :readlines, :to_a) + end + + ::Asciidoctor.load(input, @document.options) + end + end +end |