@waveso/docs 0.5.0 → 0.7.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.
package/dist/render.js CHANGED
@@ -1,5 +1,7 @@
1
+ import { collectAnchorIds, splitAnchor } from "./anchors.js";
1
2
  import { docsError } from "./docs-error.js";
2
3
  import { DEFAULT_DOCS_THEMES, createDocsHighlighter } from "./highlighter.js";
4
+ import { describeSuggestion } from "./link-suggestion.js";
3
5
  import { rehypeCaptureToc } from "./plugins/rehype-capture-toc.js";
4
6
  import { rehypeCodeFrame } from "./plugins/rehype-code-frame.js";
5
7
  import { rehypeNormalizeCodeLanguage, rehypeRestoreExcludedCode } from "./plugins/rehype-code-language.js";
@@ -8,6 +10,7 @@ import { rehypeFlattenRoots } from "./plugins/rehype-flatten-roots.js";
8
10
  import { foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref } from "./plugins/remark-doc-links.js";
9
11
  import { remarkUnwrapImages } from "./plugins/remark-unwrap-images.js";
10
12
  import { remarkYouTube } from "./plugins/remark-youtube.js";
13
+ import { visit } from "unist-util-visit";
11
14
  import rehypeShikiFromHighlighter from "@shikijs/rehype/core";
12
15
  import { rehypeGithubAlerts } from "rehype-github-alerts";
13
16
  import rehypeAutolinkHeadings from "rehype-autolink-headings";
@@ -16,7 +19,6 @@ import remarkGfm from "remark-gfm";
16
19
  import remarkParse from "remark-parse";
17
20
  import remarkRehype from "remark-rehype";
18
21
  import { unified } from "unified";
19
- import { visit } from "unist-util-visit";
20
22
  import { VFile } from "vfile";
21
23
  //#region src/render.ts
22
24
  /**
@@ -121,6 +123,18 @@ function withSuffix(src, suffix) {
121
123
  return `${src}${suffix}`;
122
124
  }
123
125
  /** Route without its `?query` / `#anchor`, for existence checks. */
126
+ /**
127
+ * Does this route belong to the host application rather than the docs?
128
+ *
129
+ * Prefix match on a segment boundary, so `/api` covers `/api/keys` and does not
130
+ * accidentally cover `/apiary`.
131
+ */
132
+ function isExternalRoute(route, external) {
133
+ return external.some((prefix) => {
134
+ const trimmed = prefix.replace(/\/+$/, "");
135
+ return route === trimmed || route.startsWith(`${trimmed}/`);
136
+ });
137
+ }
124
138
  function toRouteKey(href) {
125
139
  const cut = href.search(/[?#]/);
126
140
  return cut === -1 ? href : href.slice(0, cut);
@@ -343,21 +357,65 @@ function createDocsRenderer(options) {
343
357
  }));
344
358
  }
345
359
  /**
346
- * Fail the build on a link that would have 404'd.
360
+ * Act on a link problem at the severity the site chose.
361
+ *
362
+ * ⚠️ `'throw'` IS THE DEFAULT AND SHOULD STAY IT. The link was valid in the
363
+ * editor and on GitHub, so a warning in a build log is a warning nobody
364
+ * reads. The setting exists because a migration may knowingly run against an
365
+ * incomplete corpus for a while and the tool cannot know that — which is the
366
+ * same reason Docusaurus has one.
367
+ */
368
+ function report(severity, code, message) {
369
+ if (severity === "ignore") return;
370
+ if (severity === "throw") throw docsError(code, message);
371
+ console.warn(docsError(code, message).message);
372
+ }
373
+ /**
374
+ * Anchors that point into the page being rendered.
375
+ *
376
+ * ⚠️ HERE, NOT IN THE CORPUS PASS, BECAUSE THIS IS WHERE THE LINE NUMBER IS.
377
+ * The recorded refs carry the source line and the tree still has its
378
+ * positions; both are gone from a returned `RenderedDoc`. A same-page anchor
379
+ * is also the one a writer produces most — every "see below" — so it is worth
380
+ * the better message.
347
381
  *
348
- * Deliberately a throw and not a warning: the link was valid in the editor
349
- * and on GitHub, so a warning in a build log is a warning nobody reads.
382
+ * Cross-page anchors need the other page's ids and are checked by
383
+ * `renderAll`, which is the first point at which they exist.
350
384
  */
385
+ function assertOwnAnchors(file, tree, refs) {
386
+ if (config.onBrokenAnchors === "ignore") return;
387
+ let ids;
388
+ for (const ref of refs) {
389
+ const anchor = splitAnchor(ref.href ?? ref.raw);
390
+ if (anchor === void 0) continue;
391
+ if (anchor.route !== "" && anchor.route !== file.href) continue;
392
+ ids ??= collectAnchorIds(tree);
393
+ if (ids.has(anchor.fragment)) continue;
394
+ report(config.onBrokenAnchors, "broken-anchor", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', and this page has no '#${anchor.fragment}'.${describeSuggestion(anchor.fragment, ids)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
395
+ }
396
+ }
397
+ /** Check every link a page recorded, and act at the configured severity. */
351
398
  function assertLinks(file, refs) {
352
399
  for (const ref of refs) {
353
- if (ref.href === void 0) throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
354
- if (knownRoutes === void 0 || ref.asset) continue;
400
+ if (ref.anchorOnly) continue;
401
+ if (ref.href === void 0) {
402
+ report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which does not resolve to a documentation page. Use a path relative to this file, or an absolute URL for external links.`);
403
+ continue;
404
+ }
405
+ if (knownRoutes === void 0 || ref.asset || ref.anchorOnly) continue;
355
406
  const route = toRouteKey(ref.href);
407
+ if (isExternalRoute(route, config.externalRoutes)) continue;
356
408
  if (knownRoutes.has(route)) continue;
357
- if (draftRoutes?.has(route)) throw docsError("draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
409
+ if (draftRoutes?.has(route)) {
410
+ report(config.onBrokenLinks, "draft-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — a page marked \`draft: true\`, so it is not published and the link would 404. Publish the page, remove the link, or build with \`includeDrafts\`.`);
411
+ continue;
412
+ }
358
413
  const aliasTarget = aliasRoutes?.get(route);
359
- if (aliasTarget !== void 0) throw docsError("alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
360
- throw docsError("broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists. Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
414
+ if (aliasTarget !== void 0) {
415
+ report(config.onBrokenLinks, "alias-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — an alias that redirects to '${aliasTarget}'. An alias is not a page: it 404s unless \`createDocsRedirects\` is wired into \`next.config.ts\`, and it is never prerendered. Link to '${aliasTarget}' directly.`);
416
+ continue;
417
+ }
418
+ report(config.onBrokenLinks, "broken-link", `@waveso/docs: ${describeLink(file, ref)} links to '${ref.raw}', which resolves to '${route}' — no such page exists.${describeSuggestion(route, knownRoutes)} Fix the link, or add an \`aliases\` entry to the page it used to point at.`);
361
419
  }
362
420
  }
363
421
  return { async render(file) {
@@ -373,9 +431,11 @@ function createDocsRenderer(options) {
373
431
  relativePath: file.relativePath
374
432
  };
375
433
  const hast = await processor.run(processor.parse(vfile), vfile);
376
- if (titleHeading && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
434
+ const hasHero = (file.frontmatter.actions?.length ?? 0) > 0;
435
+ if (titleHeading && !hasHero && !hasHeadingOne(hast)) hast.children.unshift(titleHeadingNode(file.frontmatter.title));
377
436
  await resolveImages(hast, file, imageResolver);
378
- if (config.assertLinks) assertLinks(file, vfile.data.docLinks ?? []);
437
+ assertLinks(file, vfile.data.docLinks ?? []);
438
+ assertOwnAnchors(file, hast, vfile.data.docLinks ?? []);
379
439
  return {
380
440
  frontmatter: file.frontmatter,
381
441
  hast: stripPositions(hast),
package/dist/source.js CHANGED
@@ -18,7 +18,7 @@ const INDEX_NAME = "index";
18
18
  * ⚠️ THE SCAN USED TO OPEN EVERY MARKDOWN FILE AT ONCE. `scanDir` recursed into
19
19
  * its subdirectories in parallel and read that directory's pages with a bare
20
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
21
+ * of markdown files in the entire tree. On a 1,201-page corpus and the common
22
22
  * 1,024-descriptor soft limit, `next build` died with a bare `EMFILE: too many
23
23
  * open files` — no error code, no mention that this was the docs scan, and
24
24
  * nothing pointing at the fix. Exactly the large content set this package is
@@ -71,7 +71,9 @@ function resolveDocsConfig(config) {
71
71
  ),
72
72
  basePath: normalizeBasePath(config.basePath ?? "/docs"),
73
73
  includeDrafts: config.includeDrafts ?? false,
74
- assertLinks: config.assertLinks ?? true,
74
+ onBrokenLinks: config.onBrokenLinks ?? "throw",
75
+ onBrokenAnchors: config.onBrokenAnchors ?? "throw",
76
+ externalRoutes: config.externalRoutes ?? [],
75
77
  ...config.frontmatterSchema === void 0 ? {} : { frontmatterSchema: config.frontmatterSchema }
76
78
  };
77
79
  }
@@ -114,7 +116,9 @@ function createDocsSource(config) {
114
116
  resolved.contentDir,
115
117
  resolved.basePath,
116
118
  resolved.includeDrafts,
117
- resolved.assertLinks,
119
+ resolved.onBrokenLinks,
120
+ resolved.onBrokenAnchors,
121
+ resolved.externalRoutes.join(","),
118
122
  schemaKey(resolved.frontmatterSchema)
119
123
  ].join("\0");
120
124
  const existing = sources.get(key);