@waveso/docs 0.1.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/LICENSE +21 -0
- package/README.md +518 -0
- package/dist/frontmatter.d.ts +55 -0
- package/dist/frontmatter.js +80 -0
- package/dist/highlighter.d.ts +99 -0
- package/dist/highlighter.js +183 -0
- package/dist/meta.d.ts +75 -0
- package/dist/meta.js +183 -0
- package/dist/next.d.ts +256 -0
- package/dist/next.js +365 -0
- package/dist/plugins/rehype-capture-toc.d.ts +18 -0
- package/dist/plugins/rehype-capture-toc.js +69 -0
- package/dist/plugins/remark-doc-links.d.ts +63 -0
- package/dist/plugins/remark-doc-links.js +122 -0
- package/dist/plugins/remark-unwrap-images.d.ts +11 -0
- package/dist/plugins/remark-unwrap-images.js +25 -0
- package/dist/plugins/remark-youtube.d.ts +22 -0
- package/dist/plugins/remark-youtube.js +84 -0
- package/dist/react/callout.d.ts +37 -0
- package/dist/react/callout.js +113 -0
- package/dist/react/doc-content.d.ts +29 -0
- package/dist/react/doc-content.js +30 -0
- package/dist/react/markdown-components.d.ts +84 -0
- package/dist/react/markdown-components.js +122 -0
- package/dist/react/search-dialog.d.ts +41 -0
- package/dist/react/search-dialog.js +404 -0
- package/dist/react/sidebar.d.ts +29 -0
- package/dist/react/sidebar.js +196 -0
- package/dist/react/skip-link.d.ts +37 -0
- package/dist/react/skip-link.js +37 -0
- package/dist/react/toc.d.ts +35 -0
- package/dist/react/toc.js +87 -0
- package/dist/react/youtube.d.ts +27 -0
- package/dist/react/youtube.js +75 -0
- package/dist/render.d.ts +72 -0
- package/dist/render.js +279 -0
- package/dist/search-index.d.ts +51 -0
- package/dist/search-index.js +274 -0
- package/dist/search-options.d.ts +18 -0
- package/dist/search-options.js +40 -0
- package/dist/source.d.ts +67 -0
- package/dist/source.js +332 -0
- package/dist/styles.css +1033 -0
- package/dist/types.d.ts +334 -0
- package/dist/types.js +0 -0
- package/package.json +166 -0
package/dist/next.d.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { DocFile, DocFrontmatter, DocsConfig, ImageResolver, LinkResolver, RenderedDoc } from "./types.js";
|
|
2
|
+
import { DocsHighlighter, DocsLang, DocsTheme, DocsThemes } from "./highlighter.js";
|
|
3
|
+
import { MarkdownComponents } from "./react/markdown-components.js";
|
|
4
|
+
import { DocsSource } from "./source.js";
|
|
5
|
+
import { ReactNode } from "react";
|
|
6
|
+
//#region src/next.d.ts
|
|
7
|
+
interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
|
|
8
|
+
/** Overrides merged over the Next-flavoured defaults (`next/link` + `next/image`). */
|
|
9
|
+
components?: MarkdownComponents;
|
|
10
|
+
/** Reuse an existing Shiki highlighter. */
|
|
11
|
+
highlighter?: DocsHighlighter | Promise<DocsHighlighter>;
|
|
12
|
+
/** Grammars to load, when building the default highlighter. */
|
|
13
|
+
langs?: readonly DocsLang[];
|
|
14
|
+
/** Theme pair. */
|
|
15
|
+
themes?: DocsThemes;
|
|
16
|
+
/**
|
|
17
|
+
* Prepend an `<h1>` from `frontmatter.title` when the markdown has none.
|
|
18
|
+
* Defaults to `true`; turn it off if your layout renders the title itself.
|
|
19
|
+
*/
|
|
20
|
+
titleHeading?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* `id` of the rendered `<article>`, which is also what
|
|
23
|
+
* `@waveso/docs/react/skip-link` targets by default. Defaults to
|
|
24
|
+
* `'docs-content'`. Pass `false` to render no id at all.
|
|
25
|
+
*/
|
|
26
|
+
contentId?: string | false;
|
|
27
|
+
/**
|
|
28
|
+
* Re-read the content directory on every request.
|
|
29
|
+
*
|
|
30
|
+
* Defaults to `true` outside `NODE_ENV=production`. Markdown files are not in
|
|
31
|
+
* Next's module graph, so nothing re-evaluates a route module when one
|
|
32
|
+
* changes: without this, `next dev` serves whatever it read on the first
|
|
33
|
+
* request until the server restarts, and a file added afterwards is never
|
|
34
|
+
* found. A rescan of a few hundred small files costs single-digit
|
|
35
|
+
* milliseconds; a production build reads the tree once, as it should.
|
|
36
|
+
*/
|
|
37
|
+
rescanPerRequest?: boolean;
|
|
38
|
+
/** Replaces the built-in markdown-link resolution. */
|
|
39
|
+
linkResolver?: LinkResolver;
|
|
40
|
+
/**
|
|
41
|
+
* Resolves image `src` to a public URL and intrinsic dimensions. Without one,
|
|
42
|
+
* markdown images render as a plain `<img>`: `next/image` refuses to render
|
|
43
|
+
* without dimensions, and markdown carries none.
|
|
44
|
+
*/
|
|
45
|
+
imageResolver?: ImageResolver;
|
|
46
|
+
/**
|
|
47
|
+
* Absolute site origin, e.g. `'https://example.com'`.
|
|
48
|
+
*
|
|
49
|
+
* When set, `alternates.canonical` is an absolute URL. When omitted it is a
|
|
50
|
+
* root-relative path, which Next resolves against `metadataBase` — so if you
|
|
51
|
+
* set neither, you ship pages with no usable canonical.
|
|
52
|
+
*/
|
|
53
|
+
siteUrl?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Props Next hands a page in the App Router. */
|
|
56
|
+
interface DocsPageProps {
|
|
57
|
+
/**
|
|
58
|
+
* Next 16 passes route params as a promise, and reading it without awaiting
|
|
59
|
+
* yields a `Promise` object where a string array was expected.
|
|
60
|
+
*
|
|
61
|
+
* `slug` is optional so one signature serves both the catch-all route and
|
|
62
|
+
* the index route, which has no params at all.
|
|
63
|
+
*/
|
|
64
|
+
params: Promise<{
|
|
65
|
+
slug?: string[];
|
|
66
|
+
}>;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Page metadata, shaped to be assignable to Next's `Metadata`.
|
|
70
|
+
*
|
|
71
|
+
* Structural rather than imported for the same reason as
|
|
72
|
+
* {@link NextLinkComponent}: `next` is optional, and our published types must
|
|
73
|
+
* not require it.
|
|
74
|
+
*/
|
|
75
|
+
interface DocsPageMetadata {
|
|
76
|
+
title?: string;
|
|
77
|
+
description?: string;
|
|
78
|
+
alternates?: {
|
|
79
|
+
canonical: string;
|
|
80
|
+
};
|
|
81
|
+
openGraph?: {
|
|
82
|
+
type: 'article';
|
|
83
|
+
title: string;
|
|
84
|
+
description?: string;
|
|
85
|
+
url: string;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* What {@link createDocsRoute} returns.
|
|
90
|
+
*
|
|
91
|
+
* The type parameter comes from `frontmatterSchema` on the options, so
|
|
92
|
+
* `docs.getPage(...)` and `docs.source.all()` hand back your own frontmatter
|
|
93
|
+
* fields without a type argument anywhere in the route file.
|
|
94
|
+
*/
|
|
95
|
+
interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
96
|
+
/**
|
|
97
|
+
* Default export for `app/<basePath>/[...slug]/page.tsx`.
|
|
98
|
+
*
|
|
99
|
+
* The catch-all is required — see {@link DocsRoute.IndexPage}.
|
|
100
|
+
*/
|
|
101
|
+
Page: (props: DocsPageProps) => Promise<ReactNode>;
|
|
102
|
+
/**
|
|
103
|
+
* Default export for `app/<basePath>/page.tsx`. Renders the content root's
|
|
104
|
+
* `index.md`.
|
|
105
|
+
*
|
|
106
|
+
* This second file is not optional, and leaving it out is the most common way
|
|
107
|
+
* to ship this broken. `[...slug]` does not match `/docs` itself: the route
|
|
108
|
+
* table emits `/docs/index`, and `/docs` returns 404.
|
|
109
|
+
*
|
|
110
|
+
* The fix is a sibling `page.tsx`, *not* an optional catch-all `[[...slug]]`.
|
|
111
|
+
* The optional form does match `/docs`, but it also leaves `/docs/index` live
|
|
112
|
+
* and serving byte-identical HTML — a duplicate-content pair with no
|
|
113
|
+
* canonical between them — and it makes `params.slug` possibly `undefined`
|
|
114
|
+
* for every page.
|
|
115
|
+
*/
|
|
116
|
+
IndexPage: () => Promise<ReactNode>;
|
|
117
|
+
/**
|
|
118
|
+
* `generateStaticParams` for the catch-all route.
|
|
119
|
+
*
|
|
120
|
+
* The root `index.md` is deliberately absent: its segments are `[]`, and a
|
|
121
|
+
* catch-all cannot render an empty parameter list. It is served by
|
|
122
|
+
* {@link DocsRoute.IndexPage}.
|
|
123
|
+
*/
|
|
124
|
+
generateStaticParams: () => Promise<Array<{
|
|
125
|
+
slug: string[];
|
|
126
|
+
}>>;
|
|
127
|
+
/** `generateMetadata` for either route file. Sets `alternates.canonical`. */
|
|
128
|
+
generateMetadata: (props: DocsPageProps) => Promise<DocsPageMetadata>;
|
|
129
|
+
/**
|
|
130
|
+
* The value your route file must declare. Read it, do not re-export it.
|
|
131
|
+
*
|
|
132
|
+
* ```ts
|
|
133
|
+
* export const dynamicParams = false; // ✅ a literal
|
|
134
|
+
* export const dynamicParams = docs.dynamicParams; // ❌ fails the build
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* Route segment config is statically parsed out of the module by the
|
|
138
|
+
* compiler, before any of it runs, so it has to be a literal. A member
|
|
139
|
+
* expression fails `next build` outright with "Next.js can't recognize the
|
|
140
|
+
* exported `dynamicParams` field in route. It needs to be a static boolean."
|
|
141
|
+
* (verified against Next 16.3.0 / Turbopack). This field exists so the value
|
|
142
|
+
* is documented in one place and typed as `false`, not so it can be
|
|
143
|
+
* forwarded.
|
|
144
|
+
*
|
|
145
|
+
* Declaring it is not optional. Next defaults `dynamicParams` to `true`,
|
|
146
|
+
* which means a URL that `generateStaticParams` never listed is still
|
|
147
|
+
* rendered on demand — so `/docs/typo` reaches the source layer,
|
|
148
|
+
* `fs.readFile` throws `ENOENT`, and Next answers **HTTP 500**. Google
|
|
149
|
+
* treats a 5xx as a crawl failure and retries it; it treats a 404 as an
|
|
150
|
+
* answer. With the full page set known at build time there is nothing to
|
|
151
|
+
* render on demand anyway.
|
|
152
|
+
*/
|
|
153
|
+
dynamicParams: false;
|
|
154
|
+
/**
|
|
155
|
+
* The underlying source, for layouts: `docs.source.nav()` feeds
|
|
156
|
+
* `DocsSidebar`.
|
|
157
|
+
*/
|
|
158
|
+
source: DocsSource<TFrontmatter>;
|
|
159
|
+
/**
|
|
160
|
+
* Render one page yourself, for a custom layout that needs the TOC or the
|
|
161
|
+
* frontmatter alongside the content. Resolves to `undefined` when no such
|
|
162
|
+
* page exists, or when it is a draft and `includeDrafts` is off.
|
|
163
|
+
*/
|
|
164
|
+
getPage: (segments: string[]) => Promise<RenderedDoc<TFrontmatter> | undefined>;
|
|
165
|
+
/**
|
|
166
|
+
* Every published page, rendered. The input to
|
|
167
|
+
* `extractSearchRecords`/`writeSearchIndex` — nothing builds the search index
|
|
168
|
+
* for you.
|
|
169
|
+
*/
|
|
170
|
+
renderAll: () => Promise<Array<RenderedDoc<TFrontmatter>>>;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Create the route handlers for a documentation tree.
|
|
174
|
+
*
|
|
175
|
+
* Call it once at module scope in each of the two route files. The filesystem
|
|
176
|
+
* scan, the highlighter and the component map are all shared per process, so
|
|
177
|
+
* the second call is free.
|
|
178
|
+
*/
|
|
179
|
+
declare function createDocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter>(options: DocsRouteOptions<TFrontmatter>): DocsRoute<TFrontmatter>;
|
|
180
|
+
/** One entry of Next's `MetadataRoute.Sitemap`. */
|
|
181
|
+
interface DocsSitemapEntry {
|
|
182
|
+
url: string;
|
|
183
|
+
lastModified?: Date;
|
|
184
|
+
changeFrequency?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
|
185
|
+
priority?: number;
|
|
186
|
+
}
|
|
187
|
+
interface DocsSitemapOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter> extends DocsConfig<TFrontmatter> {
|
|
188
|
+
/**
|
|
189
|
+
* Absolute site origin, e.g. `'https://example.com'`. Required: sitemap
|
|
190
|
+
* URLs must be absolute, and a relative one makes the whole file invalid.
|
|
191
|
+
*/
|
|
192
|
+
siteUrl: string;
|
|
193
|
+
/** Applied to every entry. Omitted by default — Google ignores it anyway. */
|
|
194
|
+
changeFrequency?: DocsSitemapEntry['changeFrequency'];
|
|
195
|
+
/** Applied to every entry. Omitted by default. */
|
|
196
|
+
priority?: number;
|
|
197
|
+
/**
|
|
198
|
+
* Override the last-modified date per page.
|
|
199
|
+
*
|
|
200
|
+
* The default is the file's mtime, which on a CI runner is the *checkout*
|
|
201
|
+
* time — every page then claims to have changed today, and `lastmod` becomes
|
|
202
|
+
* noise. Wire this to your git history if the dates are load-bearing.
|
|
203
|
+
*/
|
|
204
|
+
lastModified?: (file: DocFile<TFrontmatter>) => Date | undefined | Promise<Date | undefined>;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Sitemap entries for every published page, for `app/sitemap.ts`.
|
|
208
|
+
*
|
|
209
|
+
* Drafts are excluded, as are aliases — an alias is a redirect, and listing a
|
|
210
|
+
* redirect in a sitemap is a crawl error.
|
|
211
|
+
*
|
|
212
|
+
* ```ts
|
|
213
|
+
* // app/sitemap.ts
|
|
214
|
+
* import { createDocsSitemap } from '@waveso/docs/next';
|
|
215
|
+
*
|
|
216
|
+
* export default async function sitemap() {
|
|
217
|
+
* return createDocsSitemap({
|
|
218
|
+
* contentDir: 'content/docs',
|
|
219
|
+
* siteUrl: 'https://example.com',
|
|
220
|
+
* });
|
|
221
|
+
* }
|
|
222
|
+
* ```
|
|
223
|
+
*/
|
|
224
|
+
declare function createDocsSitemap<TFrontmatter extends DocFrontmatter = DocFrontmatter>(options: DocsSitemapOptions<TFrontmatter>): Promise<DocsSitemapEntry[]>;
|
|
225
|
+
/** One entry of `next.config`'s `redirects()`. */
|
|
226
|
+
interface DocsRedirect {
|
|
227
|
+
source: string;
|
|
228
|
+
destination: string;
|
|
229
|
+
/** Always `true`: a renamed page is not coming back to its old URL. */
|
|
230
|
+
permanent: true;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Permanent redirects from every page's `aliases` frontmatter.
|
|
234
|
+
*
|
|
235
|
+
* Renaming a documentation page otherwise breaks every inbound link that ever
|
|
236
|
+
* pointed at it — search results, blog posts, Stack Overflow answers, other
|
|
237
|
+
* people's bookmarks. Adding one line of frontmatter should be the whole cost
|
|
238
|
+
* of a rename.
|
|
239
|
+
*
|
|
240
|
+
* ```ts
|
|
241
|
+
* // next.config.ts
|
|
242
|
+
* import { createDocsRedirects } from '@waveso/docs/next';
|
|
243
|
+
*
|
|
244
|
+
* export default {
|
|
245
|
+
* redirects: () => createDocsRedirects({ contentDir: 'content/docs' }),
|
|
246
|
+
* };
|
|
247
|
+
* ```
|
|
248
|
+
*
|
|
249
|
+
* Aliases are resolved against the docs base path, so `aliases: ['quickstart']`
|
|
250
|
+
* on `/docs/getting-started` redirects `/docs/quickstart`. Throws when two
|
|
251
|
+
* pages claim the same alias, or when an alias collides with a real page —
|
|
252
|
+
* both silently lose a page otherwise, and both are typos.
|
|
253
|
+
*/
|
|
254
|
+
declare function createDocsRedirects(config: DocsConfig): Promise<DocsRedirect[]>;
|
|
255
|
+
//#endregion
|
|
256
|
+
export { type DocsLang, DocsPageMetadata, DocsPageProps, DocsRedirect, DocsRoute, DocsRouteOptions, DocsSitemapEntry, DocsSitemapOptions, type DocsTheme, type DocsThemes, createDocsRedirects, createDocsRoute, createDocsSitemap };
|
package/dist/next.js
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import { createMarkdownComponents } from "./react/markdown-components.js";
|
|
2
|
+
import { DocContent } from "./react/doc-content.js";
|
|
3
|
+
import "./react/skip-link.js";
|
|
4
|
+
import { createDocsRenderer } from "./render.js";
|
|
5
|
+
import { createDocsSource, resolveDocsConfig, toAliasRoute } from "./source.js";
|
|
6
|
+
import { stat } from "node:fs/promises";
|
|
7
|
+
import { createElement } from "react";
|
|
8
|
+
//#region src/next.ts
|
|
9
|
+
/**
|
|
10
|
+
* The Next.js App Router adapter.
|
|
11
|
+
*
|
|
12
|
+
* Wires a content directory to a catch-all route in five lines, with the
|
|
13
|
+
* details that separate a docs site Google indexes from one it does not:
|
|
14
|
+
* `dynamicParams: false`, a real index route, awaited `params`, and a canonical
|
|
15
|
+
* URL on every page.
|
|
16
|
+
*
|
|
17
|
+
* Two route files are required — see {@link DocsRoute.IndexPage} for why:
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* // app/docs/[...slug]/page.tsx
|
|
21
|
+
* import { createDocsRoute } from '@waveso/docs/next';
|
|
22
|
+
*
|
|
23
|
+
* const docs = createDocsRoute({ contentDir: 'content/docs' });
|
|
24
|
+
*
|
|
25
|
+
* export default docs.Page;
|
|
26
|
+
* export const generateStaticParams = docs.generateStaticParams;
|
|
27
|
+
* export const generateMetadata = docs.generateMetadata;
|
|
28
|
+
* export const dynamicParams = false; // must be a literal, see `DocsRoute`
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* ```tsx
|
|
32
|
+
* // app/docs/page.tsx
|
|
33
|
+
* import { createDocsRoute } from '@waveso/docs/next';
|
|
34
|
+
*
|
|
35
|
+
* const docs = createDocsRoute({ contentDir: 'content/docs' });
|
|
36
|
+
*
|
|
37
|
+
* export default docs.IndexPage;
|
|
38
|
+
* export const generateMetadata = docs.generateMetadata;
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* `next` is an *optional* peer dependency, so its modules are imported lazily
|
|
42
|
+
* and only from the code paths that render. That is what lets
|
|
43
|
+
* {@link createDocsSitemap} and {@link createDocsRedirects} be called from
|
|
44
|
+
* `next.config.ts` — which Node loads outside the Next runtime — without
|
|
45
|
+
* dragging React and Next's client runtime into the config load.
|
|
46
|
+
*/
|
|
47
|
+
function isRecord(value) {
|
|
48
|
+
return typeof value === "object" && value !== null;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Pull the default export out of a lazily-imported module.
|
|
52
|
+
*
|
|
53
|
+
* This is the one place a cast is unavoidable — the import crosses a boundary
|
|
54
|
+
* the compiler cannot see. It is guarded by a runtime check so a missing or
|
|
55
|
+
* mis-shaped `next` surfaces as an error naming the package to install, rather
|
|
56
|
+
* than as `undefined is not a function` four frames inside React.
|
|
57
|
+
*/
|
|
58
|
+
function readDefaultExport(mod, specifier) {
|
|
59
|
+
const value = isRecord(mod) && "default" in mod ? mod.default : mod;
|
|
60
|
+
if (typeof value !== "function" && !isRecord(value)) throw new Error(`@waveso/docs: '${specifier}' has no usable default export. The \`@waveso/docs/next\` entry point requires Next.js 16 — install \`next\`, or build your pages from \`@waveso/docs/react/*\` instead.`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Import a `next` module, or explain what is missing.
|
|
65
|
+
*
|
|
66
|
+
* Node's own `ERR_MODULE_NOT_FOUND` for `next/navigation` names a file inside
|
|
67
|
+
* this package, which reads as our bug rather than a missing peer — and the
|
|
68
|
+
* most likely way to reach it is importing `@waveso/docs/next` from a non-Next
|
|
69
|
+
* app, where the fix is to use `@waveso/docs/react/*` with your own loader.
|
|
70
|
+
*/
|
|
71
|
+
async function importNext(load, specifier) {
|
|
72
|
+
try {
|
|
73
|
+
return await load();
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`@waveso/docs: could not load '${specifier}'. The \`@waveso/docs/next\` entry point needs Next.js 16, which is an optional peer dependency — install \`next\`. Outside Next, build your pages from \`@waveso/docs/react/*\` and load content yourself with \`@waveso/docs/source\` and \`@waveso/docs/render\`.`, { cause: error });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async function loadNotFound() {
|
|
79
|
+
const mod = await importNext(() => import("next/navigation"), "next/navigation");
|
|
80
|
+
const value = isRecord(mod) ? mod.notFound : void 0;
|
|
81
|
+
if (typeof value !== "function") throw new Error("@waveso/docs: 'next/navigation' has no `notFound` export. The `@waveso/docs/next` entry point requires Next.js 16.");
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Adapt `next/link` to {@link DocsLinkProps}.
|
|
86
|
+
*
|
|
87
|
+
* `next/link` widens `href` to `string | UrlObject` and `prefetch` to
|
|
88
|
+
* `boolean | null`; the React layer promises neither, because it must also run
|
|
89
|
+
* with a plain `<a>`. One wrapper keeps that mismatch in a single
|
|
90
|
+
* place instead of at every call site.
|
|
91
|
+
*/
|
|
92
|
+
function wrapNextLink(NextLink) {
|
|
93
|
+
return function DocsNextLink({ href, prefetch, children, ...rest }) {
|
|
94
|
+
return createElement(NextLink, {
|
|
95
|
+
...rest,
|
|
96
|
+
href,
|
|
97
|
+
...prefetch === void 0 ? {} : { prefetch }
|
|
98
|
+
}, children);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Adapt `next/image` to {@link DocsImageProps}.
|
|
103
|
+
*
|
|
104
|
+
* Every prop is named rather than spread. `next/image` types `width`/`height`
|
|
105
|
+
* as `number | \`${number}\``, and spreading a `ComponentProps<'img'>`-shaped
|
|
106
|
+
* object into it fails with TS2322 because the DOM types allow a bare `string`.
|
|
107
|
+
* The build-time {@link ImageResolver} has already produced real numbers here.
|
|
108
|
+
*/
|
|
109
|
+
function wrapNextImage(NextImage) {
|
|
110
|
+
return function DocsNextImage({ src, alt, width, height, title, className, sizes, loading }) {
|
|
111
|
+
return createElement(NextImage, {
|
|
112
|
+
src,
|
|
113
|
+
alt,
|
|
114
|
+
width,
|
|
115
|
+
height,
|
|
116
|
+
...title === void 0 ? {} : { title },
|
|
117
|
+
...className === void 0 ? {} : { className },
|
|
118
|
+
...sizes === void 0 ? {} : { sizes },
|
|
119
|
+
...loading === void 0 ? {} : { loading }
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The component map is built once per process.
|
|
125
|
+
*
|
|
126
|
+
* `createMarkdownComponents` returns fresh component identities on every call,
|
|
127
|
+
* and a new identity for `a` remounts every link in the document on every
|
|
128
|
+
* render — so this memo is correctness, not micro-optimisation.
|
|
129
|
+
*/
|
|
130
|
+
let nextComponents = null;
|
|
131
|
+
function loadNextComponents() {
|
|
132
|
+
if (nextComponents === null) nextComponents = buildNextComponents().catch((error) => {
|
|
133
|
+
nextComponents = null;
|
|
134
|
+
throw error;
|
|
135
|
+
});
|
|
136
|
+
return nextComponents;
|
|
137
|
+
}
|
|
138
|
+
async function buildNextComponents() {
|
|
139
|
+
const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
|
|
140
|
+
const NextLink = readDefaultExport(linkMod, "next/link");
|
|
141
|
+
const NextImage = readDefaultExport(imageMod, "next/image");
|
|
142
|
+
return createMarkdownComponents({
|
|
143
|
+
Link: wrapNextLink(NextLink),
|
|
144
|
+
Image: wrapNextImage(NextImage)
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Create the route handlers for a documentation tree.
|
|
149
|
+
*
|
|
150
|
+
* Call it once at module scope in each of the two route files. The filesystem
|
|
151
|
+
* scan, the highlighter and the component map are all shared per process, so
|
|
152
|
+
* the second call is free.
|
|
153
|
+
*/
|
|
154
|
+
function createDocsRoute(options) {
|
|
155
|
+
const config = resolveDocsConfig(options);
|
|
156
|
+
const source = createDocsSource(options);
|
|
157
|
+
const siteUrl = normalizeSiteUrl(options.siteUrl);
|
|
158
|
+
const contentId = options.contentId ?? "docs-content";
|
|
159
|
+
const rescanPerRequest = options.rescanPerRequest ?? process.env.NODE_ENV !== "production";
|
|
160
|
+
let renderer = null;
|
|
161
|
+
const knownRoutes = /* @__PURE__ */ new Set();
|
|
162
|
+
let routesLoaded = null;
|
|
163
|
+
/**
|
|
164
|
+
* Drop the cached scan so the next query reads the disk again.
|
|
165
|
+
*
|
|
166
|
+
* `knownRoutes` is added to, never cleared: it is shared with the renderer,
|
|
167
|
+
* and emptying it while a concurrent render is asserting links would fail
|
|
168
|
+
* that page for a link that is perfectly valid. A route left behind by a
|
|
169
|
+
* deleted page only makes dev *more* permissive than the production build,
|
|
170
|
+
* which is the right direction to be wrong in.
|
|
171
|
+
*/
|
|
172
|
+
const invalidate = () => {
|
|
173
|
+
source.invalidate();
|
|
174
|
+
routesLoaded = null;
|
|
175
|
+
};
|
|
176
|
+
const loadRoutes = () => routesLoaded ??= source.all().then((files) => {
|
|
177
|
+
for (const file of files) {
|
|
178
|
+
knownRoutes.add(file.href);
|
|
179
|
+
for (const alias of file.frontmatter.aliases ?? []) knownRoutes.add(toAliasRoute(alias, config.basePath, file.relativePath));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
const loadRenderer = () => renderer ??= createDocsRenderer({
|
|
183
|
+
config,
|
|
184
|
+
knownRoutes,
|
|
185
|
+
...options.highlighter === void 0 ? {} : { highlighter: options.highlighter },
|
|
186
|
+
...options.langs === void 0 ? {} : { langs: options.langs },
|
|
187
|
+
...options.themes === void 0 ? {} : { themes: options.themes },
|
|
188
|
+
...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
|
|
189
|
+
...options.linkResolver === void 0 ? {} : { linkResolver: options.linkResolver },
|
|
190
|
+
...options.imageResolver === void 0 ? {} : { imageResolver: options.imageResolver }
|
|
191
|
+
});
|
|
192
|
+
/**
|
|
193
|
+
* `find` returns drafts regardless of config so a preview route can opt in;
|
|
194
|
+
* a public route must not.
|
|
195
|
+
*/
|
|
196
|
+
const findVisible = async (segments) => {
|
|
197
|
+
if (rescanPerRequest) invalidate();
|
|
198
|
+
const file = await source.find(segments);
|
|
199
|
+
if (file === void 0) return;
|
|
200
|
+
if (!config.includeDrafts && file.frontmatter.draft === true) return;
|
|
201
|
+
return file;
|
|
202
|
+
};
|
|
203
|
+
const getPage = async (segments) => {
|
|
204
|
+
const file = await findVisible(segments);
|
|
205
|
+
if (file === void 0) return;
|
|
206
|
+
await loadRoutes();
|
|
207
|
+
return loadRenderer().render(file);
|
|
208
|
+
};
|
|
209
|
+
const renderAll = async () => {
|
|
210
|
+
if (rescanPerRequest) invalidate();
|
|
211
|
+
const files = await source.all();
|
|
212
|
+
await loadRoutes();
|
|
213
|
+
const renderer = loadRenderer();
|
|
214
|
+
return Promise.all(files.map((file) => renderer.render(file)));
|
|
215
|
+
};
|
|
216
|
+
async function renderRoute(segments) {
|
|
217
|
+
const doc = await getPage(segments);
|
|
218
|
+
if (doc === void 0) return (await loadNotFound())();
|
|
219
|
+
const components = await loadNextComponents();
|
|
220
|
+
return createElement("article", {
|
|
221
|
+
className: "wave-docs-prose",
|
|
222
|
+
...contentId === false ? {} : {
|
|
223
|
+
id: contentId,
|
|
224
|
+
tabIndex: -1
|
|
225
|
+
}
|
|
226
|
+
}, createElement(DocContent, {
|
|
227
|
+
hast: doc.hast,
|
|
228
|
+
components: {
|
|
229
|
+
...components,
|
|
230
|
+
...options.components
|
|
231
|
+
}
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
source,
|
|
236
|
+
getPage,
|
|
237
|
+
renderAll,
|
|
238
|
+
dynamicParams: false,
|
|
239
|
+
async Page({ params }) {
|
|
240
|
+
const { slug } = await params;
|
|
241
|
+
return renderRoute(slug ?? []);
|
|
242
|
+
},
|
|
243
|
+
async IndexPage() {
|
|
244
|
+
return renderRoute([]);
|
|
245
|
+
},
|
|
246
|
+
async generateStaticParams() {
|
|
247
|
+
if (rescanPerRequest) invalidate();
|
|
248
|
+
return (await source.slugs()).filter((segments) => segments.length > 0).map((segments) => ({ slug: segments }));
|
|
249
|
+
},
|
|
250
|
+
async generateMetadata({ params }) {
|
|
251
|
+
const { slug } = await params;
|
|
252
|
+
const file = await findVisible(slug ?? []);
|
|
253
|
+
if (file === void 0) return {};
|
|
254
|
+
const { title, description } = file.frontmatter;
|
|
255
|
+
const canonical = siteUrl === void 0 ? file.href : new URL(file.href, siteUrl).toString();
|
|
256
|
+
return {
|
|
257
|
+
title,
|
|
258
|
+
...description === void 0 ? {} : { description },
|
|
259
|
+
alternates: { canonical },
|
|
260
|
+
openGraph: {
|
|
261
|
+
type: "article",
|
|
262
|
+
title,
|
|
263
|
+
...description === void 0 ? {} : { description },
|
|
264
|
+
url: canonical
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Sitemap entries for every published page, for `app/sitemap.ts`.
|
|
272
|
+
*
|
|
273
|
+
* Drafts are excluded, as are aliases — an alias is a redirect, and listing a
|
|
274
|
+
* redirect in a sitemap is a crawl error.
|
|
275
|
+
*
|
|
276
|
+
* ```ts
|
|
277
|
+
* // app/sitemap.ts
|
|
278
|
+
* import { createDocsSitemap } from '@waveso/docs/next';
|
|
279
|
+
*
|
|
280
|
+
* export default async function sitemap() {
|
|
281
|
+
* return createDocsSitemap({
|
|
282
|
+
* contentDir: 'content/docs',
|
|
283
|
+
* siteUrl: 'https://example.com',
|
|
284
|
+
* });
|
|
285
|
+
* }
|
|
286
|
+
* ```
|
|
287
|
+
*/
|
|
288
|
+
async function createDocsSitemap(options) {
|
|
289
|
+
const siteUrl = requireSiteUrl(options.siteUrl);
|
|
290
|
+
const files = await createDocsSource(options).all();
|
|
291
|
+
const readDate = options.lastModified ?? readMtime;
|
|
292
|
+
return Promise.all(files.map(async (file) => {
|
|
293
|
+
const lastModified = await readDate(file);
|
|
294
|
+
return {
|
|
295
|
+
url: new URL(file.href, siteUrl).toString(),
|
|
296
|
+
...lastModified === void 0 ? {} : { lastModified },
|
|
297
|
+
...options.changeFrequency === void 0 ? {} : { changeFrequency: options.changeFrequency },
|
|
298
|
+
...options.priority === void 0 ? {} : { priority: options.priority }
|
|
299
|
+
};
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
/** Best-effort mtime. A sitemap is not worth failing a build over. */
|
|
303
|
+
async function readMtime(file) {
|
|
304
|
+
try {
|
|
305
|
+
return (await stat(file.filePath)).mtime;
|
|
306
|
+
} catch {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Permanent redirects from every page's `aliases` frontmatter.
|
|
312
|
+
*
|
|
313
|
+
* Renaming a documentation page otherwise breaks every inbound link that ever
|
|
314
|
+
* pointed at it — search results, blog posts, Stack Overflow answers, other
|
|
315
|
+
* people's bookmarks. Adding one line of frontmatter should be the whole cost
|
|
316
|
+
* of a rename.
|
|
317
|
+
*
|
|
318
|
+
* ```ts
|
|
319
|
+
* // next.config.ts
|
|
320
|
+
* import { createDocsRedirects } from '@waveso/docs/next';
|
|
321
|
+
*
|
|
322
|
+
* export default {
|
|
323
|
+
* redirects: () => createDocsRedirects({ contentDir: 'content/docs' }),
|
|
324
|
+
* };
|
|
325
|
+
* ```
|
|
326
|
+
*
|
|
327
|
+
* Aliases are resolved against the docs base path, so `aliases: ['quickstart']`
|
|
328
|
+
* on `/docs/getting-started` redirects `/docs/quickstart`. Throws when two
|
|
329
|
+
* pages claim the same alias, or when an alias collides with a real page —
|
|
330
|
+
* both silently lose a page otherwise, and both are typos.
|
|
331
|
+
*/
|
|
332
|
+
async function createDocsRedirects(config) {
|
|
333
|
+
const resolved = resolveDocsConfig(config);
|
|
334
|
+
const files = await createDocsSource(config).all();
|
|
335
|
+
const routes = new Map(files.map((file) => [file.href, file]));
|
|
336
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
337
|
+
const redirects = [];
|
|
338
|
+
for (const file of files) for (const alias of file.frontmatter.aliases ?? []) {
|
|
339
|
+
const route = toAliasRoute(alias, resolved.basePath, file.relativePath);
|
|
340
|
+
const page = routes.get(route);
|
|
341
|
+
if (page !== void 0) throw new Error(`@waveso/docs: the alias '${alias}' in ${file.relativePath} redirects '${route}', which is already the route of ${page.relativePath}. Remove the alias, or rename the page it collides with.`);
|
|
342
|
+
const other = claimed.get(route);
|
|
343
|
+
if (other !== void 0) throw new Error(`@waveso/docs: '${route}' is claimed as an alias by both ${other.relativePath} and ${file.relativePath}. An alias can only redirect to one page.`);
|
|
344
|
+
claimed.set(route, file);
|
|
345
|
+
redirects.push({
|
|
346
|
+
source: route,
|
|
347
|
+
destination: file.href,
|
|
348
|
+
permanent: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return redirects;
|
|
352
|
+
}
|
|
353
|
+
function normalizeSiteUrl(siteUrl) {
|
|
354
|
+
return siteUrl === void 0 ? void 0 : requireSiteUrl(siteUrl);
|
|
355
|
+
}
|
|
356
|
+
/** Fail at config time, not with a malformed `<link rel="canonical">`. */
|
|
357
|
+
function requireSiteUrl(siteUrl) {
|
|
358
|
+
try {
|
|
359
|
+
return new URL(siteUrl).toString();
|
|
360
|
+
} catch {
|
|
361
|
+
throw new Error(`@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
export { createDocsRedirects, createDocsRoute, createDocsSitemap };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TocEntry } from "../types.js";
|
|
2
|
+
import { Plugin } from "unified";
|
|
3
|
+
import { Root } from "hast";
|
|
4
|
+
//#region src/plugins/rehype-capture-toc.d.ts
|
|
5
|
+
declare module 'vfile' {
|
|
6
|
+
interface DataMap {
|
|
7
|
+
/** Written by {@link rehypeCaptureToc}. */
|
|
8
|
+
toc: TocEntry[];
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* rehype plugin. Must run after `rehype-slug`; ordering is enforced by the
|
|
13
|
+
* renderer rather than checked here, because a heading legitimately may have
|
|
14
|
+
* been given an id by hand.
|
|
15
|
+
*/
|
|
16
|
+
declare const rehypeCaptureToc: Plugin<[], Root>;
|
|
17
|
+
//#endregion
|
|
18
|
+
export { rehypeCaptureToc };
|