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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4f49c60e8c57f7661b7fdec54b0d4d8ce2e9564dca858e86126ed2f810bfdec
4
- data.tar.gz: f0c22961ff04cc0493e923676d45a9de3c69ff6720f72160e7897bf921272687
3
+ metadata.gz: 583920383adff1b25fb21db2130a379a76c6070780bb6fceddeb04c8eb0c5340
4
+ data.tar.gz: 528d4185f7ee05eced524a732096f19eea13750cb2ca85efa0d9cf325ad532dd
5
5
  SHA512:
6
- metadata.gz: d4b352ce61991bf340b239a9aa881e1314274371637a96e24f317597a6343fb5bebb763fbe6b60f31d8877b3e01f1e4bfc5076994382940f08a757906f632933
7
- data.tar.gz: 1ec40c78e1bf65e5781b580c4effc37e5510424031267c120a08be6bb6a0c5ffc1f36ccffa1fc4817b6c1ddc451fe996947b4403af48e36db7112d875dcd0bc2
6
+ metadata.gz: ede20ebf3d937fa6deda8da76f087d4040f7de50204b70675646cfcc3b860014d69929afde3de3c1f243cf8810f31a723ab914373ca8120a68308d6852501b9e
7
+ data.tar.gz: 3a5a9f5b7d9ed651af6bd9485749d6b0cf37d366f4da3a238d05c4f533440374bbc27dcfd3b43643d62c91a2a80646037130a296ecf86b3c59f3c8d38303e958
@@ -15,6 +15,7 @@ class Configuration
15
15
  FRONTMATTER = {
16
16
  'base' => 'relationships',
17
17
  'primary' => nil,
18
+ 'scope' => nil,
18
19
  'foreign' => '<collection>',
19
20
  'output' => nil
20
21
  }.freeze
@@ -9,9 +9,17 @@ class Configuration
9
9
 
10
10
  # Encapsulates one merged frontmatter configuration block.
11
11
  #
12
- # Instances resolve `base`, `primary`, `foreign`, and `output` paths so the
12
+ # Instances resolve `base`, `primary`, `scope`, `foreign`, and `output` paths so the
13
13
  # rest of the engine can work with explicit values only.
14
14
  class Frontmatter
15
+ # Describes one configured scope field while preserving both its public name and its base-resolved document path.
16
+ ScopeField = Struct.new(:name, :path) do
17
+ # Returns a stable value signature suitable for registry cache keys.
18
+ def signature
19
+ [name, path]
20
+ end
21
+ end
22
+
15
23
  # Builds one frontmatter configuration helper.
16
24
  def initialize(raw_config:, string_array:)
17
25
  @raw_config = raw_config.is_a?(Hash) ? raw_config : {}
@@ -34,6 +42,25 @@ class Configuration
34
42
  apply_base(raw_primary.to_s)
35
43
  end
36
44
 
45
+ # Returns the configured scope fields with their literal reference keys and resolved document paths.
46
+ def scope_fields
47
+ raw_scope = fetch_value('scope')
48
+ return [] if raw_scope.nil?
49
+ unless raw_scope.is_a?(String) || raw_scope.is_a?(Array)
50
+ raise ConfigurationError, '`frontmatter.scope` must be a string, comma-delimited string, or array of non-empty path strings.'
51
+ end
52
+
53
+ raw_paths = raw_scope.is_a?(String) ? @string_array.interpret(raw_scope, split: true, flatten: true) : raw_scope
54
+ if raw_paths.empty? || raw_paths.any? { |path| !path.is_a?(String) || path.strip.empty? }
55
+ raise ConfigurationError, '`frontmatter.scope` must contain one or more non-empty path strings.'
56
+ end
57
+
58
+ raw_paths.map do |path|
59
+ name = path.strip
60
+ ScopeField.new(name, apply_base(name)).freeze
61
+ end.uniq { |field| field.name }
62
+ end
63
+
37
64
  # Returns the resolved foreign input paths for one target collection.
38
65
  def foreign_paths_for(to_collection:)
39
66
  raw_foreign = fetch_value('foreign')
@@ -241,6 +241,7 @@ class Configuration
241
241
  from_collection: from_collection,
242
242
  to_collection: to_collection,
243
243
  primary_path: frontmatter.primary_path,
244
+ scope_fields: frontmatter.scope_fields,
244
245
  parent_child_pairs: parent_child_pairs(from_collection: from_collection, to_collection: to_collection, mode: mode),
245
246
  tree_settings: tree_settings,
246
247
  debug: debug,
@@ -259,6 +260,7 @@ class Configuration
259
260
  from_collection: from_collection,
260
261
  to_collection: to_collection,
261
262
  primary_path: frontmatter.primary_path,
263
+ scope_fields: frontmatter.scope_fields,
262
264
  foreign_paths: frontmatter.foreign_paths_for(to_collection: to_collection),
263
265
  output_path: frontmatter.output_path_for(to_collection: to_collection),
264
266
  debug: debug,
@@ -291,11 +293,25 @@ class Configuration
291
293
  if prune_configuration.shortcut?
292
294
  raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot use `prune: <int>` on a tree relationship."
293
295
  end
294
- if prune_configuration.depth.nil?
296
+ if prune_configuration.min && prune_configuration.depth.nil?
295
297
  raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.depth` when pruning a tree relationship."
296
298
  end
297
- elsif !prune_configuration.depth.nil?
298
- raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.depth` on a normal relationship."
299
+ if prune_configuration.min.nil? && prune_configuration.depth
300
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.min` when defining `prune.depth` on a tree relationship."
301
+ end
302
+ if prune_configuration.min.nil? && prune_configuration.where.nil?
303
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define either `prune.where` or both `prune.min` and `prune.depth` when pruning a tree relationship."
304
+ end
305
+ else
306
+ if prune_configuration.where
307
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.where` on a normal relationship."
308
+ end
309
+ if !prune_configuration.depth.nil?
310
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.depth` on a normal relationship."
311
+ end
312
+ if prune_configuration.min.nil?
313
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.min` when pruning a normal relationship."
314
+ end
299
315
  end
300
316
 
301
317
  prune_rules_for_entry(
@@ -358,6 +374,7 @@ class Configuration
358
374
  members: members,
359
375
  min: prune_configuration.min,
360
376
  depth: prune_configuration.depth,
377
+ where: prune_configuration.where,
361
378
  inverse: prune_configuration.inverse?,
362
379
  entry_index: entry_index
363
380
  )
@@ -11,10 +11,10 @@ class Configuration
11
11
  #
12
12
  # Relationship entries and hash-form targets may opt into pruning by defining
13
13
  # a minimum neighbour count and, optionally, switching the subject of the rule
14
- # to the inverse side of the relationship. Tree prune rules may also
15
- # constrain which depths are eligible for pruning.
14
+ # to the inverse side of the relationship. Tree prune rules may constrain
15
+ # eligible depths, select frontmatter values, or combine both modes.
16
16
  class PruneRuleSettings
17
- attr_reader :mode, :min, :depth
17
+ attr_reader :mode, :min, :depth, :where
18
18
 
19
19
  # Interprets one loose prune config value.
20
20
  def self.build(raw_config:, context:)
@@ -31,6 +31,7 @@ class Configuration
31
31
  @mode = 'direct'
32
32
  @min = normalise_min(raw_config, context: context)
33
33
  @depth = nil
34
+ @where = nil
34
35
  return
35
36
  end
36
37
 
@@ -43,11 +44,15 @@ class Configuration
43
44
  @min = normalise_min(
44
45
  Configuration::HashUtilities.fetch_hash_value(raw_config, 'min'),
45
46
  context: context
46
- )
47
+ ) if Configuration::HashUtilities.hash_key?(raw_config, 'min')
47
48
  @depth = normalise_depth(
48
49
  Configuration::HashUtilities.hash_key?(raw_config, 'depth') ? Configuration::HashUtilities.fetch_hash_value(raw_config, 'depth') : nil,
49
50
  context: context
50
51
  )
52
+ @where = normalise_where(
53
+ Configuration::HashUtilities.fetch_hash_value(raw_config, 'where'),
54
+ context: context
55
+ ) if Configuration::HashUtilities.hash_key?(raw_config, 'where')
51
56
  end
52
57
 
53
58
  # Returns true when the rule prunes the inverse side of the relationship.
@@ -73,7 +78,7 @@ class Configuration
73
78
  raise ConfigurationError, "Unsupported `#{context}.mode` value `#{raw_mode}`."
74
79
  end
75
80
 
76
- # Resolves the configured minimum count and rejects non-positive values.
81
+ # Resolves the configured minimum count and rejects missing or non-positive values.
77
82
  def normalise_min(raw_min, context:)
78
83
  raise ConfigurationError, "`#{context}` must define `min`." if raw_min.nil?
79
84
 
@@ -92,6 +97,29 @@ class Configuration
92
97
 
93
98
  interpreted_depth
94
99
  end
100
+
101
+ # Resolves a non-empty hash of valid dot paths to exact expected values.
102
+ def normalise_where(raw_where, context:)
103
+ raise ConfigurationError, "`#{context}.where` must be a non-empty hash." unless raw_where.is_a?(Hash) && !raw_where.empty?
104
+
105
+ raw_where.each_with_object({}) do |(raw_path, expected_value), where|
106
+ path = normalise_where_path(raw_path, context: context)
107
+ raise ConfigurationError, "`#{context}.where` defines path `#{path}` more than once." if where.key?(path)
108
+
109
+ where[path] = expected_value
110
+ end.freeze
111
+ end
112
+
113
+ # Resolves one dot path and rejects blank segments.
114
+ def normalise_where_path(raw_path, context:)
115
+ path = raw_path.to_s.strip
116
+ segments = path.split('.', -1).map(&:strip)
117
+ if path.empty? || segments.any?(&:empty?)
118
+ raise ConfigurationError, "`#{context}.where` paths must contain non-blank dot-separated fields."
119
+ end
120
+
121
+ segments.join('.')
122
+ end
95
123
  end
96
124
  end
97
125
 
@@ -53,10 +53,6 @@ class Configuration
53
53
  ),
54
54
  string_array: @string_array
55
55
  )
56
- @reference_template = References::Template.new(
57
- config: build_reference_config,
58
- count_enabled: @multiple_settings.count?
59
- )
60
56
  @tree_settings = TreeSettings.defaults(
61
57
  string_array: @string_array
62
58
  ).merge_level(
@@ -81,6 +77,11 @@ class Configuration
81
77
  @configured_relationships = parsed_result.fetch(:configured_relationships)
82
78
  @normal_prune_rules = parsed_result.fetch(:normal_prune_rules)
83
79
  @tree_prune_rules = parsed_result.fetch(:tree_prune_rules)
80
+ @reference_template = References::Template.new(
81
+ config: build_reference_config(scope_enabled: scope_configured?),
82
+ count_enabled: @multiple_settings.count?
83
+ )
84
+ validate_scope_reference_template!
84
85
  attach_resolvers!(parser: parsed_relationships)
85
86
  end
86
87
 
@@ -119,6 +120,24 @@ class Configuration
119
120
  (normal_paths + tree_paths).uniq
120
121
  end
121
122
 
123
+ # Returns every distinct primary-key and scope scheme used by configured relationships.
124
+ def identity_schemes
125
+ definitions = @normal_relationships.values.flat_map(&:values) + @tree_relationships
126
+ definitions.group_by do |definition|
127
+ [
128
+ definition.primary_path,
129
+ definition.scope_fields.map(&:signature)
130
+ ]
131
+ end.map do |_, matching_definitions|
132
+ {
133
+ primary_path: matching_definitions.first.primary_path,
134
+ scope_fields: matching_definitions.first.scope_fields,
135
+ collections: matching_definitions.flat_map { |definition| [definition.from_collection, definition.to_collection] }.uniq.sort,
136
+ relationships: matching_definitions.map { |definition| "#{definition.from_collection} -> #{definition.to_collection}" }.uniq
137
+ }
138
+ end
139
+ end
140
+
122
141
  private
123
142
 
124
143
  # Builds the active global enabled setting.
@@ -142,7 +161,7 @@ class Configuration
142
161
  string_array: @string_array
143
162
  )
144
163
  @reference_template = References::Template.new(
145
- config: default_reference_config,
164
+ config: default_reference_config(scope_enabled: false),
146
165
  count_enabled: @multiple_settings.count?
147
166
  )
148
167
  @tree_settings = TreeSettings.defaults(string_array: @string_array)
@@ -195,8 +214,8 @@ class Configuration
195
214
  end
196
215
 
197
216
  # Builds the resolved reference template config.
198
- def build_reference_config
199
- default_references = default_reference_config
217
+ def build_reference_config(scope_enabled:)
218
+ default_references = default_reference_config(scope_enabled: scope_enabled)
200
219
  explicit_config = Configuration::HashUtilities.fetch_hash_value(@raw_config, 'references')
201
220
  return explicit_config if explicit_config.is_a?(Hash)
202
221
 
@@ -212,12 +231,15 @@ class Configuration
212
231
  end
213
232
 
214
233
  # Builds the default reference template shape for the active duplicate mode.
215
- def default_reference_config
234
+ def default_reference_config(scope_enabled:)
216
235
  default_references = {
217
236
  'id' => Jekyll::Plugins::Relationships::Support::Placeholders::KEY,
218
237
  'collection' => Jekyll::Plugins::Relationships::Support::Placeholders::COLLECTION,
219
238
  'page' => Jekyll::Plugins::Relationships::Support::Placeholders::PAGE
220
239
  }
240
+ if scope_enabled
241
+ default_references['scope'] = Jekyll::Plugins::Relationships::Support::Placeholders::SCOPE
242
+ end
221
243
  if @multiple_settings.count?
222
244
  default_references['count'] = Jekyll::Plugins::Relationships::Support::Placeholders::COUNT
223
245
  end
@@ -225,6 +247,19 @@ class Configuration
225
247
  default_references
226
248
  end
227
249
 
250
+ # Returns true when at least one concrete relationship uses scoped identity.
251
+ def scope_configured?
252
+ identity_schemes.any? { |scheme| !scheme.fetch(:scope_fields).empty? }
253
+ end
254
+
255
+ # Ensures scoped relationships have a reserved location for their complete effective scope.
256
+ def validate_scope_reference_template!
257
+ return unless scope_configured?
258
+ return if @reference_template.scope_property
259
+
260
+ raise ConfigurationError, 'Reference config must define exactly one <scope> property when any relationship configures `frontmatter.scope`.'
261
+ end
262
+
228
263
  # Attaches registered resolver classes to matching concrete relationships.
229
264
  def attach_resolvers!(parser:)
230
265
  Resolvers::Base.registered_subclasses.each do |resolver_class|
@@ -11,14 +11,15 @@ module Definitions
11
11
  # Each instance describes one source collection, one target collection, and the
12
12
  # resolved frontmatter rules that apply to links between them.
13
13
  class NormalRelationship
14
- attr_reader :from_collection, :to_collection, :primary_path, :foreign_paths,
14
+ attr_reader :from_collection, :to_collection, :primary_path, :scope_fields, :foreign_paths,
15
15
  :output_path, :sequence, :reads_frontmatter, :bidirectional, :resolver_classes
16
16
 
17
17
  # Captures the resolved configuration for one concrete pair.
18
- def initialize(from_collection:, to_collection:, primary_path:, foreign_paths:, output_path:, debug:, sequence:, reads_frontmatter:, bidirectional:)
18
+ def initialize(from_collection:, to_collection:, primary_path:, scope_fields: [], foreign_paths:, output_path:, debug:, sequence:, reads_frontmatter:, bidirectional:)
19
19
  @from_collection = from_collection
20
20
  @to_collection = to_collection
21
21
  @primary_path = primary_path
22
+ @scope_fields = scope_fields.freeze
22
23
  @foreign_paths = foreign_paths
23
24
  @output_path = output_path
24
25
  @debug = debug
@@ -38,6 +39,11 @@ class NormalRelationship
38
39
  @debug.enabled?(area)
39
40
  end
40
41
 
42
+ # Returns true when this relationship is the synthetic inverse of a bidirectional pair.
43
+ def bidirectional_mirror?
44
+ @bidirectional && !@reads_frontmatter
45
+ end
46
+
41
47
  # Returns true when one event's related IDs satisfy this pair's debug filter.
42
48
  def debug_ids_match?(ids)
43
49
  @debug.matches_ids?(ids)
@@ -11,17 +11,19 @@ module Definitions
11
11
  # Raw relationship entries may expand into many concrete relationship members.
12
12
  # After the global prune settings have been applied, each expanded prune rule
13
13
  # targets exactly one subject collection and one set of configured relationship
14
- # members whose neighbour counts should be combined for that subject.
14
+ # members. Rules select by neighbour count, frontmatter values, or both.
15
15
  class PruneRule
16
- attr_reader :kind, :subject_collection, :members, :min, :depth, :entry_index
16
+ attr_reader :kind, :subject_collection, :members, :min, :depth, :where, :entry_index
17
17
 
18
18
  # Captures one immutable prune rule.
19
- def initialize(kind:, subject_collection:, members:, min:, depth:, inverse:, entry_index:)
19
+ def initialize(kind:, subject_collection:, members:, min:, depth:, where:, inverse:, entry_index:)
20
20
  @kind = kind
21
21
  @subject_collection = subject_collection
22
22
  @members = members.sort_by(&:sequence).freeze
23
23
  @min = min
24
24
  @depth = depth
25
+ @where = where
26
+ @frontmatter_matcher = Support::FrontmatterMatcher.new(expected_values: @where) unless @where.nil?
25
27
  @inverse = inverse
26
28
  @entry_index = entry_index
27
29
  end
@@ -41,6 +43,20 @@ class PruneRule
41
43
  @kind == :normal
42
44
  end
43
45
 
46
+ # Returns true when the document satisfies this rule's frontmatter mode.
47
+ def frontmatter_selected?(document)
48
+ return true unless @frontmatter_matcher
49
+
50
+ @frontmatter_matcher.matches?(document.data)
51
+ end
52
+
53
+ # Returns true when the document satisfies this rule's relationship mode.
54
+ def relationship_selected?(graph:, document:)
55
+ return true if @min.nil?
56
+
57
+ neighbour_documents(graph: graph, document: document).length < @min
58
+ end
59
+
44
60
  # Returns the combined neighbour documents for one subject document.
45
61
  def neighbour_documents(graph:, document:)
46
62
  @members.each_with_object({}) do |member, neighbours|
@@ -12,13 +12,14 @@ module Definitions
12
12
  # and which frontmatter, maximum, URL, and primary-key rules should be used to
13
13
  # resolve references for this concrete relationship definition.
14
14
  class TreeRelationship
15
- attr_reader :from_collection, :to_collection, :primary_path, :tree_settings, :sequence
15
+ attr_reader :from_collection, :to_collection, :primary_path, :scope_fields, :tree_settings, :sequence
16
16
 
17
17
  # Captures the allowed parent-child directions for one tree definition.
18
- def initialize(from_collection:, to_collection:, primary_path:, parent_child_pairs:, tree_settings:, debug:, sequence:)
18
+ def initialize(from_collection:, to_collection:, primary_path:, scope_fields: [], parent_child_pairs:, tree_settings:, debug:, sequence:)
19
19
  @from_collection = from_collection
20
20
  @to_collection = to_collection
21
21
  @primary_path = primary_path
22
+ @scope_fields = scope_fields.freeze
22
23
  @parent_child_pairs = parent_child_pairs
23
24
  @tree_settings = tree_settings
24
25
  @debug = debug
@@ -6,17 +6,18 @@ module Plugins
6
6
  module Relationships
7
7
  module Documents
8
8
 
9
- # Indexes participating documents by collection and primary key.
9
+ # Indexes participating documents by collection, primary key, and optional complete scope.
10
10
  #
11
- # Indices are built lazily per primary-key path so relationship definitions can
12
- # use different key schemes without rebuilding unrelated lookups.
11
+ # Indices are built lazily per complete identity scheme so relationship definitions can
12
+ # use different primary and scope paths without rebuilding unrelated lookups.
13
13
  class Registry
14
14
 
15
15
  # Builds the registry over the collections used by the configuration.
16
16
  def initialize(site:, collections:)
17
17
  @site = site
18
18
  @collections = collections
19
- @indices_by_primary_path = {}
19
+ @indices_by_identity = {}
20
+ @data_path = Jekyll::Plugins::Relationships::Support::DataPath.new
20
21
  end
21
22
 
22
23
  # Returns true when the document belongs to one of the tracked collections.
@@ -48,7 +49,19 @@ class Registry
48
49
  # Builds every requested primary-key index so duplicates fail eagerly.
49
50
  def validate_primary_paths!(primary_paths:)
50
51
  Array(primary_paths).uniq.each do |primary_path|
51
- index_for(primary_path)
52
+ index_for(primary_path: primary_path, scope_fields: [])
53
+ end
54
+ end
55
+
56
+ # Builds every requested identity index so missing scope values and duplicate identities fail eagerly.
57
+ def validate_identity_schemes!(identity_schemes:)
58
+ Array(identity_schemes).each do |scheme|
59
+ index_for(
60
+ primary_path: scheme.fetch(:primary_path),
61
+ scope_fields: scheme.fetch(:scope_fields),
62
+ collections: scheme.fetch(:collections),
63
+ relationship: scheme.fetch(:relationships).join(', ')
64
+ )
52
65
  end
53
66
  end
54
67
 
@@ -57,61 +70,157 @@ class Registry
57
70
  if primary_path.nil?
58
71
  default_relative_key(document)
59
72
  else
60
- value = Jekyll::Plugins::Relationships::Support::DataPath.new.read(document.data, primary_path)
73
+ value = @data_path.read(document.data, primary_path)
61
74
  raise ResolutionError, "Document `#{document.relative_path}` is missing primary key path `#{primary_path}`." if value.nil?
62
75
 
63
76
  value.to_s
64
77
  end
65
78
  end
66
79
 
67
- # Resolves one key to a document, optionally constrained to a collection.
68
- def lookup(key:, primary_path:, collection: nil)
69
- index = index_for(primary_path)
70
- if collection
71
- document = fetch_nested(index.fetch(:by_collection), collection, key.to_s)
72
- raise ResolutionError, "Could not resolve key `#{key}` in collection `#{collection}`." unless document
80
+ # Reads and validates a document's complete scope for one configured identity scheme.
81
+ def scope_for(document, scope_fields:, relationship: nil)
82
+ return nil if scope_fields.empty?
83
+
84
+ scope_fields.each_with_object({}) do |field, scope|
85
+ value = @data_path.read(document.data, field.path)
86
+ if value.nil?
87
+ raise ResolutionError, "#{relationship_prefix(relationship)}document `#{document.relative_path}` is missing required scope field `#{field.name}` at frontmatter path `#{field.path}`."
88
+ end
89
+
90
+ scope[field.name] = value
91
+ end
92
+ end
93
+
94
+ # Resolves reference overrides over the referring document's scope and validates every configured field.
95
+ def effective_scope_for(reference_scope:, referring_document:, scope_fields:, relationship: nil)
96
+ return nil if scope_fields.empty? && (reference_scope.nil? || reference_scope.empty?)
97
+
98
+ reference_scope ||= {}
99
+ unless reference_scope.is_a?(Hash)
100
+ raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` must provide scope as a hash."
101
+ end
102
+
103
+ configured_names = scope_fields.map(&:name)
104
+ unknown_names = reference_scope.keys.map(&:to_s) - configured_names
105
+ unless unknown_names.empty?
106
+ raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` uses unconfigured scope field `#{unknown_names.first}`."
107
+ end
108
+
109
+ scope_fields.each_with_object({}) do |field, effective_scope|
110
+ value = if hash_property?(reference_scope, field.name)
111
+ Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(reference_scope, field.name)
112
+ else
113
+ @data_path.read(referring_document.data, field.path)
114
+ end
115
+ if value.nil?
116
+ raise ResolutionError, "#{relationship_prefix(relationship)}reference on document `#{referring_document.relative_path}` has no effective value for scope field `#{field.name}`."
117
+ end
73
118
 
74
- return document
119
+ effective_scope[field.name] = value
75
120
  end
121
+ end
122
+
123
+ # Ensures one already-selected target contains and exactly matches an effective scope.
124
+ def validate_scope_match!(document:, scope:, scope_fields:, relationship: nil, referring_document: nil)
125
+ return nil if scope_fields.empty?
76
126
 
77
- matches = index.fetch(:by_key).fetch(key.to_s, [])
78
- raise ResolutionError, "Could not resolve key-only reference `#{key}`." if matches.empty?
79
- return matches.first if index.fetch(:site_wide_unique)
80
- return matches.first if matches.length == 1
127
+ target_scope = scope_for(document, scope_fields: scope_fields, relationship: relationship)
128
+ scope_fields.each do |field|
129
+ next if target_scope.fetch(field.name) == scope.fetch(field.name)
81
130
 
82
- collections = matches.map { |document| collection_label(document) }.uniq.sort.join(', ')
83
- raise ResolutionError, "Key-only reference `#{key}` is ambiguous across collections: #{collections}."
131
+ raise ResolutionError, scope_mismatch_message(
132
+ document: document,
133
+ field: field,
134
+ expected_value: scope.fetch(field.name),
135
+ actual_value: target_scope.fetch(field.name),
136
+ relationship: relationship,
137
+ referring_document: referring_document
138
+ )
139
+ end
140
+
141
+ target_scope
142
+ end
143
+
144
+ # Resolves one key and complete scope to a document, optionally constrained to a collection.
145
+ def lookup(key:, primary_path:, collection: nil, scope_fields: [], scope: nil, relationship: nil, referring_document: nil)
146
+ index = index_for(primary_path: primary_path, scope_fields: scope_fields, relationship: relationship)
147
+ candidates = if collection
148
+ fetch_nested(index.fetch(:by_collection), collection, key.to_s) || []
149
+ else
150
+ index.fetch(:by_key).fetch(key.to_s, [])
151
+ end
152
+ if candidates.empty?
153
+ if collection
154
+ raise ResolutionError, "#{relationship_prefix(relationship)}could not resolve key `#{key}` in collection `#{collection}`#{source_suffix(referring_document)}."
155
+ end
156
+
157
+ raise ResolutionError, "#{relationship_prefix(relationship)}could not resolve key-only reference `#{key}`#{source_suffix(referring_document)}."
158
+ end
159
+
160
+ matches = if scope_fields.empty?
161
+ candidates
162
+ else
163
+ candidates.select { |entry| entry.fetch(:scope) == scope }
164
+ end
165
+ if matches.empty?
166
+ raise_scope_mismatch!(
167
+ candidates: candidates,
168
+ scope: scope,
169
+ scope_fields: scope_fields,
170
+ relationship: relationship,
171
+ referring_document: referring_document
172
+ )
173
+ end
174
+
175
+ matched_documents = matches.map { |entry| entry.fetch(:document) }
176
+ return matched_documents.first if collection
177
+
178
+ return matched_documents.first if matched_documents.length == 1
179
+
180
+ collections = matched_documents.map { |document| collection_label(document) }.uniq.sort.join(', ')
181
+ raise ResolutionError, "#{relationship_prefix(relationship)}key-only reference `#{key}` is ambiguous across collections: #{collections}#{source_suffix(referring_document)}."
84
182
  end
85
183
 
86
184
  private
87
185
 
88
- # Returns or builds the index for one primary-key scheme.
89
- def index_for(primary_path)
90
- signature = primary_path.nil? ? '__relative_path__' : primary_path
91
- @indices_by_primary_path[signature] ||= build_index(primary_path)
186
+ # Returns or builds the index for one complete identity scheme.
187
+ def index_for(primary_path:, scope_fields:, collections: nil, relationship: nil)
188
+ signature = [primary_path.nil? ? '__relative_path__' : primary_path, scope_fields.map(&:signature)]
189
+ @indices_by_identity[signature] ||= build_index(
190
+ primary_path: primary_path,
191
+ scope_fields: scope_fields,
192
+ collections: collections || @collections,
193
+ relationship: relationship
194
+ )
92
195
  end
93
196
 
94
- # Builds the collection and global indices for one scheme.
95
- def build_index(primary_path)
96
- by_collection = Hash.new { |hash, key| hash[key] = {} }
197
+ # Builds the collection and global indices for one complete identity scheme.
198
+ def build_index(primary_path:, scope_fields:, collections:, relationship: nil)
199
+ by_collection = Hash.new { |hash, key| hash[key] = Hash.new { |nested_hash, nested_key| nested_hash[nested_key] = [] } }
97
200
  by_key = Hash.new { |hash, key| hash[key] = [] }
98
201
 
99
- @collections.each do |collection|
202
+ collections.each do |collection|
100
203
  documents_for(collection).each do |document|
101
204
  key = key_for(document, primary_path: primary_path)
102
- if by_collection[collection].key?(key)
103
- raise ResolutionError, "Primary key `#{key}` is duplicated within collection `#{collection}`."
205
+ scope = scope_for(document, scope_fields: scope_fields, relationship: relationship)
206
+ existing_entry = by_collection[collection][key].find { |entry| entry.fetch(:scope) == scope }
207
+ if existing_entry
208
+ if scope_fields.empty?
209
+ raise ResolutionError, "Primary key `#{key}` is duplicated within collection `#{collection}`."
210
+ end
211
+
212
+ raise ResolutionError, "#{relationship_prefix(relationship)}primary key `#{key}` is duplicated within collection `#{collection}` and scope #{scope.inspect} by documents `#{existing_entry.fetch(:document).relative_path}` and `#{document.relative_path}`."
104
213
  end
105
214
 
106
- by_collection[collection][key] = document
107
- by_key[key] << document
215
+ entry = { document: document, scope: scope }
216
+ by_collection[collection][key] << entry
217
+ by_key[key] << entry
108
218
  end
109
219
  end
110
220
 
111
221
  {
112
222
  by_collection: by_collection,
113
- by_key: by_key,
114
- site_wide_unique: by_key.values.all? { |documents| documents.length <= 1 }
223
+ by_key: by_key
115
224
  }
116
225
  end
117
226
 
@@ -123,6 +232,46 @@ class Registry
123
232
  first_hash[second_key]
124
233
  end
125
234
 
235
+ # Raises a field-specific error when key candidates exist but none match the requested complete scope.
236
+ def raise_scope_mismatch!(candidates:, scope:, scope_fields:, relationship:, referring_document:)
237
+ candidate = candidates.first
238
+ field = scope_fields.find do |configured_field|
239
+ candidate.fetch(:scope).fetch(configured_field.name) != scope.fetch(configured_field.name)
240
+ end
241
+ raise ResolutionError, scope_mismatch_message(
242
+ document: candidate.fetch(:document),
243
+ field: field,
244
+ expected_value: scope.fetch(field.name),
245
+ actual_value: candidate.fetch(:scope).fetch(field.name),
246
+ relationship: relationship,
247
+ referring_document: referring_document
248
+ )
249
+ end
250
+
251
+ # Builds one detailed exact-match failure for a configured scope field.
252
+ def scope_mismatch_message(document:, field:, expected_value:, actual_value:, relationship:, referring_document:)
253
+ "#{relationship_prefix(relationship)}target document `#{document.relative_path}` has scope field `#{field.name}` value `#{actual_value.inspect}`, not the required `#{expected_value.inspect}`#{source_suffix(referring_document)}."
254
+ end
255
+
256
+ # Returns true when one literal scope key exists in string or symbol form.
257
+ def hash_property?(hash, property)
258
+ hash.key?(property) || hash.key?(property.to_s) || hash.key?(property.to_s.to_sym)
259
+ end
260
+
261
+ # Prefixes an error with its concrete relationship where available.
262
+ def relationship_prefix(relationship)
263
+ return '' if relationship.nil? || relationship.to_s.empty?
264
+
265
+ "Relationship `#{relationship}`: "
266
+ end
267
+
268
+ # Adds the referring document to lookup diagnostics where available.
269
+ def source_suffix(document)
270
+ return '' unless document
271
+
272
+ " from document `#{document.relative_path}`"
273
+ end
274
+
126
275
  # Implements the README's default relative-path key rule.
127
276
  def default_relative_key(document)
128
277
  document.relative_path.sub(/\A_/, '').sub(/#{Regexp.escape(document.extname)}\z/, '')