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 +4 -4
- data/CHANGELOG.md +23 -1
- data/Gemfile.lock +2 -2
- data/README.md +67 -8
- data/lib/rhales/configuration.rb +16 -0
- data/lib/rhales/core/rue_document.rb +7 -1
- data/lib/rhales/hydration/hydration_registry.rb +0 -4
- data/lib/rhales/utils/schema_extractor.rb +121 -3
- data/lib/rhales/utils/schema_generator.rb +152 -5
- data/lib/rhales/version.rb +1 -1
- data/lib/tasks/rhales_schema.rake +9 -1
- data/package.json +1 -1
- data/pnpm-lock.yaml +5 -5
- data/rhales.gemspec +3 -6
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 441e27aff0e4003c6e0351c6cf065cad3c3348c2aba7059acdfe6e26670df95c
|
|
4
|
+
data.tar.gz: bb38a8fd9c007c9efd3a69a6f5a1355ecbff4874553d7f63bc8b000e730677b7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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] -
|
|
57
|
+
## [0.1.0] - 2025-07-21
|
|
36
58
|
|
|
37
59
|
### Added
|
|
38
60
|
- Initial release of Rhales
|
data/Gemfile.lock
CHANGED
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.
|
|
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.
|
|
10
|
+
## What's New in v0.6
|
|
11
11
|
|
|
12
|
-
- ✅ **Schema
|
|
13
|
-
- ✅ **
|
|
14
|
-
- ✅ **
|
|
15
|
-
- ✅ **
|
|
16
|
-
|
|
17
|
-
-
|
|
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
|
|
data/lib/rhales/configuration.rb
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
data/lib/rhales/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
data/pnpm-lock.yaml
CHANGED
|
@@ -9,8 +9,8 @@ importers:
|
|
|
9
9
|
.:
|
|
10
10
|
dependencies:
|
|
11
11
|
zod:
|
|
12
|
-
specifier: ^4.
|
|
13
|
-
version: 4.
|
|
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.
|
|
212
|
-
resolution: {integrity: sha512-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
192
|
+
version: '3.4'
|
|
193
193
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
194
194
|
requirements:
|
|
195
195
|
- - ">="
|