typelizer 0.1.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: 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: []