rhales 0.4.0 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/renovate.json5 +52 -0
- data/.github/workflows/ci.yml +123 -0
- data/.github/workflows/claude-code-review.yml +69 -0
- data/.github/workflows/claude.yml +49 -0
- data/.github/workflows/code-smells.yml +146 -0
- data/.github/workflows/ruby-lint.yml +78 -0
- data/.github/workflows/yardoc.yml +126 -0
- data/.gitignore +55 -0
- data/.pr_agent.toml +63 -0
- data/.pre-commit-config.yaml +89 -0
- data/.prettierignore +8 -0
- data/.prettierrc +38 -0
- data/.reek.yml +98 -0
- data/.rubocop.yml +428 -0
- data/.serena/.gitignore +3 -0
- data/.yardopts +56 -0
- data/CHANGELOG.md +44 -0
- data/CLAUDE.md +1 -1
- data/Gemfile +29 -0
- data/Gemfile.lock +189 -0
- data/README.md +686 -868
- data/Rakefile +46 -0
- data/debug_context.rb +25 -0
- data/demo/rhales-roda-demo/.gitignore +7 -0
- data/demo/rhales-roda-demo/Gemfile +32 -0
- data/demo/rhales-roda-demo/Gemfile.lock +151 -0
- data/demo/rhales-roda-demo/MAIL.md +405 -0
- data/demo/rhales-roda-demo/README.md +376 -0
- data/demo/rhales-roda-demo/RODA-TEMPLATE-ENGINES.md +192 -0
- data/demo/rhales-roda-demo/Rakefile +49 -0
- data/demo/rhales-roda-demo/app.rb +325 -0
- data/demo/rhales-roda-demo/bin/rackup +26 -0
- data/demo/rhales-roda-demo/config.ru +13 -0
- data/demo/rhales-roda-demo/db/migrate/001_create_rodauth_tables.rb +266 -0
- data/demo/rhales-roda-demo/db/migrate/002_create_rodauth_password_tables.rb +79 -0
- data/demo/rhales-roda-demo/db/migrate/003_add_admin_account.rb +68 -0
- data/demo/rhales-roda-demo/templates/change_login.rue +31 -0
- data/demo/rhales-roda-demo/templates/change_password.rue +36 -0
- data/demo/rhales-roda-demo/templates/close_account.rue +31 -0
- data/demo/rhales-roda-demo/templates/create_account.rue +40 -0
- data/demo/rhales-roda-demo/templates/dashboard.rue +150 -0
- data/demo/rhales-roda-demo/templates/home.rue +78 -0
- data/demo/rhales-roda-demo/templates/layouts/main.rue +168 -0
- data/demo/rhales-roda-demo/templates/login.rue +65 -0
- data/demo/rhales-roda-demo/templates/logout.rue +25 -0
- data/demo/rhales-roda-demo/templates/reset_password.rue +26 -0
- data/demo/rhales-roda-demo/templates/verify_account.rue +27 -0
- data/demo/rhales-roda-demo/test_full_output.rb +27 -0
- data/demo/rhales-roda-demo/test_simple.rb +24 -0
- data/docs/.gitignore +9 -0
- data/docs/architecture/data-flow.md +499 -0
- data/examples/dashboard-with-charts.rue +271 -0
- data/examples/form-with-validation.rue +180 -0
- data/examples/simple-page.rue +61 -0
- data/examples/vue.rue +136 -0
- data/generate-json-schemas.ts +158 -0
- data/json_schemer_migration_summary.md +172 -0
- data/lib/rhales/adapters/base_auth.rb +2 -0
- data/lib/rhales/adapters/base_request.rb +2 -0
- data/lib/rhales/adapters/base_session.rb +2 -0
- data/lib/rhales/adapters.rb +7 -0
- data/lib/rhales/configuration.rb +47 -0
- data/lib/rhales/core/context.rb +354 -0
- data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
- data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
- data/lib/rhales/{view.rb → core/view.rb} +112 -135
- data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
- data/lib/rhales/core.rb +9 -0
- data/lib/rhales/errors/hydration_collision_error.rb +2 -0
- data/lib/rhales/errors.rb +2 -0
- data/lib/rhales/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
- data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
- data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
- data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -0
- data/lib/rhales/{hydration_registry.rb → hydration/hydration_registry.rb} +2 -0
- data/lib/rhales/hydration/hydrator.rb +102 -0
- data/lib/rhales/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
- data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
- data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
- data/lib/rhales/hydration.rb +13 -0
- data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
- data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
- data/lib/rhales/integrations.rb +6 -0
- data/lib/rhales/middleware/json_responder.rb +191 -0
- data/lib/rhales/middleware/schema_validator.rb +300 -0
- data/lib/rhales/middleware.rb +6 -0
- data/lib/rhales/parsers/handlebars_parser.rb +2 -0
- data/lib/rhales/parsers/rue_format_parser.rb +9 -7
- data/lib/rhales/parsers.rb +9 -0
- data/lib/rhales/{csp.rb → security/csp.rb} +27 -3
- data/lib/rhales/utils/json_serializer.rb +114 -0
- data/lib/rhales/utils/logging_helpers.rb +75 -0
- data/lib/rhales/utils/schema_extractor.rb +132 -0
- data/lib/rhales/utils/schema_generator.rb +194 -0
- data/lib/rhales/utils.rb +40 -0
- data/lib/rhales/version.rb +3 -1
- data/lib/rhales.rb +41 -24
- data/lib/tasks/rhales_schema.rake +197 -0
- data/package.json +10 -0
- data/pnpm-lock.yaml +345 -0
- data/pnpm-workspace.yaml +2 -0
- data/proofs/error_handling.rb +79 -0
- data/proofs/expanded_object_inheritance.rb +82 -0
- data/proofs/partial_context_scoping_fix.rb +168 -0
- data/proofs/ui_context_partial_inheritance.rb +236 -0
- data/rhales.gemspec +14 -6
- data/schema_vs_data_comparison.md +254 -0
- data/test_direct_access.rb +36 -0
- metadata +141 -23
- data/CLAUDE.locale.txt +0 -7
- data/lib/rhales/context.rb +0 -239
- data/lib/rhales/hydration_data_aggregator.rb +0 -221
- data/lib/rhales/hydrator.rb +0 -141
- data/lib/rhales/parsers/handlebars-grammar-review.txt +0 -39
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# lib/rhales/context.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative '../configuration'
|
|
6
|
+
require_relative '../adapters/base_auth'
|
|
7
|
+
require_relative '../adapters/base_session'
|
|
8
|
+
require_relative '../adapters/base_request'
|
|
9
|
+
require_relative '../security/csp'
|
|
10
|
+
|
|
11
|
+
module Rhales
|
|
12
|
+
# RSFCContext provides a clean interface for RSFC templates to access
|
|
13
|
+
# server-side data. Follows the established pattern from InitScriptContext
|
|
14
|
+
# and EnvironmentContext for focused, single-responsibility context objects.
|
|
15
|
+
#
|
|
16
|
+
# The context provides three layers of data:
|
|
17
|
+
# 1. Request: Framework-provided data (CSRF tokens, authentication, config)
|
|
18
|
+
# 2. Server: Template-only variables (page titles, HTML content, etc.)
|
|
19
|
+
# 3. Client: Application data that gets serialized to window state
|
|
20
|
+
#
|
|
21
|
+
# Request data and server data are accessible in templates.
|
|
22
|
+
# Client data takes precedence over server data for variable resolution.
|
|
23
|
+
# Only client data is serialized to the browser via <schema> sections.
|
|
24
|
+
#
|
|
25
|
+
# One RSFCContext instance is created per page render and shared across
|
|
26
|
+
# the main template and all partials to maintain security boundaries.
|
|
27
|
+
class Context
|
|
28
|
+
attr_reader :req, :client, :server, :config
|
|
29
|
+
|
|
30
|
+
def initialize(req, client: {}, server: {}, config: nil)
|
|
31
|
+
@req = req
|
|
32
|
+
@config = config || Rhales.configuration
|
|
33
|
+
|
|
34
|
+
# Normalize keys to strings for consistent access and expose with clean names
|
|
35
|
+
@client_data = normalize_keys(client).freeze
|
|
36
|
+
@client = @client_data # Public accessor
|
|
37
|
+
|
|
38
|
+
# Build context layers (three-layer model: request + server + client)
|
|
39
|
+
# Server data is merged with built-in request/app data
|
|
40
|
+
@server_data = build_app_data.merge(normalize_keys(server)).freeze
|
|
41
|
+
@server = @server_data # Public accessor
|
|
42
|
+
|
|
43
|
+
# Pre-compute all_data before freezing
|
|
44
|
+
# Client takes precedence over server, and add app namespace for backward compatibility
|
|
45
|
+
@all_data = @server_data.merge(@client_data).merge({ 'app' => @server_data }).freeze
|
|
46
|
+
|
|
47
|
+
# Make context immutable after creation
|
|
48
|
+
freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get variable value with dot notation support (e.g., "user.id", "features.account_creation")
|
|
52
|
+
def get(variable_path)
|
|
53
|
+
path_parts = variable_path.split('.')
|
|
54
|
+
current_value = all_data
|
|
55
|
+
|
|
56
|
+
path_parts.each do |part|
|
|
57
|
+
case current_value
|
|
58
|
+
when Hash
|
|
59
|
+
if current_value.key?(part)
|
|
60
|
+
current_value = current_value[part]
|
|
61
|
+
elsif current_value.key?(part.to_sym)
|
|
62
|
+
current_value = current_value[part.to_sym]
|
|
63
|
+
else
|
|
64
|
+
return nil
|
|
65
|
+
end
|
|
66
|
+
when Object
|
|
67
|
+
if current_value.respond_to?(part)
|
|
68
|
+
current_value = current_value.public_send(part)
|
|
69
|
+
elsif current_value.respond_to?("#{part}?")
|
|
70
|
+
current_value = current_value.public_send("#{part}?")
|
|
71
|
+
else
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
return nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return nil if current_value.nil?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
current_value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get all available data (runtime + business + computed)
|
|
85
|
+
attr_reader :all_data
|
|
86
|
+
|
|
87
|
+
# Check if variable exists
|
|
88
|
+
def variable?(variable_path)
|
|
89
|
+
!get(variable_path).nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get list of all available variable paths (for validation)
|
|
93
|
+
def available_variables
|
|
94
|
+
collect_variable_paths(all_data)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Resolve variable (alias for get method for hydrator compatibility)
|
|
98
|
+
def resolve_variable(variable_path)
|
|
99
|
+
get(variable_path)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Add accessor for request data (maps to @server_data for 'app' namespace compatibility)
|
|
103
|
+
def request
|
|
104
|
+
@server_data
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get session from request
|
|
108
|
+
#
|
|
109
|
+
# Requires: Request object must respond to #session
|
|
110
|
+
# Provided by: Rack::Request extension in Onetime
|
|
111
|
+
#
|
|
112
|
+
# @return [Hash, AnonymousSession] Rack session hash or anonymous session
|
|
113
|
+
def sess
|
|
114
|
+
return Adapters::AnonymousSession.new unless req.respond_to?(:session)
|
|
115
|
+
|
|
116
|
+
session = req.session
|
|
117
|
+
# If session doesn't have authenticated? method, wrap it in AnonymousSession
|
|
118
|
+
# This handles both Hash and Rack::Session hash-like objects after hot reload
|
|
119
|
+
return Adapters::AnonymousSession.new unless session.respond_to?(:authenticated?)
|
|
120
|
+
|
|
121
|
+
session
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get user from request
|
|
125
|
+
#
|
|
126
|
+
# Requires: Request object must respond to #user
|
|
127
|
+
# Provided by: Rack::Request extension in Onetime
|
|
128
|
+
#
|
|
129
|
+
# @return [Object, AnonymousAuth] User object or anonymous auth
|
|
130
|
+
def user
|
|
131
|
+
return Adapters::AnonymousAuth.new unless req&.respond_to?(:user)
|
|
132
|
+
|
|
133
|
+
req.user
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get locale from request
|
|
137
|
+
#
|
|
138
|
+
# Parses locale from HTTP_ACCEPT_LANGUAGE or rhales.locale env variable
|
|
139
|
+
#
|
|
140
|
+
# @return [String] Locale code
|
|
141
|
+
def locale
|
|
142
|
+
return @config.default_locale unless req
|
|
143
|
+
|
|
144
|
+
if req.respond_to?(:env) && req.env
|
|
145
|
+
# Check for custom rhales.locale first, then HTTP_ACCEPT_LANGUAGE
|
|
146
|
+
custom_locale = req.env['rhales.locale']
|
|
147
|
+
return custom_locale if custom_locale
|
|
148
|
+
|
|
149
|
+
# Parse HTTP_ACCEPT_LANGUAGE header
|
|
150
|
+
accept_language = req.env['HTTP_ACCEPT_LANGUAGE']
|
|
151
|
+
if accept_language
|
|
152
|
+
# Extract first locale from Accept-Language header (e.g., "en-US,en;q=0.9" -> "en-US")
|
|
153
|
+
first_locale = accept_language.split(',').first&.strip&.split(';')&.first
|
|
154
|
+
return first_locale if first_locale && !first_locale.empty?
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@config.default_locale
|
|
158
|
+
else
|
|
159
|
+
@config.default_locale
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Create a new context with updated client data
|
|
164
|
+
def with_client(new_client_data)
|
|
165
|
+
self.class.new(
|
|
166
|
+
@req,
|
|
167
|
+
client: normalize_keys(new_client_data),
|
|
168
|
+
server: @server_data,
|
|
169
|
+
config: @config,
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Create a new context with updated server data
|
|
174
|
+
def with_server(new_server_data)
|
|
175
|
+
self.class.new(
|
|
176
|
+
@req,
|
|
177
|
+
client: @client_data,
|
|
178
|
+
server: normalize_keys(new_server_data),
|
|
179
|
+
config: @config,
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Create a new context with merged client data
|
|
184
|
+
def merge_client(additional_client_data)
|
|
185
|
+
self.class.new(
|
|
186
|
+
@req,
|
|
187
|
+
client: @client_data.merge(normalize_keys(additional_client_data)),
|
|
188
|
+
server: @server_data,
|
|
189
|
+
config: @config,
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Build framework-provided server data
|
|
196
|
+
def build_app_data
|
|
197
|
+
app = {}
|
|
198
|
+
|
|
199
|
+
# Request context (from current runtime_data)
|
|
200
|
+
if req && req.respond_to?(:env) && req.env
|
|
201
|
+
app['csrf_token'] = req.env.fetch(@config.csrf_token_name, nil)
|
|
202
|
+
app['nonce'] = get_or_generate_nonce
|
|
203
|
+
app['request_id'] = req.env.fetch('request_id', nil)
|
|
204
|
+
app['domain_strategy'] = req.env.fetch('domain_strategy', :default)
|
|
205
|
+
app['display_domain'] = req.env.fetch('display_domain', nil)
|
|
206
|
+
else
|
|
207
|
+
# Generate nonce even without request if CSP is enabled
|
|
208
|
+
app['nonce'] = get_or_generate_nonce
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Configuration (from both layers)
|
|
212
|
+
app['environment'] = @config.app_environment
|
|
213
|
+
app['api_base_url'] = @config.api_base_url
|
|
214
|
+
app['features'] = @config.features
|
|
215
|
+
app['development'] = @config.development?
|
|
216
|
+
|
|
217
|
+
# Authentication & UI (from current computed_data)
|
|
218
|
+
app['authenticated'] = authenticated?
|
|
219
|
+
app['theme_class'] = determine_theme_class
|
|
220
|
+
|
|
221
|
+
app
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Determine theme class for CSS
|
|
225
|
+
def determine_theme_class
|
|
226
|
+
# Default theme logic - can be overridden by business data
|
|
227
|
+
if @client_data['theme']
|
|
228
|
+
"theme-#{@client_data['theme']}"
|
|
229
|
+
elsif user && user.respond_to?(:theme_preference)
|
|
230
|
+
"theme-#{user.theme_preference}"
|
|
231
|
+
else
|
|
232
|
+
'theme-light'
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Check if user is authenticated
|
|
237
|
+
def authenticated?
|
|
238
|
+
# Try Otto strategy_result first (framework integration)
|
|
239
|
+
if req&.respond_to?(:env)
|
|
240
|
+
strategy_result = req.env['otto.strategy_result']
|
|
241
|
+
if strategy_result&.respond_to?(:authenticated?)
|
|
242
|
+
return strategy_result.authenticated? && valid_user_present?
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Fall back to checking session and user directly
|
|
247
|
+
sess&.authenticated? && valid_user_present?
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Check if we have a valid (non-anonymous) user
|
|
251
|
+
def valid_user_present?
|
|
252
|
+
user && !user.anonymous?
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Get or generate CSP nonce
|
|
256
|
+
def get_or_generate_nonce
|
|
257
|
+
# Try to get existing nonce from request env
|
|
258
|
+
if req && req.respond_to?(:env) && req.env
|
|
259
|
+
existing_nonce = req.env.fetch(@config.nonce_header_name, nil)
|
|
260
|
+
return existing_nonce if existing_nonce
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Generate new nonce if auto_nonce is enabled or CSP is enabled
|
|
264
|
+
return CSP.generate_nonce if @config.auto_nonce || (@config.csp_enabled && csp_nonce_required?)
|
|
265
|
+
|
|
266
|
+
# Return nil if nonce is not needed
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Check if CSP policy requires nonce
|
|
271
|
+
def csp_nonce_required?
|
|
272
|
+
return false unless @config.csp_enabled
|
|
273
|
+
|
|
274
|
+
csp = CSP.new(@config)
|
|
275
|
+
csp.nonce_required?
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Normalize hash keys to strings recursively
|
|
281
|
+
def normalize_keys(data)
|
|
282
|
+
case data
|
|
283
|
+
when Hash
|
|
284
|
+
data.each_with_object({}) do |(key, value), result|
|
|
285
|
+
result[key.to_s] = normalize_keys(value)
|
|
286
|
+
end
|
|
287
|
+
when Array
|
|
288
|
+
data.map { |item| normalize_keys(item) }
|
|
289
|
+
else
|
|
290
|
+
data
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Recursively collect all variable paths from nested data
|
|
295
|
+
def collect_variable_paths(data, prefix = '')
|
|
296
|
+
paths = []
|
|
297
|
+
|
|
298
|
+
case data
|
|
299
|
+
when Hash
|
|
300
|
+
data.each do |key, value|
|
|
301
|
+
current_path = prefix.empty? ? key.to_s : "#{prefix}.#{key}"
|
|
302
|
+
paths << current_path
|
|
303
|
+
|
|
304
|
+
if value.is_a?(Hash) || value.is_a?(Object)
|
|
305
|
+
paths.concat(collect_variable_paths(value, current_path))
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
when Object
|
|
309
|
+
# For objects, collect method names that look like attributes
|
|
310
|
+
data.public_methods(false).each do |method|
|
|
311
|
+
method_name = method.to_s
|
|
312
|
+
next if method_name.end_with?('=') # Skip setters
|
|
313
|
+
next if method_name.start_with?('_') # Skip private-ish methods
|
|
314
|
+
|
|
315
|
+
current_path = prefix.empty? ? method_name : "#{prefix}.#{method_name}"
|
|
316
|
+
paths << current_path
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
paths
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Minimal request object for testing that supports required methods
|
|
324
|
+
class MinimalRequest
|
|
325
|
+
attr_reader :env, :session, :user, :locale, :nonce
|
|
326
|
+
|
|
327
|
+
def initialize(env = {}, session: {}, user: nil, locale: 'en', nonce: 'test-nonce')
|
|
328
|
+
@env = env
|
|
329
|
+
@session = session
|
|
330
|
+
@user = user
|
|
331
|
+
@locale = locale
|
|
332
|
+
@nonce = nonce
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def authenticated?
|
|
336
|
+
@user && (!@user.respond_to?(:anonymous?) || !@user.anonymous?)
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
class << self
|
|
341
|
+
# Create context with business data for a specific view
|
|
342
|
+
def for_view(req, client: {}, server: {}, config: nil, **additional_client)
|
|
343
|
+
all_client = client.merge(additional_client)
|
|
344
|
+
new(req, client: all_client, server: server, config: config)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Create minimal context for testing with optional env override
|
|
348
|
+
def minimal(client: {}, server: {}, config: nil, env: nil)
|
|
349
|
+
req = env ? MinimalRequest.new(env) : nil
|
|
350
|
+
new(req, client: client, server: server, config: config)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# lib/rhales/rue_document.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
2
4
|
|
|
3
|
-
require_relative 'parsers/rue_format_parser'
|
|
5
|
+
require_relative '../parsers/rue_format_parser'
|
|
4
6
|
|
|
5
7
|
module Rhales
|
|
6
8
|
# High-level interface for parsed .rue files
|
|
@@ -32,12 +34,12 @@ module Rhales
|
|
|
32
34
|
class InvalidSyntaxError < ParseError; end
|
|
33
35
|
|
|
34
36
|
# At least one of these sections must be present
|
|
35
|
-
REQUIRES_ONE_OF_SECTIONS = %w[
|
|
36
|
-
KNOWN_SECTIONS = %w[
|
|
37
|
+
REQUIRES_ONE_OF_SECTIONS = %w[schema template].freeze
|
|
38
|
+
KNOWN_SECTIONS = %w[schema template logic].freeze
|
|
37
39
|
ALL_SECTIONS = KNOWN_SECTIONS.freeze
|
|
38
40
|
|
|
39
|
-
# Known
|
|
40
|
-
|
|
41
|
+
# Known schema section attributes
|
|
42
|
+
KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends].freeze
|
|
41
43
|
|
|
42
44
|
attr_reader :content, :file_path, :grammar, :ast
|
|
43
45
|
|
|
@@ -97,7 +99,7 @@ module Rhales
|
|
|
97
99
|
content = convert_nodes_to_string(node.value[:content])
|
|
98
100
|
"{{#each #{items}}}#{content}{{/each}}"
|
|
99
101
|
when :handlebars_expression
|
|
100
|
-
# Handle
|
|
102
|
+
# Handle raw handlebars expressions
|
|
101
103
|
if node.value[:raw]
|
|
102
104
|
"{{{#{node.value[:content]}}}"
|
|
103
105
|
else
|
|
@@ -112,24 +114,41 @@ module Rhales
|
|
|
112
114
|
sections[name]
|
|
113
115
|
end
|
|
114
116
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
+
def layout
|
|
118
|
+
schema_attributes['layout']
|
|
117
119
|
end
|
|
118
120
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
+
# Schema section accessors
|
|
122
|
+
def schema_attributes
|
|
123
|
+
@schema_attributes ||= {}
|
|
121
124
|
end
|
|
122
125
|
|
|
123
|
-
def
|
|
124
|
-
|
|
126
|
+
def schema_lang
|
|
127
|
+
schema_attributes['lang']
|
|
125
128
|
end
|
|
126
129
|
|
|
127
|
-
def
|
|
128
|
-
|
|
130
|
+
def schema_version
|
|
131
|
+
schema_attributes['version']
|
|
129
132
|
end
|
|
130
133
|
|
|
131
|
-
def
|
|
132
|
-
|
|
134
|
+
def schema_envelope
|
|
135
|
+
schema_attributes['envelope']
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def schema_window
|
|
139
|
+
schema_attributes['window'] || 'data'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def schema_merge_strategy
|
|
143
|
+
schema_attributes['merge']
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def schema_layout
|
|
147
|
+
schema_attributes['layout']
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def schema_extends
|
|
151
|
+
schema_attributes['extends']
|
|
133
152
|
end
|
|
134
153
|
|
|
135
154
|
def section?(name)
|
|
@@ -153,12 +172,8 @@ module Rhales
|
|
|
153
172
|
extract_variables_from_section('template', exclude_partials: true)
|
|
154
173
|
end
|
|
155
174
|
|
|
156
|
-
def data_variables
|
|
157
|
-
extract_variables_from_section('data')
|
|
158
|
-
end
|
|
159
|
-
|
|
160
175
|
def all_variables
|
|
161
|
-
|
|
176
|
+
template_variables.uniq
|
|
162
177
|
end
|
|
163
178
|
|
|
164
179
|
private
|
|
@@ -186,7 +201,7 @@ module Rhales
|
|
|
186
201
|
when :unless_block, :each_block
|
|
187
202
|
extract_partials_from_content_nodes(content_node.value[:content], partials)
|
|
188
203
|
when :handlebars_expression
|
|
189
|
-
# Handle
|
|
204
|
+
# Handle handlebars expressions
|
|
190
205
|
content = content_node.value[:content]
|
|
191
206
|
if content.start_with?('>')
|
|
192
207
|
partials << content[1..].strip
|
|
@@ -226,10 +241,10 @@ module Rhales
|
|
|
226
241
|
# Skip partials if requested
|
|
227
242
|
next if exclude_partials
|
|
228
243
|
when :text
|
|
229
|
-
# Extract handlebars expressions from text content
|
|
244
|
+
# Extract handlebars expressions from text content
|
|
230
245
|
extract_variables_from_text(node.value, variables, exclude_partials: exclude_partials)
|
|
231
246
|
when :handlebars_expression
|
|
232
|
-
# Handle
|
|
247
|
+
# Handle handlebars expressions
|
|
233
248
|
content = node.value[:content]
|
|
234
249
|
|
|
235
250
|
# Skip partials if requested
|
|
@@ -259,31 +274,34 @@ module Rhales
|
|
|
259
274
|
end
|
|
260
275
|
|
|
261
276
|
def parse_data_attributes!
|
|
262
|
-
|
|
263
|
-
@
|
|
264
|
-
|
|
265
|
-
if data_section
|
|
266
|
-
@data_attributes = data_section.value[:attributes].dup
|
|
277
|
+
schema_section = @grammar.sections['schema']
|
|
278
|
+
@schema_attributes = {}
|
|
267
279
|
|
|
280
|
+
if schema_section
|
|
281
|
+
@schema_attributes = schema_section.value[:attributes].dup
|
|
268
282
|
# Validate attributes and warn about unknown ones
|
|
269
|
-
|
|
270
|
-
|
|
283
|
+
validate_schema_attributes!
|
|
284
|
+
# Set default window attribute for schema section
|
|
285
|
+
@schema_attributes['window'] ||= 'data'
|
|
271
286
|
|
|
272
|
-
|
|
273
|
-
|
|
287
|
+
# Schema sections require lang attribute
|
|
288
|
+
unless @schema_attributes['lang']
|
|
289
|
+
raise ParseError, "Schema section requires 'lang' attribute (e.g., lang=\"ts-zod\")"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
274
292
|
end
|
|
275
293
|
|
|
276
|
-
def
|
|
277
|
-
unknown_attributes = @
|
|
294
|
+
def validate_schema_attributes!
|
|
295
|
+
unknown_attributes = @schema_attributes.keys - KNOWN_SCHEMA_ATTRIBUTES
|
|
278
296
|
|
|
279
297
|
unknown_attributes.each do |attr|
|
|
280
|
-
|
|
298
|
+
warn_unknown_schema_attribute(attr)
|
|
281
299
|
end
|
|
282
300
|
end
|
|
283
301
|
|
|
284
|
-
def
|
|
302
|
+
def warn_unknown_schema_attribute(attribute)
|
|
285
303
|
file_info = @file_path ? " in #{@file_path}" : ''
|
|
286
|
-
warn "Warning:
|
|
304
|
+
warn "Warning: schema section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
|
|
287
305
|
end
|
|
288
306
|
|
|
289
307
|
class << self
|