coradoc 2.0.21 → 2.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/coradoc/cli.rb +26 -1
- data/lib/coradoc/coradoc.rb +66 -8
- data/lib/coradoc/core_model/children_content.rb +5 -0
- data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
- data/lib/coradoc/core_model/has_children.rb +23 -0
- data/lib/coradoc/core_model/include.rb +43 -0
- data/lib/coradoc/core_model/include_level_offset.rb +71 -0
- data/lib/coradoc/core_model/include_options.rb +100 -0
- data/lib/coradoc/core_model/structural_element.rb +5 -0
- data/lib/coradoc/core_model.rb +4 -0
- data/lib/coradoc/errors.rb +56 -0
- data/lib/coradoc/include_resolver/filesystem.rb +84 -0
- data/lib/coradoc/include_resolver.rb +67 -0
- data/lib/coradoc/include_selectors/indent.rb +54 -0
- data/lib/coradoc/include_selectors/level_offset.rb +86 -0
- data/lib/coradoc/include_selectors/lines.rb +60 -0
- data/lib/coradoc/include_selectors/tags.rb +138 -0
- data/lib/coradoc/include_selectors.rb +26 -0
- data/lib/coradoc/resolve_includes.rb +202 -0
- data/lib/coradoc/version.rb +1 -1
- metadata +14 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Include resolver protocol.
|
|
5
|
+
#
|
|
6
|
+
# A resolver is anything that responds to +#call(target:, base_dir:,
|
|
7
|
+
# options:, context:)+ and returns the raw bytes of the included
|
|
8
|
+
# target, BEFORE tag/line/indent selectors are applied. Selectors
|
|
9
|
+
# live in the processor; the resolver only fetches bytes.
|
|
10
|
+
#
|
|
11
|
+
# The default resolver is {IncludeResolver::Filesystem}. Custom
|
|
12
|
+
# resolvers (HTTP, database, generated) plug in here without changes
|
|
13
|
+
# to the processor (OCP).
|
|
14
|
+
#
|
|
15
|
+
# Contract:
|
|
16
|
+
# call(target:, base_dir:, options:, context:) -> String
|
|
17
|
+
# target String path/URL as authored
|
|
18
|
+
# base_dir String absolute path to the including file's dir
|
|
19
|
+
# options CoreModel::IncludeOptions
|
|
20
|
+
# context Hash recursion state (depth, parent_chain, ...)
|
|
21
|
+
#
|
|
22
|
+
# Raises Coradoc::IncludeNotFoundError if the target cannot be located.
|
|
23
|
+
# The processor's missing-file policy decides what to do with that.
|
|
24
|
+
#
|
|
25
|
+
# This base class is provided for documentation and for is_a? checks.
|
|
26
|
+
# Custom resolvers do NOT need to inherit — duck typing on the call
|
|
27
|
+
# signature is sufficient. ( SPEC 13 uses a bare Object with
|
|
28
|
+
# define_singleton_method, which we support.)
|
|
29
|
+
class IncludeResolver
|
|
30
|
+
autoload :Filesystem, "#{__dir__}/include_resolver/filesystem"
|
|
31
|
+
|
|
32
|
+
def call(target:, base_dir:, options:, context:)
|
|
33
|
+
raise NotImplementedError,
|
|
34
|
+
"#{self.class} must implement #call(target:, base_dir:, options:, context:)"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# Coerce +value+ into something that quacks like an IncludeResolver.
|
|
39
|
+
# - Already-callable objects (respond to :call) are returned as-is.
|
|
40
|
+
# - Symbols are interpreted as built-in names: +:filesystem+,
|
|
41
|
+
# +:filesystem_strict+ (path-traversal protection on).
|
|
42
|
+
#
|
|
43
|
+
# @param value [Object, nil] the resolver or built-in name
|
|
44
|
+
# @param base_dir [String] required for built-in filesystem resolvers
|
|
45
|
+
# @param allow_unsafe [Boolean] opt-out of path-traversal protection
|
|
46
|
+
# @return [Object] something callable as a resolver
|
|
47
|
+
def coerce(value, base_dir:, allow_unsafe: false)
|
|
48
|
+
return Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe) if value.nil?
|
|
49
|
+
|
|
50
|
+
case value
|
|
51
|
+
when Symbol then coerce_symbol(value, base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
52
|
+
else value
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def coerce_symbol(name, base_dir:, allow_unsafe:)
|
|
59
|
+
case name
|
|
60
|
+
when :filesystem then Filesystem.new(base_dir: base_dir, allow_unsafe: allow_unsafe)
|
|
61
|
+
else
|
|
62
|
+
raise ArgumentError, "Unknown include resolver: #{name.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Indent normalization. Two modes per asciidoctor:
|
|
6
|
+
#
|
|
7
|
+
# indent=0 strip all leading whitespace from every line
|
|
8
|
+
# indent=N normalize leading whitespace to exactly N spaces
|
|
9
|
+
# nil pass through unchanged
|
|
10
|
+
module Indent
|
|
11
|
+
# @param text [String]
|
|
12
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
13
|
+
# @return [String]
|
|
14
|
+
def self.call(text, options:)
|
|
15
|
+
return text if options.indent.nil?
|
|
16
|
+
|
|
17
|
+
if options.indent.zero?
|
|
18
|
+
strip_all(text)
|
|
19
|
+
else
|
|
20
|
+
reindent(text, options.indent)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def strip_all(text)
|
|
28
|
+
text.lines.map { |line| line.sub(/\A[[:space:]]+/, '') }.join
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reindent(text, target)
|
|
32
|
+
min_indent = text.lines
|
|
33
|
+
.reject { |l| l.strip.empty? }
|
|
34
|
+
.map { |l| l.length - l.lstrip.length }
|
|
35
|
+
.min || 0
|
|
36
|
+
|
|
37
|
+
pad = ' ' * target
|
|
38
|
+
text.lines.map do |line|
|
|
39
|
+
stripped = strip_common_prefix(line, min_indent)
|
|
40
|
+
if stripped.strip.empty?
|
|
41
|
+
stripped.strip + "\n"
|
|
42
|
+
else
|
|
43
|
+
pad + stripped
|
|
44
|
+
end
|
|
45
|
+
end.join
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def strip_common_prefix(line, count)
|
|
49
|
+
line.sub(/\A[[:space:]]{0,#{count}}/, '')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Shifts section heading levels in a parsed CoreModel subtree.
|
|
6
|
+
#
|
|
7
|
+
# Applied AFTER parsing — works on SectionElement instances. Two modes:
|
|
8
|
+
#
|
|
9
|
+
# relative (+N / -N) every SectionElement#level += delta
|
|
10
|
+
# absolute (N) the FIRST section's level becomes N, and
|
|
11
|
+
# descendants shift by the same delta so their
|
|
12
|
+
# relative structure is preserved (asciidoctor
|
|
13
|
+
# behavior).
|
|
14
|
+
#
|
|
15
|
+
# The processor passes a freshly-parsed subtree to this selector, so
|
|
16
|
+
# there are no external references and in-place mutation is safe.
|
|
17
|
+
module LevelOffset
|
|
18
|
+
# @param core [Coradoc::CoreModel::Base] freshly parsed — mutated
|
|
19
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
20
|
+
# @return [Coradoc::CoreModel::Base] the same core (mutated)
|
|
21
|
+
def self.call(core, options:)
|
|
22
|
+
offset = options.leveloffset
|
|
23
|
+
return core if offset.nil?
|
|
24
|
+
|
|
25
|
+
first_level = find_first_level(core)
|
|
26
|
+
actual_delta = compute_actual_delta(offset, first_level)
|
|
27
|
+
return core if actual_delta.zero?
|
|
28
|
+
|
|
29
|
+
walk_and_shift(core, actual_delta)
|
|
30
|
+
core
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def compute_actual_delta(offset, first_level)
|
|
37
|
+
case offset.mode
|
|
38
|
+
when 'relative' then offset.delta
|
|
39
|
+
when 'absolute'
|
|
40
|
+
return 0 if first_level.nil?
|
|
41
|
+
|
|
42
|
+
offset.delta - first_level
|
|
43
|
+
else 0
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_first_level(node)
|
|
48
|
+
case node
|
|
49
|
+
when Coradoc::CoreModel::SectionElement
|
|
50
|
+
node.level || 1
|
|
51
|
+
when Coradoc::CoreModel::StructuralElement, Coradoc::CoreModel::Block
|
|
52
|
+
walk_for_first_level(node.children)
|
|
53
|
+
else
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def walk_for_first_level(children)
|
|
59
|
+
return nil if children.nil?
|
|
60
|
+
|
|
61
|
+
children.each do |child|
|
|
62
|
+
lvl = find_first_level(child)
|
|
63
|
+
return lvl unless lvl.nil?
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def walk_and_shift(node, delta)
|
|
69
|
+
case node
|
|
70
|
+
when Coradoc::CoreModel::SectionElement
|
|
71
|
+
node.level = [(node.level || 1) + delta, 0].max
|
|
72
|
+
walk_children(node, delta)
|
|
73
|
+
when Coradoc::CoreModel::StructuralElement, Coradoc::CoreModel::Block
|
|
74
|
+
walk_children(node, delta)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def walk_children(node, delta)
|
|
79
|
+
return if node.children.nil?
|
|
80
|
+
|
|
81
|
+
node.children.each { |c| walk_and_shift(c, delta) }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Line-range selection. Parses asciidoctor-style specs:
|
|
6
|
+
#
|
|
7
|
+
# N single line
|
|
8
|
+
# A..B inclusive range
|
|
9
|
+
# A..B;C;D..E multiple, semicolon-separated
|
|
10
|
+
#
|
|
11
|
+
# Out-of-bounds clamps gracefully (SPEC 3.4). One-based indexing
|
|
12
|
+
# (asciidoctor convention).
|
|
13
|
+
module Lines
|
|
14
|
+
SPEC_PART = %r{
|
|
15
|
+
\A
|
|
16
|
+
(?<start>\d+)
|
|
17
|
+
(?:\.\.(?<finish>\d+))?
|
|
18
|
+
\z
|
|
19
|
+
}x.freeze
|
|
20
|
+
|
|
21
|
+
# @param text [String]
|
|
22
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
23
|
+
# @return [String]
|
|
24
|
+
def self.call(text, options:)
|
|
25
|
+
return text unless options.lines?
|
|
26
|
+
|
|
27
|
+
ranges = parse_spec(options.lines_spec, max: text.lines.length)
|
|
28
|
+
return '' if ranges.empty?
|
|
29
|
+
|
|
30
|
+
indices = ranges.flat_map { |start, finish| (start..finish).to_a }.uniq.sort
|
|
31
|
+
text.lines.values_at(*indices.map { |i| i - 1 }).join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse_spec(spec, max:)
|
|
38
|
+
spec.split(';').map(&:strip).filter_map do |part|
|
|
39
|
+
parse_part(part, max: max)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_part(part, max:)
|
|
44
|
+
SPEC_PART.match(part) do |m|
|
|
45
|
+
start = m[:start].to_i
|
|
46
|
+
finish = m[:finish] ? m[:finish].to_i : start
|
|
47
|
+
[start, finish].minmax.map { |n| clamp(n, max: max) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clamp(n, max:)
|
|
52
|
+
return nil if n < 1
|
|
53
|
+
return max if n > max
|
|
54
|
+
|
|
55
|
+
n
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module IncludeSelectors
|
|
5
|
+
# Extracts regions delimited by +// tag::name[]+ / +// end::name[]+
|
|
6
|
+
# markers from included content. Supports single, multiple,
|
|
7
|
+
# wildcard (*), and inverted (**) selection.
|
|
8
|
+
#
|
|
9
|
+
# Tag markers themselves are never emitted as text (SPEC 2.8).
|
|
10
|
+
# Unknown tag names yield empty content (SPEC 2.6). Nested tag
|
|
11
|
+
# regions are included when an outer tag is selected (SPEC 2.7).
|
|
12
|
+
#
|
|
13
|
+
# Marker forms recognized:
|
|
14
|
+
# // tag::name[] (AsciiDoc line comment)
|
|
15
|
+
# ## tag::name[] (Markdown line comment, permissive)
|
|
16
|
+
#
|
|
17
|
+
# Markers may appear on their own line; they must be the first
|
|
18
|
+
# non-whitespace token on that line (asciidoctor convention).
|
|
19
|
+
module Tags
|
|
20
|
+
MARKER_OPEN = /\A[[:space:]]*(?:\/\/+|#+)[[:space:]]*tag::([^\[\]]+)\[[[:space:]]*\]/
|
|
21
|
+
MARKER_CLOSE = /\A[[:space:]]*(?:\/\/+|#+)[[:space:]]*end::([^\[\]]+)\[[[:space:]]*\]/
|
|
22
|
+
|
|
23
|
+
# @param text [String] raw included file content
|
|
24
|
+
# @param options [Coradoc::CoreModel::IncludeOptions]
|
|
25
|
+
# @return [String] filtered content
|
|
26
|
+
def self.call(text, options:)
|
|
27
|
+
return text unless options.tags?
|
|
28
|
+
|
|
29
|
+
if options.tags_inverted
|
|
30
|
+
inverted(text)
|
|
31
|
+
elsif options.tags_wildcard
|
|
32
|
+
wildcard(text)
|
|
33
|
+
else
|
|
34
|
+
named(text, options.tags)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def scan_markers(text)
|
|
42
|
+
markers = []
|
|
43
|
+
text.each_line.with_index do |line, idx|
|
|
44
|
+
if (m = MARKER_OPEN.match(line))
|
|
45
|
+
markers << [:open, m[1].strip, idx]
|
|
46
|
+
elsif (m = MARKER_CLOSE.match(line))
|
|
47
|
+
markers << [:close, m[1].strip, idx]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
markers
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def named(text, names)
|
|
54
|
+
wanted_indices = selected_line_indices(text, names).to_set
|
|
55
|
+
pick_lines(text, wanted_indices)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def selected_line_indices(text, wanted_names)
|
|
59
|
+
markers = scan_markers(text)
|
|
60
|
+
wanted = wanted_names.to_set
|
|
61
|
+
open_stack = []
|
|
62
|
+
emit = {}
|
|
63
|
+
|
|
64
|
+
markers.each do |kind, name, idx|
|
|
65
|
+
case kind
|
|
66
|
+
when :open
|
|
67
|
+
next unless wanted.include?(name)
|
|
68
|
+
|
|
69
|
+
open_stack.push([name, idx])
|
|
70
|
+
when :close
|
|
71
|
+
next unless wanted.include?(name)
|
|
72
|
+
|
|
73
|
+
open_idx = open_stack.rindex { |n, _| n == name }
|
|
74
|
+
next unless open_idx
|
|
75
|
+
|
|
76
|
+
_open_name, open_line = open_stack.delete_at(open_idx)
|
|
77
|
+
(open_line + 1...idx).each { |i| emit[i] = true }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
emit.keys
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def wildcard(text)
|
|
85
|
+
markers = scan_markers(text)
|
|
86
|
+
open_stack = []
|
|
87
|
+
emit = {}
|
|
88
|
+
|
|
89
|
+
markers.each do |kind, _name, idx|
|
|
90
|
+
case kind
|
|
91
|
+
when :open
|
|
92
|
+
open_stack.push(idx)
|
|
93
|
+
when :close
|
|
94
|
+
next if open_stack.empty?
|
|
95
|
+
|
|
96
|
+
open_line = open_stack.pop
|
|
97
|
+
(open_line + 1...idx).each { |i| emit[i] = true }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
pick_lines(text, emit.keys.to_set)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inverted(text)
|
|
105
|
+
markers = scan_markers(text)
|
|
106
|
+
open_stack = []
|
|
107
|
+
excluded = {}
|
|
108
|
+
|
|
109
|
+
markers.each do |kind, _name, idx|
|
|
110
|
+
case kind
|
|
111
|
+
when :open
|
|
112
|
+
open_stack.push(idx)
|
|
113
|
+
when :close
|
|
114
|
+
next if open_stack.empty?
|
|
115
|
+
|
|
116
|
+
open_line = open_stack.pop
|
|
117
|
+
(open_line..idx).each { |i| excluded[i] = true }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
markers.each { |_kind, _name, idx| excluded[idx] = true }
|
|
122
|
+
|
|
123
|
+
lines = text.lines
|
|
124
|
+
kept = lines.each_with_index.reject { |_line, idx| excluded[idx] }
|
|
125
|
+
kept.map(&:first).join
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pick_lines(text, wanted_indices)
|
|
129
|
+
lines = text.lines
|
|
130
|
+
lines.each_with_index.select { |_line, idx| wanted_indices.include?(idx) }
|
|
131
|
+
.map(&:first).join
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
require 'set'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Pure-function selectors applied to resolved include content.
|
|
5
|
+
#
|
|
6
|
+
# Each selector owns exactly one transformation (MECE):
|
|
7
|
+
# Tags // tag::X[] ... // end::X[] region extraction
|
|
8
|
+
# Lines line-range selection (1..N;2;3..4)
|
|
9
|
+
# Indent leading-whitespace normalization
|
|
10
|
+
# LevelOffset section-level shift (applied AFTER parsing)
|
|
11
|
+
#
|
|
12
|
+
# Tags, Lines, and Indent take a String and return a String.
|
|
13
|
+
# LevelOffset takes a parsed CoreModel and returns a new CoreModel.
|
|
14
|
+
#
|
|
15
|
+
# The processor orchestrates the order:
|
|
16
|
+
# 1. Tags (or Lines; Lines wins if both specified — SPEC 3.5)
|
|
17
|
+
# 2. Indent
|
|
18
|
+
# 3. parse → CoreModel
|
|
19
|
+
# 4. LevelOffset
|
|
20
|
+
module IncludeSelectors
|
|
21
|
+
autoload :Tags, "#{__dir__}/include_selectors/tags"
|
|
22
|
+
autoload :Lines, "#{__dir__}/include_selectors/lines"
|
|
23
|
+
autoload :Indent, "#{__dir__}/include_selectors/indent"
|
|
24
|
+
autoload :LevelOffset, "#{__dir__}/include_selectors/level_offset"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Flat-mode include processor — the explicit "flatten" step.
|
|
5
|
+
#
|
|
6
|
+
# Walks a parsed CoreModel and expands every {CoreModel::Include} link
|
|
7
|
+
# node into the parsed content of its target, recursing into the result.
|
|
8
|
+
# The original CoreModel is NOT modified — a new subtree is constructed
|
|
9
|
+
# and spliced into place.
|
|
10
|
+
#
|
|
11
|
+
# Invoked via the public API +Coradoc.resolve_includes(doc, base_dir:)+.
|
|
12
|
+
# Callers control resolution strategy (filesystem, HTTP, custom),
|
|
13
|
+
# missing-include policy, recursion depth, and path-traversal safety.
|
|
14
|
+
#
|
|
15
|
+
# Honors:
|
|
16
|
+
# - +missing_include+ policy: :error (default) | :warn | :silent | :passthrough
|
|
17
|
+
# - +max_depth+ limit (raises Coradoc::IncludeDepthExceededError)
|
|
18
|
+
# - circular detection (raises Coradoc::CircularIncludeError)
|
|
19
|
+
# - tags/lines/indent selectors (applied to raw text before parse)
|
|
20
|
+
# - leveloffset selector (applied to parsed CoreModel)
|
|
21
|
+
# - +base_dir+ re-rooting (recursive includes resolve relative to
|
|
22
|
+
# the including file — SPEC 7.2)
|
|
23
|
+
class ResolveIncludes
|
|
24
|
+
DEFAULT_MAX_DEPTH = 64
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def call(core, resolver:, base_dir:, **opts)
|
|
28
|
+
new(resolver: resolver, base_dir: base_dir, **opts).call(core)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param resolver [#call] anything responding to +#call(target:, base_dir:, options:, context:)+
|
|
33
|
+
# @param base_dir [String] absolute path to the document root directory
|
|
34
|
+
# @param missing_include [Symbol] :error | :warn | :silent | :passthrough
|
|
35
|
+
# @param max_depth [Integer] recursion cap
|
|
36
|
+
# @param parse_format [Symbol] format to use when re-parsing included content
|
|
37
|
+
def initialize(resolver:, base_dir:,
|
|
38
|
+
missing_include: :error,
|
|
39
|
+
max_depth: DEFAULT_MAX_DEPTH,
|
|
40
|
+
parse_format: :asciidoc)
|
|
41
|
+
@resolver = Coradoc::IncludeResolver.coerce(resolver, base_dir: base_dir)
|
|
42
|
+
@base_dir = base_dir
|
|
43
|
+
@missing_policy = missing_include
|
|
44
|
+
@max_depth = max_depth
|
|
45
|
+
@parse_format = parse_format
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Walk + transform. Returns a NEW CoreModel with includes expanded.
|
|
49
|
+
def call(core)
|
|
50
|
+
expand_node(core, base_dir: File.expand_path(@base_dir), chain: [], depth: 0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def expand_node(node, base_dir:, chain:, depth:)
|
|
56
|
+
return node unless node.is_a?(Coradoc::CoreModel::Base)
|
|
57
|
+
|
|
58
|
+
case node
|
|
59
|
+
when Coradoc::CoreModel::Include
|
|
60
|
+
expand_include(node, base_dir: base_dir, chain: chain, depth: depth)
|
|
61
|
+
when Coradoc::CoreModel::StructuralElement, Coradoc::CoreModel::Block
|
|
62
|
+
expand_container(node, base_dir: base_dir, chain: chain, depth: depth)
|
|
63
|
+
else
|
|
64
|
+
node
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expand_container(node, base_dir:, chain:, depth:)
|
|
69
|
+
return node if node.children.nil? || node.children.empty?
|
|
70
|
+
|
|
71
|
+
expanded_children = node.children.flat_map do |child|
|
|
72
|
+
expanded = expand_node(child, base_dir: base_dir, chain: chain, depth: depth)
|
|
73
|
+
Array(expanded)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return node if expanded_children.equal?(node.children) || same_children?(expanded_children, node.children)
|
|
77
|
+
|
|
78
|
+
duplicate_with_children(node, expanded_children)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The processor must not mutate its input. Each container that has
|
|
82
|
+
# expanded includes is replaced by a shallow copy with a new
|
|
83
|
+
# +children+ array — the original document tree stays intact so the
|
|
84
|
+
# caller can re-resolve with different options.
|
|
85
|
+
def duplicate_with_children(node, new_children)
|
|
86
|
+
duplicate = node.dup
|
|
87
|
+
duplicate.children = new_children
|
|
88
|
+
duplicate
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def same_children?(expanded, original)
|
|
92
|
+
return false unless expanded.length == original.length
|
|
93
|
+
|
|
94
|
+
expanded.each_with_index.all? { |node, i| node.equal?(original[i]) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def expand_include(include_node, base_dir:, chain:, depth:)
|
|
98
|
+
enforce_depth!(include_node, depth)
|
|
99
|
+
enforce_cycle!(include_node, base_dir: base_dir, chain: chain)
|
|
100
|
+
|
|
101
|
+
target = include_node.target
|
|
102
|
+
new_chain = chain + [resolve_target_path(target, base_dir)]
|
|
103
|
+
|
|
104
|
+
content = fetch_content(include_node, base_dir: base_dir)
|
|
105
|
+
return replacement_for_missing(include_node) if missing_content?(content)
|
|
106
|
+
|
|
107
|
+
applied = apply_text_selectors(content, include_node.options)
|
|
108
|
+
parsed = parse_included(applied)
|
|
109
|
+
|
|
110
|
+
shifted = Coradoc::IncludeSelectors::LevelOffset.call(parsed, options: include_node.options)
|
|
111
|
+
|
|
112
|
+
new_base_dir = File.dirname(resolve_target_path(target, base_dir))
|
|
113
|
+
expand_subtree(shifted, base_dir: new_base_dir, chain: new_chain, depth: depth + 1)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def missing_content?(content)
|
|
117
|
+
content.nil? || content == :passthrough
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fetch_content(include_node, base_dir:)
|
|
121
|
+
@resolver.call(
|
|
122
|
+
target: include_node.target,
|
|
123
|
+
base_dir: base_dir,
|
|
124
|
+
options: include_node.options,
|
|
125
|
+
context: {}
|
|
126
|
+
)
|
|
127
|
+
rescue Coradoc::IncludeNotFoundError => e
|
|
128
|
+
handle_missing(include_node, e)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def handle_missing(include_node, error)
|
|
132
|
+
case @missing_policy
|
|
133
|
+
when :error then raise error
|
|
134
|
+
when :warn
|
|
135
|
+
Coradoc::Logger.warn("Include target not found: #{include_node.target}")
|
|
136
|
+
nil
|
|
137
|
+
when :silent then nil
|
|
138
|
+
when :passthrough then :passthrough
|
|
139
|
+
else raise error
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def replacement_for_missing(include_node)
|
|
144
|
+
return [include_node] if @missing_policy == :passthrough
|
|
145
|
+
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def apply_text_selectors(text, options)
|
|
150
|
+
text = apply_lines_or_tags(text, options)
|
|
151
|
+
Coradoc::IncludeSelectors::Indent.call(text, options: options)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def apply_lines_or_tags(text, options)
|
|
155
|
+
# lines wins when both specified (SPEC 3.5)
|
|
156
|
+
return Coradoc::IncludeSelectors::Lines.call(text, options: options) if options.lines?
|
|
157
|
+
|
|
158
|
+
Coradoc::IncludeSelectors::Tags.call(text, options: options)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_included(text)
|
|
162
|
+
return empty_core if text.nil? || text.empty?
|
|
163
|
+
|
|
164
|
+
Coradoc.parse(text, format: @parse_format)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def empty_core
|
|
168
|
+
Coradoc::CoreModel::DocumentElement.new
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def expand_subtree(core, base_dir:, chain:, depth:)
|
|
172
|
+
expanded = expand_node(core, base_dir: base_dir, chain: chain, depth: depth)
|
|
173
|
+
return [expanded] unless expanded.is_a?(Coradoc::CoreModel::StructuralElement)
|
|
174
|
+
|
|
175
|
+
expanded.children || []
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def enforce_depth!(include_node, depth)
|
|
179
|
+
return if depth < @max_depth
|
|
180
|
+
|
|
181
|
+
raise Coradoc::IncludeDepthExceededError.new(
|
|
182
|
+
target: include_node.target,
|
|
183
|
+
depth: depth,
|
|
184
|
+
max: @max_depth
|
|
185
|
+
)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def enforce_cycle!(include_node, base_dir:, chain:)
|
|
189
|
+
full = resolve_target_path(include_node.target, base_dir)
|
|
190
|
+
return unless chain.include?(full)
|
|
191
|
+
|
|
192
|
+
raise Coradoc::CircularIncludeError.new(
|
|
193
|
+
target: include_node.target,
|
|
194
|
+
chain: chain
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def resolve_target_path(target, base_dir)
|
|
199
|
+
File.expand_path(target, base_dir)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/coradoc/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: coradoc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.0.
|
|
4
|
+
version: 2.0.22
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -73,11 +73,16 @@ files:
|
|
|
73
73
|
- lib/coradoc/core_model/frontmatter.rb
|
|
74
74
|
- lib/coradoc/core_model/frontmatter/codec.rb
|
|
75
75
|
- lib/coradoc/core_model/frontmatter/field_transform.rb
|
|
76
|
+
- lib/coradoc/core_model/frontmatter/frontmatter_value.rb
|
|
76
77
|
- lib/coradoc/core_model/frontmatter/schema_resolver.rb
|
|
77
78
|
- lib/coradoc/core_model/frontmatter/text_splitter.rb
|
|
79
|
+
- lib/coradoc/core_model/has_children.rb
|
|
78
80
|
- lib/coradoc/core_model/horizontal_rule_block.rb
|
|
79
81
|
- lib/coradoc/core_model/id_generator.rb
|
|
80
82
|
- lib/coradoc/core_model/image.rb
|
|
83
|
+
- lib/coradoc/core_model/include.rb
|
|
84
|
+
- lib/coradoc/core_model/include_level_offset.rb
|
|
85
|
+
- lib/coradoc/core_model/include_options.rb
|
|
81
86
|
- lib/coradoc/core_model/inline_element.rb
|
|
82
87
|
- lib/coradoc/core_model/list_block.rb
|
|
83
88
|
- lib/coradoc/core_model/list_item.rb
|
|
@@ -104,6 +109,13 @@ files:
|
|
|
104
109
|
- lib/coradoc/errors.rb
|
|
105
110
|
- lib/coradoc/format_module.rb
|
|
106
111
|
- lib/coradoc/hooks.rb
|
|
112
|
+
- lib/coradoc/include_resolver.rb
|
|
113
|
+
- lib/coradoc/include_resolver/filesystem.rb
|
|
114
|
+
- lib/coradoc/include_selectors.rb
|
|
115
|
+
- lib/coradoc/include_selectors/indent.rb
|
|
116
|
+
- lib/coradoc/include_selectors/level_offset.rb
|
|
117
|
+
- lib/coradoc/include_selectors/lines.rb
|
|
118
|
+
- lib/coradoc/include_selectors/tags.rb
|
|
107
119
|
- lib/coradoc/input.rb
|
|
108
120
|
- lib/coradoc/logger.rb
|
|
109
121
|
- lib/coradoc/output.rb
|
|
@@ -111,6 +123,7 @@ files:
|
|
|
111
123
|
- lib/coradoc/processor_registry.rb
|
|
112
124
|
- lib/coradoc/query.rb
|
|
113
125
|
- lib/coradoc/registry.rb
|
|
126
|
+
- lib/coradoc/resolve_includes.rb
|
|
114
127
|
- lib/coradoc/serializer/registry.rb
|
|
115
128
|
- lib/coradoc/transform.rb
|
|
116
129
|
- lib/coradoc/transform/base.rb
|