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,109 @@
|
|
|
1
|
+
# lib/rhales/hydration/mount_point_detector.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'strscan'
|
|
6
|
+
require_relative 'safe_injection_validator'
|
|
7
|
+
|
|
8
|
+
module Rhales
|
|
9
|
+
# Detects frontend application mount points in HTML templates
|
|
10
|
+
# Used to determine optimal hydration script injection points
|
|
11
|
+
#
|
|
12
|
+
# ## Mount Point Detection Order
|
|
13
|
+
#
|
|
14
|
+
# 1. **Selector Priority**: All selectors (default + custom) are checked in parallel
|
|
15
|
+
# 2. **Position Priority**: Returns the earliest mount point by position in HTML (not selector order)
|
|
16
|
+
# 3. **Safety Validation**: Validates injection points are outside unsafe contexts (scripts/styles/comments)
|
|
17
|
+
# 4. **Safe Position Search**: If original position unsafe, searches for nearest safe alternative:
|
|
18
|
+
# - First tries positions before the mount point (maintains earlier injection)
|
|
19
|
+
# - Then tries positions after the mount point (fallback)
|
|
20
|
+
# - Returns nil if no safe position found
|
|
21
|
+
#
|
|
22
|
+
# Default selectors are checked: ['#app', '#root', '[data-rsfc-mount]', '[data-mount]']
|
|
23
|
+
# Custom selectors can be added via configuration and are combined with defaults.
|
|
24
|
+
class MountPointDetector
|
|
25
|
+
DEFAULT_SELECTORS = ['#app', '#root', '[data-rsfc-mount]', '[data-mount]'].freeze
|
|
26
|
+
|
|
27
|
+
def detect(template_html, custom_selectors = [])
|
|
28
|
+
selectors = (DEFAULT_SELECTORS + Array(custom_selectors)).uniq
|
|
29
|
+
scanner = StringScanner.new(template_html)
|
|
30
|
+
validator = SafeInjectionValidator.new(template_html)
|
|
31
|
+
mount_points = []
|
|
32
|
+
|
|
33
|
+
selectors.each do |selector|
|
|
34
|
+
scanner.pos = 0
|
|
35
|
+
pattern = build_pattern(selector)
|
|
36
|
+
|
|
37
|
+
while scanner.scan_until(pattern)
|
|
38
|
+
# Calculate position where the full tag starts
|
|
39
|
+
tag_start_pos = find_tag_start(scanner, template_html)
|
|
40
|
+
|
|
41
|
+
# Only include mount points that are safe for injection
|
|
42
|
+
safe_position = find_safe_injection_position(validator, tag_start_pos)
|
|
43
|
+
|
|
44
|
+
if safe_position
|
|
45
|
+
mount_points << {
|
|
46
|
+
selector: selector,
|
|
47
|
+
position: safe_position,
|
|
48
|
+
original_position: tag_start_pos,
|
|
49
|
+
matched: scanner.matched
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Return earliest mount point by position
|
|
56
|
+
mount_points.min_by { |mp| mp[:position] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_pattern(selector)
|
|
62
|
+
case selector
|
|
63
|
+
when /^#(.+)$/
|
|
64
|
+
# ID selector: <tag id="value">
|
|
65
|
+
id_name = Regexp.escape($1)
|
|
66
|
+
/id\s*=\s*["']#{id_name}["']/i
|
|
67
|
+
when /^\.(.+)$/
|
|
68
|
+
# Class selector: <tag class="... value ...">
|
|
69
|
+
class_name = Regexp.escape($1)
|
|
70
|
+
/class\s*=\s*["'][^"']*\b#{class_name}\b[^"']*["']/i
|
|
71
|
+
when /^\[([^\]]+)\]$/
|
|
72
|
+
# Attribute selector: <tag data-attr> or <tag data-attr="value">
|
|
73
|
+
attr_name = Regexp.escape($1)
|
|
74
|
+
/#{attr_name}(?:\s*=\s*["'][^"']*["'])?/i
|
|
75
|
+
else
|
|
76
|
+
# Invalid selector, match nothing
|
|
77
|
+
/(?!.*)/
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_tag_start(scanner, template_html)
|
|
82
|
+
# Work backwards from current position to find the opening <
|
|
83
|
+
pos = scanner.pos - scanner.matched.length
|
|
84
|
+
|
|
85
|
+
while pos > 0 && template_html[pos - 1] != '<'
|
|
86
|
+
pos -= 1
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return position of the < character
|
|
90
|
+
pos > 0 ? pos - 1 : 0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_safe_injection_position(validator, preferred_position)
|
|
94
|
+
# First check if the preferred position is safe
|
|
95
|
+
return preferred_position if validator.safe_injection_point?(preferred_position)
|
|
96
|
+
|
|
97
|
+
# Try to find a safe position before the preferred position
|
|
98
|
+
safe_before = validator.nearest_safe_point_before(preferred_position)
|
|
99
|
+
return safe_before if safe_before
|
|
100
|
+
|
|
101
|
+
# As a last resort, try after the preferred position
|
|
102
|
+
safe_after = validator.nearest_safe_point_after(preferred_position)
|
|
103
|
+
return safe_after if safe_after
|
|
104
|
+
|
|
105
|
+
# No safe position found
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# lib/rhales/hydration/safe_injection_validator.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'strscan'
|
|
6
|
+
|
|
7
|
+
module Rhales
|
|
8
|
+
# Validates whether a hydration injection point is safe within HTML context
|
|
9
|
+
# Prevents injection inside script tags, style tags, comments, or other unsafe locations
|
|
10
|
+
class SafeInjectionValidator
|
|
11
|
+
UNSAFE_CONTEXTS = [
|
|
12
|
+
{ start: /<script\b[^>]*>/i, end: /<\/script>/i },
|
|
13
|
+
{ start: /<style\b[^>]*>/i, end: /<\/style>/i },
|
|
14
|
+
{ start: /<!--/, end: /-->/ },
|
|
15
|
+
{ start: /<!\[CDATA\[/, end: /\]\]>/ }
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def initialize(html)
|
|
19
|
+
@html = html
|
|
20
|
+
@unsafe_ranges = calculate_unsafe_ranges
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Check if the given position is safe for injection
|
|
24
|
+
def safe_injection_point?(position)
|
|
25
|
+
return false if position < 0 || position > @html.length
|
|
26
|
+
|
|
27
|
+
# Check if position falls within any unsafe range
|
|
28
|
+
@unsafe_ranges.none? { |range| range.cover?(position) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Find the nearest safe injection point before the given position
|
|
32
|
+
def nearest_safe_point_before(position)
|
|
33
|
+
# Work backwards from position to find a safe point
|
|
34
|
+
(0...position).reverse_each do |pos|
|
|
35
|
+
return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# If no safe point found before, return nil
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Find the nearest safe injection point after the given position
|
|
43
|
+
def nearest_safe_point_after(position)
|
|
44
|
+
# Work forwards from position to find a safe point
|
|
45
|
+
(position...@html.length).each do |pos|
|
|
46
|
+
return pos if safe_injection_point?(pos) && at_tag_boundary?(pos)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# If no safe point found after, return nil
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def calculate_unsafe_ranges
|
|
56
|
+
ranges = []
|
|
57
|
+
scanner = StringScanner.new(@html)
|
|
58
|
+
|
|
59
|
+
UNSAFE_CONTEXTS.each do |context|
|
|
60
|
+
scanner.pos = 0
|
|
61
|
+
|
|
62
|
+
while scanner.scan_until(context[:start])
|
|
63
|
+
start_pos = scanner.pos - scanner.matched.length
|
|
64
|
+
|
|
65
|
+
# Find the corresponding end tag
|
|
66
|
+
if scanner.scan_until(context[:end])
|
|
67
|
+
end_pos = scanner.pos
|
|
68
|
+
ranges << (start_pos...end_pos)
|
|
69
|
+
else
|
|
70
|
+
# If no closing tag found, consider rest of document unsafe
|
|
71
|
+
ranges << (start_pos...@html.length)
|
|
72
|
+
break
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
ranges
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Check if position is at a tag boundary (before < or after >)
|
|
81
|
+
def at_tag_boundary?(position)
|
|
82
|
+
return true if position == 0 || position == @html.length
|
|
83
|
+
|
|
84
|
+
char_before = position > 0 ? @html[position - 1] : nil
|
|
85
|
+
char_at = @html[position]
|
|
86
|
+
|
|
87
|
+
# Safe positions:
|
|
88
|
+
# - Right after a closing >
|
|
89
|
+
# - Right before an opening <
|
|
90
|
+
# - At whitespace boundaries between tags
|
|
91
|
+
char_before == '>' || char_at == '<' || (char_at&.match?(/\s/) && next_non_whitespace_is_tag?(position))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def next_non_whitespace_is_tag?(position)
|
|
95
|
+
pos = position
|
|
96
|
+
while pos < @html.length && @html[pos].match?(/\s/)
|
|
97
|
+
pos += 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
pos < @html.length && @html[pos] == '<'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# lib/rhales/hydration.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative 'hydration/hydrator'
|
|
6
|
+
require_relative 'hydration/hydration_data_aggregator'
|
|
7
|
+
require_relative 'hydration/mount_point_detector'
|
|
8
|
+
require_relative 'hydration/safe_injection_validator'
|
|
9
|
+
require_relative 'hydration/earliest_injection_detector'
|
|
10
|
+
require_relative 'hydration/link_based_injection_detector'
|
|
11
|
+
require_relative 'hydration/hydration_injector'
|
|
12
|
+
require_relative 'hydration/hydration_endpoint'
|
|
13
|
+
require_relative 'hydration/hydration_registry'
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# lib/rhales/refinements/require_refinements.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
|
-
require_relative '
|
|
5
|
+
require_relative '../../core/rue_document'
|
|
4
6
|
|
|
5
7
|
module Rhales
|
|
6
8
|
module Ruequire
|
|
@@ -111,11 +113,7 @@ module Rhales
|
|
|
111
113
|
|
|
112
114
|
parser
|
|
113
115
|
rescue StandardError => ex
|
|
114
|
-
|
|
115
|
-
OT.le "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
|
116
|
-
else
|
|
117
|
-
puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
|
118
|
-
end
|
|
116
|
+
puts "[RSFC] Failed to process .rue file #{path}: #{ex.message}"
|
|
119
117
|
raise
|
|
120
118
|
end
|
|
121
119
|
|
|
@@ -130,7 +128,7 @@ module Rhales
|
|
|
130
128
|
return File.expand_path(path) if File.exist?(path)
|
|
131
129
|
|
|
132
130
|
# Search in templates directory
|
|
133
|
-
boot_root =
|
|
131
|
+
boot_root = File.expand_path('../../..', __dir__)
|
|
134
132
|
templates_path = File.join(boot_root, 'templates', path)
|
|
135
133
|
return templates_path if File.exist?(templates_path)
|
|
136
134
|
|
|
@@ -197,9 +195,7 @@ module Rhales
|
|
|
197
195
|
current_mtime = File.mtime(full_path)
|
|
198
196
|
|
|
199
197
|
if current_mtime > last_mtime
|
|
200
|
-
|
|
201
|
-
OT.ld "[RSFC] File changed, clearing cache: #{full_path}"
|
|
202
|
-
end
|
|
198
|
+
puts "[RSFC] File changed, clearing cache: #{full_path}"
|
|
203
199
|
|
|
204
200
|
# Thread-safe cache removal
|
|
205
201
|
Rhales::Ruequire.instance_variable_get(:@cache_mutex).synchronize do
|
|
@@ -210,9 +206,7 @@ module Rhales
|
|
|
210
206
|
end
|
|
211
207
|
rescue StandardError => ex
|
|
212
208
|
# File might have been deleted
|
|
213
|
-
|
|
214
|
-
OT.ld "[RSFC] File watcher error for #{full_path}: #{ex.message}"
|
|
215
|
-
end
|
|
209
|
+
puts "[RSFC] File watcher error for #{full_path}: #{ex.message}"
|
|
216
210
|
|
|
217
211
|
# Clean up watcher entry on error
|
|
218
212
|
watchers_mutex.synchronize do
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
# lib/rhales/integrations/tilt.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
5
|
require 'tilt'
|
|
4
|
-
|
|
5
|
-
require_relative 'adapters/base_request'
|
|
6
|
+
require_relative '../adapters/base_request'
|
|
6
7
|
|
|
7
8
|
module Rhales
|
|
8
9
|
# Tilt integration for Rhales templates
|
|
@@ -109,7 +110,8 @@ module Rhales
|
|
|
109
110
|
framework_env = scope.request.env.merge({
|
|
110
111
|
'nonce' => shared_nonce,
|
|
111
112
|
'request_id' => SecureRandom.hex(8),
|
|
112
|
-
}
|
|
113
|
+
},
|
|
114
|
+
)
|
|
113
115
|
|
|
114
116
|
# Create wrapper that preserves original but adds our env
|
|
115
117
|
wrapped_request = Class.new do
|
|
@@ -118,8 +120,8 @@ module Rhales
|
|
|
118
120
|
@custom_env = custom_env
|
|
119
121
|
end
|
|
120
122
|
|
|
121
|
-
def method_missing(method,
|
|
122
|
-
@original.send(method,
|
|
123
|
+
def method_missing(method, *, &)
|
|
124
|
+
@original.send(method, *, &)
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
def respond_to_missing?(method, include_private = false)
|
|
@@ -141,11 +143,11 @@ module Rhales
|
|
|
141
143
|
env: {
|
|
142
144
|
'nonce' => shared_nonce,
|
|
143
145
|
'request_id' => SecureRandom.hex(8),
|
|
144
|
-
}
|
|
146
|
+
},
|
|
145
147
|
)
|
|
146
148
|
end
|
|
147
149
|
|
|
148
|
-
#
|
|
150
|
+
# Build session and auth adapters
|
|
149
151
|
session_data = if scope.respond_to?(:logged_in?) && scope.logged_in?
|
|
150
152
|
Rhales::Adapters::AuthenticatedSession.new(
|
|
151
153
|
{
|
|
@@ -157,25 +159,31 @@ module Rhales
|
|
|
157
159
|
Rhales::Adapters::AnonymousSession.new
|
|
158
160
|
end
|
|
159
161
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
auth_data = Rhales::Adapters::AuthenticatedAuth.new({
|
|
162
|
+
auth_data = if scope.respond_to?(:logged_in?) && scope.logged_in? && scope.respond_to?(:current_user)
|
|
163
|
+
user = scope.current_user
|
|
164
|
+
Rhales::Adapters::AuthenticatedAuth.new({
|
|
164
165
|
id: user[:id],
|
|
165
166
|
email: user[:email],
|
|
166
167
|
authenticated: true,
|
|
167
|
-
}
|
|
168
|
-
)
|
|
168
|
+
})
|
|
169
169
|
else
|
|
170
|
-
|
|
170
|
+
Rhales::Adapters::AnonymousAuth.new
|
|
171
171
|
end
|
|
172
172
|
|
|
173
|
+
# Extend request_data to expose session and user
|
|
174
|
+
request_data.define_singleton_method(:session) { session_data }
|
|
175
|
+
request_data.define_singleton_method(:user) { auth_data }
|
|
176
|
+
|
|
177
|
+
# Split props into client (serialized) and server (template-only) data
|
|
178
|
+
# By default, all Tilt locals go to client for backward compatibility
|
|
179
|
+
# Frameworks can use :server_data and :client_data keys to override
|
|
180
|
+
client_data = props.delete(:client_data) || props.dup
|
|
181
|
+
server_data = props.delete(:server_data) || {}
|
|
182
|
+
|
|
173
183
|
::Rhales::View.new(
|
|
174
184
|
request_data,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
nil, # locale_override
|
|
178
|
-
props: props,
|
|
185
|
+
client: client_data,
|
|
186
|
+
server: server_data,
|
|
179
187
|
)
|
|
180
188
|
end
|
|
181
189
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# lib/rhales/middleware/json_responder.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../utils/json_serializer'
|
|
6
|
+
|
|
7
|
+
module Rhales
|
|
8
|
+
module Middleware
|
|
9
|
+
# Rack middleware that returns hydration data as JSON when Accept: application/json
|
|
10
|
+
#
|
|
11
|
+
# When a request has Accept: application/json header, this middleware
|
|
12
|
+
# intercepts the response and returns just the hydration data as JSON
|
|
13
|
+
# instead of rendering the full HTML template.
|
|
14
|
+
#
|
|
15
|
+
# This enables:
|
|
16
|
+
# - API clients to fetch data from the same endpoints
|
|
17
|
+
# - Testing hydration data without parsing HTML
|
|
18
|
+
# - Development inspection of data flow
|
|
19
|
+
# - Mobile/native clients using the same routes
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage with Rack
|
|
22
|
+
# use Rhales::Middleware::JsonResponder,
|
|
23
|
+
# enabled: true,
|
|
24
|
+
# include_metadata: false
|
|
25
|
+
#
|
|
26
|
+
# @example With Roda
|
|
27
|
+
# use Rhales::Middleware::JsonResponder,
|
|
28
|
+
# enabled: ENV['RACK_ENV'] != 'production',
|
|
29
|
+
# include_metadata: ENV['RACK_ENV'] == 'development'
|
|
30
|
+
#
|
|
31
|
+
# @example Response format (single window)
|
|
32
|
+
# GET /dashboard
|
|
33
|
+
# Accept: application/json
|
|
34
|
+
#
|
|
35
|
+
# {
|
|
36
|
+
# "user": {"id": 1, "name": "Alice"},
|
|
37
|
+
# "authenticated": true
|
|
38
|
+
# }
|
|
39
|
+
#
|
|
40
|
+
# @example Response format (multiple windows)
|
|
41
|
+
# {
|
|
42
|
+
# "appData": {"user": {...}},
|
|
43
|
+
# "config": {"theme": "dark"}
|
|
44
|
+
# }
|
|
45
|
+
#
|
|
46
|
+
# @example Response with metadata (development)
|
|
47
|
+
# {
|
|
48
|
+
# "template": "dashboard",
|
|
49
|
+
# "data": {"user": {...}}
|
|
50
|
+
# }
|
|
51
|
+
class JsonResponder
|
|
52
|
+
# Initialize the middleware
|
|
53
|
+
#
|
|
54
|
+
# @param app [#call] The Rack application
|
|
55
|
+
# @param options [Hash] Configuration options
|
|
56
|
+
# @option options [Boolean] :enabled Whether JSON responses are enabled (default: true)
|
|
57
|
+
# @option options [Boolean] :include_metadata Whether to include metadata in responses (default: false)
|
|
58
|
+
def initialize(app, options = {})
|
|
59
|
+
@app = app
|
|
60
|
+
@enabled = options.fetch(:enabled, true)
|
|
61
|
+
@include_metadata = options.fetch(:include_metadata, false)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Process the Rack request
|
|
65
|
+
#
|
|
66
|
+
# @param env [Hash] The Rack environment
|
|
67
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
68
|
+
def call(env)
|
|
69
|
+
return @app.call(env) unless @enabled
|
|
70
|
+
return @app.call(env) unless accepts_json?(env)
|
|
71
|
+
|
|
72
|
+
# Get the response from the app
|
|
73
|
+
status, headers, body = @app.call(env)
|
|
74
|
+
|
|
75
|
+
# Only process successful HTML responses
|
|
76
|
+
return [status, headers, body] unless status == 200
|
|
77
|
+
return [status, headers, body] unless html_response?(headers)
|
|
78
|
+
|
|
79
|
+
# Extract hydration data from HTML
|
|
80
|
+
html_body = extract_body(body)
|
|
81
|
+
hydration_data = extract_hydration_data(html_body)
|
|
82
|
+
|
|
83
|
+
# Return empty object if no hydration data found
|
|
84
|
+
if hydration_data.empty?
|
|
85
|
+
return json_response({}, env)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Build response data
|
|
89
|
+
response_data = if @include_metadata
|
|
90
|
+
{
|
|
91
|
+
template: env['rhales.template_name'],
|
|
92
|
+
data: hydration_data
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
# Flatten if single window, or return all windows
|
|
96
|
+
hydration_data.size == 1 ? hydration_data.values.first : hydration_data
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
json_response(response_data, env)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Check if request accepts JSON
|
|
105
|
+
#
|
|
106
|
+
# Parses Accept header and checks for application/json.
|
|
107
|
+
# Handles weighted preferences (e.g., "application/json;q=0.9")
|
|
108
|
+
#
|
|
109
|
+
# @param env [Hash] Rack environment
|
|
110
|
+
# @return [Boolean] true if application/json is accepted
|
|
111
|
+
def accepts_json?(env)
|
|
112
|
+
accept = env['HTTP_ACCEPT']
|
|
113
|
+
return false unless accept
|
|
114
|
+
|
|
115
|
+
# Check if application/json is requested
|
|
116
|
+
# Handle weighted preferences (e.g., "application/json;q=0.9")
|
|
117
|
+
accept.split(',').any? do |type|
|
|
118
|
+
type.strip.start_with?('application/json')
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if response is HTML
|
|
123
|
+
#
|
|
124
|
+
# @param headers [Hash] Response headers
|
|
125
|
+
# @return [Boolean] true if Content-Type is text/html
|
|
126
|
+
def html_response?(headers)
|
|
127
|
+
# Support both uppercase and lowercase header names for compatibility
|
|
128
|
+
content_type = headers['content-type'] || headers['Content-Type']
|
|
129
|
+
content_type && content_type.include?('text/html')
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extract response body as string
|
|
133
|
+
#
|
|
134
|
+
# Handles different Rack body types (Array, IO, String)
|
|
135
|
+
#
|
|
136
|
+
# @param body [Array, IO, String] Rack response body
|
|
137
|
+
# @return [String] Body content as string
|
|
138
|
+
def extract_body(body)
|
|
139
|
+
if body.respond_to?(:each)
|
|
140
|
+
body.each.to_a.join
|
|
141
|
+
elsif body.respond_to?(:read)
|
|
142
|
+
body.read
|
|
143
|
+
else
|
|
144
|
+
body.to_s
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Extract hydration JSON blocks from HTML
|
|
149
|
+
#
|
|
150
|
+
# Looks for <script type="application/json" data-window="varName"> tags
|
|
151
|
+
# and parses their JSON content. Returns a hash keyed by window variable name.
|
|
152
|
+
#
|
|
153
|
+
# @param html [String] HTML response body
|
|
154
|
+
# @return [Hash] Hydration data keyed by window variable name
|
|
155
|
+
def extract_hydration_data(html)
|
|
156
|
+
hydration_blocks = {}
|
|
157
|
+
|
|
158
|
+
# Match script tags with data-window attribute
|
|
159
|
+
html.scan(/<script[^>]*type=["']application\/json["'][^>]*data-window=["']([^"']+)["'][^>]*>(.*?)<\/script>/m) do |window_var, json_content|
|
|
160
|
+
begin
|
|
161
|
+
hydration_blocks[window_var] = JSONSerializer.parse(json_content.strip)
|
|
162
|
+
rescue JSON::ParserError => e
|
|
163
|
+
# Skip malformed JSON blocks
|
|
164
|
+
warn "Rhales::JsonResponder: Failed to parse hydration JSON for window.#{window_var}: #{e.message}"
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
hydration_blocks
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Build JSON response
|
|
172
|
+
#
|
|
173
|
+
# @param data [Hash, Array, Object] Response data
|
|
174
|
+
# @param env [Hash] Rack environment
|
|
175
|
+
# @return [Array] Rack response tuple
|
|
176
|
+
def json_response(data, env)
|
|
177
|
+
json_body = JSONSerializer.dump(data)
|
|
178
|
+
|
|
179
|
+
[
|
|
180
|
+
200,
|
|
181
|
+
{
|
|
182
|
+
'content-type' => 'application/json',
|
|
183
|
+
'content-length' => json_body.bytesize.to_s,
|
|
184
|
+
'cache-control' => 'no-cache'
|
|
185
|
+
},
|
|
186
|
+
[json_body]
|
|
187
|
+
]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|