typelizer 0.13.1 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90a122983cb647f8c395b12c44b59217994cba0d61db2fb71b942d5bbeb21de7
4
- data.tar.gz: 444b8aec0943fec8e53d041013ce5d1d5c0c51215184fe5a515a5cdd53de9714
3
+ metadata.gz: 8810d416e08a0541a844bf7e538fbd66999f1ab5e0b6209d5e7c071b06c58fdf
4
+ data.tar.gz: 725952a78be5d5f3e13d10e876badfc2be72e848af6bfd2a8063b0f34d0b28c8
5
5
  SHA512:
6
- metadata.gz: 1320f55fab8636e726a56494c4add8e746af0e06970a2e6ede1ae0b7e09daa3f7bb4a406533dd666d7bedbbe0aab90a2d70409b07af44e5260e10dc068217a05
7
- data.tar.gz: 1db53aabce4064e857efb3385f370d07253617556c6859c12883354a1ff55066e2115edca9be535d3c8409e74a8e8ee16b6cd807f42f23bcc2eba6bfb2c75330
6
+ metadata.gz: a6e833349308d44de700a56865f948f8fa1a2d352acf23d34ab6d9fe22c548ceeef70d566fb78e700f985334f3eb40cabbe8542900b792b6966b98a7e3c07698
7
+ data.tar.gz: a6570c070f5b20876814b47045862e2aeeba6589bbd0c6220b43d958f9d46f93e08d54fa3aa1fdc81f74bc874d86acd3f5fe5bc9d4fb4ba7d828534a7ae31c0e
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-10-03
11
+
12
+ ### Added
13
+
14
+ - [Blueprinter](https://github.com/procore-oss/blueprinter) support, including views. ([@skryukov])
15
+
16
+ ### Fixed
17
+
18
+ - Associations and nested attributes no longer take the type of a model column with the same name. ([@lukasedw])
19
+ - Generated route files no longer have duplicate keys when a resource is routed more than once, e.g. nested under several parents. ([@visini])
20
+ - OpenAPI: a string literal mixed with other types (`'auto' | number`) now becomes a string enum instead of a broken `$ref`. ([@skryukov])
21
+ - Conditional attributes (`if:`/`unless:`) stay optional under `null_strategy :optional` and `:nullable_and_optional`, even when the column is NOT NULL. ([@skryukov])
22
+ - Generation no longer crashes for a serializer whose class name contains neither `Serializer` nor `Resource`. ([@skryukov])
23
+ - Keys that aren't valid identifiers (e.g. from Alba's `transform_keys :dash`) are now quoted, and quotes in enum values are escaped. Run `rails typelizer:generate:refresh` once to fix existing files. ([@skryukov])
24
+
10
25
  ## [0.13.1] - 2026-05-18
11
26
 
12
27
  ### Added
@@ -506,6 +521,7 @@ and this project adheres to [Semantic Versioning].
506
521
  [@jonmarkgo]: https://github.com/jonmarkgo
507
522
  [@julik]: https://github.com/julik
508
523
  [@kristinemcbride]: https://github.com/kristinemcbride
524
+ [@lukasedw]: https://github.com/lukasedw
509
525
  [@nkriege]: https://github.com/nkriege
510
526
  [@NOX73]: https://github.com/NOX73
511
527
  [@okuramasafumi]: https://github.com/okuramasafumi
@@ -517,8 +533,10 @@ and this project adheres to [Semantic Versioning].
517
533
  [@rdavid1099]: https://github.com/rdavid1099
518
534
  [@skryukov]: https://github.com/skryukov
519
535
  [@ventsislaf]: https://github.com/ventsislaf
536
+ [@visini]: https://github.com/visini
520
537
 
521
- [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.13.1...HEAD
538
+ [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.14.0...HEAD
539
+ [0.14.0]: https://github.com/skryukov/typelizer/compare/v0.13.1...v0.14.0
522
540
  [0.13.1]: https://github.com/skryukov/typelizer/compare/v0.13.0...v0.13.1
523
541
  [0.13.0]: https://github.com/skryukov/typelizer/compare/v0.12.0...v0.13.0
524
542
  [0.12.0]: https://github.com/skryukov/typelizer/compare/v0.11.0...v0.12.0
data/README.md CHANGED
@@ -8,7 +8,7 @@ Typelizer generates TypeScript types, route helpers, and OpenAPI schemas from yo
8
8
 
9
9
  - Automatic TypeScript interface generation from serializers
10
10
  - Type-safe route helpers from Rails routes
11
- - Supports Alba, ActiveModel::Serializer, Oj::Serializer, Panko::Serializer
11
+ - Supports Alba, ActiveModel::Serializer, Oj::Serializer, Panko::Serializer, Blueprinter
12
12
  - OpenAPI 3.0/3.1 schema generation
13
13
  - Multiple output writers with layered configuration
14
14
  - File watching with automatic regeneration in development
@@ -60,7 +60,7 @@ cd spec/app && RAILS_ENV=test bundle exec rails db:create db:migrate && cd ../..
60
60
  bundle exec rspec
61
61
  ```
62
62
 
63
- The test suite uses a dummy Rails app in `spec/app/` with models, migrations, and serializers for all four supported frameworks (Alba, AMS, OjSerializers, Panko). Linting is done with StandardRB:
63
+ The test suite uses a dummy Rails app in `spec/app/` with models, migrations, and serializers for all five supported frameworks (Alba, AMS, OjSerializers, Panko, Blueprinter). Linting is done with StandardRB:
64
64
 
65
65
  ```bash
66
66
  bundle exec standardrb
@@ -72,9 +72,9 @@ bundle exec standardrb
72
72
 
73
73
  Typelizer is inspired by [types_from_serializers](https://github.com/ElMassimo/types_from_serializers), [js-routes](https://github.com/railsware/js-routes), and [Wayfinder](https://github.com/nicholasvansanten/wayfinder).
74
74
 
75
- <a href="https://evilmartians.com/?utm_source=typelizer&utm_campaign=project_page">
76
- <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg" alt="Built by Evil Martians" width="236" height="54">
77
- </a>
75
+ <br/>
76
+
77
+ <img src="https://cdn.evilmartians.com/badges/logo-no-label.svg" alt="Evil Martians logo" width="22" height="16" /> <b>Typelizer</b> is built by <b><a href="https://evilmartians.com/">Evil Martians</a></b>, an American design and engineering consultancy for <b>developer tools, AI, and cybersecurity startups</b>.
78
78
 
79
79
  ## License
80
80
 
@@ -100,7 +100,7 @@ module Typelizer
100
100
  return name if name.empty?
101
101
 
102
102
  # remove only the end of the line
103
- name.sub(/(Serializer|Resource)\z/, "")
103
+ name.sub(/(Serializer|Resource|Blueprint)\z/, "")
104
104
  end,
105
105
 
106
106
  filename_mapper: nil,
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ module Blueprinter
7
+ include Methods
8
+ extend Builder
9
+
10
+ hook :field, :identifier, :association
11
+ end
12
+ end
13
+ end
14
+ end
@@ -18,6 +18,9 @@ module Typelizer
18
18
  elsif defined?(::Panko::Serializer) && base.ancestors.include?(::Panko::Serializer)
19
19
  require_relative "hooks/panko"
20
20
  base.singleton_class.prepend(Panko)
21
+ elsif defined?(::Blueprinter::Base) && base.ancestors.include?(::Blueprinter::Base)
22
+ require_relative "hooks/blueprinter"
23
+ base.singleton_class.prepend(Blueprinter)
21
24
  end
22
25
  end
23
26
 
@@ -148,7 +148,7 @@ module Typelizer
148
148
  # Collect enum type names from properties
149
149
  enum_imports = all_properties.filter_map(&:enum_type_name)
150
150
 
151
- result = (custom_type_imports + serializer_types + trait_imports + enum_imports + Array(parent_interface&.name)).uniq - [self_type_name, name]
151
+ result = (custom_type_imports + serializer_types + trait_imports + enum_imports + Array(parent_interface&.name)).uniq - [self_type_name, name, *trait_interfaces.map(&:name)]
152
152
  ImportSorter.sort(result, config.imports_sort_order)
153
153
  end
154
154
  end
@@ -190,7 +190,7 @@ module Typelizer
190
190
  end
191
191
 
192
192
  def self_type_name
193
- serializer.name.match(/(\w+::)?(\w+)(Serializer|Resource)/)[2]
193
+ serializer.name[/(\w+::)?(\w+)(Serializer|Resource|Blueprint)/, 2]
194
194
  end
195
195
 
196
196
  def extract_typescript_types(type)
@@ -10,6 +10,7 @@ module Typelizer
10
10
 
11
11
  def infer_types(prop)
12
12
  infer_types_for_association(prop) ||
13
+ serializer_resolved_type?(prop) ||
13
14
  infer_types_for_column(prop) ||
14
15
  infer_types_for_association_ids(prop) ||
15
16
  infer_types_for_delegate(prop) ||
@@ -26,6 +27,7 @@ module Typelizer
26
27
  end
27
28
 
28
29
  def enum_for(prop)
30
+ return if serializer_resolved_type?(prop)
29
31
  return unless model_class&.defined_enums&.key?(prop.column_name.to_s)
30
32
 
31
33
  prop.enum = model_class.defined_enums[prop.column_name.to_s].keys
@@ -35,6 +37,12 @@ module Typelizer
35
37
 
36
38
  private
37
39
 
40
+ # An association or nested attribute is already typed by the serializer;
41
+ # a same-named column describes a different value.
42
+ def serializer_resolved_type?(prop)
43
+ prop.type.is_a?(Interface) || prop.type.is_a?(Shape)
44
+ end
45
+
38
46
  def columns_hash
39
47
  return nil unless model_class
40
48
  return nil if model_class.abstract_class?
@@ -55,6 +63,7 @@ module Typelizer
55
63
  association = model_class&.reflect_on_association(prop.column_name.to_sym)
56
64
  return nil unless association
57
65
 
66
+ prop.multi = association.collection? if prop.multi.nil?
58
67
  case association.macro
59
68
  when :belongs_to
60
69
  foreign_key = association.foreign_key
@@ -89,10 +98,10 @@ module Typelizer
89
98
  when :nullable
90
99
  prop.nullable = column.null
91
100
  when :optional
92
- prop.optional = column.null
101
+ prop.optional ||= column.null
93
102
  when :nullable_and_optional
94
103
  prop.nullable = column.null
95
- prop.optional = column.null
104
+ prop.optional ||= column.null
96
105
  else
97
106
  raise "Unknown null strategy: #{config.null_strategy}"
98
107
  end
@@ -101,6 +101,8 @@ module Typelizer
101
101
  def union_member_schema(type)
102
102
  if type.respond_to?(:properties)
103
103
  {"$ref" => "#/components/schemas/#{type.name}"}
104
+ elsif string_literal?(type)
105
+ {type: :string, enum: [unquote_string_literal(type)]}
104
106
  else
105
107
  sym = type.to_sym
106
108
  if OPENAPI_TYPES.include?(sym)
@@ -53,7 +53,7 @@ module Typelizer
53
53
  # Add nullable at the end (null should always be last in sorted output)
54
54
  type_str = "#{type_str} | null" if nullable
55
55
 
56
- "#{name}#{"?" if optional}: #{type_str}"
56
+ "#{js_key(name.to_s, prefer_double_quotes)}#{"?" if optional}: #{type_str}"
57
57
  end
58
58
 
59
59
  def fingerprint
@@ -95,7 +95,9 @@ module Typelizer
95
95
  end
96
96
 
97
97
  def quote_string(str, prefer_double_quotes)
98
- prefer_double_quotes ? "\"#{str}\"" : "'#{str}'"
98
+ quote = prefer_double_quotes ? '"' : "'"
99
+ escaped = str.to_s.gsub(/[\\#{quote}]/) { |char| "\\#{char}" }
100
+ "#{quote}#{escaped}#{quote}"
99
101
  end
100
102
 
101
103
  def js_key(str, prefer_double_quotes)
@@ -49,6 +49,12 @@ module Typelizer
49
49
  .map { |r| [r[:controller], r[:action], r[:path]] }.to_set
50
50
  routes.reject! { |r| r[:verb] == "put" && patch_keys.include?([r[:controller], r[:action], r[:path]]) }
51
51
 
52
+ # Skip unnamed duplicates of a named route (Rails repeats member routes under each parent for `shallow: true`)
53
+ named_keys = routes.select { |r| r[:named] }
54
+ .map { |r| [r[:controller], r[:verb], r[:path]] }.to_set
55
+ routes.reject! { |r| !r[:named] && named_keys.include?([r[:controller], r[:verb], r[:path]]) }
56
+ routes.uniq!
57
+
52
58
  if config.include
53
59
  patterns = Array(config.include)
54
60
  routes = routes.select { |r| patterns.any? { |p| match_route?(r, p) } }
@@ -70,12 +76,12 @@ module Typelizer
70
76
  end
71
77
 
72
78
  def build_named_paths(named_routes, path_prefix: "")
73
- named_routes.each_with_object(Set.new) do |(_name, route), paths|
79
+ named_routes.each_with_object({}) do |(name, route), paths|
74
80
  controller = route.requirements[:controller]
75
81
  action = route.requirements[:action]
76
82
  next unless controller && action
77
83
 
78
- paths << [controller, path_prefix + strip_format(route.path.spec.to_s)]
84
+ paths[[controller, path_prefix + strip_format(route.path.spec.to_s)]] ||= name.to_s
79
85
  end
80
86
  end
81
87
 
@@ -91,7 +97,8 @@ module Typelizer
91
97
 
92
98
  if controller.present? && action.present?
93
99
  # Match by [controller, path] so unnamed aliases at distinct paths (e.g. ActiveStorage representations) don't inherit a sibling's name
94
- name = route.name || (action if named_paths.include?([controller, path]))
100
+ path_name = named_paths[[controller, path]]
101
+ name = route.name || (action if path_name)
95
102
  elsif route.name.present?
96
103
  name = route.name.to_s
97
104
  controller = "_routes"
@@ -109,6 +116,7 @@ module Typelizer
109
116
  {
110
117
  name: name,
111
118
  named: !!route.name,
119
+ path_name: path_name,
112
120
  controller: controller,
113
121
  action: action,
114
122
  verb: verb,
@@ -145,8 +145,12 @@ module Typelizer
145
145
  end
146
146
 
147
147
  def route_key(route, controller_routes)
148
- collides = controller_routes.count { |r| r[:action] == route[:action] } > 1
149
- camelize_key(collides ? route[:name] : route[:action])
148
+ same_action = controller_routes.select { |r| r[:action] == route[:action] }
149
+ return camelize_key(route[:action]) if same_action.one?
150
+ return camelize_key(route[:name]) if route[:named]
151
+ return camelize_key(route[:action]) if same_action.one? { |r| !r[:named] }
152
+
153
+ camelize_key("#{route[:action]}_#{route[:path_name]}")
150
154
  end
151
155
 
152
156
  def camelize_key(key)
@@ -1,4 +1,5 @@
1
1
  require_relative "base"
2
+ require_relative "trait_interface"
2
3
 
3
4
  module Typelizer
4
5
  module SerializerPlugins
@@ -224,4 +225,3 @@ module Typelizer
224
225
  end
225
226
 
226
227
  require_relative "alba/block_attribute_collector"
227
- require_relative "alba/trait_interface"
@@ -15,6 +15,8 @@ module Typelizer
15
15
  AMS
16
16
  elsif defined?(::Panko::Serializer) && serializer.ancestors.include?(::Panko::Serializer)
17
17
  Panko
18
+ elsif defined?(::Blueprinter::Base) && serializer.ancestors.include?(::Blueprinter::Base)
19
+ Blueprinter
18
20
  else
19
21
  raise "Can't guess serializer plugin for #{serializer}. " \
20
22
  "Please specify it with `config.serializer_plugin`."
@@ -0,0 +1,71 @@
1
+ require_relative "base"
2
+ require_relative "trait_interface"
3
+
4
+ module Typelizer
5
+ module SerializerPlugins
6
+ class Blueprinter < Base
7
+ CONDITIONAL_OPTIONS = %i[if unless exclude_if_nil].freeze
8
+
9
+ def properties
10
+ view_properties(:default)
11
+ end
12
+
13
+ # Each extra view becomes its own full type, since views can also exclude fields
14
+ def trait_interfaces
15
+ @trait_interfaces ||= (serializer.reflections.keys - %i[default identifier]).map do |view|
16
+ TraitInterface.new(serializer: serializer, trait_name: view, context: context, plugin: self, suffix: "View")
17
+ end
18
+ end
19
+
20
+ def trait_properties(view)
21
+ typelizes = serializer.respond_to?(:_typelizer_attributes) ? serializer._typelizer_attributes : {}
22
+ [view_properties(view), typelizes]
23
+ end
24
+
25
+ private
26
+
27
+ def view_properties(view)
28
+ reflection = serializer.reflections.fetch(view)
29
+ reflection.fields.values.map { |field| field_property(field) } +
30
+ reflection.associations.values.map { |association| association_property(association) }
31
+ end
32
+
33
+ def field_property(field)
34
+ Property.new(
35
+ name: field.display_name,
36
+ optional: optional?(field.options),
37
+ nullable: false,
38
+ multi: false,
39
+ column_name: field.name
40
+ )
41
+ end
42
+
43
+ # `multi` stays nil: Blueprinter renders a list or a single object depending
44
+ # on the value, so the model's association decides
45
+ def association_property(association)
46
+ Property.new(
47
+ name: association.display_name,
48
+ type: association_type(association),
49
+ optional: optional?(association.options),
50
+ nullable: false,
51
+ multi: nil,
52
+ column_name: association.name
53
+ )
54
+ end
55
+
56
+ def association_type(association)
57
+ blueprint = association.blueprint
58
+ return unless blueprint.is_a?(Class)
59
+
60
+ interface = context.interface_for(blueprint)
61
+ return interface if association.view == :default
62
+
63
+ interface.trait_interfaces.find { |view| view.trait_name == association.view }&.name
64
+ end
65
+
66
+ def optional?(options)
67
+ CONDITIONAL_OPTIONS.any? { |key| options.key?(key) }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -1,19 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../../type_inference"
3
+ require_relative "../type_inference"
4
4
 
5
5
  module Typelizer
6
6
  module SerializerPlugins
7
- class Alba::TraitInterface
7
+ class TraitInterface
8
8
  include TypeInference
9
9
 
10
10
  attr_reader :serializer, :trait_name, :context, :plugin
11
11
 
12
- def initialize(serializer:, trait_name:, context:, plugin:)
12
+ def initialize(serializer:, trait_name:, context:, plugin:, suffix: "Trait")
13
13
  @serializer = serializer
14
14
  @trait_name = trait_name
15
15
  @context = context
16
16
  @plugin = plugin
17
+ @suffix = suffix
17
18
  end
18
19
 
19
20
  def config
@@ -22,7 +23,7 @@ module Typelizer
22
23
 
23
24
  def name
24
25
  base_name = config.serializer_name_mapper.call(serializer).tr_s(":", "")
25
- "#{base_name}#{trait_name.to_s.camelize}Trait"
26
+ "#{base_name}#{trait_name.to_s.camelize}#{@suffix}"
26
27
  end
27
28
 
28
29
  def properties
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typelizer
4
- VERSION = "0.13.1"
4
+ VERSION = "0.14.0"
5
5
  end
data/lib/typelizer.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "typelizer/serializer_plugins/oj_serializers"
29
29
  require_relative "typelizer/serializer_plugins/alba"
30
30
  require_relative "typelizer/serializer_plugins/ams"
31
31
  require_relative "typelizer/serializer_plugins/panko"
32
+ require_relative "typelizer/serializer_plugins/blueprinter"
32
33
 
33
34
  require_relative "typelizer/model_plugins/active_record"
34
35
  require_relative "typelizer/model_plugins/poro"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typelizer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -56,6 +56,7 @@ files:
56
56
  - lib/typelizer/dsl/hooks.rb
57
57
  - lib/typelizer/dsl/hooks/alba.rb
58
58
  - lib/typelizer/dsl/hooks/ams.rb
59
+ - lib/typelizer/dsl/hooks/blueprinter.rb
59
60
  - lib/typelizer/dsl/hooks/oj_serializers.rb
60
61
  - lib/typelizer/dsl/hooks/panko.rb
61
62
  - lib/typelizer/generator.rb
@@ -77,12 +78,13 @@ files:
77
78
  - lib/typelizer/serializer_config_layer.rb
78
79
  - lib/typelizer/serializer_plugins/alba.rb
79
80
  - lib/typelizer/serializer_plugins/alba/block_attribute_collector.rb
80
- - lib/typelizer/serializer_plugins/alba/trait_interface.rb
81
81
  - lib/typelizer/serializer_plugins/ams.rb
82
82
  - lib/typelizer/serializer_plugins/auto.rb
83
83
  - lib/typelizer/serializer_plugins/base.rb
84
+ - lib/typelizer/serializer_plugins/blueprinter.rb
84
85
  - lib/typelizer/serializer_plugins/oj_serializers.rb
85
86
  - lib/typelizer/serializer_plugins/panko.rb
87
+ - lib/typelizer/serializer_plugins/trait_interface.rb
86
88
  - lib/typelizer/shape.rb
87
89
  - lib/typelizer/templates/comment.ts.erb
88
90
  - lib/typelizer/templates/enums.ts.erb