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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ refactors, dependency updates, CI changes, and code cleanup do not belong here.

---

## [1.2.5] - 2026-08-14

### Fixed

- Fixed Mermaid diagrams with HTML labels or named entities failing to display in Markdown live preview and standalone Mermaid files.

## [1.2.4] - 2026-08-14

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/native-backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "neverwrite-native-backend"
version = "1.2.4"
version = "1.2.5"
edition = "2021"

[dependencies]
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bifrostwrite-desktop",
"private": true,
"version": "1.2.4",
"version": "1.2.5",
"homepage": "https://github.com/bifrost-proxy/BifrostWrite",
"type": "module",
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "bifrostwrite-desktop"
version = "1.2.4"
version = "1.2.5"
edition = "2021"
description = "Tauri desktop shell for BifrostWrite"
license = "Apache-2.0"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "BifrostWrite",
"version": "1.2.4",
"version": "1.2.5",
"identifier": "com.bifrostwrite.desktop",
"build": {
"beforeDevCommand": "npm run renderer:dev",
Expand Down
8 changes: 6 additions & 2 deletions apps/desktop/src/features/editor/MermaidFilePreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ describe("MermaidFilePreview", () => {
A --> B`;
mockedRenderMermaidDiagram.mockResolvedValueOnce({
status: "ok",
svg: '<svg viewBox="0 0 10 10"><text>Flow</text></svg>',
svg: [
'<svg viewBox="0 0 10 10"><foreignObject>',
'<div xmlns="http://www.w3.org/1999/xhtml">README&nbsp;Flow</div>',
"</foreignObject></svg>",
].join(""),
});

renderComponent(<MermaidFilePreview source={source} tabId="tab-1" />);

expect(screen.getByText("Rendering Mermaid diagram...")).toBeInTheDocument();

expect(await screen.findByText("Flow")).not.toBeNull();
expect(await screen.findByText("README Flow")).not.toBeNull();
expect(mockedRenderMermaidDiagram).toHaveBeenCalledWith(
source,
expect.stringMatching(/^mermaid-file-tab-1-/),
Expand Down
8 changes: 1 addition & 7 deletions apps/desktop/src/features/editor/MermaidFilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
renderMermaidDiagram,
type MermaidRenderResult,
} from "./mermaid/mermaidRenderer";
import { parseMermaidSvg } from "./mermaid/mermaidSvg";

type MermaidFilePreviewState =
| { status: "idle" }
Expand Down Expand Up @@ -130,13 +131,6 @@ function toPreviewState(result: MermaidRenderResult): MermaidFilePreviewState {
return { status: "rendered", svg };
}

function parseMermaidSvg(svg: string): SVGElement | null {
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
const root = parsed.documentElement;
if (root.nodeName.toLowerCase() !== "svg") return null;
return document.importNode(root, true) as unknown as SVGElement;
}

function sanitizeIdPart(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,11 @@ describe("code block live preview", () => {
it("renders Mermaid SVG blocks asynchronously", async () => {
mockedRenderMermaidDiagram.mockResolvedValueOnce({
status: "ok",
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>Diagram</text></svg>',
svg: [
'<svg xmlns="http://www.w3.org/2000/svg"><foreignObject>',
'<div xmlns="http://www.w3.org/1999/xhtml">README&nbsp;Diagram</div>',
"</foreignObject></svg>",
].join(""),
});
const parent = document.createElement("div");
document.body.appendChild(parent);
Expand All @@ -215,8 +219,9 @@ describe("code block live preview", () => {
await flushPromises();

expect(
view.dom.querySelector(".cm-mermaid-preview svg text")?.textContent,
).toBe("Diagram");
view.dom.querySelector(".cm-mermaid-preview svg foreignObject")
?.textContent,
).toBe("README\u00a0Diagram");
expect(view.dom.querySelector(".cm-mermaid-preview-error")).toBeNull();

view.destroy();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
renderEmbedPreview,
} from "./notePreviewSource";
import { renderMermaidDiagram } from "../mermaid/mermaidRenderer";
import { parseMermaidSvg } from "../mermaid/mermaidSvg";
import { formatCodeFenceLanguageLabel } from "../codeFencePresentation";

const IMAGE_EXTENSIONS = /\.(png|jpe?g|gif|svg|webp|bmp|ico|avif)([?#].*)?$/i;
Expand Down Expand Up @@ -1072,13 +1073,6 @@ const mermaidBlockSourceHidden = Decoration.line({
class: "cm-code-block-fence-hidden cm-mermaid-source-hidden",
});

function parseMermaidSvg(svg: string): SVGElement | null {
const parsed = new DOMParser().parseFromString(svg, "image/svg+xml");
const root = parsed.documentElement;
if (root.nodeName.toLowerCase() !== "svg") return null;
return document.importNode(root, true) as unknown as SVGElement;
}

function renderMermaidError(container: HTMLElement, message: string) {
container.className = "cm-mermaid-preview-body cm-mermaid-preview-error";

Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/features/editor/mermaid/mermaidSvg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { parseMermaidSvg } from "./mermaidSvg";

describe("parseMermaidSvg", () => {
it("accepts Mermaid HTML labels that are not strict XML", () => {
const svg = parseMermaidSvg(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 40">
<foreignObject width="100" height="40">
<div xmlns="http://www.w3.org/1999/xhtml">README&nbsp;flow<br>ready</div>
</foreignObject>
</svg>
`);

expect(svg).not.toBeNull();
expect(svg?.namespaceURI).toBe("http://www.w3.org/2000/svg");
expect(svg?.textContent).toContain("README\u00a0flow");
expect(svg?.textContent).toContain("ready");
});

it("rejects output without an SVG root", () => {
expect(parseMermaidSvg("<div>not an SVG</div>")).toBeNull();
expect(parseMermaidSvg("<parsererror>bad SVG</parsererror>")).toBeNull();
});
});
17 changes: 17 additions & 0 deletions apps/desktop/src/features/editor/mermaid/mermaidSvg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";

/**
* Mermaid SVG output may contain HTML labels inside foreignObject elements.
* Parse it as HTML so named entities and HTML-style void elements remain
* compatible with the browser/WebView, then require an SVG document root.
*/
export function parseMermaidSvg(svg: string): SVGSVGElement | null {
const parsed = new DOMParser().parseFromString(svg, "text/html");
const root = parsed.body.firstElementChild;

if (root?.localName !== "svg" || root.namespaceURI !== SVG_NAMESPACE) {
return null;
}

return document.importNode(root, true) as SVGSVGElement;
}