Core: introduce remark-feedback-block plugin

This commit is contained in:
Fuma Nama
2026-01-09 18:01:34 +08:00
parent 9d428f2ead
commit ea57dbfa76
9 changed files with 529 additions and 205 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"fumadocs-core": patch
---
Introduce `remark-feedback-block` plugin
+8 -3
View File
@@ -8,8 +8,8 @@ import { createMetadata, getPageImage } from '@/lib/metadata';
import { source } from '@/lib/source';
import { Wrapper } from '@/components/preview/wrapper';
import { Mermaid } from '@/components/mdx/mermaid';
import { Feedback } from '@/components/feedback';
import { onRateAction, owner, repo } from '@/lib/github';
import { Feedback, FeedbackBlock } from '@/components/feedback/client';
import { onBlockFeedbackAction, onPageFeedbackAction, owner, repo } from '@/lib/github';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import Link from 'fumadocs-core/link';
import { findSiblings } from 'fumadocs-core/page-tree';
@@ -104,6 +104,11 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
</HoverCard>
);
},
FeedbackBlock: ({ children, ...props }) => (
<FeedbackBlock {...props} onSendAction={onBlockFeedbackAction}>
{children}
</FeedbackBlock>
),
Banner,
Mermaid,
TypeTable,
@@ -118,7 +123,7 @@ export default async function Page(props: PageProps<'/docs/[[...slug]]'>) {
/>
{page.data.index ? <DocsCategory url={page.url} /> : null}
</div>
<Feedback onRateAction={onRateAction} />
<Feedback onSendAction={onPageFeedbackAction} />
{lastModified && <PageLastUpdate date={lastModified} />}
</DocsPage>
);
-186
View File
@@ -1,186 +0,0 @@
'use client';
import { cn } from '@/lib/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { type SyntheticEvent, useEffect, useState, useTransition } from 'react';
import { Collapsible, CollapsibleContent } from 'fumadocs-ui/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { usePathname } from 'next/navigation';
const rateButtonVariants = cva(
'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
false: 'text-fd-muted-foreground',
},
},
},
);
export interface Feedback {
opinion: 'good' | 'bad';
url?: string;
message: string;
}
export interface ActionResponse {
githubUrl: string;
}
interface Result extends Feedback {
response?: ActionResponse;
}
export function Feedback({
onRateAction,
}: {
onRateAction: (url: string, feedback: Feedback) => Promise<ActionResponse>;
}) {
const url = usePathname();
const [previous, setPrevious] = useState<Result | null>(null);
const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
const [message, setMessage] = useState('');
const [isPending, startTransition] = useTransition();
useEffect(() => {
const item = localStorage.getItem(`docs-feedback-${url}`);
if (item === null) return;
setPrevious(JSON.parse(item) as Result);
}, [url]);
useEffect(() => {
const key = `docs-feedback-${url}`;
if (previous) localStorage.setItem(key, JSON.stringify(previous));
else localStorage.removeItem(key);
}, [previous, url]);
function submit(e?: SyntheticEvent) {
if (opinion == null) return;
startTransition(async () => {
const feedback: Feedback = {
opinion,
message,
};
void onRateAction(url, feedback).then((response) => {
setPrevious({
response,
...feedback,
});
setMessage('');
setOpinion(null);
});
});
e?.preventDefault();
}
const activeOpinion = previous?.opinion ?? opinion;
return (
<Collapsible
open={opinion !== null || previous !== null}
onOpenChange={(v) => {
if (!v) setOpinion(null);
}}
className="border-y py-3"
>
<div className="flex flex-row items-center gap-2">
<p className="text-sm font-medium pe-2">How is this guide?</p>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'good',
}),
)}
onClick={() => {
setOpinion('good');
}}
>
<ThumbsUp />
Good
</button>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'bad',
}),
)}
onClick={() => {
setOpinion('bad');
}}
>
<ThumbsDown />
Bad
</button>
</div>
<CollapsibleContent className="mt-3">
{previous ? (
<div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-muted-foreground text-sm text-center rounded-xl">
<p>Thank you for your feedback!</p>
<div className="flex flex-row items-center gap-2">
<a
href={previous.response?.githubUrl}
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
color: 'primary',
}),
'text-xs',
)}
>
View on GitHub
</a>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setOpinion(previous.opinion);
setPrevious(null);
}}
>
Submit Again
</button>
</div>
</div>
) : (
<form className="flex flex-col gap-3" onSubmit={submit}>
<textarea
autoFocus
required
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<button
type="submit"
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
disabled={isPending}
>
Submit
</button>
</form>
)}
</CollapsibleContent>
</Collapsible>
);
}
+345
View File
@@ -0,0 +1,345 @@
'use client';
import { cn } from '@/lib/cn';
import { buttonVariants } from 'fumadocs-ui/components/ui/button';
import { MessageSquare, ThumbsDown, ThumbsUp } from 'lucide-react';
import {
ReactNode,
type SyntheticEvent,
useEffect,
useEffectEvent,
useState,
useTransition,
} from 'react';
import { Collapsible, CollapsibleContent } from 'fumadocs-ui/components/ui/collapsible';
import { cva } from 'class-variance-authority';
import { usePathname } from 'next/navigation';
import { Popover, PopoverContent, PopoverTrigger } from 'fumadocs-ui/components/ui/popover';
import type { FeedbackBlockProps } from 'fumadocs-core/mdx-plugins/remark-feedback-block';
import {
actionResponse,
blockFeedback,
pageFeedback,
type ActionResponse,
type BlockFeedback,
type PageFeedback,
} from './schema';
import { z } from 'zod/mini';
const rateButtonVariants = cva(
'inline-flex items-center gap-2 px-3 py-2 rounded-full font-medium border text-sm [&_svg]:size-4 disabled:cursor-not-allowed',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground [&_svg]:fill-current',
false: 'text-fd-muted-foreground',
},
},
},
);
const pageFeedbackResult = z.extend(pageFeedback, {
response: actionResponse,
});
const blockFeedbackResult = z.extend(blockFeedback, {
response: actionResponse,
});
/**
* A feedback component to be attached to the end of page
*/
export function Feedback({
onSendAction,
}: {
onSendAction: (feedback: PageFeedback) => Promise<ActionResponse>;
}) {
const url = usePathname();
const { previous, setPrevious } = useSubmissionStorage(url, (v) => {
const result = pageFeedbackResult.safeParse(v);
return result.success ? result.data : null;
});
const [opinion, setOpinion] = useState<'good' | 'bad' | null>(null);
const [message, setMessage] = useState('');
const [isPending, startTransition] = useTransition();
function submit(e?: SyntheticEvent) {
if (opinion == null) return;
startTransition(async () => {
const feedback: PageFeedback = {
url,
opinion,
message,
};
const response = await onSendAction(feedback);
setPrevious({
response,
...feedback,
});
setMessage('');
setOpinion(null);
});
e?.preventDefault();
}
const activeOpinion = previous?.opinion ?? opinion;
return (
<Collapsible
open={opinion !== null || previous !== null}
onOpenChange={(v) => {
if (!v) setOpinion(null);
}}
className="border-y py-3"
>
<div className="flex flex-row items-center gap-2">
<p className="text-sm font-medium pe-2">How is this guide?</p>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'good',
}),
)}
onClick={() => {
setOpinion('good');
}}
>
<ThumbsUp />
Good
</button>
<button
disabled={previous !== null}
className={cn(
rateButtonVariants({
active: activeOpinion === 'bad',
}),
)}
onClick={() => {
setOpinion('bad');
}}
>
<ThumbsDown />
Bad
</button>
</div>
<CollapsibleContent className="mt-3">
{previous ? (
<div className="px-3 py-6 flex flex-col items-center gap-3 bg-fd-card text-fd-muted-foreground text-sm text-center rounded-xl">
<p>Thank you for your feedback!</p>
<div className="flex flex-row items-center gap-2">
<a
href={previous.response?.githubUrl}
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
color: 'primary',
}),
'text-xs',
)}
>
View on GitHub
</a>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setOpinion(previous.opinion);
setPrevious(null);
}}
>
Submit Again
</button>
</div>
</div>
) : (
<form className="flex flex-col gap-3" onSubmit={submit}>
<textarea
autoFocus
required
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<button
type="submit"
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
disabled={isPending}
>
Submit
</button>
</form>
)}
</CollapsibleContent>
</Collapsible>
);
}
export function FeedbackBlock({
id,
body,
onSendAction,
children,
}: FeedbackBlockProps & {
onSendAction: (feedback: BlockFeedback) => Promise<ActionResponse>;
children: ReactNode;
}) {
const url = usePathname();
const blockId = `${url}-${id}`;
const { previous, setPrevious } = useSubmissionStorage(blockId, (v) => {
const result = blockFeedbackResult.safeParse(v);
if (result.success) return result.data;
return null;
});
const [message, setMessage] = useState('');
const [isPending, startTransition] = useTransition();
const [open, setOpen] = useState(false);
function submit(e?: SyntheticEvent) {
startTransition(async () => {
const feedback: BlockFeedback = {
blockId,
blockBody: body,
url,
message,
};
const response = await onSendAction(feedback);
setPrevious({
response,
...feedback,
});
setMessage('');
});
e?.preventDefault();
}
return (
<Popover open={open} onOpenChange={setOpen}>
<div className="relative group/feedback">
<div
className={cn(
'absolute -inset-1 rounded-sm pointer-events-none z-[-1]',
open ? 'bg-fd-accent' : 'group-hover/feedback:bg-fd-accent',
)}
/>
<PopoverTrigger
className={cn(
buttonVariants({ variant: 'secondary', size: 'sm' }),
'absolute -top-7 end-0 backdrop-blur-sm text-fd-muted-foreground gap-1.5',
!open && 'invisible group-hover/feedback:visible hover:visible',
)}
onClick={(e) => {
setOpen((prev) => !prev);
e.stopPropagation();
e.preventDefault();
}}
>
<MessageSquare className="size-3.5" />
Feedback
</PopoverTrigger>
{children}
</div>
<PopoverContent className="min-w-[300px]">
{previous ? (
<div className="flex flex-col items-center gap-2 text-fd-muted-foreground text-sm text-center rounded-xl">
<p>Thank you for your feedback!</p>
<div className="flex flex-row items-center gap-2">
<a
href={previous.response?.githubUrl}
rel="noreferrer noopener"
target="_blank"
className={cn(
buttonVariants({
color: 'primary',
}),
'text-xs',
)}
>
View on GitHub
</a>
<button
className={cn(
buttonVariants({
color: 'secondary',
}),
'text-xs',
)}
onClick={() => {
setPrevious(null);
}}
>
Submit Again
</button>
</div>
</div>
) : (
<form className="flex flex-col gap-2" onSubmit={submit}>
<textarea
autoFocus
required
value={message}
onChange={(e) => setMessage(e.target.value)}
className="border rounded-lg bg-fd-secondary text-fd-secondary-foreground p-3 resize-none focus-visible:outline-none placeholder:text-fd-muted-foreground"
placeholder="Leave your feedback..."
onKeyDown={(e) => {
if (!e.shiftKey && e.key === 'Enter') {
submit(e);
}
}}
/>
<button
type="submit"
className={cn(buttonVariants({ color: 'outline' }), 'w-fit px-3')}
disabled={isPending}
>
Submit
</button>
</form>
)}
</PopoverContent>
</Popover>
);
}
function useSubmissionStorage<Result>(blockId: string, validate: (v: unknown) => Result | null) {
const storageKey = `docs-feedback-${blockId}`;
const [value, setValue] = useState<Result | null>(null);
const validateCallback = useEffectEvent(validate);
useEffect(() => {
const item = localStorage.getItem(storageKey);
if (item === null) return;
const validated = validateCallback(JSON.parse(item));
if (validated !== null) setValue(validated);
}, [storageKey]);
return {
previous: value,
setPrevious(result: Result | null) {
if (result) localStorage.setItem(storageKey, JSON.stringify(result));
else localStorage.removeItem(storageKey);
setValue(result);
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod/mini';
export const blockFeedback = z.object({
url: z.string(),
blockId: z.string(),
message: z.string(),
/** the referenced text of block */
blockBody: z.optional(z.string()),
});
export const pageFeedback = z.object({
opinion: z.enum(['good', 'bad']),
url: z.string(),
message: z.string(),
});
export const actionResponse = z.object({
githubUrl: z.optional(z.string()),
});
export type BlockFeedback = z.infer<typeof blockFeedback>;
export type PageFeedback = z.infer<typeof pageFeedback>;
export type ActionResponse = z.infer<typeof actionResponse>;
+41 -16
View File
@@ -1,5 +1,11 @@
import { App, Octokit } from 'octokit';
import type { ActionResponse, Feedback } from '@/components/feedback';
import {
blockFeedback,
BlockFeedback,
pageFeedback,
type ActionResponse,
type PageFeedback,
} from '@/components/feedback/schema';
export const repo = 'fumadocs';
export const owner = 'fuma-nama';
@@ -66,22 +72,35 @@ async function getFeedbackDestination() {
return (cachedDestination = repository);
}
export async function onRateAction(url: string, feedback: Feedback): Promise<ActionResponse> {
export async function onPageFeedbackAction(feedback: PageFeedback): Promise<ActionResponse> {
'use server';
feedback = pageFeedback.parse(feedback);
return createDiscussionThread(
feedback.url,
`[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`,
);
}
export async function onBlockFeedbackAction(feedback: BlockFeedback): Promise<ActionResponse> {
'use server';
feedback = blockFeedback.parse(feedback);
return createDiscussionThread(
feedback.url,
`> ${feedback.blockBody ?? feedback.blockId}\n\n${feedback.message}\n\n> Forwarded from user feedback.`,
);
}
async function createDiscussionThread(pageId: string, body: string) {
const octokit = await getOctokit();
const destination = await getFeedbackDestination();
if (!octokit || !destination) throw new Error('GitHub comment integration is not configured.');
const category = destination.discussionCategories.nodes.find(
(category) => category.name === DocsCategory,
);
if (!category) throw new Error(`Please create a "${DocsCategory}" category in GitHub Discussion`);
const title = `Feedback for ${url}`;
const body = `[${feedback.opinion}] ${feedback.message}\n\n> Forwarded from user feedback.`;
let {
const title = `Feedback for ${pageId}`;
const {
search: {
nodes: [discussion],
},
@@ -99,26 +118,32 @@ export async function onRateAction(url: string, feedback: Feedback): Promise<Act
}`);
if (discussion) {
await octokit.graphql(`
const result: {
addDiscussionComment: {
comment: { id: string; url: string };
};
} = await octokit.graphql(`
mutation {
addDiscussionComment(input: { body: ${JSON.stringify(body)}, discussionId: "${discussion.id}" }) {
comment { id }
comment { id, url }
}
}`);
return {
githubUrl: result.addDiscussionComment.comment.url,
};
} else {
const result: {
discussion: { id: string; url: string };
} = await octokit.graphql(`
mutation {
createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category!.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
createDiscussion(input: { repositoryId: "${destination.id}", categoryId: "${category.id}", body: ${JSON.stringify(body)}, title: ${JSON.stringify(title)} }) {
discussion { id, url }
}
}`);
discussion = result.discussion;
return {
githubUrl: result.discussion.url,
};
}
return {
githubUrl: discussion.url,
};
}
+10
View File
@@ -10,6 +10,7 @@ import type { ElementContent } from 'hast';
import jsonSchema from 'fumadocs-mdx/plugins/json-schema';
import lastModified from 'fumadocs-mdx/plugins/last-modified';
import type { ShikiTransformer } from 'shiki';
import type { RemarkFeedbackBlockOptions } from 'fumadocs-core/mdx-plugins';
export const docs = defineDocs({
docs: {
@@ -76,6 +77,7 @@ export default defineConfig({
const { remarkStructureDefaultOptions } =
await import('fumadocs-core/mdx-plugins/remark-structure');
const { remarkSteps } = await import('fumadocs-core/mdx-plugins/remark-steps');
const { remarkFeedbackBlock } = await import('fumadocs-core/mdx-plugins/remark-feedback-block');
const { transformerTwoslash } = await import('fumadocs-twoslash');
const { createFileSystemTypesCache } = await import('fumadocs-twoslash/cache-fs');
const { default: remarkMath } = await import('remark-math');
@@ -87,6 +89,13 @@ export default defineConfig({
const generator = createGenerator({
cache: createFileSystemGeneratorCache('.next/fumadocs-typescript'),
});
const feedbackOptions: RemarkFeedbackBlockOptions = {
resolve(node) {
// defensive approach
if (node.type === 'mdxJsxFlowElement') return 'skip';
return node.type === 'paragraph' || node.type === 'image';
},
};
return {
remarkStructureOptions: {
types: [...remarkStructureDefaultOptions.types, 'code'],
@@ -117,6 +126,7 @@ export default defineConfig({
remarkPlugins: [
remarkSteps,
remarkMath,
[remarkFeedbackBlock, feedbackOptions],
[
remarkAutoTypeTable,
{
+1
View File
@@ -12,3 +12,4 @@ export * from './remark-npm';
export * from './codeblock-utils';
export { remarkMdxFiles, type RemarkMdxFilesOptions } from './remark-mdx-files';
export * from './remark-mdx-mermaid';
export * from './remark-feedback-block';
@@ -0,0 +1,95 @@
import type { Transformer } from 'unified';
import type { BlockContent, Root, RootContent } from 'mdast';
import type { MdxJsxFlowElement } from 'mdast-util-mdx-jsx';
import { visit } from 'unist-util-visit';
import { createHash } from 'node:crypto';
import { flattenNode } from './mdast-utils';
export interface RemarkFeedbackBlockOptions {
/**
* generate block ID, default to using MD5.
*/
generateHash?: (ctx: { body: string }) => string;
/**
* @defaultValue FeedbackBlock
*/
tagName?: string;
/**
* determine how the node should be resolved into a feedback block.
*
* scan paragraph & image nodes by default.
*
* @returns
* - `true`: convert the node into a feedback block.
* - `false`: skip the current node and look into its children.
* - `skip`: skip the current node and its children.
*/
resolve?: (node: RootContent) => boolean | 'skip';
/**
* generate & include the block body to `<FeedbackBlock body="..." />` as string
*
* @defaultValue false
*/
generateBody?: boolean;
}
export interface FeedbackBlockProps {
id: string;
/** the text body of block, only exists when `generateBody` is enabled. */
body?: string;
}
/**
* Generate MDX `<FeedbackBlock />` elements with an unique `id` for every block-like element.
*
* Note: the uniqueness is only guaranteed per MDX file/page.
*/
export function remarkFeedbackBlock({
generateHash = ({ body }) => createHash('md5').update(body).digest('hex').substring(0, 16),
tagName = 'FeedbackBlock',
resolve = (node) => node.type === 'paragraph' || node.type === 'image',
generateBody = false,
}: RemarkFeedbackBlockOptions = {}): Transformer<Root, Root> {
return (tree) => {
const counts = new Map<string, number>();
visit(tree, (node, index, parent) => {
if (node.type === 'root') return;
const resolved = resolve(node);
if (resolved === false) return;
if (resolved === 'skip') return 'skip';
const text = flattenNode(node).trim();
if (text.length === 0 || !parent || typeof index !== 'number') return;
let id = generateHash({ body: text });
const count = counts.get(id) ?? 0;
if (count > 0) id = `${id}-${count}`;
counts.set(id, count + 1);
const wrapper: MdxJsxFlowElement = {
type: 'mdxJsxFlowElement',
name: tagName,
attributes: [
{
type: 'mdxJsxAttribute',
name: 'id',
value: id,
},
],
children: [node as BlockContent],
};
if (generateBody)
wrapper.attributes.push({
type: 'mdxJsxAttribute',
name: 'body',
value: text,
});
parent.children[index] = wrapper;
return 'skip';
});
};
}