Skip to content

Components

Ten subpaths under @waveso/docs/react/, one module each, every one listed in exports — there is no wildcard, so a name that is not here is not importable.

Data in, no router

Every component takes its data as props, and every module that imports next/* is named for it — next-search for useRouter, next-link for next/link itself, and the private next-nav for usePathname, so the exceptions are visible in the file list rather than buried. Only the two that reach for a hook tie anything here to a router. Everything else has next/link and next/image injected, which keeps the renderer host-agnostic and testable without mounting one: the sidebar's tests pass a string pathname, the dialog's a stub navigate.

DocsSearch is the one component that reaches for Next itself, and it exists so the exception is ours rather than yours — it is the fifteen-line 'use client' wrapper around SearchDialog every consumer wrote by hand, and the ones who skipped Link lost hover prefetching on every result with nothing telling them.

ComponentSubpathBoundary
DocContentreact/doc-contentserverRenders a hast tree, inside .wave-docs-prose
DocsSidebarreact/sidebarclientTakes pathname as a prop, not from next/navigation
DocsTocreact/tocclientScrollspy via IntersectionObserver
DocsSearchreact/next-searchclientSearchDialog, wired to Next's router
SearchDialogreact/search-dialogclient⌘K, arrow keys, focus trap. Host-agnostic
DocsLinkreact/next-linkclientnext/link, adapted — pass it as Link when composing by hand
Calloutreact/calloutserverNote · tip · important · warning · caution
YouTubereact/youtubeserverClick-to-load facade, with no client JavaScript at all
DocsCopyPagereact/copy-pageclientCopies a page's markdown, sliced out of the corpus
SkipLinkreact/skip-linkserverTargets docs.Page's <main>; DOCS_CONTENT_ID is that id
createMarkdownComponentsreact/markdown-componentsserverThe element → component map

None of them needs a Node builtin — the whole react/* family bundles with nothing assumed, which Entry points states exactly.

The shell is not on this list. Its modules are private — Stability names them, with the reason for each. docs.Layout is the public name for the shell, and it has to be: the drawer's trigger lives in the sidebar and binds to the dialog by a fixed id, so exporting the pieces would publish a way to render half a shell. Layout covers composing your own.

DocContent

app/docs/[...slug]/page.tsx
import { DocContent } from '@waveso/docs/react/doc-content';
PropTypeDefault
hastRoot—The tree from docs.getPage, or from @waveso/docs/render
componentsMarkdownComponents—Overrides, merged over defaultMarkdownComponents
classNamestring—Appended to wave-docs-prose, never substituted for it
labelsCodeRuntimeLabels—The copy runtime's two announcements

A Server Component, and it must stay one. The parser and Shiki ran in Node at build time; this walks the resulting tree, and nothing here pulls unified, remark or a highlighter into the browser.

className is appended, never substituted — Theming has what dropping .wave-docs-prose costs.

labels takes copied, default 'Copied to the clipboard.', and copyFailed, default 'Copy failed. Select the code and press Control or Command + C.' They live here because this is the component that mounts the copy runtime, and the one no consumer can avoid. The runtime renders only when the tree contains a code frame, so a page without fences ships no extra bytes.

DocsSidebar

PropTypeDefault
navDocNavNode[]—The tree from docs.source.nav()
pathnamestring—Current route, for aria-current and for which groups open
LinkDocsLinkComponentplain <a>Client-side router link
labelstring'Docs'Accessible name for the landmark
expandGroupstring'Expand {title}'A collapsed group's toggle. {title} is the group's own name
collapseGroupstring'Collapse {title}'The same toggle when open
externalLinkstring'(opens in a new tab)'Screen-reader suffix on an external link
classNamestring—

{title} is a placeholder rather than a function because docs.Layout sets these from a Server Component, and a function cannot cross that boundary — and because a translator has to be able to move the name within the sentence.

Nearby links prefetch; the rest do not. Nearby means the list that directly contains the current page, plus the heading link of the group the reader is inside — 5 to 15 warm links on a real sidebar rather than 400 or none. No role="tree" and no tabindex anywhere in the markup either: this is the APG Disclosure Navigation pattern, ordinary links with a button per collapsible group, and sidebar.test.tsx asserts the absence of both.

DocsToc

PropTypeDefault
entriesTocEntry[]—Headings from docs.getPage, already nested by depth
labelstring'On this page'Accessible name for the landmark
rootMarginstring'0px 0px -60% 0px'The IntersectionObserver root margin
topLabelstring'Back to top'Text for the back-to-top link
classNamestring—

The default assumes nothing overlays the content, and ignores the bottom 60% of the screen so the active entry tracks what you are reading rather than what has scrolled into view. It reserved 80px for a sticky header until 0.7.0 — a figure that never matched the 3.5rem bar it was meant to clear, and one this component cannot assume at all, since it is exported standalone and the shell now renders no header. A host whose own chrome overlays the content passes that height here, which is what the prop is for. Only px and % are legal — IntersectionObserver throws on any other unit, rem included.

Scrolling is the browser's: the entries are real anchors and nothing here calls scrollTo. The observer retries for up to 60 frames — about a second at 60 Hz — while no id resolves, which covers a <Suspense> boundary or a tabs wrapper mounting its panel late. With entries empty the component renders null, and that null is load-bearing in the grid.

DocsSearch

app/docs/layout.tsx
import { DocsSearch } from '@waveso/docs/react/next-search';

<DocsSearch indexUrl={docs.searchIndexUrl} />

DocsSearchProps is Omit<SearchDialogProps, 'navigate' | 'Link'> — Next answers those two, and Internals has why navigate is a closure rather than the method itself. indexUrl stays required, and docs.searchIndexUrl is the only value to pass: defaulting it to /search-index.json would be wrong under every non-root basePath, and wrong as a 404 the reader hits and the author never sees.

SearchDialog

PropTypeDefault
indexUrlstring—URL of the serialised index
navigate(href: string) => void—Router navigation for the selected result
LinkDocsLinkComponentplain <a>Result links, so hovering a hit prefetches

The other fifteen props are the ten strings, the three numbers, resultCountLabels and miniSearchOptions. They are tabulated with their defaults on Search rather than repeated here — that page explains the tuning and the client boundary, and one table for both is one table to keep correct.

navigate is injected rather than imported so the dialog stays host-agnostic: useRouter().push, a react-router navigate and location.assign all satisfy it. The dialog portals itself to document.body, so a navbar's stacking context cannot trap it behind the page. Render it once, wherever the trigger belongs.

next/link, adapted to DocsLinkProps and ready to pass. It takes no props of its own — it is a DocsLinkComponent.

app/docs/nav.tsx
'use client';
import { DocsLink } from '@waveso/docs/react/next-link';
import { DocsSidebar } from '@waveso/docs/react/sidebar';

<DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />

A module-scope constant rather than a factory: a fresh component identity for a link remounts every link in the document on every render.

Pass it rather than next/link itself, which does not type-check into DocsSidebar under exactOptionalPropertyTypes: true — Layout has that case.

Its 'use client' is not for a hook — there is none. A function cannot be handed from a Server Component to a Client one, so without the directive passing it to DocsSidebar fails next build with "Functions cannot be passed directly to Client Components".

Callout

PropTypeDefault
typestring'note'One of CALLOUT_TYPES; anything unrecognised falls back to note
titlestringthe type's own headingOverrides the heading for this callout
labelsPartial<Record<CalloutType, string>>—Default headings per type
classNamestring—
childrenReactNode—

CALLOUT_TYPES is ['note', 'tip', 'important', 'warning', 'caution'] — the five kinds GitHub understands, and the five rehype-github-alerts emits from > [!NOTE] and friends. CalloutType is that union. type is typed as a plain string because it arrives as an unvalidated hast attribute, and a blank title counts as absent: an empty one leaves the callout with no accessible name, which is the one thing this component exists to provide.

It renders an <aside role="note"> carrying the label on aria-label, rather than leaving the kind to the coloured border — Markdown has who that border reaches and who it does not.

YouTube

PropTypeDefault
idstring—The 11-character video id. Without it the component renders null
startnumber—Seconds to start at, from the link's t or start
liststring—Playlist the video was linked inside, from the link's list
titlestring'YouTube video player'Accessible name for the player
playLabelstring'Play video: {title}'The closed facade's control
hideLabelstring'Hide video: {title}'The open facade's control
classNamestring—

{title} is replaced with title in both labels — a placeholder rather than concatenation, because several languages put the name first.

No hydration root. Without an id it renders null, and the facade is a <details> rather than a state hook — Internals has the measurement.

PropTypeDefault
hrefstring'#docs-content'Fragment id of the main content region
classNamestring—
childrenReactNode'Skip to content'

DOCS_CONTENT_ID is re-exported from the same subpath and is the string 'docs-content'. One constant for both halves, and no way to change it: the id was spelled independently in two files once, and a skip link pointing at an id nothing carries scrolls nowhere and focuses nothing — a failure with no symptom until a keyboard user hits it.

Put it first inside <body>, and give the target tabIndex={-1}. Browsers move the scroll position on a fragment link but not always the focus, so an unfocusable target strands focus at the top of the document. docs.Layout renders one for you.

createMarkdownComponents

OptionTypeDefault
LinkDocsLinkComponentplain <a>Client-side router link
ImageDocsImageComponentplain <img>Optimising image component
labelsMarkdownLabels—The ten strings this map renders itself

It returns a MarkdownComponents map with five entries — a, img, table, callout and youtube — and defaultMarkdownComponents is the result of calling it with nothing. Call it once at module scope. Every call returns fresh component identities, and remounting the whole document on each render is what that costs.

MarkdownLabels is the subset of DocsLabels this map owns: externalLink, table, the five calloutNote…calloutCaution headings, and youtubeTitle, youtubePlay and youtubeHide. All server-rendered, so overriding them costs no client bytes. Translating the chrome has the whole set.

The a mapping drops a destination whose scheme is not in the allowlist, keeping the text in a <span> and warning once per href outside production; adds target="_blank" rel="noopener noreferrer" and a visually hidden externalLink suffix to a link that opens in a new tab; and routes only in-app paths through Link, since fragments and mailto: gain nothing from a router link and break under one. table wraps the table in a labelled, focusable <section> — a scroll container that is not focusable cannot be scrolled by keyboard at all.

DocsLinkProps

Omit<ComponentProps<'a'>, 'href' | 'ref'> with href: string required, plus one addition: prefetch?: boolean, honoured by next/link and ignored by a plain <a>. false disables the hover path and the viewport path both, so it is a stronger switch in the App Router than the name suggests. The adapter omits it rather than passing undefined, since next/link's own prefetch is boolean | 'auto' | null with no undefined in it.

The interface is shaped to be satisfied by next/link without this package ever importing it. Importing next/* here would couple the React layer to Next, which is half the point of the package.

DocsImageProps

PropTypeDefault
srcstring—Required
altstring—Required; the mapping passes '' when the tree carries none
widthnumber—Required
heightnumber—Required
titlestring—
classNamestring—Joined with wave-docs-image
sizesstring—Forwarded to the image
loading'eager' | 'lazy''lazy'
decoding'async' | 'auto' | 'sync''async'
fetchPriority'high' | 'low' | 'auto'—'high' on a hero image is the usual reason

width and height are required because next/image refuses to render without them, short of fill; they come from the build-time ImageResolver. Where either is missing the mapping degrades to a plain <img> rather than failing the page — Images has that path in full.

loading defaults to lazy, but never over the author's own choice — Images has why a leading image is the exception.