typed_enums 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: feccb1b7155c9c0bce08aca4c171c1368f9a07082962903378e925eede8087e7
4
+ data.tar.gz: ce34f0912b5fa25ea49e3187798b81f776bfd2b755ca4284a466659fd4639378
5
+ SHA512:
6
+ metadata.gz: 48c3acf6c06bd274037335c9989e8021dbd3128dc65d5386699135caeea03b9ef4608fe9abd3faca189af1cc355efc0e74dc754502540a95885ba544dea5f1f3
7
+ data.tar.gz: 7f1e5519b3742de53309e81cdc28f5b70ee25e5e34ce897bcd7d13d51bffd346962d256cb6aa2d2ab4ce22eafb6ee396d03bd5ee95bef7f1e70b6019f7333b44
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial implementation of Active Record enum scanning and TypeScript generation.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 typed_enums contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,328 @@
1
+ # typed_enums
2
+
3
+ Use Rails enums in JavaScript and TypeScript without duplicating option lists.
4
+
5
+ `typed_enums` is a Rails enum generator for JavaScript and TypeScript frontends. It exports Active Record enums to JavaScript constants and TypeScript declaration types, giving any frontend a Rails-like way to consume enum values.
6
+
7
+ Rails routes have `js-routes`. Rails serializers have serializer type generators. Rails enums now have `typed_enums`.
8
+
9
+ ## What It Does
10
+
11
+ `typed_enums` scans loaded Active Record models, reads `defined_enums`, and writes one generated JavaScript module plus a TypeScript declaration file:
12
+
13
+ ```text
14
+ app/javascript/lib/enums.js
15
+ app/javascript/lib/enums.d.ts
16
+ ```
17
+
18
+ It works with React, Vue, Svelte, API-only Rails apps, plain JavaScript, or any TypeScript frontend. The primary runtime output is plain JavaScript. No frontend framework, bundler, or TypeScript runtime package is required by the gem itself.
19
+
20
+ ## What It Does Not Do
21
+
22
+ This gem is not a serializer, OpenAPI generator, route helper generator, form builder, or enum replacement. It does not generate React components, Vue components, Svelte components, migrations, or runtime enum editing tools.
23
+
24
+ ## Installation
25
+
26
+ Add the gem:
27
+
28
+ ```ruby
29
+ # Gemfile
30
+ gem "typed_enums"
31
+ ```
32
+
33
+ Install and generate the initializer:
34
+
35
+ ```bash
36
+ bundle install
37
+ bin/rails generate typed_enums:install
38
+ bin/rails typed_enums:generate
39
+ ```
40
+
41
+ ## Rails Setup
42
+
43
+ Given a Rails model:
44
+
45
+ ```ruby
46
+ class Task < ApplicationRecord
47
+ enum :work_priority, {
48
+ priority_1: 0,
49
+ priority_2: 1,
50
+ priority_3: 2,
51
+ priority_4: 3
52
+ }
53
+ end
54
+ ```
55
+
56
+ The generated JavaScript can be imported from the generated module:
57
+
58
+ ```ts
59
+ import { Task } from "@/lib/enums";
60
+
61
+ Task.workPriorities;
62
+ ```
63
+
64
+ ## Generated JavaScript
65
+
66
+ The gem writes one JavaScript module containing every model enum:
67
+
68
+ ```js
69
+ // AUTO-GENERATED BY typed_enums. DO NOT EDIT.
70
+ // enum-schema-sha256: abc123
71
+
72
+ export const Task = {
73
+ workPriorities: ["priority_1", "priority_2", "priority_3", "priority_4"],
74
+ };
75
+ ```
76
+
77
+ It also writes `enums.d.ts` for TypeScript users:
78
+
79
+ ```ts
80
+ // AUTO-GENERATED BY typed_enums. DO NOT EDIT.
81
+ // enum-schema-sha256: abc123
82
+
83
+ export declare const Task: {
84
+ readonly workPriorities: readonly ["priority_1", "priority_2", "priority_3", "priority_4"];
85
+ };
86
+
87
+ export type TaskWorkPriority = (typeof Task.workPriorities)[number];
88
+ ```
89
+
90
+ ## Naming
91
+
92
+ Rails exposes enum mappings through pluralized methods, such as `Task.work_priorities`. `typed_enums` mirrors that convention in camelCase:
93
+
94
+ | Rails enum attribute | Rails mapping method | TypeScript property | TypeScript type |
95
+ | --- | --- | --- | --- |
96
+ | `ticket_status` | `ticket_statuses` | `ticketStatuses` | `TaskTicketStatus` |
97
+ | `work_priority` | `work_priorities` | `workPriorities` | `TaskWorkPriority` |
98
+ | `fix_priority` | `fix_priorities` | `fixPriorities` | `TaskFixPriority` |
99
+ | `severity` | `severities` | `severities` | `TaskSeverity` |
100
+ | `urgency` | `urgencies` | `urgencies` | `TaskUrgency` |
101
+
102
+ This naming is intentionally not configurable in v1.
103
+
104
+ ## Type Usage
105
+
106
+ ```ts
107
+ import { Task, type TaskWorkPriority } from "@/lib/enums";
108
+
109
+ function setPriority(priority: TaskWorkPriority) {
110
+ return priority;
111
+ }
112
+ ```
113
+
114
+ ## React
115
+
116
+ ```tsx
117
+ import { Task, type TaskWorkPriority } from "@/lib/enums";
118
+
119
+ export function PrioritySelect(props: {
120
+ value: TaskWorkPriority;
121
+ onChange: (value: TaskWorkPriority) => void;
122
+ }) {
123
+ return (
124
+ <select value={props.value} onChange={(event) => props.onChange(event.target.value as TaskWorkPriority)}>
125
+ {Task.workPriorities.map((value) => (
126
+ <option key={value} value={value}>
127
+ {value}
128
+ </option>
129
+ ))}
130
+ </select>
131
+ );
132
+ }
133
+ ```
134
+
135
+ ## Vue
136
+
137
+ ```vue
138
+ <script setup lang="ts">
139
+ import { Task, type TaskWorkPriority } from "@/lib/enums";
140
+
141
+ const model = defineModel<TaskWorkPriority>();
142
+ </script>
143
+
144
+ <template>
145
+ <select v-model="model">
146
+ <option v-for="value in Task.workPriorities" :key="value" :value="value">
147
+ {{ value }}
148
+ </option>
149
+ </select>
150
+ </template>
151
+ ```
152
+
153
+ ## Svelte Or Plain TypeScript
154
+
155
+ ```ts
156
+ import { Task, type TaskWorkPriority } from "@/lib/enums";
157
+
158
+ export const priorityOptions = Task.workPriorities.map((value: TaskWorkPriority) => ({
159
+ value,
160
+ label: value,
161
+ }));
162
+ ```
163
+
164
+ ## Plain JavaScript
165
+
166
+ ```js
167
+ import { Task } from "@/lib/enums";
168
+
169
+ export const priorityOptions = Task.workPriorities.map((value) => ({
170
+ value,
171
+ label: value,
172
+ }));
173
+ ```
174
+
175
+ ## i18n Labels
176
+
177
+ The generated values are intentionally raw enum values. Use your frontend i18n layer to label them:
178
+
179
+ ```ts
180
+ import { Task } from "@/lib/enums";
181
+
182
+ Task.workPriorities.map((value) => ({
183
+ value,
184
+ label: t(`enums.task.workPriorities.${value}`),
185
+ }));
186
+ ```
187
+
188
+ Example keys:
189
+
190
+ ```json
191
+ {
192
+ "enums": {
193
+ "task": {
194
+ "workPriorities": {
195
+ "priority_1": "P1",
196
+ "priority_2": "P2"
197
+ }
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ ## Configuration
204
+
205
+ The installer creates `config/initializers/typed_enums.rb`:
206
+
207
+ ```ruby
208
+ # frozen_string_literal: true
209
+
210
+ TypedEnums.configure do |config|
211
+ config.output_dir = "app/javascript/lib"
212
+ config.root_model_class = "ApplicationRecord"
213
+ config.auto_generate_in_development = true
214
+ config.watch_models_in_development = true
215
+ end
216
+ ```
217
+
218
+ Keep the output directory isolated and import generated code from that directory. The generator never appends to user-owned frontend files.
219
+
220
+ ## Rake Tasks
221
+
222
+ Generate files:
223
+
224
+ ```bash
225
+ bin/rails typed_enums:generate
226
+ ```
227
+
228
+ Check files in CI:
229
+
230
+ ```bash
231
+ bin/rails typed_enums:check
232
+ ```
233
+
234
+ The check task exits non-zero when files are missing, changed, or stale. It does not modify files.
235
+
236
+ Watch model files and regenerate after saves:
237
+
238
+ ```bash
239
+ bin/rails typed_enums:watch
240
+ ```
241
+
242
+ This is useful when you want generated enum files to update immediately after saving a Rails model in VS Code without relying on a running Rails server.
243
+
244
+ ## Development Auto-Regeneration
245
+
246
+ In development, generation runs on Rails boot and through `Rails.application.reloader.to_prepare` when `auto_generate_in_development` is enabled.
247
+
248
+ When `watch_models_in_development` is enabled, `typed_enums` also watches `app/models/**/*.rb` and regenerates enum files after model saves. This covers normal VS Code save workflows while the Rails development process is running. If you are not running Rails, use `bin/rails typed_enums:watch` in a separate terminal.
249
+
250
+ The writer compares content before writing, so unchanged files are not rewritten and file watchers should stay quiet.
251
+
252
+ Production does not auto-write files by default. Run `bin/rails typed_enums:generate` as part of your build or release process when generated enum files are not committed.
253
+
254
+ ## Stale File Cleanup
255
+
256
+ `typed_enums` only overwrites files that it created.
257
+
258
+ If `enums.js` or `enums.d.ts` already exists without this generated marker, generation fails instead of overwriting the file:
259
+
260
+ ```ts
261
+ // AUTO-GENERATED BY typed_enums. DO NOT EDIT.
262
+ ```
263
+
264
+ Move the existing file, delete it, or configure a different `config.output_dir`.
265
+
266
+ The writer removes stale generated `.js`, `.d.ts`, and legacy `.ts` files inside the configured output directory only when they start with:
267
+
268
+ ```ts
269
+ // AUTO-GENERATED BY typed_enums. DO NOT EDIT.
270
+ ```
271
+
272
+ It does not delete user-owned files or files outside the generated directory.
273
+
274
+ ## Namespaced Models
275
+
276
+ Namespaced models are flattened into safe JavaScript identifiers:
277
+
278
+ ```ruby
279
+ Admin::Task
280
+ ```
281
+
282
+ Generates exports inside:
283
+
284
+ ```text
285
+ app/javascript/lib/enums.js
286
+ app/javascript/lib/enums.d.ts
287
+ ```
288
+
289
+ ```ts
290
+ export const AdminTask = {};
291
+ ```
292
+
293
+ ## Empty Output
294
+
295
+ If no models define enums, `typed_enums` still writes `enums.js` and `enums.d.ts` files with the generated header and no exports.
296
+
297
+ ## Troubleshooting
298
+
299
+ If models are missing, confirm they can be eager-loaded in the current environment. The scanner calls `Rails.application.eager_load!` before reading descendants.
300
+
301
+ If the root model class is not `ApplicationRecord`, configure `root_model_class`.
302
+
303
+ If generated files cannot be written, check filesystem permissions for `config.output_dir`.
304
+
305
+ If check mode fails in CI, run `bin/rails typed_enums:generate` locally and commit the generated files, or add generation to your build before JavaScript or TypeScript compilation.
306
+
307
+ ## Security
308
+
309
+ Enum values are written to frontend-visible JavaScript files. Do not put secrets, credentials, private tokens, or private application state in enum values.
310
+
311
+ ## Compatibility
312
+
313
+ The gem targets modern Rails applications and supports Rails 7.1 or newer. It uses Active Record enum APIs and ActiveSupport inflections, and it does not depend on any frontend framework.
314
+
315
+ ## Contributing
316
+
317
+ Run:
318
+
319
+ ```bash
320
+ bundle exec rspec
321
+ bundle exec rubocop
322
+ ```
323
+
324
+ Keep the gem small and focused: Active Record enums in, JavaScript constants and TypeScript declaration types out.
325
+
326
+ ## License
327
+
328
+ MIT.
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module TypedEnums
6
+ class InstallGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ def create_initializer
10
+ template "initializer.rb", "config/initializers/typed_enums.rb"
11
+ end
12
+
13
+ def create_generated_directory
14
+ create_file "app/javascript/lib/.keep", ""
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ TypedEnums.configure do |config|
4
+ # Generated JavaScript and TypeScript declaration files are written here.
5
+ config.output_dir = "app/javascript/lib"
6
+
7
+ # Automatically regenerate enum files in development.
8
+ config.auto_generate_in_development = true
9
+
10
+ # Watch app/models in development and regenerate after saves.
11
+ config.watch_models_in_development = true
12
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ class Configuration
5
+ attr_accessor :output_dir, :root_model_class, :auto_generate_in_development, :watch_models_in_development
6
+
7
+ def initialize
8
+ @output_dir = "app/javascript/lib"
9
+ @root_model_class = "ApplicationRecord"
10
+ @auto_generate_in_development = true
11
+ @watch_models_in_development = true
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ EnumDefinition = Data.define(
5
+ :model_name,
6
+ :model_export_name,
7
+ :attribute_name,
8
+ :rails_mapping_name,
9
+ :typescript_property_name,
10
+ :typescript_type_name,
11
+ :values
12
+ )
13
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ class Error < StandardError; end
5
+ class ConfigurationError < Error; end
6
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module TypedEnums
6
+ class ModelScanner
7
+ def initialize(config: TypedEnums.configuration, name_builder: Naming::NameBuilder.new)
8
+ @config = config
9
+ @name_builder = name_builder
10
+ end
11
+
12
+ def call
13
+ eager_load_application
14
+
15
+ enum_models.flat_map do |model|
16
+ model.defined_enums.sort_by { |attribute_name, _values| attribute_name }.map do |attribute_name, values|
17
+ build_definition(model:, attribute_name:, values:)
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :config, :name_builder
25
+
26
+ def eager_load_application
27
+ Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
28
+ end
29
+
30
+ def enum_models
31
+ root_model_class.descendants
32
+ .reject(&:abstract_class?)
33
+ .select { |model| model.defined_enums.any? }
34
+ .sort_by(&:name)
35
+ end
36
+
37
+ def root_model_class
38
+ config.root_model_class.to_s.constantize
39
+ rescue NameError => e
40
+ raise ConfigurationError, "Could not find root model class #{config.root_model_class.inspect}: #{e.message}"
41
+ end
42
+
43
+ def build_definition(model:, attribute_name:, values:)
44
+ model_export_name = name_builder.model_export_name(model.name)
45
+ rails_mapping_name = name_builder.rails_mapping_name(attribute_name)
46
+
47
+ EnumDefinition.new(
48
+ model_name: model.name,
49
+ model_export_name:,
50
+ attribute_name:,
51
+ rails_mapping_name:,
52
+ typescript_property_name: name_builder.property_name(rails_mapping_name),
53
+ typescript_type_name: name_builder.type_name(model_export_name:, rails_mapping_name:),
54
+ values: values.keys.map(&:to_s)
55
+ )
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+
5
+ module TypedEnums
6
+ module Naming
7
+ class NameBuilder
8
+ def model_export_name(model_name)
9
+ model_name.to_s.split("::").map(&:camelize).join
10
+ end
11
+
12
+ def rails_mapping_name(attribute_name)
13
+ attribute_name.to_s.pluralize
14
+ end
15
+
16
+ def property_name(rails_mapping_name)
17
+ rails_mapping_name.to_s.camelize(:lower)
18
+ end
19
+
20
+ def type_name(model_export_name:, rails_mapping_name:)
21
+ "#{model_export_name}#{rails_mapping_name.to_s.singularize.camelize}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module TypedEnums
7
+ module Output
8
+ class JavaScriptFile
9
+ HEADER = "// AUTO-GENERATED BY typed_enums. DO NOT EDIT."
10
+
11
+ def initialize(model_groups:)
12
+ @model_groups = model_groups
13
+ end
14
+
15
+ def render
16
+ <<~JAVASCRIPT
17
+ #{HEADER}
18
+ // enum-schema-sha256: #{schema_hash}
19
+
20
+ #{model_exports}
21
+ JAVASCRIPT
22
+ end
23
+
24
+ def schema_hash
25
+ Digest::SHA256.hexdigest(JSON.generate(schema))
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :model_groups
31
+
32
+ def model_exports
33
+ model_groups.map do |model_export_name, definitions|
34
+ <<~JAVASCRIPT.chomp
35
+ export const #{model_export_name} = {
36
+ #{property_lines(definitions)}
37
+ };
38
+ JAVASCRIPT
39
+ end.join("\n\n")
40
+ end
41
+
42
+ def property_lines(definitions)
43
+ definitions.map do |definition|
44
+ " #{definition.typescript_property_name}: #{render_array(definition.values)},"
45
+ end.join("\n")
46
+ end
47
+
48
+ def render_array(values)
49
+ "[#{values.map { |value| JSON.generate(value) }.join(', ')}]"
50
+ end
51
+
52
+ def schema
53
+ model_groups.transform_values do |definitions|
54
+ definitions.map do |definition|
55
+ {
56
+ attribute: definition.attribute_name,
57
+ rails_mapping: definition.rails_mapping_name,
58
+ property: definition.typescript_property_name,
59
+ type: definition.typescript_type_name,
60
+ values: definition.values
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TypedEnums
6
+ module Output
7
+ class TypeDeclarationFile
8
+ HEADER = JavaScriptFile::HEADER
9
+
10
+ def initialize(model_groups:, schema_hash:)
11
+ @model_groups = model_groups
12
+ @schema_hash = schema_hash
13
+ end
14
+
15
+ def render
16
+ <<~TYPESCRIPT
17
+ #{HEADER}
18
+ // enum-schema-sha256: #{schema_hash}
19
+
20
+ #{declarations}
21
+ TYPESCRIPT
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :model_groups, :schema_hash
27
+
28
+ def declarations
29
+ model_groups.map do |model_export_name, definitions|
30
+ [constant_declaration(model_export_name, definitions), type_declarations(definitions)].join("\n\n")
31
+ end.join("\n\n")
32
+ end
33
+
34
+ def constant_declaration(model_export_name, definitions)
35
+ <<~TYPESCRIPT.chomp
36
+ export declare const #{model_export_name}: {
37
+ #{property_lines(definitions)}
38
+ };
39
+ TYPESCRIPT
40
+ end
41
+
42
+ def property_lines(definitions)
43
+ definitions.map do |definition|
44
+ " readonly #{definition.typescript_property_name}: readonly #{render_tuple(definition.values)};"
45
+ end.join("\n")
46
+ end
47
+
48
+ def type_declarations(definitions)
49
+ definitions.map do |definition|
50
+ "export type #{definition.typescript_type_name} = " \
51
+ "(typeof #{definition.model_export_name}.#{definition.typescript_property_name})[number];"
52
+ end.join("\n")
53
+ end
54
+
55
+ def render_tuple(values)
56
+ "[#{values.map { |value| JSON.generate(value) }.join(', ')}]"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ module Output
5
+ class Writer
6
+ Result = Data.define(:changed, :missing, :extra, :unchanged, :conflicts) do
7
+ def stale?
8
+ changed.any? || missing.any? || extra.any? || conflicts.any?
9
+ end
10
+
11
+ def conflict?
12
+ conflicts.any?
13
+ end
14
+
15
+ def summary
16
+ {
17
+ "conflicts" => conflicts,
18
+ "missing" => missing,
19
+ "changed" => changed,
20
+ "extra" => extra
21
+ }.filter_map do |label, files|
22
+ "#{label}: #{files.join(', ')}" if files.any?
23
+ end.join("\n")
24
+ end
25
+
26
+ def applied_summary
27
+ {
28
+ "created" => missing,
29
+ "updated" => changed,
30
+ "removed" => extra
31
+ }.filter_map do |label, files|
32
+ "#{label}: #{files.join(', ')}" if files.any?
33
+ end.join("\n")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module TypedEnums
7
+ module Output
8
+ class Writer
9
+ GENERATED_MARKER = JavaScriptFile::HEADER
10
+
11
+ def initialize(output_dir:, expected_files:, logger: default_logger, check: false)
12
+ @output_dir = Pathname(output_dir)
13
+ @expected_files = expected_files
14
+ @logger = logger
15
+ @check = check
16
+ end
17
+
18
+ def call
19
+ ensure_output_dir unless check
20
+
21
+ result = Result.new(changed: [], missing: [], extra: [], unchanged: [], conflicts: [])
22
+ reconcile_expected_files(result)
23
+ reconcile_stale_files(result)
24
+ result
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :output_dir, :expected_files, :logger, :check
30
+
31
+ def reconcile_expected_files(result)
32
+ expected_files.sort.each do |relative_path, content|
33
+ path = output_dir.join(relative_path)
34
+ reconcile_expected_file(result:, relative_path:, path:, content:)
35
+ end
36
+ end
37
+
38
+ def reconcile_expected_file(result:, relative_path:, path:, content:)
39
+ if !path.exist?
40
+ mark_missing(result:, relative_path:, path:, content:)
41
+ elsif path.read == content
42
+ result.unchanged << relative_path
43
+ elsif !generated_file?(path)
44
+ result.conflicts << relative_path
45
+ else
46
+ mark_changed(result:, relative_path:, path:, content:)
47
+ end
48
+ end
49
+
50
+ def mark_missing(result:, relative_path:, path:, content:)
51
+ result.missing << relative_path
52
+ write_file(path, content) unless check
53
+ end
54
+
55
+ def mark_changed(result:, relative_path:, path:, content:)
56
+ result.changed << relative_path
57
+ write_file(path, content) unless check
58
+ end
59
+
60
+ def reconcile_stale_files(result)
61
+ generated_files.each do |path|
62
+ relative_path = path.relative_path_from(output_dir).to_s
63
+ next if expected_files.key?(relative_path)
64
+
65
+ result.extra << relative_path
66
+ remove_file(path) unless check
67
+ end
68
+ end
69
+
70
+ def ensure_output_dir
71
+ FileUtils.mkdir_p(output_dir)
72
+ rescue SystemCallError => e
73
+ raise Error, "Could not create typed_enums output directory #{output_dir}: #{e.message}"
74
+ end
75
+
76
+ def write_file(path, content)
77
+ FileUtils.mkdir_p(path.dirname)
78
+ path.write(content)
79
+ logger&.info("typed_enums: wrote #{path}")
80
+ rescue SystemCallError => e
81
+ raise Error, "Could not write generated file #{path}: #{e.message}"
82
+ end
83
+
84
+ def remove_file(path)
85
+ path.delete
86
+ logger&.info("typed_enums: removed stale #{path}")
87
+ rescue SystemCallError => e
88
+ raise Error, "Could not remove stale generated file #{path}: #{e.message}"
89
+ end
90
+
91
+ def generated_files
92
+ return [] unless output_dir.exist?
93
+
94
+ output_dir.glob("**/*").select do |path|
95
+ generated_file?(path)
96
+ end.sort
97
+ end
98
+
99
+ def generated_file?(path)
100
+ path.file? && generated_extension?(path) && path.read.start_with?(GENERATED_MARKER)
101
+ end
102
+
103
+ def generated_extension?(path)
104
+ [".js", ".ts"].include?(path.extname) || path.to_s.end_with?(".d.ts")
105
+ end
106
+
107
+ def default_logger
108
+ defined?(Rails) ? Rails.logger : nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module TypedEnums
6
+ class Railtie < Rails::Railtie
7
+ rake_tasks do
8
+ load File.expand_path("tasks.rb", __dir__)
9
+ end
10
+
11
+ config.after_initialize do
12
+ TypedEnums::Railtie.install_development_hooks
13
+ end
14
+
15
+ def self.install_development_hooks
16
+ return unless Rails.env.development?
17
+
18
+ if TypedEnums.configuration.auto_generate_in_development
19
+ Rails.application.reloader.to_prepare do
20
+ TypedEnums.generate
21
+ end
22
+ end
23
+
24
+ return unless TypedEnums.configuration.watch_models_in_development
25
+
26
+ TypedEnums.watch
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ class Registry
5
+ def initialize(scanner: ModelScanner.new)
6
+ @scanner = scanner
7
+ end
8
+
9
+ def expected_files
10
+ javascript_file = Output::JavaScriptFile.new(model_groups:)
11
+
12
+ {
13
+ "enums.js" => javascript_file.render,
14
+ "enums.d.ts" => Output::TypeDeclarationFile.new(
15
+ model_groups:,
16
+ schema_hash: javascript_file.schema_hash
17
+ ).render
18
+ }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :scanner
24
+
25
+ def model_groups
26
+ @model_groups ||= scanner.call.group_by(&:model_export_name).sort.to_h
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+
5
+ namespace :typed_enums do
6
+ desc "Generate JavaScript enum module and TypeScript declarations from Active Record enums"
7
+ task generate: :environment do
8
+ result = TypedEnums.generate
9
+ if result.conflict?
10
+ warn "typed_enums: refusing to overwrite existing non-generated files"
11
+ warn result.summary
12
+ abort "Move the files, delete them, or change config.output_dir."
13
+ end
14
+
15
+ if result.stale?
16
+ puts "typed_enums: generated files updated"
17
+ puts result.applied_summary
18
+ else
19
+ puts "typed_enums: generated files are current"
20
+ end
21
+ end
22
+
23
+ desc "Check whether generated enum files are current"
24
+ task check: :environment do
25
+ result = TypedEnums.check
26
+
27
+ if result.conflict?
28
+ warn "typed_enums: existing non-generated files block enum generation"
29
+ warn result.summary
30
+ abort "Move the files, delete them, or change config.output_dir."
31
+ end
32
+
33
+ if result.stale?
34
+ warn "typed_enums: generated files are stale"
35
+ warn result.summary
36
+ abort "Run bin/rails typed_enums:generate"
37
+ end
38
+
39
+ puts "typed_enums: generated files are current"
40
+ end
41
+
42
+ desc "Watch Rails model files and regenerate enum files after saves"
43
+ task watch: :environment do
44
+ puts "typed_enums: watching app/models for enum changes"
45
+ TypedEnums::Watcher.run_foreground(logger: Rails.logger)
46
+ end
47
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedEnums
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module TypedEnums
6
+ class Watcher
7
+ MODEL_FILE_PATTERN = /\.rb\z/
8
+ DEFAULT_LATENCY = 0.25
9
+
10
+ class << self
11
+ def start(**options)
12
+ mutex.synchronize do
13
+ return instance if instance&.started?
14
+
15
+ @instance = new(**options)
16
+ @instance.start
17
+ end
18
+ end
19
+
20
+ def stop
21
+ mutex.synchronize do
22
+ instance&.stop
23
+ @instance = nil
24
+ end
25
+ end
26
+
27
+ def started?
28
+ instance&.started? || false
29
+ end
30
+
31
+ def run_foreground(**)
32
+ watcher = start(**)
33
+ sleep
34
+ rescue Interrupt
35
+ nil
36
+ ensure
37
+ watcher&.stop
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :instance
43
+
44
+ def mutex
45
+ @mutex ||= Mutex.new
46
+ end
47
+ end
48
+
49
+ def initialize(root: Rails.root, logger: Rails.logger, listener_factory: nil, generator: TypedEnums)
50
+ @root = Pathname(root)
51
+ @logger = logger
52
+ @listener_factory = listener_factory
53
+ @generator = generator
54
+ @started = false
55
+ end
56
+
57
+ def start
58
+ return self if started?
59
+
60
+ unless watch_path.directory?
61
+ logger&.warn("typed_enums: model watch path does not exist: #{watch_path}")
62
+ return self
63
+ end
64
+
65
+ @listener = build_listener
66
+ listener.start
67
+ @started = true
68
+ logger&.info("typed_enums: watching #{watch_path}")
69
+ self
70
+ end
71
+
72
+ def stop
73
+ listener&.stop
74
+ @started = false
75
+ self
76
+ end
77
+
78
+ def started?
79
+ @started
80
+ end
81
+
82
+ private
83
+
84
+ attr_reader :root, :logger, :listener, :listener_factory, :generator
85
+
86
+ def watch_path
87
+ root.join("app/models")
88
+ end
89
+
90
+ def build_listener
91
+ factory = listener_factory || default_listener_factory
92
+ factory.to(watch_path.to_s, only: MODEL_FILE_PATTERN, latency: DEFAULT_LATENCY) do |modified, added, removed|
93
+ regenerate(modified:, added:, removed:)
94
+ end
95
+ end
96
+
97
+ def default_listener_factory
98
+ require "listen"
99
+ Listen
100
+ end
101
+
102
+ def regenerate(modified:, added:, removed:)
103
+ changed_files = modified + added + removed
104
+ return if changed_files.empty?
105
+
106
+ logger&.info("typed_enums: model file changed, regenerating enum files")
107
+ reload_application
108
+ generate
109
+ rescue StandardError => e
110
+ logger&.error("typed_enums: watch regeneration failed: #{e.class}: #{e.message}")
111
+ end
112
+
113
+ def reload_application
114
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
115
+
116
+ reloader = Rails.application.reloader
117
+ reloader.reload! if reloader.respond_to?(:reload!)
118
+ end
119
+
120
+ def generate
121
+ reloader = defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.reloader
122
+ return generator.generate(logger:) unless reloader
123
+
124
+ if reloader.respond_to?(:wrap)
125
+ reloader.wrap { generator.generate(logger:) }
126
+ else
127
+ generator.generate(logger:)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "typed_enums/version"
4
+ require "pathname"
5
+ require "typed_enums/configuration"
6
+ require "typed_enums/error"
7
+ require "typed_enums/enum_definition"
8
+ require "typed_enums/naming/name_builder"
9
+ require "typed_enums/model_scanner"
10
+ require "typed_enums/output/javascript_file"
11
+ require "typed_enums/output/type_declaration_file"
12
+ require "typed_enums/registry"
13
+ require "typed_enums/output/writer/result"
14
+ require "typed_enums/output/writer"
15
+ require "typed_enums/watcher"
16
+
17
+ module TypedEnums
18
+ class << self
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield configuration
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+
31
+ def generate(logger: nil)
32
+ write(check: false, logger:)
33
+ end
34
+
35
+ def check(logger: nil)
36
+ write(check: true, logger:)
37
+ end
38
+
39
+ def watch(logger: nil)
40
+ Watcher.start(logger: logger || default_logger)
41
+ end
42
+
43
+ def stop_watcher
44
+ Watcher.stop
45
+ end
46
+
47
+ private
48
+
49
+ def write(check:, logger:)
50
+ Output::Writer.new(
51
+ output_dir: output_path,
52
+ expected_files: Registry.new.expected_files,
53
+ logger: logger || default_logger,
54
+ check:
55
+ ).call
56
+ end
57
+
58
+ def output_path
59
+ configured_path = Pathname(configuration.output_dir)
60
+ configured_path.absolute? ? configured_path : Rails.root.join(configured_path)
61
+ end
62
+
63
+ def default_logger
64
+ defined?(Rails) ? Rails.logger : nil
65
+ end
66
+ end
67
+ end
68
+
69
+ require "typed_enums/railtie" if defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,161 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: typed_enums
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ackermann
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: listen
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '3.5'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '4.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '3.5'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '4.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rails
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: rake
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - "~>"
57
+ - !ruby/object:Gem::Version
58
+ version: '13.0'
59
+ type: :development
60
+ prerelease: false
61
+ version_requirements: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - "~>"
64
+ - !ruby/object:Gem::Version
65
+ version: '13.0'
66
+ - !ruby/object:Gem::Dependency
67
+ name: rspec
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.13'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '3.13'
80
+ - !ruby/object:Gem::Dependency
81
+ name: rubocop
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '1.60'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '1.60'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rubocop-rails
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '2.25'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '2.25'
108
+ description: typed_enums generates a framework-agnostic JavaScript enum module from
109
+ Rails model enums.
110
+ email:
111
+ - ghostkominfinity@gmail.com
112
+ executables: []
113
+ extensions: []
114
+ extra_rdoc_files: []
115
+ files:
116
+ - CHANGELOG.md
117
+ - LICENSE.txt
118
+ - README.md
119
+ - lib/generators/typed_enums/install_generator.rb
120
+ - lib/generators/typed_enums/templates/initializer.rb
121
+ - lib/typed_enums.rb
122
+ - lib/typed_enums/configuration.rb
123
+ - lib/typed_enums/enum_definition.rb
124
+ - lib/typed_enums/error.rb
125
+ - lib/typed_enums/model_scanner.rb
126
+ - lib/typed_enums/naming/name_builder.rb
127
+ - lib/typed_enums/output/javascript_file.rb
128
+ - lib/typed_enums/output/type_declaration_file.rb
129
+ - lib/typed_enums/output/writer.rb
130
+ - lib/typed_enums/output/writer/result.rb
131
+ - lib/typed_enums/railtie.rb
132
+ - lib/typed_enums/registry.rb
133
+ - lib/typed_enums/tasks.rb
134
+ - lib/typed_enums/version.rb
135
+ - lib/typed_enums/watcher.rb
136
+ homepage: https://github.com/AckermannTM/typed_enums
137
+ licenses:
138
+ - MIT
139
+ metadata:
140
+ source_code_uri: https://github.com/AckermannTM/typed_enums
141
+ changelog_uri: https://github.com/AckermannTM/typed_enums/blob/main/CHANGELOG.md
142
+ rubygems_mfa_required: 'true'
143
+ rdoc_options: []
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: '3.2'
151
+ required_rubygems_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ requirements: []
157
+ rubygems_version: 3.7.2
158
+ specification_version: 4
159
+ summary: Export Rails Active Record enums to JavaScript constants and TypeScript declaration
160
+ types.
161
+ test_files: []