mirror of
https://github.com/langgenius/dify.git
synced 2026-03-26 22:52:38 +08:00
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:
parent
e85d20031e
commit
2b1d1e9587
@ -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 />
|
||||
|
||||
132
web/app/components/app/in-site-message/index.spec.tsx
Normal file
132
web/app/components/app/in-site-message/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
134
web/app/components/app/in-site-message/index.tsx
Normal file
134
web/app/components/app/in-site-message/index.tsx
Normal 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
|
||||
216
web/app/components/app/in-site-message/notification.spec.tsx
Normal file
216
web/app/components/app/in-site-message/notification.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
109
web/app/components/app/in-site-message/notification.tsx
Normal file
109
web/app/components/app/in-site-message/notification.tsx
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
202
web/app/components/base/markdown-with-directive/index.spec.tsx
Normal file
202
web/app/components/base/markdown-with-directive/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
287
web/app/components/base/markdown-with-directive/index.tsx
Normal file
287
web/app/components/base/markdown-with-directive/index.tsx
Normal 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>
|
||||
|
||||
)
|
||||
}
|
||||
36
web/contract/console/notification.ts
Normal file
36
web/contract/console/notification.ts
Normal 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>())
|
||||
@ -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,
|
||||
|
||||
@ -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
48
web/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
33
web/public/in-site-message/header-bg.svg
Normal file
33
web/public/in-site-message/header-bg.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 4.6 MiB |
Loading…
Reference in New Issue
Block a user