@waveso/docs 0.4.0 → 0.5.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 (48) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +160 -39
  3. package/dist/errors.d.ts +2 -0
  4. package/dist/highlighter.js +2 -1
  5. package/dist/meta.js +6 -9
  6. package/dist/next.d.ts +54 -14
  7. package/dist/next.js +115 -18
  8. package/dist/plugins/rehype-code-frame.d.ts +13 -1
  9. package/dist/plugins/rehype-code-frame.js +2 -1
  10. package/dist/plugins/remark-doc-links.d.ts +51 -1
  11. package/dist/plugins/remark-doc-links.js +27 -16
  12. package/dist/plugins/remark-youtube.d.ts +18 -3
  13. package/dist/plugins/remark-youtube.js +57 -9
  14. package/dist/react/callout.d.ts +13 -1
  15. package/dist/react/callout.js +2 -2
  16. package/dist/react/code-runtime.d.ts +12 -2
  17. package/dist/react/code-runtime.js +28 -4
  18. package/dist/react/doc-content.d.ts +12 -1
  19. package/dist/react/doc-content.js +2 -2
  20. package/dist/react/layout.d.ts +27 -10
  21. package/dist/react/layout.js +6 -3
  22. package/dist/react/markdown-components.d.ts +29 -1
  23. package/dist/react/markdown-components.js +69 -67
  24. package/dist/react/nav.d.ts +5 -1
  25. package/dist/react/nav.js +5 -2
  26. package/dist/react/next-nav.d.ts +5 -1
  27. package/dist/react/next-nav.js +5 -2
  28. package/dist/react/search-dialog.d.ts +59 -3
  29. package/dist/react/search-dialog.js +53 -9
  30. package/dist/react/shell-labels.d.ts +135 -21
  31. package/dist/react/shell-labels.js +47 -6
  32. package/dist/react/sidebar.d.ts +18 -1
  33. package/dist/react/sidebar.js +59 -23
  34. package/dist/react/youtube.d.ts +22 -1
  35. package/dist/react/youtube.js +22 -4
  36. package/dist/render.d.ts +11 -0
  37. package/dist/render.js +38 -11
  38. package/dist/route-path.js +7 -2
  39. package/dist/safe-href.d.ts +47 -0
  40. package/dist/safe-href.js +73 -0
  41. package/dist/search-index.js +1 -1
  42. package/dist/search-options.d.ts +64 -2
  43. package/dist/search-options.js +25 -1
  44. package/dist/semaphore.d.ts +46 -0
  45. package/dist/semaphore.js +60 -0
  46. package/dist/source.js +80 -10
  47. package/dist/types.d.ts +30 -0
  48. package/package.json +1 -1
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
  *
@@ -85,7 +128,7 @@ function buildSource(config) {
85
128
  const load = () => {
86
129
  cached ??= scan(config).catch((err) => {
87
130
  cached = null;
88
- throw err;
131
+ throw describeScanFailure(err, config.contentDir);
89
132
  });
90
133
  return cached;
91
134
  };
@@ -143,9 +186,32 @@ function buildSource(config) {
143
186
  }
144
187
  };
145
188
  }
189
+ /**
190
+ * Say that a descriptor exhaustion happened here, and that it was not us.
191
+ *
192
+ * `EMFILE`/`ENFILE` arrive from Node as a bare `Error` with no hint of what was
193
+ * being read, and the message a reader used to get was
194
+ * `EMFILE: too many open files, open '<contentDir>/p829.md'` and nothing else.
195
+ * That is a filename out of a corpus of a thousand, from a stack of `dist/`
196
+ * frames, during a `next build` that mentions no package.
197
+ *
198
+ * The scan itself is bounded at {@link SCAN_CONCURRENCY} now, so reaching this
199
+ * means the limit is lower than that or something else in the process has the
200
+ * descriptors — which is the useful thing to be told, and the opposite of what a
201
+ * reader concludes from an error naming one of their own markdown files.
202
+ *
203
+ * Only these two codes, and only when the failure is not already ours: a
204
+ * `broken-symlink` or an `invalid-frontmatter` that came out of the scan is
205
+ * already a better message than anything this could add.
206
+ */
207
+ function describeScanFailure(err, contentDir) {
208
+ const code = err?.code;
209
+ if (code !== "EMFILE" && code !== "ENFILE") return err;
210
+ 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 });
211
+ }
146
212
  async function scan(config) {
147
213
  await assertContentDir(config.contentDir);
148
- const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
214
+ const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await fsGate.run(() => realpath(config.contentDir))]));
149
215
  const files = [];
150
216
  const bySlug = /* @__PURE__ */ new Map();
151
217
  collect(root, files, bySlug);
@@ -157,13 +223,13 @@ async function scan(config) {
157
223
  }
158
224
  async function assertContentDir(contentDir) {
159
225
  try {
160
- if ((await stat(contentDir)).isDirectory()) return;
226
+ if ((await fsGate.run(() => stat(contentDir))).isDirectory()) return;
161
227
  } catch {}
162
228
  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
229
  }
164
230
  const SKIP = { kind: "skip" };
165
231
  async function scanDir(absPath, segments, name, config, ancestors) {
166
- const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
232
+ const [meta, entries] = await Promise.all([fsGate.run(() => readDocsMeta(absPath)), fsGate.run(() => readdir(absPath, { withFileTypes: true }))]);
167
233
  const classified = await Promise.all(entries.map((entry) => ({
168
234
  entry,
169
235
  name: entry.name.normalize("NFC")
@@ -206,7 +272,7 @@ async function classifyEntry(dirPath, entry, name) {
206
272
  if (!entry.isSymbolicLink()) return SKIP;
207
273
  let target;
208
274
  try {
209
- target = await stat(absPath);
275
+ target = await fsGate.run(() => stat(absPath));
210
276
  } catch {
211
277
  if (!isPageFile(name)) return SKIP;
212
278
  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 +289,23 @@ async function classifyEntry(dirPath, entry, name) {
223
289
  kind: "dir",
224
290
  absPath,
225
291
  name,
226
- realPath: await realpath(absPath)
292
+ realPath: await fsGate.run(() => realpath(absPath))
227
293
  };
228
294
  return SKIP;
229
295
  }
230
296
  async function readPage(filePath, name, dirSegments, config) {
231
- const raw = await readFile(filePath, "utf8");
297
+ const raw = await fsGate.run(() => readFile(filePath, "utf8"));
232
298
  const relativePath = toPosix(path.relative(config.contentDir, filePath));
233
299
  let data;
234
300
  let content;
301
+ let frontmatterLines = 0;
235
302
  try {
236
- const file = new VFile({ value: raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw });
303
+ const source = raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw;
304
+ const file = new VFile({ value: source });
237
305
  matter(file, { strip: true });
238
306
  data = file.data.matter;
239
307
  content = String(file);
308
+ if (source.endsWith(content)) frontmatterLines = source.slice(0, source.length - content.length).split("\n").length - 1;
240
309
  } catch (err) {
241
310
  const reason = err instanceof Error ? err.message : String(err);
242
311
  throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
@@ -253,7 +322,8 @@ async function readPage(filePath, name, dirSegments, config) {
253
322
  filePath,
254
323
  relativePath,
255
324
  frontmatter,
256
- content
325
+ content,
326
+ frontmatterLines
257
327
  }
258
328
  };
259
329
  }
@@ -409,7 +479,7 @@ function toHref(basePath, segments) {
409
479
  return `${basePath}/${encodeSegments(segments)}`;
410
480
  }
411
481
  function normalizeBasePath(basePath) {
412
- const trimmed = basePath.trim().replace(/\/+$/, "");
482
+ const trimmed = basePath.trim().replace(/\/{2,}/g, "/").replace(/\/+$/, "");
413
483
  if (trimmed === "") return "";
414
484
  return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
415
485
  }
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 {
@@ -257,6 +275,18 @@ href: string, from: DocLinkContext) => string | undefined;
257
275
  * blindly prefixes what it is handed produces `/cdn//logo.png` and
258
276
  * `/cdn/https://example.com/a.png`. Branch on them, or return `undefined` to
259
277
  * leave the src as authored.
278
+ *
279
+ * ⚠️ IT IS A FILE PATH, NOT AN HREF. `src` is percent-decoded, so
280
+ * `./getting%20started.png` — what GitHub's editor writes when you drag in a
281
+ * file whose name has a space — arrives as `getting started.png`, the name that
282
+ * is actually on disk. Any `?query` and `#fragment` are split off before the
283
+ * call and re-attached to whatever you return, so `./diagram.png?v=2` arrives
284
+ * as `diagram.png` and the reader still gets the `?v=2`. Return a src carrying
285
+ * a query or a fragment of your own and yours is kept instead, because two `?`
286
+ * in one URL is not a URL.
287
+ *
288
+ * All three used to be handed over raw, and `readFile(join(dir, src))` — the
289
+ * implementation the README gives — threw `ENOENT` on every one of them.
260
290
  */
261
291
  type ImageResolver = (src: string, from: DocLinkContext) => Promise<{
262
292
  src: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waveso/docs",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": [