Move the support line from 0.1.7-alpha.1 to 0.1.7-rc.1 (peer floor `^0.1.7-rc.1`). 0.21.0-alpha.1 never reached npm, and once the code-card labels below are fixed the same build crashes on alpha.1 (its DiffBlock still reads the `labels.files` upstream deleted), so declaring only the host we verify against is the honest floor — and from rc.1 on the host enforces peers at boot anyway: a row whose `@deepseek-ai/dsh-*` peers do not satisfy the runtime is silently disabled. Dependencies: 28 devDependency pins and 14 peers move to 0.1.7-rc.1. rc.1 also raised the vendored runtime (cordis 4.0.4, schemastery 3.18.4, cordis-plugin-group 1.0.4, cordis-plugin-include 1.0.9) and publishes DSH cross-references as exact versions, so bumping schemastery/cordis alone left four unmet transitive peers — the three vendor packages are hoisted into devDependencies, which is what makes `pnpm peers check` clean again. One compile break: rc.1 added `CodeToolbarLabels` (codeLabel / wrapLabel / unwrapLabel, all required), made DiffBlockLabels / ReadBlockLabels extend it and deleted `DiffBlockLabels.files`. Fixing SideChatView then leaving markdown on the legacy banner would render two different code-card styles inside one side-chat view, so the plugin adopts the card everywhere: `markdownTextProps()` now passes `labels.code.toolbarLabels` with the host's own wording (codeBlock.title|wrap|unwrap, added to all 20 dictionaries; `sideChatBlockFiles` retires with the deleted footer). Adopting the card surfaced a silent regression the suite caught: the new toolbar shows `codeLabel` for languages the highlighter does not know, and mermaid has no shiki grammar, so the info string no longer reaches the DOM and the banner-text probe in mermaid.tsx matched nothing — mermaid fences stopped being swapped for diagrams. Detection now matches the FENCE BODY from the source that produced the render. `tests/market-manifest.spec.ts` pins every DSH peer as a floating caret range: an exact pin (the devDependency style) or a comparator without a prerelease would fail rc.1's preflight and silently stop the plugin from loading, and `pnpm peers check` cannot see that — it validates the resolved dev tree, not the range shape the host reads.
434 lines
17 KiB
TypeScript
434 lines
17 KiB
TypeScript
/**
|
||
* Mermaid diagram renderer for the markdown preview, resident in the
|
||
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
|
||
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
|
||
* lib/client-mermaid.js and fetched only when a previewed markdown file
|
||
* actually contains a mermaid fence (see mermaid-blocks.ts).
|
||
*
|
||
* Rendering architecture: the whole document is rendered ONCE through the
|
||
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
|
||
* footnotes, ordered-list continuity — stay intact), then a layout effect
|
||
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
|
||
* swap keeps the React-managed `.md-code-block` host node in the tree and
|
||
* only replaces its children (display:contents suppresses the code-block
|
||
* chrome), so React reconciliation never loses the host; when a swapped
|
||
* block stops being a mermaid fence, the original children are restored.
|
||
*
|
||
* Security: mermaid.render → sanitized SVG injected into the block.
|
||
* `bindFunctions` is intentionally NOT applied (static diagrams; no
|
||
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
|
||
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
|
||
* as real SVG <text>, and the emitted SVG is re-sanitized (see
|
||
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
|
||
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
|
||
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
|
||
* the clone carries no event surface, so the modal adds no attack surface).
|
||
*/
|
||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||
import { createPortal } from 'react-dom'
|
||
import { createRoot, type Root } from 'react-dom/client'
|
||
import mermaid from 'mermaid'
|
||
import { IconCopyOutlineRegular, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||
import { markdownTextProps } from './markdown-labels.tsx'
|
||
import { t } from './locales.ts'
|
||
import { sanitizeSvg } from './mermaid-sanitize.ts'
|
||
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||
import { splitMermaidBlocks } from './mermaid-blocks.ts'
|
||
import css from './sidebar.module.css'
|
||
|
||
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
|
||
let mermaidSeq = 0
|
||
|
||
/** Configure mermaid for the current color scheme (idempotent). */
|
||
function configureMermaid(): void {
|
||
mermaid.initialize({
|
||
startOnLoad: false,
|
||
securityLevel: 'strict',
|
||
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
|
||
// text inside <foreignObject>, which the sanitizer strips wholesale —
|
||
// forcing pure SVG text keeps labels visible and the HTML label channel
|
||
// closed (strict already escapes label content).
|
||
htmlLabels: false,
|
||
// Mermaid 11 renders a large error SVG into document.body before rejecting
|
||
// invalid diagrams. The component already has its own error fallback, so
|
||
// suppress that global side effect to keep the DSH shell intact.
|
||
suppressErrorRendering: true,
|
||
theme: isDarkScheme() ? 'dark' : 'default',
|
||
})
|
||
}
|
||
|
||
/** First lines of a mermaid error (its dumps are huge; the head explains). */
|
||
function summarizeError(error: unknown): string {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
return message.split('\n').slice(0, 6).join('\n')
|
||
}
|
||
|
||
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
|
||
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
|
||
const overlayRef = useRef<HTMLDivElement>(null)
|
||
const stageRef = useRef<HTMLDivElement>(null)
|
||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
|
||
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
|
||
|
||
const applyTransform = (): void => {
|
||
const node = svgRef.current
|
||
if (node === null) return
|
||
const { scale, tx, ty } = zoomRef.current
|
||
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
||
}
|
||
|
||
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
|
||
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
|
||
const stage = stageRef.current
|
||
if (stage === null) return
|
||
const rect = stage.getBoundingClientRect()
|
||
const cx = centerX ?? rect.width / 2
|
||
const cy = centerY ?? rect.height / 2
|
||
const current = zoomRef.current
|
||
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
|
||
// The svg is flex-centered in the stage, so its center sits at
|
||
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
|
||
// mouse point stationary in stage coordinates across the scale change.
|
||
const sx = rect.width / 2
|
||
const sy = rect.height / 2
|
||
const ratio = newScale / current.scale
|
||
current.tx = cx - sx - (cx - sx - current.tx) * ratio
|
||
current.ty = cy - sy - (cy - sy - current.ty) * ratio
|
||
current.scale = newScale
|
||
applyTransform()
|
||
}, [])
|
||
|
||
const reset = useCallback((): void => {
|
||
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
|
||
applyTransform()
|
||
}, [])
|
||
|
||
const close = useCallback((): void => { onClose() }, [onClose])
|
||
|
||
// Mount the caller-provided (sanitized) svg clone imperatively: React
|
||
// types don't accept a raw DOM node as a child, and the modal owns the
|
||
// node's lifetime (removed on unmount; the preview copy is untouched).
|
||
useEffect(() => {
|
||
const stage = stageRef.current
|
||
if (stage === null) return
|
||
svgRef.current = svg
|
||
stage.appendChild(svg)
|
||
return () => {
|
||
svg.remove()
|
||
svgRef.current = null
|
||
}
|
||
}, [svg])
|
||
|
||
useEffect(() => {
|
||
const stage = stageRef.current
|
||
const node = svgRef.current
|
||
const overlay = overlayRef.current
|
||
if (stage === null || node === null || overlay === null) return
|
||
|
||
const onWheel = (event: WheelEvent): void => {
|
||
event.preventDefault()
|
||
const rect = stage.getBoundingClientRect()
|
||
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
|
||
}
|
||
const onKey = (event: KeyboardEvent): void => {
|
||
if (event.key === 'Escape') { close(); return }
|
||
if (event.key === '+' || event.key === '=') zoom(1.2)
|
||
else if (event.key === '-') zoom(1 / 1.2)
|
||
else if (event.key === '0') reset()
|
||
}
|
||
const onMouseDown = (event: MouseEvent): void => {
|
||
event.preventDefault()
|
||
dragRef.current = {
|
||
active: true,
|
||
startX: event.clientX - zoomRef.current.tx,
|
||
startY: event.clientY - zoomRef.current.ty,
|
||
}
|
||
}
|
||
const onMouseMove = (event: MouseEvent): void => {
|
||
if (!dragRef.current.active) return
|
||
zoomRef.current.tx = event.clientX - dragRef.current.startX
|
||
zoomRef.current.ty = event.clientY - dragRef.current.startY
|
||
applyTransform()
|
||
}
|
||
const onMouseUp = (): void => { dragRef.current.active = false }
|
||
const onOverlayClick = (event: MouseEvent): void => {
|
||
if (event.target === overlay) close()
|
||
}
|
||
|
||
// React's synthetic wheel is passive; a native listener is required to
|
||
// preventDefault (the page must not scroll while zooming the modal).
|
||
stage.addEventListener('wheel', onWheel, { passive: false })
|
||
node.addEventListener('mousedown', onMouseDown)
|
||
window.addEventListener('mousemove', onMouseMove)
|
||
window.addEventListener('mouseup', onMouseUp)
|
||
window.addEventListener('keydown', onKey)
|
||
overlay.addEventListener('click', onOverlayClick)
|
||
return () => {
|
||
stage.removeEventListener('wheel', onWheel)
|
||
node.removeEventListener('mousedown', onMouseDown)
|
||
window.removeEventListener('mousemove', onMouseMove)
|
||
window.removeEventListener('mouseup', onMouseUp)
|
||
window.removeEventListener('keydown', onKey)
|
||
overlay.removeEventListener('click', onOverlayClick)
|
||
}
|
||
}, [zoom, reset, close])
|
||
|
||
return createPortal(
|
||
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
|
||
<div className={css.mermaidModalToolbar}>
|
||
<button
|
||
type="button"
|
||
className={css.mermaidModalButton}
|
||
title={t('mermaidZoomOut')}
|
||
onClick={() => zoom(1 / 1.2)}
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={css.mermaidModalButton}
|
||
title={t('mermaidZoomIn')}
|
||
onClick={() => zoom(1.2)}
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={css.mermaidModalButton}
|
||
title={t('mermaidZoomReset')}
|
||
onClick={reset}
|
||
>
|
||
⟳
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={css.mermaidModalButton}
|
||
title={t('close')}
|
||
onClick={close}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={css.mermaidModalStage} ref={stageRef} />
|
||
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
|
||
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
|
||
const [svg, setSvg] = useState<string | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [copied, setCopied] = useState(false)
|
||
/** Scheme state: a flip re-renders the diagram with the matching theme. */
|
||
const [dark, setDark] = useState(() => isDarkScheme())
|
||
/** The cloned svg shown in the zoom modal (null = closed). */
|
||
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
|
||
const copyTimer = useRef<number | undefined>(undefined)
|
||
|
||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
setSvg(null)
|
||
setError(null)
|
||
if (code.trim() === '') return () => { cancelled = true }
|
||
configureMermaid()
|
||
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
|
||
mermaid.render(id, code)
|
||
.then(({ svg: rendered }) => {
|
||
if (cancelled) return
|
||
const clean = sanitizeSvg(rendered)
|
||
if (clean === '') {
|
||
// Parse/sanitize rejection: never pass the raw string through.
|
||
setError(t('mermaidError'))
|
||
return
|
||
}
|
||
setSvg(clean)
|
||
})
|
||
.catch((reason: unknown) => {
|
||
if (cancelled) return
|
||
setError(summarizeError(reason))
|
||
})
|
||
return () => { cancelled = true }
|
||
}, [code, dark])
|
||
|
||
const onCopy = useCallback(() => {
|
||
if (copied) return
|
||
writeClipboard(code).then((ok) => {
|
||
if (!ok) return
|
||
setCopied(true)
|
||
window.clearTimeout(copyTimer.current)
|
||
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
|
||
})
|
||
}, [code, copied])
|
||
|
||
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
|
||
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
||
const svgEl = (event.target as Element).closest('svg')
|
||
if (svgEl === null) return
|
||
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
||
// The modal owns sizing/transform; drop the preview's inline geometry.
|
||
clone.removeAttribute('style')
|
||
clone.removeAttribute('width')
|
||
clone.removeAttribute('height')
|
||
setZoomSvg(clone)
|
||
}
|
||
|
||
return (
|
||
<div className={css.mermaidWrap}>
|
||
<div className={css.mermaidHeader}>
|
||
<span className={css.mermaidInfo}>mermaid</span>
|
||
<button
|
||
type="button"
|
||
className={css.mermaidCopy}
|
||
onClick={onCopy}
|
||
aria-label={t('copy')}
|
||
title={t('copy')}
|
||
>
|
||
<IconCopyOutlineRegular />
|
||
<span>{copied ? t('copied') : t('copy')}</span>
|
||
</button>
|
||
</div>
|
||
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
|
||
{svg !== null && (
|
||
<div
|
||
className={css.mermaidBody}
|
||
data-mermaid-diagram
|
||
onClick={onBodyClick}
|
||
dangerouslySetInnerHTML={{ __html: svg }}
|
||
/>
|
||
)}
|
||
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
|
||
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
|
||
interface MermaidMount {
|
||
root: Root
|
||
source: string
|
||
removed: ChildNode[]
|
||
}
|
||
|
||
/**
|
||
* Fence bodies of every mermaid fence in the source, normalized for matching
|
||
* against the rendered block.
|
||
* @param text - the markdown source the same component renders.
|
||
* @returns the trailing-whitespace-normalized diagram sources.
|
||
*/
|
||
function mermaidFenceBodies(text: string): Set<string> {
|
||
const bodies = new Set<string>()
|
||
for (const block of splitMermaidBlocks(text)) {
|
||
if (block.kind === 'mermaid') bodies.add(block.code.trimEnd())
|
||
}
|
||
return bodies
|
||
}
|
||
|
||
/** The block's fence body as rendered: CodeBlock trims one trailing newline. */
|
||
function blockBody(block: HTMLElement): string {
|
||
return (block.querySelector('code')?.textContent ?? '').trimEnd()
|
||
}
|
||
|
||
/**
|
||
* True when a rendered CodeBlock is a mermaid fence.
|
||
*
|
||
* The match is by FENCE BODY, not by the header: DSH ≤ 0.1.7-alpha.1 rendered a
|
||
* banner whose info string said `mermaid`, but rc.1's code card shows
|
||
* `labels.codeLabel` for any language the highlighter does not know, and
|
||
* mermaid has no shiki grammar — so once the card is adopted the language word
|
||
* is nowhere in the DOM. The body is, in both card variants, and the component
|
||
* already has the source that produced it.
|
||
*
|
||
* The `language-mermaid` class stays as the first probe: the highlighted arm
|
||
* would carry it if a grammar ever appears for the fence.
|
||
* @param block - one rendered `.md-code-block`.
|
||
* @param bodies - {@link mermaidFenceBodies} of the source being rendered.
|
||
* @returns whether this block holds a mermaid diagram.
|
||
*/
|
||
function isMermaidBlock(block: HTMLElement, bodies: ReadonlySet<string>): boolean {
|
||
const code = block.querySelector('code')
|
||
if (code === null) return false
|
||
if ([...code.classList].some(c => c.startsWith('language-mermaid'))) return true
|
||
return bodies.has(blockBody(block))
|
||
}
|
||
|
||
/**
|
||
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
|
||
* the full source (cross-fence reference/footnote/list semantics intact),
|
||
* then every rendered `language-mermaid` code block is swapped for a
|
||
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
|
||
* only its children are replaced — so reconciliation never loses the host;
|
||
* a block that stops being a mermaid fence gets its original children back.
|
||
* Only mounted when the source contains at least one mermaid fence (see
|
||
* TextEditor.tsx).
|
||
*/
|
||
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
|
||
const containerRef = useRef<HTMLDivElement>(null)
|
||
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
|
||
|
||
useLayoutEffect(() => {
|
||
const container = containerRef.current
|
||
if (container === null) return
|
||
const mounts = mountsRef.current
|
||
const seen = new Set<HTMLElement>()
|
||
const bodies = mermaidFenceBodies(text)
|
||
|
||
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
|
||
const mount = mounts.get(block)
|
||
const isMermaid = isMermaidBlock(block, bodies)
|
||
if (!isMermaid) {
|
||
// A previously swapped block that is no longer a mermaid fence:
|
||
// restore the CodeBlock children React still manages, so the plain
|
||
// fence renders normally again.
|
||
if (mount !== undefined) {
|
||
mount.root.unmount()
|
||
block.replaceChildren(...mount.removed)
|
||
block.removeAttribute('data-mermaid-processed')
|
||
mounts.delete(block)
|
||
}
|
||
continue
|
||
}
|
||
seen.add(block)
|
||
// The plain body always carries the fence source in <code>.
|
||
const source = block.querySelector('code')?.textContent ?? ''
|
||
if (mount !== undefined && mount.source === source) continue
|
||
if (mount === undefined) {
|
||
const host = document.createElement('div')
|
||
const removed = [...block.childNodes]
|
||
block.replaceChildren(host)
|
||
block.setAttribute('data-mermaid-processed', 'true')
|
||
const root = createRoot(host)
|
||
mounts.set(block, { root, source, removed })
|
||
root.render(<MermaidDiagram code={source} />)
|
||
} else {
|
||
mount.source = source
|
||
mount.root.render(<MermaidDiagram code={source} />)
|
||
}
|
||
}
|
||
|
||
// Drop mounts whose code block left the tree (fence removed, document
|
||
// restructured): the host node is gone with it.
|
||
for (const [block, mount] of mounts) {
|
||
if (seen.has(block)) continue
|
||
mount.root.unmount()
|
||
mounts.delete(block)
|
||
}
|
||
}, [text])
|
||
|
||
useEffect(() => () => {
|
||
for (const { root } of mountsRef.current.values()) root.unmount()
|
||
mountsRef.current.clear()
|
||
}, [])
|
||
|
||
return (
|
||
<div className={css.mermaidMarkdown} ref={containerRef}>
|
||
<MarkdownText {...markdownTextProps(text, codeLabels)} />
|
||
</div>
|
||
)
|
||
}
|