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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +74 -8
  3. data/README.md +426 -44
  4. data/SECURITY.md +47 -0
  5. data/docs/architecture.md +30 -0
  6. data/docs/cli.md +27 -0
  7. data/docs/custom-exporters.md +36 -0
  8. data/docs/exporters.md +17 -0
  9. data/docs/input-schema.md +26 -0
  10. data/docs/migration-1.1.md +19 -0
  11. data/docs/performance.md +19 -0
  12. data/docs/releasing.md +19 -0
  13. data/exe/graphomaton +9 -0
  14. data/lib/graphomaton/atomic_file.rb +26 -0
  15. data/lib/graphomaton/cli/config.rb +102 -0
  16. data/lib/graphomaton/cli.rb +841 -0
  17. data/lib/graphomaton/errors.rb +11 -0
  18. data/lib/graphomaton/exporter_registry.rb +127 -0
  19. data/lib/graphomaton/exporters/dot.rb +255 -18
  20. data/lib/graphomaton/exporters/mermaid.rb +705 -25
  21. data/lib/graphomaton/exporters/pdf.rb +131 -0
  22. data/lib/graphomaton/exporters/plantuml.rb +250 -13
  23. data/lib/graphomaton/exporters/png.rb +172 -0
  24. data/lib/graphomaton/exporters/svg.rb +2775 -231
  25. data/lib/graphomaton/exporters/webp.rb +185 -0
  26. data/lib/graphomaton/exporters.rb +11 -4
  27. data/lib/graphomaton/identifier_allocator.rb +33 -0
  28. data/lib/graphomaton/input_policy.rb +82 -0
  29. data/lib/graphomaton/layout/force_tree.rb +127 -0
  30. data/lib/graphomaton/model.rb +218 -0
  31. data/lib/graphomaton/process_runner.rb +154 -0
  32. data/lib/graphomaton/url_policy.rb +40 -0
  33. data/lib/graphomaton/version.rb +1 -1
  34. data/lib/graphomaton.rb +2869 -54
  35. data/sig/graphomaton.rbs +127 -0
  36. metadata +34 -24
  37. data/.codespellignore +0 -0
  38. data/.rspec +0 -1
  39. data/CODE_OF_CONDUCT.md +0 -132
  40. data/Rakefile +0 -8
  41. data/sample/basic.rb +0 -30
  42. data/sample/complex.rb +0 -32
  43. data/sample/long_names.rb +0 -20
  44. data/sample/nfa.rb +0 -28
  45. data/sample/skip_states.rb +0 -23
  46. data/spec/exporters/dot_spec.rb +0 -146
  47. data/spec/exporters/mermaid_spec.rb +0 -154
  48. data/spec/exporters/plantuml_spec.rb +0 -144
  49. data/spec/exporters/svg_spec.rb +0 -314
  50. data/spec/graphomaton_edge_cases_spec.rb +0 -322
  51. data/spec/graphomaton_spec.rb +0 -371
  52. 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
- def initialize(automaton)
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 << " [*] --> #{sanitize_state_name(@automaton.initial_state)}" if @automaton.initial_state
69
+ lines << " [*] --> #{state_name(@automaton.initial_state)}" if @automaton.initial_state
14
70
 
15
- @automaton.transitions.each do |trans|
16
- from = sanitize_state_name(trans[:from])
17
- to = sanitize_state_name(trans[: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 << " #{sanitize_state_name(state)} --> [*]"
79
+ lines << " #{state_name(state)} --> [*]"
24
80
  end
25
81
 
26
- lines.join("\n")
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="ja">
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
- <title>状態図 - Graphomaton</title>
38
- <script type="module">
39
- import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
40
- mermaid.initialize({ startOnLoad: true, theme: 'default' });
41
- </script>
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>状態図</h1>
152
+ <h1>#{escape_text(title_text)}</h1>
71
153
  <div class="info">
72
- <p><strong>注意:</strong> この図はMermaid.jsを使用してブラウザ上でレンダリングされます。オフライン環境では動作しません。</p>
154
+ <p><strong>#{ui[:notice_label]}</strong> #{offline ? ui[:offline_notice] : ui[:online_notice]}</p>
73
155
  </div>
74
- <div class="mermaid">
75
- #{mermaid_code}
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 sanitize_state_name(name)
85
- sanitized = name.to_s.gsub(/[\s-]/, '_')
86
- if sanitized =~ /[^\x00-\x7F]/
87
- "\"#{sanitized}\""
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('&', '&amp;')
524
+ .gsub('<', '&lt;')
525
+ .gsub('>', '&gt;')
526
+ .gsub('"', '&quot;')
527
+ .gsub("'", '&#39;')
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('&', '&amp;')
542
+ .gsub('<', '&lt;')
543
+ .gsub('>', '&gt;')
544
+ .gsub('"', '&quot;')
545
+ .gsub("'", '&#39;')
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
- sanitized
769
+ 'LR'
90
770
  end
91
771
  end
92
772
  end