feat: support in site message (#33255)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Joel 2026-03-11 18:32:14 +08:00 committed by GitHub
parent e85d20031e
commit 2b1d1e9587
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1462 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
@ -32,6 +33,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<RoleRouteGuard>
{children}
</RoleRouteGuard>
<InSiteMessageNotification />
<PartnerStack />
<ReadmePanel />
<GotoAnything />

View File

@ -0,0 +1,132 @@
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

@ -0,0 +1,134 @@
'use client'
import { useMemo, useState } from 'react'
import Button from '@/app/components/base/button'
import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive'
import { cn } from '@/utils/classnames'
type InSiteMessageAction = 'link' | 'close'
type InSiteMessageButtonType = 'primary' | 'default'
export type InSiteMessageActionItem = {
action: InSiteMessageAction
data?: unknown
text: string
type: InSiteMessageButtonType
}
type InSiteMessageProps = {
actions: InSiteMessageActionItem[]
className?: string
headerBgUrl?: string
main: string
onAction?: (action: InSiteMessageActionItem) => void
subtitle: string
title: string
}
const LINE_BREAK_REGEX = /\\n/g
function normalizeLineBreaks(text: string): string {
return text.replace(LINE_BREAK_REGEX, '\n')
}
function normalizeLinkData(data: unknown): { href: string, rel?: string, target?: string } | null {
if (typeof data === 'string')
return { href: data, target: '_blank' }
if (!data || typeof data !== 'object')
return null
const candidate = data as { href?: unknown, rel?: unknown, target?: unknown }
if (typeof candidate.href !== 'string' || !candidate.href)
return null
return {
href: candidate.href,
rel: typeof candidate.rel === 'string' ? candidate.rel : undefined,
target: typeof candidate.target === 'string' ? candidate.target : '_blank',
}
}
const DEFAULT_HEADER_BG_URL = '/in-site-message/header-bg.svg'
function InSiteMessage({
actions,
className,
headerBgUrl = DEFAULT_HEADER_BG_URL,
main,
onAction,
subtitle,
title,
}: InSiteMessageProps) {
const [visible, setVisible] = useState(true)
const normalizedTitle = normalizeLineBreaks(title)
const normalizedSubtitle = normalizeLineBreaks(subtitle)
const headerStyle = useMemo(() => {
return {
backgroundImage: `url(${headerBgUrl || DEFAULT_HEADER_BG_URL})`,
}
}, [headerBgUrl])
const handleAction = (item: InSiteMessageActionItem) => {
onAction?.(item)
if (item.action === 'close') {
setVisible(false)
return
}
const linkData = normalizeLinkData(item.data)
if (!linkData)
return
const target = linkData.target ?? '_blank'
if (target === '_self') {
window.location.assign(linkData.href)
return
}
window.open(linkData.href, target, linkData.rel || 'noopener,noreferrer')
}
if (!visible)
return null
return (
<div
className={cn(
'fixed bottom-3 right-3 z-50 w-[360px] overflow-hidden rounded-xl border border-components-panel-border-subtle bg-components-panel-bg shadow-2xl backdrop-blur-[5px]',
className,
)}
>
<div className="flex min-h-[128px] flex-col justify-end gap-0.5 bg-cover px-4 pb-3 pt-6 text-text-primary-on-surface" style={headerStyle}>
<div className="whitespace-pre-line title-3xl-bold">
{normalizedTitle}
</div>
<div className="whitespace-pre-line body-md-regular">
{normalizedSubtitle}
</div>
</div>
<div className="px-4 pb-2 pt-4 text-text-secondary body-md-regular">
<MarkdownWithDirective markdown={main} />
</div>
<div className="flex items-center justify-end gap-2 p-4">
{actions.map(item => (
<Button
key={`${item.type}-${item.action}-${item.text}`}
variant={item.type === 'primary' ? 'primary' : 'ghost'}
size="medium"
className={cn(item.type === 'default' && 'text-text-secondary')}
onClick={() => handleAction(item)}
>
{item.text}
</Button>
))}
</div>
</div>
)
}
export default InSiteMessage

View File

@ -0,0 +1,216 @@
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', () => ({
consoleQuery: {
notification: {
queryOptions: (options?: Record<string, unknown>) => ({
queryKey: ['console', 'notification'],
queryFn: (...args: unknown[]) => mockNotification(...args),
...options,
}),
},
notificationDismiss: {
mutationOptions: (options?: Record<string, unknown>) => ({
mutationKey: ['console', 'notificationDismiss'],
mutationFn: (...args: unknown[]) => mockNotificationDismiss(...args),
...options,
}),
},
},
}))
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',
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})
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',
},
},
expect.objectContaining({
mutationKey: ['console', 'notificationDismiss'],
}),
)
})
})
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

@ -0,0 +1,109 @@
'use client'
import type { InSiteMessageActionItem } from './index'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import { consoleQuery } from '@/service/client'
import InSiteMessage from './index'
type NotificationBodyPayload = {
actions: InSiteMessageActionItem[]
main: string
}
function isValidActionItem(value: unknown): value is InSiteMessageActionItem {
if (!value || typeof value !== 'object')
return false
const candidate = value as {
action?: unknown
data?: unknown
text?: unknown
type?: unknown
}
return (
typeof candidate.text === 'string'
&& (candidate.type === 'primary' || candidate.type === 'default')
&& (candidate.action === 'link' || candidate.action === 'close')
&& (candidate.data === undefined || typeof candidate.data !== 'function')
)
}
function parseNotificationBody(body: string): NotificationBodyPayload | null {
try {
const parsed = JSON.parse(body) as {
actions?: unknown
main?: unknown
}
if (!parsed || typeof parsed !== 'object')
return null
if (typeof parsed.main !== 'string')
return null
const actions = Array.isArray(parsed.actions)
? parsed.actions.filter(isValidActionItem)
: []
return {
main: parsed.main,
actions,
}
}
catch {
return null
}
}
function InSiteMessageNotification() {
const { t } = useTranslation()
const dismissNotificationMutation = useMutation(consoleQuery.notificationDismiss.mutationOptions())
const { data } = useQuery(consoleQuery.notification.queryOptions({
enabled: IS_CLOUD_EDITION,
}))
const notification = data?.notifications?.[0]
const parsedBody = notification ? parseNotificationBody(notification.body) : null
if (!IS_CLOUD_EDITION || !notification)
return null
const fallbackActions: InSiteMessageActionItem[] = [
{
type: 'default',
text: t('operation.close', { ns: 'common' }),
action: 'close',
},
]
const actions = parsedBody?.actions?.length ? parsedBody.actions : fallbackActions
const main = parsedBody?.main ?? notification.body
const handleAction = (action: InSiteMessageActionItem) => {
if (action.action !== 'close')
return
dismissNotificationMutation.mutate({
body: {
notification_id: notification.notification_id,
},
})
}
return (
<InSiteMessage
key={notification.notification_id}
title={notification.title}
subtitle={notification.subtitle}
headerBgUrl={notification.title_pic_url}
main={main}
actions={actions}
onAction={handleAction}
/>
)
}
export default InSiteMessageNotification

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

@ -0,0 +1,56 @@
import * as z from 'zod'
const commonSchema = {
className: z.string().min(1).optional(),
}
export const withIconCardListPropsSchema = z.object(commonSchema).strict()
const HTTP_URL_REGEX = /^https?:\/\//i
export const withIconCardItemPropsSchema = z.object({
...commonSchema,
icon: z.string().trim().url().refine(
value => HTTP_URL_REGEX.test(value),
'icon must be a http/https URL',
),
}).strict()
export const directivePropsSchemas = {
withiconcardlist: withIconCardListPropsSchema,
withiconcarditem: withIconCardItemPropsSchema,
} as const
export type DirectiveName = keyof typeof directivePropsSchemas
function isDirectiveName(name: string): name is DirectiveName {
return Object.hasOwn(directivePropsSchemas, name)
}
export function validateDirectiveProps(name: string, attributes: Record<string, string>): boolean {
if (!isDirectiveName(name)) {
console.error('[markdown-with-directive] Unknown directive name.', {
attributes,
directive: name,
})
return false
}
const parsed = directivePropsSchemas[name].safeParse(attributes)
if (!parsed.success) {
console.error('[markdown-with-directive] Invalid directive props.', {
attributes,
directive: name,
issues: parsed.error.issues.map(issue => ({
code: issue.code,
message: issue.message,
path: issue.path.join('.'),
})),
})
return false
}
return true
}
export type WithIconCardListProps = z.infer<typeof withIconCardListPropsSchema>
export type WithIconCardItemProps = z.infer<typeof withIconCardItemPropsSchema>

View File

@ -0,0 +1,43 @@
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()
})
describe('rendering', () => {
it('should render a decorative icon and children content by default', () => {
const { container } = render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
const icon = container.querySelector('img')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('src', 'https://example.com/icon.png')
expect(icon).toHaveAttribute('alt', '')
expect(icon).toHaveAttribute('aria-hidden', 'true')
expect(icon).toHaveClass('object-contain')
expect(screen.getByText('Card item content')).toBeInTheDocument()
})
it('should expose alt text when iconAlt is provided', () => {
render(
<WithIconCardItem icon="https://example.com/icon.png" iconAlt="Card icon">
<span>Accessible card item content</span>
</WithIconCardItem>,
)
const icon = screen.getByAltText('Card icon')
expect(icon).toBeInTheDocument()
expect(icon).not.toHaveAttribute('aria-hidden')
expect(screen.getByText('Accessible card item content')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,34 @@
import type { ReactNode } from 'react'
import type { WithIconCardItemProps } from './markdown-with-directive-schema'
import Image from 'next/image'
import { cn } from '@/utils/classnames'
type WithIconItemProps = WithIconCardItemProps & {
children?: ReactNode
iconAlt?: string
}
function WithIconCardItem({ icon, children, className, iconAlt }: WithIconItemProps) {
return (
<div className={cn('flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2', className)}>
{/*
* unoptimized to "url parameter is not allowed" for external domains despite correct remotePatterns configuration.
* https://github.com/vercel/next.js/issues/88873
*/}
<Image
src={icon}
className="!border-none object-contain"
alt={iconAlt ?? ''}
aria-hidden={iconAlt ? undefined : true}
width={40}
height={40}
unoptimized
/>
<div className="min-w-0 grow overflow-hidden text-text-secondary system-sm-medium [&_p]:!m-0 [&_p]:block [&_p]:w-full [&_p]:overflow-hidden [&_p]:text-ellipsis [&_p]:whitespace-nowrap">
{children}
</div>
</div>
)
}
export default WithIconCardItem

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,17 @@
import type { ReactNode } from 'react'
import type { WithIconCardListProps } from './markdown-with-directive-schema'
import { cn } from '@/utils/classnames'
type WithIconListProps = WithIconCardListProps & {
children?: ReactNode
}
function WithIconCardList({ children, className }: WithIconListProps) {
return (
<div className={cn('space-y-1', className)}>
{children}
</div>
)
}
export default WithIconCardList

View File

@ -0,0 +1,202 @@
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} />,
}))
function expectDecorativeIcon(container: HTMLElement, src: string) {
const icon = container.querySelector('img')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('src', src)
expect(icon).toHaveAttribute('alt', '')
expect(icon).toHaveAttribute('aria-hidden', 'true')
}
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', () => {
const { container } = render(
<WithIconCardItem icon="https://example.com/icon.png">
<span>Card item content</span>
</WithIconCardItem>,
)
expectDecorativeIcon(container, '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()
expectDecorativeIcon(container, '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"}')
const { container } = render(<MarkdownWithDirective markdown="<script>alert(1)</script>" />)
expect(sanitizeSpy).toHaveBeenCalledWith('<script>alert(1)</script>', {
ALLOWED_ATTR: [],
ALLOWED_TAGS: [],
})
expect(screen.getByText('Sanitized')).toBeInTheDocument()
expectDecorativeIcon(container, '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

@ -0,0 +1,287 @@
'use client'
import type { ReactNode } from 'react'
import type { Components, StreamdownProps } from 'streamdown'
import DOMPurify from 'dompurify'
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 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 ReactNode}
</WithIconCardItem>
)
}
type DirectiveNode = {
type?: string
name?: string
attributes?: Record<string, unknown>
data?: {
hName?: string
hProperties?: Record<string, string>
}
}
type MdastRoot = {
type: 'root'
children: Array<{
type: string
children?: Array<{ type: string, value?: string }>
value?: string
}>
}
function isMdastRoot(node: Parameters<typeof visit>[0]): node is MdastRoot {
if (typeof node !== 'object' || node === null)
return false
const candidate = node as { type?: unknown, children?: unknown }
return candidate.type === 'root' && Array.isArray(candidate.children)
}
// 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(DIRECTIVE_ATTRIBUTE_BLOCK_REGEX)
if (!match)
return line
const directivePrefix = match[1]
const attributeBlocks = match[2]
const attrMatches = [...attributeBlocks.matchAll(ATTRIBUTE_BLOCK_REGEX)]
if (attrMatches.length === 0)
return line
const mergedAttributes = attrMatches
.map(result => result[1].trim())
.filter(Boolean)
.join(' ')
return mergedAttributes
? `${directivePrefix}{${mergedAttributes}}`
: directivePrefix
}).join('\n')
}
function normalizeDirectiveAttributes(attributes?: Record<string, unknown>): Record<string, string> {
const normalized: Record<string, string> = {}
if (!attributes)
return normalized
for (const [key, value] of Object.entries(attributes)) {
if (typeof value === 'string')
normalized[key] = value
}
return normalized
}
function isValidDirectiveAst(tree: Parameters<typeof visit>[0]): boolean {
let isValid = true
visit(
tree,
['textDirective', 'leafDirective', 'containerDirective'],
(node) => {
if (!isValid)
return
const directiveNode = node as DirectiveNode
const directiveName = directiveNode.name?.toLowerCase()
if (!directiveName) {
isValid = false
return
}
const attributes = normalizeDirectiveAttributes(directiveNode.attributes)
if (!validateDirectiveProps(directiveName, attributes))
isValid = false
},
)
return isValid
}
const UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX = /^\s*:{2,}[a-z][\w-]*/im
function hasUnparsedDirectiveLikeText(tree: Parameters<typeof visit>[0]): boolean {
let hasInvalidText = false
visit(tree, 'text', (node) => {
if (hasInvalidText)
return
const textNode = node as { value?: string }
const value = textNode.value || ''
if (UNPARSED_DIRECTIVE_LIKE_TEXT_REGEX.test(value))
hasInvalidText = true
})
return hasInvalidText
}
function replaceWithInvalidContent(tree: Parameters<typeof visit>[0]) {
if (!isMdastRoot(tree))
return
const root = tree
root.children = [
{
type: 'paragraph',
children: [
{
type: 'text',
value: 'invalid content',
},
],
},
]
}
function directivePlugin() {
return (tree: Parameters<typeof visit>[0]) => {
if (!isValidDirectiveAst(tree) || hasUnparsedDirectiveLikeText(tree)) {
replaceWithInvalidContent(tree)
return
}
visit(
tree,
['textDirective', 'leafDirective', 'containerDirective'],
(node) => {
const directiveNode = node as DirectiveNode
const attributes = normalizeDirectiveAttributes(directiveNode.attributes)
const hProperties: Record<string, string> = { ...attributes }
if (hProperties.class) {
hProperties.className = hProperties.class
delete hProperties.class
}
const data = directiveNode.data || (directiveNode.data = {})
data.hName = directiveNode.name?.toLowerCase()
data.hProperties = hProperties
},
)
}
}
const directiveComponents = {
withiconcardlist: WithIconCardListAdapter,
withiconcarditem: WithIconCardItemAdapter,
} satisfies Components
type MarkdownWithDirectiveProps = {
markdown: string
}
function sanitizeMarkdownInput(markdown: string): string {
if (!markdown)
return ''
if (typeof DOMPurify.sanitize === 'function') {
return DOMPurify.sanitize(markdown, {
ALLOWED_ATTR: [],
ALLOWED_TAGS: [],
})
}
return markdown
}
export function MarkdownWithDirective({ markdown }: MarkdownWithDirectiveProps) {
const sanitizedMarkdown = sanitizeMarkdownInput(markdown)
const normalizedMarkdown = normalizeDirectiveAttributeBlocks(sanitizedMarkdown)
if (!normalizedMarkdown)
return null
return (
<div className="markdown-body">
<Streamdown
mode="static"
remarkPlugins={[remarkDirective, directivePlugin]}
rehypePlugins={directiveRehypePlugins}
components={directiveComponents}
>
{normalizedMarkdown}
</Streamdown>
</div>
)
}

View File

@ -0,0 +1,36 @@
import { type } from '@orpc/contract'
import { base } from '../base'
export type ConsoleNotification = {
body: string
frequency: 'once' | 'always'
lang: string
notification_id: string
subtitle: string
title: string
title_pic_url?: string
}
export type ConsoleNotificationResponse = {
notifications: ConsoleNotification[]
should_show: boolean
}
export const notificationContract = base
.route({
path: '/notification',
method: 'GET',
})
.output(type<ConsoleNotificationResponse>())
export const notificationDismissContract = base
.route({
path: '/notification/dismiss',
method: 'POST',
})
.input(type<{
body: {
notification_id: string
}
}>())
.output(type<unknown>())

View File

@ -12,6 +12,7 @@ import {
exploreInstalledAppsContract,
exploreInstalledAppUninstallContract,
} from './console/explore'
import { notificationContract, notificationDismissContract } from './console/notification'
import { systemFeaturesContract } from './console/system'
import {
triggerOAuthConfigContract,
@ -67,6 +68,8 @@ export const consoleRouterContract = {
invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract,
},
notification: notificationContract,
notificationDismiss: notificationDismissContract,
triggers: {
list: triggersContract,
providerInfo: triggerProviderInfoContract,

View File

@ -147,6 +147,7 @@
"react-window": "1.8.11",
"reactflow": "11.11.4",
"remark-breaks": "4.0.0",
"remark-directive": "4.0.0",
"scheduler": "0.27.0",
"semver": "7.7.4",
"sharp": "0.34.5",
@ -155,6 +156,7 @@
"string-ts": "2.3.1",
"tailwind-merge": "2.6.1",
"tldts": "7.0.25",
"unist-util-visit": "5.1.0",
"use-context-selector": "2.0.0",
"uuid": "13.0.0",
"zod": "4.3.6",

48
web/pnpm-lock.yaml generated
View File

@ -323,6 +323,9 @@ importers:
remark-breaks:
specifier: 4.0.0
version: 4.0.0
remark-directive:
specifier: 4.0.0
version: 4.0.0
scheduler:
specifier: 0.27.0
version: 0.27.0
@ -347,6 +350,9 @@ importers:
tldts:
specifier: 7.0.25
version: 7.0.25
unist-util-visit:
specifier: 5.1.0
version: 5.1.0
use-context-selector:
specifier: 2.0.0
version: 2.0.0(react@19.2.4)(scheduler@0.27.0)
@ -5605,6 +5611,9 @@ packages:
engines: {node: '>= 20'}
hasBin: true
mdast-util-directive@3.1.0:
resolution: {integrity: sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==}
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@ -5690,6 +5699,9 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
micromark-extension-directive@4.0.0:
resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==}
micromark-extension-frontmatter@2.0.0:
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
@ -6533,6 +6545,9 @@ packages:
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
remark-directive@4.0.0:
resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@ -13138,6 +13153,20 @@ snapshots:
marked@17.0.4: {}
mdast-util-directive@3.1.0:
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 3.0.3
ccount: 2.0.1
devlop: 1.1.0
mdast-util-from-markdown: 2.0.3
mdast-util-to-markdown: 2.1.2
parse-entities: 4.0.2
stringify-entities: 4.0.4
unist-util-visit-parents: 6.0.2
transitivePeerDependencies:
- supports-color
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@ -13386,6 +13415,16 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
micromark-extension-directive@4.0.0:
dependencies:
devlop: 1.1.0
micromark-factory-space: 2.0.1
micromark-factory-whitespace: 2.0.1
micromark-util-character: 2.1.1
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
parse-entities: 4.0.2
micromark-extension-frontmatter@2.0.0:
dependencies:
fault: 2.0.1
@ -14442,6 +14481,15 @@ snapshots:
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
remark-directive@4.0.0:
dependencies:
'@types/mdast': 4.0.4
mdast-util-directive: 3.1.0
micromark-extension-directive: 4.0.0
unified: 11.0.5
transitivePeerDependencies:
- supports-color
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.6 MiB