coradoc 2.0.27 → 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.
- checksums.yaml +4 -4
- data/lib/coradoc/cli.rb +9 -6
- data/lib/coradoc/configurable.rb +6 -3
- data/lib/coradoc/coradoc.rb +5 -2
- data/lib/coradoc/core_model/attribute_reference_resolver.rb +19 -7
- data/lib/coradoc/core_model/base.rb +45 -2
- data/lib/coradoc/core_model/comment_line.rb +1 -1
- data/lib/coradoc/core_model/definition_item.rb +22 -1
- data/lib/coradoc/core_model/horizontal_rule_block.rb +4 -0
- data/lib/coradoc/core_model/include_level_offset.rb +1 -1
- data/lib/coradoc/core_model/include_options.rb +1 -0
- data/lib/coradoc/core_model/inline_content.rb +1 -0
- data/lib/coradoc/core_model/list_block.rb +1 -1
- data/lib/coradoc/core_model/listing_block.rb +4 -0
- data/lib/coradoc/core_model/literal_block.rb +4 -0
- data/lib/coradoc/core_model/pass_block.rb +4 -0
- data/lib/coradoc/core_model/source_block.rb +4 -0
- data/lib/coradoc/core_model/stem_block.rb +4 -0
- data/lib/coradoc/dispatch.rb +1 -0
- data/lib/coradoc/errors.rb +19 -0
- data/lib/coradoc/format_catalog.rb +19 -0
- data/lib/coradoc/format_module.rb +10 -0
- data/lib/coradoc/hooks.rb +1 -1
- data/lib/coradoc/include_selectors/level_offset.rb +0 -2
- data/lib/coradoc/include_selectors/lines.rb +2 -2
- data/lib/coradoc/include_selectors/tags.rb +2 -4
- data/lib/coradoc/introspection/element_counter.rb +2 -2
- data/lib/coradoc/link_rewriter/visitor.rb +5 -14
- data/lib/coradoc/pipeline.rb +64 -5
- data/lib/coradoc/query.rb +8 -8
- data/lib/coradoc/reference/address/anchor.rb +48 -0
- data/lib/coradoc/reference/address/doi.rb +40 -0
- data/lib/coradoc/reference/address/isbn.rb +39 -0
- data/lib/coradoc/reference/address/path.rb +66 -0
- data/lib/coradoc/reference/address/scoped_path.rb +47 -0
- data/lib/coradoc/reference/address/url.rb +41 -0
- data/lib/coradoc/reference/address.rb +153 -0
- data/lib/coradoc/reference/catalog/composite.rb +59 -0
- data/lib/coradoc/reference/catalog/local.rb +97 -0
- data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
- data/lib/coradoc/reference/catalog.rb +33 -0
- data/lib/coradoc/reference/edge/citation_options.rb +16 -0
- data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
- data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
- data/lib/coradoc/reference/edge/include_options.rb +15 -0
- data/lib/coradoc/reference/edge/kind.rb +72 -0
- data/lib/coradoc/reference/edge/link_options.rb +12 -0
- data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
- data/lib/coradoc/reference/edge/options.rb +13 -0
- data/lib/coradoc/reference/edge.rb +80 -0
- data/lib/coradoc/reference/edge_search.rb +128 -0
- data/lib/coradoc/reference/materializer/base.rb +44 -0
- data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
- data/lib/coradoc/reference/materializer/registry.rb +116 -0
- data/lib/coradoc/reference/materializer.rb +14 -0
- data/lib/coradoc/reference/presentation/base.rb +47 -0
- data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
- data/lib/coradoc/reference/presentation/page.rb +20 -0
- data/lib/coradoc/reference/presentation/single_document.rb +35 -0
- data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
- data/lib/coradoc/reference/presentation.rb +16 -0
- data/lib/coradoc/reference/resolution.rb +184 -0
- data/lib/coradoc/reference/resolver/base.rb +15 -0
- data/lib/coradoc/reference/resolver/caching.rb +37 -0
- data/lib/coradoc/reference/resolver/catalog.rb +69 -0
- data/lib/coradoc/reference/resolver/chain.rb +33 -0
- data/lib/coradoc/reference/resolver.rb +20 -0
- data/lib/coradoc/reference/result/ambiguous.rb +23 -0
- data/lib/coradoc/reference/result/base.rb +46 -0
- data/lib/coradoc/reference/result/missing.rb +14 -0
- data/lib/coradoc/reference/result/resolved.rb +22 -0
- data/lib/coradoc/reference/result.rb +20 -0
- data/lib/coradoc/reference.rb +65 -0
- data/lib/coradoc/registry.rb +6 -6
- data/lib/coradoc/resolve_includes.rb +2 -2
- data/lib/coradoc/validation.rb +44 -4
- data/lib/coradoc/version.rb +1 -1
- data/lib/coradoc.rb +1 -1
- metadata +45 -1
|
@@ -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
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Reference
|
|
5
|
+
module Resolver
|
|
6
|
+
# Wraps another resolver with an address-keyed memoization cache.
|
|
7
|
+
# Addresses are value types, so cache keys are stable across calls.
|
|
8
|
+
# Use to avoid re-asking the catalog for the same Edge.address.
|
|
9
|
+
class Caching < Resolver::Base
|
|
10
|
+
attr_reader :inner
|
|
11
|
+
|
|
12
|
+
def initialize(inner:)
|
|
13
|
+
super()
|
|
14
|
+
@inner = inner
|
|
15
|
+
@cache = {}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(edge)
|
|
19
|
+
cached = if @cache.key?(edge.address)
|
|
20
|
+
@cache[edge.address]
|
|
21
|
+
else
|
|
22
|
+
@cache[edge.address] = @inner.resolve(edge)
|
|
23
|
+
end
|
|
24
|
+
cached.for_edge(edge)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def clear!
|
|
28
|
+
@cache.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@cache.size
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Reference
|
|
5
|
+
module Resolver
|
|
6
|
+
# Asks one catalog. Applies the +ambiguous:+ policy in-line.
|
|
7
|
+
# Does NOT apply the +missing:+ policy — that's the caller's
|
|
8
|
+
# call (Resolution orchestrator) — but it does surface the
|
|
9
|
+
# Missing Result so the caller can decide.
|
|
10
|
+
class Catalog < Resolver::Base
|
|
11
|
+
attr_reader :catalog, :ambiguous_policy, :missing_policy
|
|
12
|
+
|
|
13
|
+
def initialize(catalog:, ambiguous: :disambiguate, missing: :warn)
|
|
14
|
+
super()
|
|
15
|
+
@catalog = catalog
|
|
16
|
+
@ambiguous_policy = ambiguous
|
|
17
|
+
@missing_policy = missing
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def resolve(edge)
|
|
21
|
+
address = edge.address
|
|
22
|
+
return missing_for(edge, address) unless catalog.recognizes_scheme?(address.scheme)
|
|
23
|
+
|
|
24
|
+
result = catalog.lookup(address)
|
|
25
|
+
return missing_for(edge, address) if result.nil?
|
|
26
|
+
|
|
27
|
+
return resolved_for(edge, address, result) unless result.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
resolve_ambiguous(edge, address, result)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def resolved_for(edge, address, target)
|
|
35
|
+
Coradoc::Reference::Result::Resolved.build(
|
|
36
|
+
edge: edge, address: address, target: target
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def resolve_ambiguous(edge, address, candidates)
|
|
41
|
+
return ambiguous_error(address, candidates) if ambiguous_policy == :error
|
|
42
|
+
return resolved_for(edge, address, candidates.first) if ambiguous_policy == :first
|
|
43
|
+
|
|
44
|
+
# :disambiguate — warn, then deterministically take the first
|
|
45
|
+
Coradoc::Logger.warn(
|
|
46
|
+
"Reference #{address} is ambiguous " \
|
|
47
|
+
"(#{candidates.size} candidates); using first"
|
|
48
|
+
)
|
|
49
|
+
resolved_for(edge, address, candidates.first)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ambiguous_error(address, candidates)
|
|
53
|
+
raise Coradoc::Reference::AmbiguousReferenceError,
|
|
54
|
+
"Address #{address} matched #{candidates.size} candidates"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def missing_for(edge, address)
|
|
58
|
+
case missing_policy
|
|
59
|
+
when :error
|
|
60
|
+
raise Coradoc::Reference::MissingReferenceError,
|
|
61
|
+
"Address #{address} not found"
|
|
62
|
+
else
|
|
63
|
+
Coradoc::Reference::Result::Missing.build(edge: edge, address: address)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Reference
|
|
5
|
+
module Resolver
|
|
6
|
+
# Tries each child resolver in order. First non-Missing result
|
|
7
|
+
# wins (Resolved or Ambiguous short-circuit). Use to combine a
|
|
8
|
+
# local catalog with a remote one without composite catalog
|
|
9
|
+
# indirection.
|
|
10
|
+
class Chain < Resolver::Base
|
|
11
|
+
attr_reader :resolvers
|
|
12
|
+
|
|
13
|
+
def initialize(*resolvers)
|
|
14
|
+
super()
|
|
15
|
+
@resolvers = resolvers.flatten
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def resolve(edge)
|
|
19
|
+
last = nil
|
|
20
|
+
@resolvers.each do |resolver|
|
|
21
|
+
result = resolver.resolve(edge)
|
|
22
|
+
return result unless result.is_a?(Coradoc::Reference::Result::Missing)
|
|
23
|
+
|
|
24
|
+
last = result
|
|
25
|
+
end
|
|
26
|
+
last || Coradoc::Reference::Result::Missing.build(
|
|
27
|
+
edge: edge, address: edge.address
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Reference
|
|
5
|
+
# Kind-agnostic resolver: given an Edge, ask the catalog, return a
|
|
6
|
+
# Result. Never materializes, never slices, never orders.
|
|
7
|
+
#
|
|
8
|
+
# resolver = Resolver::Catalog.new(catalog: catalog, ambiguous: :first, missing: :warn)
|
|
9
|
+
# case resolver.resolve(edge)
|
|
10
|
+
# in Result::Resolved => r ; use_target(r.target)
|
|
11
|
+
# in Result::Missing ; warn("could not resolve #{edge.address}")
|
|
12
|
+
# end
|
|
13
|
+
module Resolver
|
|
14
|
+
autoload :Base, "#{__dir__}/resolver/base"
|
|
15
|
+
autoload :Catalog, "#{__dir__}/resolver/catalog"
|
|
16
|
+
autoload :Chain, "#{__dir__}/resolver/chain"
|
|
17
|
+
autoload :Caching, "#{__dir__}/resolver/caching"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Coradoc
|
|
4
|
+
module Reference
|
|
5
|
+
module Result
|
|
6
|
+
# Multiple candidates matched. The catalog returned an Array.
|
|
7
|
+
# Callers' +ambiguous:+ policy decides what to do.
|
|
8
|
+
class Ambiguous < Base
|
|
9
|
+
attribute :candidates, Coradoc::CoreModel::Base, collection: true
|
|
10
|
+
|
|
11
|
+
def self.build(edge:, address:, candidates:)
|
|
12
|
+
new(edge: edge, address: address, candidates: candidates)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def result_data
|
|
18
|
+
{ candidates: candidates }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|