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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f0dbc8feba7133bc83baf88a6458774f364f7266b79625a0afc50434beeac89
4
- data.tar.gz: f4fe999b18666604d2b615545acb073e1b6f74d7b26c3ff8fa8fd403ee45ec5d
3
+ metadata.gz: 9ebca4a6342273f099bbbd2b47471ea28416669106fe33ee08dc929387019334
4
+ data.tar.gz: ee9464a2876d263910ff6f03694a3370dc259a6d040fc7ea27e9ef7a13d66430
5
5
  SHA512:
6
- metadata.gz: c4c0f7a479cfdf622faa4a26cad085c60b4e943d3ba30348e5d0426ebb10c9fbd417ceb5d069225f1e72dd9ff0960ca5e5304572d6d6cf4018236a579c4c391c
7
- data.tar.gz: 6e4e649b83732a5cbf77c1edf16c70fb04a167d2d3b7ad3c3c657a029ebea5b079408dc78641f432241ac8737afb2a46fc1d3b6e2113c01075c332c2efadde9e
6
+ metadata.gz: a0df65e13a8ef23c1a09b31fe9872cf1b5fec5b71528765c4c09651eb63edc9d4ec18d88b64b4276e881a6de04ce1cf8fd75c3b88ab45d2516c5da1fd5160eee
7
+ data.tar.gz: d1ee6224713d08bbd2270917c59f7106235216d2253a0a5639c6079497649561dee38cb6962fc8515ee6688619e71ce745fe365132c9334fd5484796fd1188b4
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.
@@ -2,57 +2,6 @@
2
2
 
3
3
  module Coradoc
4
4
  module CoreModel
5
- # Represents a single item within a list
6
- #
7
- # A list item can contain:
8
- # - Simple text content
9
- # - A nested list
10
- # - Child elements (paragraphs, blocks, etc.)
11
- #
12
- # Note: The nested_list attribute type is set after ListBlock is defined
13
- # to avoid circular dependency issues.
14
- #
15
- # @example Simple list item
16
- # item = ListItem.new(
17
- # marker: "*",
18
- # content: "Item text"
19
- # )
20
- #
21
- # @example List item with nested list
22
- # item = ListItem.new(
23
- # marker: "*",
24
- # content: "Parent item",
25
- # nested_list: nested_list_block
26
- # )
27
- class ListItem < Base
28
- # @!attribute marker
29
- # @return [String, nil] the marker character(s) for this item
30
- # (e.g., '*', '**', '.', '..', '-')
31
- attribute :marker, :string
32
-
33
- # @!attribute content
34
- # @return [String, nil] text content of the list item
35
- attribute :content, :string
36
-
37
- # @!attribute nested_list
38
- # @return [ListBlock, nil] nested list within this item
39
- # Note: Typed as string initially, retyped after ListBlock defined
40
- attribute :nested_list, :string
41
-
42
- private
43
-
44
- # Attributes to compare for semantic equivalence
45
- #
46
- # List items are semantically equivalent if they have the same
47
- # content, nested list, and children, regardless of the specific
48
- # marker used.
49
- #
50
- # @return [Array<Symbol>] list of comparable attributes
51
- def comparable_attributes
52
- %i[content nested_list children]
53
- end
54
- end
55
-
56
5
  # Represents a list block with proper nesting support
57
6
  #
58
7
  # Handles all list types:
@@ -106,7 +55,7 @@ module Coradoc
106
55
 
107
56
  # @!attribute items
108
57
  # @return [Array<ListItem>] collection of list items
109
- attribute :items, ListItem, collection: true
58
+ attribute :items, 'Coradoc::CoreModel::ListItem', collection: true
110
59
 
111
60
  # -- Fluent construction helpers (paired with Base.build) --
112
61
 
@@ -138,15 +87,51 @@ module Coradoc
138
87
  end
139
88
  end
140
89
 
141
- # Re-open ListItem to properly type nested_list now that ListBlock
142
- # is defined
143
- class ListItem
144
- # Remove the temporary string-typed attribute
145
- remove_method :nested_list if method_defined?(:nested_list)
146
- remove_method :nested_list= if method_defined?(:nested_list=)
90
+ # Represents a single item within a list
91
+ #
92
+ # A list item can contain:
93
+ # - Simple text content
94
+ # - A nested list
95
+ # - Child elements (paragraphs, blocks, etc.)
96
+ #
97
+ # @example Simple list item
98
+ # item = ListItem.new(
99
+ # marker: "*",
100
+ # content: "Item text"
101
+ # )
102
+ #
103
+ # @example List item with nested list
104
+ # item = ListItem.new(
105
+ # marker: "*",
106
+ # content: "Parent item",
107
+ # nested_list: nested_list_block
108
+ # )
109
+ class ListItem < Base
110
+ # @!attribute marker
111
+ # @return [String, nil] the marker character(s) for this item
112
+ # (e.g., '*', '**', '.', '..', '-')
113
+ attribute :marker, :string
114
+
115
+ # @!attribute content
116
+ # @return [String, nil] text content of the list item
117
+ attribute :content, :string
118
+
119
+ # @!attribute nested_list
120
+ # @return [ListBlock, nil] nested list within this item
121
+ attribute :nested_list, Coradoc::CoreModel::ListBlock
122
+
123
+ private
147
124
 
148
- # Re-define with proper ListBlock type
149
- attribute :nested_list, ListBlock
125
+ # Attributes to compare for semantic equivalence
126
+ #
127
+ # List items are semantically equivalent if they have the same
128
+ # content, nested list, and children, regardless of the specific
129
+ # marker used.
130
+ #
131
+ # @return [Array<Symbol>] list of comparable attributes
132
+ def comparable_attributes
133
+ %i[content nested_list children]
134
+ end
150
135
  end
151
136
  end
152
137
  end
@@ -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