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.
Files changed (115) 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 -1
  20. data/Gemfile +29 -0
  21. data/Gemfile.lock +189 -0
  22. data/README.md +686 -868
  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 +47 -0
  64. data/lib/rhales/core/context.rb +354 -0
  65. data/lib/rhales/{rue_document.rb → core/rue_document.rb} +56 -38
  66. data/lib/rhales/{template_engine.rb → core/template_engine.rb} +66 -59
  67. data/lib/rhales/{view.rb → core/view.rb} +112 -135
  68. data/lib/rhales/{view_composition.rb → core/view_composition.rb} +78 -8
  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/{earliest_injection_detector.rb → hydration/earliest_injection_detector.rb} +4 -0
  73. data/lib/rhales/hydration/hydration_data_aggregator.rb +487 -0
  74. data/lib/rhales/{hydration_endpoint.rb → hydration/hydration_endpoint.rb} +16 -12
  75. data/lib/rhales/{hydration_injector.rb → hydration/hydration_injector.rb} +4 -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/{link_based_injection_detector.rb → hydration/link_based_injection_detector.rb} +4 -0
  79. data/lib/rhales/{mount_point_detector.rb → hydration/mount_point_detector.rb} +4 -0
  80. data/lib/rhales/{safe_injection_validator.rb → hydration/safe_injection_validator.rb} +4 -0
  81. data/lib/rhales/hydration.rb +13 -0
  82. data/lib/rhales/{refinements → integrations/refinements}/require_refinements.rb +3 -1
  83. data/lib/rhales/{tilt.rb → integrations/tilt.rb} +22 -15
  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 +9 -7
  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 +3 -1
  98. data/lib/rhales.rb +41 -24
  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 +141 -23
  111. data/CLAUDE.locale.txt +0 -7
  112. data/lib/rhales/context.rb +0 -239
  113. data/lib/rhales/hydration_data_aggregator.rb +0 -221
  114. data/lib/rhales/hydrator.rb +0 -141
  115. 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[data template].freeze
36
- KNOWN_SECTIONS = %w[data template logic].freeze
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 data section attributes
40
- KNOWN_DATA_ATTRIBUTES = %w[window merge layout].freeze
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 legacy format for data sections
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 data_attributes
116
- @data_attributes ||= {}
117
+ def layout
118
+ schema_attributes['layout']
117
119
  end
118
120
 
119
- def window_attribute
120
- data_attributes['window'] || 'data'
121
+ # Schema section accessors
122
+ def schema_attributes
123
+ @schema_attributes ||= {}
121
124
  end
122
125
 
123
- def schema_path
124
- data_attributes['schema']
126
+ def schema_lang
127
+ schema_attributes['lang']
125
128
  end
126
129
 
127
- def merge_strategy
128
- data_attributes['merge']
130
+ def schema_version
131
+ schema_attributes['version']
129
132
  end
130
133
 
131
- def layout
132
- data_attributes['layout']
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
- (template_variables + data_variables).uniq
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 old format for data sections
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 (for data sections)
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 old format for data sections
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
- data_section = @grammar.sections['data']
263
- @data_attributes = {}
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
- validate_data_attributes!
270
- end
283
+ validate_schema_attributes!
284
+ # Set default window attribute for schema section
285
+ @schema_attributes['window'] ||= 'data'
271
286
 
272
- # Set default window attribute
273
- @data_attributes['window'] ||= 'data'
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 validate_data_attributes!
277
- unknown_attributes = @data_attributes.keys - KNOWN_DATA_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
- warn_unknown_attribute(attr)
298
+ warn_unknown_schema_attribute(attr)
281
299
  end
282
300
  end
283
301
 
284
- def warn_unknown_attribute(attribute)
302
+ def warn_unknown_schema_attribute(attribute)
285
303
  file_info = @file_path ? " in #{@file_path}" : ''
286
- warn "Warning: data section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
304
+ warn "Warning: schema section encountered '#{attribute}' attribute - not yet supported, ignoring#{file_info}"
287
305
  end
288
306
 
289
307
  class << self