rhales 0.4.0 → 0.5.3
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 +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# lib/rhales/template_engine.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'erb'
|
|
4
|
-
require_relative 'parsers/rue_format_parser'
|
|
5
|
-
require_relative 'parsers/handlebars_parser'
|
|
6
|
+
require_relative '../parsers/rue_format_parser'
|
|
7
|
+
require_relative '../parsers/handlebars_parser'
|
|
6
8
|
require_relative 'rue_document'
|
|
7
|
-
require_relative 'hydrator'
|
|
9
|
+
require_relative '../hydration/hydrator'
|
|
8
10
|
|
|
9
11
|
module Rhales
|
|
10
12
|
# Rhales - Ruby Handlebars-style template engine
|
|
@@ -28,6 +30,8 @@ module Rhales
|
|
|
28
30
|
# - {{#each items}} ... {{/each}} - Iteration with context
|
|
29
31
|
# - {{> partial_name}} - Partial inclusion
|
|
30
32
|
class TemplateEngine
|
|
33
|
+
include Rhales::Utils::LoggingHelpers
|
|
34
|
+
|
|
31
35
|
class RenderError < ::Rhales::RenderError; end
|
|
32
36
|
class PartialNotFoundError < RenderError; end
|
|
33
37
|
class UndefinedVariableError < RenderError; end
|
|
@@ -43,31 +47,46 @@ module Rhales
|
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
def render
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
50
|
+
template_type = simple_template? ? :handlebars : :rue
|
|
51
|
+
|
|
52
|
+
log_timed_operation(Rhales.logger, :debug, 'Template compiled',
|
|
53
|
+
template_type: template_type, cached: false
|
|
54
|
+
) do
|
|
55
|
+
# Check if this is a simple template or a full .rue file
|
|
56
|
+
if simple_template?
|
|
57
|
+
# Use HandlebarsParser for simple templates
|
|
58
|
+
parser = HandlebarsParser.new(@template_content)
|
|
59
|
+
parser.parse!
|
|
60
|
+
render_content_nodes(parser.ast.children)
|
|
61
|
+
else
|
|
62
|
+
# Use RueDocument for .rue files
|
|
63
|
+
@parser = RueDocument.new(@template_content)
|
|
64
|
+
@parser.parse!
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
# Get template section via RueDocument
|
|
67
|
+
template_content = @parser.section('template')
|
|
68
|
+
raise RenderError, 'Missing template section' unless template_content
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
# Render the template section as a simple template
|
|
71
|
+
render_template_string(template_content)
|
|
72
|
+
end
|
|
63
73
|
end
|
|
64
74
|
rescue ::Rhales::ParseError => ex
|
|
65
75
|
# Parse errors already have good error messages with location
|
|
76
|
+
log_with_metadata(Rhales.logger, :error, 'Template parse error',
|
|
77
|
+
error: ex.message, line: ex.line, column: ex.column, section: ex.source_type
|
|
78
|
+
)
|
|
66
79
|
raise RenderError, "Template parsing failed: #{ex.message}"
|
|
67
80
|
rescue ::Rhales::ValidationError => ex
|
|
68
81
|
# Validation errors from RueDocument
|
|
82
|
+
log_with_metadata(Rhales.logger, :error, 'Template validation error',
|
|
83
|
+
error: ex.message, template_type: :rue
|
|
84
|
+
)
|
|
69
85
|
raise RenderError, "Template validation failed: #{ex.message}"
|
|
70
86
|
rescue StandardError => ex
|
|
87
|
+
log_with_metadata(Rhales.logger, :error, 'Template render error',
|
|
88
|
+
error: ex.message, error_class: ex.class.name
|
|
89
|
+
)
|
|
71
90
|
raise RenderError, "Template rendering failed: #{ex.message}"
|
|
72
91
|
end
|
|
73
92
|
|
|
@@ -81,11 +100,6 @@ module Rhales
|
|
|
81
100
|
@parser&.schema_path
|
|
82
101
|
end
|
|
83
102
|
|
|
84
|
-
# Access all data attributes from parsed .rue file
|
|
85
|
-
def data_attributes
|
|
86
|
-
@parser&.data_attributes || {}
|
|
87
|
-
end
|
|
88
|
-
|
|
89
103
|
# Get template variables used in the template
|
|
90
104
|
def template_variables
|
|
91
105
|
@parser&.template_variables || []
|
|
@@ -99,7 +113,7 @@ module Rhales
|
|
|
99
113
|
private
|
|
100
114
|
|
|
101
115
|
def simple_template?
|
|
102
|
-
!@template_content.match?(/^<(
|
|
116
|
+
!@template_content.match?(/^<(schema|template|logic)\b/)
|
|
103
117
|
end
|
|
104
118
|
|
|
105
119
|
def render_template_string(template_string)
|
|
@@ -131,7 +145,7 @@ module Rhales
|
|
|
131
145
|
when :each_block
|
|
132
146
|
result += render_each_block(node)
|
|
133
147
|
when :handlebars_expression
|
|
134
|
-
# Handle
|
|
148
|
+
# Handle handlebars expressions
|
|
135
149
|
result += render_handlebars_expression(node)
|
|
136
150
|
end
|
|
137
151
|
end
|
|
@@ -144,7 +158,13 @@ module Rhales
|
|
|
144
158
|
raw = node.value[:raw]
|
|
145
159
|
|
|
146
160
|
value = get_variable_value(name)
|
|
147
|
-
|
|
161
|
+
|
|
162
|
+
if raw
|
|
163
|
+
warn_if_unescaped(name, value, 'variable_expression')
|
|
164
|
+
value.to_s
|
|
165
|
+
else
|
|
166
|
+
escape_html(value.to_s)
|
|
167
|
+
end
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
def render_partial_expression(node)
|
|
@@ -205,7 +225,12 @@ module Rhales
|
|
|
205
225
|
''
|
|
206
226
|
else # Variables
|
|
207
227
|
value = get_variable_value(content)
|
|
208
|
-
raw
|
|
228
|
+
if raw
|
|
229
|
+
warn_if_unescaped(content, value, 'handlebars_expression')
|
|
230
|
+
value.to_s
|
|
231
|
+
else
|
|
232
|
+
escape_html(value.to_s)
|
|
233
|
+
end
|
|
209
234
|
end
|
|
210
235
|
end
|
|
211
236
|
|
|
@@ -216,8 +241,8 @@ module Rhales
|
|
|
216
241
|
raise PartialNotFoundError, "Partial '#{partial_name}' not found" unless partial_content
|
|
217
242
|
|
|
218
243
|
# Check if this is a .rue document with sections
|
|
219
|
-
if partial_content.match?(/^<(
|
|
220
|
-
# Parse as RueDocument to handle
|
|
244
|
+
if partial_content.match?(/^<(schema|template|logic)\b/)
|
|
245
|
+
# Parse as RueDocument to handle schema sections properly
|
|
221
246
|
partial_doc = RueDocument.new(partial_content)
|
|
222
247
|
partial_doc.parse!
|
|
223
248
|
|
|
@@ -225,19 +250,8 @@ module Rhales
|
|
|
225
250
|
template_content = partial_doc.section('template')
|
|
226
251
|
raise PartialNotFoundError, "Partial '#{partial_name}' missing template section" unless template_content
|
|
227
252
|
|
|
228
|
-
#
|
|
229
|
-
|
|
230
|
-
if partial_doc.section('data')
|
|
231
|
-
# Create hydrator with parent context to process interpolations
|
|
232
|
-
hydrator = Hydrator.new(partial_doc, @context)
|
|
233
|
-
local_data = hydrator.processed_data_hash
|
|
234
|
-
|
|
235
|
-
# Create merged context (local data takes precedence)
|
|
236
|
-
merged_context = create_merged_context(@context, local_data)
|
|
237
|
-
end
|
|
238
|
-
|
|
239
|
-
# Render template with merged context
|
|
240
|
-
engine = self.class.new(template_content, merged_context, partial_resolver: @partial_resolver)
|
|
253
|
+
# Render template with current context
|
|
254
|
+
engine = self.class.new(template_content, @context, partial_resolver: @partial_resolver)
|
|
241
255
|
engine.render
|
|
242
256
|
else
|
|
243
257
|
# Simple template without sections - render as before
|
|
@@ -264,6 +278,16 @@ module Rhales
|
|
|
264
278
|
end
|
|
265
279
|
end
|
|
266
280
|
|
|
281
|
+
# Log warning for unescaped variable usage unless whitelisted
|
|
282
|
+
def warn_if_unescaped(variable_name, value, template_context)
|
|
283
|
+
return if Rhales.config.allowed_unescaped_variables.include?(variable_name.to_s)
|
|
284
|
+
|
|
285
|
+
log_with_metadata(
|
|
286
|
+
Rhales.logger, :warn, 'Unescaped variable usage',
|
|
287
|
+
variable: variable_name, value_type: value.class.name, template_context: template_context
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
267
291
|
# Evaluate condition for if/unless blocks
|
|
268
292
|
def evaluate_condition(condition)
|
|
269
293
|
value = get_variable_value(condition)
|
|
@@ -297,23 +321,6 @@ module Rhales
|
|
|
297
321
|
ERB::Util.html_escape(string)
|
|
298
322
|
end
|
|
299
323
|
|
|
300
|
-
# Create a new context with merged data
|
|
301
|
-
def create_merged_context(parent_context, local_data)
|
|
302
|
-
# Extract all props from parent context and merge with local data
|
|
303
|
-
# Local data takes precedence over parent props
|
|
304
|
-
merged_props = parent_context.props.merge(local_data)
|
|
305
|
-
|
|
306
|
-
# Create new context with merged props, preserving other context attributes
|
|
307
|
-
Context.for_view(
|
|
308
|
-
parent_context.req,
|
|
309
|
-
parent_context.sess,
|
|
310
|
-
parent_context.cust,
|
|
311
|
-
parent_context.locale,
|
|
312
|
-
config: parent_context.config,
|
|
313
|
-
**merged_props,
|
|
314
|
-
)
|
|
315
|
-
end
|
|
316
|
-
|
|
317
324
|
# Context wrapper for {{#each}} iterations
|
|
318
325
|
class EachContext
|
|
319
326
|
attr_reader :parent_context, :current_item, :current_index, :items_var
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# lib/rhales/view.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'securerandom'
|
|
6
|
+
require 'forwardable'
|
|
4
7
|
require_relative 'context'
|
|
5
8
|
require_relative 'rue_document'
|
|
6
9
|
require_relative 'template_engine'
|
|
7
|
-
require_relative 'hydrator'
|
|
10
|
+
require_relative '../hydration/hydrator'
|
|
8
11
|
require_relative 'view_composition'
|
|
9
|
-
require_relative 'hydration_data_aggregator'
|
|
10
|
-
require_relative 'csp'
|
|
11
|
-
require_relative '
|
|
12
|
+
require_relative '../hydration/hydration_data_aggregator'
|
|
13
|
+
require_relative '../security/csp'
|
|
14
|
+
require_relative '../utils/json_serializer'
|
|
15
|
+
require_relative '../integrations/refinements/require_refinements'
|
|
12
16
|
|
|
13
17
|
using Rhales::Ruequire
|
|
14
18
|
|
|
@@ -27,14 +31,15 @@ module Rhales
|
|
|
27
31
|
#
|
|
28
32
|
# ### Server Templates: Full Context Access
|
|
29
33
|
# Templates have complete access to all server-side data:
|
|
30
|
-
# - All
|
|
31
|
-
# -
|
|
34
|
+
# - All client data passed to View.new
|
|
35
|
+
# - All server data passed to View.new
|
|
36
|
+
|
|
32
37
|
# - Runtime data (CSRF tokens, nonces, request metadata)
|
|
33
38
|
# - Computed data (authentication status, theme classes)
|
|
34
39
|
# - User objects, configuration, internal APIs
|
|
35
40
|
#
|
|
36
41
|
# ### Client Data: Explicit Allowlist
|
|
37
|
-
# Only data declared in <
|
|
42
|
+
# Only client data declared in <schema> sections reaches the browser:
|
|
38
43
|
# - Creates a REST API-like boundary
|
|
39
44
|
# - Server-side variable interpolation processes secrets safely
|
|
40
45
|
# - JSON serialization validates data structure
|
|
@@ -44,53 +49,83 @@ module Rhales
|
|
|
44
49
|
# # Server template has full access:
|
|
45
50
|
# {{user.admin?}} {{csrf_token}} {{internal_config}}
|
|
46
51
|
#
|
|
47
|
-
# # Client only gets declared data:
|
|
48
|
-
#
|
|
52
|
+
# # Client only gets declared data from schema:
|
|
53
|
+
# <schema lang="js-zod" window="data">
|
|
54
|
+
# const schema = z.object({ userName: z.string() });
|
|
55
|
+
# </schema>
|
|
49
56
|
#
|
|
50
57
|
# See docs/CONTEXT_AND_DATA_BOUNDARIES.md for complete details.
|
|
51
58
|
#
|
|
52
59
|
# Subclasses can override context_class to use different context implementations.
|
|
53
60
|
class View
|
|
61
|
+
extend Forwardable
|
|
62
|
+
include Rhales::Utils::LoggingHelpers
|
|
63
|
+
|
|
54
64
|
class RenderError < StandardError; end
|
|
55
65
|
class TemplateNotFoundError < RenderError; end
|
|
56
66
|
|
|
57
|
-
attr_reader :req, :
|
|
67
|
+
attr_reader :req, :rsfc_context
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
@sess = sess
|
|
62
|
-
@cust = cust
|
|
63
|
-
@locale = locale_override
|
|
64
|
-
@props = props
|
|
65
|
-
@config = config || Rhales.configuration
|
|
69
|
+
# Delegate context accessors to rsfc_context
|
|
70
|
+
def_delegators :@rsfc_context, :sess, :user, :client, :server, :config, :locale
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
@
|
|
72
|
+
def initialize(req, client: {}, server: {}, config: nil)
|
|
73
|
+
@req = req
|
|
74
|
+
@rsfc_context = context_class.for_view(req, client: client, server: server, config: config || Rhales.configuration)
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
# Render RSFC template with hydration using two-pass architecture
|
|
72
78
|
def render(template_name = nil)
|
|
79
|
+
start_time = now_in_μs
|
|
73
80
|
template_name ||= self.class.default_template_name
|
|
74
81
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
78
|
-
merged_hydration_data = aggregator.aggregate(composition)
|
|
82
|
+
# Store template name in request env for middleware validation
|
|
83
|
+
@req.env['rhales.template_name'] = template_name if @req && @req.respond_to?(:env)
|
|
79
84
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
begin
|
|
86
|
+
# Phase 1: Build view composition and aggregate data
|
|
87
|
+
composition = build_view_composition(template_name)
|
|
88
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
89
|
+
merged_hydration_data = aggregator.aggregate(composition)
|
|
83
90
|
|
|
84
|
-
|
|
85
|
-
|
|
91
|
+
# Phase 2: Render HTML with pre-computed data
|
|
92
|
+
# Render template content
|
|
93
|
+
template_html = render_template_with_composition(composition, template_name)
|
|
86
94
|
|
|
87
|
-
|
|
88
|
-
|
|
95
|
+
# Generate hydration HTML with merged data
|
|
96
|
+
hydration_html = generate_hydration_from_merged_data(merged_hydration_data)
|
|
89
97
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
# Set CSP header if enabled
|
|
99
|
+
set_csp_header_if_enabled
|
|
100
|
+
|
|
101
|
+
# Smart hydration injection with mount point detection
|
|
102
|
+
result = inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
|
|
103
|
+
|
|
104
|
+
# Log successful render
|
|
105
|
+
duration = now_in_μs - start_time
|
|
106
|
+
hydration_size = merged_hydration_data.to_json.bytesize if merged_hydration_data
|
|
107
|
+
|
|
108
|
+
log_with_metadata(Rhales.logger, :debug, 'View rendered',
|
|
109
|
+
template: template_name,
|
|
110
|
+
layout: composition.layout,
|
|
111
|
+
partials: composition.dependencies.values.flatten.uniq,
|
|
112
|
+
duration: duration,
|
|
113
|
+
hydration_size_bytes: hydration_size
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
result
|
|
117
|
+
rescue StandardError => ex
|
|
118
|
+
duration = now_in_μs - start_time
|
|
119
|
+
|
|
120
|
+
log_with_metadata(Rhales.logger, :error, 'View render failed',
|
|
121
|
+
template: template_name,
|
|
122
|
+
duration: duration,
|
|
123
|
+
error: ex.message,
|
|
124
|
+
error_class: ex.class.name
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
raise RenderError, "Failed to render template '#{template_name}': #{ex.message}"
|
|
128
|
+
end
|
|
94
129
|
end
|
|
95
130
|
|
|
96
131
|
# Render only the template section (without data hydration)
|
|
@@ -104,46 +139,46 @@ module Rhales
|
|
|
104
139
|
|
|
105
140
|
# Render JSON response for API endpoints (link-based strategies)
|
|
106
141
|
def render_json_only(template_name = nil, additional_context = {})
|
|
107
|
-
require_relative 'hydration_endpoint'
|
|
142
|
+
require_relative '../hydration/hydration_endpoint'
|
|
108
143
|
|
|
109
144
|
template_name ||= self.class.default_template_name
|
|
110
|
-
endpoint = HydrationEndpoint.new(
|
|
145
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
111
146
|
endpoint.render_json(template_name, additional_context)
|
|
112
147
|
end
|
|
113
148
|
|
|
114
149
|
# Render ES module response for modulepreload strategy
|
|
115
150
|
def render_module_only(template_name = nil, additional_context = {})
|
|
116
|
-
require_relative 'hydration_endpoint'
|
|
151
|
+
require_relative '../hydration/hydration_endpoint'
|
|
117
152
|
|
|
118
153
|
template_name ||= self.class.default_template_name
|
|
119
|
-
endpoint = HydrationEndpoint.new(
|
|
154
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
120
155
|
endpoint.render_module(template_name, additional_context)
|
|
121
156
|
end
|
|
122
157
|
|
|
123
158
|
# Render JSONP response with callback
|
|
124
159
|
def render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {})
|
|
125
|
-
require_relative 'hydration_endpoint'
|
|
160
|
+
require_relative '../hydration/hydration_endpoint'
|
|
126
161
|
|
|
127
162
|
template_name ||= self.class.default_template_name
|
|
128
|
-
endpoint = HydrationEndpoint.new(
|
|
163
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
129
164
|
endpoint.render_jsonp(template_name, callback_name, additional_context)
|
|
130
165
|
end
|
|
131
166
|
|
|
132
167
|
# Check if template data has changed for caching
|
|
133
168
|
def data_changed?(template_name = nil, etag = nil, additional_context = {})
|
|
134
|
-
require_relative 'hydration_endpoint'
|
|
169
|
+
require_relative '../hydration/hydration_endpoint'
|
|
135
170
|
|
|
136
171
|
template_name ||= self.class.default_template_name
|
|
137
|
-
endpoint = HydrationEndpoint.new(
|
|
172
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
138
173
|
endpoint.data_changed?(template_name, etag, additional_context)
|
|
139
174
|
end
|
|
140
175
|
|
|
141
176
|
# Calculate ETag for current template data
|
|
142
177
|
def calculate_etag(template_name = nil, additional_context = {})
|
|
143
|
-
require_relative 'hydration_endpoint'
|
|
178
|
+
require_relative '../hydration/hydration_endpoint'
|
|
144
179
|
|
|
145
180
|
template_name ||= self.class.default_template_name
|
|
146
|
-
endpoint = HydrationEndpoint.new(
|
|
181
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
147
182
|
endpoint.calculate_etag(template_name, additional_context)
|
|
148
183
|
end
|
|
149
184
|
|
|
@@ -172,12 +207,6 @@ module Rhales
|
|
|
172
207
|
|
|
173
208
|
protected
|
|
174
209
|
|
|
175
|
-
# Create the appropriate context for this view
|
|
176
|
-
# Subclasses can override this to use different context types
|
|
177
|
-
def create_context
|
|
178
|
-
context_class.for_view(@req, @sess, @cust, @locale, config: @config, **@props)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
210
|
# Return the context class to use
|
|
182
211
|
# Subclasses can override this to use different context implementations
|
|
183
212
|
def context_class
|
|
@@ -201,8 +230,8 @@ module Rhales
|
|
|
201
230
|
# Resolve template path
|
|
202
231
|
def resolve_template_path(template_name)
|
|
203
232
|
# Check configured template paths first
|
|
204
|
-
if
|
|
205
|
-
|
|
233
|
+
if config && config.template_paths && !config.template_paths.empty?
|
|
234
|
+
config.template_paths.each do |path|
|
|
206
235
|
template_path = File.join(path, "#{template_name}.rue")
|
|
207
236
|
return template_path if File.exist?(template_path)
|
|
208
237
|
end
|
|
@@ -218,8 +247,8 @@ module Rhales
|
|
|
218
247
|
return templates_path if File.exist?(templates_path)
|
|
219
248
|
|
|
220
249
|
# Return first configured path or web path for error message
|
|
221
|
-
if
|
|
222
|
-
File.join(
|
|
250
|
+
if config && config.template_paths && !config.template_paths.empty?
|
|
251
|
+
File.join(config.template_paths.first, "#{template_name}.rue")
|
|
223
252
|
else
|
|
224
253
|
web_path
|
|
225
254
|
end
|
|
@@ -235,10 +264,9 @@ module Rhales
|
|
|
235
264
|
#
|
|
236
265
|
# RSFC Security Model: Templates have full server context access
|
|
237
266
|
# - Templates can access all business data, user objects, methods, etc.
|
|
238
|
-
# - Templates can access data from .rue file's <data> section (processed server-side)
|
|
239
267
|
# - This is like any server-side template (ERB, HAML, etc.)
|
|
240
268
|
# - Security boundary is at server-to-client handoff, not within server rendering
|
|
241
|
-
# - Only data declared in <
|
|
269
|
+
# - Only data declared in <schema> section reaches the client (after validation)
|
|
242
270
|
def render_template_section(parser)
|
|
243
271
|
template_content = parser.section('template')
|
|
244
272
|
return '' unless template_content
|
|
@@ -246,11 +274,8 @@ module Rhales
|
|
|
246
274
|
# Create partial resolver
|
|
247
275
|
partial_resolver = create_partial_resolver
|
|
248
276
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
# Render with full server context (props + computed context + rue data)
|
|
253
|
-
TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
|
277
|
+
# Render with full server context
|
|
278
|
+
TemplateEngine.render(template_content, @rsfc_context, partial_resolver: partial_resolver)
|
|
254
279
|
end
|
|
255
280
|
|
|
256
281
|
# Create partial resolver for {{> partial}} inclusions
|
|
@@ -268,44 +293,12 @@ module Rhales
|
|
|
268
293
|
end
|
|
269
294
|
end
|
|
270
295
|
|
|
271
|
-
# Generate data hydration HTML
|
|
272
|
-
def generate_hydration(parser)
|
|
273
|
-
Hydrator.generate(parser, @rsfc_context)
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Create context that includes data from .rue file's data section
|
|
277
|
-
def create_context_with_rue_data(parser)
|
|
278
|
-
# Get data from .rue file's data section
|
|
279
|
-
rue_data = extract_rue_data(parser)
|
|
280
|
-
|
|
281
|
-
# Merge rue data with existing props (rue data takes precedence)
|
|
282
|
-
merged_props = @props.merge(rue_data)
|
|
283
|
-
|
|
284
|
-
# Create new context with merged data
|
|
285
|
-
context_class.for_view(@req, @sess, @cust, @locale, config: @config, **merged_props)
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
# Extract and process data from .rue file's data section
|
|
289
|
-
def extract_rue_data(parser)
|
|
290
|
-
data_content = parser.section('data')
|
|
291
|
-
return {} unless data_content
|
|
292
|
-
|
|
293
|
-
# Process the data section as JSON and parse it
|
|
294
|
-
hydrator = Hydrator.new(parser, @rsfc_context)
|
|
295
|
-
hydrator.processed_data_hash
|
|
296
|
-
rescue JSON::ParserError, Hydrator::JSONSerializationError => ex
|
|
297
|
-
puts "Error processing data section: #{ex.message}"
|
|
298
|
-
# If data section isn't valid JSON, return empty hash
|
|
299
|
-
# This allows templates to work even with malformed data sections
|
|
300
|
-
{}
|
|
301
|
-
end
|
|
302
|
-
|
|
303
296
|
# Smart hydration injection with mount point detection on rendered HTML
|
|
304
297
|
def inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
|
|
305
|
-
injector = HydrationInjector.new(
|
|
298
|
+
injector = HydrationInjector.new(config.hydration, template_name)
|
|
306
299
|
|
|
307
300
|
# Check if using link-based strategy
|
|
308
|
-
if
|
|
301
|
+
if config.hydration.link_based_strategy?
|
|
309
302
|
# For link-based strategies, we need the merged data context
|
|
310
303
|
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
311
304
|
merged_data = aggregator.aggregate(composition)
|
|
@@ -319,22 +312,11 @@ module Rhales
|
|
|
319
312
|
end
|
|
320
313
|
end
|
|
321
314
|
|
|
322
|
-
# Legacy injection method (kept for backwards compatibility)
|
|
323
|
-
def inject_hydration_into_template(template_html, hydration_html)
|
|
324
|
-
# Try to inject before closing </body> tag
|
|
325
|
-
if template_html.include?('</body>')
|
|
326
|
-
template_html.sub('</body>', "#{hydration_html}\n</body>")
|
|
327
|
-
# Otherwise append to end
|
|
328
|
-
else
|
|
329
|
-
"#{template_html}\n#{hydration_html}"
|
|
330
|
-
end
|
|
331
|
-
end
|
|
332
|
-
|
|
333
315
|
# Detect mount points in fully rendered HTML
|
|
334
316
|
def detect_mount_point_in_rendered_html(template_html)
|
|
335
|
-
return nil unless
|
|
317
|
+
return nil unless config&.hydration
|
|
336
318
|
|
|
337
|
-
custom_selectors =
|
|
319
|
+
custom_selectors = config.hydration.mount_point_selectors || []
|
|
338
320
|
detector = MountPointDetector.new
|
|
339
321
|
detector.detect(template_html, custom_selectors)
|
|
340
322
|
end
|
|
@@ -342,7 +324,7 @@ module Rhales
|
|
|
342
324
|
# Build view composition for the given template
|
|
343
325
|
def build_view_composition(template_name)
|
|
344
326
|
loader = method(:load_template_for_composition)
|
|
345
|
-
composition = ViewComposition.new(template_name, loader: loader, config:
|
|
327
|
+
composition = ViewComposition.new(template_name, loader: loader, config: config)
|
|
346
328
|
composition.resolve!
|
|
347
329
|
end
|
|
348
330
|
|
|
@@ -365,8 +347,8 @@ module Rhales
|
|
|
365
347
|
# Create partial resolver that uses the composition
|
|
366
348
|
partial_resolver = create_partial_resolver_from_composition(composition)
|
|
367
349
|
|
|
368
|
-
#
|
|
369
|
-
context_with_rue_data =
|
|
350
|
+
# Use existing context for rendering
|
|
351
|
+
context_with_rue_data = @rsfc_context
|
|
370
352
|
|
|
371
353
|
# Check if template has a layout
|
|
372
354
|
if root_parser.layout && composition.template(root_parser.layout)
|
|
@@ -378,16 +360,8 @@ module Rhales
|
|
|
378
360
|
layout_content = layout_parser.section('template')
|
|
379
361
|
return '' unless layout_content
|
|
380
362
|
|
|
381
|
-
#
|
|
382
|
-
|
|
383
|
-
layout_context = Context.new(
|
|
384
|
-
context_with_rue_data.req,
|
|
385
|
-
context_with_rue_data.sess,
|
|
386
|
-
context_with_rue_data.cust,
|
|
387
|
-
context_with_rue_data.locale,
|
|
388
|
-
props: layout_props,
|
|
389
|
-
config: context_with_rue_data.config,
|
|
390
|
-
)
|
|
363
|
+
# Use builder pattern to create new context with content for layout rendering
|
|
364
|
+
layout_context = context_with_rue_data.merge_client('content' => content_html)
|
|
391
365
|
|
|
392
366
|
TemplateEngine.render(layout_content, layout_context, partial_resolver: partial_resolver)
|
|
393
367
|
else
|
|
@@ -411,16 +385,16 @@ module Rhales
|
|
|
411
385
|
merged_data.each do |window_attr, data|
|
|
412
386
|
# Generate unique ID for this data block
|
|
413
387
|
unique_id = "rsfc-data-#{SecureRandom.hex(8)}"
|
|
388
|
+
nonce_attr = nonce_attribute
|
|
414
389
|
|
|
415
390
|
# Create JSON script tag with optional reflection attributes
|
|
416
|
-
json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" :
|
|
391
|
+
json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" : ''
|
|
417
392
|
json_script = <<~HTML.strip
|
|
418
|
-
<script id="#{unique_id}" type="application/json"#{json_attrs}>#{
|
|
393
|
+
<script#{nonce_attr} id="#{unique_id}" type="application/json"#{json_attrs}>#{JSONSerializer.dump(data)}</script>
|
|
419
394
|
HTML
|
|
420
395
|
|
|
421
396
|
# Create hydration script with optional reflection attributes
|
|
422
|
-
|
|
423
|
-
hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr}\"" : ""
|
|
397
|
+
hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr}\"" : ''
|
|
424
398
|
hydration_script = if reflection_enabled?
|
|
425
399
|
<<~HTML.strip
|
|
426
400
|
<script#{nonce_attr}#{hydration_attrs}>
|
|
@@ -446,12 +420,15 @@ module Rhales
|
|
|
446
420
|
hydration_parts << generate_reflection_utilities
|
|
447
421
|
end
|
|
448
422
|
|
|
449
|
-
hydration_parts.
|
|
423
|
+
return '' if hydration_parts.empty?
|
|
424
|
+
|
|
425
|
+
hydration_content = hydration_parts.join("\n")
|
|
426
|
+
"\n\n<!-- Rhales Hydration Start -->\n#{hydration_content}\n<!-- Rhales Hydration End -->"
|
|
450
427
|
end
|
|
451
428
|
|
|
452
429
|
# Check if reflection system is enabled
|
|
453
430
|
def reflection_enabled?
|
|
454
|
-
|
|
431
|
+
config.hydration.reflection_enabled
|
|
455
432
|
end
|
|
456
433
|
|
|
457
434
|
# Generate JavaScript utilities for hydration reflection
|
|
@@ -512,14 +489,14 @@ module Rhales
|
|
|
512
489
|
|
|
513
490
|
# Set CSP header if enabled
|
|
514
491
|
def set_csp_header_if_enabled
|
|
515
|
-
return unless
|
|
492
|
+
return unless config.csp_enabled
|
|
516
493
|
return unless @req && @req.respond_to?(:env)
|
|
517
494
|
|
|
518
495
|
# Get nonce from context
|
|
519
496
|
nonce = @rsfc_context.get('nonce')
|
|
520
497
|
|
|
521
498
|
# Create CSP instance and build header
|
|
522
|
-
csp = CSP.new(
|
|
499
|
+
csp = CSP.new(config, nonce: nonce)
|
|
523
500
|
header_value = csp.build_header
|
|
524
501
|
|
|
525
502
|
# Set header in request environment for framework to use
|
|
@@ -537,15 +514,15 @@ module Rhales
|
|
|
537
514
|
.sub(/_view$/, '')
|
|
538
515
|
end
|
|
539
516
|
|
|
540
|
-
# Render template with
|
|
541
|
-
def render_with_data(req,
|
|
542
|
-
view = new(req,
|
|
517
|
+
# Render template with client data
|
|
518
|
+
def render_with_data(req, template_name: nil, config: nil, **client_data)
|
|
519
|
+
view = new(req, client: client_data, config: config)
|
|
543
520
|
view.render(template_name)
|
|
544
521
|
end
|
|
545
522
|
|
|
546
|
-
# Create view instance with
|
|
547
|
-
def with_data(req,
|
|
548
|
-
new(req,
|
|
523
|
+
# Create view instance with client data
|
|
524
|
+
def with_data(req, config: nil, **client_data)
|
|
525
|
+
new(req, client: client_data, config: config)
|
|
549
526
|
end
|
|
550
527
|
end
|
|
551
528
|
end
|