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,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+
6
+ module Relationships
7
+ module References
8
+
9
+ # Parses loose reference values and builds canonical relationship hashes.
10
+ #
11
+ # The template is configured from `relationships.references` and controls which
12
+ # property names hold the key, collection, page, and optional count values in
13
+ # output hashes.
14
+ class Template
15
+
16
+ # Represents one parsed reference before or after it is resolved.
17
+ #
18
+ # The `metadata` hash contains any non-reserved properties found on an
19
+ # existing reference hash.
20
+ class ParsedReference
21
+ attr_reader :key, :collection, :page, :count, :metadata, :original_value
22
+
23
+ # Captures the parsed reference fields in one immutable object.
24
+ def initialize(key:, collection:, page:, count:, metadata:, original_value:)
25
+ @key = key
26
+ @collection = collection
27
+ @page = page
28
+ @count = count
29
+ @metadata = metadata
30
+ @original_value = original_value
31
+ end
32
+
33
+ # Returns true when the parsed reference already points at a document.
34
+ def document?
35
+ @page.is_a?(Jekyll::Document)
36
+ end
37
+ end
38
+
39
+ attr_reader :key_property, :collection_property, :page_property, :count_property
40
+
41
+ # Builds the reference template from config and duplicate-handling settings.
42
+ def initialize(config:, count_enabled:)
43
+ @config = stringify_hash(config || {})
44
+ @count_enabled = !!count_enabled
45
+ @key_property = nil
46
+ @collection_property = nil
47
+ @page_property = nil
48
+ @count_property = nil
49
+
50
+ parse_config!
51
+ end
52
+
53
+ # Parses one raw reference value from frontmatter or resolver input.
54
+ def parse(value)
55
+ case value
56
+ when String
57
+ key = value.to_s.strip
58
+ return nil if key.empty?
59
+
60
+ ParsedReference.new(
61
+ key: key,
62
+ collection: nil,
63
+ page: nil,
64
+ count: 1,
65
+ metadata: {},
66
+ original_value: value
67
+ )
68
+ when Hash
69
+ parse_hash_reference(value)
70
+ when Jekyll::Document
71
+ ParsedReference.new(
72
+ key: nil,
73
+ collection: nil,
74
+ page: value,
75
+ count: 1,
76
+ metadata: {},
77
+ original_value: value
78
+ )
79
+ else
80
+ raise ConfigurationError, "Unsupported reference value `#{value.inspect}`."
81
+ end
82
+ end
83
+
84
+ # Builds one output reference hash for a resolved document.
85
+ def build(document:, key:, metadata: nil, count: 1, include_count: @count_enabled)
86
+ hash = {}
87
+ hash[@key_property] = key
88
+ hash[@collection_property] = document.collection.label if @collection_property
89
+ hash[@page_property] = document if @page_property
90
+ hash[@count_property] = normalise_count(count) if include_count && @count_property
91
+
92
+ filter_metadata(metadata).each do |property, value|
93
+ hash[property] = value
94
+ end
95
+
96
+ hash
97
+ end
98
+
99
+ # Returns the configured property names that are reserved for the engine.
100
+ def reserved_properties
101
+ [@key_property, @collection_property, @page_property, @count_property].compact
102
+ end
103
+
104
+ # Returns the input properties that should never survive into free-form metadata.
105
+ #
106
+ # `count` is always treated as reserved input so resolver metadata cannot
107
+ # smuggle explicit multiplicities into modes that do not support them.
108
+ def reserved_input_properties
109
+ (reserved_properties + ['count']).uniq
110
+ end
111
+
112
+ private
113
+
114
+ # Validates and captures the configured placeholder mappings.
115
+ def parse_config!
116
+ @config.each do |property, value|
117
+ case value.to_s
118
+ when Jekyll::Plugins::Relationships::Support::Placeholders::KEY
119
+ raise ConfigurationError, 'Reference config can only define one <key> property.' if @key_property
120
+ @key_property = property
121
+ when Jekyll::Plugins::Relationships::Support::Placeholders::COLLECTION
122
+ raise ConfigurationError, 'Reference config can only define one <collection> property.' if @collection_property
123
+ @collection_property = property
124
+ when Jekyll::Plugins::Relationships::Support::Placeholders::PAGE
125
+ raise ConfigurationError, 'Reference config can only define one <page> property.' if @page_property
126
+ @page_property = property
127
+ when Jekyll::Plugins::Relationships::Support::Placeholders::COUNT
128
+ raise ConfigurationError, 'Reference config can only define one <count> property.' if @count_property
129
+ @count_property = property
130
+ else
131
+ raise ConfigurationError, "Unsupported reference template value `#{value.inspect}`. Only placeholder keywords are supported."
132
+ end
133
+ end
134
+
135
+ raise ConfigurationError, 'Reference config must define exactly one <key> property.' unless @key_property
136
+ if @count_enabled && @count_property.nil?
137
+ raise ConfigurationError, 'Reference config must define exactly one <count> property when `relationships.multiple` is `count`.'
138
+ end
139
+ end
140
+
141
+ # Parses one hash reference according to the configured property names.
142
+ def parse_hash_reference(hash)
143
+ key = fetch_hash_value(hash, @key_property)
144
+ collection = @collection_property ? fetch_hash_value(hash, @collection_property) : nil
145
+ page = @page_property ? fetch_hash_value(hash, @page_property) : nil
146
+
147
+ if key.nil? && !page.is_a?(Jekyll::Document)
148
+ raise ResolutionError, "Reference hash `#{hash.inspect}` does not include the configured key property `#{@key_property}`."
149
+ end
150
+
151
+ ParsedReference.new(
152
+ key: key,
153
+ collection: collection,
154
+ page: page,
155
+ count: parsed_count(hash),
156
+ metadata: filter_metadata(hash),
157
+ original_value: hash
158
+ )
159
+ end
160
+
161
+ # Returns the parsed count value for one reference hash.
162
+ def parsed_count(hash)
163
+ return 1 unless @count_enabled
164
+ return 1 unless @count_property
165
+
166
+ raw_count = fetch_hash_value(hash, @count_property)
167
+ return 1 if raw_count.nil?
168
+
169
+ normalise_count(raw_count)
170
+ end
171
+
172
+ # Removes reserved engine properties from a metadata hash.
173
+ def filter_metadata(hash)
174
+ return {} unless hash.is_a?(Hash)
175
+
176
+ metadata = {}
177
+ hash.each do |property, value|
178
+ string_property = property.to_s
179
+ next if reserved_input_properties.include?(string_property)
180
+
181
+ metadata[string_property] = value
182
+ end
183
+
184
+ metadata
185
+ end
186
+
187
+ # Reads one property from a string- or symbol-keyed hash.
188
+ def fetch_hash_value(hash, property)
189
+ return nil unless property
190
+
191
+ Jekyll::Plugins::Relationships::Support::FrontmatterPath.read_hash(hash, property)
192
+ end
193
+
194
+ # Converts one hash to a shallow string-keyed copy.
195
+ def stringify_hash(hash)
196
+ return {} unless hash.is_a?(Hash)
197
+
198
+ hash.each_with_object({}) do |(key, value), stringified|
199
+ stringified[key.to_s] = value
200
+ end
201
+ end
202
+
203
+ # Validates and normalises one reference count.
204
+ def normalise_count(value)
205
+ integer_count = if value.is_a?(Integer)
206
+ value
207
+ elsif value.is_a?(String) && value.strip.match?(/\A\d+\z/)
208
+ value.to_i
209
+ else
210
+ raise ResolutionError, "Relationship count `#{value.inspect}` must be a positive integer."
211
+ end
212
+ raise ResolutionError, "Relationship count `#{value.inspect}` must be a positive integer." if integer_count < 1
213
+
214
+ integer_count
215
+ end
216
+ end
217
+
218
+ end
219
+ end
220
+
221
+ end
222
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+
6
+ module Relationships
7
+ module Resolvers
8
+
9
+ # Base class for custom relationship resolvers.
10
+ #
11
+ # Subclass this in `_plugins`, declare `from` and `to`, then implement
12
+ # `resolve` to add or remove links while the engine is resolving one concrete
13
+ # document-to-collection pair.
14
+ class Base
15
+
16
+ @registered_subclasses = []
17
+
18
+ class << self
19
+ attr_reader :from_definition, :to_definition
20
+
21
+ # Tracks subclasses while their class bodies are still being declared.
22
+ def inherited(subclass)
23
+ Base.remove_registered_subclass(subclass: subclass)
24
+ super
25
+ end
26
+
27
+ # Declares or reads the resolver's `from` selector.
28
+ def from(value = nil)
29
+ unless value.nil?
30
+ @from_definition = value
31
+ Base.refresh_registered_subclass(subclass: self)
32
+ end
33
+ @from_definition
34
+ end
35
+
36
+ # Declares or reads the resolver's `to` selector.
37
+ def to(value = nil)
38
+ unless value.nil?
39
+ @to_definition = value
40
+ Base.refresh_registered_subclass(subclass: self)
41
+ end
42
+ @to_definition
43
+ end
44
+
45
+ # Returns every resolver subclass whose `from` and `to` selectors are complete.
46
+ def registered_subclasses
47
+ @registered_subclasses ||= []
48
+ end
49
+
50
+ # Synchronises one resolver subclass with the shared registry after class-level declarations change.
51
+ def refresh_registered_subclass(subclass:)
52
+ remove_registered_subclass(subclass: subclass)
53
+ return if subclass == Base
54
+ return if subclass.from_definition.nil? || subclass.to_definition.nil?
55
+
56
+ registered_subclasses << subclass
57
+ end
58
+
59
+ # Removes one resolver subclass from the shared registry while declaration is incomplete.
60
+ def remove_registered_subclass(subclass:)
61
+ registered_subclasses.delete(subclass)
62
+ end
63
+ end
64
+
65
+ # Builds one resolver instance for one resolving relationship state.
66
+ def initialize(engine:, state:)
67
+ @engine = engine
68
+ @state = state
69
+ @document = state.document
70
+ @key = @engine.registry.key_for(@document, primary_path: state.definition.primary_path)
71
+ @from = state.definition.from_collection
72
+ @to = state.definition.to_collection
73
+ @site = @engine.site
74
+ end
75
+
76
+ # Override this in subclasses to change relationship output.
77
+ def resolve
78
+ raise NotImplementedError, "#{self.class} must implement `resolve`."
79
+ end
80
+
81
+ # Returns current relationships for one document and target collection.
82
+ def relationships(reference = nil, to: nil, from: nil)
83
+ target_collection = to || @to
84
+ document = resolve_helper_document(reference, from)
85
+ result = if document == @document && target_collection == @to && @state.resolving?
86
+ @state.current_references
87
+ else
88
+ @engine.resolve_relationships(document, target_collection)
89
+ end
90
+ debug_helper('resolver_relationships', {
91
+ reference: reference,
92
+ from: from,
93
+ target_document: document,
94
+ to: target_collection,
95
+ result: result
96
+ })
97
+ result
98
+ end
99
+
100
+ # Returns the referenced document, resolving its outgoing pairs if needed.
101
+ def document(reference = nil, from: nil)
102
+ document = resolve_helper_document(reference, from)
103
+ if document == @document
104
+ debug_helper('resolver_document', {
105
+ reference: reference,
106
+ from: from,
107
+ target_document: document
108
+ })
109
+ return document
110
+ end
111
+
112
+ @engine.resolve_document(document)
113
+ debug_helper('resolver_document', {
114
+ reference: reference,
115
+ from: from,
116
+ target_document: document
117
+ })
118
+ document
119
+ end
120
+
121
+ # Returns ancestors for one document reference.
122
+ def ancestors(reference = nil, from: nil, min: 1, max: -1)
123
+ document = resolve_helper_document(reference, from)
124
+ result = @engine.tree_graph.ancestors_for(document, min: min, max: max)
125
+ debug_helper('resolver_ancestors', {
126
+ reference: reference,
127
+ from: from,
128
+ target_document: document,
129
+ min: min,
130
+ max: max,
131
+ result: result
132
+ })
133
+ result
134
+ end
135
+
136
+ # Returns parents for one document reference.
137
+ def parents(reference = nil, from: nil)
138
+ ancestors(reference, from: from, min: 1, max: 1)
139
+ end
140
+
141
+ # Returns descendants for one document reference.
142
+ def descendants(reference = nil, from: nil, min: 1, max: -1)
143
+ document = resolve_helper_document(reference, from)
144
+ result = @engine.tree_graph.descendants_for(document, min: min, max: max)
145
+ debug_helper('resolver_descendants', {
146
+ reference: reference,
147
+ from: from,
148
+ target_document: document,
149
+ min: min,
150
+ max: max,
151
+ result: result
152
+ })
153
+ result
154
+ end
155
+
156
+ # Returns children for one document reference.
157
+ def children(reference = nil, from: nil)
158
+ descendants(reference, from: from, min: 1, max: 1)
159
+ end
160
+
161
+ # Adds one relationship from the current document to the target collection.
162
+ def link(target_reference, reference: nil)
163
+ @state.link(
164
+ target_reference,
165
+ metadata: reference,
166
+ origin: "resolver #{self.class.name || self.class}"
167
+ )
168
+ end
169
+
170
+ # Removes one relationship, or all of them when no reference is given.
171
+ def unlink(reference = nil)
172
+ @state.unlink(
173
+ reference,
174
+ origin: "resolver #{self.class.name || self.class}"
175
+ )
176
+ end
177
+
178
+ private
179
+
180
+ # Resolves one resolver helper argument to a real document.
181
+ def resolve_helper_document(reference, from_collection)
182
+ return @document if reference.nil?
183
+
184
+ @engine.resolve_reference_document(
185
+ reference,
186
+ primary_path: @state.definition.primary_path,
187
+ collection_hint: from_collection
188
+ )
189
+ end
190
+
191
+ # Emits one debug line for one resolver helper call when enabled.
192
+ def debug_helper(event, details)
193
+ @engine.debug_logger.relationship_event(
194
+ document: @document,
195
+ definition: @state.definition,
196
+ area: 'resolvers',
197
+ event: event,
198
+ details: details
199
+ )
200
+ end
201
+ end
202
+
203
+ end
204
+ end
205
+
206
+ end
207
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jekyll
4
+ module Plugins
5
+
6
+ module Relationships
7
+
8
+ # Emits the default high-level summary lines for relationship processing.
9
+ #
10
+ # This logger stays separate from debug logging so the engine can always report
11
+ # the configured relationship coverage, prune removals, and total run time
12
+ # without mixing that output into the lower-level trace areas.
13
+ class RunLogger
14
+ MAX_REMOVED_FILENAME_CHARACTERS = 50
15
+ NON_BREAKING_SPACE = "\u00a0"
16
+
17
+ # Builds one summary logger for one engine run.
18
+ def initialize
19
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
20
+ end
21
+
22
+ # Logs one multi-line summary of the configured relationships.
23
+ def relationship_summary(entries:, relationship_count:)
24
+ log_lines(
25
+ header: "#{relationship_count} relationships defined.",
26
+ lines: justified_lines(entries)
27
+ )
28
+ end
29
+
30
+ # Logs one multi-line summary of the documents removed by pruning.
31
+ def pruning_summary(removed_documents_by_collection:)
32
+ total_removed = removed_documents_by_collection.values.flatten.length
33
+ entries = removed_documents_by_collection.keys.sort.map do |collection|
34
+ documents = removed_documents_by_collection.fetch(collection)
35
+ {
36
+ label: "#{collection}:",
37
+ details: "#{documents.length} removed (#{removed_filenames(documents)})"
38
+ }
39
+ end
40
+ log_lines(
41
+ header: "Removed #{total_removed} items because of pruning rules.",
42
+ lines: justified_lines(entries)
43
+ )
44
+ end
45
+
46
+ # Logs the total elapsed time for relationship processing.
47
+ def finish!
48
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at
49
+ Jekyll.logger.info('Relationships:', format('Done in %.2f seconds.', elapsed))
50
+ end
51
+
52
+ private
53
+
54
+ # Writes one heading plus a tree-shaped list of lines.
55
+ def log_lines(header:, lines:)
56
+ Jekyll.logger.info('Relationships:', header)
57
+ lines.each_with_index do |line, index|
58
+ branch = index == lines.length - 1 ? '└─' : '├─'
59
+ Jekyll.logger.info('Relationships:', "#{branch} #{line}")
60
+ end
61
+ end
62
+
63
+ # Builds one truncated filename list for the removed-documents summary.
64
+ def removed_filenames(documents)
65
+ filenames = Array(documents).map do |document|
66
+ File.basename(document.relative_path)
67
+ end.sort
68
+ return '' if filenames.empty?
69
+
70
+ included_names = []
71
+ character_total = 0
72
+
73
+ filenames.each do |filename|
74
+ next_total = character_total + filename.length
75
+ break if !included_names.empty? && next_total > MAX_REMOVED_FILENAME_CHARACTERS
76
+
77
+ included_names << filename
78
+ character_total = next_total
79
+ end
80
+
81
+ text = included_names.join(', ')
82
+ return text if included_names.length == filenames.length
83
+
84
+ "#{text}, ..."
85
+ end
86
+
87
+ # Aligns summary details with non-breaking spaces so Jekyll preserves padding.
88
+ def justified_lines(entries)
89
+ maximum_label_length = Array(entries).map { |entry| entry.fetch(:label).length }.max || 0
90
+ Array(entries).map do |entry|
91
+ label = entry.fetch(:label)
92
+ padding_width = [maximum_label_length - label.length, 0].max + 1
93
+ "#{label}#{NON_BREAKING_SPACE * padding_width}#{entry.fetch(:details)}"
94
+ end
95
+ end
96
+ end
97
+
98
+ end
99
+
100
+ end
101
+ end