chiridion 0.3.4

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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +201 -0
  5. data/lib/chiridion/config.rb +128 -0
  6. data/lib/chiridion/engine/class_linker.rb +204 -0
  7. data/lib/chiridion/engine/document_model.rb +299 -0
  8. data/lib/chiridion/engine/drift_checker.rb +146 -0
  9. data/lib/chiridion/engine/extractor.rb +311 -0
  10. data/lib/chiridion/engine/file_renderer.rb +717 -0
  11. data/lib/chiridion/engine/file_writer.rb +160 -0
  12. data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
  13. data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
  14. data/lib/chiridion/engine/github_linker.rb +87 -0
  15. data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
  16. data/lib/chiridion/engine/post_processor.rb +86 -0
  17. data/lib/chiridion/engine/rbs_loader.rb +150 -0
  18. data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
  19. data/lib/chiridion/engine/renderer.rb +598 -0
  20. data/lib/chiridion/engine/semantic_extractor.rb +740 -0
  21. data/lib/chiridion/engine/semantic_renderer.rb +334 -0
  22. data/lib/chiridion/engine/spec_example_loader.rb +84 -0
  23. data/lib/chiridion/engine/template_renderer.rb +275 -0
  24. data/lib/chiridion/engine/type_merger.rb +126 -0
  25. data/lib/chiridion/engine/writer.rb +134 -0
  26. data/lib/chiridion/engine.rb +359 -0
  27. data/lib/chiridion/semantic_engine.rb +186 -0
  28. data/lib/chiridion/version.rb +5 -0
  29. data/lib/chiridion.rb +106 -0
  30. data/templates/constants.liquid +27 -0
  31. data/templates/document.liquid +48 -0
  32. data/templates/file.liquid +108 -0
  33. data/templates/index.liquid +21 -0
  34. data/templates/method.liquid +43 -0
  35. data/templates/methods.liquid +11 -0
  36. data/templates/type_aliases.liquid +26 -0
  37. data/templates/types.liquid +11 -0
  38. metadata +146 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0e87a90e1e1a9905e4ec8fe90fa2bb3dfbdf5c30c69d22dc40076e71ac282251
4
+ data.tar.gz: d5ca6cfe8f1d60040f0bc47a79a047b436f17cb6cd7ed94981cae3e4b4629396
5
+ SHA512:
6
+ metadata.gz: 2280d3d512e5ac20601e33e63ccac2fed856b1e0e9cbd89ec22ca36b32b1ac944468a44b6518fbb674ea603b4c222ea9d034fb29024626d0c618bfe94b36128d
7
+ data.tar.gz: 67ec810bdd67b201303712dcddb97d5790b7991bf89494bedcf30bfe8d32e94f5ca449445b9526268ee3808b621faa5dda647137fbf3b86212517e02cee9c43a
data/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## [0.3.4] - 2026-05-18
4
+
5
+ ### Fixed
6
+ - Declare `logger` and `base64` as runtime dependencies. Both are Ruby
7
+ default-gem extractions (`base64` @ 3.4, `logger` @ 3.5/4.0) that
8
+ chiridion needs at runtime — `logger` directly (engine), `base64`
9
+ transitively via `liquid` (which does not declare it). Without these,
10
+ `require "chiridion"` raised `LoadError` for any consumer running
11
+ under bundler on Ruby >= 3.4. No behavior change.
12
+
13
+ > Note: this changelog was not maintained for 0.2.x–0.3.3; entries
14
+ > resume here rather than reconstruct unrecorded history.
15
+
16
+ ## [0.1.0] - 2024-12-09
17
+
18
+ ### Added
19
+ - Initial release
20
+ - YARD-based documentation extraction
21
+ - RBS type signature integration
22
+ - Liquid template rendering
23
+ - Obsidian-compatible wikilinks
24
+ - Spec example extraction
25
+ - Drift detection for CI/CD
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Joseph Wecker
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,201 @@
1
+ # Chiridion
2
+
3
+ Agent-oriented documentation generator for Ruby projects.
4
+
5
+ **Chiridion** (from Greek χειρίδιον, "handbook" — the diminutive of χείρ "hand") generates documentation optimized for AI agents and LLMs working with Ruby codebases.
6
+
7
+ ## Why Agent-Oriented Documentation?
8
+
9
+ Traditional documentation is written for human developers reading in browsers. Agent-oriented documentation is optimized for LLMs processing in context windows:
10
+
11
+ - **Structured frontmatter** with navigation metadata for programmatic traversal
12
+ - **Explicit type information** from RBS (not just prose descriptions)
13
+ - **Cross-reference wikilinks** that can be followed programmatically
14
+ - **Compact method signatures** that maximize information density
15
+
16
+ ## Features
17
+
18
+ - **YARD Integration** — Extracts docstrings, @param, @return, @example tags
19
+ - **RBS Authority** — RBS type signatures (inline or in sig/) are authoritative over YARD
20
+ - **Inline RBS Preferred** — Supports `@rbs` inline annotations via rbs-inline
21
+ - **Spec Examples** — Extracts usage examples from RSpec files
22
+ - **Wikilinks** — Obsidian-compatible `[[Class::Name]]` cross-references
23
+ - **Drift Detection** — CI mode to ensure docs stay in sync with code
24
+
25
+ ## Installation
26
+
27
+ Add to your Gemfile:
28
+
29
+ ```ruby
30
+ gem "chiridion", path: "~/src/chiridion" # Local development
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Configuration
36
+
37
+ ```ruby
38
+ Chiridion.configure do |config|
39
+ config.source_path = "lib/myproject"
40
+ config.output = "docs/sys"
41
+ config.namespace_filter = "MyProject::"
42
+ config.github_repo = "user/repo"
43
+ config.include_specs = true
44
+ end
45
+ ```
46
+
47
+ ### Generate Documentation
48
+
49
+ ```ruby
50
+ Chiridion.refresh
51
+ ```
52
+
53
+ Or with explicit paths:
54
+
55
+ ```ruby
56
+ engine = Chiridion::Engine.new(
57
+ paths: ['lib/myproject'],
58
+ output: 'docs/sys',
59
+ namespace_filter: 'MyProject::'
60
+ )
61
+ engine.refresh
62
+ ```
63
+
64
+ ### Check for Drift (CI)
65
+
66
+ ```ruby
67
+ Chiridion.check # Exits with code 1 if drift detected
68
+ ```
69
+
70
+ ## Inline RBS (Preferred)
71
+
72
+ Chiridion prioritizes inline RBS annotations over separate sig/ files:
73
+
74
+ ```ruby
75
+ class Calculator
76
+ # @rbs a: Integer -- first operand
77
+ # @rbs b: Integer -- second operand
78
+ # @rbs return: Integer
79
+ def add(a, b)
80
+ a + b
81
+ end
82
+ end
83
+ ```
84
+
85
+ This keeps types co-located with code and is the recommended approach. Separate `sig/*.rbs` files are supported as a fallback.
86
+
87
+ ## Output Format
88
+
89
+ Generated markdown includes:
90
+
91
+ ```yaml
92
+ ---
93
+ generated: 2024-12-09 10:30 UTC
94
+ source: lib/myproject/calculator.rb:10-25
95
+ source_url: https://github.com/user/repo/blob/main/lib/myproject/calculator.rb#L10
96
+ type: class
97
+ parent: Object
98
+ ---
99
+
100
+ # MyProject::Calculator
101
+
102
+ Calculator for basic arithmetic operations.
103
+
104
+ ## Methods
105
+
106
+ ### `#add`
107
+
108
+ ```rbs
109
+ (Integer a, Integer b) -> Integer
110
+ ```
111
+
112
+ Adds two integers.
113
+
114
+ **Parameters:**
115
+ - `a` (`Integer`) first operand
116
+ - `b` (`Integer`) second operand
117
+
118
+ **Returns:** `Integer`
119
+ ```
120
+
121
+ ## Integration with Projects
122
+
123
+ ### Archema
124
+
125
+ ```ruby
126
+ # Gemfile
127
+ gem "chiridion", path: "~/src/chiridion"
128
+
129
+ # Configure in tasks/docs.rb
130
+ Chiridion.configure do |config|
131
+ config.namespace_filter = "Archema::"
132
+ config.output = "docs/sys"
133
+ end
134
+ ```
135
+
136
+ ### devex Integration
137
+
138
+ Create a `tools/docs.rb`:
139
+
140
+ ```ruby
141
+ # frozen_string_literal: true
142
+
143
+ desc "Documentation generation tasks"
144
+
145
+ tool "refresh" do
146
+ desc "Regenerate API documentation"
147
+
148
+ def run
149
+ require_relative "../lib/myproject"
150
+
151
+ Chiridion.configure do |c|
152
+ c.source_path = "lib/myproject"
153
+ c.output = "docs/sys"
154
+ c.namespace_filter = "MyProject::"
155
+ c.verbose = verbose? # Uses global -v flag
156
+ end
157
+ Chiridion.refresh
158
+ end
159
+ end
160
+
161
+ tool "check" do
162
+ desc "Check for documentation drift (CI mode)"
163
+
164
+ def run
165
+ Chiridion.check
166
+ end
167
+ end
168
+ ```
169
+
170
+ Then run with `dx docs refresh` or `dx docs check`.
171
+
172
+ ## Development
173
+
174
+ Chiridion uses itself to generate its own API documentation (dogfooding). The generated docs live in `docs/sys/`.
175
+
176
+ ```bash
177
+ # Run tests
178
+ dx test
179
+
180
+ # Lint
181
+ dx lint
182
+
183
+ # Regenerate Chiridion's own documentation
184
+ dx docs refresh
185
+
186
+ # Verbose output
187
+ dx -v docs refresh
188
+
189
+ # Check for drift (CI mode - exits 1 if docs are out of sync)
190
+ dx docs check
191
+ ```
192
+
193
+ This serves as both a live integration test and a reference for the output format.
194
+
195
+ ## Name Origin
196
+
197
+ "Chiridion" is the Greek word for a small handbook or manual — appropriate for a tool that generates compact, structured documentation for AI assistants to reference.
198
+
199
+ ## License
200
+
201
+ MIT
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ # Configuration for documentation generation.
5
+ #
6
+ # Chiridion can be configured globally or per-engine instance.
7
+ # All options have sensible defaults for common Ruby project layouts.
8
+ #
9
+ # @example Global configuration
10
+ # Chiridion.configure do |config|
11
+ # config.output = 'docs/sys'
12
+ # config.namespace_filter = 'MyProject::'
13
+ # config.github_repo = 'user/repo'
14
+ # end
15
+ #
16
+ # @example Per-project configuration file
17
+ # # .chiridion.yml
18
+ # output: docs/sys
19
+ # namespace_filter: MyProject::
20
+ # github_repo: user/repo
21
+ # include_specs: true
22
+ #
23
+ class Config
24
+ # @return [String] Root directory of the project (defaults to current directory)
25
+ attr_accessor :root
26
+
27
+ # @return [String] Source directory to document (relative to root)
28
+ attr_accessor :source_path
29
+
30
+ # @return [String] Output directory for generated docs
31
+ attr_accessor :output
32
+
33
+ # @return [String, nil] Namespace prefix to filter (e.g., "MyProject::")
34
+ # Only classes/modules starting with this prefix are documented.
35
+ # If nil, all classes are included.
36
+ attr_accessor :namespace_filter
37
+
38
+ # @return [String, nil] Namespace prefix to strip from output paths
39
+ # Defaults to namespace_filter value.
40
+ attr_writer :namespace_strip
41
+
42
+ # @return [String, nil] GitHub repository for source links (e.g., "user/repo")
43
+ attr_accessor :github_repo
44
+
45
+ # @return [String] Git branch for source links
46
+ attr_accessor :github_branch
47
+
48
+ # @return [Boolean] Whether to extract examples from spec files
49
+ attr_accessor :include_specs
50
+
51
+ # @return [String] Path to test directory (relative to root)
52
+ attr_accessor :spec_path
53
+
54
+ # @return [String] Path to RBS signatures directory (relative to root)
55
+ attr_accessor :rbs_path
56
+
57
+ # @return [Boolean] Verbose output during generation
58
+ attr_accessor :verbose
59
+
60
+ # @return [#info, #warn, #error, nil] Logger for output messages
61
+ attr_accessor :logger
62
+
63
+ # @return [Integer, nil] Maximum body lines for inline source display.
64
+ # Methods with body <= this many lines show their implementation inline.
65
+ # Set to nil or 0 to disable inline source. Default: 10.
66
+ attr_accessor :inline_source_threshold
67
+
68
+ # @return [Symbol] Output organization strategy (:per_class or :per_file)
69
+ attr_accessor :output_mode
70
+
71
+ def initialize
72
+ @root = Dir.pwd
73
+ @source_path = "lib"
74
+ @output = "docs/sys"
75
+ @namespace_filter = nil
76
+ @namespace_strip = nil
77
+ @github_repo = nil
78
+ @github_branch = "main"
79
+ @include_specs = false
80
+ @spec_path = "test"
81
+ @rbs_path = "sig"
82
+ @verbose = false
83
+ @logger = nil
84
+ @inline_source_threshold = 10
85
+ @output_mode = :per_file
86
+ end
87
+
88
+ # Namespace prefix to strip from output paths.
89
+ # Defaults to namespace_filter if not explicitly set.
90
+ def namespace_strip = @namespace_strip || @namespace_filter
91
+
92
+ # Load configuration from a YAML file.
93
+ #
94
+ # @param path [String] Path to YAML configuration file
95
+ # @return [Config] self
96
+ def load_file(path)
97
+ return self unless File.exist?(path)
98
+
99
+ require "yaml"
100
+ data = YAML.safe_load_file(path, symbolize_names: true)
101
+ load_hash(data)
102
+ end
103
+
104
+ # Load configuration from a hash.
105
+ #
106
+ # @param data [Hash] Configuration values
107
+ # @return [Config] self
108
+ def load_hash(data)
109
+ data.each do |key, value|
110
+ setter = :"#{key}="
111
+ public_send(setter, value) if respond_to?(setter)
112
+ end
113
+ self
114
+ end
115
+
116
+ # @return [String] Full path to source directory
117
+ def full_source_path = File.join(root, source_path)
118
+
119
+ # @return [String] Full path to output directory
120
+ def full_output_path = File.join(root, output)
121
+
122
+ # @return [String] Full path to spec directory
123
+ def full_spec_path = File.join(root, spec_path)
124
+
125
+ # @return [String] Full path to RBS directory
126
+ def full_rbs_path = File.join(root, rbs_path)
127
+ end
128
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Chiridion
4
+ class Engine
5
+ # Converts class/module references to Obsidian wikilinks.
6
+ #
7
+ # Handles various reference formats:
8
+ # - Full paths: `Autopax::Foo::Bar` → `[[foo/bar|Bar]]`
9
+ # - YARD curly braces: `{Extractor}` → `[[extractor|Extractor]]`
10
+ # - Relative names: `Writer` → `[[writer|Writer]]` (within same namespace)
11
+ class ClassLinker
12
+ # @return [Hash<String, String>] Known classes mapped to doc paths
13
+ attr_reader :known_classes
14
+
15
+ # @return [String, nil] Namespace prefix to strip from paths
16
+ attr_reader :namespace_strip
17
+
18
+ def initialize(namespace_strip: nil)
19
+ @namespace_strip = namespace_strip
20
+ @known_classes = {}
21
+ end
22
+
23
+ # Register known classes from the documentation structure.
24
+ #
25
+ # @param structure [Hash] Documentation structure from Extractor
26
+ def register_classes(structure)
27
+ (structure[:classes] + structure[:modules]).each do |obj|
28
+ path = obj[:path]
29
+ @known_classes[path] = doc_path(path)
30
+ # Also register short name for relative lookups
31
+ short_name = path.split("::").last
32
+ @known_classes[short_name] ||= doc_path(path)
33
+ end
34
+ end
35
+
36
+ # Convert a class path to a wikilink.
37
+ #
38
+ # @param class_path [String] Full or relative class path
39
+ # @param context [String, nil] Current class context for relative resolution
40
+ # @return [String] Wikilink like `[[path|Name]]` or original if not found
41
+ def link(class_path, context: nil)
42
+ display_name = class_path.split("::").last
43
+ resolved = resolve(class_path, context: context)
44
+ return display_name unless resolved
45
+
46
+ "[[#{resolved}|#{display_name}]]"
47
+ end
48
+
49
+ # Process a docstring, converting {Class} references to wikilinks.
50
+ #
51
+ # @param text [String] Docstring text
52
+ # @param context [String, nil] Current class context
53
+ # @return [String] Text with {Class} converted to wikilinks
54
+ def linkify_docstring(text, context: nil)
55
+ return text if text.nil? || text.empty?
56
+
57
+ result = text.dup
58
+
59
+ # Convert YARD headings (= Title) to markdown headings
60
+ # Direct 1:1 conversion; normalize_headers filter will adjust levels for context
61
+ result.gsub!(/^(=+)\s+(.+)$/) do
62
+ level = Regexp.last_match(1).length
63
+ "#" * level + " " + Regexp.last_match(2)
64
+ end
65
+
66
+ # Convert {Class} references to wikilinks
67
+ result.gsub(/\{([A-Z][\w:]*)\}/) do |_match|
68
+ class_ref = Regexp.last_match(1)
69
+ link(class_ref, context: context)
70
+ end
71
+ end
72
+
73
+ # Convert a type annotation to include wikilinks where possible.
74
+ #
75
+ # Returns formatted string with backticks around non-link parts.
76
+ # Wikilinks must be outside backticks to render properly.
77
+ #
78
+ # @param type_str [String] Type like `Array<Autopax::Foo>` or `Hash{String => Bar}`
79
+ # @param context [String, nil] Current class context
80
+ # @return [String] Formatted type with proper backtick placement
81
+ def linkify_type(type_str, context: nil)
82
+ return "`Object`" if type_str.nil? || type_str.empty?
83
+
84
+ segments = build_type_segments(type_str, context: context)
85
+ format_type_segments(segments)
86
+ end
87
+
88
+ # Check if a class is a known documentable class.
89
+ #
90
+ # @param class_name [String] Class name to check
91
+ # @return [Boolean]
92
+ def known?(class_name) = @known_classes.key?(class_name)
93
+
94
+ SKIP_TYPES = %w[Array Hash String Integer Float Symbol Boolean Object TrueClass FalseClass NilClass Proc
95
+ Class Module Numeric Enumerable Comparable void untyped nil self].freeze
96
+ private_constant :SKIP_TYPES
97
+
98
+ def skip_type?(class_ref) = SKIP_TYPES.include?(class_ref)
99
+
100
+ private
101
+
102
+ # Build segments from type string, identifying linkable class refs.
103
+ def build_type_segments(type_str, context:)
104
+ segments = []
105
+ last_end = 0
106
+
107
+ type_str.scan(/\b([A-Z]\w*(?:::[A-Z]\w*)*)\b/) do
108
+ class_ref = Regexp.last_match(1)
109
+ match_start = Regexp.last_match.begin(0)
110
+ match_end = Regexp.last_match.end(0)
111
+
112
+ segments << [:text, type_str[last_end...match_start]] if match_start > last_end
113
+ segments << segment_for_class(class_ref, context: context)
114
+ last_end = match_end
115
+ end
116
+
117
+ segments << [:text, type_str[last_end..]] if last_end < type_str.length
118
+ segments
119
+ end
120
+
121
+ # Create segment for a class reference (link or text).
122
+ def segment_for_class(class_ref, context:)
123
+ return [:text, class_ref] if skip_type?(class_ref)
124
+
125
+ resolved = resolve(class_ref, context: context)
126
+ return [:text, class_ref] unless resolved
127
+
128
+ [:link, "[[#{resolved}|#{class_ref.split('::').last}]]"]
129
+ end
130
+
131
+ # Format segments into final string with proper backtick placement.
132
+ def format_type_segments(segments)
133
+ return "`Object`" if segments.empty?
134
+ return format_pure_text(segments) unless segments.any? { |type, _| type == :link }
135
+ return segments.first.last if pure_link?(segments)
136
+
137
+ format_mixed_segments(segments)
138
+ end
139
+
140
+ def format_pure_text(segments) = "`#{segments.map(&:last).join}`"
141
+
142
+ def pure_link?(segments) = segments.size == 1 && segments.first.first == :link
143
+
144
+ # Format mixed content: wrap text in backticks, leave links bare.
145
+ def format_mixed_segments(segments)
146
+ result = []
147
+ text_buffer = +""
148
+
149
+ segments.each do |type, content|
150
+ if type == :text
151
+ text_buffer << content
152
+ else
153
+ result << "`#{text_buffer}`" unless text_buffer.empty?
154
+ text_buffer.clear
155
+ result << content
156
+ end
157
+ end
158
+
159
+ result << "`#{text_buffer}`" unless text_buffer.empty?
160
+ result.join
161
+ end
162
+
163
+ # Resolve a class reference to its documentation path.
164
+ def resolve(class_ref, context: nil)
165
+ # Try exact match first
166
+ return @known_classes[class_ref] if @known_classes[class_ref]
167
+
168
+ # Try with namespace prefix
169
+ if @namespace_strip
170
+ full_path = "#{@namespace_strip}#{class_ref}"
171
+ return @known_classes[full_path] if @known_classes[full_path]
172
+ end
173
+
174
+ # Try relative to context
175
+ if context
176
+ relative_path = "#{context}::#{class_ref}"
177
+ return @known_classes[relative_path] if @known_classes[relative_path]
178
+
179
+ # Try parent namespace
180
+ parent = context.split("::")[0..-2].join("::")
181
+ sibling_path = "#{parent}::#{class_ref}"
182
+ return @known_classes[sibling_path] if @known_classes[sibling_path]
183
+ end
184
+
185
+ nil
186
+ end
187
+
188
+ # Convert class path to documentation file path.
189
+ def doc_path(class_path)
190
+ stripped = @namespace_strip ? class_path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_path
191
+ parts = stripped.split("::")
192
+ kebab_parts = parts.map { |p| to_kebab_case(p) }
193
+ kebab_parts.join("/")
194
+ end
195
+
196
+ def to_kebab_case(str)
197
+ str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
198
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
199
+ .gsub(/([a-z\d])([A-Z])/, '\1-\2')
200
+ .downcase
201
+ end
202
+ end
203
+ end
204
+ end