upkeep-rails 0.1.0

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.

Potentially problematic release.


This version of upkeep-rails might be problematic. Click here for more details.

Files changed (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +424 -0
  4. data/docs/architecture/ambient-inputs-roadmap.md +306 -0
  5. data/docs/architecture/herb-roadmap.md +324 -0
  6. data/docs/architecture/identity-and-sharing.md +187 -0
  7. data/docs/architecture/query-dependencies.md +230 -0
  8. data/docs/cost-model-roadmap.md +703 -0
  9. data/docs/guides/getting-started.md +282 -0
  10. data/docs/handoff-2026-05-15.md +230 -0
  11. data/docs/production_roadmap.md +372 -0
  12. data/docs/shared-warm-scale-roadmap.md +214 -0
  13. data/docs/single-subscriber-cold-roadmap.md +192 -0
  14. data/docs/testing.md +113 -0
  15. data/lib/generators/upkeep/install/install_generator.rb +90 -0
  16. data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
  17. data/lib/generators/upkeep/install/templates/subscription.js +107 -0
  18. data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
  19. data/lib/upkeep/active_record_query.rb +294 -0
  20. data/lib/upkeep/capture/request.rb +150 -0
  21. data/lib/upkeep/dag/subscription_shape.rb +244 -0
  22. data/lib/upkeep/dag.rb +370 -0
  23. data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
  24. data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
  25. data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
  26. data/lib/upkeep/delivery/transport.rb +194 -0
  27. data/lib/upkeep/delivery/turbo_streams.rb +275 -0
  28. data/lib/upkeep/delivery.rb +7 -0
  29. data/lib/upkeep/dependencies.rb +466 -0
  30. data/lib/upkeep/herb/developer_report.rb +116 -0
  31. data/lib/upkeep/herb/manifest_cache.rb +83 -0
  32. data/lib/upkeep/herb/manifest_diff.rb +183 -0
  33. data/lib/upkeep/herb/source_instrumenter.rb +84 -0
  34. data/lib/upkeep/herb/template_manifest.rb +377 -0
  35. data/lib/upkeep/invalidation/collection_append.rb +84 -0
  36. data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
  37. data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
  38. data/lib/upkeep/invalidation/collection_remove.rb +57 -0
  39. data/lib/upkeep/invalidation/planner.rb +341 -0
  40. data/lib/upkeep/invalidation.rb +7 -0
  41. data/lib/upkeep/rails/action_view_capture.rb +765 -0
  42. data/lib/upkeep/rails/cable/channel.rb +108 -0
  43. data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
  44. data/lib/upkeep/rails/cable.rb +4 -0
  45. data/lib/upkeep/rails/client_subscription.rb +37 -0
  46. data/lib/upkeep/rails/configuration.rb +57 -0
  47. data/lib/upkeep/rails/controller_runtime.rb +137 -0
  48. data/lib/upkeep/rails/install.rb +28 -0
  49. data/lib/upkeep/rails/railtie.rb +36 -0
  50. data/lib/upkeep/rails/replay.rb +176 -0
  51. data/lib/upkeep/rails/testing.rb +36 -0
  52. data/lib/upkeep/rails.rb +276 -0
  53. data/lib/upkeep/replay.rb +408 -0
  54. data/lib/upkeep/runtime.rb +1075 -0
  55. data/lib/upkeep/shared_streams.rb +72 -0
  56. data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
  57. data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
  58. data/lib/upkeep/subscriptions/active_registry.rb +93 -0
  59. data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
  60. data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
  61. data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
  62. data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
  63. data/lib/upkeep/subscriptions/registrar.rb +36 -0
  64. data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
  65. data/lib/upkeep/subscriptions/shape.rb +116 -0
  66. data/lib/upkeep/subscriptions/store.rb +159 -0
  67. data/lib/upkeep/subscriptions.rb +7 -0
  68. data/lib/upkeep/targeting.rb +135 -0
  69. data/lib/upkeep/version.rb +5 -0
  70. data/lib/upkeep-rails.rb +3 -0
  71. data/lib/upkeep.rb +14 -0
  72. data/upkeep-rails.gemspec +53 -0
  73. metadata +296 -0
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class ManifestDiff
8
+ PARSE_OPTIONS = TemplateManifest::DEFAULT_PARSE_OPTIONS.merge(
9
+ action_view_helpers: false,
10
+ transform_conditionals: false
11
+ ).freeze
12
+
13
+ CONTENT_ONLY_OPERATION_TYPES = %i[
14
+ attribute_added
15
+ attribute_removed
16
+ attribute_value_changed
17
+ text_changed
18
+ ].freeze
19
+
20
+ Plan = Data.define(
21
+ :path,
22
+ :action,
23
+ :reason,
24
+ :topology_changed,
25
+ :diff_identical,
26
+ :operation_types,
27
+ :operations,
28
+ :old_manifest,
29
+ :new_manifest,
30
+ :old_topology_signature,
31
+ :new_topology_signature,
32
+ :error
33
+ ) do
34
+ def gate_passed?
35
+ error.nil?
36
+ end
37
+
38
+ def to_h
39
+ {
40
+ path: path,
41
+ action: action,
42
+ reason: reason,
43
+ topology_changed: topology_changed,
44
+ diff_identical: diff_identical,
45
+ operation_types: operation_types,
46
+ operations: operations,
47
+ old_manifest_fingerprint: old_manifest&.fingerprint,
48
+ new_manifest_fingerprint: new_manifest&.fingerprint,
49
+ stable_topology: old_topology_signature == new_topology_signature,
50
+ gate_passed: gate_passed?,
51
+ error: error
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ def self.plan(path:, old_source:, new_source:, parse_options: PARSE_OPTIONS)
57
+ new(path: path, old_source: old_source, new_source: new_source, parse_options: parse_options).plan
58
+ end
59
+
60
+ def initialize(path:, old_source:, new_source:, parse_options: PARSE_OPTIONS)
61
+ @path = path
62
+ @old_source = old_source
63
+ @new_source = new_source
64
+ @parse_options = parse_options
65
+ end
66
+
67
+ def plan
68
+ old_manifest = build_manifest(old_source)
69
+ new_manifest = build_manifest(new_source)
70
+ diff_result = ::Herb.diff(old_source, new_source)
71
+ old_signature = topology_signature(old_manifest)
72
+ new_signature = topology_signature(new_manifest)
73
+ action, reason, topology_changed = classify(
74
+ diff_result: diff_result,
75
+ old_manifest: old_manifest,
76
+ new_manifest: new_manifest,
77
+ old_signature: old_signature,
78
+ new_signature: new_signature
79
+ )
80
+
81
+ Plan.new(
82
+ path: path,
83
+ action: action,
84
+ reason: reason,
85
+ topology_changed: topology_changed,
86
+ diff_identical: diff_result.identical?,
87
+ operation_types: diff_result.map { |operation| operation.type.to_s },
88
+ operations: diff_result.map { |operation| operation_payload(operation) },
89
+ old_manifest: old_manifest,
90
+ new_manifest: new_manifest,
91
+ old_topology_signature: old_signature,
92
+ new_topology_signature: new_signature,
93
+ error: nil
94
+ )
95
+ rescue StandardError => error
96
+ Plan.new(
97
+ path: path,
98
+ action: "full_rebuild",
99
+ reason: "herb_diff_failed",
100
+ topology_changed: true,
101
+ diff_identical: false,
102
+ operation_types: [],
103
+ operations: [],
104
+ old_manifest: nil,
105
+ new_manifest: nil,
106
+ old_topology_signature: nil,
107
+ new_topology_signature: nil,
108
+ error: { class: error.class.name, message: error.message }
109
+ )
110
+ end
111
+
112
+ private
113
+
114
+ attr_reader :path, :old_source, :new_source, :parse_options
115
+
116
+ def build_manifest(source)
117
+ TemplateManifest.build(path: path, source: source, parse_options: parse_options)
118
+ end
119
+
120
+ def classify(diff_result:, old_manifest:, new_manifest:, old_signature:, new_signature:)
121
+ return ["full_rebuild", "manifest_parse_failure", true] unless old_manifest.parse.fetch(:ok) && new_manifest.parse.fetch(:ok)
122
+ return ["noop", "identical_source", false] if diff_result.identical?
123
+
124
+ operation_types = diff_result.map(&:type)
125
+ stable_topology = old_signature == new_signature
126
+
127
+ if content_only?(operation_types) && stable_topology
128
+ ["refresh_manifest", "content_only_stable_topology", false]
129
+ elsif stable_topology
130
+ ["rebuild_manifest", "dynamic_template_operation", true]
131
+ else
132
+ ["rebuild_manifest", "manifest_topology_changed", true]
133
+ end
134
+ end
135
+
136
+ def content_only?(operation_types)
137
+ (operation_types - CONTENT_ONLY_OPERATION_TYPES).empty?
138
+ end
139
+
140
+ def topology_signature(manifest)
141
+ return { parse_ok: false } unless manifest.parse.fetch(:ok)
142
+
143
+ {
144
+ root_shape: manifest.root_shape.slice(:significant_children, :root_elements, :root_types, :single_root, :multi_root),
145
+ fragment_roots: fragment_root_signature(manifest),
146
+ render_sites: render_site_signature(manifest)
147
+ }
148
+ end
149
+
150
+ def fragment_root_signature(manifest)
151
+ manifest.frontend_tag_plan
152
+ .select { |tag| tag.fetch(:kind) == "fragment_root" }
153
+ .map { |tag| { tag_name: tag.fetch(:tag_name), attributes: tag.fetch(:attributes).map { |attribute| attribute.fetch(:name) } } }
154
+ end
155
+
156
+ def render_site_signature(manifest)
157
+ manifest.render_nodes.map do |render_node|
158
+ {
159
+ kind: render_node.fetch(:kind),
160
+ partial: render_node.fetch(:partial),
161
+ template_path: render_node.fetch(:template_path),
162
+ collection: render_node.fetch(:collection),
163
+ object: render_node.fetch(:object),
164
+ as: render_node.fetch(:as),
165
+ locals: render_node.fetch(:locals),
166
+ block_arguments: render_node.fetch(:block_arguments)
167
+ }
168
+ end
169
+ end
170
+
171
+ def operation_payload(operation)
172
+ {
173
+ type: operation.type.to_s,
174
+ path: operation.path,
175
+ old_node: operation.old_node&.class&.name,
176
+ new_node: operation.new_node&.class&.name,
177
+ old_index: operation.old_index,
178
+ new_index: operation.new_index
179
+ }.compact
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "template_manifest"
4
+
5
+ module Upkeep
6
+ module HerbSupport
7
+ class SourceInstrumenter
8
+ def initialize(manifest:)
9
+ @manifest = manifest
10
+ end
11
+
12
+ def instrument(source)
13
+ return source unless manifest.parse.fetch(:ok)
14
+
15
+ apply_replacements(source, replacements_for(source))
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :manifest
21
+
22
+ def replacements_for(source)
23
+ render_site_replacements + root_marker_replacements(source)
24
+ end
25
+
26
+ def render_site_replacements
27
+ manifest.render_nodes.select { |render_node| render_node.fetch(:collection) }.map do |render_node|
28
+ [
29
+ render_node.fetch(:start_offset),
30
+ render_node.fetch(:end_offset),
31
+ %(<%= render_site("#{render_node.fetch(:site_id)}", manifest_path: "#{manifest.path}", manifest_fingerprint: "#{manifest.fingerprint}") { #{render_node.fetch(:expression)} } %>)
32
+ ]
33
+ end
34
+ end
35
+
36
+ def root_marker_replacements(source)
37
+ manifest.frontend_tag_plan
38
+ .select { |entry| %w[fragment_root page_root].include?(entry.fetch(:kind)) }
39
+ .filter_map { |tag| root_marker_replacement(source, tag) }
40
+ end
41
+
42
+ def root_marker_replacement(source, tag)
43
+ offset = offset_for_location(source, tag.fetch(:location).fetch(:start))
44
+ open_tag_end = source.index(">", offset)
45
+ return unless open_tag_end
46
+
47
+ open_tag = source[offset...open_tag_end]
48
+ return if tag.fetch(:attributes).any? { |attribute| open_tag.include?(%(#{attribute.fetch(:name)}=)) }
49
+
50
+ insert_at = fragment_root_insert_offset(source, offset, tag.fetch(:tag_name))
51
+ [insert_at, insert_at, " #{attributes_source(tag.fetch(:attributes))}"]
52
+ end
53
+
54
+ def fragment_root_insert_offset(source, offset, tag_name)
55
+ match = /\A<\s*#{Regexp.escape(tag_name)}\b/i.match(source[offset..])
56
+ raise "could not locate fragment root tag for #{manifest.path}" unless match
57
+
58
+ offset + match.end(0)
59
+ end
60
+
61
+ def attributes_source(attributes)
62
+ attributes.map { |attribute| %(#{attribute.fetch(:name)}="#{attribute.fetch(:value)}") }.join(" ")
63
+ end
64
+
65
+ def apply_replacements(source, replacements)
66
+ replacements.sort_by { |start_offset, end_offset, _replacement| [-start_offset, -end_offset] }.each_with_object(source.dup) do |(start_offset, end_offset, replacement), result|
67
+ result[start_offset...end_offset] = replacement
68
+ end
69
+ end
70
+
71
+ def offset_for_location(source, position)
72
+ line_offsets(source).fetch(position.fetch(:line) - 1) + position.fetch(:column)
73
+ end
74
+
75
+ def line_offsets(source)
76
+ offsets = [0]
77
+ source.each_line(chomp: false).with_index do |line, index|
78
+ offsets[index + 1] = offsets[index] + line.bytesize
79
+ end
80
+ offsets
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "herb"
5
+
6
+ module Upkeep
7
+ module HerbSupport
8
+ class TemplateManifest
9
+ DEFAULT_PARSE_OPTIONS = {
10
+ strict: true,
11
+ track_whitespace: true,
12
+ render_nodes: true,
13
+ action_view_helpers: true,
14
+ transform_conditionals: true
15
+ }.freeze
16
+
17
+ EMPTY_ROOT_SHAPE = {
18
+ significant_children: 0,
19
+ root_elements: 0,
20
+ single_root: false,
21
+ multi_root: false
22
+ }.freeze
23
+
24
+ attr_reader :path, :parse, :root_shape, :frontend_tag_plan, :render_nodes, :helper_lowered_elements
25
+
26
+ def self.build(path:, source:, parse_options: DEFAULT_PARSE_OPTIONS)
27
+ parse_result = ::Herb.parse(source, **parse_options)
28
+ parse = parse_status(parse_result)
29
+ visitor = Visitor.new(path: path, source: source)
30
+ parse_result.value&.accept(visitor) if parse.fetch(:ok)
31
+
32
+ new(
33
+ path: path,
34
+ parse: parse,
35
+ root_shape: visitor.root_shape,
36
+ frontend_tag_plan: visitor.frontend_tag_plan,
37
+ render_nodes: visitor.render_nodes,
38
+ helper_lowered_elements: visitor.helper_lowered_elements
39
+ )
40
+ rescue StandardError => error
41
+ new(
42
+ path: path,
43
+ parse: {
44
+ ok: false,
45
+ exception: error.class.name,
46
+ message: error.message
47
+ },
48
+ root_shape: {},
49
+ frontend_tag_plan: [],
50
+ render_nodes: [],
51
+ helper_lowered_elements: []
52
+ )
53
+ end
54
+
55
+ def self.summary(manifests)
56
+ partials = manifests.select(&:partial?)
57
+
58
+ {
59
+ templates_scanned: manifests.size,
60
+ strict_parse_failures: manifests.count { |manifest| !manifest.parse.fetch(:ok) },
61
+ render_nodes: manifests.sum { |manifest| manifest.render_nodes.size },
62
+ helper_lowered_elements: manifests.sum { |manifest| manifest.helper_lowered_elements.size },
63
+ frontend_tag_targets: manifests.sum { |manifest| manifest.frontend_tag_plan.size },
64
+ page_root_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "page_root" } },
65
+ fragment_root_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "fragment_root" } },
66
+ render_site_tags: manifests.sum { |manifest| manifest.frontend_tag_plan.count { |tag| tag.fetch(:kind) == "render_site" } },
67
+ partials: partials.size,
68
+ single_root_partials: partials.count { |manifest| manifest.root_shape.fetch(:single_root, false) },
69
+ multi_root_partials: partials.count { |manifest| manifest.root_shape.fetch(:multi_root, false) }
70
+ }
71
+ end
72
+
73
+ def self.parse_status(parse_result)
74
+ errors = parse_result.errors.map { |error| error_payload(error) }
75
+ warnings = parse_result.warnings.map { |warning| error_payload(warning) }
76
+
77
+ {
78
+ ok: errors.empty?,
79
+ errors: errors,
80
+ warnings: warnings
81
+ }
82
+ end
83
+ private_class_method :parse_status
84
+
85
+ def self.error_payload(error)
86
+ {
87
+ class: error.class.name,
88
+ message: error.respond_to?(:message) ? error.message : error.inspect,
89
+ location: error.respond_to?(:location) ? location_payload(error.location) : nil
90
+ }
91
+ end
92
+ private_class_method :error_payload
93
+
94
+ def self.location_payload(location)
95
+ return nil unless location
96
+
97
+ {
98
+ start: {
99
+ line: location.start.line,
100
+ column: location.start.column
101
+ },
102
+ end: {
103
+ line: location.end.line,
104
+ column: location.end.column
105
+ }
106
+ }
107
+ end
108
+ private_class_method :location_payload
109
+
110
+ def initialize(path:, parse:, root_shape:, frontend_tag_plan:, render_nodes:, helper_lowered_elements:)
111
+ @path = path
112
+ @parse = parse
113
+ @root_shape = root_shape
114
+ @frontend_tag_plan = frontend_tag_plan
115
+ @render_nodes = render_nodes
116
+ @helper_lowered_elements = helper_lowered_elements
117
+ end
118
+
119
+ def to_h
120
+ {
121
+ path: path,
122
+ parse: parse,
123
+ root_shape: root_shape,
124
+ frontend_tag_plan: frontend_tag_plan,
125
+ render_nodes: render_nodes,
126
+ helper_lowered_elements: helper_lowered_elements
127
+ }
128
+ end
129
+
130
+ def fingerprint
131
+ @fingerprint ||= Digest::SHA256.hexdigest(to_h.inspect)[0, 16]
132
+ end
133
+
134
+ def partial?
135
+ File.basename(path).start_with?("_")
136
+ end
137
+
138
+ class Visitor < ::Herb::Visitor
139
+ attr_reader :frontend_tag_plan, :render_nodes, :helper_lowered_elements
140
+
141
+ def initialize(path:, source:)
142
+ super()
143
+ @path = path
144
+ @frontend_tag_plan = []
145
+ @render_nodes = []
146
+ @helper_lowered_elements = []
147
+ @root_shape = nil
148
+ @line_offsets = build_line_offsets(source)
149
+ end
150
+
151
+ def root_shape
152
+ @root_shape || EMPTY_ROOT_SHAPE
153
+ end
154
+
155
+ def visit_document_node(node)
156
+ significant_children = node.children.reject { |child| insignificant_document_child?(child) }
157
+ root_elements = significant_children.select { |child| html_element?(child) }
158
+
159
+ @root_shape = {
160
+ significant_children: significant_children.size,
161
+ root_elements: root_elements.size,
162
+ root_types: significant_children.map { |child| child.class.name },
163
+ single_root: significant_children.size == 1 && root_elements.size == 1,
164
+ multi_root: root_elements.size > 1
165
+ }
166
+
167
+ if root_shape.fetch(:single_root)
168
+ partial_template? ? plan_fragment_root_tag(root_elements.first) : plan_page_root_tag(root_elements.first)
169
+ end
170
+
171
+ super
172
+ end
173
+
174
+ def visit_erb_render_node(node)
175
+ keywords = node.keywords
176
+ render_node = render_node_payload(node, keywords)
177
+
178
+ @render_nodes << render_node
179
+ @frontend_tag_plan << render_site_tag(render_node)
180
+
181
+ super
182
+ end
183
+
184
+ def visit_html_element_node(node)
185
+ if node.respond_to?(:element_source) && node.element_source && node.element_source != "HTML"
186
+ @helper_lowered_elements << {
187
+ location: location_payload(node.location),
188
+ tag_name: token_value(node.tag_name),
189
+ element_source: node.element_source
190
+ }
191
+ end
192
+
193
+ super
194
+ end
195
+
196
+ private
197
+
198
+ attr_reader :path, :line_offsets
199
+
200
+ def build_line_offsets(source)
201
+ offsets = [0]
202
+ source.each_line(chomp: false).with_index do |line, index|
203
+ offsets[index + 1] = offsets[index] + line.bytesize
204
+ end
205
+ offsets
206
+ end
207
+
208
+ def offset_for(position)
209
+ line_offsets.fetch(position.line - 1) + position.column
210
+ end
211
+
212
+ def render_node_payload(node, keywords)
213
+ {
214
+ location: location_payload(node.location),
215
+ site_id: site_id("render", node.location),
216
+ expression: token_value(node.content)&.strip,
217
+ start_offset: offset_for(node.location.start),
218
+ end_offset: offset_for(node.location.end),
219
+ kind: render_kind(keywords),
220
+ partial: token_value(keywords&.partial),
221
+ template_path: token_value(keywords&.template_path),
222
+ layout: token_value(keywords&.layout),
223
+ collection: token_value(keywords&.collection),
224
+ object: token_value(keywords&.object),
225
+ as: token_value(keywords&.as_name),
226
+ locals: Array(keywords&.locals).map { |local| token_value(local.name) },
227
+ block_arguments: Array(node.block_arguments).map { |argument| token_value(argument.name) }
228
+ }
229
+ end
230
+
231
+ def plan_fragment_root_tag(root_element)
232
+ @frontend_tag_plan << {
233
+ kind: "fragment_root",
234
+ target: "root_element",
235
+ location: location_payload(root_element.location),
236
+ tag_name: token_value(root_element.tag_name),
237
+ attributes: [
238
+ {
239
+ name: "data-upkeep-frame",
240
+ value: "<%= upkeep_frame_id %>"
241
+ },
242
+ {
243
+ name: "data-upkeep-template",
244
+ value: template_id
245
+ }
246
+ ],
247
+ update_role: "morph or replace this rendered fragment when runtime observations match a committed change"
248
+ }
249
+ end
250
+
251
+ def plan_page_root_tag(root_element)
252
+ @frontend_tag_plan << {
253
+ kind: "page_root",
254
+ target: "root_element",
255
+ location: location_payload(root_element.location),
256
+ tag_name: token_value(root_element.tag_name),
257
+ attributes: [
258
+ {
259
+ name: "data-upkeep-page-frame",
260
+ value: "<%= upkeep_page_frame_id %>"
261
+ }
262
+ ],
263
+ update_role: "replace this rendered page when no narrower frame can safely cover a change"
264
+ }
265
+ end
266
+
267
+ def render_site_tag(render_node)
268
+ {
269
+ kind: "render_site",
270
+ target: render_node.fetch(:kind) == "partial" && render_node.fetch(:collection) ? "collection_region" : "opaque_render_output",
271
+ location: render_node.fetch(:location),
272
+ site_id: render_node.fetch(:site_id),
273
+ attributes: [
274
+ {
275
+ name: "data-upkeep-render-site",
276
+ value: render_node.fetch(:site_id)
277
+ }
278
+ ],
279
+ render: {
280
+ kind: render_node.fetch(:kind),
281
+ partial: render_node.fetch(:partial),
282
+ collection: render_node.fetch(:collection),
283
+ object: render_node.fetch(:object),
284
+ as: render_node.fetch(:as)
285
+ },
286
+ update_role: render_site_update_role(render_node)
287
+ }
288
+ end
289
+
290
+ def render_site_update_role(render_node)
291
+ if render_node.fetch(:kind) == "partial" && render_node.fetch(:collection)
292
+ "anchor collection membership while child partial roots carry per-record frame tags"
293
+ else
294
+ "record where ActionView runtime must confirm the rendered frame target"
295
+ end
296
+ end
297
+
298
+ def render_kind(keywords)
299
+ return "unknown" unless keywords
300
+
301
+ %i[
302
+ partial template_path layout file inline_template body plain html
303
+ renderable collection object
304
+ ].find { |name| token_value(keywords.public_send(name)) }&.to_s || "unknown"
305
+ end
306
+
307
+ def insignificant_document_child?(node)
308
+ return true if html_text?(node) && token_value(node.content).to_s.strip.empty?
309
+ return true if html_doctype?(node)
310
+ return true if erb_comment?(node)
311
+
312
+ false
313
+ end
314
+
315
+ def html_text?(node)
316
+ node.class.name == "Herb::AST::HTMLTextNode"
317
+ end
318
+
319
+ def html_element?(node)
320
+ [
321
+ "Herb::AST::HTMLElementNode",
322
+ "Herb::AST::HTMLConditionalElementNode"
323
+ ].include?(node.class.name)
324
+ end
325
+
326
+ def html_doctype?(node)
327
+ node.class.name == "Herb::AST::HTMLDoctypeNode"
328
+ end
329
+
330
+ def erb_comment?(node)
331
+ node.respond_to?(:tag_opening) && token_value(node.tag_opening).to_s.start_with?("<%#")
332
+ end
333
+
334
+ def partial_template?
335
+ File.basename(path).start_with?("_")
336
+ end
337
+
338
+ def template_id
339
+ Digest::SHA256.hexdigest(path)[0, 16]
340
+ end
341
+
342
+ def site_id(kind, location)
343
+ Digest::SHA256.hexdigest([
344
+ path,
345
+ kind,
346
+ location&.start&.line,
347
+ location&.start&.column,
348
+ location&.end&.line,
349
+ location&.end&.column
350
+ ].join(":"))[0, 16]
351
+ end
352
+
353
+ def token_value(token)
354
+ return nil unless token
355
+ return token.value if token.respond_to?(:value)
356
+
357
+ token.to_s
358
+ end
359
+
360
+ def location_payload(location)
361
+ return nil unless location
362
+
363
+ {
364
+ start: {
365
+ line: location.start.line,
366
+ column: location.start.column
367
+ },
368
+ end: {
369
+ line: location.end.line,
370
+ column: location.end.column
371
+ }
372
+ }
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end