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 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,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -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,9 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Mochitype
5
+ class ConvertibleProperty < T::Struct
6
+ const :zod_definition, String
7
+ const :discovered_classes, T::Array[Class], default: []
8
+ end
9
+ 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,3 @@
1
+ module Mochitype
2
+ VERSION = '0.3.0'
3
+ 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: []