• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JBZoo / Mermaid-PHP / 30519836364

23 Jul 2026 07:33PM UTC coverage: 91.755%. Remained the same
30519836364

push

github

web-flow
feat: add Sequence Diagram support (#10) (#33)

## Summary

Adds first-class Mermaid `sequenceDiagram` support to the library,
following the same fluent, object-oriented style as the existing
`Graph`/`Timeline` families. Requested in #10.

A sequence diagram is modeled as an **ordered stream of statements with
nesting** (not two flat sets like `Graph`). A tiny `Statement` interface
(`render(int $shift): string`, extends `\Stringable`) is implemented by
every body element; participants/boxes form a header region; the
`SequenceDiagram` container renders header + ordered body and plugs into
the existing `Render`/`Helper` machinery.

## What's covered (full `sequenceDiagram` syntax)

- **Participants / actors** — aliases, `safeMode` (md5 ids, like
`Node`), repeatable per-participant `link()`
- **Messages** — all 10 arrow types, inline activation (`+`/`-`,
mutually exclusive) and explicit `activate`/`deactivate`
- **Notes** — `left of` / `right of` / `over` (one or two participants)
- **Lifecycle** — `create` / `destroy`; **comments** (`%%`)
- **Boxes** — participant grouping with optional color
- **Control blocks** — `loop`, `opt`, `break`, `rect` (single-section)
and `alt`/`else`, `par`/`and`, `critical`/`option` (multi-section),
arbitrarily nestable
- **Container** — title frontmatter, `autonumber`, `getParticipant()`,
`addMessageByIds()` (throws on unknown id), `renderHtml()`,
`getLiveEditorUrl()`

## Notes

- The `break` block class is named **`BreakBlock`** — `break` is a
reserved word in PHP, so `class Break` is a parse error. It still emits
the Mermaid `break` keyword.
- `Render::html()` / `Helper::getLiveEditorUrl()` union types widened to
accept `SequenceDiagram`.
- README updated with the new diagram type and a runnable example.

## Testing

- `tests/SequenceDiagramTest.php`: 23 tests, 58 assertions — every arrow
type, activation, notes, lifecycle, comments, boxes/links, all blocks +
nesting, container ordering, `addMessageByIds` errors, and an ... (continued)

153 of 158 new or added lines in 16 files covered. (96.84%)

13 existing lines in 2 files now uncovered.

690 of 752 relevant lines covered (91.76%)

79.63 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.82
/src/Render.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Mermaid-PHP.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Mermaid-PHP
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\MermaidPHP;
18

19
use JBZoo\MermaidPHP\ClassDiagram\ClassDiagram;
20
use JBZoo\MermaidPHP\ERDiagram\ERDiagram;
21
use JBZoo\MermaidPHP\SequenceDiagram\SequenceDiagram;
22
use JBZoo\MermaidPHP\Timeline\Timeline;
23

24
/**
25
 * @psalm-suppress ClassMustBeFinal
26
 */
27
class Render
28
{
29
    public const string THEME_DEFAULT = 'default';
30
    public const string THEME_FOREST  = 'forest';
31
    public const string THEME_DARK    = 'dark';
32
    public const string THEME_NEUTRAL = 'neutral';
33

34
    public const string DEFAULT_MERMAID_URL = 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
35

36
    /**
37
     * Valid values for the "mermaid.kind" descriptor: they bind the loader flavor to its source atomically.
38
     *  - esm-url    : ES module imported from a URL (the historical default, code-split, needs the network).
39
     *  - umd-url    : self-contained UMD bundle served from the user's own domain via <script src>.
40
     *  - umd-inline : self-contained UMD bundle read from a local file and embedded straight into the page.
41
     */
42
    private const array MERMAID_KINDS = ['esm-url', 'umd-url', 'umd-inline'];
43

44
    /** Safety cap for an inlined bundle (the standalone UMD build is ~3.5 MB; 20 MB leaves generous head-room). */
45
    private const int MAX_INLINE_BYTES = 20_000_000;
46

47
    /**
48
     * @param array<string, mixed> $params
49
     */
50
    public static function html(
51
        ClassDiagram|ERDiagram|Graph|SequenceDiagram|Timeline $graph,
52
        array $params = [],
53
    ): string {
54
        $theme    = (string)($params['theme'] ?? self::THEME_FOREST);
318✔
55
        $showZoom = (bool)($params['show-zoom'] ?? true);
318✔
56
        $isDebug  = (bool)($params['debug'] ?? false);
318✔
57

58
        $title     = (string)($params['title'] ?? '');
318✔
59
        $pageTitle = $title === '' ? $title : 'JBZoo - Mermaid Graph';
318✔
60

61
        [$mermaidKind, $mermaidSource] = self::resolveMermaidSource($params);
318✔
62

63
        /** @see https://mermaid-js.github.io/mermaid/#/mermaidAPI?id=loglevel */
64
        $mermaidParams = \json_encode([
270✔
65
            'startOnLoad' => false, // we render explicitly via mermaid.run() below
270✔
66
            'theme'       => $theme,
270✔
67
            'themeCSS'    => \implode(\PHP_EOL, [
270✔
68
                '.edgePath .path:hover {stroke-width:4px; cursor:pointer}',
270✔
69
                '.edgeLabel {border-radius:4px}',
270✔
70
                '.label {font-family:Source Sans Pro,Helvetica Neue,Arial,sans-serif;}',
270✔
71
            ]),
270✔
72
            'maxTextSize'         => 1000000, // almost no size limitation
270✔
73
            'loglevel'            => 'debug',
270✔
74
            'securityLevel'       => 'loose',
270✔
75
            'arrowMarkerAbsolute' => true,
270✔
76
            'flowchart'           => [
270✔
77
                'htmlLabels'     => true,
270✔
78
                'useMaxWidth'    => true,
270✔
79
                'diagramPadding' => 12,
270✔
80
                'curve'          => 'basis',
270✔
81
            ],
270✔
82
        ], \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT | \JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_HEX_AMP);
270✔
83

84
        $debugCode = '';
270✔
85
        if ($isDebug) {
270✔
86
            $graphParams = \json_encode($graph->getParams(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
210✔
87

88
            $debugCode .= '<hr>';
210✔
89
            $debugCode .= '<pre><code>' . \htmlentities((string)$graph) . '</code></pre>';
210✔
90
            $debugCode .= '<hr>';
210✔
91
            $debugCode .= "<pre><code>Params = {$graphParams}</code></pre>";
210✔
92
        }
93

94
        $html = [
270✔
95
            '<!DOCTYPE html>',
270✔
96
            '<html lang="en">',
270✔
97
            '<head>',
270✔
98
            '    <meta charset="utf-8">',
270✔
99
            '    <title>' . \htmlspecialchars($pageTitle, \ENT_QUOTES) . '</title>',
270✔
100
            '</head>',
270✔
101
            '<body>',
270✔
102

103
            $title !== '' ? '<h1>' . \htmlspecialchars($title, \ENT_QUOTES) . '</h1><hr>' : '',
270✔
104

105
            '    <div class="mermaid" style="margin-top:20px;">' . $graph->__toString() . '</div>',
270✔
106

107
            $debugCode,
270✔
108

109
            self::buildInteractionScripts($showZoom),
270✔
110

111
            // The loader + bootstrap live AFTER the diagram markup so a synchronous UMD/inline script
112
            // cannot fire before the DOM (or the config) is ready.
113
            self::buildMermaidBlock($mermaidKind, $mermaidSource, $mermaidParams),
270✔
114

115
            '</body>',
270✔
116
            '</html>',
270✔
117
        ];
270✔
118

119
        return \implode(\PHP_EOL, $html);
270✔
120
    }
121

122
    public static function escape(string $text): string
123
    {
UNCOV
124
        $text = \trim($text);
×
UNCOV
125
        $text = \htmlentities($text);
×
126

127
        $text = \str_replace(['&', '#lt;', '#gt;'], ['#', '<', '>'], $text);
×
128

UNCOV
129
        return "\"{$text}\"";
×
130
    }
131

132
    public static function getId(string $userFriendlyId): string
133
    {
UNCOV
134
        return \md5($userFriendlyId);
×
135
    }
136

137
    public static function getLiveEditorUrl(ERDiagram|Graph|Timeline $graph): string
138
    {
UNCOV
139
        $json = \json_encode([
×
UNCOV
140
            'code'    => (string)$graph,
×
UNCOV
141
            'mermaid' => ['theme' => 'forest'],
×
142
        ]);
×
143

144
        if ($json === false) {
×
145
            throw new \RuntimeException('Can\'t encode JSON');
×
146
        }
147

148
        $params = \base64_encode($json);
×
149

UNCOV
150
        return "https://mermaid-js.github.io/mermaid-live-editor/#/edit/{$params}";
×
151
    }
152

153
    /**
154
     * Resolve how Mermaid should be loaded into the page.
155
     *
156
     * @param  array<string, mixed>        $params
157
     * @return array{0: string, 1: string} [kind, source]
158
     */
159
    private static function resolveMermaidSource(array $params): array
160
    {
161
        $hasDescriptor = \array_key_exists('mermaid', $params);
318✔
162
        $hasLegacyUrl  = \array_key_exists('mermaid_url', $params);
318✔
163

164
        if ($hasDescriptor && $hasLegacyUrl) {
318✔
165
            throw new Exception('Use either the "mermaid" descriptor or "mermaid_url", not both.');
6✔
166
        }
167

168
        if ($hasDescriptor) {
312✔
169
            return self::resolveMermaidDescriptor($params['mermaid']);
66✔
170
        }
171

172
        $url = (string)($params['mermaid_url'] ?? self::DEFAULT_MERMAID_URL);
246✔
173
        if ($url === '' || \str_contains($url, "\0")) {
246✔
UNCOV
174
            throw new Exception('The "mermaid_url" option must be a non-empty string without NUL bytes.');
×
175
        }
176

177
        return ['esm-url', $url];
246✔
178
    }
179

180
    /**
181
     * @return array{0: string, 1: string} [kind, source]
182
     */
183
    private static function resolveMermaidDescriptor(mixed $descriptor): array
184
    {
185
        if (!\is_array($descriptor)) {
66✔
186
            throw new Exception('The "mermaid" option must be an array like ["kind" => ..., "source" => ...].');
6✔
187
        }
188

189
        $kind   = $descriptor['kind'] ?? null;
60✔
190
        $source = $descriptor['source'] ?? null;
60✔
191

192
        if (!\in_array($kind, self::MERMAID_KINDS, true)) {
60✔
193
            throw new Exception('The "mermaid.kind" must be one of: ' . \implode(', ', self::MERMAID_KINDS) . '.');
6✔
194
        }
195

196
        if (!\is_string($source) || $source === '' || \str_contains($source, "\0")) {
54✔
197
            throw new Exception('The "mermaid.source" must be a non-empty string without NUL bytes.');
12✔
198
        }
199

200
        if ($kind === 'umd-inline') {
42✔
201
            // Canonicalize to the validated real path so read-back cannot follow a different path than validation.
202
            $source = self::assertReadableLocalFile($source);
30✔
203
        }
204

205
        return [$kind, $source];
24✔
206
    }
207

208
    /**
209
     * A local file whose contents get embedded is a fresh server-side trust boundary, so validate it beyond
210
     * "is readable": reject NUL bytes and stream wrappers, require a real regular file. Returns the canonical path;
211
     * the size cap is enforced at read time (filesize() can lie for procfs-like files) — see readInlineScriptBase64().
212
     */
213
    private static function assertReadableLocalFile(string $path): string
214
    {
215
        if (\str_contains($path, "\0")) {
30✔
UNCOV
216
            throw new Exception('The mermaid inline source must not contain a NUL byte.');
×
217
        }
218

219
        if (\preg_match('~^[a-zA-Z][a-zA-Z0-9+.\-]*://~', $path) === 1) {
30✔
220
            throw new Exception("The mermaid inline source must be a local file, not a stream wrapper: {$path}");
6✔
221
        }
222

223
        $realPath = \realpath($path);
24✔
224
        if ($realPath === false || !\is_file($realPath)) {
24✔
225
            throw new Exception("The mermaid inline file was not found or is not a regular file: {$path}");
12✔
226
        }
227

228
        return $realPath;
12✔
229
    }
230

231
    /**
232
     * Build the loader + a single post-DOM bootstrap (initialize + explicit run) for the chosen source.
233
     */
234
    private static function buildMermaidBlock(string $kind, string $source, string $mermaidParams): string
235
    {
236
        // Initialize immediately, but defer the render to window.load so web fonts are ready (Mermaid's
237
        // historical startOnLoad behavior), falling back to an immediate run if load already happened.
238
        $bootstrap = [
270✔
239
            "    mermaid.initialize({$mermaidParams});",
270✔
240
            '    if (document.readyState === "complete") {',
270✔
241
            '        mermaid.run();',
270✔
242
            '    } else {',
270✔
243
            '        window.addEventListener("load", function () { mermaid.run(); }, {once: true});',
270✔
244
            '    }',
270✔
245
        ];
270✔
246

247
        if ($kind === 'esm-url') {
270✔
248
            $specifier = \json_encode(
246✔
249
                $source,
246✔
250
                \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES
246✔
251
                | \JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_HEX_AMP,
246✔
252
            );
246✔
253

254
            return \implode(\PHP_EOL, [
246✔
255
                '<script type="module">',
246✔
256
                "    import mermaid from {$specifier};",
246✔
257
                '    window.mermaid = mermaid;',
246✔
258
                ...$bootstrap,
246✔
259
                '</script>',
246✔
260
            ]);
246✔
261
        }
262

263
        $loader = $kind === 'umd-url'
24✔
264
            ? '<script src="' . \htmlspecialchars($source, \ENT_QUOTES) . '"></script>'
12✔
265
            : self::buildInlineLoader($source);
12✔
266

267
        return \implode(\PHP_EOL, [
24✔
268
            $loader,
24✔
269
            '<script>',
24✔
270
            ...$bootstrap,
24✔
271
            '</script>',
24✔
272
        ]);
24✔
273
    }
274

275
    /**
276
     * Embed a local UMD bundle without putting arbitrary JavaScript into raw <script> text (which the HTML
277
     * tokenizer can terminate early on "</script", "<!--", "<script" sequences). The bytes travel as base64
278
     * (an HTML-safe alphabet) and are decoded + injected as a real script element, executed synchronously.
279
     */
280
    private static function buildInlineLoader(string $realPath): string
281
    {
282
        $base64 = self::readInlineScriptBase64($realPath);
12✔
283

284
        return \implode(\PHP_EOL, [
12✔
285
            '<script>',
12✔
286
            '    (function () {',
12✔
287
            "        var bytes = Uint8Array.from(atob(\"{$base64}\"), function (c) { return c.charCodeAt(0); });",
12✔
288
            '        var code = new TextDecoder("utf-8").decode(bytes);',
12✔
289
            '        var element = document.createElement("script");',
12✔
290
            '        element.textContent = code;',
12✔
291
            '        document.head.appendChild(element);',
12✔
292
            '    })();',
12✔
293
            '</script>',
12✔
294
        ]);
12✔
295
    }
296

297
    private static function readInlineScriptBase64(string $realPath): string
298
    {
299
        // Bound the read to the cap + 1 byte: memory stays bounded even if the file lies about its size.
300
        $contents = \file_get_contents($realPath, false, null, 0, self::MAX_INLINE_BYTES + 1);
12✔
301
        if ($contents === false) {
12✔
UNCOV
302
            throw new Exception("Unable to read the mermaid inline file: {$realPath}");
×
303
        }
304

305
        if (\strlen($contents) > self::MAX_INLINE_BYTES) {
12✔
UNCOV
306
            throw new Exception("The mermaid inline file is too large to embed: {$realPath}");
×
307
        }
308

309
        return \base64_encode($contents);
12✔
310
    }
311

312
    /**
313
     * Native (jQuery-free) interaction handlers: a "Zoom In" button and a delegated "click a path to
314
     * highlight it" listener. Delegation on `document` is required because Mermaid creates the SVG paths later.
315
     */
316
    private static function buildInteractionScripts(bool $showZoom): string
317
    {
318
        $blocks = [];
270✔
319

320
        if ($showZoom) {
270✔
321
            $blocks[] = \implode(\PHP_EOL, [
264✔
322
                '<input type="button" class="btn btn-primary" id="zoom" value="Zoom In">',
264✔
323
                '<script>',
264✔
324
                '    document.getElementById("zoom").addEventListener("click", function () {',
264✔
325
                '        document.querySelectorAll(".mermaid").forEach(function (element) {',
264✔
326
                '            element.removeAttribute("data-processed");',
264✔
327
                '            var svg = element.querySelector("svg");',
264✔
328
                '            if (svg !== null) {',
264✔
329
                '                element.style.width = window.getComputedStyle(svg).maxWidth;',
264✔
330
                '            }',
264✔
331
                '        });',
264✔
332
                '    });',
264✔
333
                '</script>',
264✔
334
            ]);
264✔
335
        }
336

337
        $blocks[] = \implode(\PHP_EOL, [
270✔
338
            '<script>',
270✔
339
            '    document.addEventListener("click", function (event) {',
270✔
340
            '        var target = event.target;',
270✔
341
            '        var path = (target && typeof target.closest === "function") ? target.closest("path") : null;',
270✔
342
            '        if (path !== null && path.closest(".mermaid") !== null) {',
270✔
343
            '            path.style.stroke = path.style.stroke ? "" : "red";',
270✔
344
            '        }',
270✔
345
            '    });',
270✔
346
            '</script>',
270✔
347
        ]);
270✔
348

349
        return \implode(\PHP_EOL, $blocks);
270✔
350
    }
351
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc