mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 10:55:57 +00:00
Core: introduce remark-feedback-block plugin
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"fumadocs-core": patch
|
||||
---
|
||||
|
||||
Introduce `remark-feedback-block` plugin
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,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,
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user