typelizer 0.11.0 → 0.13.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ RouteConfig = Struct.new(
5
+ :enabled,
6
+ :output_dir,
7
+ :include,
8
+ :exclude,
9
+ :camel_case,
10
+ :format,
11
+ keyword_init: true
12
+ )
13
+
14
+ class RouteConfig
15
+ def self.defaults
16
+ {
17
+ enabled: false,
18
+ output_dir: nil,
19
+ include: nil,
20
+ exclude: nil,
21
+ camel_case: true,
22
+ format: :ts
23
+ }
24
+ end
25
+
26
+ def ts?
27
+ format != :js
28
+ end
29
+
30
+ def js?
31
+ format == :js
32
+ end
33
+
34
+ def file_ext
35
+ js? ? "js" : "ts"
36
+ end
37
+
38
+ def self.build(**overrides)
39
+ new(**defaults.merge(overrides))
40
+ end
41
+
42
+ def output_dir
43
+ self[:output_dir] || begin
44
+ root_path = defined?(Rails) ? Rails.root : Pathname.pwd
45
+ js_root = defined?(ViteRuby) ? ViteRuby.config.source_code_dir : "app/javascript"
46
+ root_path.join(js_root, "routes")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Typelizer
6
+ class RouteGenerator
7
+ FORMAT_SUFFIX = /\(\.:format\)$/
8
+
9
+ def self.call(**args)
10
+ new.call(**args)
11
+ end
12
+
13
+ def call(force: false, skip_check: false)
14
+ return [] if !skip_check && !(Typelizer.enabled? && config.enabled)
15
+
16
+ routes = collect_routes
17
+ return [] if routes.empty?
18
+
19
+ RouteWriter.new(config).call(routes, force: force)
20
+ end
21
+
22
+ private
23
+
24
+ def config
25
+ Typelizer.configuration.routes
26
+ end
27
+
28
+ def collect_routes
29
+ return [] unless defined?(Rails) && Rails.application
30
+
31
+ # Rails 8+ lazily loads routes
32
+ if Rails.application.respond_to?(:reload_routes_unless_loaded)
33
+ Rails.application.reload_routes_unless_loaded
34
+ end
35
+
36
+ name_by_action, name_by_path = build_name_lookups(Rails.application.routes.named_routes)
37
+
38
+ routes = Rails.application.routes.routes.flat_map do |route|
39
+ app = route.app.app
40
+ if app.is_a?(Class) && app < Rails::Engine
41
+ collect_engine_routes(route, app) || []
42
+ else
43
+ build_route_info(route, name_by_action, name_by_path)
44
+ end
45
+ end.compact
46
+
47
+ # Skip PUT where PATCH exists for the same route (Rails adds both for `resources`)
48
+ patch_keys = routes.select { |r| r[:verb] == "patch" }
49
+ .map { |r| [r[:controller], r[:action], r[:path]] }.to_set
50
+ routes.reject! { |r| r[:verb] == "put" && patch_keys.include?([r[:controller], r[:action], r[:path]]) }
51
+
52
+ if config.include
53
+ patterns = Array(config.include)
54
+ routes = routes.select { |r| patterns.any? { |p| r[:path].match?(p) } }
55
+ end
56
+ if config.exclude
57
+ patterns = Array(config.exclude)
58
+ routes = routes.reject { |r| patterns.any? { |p| r[:path].match?(p) } }
59
+ end
60
+
61
+ routes
62
+ end
63
+
64
+ def build_name_lookups(named_routes, path_prefix: "", name_prefix: "")
65
+ name_by_action = {}
66
+ name_by_path = {}
67
+
68
+ named_routes.each do |name, route|
69
+ controller = route.requirements[:controller]
70
+ action = route.requirements[:action]
71
+ next unless controller && action
72
+
73
+ path = path_prefix + strip_format(route.path.spec.to_s)
74
+ prefixed_name = "#{name_prefix}#{name}"
75
+ name_by_action[[controller, action]] = prefixed_name
76
+ name_by_path[[controller, path]] = prefixed_name
77
+ end
78
+
79
+ [name_by_action, name_by_path]
80
+ end
81
+
82
+ def strip_format(path)
83
+ path.sub(FORMAT_SUFFIX, "")
84
+ end
85
+
86
+ def build_route_info(route, name_by_action, name_by_path)
87
+ controller = route.requirements[:controller]
88
+ action = route.requirements[:action]
89
+
90
+ path = strip_format(route.path.spec.to_s)
91
+
92
+ if controller.present? && action.present?
93
+ has_own_name = !!route.name
94
+ name = route.name || name_by_action[[controller, action]]
95
+ name ||= name_by_path[[controller, path]] ? action : nil
96
+ elsif route.name.present?
97
+ has_own_name = true
98
+ name = route.name.to_s
99
+ controller = "_routes"
100
+ action = name
101
+ end
102
+
103
+ return unless name
104
+
105
+ verb = extract_verb(route)
106
+ return unless verb
107
+
108
+ required_parts = route.required_parts.map(&:to_s)
109
+ optional_parts = (route.path.optional_names || []).map(&:to_s) - ["format"]
110
+
111
+ {
112
+ name: name,
113
+ named: has_own_name || !!name_by_action[[controller, action]],
114
+ controller: controller,
115
+ action: action,
116
+ verb: verb,
117
+ path: path,
118
+ required_parts: required_parts,
119
+ optional_parts: optional_parts
120
+ }
121
+ end
122
+
123
+ def collect_engine_routes(mount_route, engine)
124
+ mount_prefix = mount_route.path.spec.to_s
125
+ engine_name = mount_route.name
126
+ return unless engine_name
127
+
128
+ name_by_action, name_by_path = build_name_lookups(
129
+ engine.routes.named_routes,
130
+ path_prefix: mount_prefix,
131
+ name_prefix: "#{engine_name}_"
132
+ )
133
+
134
+ engine.routes.routes.filter_map do |engine_route|
135
+ info = build_route_info(engine_route, name_by_action, name_by_path)
136
+ next unless info
137
+ info[:path] = mount_prefix + info[:path]
138
+ info
139
+ end
140
+ end
141
+
142
+ def extract_verb(route)
143
+ verb = route.verb
144
+ return nil if verb.blank?
145
+
146
+ verb.split("|").first&.strip&.downcase
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Typelizer
6
+ class RouteWriter
7
+ def initialize(config)
8
+ @config = config
9
+ @template_cache = {}
10
+ end
11
+
12
+ def call(routes, force:)
13
+ FileUtils.rm_rf(config.output_dir) if force
14
+
15
+ written_files = []
16
+
17
+ controllers = routes.group_by { |r| r[:controller] }
18
+ named = build_named_routes(routes, controllers)
19
+
20
+ controllers.each do |controller, controller_routes|
21
+ written_files << write_controller(controller, controller_routes)
22
+ end
23
+
24
+ written_files << write_index(controllers, named)
25
+
26
+ written_files << write_runtime
27
+
28
+ cleanup_stale_files(written_files) unless force
29
+
30
+ Typelizer.logger.debug("Generated #{written_files.size} route files in #{config.output_dir}")
31
+
32
+ written_files
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :config, :template_cache
38
+
39
+ def write_controller(controller, routes)
40
+ ctrl_path = controller_filename(controller)
41
+ filename = "#{ctrl_path}.#{config.file_ext}"
42
+
43
+ prepared_routes = routes.map do |route|
44
+ key = route_key(route, routes)
45
+
46
+ required = route[:required_parts].map { |p| camelize_key(p) }
47
+ optional = route[:optional_parts].map { |p| camelize_key(p) }
48
+ single_required = required.size == 1 && optional.empty?
49
+
50
+ route.merge(
51
+ key: key,
52
+ required_params: required,
53
+ optional_params: optional,
54
+ single_required: single_required
55
+ )
56
+ end
57
+
58
+ runtime_import = (ctrl_path.count("/") > 0) ? "#{"../" * ctrl_path.count("/")}runtime" : "./runtime"
59
+ content = render_template("route_controller.erb", ts: config.ts?, routes: prepared_routes, runtime_import: runtime_import)
60
+
61
+ write_file(filename, content) do
62
+ content
63
+ end
64
+ end
65
+
66
+ def write_index(controllers, named)
67
+ entries = controllers.map do |controller, _routes|
68
+ {
69
+ namespace: controller_namespace_name(controller),
70
+ file: controller_filename(controller)
71
+ }
72
+ end.sort_by { |e| e[:namespace] }
73
+
74
+ sorted_named = named.sort_by { |n| n[:export_name] }
75
+ content = render_template("route_index.erb", entries: entries, named_routes: sorted_named)
76
+
77
+ write_file("index.#{config.file_ext}", content) do
78
+ content
79
+ end
80
+ end
81
+
82
+ RUNTIME_TEMPLATES = {
83
+ "ts" => File.read(File.join(__dir__, "templates/route_runtime.ts")),
84
+ "js" => File.read(File.join(__dir__, "templates/route_runtime.js"))
85
+ }.freeze
86
+
87
+ def write_runtime
88
+ content = RUNTIME_TEMPLATES.fetch(config.file_ext)
89
+
90
+ write_file("runtime.#{config.file_ext}", content) do
91
+ content
92
+ end
93
+ end
94
+
95
+ def write_file(filename, fingerprint)
96
+ output_file = File.join(config.output_dir, filename)
97
+ digest = render_template("fingerprint.erb", fingerprint: fingerprint)
98
+
99
+ existing_header = begin
100
+ File.read(output_file, digest.bytesize)
101
+ rescue
102
+ nil
103
+ end
104
+ return output_file if existing_header == digest
105
+
106
+ content = yield
107
+
108
+ FileUtils.mkdir_p(File.dirname(output_file))
109
+
110
+ File.write(output_file, digest + content)
111
+ output_file
112
+ end
113
+
114
+ def cleanup_stale_files(written_files)
115
+ existing_files = Dir[File.join(config.output_dir, "**/*.#{config.file_ext}")]
116
+ stale_files = existing_files - written_files
117
+
118
+ File.delete(*stale_files) unless stale_files.empty?
119
+ end
120
+
121
+ def render_template(template, **context)
122
+ template_cache[template] ||= Renderer.new(template)
123
+ template_cache[template].call(**context)
124
+ end
125
+
126
+ def build_named_routes(routes, controllers)
127
+ controller_namespaces = controllers.keys.map { |c| controller_namespace_name(c) }.to_set
128
+
129
+ routes.filter_map do |route|
130
+ next unless route[:named]
131
+ export_name = camelize_key(route[:name])
132
+ next if controller_namespaces.include?(export_name)
133
+
134
+ ctrl_routes = controllers[route[:controller]]
135
+ key = route_key(route, ctrl_routes)
136
+ controller_var = "_#{controller_namespace_name(route[:controller])}"
137
+
138
+ {
139
+ export_name: export_name,
140
+ key: key,
141
+ controller_file: controller_filename(route[:controller]),
142
+ controller_var: controller_var
143
+ }
144
+ end
145
+ end
146
+
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])
150
+ end
151
+
152
+ def camelize_key(key)
153
+ config.camel_case ? key.camelize(:lower) : key
154
+ end
155
+
156
+ def controller_filename(controller)
157
+ @controller_filenames ||= {}
158
+ @controller_filenames[controller] ||= begin
159
+ parts = controller.split("/")
160
+ parts.map!(&:camelize)
161
+ parts[-1] = "#{parts[-1]}Controller"
162
+ parts.join("/")
163
+ end
164
+ end
165
+
166
+ def controller_namespace_name(controller)
167
+ name = controller.tr("/", "_")
168
+ camelize_key(name)
169
+ end
170
+ end
171
+ end
@@ -29,6 +29,7 @@ module Typelizer
29
29
  @properties ||= begin
30
30
  props, typelizes = plugin.trait_properties(trait_name)
31
31
  props = infer_types(props, typelizes)
32
+ props = transform_properties(props)
32
33
  PropertySorter.sort(props, config.properties_sort_order)
33
34
  end
34
35
  end
@@ -37,7 +38,7 @@ module Typelizer
37
38
 
38
39
  def infer_types(props, typelizes)
39
40
  props.map do |prop|
40
- dsl_type = typelizes[prop.column_name.to_sym] || typelizes[prop.name.to_sym]
41
+ dsl_type = prop.lookup_in(typelizes)
41
42
  prop
42
43
  .then { |p| dsl_type&.any? ? p.with(**dsl_type) : apply_model_inference(p) }
43
44
  .then { |p| apply_metadata(p) }
@@ -47,7 +47,6 @@ module Typelizer
47
47
  trait_block = traits[trait_name]
48
48
  return [], {} unless trait_block
49
49
 
50
- # Create a collector to capture attributes defined in the trait block
51
50
  collector = BlockAttributeCollector.new
52
51
  collector.instance_exec(&trait_block)
53
52
 
@@ -76,16 +75,13 @@ module Typelizer
76
75
  )
77
76
  when BlockAttributeCollector::BlockNestedAttribute
78
77
  prop_name = has_transform_key?(serializer) ? fetch_key(serializer, name) : name
79
- nested_props, nested_typelizes = collect_nested_block(attr.block)
80
78
  Property.new(
81
79
  name: prop_name,
82
- type: nil,
80
+ type: Shape.new(properties: collect_nested_block(attr.block)),
83
81
  optional: false,
84
82
  nullable: false,
85
83
  multi: false,
86
- column_name: name,
87
- nested_properties: nested_props,
88
- nested_typelizes: nested_typelizes
84
+ column_name: name
89
85
  )
90
86
  else
91
87
  build_property(name, attr)
@@ -179,16 +175,13 @@ module Typelizer
179
175
  )
180
176
  when ::Alba::NestedAttribute
181
177
  block = attr.instance_variable_get(:@block)
182
- nested_props, nested_typelizes = collect_nested_block(block)
183
178
  Property.new(
184
179
  name: name,
185
- type: nil,
180
+ type: Shape.new(properties: collect_nested_block(block)),
186
181
  optional: false,
187
182
  nullable: false,
188
183
  multi: false,
189
184
  column_name: column_name,
190
- nested_properties: nested_props,
191
- nested_typelizes: nested_typelizes,
192
185
  **options
193
186
  )
194
187
  when ::Alba::ConditionalAttribute
@@ -217,13 +210,14 @@ module Typelizer
217
210
  def collect_nested_block(block)
218
211
  collector = BlockAttributeCollector.new
219
212
  collector.instance_exec(&block)
213
+ typelizes = collector.collected_typelizes
220
214
 
221
- props = collector.collected_attributes.map do |attr_name, attr|
215
+ collector.collected_attributes.map do |attr_name, attr|
222
216
  attr_name_str = attr_name.is_a?(Symbol) ? attr_name.name : attr_name
223
- build_collected_property(attr_name_str, attr)
217
+ prop = build_collected_property(attr_name_str, attr)
218
+ override = prop.lookup_in(typelizes)
219
+ override&.any? ? prop.with(**override) : prop
224
220
  end
225
-
226
- [props, collector.collected_typelizes]
227
221
  end
228
222
  end
229
223
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typelizer
4
+ class Shape
5
+ attr_reader :properties
6
+
7
+ def initialize(properties:)
8
+ @properties = properties.freeze
9
+ @fingerprint = ["Shape", properties.map(&:fingerprint)].freeze
10
+ @hash = @fingerprint.hash
11
+ freeze
12
+ end
13
+
14
+ def map_properties
15
+ self.class.new(properties: properties.map { |p| yield p })
16
+ end
17
+
18
+ def render(sort_order: :none, prefer_double_quotes: false)
19
+ inner = properties.map { |p|
20
+ (p.render(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) + ";")
21
+ .gsub(/^/, " ")
22
+ }.join("\n")
23
+ "{\n#{inner}\n}"
24
+ end
25
+
26
+ alias_method :to_s, :render
27
+
28
+ attr_reader :fingerprint, :hash
29
+
30
+ def ==(other)
31
+ other.is_a?(Shape) && fingerprint == other.fingerprint
32
+ end
33
+ alias_method :eql?, :==
34
+
35
+ def inspect
36
+ "<Typelizer::Shape properties=#{properties.inspect}>"
37
+ end
38
+ end
39
+ end
@@ -1,3 +1,6 @@
1
1
  <%- enums.each do |property| -%>
2
2
  export <%= property.enum_definition(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) %>;
3
+ <%- if enum_runtime -%>
4
+ export <%= property.enum_runtime_definition(sort_order: sort_order, prefer_double_quotes: prefer_double_quotes) %>;
5
+ <%- end -%>
3
6
  <%- end -%>
@@ -1,6 +1,7 @@
1
1
  <%- if enums.any? -%>
2
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
+ <%- enum_keyword = enum_runtime ? "export" : "export type" -%>
4
+ <%= enum_keyword %> { <%= ImportSorter.sort(enums.map(&:enum_type_name), imports_sort_order).join(", ") %> } from <%= enums_path %>
4
5
  <%- end -%>
5
6
  <%- interfaces.each do |interface| -%>
6
7
  <%- sorted_traits = ImportSorter.sort(interface.trait_interfaces.map(&:name), interface.config.imports_sort_order) -%>
@@ -0,0 +1,36 @@
1
+ <%- if ts -%>
2
+ import type { RouteDefinition, RouteOptions } from '<%= runtime_import %>'
3
+ <%- end -%>
4
+ import { buildUrl } from '<%= runtime_import %>'
5
+
6
+ export default {
7
+ <%- routes.each_with_index do |route, idx| -%>
8
+ <%= "\n" if idx > 0 -%>
9
+ <%- parts = route[:required_params].map { |p| "#{p}#{ts ? ": string | number" : ""}" } -%>
10
+ <%- parts += route[:optional_params].map { |p| "#{p}#{ts ? "?: string | number" : ""}" } -%>
11
+ <%- has_params = parts.any? -%>
12
+ /** <%= route[:verb].upcase %> <%= route[:path] %> */
13
+ <%- if has_params -%>
14
+ <%- if ts -%>
15
+ <%= route[:key] %>: (
16
+ params: { <%= parts.join('; ') %> }<%= " | string | number" if route[:single_required] %>,
17
+ options?: RouteOptions,
18
+ ): RouteDefinition<<%= "'#{route[:verb]}'" %>> => ({
19
+ <%- else -%>
20
+ <%= route[:key] %>: (params, options) => ({
21
+ <%- end -%>
22
+ url: buildUrl('<%= route[:path] %>', params, options),
23
+ method: '<%= route[:verb] %>',
24
+ }),
25
+ <%- else -%>
26
+ <%- if ts -%>
27
+ <%= route[:key] %>: (options?: RouteOptions): RouteDefinition<<%= "'#{route[:verb]}'" %>> => ({
28
+ <%- else -%>
29
+ <%= route[:key] %>: (options) => ({
30
+ <%- end -%>
31
+ url: buildUrl('<%= route[:path] %>', {}, options),
32
+ method: '<%= route[:verb] %>',
33
+ }),
34
+ <%- end -%>
35
+ <%- end -%>
36
+ }
@@ -0,0 +1,14 @@
1
+ <%- entries.each do |entry| -%>
2
+ export { default as <%= entry[:namespace] %> } from './<%= entry[:file] %>'
3
+ <%- end -%>
4
+ <%- if named_routes.any? -%>
5
+ <%- controllers = named_routes.group_by { |r| r[:controller_file] } -%>
6
+
7
+ <%- controllers.each do |file, routes| -%>
8
+ import <%= routes.first[:controller_var] %> from './<%= file %>'
9
+ <%- end -%>
10
+
11
+ <%- named_routes.each do |route| -%>
12
+ export const <%= route[:export_name] %> = <%= route[:controller_var] %>.<%= route[:key] %>
13
+ <%- end -%>
14
+ <%- end -%>
@@ -0,0 +1,97 @@
1
+ let baseUrl = ''
2
+ let urlDefaults = {}
3
+
4
+ export function setBaseUrl(url) {
5
+ baseUrl = url.replace(/\/+$/, '')
6
+ }
7
+
8
+ export function setUrlDefaults(defaults) {
9
+ urlDefaults = defaults
10
+ }
11
+
12
+ export function addUrlDefault(key, value) {
13
+ const current = typeof urlDefaults === 'function' ? urlDefaults : { ...urlDefaults }
14
+ if (typeof current === 'function') {
15
+ const fn = current
16
+ urlDefaults = () => ({ ...fn(), [key]: value })
17
+ } else {
18
+ current[key] = value
19
+ urlDefaults = current
20
+ }
21
+ }
22
+
23
+ export function buildUrl(
24
+ template,
25
+ params,
26
+ options,
27
+ ) {
28
+ const defaults = typeof urlDefaults === 'function' ? urlDefaults() : urlDefaults
29
+ let p
30
+
31
+ if (typeof params === 'string' || typeof params === 'number') {
32
+ const key = template.match(/[:*](\w+)/)?.[1]
33
+ p = key ? { ...defaults, [key]: params } : { ...defaults }
34
+ } else {
35
+ p = { ...defaults, ...params }
36
+ }
37
+
38
+ // Optional segments: fill or remove
39
+ let url = template.replace(/\(\/:?(\w+)\)/g, (_, key) => {
40
+ const val = getParam(p, key)
41
+ if (val != null) return `/${encodeURIComponent(String(val))}`
42
+ return ''
43
+ })
44
+
45
+ // Glob params
46
+ url = url.replace(/\*(\w+)/g, (match, key) => {
47
+ const val = getParam(p, key)
48
+ if (val == null) return match
49
+ return Array.isArray(val) ? encodeURI(val.map(String).join('/')) : encodeURI(String(val))
50
+ })
51
+
52
+ // Required params
53
+ url = url.replace(/:(\w+)/g, (match, key) => {
54
+ const val = getParam(p, key)
55
+ if (val != null) return encodeURIComponent(String(val))
56
+ return match
57
+ })
58
+
59
+ // Query string
60
+ if (options?.query) {
61
+ const qs = buildQuery(options.query)
62
+ if (qs) url += `?${qs}`
63
+ }
64
+
65
+ // Anchor
66
+ if (options?.anchor) url += `#${options.anchor}`
67
+
68
+ return baseUrl + url
69
+ }
70
+
71
+ // Accepts both snake_case and camelCase param keys by looking up
72
+ // the snake_case key from the URL template, then its camelCase equivalent.
73
+ function getParam(params, key) {
74
+ if (key in params) return params[key]
75
+ return params[toCamelCase(key)]
76
+ }
77
+
78
+ function toCamelCase(key) {
79
+ return key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())
80
+ }
81
+
82
+ function buildQuery(params, prefix) {
83
+ const parts = []
84
+ for (const [key, value] of Object.entries(params)) {
85
+ const k = prefix ? `${prefix}[${key}]` : key
86
+ if (value === null || value === undefined) continue
87
+ if (Array.isArray(value)) {
88
+ value.forEach(v => parts.push(`${k}[]=${encodeURIComponent(String(v))}`))
89
+ } else if (typeof value === 'object') {
90
+ const nested = buildQuery(value, k)
91
+ if (nested) parts.push(nested)
92
+ } else {
93
+ parts.push(`${k}=${encodeURIComponent(String(value))}`)
94
+ }
95
+ }
96
+ return parts.join('&')
97
+ }