Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { highlightCode } from '../shiki-highlight'

describe('README code highlighting', () => {
it.each(['github-light', 'github-dark'] as const)('highlights dotenv with %s', async (theme) => {
const code = 'OPENAI_API_KEY=your-api-key\n# OPENAI_ORGANIZATION=org-id'
const result = renderToStaticMarkup(await highlightCode({ code, language: 'dotenv', theme }))

expect(result).toContain('OPENAI_API_KEY')
expect(result).toContain('your-api-key')
expect(result).toContain('OPENAI_ORGANIZATION=org-id')
expect(result).toContain('<span style="color:')
})

it('renders unsupported languages as readable, escaped plain text', async () => {
const code = '<custom>example</custom>'
const result = renderToStaticMarkup(
await highlightCode({
code,
language: 'unknown-readme-language',
theme: 'github-light',
}),
)

expect(result).toContain('&lt;custom&gt;example&lt;/custom&gt;')
})

it('preserves highlighting for bundled language aliases', async () => {
const result = renderToStaticMarkup(
await highlightCode({
code: 'const count = 1',
language: 'js',
theme: 'github-light',
}),
)

expect(result).toContain('const')
expect(result).toContain('<span style="color:')
})
})
4 changes: 2 additions & 2 deletions web/app/components/base/markdown-blocks/code-block.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import type { BundledTheme } from 'shiki/bundle/web'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { Toggle } from '@langgenius/dify-ui/toggle'
import ReactEcharts from 'echarts-for-react'
Expand Down Expand Up @@ -78,7 +78,7 @@ const ShikiCodeBlock = memo(

void highlightCode({
code,
language: language as BundledLanguage,
language,
theme,
})
.then((result) => {
Expand Down
21 changes: 16 additions & 5 deletions web/app/components/base/markdown-blocks/shiki-highlight.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import type { JSX } from 'react'
import type { BundledLanguage, BundledTheme } from 'shiki/bundle/web'
import type { BundledTheme } from 'shiki/bundle/web'
import { toJsxRuntime } from 'hast-util-to-jsx-runtime'
import { Fragment } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
import { codeToHast } from 'shiki/bundle/web'
import { bundledLanguages, getSingletonHighlighter } from 'shiki/bundle/web'

type HighlightCodeOptions = {
code: string
language: BundledLanguage
language: string
theme: BundledTheme
}

Expand All @@ -16,8 +16,19 @@ export const highlightCode = async ({
language,
theme,
}: HighlightCodeOptions): Promise<JSX.Element> => {
const hast = await codeToHast(code, {
lang: language,
const normalizedLanguage = language.trim().toLowerCase()
const lang =
normalizedLanguage === 'dotenv' || Object.hasOwn(bundledLanguages, normalizedLanguage)
? normalizedLanguage
: 'text'
// README fences may name languages outside the web bundle. Load dotenv on
// demand and keep unknown languages readable without throwing an error.
const highlighter = await getSingletonHighlighter({
langs: lang === 'dotenv' ? [(await import('shiki/langs/dotenv.mjs')).default] : [lang],
themes: [theme],
})
const hast = highlighter.codeToHast(code, {
lang,
theme,
})

Expand Down
28 changes: 28 additions & 0 deletions web/app/components/plugins/marketplace/__tests__/state.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,34 @@ describe('useMarketplaceData', () => {
document.body.removeChild(container)
})

it('restores collections and clears pending results when switching Models back to All', async () => {
const { useMarketplaceData } = await import('../state')
const { useActivePluginType } = await import('../atoms')
const { Wrapper } = createWrapper('?category=model')
const { result } = renderHook(
() => ({
data: useMarketplaceData(),
setCategory: useActivePluginType()[1],
}),
{ wrapper: Wrapper },
)

await waitFor(() => {
expect(result.current.data.plugins).toHaveLength(1)
})

await act(async () => {
await result.current.setCategory(PLUGIN_TYPE_SEARCH_MAP.all)
})

await waitFor(() => {
expect(result.current.data.marketplaceCollections).toHaveLength(1)
expect(result.current.data.plugins).toBeUndefined()
expect(result.current.data.pluginsTotal).toBeUndefined()
expect(result.current.data.isRefreshing).toBe(false)
})
})

it('should use the server route category for hydrated standalone search', async () => {
const { useMarketplaceData } = await import('../state')
const { Wrapper } = createWrapper('?q=openai')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,31 @@ vi.mock('@/config', async (importOriginal) => ({
MARKETPLACE_URL_PREFIX: 'https://marketplace.example.com',
}))

vi.mock('@/app/components/plugins/install-plugin/hooks/use-check-installed', () => ({
default: () => ({ installedInfo: {} }),
}))

vi.mock('@/service/plugins', () => ({
fetchPluginInfoFromMarketPlace: vi.fn().mockResolvedValue({
data: {
plugin: {
category: 'tool',
latest_package_identifier: 'langgenius/dropbox:1.0.0',
latest_version: '1.0.0',
},
},
}),
}))

vi.mock('../../detail-dialog', () => ({
default: ({ open, plugin }: { open: boolean; plugin: { name: string } }) =>
open ? (
<div role="dialog" aria-label="plugin-detail">
{plugin.name}
</div>
) : null,
}))

const banners: PluginBanner[] = [
{
id: 'recommend',
Expand Down Expand Up @@ -678,7 +703,8 @@ describe('HomeTrending', () => {
})
})

it('sends embedded cards without a delivery link to the marketplace site', () => {
it('opens embedded recommend plugin cards in the plugin dialog', async () => {
const user = userEvent.setup()
const bannerWithMixedLinks: PluginBanner = {
id: 'recommend-mixed',
style_type: 'recommend',
Expand All @@ -696,8 +722,6 @@ describe('HomeTrending', () => {
card_position: 0,
},
{
// The console has no local /plugin route, so a card without a
// delivery-provided link must open the marketplace detail page.
item_type: 'plugin',
item_id: 'langgenius/notion',
display_name: 'Notion',
Expand All @@ -723,18 +747,33 @@ describe('HomeTrending', () => {
/>,
)

expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
expect(screen.queryByRole('link', { name: 'Dropbox' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: 'Notion' })).not.toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
'href',
'https://external.example.com/dropbox',
'/templates?tid=tpl-1',
)
const marketplaceFallbackLink = screen.getByRole('link', { name: 'Notion' })
expect(marketplaceFallbackLink.getAttribute('href')).toMatch(
/^https:\/\/marketplace\.example\.com\/plugins\/langgenius\/notion/,

await user.click(screen.getByRole('button', { name: 'Dropbox' }))

expect(screen.getByRole('dialog', { name: 'plugin-detail' })).toHaveTextContent('dropbox')
expect(mockTrackMarketplaceSiteEvent).toHaveBeenCalledWith(
'marketplace_banner_click',
expect.objectContaining({
click_target: 'recommendation',
item_id: 'langgenius/dropbox',
item_type: 'plugin',
item_name: 'Dropbox',
}),
)
expect(marketplaceFallbackLink).toHaveAttribute('target', '_blank')
expect(screen.getByRole('link', { name: 'Support Bot' })).toHaveAttribute(
})

it('keeps standalone recommend plugin cards on local detail routes', () => {
render(<HomeTrending banners={[banners[0]!]} isMarketplacePlatform page="plugins" />)

expect(screen.getByRole('link', { name: 'Dropbox' })).toHaveAttribute(
'href',
'/templates?tid=tpl-1',
'/plugin/langgenius/dropbox',
)
})

Expand Down
Loading