graphomaton 0.1.1 → 1.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 +4 -4
- data/CHANGELOG.md +78 -3
- data/README.md +454 -27
- data/SECURITY.md +47 -0
- data/docs/architecture.md +30 -0
- data/docs/cli.md +27 -0
- data/docs/custom-exporters.md +36 -0
- data/docs/exporters.md +17 -0
- data/docs/input-schema.md +26 -0
- data/docs/migration-1.1.md +19 -0
- data/docs/performance.md +19 -0
- data/docs/releasing.md +19 -0
- data/exe/graphomaton +9 -0
- data/lib/graphomaton/atomic_file.rb +26 -0
- data/lib/graphomaton/cli/config.rb +102 -0
- data/lib/graphomaton/cli.rb +841 -0
- data/lib/graphomaton/errors.rb +11 -0
- data/lib/graphomaton/exporter_registry.rb +127 -0
- data/lib/graphomaton/exporters/dot.rb +284 -0
- data/lib/graphomaton/exporters/mermaid.rb +774 -0
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +284 -0
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2872 -0
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +13 -0
- data/lib/graphomaton/identifier_allocator.rb +33 -0
- data/lib/graphomaton/input_policy.rb +82 -0
- data/lib/graphomaton/layout/force_tree.rb +127 -0
- data/lib/graphomaton/model.rb +218 -0
- data/lib/graphomaton/process_runner.rb +154 -0
- data/lib/graphomaton/url_policy.rb +40 -0
- data/lib/graphomaton/version.rb +1 -1
- data/lib/graphomaton.rb +2865 -240
- data/sig/graphomaton.rbs +127 -0
- metadata +39 -17
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -19
- data/sample/complex.rb +0 -24
- data/sample/nfa.rb +0 -19
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
class ParseError < Error; end
|
|
6
|
+
class ValidationError < Error; end
|
|
7
|
+
class LayoutError < Error; end
|
|
8
|
+
class ExportError < Error; end
|
|
9
|
+
class ConversionError < ExportError; end
|
|
10
|
+
class SecurityError < Error; end
|
|
11
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
class ExporterRegistry
|
|
5
|
+
Entry = Data.define(:name, :aliases, :extensions, :binary, :capabilities, :loader) do
|
|
6
|
+
def exporter
|
|
7
|
+
resolved = loader.call
|
|
8
|
+
return resolved if resolved.respond_to?(:new)
|
|
9
|
+
|
|
10
|
+
raise ArgumentError, "Exporter #{name.inspect} loader must return a class"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@entries = {}
|
|
16
|
+
@aliases = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def register(name, aliases: [], extensions: [], binary: false, capabilities: [], exporter: nil, &loader)
|
|
20
|
+
canonical = name.to_sym
|
|
21
|
+
resolved_loader = loader || (-> { exporter })
|
|
22
|
+
raise ArgumentError, "Exporter #{canonical.inspect} requires an exporter class or loader block" unless exporter || loader
|
|
23
|
+
|
|
24
|
+
keys = [canonical] + aliases.map(&:to_sym) + extensions.map { |extension| extension.to_s.delete_prefix('.').downcase.to_sym }
|
|
25
|
+
collisions = keys.select { |key| @aliases.key?(key) }.uniq
|
|
26
|
+
unless collisions.empty?
|
|
27
|
+
raise ArgumentError, "Exporter identifiers are already registered: #{collisions.join(', ')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
entry = Entry.new(
|
|
31
|
+
name: canonical,
|
|
32
|
+
aliases: aliases.map(&:to_sym).freeze,
|
|
33
|
+
extensions: extensions.map { |extension| extension.to_s.delete_prefix('.').downcase }.freeze,
|
|
34
|
+
binary: binary,
|
|
35
|
+
capabilities: capabilities.map(&:to_sym).freeze,
|
|
36
|
+
loader: resolved_loader
|
|
37
|
+
)
|
|
38
|
+
@entries[canonical] = entry
|
|
39
|
+
([canonical] + entry.aliases + entry.extensions.map(&:to_sym)).each { |key| @aliases[key] = canonical }
|
|
40
|
+
entry
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def unregister(format)
|
|
44
|
+
canonical = resolve(format)
|
|
45
|
+
entry = @entries.delete(canonical)
|
|
46
|
+
@aliases.delete_if { |_key, value| value == canonical }
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve(format)
|
|
51
|
+
key = format.to_s.delete_prefix('.').downcase.to_sym
|
|
52
|
+
canonical = @aliases[key]
|
|
53
|
+
return canonical if canonical
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "Unknown format: #{format.inspect}. Available formats: #{formats.join(', ')}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def fetch(format)
|
|
59
|
+
@entries.fetch(resolve(format))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def formats
|
|
63
|
+
@entries.keys.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def aliases
|
|
67
|
+
@aliases.dup.freeze
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module ExporterCapabilities
|
|
72
|
+
FEATURE_DETECTORS = {
|
|
73
|
+
state_style: ->(graph) { graph.state_records.any? { |_, state| state.style } },
|
|
74
|
+
transition_style: ->(graph) { graph.transition_records.any? { |transition| transition.style } },
|
|
75
|
+
url: lambda { |graph|
|
|
76
|
+
graph.state_records.any? { |_, state| metadata_value(state.metadata, :url, :href) } ||
|
|
77
|
+
graph.transition_records.any? { |transition| metadata_value(transition.metadata, :url, :href) }
|
|
78
|
+
},
|
|
79
|
+
tooltip: lambda { |graph|
|
|
80
|
+
graph.state_records.any? { |_, state| metadata_value(state.metadata, :tooltip, :description) } ||
|
|
81
|
+
graph.transition_records.any? { |transition| metadata_value(transition.metadata, :tooltip, :description) }
|
|
82
|
+
},
|
|
83
|
+
group: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :group, :cluster) } },
|
|
84
|
+
parent: ->(graph) { graph.state_records.any? { |_, state| metadata_value(state.metadata, :parent) } },
|
|
85
|
+
pseudostate: lambda { |graph|
|
|
86
|
+
graph.state_records.any? do |_, state|
|
|
87
|
+
(state.kind && state.kind != :normal) || metadata_value(state.metadata, :kind, :type)
|
|
88
|
+
end
|
|
89
|
+
},
|
|
90
|
+
bundle: ->(graph) { graph.transition_records.any? { |transition| metadata_value(transition.metadata, :bundle) } },
|
|
91
|
+
line_style: ->(graph) { graph.transition_records.any?(&:line_style) }
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
def self.losses_for(graph, capabilities)
|
|
95
|
+
FEATURE_DETECTORS.filter_map do |feature, detector|
|
|
96
|
+
feature if detector.call(graph) && !capabilities.include?(feature)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def self.metadata_value(metadata, *keys)
|
|
101
|
+
return nil unless metadata.is_a?(Hash)
|
|
102
|
+
|
|
103
|
+
keys.each do |key|
|
|
104
|
+
value = metadata[key] || metadata[key.to_s]
|
|
105
|
+
return value if value
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
private_class_method :metadata_value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
module ExporterIntrospection
|
|
113
|
+
def capabilities
|
|
114
|
+
Graphomaton.exporter_capabilities(exporter_format)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def losses_for(graph = @automaton)
|
|
118
|
+
Graphomaton::ExporterCapabilities.losses_for(graph, capabilities)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def exporter_format
|
|
124
|
+
self.class.name.split('::').last.downcase.to_sym
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Graphomaton
|
|
4
|
+
module Exporters
|
|
5
|
+
class Dot
|
|
6
|
+
include Graphomaton::ExporterIntrospection
|
|
7
|
+
DEFAULT_DIRECTION = :lr
|
|
8
|
+
DEFAULT_RANK_CONSTRAINTS = false
|
|
9
|
+
DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
|
|
10
|
+
LINE_STYLE_OPTIONS = %i[solid dashed dotted].freeze
|
|
11
|
+
PSEUDOSTATE_SHAPES = {
|
|
12
|
+
choice: 'diamond',
|
|
13
|
+
fork: 'point',
|
|
14
|
+
join: 'point'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(automaton, direction: DEFAULT_DIRECTION, theme: nil, rank_constraints: DEFAULT_RANK_CONSTRAINTS)
|
|
18
|
+
@automaton = automaton
|
|
19
|
+
@direction = resolve_direction(direction)
|
|
20
|
+
@theme = resolve_theme(theme)
|
|
21
|
+
@rank_constraints = rank_constraints
|
|
22
|
+
@identifiers = IdentifierAllocator.new
|
|
23
|
+
@state_names = @automaton.state_records.each_key.to_h do |name|
|
|
24
|
+
[name, @identifiers.allocate([:state, name], preferred: name, prefix: 'state')]
|
|
25
|
+
end
|
|
26
|
+
@start_name = @identifiers.allocate(:start, preferred: '__start__', prefix: '__graphomaton_start')
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def export
|
|
30
|
+
lines = ['digraph finite_state_machine {']
|
|
31
|
+
lines << " rankdir=#{rankdir};"
|
|
32
|
+
lines << " graph [bgcolor=\"#{escape_label(@theme[:background])}\"];" if @theme && @theme[:background]
|
|
33
|
+
lines << " node [#{node_attributes('circle')}];"
|
|
34
|
+
lines << ''
|
|
35
|
+
state_attributes = state_attribute_lines
|
|
36
|
+
lines.concat(state_attributes)
|
|
37
|
+
lines << '' if state_attributes.any?
|
|
38
|
+
state_clusters = state_cluster_lines
|
|
39
|
+
lines.concat(state_clusters)
|
|
40
|
+
lines << '' if state_clusters.any?
|
|
41
|
+
|
|
42
|
+
if @automaton.initial_state
|
|
43
|
+
lines << " #{quoted_id(@start_name)} [shape=point];"
|
|
44
|
+
lines << " #{quoted_id(@start_name)} -> #{quoted_state_id(@automaton.initial_state)};"
|
|
45
|
+
lines << ''
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
rank_constraints = rank_constraint_lines
|
|
49
|
+
lines.concat(rank_constraints)
|
|
50
|
+
lines << '' if rank_constraints.any?
|
|
51
|
+
|
|
52
|
+
@automaton.transition_records.each do |trans|
|
|
53
|
+
lines << " #{quoted_state_id(trans[:from])} -> #{quoted_state_id(trans[:to])} [#{edge_attributes(trans)}];"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines << '}'
|
|
57
|
+
"#{lines.join("\n")}\n"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def resolve_direction(direction)
|
|
63
|
+
resolved = direction.to_sym
|
|
64
|
+
return resolved if DIRECTION_OPTIONS.include?(resolved)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def resolve_theme(theme)
|
|
70
|
+
return nil unless theme
|
|
71
|
+
|
|
72
|
+
Graphomaton::Theme.resolve(theme, context: 'DOT theme')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def node_attributes(shape)
|
|
76
|
+
return "shape = #{shape}" unless @theme
|
|
77
|
+
|
|
78
|
+
[
|
|
79
|
+
"shape = #{shape}",
|
|
80
|
+
'style=filled',
|
|
81
|
+
"fillcolor=\"#{escape_label(@theme[:state_fill])}\"",
|
|
82
|
+
"color=\"#{escape_label(@theme[:stroke])}\"",
|
|
83
|
+
"fontcolor=\"#{escape_label(@theme[:state_text])}\""
|
|
84
|
+
].join(', ')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def edge_attributes(transition)
|
|
88
|
+
attributes = ["label=\"#{escape_label(transition[:label])}\""]
|
|
89
|
+
add_metadata_attributes(attributes, transition[:metadata])
|
|
90
|
+
add_bundle_attribute(attributes, transition[:metadata])
|
|
91
|
+
add_line_style_attributes(attributes, transition[:line_style])
|
|
92
|
+
if @theme
|
|
93
|
+
attributes << "color=\"#{escape_label(@theme[:stroke])}\""
|
|
94
|
+
attributes << "fontcolor=\"#{escape_label(@theme[:transition_label])}\""
|
|
95
|
+
end
|
|
96
|
+
attributes.join(', ')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def state_attribute_lines
|
|
100
|
+
@state_attribute_lines ||= @automaton.state_records.map do |name, state|
|
|
101
|
+
attributes = state_attributes(name, state)
|
|
102
|
+
suffix = attributes.empty? ? '' : " [#{attributes.join(', ')}]"
|
|
103
|
+
" #{quoted_state_id(name)}#{suffix};"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def state_attributes(name, state)
|
|
108
|
+
attributes = []
|
|
109
|
+
label = state[:label]
|
|
110
|
+
identifier = state_name(name)
|
|
111
|
+
pseudostate_shape = @automaton.final_states.include?(name) ? 'doublecircle' : pseudostate_shape(state)
|
|
112
|
+
attributes << "shape=\"#{pseudostate_shape}\"" if pseudostate_shape
|
|
113
|
+
attributes << "label=\"#{escape_label(label || name)}\"" unless label.nil? && identifier == name.to_s
|
|
114
|
+
add_metadata_attributes(attributes, state[:metadata])
|
|
115
|
+
attributes
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def pseudostate_shape(state)
|
|
119
|
+
type = pseudostate_type(state)
|
|
120
|
+
return nil unless type
|
|
121
|
+
|
|
122
|
+
PSEUDOSTATE_SHAPES.fetch(type)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def pseudostate_type(state)
|
|
126
|
+
type = dot_metadata_value(state, :shape) ||
|
|
127
|
+
dot_metadata_value(state, :type) ||
|
|
128
|
+
dot_metadata_value(state, :kind) ||
|
|
129
|
+
state[:kind] ||
|
|
130
|
+
state_metadata_value(state, :kind) ||
|
|
131
|
+
state_metadata_value(state, :dot_shape) ||
|
|
132
|
+
state_metadata_value(state, :dot_type)
|
|
133
|
+
normalized = type.to_s.tr('-', '_').to_sym
|
|
134
|
+
|
|
135
|
+
PSEUDOSTATE_SHAPES.key?(normalized) ? normalized : nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def dot_metadata_value(state, key)
|
|
139
|
+
metadata = state[:metadata]
|
|
140
|
+
return nil unless metadata.is_a?(Hash)
|
|
141
|
+
|
|
142
|
+
dot = metadata[:dot] || metadata['dot']
|
|
143
|
+
return nil unless dot.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
dot[key] || dot[key.to_s]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def state_metadata_value(state, key)
|
|
149
|
+
metadata = state[:metadata]
|
|
150
|
+
return nil unless metadata.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
metadata[key] || metadata[key.to_s]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def state_cluster_lines
|
|
156
|
+
clusters = @automaton.state_records.each_with_object({}) do |(name, state), groups|
|
|
157
|
+
group = state_group_name(state)
|
|
158
|
+
next unless group
|
|
159
|
+
|
|
160
|
+
groups[group] ||= []
|
|
161
|
+
groups[group] << name
|
|
162
|
+
end
|
|
163
|
+
return [] if clusters.empty?
|
|
164
|
+
|
|
165
|
+
clusters.flat_map do |group, states|
|
|
166
|
+
[
|
|
167
|
+
" subgraph #{quoted_id(cluster_name(group))} {",
|
|
168
|
+
" label=\"#{escape_label(group)}\";",
|
|
169
|
+
*states.map { |state| " #{quoted_state_id(state)};" },
|
|
170
|
+
' }'
|
|
171
|
+
]
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def state_group_name(state)
|
|
176
|
+
metadata = state[:metadata]
|
|
177
|
+
return nil unless metadata.is_a?(Hash)
|
|
178
|
+
|
|
179
|
+
metadata_value(metadata, :group, :cluster)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def add_metadata_attributes(attributes, metadata)
|
|
183
|
+
return unless metadata.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
url = metadata_value(metadata, :url, :href)
|
|
186
|
+
tooltip = metadata_value(metadata, :tooltip, :description)
|
|
187
|
+
|
|
188
|
+
if url
|
|
189
|
+
safe_url = UrlPolicy.validate(url, context: 'DOT metadata URL')
|
|
190
|
+
attributes << "URL=\"#{escape_label(safe_url)}\""
|
|
191
|
+
end
|
|
192
|
+
attributes << "tooltip=\"#{escape_label(tooltip)}\"" if tooltip
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def add_bundle_attribute(attributes, metadata)
|
|
196
|
+
return unless metadata.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
bundle = metadata_value(metadata, :bundle)
|
|
199
|
+
return unless bundle
|
|
200
|
+
|
|
201
|
+
attributes << "class=\"#{dot_class_name("bundle-#{bundle}")}\""
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def add_line_style_attributes(attributes, line_style)
|
|
205
|
+
return unless line_style
|
|
206
|
+
|
|
207
|
+
resolved = line_style.to_sym
|
|
208
|
+
unless LINE_STYLE_OPTIONS.include?(resolved)
|
|
209
|
+
raise ArgumentError, "Unknown DOT transition line_style: #{line_style.inspect}. Available values: #{LINE_STYLE_OPTIONS.join(', ')}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
attributes << "style=\"#{resolved}\"" unless resolved == :solid
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def metadata_value(metadata, *keys)
|
|
216
|
+
keys.each do |key|
|
|
217
|
+
value = metadata[key] || metadata[key.to_s]
|
|
218
|
+
return value if value
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def rank_constraint_lines
|
|
225
|
+
return [] unless @rank_constraints
|
|
226
|
+
|
|
227
|
+
lines = []
|
|
228
|
+
if @automaton.initial_state
|
|
229
|
+
lines << " { rank=source; #{quoted_state_id(@automaton.initial_state)}; }"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
unless @automaton.final_states.empty?
|
|
233
|
+
final_states = @automaton.final_states.map { |state| "#{quoted_state_id(state)};" }.join(' ')
|
|
234
|
+
lines << " { rank=sink; #{final_states} }"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
lines
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def rankdir
|
|
241
|
+
case @direction
|
|
242
|
+
when :tb
|
|
243
|
+
'TB'
|
|
244
|
+
when :bt
|
|
245
|
+
'BT'
|
|
246
|
+
when :rl
|
|
247
|
+
'RL'
|
|
248
|
+
else
|
|
249
|
+
'LR'
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def escape_label(label)
|
|
254
|
+
label.to_s
|
|
255
|
+
.gsub('\\') { '\\\\' }
|
|
256
|
+
.gsub('"') { '\\"' }
|
|
257
|
+
.gsub(/\r\n?|\n/) { '\\n' }
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def state_name(name)
|
|
261
|
+
@state_names.fetch(name) do
|
|
262
|
+
@identifiers.allocate([:external_state, name], preferred: name, prefix: 'state')
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def quoted_state_id(name)
|
|
267
|
+
quoted_id(state_name(name))
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def quoted_id(identifier)
|
|
271
|
+
"\"#{escape_label(identifier)}\""
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def cluster_name(group)
|
|
275
|
+
identifier = @identifiers.allocate([:group, group], preferred: group, prefix: 'group')
|
|
276
|
+
"cluster_#{identifier}"
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def dot_class_name(value)
|
|
280
|
+
value.to_s.gsub(/[^A-Za-z0-9_-]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|