Client: Mermaid diagrams, drawn by the server

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
sarahandClaude Opus 5.5 committed 2026-10-13 12:22:49 +02:00
1 parent 292614c696
commit f99b227cc3
9 files changed
+446 -16

No files matched your search

+1
View File
@@ -10,3 +10,4 @@ Frontend/dist/
.DS_Store
.vscode/
.idea/
Backend/Vendor/
+243
View File
@@ -0,0 +1,243 @@
"""Mermaid diagrams as images, for the desktop client (POST /api/diagram).
The web view draws ```mermaid blocks itself (Frontend/src/Mermaid.js), but
Flet can't, so the client asks the server instead. This draws them the same
way, with the same Mermaid, in a headless Chromium (Playwright) that's
started on the first diagram and kept for the next ones, and returns a PNG
at twice the size (sharp on HiDPI screens; the client shows it at half).
Mermaid comes from the frontend build (the Dockerfile copies it to
Backend/Vendor/; for local development, Frontend/node_modules after
`npm install`), the font from Frontend's public/Fonts."""
from __future__ import annotations
import asyncio
import hashlib
import json
import re
import tempfile
import tomllib
from collections import OrderedDict
from pathlib import Path
from typing import Any, Optional
from Settings import DefaultThemesDirectory, PaletteRoles
ApplicationDirectory = Path(__file__).resolve().parent
ProjectDirectory = ApplicationDirectory.parent
MermaidCandidates = [
ApplicationDirectory / "Vendor" / "mermaid.min.js",
ProjectDirectory / "Frontend" / "node_modules" / "mermaid" / "dist" / "mermaid.min.js",
]
FontCandidates = [
ProjectDirectory / "Frontend" / "dist" / "Fonts" / "FSEX302.ttf",
ProjectDirectory / "Frontend" / "public" / "Fonts" / "FSEX302.ttf",
]
MaxSourceLength = 20_000
RenderTimeout = 20 # seconds
CacheSize = 200
HexColour = re.compile(r"^#[0-9a-fA-F]{6}$")
class DiagramError(Exception):
"""The diagram itself is wrong (Mermaid's message)."""
class DiagramsUnavailable(Exception):
"""This server can't draw diagrams (no Chromium or no Mermaid)."""
def _FirstExisting(Candidates: list[Path]) -> Optional[Path]:
return next((Candidate for Candidate in Candidates if Candidate.is_file()), None)
def _DefaultPalette() -> dict[str, str]:
with (DefaultThemesDirectory / "CatppuccinMocha.toml").open("rb") as ThemeFile:
return tomllib.load(ThemeFile).get("Palette", {})
def CleanPalette(Palette: dict[str, Any]) -> dict[str, str]:
"""The default theme, overridden by whichever roles were sent as #rrggbb."""
Clean = _DefaultPalette()
for Role in PaletteRoles:
Value = Palette.get(Role)
if isinstance(Value, str) and HexColour.match(Value):
Clean[Role] = Value
return Clean
def ThemeVariables(Palette: dict[str, str], FontSize: int) -> dict[str, Any]:
"""Mermaid's colours from the theme's roles: the same mapping as the web
view's Frontend/src/Mermaid.js, so a diagram looks the same in both."""
Role = Palette.get
Background = Role("Background")
Red, Green, Blue = (int(Background[Index:Index + 2], 16) for Index in (1, 3, 5))
Luminance = (0.299 * Red + 0.587 * Green + 0.114 * Blue) / 255
return {
"darkMode": Luminance < 0.5,
"background": Background,
"fontFamily": '"Fixedsys Excelsior", "Cascadia Mono", monospace',
"fontSize": f"{FontSize}px",
"textColor": Role("Text"),
"lineColor": Role("TextMuted"),
"primaryColor": Role("SurfaceAlt"),
"primaryTextColor": Role("Text"),
"primaryBorderColor": Role("Purple"),
"secondaryColor": Role("Surface"),
"secondaryTextColor": Role("Text"),
"secondaryBorderColor": Role("Blue"),
"tertiaryColor": Role("Background"),
"tertiaryTextColor": Role("Text"),
"tertiaryBorderColor": Role("Border"),
"mainBkg": Role("SurfaceAlt"),
"nodeBorder": Role("Purple"),
"clusterBkg": Role("Surface"),
"clusterBorder": Role("Border"),
"edgeLabelBackground": Role("Surface"),
"titleColor": Role("Text"),
"noteBkgColor": Role("Surface"),
"noteTextColor": Role("Text"),
"noteBorderColor": Role("Yellow"),
"actorBkg": Role("SurfaceAlt"),
"actorBorder": Role("Purple"),
"actorTextColor": Role("Text"),
"actorLineColor": Role("TextMuted"),
"signalColor": Role("Text"),
"signalTextColor": Role("Text"),
"labelBoxBkgColor": Role("SurfaceAlt"),
"labelBoxBorderColor": Role("Purple"),
"labelTextColor": Role("Text"),
"loopTextColor": Role("Text"),
"activationBkgColor": Role("Surface"),
"activationBorderColor": Role("Blue"),
"pie1": Role("Blue"),
"pie2": Role("Purple"),
"pie3": Role("Green"),
"pie4": Role("Yellow"),
"pie5": Role("Orange"),
"pie6": Role("Red"),
"pieTitleTextColor": Role("Text"),
"pieSectionTextColor": Role("Black"),
"pieLegendTextColor": Role("Text"),
"pieStrokeColor": Background,
"pieOuterStrokeColor": Role("Border"),
}
# Draws #Out from the source; the page has Mermaid and the font loaded.
RenderScript = """async ({ Source, Variables, Id }) => {
await document.fonts.ready;
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", themeVariables: Variables });
try {
await mermaid.parse(Source);
} catch (Error) {
return { Error: String(Error.message || Error) };
}
const { svg } = await mermaid.render(Id, Source);
const Out = document.getElementById("Out");
Out.innerHTML = svg;
// Mermaid shrinks diagrams to fit their container; here they get their own size.
const Picture = Out.querySelector("svg");
const Box = Picture.viewBox.baseVal;
Picture.style.maxWidth = "none";
Picture.setAttribute("width", Box.width);
Picture.setAttribute("height", Box.height);
return {};
}"""
class _Renderer:
def __init__(self) -> None:
self.Lock = asyncio.Lock()
self.Playwright = None
self.Browser = None
self.Page = None
self.PageDirectory: Optional[tempfile.TemporaryDirectory] = None
self.Count = 0
self.Cache: OrderedDict[str, bytes] = OrderedDict()
async def _EnsurePage(self) -> None:
if self.Page is not None and not self.Page.is_closed():
return
Mermaid = _FirstExisting(MermaidCandidates)
if Mermaid is None:
raise DiagramsUnavailable("Diagrams aren't set up on this server (Mermaid is missing).")
try:
from playwright.async_api import async_playwright
except ImportError:
raise DiagramsUnavailable("Diagrams aren't set up on this server (Playwright is missing).")
if self.Browser is None or not self.Browser.is_connected():
try:
self.Playwright = self.Playwright or await async_playwright().start()
self.Browser = await self.Playwright.chromium.launch()
except Exception as Error:
raise DiagramsUnavailable(f"Diagrams aren't set up on this server ({Error}).")
Font = _FirstExisting(FontCandidates)
FontFace = f'@font-face {{ font-family: "Fixedsys Excelsior"; src: url("{Font.as_uri()}"); }}' if Font else ""
self.PageDirectory = self.PageDirectory or tempfile.TemporaryDirectory(prefix="SaraViewDiagrams")
PagePath = Path(self.PageDirectory.name) / "Render.html"
PagePath.write_text(
"<!doctype html><html><head><meta charset='utf-8'>"
f"<style>{FontFace} body {{ margin: 0; }} #Out {{ display: inline-block; padding: 8px; }}</style>"
f"<script src='{Mermaid.as_uri()}'></script></head><body><div id='Out'></div></body></html>",
encoding="utf-8",
)
Context = await self.Browser.new_context(device_scale_factor=2, viewport={"width": 1600, "height": 1200})
self.Page = await Context.new_page()
# Only the page's own files: a diagram can't make the server fetch anything.
async def OnlyLocalFiles(Route) -> None:
if Route.request.url.startswith("file:"):
await Route.continue_()
else:
await Route.abort()
await self.Page.route("**/*", OnlyLocalFiles)
await self.Page.goto(PagePath.as_uri())
async def Render(self, Source: str, Palette: dict[str, Any], FontSize: int) -> bytes:
if not Source.strip():
raise DiagramError("The diagram is empty.")
if len(Source) > MaxSourceLength:
raise DiagramError(f"The diagram is too long (over {MaxSourceLength} characters).")
Variables = ThemeVariables(CleanPalette(Palette), max(8, min(int(FontSize), 40)))
Key = hashlib.sha256(json.dumps([Source, Variables], sort_keys=True).encode()).hexdigest()
async with self.Lock:
if Key in self.Cache:
self.Cache.move_to_end(Key)
return self.Cache[Key]
await self._EnsurePage()
self.Count += 1
try:
Result = await asyncio.wait_for(
self.Page.evaluate(RenderScript, {"Source": Source, "Variables": Variables, "Id": f"Diagram{self.Count}"}),
RenderTimeout,
)
if Result.get("Error"):
raise DiagramError(Result["Error"])
Png = await self.Page.locator("#Out").screenshot(omit_background=True, timeout=RenderTimeout * 1000)
except DiagramError:
raise
except Exception as Error:
# A stuck or crashed page: start a fresh one next time.
if self.Page is not None:
await self.Page.context.close()
self.Page = None
raise DiagramError(f"Couldn't draw the diagram ({Error}).")
self.Cache[Key] = Png
if len(self.Cache) > CacheSize:
self.Cache.popitem(last=False)
return Png
_SharedRenderer = _Renderer()
async def RenderDiagram(Source: str, Palette: dict[str, Any], FontSize: int) -> bytes:
return await _SharedRenderer.Render(Source, Palette, FontSize)
+20
View File
@@ -29,6 +29,7 @@ from Auth import (
from BookReader import ReadBookForFile
from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave
from Core import NormalizeRelativePath, ResolvePath
from Diagrams import DiagramError, DiagramsUnavailable, RenderDiagram
from Embeds import BuildOgTags
from Editor import WriteTextFile
from Explorer import (
@@ -833,6 +834,25 @@ def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurren
return SaveSettings(CurrentUser["Username"], NewSettings)
class DiagramRequest(BaseModel):
Source: str
Palette: dict[str, str] = {}
FontSize: int = 14
# A ```mermaid block as a PNG, for the desktop client (the web view draws its
# own). Palette is the client's theme by role, so it matches the client.
@Application.post("/api/diagram")
async def PostDiagramEndpoint(Body: DiagramRequest, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> Response:
try:
Png = await RenderDiagram(Body.Source, Body.Palette, Body.FontSize)
except DiagramError as Error:
raise HTTPException(status_code=422, detail=str(Error))
except DiagramsUnavailable as Error:
raise HTTPException(status_code=503, detail=str(Error))
return Response(content=Png, media_type="image/png")
@Application.get("/api/themes")
def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]:
return ListThemeNames()
+1
View File
@@ -2,3 +2,4 @@ fastapi==0.115.0
uvicorn[standard]==0.30.6
tomli-w==1.0.0
python-multipart==0.0.12
playwright==1.62.0
+141
View File
@@ -0,0 +1,141 @@
"""Mermaid diagrams (```mermaid blocks), like the web view's. Flet can't draw
them, so the server does (POST /api/diagram, Backend/Diagrams.py) and they're
shown as pictures in this client's theme. In a local folder (no server), or
if the server can't draw them, they stay code blocks.
A DiagramBlock shows its code straight away and swaps in the picture once it
arrives (fetched on a background thread), so opening a note never waits on
the server. Pictures are kept for the session, so the live editor's
re-renders don't ask again."""
import re
import struct
from typing import Callable, Optional
import flet as ft
from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette
from Sources import SourceError
DiagramFence = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*mermaid[ \t]*$", re.IGNORECASE)
# Per session: pictures and Mermaid's complaints by (source, font size), and
# whether the server said it can't draw diagrams at all.
Pictures: dict[tuple[str, int], bytes] = {}
Problems: dict[tuple[str, int], str] = {}
ServerCantDraw = False
def DiagramSource(BlockText: str) -> Optional[str]:
"""The diagram inside a ```mermaid code block, or None for other blocks."""
Lines = BlockText.split("\n")
Match = DiagramFence.match(Lines[0])
if not Match:
return None
Fence = Match.group(1)
Body = Lines[1:]
if Body:
Last = Body[-1].strip()
if Last.startswith(Fence) and not Last.strip(Fence[0]):
Body = Body[:-1]
return "\n".join(Body)
def ThemePalette() -> dict[str, str]:
"""This client's theme by the server's role names (see Core.Palette)."""
return {
"Background": Palette.Base, "Surface": Palette.Mantle, "SurfaceAlt": Palette.Surface0,
"Border": Palette.Surface1, "Text": Palette.Text, "TextMuted": Palette.Overlay0,
"Black": Palette.Crust, "White": Palette.White, "Red": Palette.Red, "Orange": Palette.Peach,
"Yellow": Palette.Yellow, "Green": Palette.Green, "Blue": Palette.Blue, "Purple": Palette.Mauve,
}
def CanDraw(TheApp) -> bool:
return TheApp.Source.Kind == "Server" and not ServerCantDraw
class DiagramBlock(ft.Container):
def __init__(self, TheApp, Source: str, FontSize: int):
self.TheApp = TheApp
self.Source = Source
self.FontSize = FontSize
self.Key = (Source, FontSize)
self.Mounted = False
self.Fetching = False
super().__init__(content=self._Content())
def _Code(self) -> ft.Control:
return ft.Markdown(
value=f"```\n{self.Source}\n```",
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
)
def _Content(self) -> ft.Control:
Picture = Pictures.get(self.Key)
if Picture is not None:
# The server draws at twice the size; shown at half, it's sharp on HiDPI.
Width, Height = struct.unpack(">II", Picture[16:24])
return ft.Row([ft.Image(src=Picture, width=Width / 2, height=Height / 2)],
alignment=ft.MainAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO)
Problem = Problems.get(self.Key)
if Problem is not None:
return ft.Column([self._Code(), ft.Text(f"Diagram error: {Problem}", color=Palette.Red,
size=self.FontSize - 2, selectable=True)], spacing=4)
return self._Code()
def did_mount(self) -> None:
self.Mounted = True
if self.Key not in Pictures and self.Key not in Problems and not self.Fetching and CanDraw(self.TheApp):
self.Fetching = True
self.page.run_thread(self._Fetch)
def will_unmount(self) -> None:
self.Mounted = False
def _Fetch(self) -> None:
global ServerCantDraw
try:
Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize)
except SourceError as Error:
if Error.Status == 422:
Problems[self.Key] = str(Error)
elif Error.Status in (404, 503):
ServerCantDraw = True # an older server, or one without Chromium: stay code blocks
finally:
self.Fetching = False
if self.Mounted:
self.content = self._Content()
self.update()
def MaybeDiagram(TheApp, BlockText: str, FontSize: int) -> Optional[ft.Control]:
"""A DiagramBlock for a ```mermaid code block (LiveMarkdown's blocks), else None."""
Source = DiagramSource(BlockText)
if Source is None or not CanDraw(TheApp):
return None
return DiagramBlock(TheApp, Source, FontSize)
def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]:
"""The rendered view's controls: the Markdown, split around its diagrams."""
if not CanDraw(TheApp) or not any(DiagramFence.match(Line) for Line in Document.split("\n")):
return [MakeMarkdown(Document)]
from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module
Controls: list[ft.Control] = []
Position = 0
for Block in SplitBlocks(Document):
Source = DiagramSource(Document[Block.Start:Block.End]) if Block.Kind == "Code" else None
if Source is None:
continue
if Document[Position:Block.Start].strip():
Controls.append(MakeMarkdown(Document[Position:Block.Start]))
Controls.append(DiagramBlock(TheApp, Source, FontSize))
Position = Block.End
if Document[Position:].strip():
Controls.append(MakeMarkdown(Document[Position:]))
return Controls
+7 -3
View File
@@ -6,7 +6,8 @@ another block, puts it back rendered.
A block is what Markdown itself separates: a heading line, a fenced code
block, a horizontal rule, or a run of lines up to a blank line (a paragraph,
list, table or quote). The text between blocks (blank lines) is kept
byte for byte, so editing one block never reformats the rest of the file."""
byte for byte, so editing one block never reformats the rest of the file.
A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py)."""
import re
from dataclasses import dataclass
@@ -101,7 +102,8 @@ def SplitBlocks(Document: str) -> list[Block]:
class LiveMarkdown(ft.Column):
def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None],
OnFocusChange: Callable[[bool], None], OnTapLink: Callable):
OnFocusChange: Callable[[bool], None], OnTapLink: Callable,
MakeDiagram: Optional[Callable[[str, int], Optional[ft.Control]]] = None):
super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0,
horizontal_alignment=ft.CrossAxisAlignment.STRETCH)
self.Document = Document
@@ -109,6 +111,7 @@ class LiveMarkdown(ft.Column):
self.OnChange = OnChange
self.OnFocusChange = OnFocusChange
self.OnTapLink = OnTapLink
self.MakeDiagram = MakeDiagram
self.Blocks: list[Block] = []
self.BlockControls: list[ft.Control] = []
# Rendered blocks by their text, reused when the layout is rebuilt so
@@ -217,8 +220,9 @@ class LiveMarkdown(ft.Column):
# --- Rendering -------------------------------------------------------
def _Render(self, Text: str) -> ft.Control:
Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None
Rendered = ft.Container(
content=ft.Markdown(
content=Diagram or ft.Markdown(
value=Text,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.FontSize),
+9
View File
@@ -114,6 +114,7 @@ class Source:
def LocalFile(self, Root: str, RelativePath: str) -> Path: ...
def Book(self, Root: str, RelativePath: str) -> dict: ...
def Graph(self, Root: str, RelativePath: str) -> dict: ...
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: ...
@property
def CanWrite(self) -> bool:
@@ -275,6 +276,9 @@ class LocalSource(Source):
return BuildLocalGraph(self, Root, RelativePath)
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
raise SourceError("Diagrams are drawn by a SaraView server.")
class ServerSource(Source):
"""A SaraView server, through the same /api endpoints the web frontend
@@ -464,6 +468,11 @@ class ServerSource(Source):
# A big root takes the server a while to read through.
return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120)
def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes:
"""A ```mermaid block as a PNG, drawn by the server (Diagrams.py)."""
Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize}
return self._Request("POST", "/diagram", json=Body).content
def MakeSource(Config: AppConfig, Token: str = "") -> Source:
return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config)
+20 -13
View File
@@ -2,7 +2,7 @@
EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar).
- Markdown is rendered in View; the other text types are shown as
highlighted code. Edit is a plain monospace field with an explicit Save
highlighted code. On a server, ```mermaid blocks show as diagrams (Diagrams.py). Edit is a plain monospace field with an explicit Save
(Ctrl+S). On a server, saving records line blame for you, like the web view.
- With Markdown editing set to Live (Settings > General), Markdown opens in
LiveMarkdown instead: rendered, and click a block to edit it in place.
@@ -20,6 +20,7 @@ import flet as ft
from App import App
from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette
from Diagrams import MaybeDiagram, WithDiagrams
from LiveMarkdown import LiveMarkdown
from Sources import Entry, SourceError, TextFileTypes
@@ -121,7 +122,8 @@ class FileView(ft.Container):
self.TextField.value = self.Content
if self.IsLive:
self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged,
self.OnFocusChange, self._OnTapLink)
self.OnFocusChange, self._OnTapLink,
MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size))
return self.Live
return self._RenderedView()
if self.FileType == "image":
@@ -133,16 +135,21 @@ class FileView(ft.Container):
def _RenderedView(self) -> ft.Control:
Text = self.TextField.value or ""
Views: Optional[list[ft.Control]] = None
if self.FileType == "markdown" and len(Text) <= HighlightCharLimit:
View: ft.Control = ft.Markdown(
value=Text,
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
on_tap_link=self._OnTapLink,
)
def MakeMarkdown(Part: str) -> ft.Control:
return ft.Markdown(
value=Part,
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize),
code_theme=MarkdownCodeTheme(),
code_style_sheet=CodeStyleSheet(),
on_tap_link=self._OnTapLink,
)
Views = WithDiagrams(self.TheApp, Text, self.TheApp.Config.FontSize, MakeMarkdown)
View: ft.Control = Views[0]
elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit:
Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower()
View = ft.Markdown(
@@ -156,8 +163,8 @@ class FileView(ft.Container):
else:
View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize)
if not Text:
View = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True)
return ft.Column([View], scroll=ft.ScrollMode.AUTO, expand=True)
View, Views = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True), None
return ft.Column(Views or [View], scroll=ft.ScrollMode.AUTO, expand=True)
def _InfoView(self) -> ft.Control:
Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED
+4
View File
@@ -9,8 +9,12 @@ FROM python:3.12-slim
WORKDIR /App
COPY Backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Headless Chromium for drawing the desktop client's diagrams (Backend/Diagrams.py).
RUN playwright install --with-deps --only-shell chromium
COPY Backend/ ./Backend/
COPY --from=FrontendBuild /Frontend/dist ./Frontend/dist
# The same Mermaid the web view uses, for Backend/Diagrams.py.
COPY --from=FrontendBuild /Frontend/node_modules/mermaid/dist/mermaid.min.js ./Backend/Vendor/mermaid.min.js
WORKDIR /App/Backend
EXPOSE 8420