@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/source.js CHANGED
@@ -1,17 +1,12 @@
1
+ import { docsError } from "./docs-error.js";
1
2
  import { parseFrontmatter } from "./frontmatter.js";
2
3
  import { orderNavEntries, readDocsMeta } from "./meta.js";
3
- import { readFile, readdir, stat } from "node:fs/promises";
4
+ import { encodeSegments, toAliasRoute } from "./route-path.js";
5
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
4
6
  import path from "node:path";
5
- import matter from "gray-matter";
7
+ import { VFile } from "vfile";
8
+ import { matter } from "vfile-matter";
6
9
  //#region src/source.ts
7
- /**
8
- * The source layer: a content directory on disk becomes `DocFile[]` plus a
9
- * navigation tree.
10
- *
11
- * Node-only, and the only module that touches the filesystem. Everything it
12
- * produces is plain data, so the result crosses the RSC boundary, a Vite
13
- * virtual module or a JSON cache file without ceremony.
14
- */
15
10
  /** Markdown only. MDX is deliberately out of scope for this package. */
16
11
  const PAGE_EXTENSION = ".md";
17
12
  /** A directory whose `index.md` is the directory's own route. */
@@ -26,7 +21,11 @@ const INDEX_NAME = "index";
26
21
  */
27
22
  function resolveDocsConfig(config) {
28
23
  return {
29
- contentDir: path.resolve(process.cwd(), config.contentDir),
24
+ contentDir: path.resolve(
25
+ /*turbopackIgnore: true*/
26
+ process.cwd(),
27
+ config.contentDir
28
+ ),
30
29
  basePath: normalizeBasePath(config.basePath ?? "/docs"),
31
30
  includeDrafts: config.includeDrafts ?? false,
32
31
  assertLinks: config.assertLinks ?? true,
@@ -83,10 +82,43 @@ function createDocsSource(config) {
83
82
  }
84
83
  function buildSource(config) {
85
84
  let cached = null;
86
- const load = () => cached ??= scan(config);
85
+ const load = () => {
86
+ cached ??= scan(config).catch((err) => {
87
+ cached = null;
88
+ throw err;
89
+ });
90
+ return cached;
91
+ };
87
92
  const isVisible = (file) => config.includeDrafts || file.frontmatter.draft !== true;
88
93
  return {
89
94
  config,
95
+ /**
96
+ * Throw the scan away. The next query reads the disk again.
97
+ *
98
+ * ⚠️ A STAT-WALK DIRTY CHECK WAS BUILT HERE AND MEASURED AND REMOVED. The
99
+ * idea is obvious and the roadmap called for it: mark dirty, then compare
100
+ * a stat-only fingerprint of the tree against the cached one and skip the
101
+ * re-read when nothing changed. It rests on stat being much cheaper than
102
+ * read, and on this corpus it is not — the fingerprint has to `readdir`
103
+ * every directory and `stat` every file, which is nearly everything the
104
+ * scan does apart from the read and the parse.
105
+ *
106
+ * Measured over 501 pages, median of six, against a full rescan:
107
+ *
108
+ * ~1.4 KB pages 28.3 ms vs 26.8 ms 0.95x (slower)
109
+ * ~20 KB pages 28.8 ms vs 27.4 ms 0.95x (slower)
110
+ * ~120 KB pages 39.1 ms vs 45.1 ms 1.15x (faster)
111
+ *
112
+ * Documentation pages are the first two rows. So it is a small regression
113
+ * plus a new class of invalidation bug, in exchange for a win on a corpus
114
+ * nobody has. There is no cheaper correct fingerprint either: statting
115
+ * only directories catches an added or renamed file but not an edited one,
116
+ * which is the common case in a dev server.
117
+ *
118
+ * If this is ever revisited, the thing to change is the *scan*, not the
119
+ * check — patch only the files whose mtime moved and re-derive the nav in
120
+ * memory, which is a different item with a much harder correctness story.
121
+ */
90
122
  invalidate() {
91
123
  cached = null;
92
124
  },
@@ -94,6 +126,10 @@ function buildSource(config) {
94
126
  const { files } = await load();
95
127
  return files.filter(isVisible);
96
128
  },
129
+ async drafts() {
130
+ const { files } = await load();
131
+ return files.filter((file) => file.frontmatter.draft === true);
132
+ },
97
133
  async find(segments) {
98
134
  const { bySlug } = await load();
99
135
  return bySlug.get(segments.join("/"));
@@ -109,7 +145,7 @@ function buildSource(config) {
109
145
  }
110
146
  async function scan(config) {
111
147
  await assertContentDir(config.contentDir);
112
- const root = await scanDir(config.contentDir, [], "", config);
148
+ const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
113
149
  const files = [];
114
150
  const bySlug = /* @__PURE__ */ new Map();
115
151
  collect(root, files, bySlug);
@@ -123,15 +159,21 @@ async function assertContentDir(contentDir) {
123
159
  try {
124
160
  if ((await stat(contentDir)).isDirectory()) return;
125
161
  } catch {}
126
- throw new Error(`Docs content directory not found: ${contentDir}\nSet \`contentDir\` to a directory of markdown files; relative paths resolve against the working directory (${process.cwd()}).`);
162
+ 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()}).`);
127
163
  }
128
- async function scanDir(absPath, segments, name, config) {
164
+ const SKIP = { kind: "skip" };
165
+ async function scanDir(absPath, segments, name, config, ancestors) {
129
166
  const [meta, entries] = await Promise.all([readDocsMeta(absPath), readdir(absPath, { withFileTypes: true })]);
130
- const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name, "en"));
131
- const pageEntries = sorted.filter((entry) => entry.isFile() && isPageFile(entry.name));
132
- const dirEntries = sorted.filter((entry) => entry.isDirectory() && !isIgnoredDir(entry.name));
133
- const [pages, dirs] = await Promise.all([Promise.all(pageEntries.map((entry) => readPage(path.join(absPath, entry.name), segments, config))), Promise.all(dirEntries.map((entry) => scanDir(path.join(absPath, entry.name), [...segments, entry.name], entry.name, config)))]);
134
- const index = pages.find((page) => baseName(page.filePath) === INDEX_NAME);
167
+ const classified = await Promise.all(entries.map((entry) => ({
168
+ entry,
169
+ name: entry.name.normalize("NFC")
170
+ })).sort((a, b) => a.name.localeCompare(b.name, "en")).map((listed) => classifyEntry(absPath, listed.entry, listed.name)));
171
+ const pageEntries = [];
172
+ const dirEntries = [];
173
+ for (const entry of classified) if (entry.kind === "page") pageEntries.push(entry);
174
+ else if (entry.kind === "dir" && !ancestors.has(entry.realPath)) dirEntries.push(entry);
175
+ const [pages, dirs] = await Promise.all([Promise.all(pageEntries.map((entry) => readPage(entry.absPath, entry.name, segments, config))), Promise.all(dirEntries.map((entry) => scanDir(entry.absPath, [...segments, entry.name], entry.name, config, new Set(ancestors).add(entry.realPath))))]);
176
+ const index = pages.find((page) => page.name === INDEX_NAME);
135
177
  return {
136
178
  name,
137
179
  absPath,
@@ -143,36 +185,83 @@ async function scanDir(absPath, segments, name, config) {
143
185
  dirs
144
186
  };
145
187
  }
146
- async function readPage(filePath, dirSegments, config) {
188
+ /**
189
+ * What kind of content is this directory entry?
190
+ *
191
+ * ⚠️ `isFile()` AND `isDirectory()` ARE BOTH FALSE FOR A SYMBOLIC LINK, and
192
+ * `readdir` has no follow option — so a symlinked page, or a whole symlinked
193
+ * section, was absent from `all()`, `nav()`, the sitemap and the search index
194
+ * without a word. The cascade is worse than the omission: the first link to
195
+ * the missing page fails the build with `no such page exists`, which sends the
196
+ * author after a link that is perfectly correct.
197
+ *
198
+ * `stat` follows the link, so the target's kind decides — and the name filters
199
+ * still apply to the link's own name, which is what the URL is built from.
200
+ */
201
+ async function classifyEntry(dirPath, entry, name) {
202
+ const absPath = path.join(dirPath, entry.name);
203
+ let isFile = entry.isFile();
204
+ let isDir = entry.isDirectory();
205
+ if (!isFile && !isDir) {
206
+ if (!entry.isSymbolicLink()) return SKIP;
207
+ let target;
208
+ try {
209
+ target = await stat(absPath);
210
+ } catch {
211
+ if (!isPageFile(name)) return SKIP;
212
+ 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.`);
213
+ }
214
+ isFile = target.isFile();
215
+ isDir = target.isDirectory();
216
+ }
217
+ if (isFile) return isPageFile(name) ? {
218
+ kind: "page",
219
+ absPath,
220
+ name: stripExtension(name)
221
+ } : SKIP;
222
+ if (isDir && !isIgnoredDir(name)) return {
223
+ kind: "dir",
224
+ absPath,
225
+ name,
226
+ realPath: await realpath(absPath)
227
+ };
228
+ return SKIP;
229
+ }
230
+ async function readPage(filePath, name, dirSegments, config) {
147
231
  const raw = await readFile(filePath, "utf8");
148
232
  const relativePath = toPosix(path.relative(config.contentDir, filePath));
149
233
  let data;
150
234
  let content;
151
235
  try {
152
- const parsed = matter(raw, { language: "yaml" });
153
- data = parsed.data;
154
- content = parsed.content;
236
+ const file = new VFile({ value: raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw });
237
+ matter(file, { strip: true });
238
+ data = file.data.matter;
239
+ content = String(file);
155
240
  } catch (err) {
156
241
  const reason = err instanceof Error ? err.message : String(err);
157
- throw new Error(`Could not parse the frontmatter block in ${relativePath}: ${reason}`);
242
+ throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
158
243
  }
159
- const name = baseName(filePath);
160
244
  const segments = name === INDEX_NAME ? [...dirSegments] : [...dirSegments, name];
245
+ const frontmatter = await parseFrontmatter(data, relativePath, config.frontmatterSchema);
246
+ for (const alias of frontmatter.aliases ?? []) toAliasRoute(alias, config.basePath, relativePath);
161
247
  return {
162
- segments,
163
- slug: segments.join("/"),
164
- href: toHref(config.basePath, segments),
165
- filePath,
166
- relativePath,
167
- frontmatter: await parseFrontmatter(data, relativePath, config.frontmatterSchema),
168
- content
248
+ name,
249
+ doc: {
250
+ segments,
251
+ slug: segments.join("/"),
252
+ href: toHref(config.basePath, segments),
253
+ filePath,
254
+ relativePath,
255
+ frontmatter,
256
+ content
257
+ }
169
258
  };
170
259
  }
171
260
  function collect(dir, files, bySlug) {
172
261
  const own = dir.index ? [dir.index, ...dir.pages] : dir.pages;
173
- for (const file of own) {
262
+ for (const { doc: file } of own) {
174
263
  const clash = bySlug.get(file.slug);
175
- if (clash) throw new Error(`Two files claim the route "${file.href}": ${clash.relativePath} and ${file.relativePath}. Rename one, or delete the other — a directory index and a same-named sibling file collide.`);
264
+ if (clash) throw docsError("route-collision", `Two files claim the route "${file.href}": ${clash.relativePath} and ${file.relativePath}. Rename one, or delete the other — a directory index and a same-named sibling file collide.`);
176
265
  bySlug.set(file.slug, file);
177
266
  files.push(file);
178
267
  }
@@ -193,9 +282,8 @@ function buildNav(dir, config) {
193
282
  const mergeable = new Set(dir.dirs.filter((child) => child.index === void 0).map((child) => child.name));
194
283
  const dirPages = /* @__PURE__ */ new Map();
195
284
  for (const page of dir.pages) {
196
- const name = baseName(page.filePath);
197
- if (mergeable.has(name)) {
198
- dirPages.set(name, page);
285
+ if (mergeable.has(page.name)) {
286
+ dirPages.set(page.name, page);
199
287
  continue;
200
288
  }
201
289
  entries.push(toPageEntry(page, config));
@@ -207,51 +295,61 @@ function buildNav(dir, config) {
207
295
  for (const child of dir.dirs) {
208
296
  const children = buildNav(child, config);
209
297
  const index = child.index ?? dirPages.get(child.name);
210
- const title = groupTitle(child, index);
211
- const href = index && isVisibleIn(index, config) ? index.href : void 0;
298
+ /**
299
+ * ⚠️ A DRAFT INDEX IS NOT A PUBLIC PAGE, AND ITS TITLE IS NOT EITHER.
300
+ *
301
+ * Only `href` used to be gated on visibility, so `secret/index.md` with
302
+ * `draft: true` still supplied the group heading — an unreleased codename
303
+ * rendered into the sidebar of a production build, with no link on it, so
304
+ * no click reveals it and only view-source shows it at all. Its `order`
305
+ * also still positioned the group among published ones.
306
+ */
307
+ const visible = index && isVisibleIn(index.doc, config) ? index : void 0;
308
+ const title = groupTitle(child, visible?.doc);
309
+ const href = visible?.doc.href;
212
310
  const group = {
213
311
  type: "group",
214
312
  title,
215
313
  children,
216
314
  ...href !== void 0 ? { href } : {}
217
315
  };
218
- const node = children.length === 0 && index !== void 0 && href !== void 0 ? {
316
+ const node = children.length === 0 && visible !== void 0 && href !== void 0 ? {
219
317
  type: "page",
220
318
  title,
221
319
  href,
222
- slug: index.slug
320
+ slug: visible.doc.slug
223
321
  } : group;
224
- const order = index?.frontmatter.order;
322
+ const order = visible?.doc.frontmatter.order;
225
323
  entries.push({
226
324
  name: child.name,
227
325
  title,
228
326
  node,
229
327
  inlineChildren: children,
230
- ...index !== void 0 && href !== void 0 ? { indexNode: {
328
+ ...visible !== void 0 && href !== void 0 ? { indexNode: {
231
329
  type: "page",
232
- title: navTitle(index),
330
+ title: navTitle(visible.doc),
233
331
  href,
234
- slug: index.slug
332
+ slug: visible.doc.slug
235
333
  } } : {},
236
334
  ...order !== void 0 ? { order } : {}
237
335
  });
238
336
  }
239
- return orderNavEntries(entries, dir.meta, dir.metaPath);
337
+ return orderNavEntries(entries, dir.meta, dir.metaPath, dir.segments.length);
240
338
  }
241
339
  function toPageEntry(page, config) {
242
- const title = navTitle(page);
243
- const { order } = page.frontmatter;
340
+ const title = navTitle(page.doc);
341
+ const { order } = page.doc.frontmatter;
244
342
  return {
245
- name: baseName(page.filePath),
343
+ name: page.name,
246
344
  title,
247
345
  node: {
248
346
  type: "page",
249
347
  title,
250
- href: page.href,
251
- slug: page.slug
348
+ href: page.doc.href,
349
+ slug: page.doc.slug
252
350
  },
253
351
  ...order !== void 0 ? { order } : {},
254
- ...isVisibleIn(page, config) ? {} : { hidden: true }
352
+ ...isVisibleIn(page.doc, config) ? {} : { hidden: true }
255
353
  };
256
354
  }
257
355
  /** Sidebars are narrow: `label` wins over `title` when the author set one. */
@@ -273,22 +371,16 @@ function isVisibleIn(file, config) {
273
371
  return config.includeDrafts || file.frontmatter.draft !== true;
274
372
  }
275
373
  /**
276
- * A former URL from `aliases` frontmatter, as a route.
277
- *
278
- * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
279
- * Leading and trailing slashes are tolerated because authors write them, but
280
- * the value is always relative to the base path — an alias of `'/docs/old'` on
281
- * a `/docs` site would produce `/docs/docs/old`.
374
+ * ⚠️ CHARACTERS `path-to-regexp` READS AS PATTERN SYNTAX.
282
375
  *
283
- * Shared by both adapters so they agree on which routes exist: an alias is a
284
- * redirect the host installs, so a link to one resolves, and a link that
285
- * builds under Next must build under Vite.
376
+ * Next compiles every `redirects()` source with it, so an alias is not the
377
+ * literal URL it looks like. `v1:beta` compiles to `/docs/v1([^/]+?)`, which
378
+ * `next build` accepts without a word and which then 308s the genuinely
379
+ * prerendered `/docs/v1-guide` away — config redirects run before filesystem
380
+ * routes, so the real page is unreachable in production and nothing reports
381
+ * it. `c++` is the loud sibling: the build aborts with `Unexpected MODIFIER at
382
+ * 7`, naming an offset into a string the author never wrote and no file.
286
383
  */
287
- function toAliasRoute(alias, basePath, sourceLabel) {
288
- const trimmed = alias.trim().replace(/^\/+/, "").replace(/\/+$/, "");
289
- if (trimmed === "") throw new Error(`@waveso/docs: ${sourceLabel} has an empty entry in its \`aliases\` frontmatter. Each alias is a former URL for this page, relative to the docs base path — e.g. \`aliases: [quickstart]\`.`);
290
- return `${basePath}/${trimmed}`;
291
- }
292
384
  /**
293
385
  * ⚠️ `_` AND `.` BOTH, MATCHING `isIgnoredDir` BELOW — which is what this did
294
386
  * NOT do. A leading dot was skipped and a leading underscore was not, so
@@ -302,18 +394,19 @@ function toAliasRoute(alias, basePath, sourceLabel) {
302
394
  * first. Docusaurus and Nextra skip both forms.
303
395
  */
304
396
  function isPageFile(name) {
305
- return !name.startsWith(".") && !name.startsWith("_") && name.endsWith(PAGE_EXTENSION) && name.length > 3;
397
+ return !name.startsWith(".") && !name.startsWith("_") && path.extname(name).toLowerCase() === PAGE_EXTENSION;
306
398
  }
307
399
  /** `_drafts/` and `.git/` are not content. */
308
400
  function isIgnoredDir(name) {
309
401
  return name.startsWith(".") || name.startsWith("_");
310
402
  }
311
- function baseName(filePath) {
312
- return path.basename(filePath, PAGE_EXTENSION);
403
+ /** `getting-started.MD` -> `getting-started`. */
404
+ function stripExtension(name) {
405
+ return name.slice(0, name.length - path.extname(name).length);
313
406
  }
314
407
  function toHref(basePath, segments) {
315
408
  if (segments.length === 0) return basePath === "" ? "/" : basePath;
316
- return `${basePath}/${segments.join("/")}`;
409
+ return `${basePath}/${encodeSegments(segments)}`;
317
410
  }
318
411
  function normalizeBasePath(basePath) {
319
412
  const trimmed = basePath.trim().replace(/\/+$/, "");
@@ -329,4 +422,4 @@ function humanize(name) {
329
422
  return name.split(/[-_\s]+/).filter((word) => word !== "").map((word) => `${word.charAt(0).toUpperCase()}${word.slice(1)}`).join(" ");
330
423
  }
331
424
  //#endregion
332
- export { createDocsSource, resolveDocsConfig, toAliasRoute };
425
+ export { createDocsSource, resolveDocsConfig };