rhales 0.4.0 → 0.5.4

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} +69 -7
  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} +49 -2
  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
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/handlebars_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Hand-rolled recursive descent parser for Handlebars template syntax
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/parsers/rue_format_parser.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  require 'strscan'
4
6
  require_relative 'handlebars_parser'
@@ -9,7 +11,7 @@ module Rhales
9
11
  # This parser implements .rue file parsing rules in Ruby code and produces
10
12
  # an Abstract Syntax Tree (AST) for .rue file processing. It handles:
11
13
  #
12
- # - Section-based parsing: <data>, <template>, <logic>
14
+ # - Section-based parsing: <schema>, <template>, <logic>
13
15
  # - Attribute extraction from section tags
14
16
  # - Delegation to HandlebarsParser for template content
15
17
  # - Validation of required sections
@@ -21,7 +23,7 @@ module Rhales
21
23
  # File format structure:
22
24
  # rue_file := section+
23
25
  # section := '<' tag_name attributes? '>' content '</' tag_name '>'
24
- # tag_name := 'data' | 'template' | 'logic'
26
+ # tag_name := 'schema' | 'template' | 'logic'
25
27
  # attributes := attribute+
26
28
  # attribute := key '=' quoted_value
27
29
  # content := (text | handlebars_expression)*
@@ -29,9 +31,9 @@ module Rhales
29
31
  class RueFormatParser
30
32
  # At least one of these sections must be present
31
33
  unless defined?(REQUIRES_ONE_OF_SECTIONS)
32
- REQUIRES_ONE_OF_SECTIONS = %w[data template].freeze
34
+ REQUIRES_ONE_OF_SECTIONS = %w[schema template].freeze
33
35
 
34
- KNOWN_SECTIONS = %w[data template logic].freeze
36
+ KNOWN_SECTIONS = %w[schema template logic].freeze
35
37
  ALL_SECTIONS = KNOWN_SECTIONS.freeze
36
38
 
37
39
  # Regular expression to match HTML/XML comments outside of sections
@@ -204,7 +206,7 @@ module Rhales
204
206
  handlebars_parser.parse!
205
207
  handlebars_parser.ast.children
206
208
  else
207
- # For data and logic sections, keep as simple text
209
+ # For schema and logic sections, keep as simple text
208
210
  raw_content.empty? ? [] : [Node.new(:text, current_location, value: raw_content)]
209
211
  end
210
212
  else
@@ -380,10 +382,10 @@ module Rhales
380
382
  when scanner.scan(/<!--.*?-->/m)
381
383
  # Comment token - non-greedy match for complete comments
382
384
  { type: :comment, content: scanner.matched }
383
- when scanner.scan(/<(data|template|logic)(\s[^>]*)?>/m)
385
+ when scanner.scan(/<(schema|template|logic)(\s[^>]*)?>/m)
384
386
  # Section start token - matches opening tags with optional attributes
385
387
  { type: :section_start, content: scanner.matched }
386
- when scanner.scan(%r{</(data|template|logic)>}m)
388
+ when scanner.scan(%r{</(schema|template|logic)>}m)
387
389
  # Section end token - matches closing tags
388
390
  { type: :section_end, content: scanner.matched }
389
391
  when scanner.scan(/[^<]+/)
@@ -0,0 +1,9 @@
1
+ # lib/rhales/parsers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ # Dependencies
6
+ require_relative 'errors'
7
+
8
+ require_relative 'parsers/handlebars_parser'
9
+ require_relative 'parsers/rue_format_parser'
@@ -1,4 +1,6 @@
1
1
  # lib/rhales/csp.rb
2
+ #
3
+ # frozen_string_literal: true
2
4
 
3
5
  module Rhales
4
6
  # Content Security Policy (CSP) header generation and management
@@ -11,6 +13,8 @@ module Rhales
11
13
  # header = csp.build_header
12
14
  # # => "default-src 'self'; script-src 'self' 'nonce-abc123'; ..."
13
15
  class CSP
16
+ include Rhales::Utils::LoggingHelpers
17
+
14
18
  attr_reader :config, :nonce
15
19
 
16
20
  def initialize(config, nonce: nil)
@@ -23,6 +27,7 @@ module Rhales
23
27
  return nil unless @config.csp_enabled
24
28
 
25
29
  policy_directives = []
30
+ nonce_used = false
26
31
 
27
32
  @config.csp_policy.each do |directive, sources|
28
33
  if sources.empty?
@@ -30,18 +35,37 @@ module Rhales
30
35
  policy_directives << directive
31
36
  else
32
37
  # Process sources and interpolate nonce if present
33
- processed_sources = sources.map { |source| interpolate_nonce(source) }
38
+ processed_sources = sources.map do |source|
39
+ interpolated = interpolate_nonce(source)
40
+ nonce_used = true if interpolated != source
41
+ interpolated
42
+ end
34
43
  directive_string = "#{directive} #{processed_sources.join(' ')}"
35
44
  policy_directives << directive_string
36
45
  end
37
46
  end
38
47
 
39
- policy_directives.join('; ')
48
+ header = policy_directives.join('; ')
49
+
50
+ # Log CSP header generation for security audit
51
+ log_with_metadata(Rhales.logger, :debug, "CSP header generated",
52
+ nonce_used: nonce_used,
53
+ nonce: @nonce,
54
+ directive_count: policy_directives.size,
55
+ header_length: header.length
56
+ )
57
+
58
+ header
40
59
  end
41
60
 
42
61
  # Generate a new nonce value
43
62
  def self.generate_nonce
44
- SecureRandom.hex(16)
63
+ nonce = SecureRandom.hex(16)
64
+
65
+ # Log nonce generation for security audit trail
66
+ Rhales.logger.debug("CSP nonce generated: nonce=#{nonce} length=#{nonce.length} entropy_bits=#{nonce.length * 4}")
67
+
68
+ nonce
45
69
  end
46
70
 
47
71
  # Validate CSP policy configuration
@@ -0,0 +1,114 @@
1
+ # lib/rhales/utils/json_serializer.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rhales
6
+ # Centralized JSON serialization with optional Oj support
7
+ #
8
+ # This module provides a unified interface for JSON operations with automatic
9
+ # Oj optimization when available. Oj is 10-20x faster than stdlib JSON for
10
+ # parsing and 5-10x faster for generation.
11
+ #
12
+ # The serializer backend is determined once at load time for optimal performance.
13
+ # If you want to use Oj, require it before requiring Rhales:
14
+ #
15
+ # @example Ensuring Oj is used
16
+ # require 'oj'
17
+ # require 'rhales'
18
+ #
19
+ # @example Basic usage
20
+ # Rhales::JSONSerializer.dump({ user: 'Alice' })
21
+ # # => "{\"user\":\"Alice\"}"
22
+ #
23
+ # Rhales::JSONSerializer.parse('{"user":"Alice"}')
24
+ # # => {"user"=>"Alice"}
25
+ #
26
+ # @example Pretty printing
27
+ # Rhales::JSONSerializer.pretty_dump({ user: 'Alice', count: 42 })
28
+ # # => "{\n \"user\": \"Alice\",\n \"count\": 42\n}"
29
+ #
30
+ # @example Check backend
31
+ # Rhales::JSONSerializer.backend
32
+ # # => :oj (if available) or :json (stdlib)
33
+ #
34
+ module JSONSerializer
35
+ class << self
36
+ # Serialize Ruby object to JSON string
37
+ #
38
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
39
+ #
40
+ # @param obj [Object] Ruby object to serialize
41
+ # @return [String] JSON string (compact format)
42
+ # @raise [TypeError] if object contains non-serializable types
43
+ def dump(obj)
44
+ @json_dumper.call(obj)
45
+ end
46
+
47
+ # Serialize Ruby object to pretty-printed JSON string
48
+ #
49
+ # Uses the serializer backend determined at load time (Oj or stdlib JSON).
50
+ # Output is formatted with indentation for readability.
51
+ #
52
+ # @param obj [Object] Ruby object to serialize
53
+ # @return [String] Pretty-printed JSON string with 2-space indentation
54
+ # @raise [TypeError] if object contains non-serializable types
55
+ def pretty_dump(obj)
56
+ @json_pretty_dumper.call(obj)
57
+ end
58
+
59
+ # Parse JSON string to Ruby object
60
+ #
61
+ # Uses the parser backend determined at load time (Oj or stdlib JSON).
62
+ # Always returns hashes with string keys (not symbols) for consistency.
63
+ #
64
+ # @param json_string [String] JSON string to parse
65
+ # @return [Object] parsed Ruby object (Hash with string keys)
66
+ # @raise [JSON::ParserError, Oj::ParseError] if JSON is malformed
67
+ def parse(json_string)
68
+ @json_loader.call(json_string)
69
+ end
70
+
71
+ # Returns the active JSON backend
72
+ #
73
+ # @return [Symbol] :oj or :json
74
+ def backend
75
+ @backend
76
+ end
77
+
78
+ # Reset backend detection (useful for testing)
79
+ #
80
+ # @api private
81
+ def reset!
82
+ detect_backend!
83
+ end
84
+
85
+ private
86
+
87
+ # Detect and configure JSON backend at load time
88
+ def detect_backend!
89
+ oj_available = begin
90
+ require 'oj'
91
+ true
92
+ rescue LoadError
93
+ false
94
+ end
95
+
96
+ if oj_available
97
+ @backend = :oj
98
+ @json_dumper = ->(obj) { Oj.dump(obj, mode: :strict) }
99
+ @json_pretty_dumper = ->(obj) { Oj.dump(obj, mode: :strict, indent: 2) }
100
+ @json_loader = ->(json_string) { Oj.load(json_string, mode: :strict, symbol_keys: false) }
101
+ else
102
+ require 'json'
103
+ @backend = :json
104
+ @json_dumper = ->(obj) { JSON.generate(obj) }
105
+ @json_pretty_dumper = ->(obj) { JSON.pretty_generate(obj) }
106
+ @json_loader = ->(json_string) { JSON.parse(json_string) }
107
+ end
108
+ end
109
+ end
110
+
111
+ # Initialize backend on load
112
+ detect_backend!
113
+ end
114
+ end
@@ -0,0 +1,75 @@
1
+ # lib/rhales/utils/logging_helpers.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ module Rhales
6
+ module Utils
7
+ # Helper methods for consistent logging and timing instrumentation across Rhales components
8
+ module LoggingHelpers
9
+ include Rhales::Utils
10
+
11
+ # Log with timing for an operation
12
+ #
13
+ # @param logger [Logger] The logger instance to use
14
+ # @param level [Symbol] The log level (:debug, :info, :warn, :error)
15
+ # @param message [String] The log message
16
+ # @param metadata [Hash] Additional metadata to include in the log
17
+ # @yield The block to execute and time
18
+ # @return The result of the block
19
+ #
20
+ # Logs the operation with timing information in microseconds.
21
+ def log_timed_operation(logger, level, message, **metadata)
22
+ start_time = now_in_μs
23
+ result = yield
24
+ duration = now_in_μs - start_time
25
+
26
+ log_with_metadata(logger, level, message, metadata.merge(duration: duration))
27
+
28
+ result
29
+ rescue StandardError => ex
30
+ duration = now_in_μs - start_time
31
+ log_with_metadata(logger, :error, "#{message} failed",
32
+ metadata.merge(
33
+ duration: duration,
34
+ error: ex.message,
35
+ error_class: ex.class.name,
36
+ )
37
+ )
38
+ raise
39
+ end
40
+
41
+ # Log a message with structured metadata
42
+ def log_with_metadata(logger, level, message, metadata = {})
43
+ return logger.public_send(level, message) if metadata.empty?
44
+
45
+ metadata_str = metadata.map { |k, v| "#{k}=#{format_value(v)}" }.join(' ')
46
+ logger.public_send(level, "#{message}: #{metadata_str}")
47
+ end
48
+
49
+ private
50
+
51
+ # Format individual log values
52
+ def format_value(value)
53
+ case value
54
+ when String
55
+ value.include?(' ') ? "\"#{value}\"" : value
56
+ when Symbol, Numeric, true, false, nil
57
+ value.to_s
58
+ when Array
59
+ # For arrays longer than 5 items, show count + first/last items
60
+ if value.size > 5
61
+ first_three = value.first(3).join(', ')
62
+ last_two = value.last(2).join(', ')
63
+ "[#{value.size} items: #{first_three} ... #{last_two}]"
64
+ elsif value.empty?
65
+ '[]'
66
+ else
67
+ "[#{value.join(', ')}]"
68
+ end
69
+ else
70
+ value.to_s
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,132 @@
1
+ # lib/rhales/schema_extractor.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'pathname'
6
+ require_relative '../core/rue_document'
7
+
8
+ module Rhales
9
+ # Extracts schema definitions from .rue files
10
+ #
11
+ # This class scans template directories for .rue files containing <schema>
12
+ # sections and extracts the schema code along with metadata (attributes).
13
+ #
14
+ # Usage:
15
+ # extractor = SchemaExtractor.new('./templates')
16
+ # schemas = extractor.extract_all
17
+ # schemas.each do |schema_info|
18
+ # puts "#{schema_info[:template_name]}: #{schema_info[:lang]}"
19
+ # end
20
+ class SchemaExtractor
21
+ class ExtractionError < StandardError; end
22
+
23
+ attr_reader :templates_dir
24
+
25
+ def initialize(templates_dir)
26
+ @templates_dir = File.expand_path(templates_dir)
27
+ validate_directory!
28
+ end
29
+
30
+ # Extract all schemas from .rue files in the templates directory
31
+ #
32
+ # @return [Array<Hash>] Array of schema information hashes
33
+ # @example
34
+ # [
35
+ # {
36
+ # template_name: 'dashboard',
37
+ # template_path: '/path/to/dashboard.rue',
38
+ # schema_code: 'const schema = z.object({...});',
39
+ # lang: 'ts-zod',
40
+ # version: '2',
41
+ # envelope: 'SuccessEnvelope',
42
+ # window: 'appData',
43
+ # merge: 'deep',
44
+ # layout: 'layouts/main',
45
+ # extends: nil
46
+ # }
47
+ # ]
48
+ def extract_all
49
+ rue_files = find_rue_files
50
+ schemas = []
51
+
52
+ rue_files.each do |file_path|
53
+ begin
54
+ schema_info = extract_from_file(file_path)
55
+ schemas << schema_info if schema_info
56
+ rescue => e
57
+ warn "Warning: Failed to extract schema from #{file_path}: #{e.message}"
58
+ end
59
+ end
60
+
61
+ schemas
62
+ end
63
+
64
+ # Extract schema from a single .rue file
65
+ #
66
+ # @param file_path [String] Path to the .rue file
67
+ # @return [Hash, nil] Schema information hash or nil if no schema section
68
+ def extract_from_file(file_path)
69
+ doc = RueDocument.parse_file(file_path)
70
+
71
+ return nil unless doc.section?('schema')
72
+
73
+ template_name = derive_template_name(file_path)
74
+ schema_code = doc.section('schema')
75
+
76
+ {
77
+ template_name: template_name,
78
+ template_path: file_path,
79
+ schema_code: schema_code.strip,
80
+ lang: doc.schema_lang,
81
+ version: doc.schema_version,
82
+ envelope: doc.schema_envelope,
83
+ window: doc.schema_window,
84
+ merge: doc.schema_merge_strategy,
85
+ layout: doc.schema_layout,
86
+ extends: doc.schema_extends
87
+ }
88
+ end
89
+
90
+ # Find all .rue files in the templates directory (recursive)
91
+ #
92
+ # @return [Array<String>] Array of absolute file paths
93
+ def find_rue_files
94
+ pattern = File.join(@templates_dir, '**', '*.rue')
95
+ Dir.glob(pattern).sort
96
+ end
97
+
98
+ # Count how many .rue files have schema sections
99
+ #
100
+ # @return [Hash] Count information
101
+ def schema_stats
102
+ all_files = find_rue_files
103
+ schemas = extract_all
104
+
105
+ {
106
+ total_files: all_files.count,
107
+ files_with_schemas: schemas.count,
108
+ files_without_schemas: all_files.count - schemas.count,
109
+ schemas_by_lang: schemas.group_by { |s| s[:lang] }.transform_values(&:count)
110
+ }
111
+ end
112
+
113
+ private
114
+
115
+ def validate_directory!
116
+ unless File.directory?(@templates_dir)
117
+ raise ExtractionError, "Templates directory does not exist: #{@templates_dir}"
118
+ end
119
+ end
120
+
121
+ # Derive template name from file path
122
+ # Examples:
123
+ # /path/to/templates/dashboard.rue => 'dashboard'
124
+ # /path/to/templates/pages/user/profile.rue => 'pages/user/profile'
125
+ def derive_template_name(file_path)
126
+ templates_pathname = Pathname.new(@templates_dir)
127
+ file_pathname = Pathname.new(file_path)
128
+ relative_path = file_pathname.relative_path_from(templates_pathname)
129
+ relative_path.to_s.sub(/\.rue$/, '')
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,194 @@
1
+ # lib/rhales/schema_generator.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'open3'
6
+ require 'tempfile'
7
+ require 'fileutils'
8
+ require_relative 'schema_extractor'
9
+ require_relative 'json_serializer'
10
+
11
+ module Rhales
12
+ # Generates JSON Schemas from Zod schemas using TypeScript execution
13
+ #
14
+ # This class uses pnpm exec tsx to execute Zod schema code and convert it
15
+ # to JSON Schema format. The generated schemas are saved to disk for use
16
+ # by the validation middleware.
17
+ #
18
+ # Usage:
19
+ # generator = SchemaGenerator.new(
20
+ # templates_dir: './templates',
21
+ # output_dir: './public/schemas'
22
+ # )
23
+ # results = generator.generate_all
24
+ class SchemaGenerator
25
+ class GenerationError < StandardError; end
26
+
27
+ attr_reader :templates_dir, :output_dir
28
+
29
+ # @param templates_dir [String] Directory containing .rue files
30
+ # @param output_dir [String] Directory to save generated JSON schemas
31
+ # Defaults to './public/schemas' (implementing project's public directory)
32
+ def initialize(templates_dir:, output_dir: nil)
33
+ @templates_dir = File.expand_path(templates_dir)
34
+
35
+ # Smart default: place schemas in public/schemas relative to current working directory
36
+ # This ensures schemas are generated in the implementing project, not the gem directory
37
+ @output_dir = if output_dir
38
+ File.expand_path(output_dir)
39
+ else
40
+ # Default to public/schemas in current working directory
41
+ File.expand_path('./public/schemas')
42
+ end
43
+
44
+ validate_setup!
45
+ ensure_output_directory!
46
+ end
47
+
48
+ # Generate JSON Schemas for all templates with <schema> sections
49
+ #
50
+ # @return [Hash] Generation results with stats
51
+ def generate_all
52
+ extractor = SchemaExtractor.new(@templates_dir)
53
+ schemas = extractor.extract_all
54
+
55
+ if schemas.empty?
56
+ return {
57
+ success: true,
58
+ generated: 0,
59
+ failed: 0,
60
+ message: 'No schemas found in templates'
61
+ }
62
+ end
63
+
64
+ results = {
65
+ success: true,
66
+ generated: 0,
67
+ failed: 0,
68
+ errors: []
69
+ }
70
+
71
+ schemas.each do |schema_info|
72
+ begin
73
+ generate_schema(schema_info)
74
+ results[:generated] += 1
75
+ puts "✓ Generated schema for: #{schema_info[:template_name]}"
76
+ rescue => e
77
+ results[:failed] += 1
78
+ results[:success] = false
79
+ error_msg = "Failed to generate schema for #{schema_info[:template_name]}: #{e.message}"
80
+ results[:errors] << error_msg
81
+ warn error_msg
82
+ end
83
+ end
84
+
85
+ results
86
+ end
87
+
88
+ # Generate JSON Schema for a single template
89
+ #
90
+ # @param schema_info [Hash] Schema information from SchemaExtractor
91
+ # @return [Hash] Generated JSON Schema
92
+ def generate_schema(schema_info)
93
+ # Create temp file in project directory so Node.js can resolve modules
94
+ temp_dir = File.join(Dir.pwd, 'tmp')
95
+ FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
96
+
97
+ temp_file = Tempfile.new(['schema', '.mts'], temp_dir)
98
+
99
+ begin
100
+ # Write TypeScript script
101
+ temp_file.write(build_typescript_script(schema_info))
102
+ temp_file.close
103
+
104
+ # Execute with tsx via pnpm
105
+ stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'tsx', temp_file.path)
106
+
107
+ unless status.success?
108
+ raise GenerationError, "TypeScript execution failed: #{stderr}"
109
+ end
110
+
111
+ # Parse JSON Schema from stdout
112
+ json_schema = JSONSerializer.parse(stdout)
113
+
114
+ # Save to disk
115
+ save_schema(schema_info[:template_name], json_schema)
116
+
117
+ json_schema
118
+ ensure
119
+ temp_file.unlink if temp_file
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def build_typescript_script(schema_info)
126
+ # Escape single quotes in template name for TypeScript string
127
+ safe_name = schema_info[:template_name].gsub("'", "\\'")
128
+
129
+ <<~TYPESCRIPT
130
+ // Auto-generated schema generator for #{safe_name}
131
+ import { z } from 'zod/v4';
132
+
133
+ // Schema code from .rue template
134
+ #{schema_info[:schema_code].strip}
135
+
136
+ // Generate JSON Schema
137
+ try {
138
+ const jsonSchema = z.toJSONSchema(schema, {
139
+ target: 'draft-2020-12',
140
+ unrepresentable: 'any',
141
+ cycles: 'ref',
142
+ reused: 'inline',
143
+ });
144
+
145
+ // Add metadata
146
+ const schemaWithMeta = {
147
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
148
+ $id: `https://rhales.dev/schemas/#{safe_name}.json`,
149
+ title: '#{safe_name}',
150
+ description: 'Schema for #{safe_name} template',
151
+ ...jsonSchema,
152
+ };
153
+
154
+ // Output JSON to stdout
155
+ console.log(JSON.stringify(schemaWithMeta, null, 2));
156
+ } catch (error) {
157
+ console.error('Schema generation error:', error.message);
158
+ process.exit(1);
159
+ }
160
+ TYPESCRIPT
161
+ end
162
+
163
+ def save_schema(template_name, json_schema)
164
+ # Create subdirectories if template name contains paths
165
+ schema_file = File.join(@output_dir, "#{template_name}.json")
166
+ schema_dir = File.dirname(schema_file)
167
+ FileUtils.mkdir_p(schema_dir) unless File.directory?(schema_dir)
168
+
169
+ File.write(schema_file, JSONSerializer.dump(json_schema))
170
+ end
171
+
172
+ def validate_setup!
173
+ unless File.directory?(@templates_dir)
174
+ raise GenerationError, "Templates directory does not exist: #{@templates_dir}"
175
+ end
176
+
177
+ # Check pnpm is available
178
+ stdout, stderr, status = Open3.capture3('pnpm', '--version')
179
+ unless status.success?
180
+ raise GenerationError, "pnpm not found. Install pnpm to generate schemas: npm install -g pnpm"
181
+ end
182
+
183
+ # Check tsx is available (will be installed by pnpm if needed)
184
+ stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'tsx', '--version')
185
+ unless status.success?
186
+ raise GenerationError, "tsx not found. Run: pnpm install tsx --save-dev"
187
+ end
188
+ end
189
+
190
+ def ensure_output_directory!
191
+ FileUtils.mkdir_p(@output_dir) unless File.directory?(@output_dir)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,40 @@
1
+ # lib/rhales/utils.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'pathname'
6
+
7
+ module Rhales
8
+ module Utils
9
+ # Utility modules and classes
10
+
11
+ # @return [Time] Current time in UTC
12
+ def now
13
+ Time.now.utc
14
+ end
15
+
16
+ # Returns the current time in microseconds.
17
+ # This is used to measure the duration of Database commands.
18
+ #
19
+ # Alias: now_in_microseconds
20
+ #
21
+ # @return [Integer] The current time in microseconds.
22
+ def now_in_μs
23
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
24
+ end
25
+ alias now_in_microseconds now_in_μs
26
+
27
+ # @param filepath [String, nil] The file path to prettify
28
+ # @return [String, nil] The expanded absolute path, or nil if input is
29
+ def pretty_path(filepath)
30
+ return nil if filepath.nil?
31
+
32
+ Pathname.new(filepath).expand_path.to_s
33
+ end
34
+ end
35
+ end
36
+
37
+ require_relative 'utils/json_serializer'
38
+ require_relative 'utils/schema_generator'
39
+ require_relative 'utils/schema_extractor'
40
+ require_relative 'utils/logging_helpers'