coradoc 2.0.28 → 2.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/lib/coradoc/cli.rb +4 -1
  3. data/lib/coradoc/coradoc.rb +3 -0
  4. data/lib/coradoc/core_model/list_block.rb +45 -60
  5. data/lib/coradoc/errors.rb +19 -0
  6. data/lib/coradoc/format_module.rb +10 -0
  7. data/lib/coradoc/pipeline.rb +60 -1
  8. data/lib/coradoc/reference/address/anchor.rb +48 -0
  9. data/lib/coradoc/reference/address/doi.rb +40 -0
  10. data/lib/coradoc/reference/address/isbn.rb +39 -0
  11. data/lib/coradoc/reference/address/path.rb +66 -0
  12. data/lib/coradoc/reference/address/scoped_path.rb +47 -0
  13. data/lib/coradoc/reference/address/url.rb +41 -0
  14. data/lib/coradoc/reference/address.rb +153 -0
  15. data/lib/coradoc/reference/catalog/composite.rb +59 -0
  16. data/lib/coradoc/reference/catalog/local.rb +97 -0
  17. data/lib/coradoc/reference/catalog/memory_index.rb +59 -0
  18. data/lib/coradoc/reference/catalog.rb +33 -0
  19. data/lib/coradoc/reference/edge/citation_options.rb +16 -0
  20. data/lib/coradoc/reference/edge/footnote_ref_options.rb +13 -0
  21. data/lib/coradoc/reference/edge/image_ref_options.rb +15 -0
  22. data/lib/coradoc/reference/edge/include_options.rb +15 -0
  23. data/lib/coradoc/reference/edge/kind.rb +72 -0
  24. data/lib/coradoc/reference/edge/link_options.rb +12 -0
  25. data/lib/coradoc/reference/edge/navigation_options.rb +12 -0
  26. data/lib/coradoc/reference/edge/options.rb +13 -0
  27. data/lib/coradoc/reference/edge.rb +80 -0
  28. data/lib/coradoc/reference/edge_search.rb +128 -0
  29. data/lib/coradoc/reference/materializer/base.rb +44 -0
  30. data/lib/coradoc/reference/materializer/passthrough.rb +31 -0
  31. data/lib/coradoc/reference/materializer/registry.rb +116 -0
  32. data/lib/coradoc/reference/materializer.rb +14 -0
  33. data/lib/coradoc/reference/presentation/base.rb +47 -0
  34. data/lib/coradoc/reference/presentation/custom_hierarchy.rb +104 -0
  35. data/lib/coradoc/reference/presentation/page.rb +20 -0
  36. data/lib/coradoc/reference/presentation/single_document.rb +35 -0
  37. data/lib/coradoc/reference/presentation/split_pages.rb +118 -0
  38. data/lib/coradoc/reference/presentation.rb +16 -0
  39. data/lib/coradoc/reference/resolution.rb +184 -0
  40. data/lib/coradoc/reference/resolver/base.rb +15 -0
  41. data/lib/coradoc/reference/resolver/caching.rb +37 -0
  42. data/lib/coradoc/reference/resolver/catalog.rb +69 -0
  43. data/lib/coradoc/reference/resolver/chain.rb +33 -0
  44. data/lib/coradoc/reference/resolver.rb +20 -0
  45. data/lib/coradoc/reference/result/ambiguous.rb +23 -0
  46. data/lib/coradoc/reference/result/base.rb +46 -0
  47. data/lib/coradoc/reference/result/missing.rb +14 -0
  48. data/lib/coradoc/reference/result/resolved.rb +22 -0
  49. data/lib/coradoc/reference/result.rb +20 -0
  50. data/lib/coradoc/reference.rb +65 -0
  51. data/lib/coradoc/validation.rb +40 -0
  52. data/lib/coradoc/version.rb +1 -1
  53. metadata +44 -1
@@ -0,0 +1,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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Caller-supplied hierarchy. The user provides a tree of node ids
7
+ # mapping to pages; the presentation lays out content accordingly.
8
+ # This is the presentation website builds use.
9
+ #
10
+ # CustomHierarchy.new(hierarchy: [
11
+ # { id: "intro", title: "Introduction", children: [...] },
12
+ # { id: "chap1", title: "Chapter 1", children: [
13
+ # { id: "chap1-1", title: "1.1" }
14
+ # ] }
15
+ # ])
16
+ class CustomHierarchy < Base
17
+ attr_reader :hierarchy
18
+
19
+ def self.key
20
+ :custom_hierarchy
21
+ end
22
+
23
+ def initialize(hierarchy:)
24
+ super()
25
+ @hierarchy = normalize_hierarchy(hierarchy)
26
+ end
27
+
28
+ def layout(resolved_graph)
29
+ pages = []
30
+ walk_hierarchy(@hierarchy, parent_id: nil, pages: pages, root: resolved_graph)
31
+ pages
32
+ end
33
+
34
+ def locate_page(_edge, target_content, pages:)
35
+ pages.find { |page| page.content.equal?(target_content) }
36
+ end
37
+
38
+ private
39
+
40
+ def normalize_hierarchy(tree)
41
+ Array(tree).map do |entry|
42
+ entry.merge(children: normalize_hierarchy(entry[:children]))
43
+ end
44
+ end
45
+
46
+ def walk_hierarchy(nodes, parent_id:, pages:, root:)
47
+ nodes.each do |entry|
48
+ content = find_content_by_id(root, entry[:id]) || root
49
+ pages << Page.new(
50
+ id: entry[:id],
51
+ title: entry[:title] || content.title,
52
+ content: content,
53
+ parent_id: parent_id,
54
+ order: pages.size
55
+ )
56
+ walk_hierarchy(entry[:children], parent_id: entry[:id], pages: pages, root: root)
57
+ end
58
+ end
59
+
60
+ def find_content_by_id(root, id)
61
+ return nil unless id
62
+
63
+ visitor = VisitorById.new(id)
64
+ visitor.visit(root)
65
+ visitor.found
66
+ end
67
+
68
+ # Single-purpose visitor: walks a CoreModel tree looking for
69
+ # one node by id. Cleaner than a recursive method on the
70
+ # presentation class itself.
71
+ class VisitorById
72
+ attr_reader :found
73
+
74
+ def initialize(target_id)
75
+ @target_id = target_id
76
+ @found = nil
77
+ end
78
+
79
+ def visit(node)
80
+ return @found if @found
81
+ return unless node.is_a?(Coradoc::CoreModel::Base)
82
+
83
+ @found = node if node.id == @target_id
84
+ return if @found
85
+
86
+ visit_children(node)
87
+ end
88
+
89
+ private
90
+
91
+ def visit_children(node)
92
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
93
+
94
+ children = node.children
95
+ return unless children
96
+
97
+ children.each { |c| visit(c) }
98
+ nil
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lutaml/model'
4
+
5
+ module Coradoc
6
+ module Reference
7
+ module Presentation
8
+ # A page is the unit of output a Presentation produces. One HTML
9
+ # file, one PDF page, one EPUB chapter — same data, different
10
+ # materialization. The Materializer consumes Pages.
11
+ class Page < Lutaml::Model::Serializable
12
+ attribute :id, :string
13
+ attribute :title, :string
14
+ attribute :content, Coradoc::CoreModel::Base
15
+ attribute :parent_id, :string
16
+ attribute :order, :integer
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # One page = the whole document. Use for fixed-structure output
7
+ # (single HTML, single PDF, EPUB chapter). Cross-references
8
+ # always resolve to the single page.
9
+ class SingleDocument < Base
10
+ def self.key
11
+ :single_document
12
+ end
13
+
14
+ def layout(resolved_graph)
15
+ [Page.new(
16
+ id: page_id_for(resolved_graph),
17
+ title: resolved_graph.title,
18
+ content: resolved_graph,
19
+ order: 0
20
+ )]
21
+ end
22
+
23
+ def locate_page(_edge, _target_content, pages:)
24
+ pages.first
25
+ end
26
+
27
+ private
28
+
29
+ def page_id_for(node)
30
+ node.id || 'root'
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ module Presentation
6
+ # Splits the document by top-level section (or a configurable
7
+ # boundary). Each section becomes a Page. Cross-references are
8
+ # rewritten to point at whichever page the target content lives on.
9
+ #
10
+ # SplitPages.new(split_at: :section) # default
11
+ # SplitPages.new(split_at: :chapter) # HeaderElement only
12
+ class SplitPages < Base
13
+ attr_reader :split_at
14
+
15
+ def self.key
16
+ :split_pages
17
+ end
18
+
19
+ def initialize(split_at: :section)
20
+ super()
21
+ @split_at = split_at.to_sym
22
+ @page_index = nil
23
+ end
24
+
25
+ def layout(resolved_graph)
26
+ children = read_children(resolved_graph)
27
+ return [single_page_for(resolved_graph)] if children.nil? || children.empty?
28
+
29
+ sections = children.select { |child| matches_split?(child) }
30
+ return [single_page_for(resolved_graph)] if sections.empty?
31
+
32
+ pages = sections.map.with_index { |section, idx| page_for(section, resolved_graph, idx) }
33
+ index_pages!(pages)
34
+ pages
35
+ end
36
+
37
+ def locate_page(_edge, target_content, pages:)
38
+ indexed = @page_index&.[](target_content)
39
+ return indexed if indexed
40
+
41
+ pages.find { |page| owns_target?(page, target_content) }
42
+ end
43
+
44
+ private
45
+
46
+ # O(1) target→page lookup, built once per layout instead of
47
+ # rescanning every page subtree per edge.
48
+ def index_pages!(pages)
49
+ @page_index = {}.compare_by_identity
50
+ pages.each { |page| index_node!(page.content, page) }
51
+ end
52
+
53
+ def index_node!(node, page)
54
+ @page_index[node] = page
55
+ return unless node.is_a?(Coradoc::CoreModel::HasChildren)
56
+
57
+ children = node.children
58
+ return unless children
59
+
60
+ children.each { |child| index_node!(child, page) }
61
+ end
62
+
63
+ def page_for(section, parent, idx)
64
+ Page.new(
65
+ id: section.id || "page-#{idx}",
66
+ title: section.title || "Page #{idx}",
67
+ content: section,
68
+ parent_id: parent.id,
69
+ order: idx
70
+ )
71
+ end
72
+
73
+ def read_children(node)
74
+ return nil unless node.is_a?(Coradoc::CoreModel::HasChildren)
75
+
76
+ node.children
77
+ end
78
+
79
+ def matches_split?(node)
80
+ return false unless node.is_a?(Coradoc::CoreModel::StructuralElement)
81
+
82
+ klass = boundary_class
83
+ node.is_a?(klass)
84
+ end
85
+
86
+ def boundary_class
87
+ split_at == :chapter ? Coradoc::CoreModel::HeaderElement : Coradoc::CoreModel::SectionElement
88
+ end
89
+
90
+ def single_page_for(node)
91
+ Page.new(
92
+ id: node.id || 'root',
93
+ title: node.title,
94
+ content: node,
95
+ order: 0
96
+ )
97
+ end
98
+
99
+ def owns_target?(page, target)
100
+ return true if page.content.equal?(target)
101
+
102
+ descendant_of?(page.content, target)
103
+ end
104
+
105
+ def descendant_of?(ancestor, target)
106
+ return false unless ancestor.is_a?(Coradoc::CoreModel::HasChildren)
107
+
108
+ children = ancestor.children
109
+ return false unless children
110
+
111
+ children.any? do |child|
112
+ child.equal?(target) || descendant_of?(child, target)
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coradoc
4
+ module Reference
5
+ # Model-View boundary. Same Content graph → N Presentations. A
6
+ # Presentation defines slicing, page boundaries, ordering, and
7
+ # hierarchy — never the rendering (that's the Materializer's job).
8
+ module Presentation
9
+ autoload :Base, "#{__dir__}/presentation/base"
10
+ autoload :Page, "#{__dir__}/presentation/page"
11
+ autoload :SingleDocument, "#{__dir__}/presentation/single_document"
12
+ autoload :SplitPages, "#{__dir__}/presentation/split_pages"
13
+ autoload :CustomHierarchy, "#{__dir__}/presentation/custom_hierarchy"
14
+ end
15
+ end
16
+ end