Compare commits

...

9 Commits

Author SHA1 Message Date
Joel
42d08995bc chore: another markdown 2026-03-05 17:39:14 +08:00
Joel
8e3a8ef908 feat: pure markdown 2026-03-05 17:29:17 +08:00
Joel
10bb786341 chore: fix text too long 2026-03-05 17:16:22 +08:00
Joel
2b7370b4cd fix: item not center ui 2026-03-05 17:12:09 +08:00
Joel
39701488ba chore: enchance ui 2026-03-05 16:55:02 +08:00
Joel
fa99aef0c8 feat: new component content and name 2026-03-05 16:20:17 +08:00
Joel
8c6fd6d3a2 chore: rename md comp 2026-03-05 15:13:41 +08:00
Joel
59f826570d feat: support check valid 2026-03-05 14:23:07 +08:00
Joel
908820acb4 feat: support render custom directive in markdown 2026-03-04 17:11:43 +08:00
8 changed files with 400 additions and 2 deletions

View File

@@ -0,0 +1,57 @@
'use client'
import { MarkdownWithDirective } from '@/app/components/base/markdown-with-directive'
const markdown1 = `
Were refining our messaging for technical teams, like how did you find us and did our positioning truly resonate?
Share your perspective in a 30-minute chat and receive:
::::withIconCardList
:::withIconCardItem {icon="https://assets.dify.ai/images/gift-card.png"}
$100 Amazon gift card
:::
:::withIconCardItem {icon="https://assets.dify.ai/images/dify-swag.png"}
Dify swag
:::
::::
`
const markdown2 = `
Were speaking with technical teams to better understand:
- How you discovered Dify
- What resonated — and what didnt
- How we can improve the experience
::::withIconCardList
:::withIconCardItem {icon="https://assets.dify.ai/images/gift-card.png"}
$100 Amazon gift card
:::
:::withIconCardItem {icon="https://assets.dify.ai/images/dify-swag.png"}
Dify swag
:::
::::
`
export default function RemarkDirectiveTestPage() {
return (
<main style={{ padding: 24 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}>
remark-directive test page
</h1>
<div className="markdown-body">
<MarkdownWithDirective markdown={markdown1} />
</div>
<div className="markdown-body !mt-5">
<MarkdownWithDirective markdown={markdown2} />
</div>
</main>
)
}

View File

@@ -0,0 +1,54 @@
import { z } from 'zod'
const commonSchema = {
className: z.string().min(1).optional(),
}
export const withIconCardListPropsSchema = z.object(commonSchema).strict()
export const withIconCardItemPropsSchema = z.object({
...commonSchema,
icon: z.string().trim().url().refine(
value => /^https?:\/\//i.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,20 @@
import type { ReactNode } from 'react'
import type { WithIconCardItemProps } from './markdown-with-directive-schema'
import Image from 'next/image'
type WithIconItemProps = WithIconCardItemProps & {
children?: ReactNode
}
function WithIconCardItem({ icon, children }: WithIconItemProps) {
return (
<div className="flex h-11 items-center space-x-3 rounded-lg bg-background-section px-2">
<Image src={icon} className="!border-none object-contain" alt="icon" width={40} height={40} />
<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,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 WithIconList({ children, className }: WithIconListProps) {
return (
<div className={cn('space-y-1', className)}>
{children}
</div>
)
}
export default WithIconList

View File

@@ -0,0 +1,200 @@
import type { Components } from 'react-markdown'
import DOMPurify from 'dompurify'
import ReactMarkdown from 'react-markdown'
import remarkDirective from 'remark-directive'
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'
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)
}
function normalizeDirectiveAttributeBlocks(markdown: string): string {
const lines = markdown.split('\n')
return lines.map((line) => {
const match = line.match(/^(\s*:+[a-z][\w-]*(?:\[[^\]\n]*\])?)\s+((?:\{[^}\n]*\}\s*)+)$/i)
if (!match)
return line
const directivePrefix = match[1]
const attributeBlocks = match[2]
const attrMatches = [...attributeBlocks.matchAll(/\{([^}\n]*)\}/g)]
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
}
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 (/^\s*:{2,}[a-z][\w-]*/im.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: WithIconCardList,
withiconcarditem: WithIconCardItem,
} as unknown as 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)
return (
<ReactMarkdown
skipHtml
remarkPlugins={[remarkDirective, directivePlugin]}
components={directiveComponents}
>
{normalizedMarkdown}
</ReactMarkdown>
)
}

View File

@@ -37,13 +37,13 @@ const nextConfig: NextConfig = {
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
// https://nextjs.org/docs/messages/next-image-unconfigured-host
images: {
remotePatterns: remoteImageURLs.map(remoteImageURL => ({
remotePatterns: [...remoteImageURLs.map(remoteImageURL => ({
protocol: remoteImageURL.protocol.replace(':', '') as 'http' | 'https',
hostname: remoteImageURL.hostname,
port: remoteImageURL.port,
pathname: remoteImageURL.pathname,
search: '',
})),
})), new URL('https://assets.dify.ai/images/**')],
},
typescript: {
// https://nextjs.org/docs/api-reference/next.config.js/ignoring-typescript-errors

View File

@@ -152,6 +152,7 @@
"rehype-katex": "7.0.1",
"rehype-raw": "7.0.0",
"remark-breaks": "4.0.0",
"remark-directive": "4.0.0",
"remark-gfm": "4.0.1",
"remark-math": "6.0.0",
"scheduler": "0.27.0",
@@ -162,6 +163,7 @@
"tailwind-merge": "2.6.1",
"tldts": "7.0.17",
"ufo": "1.6.3",
"unist-util-visit": "5.1.0",
"use-context-selector": "2.0.0",
"uuid": "10.0.0",
"zod": "4.3.6",

48
web/pnpm-lock.yaml generated
View File

@@ -327,6 +327,9 @@ importers:
remark-breaks:
specifier: 4.0.0
version: 4.0.0
remark-directive:
specifier: 4.0.0
version: 4.0.0
remark-gfm:
specifier: 4.0.1
version: 4.0.1
@@ -357,6 +360,9 @@ importers:
ufo:
specifier: 1.6.3
version: 1.6.3
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)
@@ -5989,6 +5995,9 @@ packages:
engines: {node: '>= 18'}
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==}
@@ -6074,6 +6083,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==}
@@ -6971,6 +6983,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==}
@@ -14048,6 +14063,20 @@ snapshots:
marked@15.0.12: {}
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.2
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
@@ -14295,6 +14324,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
@@ -15427,6 +15466,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