mochitype 0.3.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 +7 -0
- data/README.md +215 -0
- data/Rakefile +6 -0
- data/lib/mochitype/configuration.rb +39 -0
- data/lib/mochitype/convertible_class.rb +37 -0
- data/lib/mochitype/convertible_property.rb +9 -0
- data/lib/mochitype/file_watcher.rb +144 -0
- data/lib/mochitype/railtie.rb +25 -0
- data/lib/mochitype/reflection_type_converter.rb +310 -0
- data/lib/mochitype/ruby_type_utils.rb +24 -0
- data/lib/mochitype/type_converter.rb +38 -0
- data/lib/mochitype/type_data.rb +17 -0
- data/lib/mochitype/typescript_utils.rb +25 -0
- data/lib/mochitype/version.rb +3 -0
- data/lib/mochitype/view.rb +18 -0
- data/lib/mochitype.rb +31 -0
- data/lib/tasks/mochitype.rake +33 -0
- metadata +161 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 7832035805b331f4ce0e845f3104c4f0356f24489c9c649374778fc6196f3618
|
|
4
|
+
data.tar.gz: 4664df36000c2773999da7eec7b11889ab5e03eeed8836d92ee27494ee104672
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dcc0c4a00a8eb1f7c239d00e39dd839e664fda815ead297c13a444cda25b0ea2c8c6ef8181f3bf4ebc6eaa150555aa5a9ef290511db89717a30da9e587596092
|
|
7
|
+
data.tar.gz: 118f4b24547826d9bfec06a0e49da2e7c9012c3495b70919ffda5fc867683565b69221d47b23cd3845bcfece8a3c8514ed4ed7666fdfec5bf2f97877e41d29e0
|
data/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Mochitype
|
|
2
|
+
|
|
3
|
+
For Ruby on Rails apps that have a Typescript frontend, your left with few options to ensure your Typescript types are correct.
|
|
4
|
+
|
|
5
|
+
You either manually create Typescript interfaces or manually write Zod for runtime checks.
|
|
6
|
+
|
|
7
|
+
Mochitype turns your `T::Struct` classes into Zod types, allowing your frontend and backend to share a common type definition. This gives you:
|
|
8
|
+
|
|
9
|
+
- Typescript interfaces in sync with the backend with Zod infer.
|
|
10
|
+
- Runtime type checking with Zod.
|
|
11
|
+
- A typed interface using Sorbet for building your JSON payload.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add this line to your application's Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem 'mochitype'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
And then execute:
|
|
22
|
+
|
|
23
|
+
$ bundle install
|
|
24
|
+
|
|
25
|
+
Or install it yourself as:
|
|
26
|
+
|
|
27
|
+
$ gem install mochitype
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
1. Configure the
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Mochitype.configure do |config|
|
|
35
|
+
config.watch_path = "app/mochitypes"
|
|
36
|
+
config.output_path = "app/javascript/__generated__/mochitypes"
|
|
37
|
+
end
|
|
38
|
+
Mochitype.start_watcher!
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
2. When your Rails app starts in development, it'll watch all files in `watch_path` - each time a file is added, modified, or deleted, it'll update the corresponding TypeScript file in `output_path`.
|
|
42
|
+
|
|
43
|
+
3. Since your Ruby classes are T::Structs, you can also DRY up your rendering logic.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# before
|
|
47
|
+
class UsersController < ApplicationController
|
|
48
|
+
def index
|
|
49
|
+
@users = User.all
|
|
50
|
+
render json: @users
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# after
|
|
55
|
+
class UsersController < ApplicationController
|
|
56
|
+
def index
|
|
57
|
+
# has access to helpers
|
|
58
|
+
render Mochiviews::Users::Index.render(
|
|
59
|
+
users: User.all,
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class Mochiviews::Users::Index < T::Struct
|
|
65
|
+
class User < T::Struct
|
|
66
|
+
const :id, Integer
|
|
67
|
+
const :name, String
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
const :users, T::Array[Mochiviews::Users::Index::User]
|
|
71
|
+
|
|
72
|
+
sig { params(users: T::Array[User]).returns(Mochiviews::Users::Index) }
|
|
73
|
+
def self.render(users:)
|
|
74
|
+
Mochiviews::Users::Index.new(
|
|
75
|
+
users: users.map do |user|
|
|
76
|
+
User.new(
|
|
77
|
+
id: user.id,
|
|
78
|
+
name: user.name,
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Mochiviews::Users::Index is automatically converted to Zod
|
|
86
|
+
# app/javascript/__generated__/mochitypes/users/index.ts
|
|
87
|
+
export const UsersIndexSchema = z.object({
|
|
88
|
+
users: z.array(z.object({
|
|
89
|
+
id: z.number(),
|
|
90
|
+
name: z.string(),
|
|
91
|
+
})),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export type UsersIndex = z.infer<typeof UsersIndexSchema>;
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Now if you change UsersController#index to return something different, it'll update the corresponding TypeScript file. Your backend and frontend are always in sync!
|
|
98
|
+
|
|
99
|
+
## Using Mochitype::View for Rendering
|
|
100
|
+
|
|
101
|
+
The `Mochitype::View` module provides a simple way to make your `T::Struct` classes renderable in Rails controllers. When you include this module, your struct gains two methods:
|
|
102
|
+
|
|
103
|
+
- `render_in(view_context)` - Serializes the struct to JSON
|
|
104
|
+
- `format` - Returns `:json` to indicate the response format
|
|
105
|
+
|
|
106
|
+
### Basic Usage
|
|
107
|
+
|
|
108
|
+
Include `Mochitype::View` in any `T::Struct` class:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class UserResponse < T::Struct
|
|
112
|
+
include Mochitype::View
|
|
113
|
+
|
|
114
|
+
const :id, Integer
|
|
115
|
+
const :name, String
|
|
116
|
+
const :email, String
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Then use it directly in your controllers:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
class UsersController < ApplicationController
|
|
124
|
+
def show
|
|
125
|
+
user = User.find(params[:id])
|
|
126
|
+
response = UserResponse.new(id: user.id, name: user.name, email: user.email)
|
|
127
|
+
|
|
128
|
+
render response
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Advanced Example with Nested Structs
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
module API
|
|
137
|
+
module Users
|
|
138
|
+
class IndexResponse < T::Struct
|
|
139
|
+
include Mochitype::View
|
|
140
|
+
|
|
141
|
+
class UserSummary < T::Struct
|
|
142
|
+
const :id, Integer
|
|
143
|
+
const :name, String
|
|
144
|
+
const :avatar_url, T.nilable(String)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
const :users, T::Array[UserSummary]
|
|
148
|
+
const :total_count, Integer
|
|
149
|
+
const :page, Integer
|
|
150
|
+
|
|
151
|
+
sig { params(users: T::Array[User], page: Integer).returns(IndexResponse) }
|
|
152
|
+
def self.from_users(users:, page:)
|
|
153
|
+
new(
|
|
154
|
+
users:
|
|
155
|
+
users.map do |user|
|
|
156
|
+
UserSummary.new(id: user.id, name: user.name, avatar_url: user.avatar_url)
|
|
157
|
+
end,
|
|
158
|
+
total_count: users.size,
|
|
159
|
+
page: page,
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# In your controller:
|
|
167
|
+
class API::UsersController < ApplicationController
|
|
168
|
+
def index
|
|
169
|
+
users = User.page(params[:page])
|
|
170
|
+
render API::Users::IndexResponse.from_users(users: users, page: params[:page].to_i)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Benefits
|
|
176
|
+
|
|
177
|
+
1. **Type Safety**: Your responses are type-checked by Sorbet at the Ruby level
|
|
178
|
+
2. **Automatic TS Generation**: The corresponding TypeScript types are automatically generated
|
|
179
|
+
3. **Clean Controllers**: Separates response structure from controller logic
|
|
180
|
+
4. **Reusable**: Response classes can be shared across multiple endpoints
|
|
181
|
+
|
|
182
|
+
### What Gets Generated
|
|
183
|
+
|
|
184
|
+
For the examples above, Mochitype will automatically generate TypeScript types like:
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// __generated__/mochitypes/user_response.ts
|
|
188
|
+
export const UserResponse = z.object({
|
|
189
|
+
id: z.number(),
|
|
190
|
+
name: z.string(),
|
|
191
|
+
email: z.string(),
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
export type TUserResponse = z.infer<typeof UserResponse>;
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
This keeps your frontend types perfectly in sync with your backend responses!
|
|
198
|
+
|
|
199
|
+
## Limitations
|
|
200
|
+
|
|
201
|
+
- Currently only works with T::Structs, T::Enum, and standard Ruby types like String, Integer, etc. If your struct has a field that's a custom class, it will be marked as `unknown`
|
|
202
|
+
- Since we're using the Prism parser, it requires Ruby 3.3.5+
|
|
203
|
+
- It does not de-dupe types across files. For example, if you have MyStruct and reference MyOtherStruct, both of those Typescript files will contain TS on MyOtherStruct.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
208
|
+
|
|
209
|
+
## Contributing
|
|
210
|
+
|
|
211
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/mochitype.
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
require 'sorbet-runtime'
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class Configuration
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { returns(String) }
|
|
9
|
+
attr_accessor :watch_path
|
|
10
|
+
|
|
11
|
+
sig { returns(String) }
|
|
12
|
+
attr_accessor :output_path
|
|
13
|
+
|
|
14
|
+
sig { void }
|
|
15
|
+
def initialize
|
|
16
|
+
@watch_path = T.let('app/mochitypes/', String) # default path for Sorbet files
|
|
17
|
+
@output_path = T.let('app/javascript/__generated__/mochitypes', String) # default path for TypeScript output
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
extend T::Sig
|
|
23
|
+
|
|
24
|
+
sig { returns(Configuration) }
|
|
25
|
+
def configuration
|
|
26
|
+
@configuration ||= Configuration.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { params(blk: T.proc.params(config: Configuration).void).void }
|
|
30
|
+
def configure(&blk)
|
|
31
|
+
yield(configuration)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { void }
|
|
35
|
+
def start_watcher!
|
|
36
|
+
Mochitype::FileWatcher.start
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Data container that contains all the properties that can be turned into a TypeScript file for a class.
|
|
5
|
+
# The class must either be a T::Enum or T::Struct
|
|
6
|
+
class ConvertibleClass < T::Struct
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
const :klass, Class
|
|
10
|
+
prop :props, T::Hash[String, String], default: {}
|
|
11
|
+
prop :inner_classes, T::Array[ConvertibleClass], default: []
|
|
12
|
+
|
|
13
|
+
# Currently not used.
|
|
14
|
+
TS_TYPE_SUFFIX = ''
|
|
15
|
+
TS_ENUM_SUFFIX = ''
|
|
16
|
+
|
|
17
|
+
# The name of the type in the generated Typescript file.
|
|
18
|
+
# This is the name of the Zod definition.
|
|
19
|
+
sig { returns(String) }
|
|
20
|
+
def typescript_name
|
|
21
|
+
js_name = klass.name.demodulize
|
|
22
|
+
klass < T::Enum ? "#{js_name}#{TS_ENUM_SUFFIX}" : "#{js_name}#{TS_TYPE_SUFFIX}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The name of the TypeScript type alias
|
|
26
|
+
sig { returns(String) }
|
|
27
|
+
def typescript_type_name
|
|
28
|
+
js_name = klass.to_s.gsub('::', '')
|
|
29
|
+
# For very short class names (3 chars or less), use just the class name
|
|
30
|
+
# Otherwise, use T prefix + the typescript_name
|
|
31
|
+
if js_name.length <= 3
|
|
32
|
+
js_name
|
|
33
|
+
else
|
|
34
|
+
"T#{typescript_name}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class FileWatcher
|
|
6
|
+
class << self
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
attr_reader :listener, :mutex
|
|
10
|
+
|
|
11
|
+
# Logger that works with or without Rails
|
|
12
|
+
sig { returns(T.untyped) }
|
|
13
|
+
def logger
|
|
14
|
+
defined?(Rails) && Rails.logger ? Rails.logger : Logger.new($stdout)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Performs a full sweep of the watched Ruby type directory and the generated TypeScript output directory.
|
|
18
|
+
#
|
|
19
|
+
# - For every Ruby file under the configured `watch_path`, determines the expected TypeScript output path.
|
|
20
|
+
# - Deletes any orphaned TypeScript files in the output directory that do not have a corresponding Ruby source file.
|
|
21
|
+
# - Generates TypeScript files for any Ruby files that are missing outputs.
|
|
22
|
+
#
|
|
23
|
+
# This method is typically called on Rails boot in development to ensure the generated TypeScript
|
|
24
|
+
# is in sync with the Ruby source files, and to clean up any stale outputs.
|
|
25
|
+
#
|
|
26
|
+
# Logs actions and errors using Rails.logger (or stdout in headless mode).
|
|
27
|
+
sig { void }
|
|
28
|
+
def sweep
|
|
29
|
+
begin
|
|
30
|
+
ts_base = Mochitype.configuration.output_path
|
|
31
|
+
|
|
32
|
+
# Build expected TS paths from all Ruby files.
|
|
33
|
+
ruby_files = Dir.glob("#{Mochitype.configuration.watch_path}/**/*.rb")
|
|
34
|
+
expected_ts = {}
|
|
35
|
+
ruby_files.each do |rb_file|
|
|
36
|
+
ts_path = TypeConverter.determine_output_path(rb_file)
|
|
37
|
+
expected_ts[ts_path] = rb_file
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Delete orphan TS files (those without a corresponding Ruby file).
|
|
41
|
+
Dir
|
|
42
|
+
.glob("#{ts_base}/**/*.ts")
|
|
43
|
+
.each do |ts_file|
|
|
44
|
+
begin
|
|
45
|
+
next if expected_ts.key?(ts_file)
|
|
46
|
+
File.delete(ts_file)
|
|
47
|
+
logger.info "Mochitype: Removed orphan TypeScript file #{ts_file}"
|
|
48
|
+
rescue => e
|
|
49
|
+
logger.error "Mochitype: Error removing orphan TypeScript file #{ts_file}: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Generate missing TS files for Ruby files without outputs.
|
|
54
|
+
ruby_files.each do |rb_file|
|
|
55
|
+
begin
|
|
56
|
+
ts_path = TypeConverter.determine_output_path(rb_file)
|
|
57
|
+
next if File.exist?(ts_path)
|
|
58
|
+
TypeConverter.convert_file(rb_file)
|
|
59
|
+
logger.info "Mochitype: Generated missing TypeScript for #{rb_file}"
|
|
60
|
+
rescue => e
|
|
61
|
+
logger.error "Mochitype: Error generating TypeScript for #{rb_file}: #{e.message}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
rescue => e
|
|
65
|
+
logger.error "Mochitype: Initial sweep failed: #{e.message}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { void }
|
|
70
|
+
def start
|
|
71
|
+
return unless Rails.env.development?
|
|
72
|
+
return if @listener # Already started
|
|
73
|
+
|
|
74
|
+
@mutex = Mutex.new
|
|
75
|
+
|
|
76
|
+
path =
|
|
77
|
+
(
|
|
78
|
+
if Rails.root
|
|
79
|
+
Rails.root.join(Mochitype.configuration.watch_path)
|
|
80
|
+
else
|
|
81
|
+
Mochitype.configuration.watch_path
|
|
82
|
+
end
|
|
83
|
+
)
|
|
84
|
+
FileUtils.mkdir_p(path) unless File.directory?(path)
|
|
85
|
+
|
|
86
|
+
# Initial conversion of all existing files
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
Dir
|
|
89
|
+
.glob("#{path}/**/*.rb")
|
|
90
|
+
.each do |file|
|
|
91
|
+
begin
|
|
92
|
+
TypeConverter.convert_file(file)
|
|
93
|
+
logger.info "Mochitype: Successfully converted #{file} to TypeScript"
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
logger.error "Mochitype: Error converting #{file}"
|
|
96
|
+
logger.error " #{e.class}: #{e.message}"
|
|
97
|
+
logger.error " #{e.backtrace.first(3).join("\n ")}" if e.backtrace
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@listener =
|
|
103
|
+
Listen.to(path, only: /\.rb$/) do |modified, added, removed|
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
handle_file_changes(modified, added, removed)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
@listener.start
|
|
110
|
+
Kernel.puts "Mochitype starting: Watching for Sorbet struct changes in #{path}"
|
|
111
|
+
Kernel.puts "* #{Dir.glob("#{path}/**/*.rb").count} file(s) in #{path}"
|
|
112
|
+
Kernel.puts "* #{Dir.glob("#{Mochitype.configuration.output_path}/**/*.ts").count} generated TS file(s) in #{Mochitype.configuration.output_path}"
|
|
113
|
+
|
|
114
|
+
# Run sweep synchronously after initial conversion
|
|
115
|
+
@mutex.synchronize { sweep }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
sig { params(modified: T::Array[String], added: T::Array[String], removed: T::Array[String]).void }
|
|
119
|
+
def handle_file_changes(modified, added, removed)
|
|
120
|
+
(modified + added).each do |file|
|
|
121
|
+
begin
|
|
122
|
+
TypeConverter.convert_file(file)
|
|
123
|
+
logger.info "Mochitype: Successfully converted #{file} to TypeScript"
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
logger.error "Mochitype: Error converting #{file}"
|
|
126
|
+
logger.error " #{e.class}: #{e.message}"
|
|
127
|
+
logger.error " #{e.backtrace.first(3).join("\n ")}" if e.backtrace
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
removed.each do |file|
|
|
132
|
+
begin
|
|
133
|
+
ts_file = TypeConverter.determine_output_path(file)
|
|
134
|
+
File.delete(ts_file) if File.exist?(ts_file)
|
|
135
|
+
logger.info "Mochitype: Removed TypeScript file for #{file}"
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
logger.error "Mochitype: Error removing TypeScript file for #{file}"
|
|
138
|
+
logger.error " #{e.class}: #{e.message}"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class Railtie < Rails::Railtie
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
# NOTE: (overload119) Temporarily removed so that the developer can choose to invoke the thread.
|
|
9
|
+
# This gives more flexibility during adoption.
|
|
10
|
+
#
|
|
11
|
+
# Start the file watcher after all initializers have run
|
|
12
|
+
# This ensures the user's configuration in config/initializers/mochitype.rb has been loaded
|
|
13
|
+
initializer 'mochitype.start_watcher', after: :load_config_initializers do
|
|
14
|
+
if Rails.env.development?
|
|
15
|
+
Rails.application.config.after_initialize do
|
|
16
|
+
# Mochitype.start_watcher!
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
rake_tasks do
|
|
22
|
+
load 'tasks/mochitype.rake'
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class ReflectionTypeConverter
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
SORBET_TYPESCRIPT_MAPPING = {
|
|
9
|
+
String => 'z.string()',
|
|
10
|
+
Integer => 'z.number()',
|
|
11
|
+
Float => 'z.number()',
|
|
12
|
+
Numeric => 'z.number()',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
sig { params(file_path: String).void }
|
|
16
|
+
def initialize(file_path)
|
|
17
|
+
@file_path = file_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Entrypoint to generate the Typescript file content from the main struct that's being converted.
|
|
21
|
+
sig { returns(String) }
|
|
22
|
+
def build_typescript_file
|
|
23
|
+
buffer = String.new("/**\n")
|
|
24
|
+
buffer << "/* This file is generated by Mochitype. Do not edit it by hand.\n"
|
|
25
|
+
buffer << "/**/\n\n"
|
|
26
|
+
buffer << "import { z } from 'zod';\n\n"
|
|
27
|
+
|
|
28
|
+
# Track classes that have already been written to avoid duplicates
|
|
29
|
+
@written_classes = T.let(Set.new, T::Set[Class])
|
|
30
|
+
buffer << convertible_class_to_typescript(root_convertible_class)
|
|
31
|
+
buffer.strip!
|
|
32
|
+
buffer << "\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
sig { params(convertible_class: ConvertibleClass).returns(String) }
|
|
36
|
+
def convertible_class_to_typescript(convertible_class)
|
|
37
|
+
buffer = String.new
|
|
38
|
+
|
|
39
|
+
# Skip if this class has already been written
|
|
40
|
+
@written_classes ||= Set.new
|
|
41
|
+
return buffer if @written_classes.include?(convertible_class.klass)
|
|
42
|
+
|
|
43
|
+
# Mark this class as being written
|
|
44
|
+
@written_classes.add(convertible_class.klass)
|
|
45
|
+
|
|
46
|
+
if convertible_class.klass < T::Enum
|
|
47
|
+
values = T.unsafe(convertible_class.klass).values.map(&:serialize).map { "'#{_1}'" }
|
|
48
|
+
buffer << "export const #{convertible_class.typescript_name} = z.enum([#{values.join(', ')}]);\n\n"
|
|
49
|
+
elsif convertible_class.klass < T::Struct
|
|
50
|
+
# Populate the properties.
|
|
51
|
+
properties = {}
|
|
52
|
+
inner_classes = T.let([], T::Array[ConvertibleClass])
|
|
53
|
+
T
|
|
54
|
+
.cast(convertible_class.klass, T.class_of(T::Struct))
|
|
55
|
+
.props
|
|
56
|
+
.each do |property_name, data|
|
|
57
|
+
convertible_property = write_prop(data)
|
|
58
|
+
properties[property_name.to_s] = convertible_property.zod_definition
|
|
59
|
+
inner_classes +=
|
|
60
|
+
convertible_property.discovered_classes.map do |klass|
|
|
61
|
+
ConvertibleClass.new(klass: klass)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
convertible_class.props = properties
|
|
66
|
+
convertible_class.inner_classes = inner_classes
|
|
67
|
+
|
|
68
|
+
# Before adding this convertible class into the TypeScript file, we have to make sure all of
|
|
69
|
+
# its dependencies are added first.
|
|
70
|
+
convertible_class.inner_classes.each do |inner_class|
|
|
71
|
+
buffer << convertible_class_to_typescript(inner_class)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
buffer << "export const #{convertible_class.typescript_name} = z.object({\n"
|
|
75
|
+
buffer << convertible_class.props.map { |name, type| " #{name}: #{type}" }.join(",\n")
|
|
76
|
+
buffer << "\n});\n\n"
|
|
77
|
+
else
|
|
78
|
+
raise NotImplementedError, "Unknown type: #{convertible_class.klass}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
buffer << "export type #{convertible_class.typescript_type_name} = z.infer<typeof #{convertible_class.typescript_name}>;\n\n"
|
|
82
|
+
buffer
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { returns(ConvertibleClass) }
|
|
86
|
+
def root_convertible_class
|
|
87
|
+
klass_name = extract_main_klass
|
|
88
|
+
klass = klass_name.constantize
|
|
89
|
+
|
|
90
|
+
# Accept both T::Struct and T::Enum
|
|
91
|
+
unless (klass.is_a?(Class) && (klass < T::Struct || klass < T::Enum))
|
|
92
|
+
raise TypeError, "Expected T::Struct or T::Enum, got #{klass}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
ConvertibleClass.new(klass: klass)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Computes the Typescript-property of a T::Struct.
|
|
99
|
+
# @param data A `const` or `prop` property definition from a T::Struct.
|
|
100
|
+
sig { params(data: T::Hash[Symbol, T.untyped]).returns(ConvertibleProperty) }
|
|
101
|
+
def write_prop(data)
|
|
102
|
+
is_nilable = data[:_tnilable]
|
|
103
|
+
|
|
104
|
+
result =
|
|
105
|
+
case data[:type].class.to_s
|
|
106
|
+
# Handle special Sorbet types that should map to z.unknown()
|
|
107
|
+
when 'T::Types::Untyped'
|
|
108
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
109
|
+
when 'T::Types::Intersection'
|
|
110
|
+
# T.all - intersection types are not representable in Zod
|
|
111
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
112
|
+
when 'T::Types::ClassOf'
|
|
113
|
+
# T.class_of - represents a class itself, not an instance
|
|
114
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
115
|
+
when 'T::Types::AttachedClass'
|
|
116
|
+
# T.attached_class - used in module mixins
|
|
117
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
118
|
+
when 'T::Types::NoReturn'
|
|
119
|
+
# T.noreturn - function never returns
|
|
120
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
121
|
+
when 'T::Types::TypedArray', 'T::Types::TypedArray::Untyped'
|
|
122
|
+
inner_type = data[:type].type
|
|
123
|
+
inner_type_convertible_property = instance_of_class_to_convertible_property(inner_type)
|
|
124
|
+
|
|
125
|
+
ConvertibleProperty.new(
|
|
126
|
+
zod_definition: "z.array(#{inner_type_convertible_property.zod_definition})",
|
|
127
|
+
discovered_classes: inner_type_convertible_property.discovered_classes,
|
|
128
|
+
)
|
|
129
|
+
when 'T::Private::Types::SimplePairUnion'
|
|
130
|
+
first_value = data[:type].types[0].raw_type
|
|
131
|
+
second_value = data[:type].types[1].raw_type
|
|
132
|
+
if [first_value, second_value].to_set == [TrueClass, FalseClass].to_set
|
|
133
|
+
ConvertibleProperty.new(zod_definition: 'z.boolean()', discovered_classes: [])
|
|
134
|
+
else
|
|
135
|
+
fv = simple_class_to_typescript(first_value)
|
|
136
|
+
sv = simple_class_to_typescript(second_value)
|
|
137
|
+
|
|
138
|
+
ConvertibleProperty.new(
|
|
139
|
+
zod_definition: "z.union([#{fv.zod_definition}, #{sv.zod_definition}])",
|
|
140
|
+
discovered_classes: (fv.discovered_classes + sv.discovered_classes).uniq,
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
when 'T::Types::TypedHash'
|
|
144
|
+
key_type = data[:type].keys
|
|
145
|
+
value_type = data[:type].values
|
|
146
|
+
|
|
147
|
+
key_convertible_property = instance_of_class_to_convertible_property(key_type)
|
|
148
|
+
value_convertible_property = instance_of_class_to_convertible_property(value_type)
|
|
149
|
+
|
|
150
|
+
ConvertibleProperty.new(
|
|
151
|
+
zod_definition:
|
|
152
|
+
"z.record(#{key_convertible_property.zod_definition}, #{value_convertible_property.zod_definition})",
|
|
153
|
+
discovered_classes: [],
|
|
154
|
+
)
|
|
155
|
+
when 'T::Types::Union'
|
|
156
|
+
union_types = data[:type].types.map(&:raw_type)
|
|
157
|
+
properties = union_types.map { |union_type| simple_class_to_typescript(union_type) }
|
|
158
|
+
|
|
159
|
+
values = properties.map(&:zod_definition)
|
|
160
|
+
discovered_classes = properties.flat_map(&:discovered_classes).uniq
|
|
161
|
+
|
|
162
|
+
ConvertibleProperty.new(
|
|
163
|
+
zod_definition: "z.union([#{values.join(',')}])",
|
|
164
|
+
discovered_classes: discovered_classes,
|
|
165
|
+
)
|
|
166
|
+
else
|
|
167
|
+
simple_class_to_typescript(data[:type])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Add .nullable() if the type is nilable
|
|
171
|
+
if is_nilable
|
|
172
|
+
ConvertibleProperty.new(
|
|
173
|
+
zod_definition: "#{result.zod_definition}.nullable()",
|
|
174
|
+
discovered_classes: result.discovered_classes,
|
|
175
|
+
)
|
|
176
|
+
else
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
sig { params(instance: T.untyped).returns(ConvertibleProperty) }
|
|
182
|
+
def instance_of_class_to_convertible_property(instance)
|
|
183
|
+
# Handle special Sorbet types
|
|
184
|
+
case instance.class.to_s
|
|
185
|
+
when 'T::Types::Untyped'
|
|
186
|
+
return ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
187
|
+
when 'T::Types::Intersection'
|
|
188
|
+
return ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
189
|
+
when 'T::Types::ClassOf'
|
|
190
|
+
return ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
191
|
+
when 'T::Types::AttachedClass'
|
|
192
|
+
return ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
193
|
+
when 'T::Types::NoReturn'
|
|
194
|
+
return ConvertibleProperty.new(zod_definition: 'z.unknown()', discovered_classes: [])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
if instance.is_a?(T::Types::Simple)
|
|
198
|
+
simple_class_to_typescript(instance.raw_type)
|
|
199
|
+
elsif instance.is_a?(T::Types::TypedArray)
|
|
200
|
+
inner_type = instance.type
|
|
201
|
+
inner_type_convertible_property = instance_of_class_to_convertible_property(inner_type)
|
|
202
|
+
|
|
203
|
+
ConvertibleProperty.new(
|
|
204
|
+
zod_definition: "z.array(#{inner_type_convertible_property.zod_definition})",
|
|
205
|
+
discovered_classes: inner_type_convertible_property.discovered_classes,
|
|
206
|
+
)
|
|
207
|
+
elsif instance.is_a?(T::Types::TypedHash)
|
|
208
|
+
key_type = instance.keys
|
|
209
|
+
value_type = instance.values
|
|
210
|
+
|
|
211
|
+
key_convertible_property = instance_of_class_to_convertible_property(key_type)
|
|
212
|
+
value_convertible_property = instance_of_class_to_convertible_property(value_type)
|
|
213
|
+
|
|
214
|
+
ConvertibleProperty.new(
|
|
215
|
+
zod_definition:
|
|
216
|
+
"z.record(#{key_convertible_property.zod_definition}, #{value_convertible_property.zod_definition})",
|
|
217
|
+
discovered_classes: [],
|
|
218
|
+
)
|
|
219
|
+
elsif instance.is_a?(T::Types::Union)
|
|
220
|
+
union_types = instance.types.map(&:raw_type)
|
|
221
|
+
if union_types.to_set == [TrueClass, FalseClass].to_set
|
|
222
|
+
ConvertibleProperty.new(zod_definition: 'z.boolean()', discovered_classes: [])
|
|
223
|
+
else
|
|
224
|
+
properties = union_types.map { |union_type| simple_class_to_typescript(union_type) }
|
|
225
|
+
values = properties.map(&:zod_definition)
|
|
226
|
+
discovered_classes = properties.flat_map(&:discovered_classes).uniq
|
|
227
|
+
|
|
228
|
+
ConvertibleProperty.new(
|
|
229
|
+
zod_definition: "z.union([#{values.join(', ')}])",
|
|
230
|
+
discovered_classes: discovered_classes,
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()')
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
sig { params(klass: T.untyped).returns(ConvertibleProperty) }
|
|
239
|
+
def simple_class_to_typescript(klass)
|
|
240
|
+
# Check if klass is a Class first, since < operator only works on classes
|
|
241
|
+
if klass.is_a?(Class)
|
|
242
|
+
case
|
|
243
|
+
when klass < T::Struct
|
|
244
|
+
inner_klass = ConvertibleClass.new(klass: klass)
|
|
245
|
+
ConvertibleProperty.new(
|
|
246
|
+
zod_definition: inner_klass.typescript_name,
|
|
247
|
+
discovered_classes: [klass],
|
|
248
|
+
)
|
|
249
|
+
when klass < T::Enum
|
|
250
|
+
inner_klass = ConvertibleClass.new(klass: klass)
|
|
251
|
+
ConvertibleProperty.new(
|
|
252
|
+
zod_definition: inner_klass.typescript_name,
|
|
253
|
+
discovered_classes: [klass],
|
|
254
|
+
)
|
|
255
|
+
when SORBET_TYPESCRIPT_MAPPING.key?(klass)
|
|
256
|
+
ConvertibleProperty.new(zod_definition: SORBET_TYPESCRIPT_MAPPING[klass])
|
|
257
|
+
else
|
|
258
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()')
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
ConvertibleProperty.new(zod_definition: 'z.unknown()')
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Reads the Ruby file and extracts the primary class being converted.
|
|
266
|
+
# This assumes that every has a single Module or Class declaration, otherwise only the last is considered.
|
|
267
|
+
# @return a namespaced class name
|
|
268
|
+
sig { returns(String) }
|
|
269
|
+
def extract_main_klass
|
|
270
|
+
ast = Prism.parse(File.read(@file_path))
|
|
271
|
+
|
|
272
|
+
struct_class_name = T.let(nil, T.nilable(String))
|
|
273
|
+
enum_class_name = T.let(nil, T.nilable(String))
|
|
274
|
+
module_names = T.let([], T::Array[String])
|
|
275
|
+
|
|
276
|
+
# Recursively traverse modules but stop at classes to avoid nested classes
|
|
277
|
+
find_main_class =
|
|
278
|
+
lambda do |nodes, modules|
|
|
279
|
+
nodes.each do |node|
|
|
280
|
+
if node.is_a?(Prism::ModuleNode)
|
|
281
|
+
# Traverse into modules recursively
|
|
282
|
+
module_body = node.body
|
|
283
|
+
if module_body.is_a?(Prism::StatementsNode)
|
|
284
|
+
find_main_class.call(module_body.body, modules + [node.name.to_s])
|
|
285
|
+
end
|
|
286
|
+
elsif node.is_a?(Prism::ClassNode)
|
|
287
|
+
parent = node.superclass
|
|
288
|
+
if parent.is_a?(Prism::ConstantPathNode)
|
|
289
|
+
if parent.full_name == 'T::Struct'
|
|
290
|
+
# Keep updating to get the LAST struct at any level
|
|
291
|
+
# But DON'T traverse into class bodies (to avoid nested classes)
|
|
292
|
+
struct_class_name = node.name.to_s
|
|
293
|
+
module_names = modules
|
|
294
|
+
elsif parent.full_name == 'T::Enum' && struct_class_name.nil?
|
|
295
|
+
enum_class_name = node.name.to_s
|
|
296
|
+
module_names = modules if module_names.empty?
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
find_main_class.call(ast.value.statements.body, [])
|
|
304
|
+
|
|
305
|
+
# Prefer struct over enum
|
|
306
|
+
class_name = struct_class_name || enum_class_name
|
|
307
|
+
[*module_names, T.must(class_name)].compact.join('::')
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class RubyTypeUtils
|
|
6
|
+
class << self
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { params(statement_node: Prism::Node).returns(T::Boolean) }
|
|
10
|
+
def struct_call_node?(statement_node)
|
|
11
|
+
return false unless statement_node.is_a?(Prism::CallNode)
|
|
12
|
+
receiver = statement_node.receiver
|
|
13
|
+
receiver.is_a?(Prism::ConstantPathNode) && receiver.full_name == 'T::Struct'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(statement_node: Prism::Node).returns(T::Boolean) }
|
|
17
|
+
def enum_call_node?(statement_node)
|
|
18
|
+
return false unless statement_node.is_a?(Prism::CallNode)
|
|
19
|
+
receiver = statement_node.receiver
|
|
20
|
+
receiver.is_a?(Prism::ConstantPathNode) && receiver.full_name == 'T::Enum'
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative 'reflection_type_converter.rb'
|
|
5
|
+
|
|
6
|
+
module Mochitype
|
|
7
|
+
class TypeConverter < ReflectionTypeConverter
|
|
8
|
+
class << self
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(in_filepath: String, out_filepath: String).void }
|
|
12
|
+
def write_converted_file(in_filepath:, out_filepath:)
|
|
13
|
+
File.write(out_filepath, new(in_filepath).build_typescript_file)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(file_path: String).returns(T.nilable(String)) }
|
|
17
|
+
def convert_file(file_path)
|
|
18
|
+
output_filepath = determine_output_path(file_path)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(output_filepath))
|
|
20
|
+
write_converted_file(in_filepath: file_path, out_filepath: output_filepath)
|
|
21
|
+
output_filepath
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { params(file_path: String).returns(String) }
|
|
25
|
+
def determine_output_path(file_path)
|
|
26
|
+
base_path =
|
|
27
|
+
if defined?(Rails) && Rails.root
|
|
28
|
+
Rails.root.join(Mochitype.configuration.output_path)
|
|
29
|
+
else
|
|
30
|
+
Mochitype.configuration.output_path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
relative_path = file_path.sub(/.*#{Mochitype.configuration.watch_path}/, '')
|
|
34
|
+
File.join(base_path, relative_path.sub('.rb', '.ts'))
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class ConvertibleStruct < T::Struct
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
const :name, String
|
|
9
|
+
const :props, T::Hash[String, String]
|
|
10
|
+
const :type, T.nilable(String)
|
|
11
|
+
|
|
12
|
+
sig { returns(String) }
|
|
13
|
+
def typescript_name
|
|
14
|
+
"#{name}Schema"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
class TypescriptUtils
|
|
6
|
+
class << self
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
def plain_ruby_class_to_typescript_type(node)
|
|
10
|
+
case T.unsafe(node).name.to_s
|
|
11
|
+
when 'String'
|
|
12
|
+
'z.string()'
|
|
13
|
+
when 'Integer', 'Numeric', 'Float'
|
|
14
|
+
'z.number()'
|
|
15
|
+
else
|
|
16
|
+
"#{T.unsafe(node).name}Schema"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def number_type?(class_name)
|
|
21
|
+
%w[Integer Numeric Float].include?(class_name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Mochitype
|
|
5
|
+
module View
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { params(view_context: ActionView::Base).returns(String) }
|
|
9
|
+
def render_in(view_context)
|
|
10
|
+
serialize.to_json
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
sig { returns(Symbol) }
|
|
14
|
+
def format
|
|
15
|
+
:json
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/mochitype.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Core dependencies
|
|
2
|
+
require 'rails'
|
|
3
|
+
require 'listen'
|
|
4
|
+
require 'prism'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
require 'mochitype/version'
|
|
8
|
+
require 'mochitype/configuration'
|
|
9
|
+
require 'mochitype/ruby_type_utils'
|
|
10
|
+
|
|
11
|
+
require 'mochitype/convertible_property'
|
|
12
|
+
require 'mochitype/convertible_class'
|
|
13
|
+
require 'mochitype/type_converter'
|
|
14
|
+
require 'mochitype/reflection_type_converter'
|
|
15
|
+
|
|
16
|
+
require 'mochitype/view'
|
|
17
|
+
require 'mochitype/file_watcher'
|
|
18
|
+
require 'mochitype/railtie' if defined?(Rails)
|
|
19
|
+
|
|
20
|
+
module Mochitype
|
|
21
|
+
class Error < StandardError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module ViewHelper
|
|
25
|
+
def mochitype_render(*args, **kwargs)
|
|
26
|
+
# Your custom rendering logic will go here
|
|
27
|
+
# This method will be available in all Rails views
|
|
28
|
+
'Placeholder for custom rendering logic'
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
namespace :mochitype do
|
|
5
|
+
desc 'Generate TypeScript types from Ruby Sorbet definitions'
|
|
6
|
+
task generate: :environment do
|
|
7
|
+
require 'mochitype'
|
|
8
|
+
|
|
9
|
+
watch_path = Mochitype.configuration.watch_path
|
|
10
|
+
output_path = Mochitype.configuration.output_path
|
|
11
|
+
|
|
12
|
+
puts "Mochitype: Scanning #{watch_path} for Ruby type definitions..."
|
|
13
|
+
|
|
14
|
+
# Find all .rb files in watch_path
|
|
15
|
+
files = Dir.glob(File.join(watch_path, '**', '*.rb'))
|
|
16
|
+
|
|
17
|
+
if files.empty?
|
|
18
|
+
puts "No Ruby files found in #{watch_path}"
|
|
19
|
+
next
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
converted_count = 0
|
|
23
|
+
files.each do |file_path|
|
|
24
|
+
output_file = Mochitype::TypeConverter.convert_file(file_path)
|
|
25
|
+
if output_file
|
|
26
|
+
puts "Generated #{output_file}"
|
|
27
|
+
converted_count += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
puts "Successfully generated #{converted_count} TypeScript file(s) in #{output_path}"
|
|
32
|
+
end
|
|
33
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mochitype
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Your Name
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2025-11-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: listen
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.8'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.8'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: sorbet-runtime
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.5'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.5'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: sorbet
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - ">="
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 0.5.11094
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 0.5.11094
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rake
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '13.0'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '13.0'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: rspec
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.0'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: rspec-rails
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0'
|
|
104
|
+
type: :development
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
description: A Rails view helper that provides custom rendering functionality
|
|
112
|
+
email:
|
|
113
|
+
- your.email@example.com
|
|
114
|
+
executables: []
|
|
115
|
+
extensions: []
|
|
116
|
+
extra_rdoc_files: []
|
|
117
|
+
files:
|
|
118
|
+
- README.md
|
|
119
|
+
- Rakefile
|
|
120
|
+
- lib/mochitype.rb
|
|
121
|
+
- lib/mochitype/configuration.rb
|
|
122
|
+
- lib/mochitype/convertible_class.rb
|
|
123
|
+
- lib/mochitype/convertible_property.rb
|
|
124
|
+
- lib/mochitype/file_watcher.rb
|
|
125
|
+
- lib/mochitype/railtie.rb
|
|
126
|
+
- lib/mochitype/reflection_type_converter.rb
|
|
127
|
+
- lib/mochitype/ruby_type_utils.rb
|
|
128
|
+
- lib/mochitype/type_converter.rb
|
|
129
|
+
- lib/mochitype/type_data.rb
|
|
130
|
+
- lib/mochitype/typescript_utils.rb
|
|
131
|
+
- lib/mochitype/version.rb
|
|
132
|
+
- lib/mochitype/view.rb
|
|
133
|
+
- lib/tasks/mochitype.rake
|
|
134
|
+
homepage: https://github.com/username/mochitype
|
|
135
|
+
licenses:
|
|
136
|
+
- MIT
|
|
137
|
+
metadata:
|
|
138
|
+
allowed_push_host: https://rubygems.org
|
|
139
|
+
homepage_uri: https://github.com/username/mochitype
|
|
140
|
+
source_code_uri: https://github.com/username/mochitype
|
|
141
|
+
changelog_uri: https://github.com/username/mochitype/blob/master/CHANGELOG.md
|
|
142
|
+
post_install_message:
|
|
143
|
+
rdoc_options: []
|
|
144
|
+
require_paths:
|
|
145
|
+
- lib
|
|
146
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
147
|
+
requirements:
|
|
148
|
+
- - ">="
|
|
149
|
+
- !ruby/object:Gem::Version
|
|
150
|
+
version: 2.6.0
|
|
151
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
152
|
+
requirements:
|
|
153
|
+
- - ">="
|
|
154
|
+
- !ruby/object:Gem::Version
|
|
155
|
+
version: '0'
|
|
156
|
+
requirements: []
|
|
157
|
+
rubygems_version: 3.5.16
|
|
158
|
+
signing_key:
|
|
159
|
+
specification_version: 4
|
|
160
|
+
summary: Custom rendering helper for Rails applications
|
|
161
|
+
test_files: []
|