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
|
@@ -10,7 +10,7 @@ module Pruning
|
|
|
10
10
|
#
|
|
11
11
|
# Tree orphan repair needs stable provenance even after many prune rounds. This
|
|
12
12
|
# helper snapshots the first fully built tree graph before any pruning so later
|
|
13
|
-
# orphan handling can reattach surviving
|
|
13
|
+
# orphan handling can reattach nearest surviving ancestors in deterministic order.
|
|
14
14
|
class TreeProvenance
|
|
15
15
|
# Builds one immutable provenance index from one tree graph.
|
|
16
16
|
def initialize(tree_graph:)
|
|
@@ -24,37 +24,60 @@ class Accumulator
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Adds one resolved relationship entry.
|
|
27
|
-
def add(document:, key:, metadata: nil, count: 1)
|
|
28
|
-
add_result(document: document, key: key, metadata: metadata, count: count) != :ignored
|
|
27
|
+
def add(document:, key:, scope: nil, metadata: nil, count: 1)
|
|
28
|
+
add_result(document: document, key: key, scope: scope, metadata: metadata, count: count) != :ignored
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Adds one resolved relationship entry and returns how it changed the set.
|
|
32
|
-
def add_result(document:, key:, metadata: nil, count: 1)
|
|
32
|
+
def add_result(document:, key:, scope: nil, metadata: nil, count: 1)
|
|
33
|
+
add_detailed_result(
|
|
34
|
+
document: document,
|
|
35
|
+
key: key,
|
|
36
|
+
scope: scope,
|
|
37
|
+
metadata: metadata,
|
|
38
|
+
count: count
|
|
39
|
+
).fetch(:action)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Adds one resolved relationship entry and returns the stored entry as well.
|
|
43
|
+
def add_detailed_result(document:, key:, scope: nil, metadata: nil, count: 1)
|
|
33
44
|
if @multiple_settings.keep?
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
return {
|
|
46
|
+
action: :added,
|
|
47
|
+
entry: append_entry(document: document, key: key, scope: scope, metadata: metadata, count: 1)
|
|
48
|
+
}
|
|
36
49
|
end
|
|
37
50
|
|
|
38
51
|
existing_entry = @entries_by_document_id[document.object_id]
|
|
39
52
|
if existing_entry
|
|
40
|
-
return
|
|
53
|
+
return {
|
|
54
|
+
action: :ignored,
|
|
55
|
+
entry: existing_entry
|
|
56
|
+
} if @multiple_settings.drop?
|
|
41
57
|
|
|
42
58
|
existing_entry[:count] += normalise_count(count)
|
|
43
59
|
existing_entry[:metadata] = merge_metadata_under(
|
|
44
60
|
existing_metadata: existing_entry[:metadata],
|
|
45
61
|
incoming_metadata: metadata
|
|
46
62
|
)
|
|
47
|
-
return
|
|
63
|
+
return {
|
|
64
|
+
action: :merged,
|
|
65
|
+
entry: existing_entry
|
|
66
|
+
}
|
|
48
67
|
end
|
|
49
68
|
|
|
50
69
|
entry = append_entry(
|
|
51
70
|
document: document,
|
|
52
71
|
key: key,
|
|
72
|
+
scope: scope,
|
|
53
73
|
metadata: metadata,
|
|
54
74
|
count: counted_mode? ? normalise_count(count) : 1
|
|
55
75
|
)
|
|
56
76
|
@entries_by_document_id[document.object_id] = entry
|
|
57
|
-
|
|
77
|
+
{
|
|
78
|
+
action: :added,
|
|
79
|
+
entry: entry
|
|
80
|
+
}
|
|
58
81
|
end
|
|
59
82
|
|
|
60
83
|
# Removes every stored occurrence of one target document.
|
|
@@ -99,6 +122,35 @@ class Accumulator
|
|
|
99
122
|
end
|
|
100
123
|
end
|
|
101
124
|
|
|
125
|
+
# Repositions newly reinserted entries by proportional target slot.
|
|
126
|
+
def reposition_entries!(reinsertions:, base_count:)
|
|
127
|
+
return if reinsertions.empty?
|
|
128
|
+
|
|
129
|
+
reinsertions_by_entry_id = reinsertions.each_with_object({}) do |reinsertion, indexed_reinsertions|
|
|
130
|
+
indexed_reinsertions[reinsertion.fetch(:entry).object_id] = reinsertion
|
|
131
|
+
end
|
|
132
|
+
base_entries = @entries.reject do |entry|
|
|
133
|
+
reinsertions_by_entry_id.key?(entry.object_id)
|
|
134
|
+
end
|
|
135
|
+
slots = Hash.new { |hash, key| hash[key] = [] }
|
|
136
|
+
reinsertions.sort_by do |reinsertion|
|
|
137
|
+
[
|
|
138
|
+
reinsertion.fetch(:position_ratio).nil? ? Float::INFINITY : reinsertion.fetch(:position_ratio),
|
|
139
|
+
reinsertion.fetch(:order)
|
|
140
|
+
]
|
|
141
|
+
end.each do |reinsertion|
|
|
142
|
+
slots[insertion_index(position_ratio: reinsertion.fetch(:position_ratio), base_count: base_count)] << reinsertion.fetch(:entry)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
rebuilt_entries = []
|
|
146
|
+
0.upto(base_entries.length) do |index|
|
|
147
|
+
rebuilt_entries.concat(slots[index]) if slots.key?(index)
|
|
148
|
+
rebuilt_entries << base_entries[index] if index < base_entries.length
|
|
149
|
+
end
|
|
150
|
+
@entries = rebuilt_entries
|
|
151
|
+
reindex_entries!
|
|
152
|
+
end
|
|
153
|
+
|
|
102
154
|
private
|
|
103
155
|
|
|
104
156
|
# Returns true when the current mode is count.
|
|
@@ -107,10 +159,11 @@ class Accumulator
|
|
|
107
159
|
end
|
|
108
160
|
|
|
109
161
|
# Appends one entry while recording its first-seen order.
|
|
110
|
-
def append_entry(document:, key:, metadata:, count:)
|
|
162
|
+
def append_entry(document:, key:, scope:, metadata:, count:)
|
|
111
163
|
entry = {
|
|
112
164
|
document: document,
|
|
113
165
|
key: key,
|
|
166
|
+
scope: duplicate_scope(scope),
|
|
114
167
|
metadata: normalise_metadata(metadata),
|
|
115
168
|
count: count,
|
|
116
169
|
first_seen_index: @next_position
|
|
@@ -134,6 +187,7 @@ class Accumulator
|
|
|
134
187
|
{
|
|
135
188
|
document: entry.fetch(:document),
|
|
136
189
|
key: entry.fetch(:key),
|
|
190
|
+
scope: duplicate_scope(entry.fetch(:scope)),
|
|
137
191
|
metadata: normalise_metadata(entry.fetch(:metadata)),
|
|
138
192
|
count: entry.fetch(:count),
|
|
139
193
|
first_seen_index: entry.fetch(:first_seen_index)
|
|
@@ -145,11 +199,19 @@ class Accumulator
|
|
|
145
199
|
@reference_template.build(
|
|
146
200
|
document: entry.fetch(:document),
|
|
147
201
|
key: entry.fetch(:key),
|
|
202
|
+
scope: entry.fetch(:scope),
|
|
148
203
|
metadata: entry.fetch(:metadata),
|
|
149
204
|
count: entry.fetch(:count)
|
|
150
205
|
)
|
|
151
206
|
end
|
|
152
207
|
|
|
208
|
+
# Returns a shallow copy of one complete scope hash while preserving scalar values exactly.
|
|
209
|
+
def duplicate_scope(scope)
|
|
210
|
+
return nil if scope.nil?
|
|
211
|
+
|
|
212
|
+
normalise_metadata(scope)
|
|
213
|
+
end
|
|
214
|
+
|
|
153
215
|
# Merges later metadata under the first-seen metadata.
|
|
154
216
|
def merge_metadata_under(existing_metadata:, incoming_metadata:)
|
|
155
217
|
Jekyll::Plugins::Relationships::Support.hash_deep_merge(
|
|
@@ -176,6 +238,22 @@ class Accumulator
|
|
|
176
238
|
|
|
177
239
|
integer_count
|
|
178
240
|
end
|
|
241
|
+
|
|
242
|
+
# Converts one persisted proportional hint into an insertion slot.
|
|
243
|
+
def insertion_index(position_ratio:, base_count:)
|
|
244
|
+
return base_count if position_ratio.nil?
|
|
245
|
+
return 0 if base_count <= 0
|
|
246
|
+
|
|
247
|
+
[[0, (position_ratio * base_count).round].max, base_count].min
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Rewrites first-seen indexes after persisted reinsertion changes the order.
|
|
251
|
+
def reindex_entries!
|
|
252
|
+
@entries.each_with_index do |entry, index|
|
|
253
|
+
entry[:first_seen_index] = index
|
|
254
|
+
end
|
|
255
|
+
@next_position = @entries.length
|
|
256
|
+
end
|
|
179
257
|
end
|
|
180
258
|
|
|
181
259
|
end
|
|
@@ -9,7 +9,7 @@ module References
|
|
|
9
9
|
# Parses loose reference values and builds canonical relationship hashes.
|
|
10
10
|
#
|
|
11
11
|
# The template is configured from `relationships.references` and controls which
|
|
12
|
-
# property names hold the key, collection, page, and optional count values in
|
|
12
|
+
# property names hold the key, collection, scope, page, and optional count values in
|
|
13
13
|
# output hashes.
|
|
14
14
|
class Template
|
|
15
15
|
|
|
@@ -18,12 +18,13 @@ class Template
|
|
|
18
18
|
# The `metadata` hash contains any non-reserved properties found on an
|
|
19
19
|
# existing reference hash.
|
|
20
20
|
class ParsedReference
|
|
21
|
-
attr_reader :key, :collection, :page, :count, :metadata, :original_value
|
|
21
|
+
attr_reader :key, :collection, :scope, :page, :count, :metadata, :original_value
|
|
22
22
|
|
|
23
23
|
# Captures the parsed reference fields in one immutable object.
|
|
24
|
-
def initialize(key:, collection:, page:, count:, metadata:, original_value:)
|
|
24
|
+
def initialize(key:, collection:, scope: nil, page:, count:, metadata:, original_value:)
|
|
25
25
|
@key = key
|
|
26
26
|
@collection = collection
|
|
27
|
+
@scope = scope
|
|
27
28
|
@page = page
|
|
28
29
|
@count = count
|
|
29
30
|
@metadata = metadata
|
|
@@ -36,7 +37,7 @@ class Template
|
|
|
36
37
|
end
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
attr_reader :key_property, :collection_property, :page_property, :count_property
|
|
40
|
+
attr_reader :key_property, :collection_property, :scope_property, :page_property, :count_property
|
|
40
41
|
|
|
41
42
|
# Builds the reference template from config and duplicate-handling settings.
|
|
42
43
|
def initialize(config:, count_enabled:)
|
|
@@ -44,6 +45,7 @@ class Template
|
|
|
44
45
|
@count_enabled = !!count_enabled
|
|
45
46
|
@key_property = nil
|
|
46
47
|
@collection_property = nil
|
|
48
|
+
@scope_property = nil
|
|
47
49
|
@page_property = nil
|
|
48
50
|
@count_property = nil
|
|
49
51
|
|
|
@@ -51,7 +53,7 @@ class Template
|
|
|
51
53
|
end
|
|
52
54
|
|
|
53
55
|
# Parses one raw reference value from frontmatter or resolver input.
|
|
54
|
-
def parse(value)
|
|
56
|
+
def parse(value, context: nil)
|
|
55
57
|
case value
|
|
56
58
|
when String
|
|
57
59
|
key = value.to_s.strip
|
|
@@ -60,32 +62,35 @@ class Template
|
|
|
60
62
|
ParsedReference.new(
|
|
61
63
|
key: key,
|
|
62
64
|
collection: nil,
|
|
65
|
+
scope: nil,
|
|
63
66
|
page: nil,
|
|
64
67
|
count: 1,
|
|
65
68
|
metadata: {},
|
|
66
69
|
original_value: value
|
|
67
70
|
)
|
|
68
71
|
when Hash
|
|
69
|
-
parse_hash_reference(value)
|
|
72
|
+
parse_hash_reference(value, context: context)
|
|
70
73
|
when Jekyll::Document
|
|
71
74
|
ParsedReference.new(
|
|
72
75
|
key: nil,
|
|
73
76
|
collection: nil,
|
|
77
|
+
scope: nil,
|
|
74
78
|
page: value,
|
|
75
79
|
count: 1,
|
|
76
80
|
metadata: {},
|
|
77
81
|
original_value: value
|
|
78
82
|
)
|
|
79
83
|
else
|
|
80
|
-
raise ConfigurationError, "
|
|
84
|
+
raise ConfigurationError, "#{context_prefix(context)}unsupported reference value `#{value.inspect}`."
|
|
81
85
|
end
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
# Builds one output reference hash for a resolved document.
|
|
85
|
-
def build(document:, key:, metadata: nil, count: 1, include_count: @count_enabled)
|
|
89
|
+
def build(document:, key:, scope: nil, metadata: nil, count: 1, include_count: @count_enabled)
|
|
86
90
|
hash = {}
|
|
87
91
|
hash[@key_property] = key
|
|
88
92
|
hash[@collection_property] = document.collection.label if @collection_property
|
|
93
|
+
hash[@scope_property] = stringify_hash(scope) if @scope_property && !scope.nil?
|
|
89
94
|
hash[@page_property] = document if @page_property
|
|
90
95
|
hash[@count_property] = normalise_count(count) if include_count && @count_property
|
|
91
96
|
|
|
@@ -98,7 +103,7 @@ class Template
|
|
|
98
103
|
|
|
99
104
|
# Returns the configured property names that are reserved for the engine.
|
|
100
105
|
def reserved_properties
|
|
101
|
-
[@key_property, @collection_property, @page_property, @count_property].compact
|
|
106
|
+
[@key_property, @collection_property, @scope_property, @page_property, @count_property].compact
|
|
102
107
|
end
|
|
103
108
|
|
|
104
109
|
# Returns the input properties that should never survive into free-form metadata.
|
|
@@ -121,6 +126,9 @@ class Template
|
|
|
121
126
|
when Jekyll::Plugins::Relationships::Support::Placeholders::COLLECTION
|
|
122
127
|
raise ConfigurationError, 'Reference config can only define one <collection> property.' if @collection_property
|
|
123
128
|
@collection_property = property
|
|
129
|
+
when Jekyll::Plugins::Relationships::Support::Placeholders::SCOPE
|
|
130
|
+
raise ConfigurationError, 'Reference config can only define one <scope> property.' if @scope_property
|
|
131
|
+
@scope_property = property
|
|
124
132
|
when Jekyll::Plugins::Relationships::Support::Placeholders::PAGE
|
|
125
133
|
raise ConfigurationError, 'Reference config can only define one <page> property.' if @page_property
|
|
126
134
|
@page_property = property
|
|
@@ -139,18 +147,20 @@ class Template
|
|
|
139
147
|
end
|
|
140
148
|
|
|
141
149
|
# Parses one hash reference according to the configured property names.
|
|
142
|
-
def parse_hash_reference(hash)
|
|
150
|
+
def parse_hash_reference(hash, context: nil)
|
|
143
151
|
key = fetch_hash_value(hash, @key_property)
|
|
144
152
|
collection = @collection_property ? fetch_hash_value(hash, @collection_property) : nil
|
|
153
|
+
scope = parse_scope(hash, context: context)
|
|
145
154
|
page = @page_property ? fetch_hash_value(hash, @page_property) : nil
|
|
146
155
|
|
|
147
156
|
if key.nil? && !page.is_a?(Jekyll::Document)
|
|
148
|
-
raise ResolutionError, "
|
|
157
|
+
raise ResolutionError, "#{context_prefix(context)}reference hash `#{hash.inspect}` does not include the configured key property `#{@key_property}`."
|
|
149
158
|
end
|
|
150
159
|
|
|
151
160
|
ParsedReference.new(
|
|
152
161
|
key: key,
|
|
153
162
|
collection: collection,
|
|
163
|
+
scope: scope,
|
|
154
164
|
page: page,
|
|
155
165
|
count: parsed_count(hash),
|
|
156
166
|
metadata: filter_metadata(hash),
|
|
@@ -158,6 +168,19 @@ class Template
|
|
|
158
168
|
)
|
|
159
169
|
end
|
|
160
170
|
|
|
171
|
+
# Parses the reserved scope property without interpreting literal dotted field names as nested paths.
|
|
172
|
+
def parse_scope(hash, context: nil)
|
|
173
|
+
return nil unless @scope_property
|
|
174
|
+
return nil unless hash_property?(hash, @scope_property)
|
|
175
|
+
|
|
176
|
+
raw_scope = fetch_hash_value(hash, @scope_property)
|
|
177
|
+
unless raw_scope.is_a?(Hash)
|
|
178
|
+
raise ResolutionError, "#{context_prefix(context)}reference scope property `#{@scope_property}` must be a hash, but was `#{raw_scope.inspect}`."
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
stringify_hash(raw_scope)
|
|
182
|
+
end
|
|
183
|
+
|
|
161
184
|
# Returns the parsed count value for one reference hash.
|
|
162
185
|
def parsed_count(hash)
|
|
163
186
|
return 1 unless @count_enabled
|
|
@@ -191,6 +214,18 @@ class Template
|
|
|
191
214
|
Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(hash, property)
|
|
192
215
|
end
|
|
193
216
|
|
|
217
|
+
# Returns true when a string- or symbol-keyed hash contains one literal property.
|
|
218
|
+
def hash_property?(hash, property)
|
|
219
|
+
hash.key?(property) || hash.key?(property.to_s) || hash.key?(property.to_s.to_sym)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Adds relationship and document details supplied by the caller to parsing diagnostics.
|
|
223
|
+
def context_prefix(context)
|
|
224
|
+
return '' if context.nil? || context.to_s.empty?
|
|
225
|
+
|
|
226
|
+
"#{context}: "
|
|
227
|
+
end
|
|
228
|
+
|
|
194
229
|
# Converts one hash to a shallow string-keyed copy.
|
|
195
230
|
def stringify_hash(hash)
|
|
196
231
|
return {} unless hash.is_a?(Hash)
|
|
@@ -6,6 +6,17 @@ module Plugins
|
|
|
6
6
|
module Relationships
|
|
7
7
|
module Resolvers
|
|
8
8
|
|
|
9
|
+
# Declares or reads the global default persistence mode for resolver link calls.
|
|
10
|
+
#
|
|
11
|
+
# Resolver classes can override this locally with their own `persist true/false`
|
|
12
|
+
# declaration. When left unset on the class, `link(...)` falls back to this
|
|
13
|
+
# module-wide default.
|
|
14
|
+
def self.persist(value = :__read__)
|
|
15
|
+
return Base.global_persist_default if value == :__read__
|
|
16
|
+
|
|
17
|
+
Base.global_persist_default = value
|
|
18
|
+
end
|
|
19
|
+
|
|
9
20
|
# Base class for custom relationship resolvers.
|
|
10
21
|
#
|
|
11
22
|
# Subclass this in `_plugins`, declare `from` and `to`, then implement
|
|
@@ -14,6 +25,7 @@ module Resolvers
|
|
|
14
25
|
class Base
|
|
15
26
|
|
|
16
27
|
@registered_subclasses = []
|
|
28
|
+
@global_persist_default = false
|
|
17
29
|
|
|
18
30
|
class << self
|
|
19
31
|
attr_reader :from_definition, :to_definition
|
|
@@ -42,6 +54,35 @@ class Base
|
|
|
42
54
|
@to_definition
|
|
43
55
|
end
|
|
44
56
|
|
|
57
|
+
# Declares or reads the default persistence mode for this resolver class.
|
|
58
|
+
def persist(value = :__read__)
|
|
59
|
+
unless value == :__read__
|
|
60
|
+
@persist_default = normalise_persist_default(
|
|
61
|
+
value,
|
|
62
|
+
context: "#{name || self}.persist"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
return @persist_default unless @persist_default.nil?
|
|
66
|
+
return superclass.persist if self != Base && superclass.respond_to?(:persist)
|
|
67
|
+
|
|
68
|
+
global_persist_default
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Returns the module-wide fallback persistence mode.
|
|
72
|
+
def global_persist_default
|
|
73
|
+
return false unless instance_variable_defined?(:@global_persist_default)
|
|
74
|
+
|
|
75
|
+
@global_persist_default
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sets the module-wide fallback persistence mode.
|
|
79
|
+
def global_persist_default=(value)
|
|
80
|
+
@global_persist_default = normalise_persist_default(
|
|
81
|
+
value,
|
|
82
|
+
context: 'Resolvers.persist'
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
45
86
|
# Returns every resolver subclass whose `from` and `to` selectors are complete.
|
|
46
87
|
def registered_subclasses
|
|
47
88
|
@registered_subclasses ||= []
|
|
@@ -60,6 +101,15 @@ class Base
|
|
|
60
101
|
def remove_registered_subclass(subclass:)
|
|
61
102
|
registered_subclasses.delete(subclass)
|
|
62
103
|
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Normalises one resolver persistence default to a strict boolean.
|
|
108
|
+
def normalise_persist_default(value, context:)
|
|
109
|
+
return value if value == true || value == false
|
|
110
|
+
|
|
111
|
+
raise ConfigurationError, "`#{context}` must be true or false."
|
|
112
|
+
end
|
|
63
113
|
end
|
|
64
114
|
|
|
65
115
|
# Builds one resolver instance for one resolving relationship state.
|
|
@@ -159,11 +209,15 @@ class Base
|
|
|
159
209
|
end
|
|
160
210
|
|
|
161
211
|
# Adds one relationship from the current document to the target collection.
|
|
162
|
-
|
|
212
|
+
#
|
|
213
|
+
# When `persist` is true, the link may be restored in later rounds if the
|
|
214
|
+
# resolver does not recreate it and both linked documents still survive.
|
|
215
|
+
def link(target_reference, reference: nil, persist: nil)
|
|
163
216
|
@state.link(
|
|
164
217
|
target_reference,
|
|
165
218
|
metadata: reference,
|
|
166
|
-
origin: "resolver #{self.class.name || self.class}"
|
|
219
|
+
origin: "resolver #{self.class.name || self.class}",
|
|
220
|
+
persist: persist.nil? ? self.class.persist : persist
|
|
167
221
|
)
|
|
168
222
|
end
|
|
169
223
|
|
|
@@ -184,6 +238,9 @@ class Base
|
|
|
184
238
|
@engine.resolve_reference_document(
|
|
185
239
|
reference,
|
|
186
240
|
primary_path: @state.definition.primary_path,
|
|
241
|
+
scope_fields: @state.definition.scope_fields,
|
|
242
|
+
referring_document: @document,
|
|
243
|
+
relationship: "#{@state.definition.from_collection} -> #{@state.definition.to_collection}",
|
|
187
244
|
collection_hint: from_collection
|
|
188
245
|
)
|
|
189
246
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
|
|
9
|
+
# Matches exact values at dot-separated paths in document frontmatter.
|
|
10
|
+
#
|
|
11
|
+
# Paths traverse hashes only so arrays and hashes remain matchable terminal
|
|
12
|
+
# values. Lookup distinguishes a missing path from a present nil value and
|
|
13
|
+
# treats string and symbol hash keys as equivalent.
|
|
14
|
+
class FrontmatterMatcher
|
|
15
|
+
# Builds one matcher from normalised path and expected-value pairs.
|
|
16
|
+
def initialize(expected_values:)
|
|
17
|
+
@expected_values = expected_values
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns true when every configured path is present and matches exactly.
|
|
21
|
+
def matches?(data)
|
|
22
|
+
@expected_values.all? do |path, expected_value|
|
|
23
|
+
match = value_at(data: data, path: path)
|
|
24
|
+
match.fetch(:present) && type_sensitive_equal?(match.fetch(:value), expected_value)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# Reads one hash-only path without collapsing a present nil into missing.
|
|
31
|
+
def value_at(data:, path:)
|
|
32
|
+
current_value = data
|
|
33
|
+
FrontmatterPath.split_path(path).each do |segment|
|
|
34
|
+
return missing_value unless current_value.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
resolved_key = resolve_hash_key(hash: current_value, segment: segment)
|
|
37
|
+
return missing_value if resolved_key.nil?
|
|
38
|
+
|
|
39
|
+
current_value = current_value[resolved_key]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
present: true,
|
|
44
|
+
value: current_value
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Resolves string and symbol forms of one path segment.
|
|
49
|
+
def resolve_hash_key(hash:, segment:)
|
|
50
|
+
return segment if hash.key?(segment)
|
|
51
|
+
|
|
52
|
+
symbol_segment = segment.to_sym
|
|
53
|
+
return symbol_segment if hash.key?(symbol_segment)
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns one fresh missing lookup result.
|
|
59
|
+
def missing_value
|
|
60
|
+
{
|
|
61
|
+
present: false,
|
|
62
|
+
value: nil
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Requires both the Ruby type and value to match.
|
|
67
|
+
def type_sensitive_equal?(actual_value, expected_value)
|
|
68
|
+
actual_value.class == expected_value.class && actual_value == expected_value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -53,8 +53,8 @@ class EdgeBuilder
|
|
|
53
53
|
|
|
54
54
|
@graph.documents_for(child_collection).each do |child_document|
|
|
55
55
|
path_configuration.parent_input_paths(max_parents: definition.tree_settings.max_parents).each do |path|
|
|
56
|
-
parse_references(@data_path.read(child_document.data, path)).each do |reference|
|
|
57
|
-
parent_document = resolve_reference(reference: reference,
|
|
56
|
+
parse_references(@data_path.read(child_document.data, path), definition: definition, document: child_document).each do |reference|
|
|
57
|
+
parent_document = resolve_reference(reference: reference, definition: definition, referring_document: child_document)
|
|
58
58
|
next unless parent_document
|
|
59
59
|
next unless parent_document.collection.label == parent_collection
|
|
60
60
|
|
|
@@ -88,8 +88,8 @@ class EdgeBuilder
|
|
|
88
88
|
|
|
89
89
|
@graph.documents_for(parent_collection).each do |parent_document|
|
|
90
90
|
path_configuration.child_input_paths(max_children: definition.tree_settings.max_children).each do |path|
|
|
91
|
-
parse_references(@data_path.read(parent_document.data, path)).each do |reference|
|
|
92
|
-
child_document = resolve_reference(reference: reference,
|
|
91
|
+
parse_references(@data_path.read(parent_document.data, path), definition: definition, document: parent_document).each do |reference|
|
|
92
|
+
child_document = resolve_reference(reference: reference, definition: definition, referring_document: parent_document)
|
|
93
93
|
next unless child_document
|
|
94
94
|
next unless child_document.collection.label == child_collection
|
|
95
95
|
|
|
@@ -132,7 +132,7 @@ class EdgeBuilder
|
|
|
132
132
|
parents = fetch_nested(url_index, parent_collection, parent_parts)
|
|
133
133
|
next unless parents
|
|
134
134
|
|
|
135
|
-
parents.each do |parent_document|
|
|
135
|
+
parents.select { |parent_document| same_scope?(first_document: child_document, second_document: parent_document, definition: definition) }.each do |parent_document|
|
|
136
136
|
@graph.add_edge(
|
|
137
137
|
parent_document: parent_document,
|
|
138
138
|
child_document: child_document,
|
|
@@ -162,24 +162,56 @@ class EdgeBuilder
|
|
|
162
162
|
end
|
|
163
163
|
|
|
164
164
|
# Parses one tree frontmatter value into parsed references.
|
|
165
|
-
def parse_references(value)
|
|
165
|
+
def parse_references(value, definition:, document:)
|
|
166
166
|
@string_array.interpret(value, split: -1, flatten: true).map do |entry|
|
|
167
|
-
@configuration.reference_template.parse(
|
|
167
|
+
@configuration.reference_template.parse(
|
|
168
|
+
entry,
|
|
169
|
+
context: "Relationship `#{definition.from_collection} -> #{definition.to_collection}` reference on document `#{document.relative_path}`"
|
|
170
|
+
)
|
|
168
171
|
end.compact
|
|
169
172
|
end
|
|
170
173
|
|
|
171
174
|
# Resolves one parsed reference to a real document.
|
|
172
|
-
def resolve_reference(reference:,
|
|
173
|
-
|
|
175
|
+
def resolve_reference(reference:, definition:, referring_document:)
|
|
176
|
+
relationship = "#{definition.from_collection} -> #{definition.to_collection}"
|
|
177
|
+
effective_scope = @registry.effective_scope_for(
|
|
178
|
+
reference_scope: reference.scope,
|
|
179
|
+
referring_document: referring_document,
|
|
180
|
+
scope_fields: definition.scope_fields,
|
|
181
|
+
relationship: relationship
|
|
182
|
+
)
|
|
183
|
+
if reference.document?
|
|
184
|
+
@registry.validate_scope_match!(
|
|
185
|
+
document: reference.page,
|
|
186
|
+
scope: effective_scope,
|
|
187
|
+
scope_fields: definition.scope_fields,
|
|
188
|
+
relationship: relationship,
|
|
189
|
+
referring_document: referring_document
|
|
190
|
+
)
|
|
191
|
+
return reference.page
|
|
192
|
+
end
|
|
174
193
|
|
|
175
194
|
collection = reference.collection.to_s unless reference.collection.nil?
|
|
176
195
|
@registry.lookup(
|
|
177
196
|
key: reference.key,
|
|
178
|
-
primary_path: primary_path,
|
|
179
|
-
collection: collection
|
|
197
|
+
primary_path: definition.primary_path,
|
|
198
|
+
collection: collection,
|
|
199
|
+
scope_fields: definition.scope_fields,
|
|
200
|
+
scope: effective_scope,
|
|
201
|
+
relationship: relationship,
|
|
202
|
+
referring_document: referring_document
|
|
180
203
|
)
|
|
181
204
|
end
|
|
182
205
|
|
|
206
|
+
# Returns true when two URL-selected documents share the definition's complete scope.
|
|
207
|
+
def same_scope?(first_document:, second_document:, definition:)
|
|
208
|
+
return true if definition.scope_fields.empty?
|
|
209
|
+
|
|
210
|
+
relationship = "#{definition.from_collection} -> #{definition.to_collection}"
|
|
211
|
+
@registry.scope_for(first_document, scope_fields: definition.scope_fields, relationship: relationship) ==
|
|
212
|
+
@registry.scope_for(second_document, scope_fields: definition.scope_fields, relationship: relationship)
|
|
213
|
+
end
|
|
214
|
+
|
|
183
215
|
# Reads one nested hash value without creating a default entry.
|
|
184
216
|
def fetch_nested(hash, first_key, second_key)
|
|
185
217
|
first_hash = hash[first_key]
|