coradoc 2.0.22 → 2.0.24

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/coradoc.rb +35 -402
  3. data/lib/coradoc/core_model/base.rb +39 -0
  4. data/lib/coradoc/core_model/comment_block.rb +4 -0
  5. data/lib/coradoc/core_model/comment_line.rb +4 -0
  6. data/lib/coradoc/core_model/frontmatter/codec.rb +66 -19
  7. data/lib/coradoc/core_model/frontmatter.rb +4 -0
  8. data/lib/coradoc/core_model/inline_content.rb +77 -0
  9. data/lib/coradoc/core_model/inline_element.rb +52 -26
  10. data/lib/coradoc/core_model/list_block.rb +17 -0
  11. data/lib/coradoc/core_model/list_item.rb +21 -0
  12. data/lib/coradoc/core_model/output_artifact.rb +48 -0
  13. data/lib/coradoc/core_model/paragraph_block.rb +4 -0
  14. data/lib/coradoc/core_model/stem_block.rb +21 -0
  15. data/lib/coradoc/core_model/structural_element.rb +41 -0
  16. data/lib/coradoc/core_model/text_content.rb +4 -0
  17. data/lib/coradoc/core_model.rb +3 -0
  18. data/lib/coradoc/dispatch.rb +95 -0
  19. data/lib/coradoc/format_catalog.rb +83 -0
  20. data/lib/coradoc/introspection/element_counter.rb +48 -0
  21. data/lib/coradoc/introspection.rb +72 -0
  22. data/lib/coradoc/link_rewriter/identity.rb +13 -0
  23. data/lib/coradoc/link_rewriter/visitor.rb +157 -0
  24. data/lib/coradoc/link_rewriter.rb +37 -0
  25. data/lib/coradoc/pipeline.rb +108 -0
  26. data/lib/coradoc/relative_path.rb +32 -0
  27. data/lib/coradoc/version.rb +1 -1
  28. data/lib/coradoc.rb +7 -13
  29. metadata +13 -9
  30. data/lib/coradoc/document_builder.rb +0 -184
  31. data/lib/coradoc/document_manipulator.rb +0 -203
  32. data/lib/coradoc/input.rb +0 -22
  33. data/lib/coradoc/output.rb +0 -22
  34. data/lib/coradoc/processor_registry.rb +0 -50
  35. data/lib/coradoc/serializer/registry.rb +0 -150
  36. data/lib/coradoc/transform/base.rb +0 -21
  37. data/lib/coradoc/transform.rb +0 -7
@@ -10,42 +10,89 @@ module Coradoc
10
10
  # No other code in any gem may call YAML directly for frontmatter.
11
11
  # This isolates permitted-classes configuration and error handling
12
12
  # in one MECE location (DRY).
13
+ #
14
+ # The Codec emits flat YAML — values rendered with their natural
15
+ # YAML type. This is what Jekyll, Hugo, VitePress, VuePress, 11ty
16
+ # and every SSG expects: +title: Foo+ / +date: 2024-01-01+.
17
+ # Round-trip fidelity for typed values (Date, Time, Symbol) is
18
+ # preserved by Psych's permitted-classes mechanism, not by a
19
+ # custom discriminator scheme.
20
+ #
21
+ # For the typed-tree representation used by the coradoc-mirror JSON
22
+ # pipeline, see +Coradoc::Mirror::Node::FrontmatterValue+ and
23
+ # +Coradoc::Mirror::Handlers::Frontmatter+. The typed-tree concern
24
+ # lives in the mirror gem; this Codec stays focused on YAML.
13
25
  module Codec
14
26
  PERMITTED_CLASSES = [Date, Time, DateTime, Symbol].freeze
15
27
 
16
28
  class << self
17
- # Parse a YAML string into a FrontmatterBlock.
18
- # Returns an empty FrontmatterBlock on malformed YAML (graceful
19
- # degradation body parsing continues).
29
+ # Parse YAML text into a FrontmatterBlock. Returns an empty
30
+ # FrontmatterBlock on malformed YAML or non-Hash payload.
31
+ # Logs a warning so the conversion pipeline can surface the
32
+ # skip rather than silently dropping user-authored content.
20
33
  def from_yaml(yaml_text)
21
34
  return FrontmatterBlock.new if yaml_text.nil? || yaml_text.strip.empty?
22
35
 
23
- parsed = YAML.safe_load(
24
- yaml_text,
25
- permitted_classes: PERMITTED_CLASSES,
26
- aliases: true
27
- )
28
- return FrontmatterBlock.new unless parsed.is_a?(Hash)
29
-
30
- schema = parsed['$schema']
31
- data = parsed.except('$schema')
32
- FrontmatterBlock.new(schema: schema&.to_s, data: data)
33
- rescue YAML::SyntaxError, Psych::DisallowedClass
36
+ build_from_loaded(load_yaml(yaml_text))
37
+ rescue YAML::SyntaxError, Psych::DisallowedClass => e
38
+ Coradoc::Logger.warn("frontmatter parse failed: #{e.message}")
34
39
  FrontmatterBlock.new
35
40
  end
36
41
 
42
+ # Build a FrontmatterBlock from a Ruby hash with native-typed
43
+ # values (String, Integer, Date, …). Returns an empty block
44
+ # for non-Hash input.
45
+ def from_hash(hash)
46
+ return FrontmatterBlock.new unless hash.is_a?(Hash)
47
+
48
+ build_from_loaded(hash)
49
+ end
50
+
37
51
  # Serialize a FrontmatterBlock to canonical YAML text.
38
- # Does NOT include leading/trailing `---` delimiters; the caller
39
- # wraps the output.
52
+ # Does NOT include leading/trailing +---+ delimiters; the
53
+ # caller wraps the output. Returns +''+ for empty blocks.
40
54
  def to_yaml(block)
41
55
  return '' unless block.is_a?(FrontmatterBlock)
42
56
 
57
+ payload = flat_tree(block)
58
+ return '' if payload.empty?
59
+
60
+ YAML.dump(payload).delete_prefix("---\n").delete_suffix("\n...")
61
+ end
62
+
63
+ # Return the frontmatter as a native-typed Ruby hash.
64
+ # +$schema+ is included when present.
65
+ def to_hash(block)
66
+ return {} unless block.is_a?(FrontmatterBlock)
67
+
68
+ flat_tree(block)
69
+ end
70
+
71
+ private
72
+
73
+ def load_yaml(yaml_text)
74
+ YAML.safe_load(
75
+ yaml_text,
76
+ permitted_classes: PERMITTED_CLASSES,
77
+ aliases: true
78
+ )
79
+ end
80
+
81
+ def build_from_loaded(loaded)
82
+ return FrontmatterBlock.new unless loaded.is_a?(Hash)
83
+
84
+ schema = loaded['$schema']
85
+ FrontmatterBlock.new(
86
+ schema: schema&.to_s,
87
+ data: loaded.except('$schema')
88
+ )
89
+ end
90
+
91
+ def flat_tree(block)
43
92
  tree = {}
44
93
  tree['$schema'] = block.schema if block.schema
45
94
  tree.merge!(block.data || {})
46
- return '' if tree.empty?
47
-
48
- YAML.dump(tree).delete_prefix("---\n").delete_suffix("\n...")
95
+ tree
49
96
  end
50
97
  end
51
98
  end
@@ -50,6 +50,10 @@ module Coradoc
50
50
  schema.nil? && (data.nil? || data.empty?)
51
51
  end
52
52
 
53
+ def body_content?
54
+ false
55
+ end
56
+
53
57
  # Sub-namespaces (Codec, SchemaResolver, FieldTransform, TextSplitter)
54
58
  # live under FrontmatterBlock and autoload lazily.
55
59
  autoload :Codec, "#{__dir__}/frontmatter/codec"
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module CoreModel
5
+ # Operations on mixed arrays of inline content (String /
6
+ # InlineElement / CoreModel::Base). Single source of truth for text
7
+ # extraction and edge cleanup, replacing parallel implementations
8
+ # that previously lived in the HTML converters.
9
+ #
10
+ # No method here mutates its inputs — InlineElements are duplicated
11
+ # via #with_content, Strings are replaced with new instances.
12
+ module InlineContent
13
+ class << self
14
+ # Extract plain text from a mixed content value.
15
+ #
16
+ # nil → '' / String → itself / Array → text_of mapped + joined /
17
+ # InlineElement → #content.to_s / StructuralElement → recurse on
18
+ # #children / other Base → #content if String else #title.to_s /
19
+ # anything else → #to_s.
20
+ def text_of(content)
21
+ return '' if content.nil?
22
+ return content if content.is_a?(String)
23
+ return text_of_one(content) unless content.is_a?(Array)
24
+
25
+ content.map { |item| text_of_one(item) }.join
26
+ end
27
+
28
+ # Return a new array with leading whitespace stripped from the
29
+ # first text-carrying item and trailing whitespace stripped from
30
+ # the last. Inputs are not mutated. Non-Array inputs return
31
+ # unchanged. If no item carries text, returns the input array
32
+ # unchanged.
33
+ def strip_edges(content)
34
+ return content unless content.is_a?(Array)
35
+ return content if content.empty?
36
+
37
+ first_idx = content.index { |i| text_carrier?(i) }
38
+ return content if first_idx.nil?
39
+ last_idx = content.rindex { |i| text_carrier?(i) }
40
+
41
+ content.map.with_index do |item, idx|
42
+ next item unless text_carrier?(item)
43
+
44
+ stripped = item_text(item)
45
+ stripped = stripped.lstrip if idx == first_idx
46
+ stripped = stripped.rstrip if idx == last_idx
47
+ item.is_a?(String) ? stripped : item.with_content(stripped)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def text_of_one(item)
54
+ case item
55
+ when String then item
56
+ when CoreModel::InlineElement then item.content.to_s
57
+ when CoreModel::StructuralElement then text_of(Array(item.children))
58
+ when CoreModel::Block
59
+ item.children.is_a?(Array) && !item.children.empty? ? text_of(item.children) : item.content.to_s
60
+ when CoreModel::Base
61
+ item.content.is_a?(String) ? item.content : item.title.to_s
62
+ else
63
+ item.to_s
64
+ end
65
+ end
66
+
67
+ def text_carrier?(item)
68
+ item.is_a?(String) || item.is_a?(CoreModel::InlineElement)
69
+ end
70
+
71
+ def item_text(item)
72
+ item.is_a?(String) ? item : item.content.to_s
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -24,13 +24,24 @@ module Coradoc
24
24
  self.class.format_type || format_type
25
25
  end
26
26
 
27
- FORMAT_TYPES = %w[
28
- bold italic monospace underline strikethrough
29
- subscript superscript highlight
30
- link xref stem footnote
31
- hard_line_break text span term
32
- line_break quotation
33
- ].freeze
27
+ # Return a duplicate of this element with its content replaced.
28
+ # Used by InlineContent.strip_edges (and other edge-cleanup
29
+ # callers) so that mutations never leak through the public API.
30
+ def with_content(new_content)
31
+ dup.tap { |copy| copy.content = new_content }
32
+ end
33
+
34
+ # Polymorphic classification used by LinkRewriter::Visitor. Returns
35
+ # :link / :xref when this node carries a rewrite-able target, nil
36
+ # otherwise. Generic InlineElement instances defer to their
37
+ # resolved format_type; typed subclasses override with a literal.
38
+ # Keeps the visitor free of class-keyed case/when (OCP).
39
+ def link_kind
40
+ case resolve_format_type
41
+ when 'link' then :link
42
+ when 'xref' then :xref
43
+ end
44
+ end
34
45
 
35
46
  attribute :format_type, :string
36
47
  attribute :content, :string
@@ -99,12 +110,20 @@ module Coradoc
99
110
  def self.format_type
100
111
  'link'
101
112
  end
113
+
114
+ def link_kind
115
+ :link
116
+ end
102
117
  end
103
118
 
104
119
  class CrossReferenceElement < InlineElement
105
120
  def self.format_type
106
121
  'xref'
107
122
  end
123
+
124
+ def link_kind
125
+ :xref
126
+ end
108
127
  end
109
128
 
110
129
  class StemElement < InlineElement
@@ -149,24 +168,31 @@ module Coradoc
149
168
  end
150
169
  end
151
170
 
152
- FORMAT_TYPE_CLASS_MAP = {
153
- 'bold' => BoldElement,
154
- 'italic' => ItalicElement,
155
- 'monospace' => MonospaceElement,
156
- 'underline' => UnderlineElement,
157
- 'strikethrough' => StrikethroughElement,
158
- 'subscript' => SubscriptElement,
159
- 'superscript' => SuperscriptElement,
160
- 'highlight' => HighlightElement,
161
- 'link' => LinkElement,
162
- 'xref' => CrossReferenceElement,
163
- 'stem' => StemElement,
164
- 'footnote' => FootnoteElement,
165
- 'hard_line_break' => HardLineBreakElement,
166
- 'text' => TextElement,
167
- 'span' => SpanElement,
168
- 'term' => TermElement,
169
- 'line_break' => LineBreakElement
170
- }.freeze
171
+ # Wire-name table: bidirectional string ↔ class index. Single source
172
+ # of truth for serialization names and runtime dispatch. Reopened
173
+ # onto InlineElement so the subclasses above are defined first.
174
+ class InlineElement
175
+ FORMAT_TYPE_CLASS_MAP = {
176
+ 'bold' => BoldElement,
177
+ 'italic' => ItalicElement,
178
+ 'monospace' => MonospaceElement,
179
+ 'underline' => UnderlineElement,
180
+ 'strikethrough' => StrikethroughElement,
181
+ 'subscript' => SubscriptElement,
182
+ 'superscript' => SuperscriptElement,
183
+ 'highlight' => HighlightElement,
184
+ 'link' => LinkElement,
185
+ 'xref' => CrossReferenceElement,
186
+ 'stem' => StemElement,
187
+ 'footnote' => FootnoteElement,
188
+ 'hard_line_break' => HardLineBreakElement,
189
+ 'text' => TextElement,
190
+ 'span' => SpanElement,
191
+ 'term' => TermElement,
192
+ 'line_break' => LineBreakElement
193
+ }.freeze
194
+
195
+ FORMAT_TYPES = FORMAT_TYPE_CLASS_MAP.keys.freeze
196
+ end
171
197
  end
172
198
  end
@@ -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
@@ -52,6 +52,38 @@ module Coradoc
52
52
  false
53
53
  end
54
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
+
55
87
  # Derived element_type string for backward compatibility with
56
88
  # templates and legacy consumers. Subclasses override this.
57
89
  def element_type
@@ -108,6 +140,15 @@ module Coradoc
108
140
  true
109
141
  end
110
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
+
111
152
  def self.element_type_name
112
153
  'header'
113
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
@@ -13,6 +13,7 @@ module Coradoc
13
13
  autoload :Base, "#{__dir__}/core_model/base"
14
14
  autoload :ChildrenContent, "#{__dir__}/core_model/children_content"
15
15
  autoload :HasChildren, "#{__dir__}/core_model/has_children"
16
+ autoload :InlineContent, "#{__dir__}/core_model/inline_content"
16
17
  autoload :Callout, "#{__dir__}/core_model/callout"
17
18
  autoload :CalloutText, "#{__dir__}/core_model/callout_text"
18
19
  autoload :Block, "#{__dir__}/core_model/block"
@@ -70,6 +71,7 @@ module Coradoc
70
71
  autoload :SidebarBlock, "#{__dir__}/core_model/sidebar_block"
71
72
  autoload :LiteralBlock, "#{__dir__}/core_model/literal_block"
72
73
  autoload :PassBlock, "#{__dir__}/core_model/pass_block"
74
+ autoload :StemBlock, "#{__dir__}/core_model/stem_block"
73
75
  autoload :ListingBlock, "#{__dir__}/core_model/listing_block"
74
76
  autoload :OpenBlock, "#{__dir__}/core_model/open_block"
75
77
  autoload :VerseBlock, "#{__dir__}/core_model/verse_block"
@@ -82,5 +84,6 @@ module Coradoc
82
84
  autoload :Include, "#{__dir__}/core_model/include"
83
85
  autoload :IncludeOptions, "#{__dir__}/core_model/include_options"
84
86
  autoload :IncludeLevelOffset, "#{__dir__}/core_model/include_level_offset"
87
+ autoload :OutputArtifact, "#{__dir__}/core_model/output_artifact"
85
88
  end
86
89
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ # Type-keyed handler dispatch.
5
+ #
6
+ # Replaces the bespoke `register/lookup` hashes that recur across gems with
7
+ # one cohesive registry. Each gem that needs type-keyed dispatch configures
8
+ # a single Dispatch instance and exposes its legacy DSL as thin delegates.
9
+ #
10
+ # Two resolution policies cover the common shapes:
11
+ #
12
+ # - `Coradoc::Dispatch.strict` — exact key match, raises on miss
13
+ # - `Coradoc::Dispatch.hierarchical` — walks ancestors on miss, returns nil
14
+ #
15
+ # Registries that need priority ordering, predicate matching, or lazy
16
+ # loading do not fit this shape and stay as they are; the friction there is
17
+ # genuine semantic difference, not duplicated mechanism.
18
+ class Dispatch
19
+ TERMINAL_ANCESTORS = [Object, BasicObject].freeze
20
+ private_constant :TERMINAL_ANCESTORS
21
+
22
+ class << self
23
+ # Exact key match; raises if no entry. Used by registries that map a
24
+ # concrete type to its sole handler (e.g. AsciiDoc ElementRegistry).
25
+ def strict = new(walk_ancestors: false)
26
+
27
+ # Walks the key's class ancestors on miss; returns nil if no entry.
28
+ # Used by registries that want base-class handlers to apply to all
29
+ # subclasses (e.g. Mirror HandlerRegistry).
30
+ def hierarchical = new(walk_ancestors: true, miss: :return_nil)
31
+ end
32
+
33
+ def initialize(walk_ancestors: false, miss: :raise, &default)
34
+ @walk_ancestors = walk_ancestors
35
+ @miss = miss
36
+ @default = default
37
+ @entries = {}
38
+ end
39
+
40
+ def register(key, handler)
41
+ @entries[key] = handler
42
+ end
43
+
44
+ # Replace the handler for an existing key. Returns the previous handler
45
+ # so wrappers can chain: original = dispatch.override(K, Wrapper.new(original))
46
+ def override(key, handler)
47
+ previous = @entries[key]
48
+ @entries[key] = handler
49
+ previous
50
+ end
51
+
52
+ def unregister(key)
53
+ @entries.delete(key)
54
+ end
55
+
56
+ # Resolve the handler for a key. Returns nil if no handler matches
57
+ # unless the dispatch is configured to raise.
58
+ def lookup(key)
59
+ exact = @entries[key]
60
+ return exact if exact
61
+ return walk(key) if @walk_ancestors && key.is_a?(Class)
62
+
63
+ apply_default(key)
64
+ end
65
+
66
+ # Resolve the handler, raising Coradoc::Error on miss.
67
+ def lookup!(key)
68
+ lookup(key) || raise(Coradoc::Error, "no handler registered for #{key.inspect}")
69
+ end
70
+
71
+ def registered?(key) = @entries.key?(key)
72
+
73
+ def registered_keys = @entries.keys
74
+
75
+ def clear! = @entries.clear
76
+
77
+ private
78
+
79
+ def walk(klass)
80
+ klass.ancestors.each do |ancestor|
81
+ next if ancestor == klass
82
+ break if TERMINAL_ANCESTORS.include?(ancestor)
83
+
84
+ entry = @entries[ancestor]
85
+ return entry if entry
86
+ end
87
+ apply_default(klass)
88
+ end
89
+
90
+ def apply_default(key)
91
+ return @default.call(key) if @default
92
+ nil
93
+ end
94
+ end
95
+ end