Add mermaid suport

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-09 18:48:24 +01:00
parent 85ed5296e7
commit c98ffaeda3
12 changed files with 4241 additions and 33 deletions

View File

@@ -1,9 +1,12 @@
import { createRequire } from "node:module";
import { fileURLToPath, URL } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const require = createRequire(import.meta.url);
// https://vite.dev/config/
export default defineConfig({
plugins: [
@@ -54,6 +57,7 @@ export default defineConfig({
resolve: {
alias: {
"#": fileURLToPath(new URL("./src", import.meta.url)),
"mermaid": require.resolve("mermaid/dist/mermaid.esm.min.mjs"),
},
},
});

1
go.mod
View File

@@ -27,6 +27,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/vektah/gqlparser/v2 v2.5.32
github.com/yuin/goldmark v1.7.16
go.abhg.dev/goldmark/mermaid v0.6.0
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712
go.gearno.de/kit v0.1.1
go.gearno.de/x/ref v0.0.0-20260216110753-a700c951377c

2
go.sum
View File

@@ -258,6 +258,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712 h1:J5ccbcxFuwxe6Oa9fVi9FqQOo+n17ni4wbl9t4NuEzc=
go.gearno.de/crypto/uuid v0.1.1-0.20251208105319-3f587312a712/go.mod h1:fnIIvKO9QnsyLO3ZJLJT3r8KZv/p0FOeT5eZKilYWXg=
go.gearno.de/kit v0.1.1 h1:QuBZCZ/h2Eyh6DjjR6CGjkdsab/ztHz6xUiIk0FeREE=

1231
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,6 +24,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"mermaid": "^11.13.0",
"react-dropzone": "^14.3.8",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",

View File

@@ -1,7 +1,10 @@
import { isValidElement } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
import { MermaidDiagram } from "./MermaidDiagram";
type Props = {
content: string;
};
@@ -23,19 +26,31 @@ export function Markdown({ content }: Props) {
{children}
</a>
),
pre: ({ children, ...props }) => (
<pre
className="border border-border-solid rounded p-4 bg-transparent font-mono text-sm overflow-x-auto text-inherit"
{...props}
>
{children}
</pre>
),
code: ({ children, ...props }) => (
<code className="font-mono text-sm text-inherit" {...props}>
{children}
</code>
),
pre: ({ children, ...props }) => {
const child = isValidElement<{
className?: string;
children?: string;
}>(children)
? children
: null;
if (
child?.type === "code"
&& child.props.className === "language-mermaid"
&& typeof child.props.children === "string"
) {
return <MermaidDiagram chart={child.props.children} />;
}
return (
<pre
className="border border-border-solid rounded p-4 bg-transparent font-mono text-sm overflow-x-auto text-inherit"
{...props}
>
{children}
</pre>
);
},
}}
>
{content}

View File

@@ -0,0 +1,51 @@
import mermaid from "mermaid";
import { useEffect, useId, useState } from "react";
type Props = {
chart: string;
};
export function MermaidDiagram({ chart }: Props) {
const id = useId().replace(/:/g, "");
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
mermaid
.render(`mermaid-${id}`, chart.trim())
.then((result) => {
if (!cancelled) {
setSvg(result.svg);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : String(err));
}
});
return () => {
cancelled = true;
};
}, [chart, id]);
if (error) {
return (
<pre className="border border-border-solid rounded p-4 bg-transparent font-mono text-sm overflow-x-auto text-inherit">
<code>{chart}</code>
</pre>
);
}
return (
<div
className="flex justify-center my-4"
dangerouslySetInnerHTML={svg ? { __html: svg } : undefined}
/>
);
}

View File

@@ -26,10 +26,14 @@ import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
gmhtml "github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/mermaid"
"go.probo.inc/probo/pkg/coredata"
)
var (
//go:embed mermaid.min.js
mermaidJSSource string
//go:embed template.html
htmlTemplateContent string
@@ -72,7 +76,13 @@ var (
},
"formatContent": func(content string) template.HTML {
md := goldmark.New(
goldmark.WithExtensions(extension.Table),
goldmark.WithExtensions(
extension.Table,
&mermaid.Extender{
RenderMode: mermaid.RenderModeClient,
NoScript: true,
},
),
goldmark.WithRendererOptions(
gmhtml.WithUnsafe(),
),
@@ -209,6 +219,7 @@ type (
PublishedAt *time.Time
Signatures []SignatureData
CompanyHorizontalLogoBase64 string
MermaidJS template.JS
}
SignatureData struct {
@@ -321,6 +332,8 @@ const (
)
func RenderHTML(data DocumentData) ([]byte, error) {
data.MermaidJS = template.JS(mermaidJSSource)
var buf bytes.Buffer
if err := documentTemplate.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("cannot execute template: %w", err)

2843
pkg/docgen/mermaid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -341,7 +341,31 @@
background: transparent;
border: none;
}
.document-content pre.mermaid {
border: none;
padding: 0;
background: transparent;
text-align: center;
}
.document-content pre.mermaid svg {
max-width: 100%;
height: auto;
}
</style>
<script>{{.MermaidJS}}</script>
<script>
window.__mermaidReady = false;
mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
document.addEventListener('DOMContentLoaded', function () {
mermaid.run().then(function () {
window.__mermaidReady = true;
}).catch(function () {
window.__mermaidReady = true;
});
});
</script>
</head>
<body>
<div class="document-container">

View File

@@ -21,6 +21,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
@@ -41,14 +42,16 @@ var (
type (
RenderConfig struct {
PageFormat PageFormat
Orientation Orientation
MarginTop Margin
MarginBottom Margin
MarginLeft Margin
MarginRight Margin
PrintBackground bool
Scale float64 // Print scale (0.1 to 2.0)
PageFormat PageFormat
Orientation Orientation
MarginTop Margin
MarginBottom Margin
MarginLeft Margin
MarginRight Margin
PrintBackground bool
Scale float64 // Print scale (0.1 to 2.0)
WaitForExpression string // Optional JS expression polled until true before printing
WaitForExpressionTimeout time.Duration // Max time to wait (default 15s)
}
Option func(*Converter)
@@ -162,6 +165,30 @@ func (c *Converter) GeneratePDF(ctx context.Context, htmlDocument []byte, cfg Re
htmlContent := string(htmlDocument)
waitTimeout := cfg.WaitForExpressionTimeout
if waitTimeout <= 0 {
waitTimeout = 15 * time.Second
}
waitForExpr := chromedp.ActionFunc(func(ctx context.Context) error {
if cfg.WaitForExpression == "" {
return nil
}
deadline := time.Now().Add(waitTimeout)
for time.Now().Before(deadline) {
var ready bool
if err := chromedp.Evaluate(cfg.WaitForExpression, &ready).Do(ctx); err != nil {
time.Sleep(100 * time.Millisecond)
continue
}
if ready {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return nil // proceed even on timeout
})
err := chromedp.Run(
ctx,
chromedp.Navigate("about:blank"),
@@ -178,6 +205,7 @@ func (c *Converter) GeneratePDF(ctx context.Context, htmlDocument []byte, cfg Re
}),
chromedp.WaitReady("body"),
chromedp.ActionFunc(waitUntilDocumentReady),
waitForExpr,
chromedp.ActionFunc(
func(ctx context.Context) (err error) {
pdfBytes, _, err = page.PrintToPDF().

View File

@@ -1884,14 +1884,15 @@ func exportDocumentPDF(
}
cfg := html2pdf.RenderConfig{
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),
MarginRight: html2pdf.NewMarginInches(1.0),
PrintBackground: true,
Scale: 1.0,
PageFormat: html2pdf.PageFormatA4,
Orientation: html2pdf.OrientationPortrait,
MarginTop: html2pdf.NewMarginInches(1.0),
MarginBottom: html2pdf.NewMarginInches(1.0),
MarginLeft: html2pdf.NewMarginInches(1.0),
MarginRight: html2pdf.NewMarginInches(1.0),
PrintBackground: true,
Scale: 1.0,
WaitForExpression: "window.__mermaidReady === true",
}
pdfReader, err := html2pdfConverter.GeneratePDF(ctx, htmlContent, cfg)