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.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/.github/renovate.json5 +52 -0
  3. data/.github/workflows/ci.yml +123 -0
  4. data/.github/workflows/claude-code-review.yml +69 -0
  5. data/.github/workflows/claude.yml +49 -0
  6. data/.github/workflows/code-smells.yml +146 -0
  7. data/.github/workflows/ruby-lint.yml +78 -0
  8. data/.github/workflows/yardoc.yml +126 -0
  9. data/.gitignore +55 -0
  10. data/.pr_agent.toml +63 -0
  11. data/.pre-commit-config.yaml +89 -0
  12. data/.prettierignore +8 -0
  13. data/.prettierrc +38 -0
  14. data/.reek.yml +98 -0
  15. data/.rubocop.yml +428 -0
  16. data/.serena/.gitignore +3 -0
  17. data/.yardopts +56 -0
  18. data/CHANGELOG.md +44 -0
  19. data/CLAUDE.md +1 -2
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +706 -589
  23. data/Rakefile +46 -0
  24. data/debug_context.rb +25 -0
  25. data/demo/rhales-roda-demo/.gitignore +7 -0
  26. data/demo/rhales-roda-demo/Gemfile +32 -0
  27. data/demo/rhales-roda-demo/Gemfile.lock +151 -0
  28. data/demo/rhales-roda-demo/MAIL.md +405 -0
  29. data/demo/rhales-roda-demo/README.md +376 -0
  30. data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
  31. data/demo/rhales-roda-demo/Rakefile +49 -0
  32. data/demo/rhales-roda-demo/app.rb +325 -0
  33. data/demo/rhales-roda-demo/bin/rackup +26 -0
  34. data/demo/rhales-roda-demo/config.ru +13 -0
  35. data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
  36. data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
  37. data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
  38. data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
  39. data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
  40. data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
  41. data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
  42. data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
  43. data/demo/rhales-roda-demo/templates/home.rue +78 -0
  44. data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
  45. data/demo/rhales-roda-demo/templates/login.rue +65 -0
  46. data/demo/rhales-roda-demo/templates/logout.rue +25 -0
  47. data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
  48. data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
  49. data/demo/rhales-roda-demo/test_full_output.rb +27 -0
  50. data/demo/rhales-roda-demo/test_simple.rb +24 -0
  51. data/docs/.gitignore +9 -0
  52. data/docs/architecture/data-flow.md +499 -0
  53. data/examples/dashboard-with-charts.rue +271 -0
  54. data/examples/form-with-validation.rue +180 -0
  55. data/examples/simple-page.rue +61 -0
  56. data/examples/vue.rue +136 -0
  57. data/generate-json-schemas.ts +158 -0
  58. data/json_schemer_migration_summary.md +172 -0
  59. data/lib/rhales/adapters/base_auth.rb +2 -0
  60. data/lib/rhales/adapters/base_request.rb +2 -0
  61. data/lib/rhales/adapters/base_session.rb +2 -0
  62. data/lib/rhales/adapters.rb +7 -0
  63. data/lib/rhales/configuration.rb +161 -1
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +59 -43
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +80 -33
  67. data/lib/rhales/core/view.rb +529 -0
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +81 -9
  69. data/lib/rhales/core.rb +9 -0
  70. data/lib/rhales/errors/hydration_collision_error.rb +2 -0
  71. data/lib/rhales/errors.rb +2 -0
  72. data/lib/rhales/hydration/earliest_injection_detector.rb +153 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/hydration/hydration_endpoint.rb +215 -0
  75. data/lib/rhales/hydration/hydration_injector.rb +175 -0
  76. data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
  77. data/lib/rhales/hydration/hydrator.rb +102 -0
  78. data/lib/rhales/hydration/link_based_injection_detector.rb +195 -0
  79. data/lib/rhales/hydration/mount_point_detector.rb +109 -0
  80. data/lib/rhales/hydration/safe_injection_validator.rb +103 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +7 -13
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +26 -18
  84. data/lib/rhales/integrations.rb +6 -0
  85. data/lib/rhales/middleware/json_responder.rb +191 -0
  86. data/lib/rhales/middleware/schema_validator.rb +300 -0
  87. data/lib/rhales/middleware.rb +6 -0
  88. data/lib/rhales/parsers/handlebars_parser.rb +2 -0
  89. data/lib/rhales/parsers/rue_format_parser.rb +55 -36
  90. data/lib/rhales/parsers.rb +9 -0
  91. data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
  92. data/lib/rhales/utils/json_serializer.rb +114 -0
  93. data/lib/rhales/utils/logging_helpers.rb +75 -0
  94. data/lib/rhales/utils/schema_extractor.rb +132 -0
  95. data/lib/rhales/utils/schema_generator.rb +194 -0
  96. data/lib/rhales/utils.rb +40 -0
  97. data/lib/rhales/version.rb +5 -1
  98. data/lib/rhales.rb +47 -19
  99. data/lib/tasks/rhales_schema.rake +197 -0
  100. data/package.json +10 -0
  101. data/pnpm-lock.yaml +345 -0
  102. data/pnpm-workspace.yaml +2 -0
  103. data/proofs/error_handling.rb +79 -0
  104. data/proofs/expanded_object_inheritance.rb +82 -0
  105. data/proofs/partial_context_scoping_fix.rb +168 -0
  106. data/proofs/ui_context_partial_inheritance.rb +236 -0
  107. data/rhales.gemspec +14 -6
  108. data/schema_vs_data_comparison.md +254 -0
  109. data/test_direct_access.rb +36 -0
  110. metadata +142 -18
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -240
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -220
  114. data/lib/rhales/hydrator.rb +0 -141
  115. data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
  116. 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
- load_template_recursive(@root_template_name)
42
- freeze_composition
43
- self
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 has_template?(name)
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
- return if @templates.key?(template_name)
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).each do |partial_name|
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
@@ -0,0 +1,9 @@
1
+ # lib/rhales/core.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'core/context'
6
+ require_relative 'core/rue_document'
7
+ require_relative 'core/template_engine'
8
+ require_relative 'core/view_composition'
9
+ require_relative 'core/view'