This commit is contained in:
Joel
2026-03-11 11:07:05 +08:00
13 changed files with 1081 additions and 479 deletions

View File

@ -0,0 +1,133 @@
/* eslint-disable e18e/prefer-static-regex */
import type { InSiteMessageActionItem } from './index'
import { fireEvent, render, screen } from '@testing-library/react'
import InSiteMessage from './index'
describe('InSiteMessage', () => {
const originalLocation = window.location
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
})
vi.unstubAllGlobals()
})
const renderComponent = (actions: InSiteMessageActionItem[], props?: Partial<React.ComponentProps<typeof InSiteMessage>>) => {
return render(
<InSiteMessage
title="Title\\nLine"
subtitle="Subtitle\\nLine"
main="Main content"
actions={actions}
{...props}
/>,
)
}
// Validate baseline rendering and content normalization.
describe('Rendering', () => {
it('should render title, subtitle, markdown content, and action buttons', () => {
const actions: InSiteMessageActionItem[] = [
{ action: 'close', text: 'Close', type: 'default' },
{ action: 'link', text: 'Learn more', type: 'primary', data: 'https://example.com' },
]
renderComponent(actions, { className: 'custom-message' })
const closeButton = screen.getByRole('button', { name: 'Close' })
const learnMoreButton = screen.getByRole('button', { name: 'Learn more' })
const panel = closeButton.closest('div.fixed')
const titleElement = panel?.querySelector('.title-3xl-bold')
const subtitleElement = panel?.querySelector('.body-md-regular')
expect(panel).toHaveClass('custom-message')
expect(titleElement).toHaveTextContent(/Title.*Line/s)
expect(subtitleElement).toHaveTextContent(/Subtitle.*Line/s)
expect(titleElement?.textContent).not.toContain('\\n')
expect(subtitleElement?.textContent).not.toContain('\\n')
expect(screen.getByText('Main content')).toBeInTheDocument()
expect(closeButton).toBeInTheDocument()
expect(learnMoreButton).toBeInTheDocument()
})
it('should fallback to default header background when headerBgUrl is empty string', () => {
const actions: InSiteMessageActionItem[] = [{ action: 'close', text: 'Close', type: 'default' }]
const { container } = renderComponent(actions, { headerBgUrl: '' })
const header = container.querySelector('div[style]')
expect(header).toHaveStyle({ backgroundImage: 'url(/in-site-message/header-bg.svg)' })
})
})
// Validate action handling for close and link actions.
describe('Actions', () => {
it('should call onAction and hide component when close action is clicked', () => {
const onAction = vi.fn()
const closeAction: InSiteMessageActionItem = { action: 'close', text: 'Close', type: 'default' }
renderComponent([closeAction], { onAction })
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(onAction).toHaveBeenCalledWith(closeAction)
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
})
it('should open a new tab when link action data is a string', () => {
const linkAction: InSiteMessageActionItem = {
action: 'link',
text: 'Open link',
type: 'primary',
data: 'https://example.com',
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open link' }))
expect(window.open).toHaveBeenCalledWith('https://example.com', '_blank', 'noopener,noreferrer')
})
it('should navigate with location.assign when link action target is _self', () => {
const assignSpy = vi.fn()
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
assign: assignSpy,
},
configurable: true,
})
const linkAction: InSiteMessageActionItem = {
action: 'link',
text: 'Open self',
type: 'primary',
data: { href: 'https://example.com/self', target: '_self' },
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Open self' }))
expect(assignSpy).toHaveBeenCalledWith('https://example.com/self')
expect(window.open).not.toHaveBeenCalled()
})
it('should not trigger navigation when link data is invalid', () => {
const linkAction: InSiteMessageActionItem = {
action: 'link',
text: 'Broken link',
type: 'primary',
data: { rel: 'noopener' },
}
renderComponent([linkAction])
fireEvent.click(screen.getByRole('button', { name: 'Broken link' }))
expect(window.open).not.toHaveBeenCalled()
})
})
})

View File

@ -25,9 +25,10 @@ type InSiteMessageProps = {
title: string
}
const breakLineRegex = /\\n/g
const LINE_BREAK_REGEX = /\\n/g
function normalizeLineBreaks(text: string): string {
return text.replace(breakLineRegex, '\n')
return text.replace(LINE_BREAK_REGEX, '\n')
}
function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null {
@ -109,7 +110,7 @@ function InSiteMessage({
</div>
</div>
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular [&_p]:mb-2 [&_ul]:list-disc [&_ul]:pl-5">
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular">
<MarkdownWithDirective markdown={main} />
</div>

View File

@ -0,0 +1,202 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import InSiteMessageNotification from './notification'
const {
mockConfig,
mockNotification,
mockNotificationDismiss,
} = vi.hoisted(() => ({
mockConfig: {
isCloudEdition: true,
},
mockNotification: vi.fn(),
mockNotificationDismiss: vi.fn(),
}))
vi.mock('@/config', () => ({
get IS_CLOUD_EDITION() {
return mockConfig.isCloudEdition
},
}))
vi.mock('@/service/client', () => ({
consoleClient: {
notification: (...args: unknown[]) => mockNotification(...args),
notificationDismiss: (...args: unknown[]) => mockNotificationDismiss(...args),
},
consoleQuery: {
notification: {
queryKey: () => ['console', 'notification'],
},
notificationDismiss: {
mutationKey: () => ['console', 'notificationDismiss'],
},
},
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
const Wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
return Wrapper
}
describe('InSiteMessageNotification', () => {
beforeEach(() => {
vi.clearAllMocks()
mockConfig.isCloudEdition = true
vi.stubGlobal('open', vi.fn())
})
afterEach(() => {
vi.unstubAllGlobals()
})
// Validate query gating and empty state rendering.
describe('Rendering', () => {
it('should render null and skip query when not cloud edition', async () => {
mockConfig.isCloudEdition = false
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(mockNotification).not.toHaveBeenCalled()
})
expect(container).toBeEmptyDOMElement()
})
it('should render null when notification list is empty', async () => {
mockNotification.mockResolvedValue({ notifications: [] })
const Wrapper = createWrapper()
const { container } = render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(mockNotification).toHaveBeenCalledTimes(1)
})
expect(container).toBeEmptyDOMElement()
})
})
// Validate parsed-body behavior and action handling.
describe('Notification body parsing and actions', () => {
it('should render parsed main/actions and dismiss only on close action', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-1',
title: 'Update title',
subtitle: 'Update subtitle',
title_pic_url: 'https://example.com/bg.png',
body: JSON.stringify({
main: 'Parsed body main',
actions: [
{ action: 'link', data: 'https://example.com/docs', text: 'Visit docs', type: 'primary' },
{ action: 'close', text: 'Dismiss now', type: 'default' },
{ action: 'link', data: 'https://example.com/invalid', text: 100, type: 'primary' },
],
}),
},
],
})
mockNotificationDismiss.mockResolvedValue({ success: true })
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('Parsed body main')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'Visit docs' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss now' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Invalid' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Visit docs' }))
expect(mockNotificationDismiss).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Dismiss now' }))
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith({
body: {
notification_id: 'n-1',
},
})
})
})
it('should fallback to raw body and default close action when body is invalid json', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-2',
title: 'Fallback title',
subtitle: 'Fallback subtitle',
title_pic_url: 'https://example.com/bg-2.png',
body: 'raw body text',
},
],
})
mockNotificationDismiss.mockResolvedValue({ success: true })
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('raw body text')).toBeInTheDocument()
})
const closeButton = screen.getByRole('button', { name: 'common.operation.close' })
fireEvent.click(closeButton)
await waitFor(() => {
expect(mockNotificationDismiss).toHaveBeenCalledWith({
body: {
notification_id: 'n-2',
},
})
})
})
it('should fallback to default close action when parsed actions are all invalid', async () => {
mockNotification.mockResolvedValue({
notifications: [
{
notification_id: 'n-3',
title: 'Invalid action title',
subtitle: 'Invalid action subtitle',
title_pic_url: 'https://example.com/bg-3.png',
body: JSON.stringify({
main: 'Main from parsed body',
actions: [
{ action: 'link', type: 'primary', text: 100, data: 'https://example.com' },
],
}),
},
],
})
const Wrapper = createWrapper()
render(<InSiteMessageNotification />, { wrapper: Wrapper })
await waitFor(() => {
expect(screen.getByText('Main from parsed body')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
})
})

View File

@ -65,7 +65,7 @@ const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, srcs }) => {
if (primarySrc) {
// Delayed generation of waveform data
// eslint-disable-next-line ts/no-use-before-define
const timer = setTimeout(() => generateWaveformData(primarySrc), 1000)
const timer = setTimeout(generateWaveformData, 1000, primarySrc)
return () => {
audio.removeEventListener('loadedmetadata', setAudioData)
audio.removeEventListener('timeupdate', setAudioTime)

View File

@ -0,0 +1,73 @@
import { validateDirectiveProps } from './markdown-with-directive-schema'
describe('markdown-with-directive-schema', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Validate the happy path for known directives.
describe('valid props', () => {
it('should return true for withiconcardlist when className is provided', () => {
expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true)
})
it('should return true for withiconcarditem when icon is https URL', () => {
expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true)
})
})
// Validate strict schema constraints and error branches.
describe('invalid props', () => {
it('should return false and log error for unknown directive name', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' })
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Unknown directive name.',
expect.objectContaining({
attributes: { className: 'custom-list' },
directive: 'unknown-directive',
}),
)
})
it('should return false and log error for non-http icon URL', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' })
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Invalid directive props.',
expect.objectContaining({
attributes: { icon: 'ftp://example.com/icon.png' },
directive: 'withiconcarditem',
issues: expect.arrayContaining([
expect.objectContaining({
path: 'icon',
}),
]),
}),
)
})
it('should return false when extra field is provided to strict list schema', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('withiconcardlist', {
className: 'custom-list',
extra: 'not-allowed',
})
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Invalid directive props.',
expect.objectContaining({
directive: 'withiconcardlist',
}),
)
})
})
})

View File

@ -5,11 +5,12 @@ const commonSchema = {
}
export const withIconCardListPropsSchema = z.object(commonSchema).strict()
const startWithHttpOrHttps = /^https?:\/\//i
const HTTP_URL_REGEX = /^https?:\/\//i
export const withIconCardItemPropsSchema = z.object({
...commonSchema,
icon: z.string().trim().url().refine(
value => startWithHttpOrHttps.test(value),
value => HTTP_URL_REGEX.test(value),
'icon must be a http/https URL',
),
}).strict()

View File

@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react'
import WithIconCardItem from './with-icon-card-item'
vi.mock('next/image', () => ({
default: ({ unoptimized: _unoptimized, ...props }: React.ImgHTMLAttributes<HTMLImageElement> & { unoptimized?: boolean }) => <img {...props} />,
}))
describe('WithIconCardItem', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify icon image and content rendering.
describe('rendering', () => {
it('should render icon image and children content', () => {
render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
expect(icon).toHaveClass('object-contain')
expect(screen.getByText('Card item content')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,34 @@
import { render, screen } from '@testing-library/react'
import WithIconCardList from './with-icon-card-list'
describe('WithIconCardList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Verify baseline rendering and className merge behavior.
describe('rendering', () => {
it('should render children and merge custom className with base class', () => {
const { container } = render(
<WithIconCardList className="custom-list-class">
<span>List child</span>
</WithIconCardList>,
)
expect(screen.getByText('List child')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('space-y-1')
expect(container.firstElementChild).toHaveClass('custom-list-class')
})
it('should keep base class when className is not provided', () => {
const { container } = render(
<WithIconCardList>
<span>Only base class</span>
</WithIconCardList>,
)
expect(screen.getByText('Only base class')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('space-y-1')
})
})
})

View File

@ -0,0 +1,196 @@
import { render, screen } from '@testing-library/react'
import DOMPurify from 'dompurify'
import { validateDirectiveProps } from './components/markdown-with-directive-schema'
import WithIconCardItem from './components/with-icon-card-item'
import WithIconCardList from './components/with-icon-card-list'
import { MarkdownWithDirective } from './index'
const FOUR_COLON_RE = /:{4}/
vi.mock('next/image', () => ({
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,
}))
describe('markdown-with-directive', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Validate directive prop schemas and error paths.
describe('Directive schema validation', () => {
it('should return true when withiconcardlist props are valid', () => {
expect(validateDirectiveProps('withiconcardlist', { className: 'custom-list' })).toBe(true)
})
it('should return true when withiconcarditem props are valid', () => {
expect(validateDirectiveProps('withiconcarditem', { icon: 'https://example.com/icon.png' })).toBe(true)
})
it('should return false and log when directive name is unknown', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('unknown-directive', { className: 'custom-list' })
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Unknown directive name.',
expect.objectContaining({
attributes: { className: 'custom-list' },
directive: 'unknown-directive',
}),
)
})
it('should return false and log when withiconcarditem icon is not http/https', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('withiconcarditem', { icon: 'ftp://example.com/icon.png' })
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Invalid directive props.',
expect.objectContaining({
attributes: { icon: 'ftp://example.com/icon.png' },
directive: 'withiconcarditem',
issues: expect.arrayContaining([
expect.objectContaining({
path: 'icon',
}),
]),
}),
)
})
it('should return false when extra props are provided to strict schema', () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const isValid = validateDirectiveProps('withiconcardlist', {
className: 'custom-list',
extra: 'not-allowed',
})
expect(isValid).toBe(false)
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[markdown-with-directive] Invalid directive props.',
expect.objectContaining({
directive: 'withiconcardlist',
}),
)
})
})
// Validate WithIconCardList rendering and class merge behavior.
describe('WithIconCardList component', () => {
it('should render children and merge className with base class', () => {
const { container } = render(
<WithIconCardList className="custom-list-class">
<span>List child</span>
</WithIconCardList>,
)
expect(screen.getByText('List child')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('space-y-1')
expect(container.firstElementChild).toHaveClass('custom-list-class')
})
it('should render base class when className is not provided', () => {
const { container } = render(
<WithIconCardList>
<span>Only base class</span>
</WithIconCardList>,
)
expect(screen.getByText('Only base class')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('space-y-1')
})
})
// Validate WithIconCardItem rendering and image prop forwarding.
describe('WithIconCardItem component', () => {
it('should render icon image and child content', () => {
render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
expect(screen.getByText('Card item content')).toBeInTheDocument()
})
})
// Validate markdown parsing pipeline, sanitizer usage, and invalid fallback.
describe('MarkdownWithDirective component', () => {
it('should render directives when markdown is valid', () => {
const markdown = [
'::withiconcardlist {className="custom-list"}',
':withiconcarditem[Card Title] {icon="https://example.com/icon.png"} {className="custom-item"}',
'::',
].join('\n')
const { container } = render(<MarkdownWithDirective markdown={markdown} />)
const list = container.querySelector('.custom-list')
expect(list).toBeInTheDocument()
expect(list).toHaveClass('space-y-1')
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/icon.png')
})
it('should replace output with invalid content when directive is unknown', () => {
const markdown = ':unknown[Bad Content]{foo="bar"}'
render(<MarkdownWithDirective markdown={markdown} />)
expect(screen.getByText('invalid content')).toBeInTheDocument()
expect(screen.queryByText('Bad Content')).not.toBeInTheDocument()
})
it('should replace output with invalid content when directive props are invalid', () => {
const markdown = ':withiconcarditem[Invalid Icon]{icon="not-a-url"}'
render(<MarkdownWithDirective markdown={markdown} />)
expect(screen.getByText('invalid content')).toBeInTheDocument()
expect(screen.queryByText('Invalid Icon')).not.toBeInTheDocument()
})
it('should not render trailing fence text for four-colon container directives', () => {
const markdown = [
'::::withiconcardlist {className="custom-list"}',
':withiconcarditem[Card Title]{icon="https://example.com/icon.png"}',
'::::',
].join('\n')
const { container } = render(<MarkdownWithDirective markdown={markdown} />)
expect(screen.getByText('Card Title')).toBeInTheDocument()
expect(screen.queryByText(FOUR_COLON_RE)).not.toBeInTheDocument()
expect(container.textContent).not.toContain('::::')
})
it('should call sanitizer and render based on sanitized markdown', () => {
const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize')
.mockReturnValue(':withiconcarditem[Sanitized]{icon="https://example.com/safe.png"}')
render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />)
expect(sanitizeSpy).toHaveBeenCalledWith('<script>alert(1)</script>', {
ALLOWED_ATTR: [],
ALLOWED_TAGS: [],
})
expect(screen.getByText('Sanitized')).toBeInTheDocument()
expect(screen.getByAltText('icon')).toHaveAttribute('src', 'https://example.com/safe.png')
})
it('should render empty output and skip sanitizer when markdown is empty', () => {
const sanitizeSpy = vi.spyOn(DOMPurify, 'sanitize')
const { container } = render(<MarkdownWithDirective markdown="" />)
expect(sanitizeSpy).not.toHaveBeenCalled()
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -1,12 +1,37 @@
import type { Components } from 'react-markdown'
import type { Components, StreamdownProps } from 'streamdown'
import DOMPurify from 'dompurify'
import ReactMarkdown from 'react-markdown'
import remarkDirective from 'remark-directive'
import { defaultRehypePlugins, Streamdown } from 'streamdown'
import { visit } from 'unist-util-visit'
import { validateDirectiveProps } from './components/markdown-with-directive-schema'
import WithIconCardItem from './components/with-icon-card-item'
import WithIconCardList from './components/with-icon-card-list'
// Adapter to map generic props to WithIconListProps
function WithIconCardListAdapter(props: Record<string, unknown>) {
// Extract expected props, fallback to undefined if not present
const { children, className } = props
return (
<WithIconCardList
children={children as React.ReactNode}
className={typeof className === 'string' ? className : undefined}
/>
)
}
// Adapter to map generic props to WithIconCardItemProps
function WithIconCardItemAdapter(props: Record<string, unknown>) {
const { icon, className, children } = props
return (
<WithIconCardItem
icon={typeof icon === 'string' ? icon : ''}
className={typeof className === 'string' ? className : undefined}
>
{children as React.ReactNode}
</WithIconCardItem>
)
}
type DirectiveNode = {
type?: string
name?: string
@ -34,20 +59,68 @@ function isMdastRoot(node: Parameters<typeof visit>[0]): node is MdastRoot {
return candidate.type === 'root' && Array.isArray(candidate.children)
}
const lineReg = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i
const attrReg = /\{([^}\n]*)\}/g
// Move the regex to module scope to avoid recompilation
const DIRECTIVE_ATTRIBUTE_BLOCK_REGEX = /^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i
const ATTRIBUTE_BLOCK_REGEX = /\{([^}\n]*)\}/g
type PluggableList = NonNullable<StreamdownProps['rehypePlugins']>
type Pluggable = PluggableList[number]
type AttributeDefinition = string | [string, ...(string | boolean | RegExp)[]]
type SanitizeSchema = {
tagNames?: string[]
attributes?: Record<string, AttributeDefinition[]>
required?: Record<string, Record<string, unknown>>
clobber?: string[]
clobberPrefix?: string
[key: string]: unknown
}
const DIRECTIVE_ALLOWED_TAGS: Record<string, AttributeDefinition[]> = {
withiconcardlist: ['className'],
withiconcarditem: ['icon', 'className'],
}
function buildDirectiveRehypePlugins(): PluggableList {
const [sanitizePlugin, defaultSanitizeSchema]
= defaultRehypePlugins.sanitize as [Pluggable, SanitizeSchema]
const tagNames = new Set([
...(defaultSanitizeSchema.tagNames ?? []),
...Object.keys(DIRECTIVE_ALLOWED_TAGS),
])
const attributes: Record<string, AttributeDefinition[]> = {
...(defaultSanitizeSchema.attributes ?? {}),
}
for (const [tagName, allowedAttributes] of Object.entries(DIRECTIVE_ALLOWED_TAGS))
attributes[tagName] = [...(attributes[tagName] ?? []), ...allowedAttributes]
const sanitizeSchema: SanitizeSchema = {
...defaultSanitizeSchema,
tagNames: [...tagNames],
attributes,
}
return [
defaultRehypePlugins.raw,
[sanitizePlugin, sanitizeSchema] as Pluggable,
defaultRehypePlugins.harden,
]
}
const directiveRehypePlugins = buildDirectiveRehypePlugins()
function normalizeDirectiveAttributeBlocks(markdown: string): string {
const lines = markdown.split('\n')
return lines.map((line) => {
const match = line.match(lineReg)
const match = line.match(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX)
if (!match)
return line
const directivePrefix = match[1]
const attributeBlocks = match[2]
const attrMatches = [...attributeBlocks.matchAll(attrReg)]
const attrMatches = [...attributeBlocks.matchAll(ATTRIBUTE_BLOCK_REGEX)]
if (attrMatches.length === 0)
return line
@ -102,7 +175,8 @@ function isValidDirectiveAst(tree: Parameters<typeof visit>[0]): boolean {
return isValid
}
const invalidTextReg = /^\s*:{2,}[a-z][\w-]*/im
const UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX = /^\s*:{2,}[a-z][\w-]*/im
function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolean {
let hasInvalidText = false
@ -112,7 +186,7 @@ function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolea
const textNode = node as { value?: string }
const value = textNode.value || ''
if (invalidTextReg.test(value))
if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value))
hasInvalidText = true
})
@ -166,9 +240,9 @@ function directivePlugin() {
}
const directiveComponents = {
withiconcardlist: WithIconCardList,
withiconcarditem: WithIconCardItem,
} as unknown as Components
withiconcardlist: WithIconCardListAdapter,
withiconcarditem: WithIconCardItemAdapter,
} satisfies Components
type MarkdownWithDirectiveProps = {
markdown: string
@ -192,13 +266,20 @@ export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps)
const sanitizedMarkdown = sanitizeMarkdownInput(markdown)
const normalizedMarkdown = normalizeDirectiveAttributeBlocks(sanitizedMarkdown)
if (!normalizedMarkdown)
return null
return (
<ReactMarkdown
skipHtml
remarkPlugins={[remarkDirective, directivePlugin]}
components={directiveComponents}
>
{normalizedMarkdown}
</ReactMarkdown>
<div className="markdown-body">
<Streamdown
mode="static"
remarkPlugins={[remarkDirective, directivePlugin]}
rehypePlugins={directiveRehypePlugins}
components={directiveComponents}
>
{normalizedMarkdown}
</Streamdown>
</div>
)
}

View File

@ -26,8 +26,8 @@ type CapturedProps = {
const getLastWrapperProps = (): CapturedProps => {
const calls = mockReactMarkdownWrapper.mock.calls
const lastCall = calls[calls.length - 1]
return lastCall[0] as CapturedProps
const lastCall = calls.at(-1)
return lastCall?.[0] as CapturedProps
}
describe('Markdown', () => {

View File

@ -151,8 +151,6 @@
"reactflow": "11.11.4",
"remark-breaks": "4.0.0",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"scheduler": "0.27.0",
"semver": "7.7.4",
"sharp": "0.34.5",

756
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff