@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +518 -0
  3. package/dist/frontmatter.d.ts +55 -0
  4. package/dist/frontmatter.js +80 -0
  5. package/dist/highlighter.d.ts +99 -0
  6. package/dist/highlighter.js +183 -0
  7. package/dist/meta.d.ts +75 -0
  8. package/dist/meta.js +183 -0
  9. package/dist/next.d.ts +256 -0
  10. package/dist/next.js +365 -0
  11. package/dist/plugins/rehype-capture-toc.d.ts +18 -0
  12. package/dist/plugins/rehype-capture-toc.js +69 -0
  13. package/dist/plugins/remark-doc-links.d.ts +63 -0
  14. package/dist/plugins/remark-doc-links.js +122 -0
  15. package/dist/plugins/remark-unwrap-images.d.ts +11 -0
  16. package/dist/plugins/remark-unwrap-images.js +25 -0
  17. package/dist/plugins/remark-youtube.d.ts +22 -0
  18. package/dist/plugins/remark-youtube.js +84 -0
  19. package/dist/react/callout.d.ts +37 -0
  20. package/dist/react/callout.js +113 -0
  21. package/dist/react/doc-content.d.ts +29 -0
  22. package/dist/react/doc-content.js +30 -0
  23. package/dist/react/markdown-components.d.ts +84 -0
  24. package/dist/react/markdown-components.js +122 -0
  25. package/dist/react/search-dialog.d.ts +41 -0
  26. package/dist/react/search-dialog.js +404 -0
  27. package/dist/react/sidebar.d.ts +29 -0
  28. package/dist/react/sidebar.js +196 -0
  29. package/dist/react/skip-link.d.ts +37 -0
  30. package/dist/react/skip-link.js +37 -0
  31. package/dist/react/toc.d.ts +35 -0
  32. package/dist/react/toc.js +87 -0
  33. package/dist/react/youtube.d.ts +27 -0
  34. package/dist/react/youtube.js +75 -0
  35. package/dist/render.d.ts +72 -0
  36. package/dist/render.js +279 -0
  37. package/dist/search-index.d.ts +51 -0
  38. package/dist/search-index.js +274 -0
  39. package/dist/search-options.d.ts +18 -0
  40. package/dist/search-options.js +40 -0
  41. package/dist/source.d.ts +67 -0
  42. package/dist/source.js +332 -0
  43. package/dist/styles.css +1033 -0
  44. package/dist/types.d.ts +334 -0
  45. package/dist/types.js +0 -0
  46. 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 };