rhales 0.5.4 → 0.6.0

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba52e82c80de6bba26167aaf61b424d72a03fc195ec69be7e38dbd17422cf064
4
- data.tar.gz: d87dc62997bf10cfde421bb93e7d1025b2374e9cd53a2280acd37c157d553227
3
+ metadata.gz: 441e27aff0e4003c6e0351c6cf065cad3c3348c2aba7059acdfe6e26670df95c
4
+ data.tar.gz: bb38a8fd9c007c9efd3a69a6f5a1355ecbff4874553d7f63bc8b000e730677b7
5
5
  SHA512:
6
- metadata.gz: 8368580c220c662432ede3e323b9460b6edba4ff5012c57fbbe0b4760572d063aed07225a4e9b29d542d00f169c7ccdab9ddf7c1aa742b67d0a80c438ad91b18
7
- data.tar.gz: 2ff74a6825fda46507e849dfc19139c7084be205024a03d2b0fd5f8d930d71366ce5c3407fbeeb77acf133bdd5486b17dbc5b068780ec169a1ee29c15669f1e2
6
+ metadata.gz: 0733d8449de842934d86fe0af89fa6dc8b0c345248bda7c11c575f004b2996d1d845d3b90d584e28d919d6db1730bb137627af5471215c4ac8b018db06c6db79
7
+ data.tar.gz: d8d871a587836562d94a249f5bf1abf9624a30391b7186411f2e167afd5376006d6f8d68132e1958ca0eb59f091a19b976f177f4d1757c494bec7c32164ebd2c
data/CHANGELOG.md CHANGED
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-03-21
11
+
10
12
  ### Added
13
+ - **External Schema References**: Schema definitions can now reference external TypeScript/JavaScript files via the `src` attribute
14
+ - Enables single-source-of-truth patterns where TypeScript schemas drive both frontend types and Rhales validation
15
+ - Path resolution relative to template file with security checks to prevent path traversal
16
+ - Rake task output now shows inline vs external schema sources
17
+ - Example: `<schema src="schemas/user.schema.ts" lang="js-zod" window="__USER__">`
18
+ - **Multi-directory Schema Search**: New `schema_search_paths` configuration option
19
+ - Allows searching multiple directories for external schema files
20
+ - Resolution order: template-relative first, then search paths in order
21
+ - Security checks apply to all configured paths
22
+ - **tsx Import Mode**: New bundling mode for external schemas with imports
23
+ - `schema_use_tsx_import = true` enables esbuild bundling
24
+ - `schema_tsconfig_path` allows custom TypeScript configuration
25
+ - Externalizes zod to prevent dual-instance issues
26
+ - Cross-platform file:// URL support for Windows ESM compatibility
11
27
  - **Production Logging**: Structured logging via `Rhales.logger=` for security auditing and debugging
12
28
  - View rendering events with template details, timing, and hydration size
13
29
  - Schema validation warnings for production debugging (missing/extra keys)
@@ -23,16 +39,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
23
39
  - Comprehensive test coverage for collision detection and merge strategies
24
40
 
25
41
  ### Changed
42
+ - **Ruby 3.4+ required** (was 3.3.4) - aligns with current LTS ecosystem; no Ruby 3.3 features relied upon, but 3.4 is recommended for YJIT improvements and json_schemer performance
43
+ - Updated zod to 4.3.6
44
+ - Relaxed json_schemer dependency from ~> 2.3 to ~> 2
26
45
  - Replaced `json-schema` gem with `json_schemer` for better JSON Schema Draft 2020-12 support
27
46
  - Improved validation error messages with more structured output from json_schemer
28
47
  - Validation performance improved to <0.05ms average (was ~2ms with json-schema)
29
48
 
49
+ ### Removed
50
+ - Unused `HydrationRegistry.clear!` method
51
+
30
52
  ### Security
31
53
  - Window collision detection prevents accidental data exposure by making overwrites explicit
32
54
  - All merge operations happen client-side after server-side interpolation and JSON serialization
33
55
  - Request-scoped registry prevents cross-request data leakage
34
56
 
35
- ## [0.1.0] - 2024-01-XX
57
+ ## [0.1.0] - 2025-07-21
36
58
 
37
59
  ### Added
38
60
  - Initial release of Rhales
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rhales (0.5.3)
5
- json_schemer (~> 2.3)
4
+ rhales (0.6.0)
5
+ json_schemer (~> 2)
6
6
  logger
7
7
  tilt (~> 2)
8
8
 
data/README.md CHANGED
@@ -1,20 +1,20 @@
1
1
  # Rhales - Ruby Single File Components
2
2
 
3
3
  > [!CAUTION]
4
- > **Early Development Release** - Rhales is in active development (v0.5). The API underwent breaking changes from v0.4. While functional and tested, it's recommended for experimental use and contributions. Please report issues and provide feedback through GitHub.
4
+ > **Early Development Release** - Rhales is in active development (v0.6). The API underwent breaking changes from v0.4. While functional and tested, it's recommended for experimental use and contributions. Please report issues and provide feedback through GitHub.
5
5
 
6
6
  Rhales is a **type-safe contract enforcement framework** for server-rendered pages with client-side data hydration. It uses `.rue` files (Ruby Single File Components) that combine Zod v4 schemas, Handlebars templates, and documentation into a single contract-first format.
7
7
 
8
8
  **About the name:** It all started with a simple mustache template many years ago. Mustache's successor, "Handlebars," is a visual analog for a mustache. "Two Whales Kissing" is another visual analog for a mustache, and since we're working with Ruby, we call it "Rhales" (Ruby + Whales). It's a perfect name with absolutely no ambiguity or risk of confusion.
9
9
 
10
- ## What's New in v0.5
10
+ ## What's New in v0.6
11
11
 
12
- - ✅ **Schema-First Design**: Replaced `<data>` sections with Zod v4 `<schema>` sections
13
- - ✅ **Type Safety**: Contract enforcement between backend and frontend
14
- - ✅ **Simplified API**: Removed deprecated parameters (`sess`, `cust`, `props:`, `app_data:`)
15
- - ✅ **Clear Context Layers**: Renamed `app` `request` for clarity
16
- - ✅ **Schema Tooling**: Rake tasks for schema generation and validation
17
- - **100% Migration**: All demo templates use schemas
12
+ - ✅ **External Schema References**: Reference TypeScript schema files via `src` attribute for single-source-of-truth patterns
13
+ - ✅ **Multi-directory Search**: Configure `schema_search_paths` to search multiple directories for shared schemas
14
+ - ✅ **tsx Import Mode**: Bundle external schemas with imports via esbuild (`schema_use_tsx_import`)
15
+ - ✅ **Ruby 3.4+ Required**: Updated minimum Ruby version
16
+
17
+ **v0.5 features:** Schema-first design, type safety, simplified API, clear context layers, schema tooling.
18
18
 
19
19
  **Breaking changes from v0.4:** See [Migration Guide](#migration-from-v04-to-v05) below.
20
20
 
@@ -134,6 +134,65 @@ const schema = z.object({
134
134
  | `version` | No | Schema version | `"2"` |
135
135
  | `envelope` | No | Response wrapper type | `"SuccessEnvelope"` |
136
136
  | `layout` | No | Layout template reference | `"layouts/main"` |
137
+ | `src` | No | External schema file path | `"schemas/user.schema.ts"` |
138
+
139
+ ### External Schema References
140
+
141
+ Instead of defining schemas inline, you can reference external TypeScript/JavaScript files. This enables single-source-of-truth patterns where the same schema file drives both frontend TypeScript types (via `z.infer<>`) and Rhales validation.
142
+
143
+ ```xml
144
+ <!-- templates/dashboard.rue -->
145
+ <schema src="schemas/dashboard.schema.ts" lang="js-zod" window="__DASHBOARD__">
146
+ </schema>
147
+
148
+ <template>
149
+ <div>{{user.name}}</div>
150
+ </template>
151
+ ```
152
+
153
+ ```typescript
154
+ // templates/schemas/dashboard.schema.ts
155
+ import { z } from 'zod';
156
+
157
+ const schema = z.object({
158
+ user: z.object({
159
+ name: z.string(),
160
+ email: z.string().email()
161
+ }),
162
+ items: z.array(z.string())
163
+ });
164
+
165
+ export default schema;
166
+
167
+ // TypeScript frontend can import and use: z.infer<typeof schema>
168
+ ```
169
+
170
+ The `src` path is resolved relative to the template file. Security checks prevent path traversal outside the templates directory.
171
+
172
+ #### Multi-directory Search
173
+
174
+ Configure additional directories to search for shared schema files:
175
+
176
+ ```ruby
177
+ Rhales.configure do |config|
178
+ config.schema_search_paths = ['./shared/schemas', './lib/schemas']
179
+ end
180
+ ```
181
+
182
+ Resolution order: template-relative first, then search paths in order.
183
+
184
+ #### tsx Import Mode
185
+
186
+ For external schemas that import other modules, enable esbuild bundling:
187
+
188
+ ```ruby
189
+ Rhales.configure do |config|
190
+ config.schema_use_tsx_import = true
191
+ config.schema_tsconfig_path = './tsconfig.json' # optional
192
+ end
193
+ ```
194
+
195
+ This bundles the schema with all its imports while externalizing zod to prevent dual-instance issues.
137
196
 
138
197
  ### Zod Schema Examples
139
198
 
@@ -157,6 +157,9 @@ module Rhales
157
157
  # Hydration mismatch reporting settings
158
158
  attr_accessor :hydration_mismatch_format, :hydration_authority
159
159
 
160
+ # External schema settings
161
+ attr_accessor :schema_search_paths, :schema_tsconfig_path, :schema_use_tsx_import
162
+
160
163
  def initialize
161
164
  # Set sensible defaults
162
165
  @default_locale = 'en'
@@ -191,6 +194,11 @@ module Rhales
191
194
  @hydration_mismatch_format = :compact # :compact, :multiline, :sidebyside, :json
192
195
  @hydration_authority = :schema # :schema or :data
193
196
 
197
+ # External schema defaults
198
+ @schema_search_paths = [] # Additional paths to search for external schemas
199
+ @schema_tsconfig_path = nil # Path to tsconfig.json for tsx import execution
200
+ @schema_use_tsx_import = false # Use tsx import (runs through project tsconfig)
201
+
194
202
  # Yield to block for configuration if provided
195
203
  yield(self) if block_given?
196
204
  end
@@ -257,6 +265,13 @@ module Rhales
257
265
  end
258
266
  end
259
267
 
268
+ # Validate schema search paths exist if specified
269
+ @schema_search_paths.each do |path|
270
+ unless Dir.exist?(path)
271
+ errors << "Schema search path does not exist: #{path}"
272
+ end
273
+ end
274
+
260
275
  # Validate cache TTL
261
276
  if @cache_ttl && @cache_ttl <= 0
262
277
  errors << 'cache_ttl must be positive'
@@ -269,6 +284,7 @@ module Rhales
269
284
  def freeze!
270
285
  @features.freeze
271
286
  @template_paths.freeze
287
+ @schema_search_paths.freeze
272
288
  freeze
273
289
  end
274
290
 
@@ -39,7 +39,7 @@ module Rhales
39
39
  ALL_SECTIONS = KNOWN_SECTIONS.freeze
40
40
 
41
41
  # Known schema section attributes
42
- KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends].freeze
42
+ KNOWN_SCHEMA_ATTRIBUTES = %w[lang version envelope window merge layout extends src].freeze
43
43
 
44
44
  attr_reader :content, :file_path, :grammar, :ast
45
45
 
@@ -151,6 +151,12 @@ module Rhales
151
151
  schema_attributes['extends']
152
152
  end
153
153
 
154
+ # External schema file reference (optional)
155
+ # When present, schema code is loaded from this path instead of inline content
156
+ def schema_src
157
+ schema_attributes['src']
158
+ end
159
+
154
160
  def section?(name)
155
161
  @grammar.sections.key?(name)
156
162
  end
@@ -27,10 +27,6 @@ module Rhales
27
27
  }
28
28
  end
29
29
 
30
- def clear!
31
- Thread.current[:rhales_hydration_registry] = {}
32
- end
33
-
34
30
  # Expose registry for testing purposes
35
31
  def registry
36
32
  thread_local_registry
@@ -71,7 +71,18 @@ module Rhales
71
71
  return nil unless doc.section?('schema')
72
72
 
73
73
  template_name = derive_template_name(file_path)
74
- schema_code = doc.section('schema')
74
+ src = doc.schema_src
75
+ resolved_path = nil
76
+ schema_code = nil
77
+
78
+ if src
79
+ # External schema: resolve path and read content
80
+ resolved_path = resolve_schema_src_path(file_path, src)
81
+ schema_code = read_schema_from_src(resolved_path, src, template_name)
82
+ else
83
+ # Inline schema: use content from the schema section
84
+ schema_code = doc.section('schema')
85
+ end
75
86
 
76
87
  {
77
88
  template_name: template_name,
@@ -83,7 +94,9 @@ module Rhales
83
94
  window: doc.schema_window,
84
95
  merge: doc.schema_merge_strategy,
85
96
  layout: doc.schema_layout,
86
- extends: doc.schema_extends
97
+ extends: doc.schema_extends,
98
+ src: src,
99
+ resolved_path: resolved_path
87
100
  }
88
101
  end
89
102
 
@@ -97,15 +110,20 @@ module Rhales
97
110
 
98
111
  # Count how many .rue files have schema sections
99
112
  #
100
- # @return [Hash] Count information
113
+ # @return [Hash] Count information including external vs inline breakdown
101
114
  def schema_stats
102
115
  all_files = find_rue_files
103
116
  schemas = extract_all
104
117
 
118
+ external_count = schemas.count { |s| s[:src] }
119
+ inline_count = schemas.count { |s| s[:src].nil? }
120
+
105
121
  {
106
122
  total_files: all_files.count,
107
123
  files_with_schemas: schemas.count,
108
124
  files_without_schemas: all_files.count - schemas.count,
125
+ external_schemas: external_count,
126
+ inline_schemas: inline_count,
109
127
  schemas_by_lang: schemas.group_by { |s| s[:lang] }.transform_values(&:count)
110
128
  }
111
129
  end
@@ -128,5 +146,105 @@ module Rhales
128
146
  relative_path = file_pathname.relative_path_from(templates_pathname)
129
147
  relative_path.to_s.sub(/\.rue$/, '')
130
148
  end
149
+
150
+ # Resolve external schema src path
151
+ #
152
+ # Resolution order:
153
+ # 1. Relative to template file directory
154
+ # 2. Search through configured schema_search_paths
155
+ #
156
+ # @param template_path [String] Absolute path to the .rue template
157
+ # @param src [String] The src attribute value from the schema tag
158
+ # @return [String] Absolute path to the external schema file
159
+ # @raise [ExtractionError] If path traversal is detected or file not found
160
+ def resolve_schema_src_path(template_path, src)
161
+ template_dir = File.dirname(template_path)
162
+ resolved = File.expand_path(src, template_dir)
163
+ searched_paths = [resolved]
164
+
165
+ # First, check if the path exists relative to template
166
+ if File.exist?(resolved) && path_within_allowed_directories?(resolved)
167
+ return resolved
168
+ end
169
+
170
+ # If the relative path does not exist or is not allowed,
171
+ # search through configured schema_search_paths
172
+ search_paths = Rhales.configuration.schema_search_paths || []
173
+ search_paths.each do |search_path|
174
+ expanded_search_path = File.expand_path(search_path)
175
+ candidate = File.join(expanded_search_path, src)
176
+ searched_paths << candidate
177
+
178
+ if File.exist?(candidate) && path_within_allowed_directories?(candidate)
179
+ return candidate
180
+ end
181
+ end
182
+
183
+ # Security check on the template-relative path
184
+ unless path_within_allowed_directories?(resolved)
185
+ raise ExtractionError,
186
+ "Schema src path traversal not allowed: '#{src}' resolves outside allowed directories"
187
+ end
188
+
189
+ # File not found in any location - raise helpful error listing all searched paths
190
+ raise ExtractionError,
191
+ "Schema file not found: '#{src}'. Searched:\n - #{searched_paths.join("\n - ")}"
192
+ end
193
+
194
+ # Check if a path is within any allowed directory
195
+ #
196
+ # Allowed directories include:
197
+ # - The templates directory
198
+ # - Any configured schema_search_paths
199
+ #
200
+ # @param path [String] Path to check
201
+ # @return [Boolean] True if path is within an allowed directory
202
+ def path_within_allowed_directories?(path)
203
+ return true if path_within_directory?(path, @templates_dir)
204
+
205
+ search_paths = Rhales.configuration.schema_search_paths || []
206
+ search_paths.any? do |search_path|
207
+ expanded_search_path = File.expand_path(search_path)
208
+ path_within_directory?(path, expanded_search_path)
209
+ end
210
+ end
211
+
212
+ # Read schema content from external file
213
+ #
214
+ # @param resolved_path [String] Absolute path to the schema file
215
+ # @param src [String] Original src attribute value (for error messages)
216
+ # @param template_name [String] Template name (for error messages)
217
+ # @return [String] Schema file content
218
+ # @raise [ExtractionError] If file cannot be read
219
+ def read_schema_from_src(resolved_path, src, template_name)
220
+ unless File.exist?(resolved_path)
221
+ raise ExtractionError,
222
+ "External schema file not found: '#{src}' (resolved to: #{resolved_path}) " \
223
+ "referenced by template '#{template_name}'"
224
+ end
225
+
226
+ File.read(resolved_path)
227
+ rescue Errno::EACCES => e
228
+ raise ExtractionError,
229
+ "Permission denied reading external schema '#{src}': #{e.message}"
230
+ rescue Errno::EISDIR
231
+ raise ExtractionError,
232
+ "External schema path '#{src}' is a directory, not a file"
233
+ end
234
+
235
+ # Check if a path is within a given directory (security check)
236
+ #
237
+ # @param path [String] Path to check
238
+ # @param directory [String] Directory that should contain the path
239
+ # @return [Boolean] True if path is within directory
240
+ def path_within_directory?(path, directory)
241
+ expanded_path = File.expand_path(path)
242
+ expanded_dir = File.expand_path(directory)
243
+
244
+ # Ensure directory ends with separator for accurate prefix matching
245
+ expanded_dir_with_sep = expanded_dir.end_with?(File::SEPARATOR) ? expanded_dir : "#{expanded_dir}#{File::SEPARATOR}"
246
+
247
+ expanded_path.start_with?(expanded_dir_with_sep) || expanded_path == expanded_dir
248
+ end
131
249
  end
132
250
  end
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require 'open3'
6
+ require 'securerandom'
6
7
  require 'tempfile'
7
8
  require 'fileutils'
8
9
  require_relative 'schema_extractor'
@@ -76,7 +77,8 @@ module Rhales
76
77
  rescue => e
77
78
  results[:failed] += 1
78
79
  results[:success] = false
79
- error_msg = "Failed to generate schema for #{schema_info[:template_name]}: #{e.message}"
80
+ source_info = schema_info[:src] ? " (from #{schema_info[:src]})" : ""
81
+ error_msg = "Failed to generate schema for #{schema_info[:template_name]}#{source_info}: #{e.message}"
80
82
  results[:errors] << error_msg
81
83
  warn error_msg
82
84
  end
@@ -95,14 +97,20 @@ module Rhales
95
97
  FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
96
98
 
97
99
  temp_file = Tempfile.new(['schema', '.mts'], temp_dir)
100
+ bundled_file = nil
98
101
 
99
102
  begin
100
- # Write TypeScript script
101
- temp_file.write(build_typescript_script(schema_info))
103
+ # Write TypeScript script - use import mode for external schemas when configured
104
+ if use_tsx_import_mode?(schema_info)
105
+ script, bundled_file = build_typescript_import_script(schema_info)
106
+ else
107
+ script = build_typescript_script(schema_info)
108
+ end
109
+ temp_file.write(script)
102
110
  temp_file.close
103
111
 
104
- # Execute with tsx via pnpm
105
- stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'tsx', temp_file.path)
112
+ # Execute with tsx via pnpm, optionally with tsconfig
113
+ stdout, stderr, status = execute_tsx(temp_file.path)
106
114
 
107
115
  unless status.success?
108
116
  raise GenerationError, "TypeScript execution failed: #{stderr}"
@@ -117,17 +125,132 @@ module Rhales
117
125
  json_schema
118
126
  ensure
119
127
  temp_file.unlink if temp_file
128
+ File.unlink(bundled_file) if bundled_file && File.exist?(bundled_file)
120
129
  end
121
130
  end
122
131
 
123
132
  private
124
133
 
134
+ # Determine if we should use tsx import mode for this schema
135
+ #
136
+ # Import mode is used when:
137
+ # 1. schema_use_tsx_import is enabled in configuration
138
+ # 2. The schema has an external src (not inline)
139
+ # 3. The resolved_path exists
140
+ #
141
+ # @param schema_info [Hash] Schema information
142
+ # @return [Boolean]
143
+ def use_tsx_import_mode?(schema_info)
144
+ return false unless Rhales.configuration.schema_use_tsx_import
145
+ return false unless schema_info[:src]
146
+ return false unless schema_info[:resolved_path]
147
+
148
+ File.exist?(schema_info[:resolved_path])
149
+ end
150
+
151
+ # Execute tsx with optional tsconfig
152
+ #
153
+ # @param script_path [String] Path to the TypeScript script to execute
154
+ # @return [Array] stdout, stderr, status from Open3.capture3
155
+ def execute_tsx(script_path)
156
+ tsconfig_path = Rhales.configuration.schema_tsconfig_path
157
+
158
+ if tsconfig_path
159
+ if File.exist?(tsconfig_path)
160
+ Open3.capture3('pnpm', 'exec', 'tsx', '--tsconfig', tsconfig_path, script_path)
161
+ else
162
+ warn "[Rhales] Warning: schema_tsconfig_path '#{tsconfig_path}' does not exist, ignoring"
163
+ Open3.capture3('pnpm', 'exec', 'tsx', script_path)
164
+ end
165
+ else
166
+ Open3.capture3('pnpm', 'exec', 'tsx', script_path)
167
+ end
168
+ end
169
+
170
+ # Build TypeScript script from bundled external schema
171
+ #
172
+ # Uses esbuild to bundle the external schema with all imports resolved,
173
+ # writes to a temp file, then imports via default export. This allows
174
+ # external files to name their schema variable anything they want.
175
+ #
176
+ # External schema files must use default export:
177
+ # const mySchema = z.object({ ... });
178
+ # export default mySchema;
179
+ #
180
+ # @param schema_info [Hash] Schema information with resolved_path
181
+ # @return [Array<String, String>] [script content, bundled_file_path] - caller must clean up bundled file
182
+ def build_typescript_import_script(schema_info)
183
+ safe_name = schema_info[:template_name].gsub("'", "\\'")
184
+ schema_path = schema_info[:resolved_path]
185
+
186
+ # Bundle external schema with esbuild to temp file - resolves all imports
187
+ # Use SecureRandom for uniqueness across concurrent invocations
188
+ temp_dir = File.join(Dir.pwd, 'tmp')
189
+ FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
190
+ unique_suffix = "#{Process.pid}_#{SecureRandom.hex(4)}"
191
+ bundled_file = File.join(temp_dir, "bundled_#{File.basename(schema_path, '.*')}_#{unique_suffix}.mjs")
192
+
193
+ stdout, stderr, status = Open3.capture3(
194
+ 'pnpm', 'exec', 'esbuild', schema_path,
195
+ '--bundle', '--format=esm', '--platform=node',
196
+ '--external:zod',
197
+ "--outfile=#{bundled_file}"
198
+ )
199
+
200
+ unless status.success?
201
+ raise GenerationError, "esbuild bundling failed for #{schema_path}: #{stderr}"
202
+ end
203
+
204
+ # Convert to file:// URL for cross-platform ESM import compatibility
205
+ bundled_file_url = path_to_file_url(bundled_file)
206
+
207
+ script = <<~TYPESCRIPT
208
+ // Auto-generated schema generator for #{safe_name}
209
+ // Source: #{schema_info[:src]} (bundled via esbuild)
210
+ import { z } from 'zod/v4';
211
+ import schema from '#{bundled_file_url}';
212
+
213
+ // Generate JSON Schema
214
+ try {
215
+ const jsonSchema = z.toJSONSchema(schema, {
216
+ target: 'draft-2020-12',
217
+ unrepresentable: 'any',
218
+ cycles: 'ref',
219
+ reused: 'inline',
220
+ });
221
+
222
+ // Add metadata
223
+ const schemaWithMeta = {
224
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
225
+ $id: `https://rhales.dev/schemas/#{safe_name}.json`,
226
+ title: '#{safe_name}',
227
+ description: 'Schema for #{safe_name} template',
228
+ ...jsonSchema,
229
+ };
230
+
231
+ // Output JSON to stdout
232
+ console.log(JSON.stringify(schemaWithMeta, null, 2));
233
+ } catch (error) {
234
+ console.error('Schema generation error:', error.message);
235
+ process.exit(1);
236
+ }
237
+ TYPESCRIPT
238
+
239
+ [script, bundled_file]
240
+ end
241
+
125
242
  def build_typescript_script(schema_info)
126
243
  # Escape single quotes in template name for TypeScript string
127
244
  safe_name = schema_info[:template_name].gsub("'", "\\'")
245
+ source_comment = if schema_info[:src]
246
+ "// Source: #{schema_info[:src]} (external)"
247
+ else
248
+ "// Source: inline schema"
249
+ end
128
250
 
129
251
  <<~TYPESCRIPT
130
252
  // Auto-generated schema generator for #{safe_name}
253
+ #{source_comment}
131
254
  import { z } from 'zod/v4';
132
255
 
133
256
  // Schema code from .rue template
@@ -185,10 +308,34 @@ module Rhales
185
308
  unless status.success?
186
309
  raise GenerationError, "tsx not found. Run: pnpm install tsx --save-dev"
187
310
  end
311
+
312
+ # Check esbuild is available when tsx import mode is enabled
313
+ if Rhales.configuration.schema_use_tsx_import
314
+ stdout, stderr, status = Open3.capture3('pnpm', 'exec', 'esbuild', '--version')
315
+ unless status.success?
316
+ raise GenerationError, "esbuild not found (required for external schema bundling). Run: pnpm install esbuild --save-dev"
317
+ end
318
+ end
188
319
  end
189
320
 
190
321
  def ensure_output_directory!
191
322
  FileUtils.mkdir_p(@output_dir) unless File.directory?(@output_dir)
192
323
  end
324
+
325
+ # Convert absolute path to file:// URL for cross-platform ESM imports
326
+ #
327
+ # On Windows, paths like C:\foo\bar need to become file:///C:/foo/bar
328
+ # On Unix, paths like /foo/bar become file:///foo/bar
329
+ #
330
+ # @param path [String] Absolute file path
331
+ # @return [String] file:// URL
332
+ def path_to_file_url(path)
333
+ normalized = path.tr('\\', '/')
334
+ if normalized.match?(%r{^[A-Za-z]:}) # Windows drive letter
335
+ "file:///#{normalized}"
336
+ else
337
+ "file://#{normalized}"
338
+ end
339
+ end
193
340
  end
194
341
  end
@@ -5,6 +5,6 @@
5
5
  module Rhales
6
6
  # Version information for the RSFC gem
7
7
  unless defined?(Rhales::VERSION)
8
- VERSION = '0.5.4'
8
+ VERSION = '0.6.0'
9
9
  end
10
10
  end
@@ -45,7 +45,8 @@ namespace :rhales do
45
45
 
46
46
  puts "Found #{schemas.size} schema section(s):"
47
47
  schemas.each do |schema|
48
- puts " - #{schema[:template_name]} (#{schema[:lang]})"
48
+ source_type = schema[:src] ? "external: #{schema[:src]}" : "inline"
49
+ puts " - #{schema[:template_name]} (#{schema[:lang]}, #{source_type})"
49
50
  end
50
51
  puts
51
52
 
@@ -186,6 +187,13 @@ namespace :rhales do
186
187
  puts "Files without <schema>: #{stats[:files_without_schemas]}"
187
188
  puts
188
189
 
190
+ if stats[:files_with_schemas] > 0
191
+ puts "Schema sources:"
192
+ puts " External (src attribute): #{stats[:external_schemas]}"
193
+ puts " Inline: #{stats[:inline_schemas]}"
194
+ puts
195
+ end
196
+
189
197
  if stats[:schemas_by_lang].any?
190
198
  puts "By language:"
191
199
  stats[:schemas_by_lang].each do |lang, count|
data/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "dependencies": {
4
- "zod": "^4.1.12"
4
+ "zod": "^4.3.6"
5
5
  },
6
6
  "devDependencies": {
7
7
  "@prettier/plugin-ruby": "^4.0.4",
data/pnpm-lock.yaml CHANGED
@@ -9,8 +9,8 @@ importers:
9
9
  .:
10
10
  dependencies:
11
11
  zod:
12
- specifier: ^4.1.12
13
- version: 4.1.12
12
+ specifier: ^4.3.6
13
+ version: 4.3.6
14
14
  devDependencies:
15
15
  '@prettier/plugin-ruby':
16
16
  specifier: ^4.0.4
@@ -208,8 +208,8 @@ packages:
208
208
  engines: {node: '>=18.0.0'}
209
209
  hasBin: true
210
210
 
211
- zod@4.1.12:
212
- resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
211
+ zod@4.3.6:
212
+ resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
213
213
 
214
214
  snapshots:
215
215
 
@@ -342,4 +342,4 @@ snapshots:
342
342
  optionalDependencies:
343
343
  fsevents: 2.3.3
344
344
 
345
- zod@4.1.12: {}
345
+ zod@4.3.6: {}
data/rhales.gemspec CHANGED
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.homepage = 'https://github.com/onetimesecret/rhales'
23
23
  spec.license = 'MIT'
24
- spec.required_ruby_version = '>= 3.3.4'
24
+ spec.required_ruby_version = '>= 3.4'
25
25
 
26
26
  spec.metadata['source_code_uri'] = 'https://github.com/onetimesecret/rhales'
27
27
  spec.metadata['changelog_uri'] = 'https://github.com/onetimesecret/rhales/blob/main/CHANGELOG.md'
@@ -41,14 +41,11 @@ Gem::Specification.new do |spec|
41
41
  spec.require_paths = ['lib']
42
42
 
43
43
  # Runtime dependencies
44
- spec.add_dependency 'json_schemer', '~> 2.3' # JSON Schema validation in middleware
44
+
45
+ spec.add_dependency 'json_schemer', '~> 2' # JSON Schema validation in middleware
45
46
  spec.add_dependency 'logger' # Standard library logger for logging support
46
47
  spec.add_dependency 'tilt', '~> 2' # Templating engine for rendering RSFCs
47
48
 
48
- # Optional dependencies for performance optimization
49
- # Install oj for 10-20x faster JSON parsing and 5-10x faster generation
50
- # spec.add_dependency 'oj', '~> 3.13'
51
-
52
49
  # Development dependencies should be specified in Gemfile instead of gemspec
53
50
  # See: https://bundler.io/guides/creating_gem.html#testing-our-gem
54
51
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rhales
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - delano
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '2.3'
18
+ version: '2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '2.3'
25
+ version: '2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: logger
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -189,7 +189,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
189
189
  requirements:
190
190
  - - ">="
191
191
  - !ruby/object:Gem::Version
192
- version: 3.3.4
192
+ version: '3.4'
193
193
  required_rubygems_version: !ruby/object:Gem::Requirement
194
194
  requirements:
195
195
  - - ">="