rhales 0.3.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.
- checksums.yaml +7 -0
- data/CLAUDE.locale.txt +7 -0
- data/CLAUDE.md +90 -0
- data/LICENSE.txt +21 -0
- data/README.md +881 -0
- data/lib/rhales/adapters/base_auth.rb +106 -0
- data/lib/rhales/adapters/base_request.rb +97 -0
- data/lib/rhales/adapters/base_session.rb +93 -0
- data/lib/rhales/configuration.rb +156 -0
- data/lib/rhales/context.rb +240 -0
- data/lib/rhales/csp.rb +94 -0
- data/lib/rhales/errors/hydration_collision_error.rb +85 -0
- data/lib/rhales/errors.rb +36 -0
- data/lib/rhales/hydration_data_aggregator.rb +220 -0
- data/lib/rhales/hydration_registry.rb +58 -0
- data/lib/rhales/hydrator.rb +141 -0
- data/lib/rhales/parsers/handlebars-grammar-review.txt +39 -0
- data/lib/rhales/parsers/handlebars_parser.rb +727 -0
- data/lib/rhales/parsers/rue_format_parser.rb +385 -0
- data/lib/rhales/refinements/require_refinements.rb +236 -0
- data/lib/rhales/rue_document.rb +304 -0
- data/lib/rhales/template_engine.rb +353 -0
- data/lib/rhales/tilt.rb +214 -0
- data/lib/rhales/version.rb +6 -0
- data/lib/rhales/view.rb +412 -0
- data/lib/rhales/view_composition.rb +165 -0
- data/lib/rhales.rb +57 -0
- data/rhales.gemspec +46 -0
- metadata +78 -0
data/lib/rhales/csp.rb
ADDED
@@ -0,0 +1,94 @@
|
|
1
|
+
# lib/rhales/csp.rb
|
2
|
+
|
3
|
+
module Rhales
|
4
|
+
# Content Security Policy (CSP) header generation and management
|
5
|
+
#
|
6
|
+
# Provides secure defaults and nonce integration for CSP headers.
|
7
|
+
# Converts policy configuration into proper CSP header strings.
|
8
|
+
#
|
9
|
+
# Usage:
|
10
|
+
# csp = Rhales::CSP.new(config, nonce: 'abc123')
|
11
|
+
# header = csp.build_header
|
12
|
+
# # => "default-src 'self'; script-src 'self' 'nonce-abc123'; ..."
|
13
|
+
class CSP
|
14
|
+
attr_reader :config, :nonce
|
15
|
+
|
16
|
+
def initialize(config, nonce: nil)
|
17
|
+
@config = config
|
18
|
+
@nonce = nonce
|
19
|
+
end
|
20
|
+
|
21
|
+
# Build CSP header string from configuration
|
22
|
+
def build_header
|
23
|
+
return nil unless @config.csp_enabled
|
24
|
+
|
25
|
+
policy_directives = []
|
26
|
+
|
27
|
+
@config.csp_policy.each do |directive, sources|
|
28
|
+
if sources.empty?
|
29
|
+
# For directives with no sources (like upgrade-insecure-requests)
|
30
|
+
policy_directives << directive
|
31
|
+
else
|
32
|
+
# Process sources and interpolate nonce if present
|
33
|
+
processed_sources = sources.map { |source| interpolate_nonce(source) }
|
34
|
+
directive_string = "#{directive} #{processed_sources.join(' ')}"
|
35
|
+
policy_directives << directive_string
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
policy_directives.join('; ')
|
40
|
+
end
|
41
|
+
|
42
|
+
# Generate a new nonce value
|
43
|
+
def self.generate_nonce
|
44
|
+
SecureRandom.hex(16)
|
45
|
+
end
|
46
|
+
|
47
|
+
# Validate CSP policy configuration
|
48
|
+
def validate_policy!
|
49
|
+
return unless @config.csp_enabled
|
50
|
+
|
51
|
+
errors = []
|
52
|
+
|
53
|
+
# Ensure policy is a hash
|
54
|
+
unless @config.csp_policy.is_a?(Hash)
|
55
|
+
errors << 'csp_policy must be a hash'
|
56
|
+
raise Rhales::Configuration::ConfigurationError, "CSP policy errors: #{errors.join(', ')}"
|
57
|
+
end
|
58
|
+
|
59
|
+
# Validate each directive
|
60
|
+
@config.csp_policy.each do |directive, sources|
|
61
|
+
unless sources.is_a?(Array)
|
62
|
+
errors << "#{directive} sources must be an array"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check for dangerous sources
|
66
|
+
if sources.include?("'unsafe-eval'")
|
67
|
+
errors << "#{directive} contains dangerous 'unsafe-eval' source"
|
68
|
+
end
|
69
|
+
|
70
|
+
if sources.include?("'unsafe-inline'") && !%w[style-src].include?(directive)
|
71
|
+
errors << "#{directive} contains dangerous 'unsafe-inline' source"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
raise Rhales::Configuration::ConfigurationError, "CSP policy errors: #{errors.join(', ')}" unless errors.empty?
|
76
|
+
end
|
77
|
+
|
78
|
+
# Check if nonce is required for any directive
|
79
|
+
def nonce_required?
|
80
|
+
return false unless @config.csp_enabled
|
81
|
+
|
82
|
+
@config.csp_policy.values.flatten.any? { |source| source.include?('{{nonce}}') }
|
83
|
+
end
|
84
|
+
|
85
|
+
private
|
86
|
+
|
87
|
+
# Interpolate nonce placeholder in source values
|
88
|
+
def interpolate_nonce(source)
|
89
|
+
return source unless @nonce && source.include?('{{nonce}}')
|
90
|
+
|
91
|
+
source.gsub('{{nonce}}', @nonce)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rhales
|
4
|
+
class HydrationCollisionError < Error
|
5
|
+
attr_reader :window_attribute, :first_path, :conflict_path
|
6
|
+
|
7
|
+
def initialize(window_attribute, first_path, conflict_path)
|
8
|
+
@window_attribute = window_attribute
|
9
|
+
@first_path = first_path
|
10
|
+
@conflict_path = conflict_path
|
11
|
+
|
12
|
+
super(build_message)
|
13
|
+
end
|
14
|
+
|
15
|
+
def message
|
16
|
+
build_message
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def build_message
|
22
|
+
<<~MSG.strip
|
23
|
+
Window attribute collision detected
|
24
|
+
|
25
|
+
Attribute: '#{@window_attribute}'
|
26
|
+
First defined: #{@first_path}#{extract_tag_content(@first_path)}
|
27
|
+
Conflict with: #{@conflict_path}#{extract_tag_content(@conflict_path)}
|
28
|
+
|
29
|
+
Quick fixes:
|
30
|
+
1. Rename one: <data window="#{suggested_alternative_name}">
|
31
|
+
2. Enable merging: <data window="#{@window_attribute}" merge="deep">
|
32
|
+
|
33
|
+
Learn more: https://rhales.dev/docs/data-boundaries#collisions
|
34
|
+
MSG
|
35
|
+
end
|
36
|
+
|
37
|
+
def extract_tag_content(path)
|
38
|
+
# If the path includes the actual tag content after the line number,
|
39
|
+
# extract and format it for display
|
40
|
+
if path.include?(':<data')
|
41
|
+
tag_match = path.match(/(:.*?<data[^>]*>)/)
|
42
|
+
return "\n #{tag_match[1].sub(/^:/, '')}" if tag_match
|
43
|
+
end
|
44
|
+
''
|
45
|
+
end
|
46
|
+
|
47
|
+
def suggested_alternative_name
|
48
|
+
# For specific known patterns, use the first defined location
|
49
|
+
# This provides a more predictable suggestion
|
50
|
+
if @window_attribute == 'appState' && @first_path.include?('header')
|
51
|
+
'headerState'
|
52
|
+
elsif @window_attribute == 'data'
|
53
|
+
# For generic 'data', use the conflict path to generate unique name
|
54
|
+
base_name_from_path(@conflict_path) + 'Data'
|
55
|
+
else
|
56
|
+
# For other cases, suggest a simple modification
|
57
|
+
case @window_attribute
|
58
|
+
when /Data$/
|
59
|
+
@window_attribute.sub(/Data$/, 'State')
|
60
|
+
when /State$/
|
61
|
+
@window_attribute.sub(/State$/, 'Config')
|
62
|
+
when /Config$/
|
63
|
+
@window_attribute.sub(/Config$/, 'Settings')
|
64
|
+
else
|
65
|
+
@window_attribute + 'Data'
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def base_name_from_path(path)
|
71
|
+
# Extract a base name from the file path for suggestion
|
72
|
+
filename = path.split('/').last.split('.').first
|
73
|
+
case filename
|
74
|
+
when 'index', 'main', 'application'
|
75
|
+
'page'
|
76
|
+
when /^_/, 'partial'
|
77
|
+
'partial'
|
78
|
+
when 'header', 'footer', 'sidebar', 'nav'
|
79
|
+
filename
|
80
|
+
else
|
81
|
+
filename.gsub(/[^a-zA-Z0-9]/, '').downcase
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# lib/rhales/errors.rb
|
2
|
+
|
3
|
+
module Rhales
|
4
|
+
class Error < StandardError; end
|
5
|
+
|
6
|
+
# Parse-time errors - syntax and structure issues
|
7
|
+
class ParseError < Error
|
8
|
+
attr_reader :line, :column, :offset, :source_type
|
9
|
+
|
10
|
+
def initialize(message, line: nil, column: nil, offset: nil, source_type: nil)
|
11
|
+
@line = line
|
12
|
+
@column = column
|
13
|
+
@offset = offset
|
14
|
+
@source_type = source_type # :rue, :handlebars, or :template
|
15
|
+
|
16
|
+
location = line && column ? " at line #{line}, column #{column}" : ''
|
17
|
+
source = source_type ? " in #{source_type}" : ''
|
18
|
+
super("#{message}#{location}#{source}")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Validation-time errors - structural and semantic issues
|
23
|
+
class ValidationError < Error; end
|
24
|
+
|
25
|
+
# Render-time errors - runtime issues during template execution
|
26
|
+
class RenderError < Error; end
|
27
|
+
|
28
|
+
# Configuration errors
|
29
|
+
class ConfigurationError < Error; end
|
30
|
+
|
31
|
+
# Legacy alias for backward compatibility
|
32
|
+
class TemplateError < Error; end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Load specific error classes
|
36
|
+
require_relative 'errors/hydration_collision_error'
|
@@ -0,0 +1,220 @@
|
|
1
|
+
# lib/rhales/hydration_data_aggregator.rb
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require_relative 'template_engine'
|
5
|
+
require_relative 'errors'
|
6
|
+
|
7
|
+
module Rhales
|
8
|
+
# HydrationDataAggregator traverses the ViewComposition and executes
|
9
|
+
# all <data> sections to produce a single, merged JSON structure.
|
10
|
+
#
|
11
|
+
# This class implements the server-side data aggregation phase of the
|
12
|
+
# two-pass rendering model, handling:
|
13
|
+
# - Traversal of the template dependency tree
|
14
|
+
# - Execution of <data> sections with full server context
|
15
|
+
# - Merge strategies (deep, shallow, strict)
|
16
|
+
# - Collision detection and error reporting
|
17
|
+
#
|
18
|
+
# The aggregator replaces the HydrationRegistry by performing all
|
19
|
+
# data merging in a single, coordinated pass.
|
20
|
+
class HydrationDataAggregator
|
21
|
+
class JSONSerializationError < StandardError; end
|
22
|
+
|
23
|
+
def initialize(context)
|
24
|
+
@context = context
|
25
|
+
@window_attributes = {}
|
26
|
+
@merged_data = {}
|
27
|
+
end
|
28
|
+
|
29
|
+
# Aggregate all hydration data from the view composition
|
30
|
+
def aggregate(composition)
|
31
|
+
composition.each_document_in_render_order do |template_name, parser|
|
32
|
+
process_template(template_name, parser)
|
33
|
+
end
|
34
|
+
|
35
|
+
@merged_data
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
def process_template(template_name, parser)
|
41
|
+
data_content = parser.section('data')
|
42
|
+
return unless data_content
|
43
|
+
|
44
|
+
window_attr = parser.window_attribute || 'data'
|
45
|
+
merge_strategy = parser.merge_strategy
|
46
|
+
|
47
|
+
# Build template path for error reporting
|
48
|
+
template_path = build_template_path(parser)
|
49
|
+
|
50
|
+
# Process the data section first to check if it's empty
|
51
|
+
processed_data = process_data_section(data_content, parser)
|
52
|
+
|
53
|
+
# Check for collisions only if the data is not empty
|
54
|
+
if @window_attributes.key?(window_attr) && merge_strategy.nil? && !empty_data?(processed_data)
|
55
|
+
existing = @window_attributes[window_attr]
|
56
|
+
existing_data = @merged_data[window_attr]
|
57
|
+
|
58
|
+
# Only raise collision error if existing data is also not empty
|
59
|
+
unless empty_data?(existing_data)
|
60
|
+
raise ::Rhales::HydrationCollisionError.new(window_attr, existing[:path], template_path)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# Merge or set the data
|
65
|
+
if @merged_data.key?(window_attr)
|
66
|
+
@merged_data[window_attr] = merge_data(
|
67
|
+
@merged_data[window_attr],
|
68
|
+
processed_data,
|
69
|
+
merge_strategy || 'deep',
|
70
|
+
window_attr,
|
71
|
+
template_path
|
72
|
+
)
|
73
|
+
else
|
74
|
+
@merged_data[window_attr] = processed_data
|
75
|
+
end
|
76
|
+
|
77
|
+
# Track the window attribute
|
78
|
+
@window_attributes[window_attr] = {
|
79
|
+
path: template_path,
|
80
|
+
merge_strategy: merge_strategy
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
def process_data_section(data_content, parser)
|
85
|
+
# Create a JSON-aware context wrapper for data sections
|
86
|
+
json_context = JsonAwareContext.new(@context)
|
87
|
+
|
88
|
+
# Process template variables in the data section
|
89
|
+
processed_content = TemplateEngine.render(data_content, json_context)
|
90
|
+
|
91
|
+
# Parse as JSON
|
92
|
+
begin
|
93
|
+
JSON.parse(processed_content)
|
94
|
+
rescue JSON::ParserError => ex
|
95
|
+
template_path = build_template_path(parser)
|
96
|
+
raise JSONSerializationError,
|
97
|
+
"Invalid JSON in data section at #{template_path}: #{ex.message}\n" \
|
98
|
+
"Processed content: #{processed_content[0..200]}..."
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def merge_data(target, source, strategy, window_attr, template_path)
|
103
|
+
case strategy
|
104
|
+
when 'deep'
|
105
|
+
deep_merge(target, source)
|
106
|
+
when 'shallow'
|
107
|
+
shallow_merge(target, source, window_attr, template_path)
|
108
|
+
when 'strict'
|
109
|
+
strict_merge(target, source, window_attr, template_path)
|
110
|
+
else
|
111
|
+
raise ArgumentError, "Unknown merge strategy: #{strategy}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def deep_merge(target, source)
|
116
|
+
result = target.dup
|
117
|
+
|
118
|
+
source.each do |key, value|
|
119
|
+
if result.key?(key) && result[key].is_a?(Hash) && value.is_a?(Hash)
|
120
|
+
result[key] = deep_merge(result[key], value)
|
121
|
+
else
|
122
|
+
result[key] = value
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
result
|
127
|
+
end
|
128
|
+
|
129
|
+
def shallow_merge(target, source, window_attr, template_path)
|
130
|
+
result = target.dup
|
131
|
+
|
132
|
+
source.each do |key, value|
|
133
|
+
if result.key?(key)
|
134
|
+
raise ::Rhales::HydrationCollisionError.new(
|
135
|
+
"#{window_attr}.#{key}",
|
136
|
+
@window_attributes[window_attr][:path],
|
137
|
+
template_path
|
138
|
+
)
|
139
|
+
end
|
140
|
+
result[key] = value
|
141
|
+
end
|
142
|
+
|
143
|
+
result
|
144
|
+
end
|
145
|
+
|
146
|
+
def strict_merge(target, source, window_attr, template_path)
|
147
|
+
# In strict mode, any collision is an error
|
148
|
+
target.each_key do |key|
|
149
|
+
if source.key?(key)
|
150
|
+
raise ::Rhales::HydrationCollisionError.new(
|
151
|
+
"#{window_attr}.#{key}",
|
152
|
+
@window_attributes[window_attr][:path],
|
153
|
+
template_path
|
154
|
+
)
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
target.merge(source)
|
159
|
+
end
|
160
|
+
|
161
|
+
def build_template_path(parser)
|
162
|
+
data_node = parser.section_node('data')
|
163
|
+
line_number = data_node ? data_node.location.start_line : 1
|
164
|
+
|
165
|
+
if parser.file_path
|
166
|
+
"#{parser.file_path}:#{line_number}"
|
167
|
+
else
|
168
|
+
"<inline>:#{line_number}"
|
169
|
+
end
|
170
|
+
end
|
171
|
+
|
172
|
+
# Check if data is considered empty for collision detection
|
173
|
+
def empty_data?(data)
|
174
|
+
return true if data.nil?
|
175
|
+
return true if data == {}
|
176
|
+
return true if data == []
|
177
|
+
return true if data.respond_to?(:empty?) && data.empty?
|
178
|
+
false
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
# Context wrapper that automatically converts Ruby objects to JSON in data sections
|
183
|
+
class JsonAwareContext
|
184
|
+
def initialize(context)
|
185
|
+
@context = context
|
186
|
+
end
|
187
|
+
|
188
|
+
# Delegate all methods to the wrapped context
|
189
|
+
def method_missing(method, *args, &block)
|
190
|
+
@context.send(method, *args, &block)
|
191
|
+
end
|
192
|
+
|
193
|
+
def respond_to_missing?(method, include_private = false)
|
194
|
+
@context.respond_to?(method, include_private)
|
195
|
+
end
|
196
|
+
|
197
|
+
# Override get method to return JSON-serialized objects
|
198
|
+
def get(variable_path)
|
199
|
+
value = @context.get(variable_path)
|
200
|
+
|
201
|
+
# Convert Ruby objects to JSON for data sections
|
202
|
+
case value
|
203
|
+
when Hash, Array
|
204
|
+
begin
|
205
|
+
value.to_json
|
206
|
+
rescue JSON::GeneratorError, SystemStackError => ex
|
207
|
+
# Handle serialization errors (circular references, unsupported types, etc.)
|
208
|
+
raise JSONSerializationError,
|
209
|
+
"Failed to serialize Ruby object to JSON: #{ex.message}. " \
|
210
|
+
"Object type: #{value.class}, var path: #{variable_path}..."
|
211
|
+
end
|
212
|
+
else
|
213
|
+
value
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
217
|
+
# Alias for compatibility with template engine
|
218
|
+
alias_method :resolve_variable, :get
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# lib/rhales/hydration_registry.rb
|
2
|
+
|
3
|
+
module Rhales
|
4
|
+
# Registry to track window attributes used in hydration blocks
|
5
|
+
# within a single request. Prevents silent data overwrites.
|
6
|
+
class HydrationRegistry
|
7
|
+
class << self
|
8
|
+
def register(window_attr, template_path, merge_strategy = nil)
|
9
|
+
validate_inputs(window_attr, template_path)
|
10
|
+
|
11
|
+
registry = thread_local_registry
|
12
|
+
|
13
|
+
if registry[window_attr] && merge_strategy.nil?
|
14
|
+
existing = registry[window_attr]
|
15
|
+
raise HydrationCollisionError.new(
|
16
|
+
window_attr,
|
17
|
+
existing[:path],
|
18
|
+
template_path,
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
registry[window_attr] = {
|
23
|
+
path: template_path,
|
24
|
+
merge_strategy: merge_strategy,
|
25
|
+
}
|
26
|
+
end
|
27
|
+
|
28
|
+
def clear!
|
29
|
+
Thread.current[:rhales_hydration_registry] = {}
|
30
|
+
end
|
31
|
+
|
32
|
+
# Expose registry for testing purposes
|
33
|
+
def registry
|
34
|
+
thread_local_registry
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def thread_local_registry
|
40
|
+
Thread.current[:rhales_hydration_registry] ||= {}
|
41
|
+
end
|
42
|
+
|
43
|
+
def validate_inputs(window_attr, template_path)
|
44
|
+
if window_attr.nil?
|
45
|
+
raise ArgumentError, 'window attribute cannot be nil'
|
46
|
+
end
|
47
|
+
|
48
|
+
if window_attr.empty?
|
49
|
+
raise ArgumentError, 'window attribute cannot be empty'
|
50
|
+
end
|
51
|
+
|
52
|
+
if template_path.nil?
|
53
|
+
raise ArgumentError, 'template path cannot be nil'
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# lib/rhales/hydrator.rb
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
|
6
|
+
module Rhales
|
7
|
+
# Data Hydrator for RSFC client-side data injection
|
8
|
+
#
|
9
|
+
# ## RSFC Security Model: Server-to-Client Security Boundary
|
10
|
+
#
|
11
|
+
# The Hydrator enforces a critical security boundary between server and client:
|
12
|
+
#
|
13
|
+
# ### Server Side (Template Rendering)
|
14
|
+
# - Templates have FULL server context access (like ERB/HAML)
|
15
|
+
# - Can access user objects, database connections, internal APIs
|
16
|
+
# - Can access secrets, configuration, authentication state
|
17
|
+
# - Can process sensitive business logic
|
18
|
+
#
|
19
|
+
# ### Client Side (Data Hydration)
|
20
|
+
# - Only data declared in <data> section reaches the browser
|
21
|
+
# - Creates explicit allowlist like designing a REST API
|
22
|
+
# - Server-side variable interpolation processes secrets safely
|
23
|
+
# - JSON serialization validates data structure
|
24
|
+
#
|
25
|
+
# ### Process Flow
|
26
|
+
# 1. Server processes <data> section with full context access
|
27
|
+
# 2. Variables like {{user.name}} are interpolated server-side
|
28
|
+
# 3. Result is serialized as JSON and sent to client
|
29
|
+
# 4. Client receives only the processed, safe data
|
30
|
+
#
|
31
|
+
# ### Example
|
32
|
+
# ```rue
|
33
|
+
# <data>
|
34
|
+
# {
|
35
|
+
# "user_name": "{{user.name}}", // Safe: just the name
|
36
|
+
# "theme": "{{user.theme_preference}}" // Safe: just the theme
|
37
|
+
# }
|
38
|
+
# </data>
|
39
|
+
# ```
|
40
|
+
#
|
41
|
+
# Server template can access {{user.admin?}} and {{internal_config}},
|
42
|
+
# but client only gets the declared user_name and theme values.
|
43
|
+
#
|
44
|
+
# This creates an API-like boundary where data is serialized once and
|
45
|
+
# parsed once, enforcing the same security model as REST endpoints.
|
46
|
+
#
|
47
|
+
# Note: With the new two-pass architecture, the Hydrator's role is
|
48
|
+
# greatly simplified. All data merging happens server-side in the
|
49
|
+
# HydrationDataAggregator, so this class only handles JSON generation
|
50
|
+
# for individual templates (used during the aggregation phase).
|
51
|
+
class Hydrator
|
52
|
+
class HydrationError < StandardError; end
|
53
|
+
class JSONSerializationError < HydrationError; end
|
54
|
+
|
55
|
+
attr_reader :parser, :context, :window_attribute
|
56
|
+
|
57
|
+
def initialize(parser, context)
|
58
|
+
@parser = parser
|
59
|
+
@context = context
|
60
|
+
@window_attribute = parser.window_attribute || 'data'
|
61
|
+
end
|
62
|
+
|
63
|
+
# This method is now deprecated in favor of the two-pass architecture
|
64
|
+
# It's kept for backward compatibility but will be removed in future versions
|
65
|
+
def generate_hydration_html
|
66
|
+
warn "[DEPRECATION] Hydrator#generate_hydration_html is deprecated. Use the two-pass rendering architecture instead."
|
67
|
+
""
|
68
|
+
end
|
69
|
+
|
70
|
+
# Process <data> section and return JSON string
|
71
|
+
def process_data_section
|
72
|
+
data_content = @parser.section('data')
|
73
|
+
return '{}' unless data_content
|
74
|
+
|
75
|
+
# Process variable interpolations in the data section
|
76
|
+
processed_content = process_data_variables(data_content)
|
77
|
+
|
78
|
+
# Validate and return JSON
|
79
|
+
validate_json(processed_content)
|
80
|
+
processed_content
|
81
|
+
rescue JSON::ParserError => ex
|
82
|
+
raise JSONSerializationError, "Invalid JSON in data section: #{ex.message}"
|
83
|
+
end
|
84
|
+
|
85
|
+
# Get processed data as Ruby hash (for internal use)
|
86
|
+
def processed_data_hash
|
87
|
+
json_string = process_data_section
|
88
|
+
JSON.parse(json_string)
|
89
|
+
rescue JSON::ParserError => ex
|
90
|
+
raise JSONSerializationError, "Cannot parse processed data as JSON: #{ex.message}"
|
91
|
+
end
|
92
|
+
|
93
|
+
private
|
94
|
+
|
95
|
+
# Process variable interpolations in data section
|
96
|
+
# Uses Rhales consistently for all template processing
|
97
|
+
def process_data_variables(data_content)
|
98
|
+
rhales = TemplateEngine.new(data_content, @context)
|
99
|
+
rhales.render
|
100
|
+
end
|
101
|
+
|
102
|
+
# Validate that processed content is valid JSON
|
103
|
+
def validate_json(json_string)
|
104
|
+
JSON.parse(json_string)
|
105
|
+
rescue JSON::ParserError => ex
|
106
|
+
raise JSONSerializationError, "Processed data section is not valid JSON: #{ex.message}"
|
107
|
+
end
|
108
|
+
|
109
|
+
# Build template path with line number for error reporting
|
110
|
+
# (Used by HydrationDataAggregator)
|
111
|
+
def build_template_path
|
112
|
+
data_node = @parser.section_node('data')
|
113
|
+
line_number = data_node ? data_node.location.start_line : 1
|
114
|
+
|
115
|
+
if @parser.file_path
|
116
|
+
"#{@parser.file_path}:#{line_number}"
|
117
|
+
else
|
118
|
+
"<inline>:#{line_number}"
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
class << self
|
123
|
+
# Convenience method to generate hydration HTML
|
124
|
+
# DEPRECATED: Use the two-pass rendering architecture instead
|
125
|
+
def generate(parser, context)
|
126
|
+
warn "[DEPRECATION] Hydrator.generate is deprecated. Use the two-pass rendering architecture instead."
|
127
|
+
new(parser, context).generate_hydration_html
|
128
|
+
end
|
129
|
+
|
130
|
+
# Generate only JSON data (for testing or API endpoints)
|
131
|
+
def generate_json(parser, context)
|
132
|
+
new(parser, context).process_data_section
|
133
|
+
end
|
134
|
+
|
135
|
+
# Generate data hash (for internal processing)
|
136
|
+
def generate_data_hash(parser, context)
|
137
|
+
new(parser, context).processed_data_hash
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
According to Gemini 2.5 Pro.
|
2
|
+
|
3
|
+
|
4
|
+
The `HandlebarsGrammar` class is **not a formal grammar definition**.
|
5
|
+
|
6
|
+
Instead, it is a **parser** that **implements** the rules of the Handlebars grammar. This is a crucial distinction:
|
7
|
+
|
8
|
+
1. **Formal Grammar (The Blueprint):** A formal grammar is a set of abstract, mathematical rules that define a language. It specifies the valid sequences of symbols and their structure, often using a notation like Backus-Naur Form (BNF). It is a *specification*, not code.
|
9
|
+
|
10
|
+
*Example (simplified BNF for a Handlebars variable):*
|
11
|
+
```
|
12
|
+
expression ::= '{{' identifier '}}'
|
13
|
+
identifier ::= letter ( letter | digit )*
|
14
|
+
```
|
15
|
+
|
16
|
+
2. **Parser (The Implementation):** A parser is a piece of software that takes an input string and determines if it conforms to the rules of a formal grammar. As a byproduct, it usually produces a data structure, like an Abstract Syntax Tree (AST), that represents the input's structure.
|
17
|
+
|
18
|
+
The `HandlebarsGrammar` class is what is known as a **hand-rolled parser**. It was written manually in Ruby to recognize and structure Handlebars syntax, rather than being automatically generated by a tool.
|
19
|
+
|
20
|
+
### What Makes it a Parser (Not a Grammar)
|
21
|
+
|
22
|
+
You can see the classic components of a hand-rolled parser right in its methods:
|
23
|
+
|
24
|
+
* **Lexical Analysis (Scanning):** It consumes the input character by character and identifies tokens.
|
25
|
+
* `current_char`, `peek_char`, `advance`: These methods manage the input stream.
|
26
|
+
* `parse_text_until_handlebars`: This logic finds the boundary between plain text and a Handlebars expression (`{{`).
|
27
|
+
|
28
|
+
* **Syntactic Analysis (Parsing):** It applies the grammar rules (as implemented in Ruby logic) to the token stream to build a structural representation.
|
29
|
+
* `parse_template`: The main entry point that orchestrates the parsing process.
|
30
|
+
* `create_if_block`, `create_each_block`: These methods correspond directly to the production rules for Handlebars block helpers. They build `Node` objects.
|
31
|
+
* `Node` class: This defines the structure of the AST that the parser produces as its output.
|
32
|
+
|
33
|
+
### Why No Prism Reference?
|
34
|
+
|
35
|
+
Parser generators like Prism, ANTLR, or Treetop work by taking a formal grammar definition as input and *generating* the parser code for you.
|
36
|
+
|
37
|
+
Since `HandlebarsGrammar` is a hand-rolled parser, it doesn't need a generator. The developer has written the parsing logic directly in Ruby. This approach is common for languages with relatively simple, non-ambiguous syntax, as it avoids adding an external dependency and gives the developer full control over the parsing process and error handling.
|
38
|
+
|
39
|
+
In summary: You've correctly identified that the class isn't a formal grammar. It's a **manual implementation of a parser** for that grammar, which is why it contains procedural logic for scanning and structuring text instead of a declarative set of grammar rules.
|