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  |