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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
|
# frozen_string_literal: true
require 'asciidoctor' unless RUBY_PLATFORM == 'opal'
require 'logger' unless RUBY_PLATFORM == 'opal'
require 'asciidoctor/interdoc_reftext/version'
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, nil] the logger to use for logging warning and errors.
# Defaults to `Asciidoctor::LoggerManager.logger` if using Asciidoctor 1.5.7+,
# or `Logger.new(STDERR)` otherwise.
# @param raise_exceptions [Boolean] whether to raise exceptions, or just log them.
def initialize(document,
asciidoc_exts: ['.adoc', '.asciidoc', '.ad'],
logger: nil,
raise_exceptions: true)
logger ||= if defined? ::Asciidoctor::LoggerManager
::Asciidoctor::LoggerManager.logger
elsif defined? ::Logger
::Logger.new(STDERR)
else
# Fake logger for Opal.
# TODO: Remove after update to Asciidoctor 1.5.7 or Opal with Logger.
Object.new.tap do |o|
# rubocop: disable MethodMissing
def o.method_missing(_, *args)
STDERR.puts(*args)
end
end
end
@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
read_file(path) do |lines|
parse_reftext(lines, fragment)
end
rescue => e # rubocop: disable RescueWithoutErrorClass
raise if @raise_exceptions
@logger.error "interdoc-reftext: #{e}"
nil
end
end
alias call resolve_reftext
protected
# @return [Array<String>] AsciiDoc file extensions (e.g. `.adoc`).
attr_reader :asciidoc_exts
# @return [Hash<String, String>] a cache of resolved reftexts.
attr_reader :cache
# @return [Asciidoctor::Document] the document associated with this resolver.
attr_reader :document
# @return [Logger] the logger to use for logging warning and errors.
attr_reader :logger
# @return [Boolean] whether to raise exceptions, or just log them.
attr_reader :raise_exceptions
# @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.
# @yield [Enumerable<String>] gives lines of the file.
def read_file(path)
::File.open(path) do |f|
yield f.each_line
end
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
|