@waveso/docs 0.1.0 → 0.2.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 (41) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +111 -22
  3. package/dist/docs-error.d.ts +74 -0
  4. package/dist/docs-error.js +40 -0
  5. package/dist/frontmatter.d.ts +39 -7
  6. package/dist/frontmatter.js +51 -24
  7. package/dist/highlighter.d.ts +2 -2
  8. package/dist/highlighter.js +3 -2
  9. package/dist/map-pooled.d.ts +26 -0
  10. package/dist/map-pooled.js +45 -0
  11. package/dist/meta.d.ts +7 -3
  12. package/dist/meta.js +61 -15
  13. package/dist/next.d.ts +41 -19
  14. package/dist/next.js +117 -21
  15. package/dist/plugins/rehype-capture-toc.js +26 -15
  16. package/dist/plugins/rehype-code-language.d.ts +24 -0
  17. package/dist/plugins/rehype-code-language.js +48 -0
  18. package/dist/plugins/rehype-fallback-heading-ids.d.ts +6 -0
  19. package/dist/plugins/rehype-fallback-heading-ids.js +51 -0
  20. package/dist/plugins/rehype-flatten-roots.d.ts +7 -0
  21. package/dist/plugins/rehype-flatten-roots.js +39 -0
  22. package/dist/plugins/remark-doc-links.d.ts +12 -1
  23. package/dist/plugins/remark-doc-links.js +147 -20
  24. package/dist/react/markdown-components.js +71 -6
  25. package/dist/react/search-dialog.d.ts +23 -7
  26. package/dist/react/search-dialog.js +46 -29
  27. package/dist/react/toc.js +28 -5
  28. package/dist/react/youtube.js +6 -4
  29. package/dist/render.d.ts +43 -9
  30. package/dist/render.js +112 -50
  31. package/dist/search-index.d.ts +32 -14
  32. package/dist/search-index.js +45 -51
  33. package/dist/search-options.d.ts +32 -1
  34. package/dist/search-options.js +66 -3
  35. package/dist/section-boundary.d.ts +17 -0
  36. package/dist/section-boundary.js +43 -0
  37. package/dist/source.d.ts +13 -1
  38. package/dist/source.js +152 -56
  39. package/dist/styles.css +236 -90
  40. package/dist/types.d.ts +41 -27
  41. package/package.json +13 -12
package/dist/source.js CHANGED
@@ -1,17 +1,11 @@
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 { foldSegments } from "./plugins/remark-doc-links.js";
5
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
4
6
  import path from "node:path";
5
7
  import matter from "gray-matter";
6
8
  //#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
9
  /** Markdown only. MDX is deliberately out of scope for this package. */
16
10
  const PAGE_EXTENSION = ".md";
17
11
  /** A directory whose `index.md` is the directory's own route. */
@@ -83,7 +77,13 @@ function createDocsSource(config) {
83
77
  }
84
78
  function buildSource(config) {
85
79
  let cached = null;
86
- const load = () => cached ??= scan(config);
80
+ const load = () => {
81
+ cached ??= scan(config).catch((err) => {
82
+ cached = null;
83
+ throw err;
84
+ });
85
+ return cached;
86
+ };
87
87
  const isVisible = (file) => config.includeDrafts || file.frontmatter.draft !== true;
88
88
  return {
89
89
  config,
@@ -94,6 +94,10 @@ function buildSource(config) {
94
94
  const { files } = await load();
95
95
  return files.filter(isVisible);
96
96
  },
97
+ async drafts() {
98
+ const { files } = await load();
99
+ return files.filter((file) => file.frontmatter.draft === true);
100
+ },
97
101
  async find(segments) {
98
102
  const { bySlug } = await load();
99
103
  return bySlug.get(segments.join("/"));
@@ -109,7 +113,7 @@ function buildSource(config) {
109
113
  }
110
114
  async function scan(config) {
111
115
  await assertContentDir(config.contentDir);
112
- const root = await scanDir(config.contentDir, [], "", config);
116
+ const root = await scanDir(config.contentDir, [], "", config, /* @__PURE__ */ new Set([await realpath(config.contentDir)]));
113
117
  const files = [];
114
118
  const bySlug = /* @__PURE__ */ new Map();
115
119
  collect(root, files, bySlug);
@@ -123,15 +127,21 @@ async function assertContentDir(contentDir) {
123
127
  try {
124
128
  if ((await stat(contentDir)).isDirectory()) return;
125
129
  } 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()}).`);
130
+ 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
131
  }
128
- async function scanDir(absPath, segments, name, config) {
132
+ const SKIP = { kind: "skip" };
133
+ async function scanDir(absPath, segments, name, config, ancestors) {
129
134
  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);
135
+ const classified = await Promise.all(entries.map((entry) => ({
136
+ entry,
137
+ name: entry.name.normalize("NFC")
138
+ })).sort((a, b) => a.name.localeCompare(b.name, "en")).map((listed) => classifyEntry(absPath, listed.entry, listed.name)));
139
+ const pageEntries = [];
140
+ const dirEntries = [];
141
+ for (const entry of classified) if (entry.kind === "page") pageEntries.push(entry);
142
+ else if (entry.kind === "dir" && !ancestors.has(entry.realPath)) dirEntries.push(entry);
143
+ 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))))]);
144
+ const index = pages.find((page) => page.name === INDEX_NAME);
135
145
  return {
136
146
  name,
137
147
  absPath,
@@ -143,7 +153,49 @@ async function scanDir(absPath, segments, name, config) {
143
153
  dirs
144
154
  };
145
155
  }
146
- async function readPage(filePath, dirSegments, config) {
156
+ /**
157
+ * What kind of content is this directory entry?
158
+ *
159
+ * ⚠️ `isFile()` AND `isDirectory()` ARE BOTH FALSE FOR A SYMBOLIC LINK, and
160
+ * `readdir` has no follow option — so a symlinked page, or a whole symlinked
161
+ * section, was absent from `all()`, `nav()`, the sitemap and the search index
162
+ * without a word. The cascade is worse than the omission: the first link to
163
+ * the missing page fails the build with `no such page exists`, which sends the
164
+ * author after a link that is perfectly correct.
165
+ *
166
+ * `stat` follows the link, so the target's kind decides — and the name filters
167
+ * still apply to the link's own name, which is what the URL is built from.
168
+ */
169
+ async function classifyEntry(dirPath, entry, name) {
170
+ const absPath = path.join(dirPath, entry.name);
171
+ let isFile = entry.isFile();
172
+ let isDir = entry.isDirectory();
173
+ if (!isFile && !isDir) {
174
+ if (!entry.isSymbolicLink()) return SKIP;
175
+ let target;
176
+ try {
177
+ target = await stat(absPath);
178
+ } catch {
179
+ if (!isPageFile(name)) return SKIP;
180
+ 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.`);
181
+ }
182
+ isFile = target.isFile();
183
+ isDir = target.isDirectory();
184
+ }
185
+ if (isFile) return isPageFile(name) ? {
186
+ kind: "page",
187
+ absPath,
188
+ name: stripExtension(name)
189
+ } : SKIP;
190
+ if (isDir && !isIgnoredDir(name)) return {
191
+ kind: "dir",
192
+ absPath,
193
+ name,
194
+ realPath: await realpath(absPath)
195
+ };
196
+ return SKIP;
197
+ }
198
+ async function readPage(filePath, name, dirSegments, config) {
147
199
  const raw = await readFile(filePath, "utf8");
148
200
  const relativePath = toPosix(path.relative(config.contentDir, filePath));
149
201
  let data;
@@ -154,25 +206,29 @@ async function readPage(filePath, dirSegments, config) {
154
206
  content = parsed.content;
155
207
  } catch (err) {
156
208
  const reason = err instanceof Error ? err.message : String(err);
157
- throw new Error(`Could not parse the frontmatter block in ${relativePath}: ${reason}`);
209
+ throw docsError("invalid-frontmatter", `Could not parse the frontmatter block in ${relativePath}: ${reason}`, { cause: err });
158
210
  }
159
- const name = baseName(filePath);
160
211
  const segments = name === INDEX_NAME ? [...dirSegments] : [...dirSegments, name];
212
+ const frontmatter = await parseFrontmatter(data, relativePath, config.frontmatterSchema);
213
+ for (const alias of frontmatter.aliases ?? []) toAliasRoute(alias, config.basePath, relativePath);
161
214
  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
215
+ name,
216
+ doc: {
217
+ segments,
218
+ slug: segments.join("/"),
219
+ href: toHref(config.basePath, segments),
220
+ filePath,
221
+ relativePath,
222
+ frontmatter,
223
+ content
224
+ }
169
225
  };
170
226
  }
171
227
  function collect(dir, files, bySlug) {
172
228
  const own = dir.index ? [dir.index, ...dir.pages] : dir.pages;
173
- for (const file of own) {
229
+ for (const { doc: file } of own) {
174
230
  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.`);
231
+ 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
232
  bySlug.set(file.slug, file);
177
233
  files.push(file);
178
234
  }
@@ -193,9 +249,8 @@ function buildNav(dir, config) {
193
249
  const mergeable = new Set(dir.dirs.filter((child) => child.index === void 0).map((child) => child.name));
194
250
  const dirPages = /* @__PURE__ */ new Map();
195
251
  for (const page of dir.pages) {
196
- const name = baseName(page.filePath);
197
- if (mergeable.has(name)) {
198
- dirPages.set(name, page);
252
+ if (mergeable.has(page.name)) {
253
+ dirPages.set(page.name, page);
199
254
  continue;
200
255
  }
201
256
  entries.push(toPageEntry(page, config));
@@ -207,51 +262,61 @@ function buildNav(dir, config) {
207
262
  for (const child of dir.dirs) {
208
263
  const children = buildNav(child, config);
209
264
  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;
265
+ /**
266
+ * ⚠️ A DRAFT INDEX IS NOT A PUBLIC PAGE, AND ITS TITLE IS NOT EITHER.
267
+ *
268
+ * Only `href` used to be gated on visibility, so `secret/index.md` with
269
+ * `draft: true` still supplied the group heading — an unreleased codename
270
+ * rendered into the sidebar of a production build, with no link on it, so
271
+ * no click reveals it and only view-source shows it at all. Its `order`
272
+ * also still positioned the group among published ones.
273
+ */
274
+ const visible = index && isVisibleIn(index.doc, config) ? index : void 0;
275
+ const title = groupTitle(child, visible?.doc);
276
+ const href = visible?.doc.href;
212
277
  const group = {
213
278
  type: "group",
214
279
  title,
215
280
  children,
216
281
  ...href !== void 0 ? { href } : {}
217
282
  };
218
- const node = children.length === 0 && index !== void 0 && href !== void 0 ? {
283
+ const node = children.length === 0 && visible !== void 0 && href !== void 0 ? {
219
284
  type: "page",
220
285
  title,
221
286
  href,
222
- slug: index.slug
287
+ slug: visible.doc.slug
223
288
  } : group;
224
- const order = index?.frontmatter.order;
289
+ const order = visible?.doc.frontmatter.order;
225
290
  entries.push({
226
291
  name: child.name,
227
292
  title,
228
293
  node,
229
294
  inlineChildren: children,
230
- ...index !== void 0 && href !== void 0 ? { indexNode: {
295
+ ...visible !== void 0 && href !== void 0 ? { indexNode: {
231
296
  type: "page",
232
- title: navTitle(index),
297
+ title: navTitle(visible.doc),
233
298
  href,
234
- slug: index.slug
299
+ slug: visible.doc.slug
235
300
  } } : {},
236
301
  ...order !== void 0 ? { order } : {}
237
302
  });
238
303
  }
239
- return orderNavEntries(entries, dir.meta, dir.metaPath);
304
+ return orderNavEntries(entries, dir.meta, dir.metaPath, dir.segments.length);
240
305
  }
241
306
  function toPageEntry(page, config) {
242
- const title = navTitle(page);
243
- const { order } = page.frontmatter;
307
+ const title = navTitle(page.doc);
308
+ const { order } = page.doc.frontmatter;
244
309
  return {
245
- name: baseName(page.filePath),
310
+ name: page.name,
246
311
  title,
247
312
  node: {
248
313
  type: "page",
249
314
  title,
250
- href: page.href,
251
- slug: page.slug
315
+ href: page.doc.href,
316
+ slug: page.doc.slug
252
317
  },
253
318
  ...order !== void 0 ? { order } : {},
254
- ...isVisibleIn(page, config) ? {} : { hidden: true }
319
+ ...isVisibleIn(page.doc, config) ? {} : { hidden: true }
255
320
  };
256
321
  }
257
322
  /** Sidebars are narrow: `label` wins over `title` when the author set one. */
@@ -273,6 +338,18 @@ function isVisibleIn(file, config) {
273
338
  return config.includeDrafts || file.frontmatter.draft !== true;
274
339
  }
275
340
  /**
341
+ * ⚠️ CHARACTERS `path-to-regexp` READS AS PATTERN SYNTAX.
342
+ *
343
+ * Next compiles every `redirects()` source with it, so an alias is not the
344
+ * literal URL it looks like. `v1:beta` compiles to `/docs/v1([^/]+?)`, which
345
+ * `next build` accepts without a word and which then 308s the genuinely
346
+ * prerendered `/docs/v1-guide` away — config redirects run before filesystem
347
+ * routes, so the real page is unreachable in production and nothing reports
348
+ * it. `c++` is the loud sibling: the build aborts with `Unexpected MODIFIER at
349
+ * 7`, naming an offset into a string the author never wrote and no file.
350
+ */
351
+ const ALIAS_PATTERN_CHARS = /[:()+*?{}]/;
352
+ /**
276
353
  * A former URL from `aliases` frontmatter, as a route.
277
354
  *
278
355
  * `'quickstart'` on a site mounted at `/docs` becomes `/docs/quickstart`.
@@ -282,12 +359,17 @@ function isVisibleIn(file, config) {
282
359
  *
283
360
  * Shared by both adapters so they agree on which routes exist: an alias is a
284
361
  * redirect the host installs, so a link to one resolves, and a link that
285
- * builds under Next must build under Vite.
362
+ * builds under Next must build under Vite. The source scan calls it too, so
363
+ * every rejection below names the markdown file at the moment it is read.
286
364
  */
287
365
  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}`;
366
+ const trimmed = alias.trim();
367
+ if (trimmed.split("/").some((part) => part === "." || part === "..")) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} has a '.' or '..' segment. An alias is a former URL relative to the docs base path, not a path on disk: write \`aliases: [legacy/old-name]\`.`);
368
+ const pattern = ALIAS_PATTERN_CHARS.exec(trimmed);
369
+ if (pattern !== null) throw docsError("invalid-alias", `@waveso/docs: the alias '${alias}' in ${sourceLabel} contains '${pattern[0]}', which Next compiles as redirect pattern syntax rather than as part of the URL — the redirect then swallows every page whose route the pattern happens to match, or fails the build. Remove the character; an alias is a literal former URL.`);
370
+ const segments = foldSegments([], trimmed);
371
+ if (segments === void 0 || segments.length === 0) throw docsError("invalid-alias", `@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]\`.`);
372
+ return `${basePath}/${encodeSegments(segments)}`;
291
373
  }
292
374
  /**
293
375
  * ⚠️ `_` AND `.` BOTH, MATCHING `isIgnoredDir` BELOW — which is what this did
@@ -302,18 +384,32 @@ function toAliasRoute(alias, basePath, sourceLabel) {
302
384
  * first. Docusaurus and Nextra skip both forms.
303
385
  */
304
386
  function isPageFile(name) {
305
- return !name.startsWith(".") && !name.startsWith("_") && name.endsWith(PAGE_EXTENSION) && name.length > 3;
387
+ return !name.startsWith(".") && !name.startsWith("_") && path.extname(name).toLowerCase() === PAGE_EXTENSION;
306
388
  }
307
389
  /** `_drafts/` and `.git/` are not content. */
308
390
  function isIgnoredDir(name) {
309
391
  return name.startsWith(".") || name.startsWith("_");
310
392
  }
311
- function baseName(filePath) {
312
- return path.basename(filePath, PAGE_EXTENSION);
393
+ /** `getting-started.MD` -> `getting-started`. */
394
+ function stripExtension(name) {
395
+ return name.slice(0, name.length - path.extname(name).length);
396
+ }
397
+ /**
398
+ * Percent-encode the segments, and only here.
399
+ *
400
+ * `segments` and `slug` stay raw on purpose: Next decodes route params before
401
+ * they reach `find()`, so an encoded slug would match nothing. Unencoded, a
402
+ * `#`, `?` or `%` in a filename stops being part of the path — the sitemap
403
+ * emitted `https://example.com/docs/c#%20guide`, and `alternates.canonical`
404
+ * and `og:url` are built by the same call — while a space produced a URL that
405
+ * only works until something re-encodes it.
406
+ */
407
+ function encodeSegments(segments) {
408
+ return segments.map(encodeURIComponent).join("/");
313
409
  }
314
410
  function toHref(basePath, segments) {
315
411
  if (segments.length === 0) return basePath === "" ? "/" : basePath;
316
- return `${basePath}/${segments.join("/")}`;
412
+ return `${basePath}/${encodeSegments(segments)}`;
317
413
  }
318
414
  function normalizeBasePath(basePath) {
319
415
  const trimmed = basePath.trim().replace(/\/+$/, "");