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.
- checksums.yaml +4 -4
- data/lib/jekyll-relationships/configuration/debug_setting.rb +72 -12
- data/lib/jekyll-relationships/configuration/defaults.rb +3 -1
- data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
- data/lib/jekyll-relationships/configuration/parser.rb +20 -3
- data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
- data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +8 -2
- data/lib/jekyll-relationships/configuration.rb +43 -8
- data/lib/jekyll-relationships/debug_logger.rb +94 -3
- data/lib/jekyll-relationships/definitions/normal_relationship.rb +13 -2
- data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
- data/lib/jekyll-relationships/definitions/tree_relationship.rb +8 -2
- data/lib/jekyll-relationships/documents/registry.rb +182 -33
- data/lib/jekyll-relationships/engine/normal_seed.rb +222 -0
- data/lib/jekyll-relationships/engine/persisted_links.rb +365 -0
- data/lib/jekyll-relationships/engine/raw_path_state.rb +46 -116
- data/lib/jekyll-relationships/engine/relationship_state.rb +67 -56
- data/lib/jekyll-relationships/engine/session.rb +159 -19
- data/lib/jekyll-relationships/engine/write_back.rb +3 -42
- data/lib/jekyll-relationships/engine.rb +168 -46
- data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
- data/lib/jekyll-relationships/pruning/tree_phase.rb +128 -124
- data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
- data/lib/jekyll-relationships/references/accumulator.rb +87 -9
- data/lib/jekyll-relationships/references/template.rb +46 -11
- data/lib/jekyll-relationships/resolvers/base.rb +59 -2
- data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
- data/lib/jekyll-relationships/support/placeholders.rb +1 -0
- data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
- data/lib/jekyll-relationships/trees/graph.rb +135 -17
- data/lib/jekyll-relationships/trees/root_distances.rb +44 -0
- data/lib/jekyll-relationships/version.rb +1 -1
- data/lib/jekyll-relationships.rb +1 -0
- data/readme.md +185 -26
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 583920383adff1b25fb21db2130a379a76c6070780bb6fceddeb04c8eb0c5340
|
|
4
|
+
data.tar.gz: 528d4185f7ee05eced524a732096f19eea13750cb2ca85efa0d9cf325ad532dd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
49
|
-
def
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
|
15
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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|
|