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.
- checksums.yaml +7 -0
- data/lib/jekyll-relationships/configuration/debug_setting.rb +117 -0
- data/lib/jekyll-relationships/configuration/defaults.rb +54 -0
- data/lib/jekyll-relationships/configuration/frontmatter.rb +102 -0
- data/lib/jekyll-relationships/configuration/hash_utilities.rb +55 -0
- data/lib/jekyll-relationships/configuration/multiple_settings.rb +101 -0
- data/lib/jekyll-relationships/configuration/parser.rb +551 -0
- data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +101 -0
- data/lib/jekyll-relationships/configuration/prune_settings.rb +82 -0
- data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +244 -0
- data/lib/jekyll-relationships/configuration/tree_settings.rb +74 -0
- data/lib/jekyll-relationships/configuration.rb +250 -0
- data/lib/jekyll-relationships/debug_logger.rb +104 -0
- data/lib/jekyll-relationships/definitions/configured_relationship.rb +61 -0
- data/lib/jekyll-relationships/definitions/normal_relationship.rb +51 -0
- data/lib/jekyll-relationships/definitions/prune_rule.rb +67 -0
- data/lib/jekyll-relationships/definitions/tree_relationship.rb +48 -0
- data/lib/jekyll-relationships/documents/registry.rb +136 -0
- data/lib/jekyll-relationships/engine/raw_path_state.rb +217 -0
- data/lib/jekyll-relationships/engine/relationship_state.rb +251 -0
- data/lib/jekyll-relationships/engine/session.rb +187 -0
- data/lib/jekyll-relationships/engine/write_back.rb +154 -0
- data/lib/jekyll-relationships/engine.rb +228 -0
- data/lib/jekyll-relationships/errors.rb +30 -0
- data/lib/jekyll-relationships/generators/relationships.rb +27 -0
- data/lib/jekyll-relationships/pruning/normal_graph.rb +119 -0
- data/lib/jekyll-relationships/pruning/rule_pruner.rb +67 -0
- data/lib/jekyll-relationships/pruning/tree_phase.rb +351 -0
- data/lib/jekyll-relationships/pruning/tree_provenance.rb +32 -0
- data/lib/jekyll-relationships/references/accumulator.rb +185 -0
- data/lib/jekyll-relationships/references/template.rb +222 -0
- data/lib/jekyll-relationships/resolvers/base.rb +207 -0
- data/lib/jekyll-relationships/run_logger.rb +101 -0
- data/lib/jekyll-relationships/support/data_path.rb +194 -0
- data/lib/jekyll-relationships/support/frontmatter_path.rb +217 -0
- data/lib/jekyll-relationships/support/hash_deep_merge.rb +63 -0
- data/lib/jekyll-relationships/support/placeholders.rb +21 -0
- data/lib/jekyll-relationships/support/string_array.rb +157 -0
- data/lib/jekyll-relationships/trees/edge_builder.rb +196 -0
- data/lib/jekyll-relationships/trees/graph.rb +454 -0
- data/lib/jekyll-relationships/version.rb +11 -0
- data/lib/jekyll-relationships.rb +34 -0
- data/readme.md +509 -0
- metadata +189 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
|
|
9
|
+
# Reads and writes dot-separated frontmatter paths on plain nested hashes.
|
|
10
|
+
#
|
|
11
|
+
# This wrapper keeps all path mutation logic in one place so the rest of the
|
|
12
|
+
# engine can stay focused on relationship behaviour rather than hash surgery.
|
|
13
|
+
class DataPath
|
|
14
|
+
# Captures one concrete leaf reached by a dotted frontmatter path.
|
|
15
|
+
#
|
|
16
|
+
# Each match remembers the exact container and key so callers can update the
|
|
17
|
+
# original structure surgically without reshaping nearby hashes or arrays.
|
|
18
|
+
class Match
|
|
19
|
+
attr_reader :parent, :key, :value
|
|
20
|
+
|
|
21
|
+
# Builds one concrete leaf match.
|
|
22
|
+
def initialize(parent:, key:, value:)
|
|
23
|
+
@parent = parent
|
|
24
|
+
@key = key
|
|
25
|
+
@value = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Replaces the matched leaf in place.
|
|
29
|
+
def write(value)
|
|
30
|
+
@parent[@key] = value
|
|
31
|
+
@value = value
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reports the raw value gathered from a path together with its concrete
|
|
36
|
+
# source leaves.
|
|
37
|
+
#
|
|
38
|
+
# `array_traversed?` distinguishes a simple hash path from one that expanded
|
|
39
|
+
# through array items and therefore cannot safely receive one consolidated
|
|
40
|
+
# write-back value.
|
|
41
|
+
class ReadResult
|
|
42
|
+
attr_reader :matches
|
|
43
|
+
|
|
44
|
+
# Builds one read result from the collected concrete matches.
|
|
45
|
+
def initialize(matches:, array_traversed:)
|
|
46
|
+
@matches = matches
|
|
47
|
+
@array_traversed = array_traversed
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Reassembles the public read value from the concrete matches.
|
|
51
|
+
def value
|
|
52
|
+
values = @matches.map(&:value)
|
|
53
|
+
return nil if values.empty?
|
|
54
|
+
return values.first if values.length == 1
|
|
55
|
+
|
|
56
|
+
values
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns true when the path resolved to at least one concrete leaf.
|
|
60
|
+
def present?
|
|
61
|
+
!@matches.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns true when path traversal expanded through an array.
|
|
65
|
+
def array_traversed?
|
|
66
|
+
@array_traversed
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
TraversalNode = Struct.new(:parent, :key, :value)
|
|
71
|
+
|
|
72
|
+
# Builds one reusable accessor.
|
|
73
|
+
def initialize
|
|
74
|
+
@reader = Jekyll::Plugins::Relationships::Support::FrontmatterPath.new
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Reads one value together with its concrete leaf matches.
|
|
78
|
+
def read_result(data, path)
|
|
79
|
+
return ReadResult.new(matches: [], array_traversed: false) if blank_path?(path)
|
|
80
|
+
|
|
81
|
+
matches, array_traversed = locate_matches(data, path)
|
|
82
|
+
ReadResult.new(matches: matches, array_traversed: array_traversed)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reads one value from a nested frontmatter hash.
|
|
86
|
+
def read(data, path)
|
|
87
|
+
read_result(data, path).value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Writes one value to a nested frontmatter hash, creating hashes as needed.
|
|
91
|
+
#
|
|
92
|
+
# Existing non-hash ancestors are treated as errors so write-back never
|
|
93
|
+
# silently replaces arrays or scalar values with new hashes.
|
|
94
|
+
def write(data, path, value)
|
|
95
|
+
raise ArgumentError, 'Cannot write to a blank frontmatter path.' if blank_path?(path)
|
|
96
|
+
|
|
97
|
+
segments = Jekyll::Plugins::Relationships::Support::FrontmatterPath.split_path(path)
|
|
98
|
+
current_hash = data
|
|
99
|
+
|
|
100
|
+
segments[0..-2].each_with_index do |segment, segment_index|
|
|
101
|
+
next_hash = Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(current_hash, segment)
|
|
102
|
+
if next_hash.nil?
|
|
103
|
+
next_hash = {}
|
|
104
|
+
current_hash[segment] = next_hash
|
|
105
|
+
elsif !next_hash.is_a?(Hash)
|
|
106
|
+
failing_path = segments.first(segment_index + 1).join(@reader.separator.to_s)
|
|
107
|
+
raise ResolutionError, "Cannot write frontmatter path `#{path}` because `#{failing_path}` resolves to a #{next_hash.class}, not a hash."
|
|
108
|
+
end
|
|
109
|
+
current_hash = next_hash
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
current_hash[segments.last] = value
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Finds the concrete leaves currently matched by one path.
|
|
118
|
+
def locate_matches(data, path)
|
|
119
|
+
return [[], false] unless data.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
segments = Jekyll::Plugins::Relationships::Support::FrontmatterPath.split_path(path, @reader.separator)
|
|
122
|
+
return [[], false] if segments.empty?
|
|
123
|
+
|
|
124
|
+
nodes = [TraversalNode.new(nil, nil, data)]
|
|
125
|
+
array_traversed = false
|
|
126
|
+
|
|
127
|
+
segments.each_with_index do |_, segment_index|
|
|
128
|
+
requested_key_path = segments.first(segment_index + 1).join(@reader.separator.to_s)
|
|
129
|
+
next_nodes = []
|
|
130
|
+
last_segment = segment_index == segments.length - 1
|
|
131
|
+
|
|
132
|
+
nodes.each do |node|
|
|
133
|
+
if node.value.is_a?(Array)
|
|
134
|
+
array_traversed = true
|
|
135
|
+
next_nodes.concat(descend_array(node.value))
|
|
136
|
+
next
|
|
137
|
+
end
|
|
138
|
+
next unless node.value.is_a?(Hash)
|
|
139
|
+
|
|
140
|
+
resolved_key = Jekyll::Plugins::Relationships::Support::FrontmatterPath.resolve_hash_key(
|
|
141
|
+
node.value,
|
|
142
|
+
requested_key_path,
|
|
143
|
+
@reader.equivalent_lookup,
|
|
144
|
+
separator: @reader.separator
|
|
145
|
+
)
|
|
146
|
+
next if resolved_key.nil?
|
|
147
|
+
|
|
148
|
+
child_value = Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(node.value, resolved_key)
|
|
149
|
+
next if child_value.nil?
|
|
150
|
+
|
|
151
|
+
if child_value.is_a?(Array) && !last_segment
|
|
152
|
+
array_traversed = true
|
|
153
|
+
next_nodes.concat(descend_array(child_value))
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
next_nodes << TraversalNode.new(node.value, resolved_key, child_value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
nodes = next_nodes
|
|
161
|
+
break if nodes.empty?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
[
|
|
165
|
+
nodes.map { |node| Match.new(parent: node.parent, key: node.key, value: node.value) },
|
|
166
|
+
array_traversed
|
|
167
|
+
]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Applies the configured array-expansion rule while keeping exact indices.
|
|
171
|
+
def descend_array(array)
|
|
172
|
+
case @reader.arrays
|
|
173
|
+
when :first
|
|
174
|
+
return [] if array.empty?
|
|
175
|
+
|
|
176
|
+
[TraversalNode.new(array, 0, array.first)]
|
|
177
|
+
else
|
|
178
|
+
array.each_with_index.map do |entry, index|
|
|
179
|
+
TraversalNode.new(array, index, entry)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Treats nil and empty strings as unset paths.
|
|
185
|
+
def blank_path?(path)
|
|
186
|
+
path.nil? || path.to_s.strip.empty?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
|
|
9
|
+
# Traverses frontmatter-style nested hashes using configurable path,
|
|
10
|
+
# array, and equivalent-key rules.
|
|
11
|
+
#
|
|
12
|
+
# Use one instance per syntax configuration, then call `with` for scoped
|
|
13
|
+
# variations such as template-level separator overrides.
|
|
14
|
+
class FrontmatterPath
|
|
15
|
+
|
|
16
|
+
DEFAULT_SEPARATOR = '.'
|
|
17
|
+
DEFAULT_ARRAYS = :expand
|
|
18
|
+
UNSET = Object.new.freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :separator, :arrays, :equivalent_lookup
|
|
21
|
+
|
|
22
|
+
# Splits one configured path into non-blank segments.
|
|
23
|
+
def self.split_path(path, separator = DEFAULT_SEPARATOR)
|
|
24
|
+
path.to_s.split(separator.to_s).map(&:strip).reject(&:empty?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Normalises equivalent-key definitions into a path-aware lookup table.
|
|
28
|
+
#
|
|
29
|
+
# Keys are stored against their full requested path so aliases can be
|
|
30
|
+
# scoped to one nested level only.
|
|
31
|
+
def self.build_equivalent_lookup(raw_equivalents, split_delimiter: StringArray::DEFAULT_DELIMITER)
|
|
32
|
+
return {} if raw_equivalents == false || raw_equivalents.nil?
|
|
33
|
+
|
|
34
|
+
string_array = Jekyll::Plugins::Relationships::Support::StringArray.new(delimiter: split_delimiter)
|
|
35
|
+
lookup = {}
|
|
36
|
+
groups = raw_equivalents.is_a?(Array) ? raw_equivalents : [raw_equivalents]
|
|
37
|
+
|
|
38
|
+
groups.each do |group|
|
|
39
|
+
keys = if group.is_a?(Array)
|
|
40
|
+
group.flat_map { |entry| string_array.interpret(entry, split: -1, flatten: true) }
|
|
41
|
+
else
|
|
42
|
+
string_array.interpret(group, split: -1, flatten: true)
|
|
43
|
+
end
|
|
44
|
+
keys = keys.map { |entry| entry.to_s.strip }.reject(&:empty?).uniq
|
|
45
|
+
next if keys.length < 2
|
|
46
|
+
|
|
47
|
+
keys.each { |key| lookup[key] = keys }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
lookup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Reads a hash entry using exact, string, or symbol lookup.
|
|
54
|
+
def self.read_hash(hash, key)
|
|
55
|
+
return hash[key] if hash.key?(key)
|
|
56
|
+
|
|
57
|
+
string_key = key.to_s
|
|
58
|
+
return hash[string_key] if hash.key?(string_key)
|
|
59
|
+
|
|
60
|
+
symbol_key = string_key.to_sym
|
|
61
|
+
return hash[symbol_key] if hash.key?(symbol_key)
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Resolves the effective key present in one hash for the requested
|
|
67
|
+
# path-so-far.
|
|
68
|
+
def self.resolve_hash_key(hash, requested_key_path, equivalent_lookup, separator: DEFAULT_SEPARATOR)
|
|
69
|
+
string_key_path = requested_key_path.to_s.strip
|
|
70
|
+
return nil if string_key_path.empty?
|
|
71
|
+
|
|
72
|
+
group = equivalent_lookup[string_key_path] || [string_key_path]
|
|
73
|
+
candidate_segments = group.map { |candidate_path| split_path(candidate_path, separator).last }.reject(&:empty?).uniq
|
|
74
|
+
|
|
75
|
+
candidate_segments.reverse_each do |candidate|
|
|
76
|
+
return candidate if hash.key?(candidate)
|
|
77
|
+
|
|
78
|
+
symbol_candidate = candidate.to_sym
|
|
79
|
+
return symbol_candidate if hash.key?(symbol_candidate)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Builds a frontmatter-path helper for one default configuration.
|
|
86
|
+
def initialize(separator: DEFAULT_SEPARATOR, arrays: DEFAULT_ARRAYS, equivalents: nil, equivalent_lookup: UNSET)
|
|
87
|
+
@separator = normalise_separator(separator)
|
|
88
|
+
@arrays = normalise_array_mode(arrays)
|
|
89
|
+
@equivalent_lookup = equivalent_lookup.equal?(UNSET) ? self.class.build_equivalent_lookup(equivalents) : normalise_equivalent_lookup(equivalent_lookup)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Builds a clone with selected configuration overrides.
|
|
93
|
+
def with(separator: UNSET, arrays: UNSET, equivalents: UNSET, equivalent_lookup: UNSET)
|
|
94
|
+
resolved_separator = separator.equal?(UNSET) ? @separator : separator
|
|
95
|
+
resolved_arrays = arrays.equal?(UNSET) ? @arrays : arrays
|
|
96
|
+
|
|
97
|
+
if !equivalent_lookup.equal?(UNSET)
|
|
98
|
+
return self.class.new(
|
|
99
|
+
separator: resolved_separator,
|
|
100
|
+
arrays: resolved_arrays,
|
|
101
|
+
equivalent_lookup: equivalent_lookup
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if !equivalents.equal?(UNSET)
|
|
106
|
+
return self.class.new(
|
|
107
|
+
separator: resolved_separator,
|
|
108
|
+
arrays: resolved_arrays,
|
|
109
|
+
equivalents: equivalents
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
self.class.new(
|
|
114
|
+
separator: resolved_separator,
|
|
115
|
+
arrays: resolved_arrays,
|
|
116
|
+
equivalent_lookup: @equivalent_lookup
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Traverses one data structure and returns:
|
|
121
|
+
# - `nil` when the path is missing
|
|
122
|
+
# - one raw terminal value when one match is found
|
|
123
|
+
# - an array of raw terminal values when many matches are found
|
|
124
|
+
def traverse(data, path, separator: UNSET, arrays: UNSET, equivalents: UNSET, equivalent_lookup: UNSET)
|
|
125
|
+
active_separator = separator.equal?(UNSET) ? @separator : normalise_separator(separator)
|
|
126
|
+
active_arrays = arrays.equal?(UNSET) ? @arrays : normalise_array_mode(arrays)
|
|
127
|
+
active_lookup = resolve_lookup(equivalents, equivalent_lookup)
|
|
128
|
+
|
|
129
|
+
traverse_internal(data, path, separator: active_separator, arrays: active_arrays, equivalent_lookup: active_lookup)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Walks the object graph using the effective traversal configuration.
|
|
135
|
+
def traverse_internal(data, path, separator:, arrays:, equivalent_lookup:)
|
|
136
|
+
return nil unless data.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
segments = self.class.split_path(path, separator)
|
|
139
|
+
return nil if segments.empty?
|
|
140
|
+
|
|
141
|
+
nodes = [data]
|
|
142
|
+
segments.each_with_index do |_, segment_index|
|
|
143
|
+
next_nodes = []
|
|
144
|
+
requested_key_path = segments.first(segment_index + 1).join(separator.to_s)
|
|
145
|
+
|
|
146
|
+
nodes.each do |node|
|
|
147
|
+
if node.is_a?(Array)
|
|
148
|
+
next_nodes.concat(descend_array(node, arrays))
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
next unless node.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
resolved_key = self.class.resolve_hash_key(node, requested_key_path, equivalent_lookup, separator: separator)
|
|
154
|
+
next if resolved_key.nil?
|
|
155
|
+
|
|
156
|
+
next_nodes << self.class.read_hash(node, resolved_key)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
nodes = if segment_index == segments.length - 1
|
|
160
|
+
next_nodes.compact
|
|
161
|
+
else
|
|
162
|
+
next_nodes.flatten(1).compact
|
|
163
|
+
end
|
|
164
|
+
break if nodes.empty?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
return nil if nodes.empty?
|
|
168
|
+
return nodes.first if nodes.length == 1
|
|
169
|
+
|
|
170
|
+
nodes
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Applies the configured array traversal mode to one intermediate array.
|
|
174
|
+
def descend_array(array, arrays)
|
|
175
|
+
case arrays
|
|
176
|
+
when :first
|
|
177
|
+
array.empty? ? [] : [array.first]
|
|
178
|
+
else
|
|
179
|
+
array
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Normalises one separator override.
|
|
184
|
+
def normalise_separator(raw_separator)
|
|
185
|
+
separator = raw_separator.to_s
|
|
186
|
+
separator.empty? ? DEFAULT_SEPARATOR : separator
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Normalises one configured array traversal mode.
|
|
190
|
+
def normalise_array_mode(raw_arrays)
|
|
191
|
+
mode = raw_arrays.to_s.strip.downcase
|
|
192
|
+
mode = DEFAULT_ARRAYS.to_s if mode.empty?
|
|
193
|
+
|
|
194
|
+
return :expand if %w[expand all].include?(mode)
|
|
195
|
+
return :first if mode == 'first'
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "Unsupported array traversal mode '#{raw_arrays}'."
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Accepts only hash lookups for prebuilt equivalent-key data.
|
|
201
|
+
def normalise_equivalent_lookup(raw_lookup)
|
|
202
|
+
raw_lookup.is_a?(Hash) ? raw_lookup : {}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Resolves one optional lookup override against the instance default.
|
|
206
|
+
def resolve_lookup(raw_equivalents, raw_equivalent_lookup)
|
|
207
|
+
return normalise_equivalent_lookup(raw_equivalent_lookup) unless raw_equivalent_lookup.equal?(UNSET)
|
|
208
|
+
return self.class.build_equivalent_lookup(raw_equivalents) unless raw_equivalents.equal?(UNSET)
|
|
209
|
+
|
|
210
|
+
@equivalent_lookup
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Deep-merges two hashes without mutating either input.
|
|
11
|
+
#
|
|
12
|
+
# Values from `right_hash` win over values from `left_hash`. Nested hashes are
|
|
13
|
+
# merged recursively, while all other values are treated as leaf replacements.
|
|
14
|
+
def hash_deep_merge(left_hash, right_hash)
|
|
15
|
+
left = stringify_hash(left_hash)
|
|
16
|
+
right = stringify_hash(right_hash)
|
|
17
|
+
|
|
18
|
+
left.each_with_object({}) do |(key, value), merged|
|
|
19
|
+
right_value = fetch_hash_value(right, key)
|
|
20
|
+
|
|
21
|
+
merged[key] = if value.is_a?(Hash) && right_value.is_a?(Hash)
|
|
22
|
+
hash_deep_merge(value, right_value)
|
|
23
|
+
elsif hash_key?(right, key)
|
|
24
|
+
right_value
|
|
25
|
+
else
|
|
26
|
+
value
|
|
27
|
+
end
|
|
28
|
+
end.tap do |merged|
|
|
29
|
+
right.each do |key, value|
|
|
30
|
+
next if merged.key?(key)
|
|
31
|
+
|
|
32
|
+
merged[key] = value
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns true when a string- or symbol-keyed hash contains the given key.
|
|
38
|
+
def hash_key?(hash, key)
|
|
39
|
+
return false unless hash.is_a?(Hash)
|
|
40
|
+
|
|
41
|
+
hash.key?(key) || hash.key?(key.to_s) || hash.key?(key.to_sym)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Reads one value from a string- or symbol-keyed hash.
|
|
45
|
+
def fetch_hash_value(hash, key)
|
|
46
|
+
return nil unless hash.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(hash, key)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns one shallow string-keyed copy of a hash.
|
|
52
|
+
def stringify_hash(hash)
|
|
53
|
+
return {} unless hash.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
hash.each_with_object({}) do |(key, value), stringified|
|
|
56
|
+
stringified[key.to_s] = value
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
|
|
9
|
+
# Stores the literal placeholder tokens used in relationship configuration.
|
|
10
|
+
module Placeholders
|
|
11
|
+
KEY = '<key>'.freeze
|
|
12
|
+
COLLECTION = '<collection>'.freeze
|
|
13
|
+
PAGE = '<page>'.freeze
|
|
14
|
+
COUNT = '<count>'.freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jekyll
|
|
4
|
+
module Plugins
|
|
5
|
+
module Relationships
|
|
6
|
+
|
|
7
|
+
module Support
|
|
8
|
+
|
|
9
|
+
# Interprets loose config/frontmatter values as arrays.
|
|
10
|
+
#
|
|
11
|
+
# Use one instance per delimiter configuration, then call `with` to derive
|
|
12
|
+
# a nearby variant without restating the full configuration.
|
|
13
|
+
class StringArray
|
|
14
|
+
|
|
15
|
+
DEFAULT_DELIMITER = ','
|
|
16
|
+
UNSET = Object.new.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :delimiter
|
|
19
|
+
|
|
20
|
+
# Builds a string-array helper for one default delimiter.
|
|
21
|
+
def initialize(delimiter: DEFAULT_DELIMITER)
|
|
22
|
+
@delimiter = self.class.normalise_delimiter(delimiter, DEFAULT_DELIMITER)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Builds a clone with selected configuration overrides.
|
|
26
|
+
def with(delimiter: UNSET)
|
|
27
|
+
self.class.new(
|
|
28
|
+
delimiter: delimiter.equal?(UNSET) ? @delimiter : delimiter
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Normalises one configured delimiter.
|
|
33
|
+
#
|
|
34
|
+
# Blank or unsupported values fall back to `default_delimiter`.
|
|
35
|
+
# `false` disables string splitting.
|
|
36
|
+
def self.normalise_delimiter(raw_delimiter, default_delimiter = DEFAULT_DELIMITER)
|
|
37
|
+
return false if raw_delimiter == false
|
|
38
|
+
return default_delimiter unless raw_delimiter.is_a?(String)
|
|
39
|
+
|
|
40
|
+
delimiter = raw_delimiter
|
|
41
|
+
return false if delimiter.strip.downcase == 'false'
|
|
42
|
+
|
|
43
|
+
delimiter.empty? ? default_delimiter : delimiter
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Interprets a scalar or array as an array with configurable string
|
|
47
|
+
# splitting depth.
|
|
48
|
+
#
|
|
49
|
+
# `split` modes:
|
|
50
|
+
# - `true` / `0`: split only a top-level string
|
|
51
|
+
# - `false`: never split strings, only wrap
|
|
52
|
+
# - positive integer: split strings found at that array depth
|
|
53
|
+
# - `-1`: split strings at any depth
|
|
54
|
+
#
|
|
55
|
+
# `flatten: true` promotes split values into the surrounding array.
|
|
56
|
+
# `flatten: false` keeps split values nested at the point they arose.
|
|
57
|
+
def interpret(value, split: true, flatten: false, delimiter: UNSET)
|
|
58
|
+
active_delimiter = effective_delimiter(delimiter)
|
|
59
|
+
active_split_depth = normalise_split_depth(split)
|
|
60
|
+
interpret_value(value, split_depth: active_split_depth, flatten: !!flatten, current_level: 0, delimiter: active_delimiter)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Interprets one value at one traversal depth.
|
|
66
|
+
def interpret_value(value, split_depth:, flatten:, current_level:, delimiter:)
|
|
67
|
+
return [] if value.nil?
|
|
68
|
+
return interpret_array(value, split_depth: split_depth, flatten: flatten, current_level: current_level, delimiter: delimiter) if value.is_a?(Array)
|
|
69
|
+
return interpret_string(value, split_depth: split_depth, current_level: current_level, delimiter: delimiter) if value.is_a?(String)
|
|
70
|
+
|
|
71
|
+
[value]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Interprets one array and recursively flattens nested array structure
|
|
75
|
+
# while respecting string-splitting depth.
|
|
76
|
+
def interpret_array(array, split_depth:, flatten:, current_level:, delimiter:)
|
|
77
|
+
array.flatten(1).compact.each_with_object([]) do |entry, interpreted|
|
|
78
|
+
if entry.is_a?(Array)
|
|
79
|
+
interpreted.concat(
|
|
80
|
+
interpret_array(
|
|
81
|
+
entry,
|
|
82
|
+
split_depth: split_depth,
|
|
83
|
+
flatten: flatten,
|
|
84
|
+
current_level: current_level + 1,
|
|
85
|
+
delimiter: delimiter
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if entry.is_a?(String)
|
|
92
|
+
string_value = interpret_string(entry, split_depth: split_depth, current_level: current_level + 1, delimiter: delimiter)
|
|
93
|
+
if should_split_string?(split_depth, current_level + 1) && !flatten && string_value.length > 1
|
|
94
|
+
interpreted << string_value
|
|
95
|
+
else
|
|
96
|
+
interpreted.concat(string_value)
|
|
97
|
+
end
|
|
98
|
+
next
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
interpreted << entry
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Interprets one string according to the configured split depth.
|
|
106
|
+
def interpret_string(value, split_depth:, current_level:, delimiter:)
|
|
107
|
+
return [value] unless should_split_string?(split_depth, current_level)
|
|
108
|
+
|
|
109
|
+
split_string(value, delimiter)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Splits one string with the active delimiter, trimming and discarding
|
|
113
|
+
# blank entries.
|
|
114
|
+
def split_string(value, delimiter)
|
|
115
|
+
if delimiter == false
|
|
116
|
+
entry = value.to_s.strip
|
|
117
|
+
return [] if entry.empty?
|
|
118
|
+
|
|
119
|
+
return [entry]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
split_pattern = Regexp.new(Regexp.escape(delimiter.to_s))
|
|
123
|
+
value.to_s.split(split_pattern, -1).map(&:strip).reject(&:empty?)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resolves whether strings should be split at the current array depth.
|
|
127
|
+
def should_split_string?(split_depth, current_level)
|
|
128
|
+
return false if split_depth == false
|
|
129
|
+
return true if split_depth == -1
|
|
130
|
+
|
|
131
|
+
current_level == split_depth
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Normalises the configured split depth.
|
|
135
|
+
def normalise_split_depth(raw_split)
|
|
136
|
+
return false if raw_split == false
|
|
137
|
+
return 0 if raw_split == true
|
|
138
|
+
return raw_split if raw_split.is_a?(Integer)
|
|
139
|
+
|
|
140
|
+
return raw_split.to_i if raw_split.is_a?(String) && raw_split.strip.match?(/\A-?\d+\z/)
|
|
141
|
+
|
|
142
|
+
raise ArgumentError, "Unsupported string-array split depth '#{raw_split}'."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Resolves one optional delimiter override against the instance
|
|
146
|
+
# default.
|
|
147
|
+
def effective_delimiter(raw_override)
|
|
148
|
+
return @delimiter if raw_override.equal?(UNSET)
|
|
149
|
+
|
|
150
|
+
self.class.normalise_delimiter(raw_override, @delimiter)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|