@waveso/docs 0.1.0 → 0.3.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 (79) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/README.md +592 -88
  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 +19 -0
  10. package/dist/docs-error.js +28 -0
  11. package/dist/errors.d.ts +94 -0
  12. package/dist/errors.js +45 -0
  13. package/dist/frontmatter.d.ts +39 -7
  14. package/dist/frontmatter.js +51 -24
  15. package/dist/highlighter.d.ts +2 -2
  16. package/dist/highlighter.js +3 -2
  17. package/dist/map-pooled.d.ts +26 -0
  18. package/dist/map-pooled.js +45 -0
  19. package/dist/meta.d.ts +7 -3
  20. package/dist/meta.js +61 -15
  21. package/dist/next.d.ts +182 -35
  22. package/dist/next.js +177 -49
  23. package/dist/plugins/rehype-capture-toc.js +52 -20
  24. package/dist/plugins/rehype-code-frame.d.ts +10 -0
  25. package/dist/plugins/rehype-code-frame.js +88 -0
  26. package/dist/plugins/rehype-code-language.d.ts +24 -0
  27. package/dist/plugins/rehype-code-language.js +54 -0
  28. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  29. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  30. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  31. package/dist/plugins/rehype-flatten-roots.js +39 -0
  32. package/dist/plugins/remark-doc-links.d.ts +12 -1
  33. package/dist/plugins/remark-doc-links.js +147 -20
  34. package/dist/react/code-runtime.d.ts +14 -0
  35. package/dist/react/code-runtime.js +161 -0
  36. package/dist/react/doc-content.d.ts +39 -2
  37. package/dist/react/doc-content.js +42 -10
  38. package/dist/react/layout.d.ts +44 -0
  39. package/dist/react/layout.js +65 -0
  40. package/dist/react/markdown-components.js +71 -6
  41. package/dist/react/nav.d.ts +28 -0
  42. package/dist/react/nav.js +70 -0
  43. package/dist/react/nearest-scroll-top.d.ts +45 -0
  44. package/dist/react/nearest-scroll-top.js +44 -0
  45. package/dist/react/next-link.d.ts +34 -0
  46. package/dist/react/next-link.js +30 -0
  47. package/dist/react/next-nav.d.ts +11 -0
  48. package/dist/react/next-nav.js +32 -0
  49. package/dist/react/next-search.d.ts +22 -0
  50. package/dist/react/next-search.js +52 -0
  51. package/dist/react/search-dialog.d.ts +35 -7
  52. package/dist/react/search-dialog.js +55 -33
  53. package/dist/react/shell-labels.d.ts +43 -0
  54. package/dist/react/shell-labels.js +27 -0
  55. package/dist/react/sidebar.d.ts +38 -3
  56. package/dist/react/sidebar.js +104 -12
  57. package/dist/react/skip-link.d.ts +1 -9
  58. package/dist/react/skip-link.js +6 -5
  59. package/dist/react/toc.d.ts +12 -4
  60. package/dist/react/toc.js +46 -12
  61. package/dist/react/youtube.d.ts +31 -5
  62. package/dist/react/youtube.js +76 -52
  63. package/dist/render.d.ts +78 -10
  64. package/dist/render.js +137 -54
  65. package/dist/route-path.d.ts +46 -0
  66. package/dist/route-path.js +51 -0
  67. package/dist/search-index.d.ts +22 -21
  68. package/dist/search-index.js +27 -78
  69. package/dist/search-options.d.ts +32 -1
  70. package/dist/search-options.js +66 -3
  71. package/dist/section-boundary.d.ts +17 -0
  72. package/dist/section-boundary.js +43 -0
  73. package/dist/sitemap-limit.d.ts +34 -0
  74. package/dist/sitemap-limit.js +37 -0
  75. package/dist/source.d.ts +12 -22
  76. package/dist/source.js +165 -72
  77. package/dist/styles.css +1117 -125
  78. package/dist/types.d.ts +52 -29
  79. package/package.json +70 -34
package/dist/next.js CHANGED
@@ -1,10 +1,17 @@
1
+ import { docsError } from "./docs-error.js";
2
+ import { DOCS_CONTENT_ID } from "./docs-content-id.js";
3
+ import { mapPooled } from "./map-pooled.js";
1
4
  import { createMarkdownComponents } from "./react/markdown-components.js";
2
5
  import { DocContent } from "./react/doc-content.js";
3
- import "./react/skip-link.js";
6
+ import { DocsToc } from "./react/toc.js";
7
+ import { wrapNextLink } from "./react/next-link.js";
4
8
  import { createDocsRenderer } from "./render.js";
5
- 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";
6
12
  import { stat } from "node:fs/promises";
7
- import { createElement } from "react";
13
+ import { createHash } from "node:crypto";
14
+ import { Fragment, cache, createElement } from "react";
8
15
  //#region src/next.ts
9
16
  /**
10
17
  * The Next.js App Router adapter.
@@ -38,16 +45,39 @@ import { createElement } from "react";
38
45
  * export const generateMetadata = docs.generateMetadata;
39
46
  * ```
40
47
  *
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
48
+ * `next` is an *optional* peer dependency, so **Next's own modules** are
49
+ * imported lazily and only from the code paths that render. That is what lets
43
50
  * {@link createDocsSitemap} and {@link createDocsRedirects} be called from
44
51
  * `next.config.ts` — which Node loads outside the Next runtime — without
45
- * dragging React and Next's client runtime into the config load.
52
+ * dragging Next's client runtime into the config load.
53
+ *
54
+ * React itself is *not* excluded: this module statically imports `react` and
55
+ * the package's own React layer, so importing it from `next.config.ts` costs
56
+ * around 210 ms and ~870 modules (measured against 24 ms for an empty config).
57
+ * That is a startup cost, not a correctness problem, and it is stated here
58
+ * rather than claimed away — an earlier version of this note said React stayed
59
+ * out of the config load, which was never true.
46
60
  */
61
+ /**
62
+ * Pages rendered at once by {@link DocsRoute.renderAll}.
63
+ *
64
+ * High enough that the pipeline — CPU-bound and effectively synchronous — never
65
+ * idles, low enough that an async `imageResolver` cannot put an entire site's
66
+ * worth of trees and network calls in flight simultaneously.
67
+ */
68
+ const RENDER_CONCURRENCY = 16;
47
69
  function isRecord(value) {
48
70
  return typeof value === "object" && value !== null;
49
71
  }
50
72
  /**
73
+ * A React component type: a function, or an object tagged with `$$typeof`
74
+ * (`forwardRef`, `memo`, lazy…). Anything else that reaches `createElement`
75
+ * throws four frames inside React.
76
+ */
77
+ function isComponentLike(value) {
78
+ return typeof value === "function" || isRecord(value) && "$$typeof" in value;
79
+ }
80
+ /**
51
81
  * Pull the default export out of a lazily-imported module.
52
82
  *
53
83
  * This is the one place a cast is unavoidable — the import crosses a boundary
@@ -56,8 +86,9 @@ function isRecord(value) {
56
86
  * than as `undefined is not a function` four frames inside React.
57
87
  */
58
88
  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.`);
89
+ let value = isRecord(mod) && "default" in mod ? mod.default : mod;
90
+ if (!isComponentLike(value) && isRecord(value) && "default" in value) value = value.default;
91
+ if (typeof value !== "function" && !isRecord(value)) throw docsError("missing-peer", `@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
92
  return value;
62
93
  }
63
94
  /**
@@ -72,33 +103,16 @@ async function importNext(load, specifier) {
72
103
  try {
73
104
  return await load();
74
105
  } 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 });
106
+ throw docsError("missing-peer", `@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
107
  }
77
108
  }
78
109
  async function loadNotFound() {
79
110
  const mod = await importNext(() => import("next/navigation"), "next/navigation");
80
111
  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.");
112
+ if (typeof value !== "function") throw docsError("missing-peer", "@waveso/docs: 'next/navigation' has no `notFound` export. The `@waveso/docs/next` entry point requires Next.js 16.");
82
113
  return value;
83
114
  }
84
115
  /**
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
116
  * Adapt `next/image` to {@link DocsImageProps}.
103
117
  *
104
118
  * Every prop is named rather than spread. `next/image` types `width`/`height`
@@ -155,13 +169,26 @@ function createDocsRoute(options) {
155
169
  const config = resolveDocsConfig(options);
156
170
  const source = createDocsSource(options);
157
171
  const siteUrl = normalizeSiteUrl(options.siteUrl);
158
- const contentId = options.contentId ?? "docs-content";
159
- const rescanPerRequest = options.rescanPerRequest ?? process.env.NODE_ENV !== "production";
172
+ const rescanPerRequest = process.env.NODE_ENV !== "production";
160
173
  let renderer = null;
161
174
  const knownRoutes = /* @__PURE__ */ new Set();
175
+ const draftRoutes = /* @__PURE__ */ new Set();
176
+ const aliasRoutes = /* @__PURE__ */ new Map();
162
177
  let routesLoaded = null;
163
178
  /**
164
- * Drop the cached scan so the next query reads the disk again.
179
+ * Drop the cached scan so the next query reads the disk again — at most once
180
+ * per request.
181
+ *
182
+ * `React.cache` is doing real work here, not memoising for speed. Next runs
183
+ * `generateMetadata` and `Page` concurrently, and a layout calling
184
+ * `source.nav()` is a third caller; each used to invalidate independently,
185
+ * so each discarded the others' in-flight scan. Measured on a 401-file tree:
186
+ * 22 readdir + 824 readFile per request, against 11 + 412 for a single scan,
187
+ * at 39 ms — which is also why the old docstring's "single-digit
188
+ * milliseconds" was wrong. Inside a request the first caller invalidates and
189
+ * the rest see the memo; outside one (a sitemap built from `next.config.ts`,
190
+ * a script) `cache` does not memoise at all, so those callers keep the old
191
+ * invalidate-every-time behaviour, which is what they want.
165
192
  *
166
193
  * `knownRoutes` is added to, never cleared: it is shared with the renderer,
167
194
  * and emptying it while a concurrent render is asserting links would fail
@@ -169,26 +196,76 @@ function createDocsRoute(options) {
169
196
  * deleted page only makes dev *more* permissive than the production build,
170
197
  * which is the right direction to be wrong in.
171
198
  */
172
- const invalidate = () => {
199
+ const invalidate = cache(() => {
173
200
  source.invalidate();
174
201
  routesLoaded = null;
175
- };
176
- const loadRoutes = () => routesLoaded ??= source.all().then((files) => {
202
+ });
203
+ /**
204
+ * Record each page's own route in `into`, and each of its aliases in
205
+ * `aliasRoutes`.
206
+ *
207
+ * The two are kept apart on purpose. An alias used to be added to
208
+ * `knownRoutes` on the reasoning that a permanent redirect resolves — but it
209
+ * only resolves once `createDocsRedirects` is wired into `next.config.ts`,
210
+ * which the quick start never does, and `generateStaticParams` does not emit
211
+ * it either. So a page linking a sibling's alias built green and 404'd for
212
+ * every reader, with `dynamicParams = false` making it a hard 404. The
213
+ * renderer now names the alias's target instead, which is better advice than
214
+ * the acceptance ever was: the author gets told where the page actually is.
215
+ */
216
+ const collectRoutes = (into, files) => {
177
217
  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));
218
+ into.add(file.href);
219
+ for (const alias of file.frontmatter.aliases ?? []) aliasRoutes.set(toAliasRoute(alias, config.basePath, file.relativePath), file.href);
180
220
  }
221
+ };
222
+ const loadRoutes = () => routesLoaded ??= Promise.all([source.all(), source.drafts()]).then(([published, drafts]) => {
223
+ collectRoutes(knownRoutes, published);
224
+ collectRoutes(draftRoutes, drafts);
181
225
  });
182
226
  const loadRenderer = () => renderer ??= createDocsRenderer({
183
227
  config,
184
228
  knownRoutes,
229
+ draftRoutes,
230
+ aliasRoutes,
185
231
  ...options.highlighter === void 0 ? {} : { highlighter: options.highlighter },
186
232
  ...options.langs === void 0 ? {} : { langs: options.langs },
187
233
  ...options.themes === void 0 ? {} : { themes: options.themes },
234
+ ...options.excludeLangs === void 0 ? {} : { excludeLangs: options.excludeLangs },
188
235
  ...options.titleHeading === void 0 ? {} : { titleHeading: options.titleHeading },
236
+ ...options.remarkPlugins === void 0 ? {} : { remarkPlugins: options.remarkPlugins },
237
+ ...options.rehypePlugins === void 0 ? {} : { rehypePlugins: options.rehypePlugins },
189
238
  ...options.linkResolver === void 0 ? {} : { linkResolver: options.linkResolver },
190
239
  ...options.imageResolver === void 0 ? {} : { imageResolver: options.imageResolver }
191
240
  });
241
+ /** Re-read the disk on the route's schedule before delegating. */
242
+ const rescanned = (read) => {
243
+ return (...args) => {
244
+ if (rescanPerRequest) invalidate();
245
+ return read(...args);
246
+ };
247
+ };
248
+ /**
249
+ * The source handed to layouts.
250
+ *
251
+ * `docs.source.nav()` is the documented way to feed `DocsSidebar`, and it was
252
+ * the one reader that never invalidated — so in dev, the request after adding
253
+ * or renaming a page rendered the *new* body beside the *old* sidebar, and
254
+ * only the request after that agreed with itself. Everything else on the
255
+ * route already rescanned; this closes the last hole.
256
+ */
257
+ const requestScopedSource = {
258
+ config: source.config,
259
+ all: rescanned(() => source.all()),
260
+ drafts: rescanned(() => source.drafts()),
261
+ find: rescanned((segments) => source.find(segments)),
262
+ nav: rescanned(() => source.nav()),
263
+ slugs: rescanned(() => source.slugs()),
264
+ invalidate: () => {
265
+ source.invalidate();
266
+ routesLoaded = null;
267
+ }
268
+ };
192
269
  /**
193
270
  * `find` returns drafts regardless of config so a preview route can opt in;
194
271
  * a public route must not.
@@ -211,30 +288,58 @@ function createDocsRoute(options) {
211
288
  const files = await source.all();
212
289
  await loadRoutes();
213
290
  const renderer = loadRenderer();
214
- return Promise.all(files.map((file) => renderer.render(file)));
291
+ return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
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
+ } });
215
320
  };
216
321
  async function renderRoute(segments) {
217
322
  const doc = await getPage(segments);
218
323
  if (doc === void 0) return (await loadNotFound())();
219
324
  const components = await loadNextComponents();
220
- return createElement("article", {
221
- className: "wave-docs-prose",
222
- ...contentId === false ? {} : {
223
- id: contentId,
224
- tabIndex: -1
225
- }
325
+ return createElement(Fragment, null, createElement("main", {
326
+ className: "wave-docs-layout__main",
327
+ id: DOCS_CONTENT_ID,
328
+ tabIndex: -1
226
329
  }, createElement(DocContent, {
227
330
  hast: doc.hast,
228
331
  components: {
229
332
  ...components,
230
333
  ...options.components
231
334
  }
232
- }));
335
+ })), doc.toc.length === 0 ? null : createElement("aside", { className: "wave-docs-layout__toc" }, createElement(DocsToc, { entries: doc.toc })));
233
336
  }
234
337
  return {
235
- source,
338
+ source: requestScopedSource,
236
339
  getPage,
237
340
  renderAll,
341
+ searchIndex,
342
+ searchIndexUrl,
238
343
  dynamicParams: false,
239
344
  async Page({ params }) {
240
345
  const { slug } = await params;
@@ -243,6 +348,22 @@ function createDocsRoute(options) {
243
348
  async IndexPage() {
244
349
  return renderRoute([]);
245
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
+ },
246
367
  async generateStaticParams() {
247
368
  if (rescanPerRequest) invalidate();
248
369
  return (await source.slugs()).filter((segments) => segments.length > 0).map((segments) => ({ slug: segments }));
@@ -287,7 +408,11 @@ function createDocsRoute(options) {
287
408
  */
288
409
  async function createDocsSitemap(options) {
289
410
  const siteUrl = requireSiteUrl(options.siteUrl);
290
- const files = await createDocsSource(options).all();
411
+ const source = createDocsSource(options);
412
+ if (process.env.NODE_ENV !== "production") source.invalidate();
413
+ const files = await source.all();
414
+ const oversized = sitemapLimitWarning(files.length);
415
+ if (oversized !== void 0) console.warn(oversized);
291
416
  const readDate = options.lastModified ?? readMtime;
292
417
  return Promise.all(files.map(async (file) => {
293
418
  const lastModified = await readDate(file);
@@ -338,9 +463,9 @@ async function createDocsRedirects(config) {
338
463
  for (const file of files) for (const alias of file.frontmatter.aliases ?? []) {
339
464
  const route = toAliasRoute(alias, resolved.basePath, file.relativePath);
340
465
  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.`);
466
+ if (page !== void 0) throw docsError("alias-collision", `@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
467
  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.`);
468
+ if (other !== void 0) throw docsError("alias-collision", `@waveso/docs: '${route}' is claimed as an alias by both ${other.relativePath} and ${file.relativePath}. An alias can only redirect to one page.`);
344
469
  claimed.set(route, file);
345
470
  redirects.push({
346
471
  source: route,
@@ -355,11 +480,14 @@ function normalizeSiteUrl(siteUrl) {
355
480
  }
356
481
  /** Fail at config time, not with a malformed `<link rel="canonical">`. */
357
482
  function requireSiteUrl(siteUrl) {
483
+ let parsed;
358
484
  try {
359
- return new URL(siteUrl).toString();
485
+ parsed = new URL(siteUrl);
360
486
  } catch {
361
- throw new Error(`@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
487
+ throw docsError("invalid-config", `@waveso/docs: '${siteUrl}' is not an absolute URL. Pass an origin such as 'https://example.com'.`);
362
488
  }
489
+ if (parsed.pathname !== "/") throw docsError("invalid-config", `@waveso/docs: '${siteUrl}' has a path ('${parsed.pathname}'), and a site URL must be a bare origin — canonical and sitemap URLs are resolved against it, which discards the path. Pass '${parsed.origin}' and move '${parsed.pathname}' into \`basePath\`, which does accept multiple segments.`);
490
+ return parsed.toString();
363
491
  }
364
492
  //#endregion
365
493
  export { createDocsRedirects, createDocsRoute, createDocsSitemap };
@@ -1,14 +1,35 @@
1
- import { SKIP, visit } from "unist-util-visit";
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;
@@ -16,17 +37,6 @@ function isPermalink(child) {
16
37
  const ariaHidden = child.properties.ariaHidden;
17
38
  return ariaHidden === true || ariaHidden === "true" || Array.isArray(className) && className.includes("heading-anchor");
18
39
  }
19
- /**
20
- * The GFM footnote block `mdast-util-to-hast` appends.
21
- *
22
- * It carries a generated `<h2 id="footnote-label">Footnotes</h2>` that is
23
- * machinery rather than a section of the page — and is visually hidden, so a
24
- * TOC entry for it points the reader at nothing they can see. `search-index.ts`
25
- * skips the same subtree; the two must agree about which sections exist.
26
- */
27
- function isFootnotes(node) {
28
- return node.properties.dataFootnotes !== void 0;
29
- }
30
40
  function headingText(node) {
31
41
  return node.children.filter((child) => !isPermalink(child)).map((child) => toString(child)).join("").trim();
32
42
  }
@@ -40,8 +50,7 @@ const rehypeCaptureToc = () => {
40
50
  const toc = [];
41
51
  /** Open ancestors, outermost first. */
42
52
  const stack = [];
43
- visit(tree, "element", (node) => {
44
- if (isFootnotes(node)) return SKIP;
53
+ const capture = (node) => {
45
54
  const level = HEADING.exec(node.tagName)?.[1];
46
55
  if (level === void 0) return;
47
56
  const id = node.properties.id;
@@ -61,7 +70,30 @@ const rehypeCaptureToc = () => {
61
70
  if (parent === void 0) toc.push(entry);
62
71
  else parent.children.push(entry);
63
72
  stack.push(entry);
64
- });
73
+ };
74
+ /**
75
+ * Block-level nodes in document order, stepping into wrappers a section may
76
+ * legitimately sit inside — and no further.
77
+ *
78
+ * ⚠️ THE BOUND IS THE POINT, AND IT IS SHARED WITH THE SEARCH INDEX. A
79
+ * whole-tree walk gave a TOC entry to any heading with an id however deeply
80
+ * wrapped, while `extractSearchRecords` opens a section only inside an
81
+ * {@link isTransparentContainer} — so a `## ` written inside a list item or
82
+ * a table cell got a TOC entry with no searchable section behind it, and its
83
+ * prose was folded into the section above under the wrong breadcrumb. One
84
+ * exported predicate is what stops the two drifting apart again.
85
+ */
86
+ const walk = (nodes) => {
87
+ for (const node of nodes) {
88
+ if (node.type !== "element" || isFootnotes(node)) continue;
89
+ if (isTransparentContainer(node)) {
90
+ walk(node.children);
91
+ continue;
92
+ }
93
+ capture(node);
94
+ }
95
+ };
96
+ walk(tree.children);
65
97
  file.data.toc = toc;
66
98
  };
67
99
  };
@@ -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 };
@@ -0,0 +1,24 @@
1
+ import { Plugin } from "unified";
2
+ import { Root } from "hast";
3
+ //#region src/plugins/rehype-code-language.d.ts
4
+ /**
5
+ * The tag an excluded `<pre>` wears while Shiki walks the tree.
6
+ *
7
+ * Exported so a test can prove it never reaches the output: a leaked sentinel
8
+ * is an unstyled block with an invented tag name, which React renders happily
9
+ * and nobody notices.
10
+ */
11
+ declare const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
12
+ interface RehypeCodeLanguageOptions {
13
+ /** Lower-cased fence languages Shiki must leave alone, e.g. `['mermaid']`. */
14
+ exclude?: readonly string[];
15
+ }
16
+ declare const rehypeNormalizeCodeLanguage: Plugin<[RehypeCodeLanguageOptions?], Root>;
17
+ /**
18
+ * Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
19
+ * separate plugin rather than a flag on the first one because unified gives a
20
+ * plugin one position in the pipeline and this needs the other side of Shiki.
21
+ */
22
+ declare const rehypeRestoreExcludedCode: Plugin<[], Root>;
23
+ //#endregion
24
+ export { EXCLUDED_PRE_TAG, RehypeCodeLanguageOptions, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
@@ -0,0 +1,54 @@
1
+ import { CONTINUE, SKIP, visit } from "unist-util-visit";
2
+ //#region src/plugins/rehype-code-language.ts
3
+ /** `language-ts` on a `<code>` — how a fence's language survives to hast. */
4
+ const LANGUAGE_CLASS = /^language-(.+)$/;
5
+ /**
6
+ * The tag an excluded `<pre>` wears while Shiki walks the tree.
7
+ *
8
+ * Exported so a test can prove it never reaches the output: a leaked sentinel
9
+ * is an unstyled block with an invented tag name, which React renders happily
10
+ * and nobody notices.
11
+ */
12
+ const EXCLUDED_PRE_TAG = "wave-docs-excluded-pre";
13
+ const rehypeNormalizeCodeLanguage = (options = {}) => {
14
+ const excluded = new Set((options.exclude ?? []).map((lang) => lang.toLowerCase()));
15
+ return (tree) => {
16
+ visit(tree, "element", (node) => {
17
+ if (node.tagName !== "pre") return CONTINUE;
18
+ const code = node.children[0];
19
+ if (code === void 0 || code.type !== "element") return CONTINUE;
20
+ const classNames = code.properties.className;
21
+ if (!Array.isArray(classNames)) return SKIP;
22
+ let language;
23
+ code.properties.className = classNames.map((name) => {
24
+ if (typeof name !== "string") return name;
25
+ const found = LANGUAGE_CLASS.exec(name)?.[1];
26
+ if (found === void 0) return name;
27
+ language = found.toLowerCase();
28
+ return `language-${language}`;
29
+ });
30
+ if (language !== void 0 && excluded.has(language)) node.tagName = EXCLUDED_PRE_TAG;
31
+ return SKIP;
32
+ });
33
+ };
34
+ };
35
+ /**
36
+ * Undo the disguise. Must run after `rehypeShikiFromHighlighter`, and it is a
37
+ * separate plugin rather than a flag on the first one because unified gives a
38
+ * plugin one position in the pipeline and this needs the other side of Shiki.
39
+ */
40
+ const rehypeRestoreExcludedCode = () => {
41
+ return (tree) => {
42
+ visit(tree, "element", (node) => {
43
+ if (node.tagName === "wave-docs-excluded-pre") {
44
+ node.tagName = "pre";
45
+ node.properties = {
46
+ ...node.properties,
47
+ tabIndex: 0
48
+ };
49
+ }
50
+ });
51
+ };
52
+ };
53
+ //#endregion
54
+ export { EXCLUDED_PRE_TAG, rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode };
@@ -0,0 +1,6 @@
1
+ import { Plugin } from "unified";
2
+ import { Root } from "hast";
3
+ //#region src/plugins/rehype-fallback-heading-ids.d.ts
4
+ declare const rehypeFallbackHeadingIds: Plugin<[], Root>;
5
+ //#endregion
6
+ export { rehypeFallbackHeadingIds };