From c967269de465d43e1af3148d0f4bdc4b176599b4 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 6 Jun 2026 11:10:23 +0400 Subject: [PATCH 1/4] fix(docs): expose install steps in toc --- apps/web/content/docs/installation.mdx | 16 ++++++++-------- apps/web/src/components/docs/mdx-text.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index d06038e..9a2eb5e 100644 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -8,7 +8,7 @@ description: how to install and configure PayKit in your app -Install the package +### Install the package Let's start by adding PayKit to your project: @@ -19,7 +19,7 @@ npm install paykitjs -Create the PayKit instance +### Create the PayKit instance Create a file named `paykit.ts` anywhere in your app. @@ -39,7 +39,7 @@ export const paykit = createPayKit({ -Configure Stripe +### Configure Stripe PayKit uses Stripe for billing. Pass your Stripe keys directly to the PayKit instance. @@ -56,7 +56,7 @@ export const paykit = createPayKit({ -Configure database +### Configure database PayKit needs a database to store billing state, such as subscriptions. You can create a separate database, or simply plug it into the app's own db. @@ -80,7 +80,7 @@ export const paykit = createPayKit({ -Mount request handler +### Mount request handler To handle webhooks and client API requests, you need to set up a request handler on your server. @@ -132,7 +132,7 @@ Create a new file or route in your framework's designated catch-all route handle -Create client instance +### Create client instance The client-side library helps you interact with the server. PayKit client sdk suitable for almost all modern frameworks, including React. @@ -164,7 +164,7 @@ export const paykit = createPayKit({ -Define your products +### Define your products Optionally. PayKit provides a code-first way to create your plans, and a very useful usage billing with `track()` and `report()` out of the box. @@ -218,7 +218,7 @@ export const paykit = createPayKit({ -Push changes to DB +### Push changes to DB PayKit includes a CLI tool to keep your database in sync with your configuration. diff --git a/apps/web/src/components/docs/mdx-text.tsx b/apps/web/src/components/docs/mdx-text.tsx index 10f600a..8475ec7 100644 --- a/apps/web/src/components/docs/mdx-text.tsx +++ b/apps/web/src/components/docs/mdx-text.tsx @@ -74,7 +74,13 @@ export function Steps({ className, children, ...props }: HTMLAttributes isValidElement(child)); return ( -
+
{steps.map((child, index) => { const step = child as React.ReactElement; const isFirstStep = index === 0; From 721f1e153951ba385cefc178feb2b5d7572fe171 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 6 Jun 2026 11:10:24 +0400 Subject: [PATCH 2/4] docs: add hidden toc test page --- apps/web/content/docs/test.mdx | 153 +++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 apps/web/content/docs/test.mdx diff --git a/apps/web/content/docs/test.mdx b/apps/web/content/docs/test.mdx new file mode 100644 index 0000000..9f8c9c1 --- /dev/null +++ b/apps/web/content/docs/test.mdx @@ -0,0 +1,153 @@ +--- +title: TOC Test +description: Temporary page for testing table of contents behavior. +--- + +## Overview + +This temporary page exists to stress-test the table of contents with a mix of heading depths, long sections, repeated content shapes, and enough vertical space to make active heading tracking visible while scrolling. + +PayKit documentation pages often mix prose, lists, callouts, tabs, and code examples. The goal here is not to explain a real feature, but to approximate the density of a real guide so the right-side navigation can be checked in a realistic layout. + +- This page should not be linked from the sidebar. +- It should still be routable directly at `/docs/test`. +- The headings should appear in the table of contents. + +### Why this page exists + +A table of contents can look correct with two headings and still behave poorly on longer pages. Active indicators, sticky positioning, scroll margins, and nested heading indentation usually need a longer document to reveal issues. + +This section intentionally includes enough text to create a visible scroll region. It also has an H3 under the first H2 so nested entries can be checked near the top of the document. + +### What to look for + +When testing this page, check whether the active item updates at the right time, whether nested H3 items are visually distinct from H2 items, and whether long labels wrap or truncate in an acceptable way. + +Also check the transition between sections. The TOC should not jump erratically when a heading reaches the top of the viewport. + +## Installation-shaped section + +This section mimics a setup guide. It has multiple subsections under a single H2, which is useful for checking how several H3s group visually below one parent heading. + +The content is intentionally ordinary documentation prose. It should feel like a real setup guide with enough paragraphs to test vertical rhythm. + +### Install dependencies + +Start by adding the packages required by your application. In a real guide, this section would describe the recommended package manager command and any peer dependency expectations. + +```bash +npm install paykitjs +``` + +After installing dependencies, restart your development server if your framework requires it. Some tools pick up new dependencies automatically, while others need a fresh process. + +### Create a server instance + +The server instance is usually created in a shared module such as `src/lib/paykit.ts`. Keeping the setup in one place makes it easier to reuse from route handlers, server actions, and background jobs. + +```ts +import { createPayKit } from "paykitjs"; + +export const paykit = createPayKit({ + // configuration goes here +}); +``` + +This example is intentionally small. The important part for this test page is that the code block has normal spacing around it and does not interfere with heading detection. + +### Connect provider credentials + +Provider credentials are usually read from environment variables. The documentation should make it clear which values are required locally and which values are only needed in production. + +A realistic guide often includes a short explanation here about test mode, webhook secrets, and how to avoid committing sensitive values to source control. + +```ts +export const paykit = createPayKit({ + stripe: { + secretKey: process.env.STRIPE_SECRET_KEY!, + webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, + }, +}); +``` + +### Configure storage + +Billing state needs a durable place to live. A database connection can be passed directly or wrapped in an adapter depending on the integration. + +This section is long enough to verify that the active H3 remains selected while the reader is still inside the subsection. If active tracking moves too early, this kind of content makes it easier to notice. + +### Mount handlers + +Route handlers receive webhook deliveries and client API requests. The exact file path depends on the framework, but the shape is generally the same: export handlers that delegate to PayKit. + +This subsection closes the five-H3 group under one H2. The table of contents should show all five children under the parent section without crowding the layout. + +## Usage examples + +Usage sections often combine explanation with small snippets. They also tend to include shorter subsections, which helps test whether the TOC handles sections of varying length. + +### Subscribe a customer + +A subscription call usually needs a customer identifier and a plan identifier. If the customer does not have a saved payment method, the API may return a checkout URL. + +```ts +await paykit.subscribe({ + customerId: "user_123", + planId: "pro", +}); +``` + +The surrounding prose should be long enough to make the section feel real, but not so long that the page becomes annoying to use as a visual test fixture. + +### Check an entitlement + +Entitlement checks are typically used before gated actions. A good docs page should explain what happens when access is denied and how the result should be handled in the app. + +```ts +const result = await paykit.check({ + customerId: "user_123", + featureId: "messages", +}); +``` + +If this heading appears in the TOC, verify that the active marker moves from the previous subsection to this one at a reasonable scroll threshold. + +## Long content section + +This section exists mostly to provide scroll distance between heading groups. It should help test whether the TOC active state remains stable during long paragraphs without intermediate headings. + +PayKit is designed to feel like application code rather than a hosted billing dashboard. That means a documentation page may spend time explaining tradeoffs, data ownership, and how provider-specific details stay behind typed APIs. + +A long content block also tests whether the TOC footer, if present, remains visible and whether the active indicator line can cover a long portion of the navigation without visual glitches. + +More prose follows to add height. The exact words are not important, but the density should resemble a normal guide. Readers often skim docs pages, so headings need to be useful landmarks rather than decoration. + +When the current section has no H3 headings, the TOC should still keep the H2 active while scrolling through the body content. If it jumps to the next section too early, this section should make that obvious. + +## Edge cases + +This section contains heading labels that are intentionally a little different from each other. They are useful for checking slug generation, text extraction, and display behavior. + +### What's new? + +This heading includes punctuation. It should produce a stable anchor and should not collide unexpectedly with nearby headings. + +The content here is short but still includes more than one sentence. That keeps the layout realistic enough for visual testing. + +### Whats new + +This heading is similar to the previous one but not identical in source text. It is useful for checking whether anchor generation or TOC rendering handles near-duplicates safely. + +If the page supports duplicate heading labels, the generated IDs should still point to the correct section. + +### A heading with inline `code` + +Some docs headings include inline code. The rendered heading should look good, and the TOC label should use meaningful text rather than `[object Object]`. + +This subsection is especially useful after changes to heading text extraction logic. + +## Final section + +The final section confirms how the TOC behaves near the bottom of the page. Active tracking sometimes has special edge cases when there is not enough content below the last heading. + +Scroll to the end and verify that this final item becomes active. Also verify that the previous section does not remain selected after this heading reaches the viewport. From 08c7f05aa3e2b1d50ba7edd1d24d3faf12dcb9e7 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 6 Jun 2026 11:23:36 +0400 Subject: [PATCH 3/4] chore: update installation.md content --- apps/web/content/docs/installation.mdx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/web/content/docs/installation.mdx b/apps/web/content/docs/installation.mdx index 9a2eb5e..3c781fb 100644 --- a/apps/web/content/docs/installation.mdx +++ b/apps/web/content/docs/installation.mdx @@ -73,9 +73,7 @@ export const paykit = createPayKit({ }); ``` - - It works by creating a few tables prefixed with `paykit_`. You can learn more [here](/docs/database). - +It works by creating a few tables prefixed with `paykit_`. You can learn more [here](/docs/database). @@ -211,9 +209,7 @@ export const paykit = createPayKit({ ``` - - This is an example setup for AI chat app. Read further on how to build your own billing config. - +This is an example setup for AI chat app. Read further on how to build your own billing config. From 5b40e2f5362c3df2a83357a8d51554268b69e6b3 Mon Sep 17 00:00:00 2001 From: Max Katz Date: Sat, 6 Jun 2026 11:34:34 +0400 Subject: [PATCH 4/4] feat(docs): add custom toc --- apps/web/src/components/docs/docs-page.tsx | 9 +- apps/web/src/components/docs/docs-toc.tsx | 218 +++++++++++++++++++++ apps/web/src/styles/globals.css | 3 + 3 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/docs/docs-toc.tsx diff --git a/apps/web/src/components/docs/docs-page.tsx b/apps/web/src/components/docs/docs-page.tsx index e9d5256..44582dc 100644 --- a/apps/web/src/components/docs/docs-page.tsx +++ b/apps/web/src/components/docs/docs-page.tsx @@ -5,11 +5,12 @@ import { usePathname } from "fumadocs-core/framework"; import Link from "fumadocs-core/link"; import type * as PageTree from "fumadocs-core/page-tree"; import { useTreeContext, useTreePath } from "fumadocs-ui/contexts/tree"; -import { TOC, TOCProvider, type TOCProviderProps } from "fumadocs-ui/layouts/docs/page/slots/toc"; +import { TOCProvider, type TOCProviderProps } from "fumadocs-ui/layouts/docs/page/slots/toc"; import type { ComponentProps, ReactNode } from "react"; import { Fragment, useMemo } from "react"; import { RiArrowLeftSLine, RiArrowRightSLine } from "react-icons/ri"; +import { DocsToc } from "@/components/docs/docs-toc"; import { cn } from "@/lib/utils"; const tocWidthClassName = "xl:layout:[--fd-toc-width:250px]"; @@ -48,7 +49,7 @@ export function DocsPage({ {children} {footer && } - {hasToc && } + {hasToc && } ); } @@ -88,8 +89,8 @@ export function DocsBody({ children, className, ...props }: ComponentProps<"div" ); } -function DocsToc({ footer }: { footer?: ReactNode }) { - return ; +function DocsTocLayout({ footer }: { footer?: ReactNode }) { + return ; } function DocsTocLayoutMarker() { diff --git a/apps/web/src/components/docs/docs-toc.tsx b/apps/web/src/components/docs/docs-toc.tsx new file mode 100644 index 0000000..d1062fd --- /dev/null +++ b/apps/web/src/components/docs/docs-toc.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { Menu02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import * as Primitive from "fumadocs-core/toc"; +import { useTOCItems } from "fumadocs-ui/components/toc"; +import type { ComponentProps, ReactNode } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { extractText } from "@/components/docs/react-node-text"; +import { cn } from "@/lib/utils"; + +interface ComputedPath { + content: ReactNode; + d: string; + height: number; + positions: [top: number, bottom: number][]; + width: number; +} + +const lineBaseOffset = 7; + +function getItemOffset(depth: number) { + if (depth <= 2) return 18; + if (depth === 3) return 30; + return 42; +} + +function getLineOffset(depth: number) { + if (depth <= 2) return lineBaseOffset; + if (depth === 3) return lineBaseOffset + 10; + return lineBaseOffset + 20; +} + +function DocsTocItems({ className, children, ...props }: ComponentProps<"div">) { + const containerRef = useRef(null); + const items = useTOCItems(); + const tocInfo = Primitive.useTOC(); + const [path, setPath] = useState(null); + + const calculatePath = useCallback(() => { + const container = containerRef.current; + if (!container || container.clientHeight === 0 || items.length === 0) { + setPath(null); + return; + } + + let d = ""; + let height = 0; + let width = 0; + const positions: [top: number, bottom: number][] = []; + + for (let index = 0; index < items.length; index++) { + const item = items[index]; + if (!item) continue; + + const element: HTMLElement | null = container.querySelector(`a[href="${item.url}"]`); + if (!element) continue; + + const styles = getComputedStyle(element); + const x = getLineOffset(item.depth) + 0.5; + const top = element.offsetTop + parseFloat(styles.paddingTop); + const bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom); + const previous = positions[index - 1]; + const previousItem = items[index - 1]; + const previousX = index > 0 && previousItem ? getLineOffset(previousItem.depth) + 0.5 : x; + + if (index === 0 || !previous) { + d += `M${x} ${top} L${x} ${bottom}`; + } else { + d += ` C ${previousX} ${top - 4} ${x} ${previous[1] + 4} ${x} ${top} L${x} ${bottom}`; + } + + width = Math.max(width, x + 8); + height = Math.max(height, bottom); + positions.push([top, bottom]); + } + + setPath({ + content: , + d, + height, + positions, + width, + }); + }, [items]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const observer = new ResizeObserver(calculatePath); + observer.observe(container); + calculatePath(); + + return () => observer.disconnect(); + }, [calculatePath]); + + return ( +
+ {path ? : null} + {children} +
+ ); +} + +function getLastActiveIndex(items: Primitive.TOCItemInfo[]) { + for (let index = items.length - 1; index >= 0; index--) { + if (items[index]?.active) return index; + } + + return -1; +} + +function DocsTocTrack({ path, items }: { path: ComputedPath; items: Primitive.TOCItemInfo[] }) { + const ref = useRef(null); + + const calculateActiveStyle = useCallback(() => { + const activeStart = items.findIndex((item) => item.active); + if (activeStart === -1) return {}; + + const activeEnd = getLastActiveIndex(items); + + return { + "--track-top": `${path.positions[activeStart]?.[0] ?? 0}px`, + "--track-bottom": `${path.positions[activeEnd]?.[1] ?? 0}px`, + } as Record; + }, [items, path.positions]); + + Primitive.useTOCListener((nextItems) => { + const element = ref.current; + if (!element) return; + + const activeStart = nextItems.findIndex((item) => item.active); + const activeEnd = getLastActiveIndex(nextItems); + + element.style.setProperty("--track-top", `${path.positions[activeStart]?.[0] ?? 0}px`); + element.style.setProperty("--track-bottom", `${path.positions[activeEnd]?.[1] ?? 0}px`); + }); + + return ( + + ); +} + +function DocsTocItem({ item }: { item: Primitive.TOCItemType }) { + return ( + + {extractText(item.title)} + + ); +} + +/** Custom docs table of contents with path-style active tracking. */ +export function DocsToc({ className, footer }: { className?: string; footer?: ReactNode }) { + const items = useTOCItems(); + const scrollRef = useRef(null); + + if (items.length === 0 && !footer) { + return
; + } + + return ( +
+

+ + On this page +

+
+ + + {items.map((item) => ( + + ))} + + +
+ {footer} +
+ ); +} diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index d901dd1..f6ab677 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -122,6 +122,7 @@ --scrollbar-track: transparent; --selection: #4f4f4f; --selection-foreground: #ffffff; + --path: oklch(0.9037 0 0); --radius: 0.45rem; --fd-nav-height: 56px; --fd-banner-height: 0px; @@ -177,6 +178,7 @@ --sidebar-ring: var(--ring); --selection: #4f4f4f; --selection-foreground: #ffffff; + --path: oklch(0.269 0 0); --theme-transition-line: oklch(0.985 0 0 / 0.22); } @@ -232,6 +234,7 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-path: var(--path); --font-sans: var(--font-sans); --font-mono: var(--font-mono);