jekyll-relationships 0.1.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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/lib/jekyll-relationships/configuration/debug_setting.rb +117 -0
  3. data/lib/jekyll-relationships/configuration/defaults.rb +54 -0
  4. data/lib/jekyll-relationships/configuration/frontmatter.rb +102 -0
  5. data/lib/jekyll-relationships/configuration/hash_utilities.rb +55 -0
  6. data/lib/jekyll-relationships/configuration/multiple_settings.rb +101 -0
  7. data/lib/jekyll-relationships/configuration/parser.rb +551 -0
  8. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +101 -0
  9. data/lib/jekyll-relationships/configuration/prune_settings.rb +82 -0
  10. data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +244 -0
  11. data/lib/jekyll-relationships/configuration/tree_settings.rb +74 -0
  12. data/lib/jekyll-relationships/configuration.rb +250 -0
  13. data/lib/jekyll-relationships/debug_logger.rb +104 -0
  14. data/lib/jekyll-relationships/definitions/configured_relationship.rb +61 -0
  15. data/lib/jekyll-relationships/definitions/normal_relationship.rb +51 -0
  16. data/lib/jekyll-relationships/definitions/prune_rule.rb +67 -0
  17. data/lib/jekyll-relationships/definitions/tree_relationship.rb +48 -0
  18. data/lib/jekyll-relationships/documents/registry.rb +136 -0
  19. data/lib/jekyll-relationships/engine/raw_path_state.rb +217 -0
  20. data/lib/jekyll-relationships/engine/relationship_state.rb +251 -0
  21. data/lib/jekyll-relationships/engine/session.rb +187 -0
  22. data/lib/jekyll-relationships/engine/write_back.rb +154 -0
  23. data/lib/jekyll-relationships/engine.rb +228 -0
  24. data/lib/jekyll-relationships/errors.rb +30 -0
  25. data/lib/jekyll-relationships/generators/relationships.rb +27 -0
  26. data/lib/jekyll-relationships/pruning/normal_graph.rb +119 -0
  27. data/lib/jekyll-relationships/pruning/rule_pruner.rb +67 -0
  28. data/lib/jekyll-relationships/pruning/tree_phase.rb +351 -0
  29. data/lib/jekyll-relationships/pruning/tree_provenance.rb +32 -0
  30. data/lib/jekyll-relationships/references/accumulator.rb +185 -0
  31. data/lib/jekyll-relationships/references/template.rb +222 -0
  32. data/lib/jekyll-relationships/resolvers/base.rb +207 -0
  33. data/lib/jekyll-relationships/run_logger.rb +101 -0
  34. data/lib/jekyll-relationships/support/data_path.rb +194 -0
  35. data/lib/jekyll-relationships/support/frontmatter_path.rb +217 -0
  36. data/lib/jekyll-relationships/support/hash_deep_merge.rb +63 -0
  37. data/lib/jekyll-relationships/support/placeholders.rb +21 -0
  38. data/lib/jekyll-relationships/support/string_array.rb +157 -0
  39. data/lib/jekyll-relationships/trees/edge_builder.rb +196 -0
  40. data/lib/jekyll-relationships/trees/graph.rb +454 -0
  41. data/lib/jekyll-relationships/version.rb +11 -0
  42. data/lib/jekyll-relationships.rb +34 -0
  43. data/readme.md +509 -0
  44. metadata +189 -0
@@ -0,0 +1,551 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+
6
+ module Relationships
7
+
8
+ class Configuration
9
+
10
+ # Expands the raw relationship config into explicit definitions.
11
+ #
12
+ # This parser handles shorthand collection lists, target hashes, keyword
13
+ # expansion, duplicate detection, and frontmatter override precedence.
14
+ class Parser
15
+ # Builds one parser for the current configuration.
16
+ def initialize(raw_config:, string_array:, global_debug:, global_frontmatter:, global_tree_settings:, keywords:, prune_settings:)
17
+ @raw_config = raw_config
18
+ @string_array = string_array
19
+ @global_debug = global_debug
20
+ @global_frontmatter = global_frontmatter
21
+ @global_tree_settings = global_tree_settings
22
+ @keywords = keywords
23
+ @prune_settings = prune_settings
24
+ end
25
+
26
+ # Parses the full relationship config into concrete definitions.
27
+ def parse
28
+ entries = Configuration::HashUtilities.fetch_hash_value(@raw_config, 'relationships')
29
+ entries = [] if entries.nil?
30
+ raise ConfigurationError, '`relationships.relationships` must be an array.' unless entries.is_a?(Array)
31
+
32
+ normal_relationships = Hash.new { |hash, key| hash[key] = {} }
33
+ tree_relationships = []
34
+ collections = Set.new
35
+ occupancy = {}
36
+ configured_relationships = []
37
+ normal_prune_rules = []
38
+ tree_prune_rules = []
39
+ prune_rule_occupancy = Hash.new { |hash, key| hash[key] = [] }
40
+ sequence = 0
41
+
42
+ entries.each_with_index do |entry, entry_index|
43
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must be a hash." unless entry.is_a?(Hash)
44
+
45
+ from_collections = parse_collection_list(
46
+ value: Configuration::HashUtilities.fetch_hash_value(entry, 'from'),
47
+ context: "relationships[#{entry_index}].from"
48
+ )
49
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define at least one `from` collection." if from_collections.empty?
50
+
51
+ targets = parse_targets(
52
+ value: Configuration::HashUtilities.fetch_hash_value(entry, 'to'),
53
+ context: "relationships[#{entry_index}].to"
54
+ )
55
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define at least one `to` target." if targets.empty?
56
+
57
+ relationship_frontmatter = Configuration::HashUtilities.fetch_hash_value(entry, 'frontmatter')
58
+ relationship_tree = Configuration::HashUtilities.fetch_hash_value(entry, 'tree')
59
+ relationship_prune_configuration = relationship_prune_setting(entry: entry, entry_index: entry_index)
60
+ relationship_debug = relationship_debug_setting(entry: entry, entry_index: entry_index)
61
+ relationship_mode = normalise_mode(Configuration::HashUtilities.fetch_hash_value(entry, 'mode'))
62
+ effective_relationship_frontmatter = @global_frontmatter.merge(relationship_frontmatter)
63
+ effective_relationship_tree_settings = @global_tree_settings.merge_level(
64
+ frontmatter_override: relationship_frontmatter,
65
+ tree_override: relationship_tree
66
+ )
67
+ effective_relationship_debug = relationship_debug.nil? ? @global_debug : relationship_debug
68
+ prune_members_by_configuration = Hash.new { |hash, key| hash[key] = [] }
69
+ target_prune_configuration_cache = {}
70
+ resolved_targets = targets.map do |target|
71
+ {
72
+ descriptor: target,
73
+ prune_overridden: target.key?('prune'),
74
+ prune_configuration: target_prune_setting(target: target, entry_index: entry_index, cache: target_prune_configuration_cache),
75
+ debug_setting: target_debug_setting(target: target, entry_index: entry_index)
76
+ }
77
+ end
78
+
79
+ from_collections.each do |from_collection|
80
+ collections << from_collection
81
+
82
+ resolved_targets.each do |resolved_target|
83
+ target = resolved_target.fetch(:descriptor)
84
+ expanded_targets(
85
+ token: target.fetch('collection'),
86
+ from_collections: from_collections,
87
+ current_from_collection: from_collection
88
+ ).each do |to_collection|
89
+ collections << to_collection
90
+ effective_mode = normalise_mode(target['mode'].nil? ? relationship_mode : target['mode'])
91
+ effective_frontmatter = effective_relationship_frontmatter.merge(target['frontmatter'])
92
+ effective_tree_settings = effective_relationship_tree_settings.merge_level(
93
+ frontmatter_override: target['frontmatter'],
94
+ tree_override: target['tree']
95
+ )
96
+ target_debug = resolved_target.fetch(:debug_setting)
97
+ effective_debug = target_debug.nil? ? effective_relationship_debug : target_debug
98
+ effective_prune_configuration = effective_prune_configuration(
99
+ target_prune_configuration: resolved_target.fetch(:prune_configuration),
100
+ target_overrides_prune: resolved_target.fetch(:prune_overridden),
101
+ relationship_prune_configuration: relationship_prune_configuration
102
+ )
103
+
104
+ if tree_mode?(effective_mode)
105
+ tree_definition = register_tree_relationship(
106
+ tree_relationships: tree_relationships,
107
+ occupancy: occupancy,
108
+ from_collection: from_collection,
109
+ to_collection: to_collection,
110
+ mode: effective_mode,
111
+ frontmatter: effective_frontmatter,
112
+ tree_settings: effective_tree_settings,
113
+ debug: effective_debug,
114
+ sequence: sequence
115
+ )
116
+ configured_relationships << build_configured_relationship(
117
+ definition: tree_definition,
118
+ kind: :tree,
119
+ mode: effective_mode,
120
+ sequence: sequence
121
+ )
122
+ register_prune_member!(
123
+ prune_members_by_configuration: prune_members_by_configuration,
124
+ prune_configuration: effective_prune_configuration,
125
+ member: configured_relationships.last
126
+ )
127
+ sequence += 1
128
+ else
129
+ registration = register_normal_relationships(
130
+ normal_relationships: normal_relationships,
131
+ occupancy: occupancy,
132
+ from_collection: from_collection,
133
+ to_collection: to_collection,
134
+ mode: effective_mode,
135
+ frontmatter: effective_frontmatter,
136
+ debug: effective_debug,
137
+ sequence: sequence
138
+ )
139
+ configured_relationships << build_configured_relationship(
140
+ definition: registration.fetch(:definition),
141
+ kind: :normal,
142
+ mode: effective_mode,
143
+ sequence: sequence
144
+ )
145
+ register_prune_member!(
146
+ prune_members_by_configuration: prune_members_by_configuration,
147
+ prune_configuration: effective_prune_configuration,
148
+ member: configured_relationships.last
149
+ )
150
+ sequence = registration.fetch(:sequence)
151
+ end
152
+ end
153
+ end
154
+ end
155
+
156
+ prune_members_by_configuration.each do |prune_configuration, prune_members|
157
+ register_prune_rules!(
158
+ entry_members: prune_members,
159
+ prune_configuration: prune_configuration,
160
+ normal_prune_rules: normal_prune_rules,
161
+ tree_prune_rules: tree_prune_rules,
162
+ prune_rule_occupancy: prune_rule_occupancy,
163
+ entry_index: entry_index
164
+ )
165
+ end
166
+ end
167
+
168
+ {
169
+ normal_relationships: normal_relationships,
170
+ tree_relationships: tree_relationships,
171
+ collections: collections.to_a.sort,
172
+ configured_relationships: configured_relationships,
173
+ normal_prune_rules: normal_prune_rules,
174
+ tree_prune_rules: tree_prune_rules
175
+ }
176
+ end
177
+
178
+ # Expands resolver `from` and `to` selectors into explicit pairs.
179
+ def pairs_for(from_definition:, to_definition:)
180
+ from_collections = parse_collection_list(value: from_definition, context: 'resolver.from')
181
+
182
+ from_collections.each_with_object([]) do |from_collection, pairs|
183
+ expanded_target_descriptors(to_definition).each do |target|
184
+ expanded_targets(
185
+ token: target.fetch('collection'),
186
+ from_collections: from_collections,
187
+ current_from_collection: from_collection
188
+ ).each do |to_collection|
189
+ pairs << [from_collection, to_collection]
190
+ end
191
+ end
192
+ end.uniq
193
+ end
194
+
195
+ private
196
+
197
+ # Adds one concrete normal relationship pair.
198
+ def register_normal_relationships(normal_relationships:, occupancy:, from_collection:, to_collection:, mode:, frontmatter:, debug:, sequence:)
199
+ ensure_unoccupied_pair!(occupancy, from_collection, to_collection, "normal relationship #{from_collection} -> #{to_collection}")
200
+ forward_definition = build_normal_relationship(
201
+ from_collection: from_collection,
202
+ to_collection: to_collection,
203
+ frontmatter: frontmatter,
204
+ debug: debug,
205
+ sequence: sequence,
206
+ reads_frontmatter: true,
207
+ bidirectional: mode == 'bidirectional'
208
+ )
209
+ normal_relationships[from_collection][to_collection] = forward_definition
210
+ occupancy[[from_collection, to_collection]] = mode
211
+ sequence += 1
212
+
213
+ return {
214
+ sequence: sequence,
215
+ definition: forward_definition
216
+ } unless mode == 'bidirectional'
217
+
218
+ ensure_unoccupied_pair!(occupancy, to_collection, from_collection, "bidirectional reverse relationship #{to_collection} -> #{from_collection}")
219
+ normal_relationships[to_collection][from_collection] = build_normal_relationship(
220
+ from_collection: to_collection,
221
+ to_collection: from_collection,
222
+ frontmatter: frontmatter,
223
+ debug: debug,
224
+ sequence: sequence,
225
+ reads_frontmatter: false,
226
+ bidirectional: true
227
+ )
228
+ occupancy[[to_collection, from_collection]] = mode
229
+ {
230
+ sequence: sequence + 1,
231
+ definition: forward_definition
232
+ }
233
+ end
234
+
235
+ # Adds one concrete tree relationship definition.
236
+ def register_tree_relationship(tree_relationships:, occupancy:, from_collection:, to_collection:, mode:, frontmatter:, tree_settings:, debug:, sequence:)
237
+ ensure_unoccupied_pair!(occupancy, from_collection, to_collection, "tree relationship #{from_collection} <-> #{to_collection}")
238
+ ensure_unoccupied_pair!(occupancy, to_collection, from_collection, "tree relationship #{to_collection} <-> #{from_collection}") unless from_collection == to_collection
239
+
240
+ definition = Definitions::TreeRelationship.new(
241
+ from_collection: from_collection,
242
+ to_collection: to_collection,
243
+ primary_path: frontmatter.primary_path,
244
+ parent_child_pairs: parent_child_pairs(from_collection: from_collection, to_collection: to_collection, mode: mode),
245
+ tree_settings: tree_settings,
246
+ debug: debug,
247
+ sequence: sequence
248
+ )
249
+ tree_relationships << definition
250
+
251
+ occupancy[[from_collection, to_collection]] = mode
252
+ occupancy[[to_collection, from_collection]] = mode
253
+ definition
254
+ end
255
+
256
+ # Builds one normal relationship definition.
257
+ def build_normal_relationship(from_collection:, to_collection:, frontmatter:, debug:, sequence:, reads_frontmatter:, bidirectional:)
258
+ Definitions::NormalRelationship.new(
259
+ from_collection: from_collection,
260
+ to_collection: to_collection,
261
+ primary_path: frontmatter.primary_path,
262
+ foreign_paths: frontmatter.foreign_paths_for(to_collection: to_collection),
263
+ output_path: frontmatter.output_path_for(to_collection: to_collection),
264
+ debug: debug,
265
+ sequence: sequence,
266
+ reads_frontmatter: reads_frontmatter,
267
+ bidirectional: bidirectional
268
+ )
269
+ end
270
+
271
+ # Builds one forward-facing configured relationship member.
272
+ def build_configured_relationship(definition:, kind:, mode:, sequence:)
273
+ Definitions::ConfiguredRelationship.new(
274
+ definition: definition,
275
+ kind: kind,
276
+ mode: mode,
277
+ sequence: sequence
278
+ )
279
+ end
280
+
281
+ # Registers every prune rule produced by one raw relationship entry.
282
+ def register_prune_rules!(entry_members:, prune_configuration:, normal_prune_rules:, tree_prune_rules:, prune_rule_occupancy:, entry_index:)
283
+ return unless prune_configuration
284
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} defines `prune` but did not expand to any relationships." if entry_members.empty?
285
+
286
+ member_kinds = entry_members.map(&:kind).uniq
287
+ if member_kinds.length > 1
288
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot combine tree and normal relationships within one `prune` block."
289
+ end
290
+ if member_kinds.first == :tree
291
+ if prune_configuration.shortcut?
292
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot use `prune: <int>` on a tree relationship."
293
+ end
294
+ if prune_configuration.depth.nil?
295
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} must define `prune.depth` when pruning a tree relationship."
296
+ end
297
+ elsif !prune_configuration.depth.nil?
298
+ raise ConfigurationError, "Relationship entry #{entry_index + 1} cannot define `prune.depth` on a normal relationship."
299
+ end
300
+
301
+ prune_rules_for_entry(
302
+ entry_members: entry_members,
303
+ prune_configuration: prune_configuration,
304
+ entry_index: entry_index
305
+ ).each do |rule|
306
+ ensure_unoccupied_prune_rule!(prune_rule_occupancy, rule)
307
+ if rule.normal?
308
+ normal_prune_rules << rule
309
+ else
310
+ tree_prune_rules << rule
311
+ end
312
+ end
313
+ end
314
+
315
+ # Adds one configured member to the prune group that should govern it.
316
+ def register_prune_member!(prune_members_by_configuration:, prune_configuration:, member:)
317
+ return unless prune_configuration
318
+
319
+ prune_members_by_configuration[prune_configuration] << member
320
+ end
321
+
322
+ # Expands one raw relationship entry into one or more prune rules.
323
+ def prune_rules_for_entry(entry_members:, prune_configuration:, entry_index:)
324
+ return expanded_prune_rules_for_entry(entry_members: entry_members, prune_configuration: prune_configuration, entry_index: entry_index) unless @prune_settings.combine?
325
+
326
+ grouped_members = if prune_configuration.inverse?
327
+ entry_members.group_by(&:to_collection)
328
+ else
329
+ entry_members.group_by(&:from_collection)
330
+ end
331
+ grouped_members.map do |subject_collection, members|
332
+ build_prune_rule(
333
+ subject_collection: subject_collection,
334
+ members: members,
335
+ prune_configuration: prune_configuration,
336
+ entry_index: entry_index
337
+ )
338
+ end
339
+ end
340
+
341
+ # Expands one raw relationship entry into one separate prune rule per member.
342
+ def expanded_prune_rules_for_entry(entry_members:, prune_configuration:, entry_index:)
343
+ entry_members.map do |member|
344
+ build_prune_rule(
345
+ subject_collection: prune_configuration.inverse? ? member.to_collection : member.from_collection,
346
+ members: [member],
347
+ prune_configuration: prune_configuration,
348
+ entry_index: entry_index
349
+ )
350
+ end
351
+ end
352
+
353
+ # Builds one concrete prune rule for one subject collection.
354
+ def build_prune_rule(subject_collection:, members:, prune_configuration:, entry_index:)
355
+ Definitions::PruneRule.new(
356
+ kind: members.first.kind,
357
+ subject_collection: subject_collection,
358
+ members: members,
359
+ min: prune_configuration.min,
360
+ depth: prune_configuration.depth,
361
+ inverse: prune_configuration.inverse?,
362
+ entry_index: entry_index
363
+ )
364
+ end
365
+
366
+ # Raises when two prune rules overlap on the same subject collection.
367
+ def ensure_unoccupied_prune_rule!(prune_rule_occupancy, rule)
368
+ occupancy_key = [rule.kind, rule.subject_collection]
369
+ existing_rules = prune_rule_occupancy[occupancy_key]
370
+ overlapping_rule = existing_rules.find do |existing_rule|
371
+ !(existing_rule.member_identifiers & rule.member_identifiers).empty?
372
+ end
373
+ if overlapping_rule
374
+ raise ConfigurationError, "Clashing prune rules target collection `#{rule.subject_collection}` more than once across overlapping #{rule.kind} relationships."
375
+ end
376
+
377
+ existing_rules << rule
378
+ end
379
+
380
+ # Parses one loose collection list into explicit collection labels.
381
+ def parse_collection_list(value:, context:)
382
+ @string_array.interpret(value, split: true, flatten: true).map do |entry|
383
+ string_value = entry.to_s.strip
384
+ raise ConfigurationError, "`#{context}` cannot contain blank collection names." if string_value.empty?
385
+
386
+ string_value
387
+ end.uniq
388
+ end
389
+
390
+ # Parses one loose target list into explicit target descriptors.
391
+ def parse_targets(value:, context:)
392
+ expanded_target_descriptors(value).each do |target|
393
+ raise ConfigurationError, "`#{context}` target collections cannot be blank." if target.fetch('collection').to_s.strip.empty?
394
+ end
395
+ end
396
+
397
+ # Expands strings and hashes into target descriptor hashes.
398
+ def expanded_target_descriptors(value)
399
+ target_entries = value.is_a?(Array) ? value : [value]
400
+
401
+ target_entries.flat_map do |target_entry|
402
+ case target_entry
403
+ when String
404
+ @string_array.interpret(target_entry, split: true, flatten: true).map do |collection|
405
+ { 'collection' => collection.to_s.strip }
406
+ end
407
+ when Hash
408
+ build_target_descriptors(target_entry)
409
+ else
410
+ raise ConfigurationError, '`to` entries must be strings or hashes.'
411
+ end
412
+ end
413
+ end
414
+
415
+ # Builds one or more explicit target descriptors while preserving whether
416
+ # optional keys were actually present in the source config.
417
+ def build_target_descriptors(target_entry)
418
+ parse_collection_list(
419
+ value: Configuration::HashUtilities.fetch_hash_value(target_entry, 'collection'),
420
+ context: 'to.collection'
421
+ ).map do |collection|
422
+ descriptor = {
423
+ 'collection' => collection.to_s.strip
424
+ }
425
+
426
+ %w[frontmatter tree mode debug prune].each do |key|
427
+ next unless Configuration::HashUtilities.hash_key?(target_entry, key)
428
+
429
+ descriptor[key] = Configuration::HashUtilities.fetch_hash_value(target_entry, key)
430
+ end
431
+
432
+ descriptor
433
+ end
434
+ end
435
+
436
+ # Expands keyword targets such as `self`, `others`, and `all`.
437
+ def expanded_targets(token:, from_collections:, current_from_collection:)
438
+ case token
439
+ when keyword('self')
440
+ [current_from_collection]
441
+ when keyword('others')
442
+ from_collections.reject { |collection| collection == current_from_collection }
443
+ when keyword('all')
444
+ from_collections.dup
445
+ else
446
+ [token]
447
+ end.uniq
448
+ end
449
+
450
+ # Returns true when one mode is a tree mode.
451
+ def tree_mode?(mode)
452
+ %w[parent child parent/child child/parent].include?(mode)
453
+ end
454
+
455
+ # Converts one loose mode value into its canonical form.
456
+ def normalise_mode(mode)
457
+ string_mode = mode.nil? ? 'link' : mode.to_s.strip.downcase
458
+ string_mode = 'link' if string_mode.empty?
459
+
460
+ return string_mode if %w[link bidirectional parent child parent/child child/parent].include?(string_mode)
461
+
462
+ raise ConfigurationError, "Unsupported relationship mode `#{mode}`."
463
+ end
464
+
465
+ # Resolves one optional per-relationship prune block.
466
+ def relationship_prune_setting(entry:, entry_index:)
467
+ return nil unless Configuration::HashUtilities.hash_key?(entry, 'prune')
468
+
469
+ Configuration::PruneRuleSettings.build(
470
+ raw_config: Configuration::HashUtilities.fetch_hash_value(entry, 'prune'),
471
+ context: "relationships.relationships[#{entry_index}].prune"
472
+ )
473
+ end
474
+
475
+ # Resolves one optional per-relationship debug override.
476
+ def relationship_debug_setting(entry:, entry_index:)
477
+ return nil unless Configuration::HashUtilities.hash_key?(entry, 'debug')
478
+
479
+ Configuration::DebugSetting.build(
480
+ value: Configuration::HashUtilities.fetch_hash_value(entry, 'debug'),
481
+ string_array: @string_array,
482
+ context: "relationships.relationships[#{entry_index}].debug"
483
+ )
484
+ end
485
+
486
+ # Resolves one optional per-target debug override.
487
+ def target_debug_setting(target:, entry_index:)
488
+ return nil unless target.key?('debug')
489
+
490
+ Configuration::DebugSetting.build(
491
+ value: target.fetch('debug'),
492
+ string_array: @string_array,
493
+ context: "relationships.relationships[#{entry_index}].to.debug"
494
+ )
495
+ end
496
+
497
+ # Resolves one optional per-target prune override.
498
+ def target_prune_setting(target:, entry_index:, cache:)
499
+ return nil unless target.key?('prune')
500
+
501
+ raw_config = target.fetch('prune')
502
+ return cache[raw_config.object_id] if cache.key?(raw_config.object_id)
503
+
504
+ cache[raw_config.object_id] = Configuration::PruneRuleSettings.build(
505
+ raw_config: raw_config,
506
+ context: "relationships.relationships[#{entry_index}].to.prune"
507
+ )
508
+ end
509
+
510
+ # Resolves the effective prune config for one expanded target.
511
+ #
512
+ # Targets may either inherit the relationship-level rule, replace it with a
513
+ # target-level rule, or explicitly disable it with `prune: false`.
514
+ def effective_prune_configuration(target_prune_configuration:, target_overrides_prune:, relationship_prune_configuration:)
515
+ return target_prune_configuration if target_overrides_prune
516
+
517
+ relationship_prune_configuration
518
+ end
519
+
520
+ # Builds the allowed parent-child collection directions for one tree mode.
521
+ def parent_child_pairs(from_collection:, to_collection:, mode:)
522
+ case mode
523
+ when 'parent'
524
+ [[to_collection, from_collection]]
525
+ when 'child'
526
+ [[from_collection, to_collection]]
527
+ when 'parent/child', 'child/parent'
528
+ [[to_collection, from_collection], [from_collection, to_collection]].uniq
529
+ else
530
+ raise ConfigurationError, "Mode `#{mode}` is not a tree mode."
531
+ end
532
+ end
533
+
534
+ # Raises when one concrete collection pair has already been defined.
535
+ def ensure_unoccupied_pair!(occupancy, from_collection, to_collection, description)
536
+ return unless occupancy.key?([from_collection, to_collection])
537
+
538
+ raise ConfigurationError, "Duplicate or clashing relationship definition for #{description}."
539
+ end
540
+
541
+ # Returns one active keyword string.
542
+ def keyword(name)
543
+ @keywords.fetch(name.to_s)
544
+ end
545
+ end
546
+ end
547
+
548
+ end
549
+
550
+ end
551
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+
6
+ module Relationships
7
+
8
+ class Configuration
9
+
10
+ # Normalises one relationship-level or target-level prune configuration block.
11
+ #
12
+ # Relationship entries and hash-form targets may opt into pruning by defining
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.
16
+ class PruneRuleSettings
17
+ attr_reader :mode, :min, :depth
18
+
19
+ # Interprets one loose prune config value.
20
+ def self.build(raw_config:, context:)
21
+ return false if raw_config == false
22
+ return new(raw_config: raw_config, context: context, shortcut: true) if raw_config.is_a?(Integer)
23
+
24
+ new(raw_config: raw_config, context: context, shortcut: false)
25
+ end
26
+
27
+ # Builds one immutable prune-rule helper from raw configuration.
28
+ def initialize(raw_config:, context:, shortcut:)
29
+ @shortcut = shortcut
30
+ if @shortcut
31
+ @mode = 'direct'
32
+ @min = normalise_min(raw_config, context: context)
33
+ @depth = nil
34
+ return
35
+ end
36
+
37
+ raise ConfigurationError, "`#{context}` must be a hash." unless raw_config.is_a?(Hash)
38
+
39
+ @mode = normalise_mode(
40
+ Configuration::HashUtilities.hash_key?(raw_config, 'mode') ? Configuration::HashUtilities.fetch_hash_value(raw_config, 'mode') : nil,
41
+ context: context
42
+ )
43
+ @min = normalise_min(
44
+ Configuration::HashUtilities.fetch_hash_value(raw_config, 'min'),
45
+ context: context
46
+ )
47
+ @depth = normalise_depth(
48
+ Configuration::HashUtilities.hash_key?(raw_config, 'depth') ? Configuration::HashUtilities.fetch_hash_value(raw_config, 'depth') : nil,
49
+ context: context
50
+ )
51
+ end
52
+
53
+ # Returns true when the rule prunes the inverse side of the relationship.
54
+ def inverse?
55
+ @mode == 'inverse'
56
+ end
57
+
58
+ # Returns true when the rule came from the integer shorthand form.
59
+ def shortcut?
60
+ @shortcut
61
+ end
62
+
63
+ private
64
+
65
+ # Resolves the configured prune mode.
66
+ def normalise_mode(raw_mode, context:)
67
+ return 'direct' if raw_mode.nil?
68
+
69
+ string_mode = raw_mode.to_s.strip.downcase
70
+ return 'direct' if string_mode.empty?
71
+ return 'inverse' if string_mode == 'inverse'
72
+
73
+ raise ConfigurationError, "Unsupported `#{context}.mode` value `#{raw_mode}`."
74
+ end
75
+
76
+ # Resolves the configured minimum count and rejects non-positive values.
77
+ def normalise_min(raw_min, context:)
78
+ raise ConfigurationError, "`#{context}` must define `min`." if raw_min.nil?
79
+
80
+ interpreted_min = Configuration::HashUtilities.integer_value(raw_min, "#{context}.min")
81
+ raise ConfigurationError, "`#{context}.min` must be at least 1." if interpreted_min < 1
82
+
83
+ interpreted_min
84
+ end
85
+
86
+ # Resolves the optional tree depth selector.
87
+ def normalise_depth(raw_depth, context:)
88
+ return nil if raw_depth.nil?
89
+
90
+ interpreted_depth = Configuration::HashUtilities.integer_value(raw_depth, "#{context}.depth")
91
+ raise ConfigurationError, "`#{context}.depth` cannot be 0." if interpreted_depth == 0
92
+
93
+ interpreted_depth
94
+ end
95
+ end
96
+ end
97
+
98
+ end
99
+
100
+ end
101
+ end