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.
- checksums.yaml +4 -4
- data/lib/coradoc/cli.rb +4 -1
- data/lib/coradoc/coradoc.rb +3 -0
- data/lib/coradoc/errors.rb +19 -0
- data/lib/coradoc/format_module.rb +10 -0
- data/lib/coradoc/pipeline.rb +60 -1
- 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/validation.rb +40 -0
- data/lib/coradoc/version.rb +1 -1
- 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
|