@waveso/docs 0.3.0 → 0.5.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 +201 -0
- package/README.md +160 -39
- package/dist/errors.d.ts +2 -0
- package/dist/highlighter.js +2 -1
- package/dist/meta.js +6 -9
- package/dist/next.d.ts +54 -14
- package/dist/next.js +115 -18
- package/dist/plugins/rehype-code-frame.d.ts +13 -1
- package/dist/plugins/rehype-code-frame.js +2 -1
- package/dist/plugins/remark-doc-links.d.ts +51 -1
- package/dist/plugins/remark-doc-links.js +27 -16
- package/dist/plugins/remark-youtube.d.ts +18 -3
- package/dist/plugins/remark-youtube.js +57 -9
- package/dist/react/callout.d.ts +13 -1
- package/dist/react/callout.js +2 -2
- package/dist/react/code-runtime.d.ts +12 -2
- package/dist/react/code-runtime.js +28 -4
- package/dist/react/doc-content.d.ts +12 -1
- package/dist/react/doc-content.js +2 -2
- package/dist/react/layout.d.ts +27 -10
- package/dist/react/layout.js +6 -3
- package/dist/react/markdown-components.d.ts +29 -1
- package/dist/react/markdown-components.js +69 -67
- package/dist/react/nav.d.ts +5 -1
- package/dist/react/nav.js +5 -2
- package/dist/react/next-nav.d.ts +5 -1
- package/dist/react/next-nav.js +5 -2
- package/dist/react/search-dialog.d.ts +92 -5
- package/dist/react/search-dialog.js +182 -43
- package/dist/react/shell-labels.d.ts +135 -21
- package/dist/react/shell-labels.js +47 -6
- package/dist/react/sidebar.d.ts +18 -1
- package/dist/react/sidebar.js +59 -23
- package/dist/react/youtube.d.ts +22 -1
- package/dist/react/youtube.js +22 -4
- package/dist/render.d.ts +11 -0
- package/dist/render.js +38 -11
- package/dist/route-path.js +7 -2
- package/dist/safe-href.d.ts +47 -0
- package/dist/safe-href.js +73 -0
- package/dist/search-index.js +1 -1
- package/dist/search-options.d.ts +64 -2
- package/dist/search-options.js +25 -1
- package/dist/semaphore.d.ts +46 -0
- package/dist/semaphore.js +60 -0
- package/dist/source.js +80 -10
- package/dist/styles.css +62 -13
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,206 @@
|
|
|
1
1
|
# @waveso/docs
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 0d61f5d: **The content scan no longer opens every markdown file at once.** It failed on exactly the large documentation sets this package exists for, and it got worse as a site grew.
|
|
8
|
+
|
|
9
|
+
`scanDir` read its own pages with a bare `Promise.all` _and_ recursed into its subdirectories with another, so the number of `readFile` calls in flight equalled the page count of the whole tree. Reproduced on a 1,201-page corpus at the 1,024-descriptor soft limit every Linux and CI image ships with: `next build` died with
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
Error: EMFILE: too many open files, open '<contentDir>/s33/p0829.md'
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
— no error code, no mention that this was the docs scan, and a filename out of a thousand that sends the author to inspect a page which is perfectly fine.
|
|
16
|
+
|
|
17
|
+
**Every filesystem call in the scan now goes through one process-wide semaphore**, bounded at 64. Process-wide rather than per-scan because descriptors are a process resource: two routes scanning two content directories would each stay under a per-scan bound and together exceed the only one that matters. The bound is on the leaf calls — `readFile`, `readdir`, `stat`, `realpath` — and deliberately not on the recursion, which would deadlock the moment every slot were held by a directory waiting for a slot to read its children.
|
|
18
|
+
|
|
19
|
+
**64 is chosen for the tightest descriptor limit, not for speed.** Measured over those 1,201 pages in 49 directories, nine runs, medians: **88 ms ungated, 106 ms at a bound of 16, 102 ms at 64, 101 ms at 128 and at 256.** Flat from 64 upwards — so the ~14 ms is the gate's own per-call overhead, not lost parallelism, and there is no speed to buy above 64. libuv's filesystem pool is four threads by default, so there was never 1,201-way parallelism there to lose. 14 ms sits against a build that highlights those same pages with Shiki, which is three orders of magnitude more.
|
|
20
|
+
|
|
21
|
+
**New error code `descriptor-limit`**, for when the limit is lower than the bound or something else in the process has the descriptors. It says which content directory was being scanned, that the scan holds at most 64 open, and that the fix is `ulimit -n` rather than a smaller corpus — the opposite of what a reader concludes from an error naming one of their own pages. Documented in the troubleshooting table and offered in the bug form.
|
|
22
|
+
|
|
23
|
+
The regression test asserts the invariant rather than the number: peak concurrent filesystem calls during a 300-page scan across 30 directories, which had 300 in flight before and has a fixed ceiling now however large the corpus.
|
|
24
|
+
|
|
25
|
+
- 6c244e5: **The mobile drawer now opens on the page you are reading.** Scroll-the-current-item-into-view had never run on a phone — not intermittently, never.
|
|
26
|
+
|
|
27
|
+
Below 64rem the sidebar lives inside `<dialog class="wave-docs-layout__drawer">`, which the UA stylesheet keeps at `display: none` until `showModal()`, wrapped in a `.wave-docs-layout__sidebar` that is `display: contents`. An element in a `display: none` subtree generates no boxes at all, so both report `scrollHeight === clientHeight === 0` and the walk for a scrollable ancestor went past the drawer, past the grid, and returned `null`.
|
|
28
|
+
|
|
29
|
+
The timing is what made it unreachable rather than merely unreliable: the effect was keyed on `pathname` alone, and `DocsNav` closes the drawer on every `pathname` change — so at the one moment it could fire, the drawer was always shut, and nothing re-ran when the reader opened it. Measured in Chromium at 390×800 on a 60-item nav: the active item's bottom edge sat at **1594px in an 800px drawer**, 794px below the fold, on the one navigation where the reader knows exactly what they asked for.
|
|
30
|
+
|
|
31
|
+
`DocsSidebar` now also positions itself when a `<dialog>` around it opens. It finds that dialog with `closest('dialog')` rather than taking a prop from `DocsNav`, because the condition is "I am inside something that can be hidden and revealed" rather than "I am inside the drawer" — so a consumer who puts `DocsSidebar` in a dialog of their own gets the same behaviour, and nothing in the sidebar has to know what the drawer is.
|
|
32
|
+
|
|
33
|
+
**Size budgets raised deliberately:** `sidebar` 1.8 → 1.9 KB, `nav` 2.2 → 2.25 KB, `next-nav` 2.25 → 2.34 KB. The cost is one `toggle` listener; the sidebar was sitting at exactly 100% of its old budget, which is a CI failure waiting for the next byte rather than a limit doing any work.
|
|
34
|
+
|
|
35
|
+
**Why no other tier could see it:** jsdom has no layout and no `showModal`, and the stylesheet read as text says nothing about what `display: contents` does to a scrollport. The new tests assert the premise first — that the item measures zero height while the drawer is closed — so they cannot quietly degrade into tests that pass by measuring nothing.
|
|
36
|
+
|
|
37
|
+
- f2ad4f4: **Every string this package renders is now yours to set.** `DocsLayoutProps.labels` documented itself as "the whole of what a non-English site has to say" and reached four strings of twenty-two.
|
|
38
|
+
|
|
39
|
+
Verified in this repository's own `site/out`, which is how it was found: a site built exactly the documented way shipped `<nav aria-label="On this page">`, a visible `Back to top`, `aria-label="Tip"` on every callout, `aria-label="Table"` on every wide table, `Copy code` on every fence, `(opens in a new tab)` after every external link, `Expand <group>` on every sidebar disclosure, and `Play video` on every embed — in English, whatever language the site was written in. The copy runtime announced `Copied to the clipboard.` to a screen reader, in English, on every site on earth.
|
|
40
|
+
|
|
41
|
+
**`createDocsRoute({ labels })` is where they live now**, because they are not rendered in one place and a layout prop could never have reached them:
|
|
42
|
+
|
|
43
|
+
| where | strings | cost to override |
|
|
44
|
+
| -------------------------------------------------------- | ------- | ----------------------------- |
|
|
45
|
+
| the shell | 4 | none, server-rendered |
|
|
46
|
+
| the navigation tree | 3 | crosses to a client component |
|
|
47
|
+
| the table of contents | 2 | crosses to a client component |
|
|
48
|
+
| your content — callouts, tables, external links, YouTube | 9 | none, server-rendered |
|
|
49
|
+
| code frames | 2 | none, baked in at build time |
|
|
50
|
+
| the copy runtime | 2 | crosses to a client component |
|
|
51
|
+
|
|
52
|
+
`docs.Layout`'s `labels` prop still exists and now overrides the route's **key by key**, for a site with two shells or a section in another language — a whole-object override would mean naming one string cost you the other twenty-one.
|
|
53
|
+
|
|
54
|
+
`{title}` is a placeholder in the five strings that interpolate a name, rather than a function: three of them cross a Server → Client boundary where a function cannot go, and a translator has to be able to move the name within the sentence, which concatenation forbids.
|
|
55
|
+
|
|
56
|
+
**`rehypeCodeFrame` has taken a `copyLabel` since it was written and nothing ever passed one.** The plugin is private, so the option was unreachable from every entry point while the README said the label was configurable. It is wired now, and joined by `copyCodeFrom` for a titled fence.
|
|
57
|
+
|
|
58
|
+
**Sizes moved, and the published figures moved with them.** `sidebar` 1.9 → 2.0 KB, `nav` 2.25 → 2.45, `next-nav` 2.34 → 2.55, `code-runtime` 0.98 → 1.15. The README's cost table now reads 13.5 KB for the quick start, 2.4 KB for navigation and 1.1 KB for the copy runtime. About 200 gzipped bytes for a chrome that can be translated at all.
|
|
59
|
+
|
|
60
|
+
**A key that is declared and never wired now fails the suite.** `LABEL_COVERAGE` in `next.test.ts` requires every member of `DocsLabels` to name where it is proven, and fourteen of them are asserted against real rendered markup with sentinel values — a string that cannot occur by accident cannot pass by accident.
|
|
61
|
+
|
|
62
|
+
- 952e182: **Image sources are now split and percent-decoded, exactly as links have always been.** `foldImageSrc` handed the authored `src` straight to `foldSegments`, so three ordinary spellings broke — and the first is the one GitHub's own editor writes for you.
|
|
63
|
+
|
|
64
|
+
- **``** — drag a file whose name has a space into GitHub's editor and this is what it writes. It reached the `imageResolver` still encoded, so `readFile(path.join('content/docs', src))` — the implementation this README gives — threw `ENOENT` and the build died with `invalid-image` on a file that is plainly on disk and that GitHub renders correctly.
|
|
65
|
+
- **`` and ``** — the query and the fragment were baked into the filename, so the resolver looked for a file called `diagram.png?v=2`. They are split off before the call now and re-attached to whatever the resolver returns, so the cache-buster survives and `#icon` still selects the symbol inside the sprite. A resolver returning a query or fragment of its own keeps its own, because two `?` in one URL is not a URL.
|
|
66
|
+
- **``** — folded without decoding, that is one segment with no slash in it, so the climb check never fired. `ImageResolver`'s contract promises the path is contained; any resolver that decodes — anything building a `URL` — was outside it. `foldSegments` can only refuse a `../` that is spelled as one, which is why decoding happens first.
|
|
67
|
+
|
|
68
|
+
**One implementation, in `splitHref`.** Four call sites need the same split-decode-fold-reattach sequence; three had it inline and the fourth had none of it. They now share one.
|
|
69
|
+
|
|
70
|
+
**`invalid-image` covers malformed encoding too.** Decoding means the image path can raise `URIError`, and an unwrapped one would reach the build with no code, no file and no line — past the very check that exists to make image failures locatable.
|
|
71
|
+
|
|
72
|
+
`ImageResolver`'s docstring now says what its argument is: a file path, decoded, with the query and fragment already removed.
|
|
73
|
+
|
|
74
|
+
- 791c1c0: **Link errors now name the line the link is on.** Every `broken-link`, `draft-link` and `alias-link` reported a line number offset by the length of the frontmatter block — which is to say, a wrong one, on every page that has frontmatter, which is every page.
|
|
75
|
+
|
|
76
|
+
`vfile-matter` with `strip: true` deletes the block from the body, so remark counts `node.position.start.line` from the first line of the _body_. The error prints `relativePath:line`, the exact `file:line` form a terminal and an editor turn into a jump. A page with `title`, `description`, `label` and `order` — four fields and two delimiters — is six lines out:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
- @waveso/docs: setup.md:4 links to './nowhere.md', which resolves to '/docs/nowhere'
|
|
80
|
+
+ @waveso/docs: setup.md:10 links to './nowhere.md', which resolves to '/docs/nowhere'
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Line 4 is the middle of a block that is no longer there. Locatability is that error's entire job.
|
|
84
|
+
|
|
85
|
+
**The fix is padding rather than arithmetic.** `DocFile` carries a new `frontmatterLines`, set by the scan, and the renderer prepends that many newlines to what the parser sees. A blank line produces no markdown node, so it costs nothing in the output — and every position downstream is simply correct, including any a future plugin reports, which an offset applied at the four known throw sites would not be.
|
|
86
|
+
|
|
87
|
+
`content` is untouched. It is public, its exact value is pinned by tests, and a consumer measuring it should not have to know about this.
|
|
88
|
+
|
|
89
|
+
`frontmatterLines` is optional and treated as `0` when absent, so a host loading content itself — the documented reason `@waveso/docs/render` is an entry point — is unaffected.
|
|
90
|
+
|
|
91
|
+
**Why nothing caught it:** the render tests hand the renderer a body they wrote themselves, with no frontmatter and therefore no offset, and the source tests never render. The defect lived in the seam and was invisible from either side; the new tests scan a real file and render it.
|
|
92
|
+
|
|
93
|
+
- 5117aa4: **The search dialog's props are in the README, and the ones that were missing were the ones the changelog told you to migrate to.** A consumer on 0.3.0 with `<DocsSearch maxResults={20} />` upgraded to 0.4.0, watched TypeScript reject `maxResults`, opened the file the Stability section calls the definition of public API, searched 57 KB of it for the replacement — and found neither `pageSize` nor `minQueryLength`.
|
|
94
|
+
|
|
95
|
+
There is a props table under **Search → The dialog's props** now, and a check that stops the next rename shipping undocumented: `manifest.test.ts` reads every `…Props` interface out of the emitted `.d.ts` and requires each member to be named in the README. It found seven more the moment it was written — `DocsTocProps.rootMargin` and `.topLabel`, `DocsLinkProps.prefetch`, `DocsImageProps.sizes` and `.loading`, and two of my own — all now documented rather than allowlisted.
|
|
96
|
+
|
|
97
|
+
**Six of the dialog's strings turned out to be hardcoded English**, which is the same defect this release fixes everywhere else and was hiding behind a prop list that looked complete: `triggerLabel`, `placeholder` and `dialogLabel` were props from the start, so `search={{ … }}` read as _the_ channel for the dialog's words — while every state message was a literal. They are props now: `hintLabel`, `shortQueryLabel`, `loadingLabel`, `errorLabel`, `emptyLabel`, with `{min}` and `{query}` interpolated.
|
|
98
|
+
|
|
99
|
+
**And the live region announced "3 results" in English on every site on earth.** `resultCountLabels` is keyed by plural category rather than being a singular and a plural, because most languages are not English — Polish takes four forms, Arabic six. `Intl.PluralRules` picks, using `locale` or the document's own `<html lang>`, and a category you do not list falls back to `other`. An invalid `lang` is caught rather than thrown: that is the site's typo, not a reason to announce nothing.
|
|
100
|
+
|
|
101
|
+
**`hotkey` does not exist and never did.** `DocsLayoutProps.search` listed it among the props an object may carry. The shortcut is ⌘K / Ctrl-K and is not configurable; the docstring says so now. `SearchDialogProps.indexUrl` likewise documented itself in terms of `writeSearchIndex`, which was deleted in 0.3.0.
|
|
102
|
+
|
|
103
|
+
**Published figure raised:** search dialog and router wiring, 9.0 → 9.3 KB.
|
|
104
|
+
|
|
105
|
+
- 114eb3d: **`docs.Layout` no longer breaks `next build` when the route tunes MiniSearch with a function.** It has been doing so since 0.3.0, in exactly the case the option's own capitalised warning tells you to use it for.
|
|
106
|
+
|
|
107
|
+
`createDocsRoute({ miniSearchOptions })` and `export default docs.Layout` are the two things the quick start tells you to do, and the layout forwards those options to the dialog because it must — MiniSearch reads `tokenize` and `processTerm` when indexing _and_ when querying, so an index built with one and queried with another matches nothing at all and says nothing. But `docs.Layout` is a Server Component and the dialog is a Client Component, and React serialises what crosses between them. So a `processTerm` in that object took the whole build down:
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
Error: Functions cannot be passed directly to Client Components
|
|
111
|
+
{processTerm: function processTerm}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Serialisable overrides — `storeFields`, `boost`, anything under `searchOptions` — were unaffected, which is why this survived two releases: every documented example uses those.
|
|
115
|
+
|
|
116
|
+
**The fix is a refusal, not a silent drop.** Forwarding the serialisable half and discarding the rest would rebuild the original defect: an index built with a `processTerm` the query does not share is the zero-results-and-no-error failure the forwarding exists to prevent. `docs.Layout` now throws `invalid-config` instead, naming every offending option — `miniSearchOptions.processTerm`, `miniSearchOptions.searchOptions.filter` — and naming the remedy.
|
|
117
|
+
|
|
118
|
+
**The remedy is a client boundary of your own**, which is the only place the two halves can share a function by module reference rather than by prop. Keep it on `createDocsRoute` so the index is still built with it, pass `search={false}`, and render the dialog from a `'use client'` module that imports the same function — `README.md` has the three files under **Search → Functions need a client boundary**. `search={false}` deliberately does not throw: it is the supported path, so it must not be the one that fails.
|
|
119
|
+
|
|
120
|
+
**TypeScript now says so at the seam.** `DocsLayoutProps['search']` takes the new `SerializableSearchOptions` rather than MiniSearch's full `Options`, so `<docs.Layout search={{ miniSearchOptions: { tokenize } }}>` does not compile. `DocsSearch` and `SearchDialog` are unchanged and still take everything — they are already client components, which is the whole point. The type is the friendlier half of the guard, not the load-bearing one: a JavaScript caller has no types, and an `Omit` list goes stale the minor MiniSearch adds a callback, so the runtime check walks the values structurally and finds a function wherever it is.
|
|
121
|
+
|
|
122
|
+
**`DocsLayoutSearchProps` and `SerializableSearchOptions` are now exported from `@waveso/docs/next`.** Both already appeared in `DocsLayoutProps.search`; until now there was no way to spell either one.
|
|
123
|
+
|
|
124
|
+
**Why nothing caught it:** the test asserted on `element.props.search` and so never crossed the boundary it was testing. A props assertion is not an RSC test. The smoke build now checks that the forwarded options reach the flight payload — `pnpm test:smoke` reads them back out of the prerendered HTML — because only a real build can prove the allowed half arrives.
|
|
125
|
+
|
|
126
|
+
### Patch Changes
|
|
127
|
+
|
|
128
|
+
- a5136d5: **An alias copied out of the address bar was encoded twice.** An alias _is_ a former URL, so `aliases: [getting%20started]` is the ordinary way to write one — and the route builder encoded the `%` again, producing a redirect source of `/docs/getting%2520started` that no request can ever match. The page moved, the alias was written, and the old URL still 404'd.
|
|
129
|
+
|
|
130
|
+
It decodes first now, which makes both spellings round-trip: the pasted form decodes and re-encodes to itself, and the readable `c# guide` has nothing to decode and still becomes `c%23%20guide`. The `.`/`..` and redirect-pattern checks moved to the decoded value too — `%2E%2E` is `..` in disguise and `%28` is `(`, so checking the raw text passed exactly the inputs those checks exist to catch.
|
|
131
|
+
|
|
132
|
+
**`basePath: '//docs'` pointed the whole site off itself.** A browser reads a leading `//` as scheme-relative, so `//docs/setup` navigates to a host called `docs` — and `basePath` builds every canonical, every `og:url` and every sitemap entry. Runs of slashes collapse now.
|
|
133
|
+
|
|
134
|
+
- 511ec6f: **Five documentation claims that were not true, and the tests that stop them rotting again.**
|
|
135
|
+
|
|
136
|
+
**`pnpm size` now really does gate `prepublishOnly`.** The paragraph introducing the cost table says every figure is enforced "in CI and again in `prepublishOnly`" — the sentence that makes the whole table worth trusting — and the script ran typecheck, lint, test, build, `check:readme` and `check:package`. Nothing verified the published numbers at the one moment they became published. The claim is now true rather than deleted, and a test compares the two.
|
|
137
|
+
|
|
138
|
+
**The README no longer claims CI runs `pnpm shoot --check`.** It never has: the gate was added and removed without running once, because byte-compared PNGs cannot survive a change of operating system — the font stack resolves to SF Pro on the machine that shot them and to DejaVu on a Linux runner, so every text pixel differs and no tolerance rescues glyphs that are different shapes. A test now requires the README and `ci.yml` to agree either way.
|
|
139
|
+
|
|
140
|
+
**The screenshots are pinned to a tag whose images they actually are.** The pin said `v0.3.0` while the committed PNGs had been regenerated for 0.4.0's search work, so the README on npm showed a dialog the release it documented had replaced. A test compares the committed bytes against `git show <tag>:docs/media/…` — exact, offline, and skipped only in a shallow clone.
|
|
141
|
+
|
|
142
|
+
**`src/react/` does not import nothing from `next/*`.** Two modules do, and are named after the fact: `next-nav` for `usePathname` and `next-search` for `useRouter`. The project-structure listing and the Components paragraph both said otherwise.
|
|
143
|
+
|
|
144
|
+
**`SECURITY.md` supports `0.5.x`**, not `0.3.x`.
|
|
145
|
+
|
|
146
|
+
And two comments in `next.ts` that misdescribed the code they sit on: `layout.tsx` was called a `'use client'` module — it is a Server Component, and its own docstring has always said so — and the lazy import beside it was justified by that non-fact rather than by the real reason, which is that `layout.tsx` statically imports two client modules that reach `next/navigation` and `next/link`.
|
|
147
|
+
|
|
148
|
+
- b3c0fa6: **`search={{ className }}` no longer deletes the class the header depends on.** It was applied before the spread, so a host passing a class — the ordinary reason to pass `search` an object — replaced `wave-docs-layout__search` instead of adding to it, and the trigger lost its place in the header grid. Adding a class should not remove one.
|
|
149
|
+
|
|
150
|
+
**A declining `imageResolver` no longer emits an unusable src.** Returning `undefined` for a relative image wrote the folded path — `guide/diagram.png` — which the browser resolves against the _route_: `/docs/guide` asks for `/docs/guide/diagram.png` and `/docs/guide/setup` asks for `/docs/guide/setup/guide/diagram.png`, from identical markdown, with a green build. That is the precise failure unconditional folding exists to prevent, surviving in the one branch that skipped the check for it. It is the same `invalid-image` as having no resolver at all, because it is the same situation. A _public_ src that a resolver declines still passes through untouched — "leave it as it is" is a complete answer for one of those.
|
|
151
|
+
|
|
152
|
+
**`decoding` and `fetchPriority` reach a custom `Image` component.** `createImage` spreads the tree's attributes into whichever component it was given, under a comment saying those two survive into the optimising branch — and `wrapNextImage` destructures a fixed list, so they did not. They are declared members of `DocsImageProps` now, which is the right shape for a component seam; a comment promising an open one was not.
|
|
153
|
+
|
|
154
|
+
**An equal theme pair written two ways no longer builds two highlighters.** The cache key was `JSON.stringify({ langs, themes })`, and `JSON.stringify` preserves insertion order — so `{ light, dark }` and `{ dark, light }` produced two keys and two whole Shiki instances, each loading every grammar. `langs` was already sorted for exactly this reason.
|
|
155
|
+
|
|
156
|
+
**`@waveso/docs/highlighter` has tests.** It is a public subpath and had none: the escape hatch a consumer reaches for precisely when the defaults do not fit, and the least likely to be exercised by anything else here. Seven now cover the cache identity, both refusals, every default grammar, and the `cfg / `conf aliases the module exists for — which Shiki resolves against a grammar's own alias list rather than against ours, so registering `ini` under a `cfg` key would not have worked and nothing would have said so.
|
|
157
|
+
|
|
158
|
+
- f97c345: **`meta.json` is now read the way markdown is.** Four ways it was not, and one of them is a security gap.
|
|
159
|
+
|
|
160
|
+
**A hand-written nav entry went round the link allowlist.** `{ "title": "Status", "href": "javascript:alert(1)" }` reached `<a href>` through `DocsSidebar` with nothing looking at its scheme — while a `javascript:` link in the markdown beside it was dropped by a check whose own comment calls it load-bearing. Both paths end at the same anchor, so both now use the same allowlist, and it lives in one module rather than two copies. `meta.json` is refused at parse time rather than dropped at render, because the file is authored and a nav entry that silently vanishes is the quietest possible failure.
|
|
161
|
+
|
|
162
|
+
**A `mailto:` entry was announced as opening a new tab.** `external` was "has a scheme", so `mailto:` and `tel:` were given `target="_blank"`, an external-link icon and an "(opens in a new tab)" suffix — describing a tab that never appears, to precisely the reader who cannot see that it did not. The markdown path has always drawn the line at http(s); the sidebar does now too. (The test asserting the old behaviour asserted the bug, and said so in its own name.)
|
|
163
|
+
|
|
164
|
+
**A UTF-8 BOM failed the build on a character nobody can see.** `readFile(…, 'utf8')` leaves it in and `JSON.parse` refuses it — `Unexpected token ''`, in the file the author is looking straight at. `readPage` learned this for markdown when `gray-matter` was swapped out; this is the same line for the same reason.
|
|
165
|
+
|
|
166
|
+
**A name in `meta.json` is matched in NFC**, because the filenames it is matched against are. `source.ts` normalises at the `readdir` boundary — macOS hands back decomposed forms — so a `meta.json` written on a Mac could list `café` and fail with `lists "café", which does not exist`, beside a list of available names containing a visually identical `café`.
|
|
167
|
+
|
|
168
|
+
- 0670710: **A YouTube link's timestamp and playlist are no longer dropped.** Only the video id survived the substitution, so `https://youtu.be/x?t=754` — a link to one moment in a two-hour talk, which is most of why anyone deep-links a video — opened at zero. And because the facade passes `autoplay=1`, it did not merely start in the wrong place: it started _playing_ there, leaving the reader to work out that the author had meant somewhere else.
|
|
169
|
+
|
|
170
|
+
Every spelling YouTube's own share dialog produces is understood — `t=754`, `t=90s`, `t=1m30s`, `t=1h2m3s`, the older `#t=` fragment, and `start=` on an embed URL — along with `list=`.
|
|
171
|
+
|
|
172
|
+
Both go into a URL, so both are checked: a playlist id has to look like one, a timestamp has to parse to a positive number of seconds, and the embed URL is built with `URLSearchParams` rather than by concatenation. Hand-built, a crafted `list=x%26autoplay%3D0` would have decoded into a real `&autoplay=0` and silently turned off the one interaction the facade exists to own.
|
|
173
|
+
|
|
174
|
+
`YouTube` takes `start` and `list` props to match. `parseYouTubeId` is now `parseYouTubeRef` and returns the whole reference; it is private, so nothing outside the package moves.
|
|
175
|
+
|
|
176
|
+
## 0.4.0
|
|
177
|
+
|
|
178
|
+
### Minor Changes
|
|
179
|
+
|
|
180
|
+
- The search dialog, corrected by using it. Five defects a reader meets and no test could see, and one rename that follows from the largest of them.
|
|
181
|
+
|
|
182
|
+
**`maxResults` is now `pageSize`, and it is a window rather than a ceiling.** It capped the list at 20 (8 in 0.2.0): on a _six-page_ site "docs" matches 18, so results were unreachable, and the live region announced the slice as though it were the total — not a smaller truth but a false one.
|
|
183
|
+
|
|
184
|
+
The cap was justified by a claim nobody had measured, and measuring it did not support the claim. On a 300-page corpus (2,100 records) a MiniSearch query costs **1.3–3.0 ms**, and rendering _every_ matching row costs **40 ms**, 128 ms at 4× CPU throttle. The search was never the cost; the DOM only becomes one in the thousands.
|
|
185
|
+
|
|
186
|
+
So the list pages. Twenty rows render, and another twenty each time the reader scrolls near the end — the DOM stays bounded and nothing is withheld. The keyboard widens the window too, or `aria-activedescendant` points at an option that is not in the DOM. `aria-setsize` carries the real total on every option, because a listbox rendering 20 of 2,100 that says "20 of 20" tells a reader they have reached the end when they have not.
|
|
187
|
+
|
|
188
|
+
**Migration:** rename the prop. `maxResults={20}` becomes `pageSize={20}`, and it now means "reveal this many at a time" rather than "never show more than this".
|
|
189
|
+
|
|
190
|
+
**The dialog sizes to its results.** It was 32rem tall in every state — measured 514px with no query, 514px with eight results, 514px with none, of which 392px was an empty results area, so a reader typed into a box floating at the top of a large blank rectangle. A flex container defaults to `align-items: stretch`, so the dialog stretched to the viewport and `max-height` capped it at a constant instead of being the ceiling it was written to be. It is content-height now, and starts scrolling at the same 32rem.
|
|
191
|
+
|
|
192
|
+
**Hovering a half-visible result no longer yanks the list.** Pointing at a row clipped by the top or bottom edge set the active option, which fired the scroll-into-view meant for the arrow keys: the row snapped flush, the whole list moved under the cursor, and the cursor was then over a different row. Measured as a 28px jump. Only the keyboard scrolls now, and a new result set explicitly returns to the top — which that effect had been doing by accident.
|
|
193
|
+
|
|
194
|
+
**Every result row says where it lands, in the same words.** The second line was a breadcrumb of page and heading names, except on a page's own record — whose heading _is_ its page title — which got no second line at all rather than repeat itself. On a six-page site that is six of twenty-nine records, so the list came out ragged and the barest rows said the least: a row reading only "Wave Docs" told a reader nothing about what it opened.
|
|
195
|
+
|
|
196
|
+
Every row now shows the page it lands on: the route, without its anchor. The anchor is slugged from the heading printed directly above it, so it spent the line restating line one. The link keeps it, so a hit still deep-links to its section.
|
|
197
|
+
|
|
198
|
+
**Breaking in rendered output:** `.wave-docs-search-result-breadcrumb`, `.wave-docs-search-result-crumb` and `.wave-docs-search-result-crumb-separator` are replaced by a single `.wave-docs-search-result-location`, because a breadcrumb it is not. A screen reader still hears the words — a route read aloud is punctuation — so the option's `aria-label` carries "Layout tokens, Styling" while the visible line carries the address.
|
|
199
|
+
|
|
200
|
+
**A query is at least two characters**, settable with `minQueryLength`. Measured on this package's own documentation: `a` matches 100% of the corpus, `i` 97%, `s` 93%. One character is not a query, it is a reader halfway through typing one, and answering it with everything teaches them that search returns noise. Below the floor nothing runs — no search, no index request — and the dialog says "Keep typing" rather than sitting there answering nothing.
|
|
201
|
+
|
|
202
|
+
Two rather than three, and the difference matters on a docs site: three would refuse `ts`, `js`, `id`, `h1` and `px`, each a real query here and each selective — 10%, 17%, 14%, 3%, 0%. The noise is at one character, so that is where the floor goes.
|
|
203
|
+
|
|
3
204
|
## 0.3.0
|
|
4
205
|
|
|
5
206
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -22,23 +22,29 @@
|
|
|
22
22
|
old version's README displays a future product: someone reading 0.3.0 in 2027
|
|
23
23
|
would see whatever the shell looks like then.
|
|
24
24
|
|
|
25
|
-
`pnpm shoot` regenerates these from the real site build
|
|
26
|
-
|
|
27
|
-
|
|
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.
|
|
28
34
|
-->
|
|
29
35
|
<picture>
|
|
30
36
|
<source
|
|
31
37
|
media="(prefers-color-scheme: dark)"
|
|
32
|
-
srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.
|
|
38
|
+
srcset="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-dark.png"
|
|
33
39
|
/>
|
|
34
40
|
<img
|
|
35
|
-
src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.
|
|
41
|
+
src="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.4.0/docs/media/hero-light.png"
|
|
36
42
|
alt="A documentation page rendered by @waveso/docs: a navigation sidebar, prose with syntax-highlighted code frames, and a table of contents."
|
|
37
43
|
width="100%"
|
|
38
44
|
/>
|
|
39
45
|
</picture>
|
|
40
46
|
|
|
41
|
-
<p align="center"><em>The default page, with no CSS of your own. <a href="https://raw.githubusercontent.com/wavedotso/wave-docs/v0.
|
|
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>
|
|
42
48
|
|
|
43
49
|
---
|
|
44
50
|
|
|
@@ -90,16 +96,16 @@ Every figure below is a **ceiling**, and `pnpm size` fails the build if the meas
|
|
|
90
96
|
|
|
91
97
|
| | At most |
|
|
92
98
|
| --- | --- |
|
|
93
|
-
| Everything the quick start ships, gzipped | 13.
|
|
94
|
-
| Search dialog and router wiring | 9.
|
|
95
|
-
| Navigation: sidebar and mobile drawer | 2.
|
|
99
|
+
| Everything the quick start ships, gzipped | 13.5 KB |
|
|
100
|
+
| Search dialog and router wiring | 9.3 KB |
|
|
101
|
+
| Navigation: sidebar and mobile drawer | 2.4 KB |
|
|
96
102
|
| Table of contents | 0.9 KB |
|
|
97
|
-
| Copy-button runtime |
|
|
103
|
+
| Copy-button runtime | 1.1 KB |
|
|
98
104
|
| hast over the wire vs HTML, prose page | 1.20× |
|
|
99
105
|
| hast over the wire vs HTML, code and tables | 1.12× |
|
|
100
106
|
| Highlighting vs no highlighting | 2.00× |
|
|
101
107
|
|
|
102
|
-
The first row is the honest total: a reader who lands on a page of your documentation downloads under 13 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.
|
|
108
|
+
The first row is the honest total: a reader who lands on a page of your documentation downloads under 13.5 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.
|
|
103
109
|
|
|
104
110
|
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.
|
|
105
111
|
|
|
@@ -211,20 +217,24 @@ Every subpath is enumerated in `exports` — there is no wildcard. A name that i
|
|
|
211
217
|
|
|
212
218
|
## Components
|
|
213
219
|
|
|
214
|
-
Every component takes data as props and
|
|
220
|
+
Every component takes data as props, and two modules in `src/react/` import from `next/*` — `next-nav` for `usePathname` and `next-search` for `useRouter`, each named so the exception is visible in the file list. 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`.
|
|
215
221
|
|
|
216
222
|
| Component | Subpath | Notes |
|
|
217
223
|
| --- | --- | --- |
|
|
218
224
|
| `DocContent` | `react/doc-content` | Renders a hast tree, inside `.wave-docs-prose`. Server Component |
|
|
219
225
|
| `DocsSidebar` | `react/sidebar` | Takes `pathname` as a prop, not from `next/navigation` |
|
|
220
|
-
| `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver` |
|
|
226
|
+
| `DocsToc` | `react/toc` | Scrollspy via `IntersectionObserver`. `label`, `topLabel`, `rootMargin`, `className` |
|
|
221
227
|
| `DocsSearch` | `react/next-search` | `SearchDialog`, wired to Next's router. What you want |
|
|
222
228
|
| `SearchDialog` | `react/search-dialog` | ⌘K, arrow keys, focus trap. Host-agnostic |
|
|
223
229
|
| `Callout` | `react/callout` | Note · tip · important · warning · caution. `CALLOUT_TYPES` is the list |
|
|
224
|
-
| `YouTube` | `react/youtube` | Click-to-load facade |
|
|
230
|
+
| `YouTube` | `react/youtube` | Click-to-load facade. `title`, `playLabel`, `hideLabel` — `{title}` interpolates the first |
|
|
225
231
|
| `SkipLink` | `react/skip-link` | Targets `docs.Page`'s `<main>`; `DOCS_CONTENT_ID` is that id. `docs.Layout` renders one |
|
|
226
232
|
| `createMarkdownComponents` | `react/markdown-components` | The element → component map. `defaultMarkdownComponents` is the unwired one |
|
|
227
233
|
|
|
234
|
+
`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
|
+
|
|
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 `next/image`; 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
|
+
|
|
228
238
|
### Layout
|
|
229
239
|
|
|
230
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, sticky header, sidebar column, mobile drawer, 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.
|
|
@@ -255,35 +265,13 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
|
255
265
|
| `title` | `ReactNode` | — | Brand, at the header start |
|
|
256
266
|
| `actions` | `ReactNode` | — | Header end, after search |
|
|
257
267
|
| `search` | `boolean \| DocsSearchProps` | `true` | The search trigger. An object configures the dialog |
|
|
258
|
-
| `labels` | `DocsLabels` |
|
|
268
|
+
| `labels` | `DocsLabels` | the route's | Overrides `createDocsRoute`'s labels, key by key |
|
|
259
269
|
|
|
260
270
|
Five props, and two of them are small objects. 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`. The header bar was the one region nothing else could reach — hence `actions`. Two node props can become a slots map later; a slots map cannot become two props.
|
|
261
271
|
|
|
262
272
|
`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.
|
|
263
273
|
|
|
264
|
-
`labels`
|
|
265
|
-
|
|
266
|
-
The same `app/docs/layout.tsx` as the quick start, written out instead of re-exported, because passing a prop needs a function:
|
|
267
|
-
|
|
268
|
-
```tsx
|
|
269
|
-
import type { ReactNode } from 'react';
|
|
270
|
-
import '@waveso/docs/styles.css';
|
|
271
|
-
import { docs } from '@/lib/docs';
|
|
272
|
-
|
|
273
|
-
export default function Layout(props: { children: ReactNode }) {
|
|
274
|
-
return docs.Layout({
|
|
275
|
-
...props,
|
|
276
|
-
labels: {
|
|
277
|
-
nav: 'Documentação',
|
|
278
|
-
openNav: 'Abrir navegação',
|
|
279
|
-
closeNav: 'Fechar navegação',
|
|
280
|
-
skipToContent: 'Ir para o conteúdo',
|
|
281
|
-
},
|
|
282
|
-
});
|
|
283
|
-
}
|
|
284
|
-
```
|
|
285
|
-
|
|
286
|
-
Each key falls back on its own, so a partial map is not a half-translated shell.
|
|
274
|
+
`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.
|
|
287
275
|
|
|
288
276
|
#### The mobile drawer
|
|
289
277
|
|
|
@@ -653,6 +641,35 @@ The response carries `cache-control: public, max-age=0, must-revalidate` and a s
|
|
|
653
641
|
|
|
654
642
|
If your site sets Next's own `basePath` config, prefix `indexUrl` yourself: Next applies it to `<Link>` and to navigation, but never to a client `fetch()`.
|
|
655
643
|
|
|
644
|
+
### The dialog's props
|
|
645
|
+
|
|
646
|
+
`DocsSearch` takes everything `SearchDialog` does except `navigate` and `Link`, which the Next adapter wires. `docs.Layout`'s `search={{ … }}` takes all of it except `indexUrl`, which it derives.
|
|
647
|
+
|
|
648
|
+
| Prop | Type | Default | |
|
|
649
|
+
| --- | --- | --- | --- |
|
|
650
|
+
| `indexUrl` | `string` | — | Where the index is served. Pass `docs.searchIndexUrl` |
|
|
651
|
+
| `pageSize` | `number` | `20` | Results rendered at a time. **Not a cap** — another page loads as the reader nears the end |
|
|
652
|
+
| `minQueryLength` | `number` | `2` | Shortest query that runs |
|
|
653
|
+
| `debounceMs` | `number` | `120` | Input debounce |
|
|
654
|
+
| `className` | `string` | — | Extra classes for the trigger button |
|
|
655
|
+
| `triggerLabel` | `string` | `'Search'` | The trigger's text |
|
|
656
|
+
| `placeholder` | `string` | `'Search documentation'` | The input's placeholder |
|
|
657
|
+
| `dialogLabel` | `string` | `'Search documentation'` | The dialog's accessible name |
|
|
658
|
+
| `hintLabel` | `string` | `'Start typing to search the documentation.'` | Before anything is typed |
|
|
659
|
+
| `shortQueryLabel` | `string` | `'Keep typing — {min} characters or more.'` | Below `minQueryLength`. `{min}` is that number |
|
|
660
|
+
| `loadingLabel` | `string` | `'Loading the search index…'` | While the index is fetched |
|
|
661
|
+
| `errorLabel` | `string` | `'Search is unavailable right now. Try reloading the page.'` | When it cannot be |
|
|
662
|
+
| `emptyLabel` | `string` | `'No results for “{query}”.'` | No matches. `{query}` is what was typed |
|
|
663
|
+
| `resultCountLabels` | `Partial<Record<Intl.LDMLPluralRule, string>>` | `{ one: '{count} result', other: '{count} results' }` | The live region, by plural category |
|
|
664
|
+
| `locale` | `string` | `<html lang>`, then `'en'` | Language tag for those plural rules |
|
|
665
|
+
| `miniSearchOptions` | `Partial<Options<SearchRecord>>` | — | See [Tuning](#tuning) |
|
|
666
|
+
|
|
667
|
+
**`pageSize` replaced `maxResults` in 0.4.0**, and the meaning changed with the name: `maxResults` was a hard ceiling of 8 that made results unreachable on a six-page site, and the live region announced the slice as though it were the total. `pageSize` is a window — every match is reachable by scrolling, and the count announced is the real one.
|
|
668
|
+
|
|
669
|
+
`resultCountLabels` is keyed by plural category rather than being a singular and a plural, because most languages are not English: Polish takes four forms and Arabic six. `Intl.PluralRules` picks, and a category you do not list falls back to `other`.
|
|
670
|
+
|
|
671
|
+
There is no `hotkey` prop. The shortcut is ⌘K on Apple platforms and Ctrl-K elsewhere, and it is not configurable.
|
|
672
|
+
|
|
656
673
|
### What gets indexed
|
|
657
674
|
|
|
658
675
|
**The whole section**, not a preview of it. `extractSearchRecords` once truncated `text` to 300 characters *before* indexing, which dropped roughly 80% of a normal corpus — and because the default `combineWith: 'AND'` requires every term to land in the same record, a two-word query against a page that plainly contained both words returned nothing. Indexing and display are now separate concerns: the full text is searchable, and `storeFields` carries only what the dialog renders.
|
|
@@ -692,6 +709,60 @@ export function Search() {
|
|
|
692
709
|
|
|
693
710
|
`fuzzy`, `prefix`, `combineWith` and `boost` are MiniSearch *query* defaults, so they nest under `searchOptions`; `fields`, `storeFields`, `tokenize` and `processTerm` sit at the top level. The nesting is easy to get wrong and wrong is silent — a stray `fuzzy` at the top level is simply never read — so both examples above are type-checked in CI.
|
|
694
711
|
|
|
712
|
+
### Functions need a client boundary
|
|
713
|
+
|
|
714
|
+
`tokenize` and `processTerm` are functions, and `docs.Layout` cannot hand a function to the dialog. The layout is a Server Component and the dialog is a Client Component, so props crossing between them are serialised — React refuses a function outright and `next build` fails while prerendering, with *"Functions cannot be passed directly to Client Components"*.
|
|
715
|
+
|
|
716
|
+
So `docs.Layout` forwards the serialisable half of `miniSearchOptions` — `fields`, `storeFields`, `boost`, and everything under `searchOptions` that is not a callback — and refuses the rest by name. It does not quietly drop them: an index built with a `processTerm` the query does not share matches nothing at all and says nothing, which is the exact failure the forwarding exists to prevent.
|
|
717
|
+
|
|
718
|
+
Function tuning means taking the boundary yourself, so the function is a module import on both sides rather than a prop between them:
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
// lib/search-terms.ts — one function, imported by both halves
|
|
722
|
+
export function stripDashes(term: string): string {
|
|
723
|
+
return term.replace(/-/g, '');
|
|
724
|
+
}
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
```tsx
|
|
728
|
+
// components/docs-search.tsx
|
|
729
|
+
'use client';
|
|
730
|
+
|
|
731
|
+
import { DocsSearch } from '@waveso/docs/react/next-search';
|
|
732
|
+
import { stripDashes } from '@/lib/search-terms';
|
|
733
|
+
|
|
734
|
+
export function DocsSearchTrigger({ indexUrl }: { indexUrl: string }) {
|
|
735
|
+
return (
|
|
736
|
+
<DocsSearch
|
|
737
|
+
indexUrl={indexUrl}
|
|
738
|
+
miniSearchOptions={{ processTerm: stripDashes }}
|
|
739
|
+
/>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
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 `actions`, in `app/docs/layout.tsx`:
|
|
745
|
+
|
|
746
|
+
```tsx
|
|
747
|
+
import '@waveso/docs/styles.css';
|
|
748
|
+
import type { ReactNode } from 'react';
|
|
749
|
+
import { DocsSearchTrigger } from '@/components/docs-search';
|
|
750
|
+
import { docs } from '@/lib/docs';
|
|
751
|
+
|
|
752
|
+
export default function DocsLayout({ children }: { children: ReactNode }) {
|
|
753
|
+
return (
|
|
754
|
+
<docs.Layout
|
|
755
|
+
search={false}
|
|
756
|
+
actions={<DocsSearchTrigger indexUrl={docs.searchIndexUrl} />}
|
|
757
|
+
>
|
|
758
|
+
{children}
|
|
759
|
+
</docs.Layout>
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
```
|
|
763
|
+
|
|
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.
|
|
765
|
+
|
|
695
766
|
### Building the index yourself
|
|
696
767
|
|
|
697
768
|
Only if the route cannot express what you need — a second index per locale, say, or an artifact consumed by something other than the dialog:
|
|
@@ -767,6 +838,55 @@ interface DocsConfig<TFrontmatter extends DocFrontmatter = DocFrontmatter> {
|
|
|
767
838
|
|
|
768
839
|
The `<main>` always carries `id="docs-content"`, which is what `SkipLink` targets by default — there is no option to change it, because there was no matching option on `SkipLink` to follow it with, so changing it silently pointed the skip link at nothing. Outside `NODE_ENV=production` the content directory is always re-scanned per request; `docs.source.invalidate()` is the escape hatch if you need to force one.
|
|
769
840
|
|
|
841
|
+
### Translating the chrome
|
|
842
|
+
|
|
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.
|
|
844
|
+
|
|
845
|
+
```ts
|
|
846
|
+
// lib/docs-pt.ts
|
|
847
|
+
import { createDocsRoute } from '@waveso/docs/next';
|
|
848
|
+
|
|
849
|
+
export const docs = createDocsRoute({
|
|
850
|
+
contentDir: 'content/docs',
|
|
851
|
+
labels: {
|
|
852
|
+
// The shell
|
|
853
|
+
nav: 'Documentação',
|
|
854
|
+
openNav: 'Abrir navegação',
|
|
855
|
+
closeNav: 'Fechar navegação',
|
|
856
|
+
skipToContent: 'Ir para o conteúdo',
|
|
857
|
+
// The navigation tree — `{title}` is the group's own name
|
|
858
|
+
expandGroup: 'Abrir {title}',
|
|
859
|
+
collapseGroup: 'Fechar {title}',
|
|
860
|
+
externalLink: '(abre num novo separador)',
|
|
861
|
+
// The table of contents
|
|
862
|
+
toc: 'Nesta página',
|
|
863
|
+
backToTop: 'Voltar ao topo',
|
|
864
|
+
// Your content
|
|
865
|
+
table: 'Tabela',
|
|
866
|
+
calloutNote: 'Nota',
|
|
867
|
+
calloutTip: 'Dica',
|
|
868
|
+
calloutImportant: 'Importante',
|
|
869
|
+
calloutWarning: 'Aviso',
|
|
870
|
+
calloutCaution: 'Atenção',
|
|
871
|
+
youtubeTitle: 'Vídeo do YouTube',
|
|
872
|
+
youtubePlay: 'Reproduzir: {title}',
|
|
873
|
+
youtubeHide: 'Esconder: {title}',
|
|
874
|
+
// Code frames
|
|
875
|
+
copyCode: 'Copiar código',
|
|
876
|
+
copyCodeFrom: 'Copiar código de {title}',
|
|
877
|
+
copied: 'Copiado para a área de transferência.',
|
|
878
|
+
copyFailed: 'Falhou. Selecione o código e prima Control ou Command + C.',
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
```
|
|
882
|
+
|
|
883
|
+
Each key falls back on its own, so a partial map is not a half-translated site. `{title}` is a placeholder rather than a function, because three of these cross from a Server Component to a Client one — and because a translator has to be able to move the name within the sentence, which concatenation forbids.
|
|
884
|
+
|
|
885
|
+
The search dialog's own strings are separate, and reachable through `search={{ … }}` — see [Search](#search).
|
|
886
|
+
|
|
887
|
+
> [!NOTE]
|
|
888
|
+
> These were hardcoded English until 0.5.0, under a `labels` prop on `docs.Layout` that documented itself as the whole of a site's translatable chrome and reached four strings of the twenty-two. A site built the documented way shipped `aria-label="On this page"`, a visible `Back to top`, `aria-label="Tip"` on every callout and `Copy code` on every fence, in English, whatever language it was written in.
|
|
889
|
+
|
|
770
890
|
### Redirects and sitemap
|
|
771
891
|
|
|
772
892
|
Separate calls, usable from `next.config.ts` and `app/sitemap.ts` — neither loads the Next runtime:
|
|
@@ -860,6 +980,7 @@ try {
|
|
|
860
980
|
| `invalid-config` | An option passed to this package cannot be used as given. | The message names the option. `siteUrl` must be an absolute origin with no path. |
|
|
861
981
|
| `missing-content-dir` | `contentDir` does not point at a readable directory. | It resolves against `process.cwd()`, which is your project root under `next build`. |
|
|
862
982
|
| `broken-symlink` | A markdown page is reachable only through a broken symbolic link. | Repoint or delete the link; skipping it would silently drop a route. |
|
|
983
|
+
| `descriptor-limit` | The process ran out of file descriptors while scanning the content directory. | Raise the limit — `ulimit -n`, or `LimitNOFILE` under systemd. The scan holds at most 64 open at once, so something else in the process has them. |
|
|
863
984
|
| `invalid-image` | A relative image needs an `imageResolver`, or one returned an unusable shape. | Pass `imageResolver`, or use an absolute `/path` the browser can resolve. |
|
|
864
985
|
| `unknown-theme` | A theme name outside the supported set. | Pass a `highlighter` of your own if you need a theme this package does not load. |
|
|
865
986
|
| `unknown-language` | A fence language outside the loaded set. | Add it to `langs`, or accept the plain-text fallback. |
|
|
@@ -1005,7 +1126,7 @@ src/
|
|
|
1005
1126
|
next.ts # The App Router adapter
|
|
1006
1127
|
meta.ts # meta.json ordering
|
|
1007
1128
|
plugins/ # remark/rehype plugins
|
|
1008
|
-
react/ # Components.
|
|
1129
|
+
react/ # Components. Only next-nav and next-search touch next/*
|
|
1009
1130
|
styles.css # Theme tokens + prose styles
|
|
1010
1131
|
```
|
|
1011
1132
|
|
package/dist/errors.d.ts
CHANGED
|
@@ -63,6 +63,8 @@ type DocsErrorCode =
|
|
|
63
63
|
'missing-content-dir' |
|
|
64
64
|
/** A markdown page is reachable only through a broken symbolic link. */
|
|
65
65
|
'broken-symlink' |
|
|
66
|
+
/** The process ran out of file descriptors while scanning the content. */
|
|
67
|
+
'descriptor-limit' |
|
|
66
68
|
/** An `imageResolver` returned an unusable shape, threw, or was needed. */
|
|
67
69
|
'invalid-image' |
|
|
68
70
|
/** A theme name outside the supported set. */
|
package/dist/highlighter.js
CHANGED
package/dist/meta.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { docsError } from "./docs-error.js";
|
|
2
|
+
import { isSafeHref, opensInNewTab } from "./safe-href.js";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { readFile } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
@@ -16,11 +17,6 @@ const SEPARATOR_PATTERN = /^---(.+)---$/;
|
|
|
16
17
|
/** The rest wildcard: everything not named explicitly, in place. */
|
|
17
18
|
const REST = "...";
|
|
18
19
|
/**
|
|
19
|
-
* `<scheme>:` or protocol-relative `//host` — i.e. a URL that leaves the site.
|
|
20
|
-
* Anything else (`/changelog`, `../pricing`) is internal.
|
|
21
|
-
*/
|
|
22
|
-
const ABSOLUTE_HREF_PATTERN = /^(?:[a-zA-Z][a-zA-Z\d+\-.]*:|\/\/)/;
|
|
23
|
-
/**
|
|
24
20
|
* Zod mirror of {@link DocsMeta}.
|
|
25
21
|
*
|
|
26
22
|
* Strict on purpose: `meta.json` is hand-written and unvalidated keys are
|
|
@@ -31,7 +27,7 @@ const docsMetaSchema = z.strictObject({
|
|
|
31
27
|
title: z.string().exactOptional(),
|
|
32
28
|
pages: z.array(z.union([z.string(), z.strictObject({
|
|
33
29
|
title: z.string(),
|
|
34
|
-
href: z.string()
|
|
30
|
+
href: z.string().refine(isSafeHref, { message: "that is not a scheme this package will put in a link. Use http(s), mailto, tel, sms, ftp, irc, xmpp, news, feed, git or matrix — or a path, which needs no scheme at all." })
|
|
35
31
|
})])).exactOptional()
|
|
36
32
|
});
|
|
37
33
|
/**
|
|
@@ -58,7 +54,7 @@ async function readDocsMeta(dirPath) {
|
|
|
58
54
|
}
|
|
59
55
|
let parsed;
|
|
60
56
|
try {
|
|
61
|
-
parsed = JSON.parse(raw);
|
|
57
|
+
parsed = JSON.parse(raw.charCodeAt(0) === 65279 ? raw.slice(1) : raw);
|
|
62
58
|
} catch (err) {
|
|
63
59
|
const reason = err instanceof Error ? err.message : String(err);
|
|
64
60
|
throw docsError("invalid-meta", `Could not parse ${filePath} as JSON: ${reason}`, { cause: err });
|
|
@@ -90,13 +86,14 @@ function orderNavEntries(entries, meta, metaPath, depth) {
|
|
|
90
86
|
const used = /* @__PURE__ */ new Set();
|
|
91
87
|
const nodes = [];
|
|
92
88
|
let restAt = -1;
|
|
93
|
-
for (const
|
|
89
|
+
for (const raw of pages) {
|
|
90
|
+
const page = typeof raw === "string" ? raw.normalize("NFC") : raw;
|
|
94
91
|
if (typeof page !== "string") {
|
|
95
92
|
nodes.push({
|
|
96
93
|
type: "link",
|
|
97
94
|
title: page.title,
|
|
98
95
|
href: page.href,
|
|
99
|
-
external:
|
|
96
|
+
external: opensInNewTab(page.href)
|
|
100
97
|
});
|
|
101
98
|
continue;
|
|
102
99
|
}
|