typelizer 0.6.0 → 0.7.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: 57cc88a8063ebe9f00e0558f3dbd59aca579b5e191bdce38d3f8ca9b0451acee
4
- data.tar.gz: 32cb1941e3ece707061fd7f2b1e6287710a0f9e2e8f8fc4d56b7f39fe444a7f6
3
+ metadata.gz: 819c25a9bfc5dd74c3b7c77045bb5f5ef968f518f8a8d228502badb6936ea50d
4
+ data.tar.gz: c8d8d9affec2d6eb07cf1e58942fe46009fad2782c57689218b8357a85c227f0
5
5
  SHA512:
6
- metadata.gz: a9fb264406d6ea58bc1b7cc7e875bef11e08cf8f758786f0f63e4d110db5b0e39832615167068baea973a5cf5236f76cac537ce1cfd3f21abd3def162a33edd2
7
- data.tar.gz: 74a8a3552d25fff6c5dee5837d42dc6f257d097944874ade9aee247d432ff7d85303ebb9a6e60ae000bb8634c2a47e4be2e52dc5f4b08d49bd06963473a19b43
6
+ metadata.gz: b4b39192ff398bb25c6b537fcb5c73893de40d82f14a09a254e4ef8317a08232819c3285b7e6806938f1c7058f895d0100b03884574158bbaa2e329982518683
7
+ data.tar.gz: 8971c5a2cc78992fd38dc101e7b2fc1eaefe82ad756f828bca4416a4a6ed54d1ff25eace61fd80cd293cca3dc66b6b6c24706a0fa45156fb275eb9bfe4976c46
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning].
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-01-15
11
+
12
+ ### Changed
13
+
14
+ - Use DSL hooks instead of TracePoint for `typelize` method. ([@skryukov])
15
+
16
+ ### Fixed
17
+
18
+ - Apply sorting and quote style configs consistently to all generated files. ([@jonmarkgo], [@skryukov])
19
+ - Fix fingerprint calculations to include all config options. ([@skryukov])
20
+
10
21
  ## [0.6.0] - 2026-01-14
11
22
 
12
23
  ### Added
@@ -347,7 +358,8 @@ and this project adheres to [Semantic Versioning].
347
358
  [@prog-supdex]: https://github.com/prog-supdex
348
359
  [@ventsislaf]: https://github.com/ventsislaf
349
360
 
350
- [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.6.0...HEAD
361
+ [Unreleased]: https://github.com/skryukov/typelizer/compare/v0.7.0...HEAD
362
+ [0.7.0]: https://github.com/skryukov/typelizer/compare/v0.6.0...v0.7.0
351
363
  [0.6.0]: https://github.com/skryukov/typelizer/compare/v0.5.6...v0.6.0
352
364
  [0.5.6]: https://github.com/skryukov/typelizer/compare/v0.5.5...v0.5.6
353
365
  [0.5.5]: https://github.com/skryukov/typelizer/compare/v0.5.4...v0.5.5
@@ -19,6 +19,40 @@ module Typelizer
19
19
 
20
20
  DEFAULT_TYPES_GLOBAL = %w[Array Date Record File FileList].freeze
21
21
 
22
+ # Config keys that affect generated file content and must be included in fingerprints.
23
+ # When adding a new config, add it here if it affects output, or to CONFIGS_NOT_AFFECTING_OUTPUT.
24
+ CONFIGS_AFFECTING_OUTPUT = %i[
25
+ types_import_path
26
+ types_global
27
+ prefer_double_quotes
28
+ comments
29
+ verbatim_module_syntax
30
+ imports_sort_order
31
+ properties_sort_order
32
+ ].freeze
33
+
34
+ # Subset of CONFIGS_AFFECTING_OUTPUT that specifically affect index.ts output.
35
+ CONFIGS_AFFECTING_INDEX_OUTPUT = %i[
36
+ verbatim_module_syntax
37
+ prefer_double_quotes
38
+ imports_sort_order
39
+ ].freeze
40
+
41
+ # Config keys that don't affect file content (runtime behavior, or effects captured via properties).
42
+ CONFIGS_NOT_AFFECTING_OUTPUT = %i[
43
+ serializer_name_mapper
44
+ serializer_model_mapper
45
+ properties_transformer
46
+ model_plugin
47
+ serializer_plugin
48
+ plugin_configs
49
+ type_mapping
50
+ null_strategy
51
+ output_dir
52
+ inheritance_strategy
53
+ associations_strategy
54
+ ].freeze
55
+
22
56
  Config = Struct.new(
23
57
  :serializer_name_mapper,
24
58
  :serializer_model_mapper,
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ module Alba
7
+ include Methods
8
+ extend Builder
9
+
10
+ hook :attribute, :association, :one, :has_one
11
+ hook :nested_attribute, :nested, :meta
12
+ hook :many, :has_many, multi: true
13
+ hook_method_added
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ module AMS
7
+ include Methods
8
+ extend Builder
9
+
10
+ hook :attribute, :has_one, :belongs_to
11
+ hook :has_many, multi: true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ module OjSerializers
7
+ include Methods
8
+ extend Builder
9
+
10
+ hook :attribute, :has_one, :belongs_to, :flat_one
11
+ hook :has_many, multi: true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ module Panko
7
+ include Methods
8
+ extend Builder
9
+
10
+ hook :has_one
11
+ hook :has_many, multi: true
12
+ hook_method_added
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ module DSL
5
+ module Hooks
6
+ def self.install(base)
7
+ # Always install hooks to capture multi associations.
8
+ # The hooks only record data to a Set, so overhead is minimal.
9
+ if defined?(::Alba::Resource) && base.ancestors.include?(::Alba::Resource)
10
+ require_relative "hooks/alba"
11
+ base.singleton_class.prepend(Alba)
12
+ elsif defined?(::ActiveModel::Serializer) && base.ancestors.include?(::ActiveModel::Serializer)
13
+ require_relative "hooks/ams"
14
+ base.singleton_class.prepend(AMS)
15
+ elsif defined?(::Oj::Serializer) && base.ancestors.include?(::Oj::Serializer)
16
+ require_relative "hooks/oj_serializers"
17
+ base.singleton_class.prepend(OjSerializers)
18
+ elsif defined?(::Panko::Serializer) && base.ancestors.include?(::Panko::Serializer)
19
+ require_relative "hooks/panko"
20
+ base.singleton_class.prepend(Panko)
21
+ end
22
+ end
23
+
24
+ # Shared methods available to all hook modules
25
+ module Methods
26
+ private
27
+
28
+ def consume_keyless_type(name)
29
+ return unless keyless_type
30
+
31
+ type, attrs = keyless_type
32
+ typelize(name => [type, attrs])
33
+ self.keyless_type = nil
34
+ end
35
+
36
+ def record_multi(name)
37
+ _own_typelizer_multi_attributes << name.to_sym
38
+ end
39
+ end
40
+
41
+ # DSL for defining hooks with less boilerplate
42
+ module Builder
43
+ def hook(*methods, multi: false)
44
+ methods.each do |method|
45
+ define_method(method) do |name = nil, *args, **kwargs, &block|
46
+ if name
47
+ record_multi(name) if multi
48
+ consume_keyless_type(name)
49
+ end
50
+ super(name, *args, **kwargs, &block)
51
+ end
52
+ end
53
+ end
54
+
55
+ def hook_method_added
56
+ define_method(:method_added) do |method_name|
57
+ consume_keyless_type(method_name)
58
+ super(method_name)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/typelizer/dsl.rb CHANGED
@@ -1,3 +1,6 @@
1
+ require "set"
2
+ require_relative "dsl/hooks"
3
+
1
4
  module Typelizer
2
5
  module DSL
3
6
  # typelize_from Model
@@ -6,11 +9,13 @@ module Typelizer
6
9
  def self.included(base)
7
10
  Typelizer.base_classes << base.to_s if base.name
8
11
  base.extend(ClassMethods)
12
+ Hooks.install(base)
9
13
  end
10
14
 
11
15
  def self.extended(base)
12
16
  Typelizer.base_classes << base.to_s if base.name
13
17
  base.extend(ClassMethods)
18
+ Hooks.install(base)
14
19
  end
15
20
 
16
21
  module ClassMethods
@@ -49,6 +54,21 @@ module Typelizer
49
54
 
50
55
  attr_accessor :keyless_type
51
56
 
57
+ # Returns union of own + ancestors' multi attributes
58
+ def _typelizer_multi_attributes
59
+ result = @_typelizer_multi_attributes || Set.new
60
+ if superclass.respond_to?(:_typelizer_multi_attributes)
61
+ superclass._typelizer_multi_attributes | result
62
+ else
63
+ result
64
+ end
65
+ end
66
+
67
+ # Returns own Set (initializing if needed) for writing
68
+ def _own_typelizer_multi_attributes
69
+ @_typelizer_multi_attributes ||= Set.new
70
+ end
71
+
52
72
  def typelize_meta(**attributes)
53
73
  assign_type_information(:_typelizer_meta_attributes, attributes)
54
74
  end
@@ -9,15 +9,9 @@ module Typelizer
9
9
  def call(force: false)
10
10
  return [] unless Typelizer.enabled?
11
11
 
12
- # plugin scan per run cache
13
- @scan_plugin_cache = {}
14
-
15
- read_serializers
12
+ load_serializers
16
13
  serializers = target_serializers
17
14
 
18
- # For each writer, build a dedicated WriterContext. The context holds that writer's
19
- # configuration and resolves the effective Config for every Interface (per serializer)
20
- # by merging global, writer, and per-serializer (DSL) overrides
21
15
  Typelizer.configuration.writers.each do |writer_name, writer_config|
22
16
  context = WriterContext.new(writer_name: writer_name)
23
17
  interfaces = serializers.map { |klass| context.interface_for(klass) }
@@ -30,6 +24,12 @@ module Typelizer
30
24
 
31
25
  private
32
26
 
27
+ def load_serializers
28
+ Typelizer.dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }.each do |file|
29
+ require file
30
+ end
31
+ end
32
+
33
33
  def target_serializers
34
34
  base_classes = Typelizer.base_classes.filter_map do |base_class|
35
35
  Object.const_get(base_class) if Object.const_defined?(base_class)
@@ -41,51 +41,5 @@ module Typelizer
41
41
  .reject { |serializer| Typelizer.reject_class.call(serializer: serializer) }
42
42
  .sort_by(&:name)
43
43
  end
44
-
45
- def read_serializers(files = nil)
46
- files ||= Typelizer.dirs.flat_map { |dir| Dir["#{dir}/**/*.rb"] }
47
- files.each do |file|
48
- trace = TracePoint.new(:call) do |tp|
49
- next unless typelized_class?(tp.self)
50
-
51
- serializer_plugin = build_scan_plugin_for(tp.self)
52
- next unless serializer_plugin
53
-
54
- if tp.callee_id.in?(serializer_plugin.methods_to_typelize)
55
- type, attrs = tp.self.keyless_type
56
- name = tp.binding.local_variable_get(:name) if tp.binding.local_variable_defined?(:name)
57
- tp.self.typelize(**serializer_plugin.typelize_method_transform(method: tp.callee_id, binding: tp.binding, name: name, type: type, attrs: attrs || {}))
58
- tp.self.keyless_type = nil
59
- end
60
- end
61
-
62
- trace.enable
63
- require file
64
- trace.disable
65
- end
66
- end
67
-
68
- def typelized_class?(klass)
69
- klass.is_a?(Class) && klass.respond_to?(:typelizer_config)
70
- rescue
71
- false
72
- end
73
-
74
- # Builds a minimal plugin instance used only during scan time for TracePoint
75
- def build_scan_plugin_for(serializer_klass)
76
- return @scan_plugin_cache[serializer_klass] if @scan_plugin_cache&.key?(serializer_klass)
77
-
78
- base = Typelizer.configuration.writer_config(:default)
79
- local_configuration = serializer_klass.typelizer_config.to_h.slice(:serializer_plugin, :plugin_configs)
80
- cfg = base.with_overrides(**local_configuration)
81
-
82
- @scan_plugin_cache[serializer_klass] = cfg.serializer_plugin.new(
83
- serializer: serializer_klass,
84
- config: cfg,
85
- context: Typelizer::ScanContext
86
- )
87
- rescue NameError
88
- nil
89
- end
90
44
  end
91
45
  end
@@ -25,7 +25,7 @@ module Typelizer
25
25
 
26
26
  def name
27
27
  if inline?
28
- Renderer.call("inline_type.ts.erb", properties: properties).strip
28
+ Renderer.call("inline_type.ts.erb", properties: properties, sort_order: config.properties_sort_order).strip
29
29
  else
30
30
  config.serializer_name_mapper.call(serializer).tr_s(":", "")
31
31
  end
@@ -141,12 +141,15 @@ module Typelizer
141
141
  end
142
142
 
143
143
  def fingerprint
144
- if trait_interfaces.empty?
145
- "<#{self.class.name} #{name} properties=[#{properties_to_print.map(&:fingerprint).join(", ")}]>"
146
- else
147
- traits_fingerprint = trait_interfaces.map { |t| "#{t.name}=[#{t.properties.map(&:fingerprint).join(", ")}]" }.join(", ")
148
- "<#{self.class.name} #{name} properties=[#{properties_to_print.map(&:fingerprint).join(", ")}] traits=[#{traits_fingerprint}]>"
149
- end
144
+ [
145
+ name,
146
+ properties_to_print.map(&:fingerprint),
147
+ parent_interface&.name,
148
+ root_key,
149
+ meta_fields.map(&:fingerprint),
150
+ trait_interfaces.map { |t| [t.name, t.properties.map(&:fingerprint)] },
151
+ CONFIGS_AFFECTING_OUTPUT.map { |key| config.public_send(key) }
152
+ ].inspect
150
153
  end
151
154
 
152
155
  def quote(str)
@@ -168,18 +171,41 @@ module Typelizer
168
171
  end
169
172
 
170
173
  def infer_types(props, hash_name = :_typelizer_attributes)
174
+ dsl_attrs = serializer.respond_to?(hash_name) ? serializer.public_send(hash_name) : {}
175
+ multi_attrs = serializer.respond_to?(:_typelizer_multi_attributes) ? serializer._typelizer_multi_attributes : Set.new
176
+
171
177
  props.map do |prop|
172
- if serializer.respond_to?(hash_name)
173
- dsl_type = serializer.public_send(hash_name)[prop.column_name.to_sym]
174
- if dsl_type&.any?
175
- next Property.new(prop.to_h.merge(dsl_type)).tap do |property|
176
- property.comment ||= model_plugin.comment_for(property) if config.comments && property.comment != false
177
- property.enum ||= model_plugin.enum_for(property) if property.enum != false
178
- end
179
- end
180
- end
178
+ has_dsl = dsl_attrs[prop.column_name.to_sym]&.any?
179
+
180
+ prop
181
+ .then { |p| apply_dsl_type(p, dsl_attrs) }
182
+ .then { |p| has_dsl ? p : apply_model_inference(p) }
183
+ .then { |p| apply_multi_flag(p, multi_attrs) }
184
+ .then { |p| apply_metadata(p) }
185
+ end
186
+ end
187
+
188
+ def apply_dsl_type(prop, dsl_attrs)
189
+ dsl_type = dsl_attrs[prop.column_name.to_sym]
190
+ return prop unless dsl_type&.any?
191
+
192
+ prop.with(**dsl_type)
193
+ end
194
+
195
+ def apply_model_inference(prop)
196
+ model_plugin.infer_types(prop)
197
+ end
198
+
199
+ def apply_multi_flag(prop, multi_attrs)
200
+ return prop unless multi_attrs.include?(prop.column_name.to_sym)
201
+
202
+ prop.with(multi: true)
203
+ end
181
204
 
182
- model_plugin.infer_types(prop)
205
+ def apply_metadata(prop)
206
+ prop.tap do |p|
207
+ p.comment ||= model_plugin.comment_for(p) if config.comments && p.comment != false
208
+ p.enum ||= model_plugin.enum_for(p) if p.enum != false
183
209
  end
184
210
  end
185
211
 
@@ -5,6 +5,10 @@ module Typelizer
5
5
  :with_traits,
6
6
  keyword_init: true
7
7
  ) do
8
+ def with(**attrs)
9
+ dup.tap { |p| attrs.each { |k, v| p[k] = v } }
10
+ end
11
+
8
12
  def inspect
9
13
  props = to_h.merge(type: type_name).map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
10
14
  "<#{self.class.name} #{props}>"
@@ -16,8 +20,17 @@ module Typelizer
16
20
  fingerprint == other.fingerprint
17
21
  end
18
22
 
23
+ # Default to_s for backward compatibility (no sorting)
19
24
  def to_s
20
- type_str = type_name
25
+ render(sort_order: :none)
26
+ end
27
+
28
+ # Renders the property as a TypeScript property string
29
+ # @param sort_order [Symbol, Proc, nil] Sort order for union types (:none, :alphabetical, or Proc)
30
+ # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
31
+ # @return [String] The property string like "name?: Type1 | Type2"
32
+ def render(sort_order: :none, prefer_double_quotes: false)
33
+ type_str = type_name(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes)
21
34
 
22
35
  # Handle intersection types for traits
23
36
  if with_traits&.any? && type.respond_to?(:name)
@@ -26,30 +39,56 @@ module Typelizer
26
39
  end
27
40
 
28
41
  type_str = "Array<#{type_str}>" if multi
42
+
43
+ # Apply union sorting to the final type string (handles Array<...> unions too)
44
+ type_str = UnionTypeSorter.sort(type_str, sort_order)
45
+
46
+ # Add nullable at the end (null should always be last in sorted output)
29
47
  type_str = "#{type_str} | null" if nullable
30
48
 
31
49
  "#{name}#{"?" if optional}: #{type_str}"
32
50
  end
33
51
 
34
52
  def fingerprint
35
- props = to_h
36
- props[:type] = type_name
37
- props.each_with_object(+"<#{self.class.name}") do |(k, v), fp|
38
- fp << " #{k}=#{v.inspect}" unless v.nil?
39
- end << ">"
53
+ # Use array format for consistent output across Ruby versions
54
+ # (Hash#inspect format changed in Ruby 3.4)
55
+ to_h.merge(type: UnionTypeSorter.sort(type_name(sort_order: :alphabetical), :alphabetical))
56
+ .to_a.inspect
40
57
  end
41
58
 
42
- def enum_definition
59
+ # Generates a TypeScript type definition for named enums
60
+ # @param sort_order [Symbol, Proc, nil] Sort order for enum values (:none, :alphabetical, or Proc)
61
+ # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
62
+ # @return [String, nil] The type definition like "type UserRole = 'admin' | 'user'"
63
+ def enum_definition(sort_order: :none, prefer_double_quotes: false)
43
64
  return unless enum && enum_type_name
44
65
 
45
- "type #{enum_type_name} = #{enum.map { |v| v.to_s.inspect }.join(" | ")}"
66
+ values = enum.map { |v| quote_string(v.to_s, prefer_double_quotes) }
67
+ values = values.sort_by(&:downcase) if sort_order == :alphabetical
68
+ "type #{enum_type_name} = #{values.join(" | ")}"
46
69
  end
47
70
 
48
71
  private
49
72
 
50
- def type_name
73
+ def quote_string(str, prefer_double_quotes)
74
+ prefer_double_quotes ? "\"#{str}\"" : "'#{str}'"
75
+ end
76
+
77
+ # Returns the type name, optionally sorting union members
78
+ # @param sort_order [Symbol, Proc, nil] Sort order for union types
79
+ # @param prefer_double_quotes [Boolean] Whether to use double quotes for string values
80
+ # @return [String] The type name
81
+ def type_name(sort_order: :none, prefer_double_quotes: false)
82
+ # If enum_type_name is set, use it (named enum type)
51
83
  return enum_type_name if enum_type_name
52
84
 
85
+ if enum
86
+ # Sort enum values if alphabetical sorting is requested
87
+ enum_values = enum.map { |v| quote_string(v.to_s, prefer_double_quotes) }
88
+ enum_values = enum_values.sort_by(&:downcase) if sort_order == :alphabetical
89
+ return enum_values.join(" | ")
90
+ end
91
+
53
92
  type.respond_to?(:name) ? type.name : type || "unknown"
54
93
  end
55
94
  end
@@ -36,7 +36,7 @@ module Typelizer
36
36
  # First check for typelize DSL in the trait
37
37
  dsl_type = typelizes[prop.column_name.to_sym]
38
38
  if dsl_type&.any?
39
- next Property.new(prop.to_h.merge(dsl_type)).tap do |property|
39
+ next prop.with(**dsl_type).tap do |property|
40
40
  property.comment ||= model_plugin.comment_for(property) if config.comments && property.comment != false
41
41
  property.enum ||= model_plugin.enum_for(property) if property.enum != false
42
42
  end
@@ -17,29 +17,6 @@ module Typelizer
17
17
  end
18
18
  end
19
19
 
20
- def methods_to_typelize
21
- [
22
- :association, :one, :has_one,
23
- :many, :has_many,
24
- :attributes, :attribute,
25
- :method_added,
26
- :nested_attribute, :nested,
27
- :meta
28
- ]
29
- end
30
-
31
- def typelize_method_transform(method:, name:, binding:, type:, attrs:)
32
- if method == :method_added && binding.local_variable_defined?(:method_name)
33
- name = binding.local_variable_get(:method_name)
34
- end
35
-
36
- if [:many, :has_many].include?(method)
37
- return {name => [type, attrs.merge(multi: true)]}
38
- end
39
-
40
- super
41
- end
42
-
43
20
  def root_key
44
21
  root = serializer.new({}).send(:_key)
45
22
  if !root.nil? && has_transform_key?(serializer) && should_transform_root_key?(serializer)
@@ -3,19 +3,6 @@ require_relative "base"
3
3
  module Typelizer
4
4
  module SerializerPlugins
5
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
6
  def properties
20
7
  serializer._attributes_data.merge(serializer._reflections).flat_map do |key, association|
21
8
  type = association.options[:serializer] ? context.interface_for(association.options[:serializer]) : nil
@@ -15,14 +15,6 @@ module Typelizer
15
15
  nil
16
16
  end
17
17
 
18
- def typelize_method_transform(method:, name:, binding:, type:, attrs:)
19
- {name => [type, attrs]}
20
- end
21
-
22
- def methods_to_typelize
23
- []
24
- end
25
-
26
18
  def properties
27
19
  []
28
20
  end
@@ -3,13 +3,6 @@ require_relative "base"
3
3
  module Typelizer
4
4
  module SerializerPlugins
5
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
6
  def properties
14
7
  transform_keys = serializer.try(:_transform_keys)
15
8
  attributes = serializer._attributes
@@ -3,10 +3,6 @@ require_relative "base"
3
3
  module Typelizer
4
4
  module SerializerPlugins
5
5
  class Panko < Base
6
- def methods_to_typelize
7
- [:has_many, :has_one, :attributes, :method_added]
8
- end
9
-
10
6
  def properties
11
7
  descriptor = serializer.new.instance_variable_get(:@descriptor)
12
8
  attributes = descriptor.attributes
@@ -25,14 +21,6 @@ module Typelizer
25
21
  end
26
22
  end
27
23
 
28
- def typelize_method_transform(method:, name:, binding:, type:, attrs:)
29
- if method == :method_added && binding.local_variable_defined?(:method)
30
- name = binding.local_variable_get(:method)
31
- end
32
-
33
- super
34
- end
35
-
36
24
  private
37
25
 
38
26
  def attribute_property(att)
@@ -1,3 +1,3 @@
1
1
  <%- enums.each do |property| -%>
2
- export <%= property.enum_definition %>;
2
+ export <%= property.enum_definition(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) %>;
3
3
  <%- end -%>
@@ -1,10 +1,12 @@
1
1
  <%- if enums.any? -%>
2
- export type { <%= enums.map(&:enum_type_name).join(", ") %> } from './Enums'
2
+ <%- enums_path = prefer_double_quotes ? '"./Enums"' : "'./Enums'" -%>
3
+ export type { <%= ImportSorter.sort(enums.map(&:enum_type_name), imports_sort_order).join(", ") %> } from <%= enums_path %>
3
4
  <%- end -%>
4
5
  <%- interfaces.each do |interface| -%>
6
+ <%- sorted_traits = ImportSorter.sort(interface.trait_interfaces.map(&:name), interface.config.imports_sort_order) -%>
5
7
  <%- if interface.config.verbatim_module_syntax -%>
6
- export type { <%= interface.name %><%= ", " + interface.trait_interfaces.map(&:name).join(", ") if interface.trait_interfaces.any? %> } from <%= interface.quote('./' + interface.filename) %>
8
+ export type { <%= interface.name %><%= ", " + sorted_traits.join(", ") if sorted_traits.any? %> } from <%= interface.quote('./' + interface.filename) %>
7
9
  <%- else -%>
8
- export type { default as <%= interface.name %><%= ", " + interface.trait_interfaces.map(&:name).join(", ") if interface.trait_interfaces.any? %> } from <%= interface.quote('./' + interface.filename) %>
10
+ export type { default as <%= interface.name %><%= ", " + sorted_traits.join(", ") if sorted_traits.any? %> } from <%= interface.quote('./' + interface.filename) %>
9
11
  <%- end -%>
10
12
  <%- end -%>
@@ -1 +1,5 @@
1
- <%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + interface.overwritten_properties.map { |pr| interface.quote(pr.name) }.join(' | ') + ">" : ""%>
1
+ <%
2
+ omit_props = interface.overwritten_properties.map { |pr| interface.quote(pr.name) }
3
+ omit_props = omit_props.sort_by(&:downcase) if interface.config.properties_sort_order == :alphabetical
4
+ -%>
5
+ <%= interface.overwritten_properties.any? ? "Omit<" : "" %><%= interface.parent_interface.name %><%= "[" + interface.quote(interface.parent_interface.root_key) + "]" if interface.parent_interface.root_key %><%= interface.overwritten_properties.any? ? ", " + omit_props.join(' | ') + ">" : ""%>
@@ -1,5 +1,5 @@
1
1
  {
2
2
  <%- properties.each do |property| -%>
3
- <%= indent(property) %>;
3
+ <%= indent(property.render(sort_order: sort_order || :none)) %>;
4
4
  <%- end -%>
5
5
  }
@@ -9,14 +9,14 @@ render("inheritance.ts.erb", interface: interface).strip if interface.parent_int
9
9
  <%= " & " if interface.parent_interface %>{
10
10
  <% interface.properties_to_print.each do |property| -%>
11
11
  <%= render("comment.ts.erb", interface: interface, property: property) -%>
12
- <%= indent(property) %>;
12
+ <%= indent(property.render(sort_order: interface.config.properties_sort_order, prefer_double_quotes: interface.config.prefer_double_quotes)) %>;
13
13
  <% end -%>
14
14
  }
15
15
  <% end %><% if interface.root_key %>
16
16
  type <%= interface.name %> = {
17
17
  <%= indent(interface.root_key) %>: <%= interface.name %>Data;
18
18
  <% interface.meta_fields&.each do |property| -%>
19
- <%= indent(property) %>;
19
+ <%= indent(property.render(sort_order: interface.config.properties_sort_order, prefer_double_quotes: interface.config.prefer_double_quotes)) %>;
20
20
  <% end -%>
21
21
  }
22
22
  <% end -%>
@@ -24,16 +24,17 @@ type <%= interface.name %> = {
24
24
 
25
25
  type <%= trait.name %> = {
26
26
  <% trait.properties.each do |property| -%>
27
- <%= indent(property) %>;
27
+ <%= indent(property.render(sort_order: interface.config.properties_sort_order, prefer_double_quotes: interface.config.prefer_double_quotes)) %>;
28
28
  <% end -%>
29
29
  }
30
30
  <% end -%>
31
31
 
32
+ <% sorted_trait_names = ImportSorter.sort(interface.trait_interfaces.map(&:name), interface.config.imports_sort_order) -%>
32
33
  <% if interface.config.verbatim_module_syntax -%>
33
- export type { <%= interface.name %><%= ", " + interface.trait_interfaces.map(&:name).join(", ") if interface.trait_interfaces.any? %> };
34
+ export type { <%= interface.name %><%= ", " + sorted_trait_names.join(", ") if sorted_trait_names.any? %> };
34
35
  <% else -%>
35
36
  export default <%= interface.name %>;
36
- <% if interface.trait_interfaces.any? -%>
37
- export type { <%= interface.trait_interfaces.map(&:name).join(", ") %> };
37
+ <% if sorted_trait_names.any? -%>
38
+ export type { <%= sorted_trait_names.join(", ") %> };
38
39
  <% end -%>
39
40
  <% end -%>
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ # Sorts union type members within TypeScript type strings.
5
+ # Handles types like "Type3 | Type1 | Type2" -> "Type1 | Type2 | Type3"
6
+ # Also handles complex nested types like "Array<Type3 | Type1>" -> "Array<Type1 | Type3>"
7
+ module UnionTypeSorter
8
+ # Sorts union type members in a type string
9
+ # @param type_str [String] The type string potentially containing unions
10
+ # @param sort_order [Symbol, Proc, nil] The sort order (:none, :alphabetical, or Proc)
11
+ # @return [String] The type string with sorted union members
12
+ def self.sort(type_str, sort_order)
13
+ return type_str if type_str.nil? || type_str.empty?
14
+
15
+ case sort_order
16
+ when :none, nil
17
+ type_str
18
+ when :alphabetical
19
+ sort_unions_alphabetically(type_str)
20
+ when Proc
21
+ result = sort_order.call(type_str)
22
+ result.is_a?(String) ? result : type_str
23
+ else
24
+ type_str
25
+ end
26
+ rescue => e
27
+ Typelizer.logger.warn("UnionTypeSorter error: #{e.message}, preserving original order")
28
+ type_str
29
+ end
30
+
31
+ # Sorts union members alphabetically while preserving structure
32
+ # @param type_str [String] The type string to sort
33
+ # @return [String] The sorted type string
34
+ def self.sort_unions_alphabetically(type_str)
35
+ # Handle the string by sorting unions at each level
36
+ # We need to be careful with nested generics like Array<A | B | C>
37
+
38
+ result = type_str.dup
39
+
40
+ # First, handle unions inside angle brackets (generics)
41
+ # Match content inside < > and sort unions within
42
+ result = result.gsub(/<([^<>]+)>/) do |match|
43
+ inner = Regexp.last_match(1)
44
+ sorted_inner = sort_simple_union(inner)
45
+ "<#{sorted_inner}>"
46
+ end
47
+
48
+ # Then handle any remaining top-level unions
49
+ # But avoid sorting if the string has unbalanced brackets
50
+ if balanced_brackets?(result)
51
+ result = sort_top_level_union(result)
52
+ end
53
+
54
+ result
55
+ end
56
+
57
+ # Sorts a simple union string (no nested generics)
58
+ # @param union_str [String] String like "Type3 | Type1 | Type2"
59
+ # @return [String] Sorted string like "Type1 | Type2 | Type3"
60
+ def self.sort_simple_union(union_str)
61
+ return union_str unless union_str.include?("|")
62
+
63
+ parts = split_union_members(union_str)
64
+ return union_str if parts.size <= 1
65
+
66
+ # Sort while preserving special cases:
67
+ # - 'null' should typically stay at the end
68
+ # - Keep the relative order of complex nested types
69
+ regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" }
70
+
71
+ sorted_regular = regular_parts.sort_by { |p| p.strip.downcase }
72
+ (sorted_regular + null_parts).join(" | ")
73
+ end
74
+
75
+ # Sorts top-level union (handles cases where unions aren't inside generics)
76
+ # @param type_str [String] The type string
77
+ # @return [String] The sorted type string
78
+ def self.sort_top_level_union(type_str)
79
+ return type_str unless type_str.include?("|")
80
+
81
+ parts = split_union_members(type_str)
82
+ return type_str if parts.size <= 1
83
+
84
+ # Separate null from other types
85
+ regular_parts, null_parts = parts.partition { |p| p.strip.downcase != "null" }
86
+
87
+ sorted_regular = regular_parts.sort_by { |p| p.strip.downcase }
88
+ (sorted_regular + null_parts).join(" | ")
89
+ end
90
+
91
+ # Splits union members while respecting nested brackets
92
+ # @param str [String] The string to split
93
+ # @return [Array<String>] Array of union members
94
+ def self.split_union_members(str)
95
+ members = []
96
+ current = +""
97
+ depth = 0
98
+
99
+ str.each_char do |char|
100
+ case char
101
+ when "<", "("
102
+ depth += 1
103
+ current << char
104
+ when ">", ")"
105
+ depth -= 1
106
+ current << char
107
+ when "|"
108
+ if depth == 0
109
+ members << current.strip unless current.strip.empty?
110
+ current = +""
111
+ else
112
+ current << char
113
+ end
114
+ else
115
+ current << char
116
+ end
117
+ end
118
+
119
+ members << current.strip unless current.strip.empty?
120
+ members
121
+ end
122
+
123
+ # Checks if brackets are balanced in the string
124
+ # @param str [String] The string to check
125
+ # @return [Boolean] True if brackets are balanced
126
+ def self.balanced_brackets?(str)
127
+ angle_depth = 0
128
+ paren_depth = 0
129
+
130
+ str.each_char do |char|
131
+ case char
132
+ when "<"
133
+ angle_depth += 1
134
+ when ">"
135
+ angle_depth -= 1
136
+ return false if angle_depth < 0
137
+ when "("
138
+ paren_depth += 1
139
+ when ")"
140
+ paren_depth -= 1
141
+ return false if paren_depth < 0
142
+ end
143
+ end
144
+
145
+ angle_depth == 0 && paren_depth == 0
146
+ end
147
+ end
148
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Typelizer
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -60,15 +60,21 @@ module Typelizer
60
60
  end
61
61
 
62
62
  def write_enums(enums)
63
- write_file("Enums.ts", enums.map(&:fingerprint).join) do
64
- render_template("enums.ts.erb", enums: enums)
63
+ fingerprint = [enums.map(&:fingerprint), config.properties_sort_order, config.prefer_double_quotes].inspect
64
+ write_file("Enums.ts", fingerprint) do
65
+ render_template("enums.ts.erb", enums: enums, sort_order: config.properties_sort_order, prefer_double_quotes: config.prefer_double_quotes)
65
66
  end
66
67
  end
67
68
 
68
69
  def write_index(interfaces, enums: [])
69
- fingerprint = interfaces.map(&:fingerprint).join + enums.map(&:fingerprint).join
70
+ fingerprint = [
71
+ enums.map(&:enum_type_name),
72
+ interfaces.map { |i|
73
+ [i.name, i.filename, i.trait_interfaces.map(&:name), CONFIGS_AFFECTING_INDEX_OUTPUT.map { |key| i.config.public_send(key) }]
74
+ }
75
+ ].inspect
70
76
  write_file("index.ts", fingerprint) do
71
- render_template("index.ts.erb", interfaces: interfaces, enums: enums)
77
+ render_template("index.ts.erb", interfaces: interfaces, enums: enums, imports_sort_order: config.imports_sort_order, prefer_double_quotes: config.prefer_double_quotes)
72
78
  end
73
79
  end
74
80
 
data/lib/typelizer.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "typelizer/version"
4
+ require_relative "typelizer/union_type_sorter"
4
5
  require_relative "typelizer/property"
5
6
  require_relative "typelizer/model_plugins/auto"
6
7
  require_relative "typelizer/serializer_plugins/auto"
@@ -10,7 +11,6 @@ require_relative "typelizer/configuration"
10
11
  require_relative "typelizer/serializer_config_layer"
11
12
 
12
13
  require_relative "typelizer/contexts/writer_context"
13
- require_relative "typelizer/contexts/scan_context"
14
14
  require_relative "typelizer/property_sorter"
15
15
  require_relative "typelizer/import_sorter"
16
16
  require_relative "typelizer/interface"
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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -50,9 +50,13 @@ files:
50
50
  - lib/typelizer.rb
51
51
  - lib/typelizer/config.rb
52
52
  - lib/typelizer/configuration.rb
53
- - lib/typelizer/contexts/scan_context.rb
54
53
  - lib/typelizer/contexts/writer_context.rb
55
54
  - lib/typelizer/dsl.rb
55
+ - lib/typelizer/dsl/hooks.rb
56
+ - lib/typelizer/dsl/hooks/alba.rb
57
+ - lib/typelizer/dsl/hooks/ams.rb
58
+ - lib/typelizer/dsl/hooks/oj_serializers.rb
59
+ - lib/typelizer/dsl/hooks/panko.rb
56
60
  - lib/typelizer/generator.rb
57
61
  - lib/typelizer/import_sorter.rb
58
62
  - lib/typelizer/interface.rb
@@ -81,6 +85,7 @@ files:
81
85
  - lib/typelizer/templates/inline_type.ts.erb
82
86
  - lib/typelizer/templates/interface.ts.erb
83
87
  - lib/typelizer/type_parser.rb
88
+ - lib/typelizer/union_type_sorter.rb
84
89
  - lib/typelizer/version.rb
85
90
  - lib/typelizer/writer.rb
86
91
  homepage: https://github.com/skryukov/typelizer
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Typelizer
4
- # Builds a minimal plugin used during scan time
5
- class ScanContext
6
- class InvalidOperationError < StandardError; end
7
-
8
- # Interface creation is not available during DSL scanning phase (TracePoint)
9
- def self.interface_for(serializer_class)
10
- class_name = serializer_class&.name || "unknown class"
11
- raise InvalidOperationError,
12
- "Interface creation is not allowed during DSL scan (#{class_name})"
13
- end
14
-
15
- # just in case, if we call ScanContext like an object
16
- def interface_for(serializer_class)
17
- self.class.interface_for(serializer_class)
18
- end
19
- end
20
- end