dsh-plugin-mermaid-preview
Mermaid diagrams in DeepSeek Harness, in the two places they belong:
.mmd / .mermaid files open in the right-sidebar document viewer as
diagrams instead of plain text.
```mermaid fences in chat messages render as diagrams instead of
syntax-highlighted code blocks.
What it does
- Registers a document-preview implementation for the
mmd and mermaid
file suffixes through the harness's public documentPreviews extension point.
- Claims the
mermaid Markdown fence language through ui-primitives's
fence registry, so chat transcripts render the diagram with no cooperation
from the chat package.
- Renders with mermaid 11, bundled into the plugin's
own client artifact. Both surfaces share one runtime (configuration, palette
sampling, measurement, device-pixel sizing), so a diagram cannot look right in
one place and wrong in the other.
- Follows the shell's light/dark palette and re-renders when the theme changes.
- Zooms per diagram view:
− / percentage / + / 重置 in a small control under
the diagram, plus Ctrl (or ⌘) + wheel over it. 100% means "fills the pane
width", the remembered zoom is a view preference rather than document state,
and each preview tab keeps its own zoom because the state lives in the
renderer instance.
- In chat, a fence that is still streaming keeps its code block until the body is
complete: a half-drawn diagram is worse than the source the reader can already
see.
- Falls back to showing the source, with the parse error above it, when a
diagram does not parse — a file the agent is still writing is the common case,
so a failed render never hides the content.
- Ships Simplified Chinese and English copy through the shell's locale service.
Supported diagram types are mermaid's own: flowchart, sequence, class, state,
entity-relationship, gantt, pie, git graph, mindmap, timeline, quadrant,
requirement, sankey, block, architecture, and the rest.
Requirements
- DSH with the
web profile (@deepseek-ai/dsh-web-app), which mounts
@deepseek-ai/dsh-client-ui-sidebar-documentpreview. The plugin stays parked
until that viewer is mounted, because documentPreviews is the service it
extends.
- Chat fences additionally need a harness that has the Markdown fence
registry — a
ui-primitives exporting registeredFences. On an older
harness the .mmd preview works normally and ```mermaid blocks keep
rendering as code blocks; the plugin logs one line saying so. It does not fail,
and it does not take the file preview down with it.
Install
From npm
dsh plugin --profile web add dsh-plugin-mermaid-preview
The published tarball ships the built artifacts, and prepublishOnly rebuilds
them before every release.
From a checkout
The harness serves the built lib/client.js and never reads sources, and build
output is not committed, so build first:
git clone https://github.com/huiyeo/dsh-plugin-mermaid-preview
cd dsh-plugin-mermaid-preview
pnpm install
pnpm run build
Then install that directory as a profile layer:
dsh plugin --profile web add /path/to/dsh-plugin-mermaid-preview
dsh plugin add installs the package and, because the manifest declares
dsh.bundle.patch, appends it to dsh.profile.bundles. The bundle layer is read
at process start, so restart the dsh web process, then reload the page.
To see it without waiting for a restart, a profile with patchReload: live also
accepts the row directly in $DSH_HOME/profiles/web/cordis.patch.yml, which
hot-mounts on save so only a page reload is needed:
- insert:
- id: mermaid-preview
name: dsh-plugin-mermaid-preview
Add that only while iterating, and remove it once the process restarts: the
bundle layer then mounts the same row, and documentPreviews.register() throws
on a duplicate implementation id.
Verify the layer landed:
node -e "console.log(require('./package.json').dsh.profile.bundles)" \
# run in $DSH_HOME/profiles/web → should list dsh-plugin-mermaid-preview
Remove it with dsh plugin --profile web remove dsh-plugin-mermaid-preview.
Development
pnpm install
pnpm run build # lib/index.js (host half) + lib/client.js (browser half)
pnpm test # load the built artifact the way the module system does
tests/browser-smoke.mjs additionally renders a diagram in headless Chrome and
asserts on the produced SVG. It needs a browser that can actually start —
a confined sandbox blocks Chrome's own IPC, so run it somewhere unrestricted:
CHROME_PATH=/path/to/chrome pnpm run test:browser
How the two halves fit together
lib/index.js is the host half and deliberately contributes nothing: a package
is only scanned for a browser half when it is mounted as a host row, so the row
exists to make the browser half discoverable.
lib/client.js is a single classic script that calls
window.__ModuleLoader__.load({ id, factory }) with a CommonJS-style factory.
That format is the harness's own client-bundle contract; the browser half of
@deepseek-ai/dsh-client-modules serves the file and drives it. Two
consequences shape the build:
- Only the harness browser platform's module-table words may stay external
(
react, react/jsx-runtime, the UI registries). Everything else — mermaid
included — must be inlined, because a require() the module table cannot
answer throws when the bundle materializes.
- The artifact must be one file. mermaid loads each diagram grammar through
a dynamic
import(); the plugin loader serves exactly one file per package
and there is no dynamic-import hook, so codeSplitting: false folds every
grammar into the factory. The result is ~7 MB uncompressed, fetched once and
cached by revision.
The document body receives loading: 'bytes-complete' content, so it gets the
whole file in one shot and never has to page — the paged mode would hand it an
accumulated prefix with no way to request the rest.
Zoom must snap to the DEVICE pixel grid
The renderer re-sizes the SVG through its own width/height attributes rather
than through CSS, so the browser re-rasterizes the vector at the target size
instead of resampling a bitmap. The size it writes is snapped so that
width × devicePixelRatio is a whole number:
devicePixels / devicePixelRatio // not Math.round(cssPixels)
A whole number of CSS pixels is not a whole number of device pixels. On a
125%-scaled Windows display (devicePixelRatio === 1.25) a 326 px box covers
407.5 device pixels — half a pixel off the grid — and the browser resolves that
by resampling, which reads as blur. It shows up at only some zoom steps: 50%
was soft while 100% happened to land whole. Rounding CSS pixels does not fix it;
snapping device pixels does. tests/ cannot catch this, because jsdom has no
rasterizer.
If it ever needs re-checking, the numbers are visible from the page itself —
getBoundingClientRect() on the SVG times devicePixelRatio must be a whole
number at every zoom step. To reach a real browser from an agent session, the
DSH Browser Control extension
(Chrome MV3 + a local WebSocket bridge on 127.0.0.1:9777) works when a sandbox
forbids launching one: the extension dials out as a client, so the agent only has
to accept that socket and can then eval in the tab.
Layout
src/index.js host half (empty by design)
src/client/index.ts plugin body: file-preview registration + chat fence claim
src/client/MermaidBody.tsx the document-preview renderer (owns its zoom)
src/client/MermaidFence.tsx the chat fence renderer (falls back to the code block)
src/client/mermaid-runtime.ts shared by both: config, palette, measurement, sizing
src/client/theme.ts shell palette sampling + dark-mode subscription
src/client/locales.ts zh/en copy
src/client/styles.ts plugin-owned stylesheet, injected with a tagged tag
src/client/types.ts the minimal ambient contracts this package compiles against
cordis.patch.yml the profile patch that mounts the row
tests/load-artifact.mjs loads the artifact; asserts the shared-registry contract
tests/load-artifact-legacy.mjs asserts clean degradation without the fence registry
tests/browser-smoke.mjs headless render check (needs an unrestricted browser)
tests/zoom.html two-instance harness proving zoom is per-view
License
MIT