rhales 0.3.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 -2
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +706 -589
- 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 +161 -1
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
- data/lib/rhales/core/view.rb +529 -0
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
- 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/hydration/earliest_injection_detector.rb +153 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
- data/lib/rhales/hydration/hydration_injector.rb +175 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
- data/lib/rhales/hydration/mount_point_detector.rb +109 -0
- data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
- 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 +55 -36
- 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 +5 -1
- data/lib/rhales.rb +47 -19
- 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 +142 -18
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -240
- data/lib/rhales/hydration_data_aggregator.rb +0 -220
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
- data/lib/rhales/view.rb +0 -412
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# lib/rhales/view.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'forwardable'
|
|
7
|
+
require_relative 'context'
|
|
8
|
+
require_relative 'rue_document'
|
|
9
|
+
require_relative 'template_engine'
|
|
10
|
+
require_relative '../hydration/hydrator'
|
|
11
|
+
require_relative 'view_composition'
|
|
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'
|
|
16
|
+
|
|
17
|
+
using Rhales::Ruequire
|
|
18
|
+
|
|
19
|
+
module Rhales
|
|
20
|
+
# Complete RSFC view implementation
|
|
21
|
+
#
|
|
22
|
+
# Single public interface for RSFC template rendering that handles:
|
|
23
|
+
# - Context creation (with pluggable context classes)
|
|
24
|
+
# - Template loading and parsing
|
|
25
|
+
# - Template rendering with Rhales
|
|
26
|
+
# - Data hydration and injection
|
|
27
|
+
#
|
|
28
|
+
# ## Context and Data Boundaries
|
|
29
|
+
#
|
|
30
|
+
# Views implement a two-phase security model:
|
|
31
|
+
#
|
|
32
|
+
# ### Server Templates: Full Context Access
|
|
33
|
+
# Templates have complete access to all server-side data:
|
|
34
|
+
# - All client data passed to View.new
|
|
35
|
+
# - All server data passed to View.new
|
|
36
|
+
|
|
37
|
+
# - Runtime data (CSRF tokens, nonces, request metadata)
|
|
38
|
+
# - Computed data (authentication status, theme classes)
|
|
39
|
+
# - User objects, configuration, internal APIs
|
|
40
|
+
#
|
|
41
|
+
# ### Client Data: Explicit Allowlist
|
|
42
|
+
# Only client data declared in <schema> sections reaches the browser:
|
|
43
|
+
# - Creates a REST API-like boundary
|
|
44
|
+
# - Server-side variable interpolation processes secrets safely
|
|
45
|
+
# - JSON serialization validates data structure
|
|
46
|
+
# - No accidental exposure of sensitive server data
|
|
47
|
+
#
|
|
48
|
+
# Example:
|
|
49
|
+
# # Server template has full access:
|
|
50
|
+
# {{user.admin?}} {{csrf_token}} {{internal_config}}
|
|
51
|
+
#
|
|
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>
|
|
56
|
+
#
|
|
57
|
+
# See docs/CONTEXT_AND_DATA_BOUNDARIES.md for complete details.
|
|
58
|
+
#
|
|
59
|
+
# Subclasses can override context_class to use different context implementations.
|
|
60
|
+
class View
|
|
61
|
+
extend Forwardable
|
|
62
|
+
include Rhales::Utils::LoggingHelpers
|
|
63
|
+
|
|
64
|
+
class RenderError < StandardError; end
|
|
65
|
+
class TemplateNotFoundError < RenderError; end
|
|
66
|
+
|
|
67
|
+
attr_reader :req, :rsfc_context
|
|
68
|
+
|
|
69
|
+
# Delegate context accessors to rsfc_context
|
|
70
|
+
def_delegators :@rsfc_context, :sess, :user, :client, :server, :config, :locale
|
|
71
|
+
|
|
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)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Render RSFC template with hydration using two-pass architecture
|
|
78
|
+
def render(template_name = nil)
|
|
79
|
+
start_time = now_in_μs
|
|
80
|
+
template_name ||= self.class.default_template_name
|
|
81
|
+
|
|
82
|
+
# Store template name in request env for middleware validation
|
|
83
|
+
@req.env['rhales.template_name'] = template_name if @req && @req.respond_to?(:env)
|
|
84
|
+
|
|
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)
|
|
90
|
+
|
|
91
|
+
# Phase 2: Render HTML with pre-computed data
|
|
92
|
+
# Render template content
|
|
93
|
+
template_html = render_template_with_composition(composition, template_name)
|
|
94
|
+
|
|
95
|
+
# Generate hydration HTML with merged data
|
|
96
|
+
hydration_html = generate_hydration_from_merged_data(merged_hydration_data)
|
|
97
|
+
|
|
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
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Render only the template section (without data hydration)
|
|
132
|
+
def render_template_only(template_name = nil)
|
|
133
|
+
template_name ||= self.class.default_template_name
|
|
134
|
+
|
|
135
|
+
# Build composition for consistent behavior
|
|
136
|
+
composition = build_view_composition(template_name)
|
|
137
|
+
render_template_with_composition(composition, template_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Render JSON response for API endpoints (link-based strategies)
|
|
141
|
+
def render_json_only(template_name = nil, additional_context = {})
|
|
142
|
+
require_relative '../hydration/hydration_endpoint'
|
|
143
|
+
|
|
144
|
+
template_name ||= self.class.default_template_name
|
|
145
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
146
|
+
endpoint.render_json(template_name, additional_context)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Render ES module response for modulepreload strategy
|
|
150
|
+
def render_module_only(template_name = nil, additional_context = {})
|
|
151
|
+
require_relative '../hydration/hydration_endpoint'
|
|
152
|
+
|
|
153
|
+
template_name ||= self.class.default_template_name
|
|
154
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
155
|
+
endpoint.render_module(template_name, additional_context)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Render JSONP response with callback
|
|
159
|
+
def render_jsonp_only(template_name = nil, callback_name = 'callback', additional_context = {})
|
|
160
|
+
require_relative '../hydration/hydration_endpoint'
|
|
161
|
+
|
|
162
|
+
template_name ||= self.class.default_template_name
|
|
163
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
164
|
+
endpoint.render_jsonp(template_name, callback_name, additional_context)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check if template data has changed for caching
|
|
168
|
+
def data_changed?(template_name = nil, etag = nil, additional_context = {})
|
|
169
|
+
require_relative '../hydration/hydration_endpoint'
|
|
170
|
+
|
|
171
|
+
template_name ||= self.class.default_template_name
|
|
172
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
173
|
+
endpoint.data_changed?(template_name, etag, additional_context)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Calculate ETag for current template data
|
|
177
|
+
def calculate_etag(template_name = nil, additional_context = {})
|
|
178
|
+
require_relative '../hydration/hydration_endpoint'
|
|
179
|
+
|
|
180
|
+
template_name ||= self.class.default_template_name
|
|
181
|
+
endpoint = HydrationEndpoint.new(config, @rsfc_context)
|
|
182
|
+
endpoint.calculate_etag(template_name, additional_context)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Generate only the data hydration HTML
|
|
186
|
+
def render_hydration_only(template_name = nil)
|
|
187
|
+
template_name ||= self.class.default_template_name
|
|
188
|
+
|
|
189
|
+
# Build composition and aggregate data
|
|
190
|
+
composition = build_view_composition(template_name)
|
|
191
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
192
|
+
merged_hydration_data = aggregator.aggregate(composition)
|
|
193
|
+
|
|
194
|
+
# Generate hydration HTML
|
|
195
|
+
generate_hydration_from_merged_data(merged_hydration_data)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Get processed data as hash (for API endpoints or testing)
|
|
199
|
+
def data_hash(template_name = nil)
|
|
200
|
+
template_name ||= self.class.default_template_name
|
|
201
|
+
|
|
202
|
+
# Build composition and aggregate data
|
|
203
|
+
composition = build_view_composition(template_name)
|
|
204
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
205
|
+
aggregator.aggregate(composition)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
protected
|
|
209
|
+
|
|
210
|
+
# Return the context class to use
|
|
211
|
+
# Subclasses can override this to use different context implementations
|
|
212
|
+
def context_class
|
|
213
|
+
Context
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Load and parse template
|
|
219
|
+
def load_template(template_name)
|
|
220
|
+
template_path = resolve_template_path(template_name)
|
|
221
|
+
|
|
222
|
+
unless File.exist?(template_path)
|
|
223
|
+
raise TemplateNotFoundError, "Template not found: #{template_path}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Use refinement to load .rue file
|
|
227
|
+
require template_path
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Resolve template path
|
|
231
|
+
def resolve_template_path(template_name)
|
|
232
|
+
# Check configured template paths first
|
|
233
|
+
if config && config.template_paths && !config.template_paths.empty?
|
|
234
|
+
config.template_paths.each do |path|
|
|
235
|
+
template_path = File.join(path, "#{template_name}.rue")
|
|
236
|
+
return template_path if File.exist?(template_path)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Fallback to default template structure
|
|
241
|
+
# First try templates/web directory
|
|
242
|
+
web_path = File.join(templates_root, 'web', "#{template_name}.rue")
|
|
243
|
+
return web_path if File.exist?(web_path)
|
|
244
|
+
|
|
245
|
+
# Then try templates directory
|
|
246
|
+
templates_path = File.join(templates_root, "#{template_name}.rue")
|
|
247
|
+
return templates_path if File.exist?(templates_path)
|
|
248
|
+
|
|
249
|
+
# Return first configured path or web path for error message
|
|
250
|
+
if config && config.template_paths && !config.template_paths.empty?
|
|
251
|
+
File.join(config.template_paths.first, "#{template_name}.rue")
|
|
252
|
+
else
|
|
253
|
+
web_path
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Get templates root directory
|
|
258
|
+
def templates_root
|
|
259
|
+
boot_root = File.expand_path('../../..', __dir__)
|
|
260
|
+
File.join(boot_root, 'templates')
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Render template section with Rhales
|
|
264
|
+
#
|
|
265
|
+
# RSFC Security Model: Templates have full server context access
|
|
266
|
+
# - Templates can access all business data, user objects, methods, etc.
|
|
267
|
+
# - This is like any server-side template (ERB, HAML, etc.)
|
|
268
|
+
# - Security boundary is at server-to-client handoff, not within server rendering
|
|
269
|
+
# - Only data declared in <schema> section reaches the client (after validation)
|
|
270
|
+
def render_template_section(parser)
|
|
271
|
+
template_content = parser.section('template')
|
|
272
|
+
return '' unless template_content
|
|
273
|
+
|
|
274
|
+
# Create partial resolver
|
|
275
|
+
partial_resolver = create_partial_resolver
|
|
276
|
+
|
|
277
|
+
# Render with full server context
|
|
278
|
+
TemplateEngine.render(template_content, @rsfc_context, partial_resolver: partial_resolver)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Create partial resolver for {{> partial}} inclusions
|
|
282
|
+
def create_partial_resolver
|
|
283
|
+
templates_dir = File.join(templates_root, 'web')
|
|
284
|
+
|
|
285
|
+
proc do |partial_name|
|
|
286
|
+
partial_path = File.join(templates_dir, "#{partial_name}.rue")
|
|
287
|
+
|
|
288
|
+
if File.exist?(partial_path)
|
|
289
|
+
# Return full partial content so TemplateEngine can process
|
|
290
|
+
# data sections, otherwise nil.
|
|
291
|
+
File.read(partial_path)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Smart hydration injection with mount point detection on rendered HTML
|
|
297
|
+
def inject_hydration_with_mount_points(composition, template_name, template_html, hydration_html)
|
|
298
|
+
injector = HydrationInjector.new(config.hydration, template_name)
|
|
299
|
+
|
|
300
|
+
# Check if using link-based strategy
|
|
301
|
+
if config.hydration.link_based_strategy?
|
|
302
|
+
# For link-based strategies, we need the merged data context
|
|
303
|
+
aggregator = HydrationDataAggregator.new(@rsfc_context)
|
|
304
|
+
merged_data = aggregator.aggregate(composition)
|
|
305
|
+
nonce = @rsfc_context.get('nonce')
|
|
306
|
+
|
|
307
|
+
injector.inject_link_based_strategy(template_html, merged_data, nonce)
|
|
308
|
+
else
|
|
309
|
+
# Traditional strategies (early, earliest, late)
|
|
310
|
+
mount_point = detect_mount_point_in_rendered_html(template_html)
|
|
311
|
+
injector.inject(template_html, hydration_html, mount_point)
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Detect mount points in fully rendered HTML
|
|
316
|
+
def detect_mount_point_in_rendered_html(template_html)
|
|
317
|
+
return nil unless config&.hydration
|
|
318
|
+
|
|
319
|
+
custom_selectors = config.hydration.mount_point_selectors || []
|
|
320
|
+
detector = MountPointDetector.new
|
|
321
|
+
detector.detect(template_html, custom_selectors)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Build view composition for the given template
|
|
325
|
+
def build_view_composition(template_name)
|
|
326
|
+
loader = method(:load_template_for_composition)
|
|
327
|
+
composition = ViewComposition.new(template_name, loader: loader, config: config)
|
|
328
|
+
composition.resolve!
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Loader proc for ViewComposition
|
|
332
|
+
def load_template_for_composition(template_name)
|
|
333
|
+
template_path = resolve_template_path(template_name)
|
|
334
|
+
return nil unless File.exist?(template_path)
|
|
335
|
+
|
|
336
|
+
require template_path
|
|
337
|
+
rescue StandardError => ex
|
|
338
|
+
raise TemplateNotFoundError, "Failed to load template #{template_name}: #{ex.message}"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Render template using the view composition
|
|
342
|
+
def render_template_with_composition(composition, root_template_name)
|
|
343
|
+
root_parser = composition.template(root_template_name)
|
|
344
|
+
template_content = root_parser.section('template')
|
|
345
|
+
return '' unless template_content
|
|
346
|
+
|
|
347
|
+
# Create partial resolver that uses the composition
|
|
348
|
+
partial_resolver = create_partial_resolver_from_composition(composition)
|
|
349
|
+
|
|
350
|
+
# Use existing context for rendering
|
|
351
|
+
context_with_rue_data = @rsfc_context
|
|
352
|
+
|
|
353
|
+
# Check if template has a layout
|
|
354
|
+
if root_parser.layout && composition.template(root_parser.layout)
|
|
355
|
+
# Render content template first
|
|
356
|
+
content_html = TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
|
357
|
+
|
|
358
|
+
# Then render layout with content
|
|
359
|
+
layout_parser = composition.template(root_parser.layout)
|
|
360
|
+
layout_content = layout_parser.section('template')
|
|
361
|
+
return '' unless layout_content
|
|
362
|
+
|
|
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)
|
|
365
|
+
|
|
366
|
+
TemplateEngine.render(layout_content, layout_context, partial_resolver: partial_resolver)
|
|
367
|
+
else
|
|
368
|
+
# Render with full server context (no layout)
|
|
369
|
+
TemplateEngine.render(template_content, context_with_rue_data, partial_resolver: partial_resolver)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Create partial resolver that uses pre-loaded templates from composition
|
|
374
|
+
def create_partial_resolver_from_composition(composition)
|
|
375
|
+
proc do |partial_name|
|
|
376
|
+
parser = composition.template(partial_name)
|
|
377
|
+
parser ? parser.content : nil
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Generate hydration HTML from pre-merged data
|
|
382
|
+
def generate_hydration_from_merged_data(merged_data)
|
|
383
|
+
hydration_parts = []
|
|
384
|
+
|
|
385
|
+
merged_data.each do |window_attr, data|
|
|
386
|
+
# Generate unique ID for this data block
|
|
387
|
+
unique_id = "rsfc-data-#{SecureRandom.hex(8)}"
|
|
388
|
+
nonce_attr = nonce_attribute
|
|
389
|
+
|
|
390
|
+
# Create JSON script tag with optional reflection attributes
|
|
391
|
+
json_attrs = reflection_enabled? ? " data-window=\"#{window_attr}\"" : ''
|
|
392
|
+
json_script = <<~HTML.strip
|
|
393
|
+
<script#{nonce_attr} id="#{unique_id}" type="application/json"#{json_attrs}>#{JSONSerializer.dump(data)}</script>
|
|
394
|
+
HTML
|
|
395
|
+
|
|
396
|
+
# Create hydration script with optional reflection attributes
|
|
397
|
+
hydration_attrs = reflection_enabled? ? " data-hydration-target=\"#{window_attr}\"" : ''
|
|
398
|
+
hydration_script = if reflection_enabled?
|
|
399
|
+
<<~HTML.strip
|
|
400
|
+
<script#{nonce_attr}#{hydration_attrs}>
|
|
401
|
+
var dataScript = document.getElementById('#{unique_id}');
|
|
402
|
+
var targetName = dataScript.getAttribute('data-window') || '#{window_attr}';
|
|
403
|
+
window[targetName] = JSON.parse(dataScript.textContent);
|
|
404
|
+
</script>
|
|
405
|
+
HTML
|
|
406
|
+
else
|
|
407
|
+
<<~HTML.strip
|
|
408
|
+
<script#{nonce_attr}#{hydration_attrs}>
|
|
409
|
+
window['#{window_attr}'] = JSON.parse(document.getElementById('#{unique_id}').textContent);
|
|
410
|
+
</script>
|
|
411
|
+
HTML
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
hydration_parts << json_script
|
|
415
|
+
hydration_parts << hydration_script
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Add reflection utilities if enabled
|
|
419
|
+
if reflection_enabled? && !merged_data.empty?
|
|
420
|
+
hydration_parts << generate_reflection_utilities
|
|
421
|
+
end
|
|
422
|
+
|
|
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 -->"
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Check if reflection system is enabled
|
|
430
|
+
def reflection_enabled?
|
|
431
|
+
config.hydration.reflection_enabled
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Generate JavaScript utilities for hydration reflection
|
|
435
|
+
def generate_reflection_utilities
|
|
436
|
+
nonce_attr = nonce_attribute
|
|
437
|
+
|
|
438
|
+
<<~HTML.strip
|
|
439
|
+
<script#{nonce_attr}>
|
|
440
|
+
// Rhales hydration reflection utilities
|
|
441
|
+
window.__rhales__ = window.__rhales__ || {
|
|
442
|
+
getHydrationTargets: function() {
|
|
443
|
+
return Array.from(document.querySelectorAll('[data-hydration-target]'));
|
|
444
|
+
},
|
|
445
|
+
getDataForTarget: function(target) {
|
|
446
|
+
var targetName = target.dataset.hydrationTarget;
|
|
447
|
+
return targetName ? window[targetName] : undefined;
|
|
448
|
+
},
|
|
449
|
+
getWindowAttribute: function(scriptEl) {
|
|
450
|
+
return scriptEl.dataset.window;
|
|
451
|
+
},
|
|
452
|
+
getDataScripts: function() {
|
|
453
|
+
return Array.from(document.querySelectorAll('script[data-window]'));
|
|
454
|
+
},
|
|
455
|
+
refreshData: function(target) {
|
|
456
|
+
var targetName = target.dataset.hydrationTarget;
|
|
457
|
+
var dataScript = document.querySelector('script[data-window="' + targetName + '"]');
|
|
458
|
+
if (dataScript && targetName) {
|
|
459
|
+
try {
|
|
460
|
+
window[targetName] = JSON.parse(dataScript.textContent);
|
|
461
|
+
return true;
|
|
462
|
+
} catch (e) {
|
|
463
|
+
console.error('Rhales: Failed to refresh data for ' + targetName, e);
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return false;
|
|
468
|
+
},
|
|
469
|
+
getAllHydrationData: function() {
|
|
470
|
+
var data = {};
|
|
471
|
+
this.getHydrationTargets().forEach(function(target) {
|
|
472
|
+
var targetName = target.dataset.hydrationTarget;
|
|
473
|
+
if (targetName) {
|
|
474
|
+
data[targetName] = window[targetName];
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
return data;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
</script>
|
|
481
|
+
HTML
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Get nonce attribute if available
|
|
485
|
+
def nonce_attribute
|
|
486
|
+
nonce = @rsfc_context.get('nonce')
|
|
487
|
+
nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# Set CSP header if enabled
|
|
491
|
+
def set_csp_header_if_enabled
|
|
492
|
+
return unless config.csp_enabled
|
|
493
|
+
return unless @req && @req.respond_to?(:env)
|
|
494
|
+
|
|
495
|
+
# Get nonce from context
|
|
496
|
+
nonce = @rsfc_context.get('nonce')
|
|
497
|
+
|
|
498
|
+
# Create CSP instance and build header
|
|
499
|
+
csp = CSP.new(config, nonce: nonce)
|
|
500
|
+
header_value = csp.build_header
|
|
501
|
+
|
|
502
|
+
# Set header in request environment for framework to use
|
|
503
|
+
@req.env['csp_header'] = header_value if header_value
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
class << self
|
|
507
|
+
# Get default template name based on class name
|
|
508
|
+
def default_template_name
|
|
509
|
+
# Convert ClassName to class_name
|
|
510
|
+
name.split('::').last
|
|
511
|
+
.gsub(/([A-Z])/, '_\1')
|
|
512
|
+
.downcase
|
|
513
|
+
.sub(/^_/, '')
|
|
514
|
+
.sub(/_view$/, '')
|
|
515
|
+
end
|
|
516
|
+
|
|
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)
|
|
520
|
+
view.render(template_name)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Create view instance with client data
|
|
524
|
+
def with_data(req, config: nil, **client_data)
|
|
525
|
+
new(req, client: client_data, config: config)
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# lib/rhales/view_composition.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require_relative 'rue_document'
|
|
4
|
-
require_relative 'refinements/require_refinements'
|
|
6
|
+
require_relative '../integrations/refinements/require_refinements'
|
|
5
7
|
|
|
6
8
|
using Rhales::Ruequire
|
|
7
9
|
|
|
@@ -23,14 +25,17 @@ module Rhales
|
|
|
23
25
|
# - Immutable: Once created, the composition is read-only
|
|
24
26
|
# - Cacheable: Can be cached in production for performance
|
|
25
27
|
class ViewComposition
|
|
28
|
+
include Rhales::Utils::LoggingHelpers
|
|
29
|
+
|
|
26
30
|
class TemplateNotFoundError < StandardError; end
|
|
27
31
|
class CircularDependencyError < StandardError; end
|
|
28
32
|
|
|
29
33
|
attr_reader :root_template_name, :templates, :dependencies
|
|
30
34
|
|
|
31
|
-
def initialize(root_template_name, loader:)
|
|
35
|
+
def initialize(root_template_name, loader:, config: nil)
|
|
32
36
|
@root_template_name = root_template_name
|
|
33
37
|
@loader = loader
|
|
38
|
+
@config = config
|
|
34
39
|
@templates = {}
|
|
35
40
|
@dependencies = {}
|
|
36
41
|
@loading = Set.new
|
|
@@ -38,9 +43,23 @@ module Rhales
|
|
|
38
43
|
|
|
39
44
|
# Resolve all template dependencies
|
|
40
45
|
def resolve!
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
log_timed_operation(Rhales.logger, :debug, 'Template dependency resolution',
|
|
47
|
+
root_template: @root_template_name
|
|
48
|
+
) do
|
|
49
|
+
load_template_recursive(@root_template_name)
|
|
50
|
+
freeze_composition
|
|
51
|
+
|
|
52
|
+
# Log resolution results
|
|
53
|
+
log_with_metadata(Rhales.logger, :debug, 'Template composition resolved',
|
|
54
|
+
root_template: @root_template_name,
|
|
55
|
+
total_templates: @templates.size,
|
|
56
|
+
total_dependencies: @dependencies.values.sum(&:size),
|
|
57
|
+
partials: @dependencies.values.flatten.uniq,
|
|
58
|
+
layout: layout
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self
|
|
62
|
+
end
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
# Iterate through all documents in render order
|
|
@@ -69,7 +88,7 @@ module Rhales
|
|
|
69
88
|
end
|
|
70
89
|
|
|
71
90
|
# Check if a template exists in the composition
|
|
72
|
-
def
|
|
91
|
+
def template?(name)
|
|
73
92
|
@templates.key?(name)
|
|
74
93
|
end
|
|
75
94
|
|
|
@@ -83,24 +102,50 @@ module Rhales
|
|
|
83
102
|
@dependencies[template_name] || []
|
|
84
103
|
end
|
|
85
104
|
|
|
105
|
+
# Get the layout for the root template (if any)
|
|
106
|
+
def layout
|
|
107
|
+
root_template = @templates[@root_template_name]
|
|
108
|
+
return nil unless root_template
|
|
109
|
+
|
|
110
|
+
root_template.layout
|
|
111
|
+
end
|
|
112
|
+
|
|
86
113
|
private
|
|
87
114
|
|
|
88
115
|
def load_template_recursive(template_name, parent_path = nil)
|
|
116
|
+
depth = @loading.size
|
|
117
|
+
|
|
89
118
|
# Check for circular dependencies
|
|
90
119
|
if @loading.include?(template_name)
|
|
120
|
+
log_with_metadata(Rhales.logger, :error, 'Circular dependency detected',
|
|
121
|
+
template: template_name,
|
|
122
|
+
dependency_chain: @loading.to_a,
|
|
123
|
+
depth: depth
|
|
124
|
+
)
|
|
91
125
|
raise CircularDependencyError, "Circular dependency detected: #{template_name} -> #{@loading.to_a.join(' -> ')}"
|
|
92
126
|
end
|
|
93
127
|
|
|
94
|
-
# Skip if already loaded
|
|
95
|
-
|
|
128
|
+
# Skip if already loaded (cache hit)
|
|
129
|
+
if @templates.key?(template_name)
|
|
130
|
+
Rhales.logger.debug("Template cache hit: template=#{template_name}")
|
|
131
|
+
return
|
|
132
|
+
end
|
|
96
133
|
|
|
97
134
|
@loading.add(template_name)
|
|
98
135
|
|
|
99
136
|
begin
|
|
100
137
|
# Load template using the provided loader
|
|
138
|
+
start_time = now_in_μs
|
|
101
139
|
parser = @loader.call(template_name)
|
|
140
|
+
load_duration = now_in_μs - start_time
|
|
102
141
|
|
|
103
142
|
unless parser
|
|
143
|
+
log_with_metadata(Rhales.logger, :error, 'Template not found',
|
|
144
|
+
template: template_name,
|
|
145
|
+
parent: parent_path,
|
|
146
|
+
depth: depth,
|
|
147
|
+
search_duration: load_duration
|
|
148
|
+
)
|
|
104
149
|
raise TemplateNotFoundError, "Template not found: #{template_name}"
|
|
105
150
|
end
|
|
106
151
|
|
|
@@ -109,15 +154,42 @@ module Rhales
|
|
|
109
154
|
@dependencies[template_name] = []
|
|
110
155
|
|
|
111
156
|
# Extract and load partials
|
|
112
|
-
extract_partials(parser)
|
|
157
|
+
partials = extract_partials(parser)
|
|
158
|
+
|
|
159
|
+
if partials.any?
|
|
160
|
+
log_with_metadata(Rhales.logger, :debug, 'Resolved partial',
|
|
161
|
+
template: template_name,
|
|
162
|
+
partials_found: partials,
|
|
163
|
+
partial_count: partials.size,
|
|
164
|
+
depth: depth,
|
|
165
|
+
load_duration: load_duration
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
partials.each do |partial_name|
|
|
113
170
|
@dependencies[template_name] << partial_name
|
|
114
171
|
load_template_recursive(partial_name, template_name)
|
|
115
172
|
end
|
|
116
173
|
|
|
117
174
|
# Load layout if specified and not already loaded
|
|
118
175
|
if parser.layout && !@templates.key?(parser.layout)
|
|
176
|
+
log_with_metadata(Rhales.logger, :debug, 'Layout resolution',
|
|
177
|
+
template: template_name,
|
|
178
|
+
layout: parser.layout,
|
|
179
|
+
depth: depth
|
|
180
|
+
)
|
|
119
181
|
load_template_recursive(parser.layout, template_name)
|
|
120
182
|
end
|
|
183
|
+
|
|
184
|
+
# Log successful template load
|
|
185
|
+
log_with_metadata(Rhales.logger, :debug, 'Template loaded',
|
|
186
|
+
template: template_name,
|
|
187
|
+
parent: parent_path,
|
|
188
|
+
depth: depth,
|
|
189
|
+
has_partials: partials.any?,
|
|
190
|
+
has_layout: !parser.layout.nil?,
|
|
191
|
+
load_duration: load_duration
|
|
192
|
+
)
|
|
121
193
|
ensure
|
|
122
194
|
@loading.delete(template_name)
|
|
123
195
|
end
|
data/lib/rhales/core.rb
ADDED