@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/README.md
CHANGED
|
@@ -13,38 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
<br />
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
a broken image on npmjs.com. And pinning to `main` rather than a tag means an
|
|
22
|
-
old version's README displays a future product: someone reading 0.3.0 in 2027
|
|
23
|
-
would see whatever the shell looks like then.
|
|
24
|
-
|
|
25
|
-
`pnpm shoot` regenerates these from the real site build. It is a LOCAL
|
|
26
|
-
command and CI deliberately does not run `pnpm shoot --check`: these PNGs are
|
|
27
|
-
compared byte for byte, and bytes do not survive a change of operating system
|
|
28
|
-
— the font stack resolves to SF Pro on the machine that shot them and to
|
|
29
|
-
DejaVu on a Linux runner, so every pixel of text differs and no tolerance
|
|
30
|
-
rescues glyphs that are different shapes. The regression that gate was meant
|
|
31
|
-
to catch — a stylesheet change reflowing the shell — is covered by the browser
|
|
32
|
-
tier, which asserts geometry rather than pixels in the same Chromium
|
|
33
|
-
everywhere. `scripts/shoot.ts` says the same at greater length.
|
|
34
|
-
-->
|
|
35
|
-
<picture>
|
|
36
|
-
<source
|
|
37
|
-
media="(prefers-color-scheme: dark)"
|
|
38
|
-
srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-dark.png"
|
|
39
|
-
/>
|
|
40
|
-
<img
|
|
41
|
-
src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-light.png"
|
|
42
|
-
alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
|
|
43
|
-
width="100%"
|
|
44
|
-
/>
|
|
45
|
-
</picture>
|
|
46
|
-
|
|
47
|
-
<p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/search.png">Search dialog →</a></em></p>
|
|
16
|
+
<p align="center">
|
|
17
|
+
<strong><a href="https://docs.wave.so">docs.wave.so</a></strong> — the documentation for this package, built with this package.
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center"><em>Every page you see there is markdown in <code>site/content/</code>, rendered by <code>docs.Layout</code> with no layout CSS of its own. It is the acceptance harness and the showcase, and it is the same build CI runs on every commit — so it cannot drift from what this README claims.</em></p>
|
|
48
21
|
|
|
49
22
|
---
|
|
50
23
|
|
|
@@ -96,16 +69,16 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
|
|
|
96
69
|
|
|
97
70
|
| | At most |
|
|
98
71
|
| --- | --- |
|
|
99
|
-
| Everything the quick start ships, gzipped | 13.
|
|
72
|
+
| Everything the quick start ships, gzipped | 13.9 KB |
|
|
100
73
|
| Search dialog and router wiring | 9.3 KB |
|
|
101
|
-
| Navigation: sidebar and
|
|
74
|
+
| Navigation: one sidebar, open and closed | 2.9 KB |
|
|
102
75
|
| Table of contents | 0.9 KB |
|
|
103
76
|
| Copy-button runtime | 1.1 KB |
|
|
104
77
|
| hast over the wire vs HTML, prose page | 1.20× |
|
|
105
78
|
| hast over the wire vs HTML, code and tables | 1.12× |
|
|
106
79
|
| Highlighting vs no highlighting | 2.00× |
|
|
107
80
|
|
|
108
|
-
The first row is the honest total: a reader who lands on a page of your documentation downloads under 13.
|
|
81
|
+
The first row is the honest total: a reader who lands on a page of your documentation downloads under 13.9 KB gzipped of JavaScript from this package, and that is the whole of it. No markdown parser and no syntax highlighter reach the browser at all — those run in Node at build time. Drop the search dialog and it is under 4 KB.
|
|
109
82
|
|
|
110
83
|
The one real cost is the middle pair: shipping a tree instead of a string is about 20% more brotli on a prose page, and about 12% on a page with code and tables, where Shiki's token spans dominate both representations equally. That is the price of never handing markup to `dangerouslySetInnerHTML`, and it is the first number a skeptical reviewer should ask for.
|
|
111
84
|
|
|
@@ -167,7 +140,7 @@ content/docs/
|
|
|
167
140
|
authentication.md
|
|
168
141
|
```
|
|
169
142
|
|
|
170
|
-
That is a working documentation site: routing, a navigation sidebar, a table of contents, syntax highlighting, search
|
|
143
|
+
That is a working documentation site: routing, a navigation sidebar that opens and closes, a table of contents, syntax highlighting, search and a skip link.
|
|
171
144
|
|
|
172
145
|
The search route is in the quick start rather than in a section further down because `docs.Layout` renders the search trigger by default — leave the route out and a reader gets a control that opens onto "Search is unavailable". If you genuinely do not want search, `export default function Layout(props) { return docs.Layout({ ...props, search: false }) }` drops both the trigger and this file. See [Search](#search) for tuning.
|
|
173
146
|
|
|
@@ -185,7 +158,7 @@ There is no root export. Every entry point is a subpath, so an import always nam
|
|
|
185
158
|
| `@waveso/docs/render` | Node | `createDocsRenderer`, `resolveMarkdownLink` |
|
|
186
159
|
| `@waveso/docs/highlighter` | Node | `createDocsHighlighter`, `DEFAULT_DOCS_LANGS`, `DEFAULT_DOCS_THEMES` |
|
|
187
160
|
| `@waveso/docs/search-index` | Node | `extractSearchRecords`, `buildSearchIndex` |
|
|
188
|
-
| `@waveso/docs/react/<name>` | Browser + RSC |
|
|
161
|
+
| `@waveso/docs/react/<name>` | Browser + RSC | Ten subpaths, one component each — see [Components](#components) |
|
|
189
162
|
| `@waveso/docs/frontmatter` | Any | `docFrontmatterSchema`, `parseFrontmatter`, `z` |
|
|
190
163
|
| `@waveso/docs/types` | Any | Every shared type. Type-only |
|
|
191
164
|
| `@waveso/docs/errors` | Any | `DocsErrorCode`, `DocsError`, `isDocsError`, `DOCS_ERROR_PREFIX` |
|
|
@@ -199,15 +172,21 @@ That is about **weight, not about `node:fs`** — and the distinction matters, b
|
|
|
199
172
|
|
|
200
173
|
### Layout tokens
|
|
201
174
|
|
|
202
|
-
Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins.
|
|
175
|
+
Five custom properties size the shell, all layered so an unlayered `:root` of your own still wins. All six are public API: a name changes only in a release that carries the migration.
|
|
203
176
|
|
|
204
177
|
| Token | Default | Controls |
|
|
205
178
|
| --- | --- | --- |
|
|
206
179
|
| `--wave-docs-measure` | `46rem` | Prose column width. `none` opts out |
|
|
207
|
-
| `--wave-docs-
|
|
208
|
-
| `--wave-docs-
|
|
180
|
+
| `--wave-docs-sidebar-width` | `16rem` | The navigation's width |
|
|
181
|
+
| `--wave-docs-trigger-width` | `1.25rem` | The trigger's button. The strip around it is this plus 4px a side |
|
|
209
182
|
| `--wave-docs-toc-width` | `15rem` | Table-of-contents track |
|
|
210
|
-
| `--wave-docs-
|
|
183
|
+
| `--wave-docs-chrome-offset` | `0rem` | Where our sticky chrome starts, below a bar of yours |
|
|
184
|
+
|
|
185
|
+
**`--wave-docs-shell-width` was removed in 0.7.0, and it is the sidebar's edge that replaced it.** It capped the whole shell and centred it, which put the sidebar's inline start 480px in from the screen on a 2560px display — and, worse, left a *closed* navigation parked in the centring margin instead of off the page. The reading column is the thing that should not stretch, so `--wave-docs-measure` caps it and it centres itself in its track; the sidebar keeps the page's inline start edge at every width, which is what makes "closed" mean off the screen by construction.
|
|
186
|
+
|
|
187
|
+
**`--wave-docs-header-height` is gone in 0.7.0, and renaming it to one thing would have lost the other.** It sized the header *and* it was the offset both sticky columns parked below. The header is gone, and so is every shape that sat above the content, so the sizing half has nothing left to size. The offset half is the one that matters and it is `--wave-docs-chrome-offset`: `--wave-docs-chrome-offset: 4rem` starts our sidebar and our table of contents 4rem down, and feeds the scroll padding that keeps an anchored heading clear of your bar.
|
|
188
|
+
|
|
189
|
+
The default is `0rem` rather than `0`, and the unit is load-bearing: it is read inside `calc(100dvh - …)` on both sticky columns, and `calc(100dvh - 0)` is invalid at computed-value time — a unitless zero kills the `max-height` instead of resolving to no change.
|
|
211
190
|
|
|
212
191
|
The shell has three breakpoints, in `rem` so they scale with the reader's base font size: the sidebar appears at **64rem**, the table of contents at **80rem**, and the whole grid stops growing at **100rem**. 64rem is arithmetic rather than taste — a 16rem sidebar plus a 46rem measure plus two 1.5rem gutters is 65rem, so anything narrower introduces the sidebar exactly where it starts eating the measure it frames.
|
|
213
192
|
|
|
@@ -217,14 +196,16 @@ Every subpath is enumerated in `exports` — there is no wildcard. A name that i
|
|
|
217
196
|
|
|
218
197
|
## Components
|
|
219
198
|
|
|
220
|
-
Every component takes data as props, and
|
|
199
|
+
Every component takes data as props, and every module that imports from `next/*` is named for it — `next-nav` for `usePathname`, `next-search` for `useRouter`, `next-link` for `next/link` itself — so the exception is visible in the file list rather than three imports deep. Everything else has `next/link` and `next/image` injected. That keeps the renderer host-agnostic and testable without a router. `DocsSearch` is the one exception, and it exists precisely so that the exception is ours rather than yours: it is the fifteen-line wrapper you would otherwise write around `SearchDialog`.
|
|
221
200
|
|
|
222
201
|
| Component | Subpath | Notes |
|
|
223
202
|
| --- | --- | --- |
|
|
224
203
|
| `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
|
|
204
|
+
| `DocsHero` | `react/hero` | A landing page's header. `title`, `description`, `actions`, `Link`, `externalLabel`. Server Component |
|
|
225
205
|
| `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
|
|
226
206
|
| `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
|
|
227
207
|
| `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
|
|
208
|
+
| `DocsLink` | `react/next-link` | `next/link`, adapted — pass it as `Link` when composing by hand |
|
|
228
209
|
| `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
|
|
229
210
|
| `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
|
|
230
211
|
| `YouTube` | `react/youtube` | Click-to-load facade. `title`, `playLabel`, `hideLabel` — `{title}` interpolates the first |
|
|
@@ -233,51 +214,105 @@ Every component takes data as props, and two modules in `src/react/` import from
|
|
|
233
214
|
|
|
234
215
|
`DocsToc`'s `rootMargin` is the `IntersectionObserver` margin that decides how far above the viewport a heading counts as current; the default keeps the highlight on the section you are reading rather than the one about to arrive. `topLabel` is the back-to-top link at the end.
|
|
235
216
|
|
|
236
|
-
The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to
|
|
217
|
+
The two components the adapter injects take a little more than an `<a>` and an `<img>`. `DocsLinkProps` adds `prefetch` — passed straight to `next/link`, where `false` disables the hover and viewport paths both, so it is a stronger switch in the App Router than the name suggests. `DocsImageProps` carries `src`, `alt`, `width` and `height` — the four `next/image` refuses to render without — and adds `sizes`, `loading`, `decoding` and `fetchPriority`, forwarded to it; markdown carries none of them, so they come from your `imageResolver` or from a `components` override. `decoding` defaults to `async`, and `loading` to `lazy` — except on an image the author marked `eager`, which is usually the page's largest element.
|
|
237
218
|
|
|
238
219
|
### Layout
|
|
239
220
|
|
|
240
|
-
`export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link,
|
|
221
|
+
`export default docs.Layout` — the one line from the [quick start](#quick-start) — is a Server Component that renders the whole shell: skip link, sidebar, search trigger, and the grid that arranges them. It reads the navigation tree and the search index URL itself, so there is nothing to fetch and nothing to pass.
|
|
222
|
+
|
|
223
|
+
**It renders no header, and that is deliberate.** Two kinds of site use this package: documentation mounted inside an application that already has a header, a navbar and its own search, and documentation that is the whole site. A full-width sticky bar of ours serves the second and fights the first — two stacked bars competing for the viewport's top edge, and two search boxes on one page, one of which knows nothing about the documentation.
|
|
224
|
+
|
|
225
|
+
So the sidebar is the chrome. It is a real grid item at every width, and it is the same shell on a phone and on a desktop.
|
|
226
|
+
|
|
227
|
+
**Nothing this package renders is anchored to the viewport.** The sidebar is a grid item, the trigger is a flex child of it, and the scrim is `position: absolute` inside `.wave-docs-layout` — so every one of them resolves against a box this package owns and *your* layout placed. `position: fixed` is the thing to avoid, and the reason is specific: a fixed element is anchored to the viewport you share with it, your navbar is in the same viewport, and neither can detect the other. The search dialog is the one exception and always was — top-layer, present only while open, and nothing collides with something that is not there.
|
|
241
228
|
|
|
242
229
|
Your layout stays a Server Component. The two pieces that need a client — the navigation's `usePathname`, the search dialog — carry their own `'use client'` boundaries inside the package.
|
|
243
230
|
|
|
244
|
-
|
|
231
|
+
Your own chrome goes *around* `docs.Layout`, in the layout file you already write — the same place `<html>` and `<body>` live. Call it instead of re-exporting it when you want to wrap it, configure it, or both:
|
|
245
232
|
|
|
246
233
|
```tsx
|
|
247
234
|
import type { ReactNode } from 'react';
|
|
248
235
|
import '@waveso/docs/styles.css';
|
|
249
236
|
import { docs } from '@/lib/docs';
|
|
250
237
|
|
|
238
|
+
/** Yours: the header, theme toggle and repository link the rest of the site has. */
|
|
239
|
+
declare function SiteHeader(): ReactNode;
|
|
240
|
+
|
|
251
241
|
export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
252
242
|
return (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
243
|
+
<>
|
|
244
|
+
<SiteHeader />
|
|
245
|
+
<docs.Layout search={{ placeholder: 'Search the docs' }}>
|
|
246
|
+
{children}
|
|
247
|
+
</docs.Layout>
|
|
248
|
+
</>
|
|
259
249
|
);
|
|
260
250
|
}
|
|
261
251
|
```
|
|
262
252
|
|
|
253
|
+
If that header of yours is sticky, say how tall it is once and the shell's sticky columns start below it:
|
|
254
|
+
|
|
255
|
+
```css
|
|
256
|
+
:root {
|
|
257
|
+
--wave-docs-chrome-offset: 4rem;
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
263
261
|
| Prop | Type | Default | |
|
|
264
262
|
| --- | --- | --- | --- |
|
|
265
|
-
| `
|
|
266
|
-
| `actions` | `ReactNode` | — | Header end, after search |
|
|
263
|
+
| `children` | `ReactNode` | — | What `docs.Page` returns — the `<main>` and the TOC, as two siblings |
|
|
267
264
|
| `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
|
|
268
265
|
| `labels` | `DocsLabels` | the route's | Overrides `createDocsRoute`'s labels, key by key |
|
|
269
266
|
|
|
270
|
-
|
|
267
|
+
Three props, and one of them is `children`. That is deliberate, and it is the difference between this and an eleven-slot layout: everything else a docs shell gets asked for is already reachable. An announcement banner goes *above* `<docs.Layout>` in your own layout, because this does not own `<body>`. A content footer goes inside `children`. Sidebar links, social icons and separators are `DocNavNode`s you author in `meta.json`. A theme toggle and a repository link go in the layout you write around this one.
|
|
268
|
+
|
|
269
|
+
**`title` and `actions` were removed in 0.7.0, with the header they lived in.** `title` was a brand slot, and a brand belongs to the index page's own title — content, authored and translatable, part of what the reader came for. The argument for `actions` was that the header bar was the one region nothing else could reach; there is no header bar, and the host wraps `docs.Layout` exactly as it already wraps `<html>` and `<body>`, so there is no region only this package can reach. The one place a host cannot reach through this prop list is *inside* the sidebar, and the answer to that is [composing the primitives yourself](#composing-it-yourself).
|
|
271
270
|
|
|
272
271
|
`search` takes anything `DocsSearch` takes except `indexUrl`, which stays derived from your `basePath`. You do not need to repeat `miniSearchOptions` here to match `createDocsRoute` — the route's own value is forwarded, so the object that built the index is the object that queries it.
|
|
273
272
|
|
|
274
273
|
`labels` belongs on `createDocsRoute` — see [Translating the chrome](#translating-the-chrome) — and this prop overrides it key by key, for a site with two shells or a section in another language.
|
|
275
274
|
|
|
276
|
-
####
|
|
275
|
+
#### One sidebar, open and closed
|
|
277
276
|
|
|
278
|
-
|
|
277
|
+
There is no mobile version. The sidebar is a shell holding two things in a row:
|
|
279
278
|
|
|
280
|
-
|
|
279
|
+
```
|
|
280
|
+
.wave-docs-shell the query container
|
|
281
|
+
└─ .wave-docs-layout the grid
|
|
282
|
+
├─ .wave-docs-layout__sidebar paints nothing, and moves
|
|
283
|
+
│ ├─ …__sidebar-nav the surface, and the one border
|
|
284
|
+
│ └─ …__sidebar-trigger the strip — paints nothing at rest
|
|
285
|
+
├─ .wave-docs-layout__sidebar-scrim
|
|
286
|
+
├─ .wave-docs-layout__main
|
|
287
|
+
└─ .wave-docs-layout__toc
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Pressing the trigger translates the shell by `calc(var(--wave-docs-trigger-width) - 100%)` — "minus all of me, plus the trigger back" — so the navigation goes entirely off the page and the trigger's outer edge lands exactly on the inline start edge. **The navigation's width appears nowhere in that expression**, so the two cannot drift apart. The trigger rides on the navigation's outer edge because it is the next flex item, not because a number says so.
|
|
291
|
+
|
|
292
|
+
One navigation in the DOM at every width: one landmark, one copy of the links in the payload, nothing to keep in step.
|
|
293
|
+
|
|
294
|
+
#### It adapts to its container, not to your screen
|
|
295
|
+
|
|
296
|
+
**There is not one width-based `@media` query in this package.** Every breakpoint is `@container`, and that is not a stylistic preference — it is the difference between a docs theme and a component you can mount inside something else.
|
|
297
|
+
|
|
298
|
+
`@media` asks how wide the *screen* is. If you put this in a 700px panel on a 1920px monitor, `@media` says "wide", the sidebar takes its 16rem column, and the reading column comes out around 60px. `@container` asks how wide the box you *gave* it is, which is the question with an answer.
|
|
299
|
+
|
|
300
|
+
Two shapes fall out of that:
|
|
301
|
+
|
|
302
|
+
- **Push**, in a container 64rem or wider: the navigation sits beside the article, and opening or closing it changes the article's width.
|
|
303
|
+
- **Cover**, below that: the navigation sits on top of the article behind a scrim, and the article's measure never changes when you toggle.
|
|
304
|
+
|
|
305
|
+
Same markup, same classes, same control, same translate. The only thing that differs is whether the article gets out of the way.
|
|
306
|
+
|
|
307
|
+
Cover mode is a real overlay, so it ships what an overlay owes a reader: `inert` on everything the navigation covers, Escape to close, click-the-scrim to dismiss, and focus moving into the navigation and back out again. Those five were the browser's while this was a `<dialog>`; they are hand-written now, and skipping them is how an overlay becomes a keyboard trap in the wrong direction.
|
|
308
|
+
|
|
309
|
+
`--wave-docs-sidebar-mode` is how the component knows which shape it is in: the stylesheet declares it, the component reads it back with `getComputedStyle`. `matchMedia` cannot answer a container query, and duplicating the breakpoint in JavaScript is how the two drift.
|
|
310
|
+
|
|
311
|
+
#### Three states, and no flash
|
|
312
|
+
|
|
313
|
+
The server renders no `data-state` at all. That absence means "nobody has chosen yet", and CSS resolves it per mode — closed where the navigation would cover the article, open where it would sit beside it. So the first paint is already right at both shapes, with no JavaScript and nothing to correct. Once a reader presses the trigger their choice is explicit and wins at every width.
|
|
314
|
+
|
|
315
|
+
**This replaced a `<dialog>` drawer in 0.7.0.** Below 64rem the navigation used to be a modal opened by a second control, with `display: contents` above it so the same DOM could serve as the desktop column. It bought focus trapping, Escape and a scroll lock from the browser, and it worked before hydration. It cost two controls for one piece of navigation, a drawer that painted over the tree it contained, and a scroll-into-view that could never run on a phone — a closed `<dialog>` has no layout, so the current page was always below the fold. A closed sidebar is *moved*, not hidden, so that last one is structurally impossible now.
|
|
281
316
|
|
|
282
317
|
#### Composing it yourself
|
|
283
318
|
|
|
@@ -312,7 +347,7 @@ export default async function Page({ params }: { params: Promise<{ slug?: string
|
|
|
312
347
|
|
|
313
348
|
`docs.Page` returns exactly this shape: the `<main>` and the table of contents as **two siblings**, not one wrapped element. They land as direct children of the grid, which is what puts them in separate columns — so if you compose your own page inside `docs.Layout`, return a fragment rather than a wrapper.
|
|
314
349
|
|
|
315
|
-
**The two class names are load-bearing**, and they are the part of this that is easy to leave off. `wave-docs-layout__main` carries `min-width: 0`, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). `wave-docs-layout__toc` is what the grid reserves its third track with, via `:has()` — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both are
|
|
350
|
+
**The two class names are load-bearing**, and they are the part of this that is easy to leave off. `wave-docs-layout__main` carries `min-width: 0`, without which a wide table pushes the whole document into horizontal scroll (measured: 1048px of document inside a 1024px viewport). `wave-docs-layout__toc` is what the grid reserves its third track with, via `:has()` — unclassed, the table of contents auto-places into the next row underneath the sidebar above 80rem, and renders inline on a phone instead of being hidden. Both names are public API and change only in a release that carries the migration, so they are safe to write by hand.
|
|
316
351
|
|
|
317
352
|
The `null` is load-bearing too: `:has()` matches an empty `<aside>` exactly as well as a full one, so a page with no headings would give up 15rem to nothing.
|
|
318
353
|
|
|
@@ -326,6 +361,12 @@ label: Auth # sidebar label, when the title is too long
|
|
|
326
361
|
draft: true # excluded from nav, search and static params
|
|
327
362
|
order: 10 # sort weight where there is no meta.json
|
|
328
363
|
aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
|
|
364
|
+
actions: # calls to action — and the opt-in for a hero
|
|
365
|
+
- label: Quick start
|
|
366
|
+
href: /getting-started
|
|
367
|
+
- label: GitHub
|
|
368
|
+
href: https://github.com/waveso/docs
|
|
369
|
+
variant: secondary
|
|
329
370
|
---
|
|
330
371
|
```
|
|
331
372
|
|
|
@@ -333,6 +374,18 @@ aliases: [old-auth, legacy/auth] # former URLs → permanent redirects
|
|
|
333
374
|
|
|
334
375
|
`draft` is deliberately **not** tied to `NODE_ENV`. Preview deployments are production builds, so branching on it would hide drafts in exactly the place reviewers look — drive `includeDrafts` from your own environment check instead.
|
|
335
376
|
|
|
377
|
+
### The hero
|
|
378
|
+
|
|
379
|
+
`actions` is the only thing that turns a page into a landing page. Declare it and `title` and `description` become a page header — a large heading, the description as a tagline under it, and these links beneath that. Leave it off and the page is exactly what it was: `description` stays a `<meta>` tag and the title is the first thing in the prose.
|
|
380
|
+
|
|
381
|
+
Each action takes a `label`, an `href` and an optional `variant` of `primary` or `secondary`. Omit the variant and the first action is the primary and the rest are secondary, which is the shape every landing page has. An `href` that leaves the site gets `target="_blank"`, `rel="noreferrer"` and a screen-reader suffix; `mailto:` and `tel:` do not, because they open no tab. Unsafe hrefs fail the build rather than reaching an `<a>`.
|
|
382
|
+
|
|
383
|
+
**That is the whole of the adaptation for the two shapes this package serves.** Documentation that is the entire site puts a hero on its index. Documentation mounted at `/docs` inside an application that already has a marketing page leaves `actions` off and gets an ordinary page. There is no mode, no `standalone` flag and nothing to configure — the opt-in lives in the file that wants it.
|
|
384
|
+
|
|
385
|
+
⚠️ **A hero page must not also write its own `# Title`.** `render` normally prepends an `<h1>` from `frontmatter.title`; on a hero page the hero renders that heading instead, because the tagline and the actions have to sit beneath it. Writing one in the body as well ships two `h1`s — the same duplication `titleHeading` has always warned about.
|
|
386
|
+
|
|
387
|
+
The background is a rotated line grid under scrims painted in the page's own colour rather than behind a mask: a soft ellipse over the words, a bottom fade, and a vignette that closes at the corners. The vignette is an inset `box-shadow` rather than a gradient — a `radial-gradient` is only ever a circle or an ellipse, and this one needs a corner radius. `border-radius` is that knob, and `corner-shape: superellipse(3)` makes it a squircle where the browser supports it. Alpha compositing of a solid blends where mask layers multiply, so the falloff is smooth instead of compounding into a shoulder. It is drawn with `repeating-linear-gradient` rather than an inlined SVG — no data URI in the stylesheet. The lines are `--wave-docs-hero-grid` and `--wave-docs-hero-grid-strong`, which are Wave 200 and Wave 300 from `@waveso/ui` in the light theme and Wave 900 and Wave 800 in the dark one. They are tokens of their own rather than the border colours: the grid is decoration behind a mask that leaves it near-invisible where the words are, and a border is a boundary a reader has to be able to see.
|
|
388
|
+
|
|
336
389
|
### Your own fields
|
|
337
390
|
|
|
338
391
|
Pass a `frontmatterSchema` and every `DocFile` and `RenderedDoc` carries your fields, inferred, with no type argument anywhere:
|
|
@@ -741,7 +794,7 @@ export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
|
|
|
741
794
|
}
|
|
742
795
|
```
|
|
743
796
|
|
|
744
|
-
Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in `
|
|
797
|
+
Add the same function to your `createDocsRoute` call — `miniSearchOptions: { processTerm: stripDashes }` — so the index is built with it. Then turn the built-in trigger off and render yours in the layout you write around `docs.Layout`, in `app/docs/layout.tsx`:
|
|
745
798
|
|
|
746
799
|
```tsx
|
|
747
800
|
import '@waveso/docs/styles.css';
|
|
@@ -751,17 +804,15 @@ import { docs } from '@/lib/docs';
|
|
|
751
804
|
|
|
752
805
|
export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
753
806
|
return (
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
{children}
|
|
759
|
-
</docs.Layout>
|
|
807
|
+
<>
|
|
808
|
+
<DocsSearchTrigger indexUrl={docs.searchIndexUrl} />
|
|
809
|
+
<docs.Layout search={false}>{children}</docs.Layout>
|
|
810
|
+
</>
|
|
760
811
|
);
|
|
761
812
|
}
|
|
762
813
|
```
|
|
763
814
|
|
|
764
|
-
`search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string.
|
|
815
|
+
`search={false}` omits the built-in trigger so yours is the only one, and it is also why the refusal is scoped to the forward: the route keeps the function for the index it builds on the server, and nothing crosses to the client but a string. To put your trigger *inside* the sidebar rather than above it, [compose the shell yourself](#composing-it-yourself) — `docs.Layout` has no slot for it, deliberately.
|
|
765
816
|
|
|
766
817
|
### Building the index yourself
|
|
767
818
|
|
|
@@ -817,30 +868,65 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
817
868
|
contentDir: string; // relative paths resolve against process.cwd()
|
|
818
869
|
basePath?: string; // default '/docs'; '/' normalises to ''
|
|
819
870
|
includeDrafts?: boolean; // default false
|
|
820
|
-
|
|
871
|
+
onBrokenLinks?: DocsLinkSeverity; // default 'throw'
|
|
872
|
+
onBrokenAnchors?: DocsLinkSeverity; // default 'throw'
|
|
873
|
+
externalRoutes?: readonly string[]; // routes your app owns, not the docs
|
|
821
874
|
frontmatterSchema?: StandardSchemaV1<unknown, TFrontmatter>;
|
|
822
875
|
}
|
|
876
|
+
|
|
877
|
+
type DocsLinkSeverity = 'throw' | 'warn' | 'ignore';
|
|
823
878
|
```
|
|
824
879
|
|
|
825
|
-
|
|
880
|
+
### Broken links
|
|
826
881
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
882
|
+
**`onBrokenLinks` defaults to `'throw'`, and should stay there.** A link that 404s was valid in your editor and on GitHub, so it is the kind of mistake nobody finds by reading — and a warning in a build log is a warning nobody reads. `'warn'` exists for a migration running knowingly against an incomplete corpus.
|
|
883
|
+
|
|
884
|
+
The error names the file, the line and the closest published route when the link looks like a typo of one:
|
|
885
|
+
|
|
886
|
+
```
|
|
887
|
+
@waveso/docs: guide.md:12 links to './instalation.md', which resolves to
|
|
888
|
+
'/docs/instalation' — no such page exists. Did you mean '/docs/installation'?
|
|
889
|
+
Fix the link, or add an `aliases` entry to the page it used to point at.
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
A suggestion is offered only for a genuine near-miss. `/docs/instructions` is five edits from `/docs/installation` — a different word, not a typo — and gets none, because a wrong suggestion sends you to rename a link that was correct.
|
|
893
|
+
|
|
894
|
+
### Broken anchors
|
|
895
|
+
|
|
896
|
+
**`onBrokenAnchors` defaults to `'throw'`.** A route used to be verified and its fragment thrown away, so `[setup](./install.md#setup)` built green with no `#setup` anywhere on the page. It is the more common of the two failures: headings get renamed constantly, and nothing renames the links into them.
|
|
897
|
+
|
|
898
|
+
```
|
|
899
|
+
@waveso/docs: guide.md:12 links to '#instalation', and this page has no
|
|
900
|
+
'#instalation'. Did you mean 'installation'? Heading ids come from the heading
|
|
901
|
+
text, so renaming a heading renames its anchor.
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
Checked against every `id` in the rendered page rather than against the table of contents — which captures `h2`–`h3` only, so a link to an `h4` is fine, and so is a link to an id one of your `rehypePlugins` added. Lower it to `'warn'` if a plugin of yours adds ids this package cannot see at render time.
|
|
905
|
+
|
|
906
|
+
Same-page anchors are checked as each page renders, so those errors carry the file and the line. Cross-page anchors need the target page's ids, which exist only once everything has been rendered — `docs.renderAll()` does that pass, and it runs in every build that serves search, because the index route is `force-static`.
|
|
836
907
|
|
|
837
|
-
|
|
908
|
+
### Routes your application owns
|
|
838
909
|
|
|
839
|
-
|
|
910
|
+
**`externalRoutes` only matters at a root mount.** Under `basePath: '/docs'` an absolute link either carries the prefix — so it is documentation and is checked — or it does not, and this package leaves it alone. Under `basePath: '/'` there is no prefix: `/setup` and `/login` look identical, and both are checked against the published pages.
|
|
911
|
+
|
|
912
|
+
That is the right default, because a root mount is what you choose when the origin serves documentation and nothing else. If yours serves something else too, name what is yours:
|
|
913
|
+
|
|
914
|
+
```ts
|
|
915
|
+
// lib/docs-root.ts
|
|
916
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
917
|
+
|
|
918
|
+
export const docs = createDocsRoute({
|
|
919
|
+
contentDir: 'content/docs',
|
|
920
|
+
basePath: '/',
|
|
921
|
+
externalRoutes: ['/login', '/dashboard', '/api/'],
|
|
922
|
+
});
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
A link is skipped when it equals one of these or begins with one followed by `/` — so `/api` covers `/api/keys` and not `/apiary`. It is a statement about your application, so nothing here infers it.
|
|
840
926
|
|
|
841
927
|
### Translating the chrome
|
|
842
928
|
|
|
843
|
-
Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
|
|
929
|
+
Twenty-two strings, and every one of them is yours to set. They go on `createDocsRoute` rather than on `docs.Layout`, because they are not all rendered in the same place: four are the shell's, three the navigation tree's, two the table of contents', nine come from the markdown component map, two are baked into the HTML by a rehype plugin at build time, and two are announced by a client-side runtime after a copy. A layout prop is upstream of the first four and nothing else.
|
|
844
930
|
|
|
845
931
|
```ts
|
|
846
932
|
// lib/docs-pt.ts
|
|
@@ -972,6 +1058,7 @@ try {
|
|
|
972
1058
|
| `broken-link` | A markdown link resolves to a route no published page owns. | Fix the link, or add an `aliases` entry to the page that moved. |
|
|
973
1059
|
| `draft-link` | A link points at a page that exists but is `draft: true`. | Publish the page, or drop the link until it ships. |
|
|
974
1060
|
| `alias-link` | A link points at an alias, which is a redirect and not a page. | Link the page the alias redirects to — the error names it. |
|
|
1061
|
+
| `broken-anchor` | A `#fragment` that no heading on the target page owns. | Fix the link, or restore the heading. Heading ids come from the heading text, so renaming one renames its anchor. |
|
|
975
1062
|
| `invalid-alias` | An `aliases` entry is empty, escapes the content root, or is not URL-safe. | Write it as a root-relative path, e.g. `/docs/old-name`. |
|
|
976
1063
|
| `alias-collision` | Two pages claim one alias, or an alias shadows a real route. | Remove one of them; a redirect cannot have two destinations. |
|
|
977
1064
|
| `route-collision` | Two files resolve to the same route. | Usually `about.md` beside `about/index.md`. Keep one. |
|
|
@@ -1005,14 +1092,25 @@ ESM-only is forced rather than chosen: `unified` and the entire `remark-*` / `re
|
|
|
1005
1092
|
If you extend the frontmatter schema, use `.exactOptional()` rather than `.optional()` for optional fields: the latter infers `{ description?: string | undefined }`, which is not assignable to `DocFrontmatter`.
|
|
1006
1093
|
|
|
1007
1094
|
> [!NOTE]
|
|
1008
|
-
>
|
|
1009
|
-
> `
|
|
1010
|
-
> `onMouseEnter?` and `onTouchStart?` *without* `| undefined
|
|
1095
|
+
> Passing `next/link` **straight** into `DocsSidebar` does not type-check under
|
|
1096
|
+
> `exactOptionalPropertyTypes: true`. Next's `LinkProps` re-declares `onClick?`,
|
|
1097
|
+
> `onMouseEnter?` and `onTouchStart?` *without* `| undefined` while React's
|
|
1011
1098
|
> anchor props include it, so the two declaration files disagree — about props
|
|
1012
|
-
> `next/link` accepts perfectly well at runtime. It is true of every
|
|
1013
|
-
>
|
|
1014
|
-
>
|
|
1015
|
-
>
|
|
1099
|
+
> `next/link` accepts perfectly well at runtime. It is true of every `next/link`
|
|
1100
|
+
> call site in a project with that flag on, not just this one.
|
|
1101
|
+
>
|
|
1102
|
+
> Import `DocsLink` instead of casting:
|
|
1103
|
+
>
|
|
1104
|
+
> ```tsx
|
|
1105
|
+
> 'use client';
|
|
1106
|
+
> import { DocsLink } from '@waveso/docs/react/next-link';
|
|
1107
|
+
> import { DocsSidebar } from '@waveso/docs/react/sidebar';
|
|
1108
|
+
>
|
|
1109
|
+
> <DocsSidebar nav={nav} pathname={pathname} Link={DocsLink} />
|
|
1110
|
+
> ```
|
|
1111
|
+
>
|
|
1112
|
+
> `docs.Layout` and `DocsSearch` have always used the same adapter internally,
|
|
1113
|
+
> so this only ever came up when composing a shell by hand.
|
|
1016
1114
|
>
|
|
1017
1115
|
> `docs.Page` and `DocsSearch` are both unaffected — each wraps `next/link`
|
|
1018
1116
|
> inside the package, which is where that cast belongs. Without the flag,
|
|
@@ -1030,9 +1128,9 @@ breaking — which is the part most packages leave unsaid until someone is angry
|
|
|
1030
1128
|
| --- | --- |
|
|
1031
1129
|
| Every subpath in `exports`, and every runtime name it exports | ✅ enforced by `manifest.test.ts` |
|
|
1032
1130
|
| Exported types, including `DocsErrorCode`'s members | ✅ enforced by `error-taxonomy.test.ts` |
|
|
1033
|
-
| **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅
|
|
1131
|
+
| **CSS class names** — `wave-docs-*`, and the shell's element tree | ✅ a name changes only in a release that carries the migration |
|
|
1034
1132
|
| **The hast this emits** — element names, and the attributes on them | ✅ the same policy as the types |
|
|
1035
|
-
| Layout tokens — the
|
|
1133
|
+
| Layout tokens — the six custom properties above | ✅ |
|
|
1036
1134
|
| Anything reachable only through `dist/` internals, or a private module | ❌ |
|
|
1037
1135
|
|
|
1038
1136
|
The two in bold are the ones usually omitted, and omitting them is how a
|
|
@@ -1126,7 +1224,7 @@ src/
|
|
|
1126
1224
|
next.ts # The App Router adapter
|
|
1127
1225
|
meta.ts # meta.json ordering
|
|
1128
1226
|
plugins/ # remark/rehype plugins
|
|
1129
|
-
react/ # Components. Only next
|
|
1227
|
+
react/ # Components. Only the next-* modules touch next/*
|
|
1130
1228
|
styles.css # Theme tokens + prose styles
|
|
1131
1229
|
```
|
|
1132
1230
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { RenderedDoc } from "./types.js";
|
|
2
|
+
import { Root } from "hast";
|
|
3
|
+
//#region src/anchors.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Every `id` in a tree, which is the set an anchor may target.
|
|
6
|
+
*
|
|
7
|
+
* Not the table of contents: that captures `h2`–`h3` only, so checking against
|
|
8
|
+
* it would reject a perfectly good link to an `h4` — and would miss an id a
|
|
9
|
+
* `rehypePlugins` entry put on something that is not a heading at all.
|
|
10
|
+
*/
|
|
11
|
+
declare function collectAnchorIds(tree: Root): Set<string>;
|
|
12
|
+
/** An internal link carrying a fragment, as found in a rendered tree. */
|
|
13
|
+
interface AnchorLink {
|
|
14
|
+
/** The href exactly as it stands in the tree, e.g. `/docs/install#setup`. */
|
|
15
|
+
href: string;
|
|
16
|
+
/** Route without the fragment. */
|
|
17
|
+
route: string;
|
|
18
|
+
/** Fragment without the `#`, percent-decoded. */
|
|
19
|
+
fragment: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* `#fragment` split off an internal href, or `undefined`.
|
|
23
|
+
*
|
|
24
|
+
* Skips anything with a scheme and anything protocol-relative: an anchor on
|
|
25
|
+
* somebody else's page is theirs to get wrong, and a fragment there is a
|
|
26
|
+
* routine way to link into a spec.
|
|
27
|
+
*/
|
|
28
|
+
declare function splitAnchor(href: string): AnchorLink | undefined;
|
|
29
|
+
/** Every internal anchor link in a tree, in document order. */
|
|
30
|
+
declare function collectAnchorLinks(tree: Root): AnchorLink[];
|
|
31
|
+
/**
|
|
32
|
+
* Cross-page anchors, once every page has been rendered.
|
|
33
|
+
*
|
|
34
|
+
* `report` is passed in rather than imported so this module stays free of the
|
|
35
|
+
* error factory and the severity plumbing — it answers "which anchors are
|
|
36
|
+
* wrong", and the caller owns what that costs.
|
|
37
|
+
*
|
|
38
|
+
* A link to a route nothing rendered is a *broken link*, not a broken anchor,
|
|
39
|
+
* and `assertLinks` has already reported it at its own severity. Skipped here
|
|
40
|
+
* so one mistake is not two failures.
|
|
41
|
+
*/
|
|
42
|
+
declare function assertAnchors(docs: ReadonlyArray<RenderedDoc<never>> | ReadonlyArray<RenderedDoc>, report: (from: string, link: AnchorLink, known: Set<string>) => void): void;
|
|
43
|
+
//#endregion
|
|
44
|
+
export { AnchorLink, assertAnchors, collectAnchorIds, collectAnchorLinks, splitAnchor };
|
package/dist/anchors.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { visit } from "unist-util-visit";
|
|
2
|
+
//#region src/anchors.ts
|
|
3
|
+
/**
|
|
4
|
+
* Every `id` in a tree, which is the set an anchor may target.
|
|
5
|
+
*
|
|
6
|
+
* Not the table of contents: that captures `h2`–`h3` only, so checking against
|
|
7
|
+
* it would reject a perfectly good link to an `h4` — and would miss an id a
|
|
8
|
+
* `rehypePlugins` entry put on something that is not a heading at all.
|
|
9
|
+
*/
|
|
10
|
+
function collectAnchorIds(tree) {
|
|
11
|
+
const ids = /* @__PURE__ */ new Set();
|
|
12
|
+
visit(tree, "element", (node) => {
|
|
13
|
+
const id = node.properties.id;
|
|
14
|
+
if (typeof id === "string" && id !== "") ids.add(id);
|
|
15
|
+
});
|
|
16
|
+
return ids;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `#fragment` split off an internal href, or `undefined`.
|
|
20
|
+
*
|
|
21
|
+
* Skips anything with a scheme and anything protocol-relative: an anchor on
|
|
22
|
+
* somebody else's page is theirs to get wrong, and a fragment there is a
|
|
23
|
+
* routine way to link into a spec.
|
|
24
|
+
*/
|
|
25
|
+
function splitAnchor(href) {
|
|
26
|
+
if (href === "" || /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(href)) return void 0;
|
|
27
|
+
const hash = href.indexOf("#");
|
|
28
|
+
if (hash === -1 || hash === href.length - 1) return void 0;
|
|
29
|
+
const raw = href.slice(hash + 1);
|
|
30
|
+
let fragment;
|
|
31
|
+
try {
|
|
32
|
+
fragment = decodeURIComponent(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
href,
|
|
38
|
+
route: href.slice(0, hash),
|
|
39
|
+
fragment
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** Every internal anchor link in a tree, in document order. */
|
|
43
|
+
function collectAnchorLinks(tree) {
|
|
44
|
+
const links = [];
|
|
45
|
+
visit(tree, "element", (node) => {
|
|
46
|
+
if (node.tagName !== "a") return;
|
|
47
|
+
const href = node.properties.href;
|
|
48
|
+
if (typeof href !== "string") return;
|
|
49
|
+
const anchor = splitAnchor(href);
|
|
50
|
+
if (anchor !== void 0) links.push(anchor);
|
|
51
|
+
});
|
|
52
|
+
return links;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Cross-page anchors, once every page has been rendered.
|
|
56
|
+
*
|
|
57
|
+
* `report` is passed in rather than imported so this module stays free of the
|
|
58
|
+
* error factory and the severity plumbing — it answers "which anchors are
|
|
59
|
+
* wrong", and the caller owns what that costs.
|
|
60
|
+
*
|
|
61
|
+
* A link to a route nothing rendered is a *broken link*, not a broken anchor,
|
|
62
|
+
* and `assertLinks` has already reported it at its own severity. Skipped here
|
|
63
|
+
* so one mistake is not two failures.
|
|
64
|
+
*/
|
|
65
|
+
function assertAnchors(docs, report) {
|
|
66
|
+
const idsByRoute = /* @__PURE__ */ new Map();
|
|
67
|
+
for (const doc of docs) idsByRoute.set(doc.href, collectAnchorIds(doc.hast));
|
|
68
|
+
for (const doc of docs) for (const link of collectAnchorLinks(doc.hast)) {
|
|
69
|
+
if (link.route === "") continue;
|
|
70
|
+
const known = idsByRoute.get(link.route);
|
|
71
|
+
if (known === void 0 || known.has(link.fragment)) continue;
|
|
72
|
+
report(doc.href, link, known);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { assertAnchors, collectAnchorIds, collectAnchorLinks, splitAnchor };
|
package/dist/errors.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ type DocsErrorCode =
|
|
|
47
47
|
'draft-link' |
|
|
48
48
|
/** A link resolves to an alias, which is a redirect and not a page. */
|
|
49
49
|
'alias-link' |
|
|
50
|
+
/** A `#fragment` that no heading on the target page owns. */
|
|
51
|
+
'broken-anchor' |
|
|
50
52
|
/** An `aliases` entry is empty, escapes the root, or is not URL-safe. */
|
|
51
53
|
'invalid-alias' |
|
|
52
54
|
/** Two pages claim one alias, or an alias shadows a real route. */
|
package/dist/frontmatter.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ declare const docFrontmatterSchema: z.ZodObject<{
|
|
|
32
32
|
draft: z.ZodExactOptional<z.ZodBoolean>;
|
|
33
33
|
aliases: z.ZodExactOptional<z.ZodArray<z.ZodString>>;
|
|
34
34
|
order: z.ZodExactOptional<z.ZodNumber>;
|
|
35
|
+
actions: z.ZodExactOptional<z.ZodArray<z.ZodObject<{
|
|
36
|
+
label: z.ZodString;
|
|
37
|
+
href: z.ZodString;
|
|
38
|
+
variant: z.ZodExactOptional<z.ZodEnum<{
|
|
39
|
+
primary: "primary";
|
|
40
|
+
secondary: "secondary";
|
|
41
|
+
}>>;
|
|
42
|
+
}, z.core.$strip>>>;
|
|
35
43
|
}, z.core.$strip>;
|
|
36
44
|
/**
|
|
37
45
|
* Validate one file's frontmatter, or throw an error that names the file.
|