diff --git a/.gitignore b/.gitignore index 150de1370..d7fd20052 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,4 @@ yarn-error.log* .fleet # Fumadocs build (TEMPORARY) -fumadocs +# fumadocs diff --git a/convert-themed-images.js b/convert-themed-images.js new file mode 100755 index 000000000..117e88b08 --- /dev/null +++ b/convert-themed-images.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +/** + * Converts ThemedImage components to standard markdown image syntax + * @param {string} content - The file content to process + * @returns {string} - The converted content + */ +function convertThemedImages(content) { + // Regex to match ThemedImage components with optional p tags + const themedImageRegex = + /

\s*\s*<\/p>/g; + + // Also match without p tags + const themedImageNoPTagRegex = + //g; + + // Convert ThemedImage components with p tags + let convertedContent = content.replace( + themedImageRegex, + (match, altText, lightSource, darkSource) => { + return `![${altText}](${lightSource})`; + }, + ); + + // Convert ThemedImage components without p tags + convertedContent = convertedContent.replace( + themedImageNoPTagRegex, + (match, altText, lightSource, darkSource) => { + return `![${altText}](${lightSource})`; + }, + ); + + return convertedContent; +} + +/** + * Process a single file + * @param {string} filePath - Path to the file to process + */ +function processFile(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf8'); + const convertedContent = convertThemedImages(content); + + if (content !== convertedContent) { + fs.writeFileSync(filePath, convertedContent, 'utf8'); + console.log(`✅ Converted: ${filePath}`); + } else { + console.log(`⏭️ No changes needed: ${filePath}`); + } + } catch (error) { + console.error(`❌ Error processing ${filePath}:`, error.message); + } +} + +/** + * Recursively process all .mdx files in a directory + * @param {string} dirPath - Directory path to process + */ +function processDirectory(dirPath) { + try { + const items = fs.readdirSync(dirPath); + + for (const item of items) { + const fullPath = path.join(dirPath, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory()) { + processDirectory(fullPath); + } else if (item.endsWith('.mdx')) { + processFile(fullPath); + } + } + } catch (error) { + console.error(`❌ Error processing directory ${dirPath}:`, error.message); + } +} + +// Main execution +function main() { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log('Usage: node convert-themed-images.js '); + console.log('Examples:'); + console.log( + ' node convert-themed-images.js fumadocs/content/blog/2023-10-02-what-is-a-state-machine/index.mdx', + ); + console.log(' node convert-themed-images.js fumadocs/content/blog/'); + process.exit(1); + } + + const targetPath = args[0]; + + if (!fs.existsSync(targetPath)) { + console.error(`❌ Path does not exist: ${targetPath}`); + process.exit(1); + } + + const stat = fs.statSync(targetPath); + + if (stat.isFile()) { + if (targetPath.endsWith('.mdx')) { + processFile(targetPath); + } else { + console.error('❌ File must be a .mdx file'); + process.exit(1); + } + } else if (stat.isDirectory()) { + console.log(`🔄 Processing directory: ${targetPath}`); + processDirectory(targetPath); + } + + console.log('✨ Conversion complete!'); +} + +// Run the script +main(); diff --git a/fumadocs/.gitignore b/fumadocs/.gitignore new file mode 100644 index 000000000..55a12ae71 --- /dev/null +++ b/fumadocs/.gitignore @@ -0,0 +1,28 @@ +# deps +/node_modules + +# generated content +.contentlayer +.content-collections +.source + +# test & build +/coverage +/.next/ +/out/ +/build +*.tsbuildinfo + +# misc +.DS_Store +*.pem +/.pnp +.pnp.js +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# others +.env*.local +.vercel +next-env.d.ts \ No newline at end of file diff --git a/fumadocs/README.md b/fumadocs/README.md new file mode 100644 index 000000000..70fd7dfd2 --- /dev/null +++ b/fumadocs/README.md @@ -0,0 +1,45 @@ +# fumadocs + +This is a Next.js application generated with +[Create Fumadocs](https://github.com/fuma-nama/fumadocs). + +Run development server: + +```bash +npm run dev +# or +pnpm dev +# or +yarn dev +``` + +Open http://localhost:3000 with your browser to see the result. + +## Explore + +In the project, you can see: + +- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content. +- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep. + +| Route | Description | +| ------------------------- | ------------------------------------------------------ | +| `app/(home)` | The route group for your landing page and other pages. | +| `app/docs` | The documentation layout and pages. | +| `app/api/search/route.ts` | The Route Handler for search. | + +### Fumadocs MDX + +A `source.config.ts` config file has been included, you can customise different options like frontmatter schema. + +Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details. + +## Learn More + +To learn more about Next.js and Fumadocs, take a look at the following +resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js + features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs diff --git a/fumadocs/app/(home)/layout.tsx b/fumadocs/app/(home)/layout.tsx new file mode 100644 index 000000000..b8b7d1b90 --- /dev/null +++ b/fumadocs/app/(home)/layout.tsx @@ -0,0 +1,77 @@ +import { baseOptions } from '@/lib/layout.shared'; +import type { ReactNode } from 'react'; +import { HomeLayout } from 'fumadocs-ui/layouts/home'; +import { TelescopeIcon } from 'lucide-react'; + +export default function Layout({ children }: { children: ReactNode }) { + return ( + , + }, + { + text: 'Blog', + url: '/blog', + active: 'nested-url', + }, + { + type: 'icon', + text: 'Visit XState GitHub repository', + icon: ( + + + + + ), + url: 'https://github.com/statelyai/xstate', + external: true, + }, + ]} + > + {children} + + ); +} diff --git a/fumadocs/app/(home)/page.tsx b/fumadocs/app/(home)/page.tsx new file mode 100644 index 000000000..4393163a5 --- /dev/null +++ b/fumadocs/app/(home)/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function HomePage() { + redirect('/docs'); +} diff --git a/fumadocs/app/api/chat/route.ts b/fumadocs/app/api/chat/route.ts new file mode 100644 index 000000000..9952728f6 --- /dev/null +++ b/fumadocs/app/api/chat/route.ts @@ -0,0 +1,30 @@ +import { ProvideLinksToolSchema } from '../../../lib/inkeep-qa-schema'; +import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; +import { convertToModelMessages, streamText } from 'ai'; + +export const runtime = 'edge'; + +const openai = createOpenAICompatible({ + name: 'inkeep', + apiKey: process.env.INKEEP_API_KEY, + baseURL: 'https://api.inkeep.com/v1', +}); + +export async function POST(req: Request) { + const reqJson = await req.json(); + + const result = streamText({ + model: openai('inkeep-qa-sonnet-4'), + tools: { + provideLinks: { + inputSchema: ProvideLinksToolSchema, + }, + }, + messages: convertToModelMessages(reqJson.messages, { + ignoreIncompleteToolCalls: true, + }), + toolChoice: 'auto', + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/fumadocs/app/api/search/route.ts b/fumadocs/app/api/search/route.ts new file mode 100644 index 000000000..7ba7e8231 --- /dev/null +++ b/fumadocs/app/api/search/route.ts @@ -0,0 +1,7 @@ +import { source } from '@/lib/source'; +import { createFromSource } from 'fumadocs-core/search/server'; + +export const { GET } = createFromSource(source, { + // https://docs.orama.com/docs/orama-js/supported-languages + language: 'english', +}); diff --git a/fumadocs/app/blog/[slug]/page.client.tsx b/fumadocs/app/blog/[slug]/page.client.tsx new file mode 100644 index 000000000..d88f56be3 --- /dev/null +++ b/fumadocs/app/blog/[slug]/page.client.tsx @@ -0,0 +1,24 @@ +'use client'; +import { Check, Share } from 'lucide-react'; +import { cn } from '@/lib/cn'; +import { buttonVariants } from '@/components/ui/button'; +import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; + +export function Control({ url }: { url: string }) { + const [isChecked, onCopy] = useCopyButton(() => { + void navigator.clipboard.writeText(`${window.location.origin}${url}`); + }); + + return ( + + ); +} diff --git a/fumadocs/app/blog/[slug]/page.tsx b/fumadocs/app/blog/[slug]/page.tsx new file mode 100644 index 000000000..1a3fcb702 --- /dev/null +++ b/fumadocs/app/blog/[slug]/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from 'next'; +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; +import { blog } from '@/lib/source'; +import { buttonVariants } from '@/components/ui/button'; +import { Control } from '@/app/blog/[slug]/page.client'; +import { getMDXComponents } from '@/mdx-components'; +import path from 'node:path'; + +export default async function Page(props: PageProps<'/blog/[slug]'>) { + const params = await props.params; + const page = blog.getPage([params.slug]); + + if (!page) notFound(); + const { body: Mdx, toc } = page.data; + + return ( + <> +

+

+ {page.data.title} +

+

{page.data.description}

+ + Back + +
+
+
+ + +
+
+
+

Written by

+

{page.data.authors.join(', ')}

+
+
+

At

+

+ {new Date( + page.data.date ?? + path.basename(page.path, path.extname(page.path)), + ).toDateString()} +

+
+ +
+
+ + ); +} + +export async function generateMetadata( + props: PageProps<'/blog/[slug]'>, +): Promise { + const params = await props.params; + const page = blog.getPage([params.slug]); + + if (!page) notFound(); + + return { + title: page.data.title, + description: + page.data.description ?? 'The library for building documentation sites', + }; +} + +export function generateStaticParams(): { slug: string }[] { + return blog.getPages().map((page) => ({ + slug: page.slugs[0], + })); +} diff --git a/fumadocs/app/blog/layout.tsx b/fumadocs/app/blog/layout.tsx new file mode 100644 index 000000000..8e9ec7226 --- /dev/null +++ b/fumadocs/app/blog/layout.tsx @@ -0,0 +1,11 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; +import { source } from '@/lib/source'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return ( + + {children} + + ); +} diff --git a/fumadocs/app/blog/page.tsx b/fumadocs/app/blog/page.tsx new file mode 100644 index 000000000..3cba584c3 --- /dev/null +++ b/fumadocs/app/blog/page.tsx @@ -0,0 +1,64 @@ +import Link from 'next/link'; +import { blog } from '@/lib/source'; + +export default function Page() { + const posts = [...blog.getPages()].sort( + (a, b) => + new Date(b.data.date ?? b.file.name).getTime() - + new Date(a.data.date ?? a.file.name).getTime(), + ); + + const svg = ` + + + + + +`; + + return ( +
+
+

+ Stately Blog +

+

+ The latest news and updates from the Stately team +

+
+
+ {posts.map((post) => ( + +

{post.data.title}

+

+ {post.data.description} +

+ +

+ {new Date(post.data.date ?? post.file.name).toDateString()} +

+ + ))} +
+
+ ); +} diff --git a/fumadocs/app/docs/[[...slug]]/page.tsx b/fumadocs/app/docs/[[...slug]]/page.tsx new file mode 100644 index 000000000..357971e55 --- /dev/null +++ b/fumadocs/app/docs/[[...slug]]/page.tsx @@ -0,0 +1,62 @@ +import { getPageImage, source } from '@/lib/source'; +import { + DocsBody, + DocsDescription, + DocsPage, + DocsTitle, +} from 'fumadocs-ui/page'; +import { notFound } from 'next/navigation'; +import { getMDXComponents } from '@/mdx-components'; +import type { Metadata } from 'next'; +import { createRelativeLink } from 'fumadocs-ui/mdx'; +import { LLMCopyButton, ViewOptions } from '@/components/page-actions'; + +export default async function Page(props: PageProps<'/docs/[[...slug]]'>) { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + const MDX = page.data.body; + + return ( + + {page.data.title} + {page.data.description} + +
+ + +
+ +
+
+ ); +} + +export async function generateStaticParams() { + return source.generateParams(); +} + +export async function generateMetadata( + props: PageProps<'/docs/[[...slug]]'>, +): Promise { + const params = await props.params; + const page = source.getPage(params.slug); + if (!page) notFound(); + + return { + title: page.data.title, + description: page.data.description, + openGraph: { + images: getPageImage(page).url, + }, + }; +} diff --git a/fumadocs/app/docs/layout.tsx b/fumadocs/app/docs/layout.tsx new file mode 100644 index 000000000..bac1433aa --- /dev/null +++ b/fumadocs/app/docs/layout.tsx @@ -0,0 +1,576 @@ +import { DocsLayout } from 'fumadocs-ui/layouts/docs'; +import { baseOptions } from '@/lib/layout.shared'; + +export default function Layout({ children }: LayoutProps<'/docs'>) { + return ( + + {children} + + ); +} diff --git a/fumadocs/app/global.css b/fumadocs/app/global.css new file mode 100644 index 000000000..b6b46defd --- /dev/null +++ b/fumadocs/app/global.css @@ -0,0 +1,85 @@ +@import 'tailwindcss'; +@import 'fumadocs-ui/css/neutral.css'; +@import 'fumadocs-ui/css/preset.css'; +@import 'fumadocs-twoslash/twoslash.css'; + +@theme { + /* Light mode - matching original custom.css colors */ + --color-fd-background: white; + --color-fd-foreground: hsl(240, 4.3%, 17.6%); /* #2d2e34 - st-gray-800 */ + --color-fd-muted: hsl(240, 5.9%, 94.1%); /* #efeff1 - st-gray-50 */ + --color-fd-muted-foreground: hsl( + 240, + 3.8%, + 40.4% + ); /* #5d5f6a - st-gray-600 */ + --color-fd-popover: white; + --color-fd-popover-foreground: hsl( + 240, + 4.3%, + 17.6% + ); /* #2d2e34 - st-gray-800 */ + --color-fd-card: hsl(240, 5.9%, 94.1%); /* #efeff1 - st-gray-50 */ + --color-fd-card-foreground: hsl(240, 4.3%, 17.6%); /* #2d2e34 - st-gray-800 */ + --color-fd-border: hsl(240, 5.9%, 88.2%); /* #e1e2e5 - st-gray-100 */ + --color-fd-primary: hsl(240, 5.1%, 8.2%); /* #15151d - st-gray-900 */ + --color-fd-primary-foreground: white; + --color-fd-secondary: hsl(240, 5.9%, 94.1%); /* #efeff1 - st-gray-50 */ + --color-fd-secondary-foreground: hsl( + 240, + 4.3%, + 17.6% + ); /* #2d2e34 - st-gray-800 */ + --color-fd-accent: hsl(240, 5.9%, 88.2%); /* #e1e2e5 - st-gray-100 */ + --color-fd-accent-foreground: hsl( + 240, + 4.3%, + 17.6% + ); /* #2d2e34 - st-gray-800 */ + --color-fd-ring: hsl(240, 3.7%, 46.1%); /* #757785 - st-gray-500 */ +} +.dark { + /* Dark mode - matching original custom.css colors */ + --color-fd-background: #15151d; /* #15151d - st-gray-900 */ + --color-fd-foreground: hsl(240, 4.9%, 78.8%); /* #c6c7cd - st-gray-200 */ + --color-fd-muted: hsl(240, 4.3%, 17.6%); /* #2d2e34 - st-gray-800 */ + --color-fd-muted-foreground: hsl( + 240, + 3.7%, + 58.2% + ); /* #8f919d - st-gray-400 */ + --color-fd-popover: hsl(240, 4.3%, 17.6%); /* #2d2e34 - st-gray-800 */ + --color-fd-popover-foreground: hsl( + 240, + 4.9%, + 78.8% + ); /* #c6c7cd - st-gray-200 */ + --color-fd-card: #15151d; /* #2d2e34 - st-gray-800 */ + --color-fd-card-foreground: hsl(240, 5.9%, 96.1%); /* #efeff1 - st-gray-50 */ + --color-fd-border: hsl(240, 3.4%, 28.6%); /* #45464f - st-gray-700 */ + --color-fd-primary: hsl(240, 5.9%, 96.1%); /* #efeff1 - st-gray-50 */ + --color-fd-primary-foreground: hsl( + 240, + 5.1%, + 8.2% + ); /* #15151d - st-gray-900 */ + --color-fd-secondary: hsl(240, 4.3%, 17.6%); /* #2d2e34 - st-gray-800 */ + --color-fd-secondary-foreground: hsl( + 240, + 4.9%, + 78.8% + ); /* #c6c7cd - st-gray-200 */ + --color-fd-accent: hsl(240, 3.4%, 28.6%); /* #45464f - st-gray-700 */ + --color-fd-accent-foreground: hsl( + 240, + 5.9%, + 96.1% + ); /* #efeff1 - st-gray-50 */ + --color-fd-ring: hsl(240, 3.7%, 46.1%); /* #757785 - st-gray-500 */ +} + +.prose { + svg { + display: inline-block; + } +} diff --git a/fumadocs/app/layout.tsx b/fumadocs/app/layout.tsx new file mode 100644 index 000000000..0b9d76415 --- /dev/null +++ b/fumadocs/app/layout.tsx @@ -0,0 +1,17 @@ +import '@/app/global.css'; +import { RootProvider } from 'fumadocs-ui/provider/next'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ + subsets: ['latin'], +}); + +export default function Layout({ children }: LayoutProps<'/'>) { + return ( + + + {children} + + + ); +} diff --git a/fumadocs/app/llms-full.txt/route.ts b/fumadocs/app/llms-full.txt/route.ts new file mode 100644 index 000000000..d494d2cbb --- /dev/null +++ b/fumadocs/app/llms-full.txt/route.ts @@ -0,0 +1,10 @@ +import { getLLMText, source } from '@/lib/source'; + +export const revalidate = false; + +export async function GET() { + const scan = source.getPages().map(getLLMText); + const scanned = await Promise.all(scan); + + return new Response(scanned.join('\n\n')); +} diff --git a/fumadocs/app/llms.mdx/[[...slug]]/route.ts b/fumadocs/app/llms.mdx/[[...slug]]/route.ts new file mode 100644 index 000000000..c029b0f44 --- /dev/null +++ b/fumadocs/app/llms.mdx/[[...slug]]/route.ts @@ -0,0 +1,24 @@ +import { getLLMText } from '@/lib/get-llm-text'; +import { source } from '@/lib/source'; +import { notFound } from 'next/navigation'; + +export const revalidate = false; + +export async function GET( + _req: Request, + { params }: RouteContext<'/llms.mdx/[[...slug]]'>, +) { + const { slug } = await params; + const page = source.getPage(slug); + if (!page) notFound(); + + return new Response(await getLLMText(page), { + headers: { + 'Content-Type': 'text/markdown', + }, + }); +} + +export function generateStaticParams() { + return source.generateParams(); +} diff --git a/fumadocs/app/middleware.ts b/fumadocs/app/middleware.ts new file mode 100644 index 000000000..826a697b0 --- /dev/null +++ b/fumadocs/app/middleware.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { isMarkdownPreferred, rewritePath } from 'fumadocs-core/negotiation'; +const { rewrite: rewriteLLM } = rewritePath('/docs/*path', '/llms.mdx/*path'); +export function middleware(request: NextRequest) { + if (isMarkdownPreferred(request)) { + const result = rewriteLLM(request.nextUrl.pathname); + if (result) { + return NextResponse.rewrite(new URL(result, request.nextUrl)); + } + } + return NextResponse.next(); +} diff --git a/fumadocs/app/og/docs/[...slug]/route.tsx b/fumadocs/app/og/docs/[...slug]/route.tsx new file mode 100644 index 000000000..f5df96dbc --- /dev/null +++ b/fumadocs/app/og/docs/[...slug]/route.tsx @@ -0,0 +1,36 @@ +import { getPageImage, source } from '@/lib/source'; +import { notFound } from 'next/navigation'; +import { ImageResponse } from 'next/og'; +import { generate as DefaultImage } from 'fumadocs-ui/og'; + +export const revalidate = false; + +export async function GET( + _req: Request, + { params }: RouteContext<'/og/docs/[...slug]'>, +) { + const { slug } = await params; + const page = source.getPage(slug.slice(0, -1)); + if (!page) notFound(); + + return new ImageResponse( + ( + + ), + { + width: 1200, + height: 630, + }, + ); +} + +export function generateStaticParams() { + return source.getPages().map((page) => ({ + lang: page.locale, + slug: getPageImage(page).segments, + })); +} diff --git a/fumadocs/cli.json b/fumadocs/cli.json new file mode 100644 index 000000000..15844394d --- /dev/null +++ b/fumadocs/cli.json @@ -0,0 +1,11 @@ +{ + "aliases": { + "uiDir": "./components/ui", + "componentsDir": "./components", + "blockDir": "./components", + "cssDir": "./styles", + "libDir": "./lib" + }, + "baseDir": "", + "commands": {} +} \ No newline at end of file diff --git a/fumadocs/components/markdown.tsx b/fumadocs/components/markdown.tsx new file mode 100644 index 000000000..7c2cddc3d --- /dev/null +++ b/fumadocs/components/markdown.tsx @@ -0,0 +1,119 @@ +import { remark } from 'remark'; +import remarkGfm from 'remark-gfm'; +import remarkRehype from 'remark-rehype'; +import { toJsxRuntime } from 'hast-util-to-jsx-runtime'; +import { + Children, + type ComponentProps, + type ReactElement, + type ReactNode, + Suspense, + use, + useDeferredValue, +} from 'react'; +import { Fragment, jsx, jsxs } from 'react/jsx-runtime'; +import { DynamicCodeBlock } from 'fumadocs-ui/components/dynamic-codeblock'; +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import { visit } from 'unist-util-visit'; +import type { ElementContent, Root, RootContent } from 'hast'; + +export interface Processor { + process: (content: string) => Promise; +} + +export function rehypeWrapWords() { + return (tree: Root) => { + visit(tree, ['text', 'element'], (node, index, parent) => { + if (node.type === 'element' && node.tagName === 'pre') return 'skip'; + if (node.type !== 'text' || !parent || index === undefined) return; + + const words = node.value.split(/(?=\s)/); + + // Create new span nodes for each word and whitespace + const newNodes: ElementContent[] = words.flatMap((word) => { + if (word.length === 0) return []; + + return { + type: 'element', + tagName: 'span', + properties: { + class: 'animate-fd-fade-in', + }, + children: [{ type: 'text', value: word }], + }; + }); + + Object.assign(node, { + type: 'element', + tagName: 'span', + properties: {}, + children: newNodes, + } satisfies RootContent); + return 'skip'; + }); + }; +} + +function createProcessor(): Processor { + const processor = remark() + .use(remarkGfm) + .use(remarkRehype) + .use(rehypeWrapWords); + + return { + async process(content) { + const nodes = processor.parse({ value: content }); + const hast = await processor.run(nodes); + + return toJsxRuntime(hast, { + development: false, + jsx, + jsxs, + Fragment, + components: { + ...defaultMdxComponents, + pre: Pre, + img: undefined, // use JSX + }, + }); + }, + }; +} + +function Pre(props: ComponentProps<'pre'>) { + const code = Children.only(props.children) as ReactElement; + const codeProps = code.props as ComponentProps<'code'>; + const content = codeProps.children; + if (typeof content !== 'string') return null; + + let lang = + codeProps.className + ?.split(' ') + .find((v) => v.startsWith('language-')) + ?.slice('language-'.length) ?? 'text'; + + if (lang === 'mdx') lang = 'md'; + + return ; +} + +const processor = createProcessor(); + +export function Markdown({ text }: { text: string }) { + const deferredText = useDeferredValue(text); + + return ( + {text}

}> + +
+ ); +} + +const cache = new Map>(); + +function Renderer({ text }: { text: string }) { + const result = cache.get(text) ?? processor.process(text); + cache.set(text, result); + + return use(result); +} diff --git a/fumadocs/components/page-actions.tsx b/fumadocs/components/page-actions.tsx new file mode 100644 index 000000000..63ccf70cd --- /dev/null +++ b/fumadocs/components/page-actions.tsx @@ -0,0 +1,247 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { + Check, + ChevronDown, + Copy, + ExternalLinkIcon, + MessageCircleIcon, +} from 'lucide-react'; +import { cn } from '../lib/cn'; +import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'; +import { buttonVariants } from './ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from 'fumadocs-ui/components/ui/popover'; +import { cva } from 'class-variance-authority'; + +const cache = new Map(); + +export function LLMCopyButton({ + /** + * A URL to fetch the raw Markdown/MDX content of page + */ + markdownUrl, +}: { + markdownUrl: string; +}) { + const [isLoading, setLoading] = useState(false); + const [checked, onClick] = useCopyButton(async () => { + const cached = cache.get(markdownUrl); + if (cached) return navigator.clipboard.writeText(cached); + + setLoading(true); + + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': fetch(markdownUrl).then(async (res) => { + const content = await res.text(); + cache.set(markdownUrl, content); + + return content; + }), + }), + ]); + } finally { + setLoading(false); + } + }); + + return ( + + ); +} + +const optionVariants = cva( + 'text-sm p-2 rounded-lg inline-flex items-center gap-2 hover:text-fd-accent-foreground hover:bg-fd-accent [&_svg]:size-4', +); + +export function ViewOptions({ + markdownUrl, + githubUrl, +}: { + /** + * A URL to the raw Markdown/MDX content of page + */ + markdownUrl: string; + + /** + * Source file URL on GitHub + */ + githubUrl: string; +}) { + const items = useMemo(() => { + const fullMarkdownUrl = + typeof window !== 'undefined' + ? new URL(markdownUrl, window.location.origin) + : 'loading'; + const q = `Read ${fullMarkdownUrl}, I want to ask questions about it.`; + + return [ + { + title: 'Open in GitHub', + href: githubUrl, + icon: ( + + GitHub + + + ), + }, + { + title: 'Open in Scira AI', + href: `https://scira.ai/?${new URLSearchParams({ + q, + })}`, + icon: ( + + Scira AI + + + + + + + + + ), + }, + { + title: 'Open in ChatGPT', + href: `https://chatgpt.com/?${new URLSearchParams({ + hints: 'search', + q, + })}`, + icon: ( + + OpenAI + + + ), + }, + { + title: 'Open in Claude', + href: `https://claude.ai/new?${new URLSearchParams({ + q, + })}`, + icon: ( + + Anthropic + + + ), + }, + { + title: 'Open in T3 Chat', + href: `https://t3.chat/new?${new URLSearchParams({ + q, + })}`, + icon: , + }, + ]; + }, [githubUrl, markdownUrl]); + + return ( + + + Open + + + + {items.map((item) => ( + + {item.icon} + {item.title} + + + ))} + + + ); +} diff --git a/fumadocs/components/search.tsx b/fumadocs/components/search.tsx new file mode 100644 index 000000000..42ab84d10 --- /dev/null +++ b/fumadocs/components/search.tsx @@ -0,0 +1,385 @@ +'use client'; +import { RemoveScroll } from 'react-remove-scroll'; +import { + type ComponentProps, + createContext, + type SyntheticEvent, + use, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Loader2, RefreshCw, SearchIcon, Send, X } from 'lucide-react'; +import { cn } from '../lib/cn'; +import { buttonVariants } from './ui/button'; +import Link from 'fumadocs-core/link'; +import { type UIMessage, useChat, type UseChatHelpers } from '@ai-sdk/react'; +import type { ProvideLinksToolSchema } from '../lib/inkeep-qa-schema'; +import type { z } from 'zod'; +import { DefaultChatTransport } from 'ai'; +import { Markdown } from './markdown'; +import { Presence } from '@radix-ui/react-presence'; + +const Context = createContext<{ + open: boolean; + setOpen: (open: boolean) => void; + chat: UseChatHelpers; +} | null>(null); + +function useChatContext() { + return use(Context)!.chat; +} + +function SearchAIActions() { + const { messages, status, setMessages, regenerate } = useChatContext(); + const isLoading = status === 'streaming'; + + if (messages.length === 0) return null; + + return ( + <> + {!isLoading && messages.at(-1)?.role === 'assistant' && ( + + )} + + + ); +} + +function SearchAIInput(props: ComponentProps<'form'>) { + const { status, sendMessage, stop } = useChatContext(); + const [input, setInput] = useState(''); + const isLoading = status === 'streaming' || status === 'submitted'; + const onStart = (e?: SyntheticEvent) => { + e?.preventDefault(); + void sendMessage({ text: input }); + setInput(''); + }; + + useEffect(() => { + if (isLoading) document.getElementById('nd-ai-input')?.focus(); + }, [isLoading]); + + return ( +
+ { + setInput(e.target.value); + }} + onKeyDown={(event) => { + if (!event.shiftKey && event.key === 'Enter') { + onStart(event); + } + }} + /> + {isLoading ? ( + + ) : ( + + )} +
+ ); +} + +function List(props: Omit, 'dir'>) { + const containerRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + function callback() { + const container = containerRef.current; + if (!container) return; + + container.scrollTo({ + top: container.scrollHeight, + behavior: 'instant', + }); + } + + const observer = new ResizeObserver(callback); + callback(); + + const element = containerRef.current?.firstElementChild; + + if (element) { + observer.observe(element); + } + + return () => { + observer.disconnect(); + }; + }, []); + + return ( +
+ {props.children} +
+ ); +} + +function Input(props: ComponentProps<'textarea'>) { + const ref = useRef(null); + const shared = cn('col-start-1 row-start-1', props.className); + + return ( +
+