Checkpoint

This commit is contained in:
2026-07-28 03:53:38 -03:00
parent 0f506ec19e
commit 435e4e58e8
26 changed files with 1174 additions and 48 deletions
+1 -1
View File
@@ -58,7 +58,7 @@ export async function generateMetadata(
if (!page) notFound();
return {
title: page.data.title,
title: page.data.title + " · Duckity Docs",
description: page.data.description,
openGraph: {
images: getPageImageUrl(page).url,
+1
View File
@@ -1,6 +1,7 @@
@import 'tailwindcss';
@import 'fumadocs-ui/css/neutral.css';
@import 'fumadocs-ui/css/preset.css';
@import 'fumadocs-twoslash/twoslash.css';
html {
scrollbar-gutter: stable;
+265
View File
@@ -0,0 +1,265 @@
'use client';
import { Check, Clipboard } from 'lucide-react';
import {
type ComponentProps,
createContext,
type HTMLAttributes,
type ReactNode,
type RefObject,
use,
useMemo,
useRef,
} from 'react';
import { cn } from '../lib/cn';
import { useCopyButton } from '@fumadocs/base-ui/utils/use-copy-button';
import { buttonVariants } from './ui/button';
import { useTranslations } from '@fuma-translate/react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { mergeRefs } from '../lib/merge-refs';
export interface CodeBlockProps extends Omit<ComponentProps<'figure'>, 'title'> {
title?: ReactNode;
/**
* Icon of code block
*
* When passed as a string, it assumes the value is the HTML of icon
*/
icon?: ReactNode;
/**
* Allow to copy code with copy button
*
* @defaultValue true
*/
allowCopy?: boolean | 'true' | 'false';
/**
* Keep original background color generated by Shiki or Rehype Code
*
* @defaultValue false
*/
keepBackground?: boolean;
viewportProps?: HTMLAttributes<HTMLElement>;
/**
* show line numbers
*/
'data-line-numbers'?: boolean;
/**
* @defaultValue 1
*/
'data-line-numbers-start'?: number;
Actions?: (props: { className?: string; children?: ReactNode }) => ReactNode;
}
const TabsContext = createContext<{
containerRef: RefObject<HTMLDivElement | null>;
nested: boolean;
} | null>(null);
export function Pre(props: ComponentProps<'pre'>) {
return (
<pre {...props} className={cn('min-w-full w-max *:flex *:flex-col', props.className)}>
{props.children}
</pre>
);
}
export function CodeBlock({
ref,
title,
allowCopy = true,
keepBackground = false,
icon,
viewportProps = {},
children,
Actions = (props) => <div {...props} className={cn('empty:hidden', props.className)} />,
...props
}: CodeBlockProps) {
const inTab = use(TabsContext) !== null;
const areaRef = useRef<HTMLDivElement>(null);
if (allowCopy === 'true') allowCopy = true;
else if (allowCopy === 'false') allowCopy = false;
return (
<figure
ref={ref}
dir="ltr"
{...props}
tabIndex={-1}
className={cn(
inTab ? 'bg-fd-secondary -mx-px -mb-px last:rounded-b-xl' : 'my-4 bg-fd-card rounded-xl',
keepBackground && 'bg-(--shiki-light-bg) dark:bg-(--shiki-dark-bg)',
'shiki relative border shadow-sm not-prose overflow-hidden text-sm',
props.className,
)}
>
{title ? (
<div className="flex text-fd-muted-foreground items-center gap-2 h-9.5 border-b px-4">
{typeof icon === 'string' ? (
<div
className="[&_svg]:size-3.5"
dangerouslySetInnerHTML={{
__html: icon,
}}
/>
) : (
icon
)}
<figcaption className="flex-1 truncate">{title}</figcaption>
{Actions({
className: '-me-2',
children: allowCopy && <CopyButton containerRef={areaRef} />,
})}
</div>
) : (
Actions({
className:
'absolute top-2 right-2 z-2 backdrop-blur-lg rounded-lg text-fd-muted-foreground',
children: allowCopy && <CopyButton containerRef={areaRef} />,
})
)}
<div
ref={areaRef}
{...viewportProps}
role="region"
tabIndex={0}
className={cn(
'text-[0.8125rem] py-3.5 overflow-auto max-h-[600px] fd-scroll-container focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-fd-ring',
viewportProps.className,
)}
style={
{
// space for toolbar
'--padding-right': !title ? 'calc(var(--spacing) * 8)' : undefined,
counterSet: props['data-line-numbers']
? `line ${Number(props['data-line-numbers-start'] ?? 1) - 1}`
: undefined,
...viewportProps.style,
} as object
}
>
{children}
</div>
</figure>
);
}
function CopyButton({
className,
containerRef,
...props
}: ComponentProps<'button'> & {
containerRef: RefObject<HTMLElement | null>;
}) {
const t = useTranslations({ note: 'code block' });
const [checked, onClick] = useCopyButton(() => {
const pre = containerRef.current?.getElementsByTagName('pre').item(0);
if (!pre) return;
const clone = pre.cloneNode(true) as HTMLElement;
clone.querySelectorAll('.nd-copy-ignore').forEach((node) => {
node.replaceWith('\n');
});
void navigator.clipboard.writeText(clone.textContent ?? '');
});
return (
<button
type="button"
data-checked={checked || undefined}
className={cn(
buttonVariants({
className: 'hover:text-fd-accent-foreground data-checked:text-fd-accent-foreground',
size: 'icon-xs',
}),
className,
)}
aria-label={
checked ? t('Copied Text', { note: 'aria-label' }) : t('Copy Text', { note: 'aria-label' })
}
onClick={onClick}
{...props}
>
{checked ? <Check /> : <Clipboard />}
</button>
);
}
export function CodeBlockTabs({ ref, className, ...props }: ComponentProps<typeof Tabs>) {
const containerRef = useRef<HTMLDivElement>(null);
const nested = use(TabsContext) !== null;
return (
<Tabs
ref={mergeRefs(containerRef, ref)}
{...props}
className={(s) =>
cn(
'bg-fd-card rounded-xl border',
!nested && 'my-4',
typeof className === 'function' ? className(s) : className,
)
}
>
<TabsContext
value={useMemo(
() => ({
containerRef,
nested,
}),
[nested],
)}
>
{props.children}
</TabsContext>
</Tabs>
);
}
export function CodeBlockTabsList({ className, ...props }: ComponentProps<typeof TabsList>) {
return (
<TabsList
{...props}
className={(s) =>
cn(
'flex flex-row px-2 overflow-x-auto text-fd-muted-foreground',
typeof className === 'function' ? className(s) : className,
)
}
>
{props.children}
</TabsList>
);
}
export function CodeBlockTabsTrigger({
children,
className,
...props
}: ComponentProps<typeof TabsTrigger>) {
return (
<TabsTrigger
{...props}
className={(s) =>
cn(
'relative group inline-flex text-sm font-medium text-nowrap items-center transition-colors gap-2 px-2 py-1.5 [&_svg]:size-3.5',
s.active ? 'text-fd-primary' : 'hover:text-fd-accent-foreground',
typeof className === 'function' ? className(s) : className,
)
}
>
<div className="absolute inset-x-2 bottom-0 h-px group-data-active:bg-fd-primary" />
{children}
</TabsTrigger>
);
}
export function CodeBlockTab(props: ComponentProps<typeof TabsContent>) {
return <TabsContent {...props} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { cn } from '../lib/cn';
import { GitFork, Star } from 'lucide-react';
import { type ComponentProps, use } from 'react';
export interface FetchRepositoryInfoOptions {
owner: string;
repo: string;
baseUrl?: string;
token?: string;
fetchOptions?: RequestInit;
}
export interface RepositoryInfo {
stars: number;
forks: number;
}
export interface GithubInfoProps extends ComponentProps<'a'>, FetchRepositoryInfoOptions {
locale?: Intl.LocalesArgument;
}
export async function fetchRepositoryInfo({
owner,
repo,
token,
baseUrl = 'https://api.github.com',
fetchOptions = {
// default revalidate options for Next.js (optional)
next: {
revalidate: 60,
},
} as RequestInit,
}: FetchRepositoryInfoOptions): Promise<RepositoryInfo> {
const endpoint = `${baseUrl}/repos/${owner}/${repo}`;
const headers = new Headers(fetchOptions.headers);
headers.set('Content-Type', 'application/json');
if (token) headers.set('Authorization', `Bearer ${token}`);
const response = await fetch(endpoint, {
...fetchOptions,
headers,
} as RequestInit);
if (!response.ok) {
const message = await response.text();
throw new Error(`Failed to fetch repository data: ${message}`);
}
const data = await response.json();
return {
stars: data.stargazers_count,
forks: data.forks_count,
};
}
/**
* Uses compact notation (e.g., 1.5K, 2.3M).
*/
const formatterOptions: Intl.NumberFormatOptions = {
notation: 'compact',
maximumFractionDigits: 1,
};
const defaultFormatter = new Intl.NumberFormat(undefined, formatterOptions);
const promises: Record<string, Promise<RepositoryInfo>> = {};
export function GithubInfo({
repo,
owner,
token,
baseUrl,
fetchOptions,
locale,
...props
}: GithubInfoProps) {
const options: FetchRepositoryInfoOptions = {
repo,
owner,
token,
baseUrl,
fetchOptions,
};
const { stars, forks } = use(
(promises[JSON.stringify(options)] ??= fetchRepositoryInfo(options)),
);
const formatter = locale ? new Intl.NumberFormat(locale, formatterOptions) : defaultFormatter;
return (
<a
href={`https://github.com/${owner}/${repo}`}
rel="noreferrer noopener"
target="_blank"
{...props}
className={cn(
'flex flex-col gap-1.5 p-2 rounded-lg text-sm text-fd-foreground/80 transition-colors hover:text-fd-accent-foreground hover:bg-fd-accent',
props.className,
)}
>
<p className="flex items-center gap-2 truncate">
<svg fill="currentColor" viewBox="0 0 24 24" className="size-3.5">
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
{owner}/{repo}
</p>
<div className="flex text-xs items-center gap-1 text-fd-muted-foreground">
<Star className="size-3" />
<span>{formatter.format(stars)}</span>
<GitFork className="size-3 ms-2" />
<span>{formatter.format(forks)}</span>
</div>
</a>
);
}
+4 -1
View File
@@ -1,12 +1,15 @@
import defaultMdxComponents from "fumadocs-ui/mdx";
import * as TabsComponents from 'fumadocs-ui/components/tabs';
import Image from "next/image";
import type { MDXComponents } from "mdx/types";
import * as Twoslash from 'fumadocs-twoslash/ui';
export function getMDXComponents(components?: MDXComponents) {
return {
...defaultMdxComponents,
img: (props) => <Image {...(props as any)} />,
...TabsComponents,
...Twoslash,
...components,
} satisfies MDXComponents;
}
+200
View File
@@ -0,0 +1,200 @@
'use client';
import * as React from 'react';
import {
type ComponentProps,
createContext,
type ReactNode,
useContext,
useEffect,
useId,
useMemo,
useState,
} from 'react';
import { cn } from '../lib/cn';
import * as Unstyled from './ui/tabs';
type CollectionKey = string | symbol;
export interface TabsProps extends Omit<
ComponentProps<typeof Unstyled.Tabs>,
'value' | 'onValueChange'
> {
/**
* Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs.
*/
items?: string[];
/**
* Shortcut for `defaultValue` when `items` is provided.
*
* @defaultValue 0
*/
defaultIndex?: number;
/**
* Additional label in tabs list when `items` is provided.
*/
label?: ReactNode;
}
const TabsContext = createContext<{
items?: string[];
collection: CollectionKey[];
} | null>(null);
function useTabContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('You must wrap your component in <Tabs>');
return ctx;
}
export function TabsList({ className, ...props }: React.ComponentProps<typeof Unstyled.TabsList>) {
return (
<Unstyled.TabsList
{...props}
className={(s) =>
cn(
'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose',
typeof className === 'function' ? className(s) : className,
)
}
/>
);
}
export function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof Unstyled.TabsTrigger>) {
return (
<Unstyled.TabsTrigger
{...props}
className={(s) =>
cn(
'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active]:border-fd-primary data-[active]:text-fd-primary',
typeof className === 'function' ? className(s) : className,
)
}
/>
);
}
export function Tabs({
ref,
className,
items,
label,
defaultIndex = 0,
defaultValue = items ? escapeValue(items[defaultIndex]) : undefined,
...props
}: TabsProps) {
const [value, setValue] = useState(defaultValue);
const collection = useMemo<CollectionKey[]>(() => [], []);
return (
<Unstyled.Tabs
ref={ref}
className={(s) =>
cn(
'flex flex-col overflow-hidden rounded-xl border bg-fd-secondary my-4',
typeof className === 'function' ? className(s) : className,
)
}
value={value}
onValueChange={(v: string) => {
if (items && !items.some((item) => escapeValue(item) === v)) return;
setValue(v);
}}
{...props}
>
{items && (
<TabsList>
{label && <span className="text-sm font-medium my-auto me-auto">{label}</span>}
{items.map((item) => (
<TabsTrigger key={item} value={escapeValue(item)}>
{item}
</TabsTrigger>
))}
</TabsList>
)}
<TabsContext.Provider value={useMemo(() => ({ items, collection }), [collection, items])}>
{props.children}
</TabsContext.Provider>
</Unstyled.Tabs>
);
}
export interface TabProps extends Omit<ComponentProps<typeof Unstyled.TabsContent>, 'value'> {
/**
* Value of tab, detect from index if unspecified.
*/
value?: string;
}
export function Tab({ value, ...props }: TabProps) {
const { items } = useTabContext();
const resolved =
value ??
// eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change
items?.at(useCollectionIndex());
if (!resolved)
throw new Error(
'Failed to resolve tab `value`, please pass a `value` prop to the Tab component.',
);
return (
<TabsContent value={escapeValue(resolved)} {...props}>
{props.children}
</TabsContent>
);
}
export function TabsContent({
value,
className,
...props
}: ComponentProps<typeof Unstyled.TabsContent>) {
return (
<Unstyled.TabsContent
value={value}
className={(s) =>
cn(
'p-4 text-[0.9375rem] bg-fd-background rounded-xl outline-none prose-no-margin data-[inactive]:hidden [&>figure:only-child]:-m-4 [&>figure:only-child]:border-none',
typeof className === 'function' ? className(s) : className,
)
}
{...props}
>
{props.children}
</Unstyled.TabsContent>
);
}
/**
* Inspired by Headless UI.
*
* Return the index of children, this is made possible by registering the order of render from children using React context.
* This is supposed by work with pre-rendering & pure client-side rendering.
*/
function useCollectionIndex() {
const key = useId();
const { collection } = useTabContext();
useEffect(() => {
return () => {
const idx = collection.indexOf(key);
if (idx !== -1) collection.splice(idx, 1);
};
}, [key, collection]);
if (!collection.includes(key)) collection.push(key);
return collection.indexOf(key);
}
/**
* only escape whitespaces in values in simple mode
*/
function escapeValue(v: string): string {
return v.toLowerCase().replace(/\s/, '-');
}
+28
View File
@@ -0,0 +1,28 @@
import { cva, type VariantProps } from 'class-variance-authority';
const variants = {
primary: 'bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/80',
outline: 'border hover:bg-fd-accent hover:text-fd-accent-foreground',
ghost: 'hover:bg-fd-accent hover:text-fd-accent-foreground',
secondary:
'border bg-fd-secondary text-fd-secondary-foreground hover:bg-fd-accent hover:text-fd-accent-foreground',
} as const;
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md p-2 text-sm font-medium transition-colors duration-100 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fd-ring',
{
variants: {
variant: variants,
// fumadocs use `color` instead of `variant`
color: variants,
size: {
sm: 'gap-1 px-2 py-1.5 text-xs',
icon: 'p-1.5 [&_svg]:size-5',
'icon-sm': 'p-1.5 [&_svg]:size-4.5',
'icon-xs': 'p-1 [&_svg]:size-4',
},
},
},
);
export type ButtonProps = VariantProps<typeof buttonVariants>;
+179
View File
@@ -0,0 +1,179 @@
'use client';
import {
type ComponentProps,
createContext,
use,
useEffectEvent,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Tabs as Primitive } from '@base-ui/react/tabs';
import { mergeRefs } from '../../lib/merge-refs';
type ChangeListener = (v: string) => void;
const listeners = new Map<string, Set<ChangeListener>>();
export interface TabsProps extends ComponentProps<typeof Primitive.Root> {
/**
* Identifier for Sharing value of tabs
*/
groupId?: string;
/**
* Enable persistent
*/
persist?: boolean;
/**
* If true, updates the URL hash based on the tab's id
*/
updateAnchor?: boolean;
onValueChange?: (value: string) => void;
}
const TabsContext = createContext<{
valueToIdMap: Map<string, string>;
/**
* Mounted tab panels, mapped by their value.
*
* Only populated for panels that stay in the DOM (e.g. `keepMounted`), which is
* what allows us to open the tab containing a hash target.
*/
panels: Map<string, HTMLElement>;
} | null>(null);
function useTabContext() {
const ctx = use(TabsContext);
if (!ctx) throw new Error('You must wrap your component in <Tabs>');
return ctx;
}
export const TabsList = Primitive.List;
export const TabsTrigger = Primitive.Tab;
export function Tabs({
ref,
groupId,
persist = false,
updateAnchor = false,
defaultValue,
value: _value,
onValueChange: _onValueChange,
...props
}: TabsProps) {
const tabsRef = useRef<HTMLDivElement>(null);
const valueToIdMap = useMemo(() => new Map<string, string>(), []);
const panels = useMemo(() => new Map<string, HTMLElement>(), []);
const [value, setValue] =
_value === undefined
? // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled
useState(defaultValue)
: // eslint-disable-next-line react-hooks/rules-of-hooks -- not supposed to change controlled/uncontrolled
[_value, useEffectEvent((v: string) => _onValueChange?.(v))];
useLayoutEffect(() => {
if (!groupId) return;
let previous = sessionStorage.getItem(groupId);
if (persist) previous ??= localStorage.getItem(groupId);
if (previous) setValue(previous);
const groupListeners = listeners.get(groupId) ?? new Set();
groupListeners.add(setValue);
listeners.set(groupId, groupListeners);
return () => {
groupListeners.delete(setValue);
};
}, [groupId, persist, setValue]);
useLayoutEffect(() => {
const openFromHash = () => {
const hash = window.location.hash.slice(1);
if (!hash) return;
// hash points to a tab's own anchor id
for (const [value, id] of valueToIdMap.entries()) {
if (id === hash) {
setValue(value);
tabsRef.current?.scrollIntoView();
return;
}
}
// hash points to an element inside a mounted (e.g. `keepMounted`) panel,
// open the tab it belongs to, then scroll to it once the panel is visible.
const target = document.getElementById(hash);
if (!target) return;
for (const [value, panel] of panels.entries()) {
if (!panel.contains(target)) continue;
setValue(value);
requestAnimationFrame(() => target.scrollIntoView());
return;
}
};
openFromHash();
window.addEventListener('hashchange', openFromHash);
return () => window.removeEventListener('hashchange', openFromHash);
}, [setValue, valueToIdMap, panels]);
return (
<Primitive.Root
ref={mergeRefs(ref, tabsRef)}
value={value}
onValueChange={(v: string) => {
if (updateAnchor) {
const id = valueToIdMap.get(v);
if (id) {
window.history.replaceState(null, '', `#${id}`);
}
}
if (groupId) {
const groupListeners = listeners.get(groupId);
if (groupListeners) {
for (const listener of groupListeners) listener(v);
}
sessionStorage.setItem(groupId, v);
if (persist) localStorage.setItem(groupId, v);
} else {
setValue(v);
}
}}
{...props}
>
<TabsContext value={useMemo(() => ({ valueToIdMap, panels }), [valueToIdMap, panels])}>
{props.children}
</TabsContext>
</Primitive.Root>
);
}
export function TabsContent({ value, ref, ...props }: ComponentProps<typeof Primitive.Panel>) {
const { valueToIdMap, panels } = useTabContext();
if (props.id) {
valueToIdMap.set(value, props.id);
}
return (
<Primitive.Panel
ref={mergeRefs(ref, (element) => {
if (element) panels.set(value, element);
else panels.delete(value);
})}
value={value}
{...props}
>
{props.children}
</Primitive.Panel>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type * as React from 'react';
export function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
return (value) => {
refs.forEach((ref) => {
if (typeof ref === 'function') {
ref(value);
} else if (ref) {
ref.current = value;
}
});
};
}
+1 -1
View File
@@ -5,7 +5,7 @@ export const docsContentRoute = '/llms.mdx/docs';
// fill this with your actual GitHub info, for example:
export const gitConfig = {
user: 'duckity',
user: 'duckity-dev',
repo: 'documentation',
branch: 'main',
};
+6 -2
View File
@@ -1,6 +1,7 @@
import { docs } from "collections/server";
import { loader } from "fumadocs-core/source";
import { icons } from "lucide-react";
import { icons as lucide } from "lucide-react";
import * as simple from "@icons-pack/react-simple-icons";
import { createElement } from "react";
import { docsContentRoute, docsImageRoute, docsRoute } from "./shared";
@@ -15,7 +16,10 @@ export const source = loader({
return;
}
if (icon in icons) return createElement(icons[icon as keyof typeof icons]);
if (icon in lucide)
return createElement(lucide[icon as keyof typeof lucide]);
if (icon in simple)
return createElement(simple[icon as keyof typeof simple]);
},
});