@waveso/docs 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.md +490 -75
- package/dist/code-frame.d.ts +29 -0
- package/dist/code-frame.js +41 -0
- package/dist/code-meta.d.ts +48 -0
- package/dist/code-meta.js +72 -0
- package/dist/docs-content-id.d.ts +19 -0
- package/dist/docs-content-id.js +19 -0
- package/dist/docs-error.d.ts +2 -57
- package/dist/docs-error.js +3 -15
- package/dist/errors.d.ts +94 -0
- package/dist/errors.js +45 -0
- package/dist/next.d.ts +153 -28
- package/dist/next.js +65 -33
- package/dist/plugins/rehype-capture-toc.js +26 -5
- package/dist/plugins/rehype-code-frame.d.ts +10 -0
- package/dist/plugins/rehype-code-frame.js +88 -0
- package/dist/plugins/rehype-code-language.js +7 -1
- package/dist/react/code-runtime.d.ts +14 -0
- package/dist/react/code-runtime.js +161 -0
- package/dist/react/doc-content.d.ts +39 -2
- package/dist/react/doc-content.js +42 -10
- package/dist/react/layout.d.ts +44 -0
- package/dist/react/layout.js +65 -0
- package/dist/react/nav.d.ts +28 -0
- package/dist/react/nav.js +70 -0
- package/dist/react/nearest-scroll-top.d.ts +45 -0
- package/dist/react/nearest-scroll-top.js +44 -0
- package/dist/react/next-link.d.ts +34 -0
- package/dist/react/next-link.js +30 -0
- package/dist/react/next-nav.d.ts +11 -0
- package/dist/react/next-nav.js +32 -0
- package/dist/react/next-search.d.ts +22 -0
- package/dist/react/next-search.js +52 -0
- package/dist/react/search-dialog.d.ts +53 -10
- package/dist/react/search-dialog.js +147 -47
- package/dist/react/shell-labels.d.ts +43 -0
- package/dist/react/shell-labels.js +27 -0
- package/dist/react/sidebar.d.ts +38 -3
- package/dist/react/sidebar.js +104 -12
- package/dist/react/skip-link.d.ts +1 -9
- package/dist/react/skip-link.js +6 -5
- package/dist/react/toc.d.ts +12 -4
- package/dist/react/toc.js +18 -7
- package/dist/react/youtube.d.ts +31 -5
- package/dist/react/youtube.js +76 -54
- package/dist/render.d.ts +35 -1
- package/dist/render.js +35 -14
- package/dist/route-path.d.ts +46 -0
- package/dist/route-path.js +51 -0
- package/dist/search-index.d.ts +6 -23
- package/dist/search-index.js +6 -51
- package/dist/sitemap-limit.d.ts +34 -0
- package/dist/sitemap-limit.js +37 -0
- package/dist/source.d.ts +1 -23
- package/dist/source.js +40 -43
- package/dist/styles.css +1001 -106
- package/dist/types.d.ts +11 -2
- package/package.json +58 -23
package/dist/next.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc } from "./types.js";
|
|
1
|
+
import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc, SearchRecord } from "./types.js";
|
|
2
2
|
import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
|
|
3
3
|
import { MarkdownComponents } from "./react/markdown-components.js";
|
|
4
|
+
import { DocsLabels } from "./react/shell-labels.js";
|
|
5
|
+
import { DocsLayoutSearchProps } from "./react/layout.js";
|
|
4
6
|
import { DocsSource } from "./source.js";
|
|
5
7
|
import { ReactNode } from "react";
|
|
8
|
+
import { PluggableList } from "unified";
|
|
9
|
+
import { Options } from "minisearch";
|
|
6
10
|
//#region src/next.d.ts
|
|
7
11
|
interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
|
|
8
12
|
/** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
|
|
@@ -27,22 +31,17 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
|
|
|
27
31
|
*/
|
|
28
32
|
titleHeading?: boolean | undefined;
|
|
29
33
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
34
|
+
* Extra remark plugins, attached after `remarkGfm` and before link
|
|
35
|
+
* resolution — so anything they emit is folded, contained and asserted like
|
|
36
|
+
* authored markdown.
|
|
33
37
|
*/
|
|
34
|
-
|
|
38
|
+
remarkPlugins?: PluggableList | undefined;
|
|
35
39
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* Next's module graph, so nothing re-evaluates a route module when one
|
|
40
|
-
* changes: without this, `next dev` serves whatever it read on the first
|
|
41
|
-
* request until the server restarts, and a file added afterwards is never
|
|
42
|
-
* found. A rescan of a few hundred small files costs single-digit
|
|
43
|
-
* milliseconds; a production build reads the tree once, as it should.
|
|
40
|
+
* Extra rehype plugins, attached after heading ids and permalinks exist and
|
|
41
|
+
* before the code steps — so a `<pre>` is still the author's text rather
|
|
42
|
+
* than Shiki's token spans.
|
|
44
43
|
*/
|
|
45
|
-
|
|
44
|
+
rehypePlugins?: PluggableList | undefined;
|
|
46
45
|
/** Replaces the built-in markdown-link resolution. */
|
|
47
46
|
linkResolver?: LinkResolver | undefined;
|
|
48
47
|
/**
|
|
@@ -59,6 +58,64 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
|
|
|
59
58
|
* set neither, you ship pages with no usable canonical.
|
|
60
59
|
*/
|
|
61
60
|
siteUrl?: string | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* MiniSearch overrides for the index {@link DocsRoute.searchIndex} builds.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ THE IDENTICAL OBJECT MUST REACH THE DIALOG — pass it to `DocsSearch`'s
|
|
65
|
+
* (or `SearchDialog`'s) `miniSearchOptions`. MiniSearch reads `tokenize` and
|
|
66
|
+
* `processTerm` both when indexing and when querying, so applying one here
|
|
67
|
+
* and not there produces an index whose terms no query can spell: zero
|
|
68
|
+
* results, no error, nothing in the console.
|
|
69
|
+
*/
|
|
70
|
+
miniSearchOptions?: Partial<Options<SearchRecord>> | undefined;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Props for {@link DocsRoute.Layout}.
|
|
74
|
+
*
|
|
75
|
+
* Four, and the fourth is a boolean. Everything else a docs shell is asked for
|
|
76
|
+
* turned out to be reachable already: an announcement banner renders *above*
|
|
77
|
+
* `<docs.Layout>` in your own `layout.tsx`, because this does not own `<body>`;
|
|
78
|
+
* a content footer goes inside `children`; and sidebar links, social icons and
|
|
79
|
+
* separators are `DocNavNode`s authored in `meta.json`. The header bar is the
|
|
80
|
+
* one region nothing else can reach, which is what `actions` is for.
|
|
81
|
+
*
|
|
82
|
+
* A `slots` map was the alternative, and it can still be added later — two node
|
|
83
|
+
* props can become a slots map, a slots map cannot become two props.
|
|
84
|
+
*/
|
|
85
|
+
interface DocsLayoutProps {
|
|
86
|
+
children: ReactNode;
|
|
87
|
+
/**
|
|
88
|
+
* Brand at the header start. A string, or your own logo component.
|
|
89
|
+
*
|
|
90
|
+
* `ReactNode`, so it cannot also serve as the `<title>` or as the header's
|
|
91
|
+
* accessible name; the landmark carries a fixed label instead.
|
|
92
|
+
*/
|
|
93
|
+
title?: ReactNode;
|
|
94
|
+
/** Header end, after search: a theme toggle, a version switcher, a link. */
|
|
95
|
+
actions?: ReactNode;
|
|
96
|
+
/**
|
|
97
|
+
* The search trigger. Defaults to on, and the URL is always derived.
|
|
98
|
+
*
|
|
99
|
+
* `false` omits it. An object configures the dialog — `placeholder`,
|
|
100
|
+
* `hotkey`, `miniSearchOptions` and the rest of `DocsSearch`'s surface,
|
|
101
|
+
* minus `indexUrl`.
|
|
102
|
+
*
|
|
103
|
+
* You do not need to pass `miniSearchOptions` here to match what
|
|
104
|
+
* `createDocsRoute` was given: the route's own value is forwarded, so the
|
|
105
|
+
* object that built the index is the object that queries it. Pass one only
|
|
106
|
+
* to override that.
|
|
107
|
+
*/
|
|
108
|
+
search?: boolean | DocsLayoutSearchProps | undefined;
|
|
109
|
+
/**
|
|
110
|
+
* The four strings the shell renders itself: the navigation landmark's name,
|
|
111
|
+
* the drawer's open and close buttons, and the skip link.
|
|
112
|
+
*
|
|
113
|
+
* Everything else a reader sees is your markdown or your `title`. This is the
|
|
114
|
+
* whole of what a non-English site has to say — and it is the fifth prop,
|
|
115
|
+
* added deliberately: a documentation shell nobody can translate is not a
|
|
116
|
+
* shell for the whole ecosystem.
|
|
117
|
+
*/
|
|
118
|
+
labels?: DocsLabels | undefined;
|
|
62
119
|
}
|
|
63
120
|
/** Props Next hands a page in the App Router. */
|
|
64
121
|
interface DocsPageProps {
|
|
@@ -175,11 +232,89 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
175
232
|
*/
|
|
176
233
|
getPage: (segments: string[]) => Promise<RenderedDoc<TFrontmatter> | undefined>;
|
|
177
234
|
/**
|
|
178
|
-
* Every published page, rendered. The
|
|
179
|
-
*
|
|
180
|
-
*
|
|
235
|
+
* Every published page, rendered. The escape hatch behind
|
|
236
|
+
* {@link DocsRoute.searchIndex}, for anyone building their own artifact out
|
|
237
|
+
* of `extractSearchRecords`.
|
|
181
238
|
*/
|
|
182
239
|
renderAll: () => Promise<Array<RenderedDoc<TFrontmatter>>>;
|
|
240
|
+
/**
|
|
241
|
+
* `GET` handler for `app/<basePath>/search-index.json/route.ts`, serving the
|
|
242
|
+
* MiniSearch index the dialog fetches.
|
|
243
|
+
*
|
|
244
|
+
* ```ts
|
|
245
|
+
* // app/docs/search-index.json/route.ts — the whole file
|
|
246
|
+
* import { docs } from '@/lib/docs';
|
|
247
|
+
*
|
|
248
|
+
* export const GET = docs.searchIndex;
|
|
249
|
+
* export const dynamic = 'force-static'; // a literal, see below
|
|
250
|
+
* ```
|
|
251
|
+
*
|
|
252
|
+
* **`dynamic = 'force-static'` is not optional and must be a literal**, for
|
|
253
|
+
* the same reason as {@link DocsRoute.dynamicParams}: route segment config is
|
|
254
|
+
* parsed out of the module before any of it runs. Without it Next marks the
|
|
255
|
+
* route `ƒ` (Dynamic) and re-renders your entire corpus on every request —
|
|
256
|
+
* from markdown that output tracing did not put in the deployment bundle, so
|
|
257
|
+
* on a serverless host it does not merely get slow, it throws, at the reader,
|
|
258
|
+
* inside the search dialog. The build prints no warning for this, so the
|
|
259
|
+
* handler detects it at runtime and throws with `code:
|
|
260
|
+
* 'search-index-dynamic'` instead of failing quietly.
|
|
261
|
+
*
|
|
262
|
+
* The index is built from the same `renderAll()` → `extractSearchRecords` →
|
|
263
|
+
* `buildSearchIndex` pipeline you could write by hand, with the `charset`-free
|
|
264
|
+
* `application/json` content type, a strong `ETag` and
|
|
265
|
+
* `cache-control: public, max-age=0, must-revalidate` — Next's default for a
|
|
266
|
+
* prerendered route is a year of `s-maxage` with no validator, which on a
|
|
267
|
+
* stable URL means a CDN serving last year's index until someone purges it.
|
|
268
|
+
*/
|
|
269
|
+
searchIndex: () => Promise<Response>;
|
|
270
|
+
/**
|
|
271
|
+
* Default export for `app/<basePath>/layout.tsx` — the entire docs shell.
|
|
272
|
+
*
|
|
273
|
+
* ```tsx
|
|
274
|
+
* // app/docs/layout.tsx — the whole file
|
|
275
|
+
* import '@waveso/docs/styles.css';
|
|
276
|
+
* import { docs } from '@/lib/docs';
|
|
277
|
+
*
|
|
278
|
+
* export default docs.Layout;
|
|
279
|
+
* ```
|
|
280
|
+
*
|
|
281
|
+
* Or, with your own chrome in the header:
|
|
282
|
+
*
|
|
283
|
+
* ```tsx
|
|
284
|
+
* export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
285
|
+
* return (
|
|
286
|
+
* <docs.Layout title={<Logo />} actions={<ThemeToggle />}>
|
|
287
|
+
* {children}
|
|
288
|
+
* </docs.Layout>
|
|
289
|
+
* );
|
|
290
|
+
* }
|
|
291
|
+
* ```
|
|
292
|
+
*
|
|
293
|
+
* It owns the skip link, the header, the sidebar column, the mobile drawer
|
|
294
|
+
* and the grid, and it reads `source.nav()` and `searchIndexUrl` itself — so
|
|
295
|
+
* there is no nav to fetch and no URL to pass. It does **not** own the table
|
|
296
|
+
* of contents: a Next layout receives `{children, params}` and cannot know
|
|
297
|
+
* which page is rendering, so `docs.Page` emits the TOC as its second child
|
|
298
|
+
* and the grid places it.
|
|
299
|
+
*
|
|
300
|
+
* Your `layout.tsx` stays a Server Component. The two pieces that need a
|
|
301
|
+
* client — the nav's `usePathname`, the search dialog — carry their own
|
|
302
|
+
* `'use client'` boundaries inside the package.
|
|
303
|
+
*
|
|
304
|
+
* Next passes `{ children, params }`; the extra `params` is ignored, which is
|
|
305
|
+
* why `export default docs.Layout` type-checks as a layout.
|
|
306
|
+
*/
|
|
307
|
+
Layout: (props: DocsLayoutProps) => Promise<ReactNode>;
|
|
308
|
+
/**
|
|
309
|
+
* `${basePath}/search-index.json` — hand it to `DocsSearch`'s `indexUrl`.
|
|
310
|
+
*
|
|
311
|
+
* Derived from the route's own `basePath`, so it is right when the docs are
|
|
312
|
+
* mounted at `/`, at `/docs`, or under a nested prefix. It is *not* prefixed
|
|
313
|
+
* with Next's `basePath` config, which Next applies to `<Link>` and to
|
|
314
|
+
* navigation but never to a client `fetch()` — on a site setting that, prefix
|
|
315
|
+
* it yourself.
|
|
316
|
+
*/
|
|
317
|
+
searchIndexUrl: string;
|
|
183
318
|
}
|
|
184
319
|
/**
|
|
185
320
|
* Create the route handlers for a documentation tree.
|
|
@@ -214,16 +349,6 @@ interface DocsSitemapOptions<TFrontmatter extends DocFrontmatter = DocFrontmatte
|
|
|
214
349
|
* noise. Wire this to your git history if the dates are load-bearing.
|
|
215
350
|
*/
|
|
216
351
|
lastModified?: (file: DocFile<TFrontmatter>) => Date | undefined | Promise<Date | undefined>;
|
|
217
|
-
/**
|
|
218
|
-
* Re-read the content directory before building the sitemap.
|
|
219
|
-
*
|
|
220
|
-
* Defaults to `true` outside `NODE_ENV=production`, matching
|
|
221
|
-
* {@link DocsRouteOptions.rescanPerRequest}. `createDocsSource` memoises by
|
|
222
|
-
* config, so without this the first scan of the process is the only one —
|
|
223
|
-
* and `app/sitemap.ts` in `next dev` would keep serving the page set as it
|
|
224
|
-
* stood when the server booted.
|
|
225
|
-
*/
|
|
226
|
-
rescanPerRequest?: boolean | undefined;
|
|
227
352
|
}
|
|
228
353
|
/**
|
|
229
354
|
* Sitemap entries for every published page, for `app/sitemap.ts`.
|
|
@@ -275,4 +400,4 @@ interface DocsRedirect {
|
|
|
275
400
|
*/
|
|
276
401
|
declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
|
|
277
402
|
//#endregion
|
|
278
|
-
export { type DocsLang, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
|
403
|
+
export { type DocsLang, DocsLayoutProps, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
package/dist/next.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { DOCS_CONTENT_ID } from "./docs-content-id.js";
|
|
2
3
|
import { mapPooled } from "./map-pooled.js";
|
|
3
4
|
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
4
5
|
import { DocContent } from "./react/doc-content.js";
|
|
5
|
-
import "./react/
|
|
6
|
+
import { DocsToc } from "./react/toc.js";
|
|
7
|
+
import { wrapNextLink } from "./react/next-link.js";
|
|
6
8
|
import { createDocsRenderer } from "./render.js";
|
|
7
|
-
import {
|
|
9
|
+
import { toAliasRoute } from "./route-path.js";
|
|
10
|
+
import { createDocsSource, resolveDocsConfig } from "./source.js";
|
|
11
|
+
import { sitemapLimitWarning } from "./sitemap-limit.js";
|
|
8
12
|
import { stat } from "node:fs/promises";
|
|
9
|
-
import {
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { Fragment, cache, createElement } from "react";
|
|
10
15
|
//#region src/next.ts
|
|
11
16
|
/**
|
|
12
17
|
* The Next.js App Router adapter.
|
|
@@ -61,8 +66,6 @@ import { cache, createElement } from "react";
|
|
|
61
66
|
* worth of trees and network calls in flight simultaneously.
|
|
62
67
|
*/
|
|
63
68
|
const RENDER_CONCURRENCY = 16;
|
|
64
|
-
/** Google's per-sitemap URL cap. */
|
|
65
|
-
const SITEMAP_URL_LIMIT = 5e4;
|
|
66
69
|
function isRecord(value) {
|
|
67
70
|
return typeof value === "object" && value !== null;
|
|
68
71
|
}
|
|
@@ -110,23 +113,6 @@ async function loadNotFound() {
|
|
|
110
113
|
return value;
|
|
111
114
|
}
|
|
112
115
|
/**
|
|
113
|
-
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
114
|
-
*
|
|
115
|
-
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
116
|
-
* `boolean | null`; the React layer promises neither, because it must also run
|
|
117
|
-
* with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
118
|
-
* place instead of at every call site.
|
|
119
|
-
*/
|
|
120
|
-
function wrapNextLink(NextLink) {
|
|
121
|
-
return function DocsNextLink({ href, prefetch, children, ...rest }) {
|
|
122
|
-
return createElement(NextLink, {
|
|
123
|
-
...rest,
|
|
124
|
-
href,
|
|
125
|
-
...prefetch === void 0 ? {} : { prefetch }
|
|
126
|
-
}, children);
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
116
|
* Adapt `next/image` to {@link DocsImageProps}.
|
|
131
117
|
*
|
|
132
118
|
* Every prop is named rather than spread. `next/image` types `width`/`height`
|
|
@@ -183,8 +169,7 @@ function createDocsRoute(options) {
|
|
|
183
169
|
const config = resolveDocsConfig(options);
|
|
184
170
|
const source = createDocsSource(options);
|
|
185
171
|
const siteUrl = normalizeSiteUrl(options.siteUrl);
|
|
186
|
-
const
|
|
187
|
-
const rescanPerRequest = options.rescanPerRequest ?? process.env.NODE_ENV !== "production";
|
|
172
|
+
const rescanPerRequest = process.env.NODE_ENV !== "production";
|
|
188
173
|
let renderer = null;
|
|
189
174
|
const knownRoutes = /* @__PURE__ */ new Set();
|
|
190
175
|
const draftRoutes = /* @__PURE__ */ new Set();
|
|
@@ -248,6 +233,8 @@ function createDocsRoute(options) {
|
|
|
248
233
|
...options.themes === void 0 ? {} : { themes: options.themes },
|
|
249
234
|
...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
|
|
250
235
|
...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
|
|
236
|
+
...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
|
|
237
|
+
...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
|
|
251
238
|
...options.linkResolver === void 0 ? {} : { linkResolver: options.linkResolver },
|
|
252
239
|
...options.imageResolver === void 0 ? {} : { imageResolver: options.imageResolver }
|
|
253
240
|
});
|
|
@@ -303,28 +290,56 @@ function createDocsRoute(options) {
|
|
|
303
290
|
const renderer = loadRenderer();
|
|
304
291
|
return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
|
|
305
292
|
};
|
|
293
|
+
const searchIndexUrl = `${config.basePath}/search-index.json`;
|
|
294
|
+
/**
|
|
295
|
+
* Fail loudly when the search-index route was not frozen at build time.
|
|
296
|
+
*
|
|
297
|
+
* The signal is `NEXT_PHASE`, which Next sets to `phase-production-build`
|
|
298
|
+
* while prerendering — verified in both output modes, and `undefined` under
|
|
299
|
+
* `next start`. It is internal and undocumented, which is why the CI smoke
|
|
300
|
+
* build asserts the prerendered body exists: if this ever changes meaning it
|
|
301
|
+
* fails there, in this repository, rather than in a consumer's deploy.
|
|
302
|
+
*
|
|
303
|
+
* The alternative was a `console.error`, and it is not one. A warning in a
|
|
304
|
+
* serverless log is unread, and the observable symptom — a search dialog
|
|
305
|
+
* stuck on "could not load the index" — arrives days later with nothing
|
|
306
|
+
* connecting it to a missing line in a route file.
|
|
307
|
+
*/
|
|
308
|
+
const assertPrerendered = () => {
|
|
309
|
+
if (process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build") throw docsError("search-index-dynamic", `the search index was requested at runtime instead of being built into your deployment. Add \`export const dynamic = 'force-static'\` to app${searchIndexUrl}/route.ts — it has to be a literal, like \`dynamicParams\`. Without it Next re-renders every page of your corpus per request, from markdown that is not in the deployment bundle. (Building the index somewhere else on purpose? Use \`docs.renderAll()\` with \`extractSearchRecords\` and \`buildSearchIndex\` from \`@waveso/docs/search-index\` instead of calling this handler.)`);
|
|
310
|
+
};
|
|
311
|
+
const searchIndex = async () => {
|
|
312
|
+
assertPrerendered();
|
|
313
|
+
const { buildSearchIndex, extractSearchRecords } = await import("./search-index.js");
|
|
314
|
+
const json = buildSearchIndex((await renderAll()).flatMap((doc) => extractSearchRecords(doc)), options.miniSearchOptions ?? {});
|
|
315
|
+
return new Response(json, { headers: {
|
|
316
|
+
"content-type": "application/json",
|
|
317
|
+
"cache-control": "public, max-age=0, must-revalidate",
|
|
318
|
+
etag: `"${createHash("sha1").update(json).digest("hex")}"`
|
|
319
|
+
} });
|
|
320
|
+
};
|
|
306
321
|
async function renderRoute(segments) {
|
|
307
322
|
const doc = await getPage(segments);
|
|
308
323
|
if (doc === void 0) return (await loadNotFound())();
|
|
309
324
|
const components = await loadNextComponents();
|
|
310
|
-
return createElement("
|
|
311
|
-
className: "wave-docs-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
tabIndex: -1
|
|
315
|
-
}
|
|
325
|
+
return createElement(Fragment, null, createElement("main", {
|
|
326
|
+
className: "wave-docs-layout__main",
|
|
327
|
+
id: DOCS_CONTENT_ID,
|
|
328
|
+
tabIndex: -1
|
|
316
329
|
}, createElement(DocContent, {
|
|
317
330
|
hast: doc.hast,
|
|
318
331
|
components: {
|
|
319
332
|
...components,
|
|
320
333
|
...options.components
|
|
321
334
|
}
|
|
322
|
-
}));
|
|
335
|
+
})), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, { entries: doc.toc })));
|
|
323
336
|
}
|
|
324
337
|
return {
|
|
325
338
|
source: requestScopedSource,
|
|
326
339
|
getPage,
|
|
327
340
|
renderAll,
|
|
341
|
+
searchIndex,
|
|
342
|
+
searchIndexUrl,
|
|
328
343
|
dynamicParams: false,
|
|
329
344
|
async Page({ params }) {
|
|
330
345
|
const { slug } = await params;
|
|
@@ -333,6 +348,22 @@ function createDocsRoute(options) {
|
|
|
333
348
|
async IndexPage() {
|
|
334
349
|
return renderRoute([]);
|
|
335
350
|
},
|
|
351
|
+
async Layout({ children, title, actions, search, labels }) {
|
|
352
|
+
const { DocsLayoutShell } = await import("./react/layout.js");
|
|
353
|
+
const searchProps = search === false ? false : {
|
|
354
|
+
...options.miniSearchOptions === void 0 ? {} : { miniSearchOptions: options.miniSearchOptions },
|
|
355
|
+
...search === true || search === void 0 ? {} : search
|
|
356
|
+
};
|
|
357
|
+
return createElement(DocsLayoutShell, {
|
|
358
|
+
children,
|
|
359
|
+
nav: await requestScopedSource.nav(),
|
|
360
|
+
searchIndexUrl,
|
|
361
|
+
search: searchProps,
|
|
362
|
+
...title === void 0 ? {} : { title },
|
|
363
|
+
...actions === void 0 ? {} : { actions },
|
|
364
|
+
...labels === void 0 ? {} : { labels }
|
|
365
|
+
});
|
|
366
|
+
},
|
|
336
367
|
async generateStaticParams() {
|
|
337
368
|
if (rescanPerRequest) invalidate();
|
|
338
369
|
return (await source.slugs()).filter((segments) => segments.length > 0).map((segments) => ({ slug: segments }));
|
|
@@ -378,9 +409,10 @@ function createDocsRoute(options) {
|
|
|
378
409
|
async function createDocsSitemap(options) {
|
|
379
410
|
const siteUrl = requireSiteUrl(options.siteUrl);
|
|
380
411
|
const source = createDocsSource(options);
|
|
381
|
-
if (
|
|
412
|
+
if (process.env.NODE_ENV !== "production") source.invalidate();
|
|
382
413
|
const files = await source.all();
|
|
383
|
-
|
|
414
|
+
const oversized = sitemapLimitWarning(files.length);
|
|
415
|
+
if (oversized !== void 0) console.warn(oversized);
|
|
384
416
|
const readDate = options.lastModified ?? readMtime;
|
|
385
417
|
return Promise.all(files.map(async (file) => {
|
|
386
418
|
const lastModified = await readDate(file);
|
|
@@ -1,14 +1,35 @@
|
|
|
1
1
|
import { isFootnotes, isTransparentContainer } from "../section-boundary.js";
|
|
2
2
|
import { toString } from "hast-util-to-string";
|
|
3
3
|
//#region src/plugins/rehype-capture-toc.ts
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* `h2` and `h3`. `h1` is the page title, and h4–h6 are too deep to navigate.
|
|
6
|
+
*
|
|
7
|
+
* Measured on a synthetic API page — 8 methods, 3 overloads each, 3
|
|
8
|
+
* subsections apiece — capturing h2–h6 gave **104 entries and 6,797 bytes** of
|
|
9
|
+
* flight payload against **32 entries and 2,321 bytes** capped at h3. A rail
|
|
10
|
+
* with a hundred entries is not a table of contents; it is the page again, in
|
|
11
|
+
* a narrower column.
|
|
12
|
+
*
|
|
13
|
+
* Stripe, Linear, Mintlify, Fumadocs and Docusaurus all cap at h2+h3 —
|
|
14
|
+
* Docusaurus's defaults are literally 2 and 3.
|
|
15
|
+
*
|
|
16
|
+
* Nothing becomes unreachable by cutting here. `rehype-slug` and
|
|
17
|
+
* `rehype-autolink-headings` still give every h4–h6 an id and a permalink, so
|
|
18
|
+
* they are still linkable and still land in the search index, which opens
|
|
19
|
+
* sections on its own walk. There is deliberately no `maxDepth` option: the
|
|
20
|
+
* escape hatch is `rehypePlugins`, where a plugin writing its own
|
|
21
|
+
* `file.data.toc` is about forty lines.
|
|
22
|
+
*/
|
|
23
|
+
const HEADING = /^h([23])$/;
|
|
6
24
|
/**
|
|
7
25
|
* Drop the permalink anchor `rehype-autolink-headings` appends.
|
|
8
26
|
*
|
|
9
|
-
* This
|
|
10
|
-
*
|
|
11
|
-
*
|
|
27
|
+
* ⚠️ LOAD-BEARING NOW. This used to run *before* autolinking, so there was
|
|
28
|
+
* nothing to drop and this was insurance against an order that might change.
|
|
29
|
+
* The order changed: the capture is dead last, after Shiki and after the
|
|
30
|
+
* consumer's own plugins, so every heading really does carry an appended
|
|
31
|
+
* anchor by the time this walks it. Delete this and every TOC entry gains a
|
|
32
|
+
* trailing `#`.
|
|
12
33
|
*/
|
|
13
34
|
function isPermalink(child) {
|
|
14
35
|
if (child.type !== "element" || child.tagName !== "a") return false;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Plugin } from "unified";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/plugins/rehype-code-frame.d.ts
|
|
4
|
+
interface RehypeCodeFrameOptions {
|
|
5
|
+
/** Accessible name when a fence has no title. */
|
|
6
|
+
copyLabel?: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
declare const rehypeCodeFrame: Plugin<[RehypeCodeFrameOptions?], Root>;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { RehypeCodeFrameOptions, rehypeCodeFrame };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { CODE_COPY_ATTRIBUTE, CODE_FRAME_ATTRIBUTE } from "../code-frame.js";
|
|
2
|
+
import { parseCodeMeta } from "../code-meta.js";
|
|
3
|
+
import { CONTINUE, SKIP, visit } from "unist-util-visit";
|
|
4
|
+
//#region src/plugins/rehype-code-frame.ts
|
|
5
|
+
/** `language-ts` on the `<code>`, already folded to lower case by step 12. */
|
|
6
|
+
const LANGUAGE_CLASS = /^language-(.+)$/;
|
|
7
|
+
const rehypeCodeFrame = (options = {}) => {
|
|
8
|
+
const copyLabel = options.copyLabel ?? "Copy code";
|
|
9
|
+
return (tree, file) => {
|
|
10
|
+
const path = file.data.docLinkContext?.relativePath ?? file.path ?? "a document";
|
|
11
|
+
visit(tree, "element", (node, index, parent) => {
|
|
12
|
+
if (node.tagName !== "pre") return CONTINUE;
|
|
13
|
+
if (parent === void 0 || index === void 0) return CONTINUE;
|
|
14
|
+
const code = node.children[0];
|
|
15
|
+
if (code === void 0 || code.type !== "element") return CONTINUE;
|
|
16
|
+
if (code.tagName !== "code") return CONTINUE;
|
|
17
|
+
const meta = readMeta(code);
|
|
18
|
+
const { title } = parseCodeMeta(meta, path);
|
|
19
|
+
const language = readLanguage(code);
|
|
20
|
+
const children = [];
|
|
21
|
+
if (title !== void 0) children.push({
|
|
22
|
+
type: "element",
|
|
23
|
+
tagName: "figcaption",
|
|
24
|
+
properties: { className: ["wave-docs-code__title"] },
|
|
25
|
+
children: [{
|
|
26
|
+
type: "text",
|
|
27
|
+
value: title
|
|
28
|
+
}]
|
|
29
|
+
});
|
|
30
|
+
children.push(copyButton(title === void 0 ? copyLabel : `${copyLabel} from ${title}`), node);
|
|
31
|
+
parent.children[index] = {
|
|
32
|
+
type: "element",
|
|
33
|
+
tagName: "figure",
|
|
34
|
+
properties: {
|
|
35
|
+
className: ["wave-docs-code"],
|
|
36
|
+
[CODE_FRAME_ATTRIBUTE]: "",
|
|
37
|
+
...language === void 0 ? {} : { "data-lang": language }
|
|
38
|
+
},
|
|
39
|
+
children
|
|
40
|
+
};
|
|
41
|
+
return SKIP;
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* A real `<button type="button">`, so Enter and Space work with no key
|
|
47
|
+
* handling of ours and the control is announced as a button.
|
|
48
|
+
*
|
|
49
|
+
* It sits before the `<pre>`, which is what makes the tab order read "copy
|
|
50
|
+
* this block" → "the scrollable code region" rather than the reverse.
|
|
51
|
+
*/
|
|
52
|
+
function copyButton(label) {
|
|
53
|
+
return {
|
|
54
|
+
type: "element",
|
|
55
|
+
tagName: "button",
|
|
56
|
+
properties: {
|
|
57
|
+
type: "button",
|
|
58
|
+
className: ["wave-docs-code__copy"],
|
|
59
|
+
[CODE_COPY_ATTRIBUTE]: "",
|
|
60
|
+
"aria-label": label
|
|
61
|
+
},
|
|
62
|
+
children: [{
|
|
63
|
+
type: "element",
|
|
64
|
+
tagName: "span",
|
|
65
|
+
properties: { "aria-hidden": "true" },
|
|
66
|
+
children: [{
|
|
67
|
+
type: "text",
|
|
68
|
+
value: "⧉"
|
|
69
|
+
}]
|
|
70
|
+
}]
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** `code.data.meta` — read, never written. */
|
|
74
|
+
function readMeta(code) {
|
|
75
|
+
const meta = code.data?.meta;
|
|
76
|
+
return typeof meta === "string" ? meta : void 0;
|
|
77
|
+
}
|
|
78
|
+
function readLanguage(code) {
|
|
79
|
+
const classNames = code.properties.className;
|
|
80
|
+
if (!Array.isArray(classNames)) return void 0;
|
|
81
|
+
for (const name of classNames) {
|
|
82
|
+
if (typeof name !== "string") continue;
|
|
83
|
+
const found = LANGUAGE_CLASS.exec(name)?.[1];
|
|
84
|
+
if (found !== void 0) return found;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
//#endregion
|
|
88
|
+
export { rehypeCodeFrame };
|
|
@@ -40,7 +40,13 @@ const rehypeNormalizeCodeLanguage = (options = {}) => {
|
|
|
40
40
|
const rehypeRestoreExcludedCode = () => {
|
|
41
41
|
return (tree) => {
|
|
42
42
|
visit(tree, "element", (node) => {
|
|
43
|
-
if (node.tagName === "wave-docs-excluded-pre")
|
|
43
|
+
if (node.tagName === "wave-docs-excluded-pre") {
|
|
44
|
+
node.tagName = "pre";
|
|
45
|
+
node.properties = {
|
|
46
|
+
...node.properties,
|
|
47
|
+
tabIndex: 0
|
|
48
|
+
};
|
|
49
|
+
}
|
|
44
50
|
});
|
|
45
51
|
};
|
|
46
52
|
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
//#region src/react/code-runtime.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Mount the copy runtime. Renders nothing.
|
|
5
|
+
*
|
|
6
|
+
* Every hook of state lives in the DOM rather than in React: the button's
|
|
7
|
+
* copied state is a `data-copied` attribute the stylesheet reads, and the
|
|
8
|
+
* announcement is a live region. React owns none of these nodes, so nothing
|
|
9
|
+
* re-renders and there is no state to get out of step with a page that was
|
|
10
|
+
* server-rendered.
|
|
11
|
+
*/
|
|
12
|
+
declare function DocsCodeRuntime(): ReactNode;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { DocsCodeRuntime };
|