1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
|
# frozen_string_literal: true
require 'asciidoctor' unless RUBY_PLATFORM == 'opal'
require 'asciidoctor/extensions' unless RUBY_PLATFORM == 'opal'
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}.
#
# _\* If running under Opal (JavaScript), {InlineNodeMixin} is not prepended
# into the `Asciidoctor::Inline`, because Opal does not support that. Thus
# it's included and the `#text` method is overriden using poor alias method
# chain approach.
#
# 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
if RUBY_PLATFORM == 'opal'
# Opal does not support `Module#prepend`, so we have to fallback to
# `include` with poor alias method chain approach.
::Asciidoctor::Inline.send(:include, InlineNodeMixin)
else
::Asciidoctor::Inline.send(:prepend, InlineNodeMixin)
end
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
|