Compare commits

...

2 Commits

Author SHA1 Message Date
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
6 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import type { WithIconItemDirectiveProps, WithIconListDirectiveProps } from './directive-props-schema'
type WithIconListProps = WithIconListDirectiveProps & {
children?: ReactNode
className?: string
}
type WithIconItemProps = WithIconItemDirectiveProps & {
children?: ReactNode
}
export function WithIconList({ children, mt, className }: WithIconListProps) {
const classValue = className || ''
const classMarginTop = classValue.includes('mt-4') ? 16 : 0
return (
<div style={{ marginTop: Number(mt || classMarginTop) }}>
<div style={{ padding: 16 }}>
{children}
</div>
</div>
)
}
export function WithIconItem({ icon, b, children }: WithIconItemProps) {
return (
<div style={{ display: 'flex', border: '1px solid #ddd', gap: 8 }}>
<span>🔹</span>
{b && <span>{b}</span>}
<span>{children}</span>
<small style={{ color: '#999' }}>
{`(${icon})`}
</small>
</div>
)
}

View File

@@ -0,0 +1,195 @@
import type { Components } from 'react-markdown'
import ReactMarkdown from 'react-markdown'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import { WithIconItem, WithIconList } from './directive-components'
import { directivePropsSchemas } from './directive-props-schema'
type DirectiveNode = {
type?: string
name?: string
attributes?: Record<string, unknown>
data?: {
hName?: string
hProperties?: Record<string, string>
}
}
type DirectiveName = keyof typeof directivePropsSchemas
function isDirectiveName(name: string): name is DirectiveName {
return Object.hasOwn(directivePropsSchemas, name)
}
function isValidDirectiveProps(name: string, attributes: Record<string, string>): boolean {
if (!isDirectiveName(name))
return false
return directivePropsSchemas[name].safeParse(attributes).success
}
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 (!isValidDirectiveProps(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 = {
withiconlist: WithIconList,
withiconitem: WithIconItem,
} as unknown as Components
type DirectiveMarkdownRendererProps = {
markdown: string
}
export function DirectiveMarkdownRenderer({ markdown }: DirectiveMarkdownRendererProps) {
const normalizedMarkdown = normalizeDirectiveAttributeBlocks(markdown)
return (
<ReactMarkdown
remarkPlugins={[remarkDirective, directivePlugin]}
components={directiveComponents}
>
{normalizedMarkdown}
</ReactMarkdown>
)
}

View File

@@ -0,0 +1,19 @@
import { z } from 'zod'
export const withIconListDirectivePropsSchema = z.object({
class: z.string().trim().min(1).optional(),
mt: z.string().trim().min(1).optional(),
}).strict()
export const withIconItemDirectivePropsSchema = z.object({
icon: z.string().trim().min(1),
b: z.string().trim().min(1).optional(),
}).strict()
export const directivePropsSchemas = {
withiconlist: withIconListDirectivePropsSchema,
withiconitem: withIconItemDirectivePropsSchema,
} as const
export type WithIconListDirectiveProps = z.infer<typeof withIconListDirectivePropsSchema>
export type WithIconItemDirectiveProps = z.infer<typeof withIconItemDirectivePropsSchema>

View File

@@ -0,0 +1,37 @@
'use client'
import { DirectiveMarkdownRenderer } from './directive-markdown-renderer'
const markdown = `
Were speaking with technical teams to better understand:
- How you discovered Dify
- What resonated — and what didnt
- How we can improve the experience
::::withiconlist{.mt-4}
:::withiconitem {icon="amazon"} {b="3"}
$100 Amazon gift card
:::
:::withiconitem {icon="amazon2"}
$100 Amazon gift card2
:::
::withiconitem[Exclusive Dify swag]{icon="dify"}
::::
`
export default function RemarkDirectiveTestPage() {
return (
<main style={{ padding: 24 }}>
<h1 style={{ fontSize: 20, fontWeight: 600, marginBottom: 16 }}>
remark-directive test page
</h1>
<div>
<DirectiveMarkdownRenderer markdown={markdown} />
</div>
</main>
)
}

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