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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/lib/chiridion/config.rb +128 -0
- data/lib/chiridion/engine/class_linker.rb +204 -0
- data/lib/chiridion/engine/document_model.rb +299 -0
- data/lib/chiridion/engine/drift_checker.rb +146 -0
- data/lib/chiridion/engine/extractor.rb +311 -0
- data/lib/chiridion/engine/file_renderer.rb +717 -0
- data/lib/chiridion/engine/file_writer.rb +160 -0
- data/lib/chiridion/engine/frontmatter_builder.rb +248 -0
- data/lib/chiridion/engine/generated_rbs_loader.rb +344 -0
- data/lib/chiridion/engine/github_linker.rb +87 -0
- data/lib/chiridion/engine/inline_rbs_loader.rb +207 -0
- data/lib/chiridion/engine/post_processor.rb +86 -0
- data/lib/chiridion/engine/rbs_loader.rb +150 -0
- data/lib/chiridion/engine/rbs_type_alias_loader.rb +116 -0
- data/lib/chiridion/engine/renderer.rb +598 -0
- data/lib/chiridion/engine/semantic_extractor.rb +740 -0
- data/lib/chiridion/engine/semantic_renderer.rb +334 -0
- data/lib/chiridion/engine/spec_example_loader.rb +84 -0
- data/lib/chiridion/engine/template_renderer.rb +275 -0
- data/lib/chiridion/engine/type_merger.rb +126 -0
- data/lib/chiridion/engine/writer.rb +134 -0
- data/lib/chiridion/engine.rb +359 -0
- data/lib/chiridion/semantic_engine.rb +186 -0
- data/lib/chiridion/version.rb +5 -0
- data/lib/chiridion.rb +106 -0
- data/templates/constants.liquid +27 -0
- data/templates/document.liquid +48 -0
- data/templates/file.liquid +108 -0
- data/templates/index.liquid +21 -0
- data/templates/method.liquid +43 -0
- data/templates/methods.liquid +11 -0
- data/templates/type_aliases.liquid +26 -0
- data/templates/types.liquid +11 -0
- metadata +146 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Comprehensive semantic document model for extracted documentation.
|
|
6
|
+
#
|
|
7
|
+
# This module defines immutable data structures that capture ALL information
|
|
8
|
+
# from YARD and RBS sources. The goal is complete semantic extraction,
|
|
9
|
+
# independent of rendering concerns.
|
|
10
|
+
#
|
|
11
|
+
# Design principles:
|
|
12
|
+
# - Capture everything, filter/format at render time
|
|
13
|
+
# - Prefer explicit nil over missing keys
|
|
14
|
+
# - Use typed structures (Data.define) for compile-time safety
|
|
15
|
+
# - Group related data (e.g., yield info together)
|
|
16
|
+
#
|
|
17
|
+
# @see TODO.md for the complete tag inventory this model addresses
|
|
18
|
+
module DocumentModel
|
|
19
|
+
# Parameter documentation (method param or @option entry).
|
|
20
|
+
#
|
|
21
|
+
# @example Basic parameter
|
|
22
|
+
# ParamDoc.new(name: "id", type: "String", description: "User ID", default: nil)
|
|
23
|
+
#
|
|
24
|
+
# @example Optional with default
|
|
25
|
+
# ParamDoc.new(name: "limit", type: "Integer", description: "Max results", default: "10")
|
|
26
|
+
ParamDoc = Data.define(
|
|
27
|
+
:name, # String - parameter name (cleaned, no sigils)
|
|
28
|
+
:type, # String? - RBS/YARD type expression
|
|
29
|
+
:description, # String? - description text
|
|
30
|
+
:default, # String? - default value (as string from source)
|
|
31
|
+
:prefix # String? - "*", "**", "&" for splat/block params
|
|
32
|
+
) do
|
|
33
|
+
def self.from_hash(h)
|
|
34
|
+
new(
|
|
35
|
+
name: h[:name]&.to_s,
|
|
36
|
+
type: normalize_type(h[:types]),
|
|
37
|
+
description: h[:text],
|
|
38
|
+
default: h[:default],
|
|
39
|
+
prefix: extract_prefix(h[:name])
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.normalize_type(types)
|
|
44
|
+
return nil if types.nil? || types.empty?
|
|
45
|
+
|
|
46
|
+
Array(types).first&.to_s
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.extract_prefix(name)
|
|
50
|
+
s = name.to_s
|
|
51
|
+
return "**" if s.start_with?("**")
|
|
52
|
+
return "*" if s.start_with?("*")
|
|
53
|
+
return "&" if s.start_with?("&")
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @option tag documentation (hash parameter options).
|
|
60
|
+
OptionDoc = Data.define(
|
|
61
|
+
:param_name, # String - the hash param this option belongs to
|
|
62
|
+
:key, # String - the option key name
|
|
63
|
+
:type, # String? - option value type
|
|
64
|
+
:description # String? - description
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Block/yield documentation.
|
|
68
|
+
#
|
|
69
|
+
# Captures @yield, @yieldparam, and @yieldreturn together.
|
|
70
|
+
YieldDoc = Data.define(
|
|
71
|
+
:description, # String? - @yield description
|
|
72
|
+
:params, # Array[ParamDoc] - @yieldparam entries
|
|
73
|
+
:return_type, # String? - @yieldreturn type
|
|
74
|
+
:return_desc, # String? - @yieldreturn description
|
|
75
|
+
:block_type # String? - RBS block signature like "^(Batch) -> void"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Exception documentation.
|
|
79
|
+
RaiseDoc = Data.define(
|
|
80
|
+
:type, # String - exception class name
|
|
81
|
+
:description # String? - when/why raised
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Return type documentation.
|
|
85
|
+
ReturnDoc = Data.define(
|
|
86
|
+
:type, # String? - return type
|
|
87
|
+
:description # String? - what it returns
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Example documentation.
|
|
91
|
+
ExampleDoc = Data.define(
|
|
92
|
+
:name, # String? - example name/title
|
|
93
|
+
:code # String - example code
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Cross-reference (@see tag).
|
|
97
|
+
SeeDoc = Data.define(
|
|
98
|
+
:target, # String - what to see (class, method, URL)
|
|
99
|
+
:text # String? - additional context
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Instance variable documentation.
|
|
103
|
+
IvarDoc = Data.define(
|
|
104
|
+
:name, # String - ivar name without @
|
|
105
|
+
:type, # String? - RBS type
|
|
106
|
+
:description # String? - description
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Constant documentation.
|
|
110
|
+
ConstantDoc = Data.define(
|
|
111
|
+
:name, # String - constant name
|
|
112
|
+
:value, # String? - constant value (stringified)
|
|
113
|
+
:type, # String? - RBS type if declared
|
|
114
|
+
:description # String? - docstring
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Type alias documentation.
|
|
118
|
+
TypeAliasDoc = Data.define(
|
|
119
|
+
:name, # String - alias name
|
|
120
|
+
:definition, # String - RBS type definition
|
|
121
|
+
:description, # String? - description
|
|
122
|
+
:namespace # String - where defined
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Method signature overload.
|
|
126
|
+
OverloadDoc = Data.define(
|
|
127
|
+
:signature, # String - full RBS signature
|
|
128
|
+
:description # String? - description for this overload
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Method documentation - comprehensive capture of all method info.
|
|
132
|
+
MethodDoc = Data.define(
|
|
133
|
+
# Identity
|
|
134
|
+
:name, # Symbol - method name
|
|
135
|
+
:scope, # Symbol - :class or :instance
|
|
136
|
+
:visibility, # Symbol - :public, :protected, :private
|
|
137
|
+
:signature, # String - Ruby signature from YARD
|
|
138
|
+
|
|
139
|
+
# Documentation
|
|
140
|
+
:docstring, # String? - main docstring
|
|
141
|
+
:params, # Array[ParamDoc] - parameters
|
|
142
|
+
:options, # Array[OptionDoc] - @option entries
|
|
143
|
+
:returns, # ReturnDoc? - return info
|
|
144
|
+
:yields, # YieldDoc? - block/yield info
|
|
145
|
+
:raises, # Array[RaiseDoc] - exceptions
|
|
146
|
+
:examples, # Array[ExampleDoc] - @example tags
|
|
147
|
+
:notes, # Array[String] - @note entries
|
|
148
|
+
:see_also, # Array[SeeDoc] - @see entries
|
|
149
|
+
|
|
150
|
+
# Metadata
|
|
151
|
+
:api, # String? - @api value (private, public, internal)
|
|
152
|
+
:deprecated, # String? - deprecation message or true/"" if just tagged
|
|
153
|
+
:abstract, # bool - is abstract?
|
|
154
|
+
:since, # String? - @since version
|
|
155
|
+
:todo, # String? - @todo message
|
|
156
|
+
|
|
157
|
+
# RBS-specific
|
|
158
|
+
:rbs_signature, # String? - full RBS signature
|
|
159
|
+
:overloads, # Array[OverloadDoc] - method overloads from RBS
|
|
160
|
+
|
|
161
|
+
# Source
|
|
162
|
+
:source, # String? - source code
|
|
163
|
+
:source_body_lines, # Integer? - body line count (for inline display threshold)
|
|
164
|
+
:attr_type, # Symbol? - :reader, :writer, :accessor if attr method
|
|
165
|
+
:file, # String? - source file
|
|
166
|
+
:line, # Integer? - line number
|
|
167
|
+
|
|
168
|
+
# Spec integration
|
|
169
|
+
:spec_examples, # Array[Hash] - examples from specs
|
|
170
|
+
:spec_behaviors # Array[String] - behavior descriptions
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Attribute documentation (synthesized from reader/writer pairs).
|
|
174
|
+
AttributeDoc = Data.define(
|
|
175
|
+
:name, # String - attribute name
|
|
176
|
+
:type, # String? - type from RBS or YARD
|
|
177
|
+
:description, # String? - description
|
|
178
|
+
:mode, # Symbol - :read, :write, :read_write
|
|
179
|
+
:reader, # MethodDoc? - reader method doc
|
|
180
|
+
:writer # MethodDoc? - writer method doc
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Class or module documentation.
|
|
184
|
+
NamespaceDoc = Data.define(
|
|
185
|
+
# Identity
|
|
186
|
+
:name, # String - short name
|
|
187
|
+
:path, # String - full path (Foo::Bar)
|
|
188
|
+
:type, # Symbol - :class or :module
|
|
189
|
+
:superclass, # String? - superclass path (classes only)
|
|
190
|
+
|
|
191
|
+
# Documentation
|
|
192
|
+
:docstring, # String? - main docstring
|
|
193
|
+
:examples, # Array[ExampleDoc] - @example tags
|
|
194
|
+
:notes, # Array[String] - @note entries
|
|
195
|
+
:see_also, # Array[SeeDoc] - @see entries
|
|
196
|
+
|
|
197
|
+
# Metadata
|
|
198
|
+
:api, # String? - @api value
|
|
199
|
+
:deprecated, # String? - deprecation message
|
|
200
|
+
:abstract, # bool - is abstract?
|
|
201
|
+
:since, # String? - @since version
|
|
202
|
+
:todo, # String? - @todo message
|
|
203
|
+
|
|
204
|
+
# Relationships
|
|
205
|
+
:includes, # Array[String] - included modules
|
|
206
|
+
:extends, # Array[String] - extended modules
|
|
207
|
+
|
|
208
|
+
# Members
|
|
209
|
+
:constants, # Array[ConstantDoc]
|
|
210
|
+
:type_aliases, # Array[TypeAliasDoc] - local type aliases
|
|
211
|
+
:ivars, # Array[IvarDoc] - instance variables
|
|
212
|
+
:attributes, # Array[AttributeDoc] - synthesized attributes
|
|
213
|
+
:methods, # Array[MethodDoc] - public methods
|
|
214
|
+
:private_methods, # Array[MethodDoc] - private/protected (may be minimal)
|
|
215
|
+
|
|
216
|
+
# Source
|
|
217
|
+
:file, # String? - source file
|
|
218
|
+
:line, # Integer? - start line
|
|
219
|
+
:end_line, # Integer? - end line
|
|
220
|
+
:rbs_file, # String? - corresponding RBS file
|
|
221
|
+
|
|
222
|
+
# Spec integration
|
|
223
|
+
:spec_examples, # Hash? - class-level spec examples
|
|
224
|
+
|
|
225
|
+
# Cross-references (populated after extraction)
|
|
226
|
+
:referenced_types # Array[TypeAliasDoc] - types used by this class
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Documentation for a single source file.
|
|
230
|
+
#
|
|
231
|
+
# Groups all namespaces (classes/modules) defined in one Ruby file.
|
|
232
|
+
# This is the primary unit for per-file documentation output.
|
|
233
|
+
FileDoc = Data.define(
|
|
234
|
+
:path, # String - source file path (relative to project root)
|
|
235
|
+
:namespaces, # Array[NamespaceDoc] - classes/modules in this file
|
|
236
|
+
:type_aliases, # Array[TypeAliasDoc] - type aliases defined in this file
|
|
237
|
+
:line_count # Integer? - total lines in source file
|
|
238
|
+
) do
|
|
239
|
+
# Short filename for display (e.g., "attributes.rb")
|
|
240
|
+
def filename = File.basename(path)
|
|
241
|
+
|
|
242
|
+
# Directory portion (e.g., "lib/archema")
|
|
243
|
+
def dirname = File.dirname(path)
|
|
244
|
+
|
|
245
|
+
# Main namespace - the one that best represents this file's purpose.
|
|
246
|
+
#
|
|
247
|
+
# Selection order:
|
|
248
|
+
# 1. Namespace whose name matches filename (query.rb -> Query)
|
|
249
|
+
# 2. Module (often the container for nested classes)
|
|
250
|
+
# 3. Shortest path (top-level namespace)
|
|
251
|
+
# 4. Most content as tiebreaker
|
|
252
|
+
def primary_namespace
|
|
253
|
+
return namespaces.first if namespaces.size == 1
|
|
254
|
+
return nil if namespaces.empty?
|
|
255
|
+
|
|
256
|
+
basename = File.basename(path, ".rb")
|
|
257
|
+
# Convert snake_case to variations for matching
|
|
258
|
+
name_variants = [
|
|
259
|
+
basename, # document_model
|
|
260
|
+
basename.gsub("_", ""), # documentmodel
|
|
261
|
+
basename.split("_").map(&:capitalize).join # DocumentModel
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
# Try to find namespace whose name matches filename
|
|
265
|
+
name_match = namespaces.find do |n|
|
|
266
|
+
name_variants.any? { |v| n.name.downcase == v.downcase }
|
|
267
|
+
end
|
|
268
|
+
return name_match if name_match
|
|
269
|
+
|
|
270
|
+
# Prefer modules (they're usually the container)
|
|
271
|
+
modules_list = namespaces.select { |n| n.type == :module }
|
|
272
|
+
if modules_list.any?
|
|
273
|
+
# Among modules, prefer shortest path (top-level)
|
|
274
|
+
return modules_list.min_by { |n| n.path.count("::") }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Among classes, prefer shortest path
|
|
278
|
+
namespaces.min_by { |n| n.path.count("::") }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def classes = namespaces.select { |n| n.type == :class }
|
|
282
|
+
def modules = namespaces.select { |n| n.type == :module }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Complete documentation structure for a project.
|
|
286
|
+
ProjectDoc = Data.define(
|
|
287
|
+
:title, # String - project title
|
|
288
|
+
:description, # String? - project description
|
|
289
|
+
:namespaces, # Array[NamespaceDoc] - all documented classes/modules
|
|
290
|
+
:files, # Array[FileDoc] - per-file documentation (for per-file output)
|
|
291
|
+
:type_aliases, # Hash[String, Array[TypeAliasDoc]] - global type aliases
|
|
292
|
+
:generated_at # Time - generation timestamp
|
|
293
|
+
) do
|
|
294
|
+
def classes = namespaces.select { |n| n.type == :class }
|
|
295
|
+
def modules = namespaces.select { |n| n.type == :module }
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chiridion
|
|
4
|
+
class Engine
|
|
5
|
+
# Detects when documentation is out of sync with source code.
|
|
6
|
+
#
|
|
7
|
+
# Compares what would be generated against existing files.
|
|
8
|
+
# Useful in CI pipelines to enforce documentation currency.
|
|
9
|
+
class DriftChecker
|
|
10
|
+
def initialize(
|
|
11
|
+
output,
|
|
12
|
+
namespace_strip,
|
|
13
|
+
include_specs,
|
|
14
|
+
verbose,
|
|
15
|
+
logger,
|
|
16
|
+
root: Dir.pwd,
|
|
17
|
+
github_repo: nil,
|
|
18
|
+
github_branch: "main",
|
|
19
|
+
project_title: "API Documentation",
|
|
20
|
+
inline_source_threshold: 10
|
|
21
|
+
)
|
|
22
|
+
@output = output
|
|
23
|
+
@namespace_strip = namespace_strip
|
|
24
|
+
@include_specs = include_specs
|
|
25
|
+
@verbose = verbose
|
|
26
|
+
@logger = logger
|
|
27
|
+
@renderer = Renderer.new(
|
|
28
|
+
namespace_strip: namespace_strip,
|
|
29
|
+
include_specs: include_specs,
|
|
30
|
+
root: root,
|
|
31
|
+
github_repo: github_repo,
|
|
32
|
+
github_branch: github_branch,
|
|
33
|
+
project_title: project_title,
|
|
34
|
+
inline_source_threshold: inline_source_threshold
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Check for drift between source and existing documentation.
|
|
39
|
+
#
|
|
40
|
+
# @param structure [Hash] Documentation structure from Extractor
|
|
41
|
+
# @raise [SystemExit] Exits with code 1 if drift is detected
|
|
42
|
+
def check(structure)
|
|
43
|
+
@renderer.register_classes(structure)
|
|
44
|
+
|
|
45
|
+
drifted = []
|
|
46
|
+
missing = []
|
|
47
|
+
orphaned = find_orphaned_files(structure)
|
|
48
|
+
|
|
49
|
+
check_index(structure, drifted, missing)
|
|
50
|
+
check_objects(structure[:classes] + structure[:modules], drifted, missing)
|
|
51
|
+
|
|
52
|
+
report_results(drifted, missing, orphaned)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def check_index(structure, drifted, missing)
|
|
58
|
+
path = File.join(@output, "index.md")
|
|
59
|
+
expected = @renderer.render_index(structure)
|
|
60
|
+
check_file(path, expected, drifted, missing)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def check_objects(objects, drifted, missing)
|
|
64
|
+
objects.each do |obj|
|
|
65
|
+
next unless obj[:needs_regeneration]
|
|
66
|
+
|
|
67
|
+
path = output_path(obj[:path])
|
|
68
|
+
expected = obj[:type] == :class ? @renderer.render_class(obj) : @renderer.render_module(obj)
|
|
69
|
+
check_file(path, expected, drifted, missing)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def check_file(path, expected, drifted, missing)
|
|
74
|
+
if File.exist?(path)
|
|
75
|
+
actual = File.read(path)
|
|
76
|
+
drifted << path if content_changed?(actual, expected)
|
|
77
|
+
else
|
|
78
|
+
missing << path
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def find_orphaned_files(structure)
|
|
83
|
+
return [] unless File.directory?(@output)
|
|
84
|
+
|
|
85
|
+
expected_files = Set.new
|
|
86
|
+
expected_files << File.join(@output, "index.md")
|
|
87
|
+
|
|
88
|
+
(structure[:classes] + structure[:modules]).each do |obj|
|
|
89
|
+
expected_files << output_path(obj[:path])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
actual_files = Dir.glob("#{@output}/**/*.md")
|
|
93
|
+
actual_files.reject { |f| expected_files.include?(f) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def content_changed?(old, new) = normalize(old) != normalize(new)
|
|
97
|
+
|
|
98
|
+
def normalize(content)
|
|
99
|
+
content
|
|
100
|
+
.gsub(/^generated: .+$/, "generated: TIMESTAMP")
|
|
101
|
+
.gsub(/\n{2,}/, "\n\n")
|
|
102
|
+
.strip
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def report_results(drifted, missing, orphaned)
|
|
106
|
+
total_issues = drifted.size + missing.size + orphaned.size
|
|
107
|
+
|
|
108
|
+
if total_issues.zero?
|
|
109
|
+
@logger.info " No drift detected. Documentation is up to date."
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
@logger.warn "Documentation drift detected!"
|
|
114
|
+
@logger.warn ""
|
|
115
|
+
report_list("Drifted (content changed)", drifted)
|
|
116
|
+
report_list("Missing (new classes)", missing)
|
|
117
|
+
report_list("Orphaned (classes removed)", orphaned)
|
|
118
|
+
@logger.warn ""
|
|
119
|
+
@logger.warn "Run 'chiridion refresh' to update documentation."
|
|
120
|
+
|
|
121
|
+
exit 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def report_list(label, files)
|
|
125
|
+
return if files.empty?
|
|
126
|
+
|
|
127
|
+
@logger.warn " #{label}:"
|
|
128
|
+
files.each { |f| @logger.warn " - #{f}" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def output_path(class_path)
|
|
132
|
+
stripped = @namespace_strip ? class_path.sub(/^#{Regexp.escape(@namespace_strip)}/, "") : class_path
|
|
133
|
+
parts = stripped.split("::")
|
|
134
|
+
kebab_parts = parts.map { |p| to_kebab_case(p) }
|
|
135
|
+
File.join(@output, *kebab_parts[0..-2], "#{kebab_parts.last}.md")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_kebab_case(str)
|
|
139
|
+
str.gsub(/([A-Za-z])([vV]\d+)/, '\1-\2')
|
|
140
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
141
|
+
.gsub(/([a-z\d])([A-Z])/, '\1-\2')
|
|
142
|
+
.downcase
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|