@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/README.md +490 -75
  3. package/dist/code-frame.d.ts +29 -0
  4. package/dist/code-frame.js +41 -0
  5. package/dist/code-meta.d.ts +48 -0
  6. package/dist/code-meta.js +72 -0
  7. package/dist/docs-content-id.d.ts +19 -0
  8. package/dist/docs-content-id.js +19 -0
  9. package/dist/docs-error.d.ts +2 -57
  10. package/dist/docs-error.js +3 -15
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/next.d.ts +153 -28
  14. package/dist/next.js +65 -33
  15. package/dist/plugins/rehype-capture-toc.js +26 -5
  16. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  17. package/dist/plugins/rehype-code-frame.js +88 -0
  18. package/dist/plugins/rehype-code-language.js +7 -1
  19. package/dist/react/code-runtime.d.ts +14 -0
  20. package/dist/react/code-runtime.js +161 -0
  21. package/dist/react/doc-content.d.ts +39 -2
  22. package/dist/react/doc-content.js +42 -10
  23. package/dist/react/layout.d.ts +44 -0
  24. package/dist/react/layout.js +65 -0
  25. package/dist/react/nav.d.ts +28 -0
  26. package/dist/react/nav.js +70 -0
  27. package/dist/react/nearest-scroll-top.d.ts +45 -0
  28. package/dist/react/nearest-scroll-top.js +44 -0
  29. package/dist/react/next-link.d.ts +34 -0
  30. package/dist/react/next-link.js +30 -0
  31. package/dist/react/next-nav.d.ts +11 -0
  32. package/dist/react/next-nav.js +32 -0
  33. package/dist/react/next-search.d.ts +22 -0
  34. package/dist/react/next-search.js +52 -0
  35. package/dist/react/search-dialog.d.ts +53 -10
  36. package/dist/react/search-dialog.js +147 -47
  37. package/dist/react/shell-labels.d.ts +43 -0
  38. package/dist/react/shell-labels.js +27 -0
  39. package/dist/react/sidebar.d.ts +38 -3
  40. package/dist/react/sidebar.js +104 -12
  41. package/dist/react/skip-link.d.ts +1 -9
  42. package/dist/react/skip-link.js +6 -5
  43. package/dist/react/toc.d.ts +12 -4
  44. package/dist/react/toc.js +18 -7
  45. package/dist/react/youtube.d.ts +31 -5
  46. package/dist/react/youtube.js +76 -54
  47. package/dist/render.d.ts +35 -1
  48. package/dist/render.js +35 -14
  49. package/dist/route-path.d.ts +46 -0
  50. package/dist/route-path.js +51 -0
  51. package/dist/search-index.d.ts +6 -23
  52. package/dist/search-index.js +6 -51
  53. package/dist/sitemap-limit.d.ts +34 -0
  54. package/dist/sitemap-limit.js +37 -0
  55. package/dist/source.d.ts +1 -23
  56. package/dist/source.js +40 -43
  57. package/dist/styles.css +1001 -106
  58. package/dist/types.d.ts +11 -2
  59. 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
- * `id` of the rendered `<article>`, which is also what
31
- * `@waveso/docs/react/skip-link` targets by default. Defaults to
32
- * `'docs-content'`. Pass `false` to render no id at all.
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
- contentId?: string | false | undefined;
38
+ remarkPlugins?: PluggableList | undefined;
35
39
  /**
36
- * Re-read the content directory on every request.
37
- *
38
- * Defaults to `true` outside `NODE_ENV=production`. Markdown files are not in
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
- rescanPerRequest?: boolean | undefined;
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 input to
179
- * `extractSearchRecords`/`writeSearchIndex` nothing builds the search index
180
- * for you.
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/skip-link.js";
6
+ import { DocsToc } from "./react/toc.js";
7
+ import { wrapNextLink } from "./react/next-link.js";
6
8
  import { createDocsRenderer } from "./render.js";
7
- import { createDocsSource, resolveDocsConfig, toAliasRoute } from "./source.js";
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 { cache, createElement } from "react";
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 contentId = options.contentId ?? "docs-content";
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("article", {
311
- className: "wave-docs-prose",
312
- ...contentId === false ? {} : {
313
- id: contentId,
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 (options.rescanPerRequest ?? process.env.NODE_ENV !== "production") source.invalidate();
412
+ if (process.env.NODE_ENV !== "production") source.invalidate();
382
413
  const files = await source.all();
383
- if (files.length > SITEMAP_URL_LIMIT) console.warn(`@waveso/docs: this sitemap has ${files.length} URLs, above Google's limit of ${SITEMAP_URL_LIMIT}. Split it with Next's \`generateSitemaps\` and slice the array this returns.`);
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
- /** `h2`–`h6`. `h1` is the page title and never appears in a TOC. */
5
- const HEADING = /^h([2-6])$/;
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 plugin is ordered before that one, so in practice there is nothing to
10
- * drop but the check costs nothing and the alternative, if the order ever
11
- * changes, is every TOC entry silently gaining a trailing `#`.
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") node.tagName = "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 };