coradoc 2.0.27 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +9 -6
  3. data/lib/coradoc/configurable.rb +6 -3
  4. data/lib/coradoc/coradoc.rb +5 -2
  5. data/lib/coradoc/core_model/attribute_reference_resolver.rb +19 -7
  6. data/lib/coradoc/core_model/base.rb +45 -2
  7. data/lib/coradoc/core_model/comment_line.rb +1 -1
  8. data/lib/coradoc/core_model/definition_item.rb +22 -1
  9. data/lib/coradoc/core_model/horizontal_rule_block.rb +4 -0
  10. data/lib/coradoc/core_model/include_level_offset.rb +1 -1
  11. data/lib/coradoc/core_model/include_options.rb +1 -0
  12. data/lib/coradoc/core_model/inline_content.rb +1 -0
  13. data/lib/coradoc/core_model/list_block.rb +1 -1
  14. data/lib/coradoc/core_model/listing_block.rb +4 -0
  15. data/lib/coradoc/core_model/literal_block.rb +4 -0
  16. data/lib/coradoc/core_model/pass_block.rb +4 -0
  17. data/lib/coradoc/core_model/source_block.rb +4 -0
  18. data/lib/coradoc/core_model/stem_block.rb +4 -0
  19. data/lib/coradoc/dispatch.rb +1 -0
  20. data/lib/coradoc/errors.rb +19 -0
  21. data/lib/coradoc/format_catalog.rb +19 -0
  22. data/lib/coradoc/format_module.rb +10 -0
  23. data/lib/coradoc/hooks.rb +1 -1
  24. data/lib/coradoc/include_selectors/level_offset.rb +0 -2
  25. data/lib/coradoc/include_selectors/lines.rb +2 -2
  26. data/lib/coradoc/include_selectors/tags.rb +2 -4
  27. data/lib/coradoc/introspection/element_counter.rb +2 -2
  28. data/lib/coradoc/link_rewriter/visitor.rb +5 -14
  29. data/lib/coradoc/pipeline.rb +64 -5
  30. data/lib/coradoc/query.rb +8 -8
  31. data/lib/coradoc/reference/address/anchor.rb +48 -0
  32. data/lib/coradoc/reference/address/doi.rb +40 -0
  33. data/lib/coradoc/reference/address/isbn.rb +39 -0
  34. data/lib/coradoc/reference/address/path.rb +66 -0
  35. data/lib/coradoc/reference/address/scoped_path.rb +47 -0
  36. data/lib/coradoc/reference/address/url.rb +41 -0
  37. data/lib/coradoc/reference/address.rb +153 -0
  38. data/lib/coradoc/reference/catalog/composite.rb +59 -0
  39. data/lib/coradoc/reference/catalog/local.rb +97 -0
  40. data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
  41. data/lib/coradoc/reference/catalog.rb +33 -0
  42. data/lib/coradoc/reference/edge/citation_options.rb +16 -0
  43. data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
  44. data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
  45. data/lib/coradoc/reference/edge/include_options.rb +15 -0
  46. data/lib/coradoc/reference/edge/kind.rb +72 -0
  47. data/lib/coradoc/reference/edge/link_options.rb +12 -0
  48. data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
  49. data/lib/coradoc/reference/edge/options.rb +13 -0
  50. data/lib/coradoc/reference/edge.rb +80 -0
  51. data/lib/coradoc/reference/edge_search.rb +128 -0
  52. data/lib/coradoc/reference/materializer/base.rb +44 -0
  53. data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
  54. data/lib/coradoc/reference/materializer/registry.rb +116 -0
  55. data/lib/coradoc/reference/materializer.rb +14 -0
  56. data/lib/coradoc/reference/presentation/base.rb +47 -0
  57. data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
  58. data/lib/coradoc/reference/presentation/page.rb +20 -0
  59. data/lib/coradoc/reference/presentation/single_document.rb +35 -0
  60. data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
  61. data/lib/coradoc/reference/presentation.rb +16 -0
  62. data/lib/coradoc/reference/resolution.rb +184 -0
  63. data/lib/coradoc/reference/resolver/base.rb +15 -0
  64. data/lib/coradoc/reference/resolver/caching.rb +37 -0
  65. data/lib/coradoc/reference/resolver/catalog.rb +69 -0
  66. data/lib/coradoc/reference/resolver/chain.rb +33 -0
  67. data/lib/coradoc/reference/resolver.rb +20 -0
  68. data/lib/coradoc/reference/result/ambiguous.rb +23 -0
  69. data/lib/coradoc/reference/result/base.rb +46 -0
  70. data/lib/coradoc/reference/result/missing.rb +14 -0
  71. data/lib/coradoc/reference/result/resolved.rb +22 -0
  72. data/lib/coradoc/reference/result.rb +20 -0
  73. data/lib/coradoc/reference.rb +65 -0
  74. data/lib/coradoc/registry.rb +6 -6
  75. data/lib/coradoc/resolve_includes.rb +2 -2
  76. data/lib/coradoc/validation.rb +44 -4
  77. data/lib/coradoc/version.rb +1 -1
  78. data/lib/coradoc.rb +1 -1
  79. metadata +45 -1
@@ -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
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Walks a CoreModel tree and yields every Edge found in it. For
6
+ # Phase 1, Edges are wrapped inside existing node types —
7
+ # +CrossReferenceElement+, +LinkElement+, +Image+, +Include+,
8
+ # +FootnoteElement+. The Phase 3 migration will move Edges to
9
+ # first-class attributes; this module abstracts the extraction so
10
+ # the rest of the resolver doesn't change.
11
+ #
12
+ # The walker is read-only: it never mutates the input tree.
13
+ module EdgeSearch
14
+ module_function
15
+
16
+ # Yields (parent_node, edge) for every Edge found in the tree.
17
+ # The parent_node is the node that owns the edge — the
18
+ # materializer replaces the edge in-place inside parent_node's
19
+ # children list.
20
+ def each_edge(root)
21
+ return to_enum(:each_edge, root) unless block_given?
22
+
23
+ walk(root) { |node| edges_for(node).each { |edge| yield node, edge } }
24
+ end
25
+
26
+ # Walk tree, yield each Base node. Stops at inline leaves.
27
+ def walk(node, &block)
28
+ return unless node.is_a?(Coradoc::CoreModel::Base)
29
+ return unless block
30
+
31
+ yield(node)
32
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
33
+
34
+ children = node.children
35
+ return unless children
36
+
37
+ children.each { |child| walk(child, &block) }
38
+ end
39
+
40
+ # Extract every Edge the given node owns. Pure function of node.
41
+ def edges_for(node)
42
+ edge_extractor = EDGE_EXTRACTORS[node.class]
43
+ return [] unless edge_extractor
44
+
45
+ edge = edge_extractor.call(node)
46
+ edge ? [edge] : []
47
+ end
48
+
49
+ def edge_from_cross_reference(node)
50
+ Edge.build(
51
+ kind: :navigation,
52
+ address: parse_address(node.target, hint: xref_hint(node.target)),
53
+ source_id: node.id,
54
+ label: node.content
55
+ )
56
+ end
57
+
58
+ def edge_from_link(node)
59
+ Edge.build(
60
+ kind: :link,
61
+ address: parse_address(node.target),
62
+ source_id: node.id,
63
+ label: node.content
64
+ )
65
+ end
66
+
67
+ def edge_from_include(node)
68
+ Edge.build(
69
+ kind: :include,
70
+ address: parse_address(node.target, hint: path_or_url_hint(node.target)),
71
+ source_id: node.id,
72
+ options: { include_options: node.options }
73
+ )
74
+ end
75
+
76
+ def edge_from_image(node)
77
+ Edge.build(
78
+ kind: :image_ref,
79
+ address: parse_address(node.src, hint: path_or_url_hint(node.src)),
80
+ source_id: node.id,
81
+ label: node.alt,
82
+ options: { alt_text: node.alt }
83
+ )
84
+ end
85
+
86
+ def edge_from_footnote(node)
87
+ Edge.build(
88
+ kind: :footnote_ref,
89
+ address: parse_address(node.target || node.id, hint: :anchor),
90
+ source_id: node.id,
91
+ options: { footnote_id: node.id }
92
+ )
93
+ end
94
+
95
+ # Xrefs are document-internal by definition (AsciiDoc): bare
96
+ # targets are anchors, even uppercase document-ID-shaped ones
97
+ # ("SEC-2"). Only an explicit "document#fragment" shape or a URL
98
+ # points outside the current document.
99
+ def xref_hint(target)
100
+ raw = target.to_s
101
+ return :url if Coradoc::Reference::Address::Url.matches?(raw)
102
+ return :path if raw.include?('#') && !raw.start_with?('#')
103
+
104
+ :anchor
105
+ end
106
+
107
+ # File-like targets (include, image): URLs stay urls, everything
108
+ # else is a path — including bare filenames ("foo.png") that the
109
+ # anchor bareword heuristic would otherwise claim.
110
+ def path_or_url_hint(target)
111
+ Coradoc::Reference::Address::Url.matches?(target.to_s) ? :url : :path
112
+ end
113
+
114
+ def parse_address(target, hint: nil)
115
+ Coradoc::Reference::Address.parse(target.to_s, hint: hint)
116
+ end
117
+
118
+ EDGE_EXTRACTORS = {
119
+ Coradoc::CoreModel::CrossReferenceElement => method(:edge_from_cross_reference),
120
+ Coradoc::CoreModel::LinkElement => method(:edge_from_link),
121
+ Coradoc::CoreModel::Include => method(:edge_from_include),
122
+ Coradoc::CoreModel::Image => method(:edge_from_image),
123
+ Coradoc::CoreModel::FootnoteElement => method(:edge_from_footnote)
124
+ }.freeze
125
+ private_constant :EDGE_EXTRACTORS
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Materializer
6
+ # Protocol base. Subclasses declare their (kind, presentation,
7
+ # format) tuple via class-level +.kind+, +.presentation+, +.format+
8
+ # methods, then implement +#materialize+ to produce a
9
+ # CoreModel::InlineElement subtree.
10
+ #
11
+ # Materializers consume Results — they never touch the catalog
12
+ # directly. The Presentation tells them where the target lives.
13
+ class Base
14
+ class << self
15
+ def kind
16
+ nil
17
+ end
18
+
19
+ def presentation
20
+ :any
21
+ end
22
+
23
+ def format
24
+ :any
25
+ end
26
+ end
27
+
28
+ # Render one resolved edge.
29
+ #
30
+ # @param edge [Edge]
31
+ # @param result [Result::Base] the resolved outcome
32
+ # @param node [CoreModel::Base] the original edge-bearing node;
33
+ # return it unchanged to keep the node as authored
34
+ # @param presentation [Presentation::Base]
35
+ # @param pages [Array<Presentation::Page>]
36
+ # @return [CoreModel::Base, nil] replacement node, the original
37
+ # +node+ to keep it, or nil to drop it from the tree
38
+ def materialize(edge:, result:, node:, presentation:, pages:)
39
+ raise NotImplementedError
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Materializer
6
+ # Default fallback. Returns the original edge-bearing node
7
+ # unchanged — an unresolvable or unrenderable reference survives
8
+ # materialization exactly as authored, so round-tripping is
9
+ # always safe and no content is ever lost.
10
+ class Passthrough < Base
11
+ class << self
12
+ def kind
13
+ :any
14
+ end
15
+
16
+ def presentation
17
+ :any
18
+ end
19
+
20
+ def format
21
+ :any
22
+ end
23
+ end
24
+
25
+ def materialize(node:, **)
26
+ node
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Materializer
6
+ # Registry of materializers keyed by [kind, presentation, format].
7
+ # Lookup falls back on +:any+ — so a materializer registered for
8
+ # +[:link, :any, :html]+ handles every presentation in HTML.
9
+ # Most specific key wins (concrete > :any).
10
+ #
11
+ # Format gems register their materializers process-wide via
12
+ # +.register_global+ (OCP): every registry instance picks them up
13
+ # on first use, and dispatch code never changes.
14
+ class Registry
15
+ # Core ships only the node-preserving fallback. Format-specific
16
+ # materializers live in the format gems (coradoc-html,
17
+ # coradoc-adoc, ...) and register globally at load time.
18
+ BUILTINS = [Materializer::Passthrough].freeze
19
+
20
+ @global_registrations = []
21
+
22
+ GLOBAL_MUTEX = Mutex.new
23
+ private_constant :GLOBAL_MUTEX
24
+
25
+ class << self
26
+ def register_global(klass)
27
+ GLOBAL_MUTEX.synchronize do
28
+ @global_registrations << klass unless @global_registrations.include?(klass)
29
+ end
30
+ end
31
+
32
+ def global_registrations
33
+ GLOBAL_MUTEX.synchronize { @global_registrations.dup }
34
+ end
35
+
36
+ def reset_globals!
37
+ GLOBAL_MUTEX.synchronize { @global_registrations.clear }
38
+ end
39
+ end
40
+
41
+ def initialize
42
+ @by_key = {}
43
+ @builtins_registered = false
44
+ end
45
+
46
+ def register(klass)
47
+ ensure_builtins_registered!
48
+ @by_key[EntryKey.new(klass.kind, klass.presentation, klass.format)] = klass
49
+ end
50
+
51
+ def lookup(kind:, presentation:, format:)
52
+ ensure_builtins_registered!
53
+ find_most_specific(kind, presentation, format) ||
54
+ find_most_specific(kind, presentation, :any) ||
55
+ find_most_specific(kind, :any, format) ||
56
+ find_most_specific(kind, :any, :any) ||
57
+ find_most_specific(:any, :any, :any)
58
+ end
59
+
60
+ def registered
61
+ ensure_builtins_registered!
62
+ @by_key.dup
63
+ end
64
+
65
+ def reset!
66
+ @by_key.clear
67
+ @builtins_registered = false
68
+ end
69
+
70
+ private
71
+
72
+ def find_most_specific(kind, presentation, format)
73
+ @by_key[EntryKey.new(kind.to_sym, presentation.to_sym, format.to_sym)]
74
+ end
75
+
76
+ def ensure_builtins_registered!
77
+ return if @builtins_registered
78
+
79
+ register_builtins!
80
+ @builtins_registered = true
81
+ end
82
+
83
+ def register_builtins!
84
+ (BUILTINS + self.class.global_registrations).each do |k|
85
+ @by_key[EntryKey.new(k.kind, k.presentation, k.format)] = k
86
+ end
87
+ end
88
+
89
+ # Internal composite key for the registry. Equality based on
90
+ # the three symbol values so two equivalent tuples compare equal.
91
+ class EntryKey
92
+ attr_reader :kind, :presentation, :format
93
+
94
+ def initialize(kind, presentation, format)
95
+ @kind = kind.to_sym
96
+ @presentation = presentation.to_sym
97
+ @format = format.to_sym
98
+ end
99
+
100
+ def ==(other)
101
+ other.is_a?(EntryKey) &&
102
+ kind == other.kind &&
103
+ presentation == other.presentation &&
104
+ format == other.format
105
+ end
106
+ alias eql? ==
107
+
108
+ def hash
109
+ [kind, presentation, format].hash
110
+ end
111
+ end
112
+ private_constant :EntryKey
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Render an Edge per (kind, presentation, format). Registry-based:
6
+ # adding a new output format or citation style is registering a
7
+ # new materializer, not editing switch statements.
8
+ module Materializer
9
+ autoload :Base, "#{__dir__}/materializer/base"
10
+ autoload :Registry, "#{__dir__}/materializer/registry"
11
+ autoload :Passthrough, "#{__dir__}/materializer/passthrough"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Protocol base. Subclasses implement +#layout+ (resolved graph →
7
+ # Pages) and +#locate_page+ (find which page a target lives on).
8
+ #
9
+ # A Presentation NEVER reads the original document — it consumes
10
+ # the resolved graph produced by the Resolver. It never renders.
11
+ class Base
12
+ # Registry key used in materializer lookup tuples
13
+ # [kind, presentation, format]. Subclasses override so
14
+ # materializers can target a specific layout.
15
+ def self.key
16
+ :any
17
+ end
18
+
19
+ def key
20
+ self.class.key
21
+ end
22
+
23
+ # Lay out the resolved graph as a tree of Pages. Subclasses
24
+ # decide slicing, ordering, hierarchy.
25
+ #
26
+ # @param resolved_graph [CoreModel::Base] the document the
27
+ # caller wants to present. Already resolved (edges → targets).
28
+ # @return [Array<Page>]
29
+ def layout(resolved_graph)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # Given an Edge and its resolved target, find the Page where
34
+ # the target lives. This is what makes cross-references
35
+ # survive re-pagination.
36
+ #
37
+ # @param edge [Edge]
38
+ # @param target_content [CoreModel::Base]
39
+ # @param pages [Array<Page>]
40
+ # @return [Page, nil]
41
+ def locate_page(edge, target_content, pages:)
42
+ raise NotImplementedError
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end