coradoc 2.0.21 → 2.0.23
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 +93 -8
- data/lib/coradoc/core_model/base.rb +39 -0
- data/lib/coradoc/core_model/children_content.rb +5 -0
- data/lib/coradoc/core_model/comment_block.rb +4 -0
- data/lib/coradoc/core_model/comment_line.rb +4 -0
- data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
- data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
- data/lib/coradoc/core_model/frontmatter.rb +4 -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/inline_element.rb +20 -0
- data/lib/coradoc/core_model/list_block.rb +17 -0
- data/lib/coradoc/core_model/list_item.rb +21 -0
- data/lib/coradoc/core_model/output_artifact.rb +48 -0
- data/lib/coradoc/core_model/paragraph_block.rb +4 -0
- data/lib/coradoc/core_model/stem_block.rb +21 -0
- data/lib/coradoc/core_model/structural_element.rb +46 -0
- data/lib/coradoc/core_model/text_content.rb +4 -0
- data/lib/coradoc/core_model.rb +6 -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/link_rewriter/identity.rb +13 -0
- data/lib/coradoc/link_rewriter/visitor.rb +157 -0
- data/lib/coradoc/link_rewriter.rb +37 -0
- data/lib/coradoc/relative_path.rb +32 -0
- data/lib/coradoc/resolve_includes.rb +202 -0
- data/lib/coradoc/version.rb +1 -1
- data/lib/coradoc.rb +2 -0
- metadata +20 -1
|
@@ -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,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module LinkRewriter
|
|
5
|
+
# Default no-op rewriter. Returns every target unchanged. Used when
|
|
6
|
+
# the caller only wants the visitor's immutable-copy guarantee.
|
|
7
|
+
class Identity
|
|
8
|
+
def call(target:, **)
|
|
9
|
+
target
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module LinkRewriter
|
|
5
|
+
# Focused rewriting visitor for the CoreModel tree.
|
|
6
|
+
#
|
|
7
|
+
# The set of node types that carry link/xref targets is closed and
|
|
8
|
+
# small: +LinkElement+, +CrossReferenceElement+, plus generic
|
|
9
|
+
# +InlineElement+ instances whose +format_type+ is +'link'+ or
|
|
10
|
+
# +'xref'+. That classification is owned polymorphically by
|
|
11
|
+
# +InlineElement#link_kind+ — the visitor just calls it. Adding a
|
|
12
|
+
# new link-bearing subclass means overriding +link_kind+ on it,
|
|
13
|
+
# not editing a case/when here (OCP).
|
|
14
|
+
#
|
|
15
|
+
# Verbatim block types are also closed: +SourceBlock+, +ListingBlock+,
|
|
16
|
+
# +LiteralBlock+, +PassBlock+, +StemBlock+. The visitor returns them
|
|
17
|
+
# unchanged so the rewriter never sees link-shaped text that is, in
|
|
18
|
+
# fact, raw code/math.
|
|
19
|
+
#
|
|
20
|
+
# Dispatch is class-based (no +respond_to?+ duck-typing). Unrecognized
|
|
21
|
+
# classes are returned unchanged — the visitor is closed by design.
|
|
22
|
+
class Visitor
|
|
23
|
+
# Verbatim block classes — content is raw, no link semantics.
|
|
24
|
+
VERBATIM_TYPES = [
|
|
25
|
+
Coradoc::CoreModel::SourceBlock,
|
|
26
|
+
Coradoc::CoreModel::ListingBlock,
|
|
27
|
+
Coradoc::CoreModel::LiteralBlock,
|
|
28
|
+
Coradoc::CoreModel::PassBlock,
|
|
29
|
+
Coradoc::CoreModel::StemBlock
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Structural/container classes that own a child collection. Each
|
|
33
|
+
# entry maps the class to the reader method that exposes its
|
|
34
|
+
# children. MECE — every "recurse into the children" case lands
|
|
35
|
+
# in this table. Exposed as a public constant so spec helpers
|
|
36
|
+
# and other visitors can mirror the same paths without restating
|
|
37
|
+
# the dispatch (DRY).
|
|
38
|
+
CONTAINER_TYPES = {
|
|
39
|
+
Coradoc::CoreModel::DocumentElement => :children,
|
|
40
|
+
Coradoc::CoreModel::SectionElement => :children,
|
|
41
|
+
Coradoc::CoreModel::PreambleElement => :children,
|
|
42
|
+
Coradoc::CoreModel::HeaderElement => :children,
|
|
43
|
+
Coradoc::CoreModel::Block => :children,
|
|
44
|
+
Coradoc::CoreModel::ListBlock => :items,
|
|
45
|
+
Coradoc::CoreModel::ListItem => :children,
|
|
46
|
+
Coradoc::CoreModel::Table => :rows,
|
|
47
|
+
Coradoc::CoreModel::TableRow => :cells,
|
|
48
|
+
Coradoc::CoreModel::TableCell => :children,
|
|
49
|
+
Coradoc::CoreModel::DefinitionList => :items,
|
|
50
|
+
Coradoc::CoreModel::Toc => :entries,
|
|
51
|
+
Coradoc::CoreModel::Bibliography => :entries,
|
|
52
|
+
Coradoc::CoreModel::AnnotationBlock => :children
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
def initialize(rewriter)
|
|
56
|
+
@rewriter = rewriter
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Entry point. Always returns a NEW root node — even Identity
|
|
60
|
+
# callers can rely on object identity to confirm the rewrite ran.
|
|
61
|
+
def visit_document(document)
|
|
62
|
+
return document unless document.is_a?(Coradoc::CoreModel::Base)
|
|
63
|
+
|
|
64
|
+
result = visit_subtree(document)
|
|
65
|
+
result.equal?(document) ? document.dup : result
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def visit_subtree(node)
|
|
71
|
+
return node if VERBATIM_TYPES.any? { |type| node.is_a?(type) }
|
|
72
|
+
return rewrite_inline(node) if node.is_a?(Coradoc::CoreModel::InlineElement)
|
|
73
|
+
|
|
74
|
+
reader = reader_for(node)
|
|
75
|
+
return node unless reader
|
|
76
|
+
|
|
77
|
+
rewrite_collection(node, reader)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Look up the children-reader method for +node+. Returns nil for
|
|
81
|
+
# unrecognized classes (no duck-typing — the CONTAINER_TYPES
|
|
82
|
+
# table is the single source of truth).
|
|
83
|
+
def reader_for(node)
|
|
84
|
+
CONTAINER_TYPES.each do |klass, reader|
|
|
85
|
+
return reader if node.is_a?(klass)
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def rewrite_collection(node, attr_name)
|
|
91
|
+
original = node.public_send(attr_name)
|
|
92
|
+
return node if original.nil? || original.empty?
|
|
93
|
+
|
|
94
|
+
rewritten = original.map { |child| visit_subtree(child) }
|
|
95
|
+
return node if unchanged?(rewritten, original)
|
|
96
|
+
|
|
97
|
+
rebuild_with(node, attr_name => rewritten)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Inline dispatch. Typed LinkElement / CrossReferenceElement are
|
|
101
|
+
# always candidates; generic InlineElement instances must declare a
|
|
102
|
+
# matching format_type. Other typed subclasses (Bold, Italic, …)
|
|
103
|
+
# are walked for nested inlines instead of being rewritten.
|
|
104
|
+
def rewrite_inline(inline)
|
|
105
|
+
kind = inline.link_kind
|
|
106
|
+
|
|
107
|
+
rewritten_target = rewrite_target(inline, kind)
|
|
108
|
+
rewritten_nested = rewrite_nested(inline)
|
|
109
|
+
|
|
110
|
+
return inline if rewritten_target.nil? && rewritten_nested.nil?
|
|
111
|
+
|
|
112
|
+
overrides = {}
|
|
113
|
+
overrides[:target] = rewritten_target unless rewritten_target.nil?
|
|
114
|
+
overrides[:nested_elements] = rewritten_nested unless rewritten_nested.nil?
|
|
115
|
+
rebuild_with(inline, overrides)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def rewrite_target(inline, kind)
|
|
119
|
+
return nil unless kind
|
|
120
|
+
|
|
121
|
+
target = inline.target
|
|
122
|
+
return nil if target.nil? || target.empty?
|
|
123
|
+
|
|
124
|
+
new_target = @rewriter.call(
|
|
125
|
+
target: target,
|
|
126
|
+
kind: kind,
|
|
127
|
+
context: { in_verbatim: false }
|
|
128
|
+
)
|
|
129
|
+
return nil if new_target == target
|
|
130
|
+
|
|
131
|
+
new_target
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def rewrite_nested(inline)
|
|
135
|
+
nested = inline.nested_elements
|
|
136
|
+
return nil if nested.nil? || nested.empty?
|
|
137
|
+
|
|
138
|
+
rewritten = nested.map { |child| visit_subtree(child) }
|
|
139
|
+
return nil if unchanged?(rewritten, nested)
|
|
140
|
+
|
|
141
|
+
rewritten
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def rebuild_with(node, overrides)
|
|
145
|
+
duplicate = node.dup
|
|
146
|
+
overrides.each { |key, value| duplicate.public_send("#{key}=", value) }
|
|
147
|
+
duplicate
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def unchanged?(rewritten, original)
|
|
151
|
+
return false unless rewritten.length == original.length
|
|
152
|
+
|
|
153
|
+
rewritten.each_with_index.all? { |node, i| node.equal?(original[i]) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Post-parse link/xref rewriting.
|
|
5
|
+
#
|
|
6
|
+
# Consumers that need to canonicalize link and xref targets (snake→kebab,
|
|
7
|
+
# strip +.adoc+, redirect maps, dialect translation) get a single
|
|
8
|
+
# immutable entry point: +Coradoc.rewrite_links(doc, rewriter:, &)+.
|
|
9
|
+
# The visitor walks the parsed CoreModel, invokes the supplied rewriter
|
|
10
|
+
# for every link/xref target, and returns a NEW document. Verbatim
|
|
11
|
+
# blocks (source, listing, literal, pass, stem) are skipped entirely —
|
|
12
|
+
# coradoc owns the parse and guarantees those bodies never reach the
|
|
13
|
+
# rewriter, removing the "track parser state to avoid verbatim bodies"
|
|
14
|
+
# footgun that plagues regex-based rewriting.
|
|
15
|
+
#
|
|
16
|
+
# Two-step API mirrors +Coradoc.resolve_includes+: parse produces the
|
|
17
|
+
# document, rewrite is a separate explicit step the caller controls.
|
|
18
|
+
module LinkRewriter
|
|
19
|
+
autoload :Identity, "#{__dir__}/link_rewriter/identity"
|
|
20
|
+
autoload :Visitor, "#{__dir__}/link_rewriter/visitor"
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Rewrite every link/xref target in +doc+.
|
|
24
|
+
#
|
|
25
|
+
# +rewriter+ responds to +#call(target:, kind:, context:)+ and returns
|
|
26
|
+
# the new target String. If a block is given it is used as the
|
|
27
|
+
# rewriter. Omitting both falls back to {Identity} (no-op) — useful
|
|
28
|
+
# for "give me a structurally identical copy" cases.
|
|
29
|
+
#
|
|
30
|
+
# Returns a NEW document; the input is never mutated.
|
|
31
|
+
def rewrite(doc, rewriter: nil, &block)
|
|
32
|
+
callable = rewriter || block || Identity.new
|
|
33
|
+
Visitor.new(callable).visit_document(doc)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
# Pure-function module for relative-path arithmetic across output keys.
|
|
5
|
+
#
|
|
6
|
+
# Every SSG wrapper (VitePress, Hugo, Astro) needs to compute "how many
|
|
7
|
+
# directories up do I walk to reach the site root from this output?".
|
|
8
|
+
# The answer is the segment count of the source output_key. Everything
|
|
9
|
+
# else (template, imports) is host-system-specific; this module owns
|
|
10
|
+
# only the one piece of arithmetic that is genuinely shared.
|
|
11
|
+
#
|
|
12
|
+
# No state. No class. No knowledge of any specific SSG.
|
|
13
|
+
#
|
|
14
|
+
# @example Compute a VitePress import path
|
|
15
|
+
# Coradoc::RelativePath.from("author/iso/ref/foo", to: ".vitepress/theme")
|
|
16
|
+
# # => "../../../.vitepress/theme"
|
|
17
|
+
module RelativePath
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Compute a relative path from an output_key to a site-root-relative
|
|
21
|
+
# target.
|
|
22
|
+
#
|
|
23
|
+
# @param output_key [String, nil] site-relative key for the source
|
|
24
|
+
# page (e.g. "author/iso/ref/foo"). No leading slash, no extension.
|
|
25
|
+
# @param to [String] destination path relative to the site root.
|
|
26
|
+
# @return [String] the composed relative path.
|
|
27
|
+
def from(output_key, to:)
|
|
28
|
+
depth = output_key.to_s.count('/')
|
|
29
|
+
('../' * depth) + to.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|