jekyll-relationships 0.1.0.alpha → 0.2.0.alpha

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jekyll-relationships/configuration/debug_setting.rb +72 -12
  3. data/lib/jekyll-relationships/configuration/defaults.rb +3 -1
  4. data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
  5. data/lib/jekyll-relationships/configuration/parser.rb +20 -3
  6. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
  7. data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +8 -2
  8. data/lib/jekyll-relationships/configuration.rb +43 -8
  9. data/lib/jekyll-relationships/debug_logger.rb +94 -3
  10. data/lib/jekyll-relationships/definitions/normal_relationship.rb +13 -2
  11. data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
  12. data/lib/jekyll-relationships/definitions/tree_relationship.rb +8 -2
  13. data/lib/jekyll-relationships/documents/registry.rb +182 -33
  14. data/lib/jekyll-relationships/engine/normal_seed.rb +222 -0
  15. data/lib/jekyll-relationships/engine/persisted_links.rb +365 -0
  16. data/lib/jekyll-relationships/engine/raw_path_state.rb +46 -116
  17. data/lib/jekyll-relationships/engine/relationship_state.rb +67 -56
  18. data/lib/jekyll-relationships/engine/session.rb +159 -19
  19. data/lib/jekyll-relationships/engine/write_back.rb +3 -42
  20. data/lib/jekyll-relationships/engine.rb +168 -46
  21. data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
  22. data/lib/jekyll-relationships/pruning/tree_phase.rb +128 -124
  23. data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
  24. data/lib/jekyll-relationships/references/accumulator.rb +87 -9
  25. data/lib/jekyll-relationships/references/template.rb +46 -11
  26. data/lib/jekyll-relationships/resolvers/base.rb +59 -2
  27. data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
  28. data/lib/jekyll-relationships/support/placeholders.rb +1 -0
  29. data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
  30. data/lib/jekyll-relationships/trees/graph.rb +135 -17
  31. data/lib/jekyll-relationships/trees/root_distances.rb +44 -0
  32. data/lib/jekyll-relationships/version.rb +1 -1
  33. data/lib/jekyll-relationships.rb +1 -0
  34. data/readme.md +185 -26
  35. metadata +6 -2
@@ -10,7 +10,7 @@ module Pruning
10
10
  #
11
11
  # Tree orphan repair needs stable provenance even after many prune rounds. This
12
12
  # helper snapshots the first fully built tree graph before any pruning so later
13
- # orphan handling can reattach surviving grandparents in deterministic order.
13
+ # orphan handling can reattach nearest surviving ancestors in deterministic order.
14
14
  class TreeProvenance
15
15
  # Builds one immutable provenance index from one tree graph.
16
16
  def initialize(tree_graph:)
@@ -24,37 +24,60 @@ class Accumulator
24
24
  end
25
25
 
26
26
  # Adds one resolved relationship entry.
27
- def add(document:, key:, metadata: nil, count: 1)
28
- add_result(document: document, key: key, metadata: metadata, count: count) != :ignored
27
+ def add(document:, key:, scope: nil, metadata: nil, count: 1)
28
+ add_result(document: document, key: key, scope: scope, metadata: metadata, count: count) != :ignored
29
29
  end
30
30
 
31
31
  # Adds one resolved relationship entry and returns how it changed the set.
32
- def add_result(document:, key:, metadata: nil, count: 1)
32
+ def add_result(document:, key:, scope: nil, metadata: nil, count: 1)
33
+ add_detailed_result(
34
+ document: document,
35
+ key: key,
36
+ scope: scope,
37
+ metadata: metadata,
38
+ count: count
39
+ ).fetch(:action)
40
+ end
41
+
42
+ # Adds one resolved relationship entry and returns the stored entry as well.
43
+ def add_detailed_result(document:, key:, scope: nil, metadata: nil, count: 1)
33
44
  if @multiple_settings.keep?
34
- append_entry(document: document, key: key, metadata: metadata, count: 1)
35
- return :added
45
+ return {
46
+ action: :added,
47
+ entry: append_entry(document: document, key: key, scope: scope, metadata: metadata, count: 1)
48
+ }
36
49
  end
37
50
 
38
51
  existing_entry = @entries_by_document_id[document.object_id]
39
52
  if existing_entry
40
- return :ignored if @multiple_settings.drop?
53
+ return {
54
+ action: :ignored,
55
+ entry: existing_entry
56
+ } if @multiple_settings.drop?
41
57
 
42
58
  existing_entry[:count] += normalise_count(count)
43
59
  existing_entry[:metadata] = merge_metadata_under(
44
60
  existing_metadata: existing_entry[:metadata],
45
61
  incoming_metadata: metadata
46
62
  )
47
- return :merged
63
+ return {
64
+ action: :merged,
65
+ entry: existing_entry
66
+ }
48
67
  end
49
68
 
50
69
  entry = append_entry(
51
70
  document: document,
52
71
  key: key,
72
+ scope: scope,
53
73
  metadata: metadata,
54
74
  count: counted_mode? ? normalise_count(count) : 1
55
75
  )
56
76
  @entries_by_document_id[document.object_id] = entry
57
- :added
77
+ {
78
+ action: :added,
79
+ entry: entry
80
+ }
58
81
  end
59
82
 
60
83
  # Removes every stored occurrence of one target document.
@@ -99,6 +122,35 @@ class Accumulator
99
122
  end
100
123
  end
101
124
 
125
+ # Repositions newly reinserted entries by proportional target slot.
126
+ def reposition_entries!(reinsertions:, base_count:)
127
+ return if reinsertions.empty?
128
+
129
+ reinsertions_by_entry_id = reinsertions.each_with_object({}) do |reinsertion, indexed_reinsertions|
130
+ indexed_reinsertions[reinsertion.fetch(:entry).object_id] = reinsertion
131
+ end
132
+ base_entries = @entries.reject do |entry|
133
+ reinsertions_by_entry_id.key?(entry.object_id)
134
+ end
135
+ slots = Hash.new { |hash, key| hash[key] = [] }
136
+ reinsertions.sort_by do |reinsertion|
137
+ [
138
+ reinsertion.fetch(:position_ratio).nil? ? Float::INFINITY : reinsertion.fetch(:position_ratio),
139
+ reinsertion.fetch(:order)
140
+ ]
141
+ end.each do |reinsertion|
142
+ slots[insertion_index(position_ratio: reinsertion.fetch(:position_ratio), base_count: base_count)] << reinsertion.fetch(:entry)
143
+ end
144
+
145
+ rebuilt_entries = []
146
+ 0.upto(base_entries.length) do |index|
147
+ rebuilt_entries.concat(slots[index]) if slots.key?(index)
148
+ rebuilt_entries << base_entries[index] if index < base_entries.length
149
+ end
150
+ @entries = rebuilt_entries
151
+ reindex_entries!
152
+ end
153
+
102
154
  private
103
155
 
104
156
  # Returns true when the current mode is count.
@@ -107,10 +159,11 @@ class Accumulator
107
159
  end
108
160
 
109
161
  # Appends one entry while recording its first-seen order.
110
- def append_entry(document:, key:, metadata:, count:)
162
+ def append_entry(document:, key:, scope:, metadata:, count:)
111
163
  entry = {
112
164
  document: document,
113
165
  key: key,
166
+ scope: duplicate_scope(scope),
114
167
  metadata: normalise_metadata(metadata),
115
168
  count: count,
116
169
  first_seen_index: @next_position
@@ -134,6 +187,7 @@ class Accumulator
134
187
  {
135
188
  document: entry.fetch(:document),
136
189
  key: entry.fetch(:key),
190
+ scope: duplicate_scope(entry.fetch(:scope)),
137
191
  metadata: normalise_metadata(entry.fetch(:metadata)),
138
192
  count: entry.fetch(:count),
139
193
  first_seen_index: entry.fetch(:first_seen_index)
@@ -145,11 +199,19 @@ class Accumulator
145
199
  @reference_template.build(
146
200
  document: entry.fetch(:document),
147
201
  key: entry.fetch(:key),
202
+ scope: entry.fetch(:scope),
148
203
  metadata: entry.fetch(:metadata),
149
204
  count: entry.fetch(:count)
150
205
  )
151
206
  end
152
207
 
208
+ # Returns a shallow copy of one complete scope hash while preserving scalar values exactly.
209
+ def duplicate_scope(scope)
210
+ return nil if scope.nil?
211
+
212
+ normalise_metadata(scope)
213
+ end
214
+
153
215
  # Merges later metadata under the first-seen metadata.
154
216
  def merge_metadata_under(existing_metadata:, incoming_metadata:)
155
217
  Jekyll::Plugins::Relationships::Support.hash_deep_merge(
@@ -176,6 +238,22 @@ class Accumulator
176
238
 
177
239
  integer_count
178
240
  end
241
+
242
+ # Converts one persisted proportional hint into an insertion slot.
243
+ def insertion_index(position_ratio:, base_count:)
244
+ return base_count if position_ratio.nil?
245
+ return 0 if base_count <= 0
246
+
247
+ [[0, (position_ratio * base_count).round].max, base_count].min
248
+ end
249
+
250
+ # Rewrites first-seen indexes after persisted reinsertion changes the order.
251
+ def reindex_entries!
252
+ @entries.each_with_index do |entry, index|
253
+ entry[:first_seen_index] = index
254
+ end
255
+ @next_position = @entries.length
256
+ end
179
257
  end
180
258
 
181
259
  end
@@ -9,7 +9,7 @@ module References
9
9
  # Parses loose reference values and builds canonical relationship hashes.
10
10
  #
11
11
  # The template is configured from `relationships.references` and controls which
12
- # property names hold the key, collection, page, and optional count values in
12
+ # property names hold the key, collection, scope, page, and optional count values in
13
13
  # output hashes.
14
14
  class Template
15
15
 
@@ -18,12 +18,13 @@ class Template
18
18
  # The `metadata` hash contains any non-reserved properties found on an
19
19
  # existing reference hash.
20
20
  class ParsedReference
21
- attr_reader :key, :collection, :page, :count, :metadata, :original_value
21
+ attr_reader :key, :collection, :scope, :page, :count, :metadata, :original_value
22
22
 
23
23
  # Captures the parsed reference fields in one immutable object.
24
- def initialize(key:, collection:, page:, count:, metadata:, original_value:)
24
+ def initialize(key:, collection:, scope: nil, page:, count:, metadata:, original_value:)
25
25
  @key = key
26
26
  @collection = collection
27
+ @scope = scope
27
28
  @page = page
28
29
  @count = count
29
30
  @metadata = metadata
@@ -36,7 +37,7 @@ class Template
36
37
  end
37
38
  end
38
39
 
39
- attr_reader :key_property, :collection_property, :page_property, :count_property
40
+ attr_reader :key_property, :collection_property, :scope_property, :page_property, :count_property
40
41
 
41
42
  # Builds the reference template from config and duplicate-handling settings.
42
43
  def initialize(config:, count_enabled:)
@@ -44,6 +45,7 @@ class Template
44
45
  @count_enabled = !!count_enabled
45
46
  @key_property = nil
46
47
  @collection_property = nil
48
+ @scope_property = nil
47
49
  @page_property = nil
48
50
  @count_property = nil
49
51
 
@@ -51,7 +53,7 @@ class Template
51
53
  end
52
54
 
53
55
  # Parses one raw reference value from frontmatter or resolver input.
54
- def parse(value)
56
+ def parse(value, context: nil)
55
57
  case value
56
58
  when String
57
59
  key = value.to_s.strip
@@ -60,32 +62,35 @@ class Template
60
62
  ParsedReference.new(
61
63
  key: key,
62
64
  collection: nil,
65
+ scope: nil,
63
66
  page: nil,
64
67
  count: 1,
65
68
  metadata: {},
66
69
  original_value: value
67
70
  )
68
71
  when Hash
69
- parse_hash_reference(value)
72
+ parse_hash_reference(value, context: context)
70
73
  when Jekyll::Document
71
74
  ParsedReference.new(
72
75
  key: nil,
73
76
  collection: nil,
77
+ scope: nil,
74
78
  page: value,
75
79
  count: 1,
76
80
  metadata: {},
77
81
  original_value: value
78
82
  )
79
83
  else
80
- raise ConfigurationError, "Unsupported reference value `#{value.inspect}`."
84
+ raise ConfigurationError, "#{context_prefix(context)}unsupported reference value `#{value.inspect}`."
81
85
  end
82
86
  end
83
87
 
84
88
  # Builds one output reference hash for a resolved document.
85
- def build(document:, key:, metadata: nil, count: 1, include_count: @count_enabled)
89
+ def build(document:, key:, scope: nil, metadata: nil, count: 1, include_count: @count_enabled)
86
90
  hash = {}
87
91
  hash[@key_property] = key
88
92
  hash[@collection_property] = document.collection.label if @collection_property
93
+ hash[@scope_property] = stringify_hash(scope) if @scope_property && !scope.nil?
89
94
  hash[@page_property] = document if @page_property
90
95
  hash[@count_property] = normalise_count(count) if include_count && @count_property
91
96
 
@@ -98,7 +103,7 @@ class Template
98
103
 
99
104
  # Returns the configured property names that are reserved for the engine.
100
105
  def reserved_properties
101
- [@key_property, @collection_property, @page_property, @count_property].compact
106
+ [@key_property, @collection_property, @scope_property, @page_property, @count_property].compact
102
107
  end
103
108
 
104
109
  # Returns the input properties that should never survive into free-form metadata.
@@ -121,6 +126,9 @@ class Template
121
126
  when Jekyll::Plugins::Relationships::Support::Placeholders::COLLECTION
122
127
  raise ConfigurationError, 'Reference config can only define one <collection> property.' if @collection_property
123
128
  @collection_property = property
129
+ when Jekyll::Plugins::Relationships::Support::Placeholders::SCOPE
130
+ raise ConfigurationError, 'Reference config can only define one <scope> property.' if @scope_property
131
+ @scope_property = property
124
132
  when Jekyll::Plugins::Relationships::Support::Placeholders::PAGE
125
133
  raise ConfigurationError, 'Reference config can only define one <page> property.' if @page_property
126
134
  @page_property = property
@@ -139,18 +147,20 @@ class Template
139
147
  end
140
148
 
141
149
  # Parses one hash reference according to the configured property names.
142
- def parse_hash_reference(hash)
150
+ def parse_hash_reference(hash, context: nil)
143
151
  key = fetch_hash_value(hash, @key_property)
144
152
  collection = @collection_property ? fetch_hash_value(hash, @collection_property) : nil
153
+ scope = parse_scope(hash, context: context)
145
154
  page = @page_property ? fetch_hash_value(hash, @page_property) : nil
146
155
 
147
156
  if key.nil? && !page.is_a?(Jekyll::Document)
148
- raise ResolutionError, "Reference hash `#{hash.inspect}` does not include the configured key property `#{@key_property}`."
157
+ raise ResolutionError, "#{context_prefix(context)}reference hash `#{hash.inspect}` does not include the configured key property `#{@key_property}`."
149
158
  end
150
159
 
151
160
  ParsedReference.new(
152
161
  key: key,
153
162
  collection: collection,
163
+ scope: scope,
154
164
  page: page,
155
165
  count: parsed_count(hash),
156
166
  metadata: filter_metadata(hash),
@@ -158,6 +168,19 @@ class Template
158
168
  )
159
169
  end
160
170
 
171
+ # Parses the reserved scope property without interpreting literal dotted field names as nested paths.
172
+ def parse_scope(hash, context: nil)
173
+ return nil unless @scope_property
174
+ return nil unless hash_property?(hash, @scope_property)
175
+
176
+ raw_scope = fetch_hash_value(hash, @scope_property)
177
+ unless raw_scope.is_a?(Hash)
178
+ raise ResolutionError, "#{context_prefix(context)}reference scope property `#{@scope_property}` must be a hash, but was `#{raw_scope.inspect}`."
179
+ end
180
+
181
+ stringify_hash(raw_scope)
182
+ end
183
+
161
184
  # Returns the parsed count value for one reference hash.
162
185
  def parsed_count(hash)
163
186
  return 1 unless @count_enabled
@@ -191,6 +214,18 @@ class Template
191
214
  Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(hash, property)
192
215
  end
193
216
 
217
+ # Returns true when a string- or symbol-keyed hash contains one literal property.
218
+ def hash_property?(hash, property)
219
+ hash.key?(property) || hash.key?(property.to_s) || hash.key?(property.to_s.to_sym)
220
+ end
221
+
222
+ # Adds relationship and document details supplied by the caller to parsing diagnostics.
223
+ def context_prefix(context)
224
+ return '' if context.nil? || context.to_s.empty?
225
+
226
+ "#{context}: "
227
+ end
228
+
194
229
  # Converts one hash to a shallow string-keyed copy.
195
230
  def stringify_hash(hash)
196
231
  return {} unless hash.is_a?(Hash)
@@ -6,6 +6,17 @@ module Plugins
6
6
  module Relationships
7
7
  module Resolvers
8
8
 
9
+ # Declares or reads the global default persistence mode for resolver link calls.
10
+ #
11
+ # Resolver classes can override this locally with their own `persist true/false`
12
+ # declaration. When left unset on the class, `link(...)` falls back to this
13
+ # module-wide default.
14
+ def self.persist(value = :__read__)
15
+ return Base.global_persist_default if value == :__read__
16
+
17
+ Base.global_persist_default = value
18
+ end
19
+
9
20
  # Base class for custom relationship resolvers.
10
21
  #
11
22
  # Subclass this in `_plugins`, declare `from` and `to`, then implement
@@ -14,6 +25,7 @@ module Resolvers
14
25
  class Base
15
26
 
16
27
  @registered_subclasses = []
28
+ @global_persist_default = false
17
29
 
18
30
  class << self
19
31
  attr_reader :from_definition, :to_definition
@@ -42,6 +54,35 @@ class Base
42
54
  @to_definition
43
55
  end
44
56
 
57
+ # Declares or reads the default persistence mode for this resolver class.
58
+ def persist(value = :__read__)
59
+ unless value == :__read__
60
+ @persist_default = normalise_persist_default(
61
+ value,
62
+ context: "#{name || self}.persist"
63
+ )
64
+ end
65
+ return @persist_default unless @persist_default.nil?
66
+ return superclass.persist if self != Base && superclass.respond_to?(:persist)
67
+
68
+ global_persist_default
69
+ end
70
+
71
+ # Returns the module-wide fallback persistence mode.
72
+ def global_persist_default
73
+ return false unless instance_variable_defined?(:@global_persist_default)
74
+
75
+ @global_persist_default
76
+ end
77
+
78
+ # Sets the module-wide fallback persistence mode.
79
+ def global_persist_default=(value)
80
+ @global_persist_default = normalise_persist_default(
81
+ value,
82
+ context: 'Resolvers.persist'
83
+ )
84
+ end
85
+
45
86
  # Returns every resolver subclass whose `from` and `to` selectors are complete.
46
87
  def registered_subclasses
47
88
  @registered_subclasses ||= []
@@ -60,6 +101,15 @@ class Base
60
101
  def remove_registered_subclass(subclass:)
61
102
  registered_subclasses.delete(subclass)
62
103
  end
104
+
105
+ private
106
+
107
+ # Normalises one resolver persistence default to a strict boolean.
108
+ def normalise_persist_default(value, context:)
109
+ return value if value == true || value == false
110
+
111
+ raise ConfigurationError, "`#{context}` must be true or false."
112
+ end
63
113
  end
64
114
 
65
115
  # Builds one resolver instance for one resolving relationship state.
@@ -159,11 +209,15 @@ class Base
159
209
  end
160
210
 
161
211
  # Adds one relationship from the current document to the target collection.
162
- def link(target_reference, reference: nil)
212
+ #
213
+ # When `persist` is true, the link may be restored in later rounds if the
214
+ # resolver does not recreate it and both linked documents still survive.
215
+ def link(target_reference, reference: nil, persist: nil)
163
216
  @state.link(
164
217
  target_reference,
165
218
  metadata: reference,
166
- origin: "resolver #{self.class.name || self.class}"
219
+ origin: "resolver #{self.class.name || self.class}",
220
+ persist: persist.nil? ? self.class.persist : persist
167
221
  )
168
222
  end
169
223
 
@@ -184,6 +238,9 @@ class Base
184
238
  @engine.resolve_reference_document(
185
239
  reference,
186
240
  primary_path: @state.definition.primary_path,
241
+ scope_fields: @state.definition.scope_fields,
242
+ referring_document: @document,
243
+ relationship: "#{@state.definition.from_collection} -> #{@state.definition.to_collection}",
187
244
  collection_hint: from_collection
188
245
  )
189
246
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+ module Relationships
6
+
7
+ module Support
8
+
9
+ # Matches exact values at dot-separated paths in document frontmatter.
10
+ #
11
+ # Paths traverse hashes only so arrays and hashes remain matchable terminal
12
+ # values. Lookup distinguishes a missing path from a present nil value and
13
+ # treats string and symbol hash keys as equivalent.
14
+ class FrontmatterMatcher
15
+ # Builds one matcher from normalised path and expected-value pairs.
16
+ def initialize(expected_values:)
17
+ @expected_values = expected_values
18
+ end
19
+
20
+ # Returns true when every configured path is present and matches exactly.
21
+ def matches?(data)
22
+ @expected_values.all? do |path, expected_value|
23
+ match = value_at(data: data, path: path)
24
+ match.fetch(:present) && type_sensitive_equal?(match.fetch(:value), expected_value)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # Reads one hash-only path without collapsing a present nil into missing.
31
+ def value_at(data:, path:)
32
+ current_value = data
33
+ FrontmatterPath.split_path(path).each do |segment|
34
+ return missing_value unless current_value.is_a?(Hash)
35
+
36
+ resolved_key = resolve_hash_key(hash: current_value, segment: segment)
37
+ return missing_value if resolved_key.nil?
38
+
39
+ current_value = current_value[resolved_key]
40
+ end
41
+
42
+ {
43
+ present: true,
44
+ value: current_value
45
+ }
46
+ end
47
+
48
+ # Resolves string and symbol forms of one path segment.
49
+ def resolve_hash_key(hash:, segment:)
50
+ return segment if hash.key?(segment)
51
+
52
+ symbol_segment = segment.to_sym
53
+ return symbol_segment if hash.key?(symbol_segment)
54
+
55
+ nil
56
+ end
57
+
58
+ # Returns one fresh missing lookup result.
59
+ def missing_value
60
+ {
61
+ present: false,
62
+ value: nil
63
+ }
64
+ end
65
+
66
+ # Requires both the Ruby type and value to match.
67
+ def type_sensitive_equal?(actual_value, expected_value)
68
+ actual_value.class == expected_value.class && actual_value == expected_value
69
+ end
70
+ end
71
+
72
+ end
73
+
74
+ end
75
+ end
76
+ end
@@ -10,6 +10,7 @@ module Support
10
10
  module Placeholders
11
11
  KEY = '<key>'.freeze
12
12
  COLLECTION = '<collection>'.freeze
13
+ SCOPE = '<scope>'.freeze
13
14
  PAGE = '<page>'.freeze
14
15
  COUNT = '<count>'.freeze
15
16
  end
@@ -53,8 +53,8 @@ class EdgeBuilder
53
53
 
54
54
  @graph.documents_for(child_collection).each do |child_document|
55
55
  path_configuration.parent_input_paths(max_parents: definition.tree_settings.max_parents).each do |path|
56
- parse_references(@data_path.read(child_document.data, path)).each do |reference|
57
- parent_document = resolve_reference(reference: reference, primary_path: definition.primary_path)
56
+ parse_references(@data_path.read(child_document.data, path), definition: definition, document: child_document).each do |reference|
57
+ parent_document = resolve_reference(reference: reference, definition: definition, referring_document: child_document)
58
58
  next unless parent_document
59
59
  next unless parent_document.collection.label == parent_collection
60
60
 
@@ -88,8 +88,8 @@ class EdgeBuilder
88
88
 
89
89
  @graph.documents_for(parent_collection).each do |parent_document|
90
90
  path_configuration.child_input_paths(max_children: definition.tree_settings.max_children).each do |path|
91
- parse_references(@data_path.read(parent_document.data, path)).each do |reference|
92
- child_document = resolve_reference(reference: reference, primary_path: definition.primary_path)
91
+ parse_references(@data_path.read(parent_document.data, path), definition: definition, document: parent_document).each do |reference|
92
+ child_document = resolve_reference(reference: reference, definition: definition, referring_document: parent_document)
93
93
  next unless child_document
94
94
  next unless child_document.collection.label == child_collection
95
95
 
@@ -132,7 +132,7 @@ class EdgeBuilder
132
132
  parents = fetch_nested(url_index, parent_collection, parent_parts)
133
133
  next unless parents
134
134
 
135
- parents.each do |parent_document|
135
+ parents.select { |parent_document| same_scope?(first_document: child_document, second_document: parent_document, definition: definition) }.each do |parent_document|
136
136
  @graph.add_edge(
137
137
  parent_document: parent_document,
138
138
  child_document: child_document,
@@ -162,24 +162,56 @@ class EdgeBuilder
162
162
  end
163
163
 
164
164
  # Parses one tree frontmatter value into parsed references.
165
- def parse_references(value)
165
+ def parse_references(value, definition:, document:)
166
166
  @string_array.interpret(value, split: -1, flatten: true).map do |entry|
167
- @configuration.reference_template.parse(entry)
167
+ @configuration.reference_template.parse(
168
+ entry,
169
+ context: "Relationship `#{definition.from_collection} -> #{definition.to_collection}` reference on document `#{document.relative_path}`"
170
+ )
168
171
  end.compact
169
172
  end
170
173
 
171
174
  # Resolves one parsed reference to a real document.
172
- def resolve_reference(reference:, primary_path:)
173
- return reference.page if reference.document?
175
+ def resolve_reference(reference:, definition:, referring_document:)
176
+ relationship = "#{definition.from_collection} -> #{definition.to_collection}"
177
+ effective_scope = @registry.effective_scope_for(
178
+ reference_scope: reference.scope,
179
+ referring_document: referring_document,
180
+ scope_fields: definition.scope_fields,
181
+ relationship: relationship
182
+ )
183
+ if reference.document?
184
+ @registry.validate_scope_match!(
185
+ document: reference.page,
186
+ scope: effective_scope,
187
+ scope_fields: definition.scope_fields,
188
+ relationship: relationship,
189
+ referring_document: referring_document
190
+ )
191
+ return reference.page
192
+ end
174
193
 
175
194
  collection = reference.collection.to_s unless reference.collection.nil?
176
195
  @registry.lookup(
177
196
  key: reference.key,
178
- primary_path: primary_path,
179
- collection: collection
197
+ primary_path: definition.primary_path,
198
+ collection: collection,
199
+ scope_fields: definition.scope_fields,
200
+ scope: effective_scope,
201
+ relationship: relationship,
202
+ referring_document: referring_document
180
203
  )
181
204
  end
182
205
 
206
+ # Returns true when two URL-selected documents share the definition's complete scope.
207
+ def same_scope?(first_document:, second_document:, definition:)
208
+ return true if definition.scope_fields.empty?
209
+
210
+ relationship = "#{definition.from_collection} -> #{definition.to_collection}"
211
+ @registry.scope_for(first_document, scope_fields: definition.scope_fields, relationship: relationship) ==
212
+ @registry.scope_for(second_document, scope_fields: definition.scope_fields, relationship: relationship)
213
+ end
214
+
183
215
  # Reads one nested hash value without creating a default entry.
184
216
  def fetch_nested(hash, first_key, second_key)
185
217
  first_hash = hash[first_key]