coradoc 2.0.28 → 2.0.30

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 (53) 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/core_model/list_block.rb +45 -60
  5. data/lib/coradoc/errors.rb +19 -0
  6. data/lib/coradoc/format_module.rb +10 -0
  7. data/lib/coradoc/pipeline.rb +60 -1
  8. data/lib/coradoc/reference/address/anchor.rb +48 -0
  9. data/lib/coradoc/reference/address/doi.rb +40 -0
  10. data/lib/coradoc/reference/address/isbn.rb +39 -0
  11. data/lib/coradoc/reference/address/path.rb +66 -0
  12. data/lib/coradoc/reference/address/scoped_path.rb +47 -0
  13. data/lib/coradoc/reference/address/url.rb +41 -0
  14. data/lib/coradoc/reference/address.rb +153 -0
  15. data/lib/coradoc/reference/catalog/composite.rb +59 -0
  16. data/lib/coradoc/reference/catalog/local.rb +97 -0
  17. data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
  18. data/lib/coradoc/reference/catalog.rb +33 -0
  19. data/lib/coradoc/reference/edge/citation_options.rb +16 -0
  20. data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
  21. data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
  22. data/lib/coradoc/reference/edge/include_options.rb +15 -0
  23. data/lib/coradoc/reference/edge/kind.rb +72 -0
  24. data/lib/coradoc/reference/edge/link_options.rb +12 -0
  25. data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
  26. data/lib/coradoc/reference/edge/options.rb +13 -0
  27. data/lib/coradoc/reference/edge.rb +80 -0
  28. data/lib/coradoc/reference/edge_search.rb +128 -0
  29. data/lib/coradoc/reference/materializer/base.rb +44 -0
  30. data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
  31. data/lib/coradoc/reference/materializer/registry.rb +116 -0
  32. data/lib/coradoc/reference/materializer.rb +14 -0
  33. data/lib/coradoc/reference/presentation/base.rb +47 -0
  34. data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
  35. data/lib/coradoc/reference/presentation/page.rb +20 -0
  36. data/lib/coradoc/reference/presentation/single_document.rb +35 -0
  37. data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
  38. data/lib/coradoc/reference/presentation.rb +16 -0
  39. data/lib/coradoc/reference/resolution.rb +184 -0
  40. data/lib/coradoc/reference/resolver/base.rb +15 -0
  41. data/lib/coradoc/reference/resolver/caching.rb +37 -0
  42. data/lib/coradoc/reference/resolver/catalog.rb +69 -0
  43. data/lib/coradoc/reference/resolver/chain.rb +33 -0
  44. data/lib/coradoc/reference/resolver.rb +20 -0
  45. data/lib/coradoc/reference/result/ambiguous.rb +23 -0
  46. data/lib/coradoc/reference/result/base.rb +46 -0
  47. data/lib/coradoc/reference/result/missing.rb +14 -0
  48. data/lib/coradoc/reference/result/resolved.rb +22 -0
  49. data/lib/coradoc/reference/result.rb +20 -0
  50. data/lib/coradoc/reference.rb +65 -0
  51. data/lib/coradoc/validation.rb +40 -0
  52. data/lib/coradoc/version.rb +1 -1
  53. metadata +44 -1
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Coradoc
6
+ module Reference
7
+ # Scheme-aware locator targeting a Content node.
8
+ #
9
+ # An Address is one of six built-in schemes — +:anchor+, +:path+,
10
+ # +:scoped_path+, +:url+, +:doi+, +:isbn+ — selected by heuristic on
11
+ # parse or by an explicit +hint:+. External gems register additional
12
+ # schemes via +Address.register_scheme+ (OCP).
13
+ #
14
+ # Address.parse("foo") # => anchor "foo"
15
+ # Address.parse("ELF-5005-1#sec-3") # => path "ELF-5005-1", fragment "sec-3"
16
+ # Address.parse("ELF:5005:1#sec-3") # => scoped_path scope "ELF"
17
+ # Address.parse("https://x.y/z") # => url
18
+ # Address.parse("10.1234/abc") # => doi
19
+ # Address.parse("ISBN 978-1-2-3") # => isbn
20
+ #
21
+ # Addresses are value types: two addresses are equal iff every
22
+ # attribute matches. Round-trip via +to_s+.
23
+ class Address < Lutaml::Model::Serializable
24
+ class ParseError < Coradoc::Reference::Error; end
25
+ class UnknownSchemeError < Coradoc::Reference::Error; end
26
+
27
+ attribute :scheme, :string
28
+ attribute :target, :string
29
+ attribute :fragment, :string
30
+ attribute :scope, :string
31
+
32
+ autoload :Url, "#{__dir__}/address/url"
33
+ autoload :Doi, "#{__dir__}/address/doi"
34
+ autoload :Isbn, "#{__dir__}/address/isbn"
35
+ autoload :ScopedPath, "#{__dir__}/address/scoped_path"
36
+ autoload :Path, "#{__dir__}/address/path"
37
+ autoload :Anchor, "#{__dir__}/address/anchor"
38
+
39
+ BUILTIN_SCHEME_ORDER = %i[url doi isbn scoped_path path anchor].freeze
40
+
41
+ class << self
42
+ def parse(raw, hint: nil)
43
+ Scheme.ensure_builtins_registered!
44
+ mod = hint ? scheme_for_hint!(hint) : Scheme.match(raw)
45
+ unless mod
46
+ raise ParseError,
47
+ "Cannot determine address scheme for #{raw.inspect}"
48
+ end
49
+ mod.parse(raw)
50
+ end
51
+
52
+ def register_scheme(mod)
53
+ Scheme.register(mod)
54
+ end
55
+
56
+ def scheme_names
57
+ Scheme.ensure_builtins_registered!
58
+ Scheme.names
59
+ end
60
+
61
+ private
62
+
63
+ def scheme_for_hint!(hint)
64
+ Scheme.for(hint) ||
65
+ raise(UnknownSchemeError,
66
+ "Unknown address scheme #{hint.inspect}. " \
67
+ "Registered: #{Scheme.names.inspect}")
68
+ end
69
+ end
70
+
71
+ # Value equality (==/eql?/hash) comes from Lutaml::Model — all
72
+ # attributes compared, class-aware, safe as Hash keys.
73
+
74
+ def to_s
75
+ Scheme.ensure_builtins_registered!
76
+ mod = Scheme.for(scheme)
77
+ unless mod
78
+ raise UnknownSchemeError,
79
+ "No registered scheme for #{scheme.inspect}. " \
80
+ "Registered: #{Scheme.names.inspect}"
81
+ end
82
+ mod.serialize(self)
83
+ end
84
+
85
+ # Registry of scheme modules. Each module provides:
86
+ # scheme_name -> Symbol
87
+ # matches?(raw) -> Boolean
88
+ # parse(raw) -> Address
89
+ # serialize(addr) -> String
90
+ #
91
+ # +register+ always forces builtin registration first, so an
92
+ # external scheme registered before first use is never clobbered
93
+ # by the lazy builtin pass. The catch-all anchor scheme is always
94
+ # tried LAST on match, so external schemes are never shadowed by
95
+ # it no matter when they register.
96
+ module Scheme
97
+ @registered = []
98
+ @builtins_registered = false
99
+ @registering_builtins = false
100
+
101
+ CATCH_ALL_SCHEME = :anchor
102
+ private_constant :CATCH_ALL_SCHEME
103
+
104
+ MUTEX = Mutex.new
105
+ private_constant :MUTEX
106
+
107
+ class << self
108
+ def register(mod)
109
+ ensure_builtins_registered! unless @registering_builtins
110
+ @registered.delete_if { |m| m.scheme_name == mod.scheme_name }
111
+ @registered << mod
112
+ end
113
+
114
+ def names
115
+ @registered.map(&:scheme_name)
116
+ end
117
+
118
+ def for(name)
119
+ @registered.find { |m| m.scheme_name == name.to_sym }
120
+ end
121
+
122
+ def match(raw)
123
+ catch_all, specific =
124
+ @registered.partition { |m| m.scheme_name == CATCH_ALL_SCHEME }
125
+ (specific + catch_all).find { |m| m.matches?(raw) }
126
+ end
127
+
128
+ def ensure_builtins_registered!
129
+ MUTEX.synchronize do
130
+ return if @builtins_registered
131
+
132
+ @registering_builtins = true
133
+ begin
134
+ BUILTIN_SCHEME_ORDER.each do |name|
135
+ const_name = name.to_s.split('_').map(&:capitalize).join
136
+ register(Address.const_get(const_name))
137
+ end
138
+ @builtins_registered = true
139
+ ensure
140
+ @registering_builtins = false
141
+ end
142
+ end
143
+ end
144
+
145
+ def reset!
146
+ @registered.clear
147
+ @builtins_registered = false
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Composes multiple catalogs. Lookup asks EVERY child and merges
7
+ # the hits: exactly one hit returns the Content; several hits
8
+ # return an Array (which the Resolver surfaces per the
9
+ # +ambiguous:+ policy). Precedence between catalogs is a
10
+ # resolver/policy concern, not a lookup concern:
11
+ #
12
+ # Composite.new(
13
+ # Local.from_doc(doc),
14
+ # Collection.from_manifest(...),
15
+ # Remote.new(client: http_client)
16
+ # )
17
+ class Composite
18
+ include Catalog::Protocol
19
+
20
+ attr_reader :children
21
+
22
+ def initialize(*children)
23
+ @children = children.flatten
24
+ end
25
+
26
+ def lookup(address)
27
+ results = []
28
+ children.each do |catalog|
29
+ result = catalog.lookup(address)
30
+ next if result.nil?
31
+
32
+ results.concat(Array(result))
33
+ end
34
+ return nil if results.empty?
35
+ return results.first if results.size == 1
36
+
37
+ results
38
+ end
39
+
40
+ def ambiguous?(address)
41
+ result = lookup(address)
42
+ result.is_a?(Array) && result.size > 1
43
+ end
44
+
45
+ def each_pair(&block)
46
+ return to_enum(:each_pair) unless block_given?
47
+
48
+ children.each do |catalog|
49
+ catalog.each_pair(&block)
50
+ end
51
+ end
52
+
53
+ def recognizes_scheme?(scheme)
54
+ children.any? { |catalog| catalog.recognizes_scheme?(scheme) }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Indexes one CoreModel document. Walks the tree once at
7
+ # construction; indexes every node carrying an +id+ by its anchor
8
+ # Address, plus the document itself by an optional path Address.
9
+ #
10
+ # catalog = Catalog::Local.from_doc(doc, path: "ELF-5005-1")
11
+ # catalog.lookup(Address.parse("ELF-5005-1")) # => doc
12
+ # catalog.lookup(Address.parse("sec-3")) # => section
13
+ class Local
14
+ include Catalog::Protocol
15
+
16
+ attr_reader :document, :document_path
17
+
18
+ def initialize(document:, document_path: nil, index: nil)
19
+ @document = document
20
+ @document_path = document_path
21
+ @index = index || MemoryIndex.new
22
+ populate! unless index
23
+ end
24
+
25
+ class << self
26
+ def from_doc(document, path: nil)
27
+ new(document: document, document_path: path)
28
+ end
29
+ end
30
+
31
+ def lookup(address)
32
+ @index.lookup(address)
33
+ end
34
+
35
+ def ambiguous?(address)
36
+ @index.ambiguous?(address)
37
+ end
38
+
39
+ def each_pair(&)
40
+ @index.each_pair(&)
41
+ end
42
+
43
+ def recognizes_scheme?(scheme)
44
+ return true if scheme.to_sym == :anchor
45
+ return true if scheme.to_sym == :path && document_path
46
+ return true if scheme.to_sym == :scoped_path && document_path&.include?(':')
47
+
48
+ @index.recognizes_scheme?(scheme)
49
+ end
50
+
51
+ private
52
+
53
+ def populate!
54
+ index_document_root!
55
+ walk(document)
56
+ end
57
+
58
+ def index_document_root!
59
+ return unless document_path
60
+
61
+ address = Coradoc::Reference::Address.parse(document_path, hint: path_hint)
62
+ @index.add(address, document)
63
+ end
64
+
65
+ def path_hint
66
+ document_path.include?(':') ? :scoped_path : :path
67
+ end
68
+
69
+ def walk(node)
70
+ return unless node.is_a?(Coradoc::CoreModel::Base)
71
+
72
+ index_node!(node)
73
+ walk_children(node)
74
+ end
75
+
76
+ def index_node!(node)
77
+ return unless node.id
78
+
79
+ address = Coradoc::Reference::Address.new(
80
+ scheme: 'anchor',
81
+ target: node.id
82
+ )
83
+ @index.add(address, node)
84
+ end
85
+
86
+ def walk_children(node)
87
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
88
+
89
+ children = node.children
90
+ return if children.nil? || children.empty?
91
+
92
+ children.each { |child| walk(child) }
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Catalog
6
+ # Shared in-memory index. Built-in catalogs compose this rather
7
+ # than reimplementing the storage layer (DRY). The index is
8
+ # immutable from the outside — entries are added by the catalog
9
+ # during construction, then read-only.
10
+ class MemoryIndex
11
+ include Enumerable
12
+
13
+ def initialize
14
+ @by_address = {}
15
+ @schemes = Set.new
16
+ end
17
+
18
+ def add(address, content)
19
+ raise ArgumentError, 'address required' unless address.is_a?(Coradoc::Reference::Address)
20
+ raise ArgumentError, 'content required' unless content
21
+
22
+ @by_address[address] = Array(@by_address[address]) << content
23
+ @schemes << address.scheme.to_sym
24
+ self
25
+ end
26
+
27
+ def lookup(address)
28
+ entries = @by_address[address]
29
+ return nil unless entries
30
+ return entries.first if entries.size == 1
31
+
32
+ # Copy: callers must not be able to corrupt the index.
33
+ entries.dup
34
+ end
35
+
36
+ def ambiguous?(address)
37
+ entries = @by_address[address]
38
+ entries && entries.size > 1
39
+ end
40
+
41
+ def each_pair
42
+ return to_enum(:each_pair) unless block_given?
43
+
44
+ @by_address.each do |address, contents|
45
+ contents.each { |c| yield address, c }
46
+ end
47
+ end
48
+
49
+ def recognizes_scheme?(scheme)
50
+ @schemes.include?(scheme.to_sym)
51
+ end
52
+
53
+ def size
54
+ @by_address.size
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Externally-built index from Address → Content. coradoc queries
6
+ # catalogs; it never owns the collection truth.
7
+ #
8
+ # A Catalog is any object responding to +lookup+, +each_pair+, and
9
+ # +recognizes_scheme?+. Built-in catalogs share an in-memory index
10
+ # (MemoryIndex) for implementation. Catalogs compose via CompositeCatalog.
11
+ module Catalog
12
+ autoload :MemoryIndex, "#{__dir__}/catalog/memory_index"
13
+ autoload :Local, "#{__dir__}/catalog/local"
14
+ autoload :Composite, "#{__dir__}/catalog/composite"
15
+
16
+ # Protocol methods every Catalog must answer. Implementations may
17
+ # include this module for documentation only; the protocol is
18
+ # duck-typed but explicit at the type-check sites via these method
19
+ # names.
20
+ module Protocol
21
+ # Resolve +address+ to a Content node (CoreModel::Base) or nil.
22
+ def lookup(address) = raise(NotImplementedError)
23
+
24
+ # Enumerate every (Address, Content) pair this catalog knows.
25
+ def each_pair(&) = raise(NotImplementedError)
26
+
27
+ # Does this catalog index addresses of the given scheme? Used by
28
+ # composite catalogs to skip irrelevant children.
29
+ def recognizes_scheme?(scheme) = raise(NotImplementedError)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options for citation edges (bib references). Style is the
7
+ # citation style name (e.g. "ieee", "apa", "chicago"); locality
8
+ # is the optional page/section reference carried separately on
9
+ # Address.fragment, not here.
10
+ class CitationOptions < Edge::Options
11
+ attribute :style, :string
12
+ attribute :suppress_author, :boolean, default: -> { false }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options for footnote references. The footnote body lives in
7
+ # the catalog as a Content node; the Edge points at it.
8
+ class FootnoteRefOptions < Edge::Options
9
+ attribute :footnote_id, :string
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options for image references.
7
+ class ImageRefOptions < Edge::Options
8
+ attribute :alt_text, :string
9
+ attribute :width, :string
10
+ attribute :height, :string
11
+ attribute :role, :string
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options for include edges: embeds the canonical
7
+ # CoreModel::IncludeOptions — the include directive's typed
8
+ # selectors (tags/wildcards, lines, typed leveloffset, indent,
9
+ # encoding). One typed form, never a re-flattened mirror.
10
+ class IncludeOptions < Edge::Options
11
+ attribute :include_options, Coradoc::CoreModel::IncludeOptions
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Registry mapping kind symbol → Entry(name, options_class).
7
+ # Built-in kinds are registered lazily on first access. External
8
+ # gems add kinds via +Edge.register_kind+ (OCP).
9
+ #
10
+ # +register+ always forces builtin registration first, so an
11
+ # external registration made before first use is never clobbered
12
+ # by the lazy builtin pass.
13
+ module Kind
14
+ @entries = {}
15
+ @builtins_registered = false
16
+ @registering_builtins = false
17
+
18
+ MUTEX = Mutex.new
19
+ private_constant :MUTEX
20
+
21
+ Entry = Struct.new(:name, :options_class)
22
+ private_constant :Entry
23
+
24
+ class << self
25
+ def register(name, options_class: nil)
26
+ ensure_builtins_registered! unless @registering_builtins
27
+ @entries[name.to_sym] = Entry.new(name.to_sym, options_class)
28
+ end
29
+
30
+ def names
31
+ ensure_builtins_registered!
32
+ @entries.keys
33
+ end
34
+
35
+ def options_class_for(name)
36
+ ensure_builtins_registered!
37
+ @entries[name.to_sym]&.options_class
38
+ end
39
+
40
+ def entry_for(name)
41
+ ensure_builtins_registered!
42
+ @entries[name.to_sym]
43
+ end
44
+
45
+ def reset!
46
+ @entries.clear
47
+ @builtins_registered = false
48
+ end
49
+
50
+ def ensure_builtins_registered!
51
+ MUTEX.synchronize do
52
+ return if @builtins_registered
53
+
54
+ @registering_builtins = true
55
+ begin
56
+ register(:navigation, options_class: Edge::NavigationOptions)
57
+ register(:citation, options_class: Edge::CitationOptions)
58
+ register(:link, options_class: Edge::LinkOptions)
59
+ register(:include, options_class: Edge::IncludeOptions)
60
+ register(:image_ref, options_class: Edge::ImageRefOptions)
61
+ register(:footnote_ref, options_class: Edge::FootnoteRefOptions)
62
+ @builtins_registered = true
63
+ ensure
64
+ @registering_builtins = false
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options for hyperlink edges.
7
+ class LinkOptions < Edge::Options
8
+ attribute :role, :string
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options specific to navigation edges (xref, anchor).
7
+ class NavigationOptions < Edge::Options
8
+ attribute :tooltip, :string
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Edge < Lutaml::Model::Serializable
6
+ # Options base class. Subclasses are typed value objects per kind —
7
+ # never +:hash+. Value equality (==/eql?/hash) comes from
8
+ # Lutaml::Model: class-aware, all attributes compared.
9
+ class Options < Lutaml::Model::Serializable
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Coradoc
6
+ module Reference
7
+ # Directed edge: a Content node references another Content via an Address.
8
+ #
9
+ # Every reference in coradoc — navigation, citation, hyperlink,
10
+ # include, image, footnote — is one Edge with a different +kind+.
11
+ # The kind is a label for materialization; resolution is kind-agnostic.
12
+ #
13
+ # Edge.new(
14
+ # kind: :navigation,
15
+ # address: Address.parse("ELF-5005-1#sec-3"),
16
+ # source_id: "para-42",
17
+ # label: "Section 3"
18
+ # )
19
+ class Edge < Lutaml::Model::Serializable
20
+ autoload :Kind, "#{__dir__}/edge/kind"
21
+ autoload :Options, "#{__dir__}/edge/options"
22
+ autoload :NavigationOptions,
23
+ "#{__dir__}/edge/navigation_options"
24
+ autoload :CitationOptions,
25
+ "#{__dir__}/edge/citation_options"
26
+ autoload :LinkOptions, "#{__dir__}/edge/link_options"
27
+ autoload :IncludeOptions, "#{__dir__}/edge/include_options"
28
+ autoload :ImageRefOptions, "#{__dir__}/edge/image_ref_options"
29
+ autoload :FootnoteRefOptions,
30
+ "#{__dir__}/edge/footnote_ref_options"
31
+
32
+ attribute :kind, :string
33
+ attribute :address, Coradoc::Reference::Address
34
+ attribute :source_id, :string
35
+ attribute :label, :string
36
+ attribute :options, Coradoc::Reference::Edge::Options
37
+
38
+ class << self
39
+ # Build an Edge with the given kind. Options are coerced to the
40
+ # kind's options class (if any) via the Kind registry — never
41
+ # hand-rolled. Raises UnknownKindError for kinds that were never
42
+ # registered — external kinds register via +register_kind+ (OCP).
43
+ def build(kind:, address:, source_id: nil, label: nil, options: nil)
44
+ entry = Kind.entry_for(kind)
45
+ unless entry
46
+ raise Coradoc::Reference::UnknownKindError,
47
+ "Unknown reference kind #{kind.inspect} — " \
48
+ 'register it via Edge.register_kind'
49
+ end
50
+
51
+ new(
52
+ kind: kind.to_s,
53
+ address: address,
54
+ source_id: source_id,
55
+ label: label,
56
+ options: coerce_options(options, entry.options_class)
57
+ )
58
+ end
59
+
60
+ def register_kind(name, options_class: nil)
61
+ Kind.register(name, options_class: options_class)
62
+ end
63
+
64
+ def kinds
65
+ Kind.names
66
+ end
67
+
68
+ private
69
+
70
+ def coerce_options(value, options_class)
71
+ klass = options_class || Options
72
+ return klass.new if value.nil?
73
+ return value if value.is_a?(klass)
74
+
75
+ klass.new(value)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end