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
6 changes: 3 additions & 3 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ store credentials or deployment configuration.
### Technical journal

The journal provides paginated active and archived lists, filters for title,
type, issue status, and tag, plus entry details with Markdown rendering. Users
can create entries, edit title/context/conclusion, assign or remove tags, and
archive, restore, or permanently delete entries.
type, issue status, and tag, plus entry details with Markdown and Mermaid
rendering. Users can create entries, edit title/context/conclusion, assign or
remove tags, and archive, restore, or permanently delete entries.

Links from Knowledge Overview can scope the active journal using `projectId`
in the URL. Search and pagination preserve that scope. **Show all projects**
Expand Down
66 changes: 66 additions & 0 deletions apps/web/e2e/mocked/markdown-mermaid.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, test } from '@playwright/test'
import { authenticatedApi, entry, entryId, mockApi, project, projectId } from './mock-api'

const diagram = '```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```'
const namedDiagram = '```mermaid\nflowchart LR\n accTitle: Delivery flow\n accDescr: Start leads to Done\n A[Start] --> B[Done]\n```'
const invalidDiagram = '```mermaid\nthis is not a diagram\n```'

test('Mermaid fences render in project and journal Markdown fields', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
const assertNoUnexpected = await mockApi(page, (request, url) => {
if (request.method() === 'GET' && url.pathname === `/api/project/${projectId}`) {
return { body: { ...project, description: `${namedDiagram}\n\n\`\`\`ts\nconst answer = 42\n\`\`\`` } }
}
if (request.method() === 'GET' && url.pathname === `/api/technical-entry/${entryId}`) {
return { body: { ...entry, context: diagram, conclusion: invalidDiagram } }
}
return authenticatedApi(request, url)
})

await page.goto(`/projects/${projectId}`)
const projectDiagram = page.getByRole('img', { name: 'Delivery flow' })
await expect(projectDiagram.locator('svg')).toBeVisible()
await expect(projectDiagram).toHaveAttribute('aria-description', 'Start leads to Done')
await expect(projectDiagram).toContainText('Start')
await expect(projectDiagram).toContainText('Done')
await expect(page.locator('pre code.language-ts')).toHaveText('const answer = 42')
await page.getByText('View diagram source').click()
await expect(page.locator('pre code.language-mermaid')).toContainText('flowchart LR')

await page.goto(`/technical-entries/${entryId}`)
const entryDiagram = page.getByRole('img', { name: 'Mermaid diagram' })
await expect(entryDiagram.locator('svg')).toBeVisible()
await expect(page.getByText('Diagram could not be rendered')).toBeVisible()
await expect(page.locator('pre code.language-mermaid').filter({ hasText: 'this is not a diagram' })).toBeVisible()

for (const width of [390, 320]) {
await page.setViewportSize({ width, height: 844 })
const layout = await page.evaluate(() => ({
viewport: document.documentElement.clientWidth,
document: document.documentElement.scrollWidth,
}))
expect(layout.document, JSON.stringify(layout)).toBeLessThanOrEqual(layout.viewport)
}
assertNoUnexpected()
})

test('compact project and journal cards leave Mermaid fences as code', async ({ page }) => {
const assertNoUnexpected = await mockApi(page, (request, url) => {
if (request.method() === 'GET' && url.pathname === '/api/project') {
return { body: { data: [{ ...project, description: diagram }], meta: { currentPage: 1, perPage: 10, lastPage: 1, total: 1 } } }
}
if (request.method() === 'GET' && url.pathname === '/api/technical-entry') {
return { body: { data: [{ ...entry, context: diagram }], meta: { currentPage: 1, perPage: 10, lastPage: 1, total: 1 } } }
}
return authenticatedApi(request, url)
})

await page.goto('/projects')
await expect(page.locator('pre code.language-mermaid')).toHaveCount(1)
await expect(page.getByRole('img', { name: 'Mermaid diagram' })).toHaveCount(0)

await page.goto('/technical-entries')
await expect(page.locator('pre code.language-mermaid')).toHaveCount(1)
await expect(page.getByRole('img', { name: 'Mermaid diagram' })).toHaveCount(0)
assertNoUnexpected()
})
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.39.0",
"mermaid": "12.2.0",
"radix-ui": "^1.6.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/components/markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { Children, isValidElement } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { cn } from "@/lib/utils";
import { MermaidDiagram } from "./mermaid-diagram";

type MarkdownProps = React.ComponentProps<typeof ReactMarkdown> & {
className?: string;
renderDiagrams?: boolean;
};

// Renders Markdown content using ReactMarkdown and remarkGfm. The wrapper
// owns typography, block spacing, and element styles so every feature gets
// the same reading experience without duplicating Markdown configuration.
export function Markdown({ className, ...props }: MarkdownProps) {
export function Markdown({ className, renderDiagrams = true, ...props }: MarkdownProps) {
return (
<div
className={cn(
Expand All @@ -19,7 +22,23 @@ export function Markdown({ className, ...props }: MarkdownProps) {
>
{/* Keep GFM enabled after spreading props so callers cannot accidentally
replace the plugin and lose tables, task lists, or strikethrough. */}
<ReactMarkdown {...props} remarkPlugins={[remarkGfm]} />
<ReactMarkdown
{...props}
components={{
...props.components,
pre({ children }) {
const code = Children.toArray(children)[0];
if (renderDiagrams && isValidElement<{ className?: string; children?: string }>(code)) {
const language = code.props.className?.split(/\s+/).includes("language-mermaid");
if (language && typeof code.props.children === "string") {
return <MermaidDiagram source={code.props.children.trimEnd()} />;
}
}
return <pre>{children}</pre>;
},
}}
remarkPlugins={[remarkGfm]}
/>
</div>
);
}
98 changes: 98 additions & 0 deletions apps/web/src/components/mermaid-diagram.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useEffect, useState } from "react";

type DiagramResult =
| { source: string; status: "ready"; svg: string; title?: string; description?: string }
| { source: string; status: "error" };

let diagramNumber = 0;
let mermaidLoader: Promise<typeof import("mermaid")["default"]> | undefined;

function loadMermaid() {
// Diagram rendering is needed only for full Markdown fields that actually
// contain a Mermaid fence, so ordinary pages do not load the large library.
mermaidLoader ??= import("mermaid").then(({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "dark",
// Dagre keeps common diagrams light to load; a diagram can still opt
// into ELK through Mermaid frontmatter when its layout needs it.
layout: "dagre",
});
return mermaid;
});
return mermaidLoader;
}

export function MermaidDiagram({ source }: { source: string }) {
const [result, setResult] = useState<DiagramResult | null>(null);

useEffect(() => {
let active = true;
const id = `markdown-mermaid-${++diagramNumber}`;

void loadMermaid()
.then((mermaid) => (active ? mermaid.render(id, source) : null))
.then((rendered) => {
if (!active || !rendered) return;
const { svg } = rendered;
// Mermaid turns accTitle/accDescr into SVG metadata. Reuse that output
// for the wrapper's accessible name instead of parsing its syntax twice.
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
const title = document.querySelector("svg > title")?.textContent?.trim();
const description = document.querySelector("svg > desc")?.textContent?.trim();
setResult({ source, status: "ready", svg, title, description });
})
.catch(() => {
if (active) setResult({ source, status: "error" });
});

// A field can change while rendering; an old result must not replace the
// diagram for the new Markdown text or update an unmounted component.
return () => {
active = false;
};
}, [source]);

const currentResult = result?.source === source ? result : null;
const sourceBlock = (
<pre className="overflow-x-auto rounded-md bg-muted p-4">
<code className="language-mermaid">{source}</code>
</pre>
);

if (currentResult?.status !== "ready") {
return (
<div>
{currentResult?.status === "error" ? (
<p className="mb-2 text-muted-foreground">Diagram could not be rendered</p>
) : null}
{sourceBlock}
</div>
);
}

return (
<figure className="min-w-0 space-y-2">
{/* The app currently uses a dark theme, so keep Mermaid's palette and
the diagram surface aligned instead of relying on CSS inversion. */}
<div className="overflow-x-auto rounded-md border bg-card p-4 text-card-foreground">
<div
aria-description={currentResult.description}
aria-label={currentResult.title || "Mermaid diagram"}
className="[&_svg]:h-auto [&_svg]:max-w-full"
// Mermaid produces this SVG from the diagram source in strict mode.
dangerouslySetInnerHTML={{ __html: currentResult.svg }}
role="img"
/>
</div>
<details className="text-xs text-muted-foreground">
<summary className="w-fit cursor-pointer hover:text-foreground">
View diagram source
</summary>
<div className="mt-2">{sourceBlock}</div>
</details>
</figure>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/features/projects/components/project-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function ProjectList({ projects }: ProjectListProps) {
</header>

{project.description ? (
<Markdown className="max-h-18 min-w-0 overflow-hidden wrap-anywhere text-sm leading-6 text-muted-foreground">
<Markdown className="max-h-18 min-w-0 overflow-hidden wrap-anywhere text-sm leading-6 text-muted-foreground" renderDiagrams={false}>
{project.description}
</Markdown>
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,14 @@ export function TechnicalEntryList({
) : null}
</header>

<Markdown className="max-h-24 overflow-hidden text-sm leading-6 text-muted-foreground">
<Markdown className="max-h-24 overflow-hidden text-sm leading-6 text-muted-foreground" renderDiagrams={false}>
{entry.context}
</Markdown>

{entry.conclusion?.trim() ? (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Conclusion</p>
<Markdown className="max-h-16 overflow-hidden text-sm leading-6 text-muted-foreground">
<Markdown className="max-h-16 overflow-hidden text-sm leading-6 text-muted-foreground" renderDiagrams={false}>
{entry.conclusion}
</Markdown>
</div>
Expand Down
28 changes: 28 additions & 0 deletions docs/guides/frontend_structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,34 @@ keeps profile consumers synchronized.

Use `presentation.ts` for display details that should not leak into the API: status text, badge colors, icons, and specific formats. For example, a `PAUSED` API value could be displayed as “Paused” by that map. Use English display text throughout.

## Markdown diagrams

Project descriptions and technical-entry context and conclusions store plain
Markdown. To include a diagram, write a fenced `mermaid` block. Add an
accessible title and description when the diagram carries useful information:

````markdown
```mermaid
flowchart LR
accTitle: Delivery flow
accDescr: A request moves from start to completion.
Start --> Done
```
````

The shared [`Markdown` component](../../apps/web/src/components/markdown.tsx)
renders these blocks in full detail fields. It loads Mermaid only when a diagram
is present; ordinary Markdown and code fences keep their existing rendering.
Compact list cards leave the fence as code because their content is clipped.
Invalid diagrams retain their source with a readable error message, so saved
text is never lost. Mermaid runs with strict security settings, which encode
HTML labels and disable diagram links. The original source is also available
from **View diagram source** below each successful diagram.

See [Mermaid's syntax guide](https://mermaid.js.org/intro/syntax-reference.html)
for diagram types and [accessibility options](https://mermaid.js.org/config/accessibility)
for `accTitle` and `accDescr`.

## Tests

The frontend uses Vitest with Testing Library and MSW. `src/test/` holds shared
Expand Down
Binary file added docs/images/issue-15-entry-mermaid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/issue-15-project-mermaid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading