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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +424 -0
- data/docs/architecture/ambient-inputs-roadmap.md +306 -0
- data/docs/architecture/herb-roadmap.md +324 -0
- data/docs/architecture/identity-and-sharing.md +187 -0
- data/docs/architecture/query-dependencies.md +230 -0
- data/docs/cost-model-roadmap.md +703 -0
- data/docs/guides/getting-started.md +282 -0
- data/docs/handoff-2026-05-15.md +230 -0
- data/docs/production_roadmap.md +372 -0
- data/docs/shared-warm-scale-roadmap.md +214 -0
- data/docs/single-subscriber-cold-roadmap.md +192 -0
- data/docs/testing.md +113 -0
- data/lib/generators/upkeep/install/install_generator.rb +90 -0
- data/lib/generators/upkeep/install/templates/create_upkeep_subscriptions.rb.erb +31 -0
- data/lib/generators/upkeep/install/templates/subscription.js +107 -0
- data/lib/generators/upkeep/install/templates/upkeep.rb +6 -0
- data/lib/upkeep/active_record_query.rb +294 -0
- data/lib/upkeep/capture/request.rb +150 -0
- data/lib/upkeep/dag/subscription_shape.rb +244 -0
- data/lib/upkeep/dag.rb +370 -0
- data/lib/upkeep/delivery/action_cable_adapter.rb +43 -0
- data/lib/upkeep/delivery/async_dispatcher.rb +102 -0
- data/lib/upkeep/delivery/broadcast_transport.rb +89 -0
- data/lib/upkeep/delivery/transport.rb +194 -0
- data/lib/upkeep/delivery/turbo_streams.rb +275 -0
- data/lib/upkeep/delivery.rb +7 -0
- data/lib/upkeep/dependencies.rb +466 -0
- data/lib/upkeep/herb/developer_report.rb +116 -0
- data/lib/upkeep/herb/manifest_cache.rb +83 -0
- data/lib/upkeep/herb/manifest_diff.rb +183 -0
- data/lib/upkeep/herb/source_instrumenter.rb +84 -0
- data/lib/upkeep/herb/template_manifest.rb +377 -0
- data/lib/upkeep/invalidation/collection_append.rb +84 -0
- data/lib/upkeep/invalidation/collection_member_replace.rb +78 -0
- data/lib/upkeep/invalidation/collection_prepend.rb +84 -0
- data/lib/upkeep/invalidation/collection_remove.rb +57 -0
- data/lib/upkeep/invalidation/planner.rb +341 -0
- data/lib/upkeep/invalidation.rb +7 -0
- data/lib/upkeep/rails/action_view_capture.rb +765 -0
- data/lib/upkeep/rails/cable/channel.rb +108 -0
- data/lib/upkeep/rails/cable/subscriber_identity.rb +214 -0
- data/lib/upkeep/rails/cable.rb +4 -0
- data/lib/upkeep/rails/client_subscription.rb +37 -0
- data/lib/upkeep/rails/configuration.rb +57 -0
- data/lib/upkeep/rails/controller_runtime.rb +137 -0
- data/lib/upkeep/rails/install.rb +28 -0
- data/lib/upkeep/rails/railtie.rb +36 -0
- data/lib/upkeep/rails/replay.rb +176 -0
- data/lib/upkeep/rails/testing.rb +36 -0
- data/lib/upkeep/rails.rb +276 -0
- data/lib/upkeep/replay.rb +408 -0
- data/lib/upkeep/runtime.rb +1075 -0
- data/lib/upkeep/shared_streams.rb +72 -0
- data/lib/upkeep/subscriptions/active_record_store.rb +292 -0
- data/lib/upkeep/subscriptions/active_record_subscription_persistence.rb +291 -0
- data/lib/upkeep/subscriptions/active_registry.rb +93 -0
- data/lib/upkeep/subscriptions/async_durable_writer.rb +136 -0
- data/lib/upkeep/subscriptions/json_snapshot.rb +98 -0
- data/lib/upkeep/subscriptions/layered_reverse_index.rb +122 -0
- data/lib/upkeep/subscriptions/persistent_reverse_index.rb +144 -0
- data/lib/upkeep/subscriptions/registrar.rb +36 -0
- data/lib/upkeep/subscriptions/reverse_index.rb +294 -0
- data/lib/upkeep/subscriptions/shape.rb +116 -0
- data/lib/upkeep/subscriptions/store.rb +159 -0
- data/lib/upkeep/subscriptions.rb +7 -0
- data/lib/upkeep/targeting.rb +135 -0
- data/lib/upkeep/version.rb +5 -0
- data/lib/upkeep-rails.rb +3 -0
- data/lib/upkeep.rb +14 -0
- data/upkeep-rails.gemspec +53 -0
- 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
|