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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module CoreModel
5
+ # Typed options for an include directive, parsed once at construction.
6
+ #
7
+ # Each asciidoctor selector (tags / lines / leveloffset / indent /
8
+ # encoding) gets one typed attribute. Selectors downstream operate on
9
+ # the typed form, never re-parsing the raw string (DRY).
10
+ #
11
+ # tags Array<String> ["body"], [] when unspecified
12
+ # tags_wildcard Boolean true for tags=*
13
+ # tags_inverted Boolean true for tags=**
14
+ # lines_spec String? raw "1..2;5;7..8" — parsed by Lines selector
15
+ # leveloffset IncludeLevelOffset?
16
+ # indent Integer? 0 = strip, N = re-indent, nil = passthrough
17
+ # file_encoding String? passed through to resolver for File.read
18
+ class IncludeOptions < Base
19
+ attribute :tags, :string, collection: true, default: -> { [] }
20
+ attribute :tags_wildcard, :boolean, default: -> { false }
21
+ attribute :tags_inverted, :boolean, default: -> { false }
22
+ attribute :lines_spec, :string
23
+ attribute :leveloffset, Coradoc::CoreModel::IncludeLevelOffset
24
+ attribute :indent, :integer
25
+ attribute :file_encoding, :string
26
+
27
+ # Whether the lines selector is in effect. Tags are ignored when
28
+ # lines is set — matches asciidoctor precedence (SPEC 3.5).
29
+ def lines?
30
+ !lines_spec.nil? && !lines_spec.strip.empty?
31
+ end
32
+
33
+ # Whether any tag selector is in effect.
34
+ def tags?
35
+ tags_wildcard || tags_inverted || !tags.empty?
36
+ end
37
+
38
+ # True when both selectors were specified (lines wins).
39
+ def conflict_resolved_to_lines?
40
+ lines? && tags?
41
+ end
42
+
43
+ # Build from a flat hash of asciidoctor-style key/value strings.
44
+ # Whitespace around keys and values is trimmed (SPEC 6.3).
45
+ #
46
+ # @param attrs [Hash{String=>String}] e.g. {"tags"=>"a;b", "leveloffset"=>"+2"}
47
+ # @return [IncludeOptions]
48
+ def self.from_hash(attrs)
49
+ new(build_args(attrs))
50
+ end
51
+
52
+ class << self
53
+ private
54
+
55
+ def build_args(attrs)
56
+ cleaned = clean_keys(attrs)
57
+ {
58
+ tags: parse_tags(cleaned['tags']),
59
+ tags_wildcard: wildcard?(cleaned['tags']),
60
+ tags_inverted: inverted?(cleaned['tags']),
61
+ lines_spec: cleaned['lines'],
62
+ leveloffset: CoreModel::IncludeLevelOffset.parse(cleaned['leveloffset']),
63
+ indent: parse_integer(cleaned['indent']),
64
+ file_encoding: cleaned['encoding']
65
+ }
66
+ end
67
+
68
+ def clean_keys(attrs)
69
+ attrs.each_with_object({}) do |(k, v), h|
70
+ h[k.to_s.strip] = v.to_s.strip
71
+ end
72
+ end
73
+
74
+ def parse_tags(raw)
75
+ return [] if raw.nil?
76
+ trimmed = raw.strip
77
+ return [] if trimmed.empty? || trimmed == '*' || trimmed == '**'
78
+
79
+ trimmed.split(';').map(&:strip).reject(&:empty?)
80
+ end
81
+
82
+ def wildcard?(raw)
83
+ !raw.nil? && raw.strip == '*'
84
+ end
85
+
86
+ def inverted?(raw)
87
+ !raw.nil? && raw.strip == '**'
88
+ end
89
+
90
+ def parse_integer(raw)
91
+ return nil if raw.nil? || raw.strip.empty?
92
+
93
+ Integer(raw.strip)
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -24,6 +24,18 @@ module Coradoc
24
24
  self.class.format_type || format_type
25
25
  end
26
26
 
27
+ # Polymorphic classification used by LinkRewriter::Visitor. Returns
28
+ # :link / :xref when this node carries a rewrite-able target, nil
29
+ # otherwise. Generic InlineElement instances defer to their
30
+ # resolved format_type; typed subclasses override with a literal.
31
+ # Keeps the visitor free of class-keyed case/when (OCP).
32
+ def link_kind
33
+ case resolve_format_type
34
+ when 'link' then :link
35
+ when 'xref' then :xref
36
+ end
37
+ end
38
+
27
39
  FORMAT_TYPES = %w[
28
40
  bold italic monospace underline strikethrough
29
41
  subscript superscript highlight
@@ -99,12 +111,20 @@ module Coradoc
99
111
  def self.format_type
100
112
  'link'
101
113
  end
114
+
115
+ def link_kind
116
+ :link
117
+ end
102
118
  end
103
119
 
104
120
  class CrossReferenceElement < InlineElement
105
121
  def self.format_type
106
122
  'xref'
107
123
  end
124
+
125
+ def link_kind
126
+ :xref
127
+ end
108
128
  end
109
129
 
110
130
  class StemElement < InlineElement
@@ -108,6 +108,23 @@ module Coradoc
108
108
  # @return [Array<ListItem>] collection of list items
109
109
  attribute :items, ListItem, collection: true
110
110
 
111
+ # -- Fluent construction helpers (paired with Base.build) --
112
+
113
+ # Append a new ListItem, built via ListItem.build. The block
114
+ # (if given) is yielded the new item so callers can chain
115
+ # +add_text+ / +add_link+ on it inline:
116
+ #
117
+ # ListBlock.build do |ul|
118
+ # children.each { |c| ul.add_item { |li| li.add_link(c[:slug], text: c[:title]) } }
119
+ # end
120
+ #
121
+ # Returns self for chaining at the list level.
122
+ def add_item(marker: self.marker_type == 'ordered' ? '.' : '*')
123
+ item = ListItem.build(marker: marker) { |li| yield li if block_given? }
124
+ self.items = Array(items) + [item]
125
+ self
126
+ end
127
+
111
128
  private
112
129
 
113
130
  # Attributes to compare for semantic equivalence
@@ -109,6 +109,27 @@ module Coradoc
109
109
  true
110
110
  end
111
111
 
112
+ # -- Fluent construction helpers (paired with Base.build) --
113
+
114
+ # Append a plain-text inline to this item's children. Returns
115
+ # self for chaining. Pairs naturally with ListItem.build:
116
+ #
117
+ # ListItem.build do |li|
118
+ # li.add_text("See ")
119
+ # li.add_link("foo.adoc", text: "Foo")
120
+ # end
121
+ def add_text(text)
122
+ self.children = Array(children) + [TextContent.new(text: text)]
123
+ self
124
+ end
125
+
126
+ # Append a +link:+ inline to this item's children. +text+ becomes
127
+ # the link's visible label; +target+ is the URL/anchor.
128
+ def add_link(target, text: nil)
129
+ self.children = Array(children) + [LinkElement.new(target: target, content: text)]
130
+ self
131
+ end
132
+
112
133
  private
113
134
 
114
135
  # Compare two list blocks for equivalence
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module CoreModel
5
+ # Output-side state object for host-system emitters (VitePress, Hugo,
6
+ # Astro, plain ERB, etc.).
7
+ #
8
+ # coradoc's source side has +IncludeResolver::Filesystem+ to resolve
9
+ # include targets without coupling to a storage layer. The output side
10
+ # has no analogous state object — until now. +OutputArtifact+ captures
11
+ # the three pieces of state coradoc genuinely needs to hand to a
12
+ # downstream emitter:
13
+ #
14
+ # - +output_key+ — site-relative key (e.g. "author/iso/ref/foo")
15
+ # - +frontmatter_block+ — parsed YAML frontmatter (may be empty)
16
+ # - +core_document+ — the canonical CoreModel document
17
+ #
18
+ # The consumer takes these and renders whatever wrapper it needs in
19
+ # its host system's native template language. coradoc does not know
20
+ # about VitePress, ERB, or Liquid. Symmetric with the source side:
21
+ # minimal protocol object, not an engine.
22
+ #
23
+ # A mirror-tree document is deliberately NOT bundled here. coradoc
24
+ # core has no runtime dependency on coradoc-mirror; consumers that
25
+ # target the mirror JSON pipeline pair an +OutputArtifact+ with a
26
+ # separately-computed +Coradoc::Mirror.transform(core)+ result.
27
+ class OutputArtifact < Base
28
+ # @!attribute output_key
29
+ # @return [String, nil] site-relative key with no leading slash
30
+ # and no trailing extension. SSGs map this to their URL space.
31
+ attribute :output_key, :string
32
+
33
+ # @!attribute frontmatter_block
34
+ # @return [FrontmatterBlock, nil] parsed YAML frontmatter
35
+ attribute :frontmatter_block, FrontmatterBlock
36
+
37
+ # @!attribute core_document
38
+ # @return [DocumentElement, nil] canonical CoreModel document
39
+ attribute :core_document, DocumentElement
40
+
41
+ private
42
+
43
+ def comparable_attributes
44
+ %i[output_key frontmatter_block core_document]
45
+ end
46
+ end
47
+ end
48
+ end
@@ -7,6 +7,10 @@ module Coradoc
7
7
  def self.semantic_type
8
8
  :paragraph
9
9
  end
10
+
11
+ def whitespace_only?
12
+ flat_text.strip.empty?
13
+ end
10
14
  end
11
15
  end
12
16
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module CoreModel
5
+ # STEM block — mathematical/scientific content authored in LaTeX,
6
+ # AsciiMath, or another STEM markup. Carries a +language+ attribute so
7
+ # downstream renderers know which interpreter to invoke.
8
+ #
9
+ # AsciiDoc surface forms:
10
+ # [stem]\n++++\nx^2\n++++ # language: "latex" (default)
11
+ # [latexmath]\n++++\nx^2\n++++ # language: "latex"
12
+ # [asciimath]\n++++\nx^2\n++++ # language: "asciimath"
13
+ class StemBlock < Block
14
+ attribute :language, :string, default: -> { 'latex' }
15
+
16
+ def self.semantic_type
17
+ :stem
18
+ end
19
+ end
20
+ end
21
+ end
@@ -11,6 +11,11 @@ module Coradoc
11
11
  # Structural elements can contain other elements (blocks, lists, etc.)
12
12
  # and can be nested hierarchically to represent document structure.
13
13
  class StructuralElement < Base
14
+ # StructuralElements carry typed block children (sections, paragraphs,
15
+ # etc.) rather than mixed inline content, so they don't include
16
+ # ChildrenContent. HasChildren marks the structural predicate that
17
+ # downstream traversal dispatches on (OCP).
18
+ include HasChildren
14
19
  # @!attribute level
15
20
  # @return [Integer, nil] hierarchical level (1-6 for sections)
16
21
  attribute :level, :integer
@@ -47,6 +52,38 @@ module Coradoc
47
52
  false
48
53
  end
49
54
 
55
+ # Override in subclasses that carry document-title semantics.
56
+ # HeaderElement at level 0 represents the document title (`= Title`
57
+ # in AsciiDoc). Consumers that walk the body — TOC builders,
58
+ # section numbering — skip these so the title is not counted as
59
+ # "section 1". Polymorphic dispatch (vs. an is_a? guard at the
60
+ # call site) keeps the predicate open for future subclasses.
61
+ def document_title?
62
+ false
63
+ end
64
+
65
+ # Children that count as body content and aren't whitespace-only.
66
+ # Derived from per-node {#body_content?} and {#whitespace_only?}
67
+ # predicates — no central walker, no is_a? switch to maintain.
68
+ def visible_children
69
+ Array(children).select(&:body_content?).reject(&:whitespace_only?)
70
+ end
71
+
72
+ # True when the body has no visible content anywhere in its subtree.
73
+ # A document with only frontmatter + comments returns true; a
74
+ # document with one non-whitespace paragraph returns false.
75
+ def empty_body?
76
+ return true if children.nil? || children.empty?
77
+
78
+ children.all? do |child|
79
+ next true unless child.body_content?
80
+ next true if child.whitespace_only?
81
+ next child.empty_body? if child.is_a?(StructuralElement)
82
+
83
+ false
84
+ end
85
+ end
86
+
50
87
  # Derived element_type string for backward compatibility with
51
88
  # templates and legacy consumers. Subclasses override this.
52
89
  def element_type
@@ -103,6 +140,15 @@ module Coradoc
103
140
  true
104
141
  end
105
142
 
143
+ # A level-0 HeaderElement represents the document title (the `= Title`
144
+ # line in AsciiDoc, the `<h1>` in HTML). It is structurally part of
145
+ # the body but semantically the document's title, not a section —
146
+ # section numbering, TOC builders, and other section-aware logic
147
+ # skip these so the title is not counted as "section 1".
148
+ def document_title?
149
+ level.to_i.zero?
150
+ end
151
+
106
152
  def self.element_type_name
107
153
  'header'
108
154
  end
@@ -17,6 +17,10 @@ module Coradoc
17
17
  def to_s
18
18
  text.to_s
19
19
  end
20
+
21
+ def whitespace_only?
22
+ text.to_s.strip.empty?
23
+ end
20
24
  end
21
25
  end
22
26
  end
@@ -12,6 +12,7 @@ module Coradoc
12
12
  # Autoload submodules lazily using relative paths
13
13
  autoload :Base, "#{__dir__}/core_model/base"
14
14
  autoload :ChildrenContent, "#{__dir__}/core_model/children_content"
15
+ autoload :HasChildren, "#{__dir__}/core_model/has_children"
15
16
  autoload :Callout, "#{__dir__}/core_model/callout"
16
17
  autoload :CalloutText, "#{__dir__}/core_model/callout_text"
17
18
  autoload :Block, "#{__dir__}/core_model/block"
@@ -69,6 +70,7 @@ module Coradoc
69
70
  autoload :SidebarBlock, "#{__dir__}/core_model/sidebar_block"
70
71
  autoload :LiteralBlock, "#{__dir__}/core_model/literal_block"
71
72
  autoload :PassBlock, "#{__dir__}/core_model/pass_block"
73
+ autoload :StemBlock, "#{__dir__}/core_model/stem_block"
72
74
  autoload :ListingBlock, "#{__dir__}/core_model/listing_block"
73
75
  autoload :OpenBlock, "#{__dir__}/core_model/open_block"
74
76
  autoload :VerseBlock, "#{__dir__}/core_model/verse_block"
@@ -78,5 +80,9 @@ module Coradoc
78
80
  autoload :CommentLine, "#{__dir__}/core_model/comment_line"
79
81
  autoload :HorizontalRuleBlock, "#{__dir__}/core_model/horizontal_rule_block"
80
82
  autoload :IdGenerator, "#{__dir__}/core_model/id_generator"
83
+ autoload :Include, "#{__dir__}/core_model/include"
84
+ autoload :IncludeOptions, "#{__dir__}/core_model/include_options"
85
+ autoload :IncludeLevelOffset, "#{__dir__}/core_model/include_level_offset"
86
+ autoload :OutputArtifact, "#{__dir__}/core_model/output_artifact"
81
87
  end
82
88
  end
@@ -283,6 +283,62 @@ module Coradoc
283
283
  end
284
284
  end
285
285
 
286
+ # Error raised when an include directive's target cannot be located.
287
+ # Honors the +missing_include+ policy: +:error+ raises this; +:warn+,
288
+ # +:silent+, and +:passthrough+ swallow it.
289
+ class IncludeNotFoundError < Error
290
+ attr_reader :target
291
+
292
+ def initialize(target)
293
+ @target = target
294
+ super("Include target not found: #{target}")
295
+ end
296
+ end
297
+
298
+ # Error raised when an include chain exceeds the configured depth limit.
299
+ class IncludeDepthExceededError < Error
300
+ attr_reader :depth, :target
301
+
302
+ def initialize(target:, depth:, max:)
303
+ @target = target
304
+ @depth = depth
305
+ super("Include depth #{depth} exceeds max #{max} at #{target}")
306
+ end
307
+ end
308
+
309
+ # Error raised when a cycle is detected in the include graph.
310
+ # The chain is the list of files leading back to the repeated target.
311
+ class CircularIncludeError < Error
312
+ attr_reader :chain, :target
313
+
314
+ def initialize(target:, chain:)
315
+ @target = target
316
+ @chain = chain
317
+ super("Circular include detected: #{chain.join(' -> ')} -> #{target}")
318
+ end
319
+ end
320
+
321
+ # Error raised when an include target escapes the resolver's safe base
322
+ # directory and +allow_unsafe_includes+ is not set.
323
+ class UnsafeIncludeError < Error
324
+ attr_reader :target
325
+
326
+ def initialize(target)
327
+ @target = target
328
+ super("Unsafe include path blocked: #{target}")
329
+ end
330
+ end
331
+
332
+ # Error raised when an include target exceeds the resolver's size limit.
333
+ class IncludeTooLargeError < Error
334
+ attr_reader :target
335
+
336
+ def initialize(target)
337
+ @target = target
338
+ super("Include target too large to read: #{target}")
339
+ end
340
+ end
341
+
286
342
  # Error raised when a requested format is not supported
287
343
  #
288
344
  # @example
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Coradoc
6
+ class IncludeResolver
7
+ # Default include resolver: reads files from the local filesystem,
8
+ # rooted at +base_dir+. Path-traversal protection is ON by default
9
+ # to match asciidoctor's +:safe+ mode.
10
+ #
11
+ # Pass +allow_unsafe: true+ to opt out (matches +:unsafe+ mode).
12
+ class Filesystem < IncludeResolver
13
+ attr_reader :base_dir, :allow_unsafe, :max_bytes
14
+
15
+ # @param base_dir [String] absolute path to the directory includes
16
+ # are resolved against. Usually the directory of the including
17
+ # document. Relative paths inside the resolver are expanded
18
+ # against this.
19
+ # @param allow_unsafe [Boolean] when false (default), refuses any
20
+ # resolved path that escapes +base_dir+ via .. or that is an
21
+ # absolute path outside +base_dir+.
22
+ # @param max_bytes [Integer, nil] if set, refuses files larger
23
+ # than this. Defense against accidental megabyte-include loops.
24
+ def initialize(base_dir:, allow_unsafe: false, max_bytes: nil)
25
+ @base_dir = File.expand_path(base_dir)
26
+ @allow_unsafe = allow_unsafe
27
+ @max_bytes = max_bytes
28
+ end
29
+
30
+ def call(target:, base_dir:, options:, context:)
31
+ full = File.expand_path(target, base_dir)
32
+ enforce_safety!(full, base_dir) unless allow_unsafe
33
+ raise Coradoc::IncludeNotFoundError, target unless File.file?(full)
34
+
35
+ enforce_size!(full, target)
36
+
37
+ encoding = options&.file_encoding || 'utf-8'
38
+ read_with_encoding(full, encoding)
39
+ end
40
+
41
+ private
42
+
43
+ def enforce_safety!(full_path, base_dir)
44
+ base_expanded = File.expand_path(base_dir)
45
+ base_with_sep = "#{base_expanded}#{File::SEPARATOR}"
46
+
47
+ return if full_path == base_expanded || full_path.start_with?(base_with_sep)
48
+
49
+ raise Coradoc::UnsafeIncludeError, full_path
50
+ end
51
+
52
+ def enforce_size!(full_path, target)
53
+ return unless max_bytes
54
+ return unless File.exist?(full_path)
55
+
56
+ size = File.size(full_path)
57
+ return if size <= max_bytes
58
+
59
+ raise Coradoc::IncludeTooLargeError, target
60
+ end
61
+
62
+ def read_with_encoding(full_path, encoding_name)
63
+ content = File.binread(full_path)
64
+ return content if encoding_name.to_s.downcase == 'binary'
65
+
66
+ content.force_encoding(clean_encoding_name(encoding_name))
67
+ encoded = content.encode('utf-8', invalid: :replace, undef: :replace)
68
+ normalize_line_endings(encoded)
69
+ rescue ArgumentError => e
70
+ raise Coradoc::Error, "Unsupported encoding #{encoding_name.inspect}: #{e.message}"
71
+ end
72
+
73
+ # asciidoctor parity: normalize CRLF and lone CR to LF so the parser
74
+ # sees consistent line endings regardless of the source platform.
75
+ def normalize_line_endings(text)
76
+ text.gsub(/\r\n?/, "\n")
77
+ end
78
+
79
+ def clean_encoding_name(name)
80
+ name.to_s.downcase.sub(/^utf-8$/, 'utf-8')
81
+ end
82
+ end
83
+ end
84
+ end
@@ -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