@waveso/docs 0.4.0 → 0.6.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 (58) hide show
  1. package/CHANGELOG.md +270 -0
  2. package/README.md +221 -79
  3. package/dist/anchors.d.ts +44 -0
  4. package/dist/anchors.js +76 -0
  5. package/dist/errors.d.ts +4 -0
  6. package/dist/highlighter.js +2 -1
  7. package/dist/link-suggestion.d.ts +31 -0
  8. package/dist/link-suggestion.js +94 -0
  9. package/dist/meta.js +6 -9
  10. package/dist/next.d.ts +54 -14
  11. package/dist/next.js +135 -20
  12. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  13. package/dist/plugins/rehype-code-frame.js +2 -1
  14. package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
  15. package/dist/plugins/remark-doc-links.d.ts +83 -1
  16. package/dist/plugins/remark-doc-links.js +50 -23
  17. package/dist/plugins/remark-youtube.d.ts +18 -3
  18. package/dist/plugins/remark-youtube.js +57 -9
  19. package/dist/react/callout.d.ts +13 -1
  20. package/dist/react/callout.js +2 -2
  21. package/dist/react/code-runtime.d.ts +12 -2
  22. package/dist/react/code-runtime.js +28 -4
  23. package/dist/react/doc-content.d.ts +12 -1
  24. package/dist/react/doc-content.js +2 -2
  25. package/dist/react/layout.d.ts +27 -10
  26. package/dist/react/layout.js +6 -3
  27. package/dist/react/link-adapter.d.ts +34 -0
  28. package/dist/react/link-adapter.js +30 -0
  29. package/dist/react/markdown-components.d.ts +29 -1
  30. package/dist/react/markdown-components.js +69 -67
  31. package/dist/react/nav.d.ts +5 -1
  32. package/dist/react/nav.js +5 -2
  33. package/dist/react/next-link.d.ts +6 -28
  34. package/dist/react/next-link.js +45 -24
  35. package/dist/react/next-nav.d.ts +5 -1
  36. package/dist/react/next-nav.js +6 -3
  37. package/dist/react/next-search.js +1 -1
  38. package/dist/react/search-dialog.d.ts +59 -3
  39. package/dist/react/search-dialog.js +53 -9
  40. package/dist/react/shell-labels.d.ts +135 -21
  41. package/dist/react/shell-labels.js +47 -6
  42. package/dist/react/sidebar.d.ts +18 -1
  43. package/dist/react/sidebar.js +59 -23
  44. package/dist/react/youtube.d.ts +22 -1
  45. package/dist/react/youtube.js +22 -4
  46. package/dist/render.d.ts +12 -1
  47. package/dist/render.js +107 -21
  48. package/dist/route-path.js +7 -2
  49. package/dist/safe-href.d.ts +47 -0
  50. package/dist/safe-href.js +73 -0
  51. package/dist/search-index.js +1 -1
  52. package/dist/search-options.d.ts +64 -2
  53. package/dist/search-options.js +25 -1
  54. package/dist/semaphore.d.ts +46 -0
  55. package/dist/semaphore.js +60 -0
  56. package/dist/source.js +86 -12
  57. package/dist/types.d.ts +102 -6
  58. package/package.json +6 -3
package/dist/source.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { docsError } from "./docs-error.js";
2
2
  import { parseFrontmatter } from "./frontmatter.js";
3
3
  import { orderNavEntries, readDocsMeta } from "./meta.js";
4
+ import { createSemaphore } from "./semaphore.js";
4
5
  import { encodeSegments, toAliasRoute } from "./route-path.js";
5
6
  import { readFile, readdir, realpath, stat } from "node:fs/promises";
6
7
  import path from "node:path";
@@ -12,6 +13,48 @@ const PAGE_EXTENSION = ".md";
12
13
  /** A directory whose `index.md` is the directory's own route. */
13
14
  const INDEX_NAME = "index";
14
15
  /**
16
+ * Filesystem calls in flight across the whole process, at most.
17
+ *
18
+ * ⚠️ THE SCAN USED TO OPEN EVERY MARKDOWN FILE AT ONCE. `scanDir` recursed into
19
+ * its subdirectories in parallel and read that directory's pages with a bare
20
+ * `Promise.all`, so the number of `readFile` calls in flight equalled the number
21
+ * of markdown files in the entire tree. On a 1,200-page corpus and the common
22
+ * 1,024-descriptor soft limit, `next build` died with a bare `EMFILE: too many
23
+ * open files` — no error code, no mention that this was the docs scan, and
24
+ * nothing pointing at the fix. Exactly the large content set this package is
25
+ * for, and the failure got worse as the site grew.
26
+ *
27
+ * 64 is far under every default soft limit — 1,024 on Linux and CI images, 256
28
+ * on an unconfigured macOS shell — so the descriptors stop scaling with the
29
+ * corpus while staying safe on the tightest of those.
30
+ *
31
+ * ⚠️ IT IS NOT FREE, AND THE COST IS NOT THE CONCURRENCY. Measured over 1,201
32
+ * pages in 49 directories, nine runs, medians: **88 ms ungated, 106 ms at a
33
+ * bound of 16, 102 ms at 64, 101 ms at 128 and at 256.** Flat from 64 upwards,
34
+ * which says the ~14 ms is the gate's own per-call overhead — about 3,600
35
+ * `run` closures — and not the reduced parallelism. libuv's filesystem pool is
36
+ * four threads by default, so there was never 1,201-way parallelism to lose.
37
+ *
38
+ * So the number is chosen for the tightest descriptor limit rather than for
39
+ * speed, because above 64 there is no speed left to buy. 14 ms sits against a
40
+ * build that highlights those same 1,201 pages with Shiki, which is three
41
+ * orders of magnitude more.
42
+ */
43
+ const SCAN_CONCURRENCY = 64;
44
+ /**
45
+ * The one gate, for the whole process.
46
+ *
47
+ * Module scope rather than per-scan, because descriptors are a process resource:
48
+ * two routes scanning two content directories at once would each stay under a
49
+ * per-scan bound and together exceed the only one that matters.
50
+ *
51
+ * ⚠️ LEAF CALLS ONLY — NEVER THE RECURSION. Gating `scanDir` itself deadlocks as
52
+ * soon as every slot is held by a directory waiting for a slot to read its
53
+ * children. Bounding the `readFile`/`readdir`/`stat`/`realpath` calls bounds the
54
+ * descriptors exactly, and leaves the walk as parallel as it was.
55
+ */
56
+ const fsGate = createSemaphore(SCAN_CONCURRENCY);
57
+ /**
15
58
  * Apply {@link DocsConfig} defaults and resolve `contentDir` against
16
59
  * `process.cwd()` — the project root under both `next build` and `vite build`.
17
60
  *
@@ -28,7 +71,9 @@ function resolveDocsConfig(config) {
28
71
  ),
29
72
  basePath: normalizeBasePath(config.basePath ?? "/docs"),
30
73
  includeDrafts: config.includeDrafts ?? false,
31
- assertLinks: config.assertLinks ?? true,
74
+ onBrokenLinks: config.onBrokenLinks ?? "throw",
75
+ onBrokenAnchors: config.onBrokenAnchors ?? "throw",
76
+ externalRoutes: config.externalRoutes ?? [],
32
77
  ...config.frontmatterSchema === void 0 ? {} : { frontmatterSchema: config.frontmatterSchema }
33
78
  };
34
79
  }
@@ -71,7 +116,9 @@ function createDocsSource(config) {
71
116
  resolved.contentDir,
72
117
  resolved.basePath,
73
118
  resolved.includeDrafts,
74
- resolved.assertLinks,
119
+ resolved.onBrokenLinks,
120
+ resolved.onBrokenAnchors,
121
+ resolved.externalRoutes.join(","),
75
122
  schemaKey(resolved.frontmatterSchema)
76
123
  ].join("\0");
77
124
  const existing = sources.get(key);
@@ -85,7 +132,7 @@ function buildSource(config) {
85
132
  const load = () => {
86
133
  cached ??= scan(config).catch((err) => {
87
134
  cached = null;
88
- throw err;
135
+ throw describeScanFailure(err, config.contentDir);
89
136
  });
90
137
  return cached;
91
138
  };
@@ -143,9 +190,32 @@ function buildSource(config) {
143
190
  }
144
191
  };
145
192
  }
193
+ /**
194
+ * Say that a descriptor exhaustion happened here, and that it was not us.
195
+ *
196
+ * `EMFILE`/`ENFILE` arrive from Node as a bare `Error` with no hint of what was
197
+ * being read, and the message a reader used to get was
198
+ * `EMFILE: too many open files, open '<contentDir>/p829.md'` and nothing else.
199
+ * That is a filename out of a corpus of a thousand, from a stack of `dist/`
200
+ * frames, during a `next build` that mentions no package.
201
+ *
202
+ * The scan itself is bounded at {@link SCAN_CONCURRENCY} now, so reaching this
203
+ * means the limit is lower than that or something else in the process has the
204
+ * descriptors — which is the useful thing to be told, and the opposite of what a
205
+ * reader concludes from an error naming one of their own markdown files.
206
+ *
207
+ * Only these two codes, and only when the failure is not already ours: a
208
+ * `broken-symlink` or an `invalid-frontmatter` that came out of the scan is
209
+ * already a better message than anything this could add.
210
+ */
211
+ function describeScanFailure(err, contentDir) {
212
+ const code = err?.code;
213
+ if (code !== "EMFILE" && code !== "ENFILE") return err;
214
+ return docsError("descriptor-limit", `the process ran out of file descriptors while scanning ${contentDir}. The scan holds at most ${String(SCAN_CONCURRENCY)} open at a time, so something else has them — raise the limit (\`ulimit -n\`, or \`LimitNOFILE\` under systemd) rather than shrinking the corpus. If it persists with a limit well above that, it is a bug in this package: https://github.com/wavedotso/wave-docs/issues`, { cause: err });
215
+ }
146
216
  async function scan(config) {
147
217
  await assertContentDir(config.contentDir);
148
- const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
218
+ const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await fsGate.run(() => realpath(config.contentDir))]));
149
219
  const files = [];
150
220
  const bySlug = /* @__PURE__ */ new Map();
151
221
  collect(root, files, bySlug);
@@ -157,13 +227,13 @@ async function scan(config) {
157
227
  }
158
228
  async function assertContentDir(contentDir) {
159
229
  try {
160
- if ((await stat(contentDir)).isDirectory()) return;
230
+ if ((await fsGate.run(() => stat(contentDir))).isDirectory()) return;
161
231
  } catch {}
162
232
  throw docsError("missing-content-dir", `Docs content directory not found: ${contentDir}\nSet \`contentDir\` to a directory of markdown files; relative paths resolve against the working directory (${process.cwd()}).`);
163
233
  }
164
234
  const SKIP = { kind: "skip" };
165
235
  async function scanDir(absPath, segments, name, config, ancestors) {
166
- const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
236
+ const [meta, entries] = await Promise.all([fsGate.run(() => readDocsMeta(absPath)), fsGate.run(() => readdir(absPath, { withFileTypes: true }))]);
167
237
  const classified = await Promise.all(entries.map((entry) => ({
168
238
  entry,
169
239
  name: entry.name.normalize("NFC")
@@ -206,7 +276,7 @@ async function classifyEntry(dirPath, entry, name) {
206
276
  if (!entry.isSymbolicLink()) return SKIP;
207
277
  let target;
208
278
  try {
209
- target = await stat(absPath);
279
+ target = await fsGate.run(() => stat(absPath));
210
280
  } catch {
211
281
  if (!isPageFile(name)) return SKIP;
212
282
  throw docsError("broken-symlink", `@waveso/docs: ${absPath} is a broken symbolic link, and it names a markdown page — so skipping it would delete a route nobody asked to delete. Point it at an existing file, or remove the link.`);
@@ -223,20 +293,23 @@ async function classifyEntry(dirPath, entry, name) {
223
293
  kind: "dir",
224
294
  absPath,
225
295
  name,
226
- realPath: await realpath(absPath)
296
+ realPath: await fsGate.run(() => realpath(absPath))
227
297
  };
228
298
  return SKIP;
229
299
  }
230
300
  async function readPage(filePath, name, dirSegments, config) {
231
- const raw = await readFile(filePath, "utf8");
301
+ const raw = await fsGate.run(() => readFile(filePath, "utf8"));
232
302
  const relativePath = toPosix(path.relative(config.contentDir, filePath));
233
303
  let data;
234
304
  let content;
305
+ let frontmatterLines = 0;
235
306
  try {
236
- const file = new VFile({ value: raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw });
307
+ const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
308
+ const file = new VFile({ value: source });
237
309
  matter(file, { strip: true });
238
310
  data = file.data.matter;
239
311
  content = String(file);
312
+ if (source.endsWith(content)) frontmatterLines = source.slice(0, source.length - content.length).split("\n").length - 1;
240
313
  } catch (err) {
241
314
  const reason = err instanceof Error ? err.message : String(err);
242
315
  throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
@@ -253,7 +326,8 @@ async function readPage(filePath, name, dirSegments, config) {
253
326
  filePath,
254
327
  relativePath,
255
328
  frontmatter,
256
- content
329
+ content,
330
+ frontmatterLines
257
331
  }
258
332
  };
259
333
  }
@@ -409,7 +483,7 @@ function toHref(basePath, segments) {
409
483
  return `${basePath}/${encodeSegments(segments)}`;
410
484
  }
411
485
  function normalizeBasePath(basePath) {
412
- const trimmed = basePath.trim().replace(/\/+$/, "");
486
+ const trimmed = basePath.trim().replace(/\/{2,}/g, "/").replace(/\/+$/, "");
413
487
  if (trimmed === "") return "";
414
488
  return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
415
489
  }
package/dist/types.d.ts CHANGED
@@ -67,6 +67,24 @@ interface DocFile<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
67
67
  frontmatter: TFrontmatter;
68
68
  /** Markdown body with the frontmatter block removed. */
69
69
  content: string;
70
+ /**
71
+ * How many lines the frontmatter block took up, so an error can name the line
72
+ * in the file rather than in the body.
73
+ *
74
+ * ⚠️ WITHOUT IT, EVERY LINK ERROR POINTED INTO THE FRONTMATTER. `content` has
75
+ * the block removed, so remark counts `node.position.start.line` from the
76
+ * first line of the *body* — and `broken-link`, `draft-link` and `alias-link`
77
+ * all report `relativePath:line`, the exact `file:line` form a terminal and an
78
+ * editor turn into a jump. A page with four frontmatter fields is six lines
79
+ * out: a link on line 10 was reported at line 4, which is the middle of the
80
+ * block that was deleted.
81
+ *
82
+ * Set by `@waveso/docs/source`. Optional and treated as `0` when absent,
83
+ * because a host loading content itself — the documented reason `./render` is
84
+ * an entry point — hands over a body with no block in front of it and has
85
+ * nothing to offset.
86
+ */
87
+ frontmatterLines?: number | undefined;
70
88
  }
71
89
  /** A link to a documentation page. */
72
90
  interface DocNavPage {
@@ -226,11 +244,24 @@ interface DocLinkContext {
226
244
  /** The source path, e.g. `'api/auth.md'`. For error messages. */
227
245
  relativePath: string;
228
246
  }
247
+ /**
248
+ * What a link problem should do to a build.
249
+ *
250
+ * The three Docusaurus settled on, and for the same reason: the tool cannot
251
+ * know how much a given site cares, and guessing produces either a build that
252
+ * fails on someone's legitimate URL or one that ships a dead link quietly.
253
+ *
254
+ * `'warn'` writes to `console.warn` and continues, which on a docs site is a
255
+ * line in a build log — useful during a migration, not a substitute for
256
+ * `'throw'`.
257
+ */
258
+ type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
229
259
  /**
230
260
  * Resolve an internal markdown link target to a route.
231
261
  *
232
262
  * Called for every relative link found in the source. Returning `undefined`
233
- * signals "not a documentation page", which — with `assertLinks` on — fails
263
+ * signals "not a documentation page", which — under the default
264
+ * `onBrokenLinks: 'throw'` — fails
234
265
  * the build rather than shipping a 404 that was valid on GitHub.
235
266
  */
236
267
  type LinkResolver = (
@@ -257,6 +288,18 @@ href: string, from: DocLinkContext) => string | undefined;
257
288
  * blindly prefixes what it is handed produces `/cdn//logo.png` and
258
289
  * `/cdn/https://example.com/a.png`. Branch on them, or return `undefined` to
259
290
  * leave the src as authored.
291
+ *
292
+ * ⚠️ IT IS A FILE PATH, NOT AN HREF. `src` is percent-decoded, so
293
+ * `./getting%20started.png` — what GitHub's editor writes when you drag in a
294
+ * file whose name has a space — arrives as `getting started.png`, the name that
295
+ * is actually on disk. Any `?query` and `#fragment` are split off before the
296
+ * call and re-attached to whatever you return, so `./diagram.png?v=2` arrives
297
+ * as `diagram.png` and the reader still gets the `?v=2`. Return a src carrying
298
+ * a query or a fragment of your own and yours is kept instead, because two `?`
299
+ * in one URL is not a URL.
300
+ *
301
+ * All three used to be handed over raw, and `readFile(join(dir, src))` — the
302
+ * implementation the README gives — threw `ENOENT` on every one of them.
260
303
  */
261
304
  type ImageResolver = (src: string, from: DocLinkContext) => Promise<{
262
305
  src: string;
@@ -291,10 +334,61 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
291
334
  */
292
335
  includeDrafts?: boolean | undefined;
293
336
  /**
294
- * Fail the build when an internal link resolves to a page that does not
295
- * exist. Defaults to `true`; there is no good reason to turn it off.
337
+ * What to do about an internal link that resolves to no published page.
338
+ * Defaults to `'throw'`.
339
+ *
340
+ * A link that 404s was valid in the editor and on GitHub, so it is the kind
341
+ * of mistake nobody finds by reading. Throwing is the default for that
342
+ * reason, and there is rarely a good reason to lower it — `'warn'` exists
343
+ * for a migration where the corpus is knowingly incomplete for a while.
344
+ *
345
+ * The error names the file and the line, and offers the closest published
346
+ * route when the link looks like a typo of one.
347
+ */
348
+ onBrokenLinks?: DocsLinkSeverity | undefined;
349
+ /**
350
+ * What to do about a `#fragment` that no heading on the target page owns.
351
+ * Defaults to `'throw'`.
352
+ *
353
+ * ⚠️ THE MORE COMMON OF THE TWO LINK FAILURES, AND IT WENT UNCHECKED. A route
354
+ * was verified and its fragment discarded, so `[setup](./install.md#setup)`
355
+ * built green with no `#setup` anywhere on the page. Headings get renamed
356
+ * constantly and nothing renames the links into them, which is exactly why it
357
+ * is worth checking and exactly why it breaks.
358
+ *
359
+ * Checked against every `id` in the rendered page rather than against the
360
+ * table of contents, which captures `h2`–`h3` only — so a link to an `h4` is
361
+ * fine, and so is one to an id a `rehypePlugins` entry put on something that
362
+ * is not a heading.
363
+ *
364
+ * Lower it to `'warn'` if a plugin of yours adds ids this package cannot see
365
+ * at render time.
366
+ */
367
+ onBrokenAnchors?: DocsLinkSeverity | undefined;
368
+ /**
369
+ * Route prefixes that belong to your application, not to the documentation.
370
+ *
371
+ * ⚠️ ONLY MEANINGFUL AT A ROOT MOUNT, WHICH IS ALSO THE ONLY PLACE IT IS
372
+ * NEEDED. Under `basePath: '/docs'` an absolute link either carries the
373
+ * prefix — so it is documentation and is checked — or it does not, and this
374
+ * package leaves it alone. Under `basePath: '/'` there is no prefix to test
375
+ * against: `/setup` and `/login` look identical, and both are checked against
376
+ * the published pages.
377
+ *
378
+ * That is the right default, because a root mount is what you choose when the
379
+ * origin serves documentation and nothing else — `docs.example.com` — and
380
+ * there an unknown absolute link is always a typo. If the origin *does* serve
381
+ * something else, name what is yours:
382
+ *
383
+ * ```ts
384
+ * externalRoutes: ['/login', '/dashboard', '/api/']
385
+ * ```
386
+ *
387
+ * A link is skipped when it equals one of these or begins with one followed
388
+ * by `/`. It is a statement about your application, so nothing here can infer
389
+ * it and nothing tries.
296
390
  */
297
- assertLinks?: boolean | undefined;
391
+ externalRoutes?: readonly string[] | undefined;
298
392
  /**
299
393
  * Validates every page's frontmatter. Defaults to `docFrontmatterSchema`
300
394
  * from `@waveso/docs/frontmatter`.
@@ -345,7 +439,9 @@ interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatte
345
439
  contentDir: string;
346
440
  basePath: string;
347
441
  includeDrafts: boolean;
348
- assertLinks: boolean;
442
+ onBrokenLinks: DocsLinkSeverity;
443
+ onBrokenAnchors: DocsLinkSeverity;
444
+ externalRoutes: readonly string[];
349
445
  /**
350
446
  * As supplied. `resolveDocsConfig` omits the key rather than setting it to
351
447
  * `undefined` when the built-in `docFrontmatterSchema` applies, so the
@@ -354,4 +450,4 @@ interface ResolvedDocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatte
354
450
  frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter> | undefined;
355
451
  }
356
452
  //#endregion
357
- export { DocFile, DocFrontmatter, DocLinkContext, DocNavGroup, DocNavLink, DocNavNode, DocNavPage, DocNavSeparator, DocsConfig, DocsMeta, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig, SearchRecord, TocEntry };
453
+ export { DocFile, DocFrontmatter, DocLinkContext, DocNavGroup, DocNavLink, DocNavNode, DocNavPage, DocNavSeparator, DocsConfig, DocsLinkSeverity, DocsMeta, ImageResolver, LinkResolver, RenderedDoc, ResolvedDocsConfig, SearchRecord, TocEntry };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Zero parser bytes in the browser: markdown docs for Next.js, built to hast in Node and rendered as your components",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -76,6 +76,10 @@
76
76
  "types": "./dist/react/markdown-components.d.ts",
77
77
  "default": "./dist/react/markdown-components.js"
78
78
  },
79
+ "./react/next-link": {
80
+ "types": "./dist/react/next-link.d.ts",
81
+ "default": "./dist/react/next-link.js"
82
+ },
79
83
  "./react/next-search": {
80
84
  "types": "./dist/react/next-search.d.ts",
81
85
  "default": "./dist/react/next-search.js"
@@ -196,7 +200,6 @@
196
200
  "test:smoke": "node smoke/check.ts",
197
201
  "size": "node scripts/size.ts",
198
202
  "build:site": "next build site",
199
- "dev:site": "next dev site",
200
- "shoot": "node scripts/shoot.ts"
203
+ "dev:site": "next dev site"
201
204
  }
202
205
  }