graphomaton 1.0.0 → 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 +74 -8
- data/README.md +426 -44
- 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 +255 -18
- data/lib/graphomaton/exporters/mermaid.rb +705 -25
- data/lib/graphomaton/exporters/pdf.rb +131 -0
- data/lib/graphomaton/exporters/plantuml.rb +250 -13
- data/lib/graphomaton/exporters/png.rb +172 -0
- data/lib/graphomaton/exporters/svg.rb +2775 -231
- data/lib/graphomaton/exporters/webp.rb +185 -0
- data/lib/graphomaton/exporters.rb +11 -4
- 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 +2869 -54
- data/sig/graphomaton.rbs +127 -0
- metadata +34 -24
- data/.codespellignore +0 -0
- data/.rspec +0 -1
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -8
- data/sample/basic.rb +0 -30
- data/sample/complex.rb +0 -32
- data/sample/long_names.rb +0 -20
- data/sample/nfa.rb +0 -28
- data/sample/skip_states.rb +0 -23
- data/spec/exporters/dot_spec.rb +0 -146
- data/spec/exporters/mermaid_spec.rb +0 -154
- data/spec/exporters/plantuml_spec.rb +0 -144
- data/spec/exporters/svg_spec.rb +0 -314
- data/spec/graphomaton_edge_cases_spec.rb +0 -322
- data/spec/graphomaton_spec.rb +0 -371
- data/spec/spec_helper.rb +0 -13
|
@@ -1,45 +1,118 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
3
5
|
class Graphomaton
|
|
4
6
|
module Exporters
|
|
5
7
|
class Mermaid
|
|
6
|
-
|
|
8
|
+
include Graphomaton::ExporterIntrospection
|
|
9
|
+
DEFAULT_DIRECTION = :lr
|
|
10
|
+
DEFAULT_THEME = :default
|
|
11
|
+
DEFAULT_CDN = 'https://cdn.jsdelivr.net/npm/mermaid@10.9.8/dist/mermaid.esm.min.mjs'
|
|
12
|
+
DEFAULT_MATHJAX = false
|
|
13
|
+
DEFAULT_MATHJAX_CDN = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js'
|
|
14
|
+
DEFAULT_LANG = 'en'
|
|
15
|
+
DEFAULT_SHOW_SOURCE = false
|
|
16
|
+
DEFAULT_PAN_ZOOM = false
|
|
17
|
+
DEFAULT_NOTES = false
|
|
18
|
+
DEFAULT_CLASS_DEFS = false
|
|
19
|
+
DIRECTION_OPTIONS = %i[lr tb rl bt].freeze
|
|
20
|
+
PSEUDOSTATE_TYPES = %i[choice fork join].freeze
|
|
21
|
+
RESERVED_IDENTIFIERS = %w[state note direction class classDef hide as of].freeze
|
|
22
|
+
UI_TEXT = {
|
|
23
|
+
'en' => {
|
|
24
|
+
default_title: 'Automaton diagram',
|
|
25
|
+
notice_label: 'Note:',
|
|
26
|
+
online_notice: 'This diagram is rendered in the browser with Mermaid.js and requires network access.',
|
|
27
|
+
offline_notice: 'Mermaid.js is loaded from a classic script asset.',
|
|
28
|
+
controls_label: 'Diagram zoom controls',
|
|
29
|
+
viewer_label: 'Zoomable automaton diagram',
|
|
30
|
+
zoom_out: 'Zoom out',
|
|
31
|
+
zoom_reset: 'Reset zoom',
|
|
32
|
+
zoom_in: 'Zoom in',
|
|
33
|
+
reset: 'Reset'
|
|
34
|
+
}.freeze,
|
|
35
|
+
'ja' => {
|
|
36
|
+
default_title: 'オートマトン図',
|
|
37
|
+
notice_label: '注意:',
|
|
38
|
+
online_notice: 'この図は Mermaid.js を使用してブラウザ上で描画されるため、ネットワーク接続が必要です。',
|
|
39
|
+
offline_notice: 'Mermaid.js は classic script アセットから読み込まれます。',
|
|
40
|
+
controls_label: '図のズーム操作',
|
|
41
|
+
viewer_label: 'ズーム可能なオートマトン図',
|
|
42
|
+
zoom_out: '縮小',
|
|
43
|
+
zoom_reset: 'ズームをリセット',
|
|
44
|
+
zoom_in: '拡大',
|
|
45
|
+
reset: 'リセット'
|
|
46
|
+
}.freeze
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
def initialize(automaton, direction: DEFAULT_DIRECTION, notes: DEFAULT_NOTES, class_defs: DEFAULT_CLASS_DEFS)
|
|
7
50
|
@automaton = automaton
|
|
51
|
+
@direction = resolve_direction(direction)
|
|
52
|
+
@notes = notes
|
|
53
|
+
@class_defs = class_defs
|
|
54
|
+
@identifiers = IdentifierAllocator.new(reserved: RESERVED_IDENTIFIERS)
|
|
55
|
+
@state_names = allocate_state_names
|
|
56
|
+
@hierarchy_usable = @automaton.validation_diagnostics.none? do |diagnostic|
|
|
57
|
+
diagnostic.code == 'invalid-state-hierarchy'
|
|
58
|
+
end
|
|
8
59
|
end
|
|
9
60
|
|
|
10
61
|
def export
|
|
11
62
|
lines = ['stateDiagram-v2']
|
|
63
|
+
lines << " direction #{direction_keyword}"
|
|
64
|
+
lines.concat(state_alias_lines)
|
|
65
|
+
lines.concat(pseudostate_lines)
|
|
66
|
+
lines.concat(composite_state_lines)
|
|
67
|
+
lines.concat(state_group_lines)
|
|
12
68
|
|
|
13
|
-
lines << " [*] --> #{
|
|
69
|
+
lines << " [*] --> #{state_name(@automaton.initial_state)}" if @automaton.initial_state
|
|
14
70
|
|
|
15
|
-
@automaton.
|
|
16
|
-
from =
|
|
17
|
-
to =
|
|
18
|
-
label = trans[:label]
|
|
71
|
+
@automaton.transition_records.each do |trans|
|
|
72
|
+
from = state_name(trans[:from])
|
|
73
|
+
to = state_name(trans[:to])
|
|
74
|
+
label = format_label(trans[:label])
|
|
19
75
|
lines << " #{from} --> #{to} : #{label}"
|
|
20
76
|
end
|
|
21
77
|
|
|
22
78
|
@automaton.final_states.each do |state|
|
|
23
|
-
lines << " #{
|
|
79
|
+
lines << " #{state_name(state)} --> [*]"
|
|
24
80
|
end
|
|
25
81
|
|
|
26
|
-
lines.
|
|
82
|
+
lines.concat(state_note_lines) if @notes
|
|
83
|
+
lines.concat(class_definition_lines) if @class_defs
|
|
84
|
+
|
|
85
|
+
"#{lines.join("\n")}\n"
|
|
27
86
|
end
|
|
28
87
|
|
|
29
|
-
def export_html
|
|
88
|
+
def export_html(theme: DEFAULT_THEME, cdn: DEFAULT_CDN, inline_mermaid: false, offline: false, title: nil, lang: DEFAULT_LANG,
|
|
89
|
+
show_source: DEFAULT_SHOW_SOURCE, pan_zoom: DEFAULT_PAN_ZOOM,
|
|
90
|
+
mathjax: DEFAULT_MATHJAX, mathjax_cdn: DEFAULT_MATHJAX_CDN,
|
|
91
|
+
inline_mathjax: false, self_contained: false, nonce: nil, csp: false,
|
|
92
|
+
mermaid_sha256: nil, mathjax_sha256: nil)
|
|
93
|
+
if self_contained
|
|
94
|
+
inline_mermaid = true
|
|
95
|
+
offline = true
|
|
96
|
+
inline_mathjax = true if mathjax
|
|
97
|
+
end
|
|
98
|
+
resolved_nonce = resolve_nonce(nonce)
|
|
30
99
|
mermaid_code = export
|
|
100
|
+
language = resolve_language(lang)
|
|
101
|
+
ui = UI_TEXT.fetch(language)
|
|
102
|
+
title_text = title || ui[:default_title]
|
|
103
|
+
InputPolicy.text!(title_text.to_s, context: 'HTML title', max_bytes: Graphomaton::DEFAULT_MAX_LABEL_LENGTH)
|
|
104
|
+
|
|
31
105
|
<<~HTML
|
|
32
106
|
<!DOCTYPE html>
|
|
33
|
-
<html lang="
|
|
107
|
+
<html lang="#{escape_attribute(language)}">
|
|
34
108
|
<head>
|
|
35
109
|
<meta charset="UTF-8">
|
|
36
110
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
<style>
|
|
111
|
+
#{csp_meta(csp, resolved_nonce)}
|
|
112
|
+
<title>#{escape_text(title_text)}</title>
|
|
113
|
+
#{script_block(cdn: cdn, theme: theme, inline_mermaid: inline_mermaid, offline: offline, nonce: resolved_nonce, sha256: mermaid_sha256)}
|
|
114
|
+
#{mathjax_block(enabled: mathjax, cdn: mathjax_cdn, inline: inline_mathjax, nonce: resolved_nonce, sha256: mathjax_sha256)}
|
|
115
|
+
<style#{nonce_attribute(resolved_nonce)}>
|
|
43
116
|
body {
|
|
44
117
|
font-family: Arial, sans-serif;
|
|
45
118
|
max-width: 1200px;
|
|
@@ -64,16 +137,28 @@ class Graphomaton
|
|
|
64
137
|
border-radius: 4px;
|
|
65
138
|
margin-bottom: 20px;
|
|
66
139
|
}
|
|
140
|
+
pre.mermaid-source {
|
|
141
|
+
background: #f8fafc;
|
|
142
|
+
border: 1px solid #ddd;
|
|
143
|
+
border-radius: 8px;
|
|
144
|
+
overflow-x: auto;
|
|
145
|
+
padding: 16px;
|
|
146
|
+
}
|
|
147
|
+
#{pan_zoom_css(pan_zoom)}
|
|
148
|
+
#{auto_theme_css(theme)}
|
|
67
149
|
</style>
|
|
68
150
|
</head>
|
|
69
151
|
<body>
|
|
70
|
-
<h1
|
|
152
|
+
<h1>#{escape_text(title_text)}</h1>
|
|
71
153
|
<div class="info">
|
|
72
|
-
<p><strong
|
|
154
|
+
<p><strong>#{ui[:notice_label]}</strong> #{offline ? ui[:offline_notice] : ui[:online_notice]}</p>
|
|
73
155
|
</div>
|
|
74
|
-
|
|
75
|
-
|
|
156
|
+
#{pan_zoom_controls(pan_zoom, ui)}
|
|
157
|
+
<div class="mermaid#{pan_zoom ? ' pan-zoom-content' : ''}"#{pan_zoom ? %( data-pan-zoom-viewer tabindex="0" role="region" aria-label="#{escape_attribute(ui[:viewer_label])}") : ''}>
|
|
158
|
+
#{escape_text(mermaid_code)}
|
|
76
159
|
</div>
|
|
160
|
+
#{source_block(mermaid_code, show_source: show_source)}
|
|
161
|
+
#{pan_zoom_script(pan_zoom, nonce: resolved_nonce)}
|
|
77
162
|
</body>
|
|
78
163
|
</html>
|
|
79
164
|
HTML
|
|
@@ -81,12 +166,607 @@ class Graphomaton
|
|
|
81
166
|
|
|
82
167
|
private
|
|
83
168
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
169
|
+
def resolve_language(lang)
|
|
170
|
+
language = (lang || DEFAULT_LANG).to_s.downcase
|
|
171
|
+
return language if UI_TEXT.key?(language)
|
|
172
|
+
|
|
173
|
+
raise ArgumentError, "Unsupported HTML language: #{lang.inspect}. Available languages: #{UI_TEXT.keys.join(', ')}"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def resolve_theme(theme)
|
|
177
|
+
theme.to_s.delete_prefix(':')
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def script_block(cdn:, theme:, inline_mermaid:, offline:, nonce:, sha256:)
|
|
181
|
+
resolved_theme = resolve_theme(theme)
|
|
182
|
+
if offline && cdn == DEFAULT_CDN
|
|
183
|
+
raise ArgumentError, 'Offline HTML export requires cdn: to name a local classic Mermaid .js asset'
|
|
184
|
+
end
|
|
185
|
+
safe_cdn = UrlPolicy.validate_asset(cdn, context: 'Mermaid asset URL')
|
|
186
|
+
if (offline || inline_mermaid) && module_asset?(safe_cdn)
|
|
187
|
+
raise ArgumentError, 'Offline and inline Mermaid assets must use a classic .js build, not an ES module'
|
|
188
|
+
end
|
|
189
|
+
escaped_cdn = escape_attribute(safe_cdn)
|
|
190
|
+
theme_expression = mermaid_theme_expression(resolved_theme)
|
|
191
|
+
if inline_mermaid
|
|
192
|
+
return mermaid_inline_script(safe_cdn, resolved_theme, nonce: nonce, sha256: sha256)
|
|
193
|
+
end
|
|
194
|
+
raise ArgumentError, 'mermaid_sha256 requires inline_mermaid or self_contained' if sha256
|
|
195
|
+
|
|
196
|
+
if offline
|
|
197
|
+
<<~SCRIPT
|
|
198
|
+
#{mermaid_render_helper(nonce: nonce)}
|
|
199
|
+
<script#{nonce_attribute(nonce)} src="#{escaped_cdn}"></script>
|
|
200
|
+
<script#{nonce_attribute(nonce)}>
|
|
201
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: #{theme_expression} });
|
|
202
|
+
window.graphomatonMermaidReady = renderGraphomatonMermaid(mermaid);
|
|
203
|
+
</script>
|
|
204
|
+
SCRIPT
|
|
205
|
+
else
|
|
206
|
+
<<~SCRIPT
|
|
207
|
+
#{mermaid_render_helper(nonce: nonce)}
|
|
208
|
+
<script#{nonce_attribute(nonce)} type="module">
|
|
209
|
+
import mermaid from #{javascript_string(safe_cdn)};
|
|
210
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: #{theme_expression} });
|
|
211
|
+
window.graphomatonMermaidReady = renderGraphomatonMermaid(mermaid);
|
|
212
|
+
</script>
|
|
213
|
+
SCRIPT
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def mermaid_inline_script(path_or_url, theme, nonce:, sha256:)
|
|
218
|
+
theme_expression = mermaid_theme_expression(theme)
|
|
219
|
+
if File.file?(path_or_url)
|
|
220
|
+
<<~SCRIPT
|
|
221
|
+
#{mermaid_render_helper(nonce: nonce)}
|
|
222
|
+
<script#{nonce_attribute(nonce)}>
|
|
223
|
+
#{trusted_script_contents(path_or_url, context: 'Mermaid', sha256: sha256)}
|
|
224
|
+
mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', theme: #{theme_expression} });
|
|
225
|
+
window.graphomatonMermaidReady = renderGraphomatonMermaid(mermaid);
|
|
226
|
+
</script>
|
|
227
|
+
SCRIPT
|
|
228
|
+
else
|
|
229
|
+
raise ArgumentError, "Unable to inline Mermaid script from: #{path_or_url}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def module_asset?(path)
|
|
234
|
+
path.to_s.split(/[?#]/, 2).first.end_with?('.mjs')
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def mermaid_render_helper(nonce:)
|
|
238
|
+
<<~SCRIPT
|
|
239
|
+
<script#{nonce_attribute(nonce)}>
|
|
240
|
+
window.renderGraphomatonMermaid = (instance) => {
|
|
241
|
+
const render = () => instance.run({ querySelector: '.mermaid' });
|
|
242
|
+
if (document.readyState === 'loading') {
|
|
243
|
+
return new Promise((resolve, reject) => {
|
|
244
|
+
document.addEventListener('DOMContentLoaded', () => render().then(resolve, reject), { once: true });
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return render();
|
|
248
|
+
};
|
|
249
|
+
</script>
|
|
250
|
+
SCRIPT
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def mermaid_theme_expression(theme)
|
|
254
|
+
return javascript_string(theme) unless theme == 'auto'
|
|
255
|
+
|
|
256
|
+
"(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'default')"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def mathjax_block(enabled:, cdn:, inline:, nonce:, sha256:)
|
|
260
|
+
return '' unless enabled
|
|
261
|
+
|
|
262
|
+
safe_cdn = UrlPolicy.validate_asset(cdn, context: 'MathJax asset URL')
|
|
263
|
+
escaped_cdn = escape_attribute(safe_cdn)
|
|
264
|
+
loader = if inline
|
|
265
|
+
raise ArgumentError, "Unable to inline MathJax script from: #{safe_cdn}" unless File.file?(safe_cdn)
|
|
266
|
+
|
|
267
|
+
<<~SCRIPT
|
|
268
|
+
<script#{nonce_attribute(nonce)}>
|
|
269
|
+
#{trusted_script_contents(safe_cdn, context: 'MathJax', sha256: sha256)}
|
|
270
|
+
</script>
|
|
271
|
+
SCRIPT
|
|
272
|
+
else
|
|
273
|
+
raise ArgumentError, 'mathjax_sha256 requires inline_mathjax or self_contained' if sha256
|
|
274
|
+
|
|
275
|
+
%(<script defer#{nonce_attribute(nonce)} src="#{escaped_cdn}"></script>)
|
|
276
|
+
end
|
|
277
|
+
<<~SCRIPT
|
|
278
|
+
<script#{nonce_attribute(nonce)}>
|
|
279
|
+
window.MathJax = {
|
|
280
|
+
tex: { inlineMath: [['$', '$'], ['\\\\(', '\\\\)']] },
|
|
281
|
+
svg: { fontCache: 'global' }
|
|
282
|
+
};
|
|
283
|
+
window.addEventListener('load', () => {
|
|
284
|
+
Promise.resolve(window.graphomatonMermaidReady)
|
|
285
|
+
.then(() => window.MathJax.startup.promise)
|
|
286
|
+
.then(() => window.MathJax.typesetPromise())
|
|
287
|
+
.catch((error) => console.error('Unable to typeset diagram labels', error));
|
|
288
|
+
});
|
|
289
|
+
</script>
|
|
290
|
+
#{loader}
|
|
291
|
+
SCRIPT
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def auto_theme_css(theme)
|
|
295
|
+
return '' unless resolve_theme(theme) == 'auto'
|
|
296
|
+
|
|
297
|
+
<<~CSS
|
|
298
|
+
@media (prefers-color-scheme: dark) {
|
|
299
|
+
body {
|
|
300
|
+
background: #111827;
|
|
301
|
+
color: #f9fafb;
|
|
302
|
+
}
|
|
303
|
+
.mermaid {
|
|
304
|
+
background: #1f2937;
|
|
305
|
+
border-color: #374151;
|
|
306
|
+
}
|
|
307
|
+
.info,
|
|
308
|
+
pre.mermaid-source {
|
|
309
|
+
background: #1f2937;
|
|
310
|
+
border-color: #374151;
|
|
311
|
+
}
|
|
312
|
+
h1 {
|
|
313
|
+
color: #f9fafb;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
CSS
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def source_block(mermaid_code, show_source:)
|
|
320
|
+
return '' unless show_source
|
|
321
|
+
|
|
322
|
+
<<~HTML
|
|
323
|
+
<pre class="mermaid-source"><code>#{escape_text(mermaid_code)}</code></pre>
|
|
324
|
+
HTML
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def pan_zoom_css(enabled)
|
|
328
|
+
return '' unless enabled
|
|
329
|
+
|
|
330
|
+
<<~CSS
|
|
331
|
+
.pan-zoom-controls {
|
|
332
|
+
display: flex;
|
|
333
|
+
gap: 8px;
|
|
334
|
+
justify-content: flex-end;
|
|
335
|
+
margin: 20px 0 8px;
|
|
336
|
+
}
|
|
337
|
+
.pan-zoom-controls button {
|
|
338
|
+
background: #0f172a;
|
|
339
|
+
border: 0;
|
|
340
|
+
border-radius: 6px;
|
|
341
|
+
color: white;
|
|
342
|
+
cursor: pointer;
|
|
343
|
+
font: inherit;
|
|
344
|
+
padding: 8px 12px;
|
|
345
|
+
}
|
|
346
|
+
.pan-zoom-controls output {
|
|
347
|
+
align-self: center;
|
|
348
|
+
min-width: 4ch;
|
|
349
|
+
text-align: right;
|
|
350
|
+
}
|
|
351
|
+
.pan-zoom-content {
|
|
352
|
+
cursor: grab;
|
|
353
|
+
overflow: auto;
|
|
354
|
+
touch-action: none;
|
|
355
|
+
}
|
|
356
|
+
.pan-zoom-content.is-panning {
|
|
357
|
+
cursor: grabbing;
|
|
358
|
+
}
|
|
359
|
+
.pan-zoom-content svg {
|
|
360
|
+
transform-origin: 0 0;
|
|
361
|
+
user-select: none;
|
|
362
|
+
}
|
|
363
|
+
CSS
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def pan_zoom_controls(enabled, ui)
|
|
367
|
+
return '' unless enabled
|
|
368
|
+
|
|
369
|
+
<<~HTML
|
|
370
|
+
<div class="pan-zoom-controls" aria-label="#{escape_attribute(ui[:controls_label])}">
|
|
371
|
+
<button type="button" data-zoom-out aria-label="#{escape_attribute(ui[:zoom_out])}">−</button>
|
|
372
|
+
<button type="button" data-zoom-reset aria-label="#{escape_attribute(ui[:zoom_reset])}">#{escape_text(ui[:reset])}</button>
|
|
373
|
+
<button type="button" data-zoom-in aria-label="#{escape_attribute(ui[:zoom_in])}">+</button>
|
|
374
|
+
<output data-zoom-value aria-live="polite">100%</output>
|
|
375
|
+
</div>
|
|
376
|
+
HTML
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def pan_zoom_script(enabled, nonce:)
|
|
380
|
+
return '' unless enabled
|
|
381
|
+
|
|
382
|
+
<<~HTML
|
|
383
|
+
<script#{nonce_attribute(nonce)}>
|
|
384
|
+
(() => {
|
|
385
|
+
const viewer = document.querySelector('[data-pan-zoom-viewer]');
|
|
386
|
+
if (!viewer) return;
|
|
387
|
+
|
|
388
|
+
let scale = 1;
|
|
389
|
+
let x = 0;
|
|
390
|
+
let y = 0;
|
|
391
|
+
let drag = null;
|
|
392
|
+
let suppressClick = false;
|
|
393
|
+
const zoomValue = document.querySelector('[data-zoom-value]');
|
|
394
|
+
|
|
395
|
+
const apply = () => {
|
|
396
|
+
const diagram = viewer.querySelector('svg');
|
|
397
|
+
if (diagram) diagram.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
|
398
|
+
if (zoomValue) zoomValue.value = `${Math.round(scale * 100)}%`;
|
|
399
|
+
};
|
|
400
|
+
const setScale = (nextScale, originX = viewer.clientWidth / 2, originY = viewer.clientHeight / 2) => {
|
|
401
|
+
const previousScale = scale;
|
|
402
|
+
scale = Math.min(3, Math.max(0.4, nextScale));
|
|
403
|
+
const ratio = scale / previousScale;
|
|
404
|
+
x = originX - ((originX - x) * ratio);
|
|
405
|
+
y = originY - ((originY - y) * ratio);
|
|
406
|
+
apply();
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
document.querySelector('[data-zoom-in]')?.addEventListener('click', () => setScale(scale + 0.2));
|
|
410
|
+
document.querySelector('[data-zoom-out]')?.addEventListener('click', () => setScale(scale - 0.2));
|
|
411
|
+
document.querySelector('[data-zoom-reset]')?.addEventListener('click', () => {
|
|
412
|
+
scale = 1;
|
|
413
|
+
x = 0;
|
|
414
|
+
y = 0;
|
|
415
|
+
apply();
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
viewer.addEventListener('wheel', (event) => {
|
|
419
|
+
if (!event.ctrlKey && !event.metaKey) return;
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
const bounds = viewer.getBoundingClientRect();
|
|
422
|
+
setScale(
|
|
423
|
+
scale + (event.deltaY < 0 ? 0.1 : -0.1),
|
|
424
|
+
event.clientX - bounds.left,
|
|
425
|
+
event.clientY - bounds.top
|
|
426
|
+
);
|
|
427
|
+
}, { passive: false });
|
|
428
|
+
viewer.addEventListener('pointerdown', (event) => {
|
|
429
|
+
if (event.button !== 0 || event.target.closest('a, button')) return;
|
|
430
|
+
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x, y, moved: false };
|
|
431
|
+
viewer.classList.add('is-panning');
|
|
432
|
+
viewer.setPointerCapture(event.pointerId);
|
|
433
|
+
});
|
|
434
|
+
viewer.addEventListener('pointermove', (event) => {
|
|
435
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
436
|
+
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 3) drag.moved = true;
|
|
437
|
+
x = drag.x + event.clientX - drag.startX;
|
|
438
|
+
y = drag.y + event.clientY - drag.startY;
|
|
439
|
+
apply();
|
|
440
|
+
});
|
|
441
|
+
const stopDrag = (event) => {
|
|
442
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
443
|
+
suppressClick = drag.moved;
|
|
444
|
+
viewer.classList.remove('is-panning');
|
|
445
|
+
drag = null;
|
|
446
|
+
};
|
|
447
|
+
viewer.addEventListener('pointerup', stopDrag);
|
|
448
|
+
viewer.addEventListener('pointercancel', stopDrag);
|
|
449
|
+
viewer.addEventListener('click', (event) => {
|
|
450
|
+
if (!suppressClick) return;
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
event.stopPropagation();
|
|
453
|
+
suppressClick = false;
|
|
454
|
+
}, true);
|
|
455
|
+
viewer.addEventListener('keydown', (event) => {
|
|
456
|
+
if (event.key === '+' || event.key === '=') setScale(scale + 0.2);
|
|
457
|
+
else if (event.key === '-') setScale(scale - 0.2);
|
|
458
|
+
else if (event.key === '0') {
|
|
459
|
+
scale = 1;
|
|
460
|
+
x = 0;
|
|
461
|
+
y = 0;
|
|
462
|
+
apply();
|
|
463
|
+
} else return;
|
|
464
|
+
event.preventDefault();
|
|
465
|
+
});
|
|
466
|
+
})();
|
|
467
|
+
</script>
|
|
468
|
+
HTML
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def resolve_nonce(nonce)
|
|
472
|
+
return nil if nonce.nil?
|
|
473
|
+
|
|
474
|
+
value = nonce.to_s
|
|
475
|
+
unless value.bytesize.between?(8, 256) && value.match?(/\A[A-Za-z0-9+\/_=-]+\z/)
|
|
476
|
+
raise ArgumentError, 'HTML nonce must be 8-256 base64-compatible characters'
|
|
477
|
+
end
|
|
478
|
+
value
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def nonce_attribute(nonce)
|
|
482
|
+
nonce ? %( nonce="#{escape_attribute(nonce)}") : ''
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def csp_meta(csp, nonce)
|
|
486
|
+
return '' unless csp
|
|
487
|
+
|
|
488
|
+
policy = if csp == true
|
|
489
|
+
raise ArgumentError, 'csp: true requires an explicit nonce' unless nonce
|
|
490
|
+
|
|
491
|
+
"default-src 'none'; base-uri 'none'; form-action 'none'; " \
|
|
492
|
+
"script-src 'nonce-#{nonce}' 'self' https:; style-src 'nonce-#{nonce}'; " \
|
|
493
|
+
"img-src data:; font-src data: https:; connect-src https:"
|
|
494
|
+
elsif csp.is_a?(String)
|
|
495
|
+
InputPolicy.text!(csp, context: 'Content Security Policy', max_bytes: 4096)
|
|
496
|
+
else
|
|
497
|
+
raise ArgumentError, 'csp must be true, false, or a policy String'
|
|
498
|
+
end
|
|
499
|
+
%(<meta http-equiv="Content-Security-Policy" content="#{escape_attribute(policy)}">)
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def trusted_script_contents(path, context:, sha256: nil)
|
|
503
|
+
raise ArgumentError, "Unable to inline #{context} script from: #{path}" unless File.file?(path)
|
|
504
|
+
raise ArgumentError, "#{context} script exceeds 20 MiB" if File.size(path) > 20 * 1024 * 1024
|
|
505
|
+
|
|
506
|
+
contents = File.binread(path).force_encoding(Encoding::UTF_8)
|
|
507
|
+
raise ArgumentError, "#{context} script exceeds 20 MiB" if contents.bytesize > 20 * 1024 * 1024
|
|
508
|
+
|
|
509
|
+
InputPolicy.text!(contents, context: "#{context} script")
|
|
510
|
+
if sha256
|
|
511
|
+
expected = sha256.to_s.downcase
|
|
512
|
+
unless expected.match?(/\A[0-9a-f]{64}\z/)
|
|
513
|
+
raise ArgumentError, "#{context} SHA-256 must be 64 hexadecimal characters"
|
|
514
|
+
end
|
|
515
|
+
actual = Digest::SHA256.hexdigest(contents)
|
|
516
|
+
raise SecurityError, "#{context} script SHA-256 mismatch" unless actual == expected
|
|
517
|
+
end
|
|
518
|
+
contents.gsub(%r{</script}i, '<\\/script')
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def escape_attribute(text)
|
|
522
|
+
text.to_s
|
|
523
|
+
.gsub('&', '&')
|
|
524
|
+
.gsub('<', '<')
|
|
525
|
+
.gsub('>', '>')
|
|
526
|
+
.gsub('"', '"')
|
|
527
|
+
.gsub("'", ''')
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def javascript_string(value)
|
|
531
|
+
JSON.generate(value.to_s)
|
|
532
|
+
.gsub('<', '\\u003c')
|
|
533
|
+
.gsub('>', '\\u003e')
|
|
534
|
+
.gsub('&', '\\u0026')
|
|
535
|
+
.gsub("\u2028", '\\u2028')
|
|
536
|
+
.gsub("\u2029", '\\u2029')
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def escape_text(text)
|
|
540
|
+
text.to_s
|
|
541
|
+
.gsub('&', '&')
|
|
542
|
+
.gsub('<', '<')
|
|
543
|
+
.gsub('>', '>')
|
|
544
|
+
.gsub('"', '"')
|
|
545
|
+
.gsub("'", ''')
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
def allocate_state_names
|
|
549
|
+
@automaton.state_records.each_key.to_h do |name|
|
|
550
|
+
preferred = name.to_s if valid_identifier?(name)
|
|
551
|
+
[name, @identifiers.allocate([:state, name], preferred: preferred, prefix: 'state')]
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def valid_identifier?(name)
|
|
556
|
+
name.to_s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) && !RESERVED_IDENTIFIERS.include?(name.to_s)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def state_name(name)
|
|
560
|
+
@state_names.fetch(name) do
|
|
561
|
+
@identifiers.allocate([:external_state, name], prefix: 'state')
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def format_label(label)
|
|
566
|
+
label.to_s.gsub(/\r\n?|\n/, '<br/>')
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def state_alias_lines
|
|
570
|
+
@automaton.state_records.filter_map do |name, state|
|
|
571
|
+
next if valid_state_parent(state)
|
|
572
|
+
next if state_group_name(state)
|
|
573
|
+
next if pseudostate_type(state)
|
|
574
|
+
|
|
575
|
+
state_declaration_line(name, state, indentation: ' ')
|
|
576
|
+
end
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def composite_state_lines
|
|
580
|
+
roots = hierarchy_children.keys.select do |parent|
|
|
581
|
+
parent_state = @automaton.state_records.fetch(parent)
|
|
582
|
+
!valid_state_parent(parent_state) && !state_group_name(parent_state)
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
roots.flat_map { |root| composite_block_lines(root, indentation: ' ') }
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
def composite_block_lines(parent, indentation:)
|
|
589
|
+
lines = ["#{indentation}state #{state_name(parent)} {"]
|
|
590
|
+
hierarchy_children.fetch(parent, []).each do |name, state|
|
|
591
|
+
child_indentation = "#{indentation} "
|
|
592
|
+
lines << state_declaration_line(name, state, indentation: child_indentation)
|
|
593
|
+
lines.concat(composite_block_lines(name, indentation: child_indentation)) if hierarchy_children.key?(name)
|
|
594
|
+
end
|
|
595
|
+
lines << "#{indentation}}"
|
|
596
|
+
lines
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def hierarchy_children
|
|
600
|
+
@hierarchy_children ||= @automaton.state_records.each_with_object({}) do |(name, state), groups|
|
|
601
|
+
parent = valid_state_parent(state)
|
|
602
|
+
next unless parent
|
|
603
|
+
|
|
604
|
+
groups[parent] ||= []
|
|
605
|
+
groups[parent] << [name, state]
|
|
606
|
+
end
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def state_declaration_line(name, state, indentation:)
|
|
610
|
+
label = state[:label]
|
|
611
|
+
state_identifier = state_name(name)
|
|
612
|
+
type = pseudostate_type(state)
|
|
613
|
+
return "#{indentation}state #{state_identifier} <<#{type}>>" if type
|
|
614
|
+
|
|
615
|
+
"#{indentation}state \"#{escape_mermaid_string(label || name)}\" as #{state_identifier}"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
def state_parent(state)
|
|
619
|
+
metadata = state[:metadata]
|
|
620
|
+
return nil unless metadata.is_a?(Hash)
|
|
621
|
+
|
|
622
|
+
metadata[:parent] || metadata['parent']
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def valid_state_parent(state)
|
|
626
|
+
return nil unless @hierarchy_usable
|
|
627
|
+
|
|
628
|
+
parent = state_parent(state)
|
|
629
|
+
return nil unless parent && @automaton.state_records.key?(parent)
|
|
630
|
+
|
|
631
|
+
parent
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
def state_group_lines
|
|
635
|
+
groups = @automaton.state_records.each_with_object({}) do |(name, state), grouped_states|
|
|
636
|
+
group = state_group_name(state)
|
|
637
|
+
next unless group
|
|
638
|
+
next if valid_state_parent(state)
|
|
639
|
+
|
|
640
|
+
grouped_states[group] ||= []
|
|
641
|
+
grouped_states[group] << [name, state]
|
|
642
|
+
end
|
|
643
|
+
return [] if groups.empty?
|
|
644
|
+
|
|
645
|
+
groups.flat_map do |group, states|
|
|
646
|
+
group_name = @identifiers.allocate([:group, group], prefix: 'group')
|
|
647
|
+
lines = [" state \"#{escape_mermaid_string(group)}\" as #{group_name} {"]
|
|
648
|
+
states.each do |name, state|
|
|
649
|
+
lines << state_declaration_line(name, state, indentation: ' ')
|
|
650
|
+
lines.concat(composite_block_lines(name, indentation: ' ')) if hierarchy_children.key?(name)
|
|
651
|
+
end
|
|
652
|
+
lines << ' }'
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
def state_group_name(state)
|
|
657
|
+
metadata = state[:metadata]
|
|
658
|
+
return nil unless metadata.is_a?(Hash)
|
|
659
|
+
|
|
660
|
+
metadata[:group] || metadata['group'] || metadata[:cluster] || metadata['cluster']
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
def pseudostate_lines
|
|
664
|
+
@automaton.state_records.filter_map do |name, state|
|
|
665
|
+
type = pseudostate_type(state)
|
|
666
|
+
next unless type
|
|
667
|
+
next if valid_state_parent(state) || state_group_name(state)
|
|
668
|
+
|
|
669
|
+
" state #{state_name(name)} <<#{type}>>"
|
|
670
|
+
end
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def pseudostate_type(state)
|
|
674
|
+
type = mermaid_metadata_value(state, :shape) ||
|
|
675
|
+
mermaid_metadata_value(state, :type) ||
|
|
676
|
+
mermaid_metadata_value(state, :kind) ||
|
|
677
|
+
state[:kind] ||
|
|
678
|
+
state_metadata_value(state, :kind) ||
|
|
679
|
+
state_metadata_value(state, :mermaid_shape) ||
|
|
680
|
+
state_metadata_value(state, :mermaid_type)
|
|
681
|
+
normalized = type.to_s.tr('-', '_').to_sym
|
|
682
|
+
|
|
683
|
+
PSEUDOSTATE_TYPES.include?(normalized) ? normalized : nil
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
def mermaid_metadata_value(state, key)
|
|
687
|
+
metadata = state[:metadata]
|
|
688
|
+
return nil unless metadata.is_a?(Hash)
|
|
689
|
+
|
|
690
|
+
mermaid = metadata[:mermaid] || metadata['mermaid']
|
|
691
|
+
return nil unless mermaid.is_a?(Hash)
|
|
692
|
+
|
|
693
|
+
mermaid[key] || mermaid[key.to_s]
|
|
694
|
+
end
|
|
695
|
+
|
|
696
|
+
def state_metadata_value(state, key)
|
|
697
|
+
metadata = state[:metadata]
|
|
698
|
+
return nil unless metadata.is_a?(Hash)
|
|
699
|
+
|
|
700
|
+
metadata[key] || metadata[key.to_s]
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
def state_note_lines
|
|
704
|
+
@automaton.state_records.filter_map do |name, state|
|
|
705
|
+
note = state_note(state)
|
|
706
|
+
next unless note
|
|
707
|
+
|
|
708
|
+
" note right of #{state_name(name)}: #{format_label(note)}"
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def state_note(state)
|
|
713
|
+
metadata = state[:metadata]
|
|
714
|
+
return nil unless metadata.is_a?(Hash)
|
|
715
|
+
|
|
716
|
+
metadata[:note] || metadata['note'] ||
|
|
717
|
+
metadata[:description] || metadata['description'] ||
|
|
718
|
+
metadata[:tooltip] || metadata['tooltip']
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
def class_definition_lines
|
|
722
|
+
lines = [
|
|
723
|
+
' classDef initial fill:#dbeafe,stroke:#2563eb,color:#1e3a8a;',
|
|
724
|
+
' classDef final fill:#dcfce7,stroke:#16a34a,color:#14532d;',
|
|
725
|
+
' classDef unreachable fill:#f3f4f6,stroke:#9ca3af,color:#6b7280;',
|
|
726
|
+
' classDef dead fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;',
|
|
727
|
+
' classDef trap fill:#fef3c7,stroke:#d97706,color:#78350f;'
|
|
728
|
+
]
|
|
729
|
+
|
|
730
|
+
lines.concat(state_class_lines('initial', [@automaton.initial_state].compact))
|
|
731
|
+
lines.concat(state_class_lines('final', @automaton.final_states))
|
|
732
|
+
lines.concat(state_class_lines('unreachable', @automaton.unreachable_states))
|
|
733
|
+
lines.concat(state_class_lines('dead', @automaton.dead_states))
|
|
734
|
+
lines.concat(state_class_lines('trap', @automaton.trap_states))
|
|
735
|
+
lines
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def state_class_lines(class_name, states)
|
|
739
|
+
states.filter_map do |state|
|
|
740
|
+
next unless @automaton.state_records.key?(state)
|
|
741
|
+
|
|
742
|
+
" class #{state_name(state)} #{class_name};"
|
|
743
|
+
end
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
def escape_mermaid_string(text)
|
|
747
|
+
text.to_s
|
|
748
|
+
.gsub('\\') { '\\\\' }
|
|
749
|
+
.gsub('"') { '\\"' }
|
|
750
|
+
.gsub(/\r\n?|\n/) { '<br/>' }
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def resolve_direction(direction)
|
|
754
|
+
resolved = direction.to_sym
|
|
755
|
+
return resolved if DIRECTION_OPTIONS.include?(resolved)
|
|
756
|
+
|
|
757
|
+
raise ArgumentError, "Unknown direction: #{direction.inspect}. Available directions: #{DIRECTION_OPTIONS.join(', ')}"
|
|
758
|
+
end
|
|
759
|
+
|
|
760
|
+
def direction_keyword
|
|
761
|
+
case @direction
|
|
762
|
+
when :tb
|
|
763
|
+
'TB'
|
|
764
|
+
when :bt
|
|
765
|
+
'BT'
|
|
766
|
+
when :rl
|
|
767
|
+
'RL'
|
|
88
768
|
else
|
|
89
|
-
|
|
769
|
+
'LR'
|
|
90
770
|
end
|
|
91
771
|
end
|
|
92
772
|
end
|