typelizer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2a3d9ee492a8abe52368f76b6312cb8dea42c1321d45f9555e69ad7165c660bf
4
+ data.tar.gz: b3327bfeebc1bae33772bd3a4ddeec312583fbc9345da5e2ca16e992f845331b
5
+ SHA512:
6
+ metadata.gz: 43771cbcdbc2ae8952abe2af958b434a1e2cf8caaa639fab7ea477be64000e7b9c4e7d83fec13c94b3ab26ac65efbae4dfab47bd3e6976e345c0b2516e95bf40
7
+ data.tar.gz: 0450a22f5189a079f7747f2c8c2d1b01445143d5a1cbdadf34d3be17ef7056b2f28ce27d880b3950d87d69e34a166481742a1d16a8fce5b9906c130b3c18f378
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog],
6
+ and this project adheres to [Semantic Versioning].
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2024-08-02
11
+
12
+ - Initial release ([@skryukov])
13
+
14
+ [@skryukov]: https://github.com/skryukov
15
+
16
+ [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.1.0...HEAD
17
+ [0.1.0]: https://github.com/skryukov/typelizer/commits/v0.1.0
18
+
19
+ [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
20
+ [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
data/README.md ADDED
@@ -0,0 +1,253 @@
1
+ # Typelizer
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/typelizer.svg)](https://rubygems.org/gems/typelizer)
4
+
5
+ Typelizer is a Ruby gem that automatically generates TypeScript interfaces from your Ruby serializers, bridging the gap between your Ruby backend and TypeScript frontend. It supports multiple serializer libraries and provides a flexible configuration system, making it easier to maintain type consistency across your full-stack application.
6
+
7
+ ## Table of Contents
8
+
9
+ - [Features](#features)
10
+ - [Installation](#installation)
11
+ - [Usage](#usage)
12
+ - [Basic Setup](#basic-setup)
13
+ - [Manual Typing](#manual-typing)
14
+ - [TypeScript Integration](#typescript-integration)
15
+ - [Manual Generation](#manual-generation)
16
+ - [Automatic Generation in Development](#automatic-generation-in-development)
17
+ - [Configuration](#configuration)
18
+ - [Global Configuration](#global-configuration)
19
+ - [Config Options](#config-options)
20
+ - [Per-Serializer Configuration](#per-serializer-configuration)
21
+ - [Credits](#credits)
22
+ - [License](#license)
23
+
24
+ <a href="https://evilmartians.com/?utm_source=typelizer&utm_campaign=project_page">
25
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Built by Evil Martians" width="236" height="54">
26
+ </a>
27
+
28
+ ## Features
29
+
30
+ - Automatic TypeScript interface generation
31
+ - Support for multiple serializer libraries (`Alba`, `ActiveModel::Serializer`, `Oj::Serializer`)
32
+ - File watching and automatic regeneration in development
33
+
34
+ ## Installation
35
+
36
+ To install Typelizer, add the following line to your `Gemfile` and run `bundle install`:
37
+
38
+ ```ruby
39
+ gem "typelizer"
40
+ ```
41
+
42
+ ## Usage
43
+
44
+ ### Basic Setup
45
+
46
+ Include the Typelizer DSL in your serializers:
47
+
48
+ ```ruby
49
+ class ApplicationResource
50
+ include Alba::Resource
51
+ include Typelizer::DSL
52
+ end
53
+
54
+ class PostResource < ApplicationResource
55
+ attributes :id, :title, :body
56
+
57
+ has_one :author, serializer: AuthorResource
58
+ end
59
+
60
+ class AuthorResource < ApplicationResource
61
+ # specify the model to infer types from (optional)
62
+ typelize_from User
63
+
64
+ attributes :id, :name
65
+ end
66
+ ```
67
+
68
+ Typelizer will automatically generate TypeScript interfaces based on your serializer definitions using information from your models.
69
+
70
+ ### Manual Typing
71
+
72
+ You can manually specify TypeScript types in your serializers:
73
+
74
+ ```ruby
75
+ class PostResource < ApplicationResource
76
+ attributes :id, :title, :body, :published_at
77
+
78
+ typelize "string"
79
+ attribute :author_name do |post|
80
+ post.author.name
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### TypeScript Integration
86
+
87
+ Typelizer generates TypeScript interfaces in the specified output directory:
88
+
89
+ ```typescript
90
+ // app/javascript/types/serializers/Post.ts
91
+ export interface Post {
92
+ id: number;
93
+ title: string;
94
+ body: string;
95
+ published_at: string | null;
96
+ author_name: string;
97
+ }
98
+ ```
99
+
100
+ All generated interfaces are automatically imported in a single file:
101
+
102
+ ```typescript
103
+ // app/javascript/types/serializers/index.ts
104
+ export * from "./post";
105
+ export * from "./author";
106
+ ```
107
+
108
+ We recommend importing this file in a central location:
109
+
110
+ ```typescript
111
+ // app/javascript/types/index.ts
112
+ import "@/types/serializers";
113
+ // Custom types can be added here
114
+ // ...
115
+ ```
116
+
117
+ With such a setup, you can import all generated interfaces in your TypeScript files:
118
+
119
+ ```typescript
120
+ import { Post } from "@/types";
121
+ ```
122
+
123
+ This setup also allows you to use custom types in your serializers:
124
+
125
+ ```ruby
126
+ class PostWithMetaResource < ApplicationResource
127
+ attributes :id, :title
128
+ typelize "PostMeta"
129
+ attribute :meta do |post|
130
+ { likes: post.likes, comments: post.comments }
131
+ end
132
+ end
133
+ ```
134
+
135
+ ```typescript
136
+ // app/javascript/types/serializers/PostWithMeta.ts
137
+
138
+ import { PostMeta } from "@/types";
139
+
140
+ export interface Post {
141
+ id: number;
142
+ title: string;
143
+ meta: PostMeta;
144
+ }
145
+ ```
146
+
147
+ The `"@/types"` import path is configurable:
148
+
149
+ ```ruby
150
+ Typelizer.configure do |config|
151
+ config.types_import_path = "@/types";
152
+ end
153
+ ```
154
+
155
+ See the [Configuration](#configuration) section for more options.
156
+
157
+ ### Manual Generation
158
+
159
+ To manually generate TypeScript interfaces:
160
+
161
+ ```
162
+ $ rails typelizer:generate
163
+ ```
164
+
165
+ ### Automatic Generation in Development
166
+
167
+ When [Listen](https://github.com/guard/listen) is installed, Typelizer automatically watches for changes and regenerates interfaces in development mode. You can disable this behavior:
168
+
169
+ ```ruby
170
+ Typelizer.listen = false
171
+ ```
172
+
173
+ ## Configuration
174
+
175
+ ### Global Configuration
176
+
177
+ Typelizer provides several global configuration options:
178
+
179
+ ```ruby
180
+ # Directories to search for serializers:
181
+ Typelizer.dirs = [Rails.root.join("app", "resources"), Rails.root.join("app", "serializers")]
182
+ # Reject specific classes from being typelized:
183
+ Typelizer.reject_class = ->(serializer:) { false }
184
+ # Logger for debugging:
185
+ Typelizer.logger = Logger.new($stdout, level: :info)
186
+ # Force enable or disable file watching with Listen:
187
+ Typelizer.listen = nil
188
+ ```
189
+
190
+ ### Config Options
191
+
192
+ `Typelizer::Config` offers fine-grained control over the gem's behavior. Here's a list of available options:
193
+
194
+ ```ruby
195
+ Typelizer.configure do |config|
196
+ # Determines how serializer names are mapped to TypeScript interface names
197
+ config.serializer_name_mapper = ->(serializer) { ... }
198
+
199
+ # Maps serializers to their corresponding model classes
200
+ config.serializer_model_mapper = ->(serializer) { ... }
201
+
202
+ # Custom transformation for generated properties
203
+ config.properties_transformer = ->(properties) { ... }
204
+
205
+ # Plugin for model type inference (default: ModelPlugins::ActiveRecord)
206
+ config.model_plugin = Typelizer::ModelPlugins::ActiveRecord
207
+
208
+ # Plugin for serializer parsing (default: SerializerPlugins::Auto)
209
+ config.serializer_plugin = Typelizer::SerializerPlugins::Auto
210
+
211
+ # Additional configurations for specific plugins
212
+ config.plugin_configs = { alba: { ts_mapper: {...} } }
213
+
214
+ # Custom DB to TypeScript type mapping
215
+ config.type_mapping = config.type_mapping.merge(jsonb: "Record<string, undefined>", ... )
216
+
217
+ # Strategy for handling null values (:nullable, :optional, or :nullable_and_optional)
218
+ config.null_strategy = :nullable
219
+
220
+ # Directory where TypeScript interfaces will be generated
221
+ config.output_dir = Rails.root.join("app/javascript/types/serializers")
222
+
223
+ # Import path for generated types in TypeScript files
224
+ # (e.g., `import { MyType } from "@/types"`)
225
+ config.types_import_path = "@/types"
226
+
227
+ # List of type names that should be considered global in TypeScript
228
+ # (i.e. not prefixed with the import path)
229
+ config.types_global << %w[Array Date Record File FileList]
230
+ end
231
+ ```
232
+
233
+ ### Per-Serializer Configuration
234
+
235
+ You can also configure Typelizer on a per-serializer basis:
236
+
237
+ ```ruby
238
+ class PostResource < ApplicationResource
239
+ typelizer_config do |config|
240
+ config.type_mapping = config.type_mapping.merge(jsonb: "Record<string, undefined>", ... )
241
+ config.null_strategy = :nullable
242
+ # ...
243
+ end
244
+ end
245
+ ```
246
+
247
+ ## Credits
248
+
249
+ Typelizer is inspired by [types_from_serializers](https://github.com/ElMassimo/types_from_serializers).
250
+
251
+ ## License
252
+
253
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,18 @@
1
+ namespace :typelizer do
2
+ desc "Generate TypeScript interfaces from serializers"
3
+ task generate: :environment do
4
+ require "benchmark"
5
+
6
+ ENV["TYPELIZER"] = "true"
7
+
8
+ puts "Generating TypeScript interfaces..."
9
+ serializers = []
10
+ time = Benchmark.realtime do
11
+ serializers = Typelizer::Generator.call
12
+ end
13
+
14
+ puts "Finished in #{time} seconds"
15
+ puts "Found #{serializers.size} serializers:"
16
+ puts serializers.map { |s| "\t#{s.name}" }.join("\n")
17
+ end
18
+ end
@@ -0,0 +1,75 @@
1
+ module Typelizer
2
+ TYPE_MAPPING = {
3
+ boolean: :boolean,
4
+ date: :string,
5
+ datetime: :string,
6
+ decimal: :number,
7
+ integer: :number,
8
+ string: :string,
9
+ text: :string,
10
+ citext: :string
11
+ }.tap do |types|
12
+ types.default = :unknown
13
+ end
14
+
15
+ class Config < Struct.new(
16
+ :serializer_name_mapper,
17
+ :serializer_model_mapper,
18
+ :properties_transformer,
19
+ :model_plugin,
20
+ :serializer_plugin,
21
+ :plugin_configs,
22
+ :type_mapping,
23
+ :null_strategy,
24
+ :output_dir,
25
+ :types_import_path,
26
+ :types_global,
27
+ keyword_init: true
28
+ ) do
29
+ class << self
30
+ def instance
31
+ @instance ||= new(
32
+ serializer_name_mapper: ->(serializer) { serializer.name.ends_with?("Serializer") ? serializer.name.delete_suffix("Serializer") : serializer.name.delete_suffix("Resource") },
33
+ serializer_model_mapper: ->(serializer) do
34
+ base_class = serializer_name_mapper.call(serializer)
35
+ Object.const_get(base_class) if Object.const_defined?(base_class)
36
+ end,
37
+
38
+ model_plugin: ModelPlugins::ActiveRecord,
39
+ serializer_plugin: SerializerPlugins::Auto,
40
+ plugin_configs: {},
41
+
42
+ type_mapping: TYPE_MAPPING,
43
+ null_strategy: :nullable,
44
+
45
+ output_dir: js_root.join("types/serializers"),
46
+
47
+ types_import_path: "@/types",
48
+ types_global: %w[Array Date Record File FileList],
49
+
50
+ properties_transformer: nil
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def js_root
57
+ root_path = defined?(Rails) ? Rails.root : Pathname.pwd
58
+ js_root = defined?(ViteRuby) ? ViteRuby.config.source_code_dir : "app/javascript"
59
+ root_path.join(js_root)
60
+ end
61
+
62
+ def respond_to_missing?(name, include_private = false)
63
+ Typelizer.respond_to?(name) ||
64
+ instance.respond_to?(name, include_private)
65
+ end
66
+
67
+ def method_missing(method, *args, &block)
68
+ return Typelizer.send(method, *args, &block) if Typelizer.respond_to?(method)
69
+
70
+ instance.send(method, *args, &block)
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,89 @@
1
+ module Typelizer
2
+ module DSL
3
+ # typelize_from Model
4
+ # typelize attribute_name: ["string", "Date", optional: true, nullable: true, multi: true]
5
+
6
+ def self.included(base)
7
+ Typelizer.base_classes << base.to_s
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ def self.extended(base)
12
+ Typelizer.base_classes << base.to_s
13
+ base.extend(ClassMethods)
14
+ end
15
+
16
+ module ClassMethods
17
+ def typelizer_config
18
+ @typelizer_config ||=
19
+ begin
20
+ parent_config = superclass.respond_to?(:typelizer_config) ? superclass.typelizer_config : Config
21
+ Config.new(parent_config.to_h.transform_values(&:dup))
22
+ end
23
+ yield @typelizer_config if block_given?
24
+ @typelizer_config
25
+ end
26
+
27
+ def typelizer_interface
28
+ @typelizer_interface ||= Interface.new(serializer: self)
29
+ end
30
+
31
+ # save association of serializer to model
32
+ def typelize_from(model)
33
+ return unless Typelizer.enabled?
34
+
35
+ define_singleton_method(:_typelizer_model_name) { model }
36
+ end
37
+
38
+ # save association of serializer attributes to type
39
+ # can be invoked multiple times
40
+ def typelize(type = nil, type_params = {}, **attributes)
41
+ if type
42
+ @keyless_type = [type, type_params.merge(attributes)]
43
+ else
44
+ assign_type_information(:_typelizer_attributes, attributes)
45
+ end
46
+ end
47
+
48
+ attr_accessor :keyless_type
49
+
50
+ def typelize_meta(**attributes)
51
+ assign_type_information(:_typelizer_meta_attributes, attributes)
52
+ end
53
+
54
+ private
55
+
56
+ def assign_type_information(attribute_name, attributes)
57
+ return unless Typelizer.enabled?
58
+
59
+ instance_variable = "@#{attribute_name}"
60
+
61
+ unless instance_variable_get(instance_variable)
62
+ instance_variable_set(instance_variable, {})
63
+ end
64
+
65
+ unless respond_to?(attribute_name)
66
+ define_singleton_method(attribute_name) do
67
+ result = instance_variable_get(instance_variable)
68
+ if superclass.respond_to?(attribute_name)
69
+ result.merge(superclass.send(attribute_name))
70
+ else
71
+ result
72
+ end
73
+ end
74
+ end
75
+
76
+ attributes.each do |name, attrs|
77
+ next unless name
78
+
79
+ attrs = [attrs] if attrs && !attrs.is_a?(Array)
80
+ options = attrs.last.is_a?(Hash) ? attrs.pop : {}
81
+
82
+ options[:type] = attrs.join(" | ") if attrs.any?
83
+ instance_variable_get(instance_variable)[name.to_sym] ||= {}
84
+ instance_variable_get(instance_variable)[name.to_sym].merge!(options)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ class Generator
5
+ def self.call
6
+ new.call
7
+ end
8
+
9
+ def initialize(config = Typelizer::Config)
10
+ @config = config
11
+ @writer = Writer.new
12
+ end
13
+
14
+ attr_reader :config, :writer
15
+
16
+ def call(force: false)
17
+ return unless Typelizer.enabled?
18
+
19
+ read_serializers
20
+
21
+ interfaces = target_serializers.map(&:typelizer_interface).reject(&:empty?)
22
+ writer.call(interfaces, force: force)
23
+
24
+ interfaces
25
+ end
26
+
27
+ private
28
+
29
+ def target_serializers
30
+ base_classes = Typelizer.base_classes.filter_map do |base_class|
31
+ Object.const_get(base_class) if Object.const_defined?(base_class)
32
+ end.tap do |base_classes|
33
+ raise ArgumentError, "Please ensure all your serializers include Typelizer::DSL." if base_classes.none?
34
+ end
35
+
36
+ (base_classes + base_classes.flat_map(&:descendants)).uniq.sort_by(&:name)
37
+ .reject { |serializer| Typelizer.reject_class.call(serializer: serializer) }
38
+ end
39
+
40
+ def read_serializers(files = nil)
41
+ files ||= Typelizer.dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }
42
+
43
+ files.each do |file|
44
+ trace = TracePoint.new(:call) do |tp|
45
+ begin
46
+ next unless tp.self.respond_to?(:typelizer_interface) && !tp.self.send(:respond_to_missing?, :typelizer_interface, false)
47
+ rescue WeakRef::RefError
48
+ next
49
+ end
50
+ serializer_plugin = tp.self.typelizer_interface.serializer_plugin
51
+
52
+ if tp.callee_id.in?(serializer_plugin.methods_to_typelize)
53
+ type, attrs = tp.self.keyless_type
54
+ name = tp.binding.local_variable_get(:name) if tp.binding.local_variable_defined?(:name)
55
+ tp.self.typelize(**serializer_plugin.typelize_method_transform(method: tp.callee_id, binding: tp.binding, name: name, type: type, attrs: attrs || {}))
56
+ tp.self.keyless_type = nil
57
+ end
58
+ end
59
+
60
+ trace.enable
61
+ require file
62
+ trace.disable
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,101 @@
1
+ module Typelizer
2
+ class Interface
3
+ attr_reader :serializer, :serializer_plugin
4
+
5
+ def config
6
+ serializer.typelizer_config
7
+ end
8
+
9
+ def initialize(serializer:)
10
+ @serializer = serializer
11
+ @serializer_plugin = config.serializer_plugin.new(serializer: serializer, config: config)
12
+ end
13
+
14
+ def name
15
+ config.serializer_name_mapper.call(serializer).tr_s(":", "")
16
+ end
17
+
18
+ def filename
19
+ name.gsub("::", "/")
20
+ end
21
+
22
+ def root_key
23
+ serializer_plugin.root_key
24
+ end
25
+
26
+ def empty?
27
+ meta_fields.empty? && properties.empty?
28
+ end
29
+
30
+ def meta_fields
31
+ @meta_fields ||= begin
32
+ props = serializer_plugin.meta_fields || []
33
+ props = infer_types(props, :_typelizer_meta_attributes)
34
+ props = config.properties_transformer.call(props) if config.properties_transformer
35
+ props
36
+ end
37
+ end
38
+
39
+ def properties
40
+ @properties ||= begin
41
+ props = serializer_plugin.properties
42
+ props = infer_types(props)
43
+ props = config.properties_transformer.call(props) if config.properties_transformer
44
+ props
45
+ end
46
+ end
47
+
48
+ def imports
49
+ association_serializers, attribute_types = properties.filter_map(&:type)
50
+ .uniq
51
+ .partition { |type| type.is_a?(Interface) }
52
+
53
+ serializer_types = association_serializers
54
+ .filter_map { |interface| interface.name if interface.name != name }
55
+
56
+ custom_type_imports = attribute_types
57
+ .flat_map { |type| extract_typescript_types(type.to_s) }
58
+ .uniq
59
+ .reject { |type| global_type?(type) }
60
+
61
+ custom_type_imports + serializer_types
62
+ end
63
+
64
+ def inspect
65
+ "<#{self.class.name} #{name} properties=#{properties.inspect}>"
66
+ end
67
+
68
+ private
69
+
70
+ def extract_typescript_types(type)
71
+ type.split(/[<>\[\],\s|]+/)
72
+ end
73
+
74
+ def global_type?(type)
75
+ type[0] == type[0].downcase || config.types_global.include?(type)
76
+ end
77
+
78
+ def infer_types(props, hash_name = :_typelizer_attributes)
79
+ props.map do |prop|
80
+ if serializer.respond_to?(hash_name)
81
+ dsl_type = serializer.public_send(hash_name)[prop.name.to_sym]
82
+ next Property.new(prop.to_h.merge(dsl_type)) if dsl_type&.any?
83
+ end
84
+
85
+ model_plugin.infer_types(prop)
86
+ end
87
+ end
88
+
89
+ def model_class
90
+ return serializer._typelizer_model_name if serializer.respond_to?(:_typelizer_model_name)
91
+
92
+ config.serializer_model_mapper.call(serializer)
93
+ rescue NameError
94
+ nil
95
+ end
96
+
97
+ def model_plugin
98
+ @model_plugin ||= config.model_plugin.new(model_class: model_class, config: config)
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module Listen
5
+ class << self
6
+ attr_accessor :started
7
+
8
+ def call(
9
+ run_on_start: true,
10
+ options: {},
11
+ &block
12
+ )
13
+ return if started
14
+ return unless Typelizer.enabled?
15
+
16
+ @block = block
17
+ @generator = Typelizer::Generator.new
18
+
19
+ gem "listen"
20
+ require "listen"
21
+
22
+ self.started = true
23
+
24
+ locales_dirs = Typelizer.dirs.filter(&:exist?).map { |path| File.expand_path(path) }
25
+
26
+ relative_paths = locales_dirs.map { |path| relative_path(path) }
27
+
28
+ debug("Watching #{relative_paths.inspect}")
29
+
30
+ listener(locales_dirs.map(&:to_s), options).start
31
+ @generator.call if run_on_start
32
+ end
33
+
34
+ def relative_path(path)
35
+ root_path = defined?(Rails) ? Rails.root : Pathname.pwd
36
+ Pathname.new(path).relative_path_from(root_path).to_s
37
+ end
38
+
39
+ def debug(message)
40
+ Typelizer.logger.debug(message)
41
+ end
42
+
43
+ def listener(paths, options)
44
+ ::Listen.to(*paths, options) do |changed, added, removed|
45
+ changes = compute_changes(paths, changed, added, removed)
46
+
47
+ next unless changes.any?
48
+
49
+ debug(changes.map { |key, value| "#{key}=#{value.inspect}" }.join(", "))
50
+
51
+ @block.call
52
+ end
53
+ end
54
+
55
+ def compute_changes(paths, changed, added, removed)
56
+ paths = paths.map { |path| relative_path(path) }
57
+
58
+ {
59
+ changed: included_on_watched_paths(paths, changed),
60
+ added: included_on_watched_paths(paths, added),
61
+ removed: included_on_watched_paths(paths, removed)
62
+ }.select { |_k, v| v.any? }
63
+ end
64
+
65
+ def included_on_watched_paths(paths, changes)
66
+ changes.map { |change| relative_path(change) }.select do |change|
67
+ paths.any? { |path| change.start_with?(path) }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,34 @@
1
+ module Typelizer
2
+ module ModelPlugins
3
+ class ActiveRecord
4
+ def initialize(model_class:, config:)
5
+ @columns_hash = model_class&.columns_hash || {}
6
+ @config = config
7
+ end
8
+
9
+ attr_reader :columns_hash, :config
10
+
11
+ def infer_types(prop)
12
+ column = columns_hash[prop.column_name.to_s]
13
+ return prop unless column
14
+
15
+ prop.multi = !!column.try(:array)
16
+ case config.null_strategy
17
+ when :nullable
18
+ prop.nullable = column.null
19
+ when :optional
20
+ prop.optional = column.null
21
+ when :nullable_and_optional
22
+ prop.nullable = column.null
23
+ prop.optional = column.null
24
+ else
25
+ raise "Unknown null strategy: #{config.null_strategy}"
26
+ end
27
+
28
+ prop.type = @config.type_mapping[column.type]
29
+
30
+ prop
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,26 @@
1
+ module Typelizer
2
+ Property = Struct.new(
3
+ :name, :type, :optional, :nullable,
4
+ :multi, :column_name,
5
+ keyword_init: true
6
+ ) do
7
+ def inspect
8
+ props = to_h.merge(type: type_name).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
9
+ "<#{self.class.name} #{props}>"
10
+ end
11
+
12
+ def to_s
13
+ type_str = type_name
14
+ type_str = "Array<#{type_str}>" if multi
15
+ type_str = "#{type_str} | null" if nullable
16
+
17
+ "#{name}#{"?" if optional}: #{type_str}"
18
+ end
19
+
20
+ private
21
+
22
+ def type_name
23
+ type.respond_to?(:name) ? type.name : type || "unknown"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,35 @@
1
+ module Typelizer
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ load "tasks/generate.rake"
5
+ end
6
+
7
+ initializer "typelizer.configure" do
8
+ Typelizer.configure do |c|
9
+ c.dirs = [
10
+ Rails.root.join("app", "resources"),
11
+ Rails.root.join("app", "serializers")
12
+ ]
13
+ end
14
+ end
15
+
16
+ initializer "typelizer.generate" do |app|
17
+ next unless Typelizer.enabled?
18
+
19
+ generator = Typelizer::Generator.new
20
+
21
+ if Typelizer.listen == true || Gem.loaded_specs["listen"] && Typelizer.listen != false
22
+ require_relative "listen"
23
+ app.config.after_initialize do
24
+ Typelizer::Listen.call do
25
+ Rails.application.reloader.reload!
26
+ end
27
+ end
28
+ end
29
+
30
+ app.config.to_prepare do
31
+ generator.call
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,120 @@
1
+ require_relative "base"
2
+
3
+ module Typelizer
4
+ module SerializerPlugins
5
+ class Alba < Base
6
+ ALBA_TS_MAPPER = {
7
+ "String" => {type: :string},
8
+ "Integer" => {type: :number},
9
+ "Boolean" => {type: :boolean},
10
+ "ArrayOfString" => {type: :string, multi: true},
11
+ "ArrayOfInteger" => {type: :number, multi: true}
12
+ }
13
+
14
+ def properties
15
+ serializer._attributes.map do |name, attr|
16
+ build_property(name, attr)
17
+ end
18
+ end
19
+
20
+ def methods_to_typelize
21
+ [
22
+ :association, :one, :has_one,
23
+ :many, :has_many,
24
+ :attributes, :attribute,
25
+ :nested_attribute, :nested,
26
+ :meta
27
+ ]
28
+ end
29
+
30
+ def typelize_method_transform(method:, name:, binding:, type:, attrs:)
31
+ return {name => [type, attrs.merge(multi: true)]} if [:many, :has_many].include?(method)
32
+
33
+ super
34
+ end
35
+
36
+ def root_key
37
+ serializer.new({}).send(:_key)
38
+ end
39
+
40
+ def meta_fields
41
+ return nil unless serializer._meta
42
+
43
+ name = serializer._meta.first
44
+ [
45
+ build_property(name, name)
46
+ ]
47
+ end
48
+
49
+ private
50
+
51
+ def build_property(name, attr, **options)
52
+ case attr
53
+ when Symbol
54
+ Property.new(
55
+ name: name,
56
+ type: nil,
57
+ optional: false,
58
+ nullable: false,
59
+ multi: false,
60
+ column_name: name,
61
+ **options
62
+ )
63
+ when Proc
64
+ Property.new(
65
+ name: name,
66
+ type: nil,
67
+ optional: false,
68
+ nullable: false,
69
+ multi: false,
70
+ column_name: nil,
71
+ **options
72
+ )
73
+ when ::Alba::Association
74
+ resource = attr.instance_variable_get(:@resource)
75
+ Property.new(
76
+ name: name,
77
+ type: Interface.new(serializer: resource),
78
+ optional: false,
79
+ nullable: false,
80
+ multi: false, # we override this in typelize_method_transform
81
+ column_name: name,
82
+ **options
83
+ )
84
+ when ::Alba::TypedAttribute
85
+ alba_type = attr.instance_variable_get(:@type)
86
+ Property.new(
87
+ name: name,
88
+ optional: false,
89
+ # not sure if that's a good default tbh
90
+ nullable: !alba_type.instance_variable_get(:@auto_convert),
91
+ multi: false,
92
+ column_name: name,
93
+ **ts_mapper[alba_type.name.to_s],
94
+ **options
95
+ )
96
+ when ::Alba::NestedAttribute
97
+ Property.new(
98
+ name: name,
99
+ type: nil,
100
+ optional: false,
101
+ nullable: false,
102
+ multi: false,
103
+ column_name: nil,
104
+ **options
105
+ )
106
+ when ::Alba::ConditionalAttribute
107
+ build_property(name, attr.instance_variable_get(:@body), optional: true)
108
+ else
109
+ raise ArgumentError, "Unsupported attribute type: #{attr.class}"
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def ts_mapper
116
+ config.plugin_configs.dig(:alba, :ts_mapper) || ALBA_TS_MAPPER
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,33 @@
1
+ require_relative "base"
2
+
3
+ module Typelizer
4
+ module SerializerPlugins
5
+ class AMS < Base
6
+ def methods_to_typelize
7
+ [
8
+ :has_many, :has_one, :belongs_to,
9
+ :attribute, :attributes
10
+ ]
11
+ end
12
+
13
+ def typelize_method_transform(method:, name:, binding:, type:, attrs:)
14
+ return {binding.local_variable_get(:attr) => [type, attrs]} if method == :attribute
15
+
16
+ super
17
+ end
18
+
19
+ def properties
20
+ serializer._attributes_data.merge(serializer._reflections).flat_map do |key, association|
21
+ type = association.options[:serializer] ? Interface.new(serializer: association.options[:serializer]) : nil
22
+ Property.new(
23
+ name: key.to_s,
24
+ type: type,
25
+ optional: association.options.key?(:if) || association.options.key?(:unless),
26
+ multi: association.respond_to?(:collection?) && association.collection?,
27
+ column_name: association.name.to_s
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,24 @@
1
+ module Typelizer
2
+ module SerializerPlugins
3
+ module Auto
4
+ class << self
5
+ def new(serializer:, config:)
6
+ plugin(serializer).new(serializer: serializer, config: config)
7
+ end
8
+
9
+ def plugin(serializer)
10
+ if defined?(::Oj::Serializer) && serializer.ancestors.include?(::Oj::Serializer)
11
+ OjSerializers
12
+ elsif defined?(::Alba) && serializer.ancestors.include?(::Alba::Resource)
13
+ Alba
14
+ elsif defined?(ActiveModel::Serializer) && serializer.ancestors.include?(ActiveModel::Serializer)
15
+ AMS
16
+ else
17
+ raise "Can't guess serializer plugin for #{serializer}. " \
18
+ "Please specify it with `config.serializer_plugin`."
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ module Typelizer
2
+ module SerializerPlugins
3
+ class Base
4
+ def initialize(serializer:, config:)
5
+ @serializer = serializer
6
+ @config = config
7
+ end
8
+
9
+ def root_key
10
+ nil
11
+ end
12
+
13
+ def meta_fields
14
+ nil
15
+ end
16
+
17
+ def typelize_method_transform(method:, name:, binding:, type:, attrs:)
18
+ {name => [type, attrs]}
19
+ end
20
+
21
+ def methods_to_typelize
22
+ []
23
+ end
24
+
25
+ def properties
26
+ []
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :serializer, :config
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ require_relative "base"
2
+
3
+ module Typelizer
4
+ module SerializerPlugins
5
+ class OjSerializers < Base
6
+ def methods_to_typelize
7
+ [
8
+ :has_many, :has_one, :belongs_to,
9
+ :flat_one, :attribute, :attributes
10
+ ]
11
+ end
12
+
13
+ def properties
14
+ serializer._attributes
15
+ .flat_map do |key, options|
16
+ if options[:association] == :flat
17
+ Interface.new(serializer: options.fetch(:serializer)).properties
18
+ else
19
+ type = options[:serializer] ? Interface.new(serializer: options[:serializer]) : options[:type]
20
+ Property.new(
21
+ name: key,
22
+ type: type,
23
+ optional: options[:optional] || options.key?(:if),
24
+ nullable: options[:nullable],
25
+ multi: options[:association] == :many,
26
+ column_name: options.fetch(:value_from)
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ // Typelizer digest <%= Digest::MD5.hexdigest(fingerprint) %>
2
+ //
3
+ // DO NOT MODIFY: This file was automatically generated by Typelizer.
@@ -0,0 +1,3 @@
1
+ <%- interfaces.each do |interface| -%>
2
+ export type { default as <%= interface.name %> } from './<%= interface.filename %>'
3
+ <%- end -%>
@@ -0,0 +1,26 @@
1
+ <%- if interface.imports.any? -%>
2
+ import type {<%= interface.imports.join(", ") %>} from '<%= interface.config.types_import_path %>'
3
+ <%- end -%>
4
+
5
+ <%- if interface.root_key -%>
6
+ type <%= interface.name %>Data = {
7
+ <%- interface.properties.each do |property| -%>
8
+ <%= property %>;
9
+ <%- end -%>
10
+ }
11
+
12
+ type <%= interface.name %> = {
13
+ <%= interface.root_key %>: <%= interface.name %>Data;
14
+ <%- interface.meta_fields&.each do |property| -%>
15
+ <%= property %>;
16
+ <%- end -%>
17
+ }
18
+ <%- else -%>
19
+ type <%= interface.name %> = {
20
+ <%- interface.properties.each do |property| -%>
21
+ <%= property %>;
22
+ <%- end -%>
23
+ }
24
+ <%- end -%>
25
+
26
+ export default <%= interface.name %>;
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "erb"
5
+
6
+ module Typelizer
7
+ class Writer
8
+ def initialize
9
+ @template_cache = {}
10
+ @config = Config
11
+ end
12
+
13
+ attr_reader :config, :template_cache
14
+
15
+ def call(interfaces, force:)
16
+ cleanup_output_dir if force
17
+
18
+ written_files = interfaces.map { |interface| write_interface(interface) }
19
+ written_files << write_index(interfaces)
20
+
21
+ existing_files = Dir[File.join(config.output_dir, "**/*.ts")]
22
+ files_to_delete = existing_files - written_files
23
+
24
+ File.delete(*files_to_delete) unless files_to_delete.empty?
25
+ end
26
+
27
+ private
28
+
29
+ def write_index(interfaces)
30
+ write_file("index.ts", interfaces.map(&:filename).join) do
31
+ render_template("index.ts.erb", interfaces: interfaces)
32
+ end
33
+ end
34
+
35
+ def write_interface(interface)
36
+ write_file("#{interface.filename}.ts", interface.inspect) do
37
+ render_template("interface.ts.erb", interface: interface)
38
+ end
39
+ end
40
+
41
+ def write_file(filename, fingerprint)
42
+ output_file = File.join(config.output_dir, filename)
43
+ existing_content = File.exist?(output_file) ? File.read(output_file) : nil
44
+ digest = render_template("fingerprint.ts.erb", fingerprint: fingerprint)
45
+
46
+ return output_file if existing_content&.start_with?(digest)
47
+
48
+ content = yield
49
+
50
+ FileUtils.mkdir_p(File.dirname(output_file))
51
+
52
+ File.write(output_file, digest + content)
53
+ output_file
54
+ end
55
+
56
+ def render_template(template, **context)
57
+ template_cache[template] ||= ERB.new(File.read(File.join(File.dirname(__FILE__), "templates/#{template}")), trim_mode: "-")
58
+ template_cache[template].result_with_hash(context)
59
+ end
60
+
61
+ def cleanup_output_dir
62
+ FileUtils.rm_rf(config.output_dir)
63
+ end
64
+ end
65
+ end
data/lib/typelizer.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "typelizer/version"
4
+ require_relative "typelizer/config"
5
+ require_relative "typelizer/property"
6
+ require_relative "typelizer/interface"
7
+ require_relative "typelizer/writer"
8
+ require_relative "typelizer/generator"
9
+
10
+ require_relative "typelizer/dsl"
11
+
12
+ require_relative "typelizer/serializer_plugins/auto"
13
+ require_relative "typelizer/serializer_plugins/oj_serializers"
14
+ require_relative "typelizer/serializer_plugins/alba"
15
+ require_relative "typelizer/serializer_plugins/ams"
16
+
17
+ require_relative "typelizer/model_plugins/active_record"
18
+
19
+ require_relative "typelizer/railtie" if defined?(Rails)
20
+
21
+ module Typelizer
22
+ class << self
23
+ def enabled?
24
+ ENV["RAILS_ENV"] == "development" || ENV["RACK_ENV"] == "development" || ENV["TYPELIZER"] == "true"
25
+ end
26
+
27
+ attr_accessor :dirs
28
+ attr_accessor :reject_class
29
+ attr_accessor :logger
30
+ attr_accessor :listen
31
+
32
+ # @private
33
+ attr_reader :base_classes
34
+
35
+ def configure
36
+ yield Config
37
+ end
38
+
39
+ private
40
+
41
+ attr_writer :base_classes
42
+ end
43
+
44
+ # Set in the Railtie
45
+ self.dirs = []
46
+ self.reject_class = ->(serializer:) { false }
47
+ self.logger = Logger.new($stdout, level: :info)
48
+ self.listen = nil
49
+
50
+ self.base_classes = Set.new
51
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typelizer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Svyatoslav Kryukov
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-08-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: railties
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 6.0.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.0
27
+ description: A TypeScript type generator for Ruby serializers.
28
+ email:
29
+ - me@skryukov.dev
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - README.md
36
+ - lib/tasks/generate.rake
37
+ - lib/typelizer.rb
38
+ - lib/typelizer/config.rb
39
+ - lib/typelizer/dsl.rb
40
+ - lib/typelizer/generator.rb
41
+ - lib/typelizer/interface.rb
42
+ - lib/typelizer/listen.rb
43
+ - lib/typelizer/model_plugins/active_record.rb
44
+ - lib/typelizer/property.rb
45
+ - lib/typelizer/railtie.rb
46
+ - lib/typelizer/serializer_plugins/alba.rb
47
+ - lib/typelizer/serializer_plugins/ams.rb
48
+ - lib/typelizer/serializer_plugins/auto.rb
49
+ - lib/typelizer/serializer_plugins/base.rb
50
+ - lib/typelizer/serializer_plugins/oj_serializers.rb
51
+ - lib/typelizer/templates/fingerprint.ts.erb
52
+ - lib/typelizer/templates/index.ts.erb
53
+ - lib/typelizer/templates/interface.ts.erb
54
+ - lib/typelizer/version.rb
55
+ - lib/typelizer/writer.rb
56
+ homepage: https://github.com/skryukov/typelizer
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ bug_tracker_uri: https://github.com/skryukov/typelizer/issues
61
+ changelog_uri: https://github.com/skryukov/typelizer/blob/main/CHANGELOG.md
62
+ documentation_uri: https://github.com/skryukov/typelizer/blob/main/README.md
63
+ homepage_uri: https://github.com/skryukov/typelizer
64
+ source_code_uri: https://github.com/skryukov/typelizer
65
+ rubygems_mfa_required: 'true'
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 2.7.0
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.5.7
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: A TypeScript type generator for Ruby serializers.
85
+ test_files: []