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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72d8a4fe9a86a7ed8f5dc806492ea147f321367f7a470b3c934747de9ea2b305
|
|
4
|
+
data.tar.gz: '042824f44538b6b64252c0667eb8d56f2ed059de0dab8866631c84394bee6a64'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/coradoc/coradoc.rb
CHANGED
|
@@ -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.
|
data/lib/coradoc/errors.rb
CHANGED
|
@@ -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.
|
data/lib/coradoc/pipeline.rb
CHANGED
|
@@ -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
|