@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/CHANGELOG.md +252 -1
- package/README.md +188 -90
- package/dist/anchors.d.ts +44 -0
- package/dist/anchors.js +76 -0
- package/dist/errors.d.ts +2 -0
- package/dist/frontmatter.d.ts +8 -0
- package/dist/frontmatter.js +7 -1
- package/dist/link-suggestion.d.ts +31 -0
- package/dist/link-suggestion.js +94 -0
- package/dist/next.d.ts +34 -27
- package/dist/next.js +40 -13
- package/dist/plugins/rehype-fallback-heading-ids.js +1 -1
- package/dist/plugins/remark-doc-links.d.ts +33 -1
- package/dist/plugins/remark-doc-links.js +24 -8
- package/dist/react/hero.d.ts +19 -0
- package/dist/react/hero.js +44 -0
- package/dist/react/layout.d.ts +1 -3
- package/dist/react/layout.js +17 -54
- package/dist/react/link-adapter.d.ts +34 -0
- package/dist/react/link-adapter.js +30 -0
- package/dist/react/nav.d.ts +11 -11
- package/dist/react/nav.js +106 -58
- package/dist/react/next-link.d.ts +6 -28
- package/dist/react/next-link.js +45 -24
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -3
- package/dist/react/next-search.js +1 -1
- package/dist/react/shell-labels.d.ts +8 -2
- package/dist/react/toc.d.ts +13 -3
- package/dist/react/toc.js +17 -10
- package/dist/render.d.ts +1 -1
- package/dist/render.js +71 -11
- package/dist/source.js +7 -3
- package/dist/styles.css +1079 -237
- package/dist/types.d.ts +102 -6
- package/package.json +10 -3
package/dist/frontmatter.js
CHANGED
|
@@ -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
|
-
*
|
|
100
|
-
* turned out to be reachable already: an announcement banner renders
|
|
101
|
-
* `<docs.Layout>` in your own `layout.tsx`, because this does not own
|
|
102
|
-
* a content footer goes inside `children`; and sidebar links, social
|
|
103
|
-
* separators are `DocNavNode`s authored in `meta.json`.
|
|
104
|
-
*
|
|
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
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
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
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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/
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 }} />\`
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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,
|
|
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))
|
|
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 };
|
package/dist/react/layout.d.ts
CHANGED
|
@@ -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,
|
|
57
|
+
declare function DocsLayoutShell({ children, nav, searchIndexUrl, search, labels }: DocsLayoutShellProps): ReactNode;
|
|
60
58
|
//#endregion
|
|
61
59
|
export { DocsLayoutSearchProps, DocsLayoutShell, DocsLayoutShellProps };
|
package/dist/react/layout.js
CHANGED
|
@@ -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,
|
|
7
|
+
function DocsLayoutShell({ children, nav, searchIndexUrl, search = true, labels }) {
|
|
9
8
|
const text = resolveLabels(labels);
|
|
10
|
-
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
11
|
-
|
|
12
|
-
/* @__PURE__ */
|
|
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(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 };
|