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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jekyll-relationships/configuration/debug_setting.rb +72 -12
  3. data/lib/jekyll-relationships/configuration/defaults.rb +3 -1
  4. data/lib/jekyll-relationships/configuration/frontmatter.rb +28 -1
  5. data/lib/jekyll-relationships/configuration/parser.rb +20 -3
  6. data/lib/jekyll-relationships/configuration/prune_rule_settings.rb +33 -5
  7. data/lib/jekyll-relationships/configuration/tree_frontmatter.rb +8 -2
  8. data/lib/jekyll-relationships/configuration.rb +43 -8
  9. data/lib/jekyll-relationships/debug_logger.rb +94 -3
  10. data/lib/jekyll-relationships/definitions/normal_relationship.rb +13 -2
  11. data/lib/jekyll-relationships/definitions/prune_rule.rb +19 -3
  12. data/lib/jekyll-relationships/definitions/tree_relationship.rb +8 -2
  13. data/lib/jekyll-relationships/documents/registry.rb +182 -33
  14. data/lib/jekyll-relationships/engine/normal_seed.rb +222 -0
  15. data/lib/jekyll-relationships/engine/persisted_links.rb +365 -0
  16. data/lib/jekyll-relationships/engine/raw_path_state.rb +46 -116
  17. data/lib/jekyll-relationships/engine/relationship_state.rb +67 -56
  18. data/lib/jekyll-relationships/engine/session.rb +159 -19
  19. data/lib/jekyll-relationships/engine/write_back.rb +3 -42
  20. data/lib/jekyll-relationships/engine.rb +168 -46
  21. data/lib/jekyll-relationships/pruning/rule_pruner.rb +7 -7
  22. data/lib/jekyll-relationships/pruning/tree_phase.rb +128 -124
  23. data/lib/jekyll-relationships/pruning/tree_provenance.rb +1 -1
  24. data/lib/jekyll-relationships/references/accumulator.rb +87 -9
  25. data/lib/jekyll-relationships/references/template.rb +46 -11
  26. data/lib/jekyll-relationships/resolvers/base.rb +59 -2
  27. data/lib/jekyll-relationships/support/frontmatter_matcher.rb +76 -0
  28. data/lib/jekyll-relationships/support/placeholders.rb +1 -0
  29. data/lib/jekyll-relationships/trees/edge_builder.rb +43 -11
  30. data/lib/jekyll-relationships/trees/graph.rb +135 -17
  31. data/lib/jekyll-relationships/trees/root_distances.rb +44 -0
  32. data/lib/jekyll-relationships/version.rb +1 -1
  33. data/lib/jekyll-relationships.rb +1 -0
  34. data/readme.md +185 -26
  35. metadata +6 -2
@@ -20,7 +20,7 @@ class Engine
20
20
  @document = document
21
21
  @definition = definition
22
22
  @status = :unseen
23
- @raw_seeded = false
23
+ @seeded = false
24
24
  @links = Jekyll::Plugins::Relationships::References::Accumulator.new(
25
25
  reference_template: @engine.configuration.reference_template,
26
26
  multiple_settings: @engine.configuration.multiple_settings
@@ -37,6 +37,16 @@ class Engine
37
37
  @status == :resolved
38
38
  end
39
39
 
40
+ # Seeds the state from the normal seed graph once, without running resolvers.
41
+ def ensure_seeded!
42
+ seed_initial_links!
43
+ end
44
+
45
+ # Returns true when one document is active in this session.
46
+ def active_document?(document = @document)
47
+ @engine.active_document?(document)
48
+ end
49
+
40
50
  # Resolves the state unless it has already been handled.
41
51
  def resolve!
42
52
  return if resolved?
@@ -50,8 +60,10 @@ class Engine
50
60
  bidirectional: @definition.bidirectional,
51
61
  resolvers: @definition.resolver_classes.map { |resolver_class| @engine.debug_logger.resolver_name(resolver_class) }
52
62
  })
53
- seed_raw_links!
63
+ ensure_seeded!
54
64
  run_resolvers!
65
+ @engine.reapply_persisted_links(state: self)
66
+ @engine.refresh_persisted_positions(state: self)
55
67
  @status = :resolved
56
68
  debug('resolution', 'resolve_finish', {
57
69
  references: current_references
@@ -59,10 +71,13 @@ class Engine
59
71
  end
60
72
 
61
73
  # Adds one link to the state and mirrors it when required.
62
- def link(reference, metadata: nil, count: 1, reflect: true, origin: 'resolver')
74
+ def link(reference, metadata: nil, count: 1, reflect: true, origin: 'resolver', persist: false)
63
75
  target_document = @engine.resolve_reference_document(
64
76
  reference,
65
77
  primary_path: @definition.primary_path,
78
+ scope_fields: @definition.scope_fields,
79
+ referring_document: @document,
80
+ relationship: relationship_description,
66
81
  collection_hint: @definition.to_collection
67
82
  )
68
83
  unless @engine.active_document?(target_document)
@@ -74,35 +89,50 @@ class Engine
74
89
  count: count,
75
90
  metadata: sanitised_metadata(metadata)
76
91
  })
77
- return
92
+ return {
93
+ action: :inactive,
94
+ entry: nil,
95
+ target_document: target_document
96
+ }
78
97
  end
79
98
  unless target_document.collection.label == @definition.to_collection
80
99
  raise ResolutionError, "Resolver attempted to link `#{target_document.relative_path}` outside the allowed target collection `#{@definition.to_collection}`."
81
100
  end
82
101
 
83
- action = @links.add_result(
102
+ result = @links.add_detailed_result(
84
103
  document: target_document,
85
104
  key: @engine.registry.key_for(target_document, primary_path: @definition.primary_path),
105
+ scope: @engine.registry.scope_for(target_document, scope_fields: @definition.scope_fields, relationship: relationship_description),
86
106
  metadata: sanitised_metadata(metadata),
87
107
  count: count
88
108
  )
89
109
  debug('mutations', 'link', {
90
- action: action,
110
+ action: result.fetch(:action),
91
111
  origin: origin,
92
112
  target: target_document,
93
113
  target_key: @engine.registry.key_for(target_document, primary_path: @definition.primary_path),
94
114
  count: count,
95
115
  metadata: sanitised_metadata(metadata)
96
116
  })
97
- return if action == :ignored
98
- return unless reflect && @definition.bidirectional
117
+ if persist && result.fetch(:action) != :ignored
118
+ @engine.persist_link(
119
+ source_state: self,
120
+ target_document: target_document,
121
+ metadata: metadata,
122
+ count: count
123
+ )
124
+ end
125
+ if reflect && @definition.bidirectional && result.fetch(:action) != :ignored
126
+ @engine.mirror_add(source_state: self, target_document: target_document, metadata: metadata, count: count)
127
+ end
99
128
 
100
- @engine.mirror_add(source_state: self, target_document: target_document, metadata: metadata, count: count)
129
+ result.merge(target_document: target_document)
101
130
  end
102
131
 
103
132
  # Removes one link, or every link when no reference is given.
104
- def unlink(reference = nil, reflect: true, origin: 'resolver')
133
+ def unlink(reference = nil, reflect: true, origin: 'resolver', clear_persisted: true)
105
134
  if reference.nil?
135
+ @engine.clear_all_persisted_links(source_state: self) if clear_persisted
106
136
  if current_link_entries.empty?
107
137
  debug('mutations', 'unlink_all', {
108
138
  action: :missing,
@@ -119,8 +149,12 @@ class Engine
119
149
  target_document = @engine.resolve_reference_document(
120
150
  reference,
121
151
  primary_path: @definition.primary_path,
152
+ scope_fields: @definition.scope_fields,
153
+ referring_document: @document,
154
+ relationship: relationship_description,
122
155
  collection_hint: @definition.to_collection
123
156
  )
157
+ @engine.clear_persisted_link(source_state: self, target_document: target_document) if clear_persisted
124
158
  remove_document(document: target_document, reflect: reflect, origin: origin)
125
159
  end
126
160
 
@@ -134,56 +168,28 @@ class Engine
134
168
  @links.encounter_entries
135
169
  end
136
170
 
137
- private
171
+ # Repositions reinserted persisted entries after they have been added.
172
+ def reposition_entries!(reinsertions:, base_count:)
173
+ @links.reposition_entries!(reinsertions: reinsertions, base_count: base_count)
174
+ end
138
175
 
139
- # Seeds the state from raw frontmatter once.
140
- def seed_raw_links!
141
- return unless @definition.reads_frontmatter
142
- return if @raw_seeded
143
-
144
- @definition.foreign_paths.each do |path|
145
- raw_state = @engine.raw_path_state(@document, path)
146
- debug('upgrading', 'raw_path', {
147
- path: path,
148
- present: raw_state.present?,
149
- value: raw_state.raw_value
150
- })
151
- next unless raw_state.present?
176
+ private
152
177
 
153
- resolved_entries = raw_state.resolved_entries_for(
154
- primary_path: @definition.primary_path,
155
- registry: @engine.registry,
156
- active_document_checker: proc { |resolved_document| @engine.active_document?(resolved_document) }
178
+ # Seeds the state from the source-derived normal seed graph.
179
+ def seed_initial_links!
180
+ return if @seeded
181
+
182
+ @engine.seed_entries_for(@document, @definition.to_collection).each do |entry|
183
+ link(
184
+ entry.fetch(:document),
185
+ metadata: entry.fetch(:metadata),
186
+ count: entry.fetch(:count),
187
+ reflect: false,
188
+ origin: 'seed graph',
189
+ persist: false
157
190
  )
158
- debug('upgrading', 'raw_path_resolved', {
159
- path: path,
160
- entries: resolved_entries.compact
161
- })
162
-
163
- resolved_entries.each do |entry|
164
- next unless entry
165
- if entry.fetch(:document).collection.label != @definition.to_collection
166
- debug('upgrading', 'raw_path_skipped', {
167
- path: path,
168
- target: entry.fetch(:document),
169
- target_key: entry.fetch(:key),
170
- actual_collection: entry.fetch(:document).collection.label,
171
- expected_collection: @definition.to_collection
172
- })
173
- next
174
- end
175
-
176
- link(
177
- entry.fetch(:document),
178
- metadata: entry.fetch(:metadata),
179
- count: entry.fetch(:count),
180
- reflect: @definition.bidirectional,
181
- origin: "frontmatter #{path}"
182
- )
183
- end
184
191
  end
185
-
186
- @raw_seeded = true
192
+ @seeded = true
187
193
  end
188
194
 
189
195
  # Instantiates and runs every configured resolver class.
@@ -232,6 +238,11 @@ class Engine
232
238
  "`#{@document.relative_path}` (#{@definition.from_collection} -> #{@definition.to_collection})"
233
239
  end
234
240
 
241
+ # Returns the concrete relationship label used in validation diagnostics.
242
+ def relationship_description
243
+ "#{@definition.from_collection} -> #{@definition.to_collection}"
244
+ end
245
+
235
246
  # Emits one debug event for this state when the definition enables it.
236
247
  def debug(area, event, details)
237
248
  @engine.debug_logger.relationship_event(
@@ -11,12 +11,13 @@ class Engine
11
11
  #
12
12
  # The main engine creates a fresh session for each rebuild round so raw-path
13
13
  # caches, resolver state, and relationship accumulators never outlive the
14
- # graph they were built against.
14
+ # graph they were built against. Every round reseeds from the current source
15
+ # graph plus any explicit persisted-link overlay.
15
16
  class Session
16
17
  attr_reader :site, :configuration, :registry, :tree_graph, :data_path, :debug_logger
17
18
 
18
19
  # Builds one new resolution session.
19
- def initialize(engine:, active_document_ids:, tree_graph:)
20
+ def initialize(engine:, active_document_ids:, tree_graph:, normal_seed:, persisted_links:)
20
21
  @engine = engine
21
22
  @site = engine.site
22
23
  @configuration = engine.configuration
@@ -24,6 +25,9 @@ class Engine
24
25
  @tree_graph = tree_graph
25
26
  @data_path = engine.data_path
26
27
  @debug_logger = engine.debug_logger
28
+ @normal_seed = normal_seed
29
+ @persisted_links = persisted_links
30
+ @persisted_links.start_round!
27
31
  @active_document_ids = active_document_ids.each_with_object({}) do |document_id, active_ids|
28
32
  active_ids[document_id] = true
29
33
  end
@@ -31,6 +35,8 @@ class Engine
31
35
  @raw_path_states = {}
32
36
  @relationship_states = {}
33
37
  @document_resolution_states = {}
38
+ @initial_links_seeded = false
39
+ @defer_bidirectional_mirror_resolvers = false
34
40
  end
35
41
 
36
42
  # Returns true when the document is active in this session.
@@ -52,8 +58,7 @@ class Engine
52
58
  path: path,
53
59
  data_path: @data_path,
54
60
  string_array: @string_array,
55
- reference_template: @configuration.reference_template,
56
- multiple_settings: @configuration.multiple_settings
61
+ reference_template: @configuration.reference_template
57
62
  )
58
63
  end
59
64
 
@@ -66,6 +71,9 @@ class Engine
66
71
  raise ResolutionError, "No relationship is defined from collection `#{document.collection.label}` to `#{to_collection}`."
67
72
  end
68
73
 
74
+ ensure_initial_links_seeded!
75
+ return state.current_references if defer_bidirectional_mirror_resolver_for?(state)
76
+
69
77
  resolve_state(state)
70
78
  state.current_references
71
79
  end
@@ -89,19 +97,86 @@ class Engine
89
97
  end
90
98
 
91
99
  # Resolves one helper or resolver reference to a document, even if inactive.
92
- def resolve_reference_document(reference, primary_path:, collection_hint: nil)
93
- return reference if reference.is_a?(Jekyll::Document)
100
+ def resolve_reference_document(reference, primary_path:, scope_fields: [], referring_document: nil, relationship: nil, collection_hint: nil)
101
+ if reference.is_a?(Jekyll::Document)
102
+ @registry.scope_for(reference, scope_fields: scope_fields, relationship: relationship)
103
+ return reference
104
+ end
94
105
 
95
- parsed_reference = @configuration.reference_template.parse(reference)
96
- return parsed_reference.page if parsed_reference.document?
106
+ parsed_reference = @configuration.reference_template.parse(
107
+ reference,
108
+ context: reference_context(relationship: relationship, document: referring_document)
109
+ )
110
+ effective_scope = @registry.effective_scope_for(
111
+ reference_scope: parsed_reference.scope,
112
+ referring_document: referring_document,
113
+ scope_fields: scope_fields,
114
+ relationship: relationship
115
+ )
116
+ if parsed_reference.document?
117
+ @registry.validate_scope_match!(
118
+ document: parsed_reference.page,
119
+ scope: effective_scope,
120
+ scope_fields: scope_fields,
121
+ relationship: relationship,
122
+ referring_document: referring_document
123
+ )
124
+ return parsed_reference.page
125
+ end
97
126
 
98
127
  @registry.lookup(
99
128
  key: parsed_reference.key,
100
129
  primary_path: primary_path,
101
- collection: collection_hint || (parsed_reference.collection.nil? ? nil : parsed_reference.collection.to_s)
130
+ collection: collection_hint || (parsed_reference.collection.nil? ? nil : parsed_reference.collection.to_s),
131
+ scope_fields: scope_fields,
132
+ scope: effective_scope,
133
+ relationship: relationship,
134
+ referring_document: referring_document
135
+ )
136
+ end
137
+
138
+ # Returns the source-derived seed entries for one concrete pair.
139
+ def seed_entries_for(document, to_collection)
140
+ return [] unless active_document?(document)
141
+
142
+ @normal_seed.entries_for(document, to_collection).select do |entry|
143
+ active_document?(entry.fetch(:document))
144
+ end
145
+ end
146
+
147
+ # Records one persisted resolver link so it can survive later rounds.
148
+ def persist_link(source_state:, target_document:, metadata:, count:)
149
+ @persisted_links.persist_link(
150
+ source_state: source_state,
151
+ target_document: target_document,
152
+ metadata: metadata,
153
+ count: count
102
154
  )
103
155
  end
104
156
 
157
+ # Removes one persisted resolver link.
158
+ def clear_persisted_link(source_state:, target_document:)
159
+ @persisted_links.clear_link(
160
+ source_state: source_state,
161
+ target_document: target_document
162
+ )
163
+ end
164
+
165
+ # Removes every persisted resolver link on one state.
166
+ def clear_all_persisted_links(source_state:)
167
+ @persisted_links.clear_all(source_state: source_state)
168
+ end
169
+
170
+ # Reapplies persisted links only when resolvers did not recreate them.
171
+ def reapply_persisted_links(state:)
172
+ @persisted_links.reapply_missing_links(state: state)
173
+ end
174
+
175
+ # Refreshes remembered persisted-link positions from the final current ordering.
176
+ def refresh_persisted_positions(state:)
177
+ @persisted_links.refresh_positions(state: state)
178
+ end
179
+
105
180
  # Mirrors one bidirectional add into the reverse state.
106
181
  def mirror_add(source_state:, target_document:, metadata:, count:)
107
182
  return unless active_document?(target_document)
@@ -151,16 +226,12 @@ class Engine
151
226
 
152
227
  # Resolves every configured normal relationship state once.
153
228
  def resolve_all_relationships!
154
- @configuration.collections.each do |collection|
155
- definitions = @configuration.normal_relationships_for(collection)
156
- next if definitions.empty?
157
-
158
- documents_for(collection).each do |document|
159
- definitions.each do |definition|
160
- resolve_relationships(document, definition.to_collection)
161
- end
162
- end
163
- end
229
+ @defer_bidirectional_mirror_resolvers = true
230
+ resolve_all_relationships_pass!
231
+ @defer_bidirectional_mirror_resolvers = false
232
+ resolve_deferred_bidirectional_mirror_resolvers!
233
+ ensure
234
+ @defer_bidirectional_mirror_resolvers = false
164
235
  end
165
236
 
166
237
  # Writes the current session's resolved relationships back to frontmatter.
@@ -170,6 +241,33 @@ class Engine
170
241
 
171
242
  private
172
243
 
244
+ # Builds consistent relationship and source-document details for reference parsing errors.
245
+ def reference_context(relationship:, document:)
246
+ parts = []
247
+ parts << "Relationship `#{relationship}`" unless relationship.nil? || relationship.to_s.empty?
248
+ parts << "reference on document `#{document.relative_path}`" if document
249
+ parts.join(' ')
250
+ end
251
+
252
+ # Seeds every normal relationship state before any resolver mutates the graph.
253
+ #
254
+ # Bidirectional removals can target reverse states that have not been
255
+ # resolved yet. Seeding the whole current graph up front ensures those
256
+ # reverse states already contain their direct seed links before any resolver
257
+ # adds or removes mirrored relationships.
258
+ def ensure_initial_links_seeded!
259
+ return if @initial_links_seeded
260
+
261
+ @configuration.collections.each do |collection|
262
+ @configuration.normal_relationships_for(collection).each do |definition|
263
+ documents_for(collection).each do |document|
264
+ relationship_state(document, definition.to_collection)&.ensure_seeded!
265
+ end
266
+ end
267
+ end
268
+ @initial_links_seeded = true
269
+ end
270
+
173
271
  # Resolves one relationship state with cycle detection.
174
272
  def resolve_state(state)
175
273
  return state if state.resolved?
@@ -178,6 +276,48 @@ class Engine
178
276
  state.resolve!
179
277
  state
180
278
  end
279
+
280
+ # Resolves every configured normal relationship state in collection and sequence order.
281
+ def resolve_all_relationships_pass!
282
+ @configuration.collections.each do |collection|
283
+ definitions = @configuration.normal_relationships_for(collection)
284
+ next if definitions.empty?
285
+
286
+ documents_for(collection).each do |document|
287
+ definitions.each do |definition|
288
+ resolve_relationships(document, definition.to_collection)
289
+ end
290
+ end
291
+ end
292
+ end
293
+
294
+ # Runs deferred resolvers for inverse bidirectional mirrors after forward resolution settles.
295
+ def resolve_deferred_bidirectional_mirror_resolvers!
296
+ @configuration.collections.each do |collection|
297
+ definitions = @configuration.normal_relationships_for(collection).select do |definition|
298
+ deferred_bidirectional_mirror_definition?(definition)
299
+ end
300
+ next if definitions.empty?
301
+
302
+ documents_for(collection).each do |document|
303
+ definitions.each do |definition|
304
+ resolve_relationships(document, definition.to_collection)
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ # Returns true when one definition should run in the deferred mirror-resolver pass.
311
+ def deferred_bidirectional_mirror_definition?(definition)
312
+ definition.bidirectional_mirror? && !definition.resolver_classes.empty?
313
+ end
314
+
315
+ # Returns true when this state should expose current links now but defer running its resolver.
316
+ def defer_bidirectional_mirror_resolver_for?(state)
317
+ return false unless @defer_bidirectional_mirror_resolvers
318
+
319
+ deferred_bidirectional_mirror_definition?(state.definition)
320
+ end
181
321
  end
182
322
  end
183
323
 
@@ -9,8 +9,8 @@ class Engine
9
9
 
10
10
  # Writes resolved normal relationships back into document frontmatter.
11
11
  #
12
- # Input paths are upgraded in place while final resolved link sets are
13
- # written to the configured output path for each relationship pair.
12
+ # Each relationship writes one final resolved link set to its configured
13
+ # output path. Any separate ingestion paths are left untouched.
14
14
  class WriteBack
15
15
  # Builds one write-back helper for the current engine instance.
16
16
  def initialize(engine:)
@@ -37,7 +37,6 @@ class Engine
37
37
  # Writes every normal relationship path for one document.
38
38
  def write_document_relationships(document:, definitions:)
39
39
  output_paths = {}
40
- input_paths = {}
41
40
 
42
41
  definitions.each do |definition|
43
42
  state = @engine.relationship_state(document, definition.to_collection)
@@ -46,13 +45,6 @@ class Engine
46
45
  output_path = definition.final_output_path
47
46
  output_paths[output_path] ||= []
48
47
  output_paths[output_path] << state
49
- definition.foreign_paths.each do |path|
50
- input_paths[path] ||= {
51
- raw_state: @engine.raw_path_state(document, path),
52
- definitions: []
53
- }
54
- input_paths[path][:definitions] << definition
55
- end
56
48
  end
57
49
 
58
50
  output_paths.each do |path, states|
@@ -74,31 +66,6 @@ class Engine
74
66
  }
75
67
  )
76
68
  end
77
-
78
- input_paths.each do |path, input_path_state|
79
- raw_state = input_path_state.fetch(:raw_state)
80
- next if output_paths.key?(path)
81
- next unless raw_state.present?
82
-
83
- raw_state.write_upgraded_input!(
84
- registry: @registry,
85
- active_document_checker: proc { |resolved_document| active_document?(resolved_document) }
86
- )
87
- value = raw_state.upgraded_raw_value(
88
- registry: @registry,
89
- active_document_checker: proc { |resolved_document| active_document?(resolved_document) }
90
- )
91
- @engine.debug_logger.document_event(
92
- document: document,
93
- definitions: input_path_state.fetch(:definitions),
94
- area: 'upgrading',
95
- event: 'upgrade_input',
96
- details: {
97
- path: path,
98
- value: value
99
- }
100
- )
101
- end
102
69
  end
103
70
 
104
71
  # Rejects any consolidated output path that expands through arrays.
@@ -123,6 +90,7 @@ class Engine
123
90
  accumulator.add(
124
91
  document: entry.fetch(:document),
125
92
  key: entry.fetch(:key),
93
+ scope: entry.fetch(:scope),
126
94
  metadata: entry.fetch(:metadata),
127
95
  count: entry.fetch(:count)
128
96
  )
@@ -138,13 +106,6 @@ class Engine
138
106
 
139
107
  @registry.documents_for(collection)
140
108
  end
141
-
142
- # Returns true when one document is active in the current engine or session.
143
- def active_document?(document)
144
- return @engine.active_document?(document) if @engine.respond_to?(:active_document?)
145
-
146
- true
147
- end
148
109
  end
149
110
  end
150
111