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

JBZoo / Mermaid-PHP / 29993003552

23 Jul 2026 08:54AM UTC coverage: 89.767% (+1.2%) from 88.553%
29993003552

push

github

web-flow
feat(render): Remove jQuery and add opt-in self-hosting for Mermaid (#30)

The generated HTML unconditionally loaded jQuery and Mermaid from
third-party CDNs. When a visitor opened the page their IP was sent to
those hosts - a GDPR concern in the EU - and the page could not render
offline. This makes the external requests avoidable and lets consumers
keep everything on their own infrastructure, while the default behavior
stays unchanged.

- Removes the hardcoded jQuery CDN dependency entirely (zoom and path
highlighting are now native JS).
- Adds an opt-in `mermaid => [kind, source]` option to load Mermaid
three ways: `esm-url` (CDN, default), `umd-url` (self-hosted from your
own domain), or `umd-inline` (embedded into a single self-contained HTML
file for zero network requests). Legacy `mermaid_url` still works.
- Bumps the default Mermaid CDN to v11.

Public method signatures are unchanged; only the generated HTML differs
(no jQuery). Closes #9.

111 of 115 new or added lines in 1 file covered. (96.52%)

500 of 557 relevant lines covered (89.77%)

87.82 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\Timeline\Timeline;
22

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

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

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

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

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

55
        $title     = (string)($params['title'] ?? '');
312✔
56
        $pageTitle = $title === '' ? $title : 'JBZoo - Mermaid Graph';
312✔
57

58
        [$mermaidKind, $mermaidSource] = self::resolveMermaidSource($params);
312✔
59

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

81
        $debugCode = '';
264✔
82
        if ($isDebug) {
264✔
83
            $graphParams = \json_encode($graph->getParams(), \JSON_THROW_ON_ERROR | \JSON_PRETTY_PRINT);
204✔
84

85
            $debugCode .= '<hr>';
204✔
86
            $debugCode .= '<pre><code>' . \htmlentities((string)$graph) . '</code></pre>';
204✔
87
            $debugCode .= '<hr>';
204✔
88
            $debugCode .= "<pre><code>Params = {$graphParams}</code></pre>";
204✔
89
        }
90

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

100
            $title !== '' ? '<h1>' . \htmlspecialchars($title, \ENT_QUOTES) . '</h1><hr>' : '',
264✔
101

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

104
            $debugCode,
264✔
105

106
            self::buildInteractionScripts($showZoom),
264✔
107

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

112
            '</body>',
264✔
113
            '</html>',
264✔
114
        ];
264✔
115

116
        return \implode(\PHP_EOL, $html);
264✔
117
    }
118

119
    public static function escape(string $text): string
120
    {
121
        $text = \trim($text);
×
122
        $text = \htmlentities($text);
×
123

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

126
        return "\"{$text}\"";
×
127
    }
128

129
    public static function getId(string $userFriendlyId): string
130
    {
131
        return \md5($userFriendlyId);
×
132
    }
133

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

141
        if ($json === false) {
×
142
            throw new \RuntimeException('Can\'t encode JSON');
×
143
        }
144

145
        $params = \base64_encode($json);
×
146

147
        return "https://mermaid-js.github.io/mermaid-live-editor/#/edit/{$params}";
×
148
    }
149

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

161
        if ($hasDescriptor && $hasLegacyUrl) {
312✔
162
            throw new Exception('Use either the "mermaid" descriptor or "mermaid_url", not both.');
6✔
163
        }
164

165
        if ($hasDescriptor) {
306✔
166
            return self::resolveMermaidDescriptor($params['mermaid']);
66✔
167
        }
168

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

174
        return ['esm-url', $url];
240✔
175
    }
176

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

186
        $kind   = $descriptor['kind'] ?? null;
60✔
187
        $source = $descriptor['source'] ?? null;
60✔
188

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

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

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

202
        return [$kind, $source];
24✔
203
    }
204

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

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

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

225
        return $realPath;
12✔
226
    }
227

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

244
        if ($kind === 'esm-url') {
264✔
245
            $specifier = \json_encode(
240✔
246
                $source,
240✔
247
                \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES
240✔
248
                | \JSON_HEX_TAG | \JSON_HEX_APOS | \JSON_HEX_QUOT | \JSON_HEX_AMP,
240✔
249
            );
240✔
250

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

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

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

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

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

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

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

306
        return \base64_encode($contents);
12✔
307
    }
308

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

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

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

346
        return \implode(\PHP_EOL, $blocks);
264✔
347
    }
348
}
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