Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 51 additions & 41 deletions web/landing/assets/controllers/mermaid_controller.js
Original file line number Diff line number Diff line change
@@ -1,60 +1,70 @@
import { Controller } from "@hotwired/stimulus";
import Panzoom from '@panzoom/panzoom';
import { mermaid } from '../services/mermaid.js';

let sequence = 0;

export default class extends Controller {
static targets = ['svg', 'zoomIn', 'zoomOut'];
static values = { src: String };

#originalSource = null;
#onThemeChanged = null;
#id = `mermaid-diagram-${sequence++}`;
#source = null;
#panzoom = null;
#onZoomIn = null;
#onZoomOut = null;
#onWheel = null;
#onThemeChanged = null;

connect() {
this.#originalSource = this.svgTarget.textContent;
this.#render();
this.#source = this.svgTarget.textContent;

this.#onThemeChanged = this.#handleThemeChange.bind(this);
this.#onZoomIn = (event) => {
event.preventDefault();
this.#panzoom?.zoomIn();
};
this.#onZoomOut = (event) => {
event.preventDefault();
this.#panzoom?.zoomOut();
};
this.#onWheel = (event) => this.#panzoom?.zoomWithWheel(event);
this.#onThemeChanged = () => this.#render();

this.zoomInTarget.addEventListener('click', this.#onZoomIn);
this.zoomOutTarget.addEventListener('click', this.#onZoomOut);
this.element.addEventListener('wheel', this.#onWheel);
document.addEventListener('theme:changed', this.#onThemeChanged);

this.#render();
}

disconnect() {
if (this.#onThemeChanged) {
document.removeEventListener('theme:changed', this.#onThemeChanged);
}
}
this.zoomInTarget.removeEventListener('click', this.#onZoomIn);
this.zoomOutTarget.removeEventListener('click', this.#onZoomOut);
this.element.removeEventListener('wheel', this.#onWheel);
document.removeEventListener('theme:changed', this.#onThemeChanged);

#handleThemeChange(event) {
const resolved = event.detail?.resolved || 'light';
mermaid.initialize({
startOnLoad: false,
theme: resolved === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
flowchart: { useMaxWidth: true, htmlLabels: true },
});

this.svgTarget.removeAttribute('data-processed');
this.svgTarget.innerHTML = '';
this.svgTarget.textContent = this.#originalSource;
this.#render();
this.#panzoom?.destroy();
this.#panzoom = null;
}

#render() {
mermaid.run({
nodes: [this.svgTarget],
postRenderCallback: () => {
this.#panzoom = Panzoom(this.svgTarget, {});
this.#panzoom.pan(0, 0);
this.element.addEventListener('wheel', this.#panzoom.zoomWithWheel);

this.zoomInTarget.addEventListener('click', (event) => {
event.preventDefault();
this.#panzoom.zoomIn();
});

this.zoomOutTarget.addEventListener('click', (event) => {
event.preventDefault();
this.#panzoom.zoomOut();
});
},
});
async #render() {
this.#panzoom?.destroy();
this.#panzoom = null;

try {
const { svg, bindFunctions } = await (await mermaid(this.srcValue)).render(this.#id, this.#source);

this.svgTarget.innerHTML = svg;
bindFunctions?.(this.svgTarget);

this.#panzoom = Panzoom(this.svgTarget, {});
this.#panzoom.pan(0, 0);
} catch (error) {
this.svgTarget.textContent = this.#source;
console.error(`Failed to render mermaid diagram "${this.#id}"`, error);
} finally {
this.element.dataset.mermaidState = 'ready';
}
}
}
2,607 changes: 2,607 additions & 0 deletions web/landing/assets/mermaid/mermaid.min.js

Large diffs are not rendered by default.

38 changes: 38 additions & 0 deletions web/landing/assets/services/mermaid.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
let loadPromise = null;
let appliedTheme = null;

function currentTheme() {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default';
}

function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');

script.src = src;
script.addEventListener('load', () => resolve(window.mermaid));
script.addEventListener('error', () => reject(new Error(`Failed to load mermaid from "${src}"`)));

document.head.appendChild(script);
});
}

export async function mermaid(src) {
loadPromise ??= loadScript(src);

const instance = await loadPromise;
const theme = currentTheme();

if (theme !== appliedTheme) {
instance.initialize({
startOnLoad: false,
theme,
securityLevel: 'loose',
flowchart: { useMaxWidth: true, htmlLabels: true },
});

appliedTheme = theme;
}

return instance;
}
5 changes: 5 additions & 0 deletions web/landing/assets/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,11 @@ code {
@apply inline-block w-[16px] h-[16px];
}

.mermaid-wrapper[data-mermaid-state="pending"] .mermaid,
.mermaid-wrapper[data-mermaid-state="pending"] .navigation {
@apply invisible;
}


.mermaid-wrapper .mermaid {
@apply z-0 !p-0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,12 @@ public function render(Node $node, ChildNodeRendererInterface $childRenderer)
if ($info === 'mermaid') {
return new HtmlElement(
'div',
['class' => 'mermaid-wrapper', 'data-controller' => 'mermaid'],
[
'class' => 'mermaid-wrapper',
'data-controller' => 'mermaid',
'data-mermaid-state' => 'pending',
'data-mermaid-src-value' => $this->packages->getUrl('mermaid/mermaid.min.js'),
],
$this->renderElements([
new HtmlElement('div', ['class' => 'navigation'], $this->renderElements([
new HTMLElement(
Expand Down
12 changes: 0 additions & 12 deletions web/landing/templates/base.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,6 @@
{% endblock %}

{% block javascripts %}
<script src="https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default',
securityLevel: 'loose',
flowchart: {
useMaxWidth: true,
htmlLabels: true
}
});
</script>
<script>
window.flow = {
dsl: {{ dsl() | raw }},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Flow\Website\Tests\Double;

use League\CommonMark\Renderer\ChildNodeRendererInterface;

final class NullChildNodeRenderer implements ChildNodeRendererInterface
{
public function getBlockSeparator(): string
{
return "\n";
}

public function getInnerSeparator(): string
{
return "\n";
}

public function renderNodes(iterable $nodes): string
{
return '';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace Flow\Website\Tests\Unit\Service\Markdown;

use Flow\Website\Service\Markdown\MermaidCodeRenderer;
use Flow\Website\Tests\Double\NullChildNodeRenderer;
use InvalidArgumentException;
use League\CommonMark\Extension\CommonMark\Node\Block\FencedCode;
use League\CommonMark\Node\Block\Paragraph;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Asset\Packages;
use Symfony\Component\Asset\PathPackage;
use Symfony\Component\Asset\VersionStrategy\EmptyVersionStrategy;

final class MermaidCodeRendererTest extends TestCase
{
public function buildRenderer(): MermaidCodeRenderer
{
return new MermaidCodeRenderer(new Packages(new PathPackage('/', new EmptyVersionStrategy())));
}

public function test_renders_wrapper_for_mermaid_fenced_code(): void
{
$node = new FencedCode(3, '`', 0);
$node->setInfo('mermaid');
$node->setLiteral("flowchart LR\n A[App] --> B[Tracer]\n");

static::assertSame(
'<div class="mermaid-wrapper" data-controller="mermaid" data-mermaid-state="pending"'
. ' data-mermaid-src-value="/mermaid/mermaid.min.js">'
. '<div class="navigation">'
. '<button class="button" data-mermaid-target="zoomIn"><img src="/images/icons/zoom-in.svg" /></button>'
. '<button class="button" data-mermaid-target="zoomOut"><img src="/images/icons/zoom-out.svg" /></button>'
. '</div>'
. "<pre class=\"mermaid\" data-mermaid-target=\"svg\">flowchart LR\n A[App] --> B[Tracer]\n</pre>"
. '</div>',
(string) $this->buildRenderer()->render($node, new NullChildNodeRenderer()),
);
}

#[TestWith(['php'])]
#[TestWith(['json'])]
#[TestWith([''])]
public function test_returns_null_for_non_mermaid_fenced_code(string $info): void
{
$node = new FencedCode(3, '`', 0);
$node->setInfo($info);
$node->setLiteral('echo "not a diagram";');

static::assertNull($this->buildRenderer()->render($node, new NullChildNodeRenderer()));
}

public function test_throws_on_incompatible_node_type(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Incompatible node type: ' . Paragraph::class);

$this->buildRenderer()->render(new Paragraph(), new NullChildNodeRenderer());
}
}