summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/asciidoctor/interdoc_reftext.rb6
-rw-r--r--lib/asciidoctor/interdoc_reftext/inline_node_mixin.rb38
-rw-r--r--lib/asciidoctor/interdoc_reftext/processor.rb78
-rw-r--r--lib/asciidoctor/interdoc_reftext/resolver.rb116
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