diff --git a/packages/web/app/api/search/route.ts b/packages/web/app/api/search/route.ts index e3a708d..1566ed6 100644 --- a/packages/web/app/api/search/route.ts +++ b/packages/web/app/api/search/route.ts @@ -1,4 +1,19 @@ import { createFromSource } from "fumadocs-core/search/server" import { source } from "@/lib/source" -export const { GET } = createFromSource(source) +const search = createFromSource(source) +const cache = "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800" + +export const runtime = "nodejs" + +export async function GET(request: Request): Promise { + const response = await search.GET(request) + const headers = new Headers(response.headers) + headers.set("cache-control", cache) + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) +} diff --git a/packages/web/app/apple-icon.png b/packages/web/app/apple-icon.png new file mode 100644 index 0000000..5d79508 Binary files /dev/null and b/packages/web/app/apple-icon.png differ diff --git a/packages/web/app/docs.md/route.ts b/packages/web/app/docs.md/route.ts new file mode 100644 index 0000000..52a3d5c --- /dev/null +++ b/packages/web/app/docs.md/route.ts @@ -0,0 +1,53 @@ +import { readdir, readFile } from "node:fs/promises" +import { join } from "node:path" + +export const runtime = "nodejs" +export const dynamic = "force-static" +export const revalidate = false + +function roots(): string[] { + return [join(process.cwd(), "content/docs"), join(process.cwd(), "packages/web/content/docs")] +} + +async function files(): Promise { + for (const root of roots()) { + try { + const list = await readdir(root) + const out = list.filter((item) => item.endsWith(".mdx")).sort() + if (out.length > 0) return out.map((item) => join(root, item)) + } catch {} + } + + return [] +} + +function clean(text: string): string { + return text.replace(/^---[\s\S]*?---\s*/m, "").trim() +} + +function name(path: string): string { + const item = path.split("/").pop() ?? "doc" + return item.replace(/\.mdx$/, "") +} + +export async function GET(): Promise { + const paths = await files() + const chunks: string[] = ["# cruel docs", ""] + + for (const path of paths) { + const raw = await readFile(path, "utf8") + const key = name(path) + const title = key === "index" ? "getting started" : key + chunks.push(`## ${title}`) + chunks.push("") + chunks.push(clean(raw)) + chunks.push("") + } + + return new Response(chunks.join("\n"), { + headers: { + "content-type": "text/markdown; charset=utf-8", + "cache-control": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", + }, + }) +} diff --git a/packages/web/app/docs/[[...slug]]/page.tsx b/packages/web/app/docs/[[...slug]]/page.tsx index ca14884..1e45e27 100644 --- a/packages/web/app/docs/[[...slug]]/page.tsx +++ b/packages/web/app/docs/[[...slug]]/page.tsx @@ -1,9 +1,42 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" import defaultMdxComponents from "fumadocs-ui/mdx" import { DocsBody, DocsPage } from "fumadocs-ui/page" import type { MDXContent } from "mdx/types" import { notFound } from "next/navigation" +import { Anchor } from "@/components/anchor" +import { Copy } from "@/components/copy" import { source } from "@/lib/source" +export const dynamic = "force-static" +export const dynamicParams = false +export const revalidate = false + +function clean(text: string): string { + return text.replace(/^---[\s\S]*?---\s*/m, "").trim() +} + +function name(slug?: string[]): string { + if (!slug || slug.length === 0) return "index" + return slug.join("/") +} + +async function markdown(slug?: string[]): Promise { + const file = `${name(slug)}.mdx` + const paths = [ + join(process.cwd(), "content/docs", file), + join(process.cwd(), "packages/web/content/docs", file), + ] + + for (const path of paths) { + try { + return clean(await readFile(path, "utf8")) + } catch {} + } + + return "" +} + export default async function Page(props: { params: Promise<{ slug?: string[] }> }) { const params = await props.params const page = source.getPage(params.slug) @@ -17,9 +50,20 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> const resolved = data.load ? await data.load() : data const MDX = resolved.body + const text = await markdown(params.slug) return ( - + , + }} + tableOfContentPopover={{ + style: "clerk", + }} + > +

{page.data.title}

{page.data.description}

diff --git a/packages/web/app/docs/docs.css b/packages/web/app/docs/docs.css index 6f8c00e..9c2ad9e 100644 --- a/packages/web/app/docs/docs.css +++ b/packages/web/app/docs/docs.css @@ -23,8 +23,7 @@ left: 20px !important; } -.docs-panel [data-radix-scroll-area-viewport], -.docs-panel [style] { +.docs-panel [data-radix-scroll-area-viewport] { mask-image: none !important; -webkit-mask-image: none !important; } @@ -35,6 +34,38 @@ -webkit-mask: none !important; } +.docs-panel #nd-toc [style*="--fd-top"] { + background: rgba(255, 255, 255, 0.22) !important; +} + +.docs-panel .prose :is(h2, h3, h4, h5, h6) > a.peer { + display: inline-flex; + align-items: center; + position: relative; + padding-inline-end: 14px; + margin-inline-end: -14px; +} + +.docs-panel .prose :is(h2, h3, h4, h5, h6) > svg { + pointer-events: auto; + cursor: pointer; + transition: opacity 140ms ease; +} + +.docs-panel .prose :is(h2, h3, h4, h5, h6) > a.peer[data-copied="true"] + svg { + opacity: 1 !important; + color: rgba(255, 255, 255, 0.88); +} + +.docs-panel .prose :is(h2, h3, h4, h5, h6)[data-lock="true"] > svg { + opacity: 0 !important; + transition: none !important; +} + +.docs-panel .prose :is(h2, h3, h4, h5, h6):hover:not([data-lock="true"]) > svg { + opacity: 1 !important; +} + [data-state="open"][class*="backdrop-blur"], [data-state="closed"][class*="backdrop-blur"] { background: rgba(0, 0, 0, 0.6) !important; diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css index 47c7790..d1f2ebc 100644 --- a/packages/web/app/globals.css +++ b/packages/web/app/globals.css @@ -69,3 +69,14 @@ radial-gradient(140% 120% at 90% 90%, rgba(140, 100, 100, 0.03) 0%, transparent 55%); background-color: #0a0a0a; } + +[class*="shadow-2xl"] [class*="max-h-[460px]"][class*="overflow-y-auto"] { + scrollbar-width: none; + -ms-overflow-style: none; +} + +[class*="shadow-2xl"] [class*="max-h-[460px]"][class*="overflow-y-auto"]::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index e80611a..4dd83d1 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -1,26 +1,43 @@ import { RootProvider } from "fumadocs-ui/provider/next" import { GeistMono } from "geist/font/mono" -import type { Metadata } from "next" +import type { Metadata, Viewport } from "next" import "./globals.css" export const metadata: Metadata = { title: "cruel", description: "chaos testing with zero mercy", metadataBase: new URL("https://cruel.dev"), + icons: { + icon: "/icon.svg", + apple: "/apple-icon.png", + }, openGraph: { title: "cruel", description: "chaos testing with zero mercy", url: "https://cruel.dev", siteName: "cruel", type: "website", + images: [ + { + url: "/og.png", + width: 1200, + height: 630, + alt: "cruel", + }, + ], }, twitter: { - card: "summary", + card: "summary_large_image", title: "cruel", description: "chaos testing with zero mercy", + images: ["/og.png"], }, } +export const viewport: Viewport = { + themeColor: "#0a0a0a", +} + export default function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/packages/web/app/llms.txt/route.ts b/packages/web/app/llms.txt/route.ts new file mode 100644 index 0000000..9bd6620 --- /dev/null +++ b/packages/web/app/llms.txt/route.ts @@ -0,0 +1,56 @@ +import { readdir } from "node:fs/promises" +import { join } from "node:path" + +export const runtime = "nodejs" +export const dynamic = "force-static" +export const revalidate = false + +function roots(): string[] { + return [join(process.cwd(), "content/docs"), join(process.cwd(), "packages/web/content/docs")] +} + +async function pages(): Promise { + for (const root of roots()) { + try { + const list = await readdir(root) + return list + .filter((item) => item.endsWith(".mdx")) + .map((item) => item.replace(/\.mdx$/, "")) + .sort() + } catch {} + } + + return [] +} + +function url(name: string): string { + if (name === "index") return "https://cruel.dev/docs" + return `https://cruel.dev/docs/${name}` +} + +export async function GET(): Promise { + const list = await pages() + const lines: string[] = [ + "project: cruel", + "site: https://cruel.dev", + "summary: chaos engineering for ai sdk and async apis", + "", + "docs_markdown: https://cruel.dev/docs.md", + "docs_root: https://cruel.dev/docs", + "", + "docs_pages:", + ] + + for (const item of list) { + lines.push(`- ${url(item)}`) + } + + lines.push("") + + return new Response(lines.join("\n"), { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800", + }, + }) +} diff --git a/packages/web/app/page.tsx b/packages/web/app/page.tsx index 2548240..3ae5101 100644 --- a/packages/web/app/page.tsx +++ b/packages/web/app/page.tsx @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises" +import { join } from "node:path" import Link from "next/link" const code = `import { cruel } from "cruel" @@ -10,7 +12,29 @@ const api = cruel(fetch, { const res = await api("https://api.example.com")` -export default function Page() { +async function version(): Promise { + const paths = [ + join(process.cwd(), "../cruel/package.json"), + join(process.cwd(), "packages/cruel/package.json"), + ] + + for (const path of paths) { + try { + const file = await readFile(path, "utf8") + const data: unknown = JSON.parse(file) + if (typeof data === "object" && data !== null) { + const value = Reflect.get(data, "version") + if (typeof value === "string" && value.length > 0) return `v${value}` + } + } catch {} + } + + return "v0.0.0" +} + +export default async function Page() { + const tag = await version() + return (
@@ -81,7 +105,7 @@ export default function Page() {
-
v1.0.1
+
{tag}
$ bun add cruel diff --git a/packages/web/app/robots.ts b/packages/web/app/robots.ts new file mode 100644 index 0000000..6f99087 --- /dev/null +++ b/packages/web/app/robots.ts @@ -0,0 +1,11 @@ +import type { MetadataRoute } from "next" + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: "https://cruel.dev/sitemap.xml", + } +} diff --git a/packages/web/app/sitemap.ts b/packages/web/app/sitemap.ts new file mode 100644 index 0000000..e91f7b7 --- /dev/null +++ b/packages/web/app/sitemap.ts @@ -0,0 +1,20 @@ +import type { MetadataRoute } from "next" +import { source } from "@/lib/source" + +export default function sitemap(): MetadataRoute.Sitemap { + const base = "https://cruel.dev" + const time = new Date() + const set = new Set([`${base}/`, `${base}/docs`, `${base}/story`]) + + for (const item of source.generateParams()) { + const slug = item.slug?.join("/") + set.add(slug ? `${base}/docs/${slug}` : `${base}/docs`) + } + + return Array.from(set).map((url) => ({ + url, + lastModified: time, + changeFrequency: "weekly", + priority: url === `${base}/` ? 1 : 0.8, + })) +} diff --git a/packages/web/components/anchor.tsx b/packages/web/components/anchor.tsx new file mode 100644 index 0000000..4ab5699 --- /dev/null +++ b/packages/web/components/anchor.tsx @@ -0,0 +1,96 @@ +"use client" + +import { useEffect } from "react" + +export function Anchor() { + useEffect(() => { + const page = document.getElementById("nd-page") + if (!(page instanceof HTMLElement)) return + const root = page + const timers = new WeakMap() + const icons = new WeakMap() + + function find(node: Element): HTMLAnchorElement | null { + const direct = node.closest("h2 > a.peer, h3 > a.peer, h4 > a.peer, h5 > a.peer, h6 > a.peer") + if (direct instanceof HTMLAnchorElement) return direct + + const icon = node.closest("h2 > svg, h3 > svg, h4 > svg, h5 > svg, h6 > svg") + if (!(icon instanceof SVGSVGElement)) return null + + const sibling = icon.previousElementSibling + if (!(sibling instanceof HTMLAnchorElement)) return null + if (!sibling.matches("a.peer")) return null + return sibling + } + + function mark(link: HTMLAnchorElement) { + const icon = link.nextElementSibling + if (!(icon instanceof SVGSVGElement)) return + const heading = link.parentElement + + const current = icons.get(icon) ?? icon.innerHTML + if (!icons.has(icon)) icons.set(icon, current) + + link.dataset.copied = "true" + icon.innerHTML = + '' + + const timer = window.setTimeout(() => { + let reveal = false + if (heading instanceof HTMLElement) { + const frame = root.getBoundingClientRect() + const rect = heading.getBoundingClientRect() + const visible = + rect.bottom > frame.top && + rect.top < frame.bottom && + rect.right > frame.left && + rect.left < frame.right + reveal = visible && heading.matches(":hover") + if (!reveal) heading.dataset.lock = "true" + } + + const value = icons.get(icon) + if (value) icon.innerHTML = value + delete link.dataset.copied + + if (heading instanceof HTMLElement && !reveal) { + heading.addEventListener( + "pointerenter", + () => { + delete heading.dataset.lock + }, + { once: true }, + ) + } + }, 1200) + + const previous = timers.get(link) + if (previous) window.clearTimeout(previous) + timers.set(link, timer) + } + + function copy(event: MouseEvent) { + const node = event.target + if (!(node instanceof Element)) return + + const link = find(node) + if (!link) return + + const hash = link.getAttribute("href") + if (!hash || !hash.startsWith("#")) return + + event.preventDefault() + + const url = new URL(window.location.href) + url.hash = hash.slice(1) + void navigator.clipboard.writeText(url.toString()) + mark(link) + link.blur() + } + + root.addEventListener("click", copy) + return () => root.removeEventListener("click", copy) + }, []) + + return null +} diff --git a/packages/web/components/copy.tsx b/packages/web/components/copy.tsx new file mode 100644 index 0000000..bee24af --- /dev/null +++ b/packages/web/components/copy.tsx @@ -0,0 +1,75 @@ +"use client" + +import { ArrowUp, Check, Copy as Duplicate, Link } from "lucide-react" +import { useState } from "react" + +type copyprops = { + text: string +} + +export function Copy({ text }: copyprops) { + const [copied, setcopied] = useState(false) + const [linked, setlinked] = useState(false) + + async function copy() { + if (!text) return + await navigator.clipboard.writeText(text) + setcopied(true) + setTimeout(() => setcopied(false), 1200) + } + + function top() { + const page = document.getElementById("nd-page") + if (page) { + page.scrollTo({ top: 0, behavior: "smooth" }) + return + } + window.scrollTo({ top: 0, behavior: "smooth" }) + } + + async function link() { + await navigator.clipboard.writeText(window.location.href) + setlinked(true) + setTimeout(() => setlinked(false), 1200) + } + + return ( +
+ + + +
+ ) +} diff --git a/packages/web/content/docs/index.mdx b/packages/web/content/docs/index.mdx index 18b0238..4c604c4 100644 --- a/packages/web/content/docs/index.mdx +++ b/packages/web/content/docs/index.mdx @@ -9,14 +9,38 @@ for the base `cruel(...)` function wrappers (fetch/services/core api), see the [ ## install + + + bun + npm + pnpm + + + + ```bash bun add cruel ``` + + + + ```bash npm install cruel ``` + + + + +```bash +pnpm add cruel +``` + + + + ## wrap a model ```ts diff --git a/packages/web/package.json b/packages/web/package.json index e7090e2..d6c2573 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,11 +15,11 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "geist": "^1.4.0", - "fumadocs-core": "latest", - "fumadocs-mdx": "latest", - "fumadocs-ui": "latest", - "@orama/core": "latest", - "@types/mdx": "latest" + "fumadocs-core": "16.6.0", + "fumadocs-mdx": "14.2.7", + "fumadocs-ui": "16.6.0", + "@orama/core": "1.2.18", + "@types/mdx": "2.0.13" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/packages/web/public/og.png b/packages/web/public/og.png new file mode 100644 index 0000000..a044d2a Binary files /dev/null and b/packages/web/public/og.png differ