@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.
@@ -1,4 +1,5 @@
1
1
  import { docsError } from "./docs-error.js";
2
+ import { isSafeHref } from "./safe-href.js";
2
3
  import { z } from "zod";
3
4
  //#region src/frontmatter.ts
4
5
  /**
@@ -30,7 +31,12 @@ const docFrontmatterSchema = z.object({
30
31
  label: z.string().exactOptional(),
31
32
  draft: z.boolean().exactOptional(),
32
33
  aliases: z.array(z.string()).exactOptional(),
33
- order: z.number().exactOptional()
34
+ order: z.number().exactOptional(),
35
+ actions: z.array(z.object({
36
+ label: z.string().min(1),
37
+ href: z.string().min(1).refine(isSafeHref, "must be a safe URL (no javascript: or data:)"),
38
+ variant: z.enum(["primary", "secondary"]).exactOptional()
39
+ })).exactOptional()
34
40
  });
35
41
  /**
36
42
  * The package's own fields, every one of them optional, for the overlay pass
@@ -0,0 +1,31 @@
1
+ //#region src/link-suggestion.d.ts
2
+ /**
3
+ * "Did you mean …?" for a link that matched no page.
4
+ *
5
+ * Private — deliberately not an entry point.
6
+ *
7
+ * A broken link is almost always a typo, and a typo is a near-miss by
8
+ * construction: `/instalation` is one edit from `/installation`, while `/login`
9
+ * is six from anything in a docs tree. That gap is what makes a suggestion
10
+ * safe to offer and safe to withhold — the same reason `git`, `tsc`, `cargo`
11
+ * and Python 3.12 all do it, and the reason none of them offers one for a word
12
+ * that is nowhere near a real name.
13
+ *
14
+ * ⚠️ A SUGGESTION ONLY, NEVER A DECISION. Nothing here decides whether a link
15
+ * is an error; `render.ts` has already decided that by the time it asks. Using
16
+ * an edit distance to pick between failing and staying silent would be a
17
+ * heuristic holding a build hostage, which is not a thing to do to somebody
18
+ * whose page happens to be called `/setting`.
19
+ */
20
+ /**
21
+ * The closest route to `target`, or `undefined` if nothing is close enough.
22
+ *
23
+ * Ties break on the shortest candidate and then alphabetically, so the message
24
+ * is the same on every machine — a suggestion that changes between runs reads
25
+ * as a flaky build.
26
+ */
27
+ declare function suggestRoute(target: string, routes: Iterable<string>): string | undefined;
28
+ /** ` Did you mean '/installation'?`, or `''` when nothing is close. */
29
+ declare function describeSuggestion(target: string, routes: Iterable<string> | undefined): string;
30
+ //#endregion
31
+ export { describeSuggestion, suggestRoute };
@@ -0,0 +1,94 @@
1
+ //#region src/link-suggestion.ts
2
+ /**
3
+ * "Did you mean …?" for a link that matched no page.
4
+ *
5
+ * Private — deliberately not an entry point.
6
+ *
7
+ * A broken link is almost always a typo, and a typo is a near-miss by
8
+ * construction: `/instalation` is one edit from `/installation`, while `/login`
9
+ * is six from anything in a docs tree. That gap is what makes a suggestion
10
+ * safe to offer and safe to withhold — the same reason `git`, `tsc`, `cargo`
11
+ * and Python 3.12 all do it, and the reason none of them offers one for a word
12
+ * that is nowhere near a real name.
13
+ *
14
+ * ⚠️ A SUGGESTION ONLY, NEVER A DECISION. Nothing here decides whether a link
15
+ * is an error; `render.ts` has already decided that by the time it asks. Using
16
+ * an edit distance to pick between failing and staying silent would be a
17
+ * heuristic holding a build hostage, which is not a thing to do to somebody
18
+ * whose page happens to be called `/setting`.
19
+ */
20
+ /**
21
+ * The ceiling on how far apart two routes may be and still be called a typo.
22
+ *
23
+ * Three is roughly one slip per word in a two-word slug — `instalation`,
24
+ * `gettting-started`, `plugns`. It is a *ceiling*, not the whole rule: the
25
+ * budget is also scaled by the length of what was written, so short routes are
26
+ * held tighter and `/api` is not offered as a fix for `/ui`.
27
+ *
28
+ * Between them, `/docs/instructions` gets no suggestion for
29
+ * `/docs/installation` — five edits apart, similar enough to tempt a generous
30
+ * threshold, and a different word. `render.test.ts` pins that case, because
31
+ * without it this constant could be any number at all.
32
+ */
33
+ const MAX_DISTANCE = 3;
34
+ /**
35
+ * Levenshtein distance, bounded.
36
+ *
37
+ * Two rows rather than a full matrix: the corpus is every route on the site and
38
+ * this runs per broken link, so the allocation is the only part worth caring
39
+ * about. Returns early once every cell in a row exceeds `limit`, which is the
40
+ * common case — most candidates are nowhere near.
41
+ */
42
+ function distance(a, b, limit) {
43
+ if (a === b) return 0;
44
+ if (Math.abs(a.length - b.length) > limit) return limit + 1;
45
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
46
+ let current = new Array(b.length + 1);
47
+ for (let i = 1; i <= a.length; i += 1) {
48
+ current[0] = i;
49
+ let best = i;
50
+ for (let j = 1; j <= b.length; j += 1) {
51
+ const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1);
52
+ const deletion = previous[j] + 1;
53
+ const insertion = current[j - 1] + 1;
54
+ const cell = Math.min(substitution, deletion, insertion);
55
+ current[j] = cell;
56
+ if (cell < best) best = cell;
57
+ }
58
+ if (best > limit) return limit + 1;
59
+ const swap = previous;
60
+ previous = current;
61
+ current = swap;
62
+ }
63
+ return previous[b.length];
64
+ }
65
+ /**
66
+ * The closest route to `target`, or `undefined` if nothing is close enough.
67
+ *
68
+ * Ties break on the shortest candidate and then alphabetically, so the message
69
+ * is the same on every machine — a suggestion that changes between runs reads
70
+ * as a flaky build.
71
+ */
72
+ function suggestRoute(target, routes) {
73
+ const limit = Math.min(MAX_DISTANCE, Math.max(1, Math.floor(target.length / 3)));
74
+ let best;
75
+ let bestDistance = limit + 1;
76
+ for (const route of routes) {
77
+ if (route === target) continue;
78
+ const measured = distance(target, route, limit);
79
+ if (measured > limit) continue;
80
+ if (measured < bestDistance || measured === bestDistance && best !== void 0 && (route.length < best.length || route.length === best.length && route < best)) {
81
+ best = route;
82
+ bestDistance = measured;
83
+ }
84
+ }
85
+ return best;
86
+ }
87
+ /** ` Did you mean '/installation'?`, or `''` when nothing is close. */
88
+ function describeSuggestion(target, routes) {
89
+ if (routes === void 0) return "";
90
+ const suggestion = suggestRoute(target, routes);
91
+ return suggestion === void 0 ? "" : ` Did you mean '${suggestion}'?`;
92
+ }
93
+ //#endregion
94
+ export { describeSuggestion, suggestRoute };
package/dist/next.d.ts CHANGED
@@ -96,27 +96,25 @@ interface DocsRouteOptions<TFrontmatter extends DocFrontmatter = DocFrontmatter>
96
96
  /**
97
97
  * Props for {@link DocsRoute.Layout}.
98
98
  *
99
- * Four, and the fourth is a boolean. Everything else a docs shell is asked for
100
- * turned out to be reachable already: an announcement banner renders *above*
101
- * `<docs.Layout>` in your own `layout.tsx`, because this does not own `<body>`;
102
- * a content footer goes inside `children`; and sidebar links, social icons and
103
- * separators are `DocNavNode`s authored in `meta.json`. The header bar is the
104
- * one region nothing else can reach, which is what `actions` is for.
99
+ * Three, and one of them is `children`. Everything else a docs shell is asked
100
+ * for turned out to be reachable already: an announcement banner renders
101
+ * *above* `<docs.Layout>` in your own `layout.tsx`, because this does not own
102
+ * `<body>`; a content footer goes inside `children`; and sidebar links, social
103
+ * icons and separators are `DocNavNode`s authored in `meta.json`. A theme
104
+ * toggle and a repository link go in the layout you write around this one — the
105
+ * host wraps `docs.Layout` exactly as it already wraps `<html>` and `<body>`,
106
+ * so there is no region only this package can reach.
105
107
  *
106
- * A `slots` map was the alternative, and it can still be added later — two node
107
- * props can become a slots map, a slots map cannot become two props.
108
+ * The one region a host cannot reach through `docs.Layout` is *inside* the
109
+ * sidebar, and the exported primitives are the answer for that: `DocsSidebar`,
110
+ * `DocsToc`, `DocContent` and `SkipLink` compose into a layout of your own.
111
+ *
112
+ * A `slots` map was the alternative, and shipping none is the reversible half —
113
+ * a map can be added the day something needs one, a map that shipped cannot be
114
+ * taken back.
108
115
  */
109
116
  interface DocsLayoutProps {
110
117
  children: ReactNode;
111
- /**
112
- * Brand at the header start. A string, or your own logo component.
113
- *
114
- * `ReactNode`, so it cannot also serve as the `<title>` or as the header's
115
- * accessible name; the landmark carries a fixed label instead.
116
- */
117
- title?: ReactNode;
118
- /** Header end, after search: a theme toggle, a version switcher, a link. */
119
- actions?: ReactNode;
120
118
  /**
121
119
  * The search trigger. Defaults to on, and the URL is always derived.
122
120
  *
@@ -318,24 +316,33 @@ interface DocsRoute<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
318
316
  * export default docs.Layout;
319
317
  * ```
320
318
  *
321
- * Or, with your own chrome in the header:
319
+ * Or, with your own chrome *around* it — the same layout file `<html>` and
320
+ * `<body>` live in, and `SiteHeader` is yours:
322
321
  *
323
322
  * ```tsx
324
323
  * export default function DocsLayout({ children }: { children: ReactNode }) {
325
324
  * return (
326
- * <docs.Layout title={<Logo />} actions={<ThemeToggle />}>
327
- * {children}
328
- * </docs.Layout>
325
+ * <>
326
+ * <SiteHeader />
327
+ * <docs.Layout search={{ placeholder: 'Search the docs' }}>
328
+ * {children}
329
+ * </docs.Layout>
330
+ * </>
329
331
  * );
330
332
  * }
331
333
  * ```
332
334
  *
333
- * It owns the skip link, the header, the sidebar column, the mobile drawer
334
- * and the grid, and it reads `source.nav()` and `searchIndexUrl` itself — so
335
- * there is no nav to fetch and no URL to pass. It does **not** own the table
336
- * of contents: a Next layout receives `{children, params}` and cannot know
337
- * which page is rendering, so `docs.Page` emits the TOC as its second child
338
- * and the grid places it.
335
+ * If that header of yours is sticky, say how tall it is once —
336
+ * `--wave-docs-chrome-offset: 4rem` and our sticky columns start below it.
337
+ *
338
+ * It owns the skip link, the sidebar one shell at every width, holding the
339
+ * navigation and the 44px strip that moves it — the search trigger and the
340
+ * grid.
341
+ * It reads `source.nav()` and `searchIndexUrl` itself, so there is no nav to
342
+ * fetch and no URL to pass. It does **not** own the table of contents: a Next
343
+ * layout receives `{children, params}` and cannot know which page is
344
+ * rendering, so `docs.Page` emits the TOC as its second child and the grid
345
+ * places it.
339
346
  *
340
347
  * Your `layout.tsx` stays a Server Component. The two pieces that need a
341
348
  * client — the nav's `usePathname`, the search dialog — carry their own
package/dist/next.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { assertAnchors } from "./anchors.js";
1
2
  import { docsError } from "./docs-error.js";
2
3
  import { DOCS_CONTENT_ID } from "./docs-content-id.js";
4
+ import { describeSuggestion } from "./link-suggestion.js";
3
5
  import { mapPooled } from "./map-pooled.js";
4
6
  import { findFunctionValuedOptions } from "./search-options.js";
5
7
  import { createMarkdownComponents } from "./react/markdown-components.js";
6
8
  import { DocContent } from "./react/doc-content.js";
9
+ import { DocsHero } from "./react/hero.js";
7
10
  import { DocsToc } from "./react/toc.js";
8
- import { wrapNextLink } from "./react/next-link.js";
11
+ import { wrapNextLink } from "./react/link-adapter.js";
9
12
  import { createDocsRenderer } from "./render.js";
10
13
  import { toAliasRoute } from "./route-path.js";
11
14
  import { createDocsSource, resolveDocsConfig } from "./source.js";
@@ -170,11 +173,15 @@ async function buildNextComponents(labels) {
170
173
  const [linkMod, imageMod] = await Promise.all([importNext(() => import("next/link"), "next/link"), importNext(() => import("next/image"), "next/image")]);
171
174
  const NextLink = readDefaultExport(linkMod, "next/link");
172
175
  const NextImage = readDefaultExport(imageMod, "next/image");
173
- return createMarkdownComponents({
174
- Link: wrapNextLink(NextLink),
175
- Image: wrapNextImage(NextImage),
176
- ...labels === void 0 ? {} : { labels }
177
- });
176
+ const link = wrapNextLink(NextLink);
177
+ return {
178
+ components: createMarkdownComponents({
179
+ Link: link,
180
+ Image: wrapNextImage(NextImage),
181
+ ...labels === void 0 ? {} : { labels }
182
+ }),
183
+ link
184
+ };
178
185
  }
179
186
  /**
180
187
  * The named subset of `labels`, or `undefined` when none of it is set.
@@ -218,7 +225,7 @@ function pickLabels(labels, map) {
218
225
  */
219
226
  function serializableSearchOptions(candidate) {
220
227
  const functions = findFunctionValuedOptions(candidate);
221
- if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` putting that component in \`actions\`. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
228
+ if (functions.length > 0) throw docsError("invalid-config", `the search dialog cannot be given MiniSearch functions from a server component: ${functions.map((name) => `\`miniSearchOptions.${name}\``).join(", ")}. \`docs.Layout\` renders the dialog as a client component, so its props are serialised on the way across and React rejects a function with "Functions cannot be passed directly to Client Components" while prerendering. Keep the function on \`createDocsRoute\` so the index is still built with it, pass \`search={false}\` to \`docs.Layout\`, and render the dialog yourself from a \`'use client'\` module that imports the same function — \`<DocsSearch indexUrl={docs.searchIndexUrl} miniSearchOptions={{ processTerm }} />\` in your own layout. Serialisable overrides (\`storeFields\`, \`boost\`, \`searchOptions.fuzzy\`) need none of this and are forwarded as before.`);
222
229
  return candidate;
223
230
  }
224
231
  /**
@@ -374,7 +381,23 @@ function createDocsRoute(options) {
374
381
  const files = await source.all();
375
382
  await loadRoutes();
376
383
  const renderer = loadRenderer();
377
- return mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
384
+ const rendered = await mapPooled(files, RENDER_CONCURRENCY, (file) => renderer.render(file));
385
+ assertAnchors(rendered, (from, link, known) => {
386
+ reportAnchor(`@waveso/docs: ${from} links to '${link.href}', and '${link.route}' has no '#${link.fragment}'.${describeSuggestion(link.fragment, known)} Heading ids come from the heading text, so renaming a heading renames its anchor.`);
387
+ });
388
+ return rendered;
389
+ };
390
+ /**
391
+ * A cross-page anchor failure, at the configured severity.
392
+ *
393
+ * No line number, unlike the same-page check: positions are stripped from a
394
+ * returned tree, so the page and the link are what there is to name. Both
395
+ * halves share `onBrokenAnchors`, because to an author they are one mistake.
396
+ */
397
+ const reportAnchor = (message) => {
398
+ if (config.onBrokenAnchors === "ignore") return;
399
+ if (config.onBrokenAnchors === "throw") throw docsError("broken-anchor", message);
400
+ console.warn(message);
378
401
  };
379
402
  const searchIndexUrl = `${config.basePath}/search-index.json`;
380
403
  /**
@@ -407,12 +430,18 @@ function createDocsRoute(options) {
407
430
  async function renderRoute(segments) {
408
431
  const doc = await getPage(segments);
409
432
  if (doc === void 0) return (await loadNotFound())();
410
- const components = await loadComponents();
433
+ const { components, link } = await loadComponents();
411
434
  return createElement(Fragment, null, createElement("main", {
412
435
  className: "wave-docs-layout__main",
413
436
  id: DOCS_CONTENT_ID,
414
437
  tabIndex: -1
415
- }, createElement(DocContent, {
438
+ }, (doc.frontmatter.actions?.length ?? 0) === 0 ? null : createElement(DocsHero, {
439
+ title: doc.frontmatter.title,
440
+ ...doc.frontmatter.description === void 0 ? {} : { description: doc.frontmatter.description },
441
+ ...doc.frontmatter.actions === void 0 ? {} : { actions: doc.frontmatter.actions },
442
+ Link: link,
443
+ ...routeLabels?.externalLink === void 0 ? {} : { externalLabel: routeLabels.externalLink }
444
+ }), createElement(DocContent, {
416
445
  hast: doc.hast,
417
446
  components: {
418
447
  ...components,
@@ -439,7 +468,7 @@ function createDocsRoute(options) {
439
468
  async IndexPage() {
440
469
  return renderRoute([]);
441
470
  },
442
- async Layout({ children, title, actions, search, labels }) {
471
+ async Layout({ children, search, labels }) {
443
472
  const { DocsLayoutShell } = await import("./react/layout.js");
444
473
  const host = search === true || search === void 0 || search === false ? void 0 : search;
445
474
  const requestedOptions = host?.miniSearchOptions ?? options.miniSearchOptions;
@@ -456,8 +485,6 @@ function createDocsRoute(options) {
456
485
  nav: await requestScopedSource.nav(),
457
486
  searchIndexUrl,
458
487
  search: searchProps,
459
- ...title === void 0 ? {} : { title },
460
- ...actions === void 0 ? {} : { actions },
461
488
  ...shellLabels === void 0 ? {} : { labels: shellLabels }
462
489
  });
463
490
  },
@@ -1,6 +1,6 @@
1
+ import { visit } from "unist-util-visit";
1
2
  import rehypeSlug from "rehype-slug";
2
3
  import { unified } from "unified";
3
- import { visit } from "unist-util-visit";
4
4
  //#region src/plugins/rehype-fallback-heading-ids.ts
5
5
  const HEADING = /^h[1-6]$/;
6
6
  /** `-1`, `-2` … — what `github-slugger` returns for a repeated empty slug. */
@@ -26,6 +26,36 @@ interface DocLinkRef {
26
26
  * never be a member of.
27
27
  */
28
28
  asset?: true;
29
+ /**
30
+ * An absolute link at a root mount, which cannot be proved to be ours.
31
+ *
32
+ * ⚠️ RECORDED RATHER THAN SKIPPED, WHICH IS THE CHANGE. Under
33
+ * `basePath: '/docs'` an absolute link either starts with `/docs` — so it is
34
+ * a documentation route and is checked — or it does not, and belongs to the
35
+ * host's application. Under `basePath: '/'` that test cannot be made:
36
+ * `/setup` may be a page here and `/login` almost certainly is not, and
37
+ * nothing in the markdown says which.
38
+ *
39
+ * So it is collected and marked, and checked like any other link — because a
40
+ * root mount is what you choose when the origin serves documentation and
41
+ * nothing else, which makes an unknown absolute link a typo. An origin that
42
+ * serves something else names what is its own through
43
+ * `DocsConfig.externalRoutes`.
44
+ */
45
+ unverifiable?: true;
46
+ /**
47
+ * A bare `#fragment` — a link into the page it is written on.
48
+ *
49
+ * ⚠️ RECORDED SO THE ANCHOR CAN BE CHECKED, AND FLAGGED SO THE ROUTE IS NOT.
50
+ * `isRelativeLink` excludes these and always did, correctly: there is no
51
+ * route to resolve. But that also meant they were never collected, so nothing
52
+ * downstream could see `#missing` at all — and a same-page anchor is the one
53
+ * a writer produces most, every "see below".
54
+ *
55
+ * `assertLinks` skips these; `assertOwnAnchors` is what reads them, and the
56
+ * recorded line is why the message can name one.
57
+ */
58
+ anchorOnly?: true;
29
59
  }
30
60
  declare module 'vfile' {
31
61
  interface DataMap {
@@ -41,6 +71,8 @@ interface RemarkDocLinksOptions {
41
71
  /** Overrides the built-in resolution entirely, for every relative link. */
42
72
  resolve?: LinkResolver;
43
73
  }
74
+ /** No prefix at all — the docs own the whole origin. */
75
+ declare function isRootMount(basePath: string): boolean;
44
76
  /**
45
77
  * Fold `.` and `..` against a starting directory.
46
78
  *
@@ -121,4 +153,4 @@ declare function resolveMarkdownLink(href: string, fromDir: readonly string[], b
121
153
  */
122
154
  declare const remarkDocLinks: Plugin<[RemarkDocLinksOptions], Root>;
123
155
  //#endregion
124
- export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
156
+ export { type DocLinkContext, DocLinkRef, HrefParts, RemarkDocLinksOptions, decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
@@ -20,6 +20,10 @@ const FILE_EXTENSION = /\.[^./]+$/;
20
20
  function isRelativeLink(href) {
21
21
  return href !== "" && !href.startsWith("#") && !href.startsWith("?") && !href.startsWith("/") && !HAS_SCHEME.test(href);
22
22
  }
23
+ /** No prefix at all — the docs own the whole origin. */
24
+ function isRootMount(basePath) {
25
+ return basePath.replace(/\/+$/, "") === "";
26
+ }
23
27
  /**
24
28
  * Is this already-absolute href one of OUR routes?
25
29
  *
@@ -28,9 +32,11 @@ function isRelativeLink(href) {
28
32
  * A typo in a hand-written absolute link is exactly as likely as one in a
29
33
  * relative link; only the rewriting differs.
30
34
  *
31
- * Requires a non-empty base path. Docs mounted at the site root cannot be told
32
- * apart from the rest of the site, and asserting `/login` against the set of
33
- * documentation routes would fail builds over links that are perfectly good.
35
+ * Answers `false` at a root mount, where there is no prefix to test against.
36
+ * That is not the end of the matter: the caller records those links anyway,
37
+ * marked, because at a root mount the *common* case is an origin that serves
38
+ * documentation and nothing else — so they are checked, and a site with other
39
+ * routes names them through `externalRoutes`.
34
40
  */
35
41
  function isInternalAbsoluteLink(href, basePath) {
36
42
  const base = basePath.replace(/\/+$/, "");
@@ -224,23 +230,33 @@ const remarkDocLinks = (options) => {
224
230
  const raw = node.url;
225
231
  if (node.type === "definition" && imageIdentifiers.has(node.identifier)) continue;
226
232
  const line = node.position?.start.line;
227
- const record = (href, asset) => {
233
+ const record = (href, flags = {}) => {
228
234
  const ref = {
229
235
  raw,
230
236
  href
231
237
  };
232
238
  if (line !== void 0) ref.line = line;
233
- if (asset !== void 0) ref.asset = asset;
239
+ if (flags.asset !== void 0) ref.asset = flags.asset;
240
+ if (flags.unverifiable !== void 0) ref.unverifiable = flags.unverifiable;
241
+ if (flags.anchorOnly !== void 0) ref.anchorOnly = flags.anchorOnly;
234
242
  refs.push(ref);
235
243
  if (href !== void 0) node.url = href;
236
244
  };
237
245
  try {
246
+ if (raw.startsWith("#")) {
247
+ record(raw, { anchorOnly: true });
248
+ continue;
249
+ }
238
250
  if (!isRelativeLink(raw)) {
239
- if (isInternalAbsoluteLink(raw, basePath) && !isAssetLink(raw)) record(normalizeInternalRoute(raw, basePath));
251
+ if (isInternalAbsoluteLink(raw, basePath) && !isAssetLink(raw)) {
252
+ record(normalizeInternalRoute(raw, basePath));
253
+ continue;
254
+ }
255
+ if (isRootMount(basePath) && raw.startsWith("/") && !raw.startsWith("//") && !isAssetLink(raw)) record(normalizeInternalRoute(raw, basePath), { unverifiable: true });
240
256
  continue;
241
257
  }
242
258
  if (resolve === void 0 && isAssetLink(raw)) {
243
- record(resolveAssetLink(raw, context.dirSegments, basePath), true);
259
+ record(resolveAssetLink(raw, context.dirSegments, basePath), { asset: true });
244
260
  continue;
245
261
  }
246
262
  if (resolve) {
@@ -257,4 +273,4 @@ const remarkDocLinks = (options) => {
257
273
  };
258
274
  };
259
275
  //#endregion
260
- export { decodePath, foldSegments, remarkDocLinks, resolveMarkdownLink, splitHref };
276
+ export { decodePath, foldSegments, isRootMount, remarkDocLinks, resolveMarkdownLink, splitHref };
@@ -0,0 +1,19 @@
1
+ import { DocAction } from "../types.js";
2
+ import { DocsLinkComponent } from "./markdown-components.js";
3
+ import { ReactNode } from "react";
4
+ //#region src/react/hero.d.ts
5
+ interface DocsHeroProps {
6
+ /** `frontmatter.title`, rendered as the page's `<h1>`. */
7
+ title: string;
8
+ /** `frontmatter.description`. Omitted rather than rendered empty. */
9
+ description?: string | undefined;
10
+ /** `frontmatter.actions`. An empty list renders no `<nav>` at all. */
11
+ actions?: readonly DocAction[] | undefined;
12
+ /** Client-side router link, e.g. `next/link`. Falls back to `<a>`. */
13
+ Link?: DocsLinkComponent | undefined;
14
+ /** Screen-reader suffix on a link that opens elsewhere. */
15
+ externalLabel?: string | undefined;
16
+ }
17
+ declare function DocsHero({ title, description, actions, Link, externalLabel }: DocsHeroProps): ReactNode;
18
+ //#endregion
19
+ export { DocsHero, DocsHeroProps };
@@ -0,0 +1,44 @@
1
+ import { opensInNewTab } from "../safe-href.js";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/react/hero.tsx
4
+ function DocsHero({ title, description, actions, Link, externalLabel = "(opens in a new tab)" }) {
5
+ const Anchor = Link ?? "a";
6
+ return /* @__PURE__ */ jsx("header", {
7
+ className: "wave-docs-hero",
8
+ children: /* @__PURE__ */ jsxs("div", {
9
+ className: "wave-docs-hero__body",
10
+ children: [
11
+ /* @__PURE__ */ jsx("h1", {
12
+ className: "wave-docs-hero__title",
13
+ children: title
14
+ }),
15
+ description === void 0 || description === "" ? null : /* @__PURE__ */ jsx("p", {
16
+ className: "wave-docs-hero__tagline",
17
+ children: description
18
+ }),
19
+ actions === void 0 || actions.length === 0 ? null : /* @__PURE__ */ jsx("div", {
20
+ className: "wave-docs-hero__actions",
21
+ children: actions.map((action, index) => {
22
+ const external = opensInNewTab(action.href);
23
+ const variant = action.variant ?? (index === 0 ? "primary" : "secondary");
24
+ return /* @__PURE__ */ jsxs(external ? "a" : Anchor, {
25
+ href: action.href,
26
+ className: "wave-docs-hero__action",
27
+ "data-variant": variant,
28
+ ...external ? {
29
+ target: "_blank",
30
+ rel: "noreferrer"
31
+ } : {},
32
+ children: [action.label, external ? /* @__PURE__ */ jsx("span", {
33
+ className: "wave-docs-sr-only",
34
+ children: ` ${externalLabel}`
35
+ }) : null]
36
+ }, action.href);
37
+ })
38
+ })
39
+ ]
40
+ })
41
+ });
42
+ }
43
+ //#endregion
44
+ export { DocsHero };
@@ -27,8 +27,6 @@ interface DocsLayoutShellProps {
27
27
  children: ReactNode;
28
28
  nav: DocNavNode[];
29
29
  searchIndexUrl: string;
30
- title?: ReactNode;
31
- actions?: ReactNode;
32
30
  /**
33
31
  * `false` to omit the trigger; an object to configure it.
34
32
  *
@@ -56,6 +54,6 @@ interface DocsLayoutShellProps {
56
54
  */
57
55
  labels?: DocsLabels | undefined;
58
56
  }
59
- declare function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search, labels }: DocsLayoutShellProps): ReactNode;
57
+ declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels }: DocsLayoutShellProps): ReactNode;
60
58
  //#endregion
61
59
  export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
@@ -1,68 +1,31 @@
1
1
  import { DocsSearch } from "./next-search.js";
2
2
  import { resolveLabels } from "./shell-labels.js";
3
- import { DOCS_NAV_ID } from "./nav.js";
4
3
  import { DocsNextNav } from "./next-nav.js";
5
4
  import { SkipLink } from "./skip-link.js";
6
5
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
7
6
  //#region src/react/layout.tsx
8
- function DocsLayoutShell({ children, nav, searchIndexUrl, title, actions, search = true, labels }) {
7
+ function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels }) {
9
8
  const text = resolveLabels(labels);
10
- return /* @__PURE__ */ jsxs(Fragment, { children: [
11
- /* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }),
12
- /* @__PURE__ */ jsx("header", {
13
- className: "wave-docs-layout__header",
14
- children: /* @__PURE__ */ jsxs("div", {
15
- className: "wave-docs-layout__header-inner",
16
- children: [
17
- /* @__PURE__ */ jsx("button", {
18
- type: "button",
19
- className: "wave-docs-layout__nav-trigger",
20
- "aria-label": text.openNav,
21
- command: "show-modal",
22
- commandfor: DOCS_NAV_ID,
23
- children: /* @__PURE__ */ jsx("svg", {
24
- "aria-hidden": "true",
25
- viewBox: "0 0 16 16",
26
- width: "18",
27
- height: "18",
28
- fill: "none",
29
- stroke: "currentColor",
30
- strokeWidth: "1.5",
31
- strokeLinecap: "round",
32
- children: /* @__PURE__ */ jsx("path", { d: "M2.5 4h11M2.5 8h11M2.5 12h11" })
33
- })
34
- }),
35
- title === void 0 ? null : /* @__PURE__ */ jsx("div", {
36
- className: "wave-docs-layout__title",
37
- children: title
38
- }),
39
- search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
40
- indexUrl: searchIndexUrl,
41
- ...search === true ? {} : search,
42
- className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
43
- }),
44
- actions === void 0 ? null : /* @__PURE__ */ jsx("div", {
45
- className: "wave-docs-layout__actions",
46
- children: actions
47
- })
48
- ]
49
- })
50
- }),
51
- /* @__PURE__ */ jsxs("div", {
9
+ return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(SkipLink, { children: text.skipToContent }), /* @__PURE__ */ jsx("div", {
10
+ className: "wave-docs-shell",
11
+ children: /* @__PURE__ */ jsxs("div", {
52
12
  className: "wave-docs-layout",
53
- children: [/* @__PURE__ */ jsx("div", {
54
- className: "wave-docs-layout__sidebar",
55
- children: /* @__PURE__ */ jsx(DocsNextNav, {
56
- nav,
57
- label: text.nav,
58
- closeLabel: text.closeNav,
59
- ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
60
- ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
61
- ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink }
13
+ children: [/* @__PURE__ */ jsx(DocsNextNav, {
14
+ nav,
15
+ label: text.nav,
16
+ closeLabel: text.closeNav,
17
+ openLabel: text.openNav,
18
+ ...labels?.expandGroup === void 0 ? {} : { expandGroup: labels.expandGroup },
19
+ ...labels?.collapseGroup === void 0 ? {} : { collapseGroup: labels.collapseGroup },
20
+ ...labels?.externalLink === void 0 ? {} : { externalLink: labels.externalLink },
21
+ children: search === false ? null : /* @__PURE__ */ jsx(DocsSearch, {
22
+ indexUrl: searchIndexUrl,
23
+ ...search === true ? {} : search,
24
+ className: ["wave-docs-layout__search", search === true ? void 0 : search?.className].filter(Boolean).join(" ")
62
25
  })
63
26
  }), children]
64
27
  })
65
- ] });
28
+ })] });
66
29
  }
67
30
  //#endregion
68
31
  export { DocsLayoutShell };