feat(ui): add trajectory inspection ledger

This commit is contained in:
_Kerman
2026-07-27 15:47:44 +08:00
parent 79eb3a9035
commit 628c1bffe0
42 changed files with 4549 additions and 178 deletions

View File

@@ -22,8 +22,12 @@
"dependencies": {
"@shikijs/langs": "^4.3.1",
"clsx": "^2.0.0",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
"micromark-extension-gfm": "^3.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-json-view-lite": "^2.5.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"shiki": "^4.3.1"

View File

@@ -0,0 +1,221 @@
.root {
--json-tree-property: #881391;
--json-tree-string: #c41a16;
--json-tree-number: #1c00cf;
--json-tree-keyword: #1c00cf;
--json-tree-punctuation: #202124;
--json-tree-icon: #5f6368;
--json-tree-hover: rgb(60 64 67 / 4%);
min-width: 0;
overflow: auto;
position: relative;
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-bg-layer-1);
font: 12px/16px var(--ds-font-family-code);
overscroll-behavior: contain;
}
:global(body[data-ds-dark-theme]) .root {
--json-tree-property: #5db0d7;
--json-tree-string: #f28b82;
--json-tree-number: #99c8ff;
--json-tree-keyword: #99c8ff;
--json-tree-punctuation: #e8eaed;
--json-tree-icon: #9aa0a6;
--json-tree-hover: rgb(232 234 237 / 5%);
}
.container {
box-sizing: border-box;
width: max-content;
min-width: 100%;
margin: 0;
padding: 6px 8px 8px;
white-space: pre;
}
.expandedTopLevel {
box-sizing: border-box;
width: max-content;
min-width: 100%;
padding: 6px 8px 8px 14px;
}
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row]:hover),
.expandedTopLevel:has(> .topLevelBracket[data-json-root-row][data-json-copy-active]) {
background: var(--json-tree-hover);
}
.expandedTopLevelContainer {
padding: 0 0 0 calc(2ch - 12px);
}
.row.topLevelBracket {
margin-left: 0;
padding-left: 0;
}
.children {
margin: 0;
padding: 0 0 0 4px;
list-style: none;
}
.row {
position: relative;
box-sizing: border-box;
min-width: 100%;
min-height: 16px;
margin: 0;
padding: 0 0 0 12px;
list-style: none;
}
.row:not(.topLevelBracket):hover:not(:has(.row:hover))::after,
.row:not(.topLevelBracket)[data-json-copy-active]::after,
.row:has(> .expander:focus-visible)::after {
position: absolute;
z-index: 0;
top: 0;
right: -100vw;
left: -100vw;
height: 16px;
background: var(--json-tree-hover);
content: '';
pointer-events: none;
}
.row > span:not(.expander) {
position: relative;
z-index: 1;
}
.label {
margin-right: 3px;
color: var(--json-tree-property);
font-weight: 400;
}
.clickableLabel {
cursor: pointer;
}
.stringValue {
color: var(--json-tree-string);
}
.numberValue {
color: var(--json-tree-number);
}
.keywordValue {
color: var(--json-tree-keyword);
}
.otherValue {
color: var(--dsw-alias-label-secondary);
}
.punctuation {
color: var(--json-tree-punctuation);
}
.preview {
color: var(--json-tree-punctuation);
}
.previewProperty {
color: var(--json-tree-punctuation);
}
.previewEllipsis {
color: var(--dsw-alias-label-tertiary);
}
.copyAnchor {
position: absolute;
z-index: 3;
display: inline-flex;
}
.copyButton {
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: 3px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-layer-1);
box-shadow: -5px 0 5px var(--dsw-alias-bg-layer-1);
cursor: pointer;
}
.copyButton:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.copyButton:focus-visible {
outline: 1px solid var(--dsw-alias-state-business-primary);
outline-offset: -1px;
}
.copyButton[data-state='failed'] {
color: var(--dsw-alias-state-error-primary);
}
.expander {
position: absolute;
z-index: 2;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 8px;
height: 16px;
margin: 0;
color: var(--json-tree-icon);
cursor: pointer;
user-select: none;
}
.expander::before {
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid currentColor;
content: '';
transform: scale(0.75);
transform-origin: center;
}
.collapseIcon::before {
transform: rotate(90deg) scale(0.75);
}
.expander:hover {
color: var(--dsw-alias-label-primary);
}
.expander:focus-visible {
outline: none;
}
.collapsedContent {
margin: 0 1px;
color: var(--json-tree-punctuation);
cursor: pointer;
}
.collapsedContent::after {
content: '…';
}

View File

@@ -0,0 +1,390 @@
import clsx from 'clsx'
import { collapseAllNested, JsonView } from 'react-json-view-lite'
import { useEffect, useRef, useState } from 'react'
import type { MouseEvent as ReactMouseEvent, ReactNode, UIEvent as ReactUIEvent } from 'react'
import type { Props as LiteJsonViewProps } from 'react-json-view-lite'
import { IconCheckOutline16, IconCopyOutline16 } from './icons/index.tsx'
import { Menu } from './Menu.tsx'
import type { MenuEntry } from './Menu.tsx'
import css from './JsonTree.module.css'
const OBJECT_PREVIEW_LIMIT = 4
const ARRAY_PREVIEW_LIMIT = 5
const PREVIEW_DEPTH_LIMIT = 2
const VALUE_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'value', label: 'Copy value' },
{ id: 'json', label: 'Copy JSON' },
{ id: 'path', label: 'Copy property path' },
]
const OBJECT_COPY_MENU_ITEMS: readonly MenuEntry[] = [
{ id: 'prettyJson', label: 'Copy pretty JSON' },
{ id: 'json', label: 'Copy compact JSON' },
{ id: 'path', label: 'Copy property path' },
]
const TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
container: css.container!,
childFieldsContainer: css.children!,
basicChildStyle: css.row!,
label: css.label!,
clickableLabel: `${css.label!} ${css.clickableLabel!}`,
nullValue: css.keywordValue!,
undefinedValue: css.keywordValue!,
numberValue: css.numberValue!,
stringValue: css.stringValue!,
booleanValue: css.keywordValue!,
otherValue: css.otherValue!,
punctuation: css.punctuation!,
expandIcon: `${css.expander!} ${css.expandIcon!}`,
collapseIcon: `${css.expander!} ${css.collapseIcon!}`,
collapsedContent: css.collapsedContent!,
noQuotesForStringValues: false,
quotesForFieldNames: false,
stringifyStringValues: true,
ariaLables: {
collapseJson: 'Collapse JSON node',
expandJson: 'Expand JSON node',
},
}
const EXPANDED_TOP_LEVEL_TREE_STYLES: NonNullable<LiteJsonViewProps['style']> = {
...TREE_STYLES,
container: `${css.container!} ${css.expandedTopLevelContainer!}`,
}
function previewPrimitive(value: unknown): ReactNode {
if (value === null) return <span className={css.keywordValue}>null</span>
if (typeof value === 'string') {
return <span className={css.stringValue}>{JSON.stringify(value)}</span>
}
if (typeof value === 'number') {
return <span className={css.numberValue}>{String(value)}</span>
}
if (typeof value === 'boolean') {
return <span className={css.keywordValue}>{String(value)}</span>
}
return <span className={css.otherValue}>{String(value)}</span>
}
function previewValue(value: unknown, depth: number): ReactNode {
if (typeof value !== 'object' || value === null) return previewPrimitive(value)
const array = Array.isArray(value)
const entries = array
? value.map((item, index) => [String(index), item] as const)
: Object.entries(value)
const limit = array ? ARRAY_PREVIEW_LIMIT : OBJECT_PREVIEW_LIMIT
const visible = entries.slice(0, limit)
const open = array ? '[' : '{'
const close = array ? ']' : '}'
return (
<>
<span className={css.punctuation}>{open}</span>
{depth >= PREVIEW_DEPTH_LIMIT
? <span className={css.previewEllipsis}></span>
: visible.map(([key, item], index) => (
<span key={key}>
{index > 0 && <span className={css.punctuation}>, </span>}
{!array && (
<>
<span className={css.previewProperty}>{key}</span>
<span className={css.punctuation}>: </span>
</>
)}
{previewValue(item, depth + 1)}
</span>
))}
{depth < PREVIEW_DEPTH_LIMIT && entries.length > limit && (
<span className={css.previewEllipsis}>{visible.length > 0 ? ', …' : '…'}</span>
)}
<span className={css.punctuation}>{close}</span>
</>
)
}
function renderExpandableValue(value: object): ReactNode {
return <span className={css.preview}>{previewValue(value, 0)}</span>
}
interface CopyTarget {
left: number
path: readonly (number | string)[]
side: 'bottom' | 'top'
top: number
value: unknown
}
function fieldOf(row: HTMLElement): string | undefined {
const label = Array.from(row.children).find(
child => child instanceof HTMLElement && child.classList.contains(css.label!),
)
const text = label?.textContent
return text === undefined || text === null ? undefined : text.slice(0, -1)
}
function resolveRow(data: object | unknown[], row: HTMLElement, expandTopLevel: boolean): {
path: readonly (number | string)[]
value: unknown
} | undefined {
if (row.hasAttribute('data-json-root-row')) return { path: [], value: data }
const lineage: HTMLElement[] = []
let cursor: HTMLElement | null = row
while (cursor !== null) {
lineage.unshift(cursor)
const group: HTMLElement | null = cursor.parentElement
const parentRow: Element | null = group?.getAttribute('role') === 'group'
? group.parentElement?.closest('[role="treeitem"]') ?? null
: null
cursor = parentRow instanceof HTMLElement ? parentRow : null
}
let value: unknown = data
const path: (number | string)[] = []
for (const item of expandTopLevel ? lineage : lineage.slice(1)) {
const field = fieldOf(item)
if (field === undefined) return undefined
if (Array.isArray(value)) {
const index = Number(field)
if (!Number.isInteger(index)) return undefined
path.push(index)
value = value[index]
} else if (typeof value === 'object' && value !== null) {
path.push(field)
value = (value as Record<string, unknown>)[field]
} else {
return undefined
}
}
return { path, value }
}
function formattedPath(path: readonly (number | string)[]): string {
return path.reduce<string>((result, part) => {
if (typeof part === 'number') return `${result}[${String(part)}]`
return /^[A-Za-z_$][\w$]*$/.test(part)
? `${result}.${part}`
: `${result}[${JSON.stringify(part)}]`
}, '$')
}
function copyText(target: CopyTarget, mode: 'json' | 'path' | 'prettyJson' | 'value'): string {
if (mode === 'path') return formattedPath(target.path)
if (mode === 'prettyJson') return JSON.stringify(target.value, null, 2)
if (mode === 'json') return JSON.stringify(target.value) ?? String(target.value)
if (typeof target.value === 'string') return target.value
if (typeof target.value === 'object' && target.value !== null) {
return JSON.stringify(target.value, null, 2)
}
return JSON.stringify(target.value) ?? String(target.value)
}
/** Props for the read-only, token-themed JSON tree. */
export interface JsonTreeProps {
/** Parsed JSON object or array. */
data: object | unknown[]
/** Accessible label for the tree. */
label?: string
/** Optional positioning class owned by the caller. */
className?: string | undefined
/** Whether JSON rows expose copy actions. */
copyable?: boolean
/** Whether the top-level object or array is always expanded. */
expandTopLevel?: boolean
}
/**
* Render parsed JSON as a compact, keyboard-accessible inspector tree.
* @param props - Parsed data, accessible label, and display options.
* @returns A read-only JSON tree with an optionally fixed-open top level.
*/
export function JsonTree({
data,
label = 'JSON',
className,
copyable = true,
expandTopLevel = true,
}: JsonTreeProps) {
const rootRef = useRef<HTMLDivElement>(null)
const activeRowRef = useRef<HTMLElement>()
const copyButtonRef = useRef<HTMLButtonElement>(null)
const copyMenuOpenRef = useRef(false)
const resetTimer = useRef<ReturnType<typeof setTimeout>>()
const [copyTarget, setCopyTarget] = useState<CopyTarget>()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle')
const [copyMenuOpen, setCopyMenuOpen] = useState(false)
useEffect(() => () => {
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
activeRowRef.current?.removeAttribute('data-json-copy-active')
}, [])
const setActiveRow = (row: HTMLElement | undefined) => {
activeRowRef.current?.removeAttribute('data-json-copy-active')
activeRowRef.current = row
row?.setAttribute('data-json-copy-active', '')
}
const positionCopyButton = (row: HTMLElement, target: {
path: readonly (number | string)[]
value: unknown
}) => {
const root = rootRef.current
if (root === null) return
const rootRect = root.getBoundingClientRect()
const rowRect = row.getBoundingClientRect()
setCopyTarget({
left: root.scrollLeft + root.clientWidth - 26,
path: target.path,
side: rowRect.top - rootRect.top > root.clientHeight / 2 ? 'top' : 'bottom',
top: root.scrollTop + rowRect.top - rootRect.top,
value: target.value,
})
}
const clearCopyTarget = () => {
setActiveRow(undefined)
setCopyTarget(undefined)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}
const handleMouseOver = (event: ReactMouseEvent<HTMLDivElement>) => {
if (!copyable || !(event.target instanceof Element)) return
if (copyMenuOpenRef.current) return
if (!event.currentTarget.contains(event.target)) return
if (event.target.closest('[data-json-copy-button]') !== null) return
const row = event.target.closest<HTMLElement>('[data-json-root-row], [role="treeitem"]')
if (row === null) {
clearCopyTarget()
return
}
if (activeRowRef.current === row) return
const resolved = resolveRow(data, row, expandTopLevel)
if (resolved === undefined) return
setActiveRow(row)
setCopyState('idle')
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
positionCopyButton(row, resolved)
}
const handleScroll = (event: ReactUIEvent<HTMLDivElement>) => {
if (event.currentTarget !== event.target) return
const row = activeRowRef.current
if (row === undefined) return
const resolved = resolveRow(data, row, expandTopLevel)
if (resolved !== undefined) positionCopyButton(row, resolved)
}
const copy = async (mode: 'json' | 'path' | 'prettyJson' | 'value') => {
if (copyTarget === undefined) return
try {
await navigator.clipboard.writeText(copyText(copyTarget, mode))
setCopyState('copied')
} catch {
setCopyState('failed')
}
if (resetTimer.current !== undefined) clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => setCopyState('idle'), 1_500)
}
const copyTargetIsObject = typeof copyTarget?.value === 'object' && copyTarget.value !== null
const defaultCopyMode = copyTargetIsObject ? 'prettyJson' : 'value'
const copyTitle = copyState === 'copied'
? 'Copied'
: copyState === 'failed'
? 'Copy failed'
: copyTargetIsObject ? 'Copy pretty JSON' : 'Copy value'
return (
<div
ref={rootRef}
className={clsx(css.root, className)}
onMouseOver={handleMouseOver}
onMouseLeave={() => {
if (!copyMenuOpenRef.current) clearCopyTarget()
}}
onScroll={handleScroll}
>
{expandTopLevel
? (
<div className={css.expandedTopLevel}>
<div className={clsx(css.row, css.topLevelBracket)} data-json-root-row>
<span className={css.punctuation}>{Array.isArray(data) ? '[' : '{'}</span>
</div>
<JsonView
aria-label={label}
compactTopLevel
data={data}
style={EXPANDED_TOP_LEVEL_TREE_STYLES}
shouldExpandNode={collapseAllNested}
clickToExpandNode
renderExpandableValue={renderExpandableValue}
/>
<div className={clsx(css.row, css.topLevelBracket)}>
<span className={css.punctuation}>{Array.isArray(data) ? ']' : '}'}</span>
</div>
</div>
)
: (
<JsonView
aria-label={label}
data={data}
style={TREE_STYLES}
shouldExpandNode={collapseAllNested}
clickToExpandNode
renderExpandableValue={renderExpandableValue}
/>
)}
{copyTarget !== undefined && (
<span
className={css.copyAnchor}
style={{ left: copyTarget.left, top: copyTarget.top }}
>
<Menu
open={copyMenuOpen}
compact
portal
align="end"
side={copyTarget.side}
anchor={(
<button
ref={copyButtonRef}
type="button"
className={css.copyButton}
data-json-copy-button
data-state={copyState}
aria-label={copyTitle}
title={`${copyTitle}; right-click for copy options`}
onClick={() => void copy(defaultCopyMode)}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
copyMenuOpenRef.current = true
setCopyMenuOpen(true)
}}
>
{copyState === 'copied'
? <IconCheckOutline16 size={12} />
: <IconCopyOutline16 size={12} />}
</button>
)}
items={copyTargetIsObject ? OBJECT_COPY_MENU_ITEMS : VALUE_COPY_MENU_ITEMS}
onSelect={(id) => {
if (id === 'value' || id === 'json' || id === 'prettyJson' || id === 'path') {
void copy(id)
}
copyMenuOpenRef.current = false
setCopyMenuOpen(false)
}}
onClose={clearCopyTarget}
getAnchorRect={() => copyButtonRef.current?.getBoundingClientRect() ?? null}
/>
</span>
)}
</div>
)
}

View File

@@ -77,6 +77,37 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.list.compactList,
.submenu.compactList {
min-width: 164px;
padding: 2px;
border-radius: 7px;
}
.compactList .item {
min-height: 26px;
gap: 6px;
padding: 3px 7px;
border-radius: 5px;
font-size: 12px;
line-height: 18px;
}
.compactList .itemIcon {
width: 14px;
height: 14px;
}
.compactList .separator {
margin: 2px;
}
.compactList .label {
padding: 4px 7px;
font-size: 11px;
line-height: 16px;
}
.item:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;

View File

@@ -66,6 +66,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.compact - use reduced menu typography and spacing.
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -74,7 +75,7 @@ function isLabel(entry: MenuEntry): entry is MenuLabel {
* scroll/resize; return null to skip placement for that frame.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, compact = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -85,6 +86,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
side?: 'bottom' | 'top'
portal?: boolean
closeOnPointerLeave?: boolean
compact?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -149,7 +151,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
const list = open && (!portal || fixedPos !== null) && (
<div
ref={listRef}
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
className={clsx(css.list, compact && css.compactList, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
@@ -196,7 +198,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
{entry.id === selectedId && <IconCheckOutline16 className={css.check} />}
</button>
{subOpen && entry.submenu !== undefined && (
<div className={css.submenu} role="menu">
<div className={clsx(css.submenu, compact && css.compactList)} role="menu">
{entry.submenu.map(sub => (
<button
key={sub.id}

View File

@@ -17,8 +17,12 @@ export { FishLogo } from './FishLogo.tsx'
export { BrandWordmark } from './BrandWordmark.tsx'
export { Tooltip } from './Tooltip.tsx'
export type { TooltipSide } from './Tooltip.tsx'
export { JsonTree } from './JsonTree.tsx'
export type { JsonTreeProps } from './JsonTree.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownText } from './markdown/MarkdownText.tsx'
export { MessageText } from './markdown/MessageText.tsx'
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
export * from './icons/index.tsx'

View File

@@ -0,0 +1,126 @@
/**
* Markdown-to-plain-text projection for compact summaries and labels.
* Parsing shares the renderer's GFM grammar; raw HTML is intentionally
* omitted, links keep their labels, images keep alt text, and code keeps its
* source text.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
/** Amount of parsed Markdown content returned by the extractor. */
export type MarkdownPlainTextMode = 'all' | 'first-line' | 'first-paragraph'
/** Options for {@link extractMarkdownPlainText}. */
export interface MarkdownPlainTextOptions {
/** Projection boundary; defaults to the complete document. */
mode?: MarkdownPlainTextMode
}
interface MarkdownNode {
type: string
value?: string
alt?: string
children?: MarkdownNode[]
}
function inlineText(node: MarkdownNode): string {
switch (node.type) {
case 'text':
case 'inlineCode':
case 'code':
return node.value ?? ''
case 'image':
case 'imageReference':
return node.alt ?? ''
case 'break':
return '\n'
case 'html':
case 'thematicBreak':
case 'definition':
return ''
default:
return node.children?.map(inlineText).join('') ?? ''
}
}
function compactInline(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
function blockText(node: MarkdownNode): string {
switch (node.type) {
case 'root':
case 'blockquote':
return node.children?.map(blockText).filter(Boolean).join('\n\n') ?? ''
case 'paragraph':
case 'heading':
return compactInline(inlineText(node))
case 'code':
return node.value?.trim() ?? ''
case 'list':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'listItem':
return node.children?.map(blockText).filter(Boolean).join(' ') ?? ''
case 'table':
return node.children?.map(blockText).filter(Boolean).join('\n') ?? ''
case 'tableRow':
return node.children?.map(blockText).join('\t') ?? ''
case 'tableCell':
return compactInline(inlineText(node))
case 'html':
case 'thematicBreak':
case 'definition':
return ''
default:
return compactInline(inlineText(node))
}
}
function findFirstParagraph(node: MarkdownNode): string | undefined {
if (node.type === 'paragraph') {
const text = compactInline(inlineText(node))
if (text !== '') return text
}
for (const child of node.children ?? []) {
const text = findFirstParagraph(child)
if (text !== undefined) return text
}
return undefined
}
function fullText(root: MarkdownNode): string {
return blockText(root)
.split('\n')
.map(line => line.trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/**
* Parse GFM Markdown and project its user-visible plain text.
* @param markdown - Markdown source.
* @param options - Optional extraction boundary.
* @returns Plain text for the whole document, first visible line, or first semantic paragraph.
*/
export function extractMarkdownPlainText(
markdown: string,
options: MarkdownPlainTextOptions = {},
): string {
const { mode = 'all' } = options
const root = fromMarkdown(markdown, {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
}) as MarkdownNode
const all = fullText(root)
switch (mode) {
case 'all':
return all
case 'first-line':
return all.split('\n').find(line => line !== '') ?? ''
case 'first-paragraph':
return findFirstParagraph(root) ?? all.split('\n').find(line => line !== '') ?? ''
}
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
const MARKDOWN = [
'# Release notes',
'',
'First **paragraph** with [a link](https://example.com) and ![diagram](diagram.png).',
'',
'- shipped',
'- `verified`',
'',
'```ts',
'const ready = true',
'```',
].join('\n')
describe('extractMarkdownPlainText', () => {
it('projects the complete GFM document without presentation syntax', () => {
expect(extractMarkdownPlainText(MARKDOWN)).toBe([
'Release notes',
'',
'First paragraph with a link and diagram.',
'',
'shipped',
'verified',
'',
'const ready = true',
].join('\n'))
})
it('selects the first visible line or first semantic paragraph', () => {
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-line' })).toBe('Release notes')
expect(extractMarkdownPlainText(MARKDOWN, { mode: 'first-paragraph' }))
.toBe('First paragraph with a link and diagram.')
})
it('omits raw HTML and returns a useful fallback when no paragraph exists', () => {
const markdown = '<script>alert(1)</script>\n\n## Safe heading'
expect(extractMarkdownPlainText(markdown)).toBe('Safe heading')
expect(extractMarkdownPlainText(markdown, { mode: 'first-paragraph' })).toBe('Safe heading')
})
})