feat: support check valid

This commit is contained in:
Joel
2026-03-05 14:23:07 +08:00
parent 908820acb4
commit 59f826570d
4 changed files with 262 additions and 94 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

@ -1,8 +1,5 @@
'use client'
import type { ReactNode } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkDirective from 'remark-directive'
import { visit } from 'unist-util-visit'
import { DirectiveMarkdownRenderer } from './directive-markdown-renderer'
const markdown = `
Were speaking with technical teams to better understand:
@ -11,109 +8,29 @@ Were speaking with technical teams to better understand:
- What resonated — and what didnt
- How we can improve the experience
:::::withiconlist{.mt-4}
::::withiconlist{.mt-4}
::::withiconitem{icon="amazon"}
:::withiconitem {icon="amazon"} {b="3"}
$100 Amazon gift card
:::withiconitem{icon="abc"}
inner
:::
::::
::::withiconitem{icon="dify"}
Exclusive **Dify** swag
::::
:::withiconitem {icon="amazon2"}
$100 Amazon gift card2
:::
:::::
::withiconitem[Exclusive Dify swag]{icon="dify"}
::::
`
type WithIconListProps = {
children?: ReactNode
mt?: string | number
className?: string
class?: string
}
type WithIconItemProps = {
icon?: string
children?: ReactNode
}
type DirectiveNode = {
name?: string
attributes?: Record<string, string>
data?: {
hName?: string
hProperties?: Record<string, string>
}
}
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>
)
}
function WithIconItem({ icon, children }: WithIconItemProps) {
return (
<div style={{ display: 'flex', border: '1px solid #ddd', gap: 8 }}>
<span>🔹</span>
<span>{children}</span>
<small style={{ color: '#999' }}>
{`(${icon})`}
</small>
</div>
)
}
function directivePlugin() {
return (tree: Parameters<typeof visit>[0]) => {
visit(
tree,
['textDirective', 'leafDirective', 'containerDirective'],
(node) => {
const directiveNode = node as DirectiveNode
const attributes = 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 import('react-markdown').Components
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">
<ReactMarkdown
remarkPlugins={[remarkDirective, directivePlugin]}
components={directiveComponents}
>
{markdown}
</ReactMarkdown>
<div>
<DirectiveMarkdownRenderer markdown={markdown} />
</div>
</main>
)