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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9656850968f173e997fe9e9a0b5500f12682c94d613f84444827bc5e52aa955a
4
- data.tar.gz: e56a5ec1a9a44b58d2aa03e2066e99f7a4b39df1335c449e5325f6a5599b6871
3
+ metadata.gz: 583920383adff1b25fb21db2130a379a76c6070780bb6fceddeb04c8eb0c5340
4
+ data.tar.gz: 528d4185f7ee05eced524a732096f19eea13750cb2ca85efa0d9cf325ad532dd
5
5
  SHA512:
6
- metadata.gz: 010f090f1083c9f8ede2f8a99bd23e05a68b474f6c90b1519a4002c2446bf97eee6256b8cb7858cbd9a7e9aa9def901885fed07290e8a83c995656f71e47afd4
7
- data.tar.gz: 1f0c0c85af8fd938f7d6d66a15e4dfb2941615057656867702a01b146e10426901969a2498e10d9fe08427d2226fe9d0f3968e50e262560223fff439ebfcda0f
6
+ metadata.gz: ede20ebf3d937fa6deda8da76f087d4040f7de50204b70675646cfcc3b860014d69929afde3de3c1f243cf8810f31a723ab914373ca8120a68308d6852501b9e
7
+ data.tar.gz: 3a5a9f5b7d9ed651af6bd9485749d6b0cf37d366f4da3a238d05c4f533440374bbc27dcfd3b43643d62c91a2a80646037130a296ecf86b3c59f3c8d38303e958
@@ -23,12 +23,32 @@ class Configuration
23
23
  'resolvers' => 'resolver execution and helper calls',
24
24
  'trees' => 'tree discovery, edge handling, and tree write-back'
25
25
  }.freeze
26
+ HASH_KEYS = %w[ids log].freeze
26
27
 
27
28
  ALL_AREAS = AREAS.keys.freeze
28
29
 
29
30
  class << self
30
31
  # Builds one explicit debug setting from a loose config value.
31
32
  def build(value:, string_array:, context:)
33
+ return build_hash_value(value: value, string_array: string_array, context: context) if value.is_a?(Hash)
34
+
35
+ build_scalar_value(value: value, string_array: string_array, context: context)
36
+ end
37
+
38
+ # Returns one disabled debug setting.
39
+ def disabled
40
+ new([])
41
+ end
42
+
43
+ # Returns one debug setting that enables every area.
44
+ def all
45
+ new(ALL_AREAS)
46
+ end
47
+
48
+ private
49
+
50
+ # Builds one debug setting from the legacy scalar forms.
51
+ def build_scalar_value(value:, string_array:, context:)
32
52
  return disabled if value.nil? || false_value?(value)
33
53
  return all if true_value?(value)
34
54
 
@@ -45,18 +65,30 @@ class Configuration
45
65
  new(areas.uniq)
46
66
  end
47
67
 
48
- # Returns one disabled debug setting.
49
- def disabled
50
- new([])
51
- end
68
+ # Builds one debug setting from the new hash form with optional ID filtering.
69
+ def build_hash_value(value:, string_array:, context:)
70
+ hash_value = stringify_hash(value)
71
+ unknown_keys = hash_value.keys - HASH_KEYS
72
+ unless unknown_keys.empty?
73
+ raise ConfigurationError, "`#{context}` contains unsupported keys #{unknown_keys.sort.join(', ')}. Supported keys: #{HASH_KEYS.join(', ')}."
74
+ end
52
75
 
53
- # Returns one debug setting that enables every area.
54
- def all
55
- new(ALL_AREAS)
76
+ log_context = hash_value.key?('log') ? "#{context}.log" : context
77
+ scalar_setting = build_scalar_value(
78
+ value: hash_value.key?('log') ? hash_value.fetch('log') : true,
79
+ string_array: string_array,
80
+ context: log_context
81
+ )
82
+ new(
83
+ scalar_setting.areas,
84
+ parse_ids(
85
+ value: hash_value['ids'],
86
+ string_array: string_array,
87
+ context: "#{context}.ids"
88
+ )
89
+ )
56
90
  end
57
91
 
58
- private
59
-
60
92
  # Returns true when a loose value means "debug everything".
61
93
  def true_value?(value)
62
94
  value == true || value.to_s.strip.casecmp('true').zero?
@@ -83,13 +115,34 @@ class Configuration
83
115
  raise ConfigurationError,
84
116
  "`#{context}` must be true, false, `all`, or a comma-delimited string/array of debug areas: #{ALL_AREAS.join(', ')}."
85
117
  end
118
+
119
+ # Parses one optional string-or-array ID filter.
120
+ def parse_ids(value:, string_array:, context:)
121
+ return [] if value.nil?
122
+
123
+ raw_ids = string_array.interpret(value, split: true, flatten: true)
124
+ raise ConfigurationError, "`#{context}` must be a string or array of strings." if raw_ids.empty?
125
+
126
+ ids = raw_ids.map { |raw_id| raw_id.to_s.strip }.reject(&:empty?).uniq
127
+ raise ConfigurationError, "`#{context}` must contain at least one non-blank ID." if ids.empty?
128
+
129
+ ids
130
+ end
131
+
132
+ # Converts one config hash to a shallow string-keyed copy.
133
+ def stringify_hash(hash)
134
+ hash.each_with_object({}) do |(key, value), stringified|
135
+ stringified[key.to_s] = value
136
+ end
137
+ end
86
138
  end
87
139
 
88
- attr_reader :areas
140
+ attr_reader :areas, :ids
89
141
 
90
- # Captures one immutable set of enabled debug areas.
91
- def initialize(areas)
142
+ # Captures one immutable set of enabled debug areas and optional ID filters.
143
+ def initialize(areas, ids = [])
92
144
  @areas = areas.freeze
145
+ @ids = Array(ids).map(&:to_s).uniq.freeze
93
146
  end
94
147
 
95
148
  # Returns true when any debug area is enabled, or when one named area is enabled.
@@ -99,6 +152,13 @@ class Configuration
99
152
  @areas.include?(normalise_runtime_area(area))
100
153
  end
101
154
 
155
+ # Returns true when this setting either has no ID filter or overlaps one.
156
+ def matches_ids?(related_ids)
157
+ return true if @ids.empty?
158
+
159
+ Array(related_ids).map(&:to_s).any? { |related_id| @ids.include?(related_id) }
160
+ end
161
+
102
162
  private
103
163
 
104
164
  # Normalises one runtime area name and raises on internal typos.
@@ -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
@@ -26,7 +27,8 @@ class Configuration
26
27
  'parents' => 'parents',
27
28
  'children' => 'children',
28
29
  'ancestors' => 'ancestors',
29
- 'descendants' => 'descendants'
30
+ 'descendants' => 'descendants',
31
+ 'depth' => 'depth'
30
32
  }.freeze,
31
33
  'max' => {
32
34
  'parents' => -1,
@@ -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
 
@@ -14,7 +14,7 @@ class Configuration
14
14
  # The resolved helper exposes explicit input and output paths so the tree
15
15
  # graph never has to reimplement config merging rules.
16
16
  class TreeFrontmatter
17
- PATH_NAMES = %w[parent child parents children ancestors descendants].freeze
17
+ PATH_NAMES = %w[parent child parents children ancestors descendants depth].freeze
18
18
 
19
19
  # Builds one tree-frontmatter helper from the resolved values.
20
20
  def initialize(base:, raw_paths:, raw_output_path:, string_array:)
@@ -118,6 +118,11 @@ class Configuration
118
118
  first_path_for('descendants')
119
119
  end
120
120
 
121
+ # Returns the absolute prevailing depth output path.
122
+ def depth_output_path
123
+ first_path_for('depth')
124
+ end
125
+
121
126
  # Returns the absolute output container path, if one is configured.
122
127
  def output_path
123
128
  return nil if blank_path?(@raw_output_path)
@@ -131,7 +136,7 @@ class Configuration
131
136
  # The configured relative tree paths become nested keys within the output
132
137
  # hash, while the configured output container path becomes the single
133
138
  # frontmatter location on which that hash is written.
134
- def output_payload(parent_value:, parents_value:, child_value:, children_value:, ancestors_value:, descendants_value:, max_parents:, max_children:)
139
+ def output_payload(parent_value:, parents_value:, child_value:, children_value:, ancestors_value:, descendants_value:, depth_value:, max_parents:, max_children:)
135
140
  payload = {}
136
141
  data_path = Jekyll::Plugins::Relationships::Support::DataPath.new
137
142
 
@@ -149,6 +154,7 @@ class Configuration
149
154
 
150
155
  data_path.write(payload, first_relative_path_for('ancestors'), ancestors_value)
151
156
  data_path.write(payload, first_relative_path_for('descendants'), descendants_value)
157
+ data_path.write(payload, first_relative_path_for('depth'), depth_value)
152
158
  payload
153
159
  end
154
160
 
@@ -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,10 +11,30 @@ module Relationships
11
11
  # Jekyll logger. Callers should pass only already-resolved runtime state.
12
12
  class DebugLogger
13
13
  MAX_VALUE_LENGTH = 500
14
+ STRING_ID_PROPERTIES = %w[
15
+ id
16
+ key
17
+ target_key
18
+ reference
19
+ result
20
+ references
21
+ entries
22
+ value
23
+ parent_value
24
+ child_value
25
+ ancestors_value
26
+ descendants_value
27
+ ].freeze
28
+
29
+ # Builds one logger with the active reference key property for hash parsing.
30
+ def initialize(reference_key_property:)
31
+ @reference_key_property = reference_key_property.to_s
32
+ @data_path = Jekyll::Plugins::Relationships::Support::DataPath.new
33
+ end
14
34
 
15
35
  # Emits one debug line for one normal relationship state.
16
36
  def relationship_event(document:, definition:, area:, event:, details: {})
17
- return unless definition.debug?(area)
37
+ return unless should_log?(definition: definition, area: area, document: document, details: details)
18
38
 
19
39
  log(
20
40
  area: area,
@@ -25,7 +45,9 @@ class DebugLogger
25
45
 
26
46
  # Emits one debug line for a document-level write-back event.
27
47
  def document_event(document:, definitions:, area:, event:, details: {})
28
- debug_definitions = Array(definitions).select { |definition| definition.debug?(area) }
48
+ debug_definitions = Array(definitions).select do |definition|
49
+ should_log?(definition: definition, area: area, document: document, details: details)
50
+ end
29
51
  return if debug_definitions.empty?
30
52
 
31
53
  log(
@@ -41,7 +63,7 @@ class DebugLogger
41
63
 
42
64
  # Emits one debug line for one tree relationship event.
43
65
  def tree_event(document:, definition:, area:, event:, details: {})
44
- return unless definition.debug?(area)
66
+ return unless should_log?(definition: definition, area: area, document: document, details: details)
45
67
 
46
68
  log(
47
69
  area: area,
@@ -62,6 +84,21 @@ class DebugLogger
62
84
 
63
85
  private
64
86
 
87
+ # Returns true when one definition wants this area and its ID filter matches.
88
+ def should_log?(definition:, area:, document:, details:)
89
+ return false unless definition.debug?(area)
90
+
91
+ definition.debug_ids_match?(related_ids_for(definition: definition, document: document, details: details))
92
+ end
93
+
94
+ # Collects every document or reference ID that one event obviously touches.
95
+ def related_ids_for(definition:, document:, details:)
96
+ ([document_key(document, primary_path: definition.primary_path)] + extract_related_ids(
97
+ details,
98
+ primary_path: definition.primary_path
99
+ )).compact.uniq
100
+ end
101
+
65
102
  # Emits one line through the standard Jekyll logger.
66
103
  def log(area:, prefix:, details:)
67
104
  detail_text = details.each_with_object([]) do |(key, value), parts|
@@ -72,6 +109,60 @@ class DebugLogger
72
109
  Jekyll.logger.info('Relationships:', "[debug:#{area}] #{message}")
73
110
  end
74
111
 
112
+ # Resolves one document back to the primary key used by the active definition.
113
+ def document_key(document, primary_path:)
114
+ return nil unless document.is_a?(Jekyll::Document)
115
+
116
+ if primary_path.nil?
117
+ document.relative_path.sub(/\A_/, '').sub(/#{Regexp.escape(document.extname)}\z/, '')
118
+ else
119
+ value = @data_path.read(document.data, primary_path)
120
+ value.nil? ? nil : value.to_s
121
+ end
122
+ end
123
+
124
+ # Walks one debug payload and extracts any obvious relationship IDs from it.
125
+ def extract_related_ids(value, primary_path:, property_name: nil)
126
+ case value
127
+ when Jekyll::Document
128
+ [document_key(value, primary_path: primary_path)].compact
129
+ when Array
130
+ value.flat_map do |item|
131
+ extract_related_ids(item, primary_path: primary_path, property_name: property_name)
132
+ end
133
+ when Hash
134
+ extract_related_ids_from_hash(value, primary_path: primary_path)
135
+ when String
136
+ return [] unless property_name && STRING_ID_PROPERTIES.include?(property_name.to_s)
137
+
138
+ trimmed_value = value.strip
139
+ trimmed_value.empty? ? [] : [trimmed_value]
140
+ else
141
+ []
142
+ end
143
+ end
144
+
145
+ # Reads likely ID-bearing properties from one debug hash before recurring.
146
+ def extract_related_ids_from_hash(hash, primary_path:)
147
+ ids = []
148
+ hash.each do |key, value|
149
+ string_key = key.to_s
150
+ if string_key == @reference_key_property || string_key == 'id'
151
+ trimmed_value = value.to_s.strip
152
+ ids << trimmed_value unless trimmed_value.empty?
153
+ end
154
+
155
+ ids.concat(
156
+ extract_related_ids(
157
+ value,
158
+ primary_path: primary_path,
159
+ property_name: string_key
160
+ )
161
+ )
162
+ end
163
+ ids.uniq
164
+ end
165
+
75
166
  # Normalises values so large document objects stay readable in logs.
76
167
  def normalise_value(value)
77
168
  case value
@@ -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,16 @@ 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
+
47
+ # Returns true when one event's related IDs satisfy this pair's debug filter.
48
+ def debug_ids_match?(ids)
49
+ @debug.matches_ids?(ids)
50
+ end
51
+
41
52
  # Registers one resolver class against this relationship pair.
42
53
  def add_resolver(resolver_class)
43
54
  @resolver_classes << resolver_class
@@ -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|