coradoc 2.0.28 → 2.0.29

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +4 -1
  3. data/lib/coradoc/coradoc.rb +3 -0
  4. data/lib/coradoc/errors.rb +19 -0
  5. data/lib/coradoc/format_module.rb +10 -0
  6. data/lib/coradoc/pipeline.rb +60 -1
  7. data/lib/coradoc/reference/address/anchor.rb +48 -0
  8. data/lib/coradoc/reference/address/doi.rb +40 -0
  9. data/lib/coradoc/reference/address/isbn.rb +39 -0
  10. data/lib/coradoc/reference/address/path.rb +66 -0
  11. data/lib/coradoc/reference/address/scoped_path.rb +47 -0
  12. data/lib/coradoc/reference/address/url.rb +41 -0
  13. data/lib/coradoc/reference/address.rb +153 -0
  14. data/lib/coradoc/reference/catalog/composite.rb +59 -0
  15. data/lib/coradoc/reference/catalog/local.rb +97 -0
  16. data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
  17. data/lib/coradoc/reference/catalog.rb +33 -0
  18. data/lib/coradoc/reference/edge/citation_options.rb +16 -0
  19. data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
  20. data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
  21. data/lib/coradoc/reference/edge/include_options.rb +15 -0
  22. data/lib/coradoc/reference/edge/kind.rb +72 -0
  23. data/lib/coradoc/reference/edge/link_options.rb +12 -0
  24. data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
  25. data/lib/coradoc/reference/edge/options.rb +13 -0
  26. data/lib/coradoc/reference/edge.rb +80 -0
  27. data/lib/coradoc/reference/edge_search.rb +128 -0
  28. data/lib/coradoc/reference/materializer/base.rb +44 -0
  29. data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
  30. data/lib/coradoc/reference/materializer/registry.rb +116 -0
  31. data/lib/coradoc/reference/materializer.rb +14 -0
  32. data/lib/coradoc/reference/presentation/base.rb +47 -0
  33. data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
  34. data/lib/coradoc/reference/presentation/page.rb +20 -0
  35. data/lib/coradoc/reference/presentation/single_document.rb +35 -0
  36. data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
  37. data/lib/coradoc/reference/presentation.rb +16 -0
  38. data/lib/coradoc/reference/resolution.rb +184 -0
  39. data/lib/coradoc/reference/resolver/base.rb +15 -0
  40. data/lib/coradoc/reference/resolver/caching.rb +37 -0
  41. data/lib/coradoc/reference/resolver/catalog.rb +69 -0
  42. data/lib/coradoc/reference/resolver/chain.rb +33 -0
  43. data/lib/coradoc/reference/resolver.rb +20 -0
  44. data/lib/coradoc/reference/result/ambiguous.rb +23 -0
  45. data/lib/coradoc/reference/result/base.rb +46 -0
  46. data/lib/coradoc/reference/result/missing.rb +14 -0
  47. data/lib/coradoc/reference/result/resolved.rb +22 -0
  48. data/lib/coradoc/reference/result.rb +20 -0
  49. data/lib/coradoc/reference.rb +65 -0
  50. data/lib/coradoc/validation.rb +40 -0
  51. data/lib/coradoc/version.rb +1 -1
  52. metadata +44 -1
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Materializer
6
+ # Registry of materializers keyed by [kind, presentation, format].
7
+ # Lookup falls back on +:any+ — so a materializer registered for
8
+ # +[:link, :any, :html]+ handles every presentation in HTML.
9
+ # Most specific key wins (concrete > :any).
10
+ #
11
+ # Format gems register their materializers process-wide via
12
+ # +.register_global+ (OCP): every registry instance picks them up
13
+ # on first use, and dispatch code never changes.
14
+ class Registry
15
+ # Core ships only the node-preserving fallback. Format-specific
16
+ # materializers live in the format gems (coradoc-html,
17
+ # coradoc-adoc, ...) and register globally at load time.
18
+ BUILTINS = [Materializer::Passthrough].freeze
19
+
20
+ @global_registrations = []
21
+
22
+ GLOBAL_MUTEX = Mutex.new
23
+ private_constant :GLOBAL_MUTEX
24
+
25
+ class << self
26
+ def register_global(klass)
27
+ GLOBAL_MUTEX.synchronize do
28
+ @global_registrations << klass unless @global_registrations.include?(klass)
29
+ end
30
+ end
31
+
32
+ def global_registrations
33
+ GLOBAL_MUTEX.synchronize { @global_registrations.dup }
34
+ end
35
+
36
+ def reset_globals!
37
+ GLOBAL_MUTEX.synchronize { @global_registrations.clear }
38
+ end
39
+ end
40
+
41
+ def initialize
42
+ @by_key = {}
43
+ @builtins_registered = false
44
+ end
45
+
46
+ def register(klass)
47
+ ensure_builtins_registered!
48
+ @by_key[EntryKey.new(klass.kind, klass.presentation, klass.format)] = klass
49
+ end
50
+
51
+ def lookup(kind:, presentation:, format:)
52
+ ensure_builtins_registered!
53
+ find_most_specific(kind, presentation, format) ||
54
+ find_most_specific(kind, presentation, :any) ||
55
+ find_most_specific(kind, :any, format) ||
56
+ find_most_specific(kind, :any, :any) ||
57
+ find_most_specific(:any, :any, :any)
58
+ end
59
+
60
+ def registered
61
+ ensure_builtins_registered!
62
+ @by_key.dup
63
+ end
64
+
65
+ def reset!
66
+ @by_key.clear
67
+ @builtins_registered = false
68
+ end
69
+
70
+ private
71
+
72
+ def find_most_specific(kind, presentation, format)
73
+ @by_key[EntryKey.new(kind.to_sym, presentation.to_sym, format.to_sym)]
74
+ end
75
+
76
+ def ensure_builtins_registered!
77
+ return if @builtins_registered
78
+
79
+ register_builtins!
80
+ @builtins_registered = true
81
+ end
82
+
83
+ def register_builtins!
84
+ (BUILTINS + self.class.global_registrations).each do |k|
85
+ @by_key[EntryKey.new(k.kind, k.presentation, k.format)] = k
86
+ end
87
+ end
88
+
89
+ # Internal composite key for the registry. Equality based on
90
+ # the three symbol values so two equivalent tuples compare equal.
91
+ class EntryKey
92
+ attr_reader :kind, :presentation, :format
93
+
94
+ def initialize(kind, presentation, format)
95
+ @kind = kind.to_sym
96
+ @presentation = presentation.to_sym
97
+ @format = format.to_sym
98
+ end
99
+
100
+ def ==(other)
101
+ other.is_a?(EntryKey) &&
102
+ kind == other.kind &&
103
+ presentation == other.presentation &&
104
+ format == other.format
105
+ end
106
+ alias eql? ==
107
+
108
+ def hash
109
+ [kind, presentation, format].hash
110
+ end
111
+ end
112
+ private_constant :EntryKey
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Render an Edge per (kind, presentation, format). Registry-based:
6
+ # adding a new output format or citation style is registering a
7
+ # new materializer, not editing switch statements.
8
+ module Materializer
9
+ autoload :Base, "#{__dir__}/materializer/base"
10
+ autoload :Registry, "#{__dir__}/materializer/registry"
11
+ autoload :Passthrough, "#{__dir__}/materializer/passthrough"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Protocol base. Subclasses implement +#layout+ (resolved graph →
7
+ # Pages) and +#locate_page+ (find which page a target lives on).
8
+ #
9
+ # A Presentation NEVER reads the original document — it consumes
10
+ # the resolved graph produced by the Resolver. It never renders.
11
+ class Base
12
+ # Registry key used in materializer lookup tuples
13
+ # [kind, presentation, format]. Subclasses override so
14
+ # materializers can target a specific layout.
15
+ def self.key
16
+ :any
17
+ end
18
+
19
+ def key
20
+ self.class.key
21
+ end
22
+
23
+ # Lay out the resolved graph as a tree of Pages. Subclasses
24
+ # decide slicing, ordering, hierarchy.
25
+ #
26
+ # @param resolved_graph [CoreModel::Base] the document the
27
+ # caller wants to present. Already resolved (edges → targets).
28
+ # @return [Array<Page>]
29
+ def layout(resolved_graph)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # Given an Edge and its resolved target, find the Page where
34
+ # the target lives. This is what makes cross-references
35
+ # survive re-pagination.
36
+ #
37
+ # @param edge [Edge]
38
+ # @param target_content [CoreModel::Base]
39
+ # @param pages [Array<Page>]
40
+ # @return [Page, nil]
41
+ def locate_page(edge, target_content, pages:)
42
+ raise NotImplementedError
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Caller-supplied hierarchy. The user provides a tree of node ids
7
+ # mapping to pages; the presentation lays out content accordingly.
8
+ # This is the presentation website builds use.
9
+ #
10
+ # CustomHierarchy.new(hierarchy: [
11
+ # { id: "intro", title: "Introduction", children: [...] },
12
+ # { id: "chap1", title: "Chapter 1", children: [
13
+ # { id: "chap1-1", title: "1.1" }
14
+ # ] }
15
+ # ])
16
+ class CustomHierarchy < Base
17
+ attr_reader :hierarchy
18
+
19
+ def self.key
20
+ :custom_hierarchy
21
+ end
22
+
23
+ def initialize(hierarchy:)
24
+ super()
25
+ @hierarchy = normalize_hierarchy(hierarchy)
26
+ end
27
+
28
+ def layout(resolved_graph)
29
+ pages = []
30
+ walk_hierarchy(@hierarchy, parent_id: nil, pages: pages, root: resolved_graph)
31
+ pages
32
+ end
33
+
34
+ def locate_page(_edge, target_content, pages:)
35
+ pages.find { |page| page.content.equal?(target_content) }
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_hierarchy(tree)
41
+ Array(tree).map do |entry|
42
+ entry.merge(children: normalize_hierarchy(entry[:children]))
43
+ end
44
+ end
45
+
46
+ def walk_hierarchy(nodes, parent_id:, pages:, root:)
47
+ nodes.each do |entry|
48
+ content = find_content_by_id(root, entry[:id]) || root
49
+ pages << Page.new(
50
+ id: entry[:id],
51
+ title: entry[:title] || content.title,
52
+ content: content,
53
+ parent_id: parent_id,
54
+ order: pages.size
55
+ )
56
+ walk_hierarchy(entry[:children], parent_id: entry[:id], pages: pages, root: root)
57
+ end
58
+ end
59
+
60
+ def find_content_by_id(root, id)
61
+ return nil unless id
62
+
63
+ visitor = VisitorById.new(id)
64
+ visitor.visit(root)
65
+ visitor.found
66
+ end
67
+
68
+ # Single-purpose visitor: walks a CoreModel tree looking for
69
+ # one node by id. Cleaner than a recursive method on the
70
+ # presentation class itself.
71
+ class VisitorById
72
+ attr_reader :found
73
+
74
+ def initialize(target_id)
75
+ @target_id = target_id
76
+ @found = nil
77
+ end
78
+
79
+ def visit(node)
80
+ return @found if @found
81
+ return unless node.is_a?(Coradoc::CoreModel::Base)
82
+
83
+ @found = node if node.id == @target_id
84
+ return if @found
85
+
86
+ visit_children(node)
87
+ end
88
+
89
+ private
90
+
91
+ def visit_children(node)
92
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
93
+
94
+ children = node.children
95
+ return unless children
96
+
97
+ children.each { |c| visit(c) }
98
+ nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Coradoc
6
+ module Reference
7
+ module Presentation
8
+ # A page is the unit of output a Presentation produces. One HTML
9
+ # file, one PDF page, one EPUB chapter — same data, different
10
+ # materialization. The Materializer consumes Pages.
11
+ class Page < Lutaml::Model::Serializable
12
+ attribute :id, :string
13
+ attribute :title, :string
14
+ attribute :content, Coradoc::CoreModel::Base
15
+ attribute :parent_id, :string
16
+ attribute :order, :integer
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # One page = the whole document. Use for fixed-structure output
7
+ # (single HTML, single PDF, EPUB chapter). Cross-references
8
+ # always resolve to the single page.
9
+ class SingleDocument < Base
10
+ def self.key
11
+ :single_document
12
+ end
13
+
14
+ def layout(resolved_graph)
15
+ [Page.new(
16
+ id: page_id_for(resolved_graph),
17
+ title: resolved_graph.title,
18
+ content: resolved_graph,
19
+ order: 0
20
+ )]
21
+ end
22
+
23
+ def locate_page(_edge, _target_content, pages:)
24
+ pages.first
25
+ end
26
+
27
+ private
28
+
29
+ def page_id_for(node)
30
+ node.id || 'root'
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Splits the document by top-level section (or a configurable
7
+ # boundary). Each section becomes a Page. Cross-references are
8
+ # rewritten to point at whichever page the target content lives on.
9
+ #
10
+ # SplitPages.new(split_at: :section) # default
11
+ # SplitPages.new(split_at: :chapter) # HeaderElement only
12
+ class SplitPages < Base
13
+ attr_reader :split_at
14
+
15
+ def self.key
16
+ :split_pages
17
+ end
18
+
19
+ def initialize(split_at: :section)
20
+ super()
21
+ @split_at = split_at.to_sym
22
+ @page_index = nil
23
+ end
24
+
25
+ def layout(resolved_graph)
26
+ children = read_children(resolved_graph)
27
+ return [single_page_for(resolved_graph)] if children.nil? || children.empty?
28
+
29
+ sections = children.select { |child| matches_split?(child) }
30
+ return [single_page_for(resolved_graph)] if sections.empty?
31
+
32
+ pages = sections.map.with_index { |section, idx| page_for(section, resolved_graph, idx) }
33
+ index_pages!(pages)
34
+ pages
35
+ end
36
+
37
+ def locate_page(_edge, target_content, pages:)
38
+ indexed = @page_index&.[](target_content)
39
+ return indexed if indexed
40
+
41
+ pages.find { |page| owns_target?(page, target_content) }
42
+ end
43
+
44
+ private
45
+
46
+ # O(1) target→page lookup, built once per layout instead of
47
+ # rescanning every page subtree per edge.
48
+ def index_pages!(pages)
49
+ @page_index = {}.compare_by_identity
50
+ pages.each { |page| index_node!(page.content, page) }
51
+ end
52
+
53
+ def index_node!(node, page)
54
+ @page_index[node] = page
55
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
56
+
57
+ children = node.children
58
+ return unless children
59
+
60
+ children.each { |child| index_node!(child, page) }
61
+ end
62
+
63
+ def page_for(section, parent, idx)
64
+ Page.new(
65
+ id: section.id || "page-#{idx}",
66
+ title: section.title || "Page #{idx}",
67
+ content: section,
68
+ parent_id: parent.id,
69
+ order: idx
70
+ )
71
+ end
72
+
73
+ def read_children(node)
74
+ return nil unless node.is_a?(Coradoc::CoreModel::HasChildren)
75
+
76
+ node.children
77
+ end
78
+
79
+ def matches_split?(node)
80
+ return false unless node.is_a?(Coradoc::CoreModel::StructuralElement)
81
+
82
+ klass = boundary_class
83
+ node.is_a?(klass)
84
+ end
85
+
86
+ def boundary_class
87
+ split_at == :chapter ? Coradoc::CoreModel::HeaderElement : Coradoc::CoreModel::SectionElement
88
+ end
89
+
90
+ def single_page_for(node)
91
+ Page.new(
92
+ id: node.id || 'root',
93
+ title: node.title,
94
+ content: node,
95
+ order: 0
96
+ )
97
+ end
98
+
99
+ def owns_target?(page, target)
100
+ return true if page.content.equal?(target)
101
+
102
+ descendant_of?(page.content, target)
103
+ end
104
+
105
+ def descendant_of?(ancestor, target)
106
+ return false unless ancestor.is_a?(Coradoc::CoreModel::HasChildren)
107
+
108
+ children = ancestor.children
109
+ return false unless children
110
+
111
+ children.any? do |child|
112
+ child.equal?(target) || descendant_of?(child, target)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Model-View boundary. Same Content graph → N Presentations. A
6
+ # Presentation defines slicing, page boundaries, ordering, and
7
+ # hierarchy — never the rendering (that's the Materializer's job).
8
+ module Presentation
9
+ autoload :Base, "#{__dir__}/presentation/base"
10
+ autoload :Page, "#{__dir__}/presentation/page"
11
+ autoload :SingleDocument, "#{__dir__}/presentation/single_document"
12
+ autoload :SplitPages, "#{__dir__}/presentation/split_pages"
13
+ autoload :CustomHierarchy, "#{__dir__}/presentation/custom_hierarchy"
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Orchestrator: resolves every Edge in the tree via the Resolver,
6
+ # enforces the missing/ambiguous policies, and — only when asked
7
+ # to materialize — rebuilds the tree with rendered inline nodes
8
+ # via the Presentation layout and the Materializer registry.
9
+ #
10
+ # Two-step contract:
11
+ # - +materialize: false+ validates references and returns the
12
+ # INPUT document (never mutated, never copied).
13
+ # - +materialize: true+ returns a NEW document. Untouched subtrees
14
+ # are shared with the input (structural sharing) — treat both as
15
+ # immutable.
16
+ #
17
+ # Wired from the public API +Coradoc.resolve_references+.
18
+ class Resolution
19
+ attr_reader :catalog, :presentation, :resolver, :missing_policy,
20
+ :ambiguous_policy, :materialize_policy, :format,
21
+ :materializer_registry
22
+
23
+ def initialize(catalog:, presentation:, missing:, ambiguous:,
24
+ materialize:, resolver: nil, format: nil,
25
+ materializer_registry: default_registry)
26
+ @catalog = catalog
27
+ @presentation = presentation
28
+ @missing_policy = missing
29
+ @ambiguous_policy = ambiguous
30
+ @materialize_policy = materialize
31
+ @format = format
32
+ @materializer_registry = materializer_registry
33
+ @resolver = resolver || Resolver::Catalog.new(
34
+ catalog: catalog, ambiguous: ambiguous, missing: missing
35
+ )
36
+ end
37
+
38
+ def call(document)
39
+ results = resolve_all_edges(document)
40
+ report_missing(results)
41
+ return document unless materialize_policy
42
+
43
+ materialize_tree(document, results)
44
+ end
45
+
46
+ private
47
+
48
+ # Keyed by the Edge itself (value equality), so every distinct
49
+ # edge gets its own Result while identical edges resolve once.
50
+ def resolve_all_edges(document)
51
+ results = {}
52
+ EdgeSearch.each_edge(document) do |_parent, edge|
53
+ results[edge] = resolver.resolve(edge)
54
+ end
55
+ results
56
+ end
57
+
58
+ def report_missing(results)
59
+ results.each_value do |result|
60
+ next unless result.missing?
61
+
62
+ case missing_policy
63
+ when :error
64
+ raise Coradoc::Reference::MissingReferenceError.new(
65
+ address: result.address
66
+ )
67
+ when :warn
68
+ Coradoc::Logger.warn("Reference not found: #{result.address}")
69
+ end
70
+ end
71
+ end
72
+
73
+ def materialize_tree(document, results)
74
+ pages = presentation.layout(document)
75
+ ReplaceWalker.new(
76
+ pages: pages,
77
+ results: results,
78
+ presentation: presentation,
79
+ registry: materializer_registry,
80
+ missing_policy: missing_policy,
81
+ format: format
82
+ ).visit(document)
83
+ end
84
+
85
+ def default_registry
86
+ Materializer::Registry.new
87
+ end
88
+
89
+ # Rebuilds the tree, replacing every Edge-bearing node with the
90
+ # materializer's output. The walker is single-purpose and only
91
+ # lives inside Resolution — no need for a separate file.
92
+ class ReplaceWalker
93
+ attr_reader :pages, :results, :presentation, :registry,
94
+ :missing_policy, :format
95
+
96
+ def initialize(pages:, results:, presentation:, registry:,
97
+ missing_policy:, format:)
98
+ @pages = pages
99
+ @results = results
100
+ @presentation = presentation
101
+ @registry = registry
102
+ @missing_policy = missing_policy
103
+ @format = format
104
+ end
105
+
106
+ def visit(node)
107
+ return node unless node.is_a?(Coradoc::CoreModel::Base)
108
+
109
+ replaced = replace_node(node)
110
+ return replaced unless replaced.is_a?(Coradoc::CoreModel::HasChildren)
111
+
112
+ rebuild_children_of(replaced)
113
+ end
114
+
115
+ private
116
+
117
+ def replace_node(node)
118
+ edges = EdgeSearch.edges_for(node)
119
+ return node if edges.empty?
120
+
121
+ materialize_edge(node, edges.first)
122
+ end
123
+
124
+ def materialize_edge(node, edge)
125
+ result = results[edge]
126
+ return drop_or_keep(node) if result.nil? || result.missing?
127
+
128
+ invoke_materializer(node, edge, result)
129
+ end
130
+
131
+ # :silent drops the unresolved node; :warn (already logged) and
132
+ # :passthrough keep the original node so round-tripping is safe.
133
+ def drop_or_keep(node)
134
+ return nil if missing_policy == :silent
135
+
136
+ node
137
+ end
138
+
139
+ def invoke_materializer(node, edge, result)
140
+ klass = lookup_materializer(edge)
141
+ return node unless klass
142
+
143
+ klass.new.materialize(
144
+ edge: edge,
145
+ result: result,
146
+ node: node,
147
+ presentation: presentation,
148
+ pages: pages
149
+ )
150
+ end
151
+
152
+ def lookup_materializer(edge)
153
+ registry.lookup(
154
+ kind: edge.kind.to_sym,
155
+ presentation: presentation.key,
156
+ format: format || :any
157
+ )
158
+ end
159
+
160
+ def rebuild_children_of(node)
161
+ children = node.children
162
+ return node unless children
163
+ return node if children.empty?
164
+
165
+ new_children = children.filter_map { |c| visit(c) }
166
+ rebuild_with(node, new_children)
167
+ end
168
+
169
+ def rebuild_with(node, new_children)
170
+ return node if identical_children?(new_children, node.children)
171
+
172
+ duplicate = node.dup
173
+ duplicate.children = new_children
174
+ duplicate
175
+ end
176
+
177
+ def identical_children?(new_children, old_children)
178
+ new_children.length == old_children.length &&
179
+ new_children.each_with_index.all? { |c, i| c.equal?(old_children[i]) }
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Resolver
6
+ # Protocol base. Concrete resolvers implement +#resolve(edge)+
7
+ # and return a Result sum type instance — never +nil+.
8
+ class Base
9
+ def resolve(edge)
10
+ raise NotImplementedError
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end