Merge remote-tracking branch 'origin/master' into worktree/fix-ui-polish
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
# @deepseek-ai/dsh-client-ui-primitives
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8.
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -15,4 +19,3 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **MessageText renders plain text** — markdown support swaps this component's internals later; consumers must not assume block structure.
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -18,5 +18,6 @@ export { BrandWordmark } from './BrandWordmark.tsx'
|
||||
export { Tooltip } from './Tooltip.tsx'
|
||||
export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { JsonBlock } from './markdown/JsonBlock.tsx'
|
||||
export { MarkdownText } from './markdown/MarkdownText.tsx'
|
||||
export { MessageText } from './markdown/MessageText.tsx'
|
||||
export * from './icons/index.tsx'
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
.markdown {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--dsw-font-markdown-base);
|
||||
}
|
||||
|
||||
.markdown :where(h1, h2, h3, h4, h5, h6, p, ul, ol, blockquote, pre, hr) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.markdown h1 {
|
||||
font: var(--dsw-font-markdown-h1);
|
||||
}
|
||||
|
||||
.markdown h2 {
|
||||
font: var(--dsw-font-markdown-h2);
|
||||
}
|
||||
|
||||
.markdown h3 {
|
||||
font: var(--dsw-font-markdown-h3);
|
||||
}
|
||||
|
||||
.markdown :where(h4, h5, h6) {
|
||||
font: var(--dsw-font-markdown-h4);
|
||||
}
|
||||
|
||||
.markdown :where(strong, th) {
|
||||
font-weight: var(--dsw-font-markdown-base-strong-font-weight);
|
||||
}
|
||||
|
||||
.markdown :where(ul, ol) {
|
||||
padding-inline-start: 24px;
|
||||
}
|
||||
|
||||
.markdown li + li {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown li > :where(ul, ol) {
|
||||
margin-block-start: 4px;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
padding-inline-start: 12px;
|
||||
border-inline-start: 3px solid var(--dsw-alias-markdown-citation);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.markdown a {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown :not(pre) > code {
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--dsw-alias-markdown-inline-code);
|
||||
font: var(--dsw-font-markdown-code);
|
||||
}
|
||||
|
||||
.markdown pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-markdown-code-block);
|
||||
font: var(--dsw-font-markdown-code-block);
|
||||
}
|
||||
|
||||
.markdown pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
overflow-wrap: normal;
|
||||
word-break: normal;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.markdown hr {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-block-start: 1px solid var(--dsw-alias-markdown-citation);
|
||||
}
|
||||
|
||||
.markdown input[type='checkbox'] {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.tableScroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
|
||||
.tableScroll table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font: var(--dsw-font-markdown-table);
|
||||
}
|
||||
|
||||
.tableScroll :where(th, td) {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-markdown-citation);
|
||||
text-align: start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tableScroll th {
|
||||
background: var(--dsw-alias-markdown-code-block-banner);
|
||||
font: var(--dsw-font-markdown-table-head);
|
||||
}
|
||||
|
||||
.imageAlt {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
64
packages/client/ui-primitives/src/markdown/MarkdownText.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import type { Components, UrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import css from './MarkdownText.module.css'
|
||||
|
||||
const remarkPlugins = [remarkGfm]
|
||||
|
||||
function sanitizeUrl(url: string): string {
|
||||
try {
|
||||
switch (new URL(url).protocol) {
|
||||
case 'http:':
|
||||
case 'https:':
|
||||
case 'mailto:':
|
||||
return url
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl: UrlTransform = url => sanitizeUrl(url)
|
||||
|
||||
const components: Components = {
|
||||
a: ({ href = '', children }) => {
|
||||
const safeHref = sanitizeUrl(href)
|
||||
if (safeHref === '') return <>{children}</>
|
||||
const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
|
||||
return (
|
||||
<a
|
||||
href={safeHref}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
},
|
||||
img: ({ alt = '' }) => <span className={css.imageAlt}>{alt}</span>,
|
||||
table: ({ children }) => (
|
||||
<div className={css.tableScroll}>
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Render untrusted assistant-authored Markdown as semantic React elements.
|
||||
* @param props - Markdown source text preserved by the session projection.
|
||||
* @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled.
|
||||
*/
|
||||
export function MarkdownText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className={css.markdown}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
components={components}
|
||||
urlTransform={safeUrl}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// MessageText: the single text-block rendering point (Markdown support later = swap this component's internals, zero card-structure changes).
|
||||
// MessageText is the literal-text primitive for user and steering content; assistant output uses MarkdownText.
|
||||
|
||||
import css from './MessageText.module.css'
|
||||
|
||||
|
||||
@@ -1,14 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { JsonBlock, MarkdownText, MessageText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('MessageText', () => {
|
||||
it('renders the text verbatim', () => {
|
||||
const { container } = render(<MessageText text={'line1\nline2'} />)
|
||||
expect(container.textContent).toBe('line1\nline2')
|
||||
const { container } = render(<MessageText text={'# line1\n`line2`'} />)
|
||||
expect(container.textContent).toBe('# line1\n`line2`')
|
||||
expect(container.querySelector('h1')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownText', () => {
|
||||
it('renders CommonMark and GFM elements as semantic DOM', () => {
|
||||
const markdown = [
|
||||
'# Heading',
|
||||
'',
|
||||
'Paragraph with **strong**, *emphasis*, ~~deleted~~, `inline`, and [safe](https://example.com). ',
|
||||
'Hard break.',
|
||||
'',
|
||||
'> Quote',
|
||||
'',
|
||||
'- parent',
|
||||
' - child',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'- [x] done',
|
||||
'- [ ] pending',
|
||||
'',
|
||||
'| Name | Value |',
|
||||
'| --- | --- |',
|
||||
'| alpha | beta |',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'```ts',
|
||||
'const answer = 42',
|
||||
'```',
|
||||
'',
|
||||
'<https://deepseek.com>',
|
||||
].join('\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Heading' })).toBeTruthy()
|
||||
expect(container.querySelector('strong')?.textContent).toBe('strong')
|
||||
expect(container.querySelector('em')?.textContent).toBe('emphasis')
|
||||
expect(container.querySelector('del')?.textContent).toBe('deleted')
|
||||
expect(container.querySelector('blockquote')?.textContent?.trim()).toBe('Quote')
|
||||
expect(container.querySelectorAll('ul')).toHaveLength(3)
|
||||
expect(container.querySelector('ol')).not.toBeNull()
|
||||
expect(container.querySelectorAll('input[type="checkbox"]')).toHaveLength(2)
|
||||
expect(container.querySelector('table')?.textContent).toContain('alphabeta')
|
||||
expect(container.querySelector('hr')).not.toBeNull()
|
||||
expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
|
||||
expect(container.querySelector('br')).not.toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
|
||||
const markdown = [
|
||||
'<script>globalThis.compromised = true</script>',
|
||||
'<img src="x" onerror="globalThis.compromised = true">',
|
||||
'[script](javascript:alert(1)) [relative](/settings)',
|
||||
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
|
||||
'',
|
||||
].join('\n\n')
|
||||
const { container } = render(<MarkdownText text={markdown} />)
|
||||
|
||||
expect(container.querySelector('script')).toBeNull()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
const neutralized = [...container.querySelectorAll('p')]
|
||||
.find(paragraph => paragraph.textContent === 'script relative')
|
||||
expect(neutralized?.querySelector('a')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
|
||||
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
|
||||
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
|
||||
expect(screen.getByText('remote diagram')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps incomplete streaming Markdown renderable', () => {
|
||||
const { container } = render(<MarkdownText text={'## Streaming\n\n- first\n- **unfinished'} />)
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Streaming' })).toBeTruthy()
|
||||
expect(container.querySelectorAll('li')).toHaveLength(2)
|
||||
expect(screen.getByText('**unfinished')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user