jekyll-relationships 0.1.1.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jekyll-relationships/configuration/defaults.rb +1 -0
  3. data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
  4. data/lib/jekyll-relationships/configuration/parser.rb +20 -3
  5. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
  6. data/lib/jekyll-relationships/configuration.rb +43 -8
  7. data/lib/jekyll-relationships/definitions/normal_relationship.rb +8 -2
  8. data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
  9. data/lib/jekyll-relationships/definitions/tree_relationship.rb +3 -2
  10. data/lib/jekyll-relationships/documents/registry.rb +182 -33
  11. data/lib/jekyll-relationships/engine/normal_seed.rb +9 -4
  12. data/lib/jekyll-relationships/engine/raw_path_state.rb +44 -22
  13. data/lib/jekyll-relationships/engine/relationship_state.rb +12 -0
  14. data/lib/jekyll-relationships/engine/session.rb +89 -15
  15. data/lib/jekyll-relationships/engine/write_back.rb +1 -0
  16. data/lib/jekyll-relationships/engine.rb +1 -1
  17. data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
  18. data/lib/jekyll-relationships/pruning/tree_phase.rb +29 -11
  19. data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
  20. data/lib/jekyll-relationships/references/accumulator.rb +18 -6
  21. data/lib/jekyll-relationships/references/template.rb +46 -11
  22. data/lib/jekyll-relationships/resolvers/base.rb +3 -0
  23. data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
  24. data/lib/jekyll-relationships/support/placeholders.rb +1 -0
  25. data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
  26. data/lib/jekyll-relationships/trees/graph.rb +30 -13
  27. data/lib/jekyll-relationships/version.rb +1 -1
  28. data/lib/jekyll-relationships.rb +1 -0
  29. data/readme.md +154 -23
  30. metadata +3 -2
@@ -79,7 +79,7 @@ class Engine
79
79
  # Reads and stores one concrete direct relationship definition from frontmatter.
80
80
  def seed_definition_from_frontmatter(accumulators:, document:, definition:)
81
81
  definition.foreign_paths.each do |path|
82
- raw_state = raw_path_state(document, path)
82
+ raw_state = raw_path_state(document, path, definition)
83
83
  debug_relationship_event(
84
84
  document: document,
85
85
  definition: definition,
@@ -94,7 +94,9 @@ class Engine
94
94
 
95
95
  resolved_entries = raw_state.resolved_entries_for(
96
96
  primary_path: definition.primary_path,
97
+ scope_fields: definition.scope_fields,
97
98
  registry: @registry,
99
+ relationship: "#{definition.from_collection} -> #{definition.to_collection}",
98
100
  active_document_checker: proc { |resolved_document| active_document?(resolved_document) }
99
101
  )
100
102
  debug_relationship_event(
@@ -144,6 +146,7 @@ class Engine
144
146
  ).add(
145
147
  document: entry.fetch(:document),
146
148
  key: entry.fetch(:key),
149
+ scope: entry.fetch(:scope),
147
150
  metadata: entry.fetch(:metadata),
148
151
  count: entry.fetch(:count)
149
152
  )
@@ -157,6 +160,7 @@ class Engine
157
160
  ).add(
158
161
  document: document,
159
162
  key: @registry.key_for(document, primary_path: definition.primary_path),
163
+ scope: @registry.scope_for(document, scope_fields: definition.scope_fields, relationship: "#{definition.from_collection} -> #{definition.to_collection}"),
160
164
  metadata: entry.fetch(:metadata),
161
165
  count: entry.fetch(:count)
162
166
  )
@@ -183,13 +187,14 @@ class Engine
183
187
  end
184
188
 
185
189
  # Returns the cached raw-path parser for one document/path pair.
186
- def raw_path_state(document, path)
187
- @raw_path_states[[document.object_id, path]] ||= RawPathState.new(
190
+ def raw_path_state(document, path, definition)
191
+ @raw_path_states[[document.object_id, path, definition.object_id]] ||= RawPathState.new(
188
192
  document: document,
189
193
  path: path,
190
194
  data_path: @data_path,
191
195
  string_array: @string_array,
192
- reference_template: @configuration.reference_template
196
+ reference_template: @configuration.reference_template,
197
+ reference_context: "Relationship `#{definition.from_collection} -> #{definition.to_collection}` reference on document `#{document.relative_path}`"
193
198
  )
194
199
  end
195
200
 
@@ -18,16 +18,17 @@ class Engine
18
18
  # reuse them without reparsing frontmatter.
19
19
  class LocationState
20
20
  # Builds one concrete-location cache.
21
- def initialize(match:, string_array:, reference_template:)
21
+ def initialize(match:, string_array:, reference_template:, reference_context: nil)
22
22
  @string_array = string_array
23
23
  @reference_template = reference_template
24
+ @reference_context = reference_context
24
25
  @entries = parse_entries(match.value)
25
26
  @resolution_cache = {}
26
27
  end
27
28
 
28
- # Resolves every parsed entry for one primary-key scheme.
29
- def resolved_entries_for(primary_path:, &resolver)
30
- signature = primary_signature(primary_path)
29
+ # Resolves every parsed entry for one complete identity scheme.
30
+ def resolved_entries_for(primary_path:, scope_fields:, &resolver)
31
+ signature = identity_signature(primary_path: primary_path, scope_fields: scope_fields)
31
32
  @resolution_cache[signature] ||= @entries.map do |entry|
32
33
  resolver.call(entry)
33
34
  end
@@ -38,20 +39,20 @@ class Engine
38
39
  # Parses one concrete raw value into individual reference entries.
39
40
  def parse_entries(raw_value)
40
41
  @string_array.interpret(raw_value, split: -1, flatten: true).map do |entry|
41
- @reference_template.parse(entry)
42
+ @reference_template.parse(entry, context: @reference_context)
42
43
  end.compact
43
44
  end
44
45
 
45
- # Normalises the cache key for one primary-key scheme.
46
- def primary_signature(primary_path)
47
- primary_path.nil? ? '__relative_path__' : primary_path
46
+ # Normalises the cache key for one primary-key and scope scheme.
47
+ def identity_signature(primary_path:, scope_fields:)
48
+ [primary_path.nil? ? '__relative_path__' : primary_path, scope_fields.map(&:signature)]
48
49
  end
49
50
  end
50
51
 
51
52
  attr_reader :document, :path, :raw_value
52
53
 
53
54
  # Builds one raw-path cache for one document and path.
54
- def initialize(document:, path:, data_path:, string_array:, reference_template:)
55
+ def initialize(document:, path:, data_path:, string_array:, reference_template:, reference_context: nil)
55
56
  @document = document
56
57
  @path = path
57
58
  @data_path = data_path
@@ -64,7 +65,8 @@ class Engine
64
65
  LocationState.new(
65
66
  match: match,
66
67
  string_array: @string_array,
67
- reference_template: @reference_template
68
+ reference_template: @reference_template,
69
+ reference_context: reference_context
68
70
  )
69
71
  end
70
72
  end
@@ -79,14 +81,16 @@ class Engine
79
81
  @read_result.array_traversed?
80
82
  end
81
83
 
82
- # Resolves every parsed entry for one primary-key scheme.
83
- def resolved_entries_for(primary_path:, registry:, active_document_checker: nil)
84
+ # Resolves every parsed entry for one complete identity scheme.
85
+ def resolved_entries_for(primary_path:, scope_fields: [], registry:, relationship: nil, active_document_checker: nil)
84
86
  @location_states.flat_map do |location_state|
85
- location_state.resolved_entries_for(primary_path: primary_path) do |entry|
87
+ location_state.resolved_entries_for(primary_path: primary_path, scope_fields: scope_fields) do |entry|
86
88
  resolve_entry(
87
89
  entry: entry,
88
90
  primary_path: primary_path,
91
+ scope_fields: scope_fields,
89
92
  registry: registry,
93
+ relationship: relationship,
90
94
  active_document_checker: active_document_checker
91
95
  )
92
96
  end
@@ -96,21 +100,39 @@ class Engine
96
100
  private
97
101
 
98
102
  # Resolves one parsed entry to a document and canonical key.
99
- def resolve_entry(entry:, primary_path:, registry:, active_document_checker:)
103
+ def resolve_entry(entry:, primary_path:, scope_fields:, registry:, relationship:, active_document_checker:)
104
+ effective_scope = registry.effective_scope_for(
105
+ reference_scope: entry.scope,
106
+ referring_document: @document,
107
+ scope_fields: scope_fields,
108
+ relationship: relationship
109
+ )
100
110
  document = if entry.document?
101
- entry.page
102
- else
103
- registry.lookup(
104
- key: entry.key,
105
- primary_path: primary_path,
106
- collection: entry.collection.nil? ? nil : entry.collection.to_s
107
- )
108
- end
111
+ registry.validate_scope_match!(
112
+ document: entry.page,
113
+ scope: effective_scope,
114
+ scope_fields: scope_fields,
115
+ relationship: relationship,
116
+ referring_document: @document
117
+ )
118
+ entry.page
119
+ else
120
+ registry.lookup(
121
+ key: entry.key,
122
+ primary_path: primary_path,
123
+ collection: entry.collection.nil? ? nil : entry.collection.to_s,
124
+ scope_fields: scope_fields,
125
+ scope: effective_scope,
126
+ relationship: relationship,
127
+ referring_document: @document
128
+ )
129
+ end
109
130
  return nil if active_document_checker && !active_document_checker.call(document)
110
131
 
111
132
  {
112
133
  document: document,
113
134
  key: registry.key_for(document, primary_path: primary_path),
135
+ scope: effective_scope,
114
136
  metadata: entry.metadata,
115
137
  count: entry.count
116
138
  }
@@ -75,6 +75,9 @@ class Engine
75
75
  target_document = @engine.resolve_reference_document(
76
76
  reference,
77
77
  primary_path: @definition.primary_path,
78
+ scope_fields: @definition.scope_fields,
79
+ referring_document: @document,
80
+ relationship: relationship_description,
78
81
  collection_hint: @definition.to_collection
79
82
  )
80
83
  unless @engine.active_document?(target_document)
@@ -99,6 +102,7 @@ class Engine
99
102
  result = @links.add_detailed_result(
100
103
  document: target_document,
101
104
  key: @engine.registry.key_for(target_document, primary_path: @definition.primary_path),
105
+ scope: @engine.registry.scope_for(target_document, scope_fields: @definition.scope_fields, relationship: relationship_description),
102
106
  metadata: sanitised_metadata(metadata),
103
107
  count: count
104
108
  )
@@ -145,6 +149,9 @@ class Engine
145
149
  target_document = @engine.resolve_reference_document(
146
150
  reference,
147
151
  primary_path: @definition.primary_path,
152
+ scope_fields: @definition.scope_fields,
153
+ referring_document: @document,
154
+ relationship: relationship_description,
148
155
  collection_hint: @definition.to_collection
149
156
  )
150
157
  @engine.clear_persisted_link(source_state: self, target_document: target_document) if clear_persisted
@@ -231,6 +238,11 @@ class Engine
231
238
  "`#{@document.relative_path}` (#{@definition.from_collection} -> #{@definition.to_collection})"
232
239
  end
233
240
 
241
+ # Returns the concrete relationship label used in validation diagnostics.
242
+ def relationship_description
243
+ "#{@definition.from_collection} -> #{@definition.to_collection}"
244
+ end
245
+
234
246
  # Emits one debug event for this state when the definition enables it.
235
247
  def debug(area, event, details)
236
248
  @engine.debug_logger.relationship_event(
@@ -36,6 +36,7 @@ class Engine
36
36
  @relationship_states = {}
37
37
  @document_resolution_states = {}
38
38
  @initial_links_seeded = false
39
+ @defer_bidirectional_mirror_resolvers = false
39
40
  end
40
41
 
41
42
  # Returns true when the document is active in this session.
@@ -71,6 +72,8 @@ class Engine
71
72
  end
72
73
 
73
74
  ensure_initial_links_seeded!
75
+ return state.current_references if defer_bidirectional_mirror_resolver_for?(state)
76
+
74
77
  resolve_state(state)
75
78
  state.current_references
76
79
  end
@@ -94,16 +97,41 @@ class Engine
94
97
  end
95
98
 
96
99
  # Resolves one helper or resolver reference to a document, even if inactive.
97
- def resolve_reference_document(reference, primary_path:, collection_hint: nil)
98
- return reference if reference.is_a?(Jekyll::Document)
100
+ def resolve_reference_document(reference, primary_path:, scope_fields: [], referring_document: nil, relationship: nil, collection_hint: nil)
101
+ if reference.is_a?(Jekyll::Document)
102
+ @registry.scope_for(reference, scope_fields: scope_fields, relationship: relationship)
103
+ return reference
104
+ end
99
105
 
100
- parsed_reference = @configuration.reference_template.parse(reference)
101
- return parsed_reference.page if parsed_reference.document?
106
+ parsed_reference = @configuration.reference_template.parse(
107
+ reference,
108
+ context: reference_context(relationship: relationship, document: referring_document)
109
+ )
110
+ effective_scope = @registry.effective_scope_for(
111
+ reference_scope: parsed_reference.scope,
112
+ referring_document: referring_document,
113
+ scope_fields: scope_fields,
114
+ relationship: relationship
115
+ )
116
+ if parsed_reference.document?
117
+ @registry.validate_scope_match!(
118
+ document: parsed_reference.page,
119
+ scope: effective_scope,
120
+ scope_fields: scope_fields,
121
+ relationship: relationship,
122
+ referring_document: referring_document
123
+ )
124
+ return parsed_reference.page
125
+ end
102
126
 
103
127
  @registry.lookup(
104
128
  key: parsed_reference.key,
105
129
  primary_path: primary_path,
106
- collection: collection_hint || (parsed_reference.collection.nil? ? nil : parsed_reference.collection.to_s)
130
+ collection: collection_hint || (parsed_reference.collection.nil? ? nil : parsed_reference.collection.to_s),
131
+ scope_fields: scope_fields,
132
+ scope: effective_scope,
133
+ relationship: relationship,
134
+ referring_document: referring_document
107
135
  )
108
136
  end
109
137
 
@@ -198,16 +226,12 @@ class Engine
198
226
 
199
227
  # Resolves every configured normal relationship state once.
200
228
  def resolve_all_relationships!
201
- @configuration.collections.each do |collection|
202
- definitions = @configuration.normal_relationships_for(collection)
203
- next if definitions.empty?
204
-
205
- documents_for(collection).each do |document|
206
- definitions.each do |definition|
207
- resolve_relationships(document, definition.to_collection)
208
- end
209
- end
210
- end
229
+ @defer_bidirectional_mirror_resolvers = true
230
+ resolve_all_relationships_pass!
231
+ @defer_bidirectional_mirror_resolvers = false
232
+ resolve_deferred_bidirectional_mirror_resolvers!
233
+ ensure
234
+ @defer_bidirectional_mirror_resolvers = false
211
235
  end
212
236
 
213
237
  # Writes the current session's resolved relationships back to frontmatter.
@@ -217,6 +241,14 @@ class Engine
217
241
 
218
242
  private
219
243
 
244
+ # Builds consistent relationship and source-document details for reference parsing errors.
245
+ def reference_context(relationship:, document:)
246
+ parts = []
247
+ parts << "Relationship `#{relationship}`" unless relationship.nil? || relationship.to_s.empty?
248
+ parts << "reference on document `#{document.relative_path}`" if document
249
+ parts.join(' ')
250
+ end
251
+
220
252
  # Seeds every normal relationship state before any resolver mutates the graph.
221
253
  #
222
254
  # Bidirectional removals can target reverse states that have not been
@@ -244,6 +276,48 @@ class Engine
244
276
  state.resolve!
245
277
  state
246
278
  end
279
+
280
+ # Resolves every configured normal relationship state in collection and sequence order.
281
+ def resolve_all_relationships_pass!
282
+ @configuration.collections.each do |collection|
283
+ definitions = @configuration.normal_relationships_for(collection)
284
+ next if definitions.empty?
285
+
286
+ documents_for(collection).each do |document|
287
+ definitions.each do |definition|
288
+ resolve_relationships(document, definition.to_collection)
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ # Runs deferred resolvers for inverse bidirectional mirrors after forward resolution settles.
295
+ def resolve_deferred_bidirectional_mirror_resolvers!
296
+ @configuration.collections.each do |collection|
297
+ definitions = @configuration.normal_relationships_for(collection).select do |definition|
298
+ deferred_bidirectional_mirror_definition?(definition)
299
+ end
300
+ next if definitions.empty?
301
+
302
+ documents_for(collection).each do |document|
303
+ definitions.each do |definition|
304
+ resolve_relationships(document, definition.to_collection)
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ # Returns true when one definition should run in the deferred mirror-resolver pass.
311
+ def deferred_bidirectional_mirror_definition?(definition)
312
+ definition.bidirectional_mirror? && !definition.resolver_classes.empty?
313
+ end
314
+
315
+ # Returns true when this state should expose current links now but defer running its resolver.
316
+ def defer_bidirectional_mirror_resolver_for?(state)
317
+ return false unless @defer_bidirectional_mirror_resolvers
318
+
319
+ deferred_bidirectional_mirror_definition?(state.definition)
320
+ end
247
321
  end
248
322
  end
249
323
 
@@ -90,6 +90,7 @@ class Engine
90
90
  accumulator.add(
91
91
  document: entry.fetch(:document),
92
92
  key: entry.fetch(:key),
93
+ scope: entry.fetch(:scope),
93
94
  metadata: entry.fetch(:metadata),
94
95
  count: entry.fetch(:count)
95
96
  )
@@ -43,7 +43,7 @@ class Engine
43
43
  return if @configuration.collections.empty?
44
44
 
45
45
  @registry.validate_collections!
46
- @registry.validate_primary_paths!(primary_paths: @configuration.primary_paths)
46
+ @registry.validate_identity_schemes!(identity_schemes: @configuration.identity_schemes)
47
47
  initial_active_document_ids = all_active_document_ids
48
48
  initial_tree_seed_graph = build_tree_graph(active_document_ids: initial_active_document_ids)
49
49
  normal_seed = NormalSeed.new(engine: self, active_document_ids: initial_active_document_ids)
@@ -6,12 +6,11 @@ module Plugins
6
6
  module Relationships
7
7
  module Pruning
8
8
 
9
- # Applies minimum-count prune rules to a mutable graph view.
9
+ # Applies configured prune-rule predicates to a mutable graph view.
10
10
  #
11
11
  # The graph view may represent either resolved normal relationships or the
12
- # current tree adjacency. Pruning is recursive within the supplied graph:
13
- # removing one document can reduce the neighbour counts of others, so the pruner
14
- # keeps deleting documents until every rule is satisfied.
12
+ # current tree adjacency. Pruning is recursive within the supplied graph because
13
+ # removing one document can make relationship predicates select others.
15
14
  class RulePruner
16
15
  # Builds one pruner over one mutable graph view and one ordered rule list.
17
16
  def initialize(graph:, rules:, subject_documents_resolver: nil)
@@ -20,7 +19,7 @@ class RulePruner
20
19
  @subject_documents_resolver = subject_documents_resolver
21
20
  end
22
21
 
23
- # Removes documents until all configured minimums are satisfied.
22
+ # Removes documents until no configured rule selects another candidate.
24
23
  def prune!
25
24
  removed_documents = []
26
25
 
@@ -40,12 +39,13 @@ class RulePruner
40
39
 
41
40
  private
42
41
 
43
- # Finds every document that currently violates at least one prune rule.
42
+ # Finds every document selected by at least one prune rule.
44
43
  def prune_candidates
45
44
  @rules.each_with_object({}) do |rule, candidates|
46
45
  subject_documents_for(rule).each do |document|
47
46
  next if candidates.key?(document.object_id)
48
- next unless rule.neighbour_documents(graph: @graph, document: document).length < rule.min
47
+ next unless rule.frontmatter_selected?(document)
48
+ next unless rule.relationship_selected?(graph: @graph, document: document)
49
49
 
50
50
  candidates[document.object_id] = document
51
51
  end
@@ -291,30 +291,31 @@ class TreePhase
291
291
  end.sort_by(&:relative_path)
292
292
  end
293
293
 
294
- # Attempts to reconnect one orphan to its surviving original grandparents.
294
+ # Attempts to reconnect one orphan to its nearest surviving original ancestors.
295
295
  def reattach_orphan!(graph:, document:)
296
- grandparent_candidates_for(graph: graph, document: document).each do |candidate|
296
+ nearest_surviving_ancestor_candidates_for(graph: graph, document: document).each do |candidate|
297
297
  graph.add_edge(
298
298
  parent_document: candidate.fetch(:document),
299
299
  child_document: document,
300
300
  tree_settings: candidate.fetch(:tree_settings),
301
301
  definition: candidate.fetch(:definition),
302
- source_description: 'pruning grandparents'
302
+ source_description: 'pruning ancestors'
303
303
  )
304
304
  end
305
305
 
306
306
  graph.parent_documents_for(document).any?
307
307
  end
308
308
 
309
- # Returns every surviving grandparent candidate for one orphan.
310
- def grandparent_candidates_for(graph:, document:)
309
+ # Returns the first surviving ancestor on every original parent lineage.
310
+ def nearest_surviving_ancestor_candidates_for(graph:, document:)
311
311
  @provenance.original_parent_entries_for(document).each_with_object({}) do |parent_entry, candidates|
312
- @provenance.original_parent_entries_for(parent_entry.fetch(:document)).each do |grandparent_entry|
313
- grandparent_document = grandparent_entry.fetch(:document)
314
- next unless graph.active_document?(grandparent_document)
315
-
316
- candidates[grandparent_document.object_id] ||= {
317
- document: grandparent_document,
312
+ nearest_surviving_ancestors_for(
313
+ graph: graph,
314
+ document: parent_entry.fetch(:document),
315
+ visited_document_ids: {}
316
+ ).each do |ancestor_document|
317
+ candidates[ancestor_document.object_id] ||= {
318
+ document: ancestor_document,
318
319
  definition: parent_entry.fetch(:definition),
319
320
  tree_settings: parent_entry.fetch(:tree_settings)
320
321
  }
@@ -322,6 +323,23 @@ class TreePhase
322
323
  end.values
323
324
  end
324
325
 
326
+ # Walks removed provenance nodes until each lineage reaches an active document.
327
+ def nearest_surviving_ancestors_for(graph:, document:, visited_document_ids:)
328
+ return [] if visited_document_ids.key?(document.object_id)
329
+ return [document] if graph.active_document?(document)
330
+
331
+ lineage_visited_document_ids = visited_document_ids.merge(document.object_id => true)
332
+ @provenance.original_parent_entries_for(document).each_with_object({}) do |parent_entry, ancestors|
333
+ nearest_surviving_ancestors_for(
334
+ graph: graph,
335
+ document: parent_entry.fetch(:document),
336
+ visited_document_ids: lineage_visited_document_ids
337
+ ).each do |ancestor_document|
338
+ ancestors[ancestor_document.object_id] ||= ancestor_document
339
+ end
340
+ end.values
341
+ end
342
+
325
343
  # Returns one document array with later duplicates removed by object id.
326
344
  def unique_documents(documents)
327
345
  Array(documents).each_with_object({}) do |document, unique_documents|
@@ -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,26 +24,27 @@ 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
33
  add_detailed_result(
34
34
  document: document,
35
35
  key: key,
36
+ scope: scope,
36
37
  metadata: metadata,
37
38
  count: count
38
39
  ).fetch(:action)
39
40
  end
40
41
 
41
42
  # Adds one resolved relationship entry and returns the stored entry as well.
42
- def add_detailed_result(document:, key:, metadata: nil, count: 1)
43
+ def add_detailed_result(document:, key:, scope: nil, metadata: nil, count: 1)
43
44
  if @multiple_settings.keep?
44
45
  return {
45
46
  action: :added,
46
- entry: append_entry(document: document, key: key, metadata: metadata, count: 1)
47
+ entry: append_entry(document: document, key: key, scope: scope, metadata: metadata, count: 1)
47
48
  }
48
49
  end
49
50
 
@@ -68,6 +69,7 @@ class Accumulator
68
69
  entry = append_entry(
69
70
  document: document,
70
71
  key: key,
72
+ scope: scope,
71
73
  metadata: metadata,
72
74
  count: counted_mode? ? normalise_count(count) : 1
73
75
  )
@@ -157,10 +159,11 @@ class Accumulator
157
159
  end
158
160
 
159
161
  # Appends one entry while recording its first-seen order.
160
- def append_entry(document:, key:, metadata:, count:)
162
+ def append_entry(document:, key:, scope:, metadata:, count:)
161
163
  entry = {
162
164
  document: document,
163
165
  key: key,
166
+ scope: duplicate_scope(scope),
164
167
  metadata: normalise_metadata(metadata),
165
168
  count: count,
166
169
  first_seen_index: @next_position
@@ -184,6 +187,7 @@ class Accumulator
184
187
  {
185
188
  document: entry.fetch(:document),
186
189
  key: entry.fetch(:key),
190
+ scope: duplicate_scope(entry.fetch(:scope)),
187
191
  metadata: normalise_metadata(entry.fetch(:metadata)),
188
192
  count: entry.fetch(:count),
189
193
  first_seen_index: entry.fetch(:first_seen_index)
@@ -195,11 +199,19 @@ class Accumulator
195
199
  @reference_template.build(
196
200
  document: entry.fetch(:document),
197
201
  key: entry.fetch(:key),
202
+ scope: entry.fetch(:scope),
198
203
  metadata: entry.fetch(:metadata),
199
204
  count: entry.fetch(:count)
200
205
  )
201
206
  end
202
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
+
203
215
  # Merges later metadata under the first-seen metadata.
204
216
  def merge_metadata_under(existing_metadata:, incoming_metadata:)
205
217
  Jekyll::Plugins::Relationships::Support.hash_deep_merge(