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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f0dbc8feba7133bc83baf88a6458774f364f7266b79625a0afc50434beeac89
4
- data.tar.gz: f4fe999b18666604d2b615545acb073e1b6f74d7b26c3ff8fa8fd403ee45ec5d
3
+ metadata.gz: 72d8a4fe9a86a7ed8f5dc806492ea147f321367f7a470b3c934747de9ea2b305
4
+ data.tar.gz: '042824f44538b6b64252c0667eb8d56f2ed059de0dab8866631c84394bee6a64'
5
5
  SHA512:
6
- metadata.gz: c4c0f7a479cfdf622faa4a26cad085c60b4e943d3ba30348e5d0426ebb10c9fbd417ceb5d069225f1e72dd9ff0960ca5e5304572d6d6cf4018236a579c4c391c
7
- data.tar.gz: 6e4e649b83732a5cbf77c1edf16c70fb04a167d2d3b7ad3c3c657a029ebea5b079408dc78641f432241ac8737afb2a46fc1d3b6e2113c01075c332c2efadde9e
6
+ metadata.gz: 6a603de0773f6b27449ccb511c3ec53860e6ec37aeb55a62f63f5902b7d0c4c0e74c5681dd1749b5f09411bd04ca34ff7bca45e4da4f9e8d4bd74f4cec3972b9
7
+ data.tar.gz: ebbc0d4ae30878f6793964a437dfd62a0ba79aafdfa57f3307d6e0b5c865650627bb6f025aa94f2c18813b16973819e3358a1458eecd3d6d9db5ac47af99efb8
data/lib/coradoc/cli.rb CHANGED
@@ -32,6 +32,9 @@ module Coradoc
32
32
  default: 64
33
33
  option :allow_unsafe_includes, desc: 'Disable path-traversal protection (asciidoctor :unsafe mode)',
34
34
  type: :boolean, default: false
35
+ option :allow_unresolved_includes, desc: 'Allow serialization with unresolved includes ' \
36
+ '(formats that cannot represent them drop them)',
37
+ type: :boolean, default: false
35
38
  def convert(file)
36
39
  source_format = resolve_format(file, :from)
37
40
  target_format = options[:to] ? Coradoc.normalize_format(options[:to]) : Coradoc.resolve_output_format(options[:output])
@@ -213,7 +216,7 @@ module Coradoc
213
216
 
214
217
  CONVERT_OPTIONS = %i[
215
218
  toc toc_levels section_numbers section_number_levels
216
- lang theme asset_delivery
219
+ lang theme asset_delivery allow_unresolved_includes
217
220
  ].freeze
218
221
  private_constant :CONVERT_OPTIONS
219
222
 
@@ -70,6 +70,8 @@ module Coradoc
70
70
 
71
71
  def resolve_includes(document, **) = Pipeline.resolve_includes(document, **)
72
72
 
73
+ def resolve_references(document, **) = Pipeline.resolve_references(document, **)
74
+
73
75
  def rewrite_links(...) = Pipeline.rewrite_links(...)
74
76
 
75
77
  def convert(text, **) = Pipeline.convert(text, **)
@@ -150,6 +152,7 @@ module Coradoc
150
152
  autoload :FormatCatalog, "#{__dir__}/format_catalog"
151
153
  autoload :Introspection, "#{__dir__}/introspection"
152
154
  autoload :Dispatch, "#{__dir__}/dispatch"
155
+ autoload :Reference, "#{__dir__}/reference"
153
156
  end
154
157
 
155
158
  # Format gems self-register via Coradoc.register_format when they are required.
@@ -295,6 +295,25 @@ module Coradoc
295
295
  end
296
296
  end
297
297
 
298
+ # Raised when a document still contains unresolved +include::+ edges
299
+ # at serialization time and the target format cannot represent them
300
+ # (it would silently drop the content). Resolve the graph first with
301
+ # +Coradoc.resolve_includes+, or opt out with
302
+ # +allow_unresolved_includes: true+.
303
+ class UnresolvedIncludesError < Error
304
+ attr_reader :targets
305
+
306
+ def initialize(targets)
307
+ @targets = targets
308
+ super(
309
+ "Document has #{targets.size} unresolved include directive(s): " \
310
+ "#{targets.join(', ')}. Resolve them before serializing with " \
311
+ 'Coradoc.resolve_includes(doc, base_dir: ...), or pass ' \
312
+ 'allow_unresolved_includes: true.'
313
+ )
314
+ end
315
+ end
316
+
298
317
  # Error raised when an include chain exceeds the configured depth limit.
299
318
  class IncludeDepthExceededError < Error
300
319
  attr_reader :depth, :target
@@ -30,6 +30,16 @@ module Coradoc
30
30
  true
31
31
  end
32
32
 
33
+ # Whether the serializer can represent unresolved include edges
34
+ # (graph-mode CoreModel::Include nodes) without losing them.
35
+ # Formats that round-trip the directive natively (asciidoc,
36
+ # mirror) or preserve it explicitly (markdown comments) override
37
+ # this to true; formats that would silently drop the content keep
38
+ # the default false, and the pipeline raises instead of losing it.
39
+ def preserves_unresolved_includes?
40
+ false
41
+ end
42
+
33
43
  # Parse a file to CoreModel, handling file-specific concerns like include resolution.
34
44
  # Format modules that support include directives or file-relative references
35
45
  # should override this method.
@@ -46,6 +46,63 @@ module Coradoc
46
46
  Coradoc::LinkRewriter.rewrite(document, rewriter: rewriter, &)
47
47
  end
48
48
 
49
+ # Resolve every reference (xref, citation, link, include, image,
50
+ # footnote) in a parsed document using a unified content-graph
51
+ # model. Mirrors +resolve_includes+ in shape: two-step, immutable.
52
+ #
53
+ # Step one always runs: every Edge is resolved through the
54
+ # Resolver and the +missing+/+ambiguous+ policies are enforced
55
+ # (raise or warn). Step two is opt-in: with +materialize: true+
56
+ # the tree is rebuilt with each Edge replaced by the output of
57
+ # the Materializer registered for its
58
+ # [kind, presentation, format] tuple.
59
+ #
60
+ # The input document is never mutated. With +materialize: false+
61
+ # the input document itself is returned; with +materialize: true+
62
+ # a new document is returned that structurally shares untouched
63
+ # subtrees with the input (treat both as immutable). Nodes whose
64
+ # kind has no registered materializer are preserved unchanged.
65
+ #
66
+ # @param document [CoreModel::Base] parsed document
67
+ # @param catalog [Reference::Catalog::*] index of addressable Content
68
+ # @param presentation [Reference::Presentation::Base] slicing and ordering
69
+ # @param resolver [Reference::Resolver::Base, nil] defaults to CatalogResolver
70
+ # @param missing [Symbol] :warn (default), :silent, :error, :passthrough
71
+ # @param ambiguous [Symbol] :disambiguate (default), :first, :error
72
+ # @param materialize [Boolean] when true, replace edges with rendered inlines
73
+ # @param format [Symbol, nil] target format for materializer lookup
74
+ # (:html, :asciidoc, ...); nil matches format-agnostic materializers
75
+ # @return [CoreModel::Base] the input document (validation only) or
76
+ # a new materialized document
77
+ #
78
+ # @example Resolve cross-references into HTML links
79
+ # doc = Coradoc.parse(text, format: :asciidoc)
80
+ # catalog = Coradoc::Reference::Catalog::Local.from_doc(doc)
81
+ # presentation = Coradoc::Reference::Presentation::SingleDocument.new
82
+ # resolved = Coradoc.resolve_references(
83
+ # doc,
84
+ # catalog: catalog,
85
+ # presentation: presentation,
86
+ # materialize: true,
87
+ # format: :html
88
+ # )
89
+ def resolve_references(document, catalog:, presentation:,
90
+ resolver: nil,
91
+ missing: :warn,
92
+ ambiguous: :disambiguate,
93
+ materialize: false,
94
+ format: nil)
95
+ Coradoc::Reference::Resolution.new(
96
+ catalog: catalog,
97
+ presentation: presentation,
98
+ resolver: resolver,
99
+ missing: missing,
100
+ ambiguous: ambiguous,
101
+ materialize: materialize,
102
+ format: format
103
+ ).call(document)
104
+ end
105
+
49
106
  def convert(text, from:, to:, **)
50
107
  core = parse(text, format: from)
51
108
  serialize(core, to: to, **)
@@ -63,10 +120,12 @@ module Coradoc
63
120
  raise TransformationError, "No transformer found for #{model.class}"
64
121
  end
65
122
 
66
- def serialize(model, to:, **)
123
+ def serialize(model, to:, allow_unresolved_includes: false, **)
67
124
  format_module = FormatCatalog.get_format(to)
68
125
  raise UnsupportedFormatError.new(to, available: FormatCatalog.registered_formats) unless format_module
69
126
 
127
+ Coradoc::Validation.guard_unresolved_includes!(model, format_module) unless allow_unresolved_includes
128
+
70
129
  model = Hooks.invoke(:before_serialize, model, format: to)
71
130
  result = format_module.serialize(model, **)
72
131
  Hooks.invoke(:after_serialize, result, format: to)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # In-document anchor — the most common navigation target.
7
+ # Matches raw strings starting with "#" plus bareword names that
8
+ # don't look like document IDs or external URLs.
9
+ #
10
+ # "#intro" => anchor target "intro"
11
+ # "intro" => anchor target "intro"
12
+ # "footnote-1" => anchor target "footnote-1"
13
+ module Anchor
14
+ module_function
15
+
16
+ def scheme_name
17
+ :anchor
18
+ end
19
+
20
+ def matches?(raw)
21
+ return false if raw.nil? || raw.empty?
22
+
23
+ stripped = raw.to_s
24
+ return false if stripped == '#'
25
+ return true if stripped.start_with?('#')
26
+
27
+ stripped.match?(/\A[\w.\-]+\z/)
28
+ end
29
+
30
+ def parse(raw)
31
+ target = raw.to_s.delete_prefix('#')
32
+ new_address(target: target)
33
+ end
34
+
35
+ def serialize(address)
36
+ target = address.target
37
+ return '' if target.nil? || target.empty?
38
+
39
+ "##{target}"
40
+ end
41
+
42
+ def new_address(target:)
43
+ Address.new(scheme: 'anchor', target: target)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # DOI — Digital Object Identifier. Regex per the DOI handbook.
7
+ module Doi
8
+ module_function
9
+
10
+ DOI_PATTERN = %r{\A(10\.\d{4,}/[^\s#]+)(?:#(.*))?\z}
11
+
12
+ def scheme_name
13
+ :doi
14
+ end
15
+
16
+ def matches?(raw)
17
+ return false if raw.nil? || raw.empty?
18
+
19
+ DOI_PATTERN.match?(raw.to_s)
20
+ end
21
+
22
+ def parse(raw)
23
+ match = DOI_PATTERN.match(raw.to_s)
24
+ raise Address::ParseError, "Invalid DOI: #{raw.inspect}" unless match
25
+
26
+ target = match[1]
27
+ fragment = match[2]
28
+ Address.new(scheme: 'doi', target: target, fragment: fragment)
29
+ end
30
+
31
+ def serialize(address)
32
+ base = address.target.to_s
33
+ return base unless address.fragment && !address.fragment.empty?
34
+
35
+ "#{base}##{address.fragment}"
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # ISBN — book identifier. Recognizes an optional "ISBN" prefix
7
+ # (case insensitive) and either ISBN-10 or ISBN-13 digits. Bare
8
+ # numbers must be long enough to be ISBN-shaped so short numeric
9
+ # anchors are not claimed.
10
+ module Isbn
11
+ module_function
12
+
13
+ ISBN_PATTERN = /\A(?:ISBN\s+)?(\d[\d\-Xx]{8,16})\z/i
14
+
15
+ def scheme_name
16
+ :isbn
17
+ end
18
+
19
+ def matches?(raw)
20
+ return false if raw.nil? || raw.empty?
21
+
22
+ ISBN_PATTERN.match?(raw.to_s)
23
+ end
24
+
25
+ def parse(raw)
26
+ match = ISBN_PATTERN.match(raw.to_s)
27
+ raise Address::ParseError, "Invalid ISBN: #{raw.inspect}" unless match
28
+
29
+ target = match[1]
30
+ Address.new(scheme: 'isbn', target: target)
31
+ end
32
+
33
+ def serialize(address)
34
+ "ISBN #{address.target}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Inter-document path — a document identifier plus optional fragment.
7
+ # Matches document-ID-shaped barewords (uppercase + dash + digit,
8
+ # e.g. "ELF-5005-1") and relative/absolute file paths
9
+ # ("images/foo.png", "../shared/common.adoc"). Locality is carried
10
+ # in fragment.
11
+ #
12
+ # "ELF-5005-1" => path target "ELF-5005-1"
13
+ # "ELF-5005-1#sec-3" => path target "ELF-5005-1", fragment "sec-3"
14
+ # "images/foo.png" => path target "images/foo.png"
15
+ module Path
16
+ module_function
17
+
18
+ # Doc-ID pattern: uppercase-led bareword ("ELF-5005-1"). Used
19
+ # together with a separate digit scan — two linear passes, no
20
+ # ambiguous adjacent quantifiers (polynomial-ReDoS-safe).
21
+ DOC_ID_PATTERN = /\A[A-Z][A-Z0-9_\-]*\z/
22
+ # Loose pattern used by +parse+ when the scheme is already chosen
23
+ # (via hint). Accepts any non-empty target plus optional fragment.
24
+ LOOSE_PATTERN = /\A([^#]+)(?:#(.*))?\z/
25
+
26
+ def scheme_name
27
+ :path
28
+ end
29
+
30
+ def matches?(raw)
31
+ return false if raw.nil? || raw.empty?
32
+
33
+ value = raw.to_s
34
+ return true if value.include?('/')
35
+
36
+ # Document IDs are uppercase-led and contain at least one digit
37
+ # (what distinguishes them from anchors like "SECTION").
38
+ id_part = value.split('#', 2).first
39
+ DOC_ID_PATTERN.match?(id_part) && id_part.match?(/\d/)
40
+ end
41
+
42
+ def parse(raw)
43
+ match = LOOSE_PATTERN.match(raw.to_s)
44
+ raise Address::ParseError, "Invalid path: #{raw.inspect}" unless match
45
+
46
+ Address.new(
47
+ scheme: 'path',
48
+ target: match[1],
49
+ fragment: normalize_fragment(match[2])
50
+ )
51
+ end
52
+
53
+ def serialize(address)
54
+ base = address.target.to_s
55
+ return base unless address.fragment && !address.fragment.empty?
56
+
57
+ "#{base}##{address.fragment}"
58
+ end
59
+
60
+ def normalize_fragment(fragment)
61
+ fragment&.empty? ? nil : fragment
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Scoped path — a path within a collection's namespace. The scope
7
+ # identifies the collection (e.g. "ELF"); the target is the path
8
+ # inside it ("5005:1"); the fragment is locality within that doc.
9
+ #
10
+ # "ELF:5005:1#sec-3" => scope "ELF", target "5005:1", fragment "sec-3"
11
+ module ScopedPath
12
+ module_function
13
+
14
+ SCOPED_PATTERN = /\A([A-Z][A-Z0-9_\-]*):([\d:]+)(?:#(.*))?\z/
15
+
16
+ def scheme_name
17
+ :scoped_path
18
+ end
19
+
20
+ def matches?(raw)
21
+ return false if raw.nil? || raw.empty?
22
+
23
+ SCOPED_PATTERN.match?(raw.to_s)
24
+ end
25
+
26
+ def parse(raw)
27
+ match = SCOPED_PATTERN.match(raw.to_s)
28
+ raise Address::ParseError, "Invalid scoped path: #{raw.inspect}" unless match
29
+
30
+ Address.new(
31
+ scheme: 'scoped_path',
32
+ scope: match[1],
33
+ target: match[2],
34
+ fragment: match[3]
35
+ )
36
+ end
37
+
38
+ def serialize(address)
39
+ base = "#{address.scope}:#{address.target}"
40
+ return base unless address.fragment && !address.fragment.empty?
41
+
42
+ "#{base}##{address.fragment}"
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ class Address < Lutaml::Model::Serializable
6
+ # Absolute URL — anything with a recognized URL scheme prefix.
7
+ # Fragment is the optional "#..." suffix.
8
+ module Url
9
+ module_function
10
+
11
+ URL_PATTERN = %r{\A([a-z][a-z0-9+\-.]*)://([^#]+)(?:#(.*))?\z}i
12
+
13
+ def scheme_name
14
+ :url
15
+ end
16
+
17
+ def matches?(raw)
18
+ return false if raw.nil? || raw.empty?
19
+
20
+ URL_PATTERN.match?(raw.to_s)
21
+ end
22
+
23
+ def parse(raw)
24
+ match = URL_PATTERN.match(raw.to_s)
25
+ raise Address::ParseError, "Invalid URL: #{raw.inspect}" unless match
26
+
27
+ target = "#{match[1]}://#{match[2]}"
28
+ fragment = match[3]
29
+ Address.new(scheme: 'url', target: target, fragment: fragment)
30
+ end
31
+
32
+ def serialize(address)
33
+ base = address.target.to_s
34
+ return base unless address.fragment && !address.fragment.empty?
35
+
36
+ "#{base}##{address.fragment}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -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