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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +26 -1
  3. data/lib/coradoc/coradoc.rb +93 -8
  4. data/lib/coradoc/core_model/base.rb +39 -0
  5. data/lib/coradoc/core_model/children_content.rb +5 -0
  6. data/lib/coradoc/core_model/comment_block.rb +4 -0
  7. data/lib/coradoc/core_model/comment_line.rb +4 -0
  8. data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
  9. data/lib/coradoc/core_model/frontmatter/frontmatter_value.rb +61 -0
  10. data/lib/coradoc/core_model/frontmatter.rb +4 -0
  11. data/lib/coradoc/core_model/has_children.rb +23 -0
  12. data/lib/coradoc/core_model/include.rb +43 -0
  13. data/lib/coradoc/core_model/include_level_offset.rb +71 -0
  14. data/lib/coradoc/core_model/include_options.rb +100 -0
  15. data/lib/coradoc/core_model/inline_element.rb +20 -0
  16. data/lib/coradoc/core_model/list_block.rb +17 -0
  17. data/lib/coradoc/core_model/list_item.rb +21 -0
  18. data/lib/coradoc/core_model/output_artifact.rb +48 -0
  19. data/lib/coradoc/core_model/paragraph_block.rb +4 -0
  20. data/lib/coradoc/core_model/stem_block.rb +21 -0
  21. data/lib/coradoc/core_model/structural_element.rb +46 -0
  22. data/lib/coradoc/core_model/text_content.rb +4 -0
  23. data/lib/coradoc/core_model.rb +6 -0
  24. data/lib/coradoc/errors.rb +56 -0
  25. data/lib/coradoc/include_resolver/filesystem.rb +84 -0
  26. data/lib/coradoc/include_resolver.rb +67 -0
  27. data/lib/coradoc/include_selectors/indent.rb +54 -0
  28. data/lib/coradoc/include_selectors/level_offset.rb +86 -0
  29. data/lib/coradoc/include_selectors/lines.rb +60 -0
  30. data/lib/coradoc/include_selectors/tags.rb +138 -0
  31. data/lib/coradoc/include_selectors.rb +26 -0
  32. data/lib/coradoc/link_rewriter/identity.rb +13 -0
  33. data/lib/coradoc/link_rewriter/visitor.rb +157 -0
  34. data/lib/coradoc/link_rewriter.rb +37 -0
  35. data/lib/coradoc/relative_path.rb +32 -0
  36. data/lib/coradoc/resolve_includes.rb +202 -0
  37. data/lib/coradoc/version.rb +1 -1
  38. data/lib/coradoc.rb +2 -0
  39. 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