fix(markdown): enhance Markdown component with middleware support,fix ai summary entry id renderer

- Updated the Markdown component to accept an `applyMiddleware` prop for custom processing.
- Modified the `parseMarkdown` function to utilize the new middleware feature, allowing for additional processing steps.
- Adjusted related components to ensure compatibility with the new middleware functionality.

These changes improve the flexibility and extensibility of the Markdown rendering process.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2025-04-22 16:21:46 +08:00
parent 614a0a99d6
commit c3812be806
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
5 changed files with 189 additions and 54 deletions

View File

@ -10,8 +10,8 @@ export const Markdown: Component<
{
children: string
} & Partial<RemarkOptions>
> = ({ children, components, className }) => {
const stableRemarkOptions = useState({ components })[0]
> = ({ children, components, className, applyMiddleware }) => {
const stableRemarkOptions = useState({ components, applyMiddleware })[0]
const markdownElement = useMemo(
() => parseMarkdown(children, { ...stableRemarkOptions }).content,

View File

@ -103,8 +103,8 @@ const Wrapper: Component<{
"flex justify-end gap-3 p-2 text-white/70 duration-200 [&_button]:hover:text-white",
"hover:!transform-none hover:!opacity-100",
sideContent ? "rounded-bl-xl" : "rounded-xl",
"bg-black/30",
// sideContent ? "rounded-bl-xl" : "rounded-xl",
"bg-black/50",
)}
onClick={stopPropagation}
>

View File

@ -16,7 +16,7 @@ import type { Components } from "hast-util-to-jsx-runtime"
import type { Variant } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import type { FC } from "react"
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { MenuItemText } from "~/atoms/context-menu"
@ -53,6 +53,7 @@ import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { remarkSnowflakeId } from "./plugins/parse-snowflake"
import type { DailyItemProps, DailyView } from "./types"
import { useParseDailyDate } from "./useParseDailyDate"
@ -176,17 +177,28 @@ export const DailyReportContent: Component<DailyReportContentProps> = ({
const content = useQueryData({ endDate, startDate, view })
const RelatedEntryLink = useState(() => createRelatedEntryLink("modal"))[0]
return (
<Card className="border-none bg-transparent">
<CardContent className={cn("space-y-0 p-0", className)}>
<ScrollArea.ScrollArea mask={false} flex viewportClassName="max-h-[calc(100vh-176px)]">
<ScrollArea.ScrollArea mask={false} flex viewportClassName="h-[calc(100vh-176px)]">
<AutoResizeHeight spring>
{content.isLoading ? (
<LoadingCircle size="large" className="mt-8 text-center" />
<LoadingCircle
size="large"
className="center flex h-[calc(100vh-176px)] text-center"
/>
) : (
!!content.data && (
<Markdown
applyMiddleware={(pipeline) => {
pipeline.use(remarkSnowflakeId)
return pipeline
}}
components={{
// @ts-expect-error
"snowflake-id": SnowflakeId,
a: RelatedEntryLink as Components["a"],
}}
className="prose-sm prose-p:my-1 prose-ul:my-1 prose-ul:list-outside prose-ul:list-disc prose-li:marker:text-accent mt-4 px-6"
@ -265,8 +277,7 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
const { href, children } = props
const entryId = isBizId(href) ? href : null
const { present } = useModalStack()
const peekModal = usePeekModal()
if (!entryId) {
return <MarkdownLink {...props} />
}
@ -275,49 +286,7 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
type="button"
className="follow-link--underline text-foreground cursor-pointer font-semibold no-underline"
onClick={() => {
const basePresentProps = {
clickOutsideToDismiss: true,
title: "Entry Preview",
}
if (variant === "toast") {
present({
...basePresentProps,
CustomModalComponent: PlainModal,
content: () => <EntryToastPreview entryId={entryId} />,
overlay: false,
modal: false,
modalContainerClassName: "right-0 left-[auto]",
})
} else {
present({
...basePresentProps,
autoFocus: false,
modalClassName:
"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0",
// eslint-disable-next-line @eslint-react/no-nested-component-definitions
CustomModalComponent: ({ children }) => {
const { feedId } = useEntry(entryId) || {}
return (
<PeekModal
rightActions={[
{
onClick: () => {},
label: "More Actions",
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`}
>
{children}
</PeekModal>
)
},
content: () => <EntryModalPreview entryId={entryId} />,
overlay: true,
})
}
peekModal(entryId, variant)
}}
>
{children}
@ -326,6 +295,57 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
)
}
const usePeekModal = () => {
const { present } = useModalStack()
return useCallback(
(entryId: string, variant: "toast" | "modal") => {
const basePresentProps = {
clickOutsideToDismiss: true,
title: "Entry Preview",
}
if (variant === "toast") {
present({
...basePresentProps,
CustomModalComponent: PlainModal,
content: () => <EntryToastPreview entryId={entryId} />,
overlay: false,
modal: false,
modalContainerClassName: "right-0 left-[auto]",
})
} else {
present({
...basePresentProps,
autoFocus: false,
modalClassName:
"relative mx-auto mt-[10vh] scrollbar-none max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0",
CustomModalComponent: ({ children }) => {
const { feedId } = useEntry(entryId) || {}
return (
<PeekModal
rightActions={[
{
onClick: () => {},
label: "More Actions",
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`}
>
{children}
</PeekModal>
)
},
content: () => <EntryModalPreview entryId={entryId} />,
overlay: true,
})
}
},
[present],
)
}
const EntryToastPreview = ({ entryId }: { entryId: string }) => {
useAuthQuery(Queries.entries.byId(entryId))
@ -508,3 +528,27 @@ const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => {
</DropdownMenu>
)
}
interface SnowflakeIdProps {
id: string
children?: React.ReactNode
index: number
}
const SnowflakeId: React.FC<SnowflakeIdProps> = ({ id: entryId, index }) => {
const peekModal = usePeekModal()
return (
<button
type="button"
className="follow-link--underline text-foreground cursor-pointer font-semibold no-underline"
onClick={() => {
peekModal(entryId, "modal")
}}
>
<sup className="inline-flex items-center gap-0.5 font-medium">
<span className="text-[0.65rem] opacity-70">{index}</span>
</sup>
</button>
)
}

View File

@ -0,0 +1,82 @@
import type { Parent } from "mdast"
import type { Node } from "unist"
import { visit } from "unist-util-visit"
interface SnowflakeOptions {
component?: string
}
const SNOWFLAKE_PATTERN = /\[(\d{15,20})\]/g
function isTextNode(node: Node): node is { type: "text"; value: string } {
return node.type === "text"
}
export function remarkSnowflakeId(options: SnowflakeOptions = {}) {
const { component = "snowflake-id" } = options
return (tree: Node) => {
let idIndex = 1
visit(tree, "text", (node, index, parent: Parent) => {
if (!isTextNode(node) || !parent) return
const { value } = node as { value: string }
const matches = Array.from(value.matchAll(SNOWFLAKE_PATTERN))
if (matches.length === 0) return
const result: (Node | string)[] = []
let lastIndex = 0
for (const match of matches) {
if (!Array.isArray(match)) continue
const [fullMatch, snowflakeId] = match
const matchIndex = match.index!
// Add text before the match
if (matchIndex > lastIndex) {
result.push({
type: "text",
// @ts-expect-error
value: value.slice(lastIndex, matchIndex),
})
}
// Add snowflake node
result.push({
type: "snowflakeId",
data: {
hName: component,
hProperties: {
id: snowflakeId,
index: idIndex++,
},
},
// @ts-expect-error
children: [
{
type: "text",
value: fullMatch,
},
],
})
lastIndex = matchIndex + fullMatch.length
}
// Add any remaining text after the last match
if (lastIndex < value.length) {
result.push({
type: "text",
// @ts-expect-error
value: value.slice(lastIndex),
})
}
// Replace the current node with the new nodes
// @ts-expect-error
parent.children.splice(index, 1, ...result)
})
}
}

View File

@ -14,17 +14,20 @@ import remarkGfm from "remark-gfm"
import remarkGithubAlerts from "remark-gh-alerts"
import remarkParse from "remark-parse"
import remarkRehype from "remark-rehype"
import type { Processor } from "unified"
import { unified } from "unified"
import { VFile } from "vfile"
export interface RemarkOptions {
components: Partial<Components>
applyMiddleware?: <T extends Processor<any, any, any, any, any>>(pipeline: T) => T
}
export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>) => {
const file = new VFile(content)
const { components } = options || {}
const { components, applyMiddleware } = options || {}
const pipeline = unified()
let pipeline: Processor<any, any, any, any, any> = unified()
.use(remarkDirective)
.use(remarkParse)
@ -61,6 +64,12 @@ export const parseMarkdown = (content: string, options?: Partial<RemarkOptions>)
},
})
// Apply custom middleware if provided
if (applyMiddleware) {
pipeline = applyMiddleware(pipeline)
}
pipeline = pipeline
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeStringify, { allowDangerousHtml: true })